@dinoxx/dinox-cli 2.3.0 → 2.5.2

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 (53) hide show
  1. package/README.md +17 -3
  2. package/dist/cli.js +2 -0
  3. package/dist/commands/gateway/index.d.ts +4 -0
  4. package/dist/commands/gateway/index.js +300 -0
  5. package/dist/commands/schema/renderSkills.js +4 -0
  6. package/dist/commands/schema/spec.js +2 -0
  7. package/dist/config/paths.d.ts +1 -0
  8. package/dist/config/paths.js +1 -1
  9. package/dist/daemon/server.d.ts +1 -0
  10. package/dist/daemon/server.js +41 -0
  11. package/dist/gateway/adapters/acp.d.ts +47 -0
  12. package/dist/gateway/adapters/acp.js +302 -0
  13. package/dist/gateway/adapters/echo.d.ts +12 -0
  14. package/dist/gateway/adapters/echo.js +51 -0
  15. package/dist/gateway/adapters/index.d.ts +9 -0
  16. package/dist/gateway/adapters/index.js +31 -0
  17. package/dist/gateway/assistantProjection.d.ts +32 -0
  18. package/dist/gateway/assistantProjection.js +193 -0
  19. package/dist/gateway/config.d.ts +60 -0
  20. package/dist/gateway/config.js +291 -0
  21. package/dist/gateway/conversationContext.d.ts +44 -0
  22. package/dist/gateway/conversationContext.js +75 -0
  23. package/dist/gateway/detect.d.ts +40 -0
  24. package/dist/gateway/detect.js +145 -0
  25. package/dist/gateway/deviceHost.d.ts +91 -0
  26. package/dist/gateway/deviceHost.js +641 -0
  27. package/dist/gateway/keepAwake.d.ts +24 -0
  28. package/dist/gateway/keepAwake.js +64 -0
  29. package/dist/gateway/mcpServer.d.ts +4 -0
  30. package/dist/gateway/mcpServer.js +138 -0
  31. package/dist/gateway/protocol.d.ts +116 -0
  32. package/dist/gateway/protocol.js +182 -0
  33. package/dist/gateway/runExecutor.d.ts +91 -0
  34. package/dist/gateway/runExecutor.js +422 -0
  35. package/dist/gateway/service.d.ts +30 -0
  36. package/dist/gateway/service.js +112 -0
  37. package/dist/gateway/streamPublisher.d.ts +33 -0
  38. package/dist/gateway/streamPublisher.js +93 -0
  39. package/dist/gateway/threadStore.d.ts +222 -0
  40. package/dist/gateway/threadStore.js +597 -0
  41. package/dist/gateway/types.d.ts +136 -0
  42. package/dist/gateway/types.js +10 -0
  43. package/dist/gateway/workspaceFolders.d.ts +33 -0
  44. package/dist/gateway/workspaceFolders.js +129 -0
  45. package/dist/vendor/electron/domain/properties.js +12 -1
  46. package/dist/vendor/electron/domain/saved-views.js +4 -1
  47. package/dist/vendor/electron/shared/external-types.d.ts +18 -0
  48. package/dist/vendor/electron/shared/note-properties.d.ts +16 -0
  49. package/dist/vendor/electron/shared/note-properties.js +45 -0
  50. package/dist/vendor/electron/shared/powersync-read-models.d.ts +7 -1
  51. package/dist/vendor/electron/shared/remote-device-agent.d.ts +373 -0
  52. package/dist/vendor/electron/shared/remote-device-agent.js +191 -0
  53. package/package.json +5 -2
