@dinoxx/dinox-cli 2.4.0 → 2.6.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.
- package/dist/commands/gateway/index.js +26 -4
- package/dist/commands/schema/renderSkills.js +1 -1
- package/dist/daemon/ensure.js +10 -19
- package/dist/daemon/env.d.ts +8 -0
- package/dist/daemon/env.js +81 -0
- package/dist/daemon/index.d.ts +27 -0
- package/dist/daemon/index.js +240 -57
- package/dist/daemon/launch.d.ts +27 -0
- package/dist/daemon/launch.js +87 -0
- package/dist/daemon/log.d.ts +12 -0
- package/dist/daemon/log.js +44 -0
- package/dist/daemon/server.js +18 -1
- package/dist/daemon/supervisor.d.ts +59 -0
- package/dist/daemon/supervisor.js +253 -0
- package/dist/daemon/types.d.ts +2 -0
- package/dist/gateway/adapters/acp.d.ts +10 -5
- package/dist/gateway/adapters/acp.js +30 -13
- package/dist/gateway/adapters/index.js +3 -2
- package/dist/gateway/config.d.ts +11 -1
- package/dist/gateway/config.js +38 -0
- package/dist/gateway/conversationContext.d.ts +44 -0
- package/dist/gateway/conversationContext.js +75 -0
- package/dist/gateway/deviceHost.d.ts +10 -1
- package/dist/gateway/deviceHost.js +119 -8
- package/dist/gateway/keepAwake.d.ts +24 -0
- package/dist/gateway/keepAwake.js +64 -0
- package/dist/gateway/mcpServer.js +20 -0
- package/dist/gateway/protocol.d.ts +88 -1
- package/dist/gateway/protocol.js +55 -0
- package/dist/gateway/runExecutor.d.ts +24 -0
- package/dist/gateway/runExecutor.js +118 -12
- package/dist/gateway/service.d.ts +4 -0
- package/dist/gateway/service.js +44 -15
- package/dist/gateway/threadStore.d.ts +71 -6
- package/dist/gateway/threadStore.js +189 -22
- package/dist/gateway/types.d.ts +22 -3
- package/dist/gateway/types.js +3 -2
- package/dist/gateway/workspaceFolders.d.ts +33 -0
- package/dist/gateway/workspaceFolders.js +129 -0
- package/dist/utils/errorCodes.d.ts +8 -0
- package/dist/utils/errorCodes.js +2 -0
- package/package.json +1 -1
|
@@ -1,10 +1,18 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
1
2
|
import { AssistantProjection, projectPendingApprovals, toTranscriptMessage } from './assistantProjection.js';
|
|
2
|
-
import {
|
|
3
|
+
import { CONTEXT_MESSAGE_LIMIT, withConversationContext } from './conversationContext.js';
|
|
4
|
+
import { commitRunTerminal, enqueueRun, finishMergedRun, startQueuedRuns, withdrawQueuedRun, readAgentInstructions, readCurrentTopic, readSessionBinding, readTranscriptPage, resolveTrustedResource, updateRunSnapshot, waitForMessage, withGatewaySession, writeSessionBinding, } from './threadStore.js';
|
|
3
5
|
import { readAgentConfig } from './protocol.js';
|
|
4
6
|
const SNAPSHOT_PERSIST_INTERVAL_MS = 1_000;
|
|
5
7
|
/** Turns waiting behind a thread's active one; more are refused as `busy`. */
|
|
6
8
|
export const MAX_QUEUED_TURNS = 10;
|
|
7
9
|
const APPROVAL_TIMEOUT_MS = 30 * 60 * 1000;
|
|
10
|
+
/** The agent's instructions travel with a new session's first prompt; longer ones are cut. */
|
|
11
|
+
const MAX_INSTRUCTIONS_CHARS = 8_000;
|
|
12
|
+
/** How long a turn waits for the client's latest messages to sync to this machine. */
|
|
13
|
+
export const CLIENT_TAIL_SYNC_TIMEOUT_MS = 5_000;
|
|
14
|
+
/** Client tails that never synced, so later turns of their thread do not wait for them again. */
|
|
15
|
+
const MAX_REMEMBERED_MISSING_TAILS = 200;
|
|
8
16
|
export class QueueFullError extends Error {
|
|
9
17
|
constructor() {
|
|
10
18
|
super(`At most ${MAX_QUEUED_TURNS} messages can wait in one conversation`);
|
|
@@ -18,6 +26,20 @@ function statusForStop(stopReason) {
|
|
|
18
26
|
return 'failed';
|
|
19
27
|
return 'succeeded';
|
|
20
28
|
}
|
|
29
|
+
/** The user's instructions for the agent, as the start of a new session. */
|
|
30
|
+
export function agentInstructionsPreamble(instructions) {
|
|
31
|
+
const text = instructions.trim();
|
|
32
|
+
if (!text)
|
|
33
|
+
return undefined;
|
|
34
|
+
const clipped = text.length > MAX_INSTRUCTIONS_CHARS ? `${text.slice(0, MAX_INSTRUCTIONS_CHARS)}…` : text;
|
|
35
|
+
return [
|
|
36
|
+
'<dinox_agent_instructions>',
|
|
37
|
+
'Instructions the user set for you in Dinox. Follow them throughout this conversation.',
|
|
38
|
+
'',
|
|
39
|
+
clipped.replaceAll('</dinox_agent_instructions>', '<\\/dinox_agent_instructions>'),
|
|
40
|
+
'</dinox_agent_instructions>',
|
|
41
|
+
].join('\n');
|
|
42
|
+
}
|
|
21
43
|
function withErrorText(message, error) {
|
|
22
44
|
return { ...message, metadata: { ...(message.metadata ?? {}), error } };
|
|
23
45
|
}
|
|
@@ -27,6 +49,8 @@ function withErrorText(message, error) {
|
|
|
27
49
|
* together as one turn (each keeps its own message; the reply follows the
|
|
28
50
|
* last). The agent session is scoped to the thread's current topic: a "new
|
|
29
51
|
* topic" marker in Dinox starts a fresh agent session, everything else continues it.
|
|
52
|
+
* The thread may have been answered by other agents meanwhile, so each turn
|
|
53
|
+
* also carries the messages this gateway's session has not seen.
|
|
30
54
|
*/
|
|
31
55
|
export class GatewayRunExecutor {
|
|
32
56
|
deps;
|
|
@@ -37,6 +61,7 @@ export class GatewayRunExecutor {
|
|
|
37
61
|
queueIndex = 0;
|
|
38
62
|
/** Accepts of one thread run one at a time, so two can never both start a turn. */
|
|
39
63
|
acceptChains = new Map();
|
|
64
|
+
missingTails = new Set();
|
|
40
65
|
constructor(deps) {
|
|
41
66
|
this.deps = deps;
|
|
42
67
|
}
|
|
@@ -144,7 +169,9 @@ export class GatewayRunExecutor {
|
|
|
144
169
|
};
|
|
145
170
|
this.activeByThread.set(request.threadId, active);
|
|
146
171
|
this.activeRequests.set(request.threadId, request);
|
|
172
|
+
const releaseAwake = this.deps.keepAwake?.();
|
|
147
173
|
void this.execute(request, active, emit, merged).finally(() => {
|
|
174
|
+
releaseAwake?.();
|
|
148
175
|
if (this.activeByThread.get(request.threadId) === active) {
|
|
149
176
|
this.activeByThread.delete(request.threadId);
|
|
150
177
|
this.activeRequests.delete(request.threadId);
|
|
@@ -235,21 +262,32 @@ export class GatewayRunExecutor {
|
|
|
235
262
|
};
|
|
236
263
|
const awaitApproval = registerApproval;
|
|
237
264
|
let errorText = null;
|
|
265
|
+
let nextBinding = null;
|
|
238
266
|
try {
|
|
239
267
|
publish(true);
|
|
240
|
-
const [
|
|
241
|
-
|
|
242
|
-
readSessionBinding(db, active.threadId),
|
|
268
|
+
const [topic, binding] = await Promise.all([
|
|
269
|
+
readCurrentTopic(db, active.threadId),
|
|
270
|
+
readSessionBinding(db, active.threadId, gateway.id),
|
|
243
271
|
]);
|
|
244
|
-
const
|
|
272
|
+
const topicId = topic?.id ?? null;
|
|
273
|
+
const continued = binding?.topicId === topicId ? binding : null;
|
|
245
274
|
const turns = [...merged, request];
|
|
246
|
-
const resources =
|
|
275
|
+
const [resources, context, instructions] = await Promise.all([
|
|
276
|
+
Promise.all(turns.map((turn) => this.resolveResources(turn.userMessage))).then((lists) => lists.flat()),
|
|
277
|
+
this.clientTailSynced(turns[0], active.controller.signal).then((synced) => this.unseenContext(active.threadId, turns[0].userMessage.id, topic?.createdAt ?? null, continued, synced)),
|
|
278
|
+
readAgentInstructions(db, active.threadId).catch(() => ''),
|
|
279
|
+
]);
|
|
280
|
+
// A session keeps the directory it started in; changing the default only affects new ones.
|
|
281
|
+
const cwd = continued ? (continued.cwd ?? gateway.previousCwd ?? gateway.cwd ?? os.homedir()) : (gateway.cwd ?? os.homedir());
|
|
247
282
|
// Validated on receipt; a malformed value never reaches here.
|
|
248
283
|
const requestedConfig = readAgentConfig(request);
|
|
249
284
|
const agentConfig = requestedConfig && requestedConfig !== 'invalid' ? requestedConfig : undefined;
|
|
285
|
+
const prompt = turns.map((turn) => turn.prompt.trim()).filter(Boolean).join('\n\n');
|
|
250
286
|
const result = await adapter.runTurn({
|
|
251
|
-
sessionId,
|
|
252
|
-
|
|
287
|
+
sessionId: continued?.sessionId ?? null,
|
|
288
|
+
cwd,
|
|
289
|
+
sessionPreamble: agentInstructionsPreamble(instructions),
|
|
290
|
+
prompt: withConversationContext(prompt, context, { canReadConversation: gateway.dinoxTools !== false }),
|
|
253
291
|
resources,
|
|
254
292
|
signal: active.controller.signal,
|
|
255
293
|
config: agentConfig,
|
|
@@ -263,9 +301,19 @@ export class GatewayRunExecutor {
|
|
|
263
301
|
publish(event.type === 'approval-request' || event.type === 'approval-resolved');
|
|
264
302
|
},
|
|
265
303
|
});
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
304
|
+
nextBinding = {
|
|
305
|
+
sessionId: result.sessionId,
|
|
306
|
+
topicId,
|
|
307
|
+
cwd,
|
|
308
|
+
lastSeenMessageId: context?.incomplete
|
|
309
|
+
? // Messages that had not synced yet sort before this turn: keep the mark so the
|
|
310
|
+
// next turn still catches up on them (its own turns are left out by session).
|
|
311
|
+
continued && continued.sessionId === result.sessionId && !continued.legacy
|
|
312
|
+
? continued.lastSeenMessageId
|
|
313
|
+
: null
|
|
314
|
+
: // Its own reply is the newest message the session has seen.
|
|
315
|
+
request.assistantMessage.id,
|
|
316
|
+
};
|
|
269
317
|
status = statusForStop(result.stopReason);
|
|
270
318
|
errorText = result.error ?? null;
|
|
271
319
|
}
|
|
@@ -287,15 +335,73 @@ export class GatewayRunExecutor {
|
|
|
287
335
|
userId: this.deps.userId,
|
|
288
336
|
userMessageId: request.userMessage.id,
|
|
289
337
|
status,
|
|
290
|
-
assistant:
|
|
338
|
+
assistant: nextBinding
|
|
339
|
+
? withGatewaySession(toTranscriptMessage(assistant), { gatewayId: gateway.id, sessionId: nextBinding.sessionId })
|
|
340
|
+
: toTranscriptMessage(assistant),
|
|
291
341
|
errorText,
|
|
292
342
|
});
|
|
293
343
|
}
|
|
294
344
|
catch (error) {
|
|
295
345
|
log('commit run terminal failed', { runId: active.runId, error: String(error) });
|
|
296
346
|
}
|
|
347
|
+
if (nextBinding) {
|
|
348
|
+
// After the commit, so the reply it marks as seen is in the transcript.
|
|
349
|
+
await writeSessionBinding(db, active.threadId, gateway.id, nextBinding).catch((error) => log('write session binding failed', { runId: active.runId, error: String(error) }));
|
|
350
|
+
}
|
|
297
351
|
emit({ revision: projection.revision, status, assistant, pendingApprovals: [] });
|
|
298
352
|
}
|
|
353
|
+
/**
|
|
354
|
+
* The client sends the last message it saw; a turn reads the transcript only
|
|
355
|
+
* once that message has synced here (its upload may still be in flight), or
|
|
356
|
+
* gives up after a short wait. A tail that never arrived is not waited for again.
|
|
357
|
+
*/
|
|
358
|
+
async clientTailSynced(request, signal) {
|
|
359
|
+
const tailId = request.parentMessageId;
|
|
360
|
+
if (!tailId)
|
|
361
|
+
return true;
|
|
362
|
+
const key = `${request.threadId}:${tailId}`;
|
|
363
|
+
const synced = await waitForMessage(this.deps.db, tailId, {
|
|
364
|
+
timeoutMs: this.missingTails.has(key) ? 0 : (this.deps.clientTailSyncTimeoutMs ?? CLIENT_TAIL_SYNC_TIMEOUT_MS),
|
|
365
|
+
signal,
|
|
366
|
+
});
|
|
367
|
+
if (synced) {
|
|
368
|
+
this.missingTails.delete(key);
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
if (this.missingTails.size >= MAX_REMEMBERED_MISSING_TAILS)
|
|
372
|
+
this.missingTails.clear();
|
|
373
|
+
this.missingTails.add(key);
|
|
374
|
+
this.deps.log('client messages not synced yet', { threadId: request.threadId, tailId });
|
|
375
|
+
}
|
|
376
|
+
return synced;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Messages before this turn that the session has not seen: the whole topic
|
|
380
|
+
* for a new session, those after the session's mark for a continued one,
|
|
381
|
+
* leaving out the turns the session answered itself. A session bound before
|
|
382
|
+
* seen messages were tracked is assumed to be up to date.
|
|
383
|
+
*/
|
|
384
|
+
async unseenContext(threadId, turnMessageId, topicStartedAt, continued, synced) {
|
|
385
|
+
if (continued?.legacy)
|
|
386
|
+
return synced ? null : { threadId, messages: [], earlier: 0, incomplete: true };
|
|
387
|
+
try {
|
|
388
|
+
const page = await readTranscriptPage(this.deps.db, {
|
|
389
|
+
threadId,
|
|
390
|
+
since: topicStartedAt,
|
|
391
|
+
afterMessageId: continued?.lastSeenMessageId ?? null,
|
|
392
|
+
beforeMessageId: turnMessageId,
|
|
393
|
+
excludeSessionId: continued?.sessionId ?? null,
|
|
394
|
+
limit: CONTEXT_MESSAGE_LIMIT,
|
|
395
|
+
});
|
|
396
|
+
return { threadId, ...page, ...(synced ? {} : { incomplete: true }) };
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
// Missing context degrades the answer; it must not fail the turn. Marked
|
|
400
|
+
// incomplete so the session's mark stays and the next turn retries.
|
|
401
|
+
this.deps.log('read conversation context failed', { threadId, error: String(error) });
|
|
402
|
+
return { threadId, messages: [], earlier: 0, incomplete: true };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
299
405
|
async resolveResources(message) {
|
|
300
406
|
const resources = [];
|
|
301
407
|
for (const part of message.parts) {
|
|
@@ -16,11 +16,15 @@ export declare class GatewayService {
|
|
|
16
16
|
private readonly deps;
|
|
17
17
|
private hosts;
|
|
18
18
|
private readonly log;
|
|
19
|
+
private readonly keepAwake;
|
|
19
20
|
constructor(deps: GatewayServiceDeps);
|
|
20
21
|
start(): Promise<void>;
|
|
21
22
|
stop(): Promise<void>;
|
|
22
23
|
/** Re-reads the account's gateways (after add/remove) without restarting the daemon. */
|
|
23
24
|
reload(): Promise<DeviceHostStatus[]>;
|
|
25
|
+
/** Saves a change made in the app, applies it to the running gateway and syncs the robot row. */
|
|
26
|
+
private updateSettings;
|
|
27
|
+
private robotRegistration;
|
|
24
28
|
status(): DeviceHostStatus[];
|
|
25
29
|
}
|
|
26
30
|
export {};
|
package/dist/gateway/service.js
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import { getPackageVersion } from '../utils/version.js';
|
|
3
|
+
import { writeDaemonLogLine } from '../daemon/log.js';
|
|
3
4
|
import { createGatewayAdapter } from './adapters/index.js';
|
|
4
|
-
import { getMachineId, loadGateways } from './config.js';
|
|
5
|
+
import { getMachineId, loadGateways, updateGatewaySettings } from './config.js';
|
|
5
6
|
import { GatewayDeviceHost } from './deviceHost.js';
|
|
7
|
+
import { KeepAwake } from './keepAwake.js';
|
|
6
8
|
import { interruptOrphanedRuns, retireRemovedRobots, upsertRobot } from './threadStore.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
/** Settings the owner changes from the app; the running gateway picks them up without a restart. */
|
|
10
|
+
const LIVE_SETTINGS = ['cwd', 'previousCwd', 'approval', 'paused', 'dinoxTools'];
|
|
11
|
+
function applyLiveSettings(target, source) {
|
|
12
|
+
for (const key of LIVE_SETTINGS) {
|
|
13
|
+
if (source[key] === undefined)
|
|
14
|
+
delete target[key];
|
|
15
|
+
else
|
|
16
|
+
target[key] = source[key];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const defaultLog = (message, details) => writeDaemonLogLine('dinox-gateway', message, details);
|
|
10
20
|
/**
|
|
11
21
|
* Runs every enabled gateway inside the daemon: the daemon owns the PowerSync
|
|
12
22
|
* database, so gateway writes share its connection and upload queue.
|
|
@@ -15,9 +25,11 @@ export class GatewayService {
|
|
|
15
25
|
deps;
|
|
16
26
|
hosts = [];
|
|
17
27
|
log;
|
|
28
|
+
keepAwake;
|
|
18
29
|
constructor(deps) {
|
|
19
30
|
this.deps = deps;
|
|
20
31
|
this.log = deps.log ?? defaultLog;
|
|
32
|
+
this.keepAwake = new KeepAwake(this.log);
|
|
21
33
|
}
|
|
22
34
|
async start() {
|
|
23
35
|
const configured = await loadGateways(this.deps.userId);
|
|
@@ -40,17 +52,7 @@ export class GatewayService {
|
|
|
40
52
|
if (interrupted > 0)
|
|
41
53
|
this.log('marked runs from a previous host as interrupted', { count: interrupted });
|
|
42
54
|
for (const gateway of gateways) {
|
|
43
|
-
await upsertRobot(this.deps.db, {
|
|
44
|
-
id: gateway.id,
|
|
45
|
-
userId: this.deps.userId,
|
|
46
|
-
name: gateway.name,
|
|
47
|
-
deviceId: gateway.deviceId,
|
|
48
|
-
agentType: gateway.kind,
|
|
49
|
-
cwd: gateway.cwd ?? null,
|
|
50
|
-
deviceName: os.hostname(),
|
|
51
|
-
machineId,
|
|
52
|
-
...(gateway.agentId ? { agentId: gateway.agentId } : {}),
|
|
53
|
-
}).catch((error) => this.log('register robot failed', { gateway: gateway.name, error: String(error) }));
|
|
55
|
+
await upsertRobot(this.deps.db, this.robotRegistration(gateway, machineId)).catch((error) => this.log('register robot failed', { gateway: gateway.name, error: String(error) }));
|
|
54
56
|
const host = new GatewayDeviceHost({
|
|
55
57
|
gateway,
|
|
56
58
|
db: this.deps.db,
|
|
@@ -60,6 +62,8 @@ export class GatewayService {
|
|
|
60
62
|
appVersion: getPackageVersion(),
|
|
61
63
|
log: (message, details) => this.log(message, { gateway: gateway.name, ...details }),
|
|
62
64
|
credentialsUrl: this.deps.credentialsUrl,
|
|
65
|
+
updateSettings: (change) => this.updateSettings(gateway, machineId, change),
|
|
66
|
+
keepAwake: () => this.keepAwake.acquire(),
|
|
63
67
|
});
|
|
64
68
|
host.start();
|
|
65
69
|
this.hosts.push(host);
|
|
@@ -69,6 +73,7 @@ export class GatewayService {
|
|
|
69
73
|
const hosts = this.hosts;
|
|
70
74
|
this.hosts = [];
|
|
71
75
|
await Promise.allSettled(hosts.map((host) => host.stop()));
|
|
76
|
+
this.keepAwake.dispose();
|
|
72
77
|
}
|
|
73
78
|
/** Re-reads the account's gateways (after add/remove) without restarting the daemon. */
|
|
74
79
|
async reload() {
|
|
@@ -76,6 +81,30 @@ export class GatewayService {
|
|
|
76
81
|
await this.start();
|
|
77
82
|
return this.status();
|
|
78
83
|
}
|
|
84
|
+
/** Saves a change made in the app, applies it to the running gateway and syncs the robot row. */
|
|
85
|
+
async updateSettings(gateway, machineId, change) {
|
|
86
|
+
const updated = await updateGatewaySettings(this.deps.userId, gateway.id, change);
|
|
87
|
+
applyLiveSettings(gateway, updated);
|
|
88
|
+
await upsertRobot(this.deps.db, this.robotRegistration(gateway, machineId)).catch((error) => this.log('sync robot settings failed', { gateway: gateway.name, error: String(error) }));
|
|
89
|
+
}
|
|
90
|
+
robotRegistration(gateway, machineId) {
|
|
91
|
+
return {
|
|
92
|
+
id: gateway.id,
|
|
93
|
+
userId: this.deps.userId,
|
|
94
|
+
name: gateway.name,
|
|
95
|
+
deviceId: gateway.deviceId,
|
|
96
|
+
agentType: gateway.kind,
|
|
97
|
+
cwd: gateway.cwd ?? null,
|
|
98
|
+
deviceName: os.hostname(),
|
|
99
|
+
machineId,
|
|
100
|
+
...(gateway.agentId ? { agentId: gateway.agentId } : {}),
|
|
101
|
+
settings: {
|
|
102
|
+
approval: gateway.approval ?? 'ask',
|
|
103
|
+
paused: gateway.paused === true,
|
|
104
|
+
dinoxTools: gateway.dinoxTools !== false,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
79
108
|
status() {
|
|
80
109
|
return this.hosts.map((host) => host.status());
|
|
81
110
|
}
|
|
@@ -105,20 +105,78 @@ export declare function withdrawQueuedRun(db: AbstractPowerSyncDatabase, input:
|
|
|
105
105
|
}): Promise<ChatUIMessage>;
|
|
106
106
|
/** Runs left non-terminal by a previous process can never finish; mark them interrupted. */
|
|
107
107
|
export declare function interruptOrphanedRuns(db: Tx, userId: string): Promise<number>;
|
|
108
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* One gateway's agent session on a thread, scoped to the thread's topic when it
|
|
110
|
+
* started. The thread can switch agents, so every gateway keeps its own session
|
|
111
|
+
* and remembers the last transcript message it saw.
|
|
112
|
+
*/
|
|
109
113
|
export type GatewaySessionBinding = {
|
|
110
114
|
sessionId: string;
|
|
111
115
|
topicId: string | null;
|
|
112
|
-
|
|
116
|
+
/** Newest transcript message the session has seen; null when it has seen nothing of the topic yet. */
|
|
117
|
+
lastSeenMessageId: string | null;
|
|
118
|
+
/** Directory the session works in; absent for bindings written before it was recorded. */
|
|
119
|
+
cwd?: string;
|
|
120
|
+
/** Written before seen messages were tracked: the session is assumed to be up to date. */
|
|
121
|
+
legacy?: true;
|
|
113
122
|
};
|
|
114
|
-
export declare function readSessionBinding(db: Tx, threadId: string): Promise<GatewaySessionBinding | null>;
|
|
115
|
-
|
|
123
|
+
export declare function readSessionBinding(db: Tx, threadId: string, gatewayId: string): Promise<GatewaySessionBinding | null>;
|
|
124
|
+
/** Replaces this gateway's binding and keeps every other gateway's. */
|
|
125
|
+
export declare function writeSessionBinding(db: AbstractPowerSyncDatabase, threadId: string, gatewayId: string, binding: GatewaySessionBinding): Promise<void>;
|
|
126
|
+
/** Directories this gateway's sessions worked in, most recently used first. */
|
|
127
|
+
export declare function readRecentSessionFolders(db: Tx, gatewayId: string, limit?: number): Promise<string[]>;
|
|
128
|
+
/** The instructions the user wrote for the thread's agent (`c_project.agent_md`); empty when none. */
|
|
129
|
+
export declare function readAgentInstructions(db: Tx, threadId: string): Promise<string>;
|
|
116
130
|
/**
|
|
117
131
|
* The thread's current topic: the latest "new topic" marker the app wrote
|
|
118
132
|
* (`entry_type = 'internal'`, `meta_data.kind = 'context_reset'`). A new topic
|
|
119
|
-
* starts a fresh agent session.
|
|
133
|
+
* starts a fresh agent session; like the app, it covers rows written after the marker.
|
|
134
|
+
*/
|
|
135
|
+
export declare function readCurrentTopic(db: Tx, threadId: string): Promise<{
|
|
136
|
+
id: string;
|
|
137
|
+
createdAt: string | null;
|
|
138
|
+
} | null>;
|
|
139
|
+
/** Marks a reply with the agent session that wrote it (`metadata.gateway`). */
|
|
140
|
+
export declare function withGatewaySession(message: ChatUIMessage, gateway: {
|
|
141
|
+
gatewayId: string;
|
|
142
|
+
sessionId: string;
|
|
143
|
+
}): ChatUIMessage;
|
|
144
|
+
/**
|
|
145
|
+
* Waits until a message exists in the local replica (deleted or not), e.g. the
|
|
146
|
+
* last message the client saw before sending a turn. False when it has not
|
|
147
|
+
* synced within the timeout or the wait was aborted.
|
|
120
148
|
*/
|
|
121
|
-
export declare function
|
|
149
|
+
export declare function waitForMessage(db: Tx, messageId: string, options: {
|
|
150
|
+
timeoutMs: number;
|
|
151
|
+
signal?: AbortSignal;
|
|
152
|
+
}): Promise<boolean>;
|
|
153
|
+
/** A transcript message reduced to what another agent needs: who said what. */
|
|
154
|
+
export type TranscriptMessage = {
|
|
155
|
+
id: string;
|
|
156
|
+
role: 'user' | 'assistant';
|
|
157
|
+
/** The answering agent's name; null for the user or an unknown agent. */
|
|
158
|
+
speaker: string | null;
|
|
159
|
+
text: string;
|
|
160
|
+
createdAt: string | null;
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* The newest transcript messages in a window, oldest first, with how many
|
|
164
|
+
* matching messages lie before the page. Bounds are exclusive: `since` is a
|
|
165
|
+
* topic start (by `created_at`, as the app scopes topics), `afterMessageId` and
|
|
166
|
+
* `beforeMessageId` are transcript positions; an id that no longer exists is no bound.
|
|
167
|
+
*/
|
|
168
|
+
export declare function readTranscriptPage(db: Tx, input: {
|
|
169
|
+
threadId: string;
|
|
170
|
+
since?: string | null;
|
|
171
|
+
afterMessageId?: string | null;
|
|
172
|
+
beforeMessageId?: string | null;
|
|
173
|
+
/** Leaves out the turns this agent session answered itself (its replies and their prompts). */
|
|
174
|
+
excludeSessionId?: string | null;
|
|
175
|
+
limit: number;
|
|
176
|
+
}): Promise<{
|
|
177
|
+
messages: TranscriptMessage[];
|
|
178
|
+
earlier: number;
|
|
179
|
+
}>;
|
|
122
180
|
/** File parts resolve only to resources this user owns with an https storage URL. */
|
|
123
181
|
export declare function resolveTrustedResource(db: Tx, input: {
|
|
124
182
|
resourceId: string;
|
|
@@ -141,6 +199,13 @@ export type RobotRegistration = {
|
|
|
141
199
|
/** Marks the robot as hosted by this installation, for reconciliation. */
|
|
142
200
|
machineId: string;
|
|
143
201
|
agentId?: string;
|
|
202
|
+
/** Owner-facing settings, synced so the app can show them while the computer is offline. */
|
|
203
|
+
settings?: RobotSettings;
|
|
204
|
+
};
|
|
205
|
+
export type RobotSettings = {
|
|
206
|
+
approval: 'ask' | 'auto_read';
|
|
207
|
+
paused: boolean;
|
|
208
|
+
dinoxTools: boolean;
|
|
144
209
|
};
|
|
145
210
|
export declare function upsertRobot(db: Tx, input: RobotRegistration): Promise<void>;
|
|
146
211
|
/**
|
|
@@ -319,38 +319,199 @@ export async function interruptOrphanedRuns(db, userId) {
|
|
|
319
319
|
}
|
|
320
320
|
return rows.length;
|
|
321
321
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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)
|
|
326
327
|
return null;
|
|
327
328
|
return {
|
|
328
|
-
sessionId:
|
|
329
|
-
topicId: typeof
|
|
330
|
-
|
|
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 } : {}),
|
|
331
334
|
};
|
|
332
335
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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() ?? '';
|
|
339
393
|
}
|
|
340
394
|
/**
|
|
341
395
|
* The thread's current topic: the latest "new topic" marker the app wrote
|
|
342
396
|
* (`entry_type = 'internal'`, `meta_data.kind = 'context_reset'`). A new topic
|
|
343
|
-
* starts a fresh agent session.
|
|
397
|
+
* starts a fresh agent session; like the app, it covers rows written after the marker.
|
|
344
398
|
*/
|
|
345
|
-
export async function
|
|
346
|
-
|
|
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
|
|
347
402
|
WHERE type = ? AND source_id = ? AND entry_type = ? AND deleted_at IS NULL
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
+
}
|
|
352
440
|
}
|
|
353
|
-
return
|
|
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()]));
|
|
354
515
|
}
|
|
355
516
|
/** File parts resolve only to resources this user owns with an https storage URL. */
|
|
356
517
|
export async function resolveTrustedResource(db, input) {
|
|
@@ -361,7 +522,13 @@ export async function resolveTrustedResource(db, input) {
|
|
|
361
522
|
return { url: row.storage_url, name: row.name ?? 'attachment', mediaType: row.mime_type };
|
|
362
523
|
}
|
|
363
524
|
function robotMetaData(input) {
|
|
364
|
-
return JSON.stringify({
|
|
525
|
+
return JSON.stringify({
|
|
526
|
+
gateway: {
|
|
527
|
+
machineId: input.machineId,
|
|
528
|
+
...(input.agentId ? { agentId: input.agentId } : {}),
|
|
529
|
+
...(input.settings ? { settings: input.settings } : {}),
|
|
530
|
+
},
|
|
531
|
+
});
|
|
365
532
|
}
|
|
366
533
|
export async function upsertRobot(db, input) {
|
|
367
534
|
const now = new Date().toISOString();
|