hanzi-browse 2.2.0

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 (78) hide show
  1. package/README.md +182 -0
  2. package/dist/agent/loop.d.ts +63 -0
  3. package/dist/agent/loop.js +186 -0
  4. package/dist/agent/system-prompt.d.ts +7 -0
  5. package/dist/agent/system-prompt.js +41 -0
  6. package/dist/agent/tools.d.ts +9 -0
  7. package/dist/agent/tools.js +154 -0
  8. package/dist/cli/detect-credentials.d.ts +31 -0
  9. package/dist/cli/detect-credentials.js +44 -0
  10. package/dist/cli/import-credentials-handler.d.ts +14 -0
  11. package/dist/cli/import-credentials-handler.js +22 -0
  12. package/dist/cli/session-files.d.ts +28 -0
  13. package/dist/cli/session-files.js +118 -0
  14. package/dist/cli/setup.d.ts +10 -0
  15. package/dist/cli/setup.js +915 -0
  16. package/dist/cli.d.ts +16 -0
  17. package/dist/cli.js +506 -0
  18. package/dist/dashboard/assets/index-CEFyesbT.js +46 -0
  19. package/dist/dashboard/assets/index-Dnht2kLU.css +1 -0
  20. package/dist/dashboard/index.html +13 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +1116 -0
  23. package/dist/ipc/index.d.ts +8 -0
  24. package/dist/ipc/index.js +8 -0
  25. package/dist/ipc/native-host.d.ts +96 -0
  26. package/dist/ipc/native-host.js +223 -0
  27. package/dist/ipc/websocket-client.d.ts +73 -0
  28. package/dist/ipc/websocket-client.js +199 -0
  29. package/dist/license/manager.d.ts +20 -0
  30. package/dist/license/manager.js +15 -0
  31. package/dist/llm/client.d.ts +72 -0
  32. package/dist/llm/client.js +227 -0
  33. package/dist/llm/credentials.d.ts +61 -0
  34. package/dist/llm/credentials.js +200 -0
  35. package/dist/llm/vertex.d.ts +22 -0
  36. package/dist/llm/vertex.js +335 -0
  37. package/dist/managed/api-http.test.d.ts +7 -0
  38. package/dist/managed/api-http.test.js +623 -0
  39. package/dist/managed/api.d.ts +51 -0
  40. package/dist/managed/api.js +1448 -0
  41. package/dist/managed/api.test.d.ts +10 -0
  42. package/dist/managed/api.test.js +146 -0
  43. package/dist/managed/auth.d.ts +38 -0
  44. package/dist/managed/auth.js +192 -0
  45. package/dist/managed/billing.d.ts +70 -0
  46. package/dist/managed/billing.js +227 -0
  47. package/dist/managed/deploy.d.ts +17 -0
  48. package/dist/managed/deploy.js +385 -0
  49. package/dist/managed/e2e.test.d.ts +15 -0
  50. package/dist/managed/e2e.test.js +151 -0
  51. package/dist/managed/hardening.test.d.ts +14 -0
  52. package/dist/managed/hardening.test.js +346 -0
  53. package/dist/managed/integration.test.d.ts +8 -0
  54. package/dist/managed/integration.test.js +274 -0
  55. package/dist/managed/log.d.ts +18 -0
  56. package/dist/managed/log.js +31 -0
  57. package/dist/managed/server.d.ts +12 -0
  58. package/dist/managed/server.js +69 -0
  59. package/dist/managed/store-pg.d.ts +191 -0
  60. package/dist/managed/store-pg.js +479 -0
  61. package/dist/managed/store.d.ts +188 -0
  62. package/dist/managed/store.js +379 -0
  63. package/dist/relay/auto-start.d.ts +19 -0
  64. package/dist/relay/auto-start.js +71 -0
  65. package/dist/relay/server.d.ts +17 -0
  66. package/dist/relay/server.js +403 -0
  67. package/dist/types/index.d.ts +5 -0
  68. package/dist/types/index.js +4 -0
  69. package/dist/types/session.d.ts +134 -0
  70. package/dist/types/session.js +16 -0
  71. package/package.json +61 -0
  72. package/skills/README.md +48 -0
  73. package/skills/a11y-auditor/SKILL.md +42 -0
  74. package/skills/e2e-tester/SKILL.md +154 -0
  75. package/skills/hanzi-browse/SKILL.md +182 -0
  76. package/skills/linkedin-prospector/SKILL.md +149 -0
  77. package/skills/social-poster/SKILL.md +146 -0
  78. package/skills/x-marketer/SKILL.md +479 -0
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Postgres-backed Managed Platform Store
3
+ *
4
+ * Drop-in replacement for store.ts (file-based).
5
+ * Uses Neon Postgres via the `pg` driver.
6
+ * Same exported function signatures — swap by changing the import.
7
+ */
8
+ import pg from "pg";
9
+ import { randomUUID, randomBytes, createHash } from "crypto";
10
+ const { Pool } = pg;
11
+ let pool = null;
12
+ function hashSecret(secret) {
13
+ return createHash("sha256").update(secret).digest("hex");
14
+ }
15
+ // --- Init ---
16
+ export function initPgStore(connectionString) {
17
+ pool = new Pool({
18
+ connectionString,
19
+ max: 10,
20
+ idleTimeoutMillis: 30000,
21
+ });
22
+ // Log is imported lazily to avoid circular deps; use direct output here
23
+ console.error(JSON.stringify({ ts: new Date().toISOString(), level: "info", msg: "Connected to Postgres" }));
24
+ }
25
+ function db() {
26
+ if (!pool)
27
+ throw new Error("PgStore not initialized. Call initPgStore() first.");
28
+ return pool;
29
+ }
30
+ // --- Workspace ---
31
+ export async function createWorkspace(name) {
32
+ const id = randomUUID();
33
+ const now = Date.now();
34
+ await db().query("INSERT INTO workspaces (id, name, created_at, plan) VALUES ($1, $2, $3, 'free')", [id, name, new Date(now)]);
35
+ return { id, name, createdAt: now, plan: "free", creditBalance: 0, freeTasksThisMonth: 0, freeTasksResetAt: now };
36
+ }
37
+ function rowToWorkspace(r) {
38
+ return {
39
+ id: r.id,
40
+ name: r.name,
41
+ createdAt: new Date(r.created_at).getTime(),
42
+ stripeCustomerId: r.stripe_customer_id || undefined,
43
+ plan: r.plan || "free",
44
+ subscriptionId: r.subscription_id || undefined,
45
+ subscriptionStatus: r.subscription_status || undefined,
46
+ creditBalance: r.credit_balance ?? 0,
47
+ freeTasksThisMonth: r.free_tasks_this_month ?? 0,
48
+ freeTasksResetAt: r.free_tasks_reset_at ? new Date(r.free_tasks_reset_at).getTime() : Date.now(),
49
+ };
50
+ }
51
+ export async function getWorkspace(id) {
52
+ const res = await db().query("SELECT * FROM workspaces WHERE id = $1", [id]);
53
+ if (res.rows.length === 0)
54
+ return null;
55
+ return rowToWorkspace(res.rows[0]);
56
+ }
57
+ export async function updateWorkspaceBilling(id, fields) {
58
+ const sets = [];
59
+ const vals = [];
60
+ let idx = 1;
61
+ if (fields.stripeCustomerId !== undefined) {
62
+ sets.push(`stripe_customer_id = $${idx++}`);
63
+ vals.push(fields.stripeCustomerId);
64
+ }
65
+ if (fields.plan !== undefined) {
66
+ sets.push(`plan = $${idx++}`);
67
+ vals.push(fields.plan);
68
+ }
69
+ if (fields.subscriptionId !== undefined) {
70
+ sets.push(`subscription_id = $${idx++}`);
71
+ vals.push(fields.subscriptionId);
72
+ }
73
+ if (fields.subscriptionStatus !== undefined) {
74
+ sets.push(`subscription_status = $${idx++}`);
75
+ vals.push(fields.subscriptionStatus);
76
+ }
77
+ if (sets.length === 0)
78
+ return getWorkspace(id);
79
+ vals.push(id);
80
+ const res = await db().query(`UPDATE workspaces SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`, vals);
81
+ if (res.rows.length === 0)
82
+ return null;
83
+ return rowToWorkspace(res.rows[0]);
84
+ }
85
+ // --- Credits ---
86
+ const FREE_TASKS_PER_MONTH = 20;
87
+ /**
88
+ * Check if a workspace can run a task. Returns allowance with source info.
89
+ * Automatically resets the free tier counter on new month.
90
+ */
91
+ export async function checkTaskAllowance(workspaceId) {
92
+ const ws = await getWorkspace(workspaceId);
93
+ if (!ws)
94
+ return { allowed: false, reason: "Workspace not found" };
95
+ // Reset free counter if new month
96
+ const now = new Date();
97
+ const resetAt = new Date(ws.freeTasksResetAt);
98
+ if (now.getUTCFullYear() !== resetAt.getUTCFullYear() || now.getUTCMonth() !== resetAt.getUTCMonth()) {
99
+ await db().query("UPDATE workspaces SET free_tasks_this_month = 0, free_tasks_reset_at = $1 WHERE id = $2", [now, workspaceId]);
100
+ ws.freeTasksThisMonth = 0;
101
+ }
102
+ // Free tier
103
+ if (ws.freeTasksThisMonth < FREE_TASKS_PER_MONTH) {
104
+ return {
105
+ allowed: true,
106
+ source: "free",
107
+ freeRemaining: FREE_TASKS_PER_MONTH - ws.freeTasksThisMonth,
108
+ creditBalance: ws.creditBalance,
109
+ };
110
+ }
111
+ // Paid credits
112
+ if (ws.creditBalance > 0) {
113
+ return {
114
+ allowed: true,
115
+ source: "credits",
116
+ freeRemaining: 0,
117
+ creditBalance: ws.creditBalance,
118
+ };
119
+ }
120
+ return {
121
+ allowed: false,
122
+ reason: `Free tier exhausted (${FREE_TASKS_PER_MONTH}/month). Add credits to continue.`,
123
+ freeRemaining: 0,
124
+ creditBalance: 0,
125
+ };
126
+ }
127
+ /**
128
+ * Deduct for a completed task. Call ONLY on status="complete".
129
+ * Uses atomic SQL to prevent double-deduct races.
130
+ */
131
+ export async function deductTaskCredit(workspaceId) {
132
+ // Try free tier first (atomic increment with check)
133
+ const freeRes = await db().query(`UPDATE workspaces
134
+ SET free_tasks_this_month = free_tasks_this_month + 1
135
+ WHERE id = $1 AND free_tasks_this_month < $2
136
+ RETURNING free_tasks_this_month`, [workspaceId, FREE_TASKS_PER_MONTH]);
137
+ if ((freeRes.rowCount ?? 0) > 0)
138
+ return "free";
139
+ // Deduct from credit balance (atomic decrement with check)
140
+ const creditRes = await db().query(`UPDATE workspaces
141
+ SET credit_balance = credit_balance - 1
142
+ WHERE id = $1 AND credit_balance > 0
143
+ RETURNING credit_balance`, [workspaceId,]);
144
+ if ((creditRes.rowCount ?? 0) > 0)
145
+ return "credits";
146
+ // Should not happen if checkTaskAllowance was called first
147
+ return "free";
148
+ }
149
+ /**
150
+ * Add purchased credits to a workspace.
151
+ */
152
+ export async function addCredits(workspaceId, amount) {
153
+ const res = await db().query(`UPDATE workspaces SET credit_balance = credit_balance + $1 WHERE id = $2 RETURNING credit_balance`, [amount, workspaceId]);
154
+ return res.rows[0]?.credit_balance ?? 0;
155
+ }
156
+ // --- API Keys ---
157
+ export async function createApiKey(workspaceId, name) {
158
+ const plainKey = `hic_live_${randomBytes(24).toString("hex")}`;
159
+ const keyHash = hashSecret(plainKey);
160
+ const id = randomUUID();
161
+ const now = Date.now();
162
+ await db().query("INSERT INTO api_keys (id, key_hash, key_prefix, name, workspace_id, created_at) VALUES ($1, $2, $3, $4, $5, $6)", [id, keyHash, plainKey.slice(0, 20), name, workspaceId, new Date(now)]);
163
+ return { id, key: plainKey, name, workspaceId, createdAt: now };
164
+ }
165
+ export async function validateApiKey(key) {
166
+ const keyHash = hashSecret(key);
167
+ const res = await db().query("UPDATE api_keys SET last_used_at = NOW() WHERE key_hash = $1 RETURNING *", [keyHash]);
168
+ if (res.rows.length === 0)
169
+ return null;
170
+ const r = res.rows[0];
171
+ return {
172
+ id: r.id,
173
+ key: keyHash,
174
+ name: r.name,
175
+ workspaceId: r.workspace_id,
176
+ createdAt: new Date(r.created_at).getTime(),
177
+ lastUsedAt: r.last_used_at ? new Date(r.last_used_at).getTime() : undefined,
178
+ };
179
+ }
180
+ export async function listApiKeys(workspaceId) {
181
+ const res = await db().query("SELECT * FROM api_keys WHERE workspace_id = $1 ORDER BY created_at DESC", [workspaceId]);
182
+ return res.rows.map((r) => ({
183
+ id: r.id,
184
+ key: r.key_prefix + "...",
185
+ keyPrefix: r.key_prefix,
186
+ name: r.name,
187
+ workspaceId: r.workspace_id,
188
+ createdAt: new Date(r.created_at).getTime(),
189
+ lastUsedAt: r.last_used_at ? new Date(r.last_used_at).getTime() : undefined,
190
+ }));
191
+ }
192
+ export async function deleteApiKey(id, workspaceId) {
193
+ const res = await db().query("DELETE FROM api_keys WHERE id = $1 AND workspace_id = $2", [id, workspaceId]);
194
+ return (res.rowCount ?? 0) > 0;
195
+ }
196
+ // --- Pairing Tokens ---
197
+ export async function createPairingToken(workspaceId, apiKeyId, metadata) {
198
+ const plainToken = `hic_pair_${randomBytes(32).toString("hex")}`;
199
+ const tokenHash = hashSecret(plainToken);
200
+ const now = Date.now();
201
+ const expiresAt = now + 5 * 60 * 1000;
202
+ await db().query("INSERT INTO pairing_tokens (token_hash, workspace_id, created_by, created_at, expires_at, label, external_user_id) VALUES ($1, $2, $3, $4, $5, $6, $7)", [tokenHash, workspaceId, apiKeyId, new Date(now), new Date(expiresAt), metadata?.label || null, metadata?.externalUserId || null]);
203
+ return {
204
+ token: tokenHash,
205
+ workspaceId,
206
+ createdBy: apiKeyId || "",
207
+ createdAt: now,
208
+ expiresAt,
209
+ consumed: false,
210
+ label: metadata?.label,
211
+ externalUserId: metadata?.externalUserId,
212
+ _plainToken: plainToken,
213
+ };
214
+ }
215
+ export async function consumePairingToken(pairingTokenStr) {
216
+ const tokenHash = hashSecret(pairingTokenStr);
217
+ const res = await db().query("UPDATE pairing_tokens SET consumed = true WHERE token_hash = $1 AND consumed = false AND expires_at > NOW() RETURNING *", [tokenHash]);
218
+ if (res.rows.length === 0)
219
+ return null;
220
+ const pt = res.rows[0];
221
+ // Create browser session
222
+ const sessionId = randomUUID();
223
+ const plainSessionToken = `hic_sess_${randomBytes(32).toString("hex")}`;
224
+ const sessionTokenHash = hashSecret(plainSessionToken);
225
+ const now = Date.now();
226
+ // Session tokens expire after 30 days
227
+ const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
228
+ const expiresAt = now + SESSION_TTL_MS;
229
+ await db().query("INSERT INTO browser_sessions (id, workspace_id, session_token_hash, status, connected_at, last_heartbeat, expires_at, label, external_user_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", [sessionId, pt.workspace_id, sessionTokenHash, "connected", new Date(now), new Date(now), new Date(expiresAt), pt.label || null, pt.external_user_id || null]);
230
+ return {
231
+ id: sessionId,
232
+ workspaceId: pt.workspace_id,
233
+ sessionToken: plainSessionToken, // Return plaintext once
234
+ status: "connected",
235
+ connectedAt: now,
236
+ lastHeartbeat: now,
237
+ label: pt.label || undefined,
238
+ externalUserId: pt.external_user_id || undefined,
239
+ };
240
+ }
241
+ // --- Browser Sessions ---
242
+ export async function validateSessionToken(sessionToken) {
243
+ const tokenHash = hashSecret(sessionToken);
244
+ const res = await db().query("SELECT * FROM browser_sessions WHERE session_token_hash = $1 AND revoked = false AND (expires_at IS NULL OR expires_at > NOW())", [tokenHash]);
245
+ if (res.rows.length === 0)
246
+ return null;
247
+ const r = res.rows[0];
248
+ return {
249
+ id: r.id,
250
+ workspaceId: r.workspace_id,
251
+ sessionToken: tokenHash,
252
+ status: r.status,
253
+ connectedAt: new Date(r.connected_at).getTime(),
254
+ lastHeartbeat: new Date(r.last_heartbeat).getTime(),
255
+ tabId: r.tab_id,
256
+ windowId: r.window_id,
257
+ };
258
+ }
259
+ export async function heartbeatSession(id) {
260
+ // Only heartbeat if not expired and not revoked
261
+ const res = await db().query("UPDATE browser_sessions SET last_heartbeat = NOW(), status = 'connected' WHERE id = $1 AND revoked = false AND (expires_at IS NULL OR expires_at > NOW())", [id]);
262
+ return (res.rowCount ?? 0) > 0;
263
+ }
264
+ /**
265
+ * Rotate a session's token. Returns the new plaintext token, or null if session is invalid.
266
+ * The old token hash is atomically replaced. One-step rotation — no dual-token window.
267
+ */
268
+ export async function rotateSessionToken(id) {
269
+ const newPlainToken = `hic_sess_${randomBytes(32).toString("hex")}`;
270
+ const newHash = hashSecret(newPlainToken);
271
+ const res = await db().query("UPDATE browser_sessions SET session_token_hash = $1 WHERE id = $2 AND revoked = false AND (expires_at IS NULL OR expires_at > NOW()) RETURNING id", [newHash, id]);
272
+ if ((res.rowCount ?? 0) === 0)
273
+ return null;
274
+ return newPlainToken;
275
+ }
276
+ export async function disconnectSession(id) {
277
+ await db().query("UPDATE browser_sessions SET status = 'disconnected' WHERE id = $1", [id]);
278
+ }
279
+ export async function updateSessionContext(id, tabId, windowId) {
280
+ await db().query("UPDATE browser_sessions SET tab_id = $1, window_id = $2 WHERE id = $3", [tabId, windowId ?? null, id]);
281
+ }
282
+ function rowToSession(r) {
283
+ return {
284
+ id: r.id,
285
+ workspaceId: r.workspace_id,
286
+ sessionToken: r.session_token_hash,
287
+ status: r.status,
288
+ connectedAt: new Date(r.connected_at).getTime(),
289
+ lastHeartbeat: new Date(r.last_heartbeat).getTime(),
290
+ tabId: r.tab_id,
291
+ windowId: r.window_id,
292
+ label: r.label || undefined,
293
+ externalUserId: r.external_user_id || undefined,
294
+ };
295
+ }
296
+ export async function getBrowserSession(id) {
297
+ const res = await db().query("SELECT * FROM browser_sessions WHERE id = $1", [id]);
298
+ if (res.rows.length === 0)
299
+ return null;
300
+ return rowToSession(res.rows[0]);
301
+ }
302
+ export async function listBrowserSessions(workspaceId) {
303
+ const query = workspaceId
304
+ ? { text: "SELECT * FROM browser_sessions WHERE workspace_id = $1 ORDER BY connected_at DESC", values: [workspaceId] }
305
+ : { text: "SELECT * FROM browser_sessions ORDER BY connected_at DESC", values: [] };
306
+ const res = await db().query(query);
307
+ return res.rows.map(rowToSession);
308
+ }
309
+ export async function deleteBrowserSession(id, workspaceId) {
310
+ const res = await db().query("DELETE FROM browser_sessions WHERE id = $1 AND workspace_id = $2", [id, workspaceId]);
311
+ return (res.rowCount ?? 0) > 0;
312
+ }
313
+ // --- Task Runs ---
314
+ export async function createTaskRun(params) {
315
+ const id = randomUUID();
316
+ const now = Date.now();
317
+ await db().query(`INSERT INTO task_runs (id, workspace_id, api_key_id, browser_session_id, task, url, context, status, created_at)
318
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'running', $8)`, [id, params.workspaceId, params.apiKeyId, params.browserSessionId ?? null, params.task, params.url ?? null, params.context ?? null, new Date(now)]);
319
+ return {
320
+ id,
321
+ ...params,
322
+ status: "running",
323
+ steps: 0,
324
+ usage: { inputTokens: 0, outputTokens: 0, apiCalls: 0 },
325
+ createdAt: now,
326
+ };
327
+ }
328
+ export async function updateTaskRun(id, updates) {
329
+ const setClauses = [];
330
+ const values = [];
331
+ let idx = 1;
332
+ if (updates.status !== undefined) {
333
+ setClauses.push(`status = $${idx++}`);
334
+ values.push(updates.status);
335
+ }
336
+ if (updates.answer !== undefined) {
337
+ setClauses.push(`answer = $${idx++}`);
338
+ values.push(updates.answer);
339
+ }
340
+ if (updates.steps !== undefined) {
341
+ setClauses.push(`steps = $${idx++}`);
342
+ values.push(updates.steps);
343
+ }
344
+ if (updates.usage) {
345
+ setClauses.push(`input_tokens = $${idx++}`);
346
+ values.push(updates.usage.inputTokens);
347
+ setClauses.push(`output_tokens = $${idx++}`);
348
+ values.push(updates.usage.outputTokens);
349
+ setClauses.push(`api_calls = $${idx++}`);
350
+ values.push(updates.usage.apiCalls);
351
+ }
352
+ if (updates.completedAt !== undefined) {
353
+ setClauses.push(`completed_at = $${idx++}`);
354
+ values.push(new Date(updates.completedAt));
355
+ }
356
+ if (setClauses.length === 0)
357
+ return null;
358
+ values.push(id);
359
+ const res = await db().query(`UPDATE task_runs SET ${setClauses.join(", ")} WHERE id = $${idx} RETURNING *`, values);
360
+ if (res.rows.length === 0)
361
+ return null;
362
+ return rowToTaskRun(res.rows[0]);
363
+ }
364
+ export async function getTaskRun(id) {
365
+ const res = await db().query("SELECT * FROM task_runs WHERE id = $1", [id]);
366
+ if (res.rows.length === 0)
367
+ return null;
368
+ return rowToTaskRun(res.rows[0]);
369
+ }
370
+ export async function listStuckTasks(maxAgeMs) {
371
+ const cutoff = new Date(Date.now() - maxAgeMs);
372
+ const res = await db().query("SELECT * FROM task_runs WHERE status = 'running' AND created_at < $1", [cutoff]);
373
+ return res.rows.map(rowToTaskRun);
374
+ }
375
+ export async function listTaskRuns(workspaceId, limit = 50) {
376
+ const res = await db().query("SELECT * FROM task_runs WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2", [workspaceId, limit]);
377
+ return res.rows.map(rowToTaskRun);
378
+ }
379
+ function rowToTaskRun(r) {
380
+ return {
381
+ id: r.id,
382
+ workspaceId: r.workspace_id,
383
+ apiKeyId: r.api_key_id,
384
+ browserSessionId: r.browser_session_id,
385
+ task: r.task,
386
+ url: r.url,
387
+ context: r.context,
388
+ status: r.status,
389
+ answer: r.answer,
390
+ steps: r.steps,
391
+ usage: { inputTokens: r.input_tokens, outputTokens: r.output_tokens, apiCalls: r.api_calls },
392
+ createdAt: new Date(r.created_at).getTime(),
393
+ completedAt: r.completed_at ? new Date(r.completed_at).getTime() : undefined,
394
+ };
395
+ }
396
+ export async function insertTaskStep(params) {
397
+ await db().query(`INSERT INTO task_steps (task_run_id, step, status, tool_name, tool_input, output, screenshot, created_at, duration_ms)
398
+ VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8)`, [
399
+ params.taskRunId, params.step, params.status,
400
+ params.toolName ?? null,
401
+ params.toolInput ? JSON.stringify(params.toolInput) : null,
402
+ params.output ?? null,
403
+ params.screenshot ?? null,
404
+ params.durationMs ?? null,
405
+ ]);
406
+ }
407
+ export async function getTaskSteps(taskRunId) {
408
+ const res = await db().query("SELECT * FROM task_steps WHERE task_run_id = $1 ORDER BY step, created_at", [taskRunId]);
409
+ return res.rows.map((r) => ({
410
+ id: r.id,
411
+ taskRunId: r.task_run_id,
412
+ step: r.step,
413
+ status: r.status,
414
+ toolName: r.tool_name,
415
+ toolInput: r.tool_input,
416
+ output: r.output?.slice(0, 2000), // truncate for API responses
417
+ screenshot: r.screenshot ? "present" : undefined, // don't return full base64 in list
418
+ createdAt: new Date(r.created_at).getTime(),
419
+ durationMs: r.duration_ms,
420
+ }));
421
+ }
422
+ export async function getTaskStepScreenshot(taskRunId, step) {
423
+ const res = await db().query("SELECT screenshot FROM task_steps WHERE task_run_id = $1 AND step = $2 AND screenshot IS NOT NULL LIMIT 1", [taskRunId, step]);
424
+ return res.rows[0]?.screenshot ?? null;
425
+ }
426
+ // --- Usage Events ---
427
+ export async function recordUsage(params) {
428
+ const inputCost = (params.inputTokens / 1_000_000) * 0.30;
429
+ const outputCost = (params.outputTokens / 1_000_000) * 2.50;
430
+ const costUsd = inputCost + outputCost;
431
+ const id = randomUUID();
432
+ const now = Date.now();
433
+ await db().query(`INSERT INTO usage_events (id, workspace_id, api_key_id, task_run_id, input_tokens, output_tokens, api_calls, model, cost_usd, created_at)
434
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [id, params.workspaceId, params.apiKeyId, params.taskRunId, params.inputTokens, params.outputTokens, params.apiCalls, params.model, costUsd, new Date(now)]);
435
+ return { id, ...params, costUsd, createdAt: now };
436
+ }
437
+ export async function getUsageSummary(workspaceId, since) {
438
+ const sinceDate = since ? new Date(since) : new Date(0);
439
+ const res = await db().query(`SELECT
440
+ COALESCE(SUM(input_tokens), 0) as total_input,
441
+ COALESCE(SUM(output_tokens), 0) as total_output,
442
+ COALESCE(SUM(api_calls), 0) as total_calls,
443
+ COALESCE(SUM(cost_usd), 0) as total_cost,
444
+ COUNT(DISTINCT task_run_id) as task_count
445
+ FROM usage_events
446
+ WHERE workspace_id = $1 AND created_at >= $2`, [workspaceId, sinceDate]);
447
+ const r = res.rows[0];
448
+ return {
449
+ totalInputTokens: parseInt(r.total_input),
450
+ totalOutputTokens: parseInt(r.total_output),
451
+ totalApiCalls: parseInt(r.total_calls),
452
+ totalCostUsd: parseFloat(r.total_cost),
453
+ taskCount: parseInt(r.task_count),
454
+ };
455
+ }
456
+ // --- Bootstrap ---
457
+ export async function ensureDefaultWorkspace() {
458
+ // Check for existing workspace
459
+ const existing = await db().query("SELECT * FROM workspaces LIMIT 1");
460
+ if (existing.rows.length > 0) {
461
+ const ws = rowToWorkspace(existing.rows[0]);
462
+ const keyRes = await db().query("SELECT key_prefix FROM api_keys WHERE workspace_id = $1 LIMIT 1", [ws.id]);
463
+ if (keyRes.rows.length > 0) {
464
+ return {
465
+ workspace: ws,
466
+ apiKey: { id: "", key: `${keyRes.rows[0].key_prefix}... (already created, plaintext not available)`, name: "default", workspaceId: ws.id, createdAt: ws.createdAt },
467
+ };
468
+ }
469
+ const apiKey = await createApiKey(ws.id, "default");
470
+ return { workspace: ws, apiKey };
471
+ }
472
+ const workspace = await createWorkspace("Default");
473
+ const apiKey = await createApiKey(workspace.id, "default");
474
+ return { workspace, apiKey };
475
+ }
476
+ // --- Heartbeat flush (no-op for Postgres, queries go to DB directly) ---
477
+ export function startHeartbeatFlush() {
478
+ // Not needed for Postgres — heartbeatSession writes to DB directly
479
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Managed Platform Store
3
+ *
4
+ * File-based persistence for MVP. Swap for Postgres/SQLite later.
5
+ * Stores: API keys, task runs, usage events, browser sessions.
6
+ */
7
+ export interface ApiKey {
8
+ id: string;
9
+ key: string;
10
+ keyPrefix?: string;
11
+ name: string;
12
+ workspaceId: string;
13
+ createdAt: number;
14
+ lastUsedAt?: number;
15
+ }
16
+ export interface Workspace {
17
+ id: string;
18
+ name: string;
19
+ createdAt: number;
20
+ stripeCustomerId?: string;
21
+ plan: "free" | "pro" | "enterprise";
22
+ subscriptionId?: string;
23
+ subscriptionStatus?: "active" | "past_due" | "cancelled";
24
+ creditBalance: number;
25
+ freeTasksThisMonth: number;
26
+ freeTasksResetAt: number;
27
+ }
28
+ export interface TaskRun {
29
+ id: string;
30
+ workspaceId: string;
31
+ apiKeyId: string;
32
+ browserSessionId?: string;
33
+ task: string;
34
+ url?: string;
35
+ context?: string;
36
+ status: "running" | "complete" | "error" | "cancelled";
37
+ answer?: string;
38
+ steps: number;
39
+ usage: {
40
+ inputTokens: number;
41
+ outputTokens: number;
42
+ apiCalls: number;
43
+ };
44
+ createdAt: number;
45
+ completedAt?: number;
46
+ }
47
+ export interface PairingToken {
48
+ token: string;
49
+ workspaceId: string;
50
+ createdBy: string;
51
+ createdAt: number;
52
+ expiresAt: number;
53
+ consumed: boolean;
54
+ /** Partner-supplied human-readable label (e.g. "Dr. Smith's browser") */
55
+ label?: string;
56
+ /** Partner's own user identifier for mapping sessions to their users */
57
+ externalUserId?: string;
58
+ }
59
+ export interface BrowserSession {
60
+ id: string;
61
+ workspaceId: string;
62
+ sessionToken: string;
63
+ status: "connected" | "disconnected";
64
+ connectedAt: number;
65
+ lastHeartbeat: number;
66
+ expiresAt?: number;
67
+ revoked?: boolean;
68
+ /** The tab/window context this session owns for managed execution */
69
+ tabId?: number;
70
+ windowId?: number;
71
+ /** Partner-supplied human-readable label (inherited from pairing token) */
72
+ label?: string;
73
+ /** Partner's own user identifier (inherited from pairing token) */
74
+ externalUserId?: string;
75
+ }
76
+ export interface UsageEvent {
77
+ id: string;
78
+ workspaceId: string;
79
+ apiKeyId: string;
80
+ taskRunId: string;
81
+ inputTokens: number;
82
+ outputTokens: number;
83
+ apiCalls: number;
84
+ model: string;
85
+ costUsd: number;
86
+ createdAt: number;
87
+ }
88
+ export declare function createWorkspace(name: string): Workspace;
89
+ export declare function getWorkspace(id: string): Workspace | null;
90
+ export declare function checkTaskAllowance(_workspaceId: string): {
91
+ allowed: boolean;
92
+ source?: string;
93
+ reason?: string;
94
+ freeRemaining?: number;
95
+ creditBalance?: number;
96
+ };
97
+ export declare function deductTaskCredit(_workspaceId: string): "free" | "credits";
98
+ export declare function addCredits(_workspaceId: string, _amount: number): number;
99
+ export declare function updateWorkspaceBilling(id: string, fields: {
100
+ stripeCustomerId?: string;
101
+ plan?: Workspace["plan"];
102
+ subscriptionId?: string;
103
+ subscriptionStatus?: Workspace["subscriptionStatus"];
104
+ }): Workspace | null;
105
+ export declare function createApiKey(workspaceId: string, name: string): ApiKey;
106
+ export declare function validateApiKey(key: string): ApiKey | null;
107
+ export declare function listApiKeys(workspaceId: string): ApiKey[];
108
+ export declare function deleteApiKey(id: string, workspaceId: string): boolean;
109
+ export declare function createTaskRun(params: {
110
+ workspaceId: string;
111
+ apiKeyId: string;
112
+ task: string;
113
+ url?: string;
114
+ context?: string;
115
+ browserSessionId?: string;
116
+ }): TaskRun;
117
+ export declare function updateTaskRun(id: string, updates: Partial<TaskRun>): TaskRun | null;
118
+ export declare function getTaskRun(id: string): TaskRun | null;
119
+ export declare function listStuckTasks(maxAgeMs: number): TaskRun[];
120
+ export declare function listTaskRuns(workspaceId: string, limit?: number): TaskRun[];
121
+ /**
122
+ * Create a short-lived pairing token. The developer (via API key) requests this,
123
+ * then gives it to the browser user. The extension exchanges it for a session token.
124
+ * The workspace binding comes from the API key, NOT from the extension.
125
+ */
126
+ export declare function createPairingToken(workspaceId: string, apiKeyId: string, metadata?: {
127
+ label?: string;
128
+ externalUserId?: string;
129
+ }): PairingToken & {
130
+ _plainToken: string;
131
+ };
132
+ /**
133
+ * Consume a pairing token and create a browser session.
134
+ * Returns null if the token is invalid, expired, or already consumed.
135
+ * The workspace is inherited from the pairing token — the extension cannot choose it.
136
+ */
137
+ export declare function consumePairingToken(pairingTokenStr: string): BrowserSession | null;
138
+ /**
139
+ * Validate a session token. Returns the session if valid, null otherwise.
140
+ * This is how the relay authenticates extension connections.
141
+ */
142
+ export declare function validateSessionToken(sessionToken: string): BrowserSession | null;
143
+ export declare function heartbeatSession(id: string): boolean;
144
+ /**
145
+ * Rotate a session's token. Returns the new plaintext token, or null if session is invalid.
146
+ * The old token is immediately invalidated (replaced by the new hash).
147
+ * Call this periodically (e.g., on heartbeat from relay) to limit token exposure window.
148
+ */
149
+ export declare function rotateSessionToken(id: string): string | null;
150
+ export declare function startHeartbeatFlush(): void;
151
+ export declare function disconnectSession(id: string): void;
152
+ export declare function updateSessionContext(id: string, tabId: number, windowId?: number): void;
153
+ export declare function getBrowserSession(id: string): BrowserSession | null;
154
+ export declare function getBrowserSessionByToken(sessionToken: string): BrowserSession | null;
155
+ export declare function listBrowserSessions(workspaceId?: string): BrowserSession[];
156
+ export declare function deleteBrowserSession(id: string, workspaceId: string): boolean;
157
+ export declare function insertTaskStep(_params: {
158
+ taskRunId: string;
159
+ step: number;
160
+ status: string;
161
+ toolName?: string;
162
+ toolInput?: Record<string, any>;
163
+ output?: string;
164
+ screenshot?: string;
165
+ durationMs?: number;
166
+ }): Promise<void>;
167
+ export declare function getTaskSteps(_taskRunId: string): Promise<any[]>;
168
+ export declare function getTaskStepScreenshot(_taskRunId: string, _step: number): Promise<string | null>;
169
+ export declare function recordUsage(params: {
170
+ workspaceId: string;
171
+ apiKeyId: string;
172
+ taskRunId: string;
173
+ inputTokens: number;
174
+ outputTokens: number;
175
+ apiCalls: number;
176
+ model: string;
177
+ }): UsageEvent;
178
+ export declare function getUsageSummary(workspaceId: string, since?: number): {
179
+ totalInputTokens: number;
180
+ totalOutputTokens: number;
181
+ totalApiCalls: number;
182
+ totalCostUsd: number;
183
+ taskCount: number;
184
+ };
185
+ export declare function ensureDefaultWorkspace(): {
186
+ workspace: Workspace;
187
+ apiKey: ApiKey;
188
+ };