@@ -0,0 +1,597 @@
1
+ import { withPowerSyncWriteTransaction } from '../powersync/runtime.js';
2
+ const THREAD_CHAT_TYPE = 'thread';
3
+ const MESSAGE_ENTRY_TYPE = 'message';
4
+ const LEGACY_MESSAGE_ENTRY_TYPE = 'thread';
5
+ const CONTEXT_RESET_ENTRY_TYPE = 'internal';
6
+ const CHAT_MESSAGE_VERSION = 'v1';
7
+ const ORDER_INDEX_STEP = 1024;
8
+ const RUN_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
9
+ const MESSAGE_ENTRY_FILTER = '(entry_type IS NULL OR entry_type = \'\' OR entry_type = ? OR entry_type = ?)';
10
+ export const TERMINAL_RUN_STATUSES = new Set([
11
+ 'succeeded',
12
+ 'failed',
13
+ 'cancelled',
14
+ 'interrupted',
15
+ 'expired',
16
+ ]);
17
+ function safeJson(value) {
18
+ return JSON.stringify(value ?? null);
19
+ }
20
+ function parseJson(value) {
21
+ if (typeof value !== 'string' || !value)
22
+ return null;
23
+ try {
24
+ return JSON.parse(value);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function toOrderIndex(value) {
31
+ const parsed = typeof value === 'string' ? Number(value) : value;
32
+ return typeof parsed === 'number' && Number.isSafeInteger(parsed) ? parsed : null;
33
+ }
34
+ /** Mirrors desktop `withRunMetadata`: the UI reads `metadata.status` for streaming state. */
35
+ export function withRunMetadata(message, runId, status) {
36
+ const uiStatus = status === 'succeeded' || status === 'waiting_approval'
37
+ ? 'done'
38
+ : TERMINAL_RUN_STATUSES.has(status)
39
+ ? 'error'
40
+ : 'streaming';
41
+ return { ...message, metadata: { ...(message.metadata ?? {}), runId, status: uiStatus } };
42
+ }
43
+ function withQueueMarker(message, marker) {
44
+ const { queue: _previous, ...metadata } = (message.metadata ?? {});
45
+ return { ...message, metadata: marker ? { ...metadata, queue: marker } : metadata };
46
+ }
47
+ /** The answering run merged this turn into a later one; it never gets its own reply. */
48
+ export function withMergedInto(message, mergedIntoRunId) {
49
+ return { ...message, metadata: { ...(message.metadata ?? {}), mergedIntoRunId } };
50
+ }
51
+ function messageCreatedAt(message, fallback) {
52
+ const raw = message.metadata?.createTime;
53
+ if (typeof raw === 'number' && Number.isFinite(raw))
54
+ return new Date(raw).toISOString();
55
+ if (typeof raw === 'string' && raw.trim())
56
+ return raw;
57
+ return fallback;
58
+ }
59
+ /** Next `order_index` after `parentId` (midpoint before its next sibling) or at the end. */
60
+ export async function resolveNextOrderIndex(tx, input) {
61
+ const row = await tx.getOptional(`SELECT MAX(order_index) AS max_order_index, COUNT(*) AS message_count,
62
+ (SELECT order_index FROM c_chat_message
63
+ WHERE id = ? AND type = ? AND source_id = ? AND deleted_at IS NULL AND ${MESSAGE_ENTRY_FILTER}
64
+ LIMIT 1) AS parent_order_index
65
+ FROM c_chat_message
66
+ WHERE type = ? AND source_id = ? AND deleted_at IS NULL AND ${MESSAGE_ENTRY_FILTER}`, [
67
+ input.parentId ?? '',
68
+ THREAD_CHAT_TYPE,
69
+ input.threadId,
70
+ MESSAGE_ENTRY_TYPE,
71
+ LEGACY_MESSAGE_ENTRY_TYPE,
72
+ THREAD_CHAT_TYPE,
73
+ input.threadId,
74
+ MESSAGE_ENTRY_TYPE,
75
+ LEGACY_MESSAGE_ENTRY_TYPE,
76
+ ]);
77
+ const maxOrderIndex = toOrderIndex(row?.max_order_index);
78
+ const parentOrderIndex = toOrderIndex(row?.parent_order_index);
79
+ const countBased = Math.max(0, Number(row?.message_count ?? 0)) * ORDER_INDEX_STEP;
80
+ if (parentOrderIndex != null) {
81
+ const next = await tx.getOptional(`SELECT MIN(order_index) AS next_order_index FROM c_chat_message
82
+ WHERE type = ? AND source_id = ? AND deleted_at IS NULL AND order_index > ? AND ${MESSAGE_ENTRY_FILTER}`, [THREAD_CHAT_TYPE, input.threadId, parentOrderIndex, MESSAGE_ENTRY_TYPE, LEGACY_MESSAGE_ENTRY_TYPE]);
83
+ const nextOrderIndex = toOrderIndex(next?.next_order_index);
84
+ if (nextOrderIndex != null && nextOrderIndex - parentOrderIndex > 1) {
85
+ return parentOrderIndex + Math.floor((nextOrderIndex - parentOrderIndex) / 2);
86
+ }
87
+ }
88
+ return Math.max(maxOrderIndex ?? 0, parentOrderIndex ?? 0, countBased) + ORDER_INDEX_STEP;
89
+ }
90
+ /**
91
+ * The thread's last message, by transcript order. A new turn is appended after
92
+ * it: what the client believed was last can be stale or out of order (another
93
+ * device, a lagging sync, a display glitch), and trusting it would splice the
94
+ * turn into the middle of the conversation.
95
+ */
96
+ async function resolveThreadTail(tx, threadId) {
97
+ const row = await tx.getOptional(`SELECT id FROM c_chat_message
98
+ WHERE type = ? AND source_id = ? AND deleted_at IS NULL AND ${MESSAGE_ENTRY_FILTER}
99
+ ORDER BY order_index DESC, created_at DESC, id DESC LIMIT 1`, [THREAD_CHAT_TYPE, threadId, MESSAGE_ENTRY_TYPE, LEGACY_MESSAGE_ENTRY_TYPE]);
100
+ return row?.id ?? null;
101
+ }
102
+ export async function threadExists(db, threadId, userId) {
103
+ const row = await db.getOptional('SELECT id FROM c_thread WHERE id = ? AND user_id = ? AND is_del = 0 LIMIT 1', [threadId, userId]);
104
+ return Boolean(row?.id);
105
+ }
106
+ /** Creates (or revives) the thread a remote run targets. Returns true when a row was written. */
107
+ async function ensureThread(tx, input, now) {
108
+ const existing = await tx.getOptional('SELECT id, is_del FROM c_thread WHERE id = ? LIMIT 1', [input.id]);
109
+ if (existing?.id && Number(existing.is_del) !== 1)
110
+ return false;
111
+ const name = (input.name ?? '').trim().slice(0, 120) || 'Untitled';
112
+ const values = [
113
+ name,
114
+ input.kind || 'chat',
115
+ input.userId,
116
+ input.projectId ?? null,
117
+ now,
118
+ now,
119
+ safeJson(input.metaData ?? {}),
120
+ input.cmdId ?? null,
121
+ safeJson(input.config ?? {}),
122
+ ];
123
+ if (existing?.id) {
124
+ await tx.execute(`UPDATE c_thread SET name = ?, kind = ?, user_id = ?, project_id = ?, last_opened_at = ?, updated_at = ?,
125
+ meta_data = ?, cmd_id = ?, config = ?, filter_condition = '{}', snapshot = '{}', memory = '{}',
126
+ is_del = 0, robot_session_id = NULL, compact_context = NULL
127
+ WHERE id = ?`, [...values, input.id]);
128
+ return true;
129
+ }
130
+ await tx.execute(`INSERT INTO c_thread (id, name, kind, user_id, project_id, last_opened_at, updated_at, meta_data, cmd_id, config,
131
+ created_at, messages, pinned_at, filter_condition, snapshot, memory, is_del, robot_session_id, compact_context)
132
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', NULL, '{}', '{}', '{}', 0, NULL, NULL)`, [input.id, ...values, now]);
133
+ return true;
134
+ }
135
+ async function writeChatMessage(tx, input) {
136
+ const existing = await tx.getOptional('SELECT id, order_index FROM c_chat_message WHERE id = ? LIMIT 1', [input.message.id]);
137
+ const orderIndex = toOrderIndex(existing?.order_index) ??
138
+ (await resolveNextOrderIndex(tx, { threadId: input.threadId, parentId: input.parentId }));
139
+ if (existing?.id) {
140
+ await tx.execute(`UPDATE c_chat_message SET role = ?, type = ?, entry_type = ?, source_id = ?, parent_id = ?, content = ?,
141
+ updated_at = ?, order_index = ?, version = ?, user_id = ?, deleted_at = NULL
142
+ WHERE id = ?`, [
143
+ input.message.role,
144
+ THREAD_CHAT_TYPE,
145
+ MESSAGE_ENTRY_TYPE,
146
+ input.threadId,
147
+ input.parentId,
148
+ safeJson(input.message),
149
+ input.updatedAt,
150
+ orderIndex,
151
+ CHAT_MESSAGE_VERSION,
152
+ input.userId,
153
+ input.message.id,
154
+ ]);
155
+ return;
156
+ }
157
+ await tx.execute(`INSERT INTO c_chat_message (id, role, type, entry_type, source_id, parent_id, content, created_at, updated_at,
158
+ order_index, meta_data, version, user_id, deleted_at)
159
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL)`, [
160
+ input.message.id,
161
+ input.message.role,
162
+ THREAD_CHAT_TYPE,
163
+ MESSAGE_ENTRY_TYPE,
164
+ input.threadId,
165
+ input.parentId,
166
+ safeJson(input.message),
167
+ input.createdAt,
168
+ input.updatedAt,
169
+ orderIndex,
170
+ CHAT_MESSAGE_VERSION,
171
+ input.userId,
172
+ ]);
173
+ }
174
+ /** Persists the accepted turn atomically: thread (if new), user message, queued run. */
175
+ export async function enqueueRun(db, input) {
176
+ const now = new Date().toISOString();
177
+ return withPowerSyncWriteTransaction(db, async (tx) => {
178
+ const threadCreated = input.createThread
179
+ ? await ensureThread(tx, { ...input.createThread, userId: input.userId }, now)
180
+ : false;
181
+ const createdAt = messageCreatedAt(input.userMessage, now);
182
+ const parentId = input.queuedAfterMessageId ?? (await resolveThreadTail(tx, input.threadId)) ?? input.parentMessageId;
183
+ const userMessage = input.queued
184
+ ? withQueueMarker(input.userMessage, { state: 'queued', runId: input.runId, executor: 'gateway', queuedAt: now })
185
+ : input.userMessage;
186
+ await writeChatMessage(tx, {
187
+ threadId: input.threadId,
188
+ userId: input.userId,
189
+ message: userMessage,
190
+ parentId,
191
+ createdAt,
192
+ updatedAt: createdAt,
193
+ });
194
+ await tx.execute(`INSERT INTO local_thread_run (id, thread_id, user_id, status, trigger, user_message_id, assistant_message_id,
195
+ parent_message_id, queued_at, permission_mode, request_payload_json, assistant_snapshot_json, queue_index,
196
+ created_at, updated_at)
197
+ VALUES (?, ?, ?, 'queued', 'submit-message', ?, ?, ?, ?, 'default', ?, ?, ?, ?, ?)`, [
198
+ input.runId,
199
+ input.threadId,
200
+ input.userId,
201
+ input.userMessage.id,
202
+ input.assistantMessage.id,
203
+ parentId,
204
+ now,
205
+ safeJson({ ...input.requestPayload, metadata: { remoteDevice: { requestId: input.requestId } } }),
206
+ safeJson(withRunMetadata(input.assistantMessage, input.runId, 'queued')),
207
+ input.queueIndex,
208
+ now,
209
+ now,
210
+ ]);
211
+ return { threadCreated };
212
+ });
213
+ }
214
+ /** Live progress for `run.status` recovery; never touches synced tables. */
215
+ export async function updateRunSnapshot(db, input) {
216
+ const now = new Date().toISOString();
217
+ await db.execute(`UPDATE local_thread_run SET status = ?, assistant_snapshot_json = ?, updated_at = ?, last_heartbeat_at = ?,
218
+ started_at = COALESCE(started_at, ?)
219
+ WHERE id = ? AND status NOT IN ('succeeded', 'failed', 'cancelled', 'interrupted', 'expired')`, [input.status, safeJson(withRunMetadata(input.assistant, input.runId, input.status)), now, now, now, input.runId]);
220
+ }
221
+ /** Ends the run and writes the final assistant message into the synced transcript. */
222
+ export async function commitRunTerminal(db, input) {
223
+ const finishedAt = new Date().toISOString();
224
+ const assistant = withRunMetadata(input.assistant, input.runId, input.status);
225
+ await withPowerSyncWriteTransaction(db, async (tx) => {
226
+ await writeChatMessage(tx, {
227
+ threadId: input.threadId,
228
+ userId: input.userId,
229
+ message: assistant,
230
+ parentId: input.userMessageId,
231
+ createdAt: messageCreatedAt(assistant, finishedAt),
232
+ updatedAt: finishedAt,
233
+ });
234
+ await tx.execute(`UPDATE local_thread_run SET status = ?, error_text = ?, finished_at = ?, updated_at = ?, expires_at = NULL,
235
+ cleanup_after_at = ?, assistant_snapshot_json = ?
236
+ WHERE id = ?`, [
237
+ input.status,
238
+ input.errorText,
239
+ finishedAt,
240
+ finishedAt,
241
+ new Date(Date.parse(finishedAt) + RUN_RETENTION_MS).toISOString(),
242
+ safeJson(assistant),
243
+ input.runId,
244
+ ]);
245
+ });
246
+ }
247
+ export async function findRun(db, input) {
248
+ const row = await db.getOptional(`SELECT id, thread_id, status, request_payload_json, assistant_snapshot_json, updated_at
249
+ FROM local_thread_run WHERE id = ? AND user_id = ? LIMIT 1`, [input.runId, input.userId]);
250
+ if (!row)
251
+ return null;
252
+ const payload = parseJson(row.request_payload_json);
253
+ const requestId = payload?.metadata?.remoteDevice?.requestId;
254
+ return {
255
+ id: row.id,
256
+ threadId: row.thread_id,
257
+ status: row.status,
258
+ requestId: typeof requestId === 'string' ? requestId : null,
259
+ assistant: parseJson(row.assistant_snapshot_json),
260
+ updatedAt: row.updated_at,
261
+ };
262
+ }
263
+ /** Rewrites a user message's queue marker in place; a missing message is ignored. */
264
+ async function writeQueueMarker(tx, messageId, marker, extra = {}) {
265
+ const row = await tx.getOptional('SELECT content FROM c_chat_message WHERE id = ? LIMIT 1', [messageId]);
266
+ const message = parseJson(row?.content);
267
+ if (!message)
268
+ return;
269
+ const now = new Date().toISOString();
270
+ await tx.execute(`UPDATE c_chat_message SET content = ?, updated_at = ?${extra.deletedAt ? ', deleted_at = ?' : ''} WHERE id = ?`, extra.deletedAt
271
+ ? [safeJson(withQueueMarker(message, marker)), now, extra.deletedAt, messageId]
272
+ : [safeJson(withQueueMarker(message, marker)), now, messageId]);
273
+ }
274
+ /** The queued turns start now: their messages stop showing as queued. */
275
+ export async function startQueuedRuns(db, userMessageIds) {
276
+ if (userMessageIds.length === 0)
277
+ return;
278
+ await withPowerSyncWriteTransaction(db, async (tx) => {
279
+ for (const messageId of userMessageIds)
280
+ await writeQueueMarker(tx, messageId, null);
281
+ });
282
+ }
283
+ /** Ends a queued turn that was merged into a later one: no reply of its own is written. */
284
+ export async function finishMergedRun(db, input) {
285
+ const now = new Date().toISOString();
286
+ const assistant = withRunMetadata(withMergedInto(input.assistant, input.mergedIntoRunId), input.runId, 'succeeded');
287
+ await db.execute(`UPDATE local_thread_run SET status = 'succeeded', finished_at = ?, updated_at = ?, assistant_snapshot_json = ?,
288
+ cleanup_after_at = ?
289
+ WHERE id = ?`, [now, now, safeJson(assistant), new Date(Date.parse(now) + RUN_RETENTION_MS).toISOString(), input.runId]);
290
+ return assistant;
291
+ }
292
+ /**
293
+ * The user took a queued turn back before it ran: the run is cancelled and its
294
+ * message removed from the transcript (marked `withdrawn` so other devices can
295
+ * tell a withdrawal from a deletion).
296
+ */
297
+ export async function withdrawQueuedRun(db, input) {
298
+ const now = new Date().toISOString();
299
+ const assistant = withRunMetadata(input.assistant, input.runId, 'cancelled');
300
+ await withPowerSyncWriteTransaction(db, async (tx) => {
301
+ await writeQueueMarker(tx, input.userMessageId, { state: 'withdrawn', runId: input.runId, executor: 'gateway', queuedAt: now }, { deletedAt: now });
302
+ await tx.execute(`UPDATE local_thread_run SET status = 'cancelled', finished_at = ?, updated_at = ?, assistant_snapshot_json = ?,
303
+ error_text = 'withdrawn by user', cleanup_after_at = ?
304
+ WHERE id = ?`, [now, now, safeJson(assistant), new Date(Date.parse(now) + RUN_RETENTION_MS).toISOString(), input.runId]);
305
+ });
306
+ return assistant;
307
+ }
308
+ /** Runs left non-terminal by a previous process can never finish; mark them interrupted. */
309
+ export async function interruptOrphanedRuns(db, userId) {
310
+ const now = new Date().toISOString();
311
+ const rows = await db.getAll(`SELECT id, user_message_id FROM local_thread_run
312
+ WHERE user_id = ? AND status NOT IN ('succeeded', 'failed', 'cancelled', 'interrupted', 'expired')
313
+ AND request_payload_json LIKE '%"gatewayId"%'`, [userId]);
314
+ for (const row of rows) {
315
+ await db.execute(`UPDATE local_thread_run SET status = 'interrupted', error_text = ?, finished_at = ?, updated_at = ? WHERE id = ?`, ['gateway host restarted', now, now, row.id]);
316
+ // A turn that was still queued will never run: it must not show as queued forever.
317
+ if (row.user_message_id)
318
+ await writeQueueMarker(db, row.user_message_id, null);
319
+ }
320
+ return rows.length;
321
+ }
322
+ function isRecord(value) {
323
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
324
+ }
325
+ function toBinding(value, legacy = false) {
326
+ if (!isRecord(value) || typeof value.sessionId !== 'string' || !value.sessionId)
327
+ return null;
328
+ return {
329
+ sessionId: value.sessionId,
330
+ topicId: typeof value.topicId === 'string' ? value.topicId : null,
331
+ lastSeenMessageId: typeof value.lastSeenMessageId === 'string' ? value.lastSeenMessageId : null,
332
+ ...(typeof value.cwd === 'string' && value.cwd ? { cwd: value.cwd } : {}),
333
+ ...(legacy ? { legacy: true } : {}),
334
+ };
335
+ }
336
+ /** `c_thread.robot_session_id`: v2 keeps a binding per gateway; v1 held a single one. */
337
+ function parseBindings(raw) {
338
+ const parsed = parseJson(raw);
339
+ const bindings = {};
340
+ if (parsed?.v === 1 && typeof parsed.gatewayId === 'string') {
341
+ const binding = toBinding(parsed, true);
342
+ if (binding)
343
+ bindings[parsed.gatewayId] = binding;
344
+ }
345
+ else if (parsed?.v === 2 && isRecord(parsed.sessions)) {
346
+ for (const [gatewayId, value] of Object.entries(parsed.sessions)) {
347
+ const binding = toBinding(value);
348
+ if (binding)
349
+ bindings[gatewayId] = binding;
350
+ }
351
+ }
352
+ return bindings;
353
+ }
354
+ async function readBindings(db, threadId) {
355
+ const row = await db.getOptional('SELECT robot_session_id FROM c_thread WHERE id = ? LIMIT 1', [threadId]);
356
+ return parseBindings(row?.robot_session_id);
357
+ }
358
+ export async function readSessionBinding(db, threadId, gatewayId) {
359
+ return (await readBindings(db, threadId))[gatewayId] ?? null;
360
+ }
361
+ /** Replaces this gateway's binding and keeps every other gateway's. */
362
+ export async function writeSessionBinding(db, threadId, gatewayId, binding) {
363
+ const { legacy: _legacy, ...stored } = binding;
364
+ await withPowerSyncWriteTransaction(db, async (tx) => {
365
+ const sessions = { ...(await readBindings(tx, threadId)), [gatewayId]: stored };
366
+ await tx.execute('UPDATE c_thread SET robot_session_id = ?, updated_at = ? WHERE id = ?', [
367
+ safeJson({ v: 2, sessions }),
368
+ new Date().toISOString(),
369
+ threadId,
370
+ ]);
371
+ });
372
+ }
373
+ /** Directories this gateway's sessions worked in, most recently used first. */
374
+ export async function readRecentSessionFolders(db, gatewayId, limit = 8) {
375
+ const rows = await db.getAll(`SELECT robot_session_id FROM c_thread
376
+ WHERE robot_session_id LIKE ? AND (is_del IS NULL OR is_del = 0)
377
+ ORDER BY updated_at DESC LIMIT 200`, [`%${gatewayId}%`]);
378
+ const folders = [];
379
+ for (const row of rows) {
380
+ const cwd = parseBindings(row.robot_session_id)[gatewayId]?.cwd;
381
+ if (cwd && !folders.includes(cwd))
382
+ folders.push(cwd);
383
+ if (folders.length >= limit)
384
+ break;
385
+ }
386
+ return folders;
387
+ }
388
+ /** The instructions the user wrote for the thread's agent (`c_project.agent_md`); empty when none. */
389
+ export async function readAgentInstructions(db, threadId) {
390
+ const row = await db.getOptional(`SELECT p.agent_md FROM c_thread t JOIN c_project p ON p.id = t.project_id
391
+ WHERE t.id = ? AND (p.is_del IS NULL OR p.is_del = 0) LIMIT 1`, [threadId]);
392
+ return row?.agent_md?.trim() ?? '';
393
+ }
394
+ /**
395
+ * The thread's current topic: the latest "new topic" marker the app wrote
396
+ * (`entry_type = 'internal'`, `meta_data.kind = 'context_reset'`). A new topic
397
+ * starts a fresh agent session; like the app, it covers rows written after the marker.
398
+ */
399
+ export async function readCurrentTopic(db, threadId) {
400
+ // Other markers (e.g. agent switches) share the entry type, so filter by kind in SQL.
401
+ const row = await db.getOptional(`SELECT id, created_at FROM c_chat_message
402
+ WHERE type = ? AND source_id = ? AND entry_type = ? AND deleted_at IS NULL
403
+ AND json_extract(meta_data, '$.kind') = 'context_reset'
404
+ ORDER BY julianday(created_at) DESC, id DESC LIMIT 1`, [THREAD_CHAT_TYPE, threadId, CONTEXT_RESET_ENTRY_TYPE]);
405
+ return row ? { id: row.id, createdAt: row.created_at } : null;
406
+ }
407
+ /** Marks a reply with the agent session that wrote it (`metadata.gateway`). */
408
+ export function withGatewaySession(message, gateway) {
409
+ return {
410
+ ...message,
411
+ metadata: { ...(message.metadata ?? {}), gateway: { id: gateway.gatewayId, sessionId: gateway.sessionId } },
412
+ };
413
+ }
414
+ const TAIL_POLL_INTERVAL_MS = 250;
415
+ /**
416
+ * Waits until a message exists in the local replica (deleted or not), e.g. the
417
+ * last message the client saw before sending a turn. False when it has not
418
+ * synced within the timeout or the wait was aborted.
419
+ */
420
+ export async function waitForMessage(db, messageId, options) {
421
+ const deadline = Date.now() + options.timeoutMs;
422
+ for (;;) {
423
+ const row = await db.getOptional('SELECT id FROM c_chat_message WHERE id = ? LIMIT 1', [messageId]);
424
+ if (row)
425
+ return true;
426
+ if (options.signal?.aborted || Date.now() >= deadline)
427
+ return false;
428
+ await new Promise((resolve) => setTimeout(resolve, TAIL_POLL_INTERVAL_MS));
429
+ }
430
+ }
431
+ function messageText(message) {
432
+ const lines = [];
433
+ for (const part of message?.parts ?? []) {
434
+ if (part.type === 'text' && typeof part.text === 'string' && part.text.trim())
435
+ lines.push(part.text.trim());
436
+ if (part.type === 'file') {
437
+ const name = part.filename;
438
+ lines.push(`[attachment: ${typeof name === 'string' && name ? name : String(part.mediaType ?? 'file')}]`);
439
+ }
440
+ }
441
+ return lines.join('\n\n');
442
+ }
443
+ /**
444
+ * The newest transcript messages in a window, oldest first, with how many
445
+ * matching messages lie before the page. Bounds are exclusive: `since` is a
446
+ * topic start (by `created_at`, as the app scopes topics), `afterMessageId` and
447
+ * `beforeMessageId` are transcript positions; an id that no longer exists is no bound.
448
+ */
449
+ export async function readTranscriptPage(db, input) {
450
+ const since = input.since ?? null;
451
+ const after = input.afterMessageId ?? null;
452
+ const before = input.beforeMessageId ?? null;
453
+ const ownSession = input.excludeSessionId ?? null;
454
+ const where = `type = ? AND source_id = ? AND deleted_at IS NULL AND ${MESSAGE_ENTRY_FILTER}
455
+ AND role IN ('user', 'assistant')
456
+ AND (? IS NULL OR julianday(created_at) > julianday(?))
457
+ AND order_index > COALESCE((SELECT order_index FROM c_chat_message WHERE id = ?), order_index - 1)
458
+ AND order_index < COALESCE((SELECT order_index FROM c_chat_message WHERE id = ?), order_index + 1)
459
+ AND (? IS NULL OR NOT (
460
+ COALESCE(json_extract(content, '$.metadata.gateway.sessionId'), '') = ?
461
+ OR id IN (SELECT parent_id FROM c_chat_message
462
+ WHERE type = ? AND source_id = ? AND parent_id IS NOT NULL
463
+ AND json_extract(content, '$.metadata.gateway.sessionId') = ?)
464
+ ))`;
465
+ const params = [
466
+ THREAD_CHAT_TYPE,
467
+ input.threadId,
468
+ MESSAGE_ENTRY_TYPE,
469
+ LEGACY_MESSAGE_ENTRY_TYPE,
470
+ since,
471
+ since,
472
+ after,
473
+ before,
474
+ ownSession,
475
+ ownSession,
476
+ THREAD_CHAT_TYPE,
477
+ input.threadId,
478
+ ownSession,
479
+ ];
480
+ const [rows, total] = await Promise.all([
481
+ db.getAll(`SELECT id, role, content, responser_id, created_at FROM c_chat_message WHERE ${where}
482
+ ORDER BY order_index DESC, created_at DESC, id DESC LIMIT ?`, [...params, input.limit]),
483
+ db.getOptional(`SELECT COUNT(*) AS count FROM c_chat_message WHERE ${where}`, params),
484
+ ]);
485
+ const parsed = rows.reverse().map((row) => {
486
+ const message = parseJson(row.content);
487
+ const responserId = message?.metadata?.responserId;
488
+ return {
489
+ row,
490
+ responserId: row.role === 'assistant' ? (typeof responserId === 'string' && responserId) || row.responser_id || null : null,
491
+ text: messageText(message),
492
+ };
493
+ });
494
+ const names = await readAgentNames(db, parsed.map((item) => item.responserId).filter((id) => Boolean(id)));
495
+ return {
496
+ messages: parsed
497
+ .filter((item) => item.text)
498
+ .map(({ row, responserId, text }) => ({
499
+ id: row.id,
500
+ role: row.role,
501
+ speaker: responserId ? (names.get(responserId) ?? null) : null,
502
+ text,
503
+ createdAt: row.created_at,
504
+ })),
505
+ earlier: Math.max(0, Number(total?.count ?? 0) - rows.length),
506
+ };
507
+ }
508
+ /** Agent (`c_project`) display names by id; unnamed agents are left out. */
509
+ async function readAgentNames(db, ids) {
510
+ const unique = [...new Set(ids)];
511
+ if (unique.length === 0)
512
+ return new Map();
513
+ const rows = await db.getAll(`SELECT id, name FROM c_project WHERE id IN (${unique.map(() => '?').join(', ')})`, unique);
514
+ return new Map(rows.filter((row) => row.name?.trim()).map((row) => [row.id, row.name.trim()]));
515
+ }
516
+ /** File parts resolve only to resources this user owns with an https storage URL. */
517
+ export async function resolveTrustedResource(db, input) {
518
+ const row = await db.getOptional(`SELECT storage_url, name, mime_type FROM c_resource
519
+ WHERE id = ? AND user_id = ? AND (is_del IS NULL OR is_del = 0) AND deleted_at IS NULL LIMIT 1`, [input.resourceId, input.userId]);
520
+ if (!row?.storage_url?.startsWith('https://'))
521
+ return null;
522
+ return { url: row.storage_url, name: row.name ?? 'attachment', mediaType: row.mime_type };
523
+ }
524
+ function robotMetaData(input) {
525
+ return JSON.stringify({
526
+ gateway: {
527
+ machineId: input.machineId,
528
+ ...(input.agentId ? { agentId: input.agentId } : {}),
529
+ ...(input.settings ? { settings: input.settings } : {}),
530
+ },
531
+ });
532
+ }
533
+ export async function upsertRobot(db, input) {
534
+ const now = new Date().toISOString();
535
+ // The backend rejects NULL c_robot columns, and PowerSync omits NULLs only from inserts.
536
+ const cwd = input.cwd ?? '';
537
+ const metaData = robotMetaData(input);
538
+ const existing = await db.getOptional('SELECT name, device_id, device_name, agent_type, cwd, meta_data, is_del FROM c_robot WHERE id = ? LIMIT 1', [
539
+ input.id,
540
+ ]);
541
+ if (existing) {
542
+ const unchanged = existing.name === input.name
543
+ && existing.device_id === input.deviceId
544
+ && existing.device_name === input.deviceName
545
+ && existing.agent_type === input.agentType
546
+ && (existing.cwd ?? '') === cwd
547
+ && sameJson(existing.meta_data, metaData)
548
+ && !existing.is_del;
549
+ // Every daemon start re-registers; skip no-op writes so they do not queue uploads.
550
+ if (unchanged)
551
+ return;
552
+ // A removed gateway that is added again revives its robot (ids are deterministic).
553
+ await db.execute(`UPDATE c_robot SET name = ?, device_id = ?, device_name = ?, agent_type = ?, cwd = ?, meta_data = ?,
554
+ updated_at = ?, is_del = 0
555
+ WHERE id = ?`, [input.name, input.deviceId, input.deviceName, input.agentType, cwd, metaData, now, input.id]);
556
+ return;
557
+ }
558
+ await db.execute(`INSERT INTO c_robot (id, name, device_id, device_name, agent_type, cwd, config, meta_data, created_at, updated_at,
559
+ user_id, is_del)
560
+ VALUES (?, ?, ?, ?, ?, ?, '{}', ?, ?, ?, ?, 0)`, [input.id, input.name, input.deviceId, input.deviceName, input.agentType, cwd, metaData, now, now, input.userId]);
561
+ }
562
+ /**
563
+ * Soft-deletes this machine's gateway robots that are no longer configured
564
+ * (removed, or replaced before ids were deterministic), so the app never
565
+ * offers an agent that nothing hosts. Robots from other machines are untouched.
566
+ */
567
+ export async function retireRemovedRobots(db, input) {
568
+ const rows = await db.getAll(`SELECT id, meta_data FROM c_robot WHERE user_id = ? AND COALESCE(is_del, 0) = 0`, [input.userId]);
569
+ const keep = new Set(input.keepIds);
570
+ const retired = rows
571
+ .filter((row) => !keep.has(row.id) && robotMachineId(row.meta_data) === input.machineId)
572
+ .map((row) => row.id);
573
+ for (const id of retired)
574
+ await softDeleteRobot(db, id);
575
+ return retired;
576
+ }
577
+ /** The backend re-serializes JSON columns, so compare them parsed. */
578
+ function sameJson(stored, expected) {
579
+ try {
580
+ return JSON.stringify(JSON.parse(stored ?? '')) === expected;
581
+ }
582
+ catch {
583
+ return false;
584
+ }
585
+ }
586
+ function robotMachineId(metaData) {
587
+ try {
588
+ const parsed = JSON.parse(metaData ?? '');
589
+ return typeof parsed?.gateway?.machineId === 'string' ? parsed.gateway.machineId : null;
590
+ }
591
+ catch {
592
+ return null;
593
+ }
594
+ }
595
+ export async function softDeleteRobot(db, robotId) {
596
+ await db.execute('UPDATE c_robot SET is_del = 1, updated_at = ? WHERE id = ?', [new Date().toISOString(), robotId]);
597
+ }