@dinoxx/dinox-cli 2.6.0 → 2.8.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 +1 -1
- package/dist/commands/schema/renderSkills.js +1 -1
- package/dist/commands/storage/repo/remote.d.ts +1 -1
- package/dist/commands/storage/repo/remote.js +2 -2
- package/dist/daemon/index.js +37 -6
- package/dist/daemon/launch.d.ts +5 -1
- package/dist/daemon/launch.js +14 -6
- package/dist/daemon/log.d.ts +2 -0
- package/dist/daemon/log.js +16 -3
- package/dist/daemon/server.js +17 -1
- package/dist/daemon/supervise.d.ts +29 -0
- package/dist/daemon/supervise.js +98 -0
- package/dist/daemon/supervisor.d.ts +21 -5
- package/dist/daemon/supervisor.js +195 -7
- package/dist/daemon/types.d.ts +1 -1
- package/dist/gateway/adapters/acp.d.ts +21 -5
- package/dist/gateway/adapters/acp.js +86 -7
- package/dist/gateway/adapters/codexImages.d.ts +4 -0
- package/dist/gateway/adapters/codexImages.js +34 -0
- package/dist/gateway/adapters/index.d.ts +5 -2
- package/dist/gateway/adapters/index.js +19 -10
- package/dist/gateway/assistantProjection.d.ts +5 -1
- package/dist/gateway/assistantProjection.js +48 -11
- package/dist/gateway/detect.js +1 -1
- package/dist/gateway/deviceHost.d.ts +3 -1
- package/dist/gateway/deviceHost.js +6 -0
- package/dist/gateway/fileUpload.d.ts +12 -0
- package/dist/gateway/fileUpload.js +154 -0
- package/dist/gateway/mcpEnv.d.ts +5 -0
- package/dist/gateway/mcpEnv.js +5 -0
- package/dist/gateway/mcpServer.d.ts +12 -2
- package/dist/gateway/mcpServer.js +41 -7
- package/dist/gateway/runExecutor.d.ts +9 -1
- package/dist/gateway/runExecutor.js +43 -2
- package/dist/gateway/service.d.ts +3 -0
- package/dist/gateway/service.js +9 -0
- package/dist/gateway/types.d.ts +27 -0
- package/package.json +1 -1
|
@@ -8,6 +8,27 @@ const APPROVAL_TTL_MS = 30 * 60 * 1000;
|
|
|
8
8
|
const MAX_PENDING_APPROVALS = 32;
|
|
9
9
|
const PREVIEW_MAX_CHARS = 1500;
|
|
10
10
|
const SENSITIVE_KEY = /password|token|secret|api[-_ ]?key|authorization|cookie|credential|private[-_ ]?key/i;
|
|
11
|
+
/** Tool input/output is shown as a preview; whole files (e.g. a generated image as base64) would overflow every event. */
|
|
12
|
+
const TOOL_STRING_MAX_CHARS = 4000;
|
|
13
|
+
const TOOL_VALUE_MAX_ITEMS = 64;
|
|
14
|
+
const TOOL_VALUE_MAX_DEPTH = 6;
|
|
15
|
+
export function compactToolValue(value, depth = 0) {
|
|
16
|
+
if (typeof value === 'string') {
|
|
17
|
+
if (value.startsWith('data:') && value.length > 256) {
|
|
18
|
+
return `[${value.slice(5, value.indexOf(';') > 0 ? value.indexOf(';') : 32)} data, ${value.length} chars]`;
|
|
19
|
+
}
|
|
20
|
+
return value.length > TOOL_STRING_MAX_CHARS ? `${value.slice(0, TOOL_STRING_MAX_CHARS)}… (${value.length} chars)` : value;
|
|
21
|
+
}
|
|
22
|
+
if (!value || typeof value !== 'object')
|
|
23
|
+
return value;
|
|
24
|
+
if (depth >= TOOL_VALUE_MAX_DEPTH)
|
|
25
|
+
return '[…]';
|
|
26
|
+
if (Array.isArray(value))
|
|
27
|
+
return value.slice(0, TOOL_VALUE_MAX_ITEMS).map((item) => compactToolValue(item, depth + 1));
|
|
28
|
+
return Object.fromEntries(Object.entries(value)
|
|
29
|
+
.slice(0, TOOL_VALUE_MAX_ITEMS)
|
|
30
|
+
.map(([key, item]) => [key, compactToolValue(item, depth + 1)]));
|
|
31
|
+
}
|
|
11
32
|
export class AssistantProjection {
|
|
12
33
|
base;
|
|
13
34
|
parts = [];
|
|
@@ -43,7 +64,7 @@ export class AssistantProjection {
|
|
|
43
64
|
part.state = 'approval-requested';
|
|
44
65
|
part.approval = { id: event.approvalId };
|
|
45
66
|
if (event.input !== undefined)
|
|
46
|
-
part.input = event.input;
|
|
67
|
+
part.input = compactToolValue(event.input);
|
|
47
68
|
break;
|
|
48
69
|
}
|
|
49
70
|
case 'approval-resolved': {
|
|
@@ -59,6 +80,18 @@ export class AssistantProjection {
|
|
|
59
80
|
}
|
|
60
81
|
this.revisionCounter += 1;
|
|
61
82
|
}
|
|
83
|
+
/** A file sent to the user, shown where the agent sent it (the app renders images inline). */
|
|
84
|
+
addFile(file) {
|
|
85
|
+
this.closeTextSegment();
|
|
86
|
+
this.parts.push({
|
|
87
|
+
type: 'file',
|
|
88
|
+
mediaType: file.mediaType,
|
|
89
|
+
url: file.url,
|
|
90
|
+
filename: file.name,
|
|
91
|
+
providerMetadata: { dinox: { resourceId: file.resourceId } },
|
|
92
|
+
});
|
|
93
|
+
this.revisionCounter += 1;
|
|
94
|
+
}
|
|
62
95
|
/** Closes streaming parts; called once when the turn ends. */
|
|
63
96
|
finish() {
|
|
64
97
|
for (const part of this.parts) {
|
|
@@ -93,13 +126,16 @@ export class AssistantProjection {
|
|
|
93
126
|
if (index !== undefined)
|
|
94
127
|
return this.parts[index];
|
|
95
128
|
// A tool call closes the text segment before it.
|
|
96
|
-
|
|
97
|
-
if (last && (last.type === 'text' || last.type === 'reasoning'))
|
|
98
|
-
last.state = 'done';
|
|
129
|
+
this.closeTextSegment();
|
|
99
130
|
const part = { type: 'dynamic-tool', toolCallId, toolName, state: 'input-streaming', input: undefined };
|
|
100
131
|
this.toolIndex.set(toolCallId, this.parts.push(part) - 1);
|
|
101
132
|
return part;
|
|
102
133
|
}
|
|
134
|
+
closeTextSegment() {
|
|
135
|
+
const last = this.parts.at(-1);
|
|
136
|
+
if (last && (last.type === 'text' || last.type === 'reasoning'))
|
|
137
|
+
last.state = 'done';
|
|
138
|
+
}
|
|
103
139
|
findToolByApproval(approvalId) {
|
|
104
140
|
return this.parts.find((part) => part.type === 'dynamic-tool' && part.approval?.id === approvalId);
|
|
105
141
|
}
|
|
@@ -108,26 +144,27 @@ export class AssistantProjection {
|
|
|
108
144
|
if (event.title)
|
|
109
145
|
part.toolName = event.title;
|
|
110
146
|
if (event.input !== undefined)
|
|
111
|
-
part.input = event.input;
|
|
147
|
+
part.input = compactToolValue(event.input);
|
|
148
|
+
const output = event.output === undefined ? undefined : compactToolValue(event.output);
|
|
112
149
|
switch (event.status) {
|
|
113
150
|
case 'completed':
|
|
114
151
|
part.state = 'output-available';
|
|
115
|
-
part.output =
|
|
152
|
+
part.output = output ?? part.output ?? null;
|
|
116
153
|
break;
|
|
117
154
|
case 'failed':
|
|
118
155
|
part.state = 'output-error';
|
|
119
|
-
part.errorText = typeof
|
|
156
|
+
part.errorText = typeof output === 'string' ? output : 'Tool failed';
|
|
120
157
|
break;
|
|
121
158
|
case 'pending':
|
|
122
159
|
case 'running':
|
|
123
160
|
if (part.state !== 'approval-requested')
|
|
124
161
|
part.state = 'input-available';
|
|
125
|
-
if (
|
|
126
|
-
part.output =
|
|
162
|
+
if (output !== undefined)
|
|
163
|
+
part.output = output;
|
|
127
164
|
break;
|
|
128
165
|
default:
|
|
129
|
-
if (
|
|
130
|
-
part.output =
|
|
166
|
+
if (output !== undefined)
|
|
167
|
+
part.output = output;
|
|
131
168
|
}
|
|
132
169
|
}
|
|
133
170
|
}
|
package/dist/gateway/detect.js
CHANGED
|
@@ -70,7 +70,7 @@ export async function findExecutable(name, envPath = process.env.PATH ?? '') {
|
|
|
70
70
|
}
|
|
71
71
|
/** Starts the agent, runs `initialize`, and stops it. Never leaves the process running. */
|
|
72
72
|
export async function probeAcpAgent(command, args, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
73
|
-
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'ignore'], env: process.env });
|
|
73
|
+
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'ignore'], env: process.env, windowsHide: true });
|
|
74
74
|
let timer;
|
|
75
75
|
try {
|
|
76
76
|
const exited = new Promise((_, reject) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { wsconnect } from '@nats-io/nats-core';
|
|
2
2
|
import type { AbstractPowerSyncDatabase } from '@powersync/common';
|
|
3
3
|
import type { GatewaySettingsPatch } from './config.js';
|
|
4
|
-
import type { GatewayAdapter, GatewayDefinition } from './types.js';
|
|
4
|
+
import type { GatewayAdapter, GatewayDefinition, GatewayFileSource, GatewaySentFile } from './types.js';
|
|
5
5
|
export declare const DEFAULT_NATS_CREDENTIALS_URL = "https://aisdk.chatgo.pro/api/nats/credentials";
|
|
6
6
|
type Logger = (message: string, details?: Record<string, unknown>) => void;
|
|
7
7
|
export type DeviceHostState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error';
|
|
@@ -59,6 +59,8 @@ export declare class GatewayDeviceHost {
|
|
|
59
59
|
/** Latest event per run while offline; the snapshot model makes older ones redundant. */
|
|
60
60
|
private readonly pendingEvents;
|
|
61
61
|
constructor(deps: DeviceHostDeps);
|
|
62
|
+
/** A `send_file` from the Dinox tools of one of this gateway's sessions; `null` when the token is another's. */
|
|
63
|
+
sendFile(token: string, file: GatewayFileSource): Promise<GatewaySentFile> | null;
|
|
62
64
|
start(): void;
|
|
63
65
|
stop(): Promise<void>;
|
|
64
66
|
status(): DeviceHostStatus;
|
|
@@ -7,6 +7,7 @@ import { isAgentConfigQuery, isAgentFoldersQuery, isAgentSettingsQuery, isCancel
|
|
|
7
7
|
import { GatewayRunExecutor, MAX_QUEUED_TURNS, QueueFullError } from './runExecutor.js';
|
|
8
8
|
import { RunStreamPublisher } from './streamPublisher.js';
|
|
9
9
|
import { findRun, readRecentSessionFolders, resolveTrustedResource, TERMINAL_RUN_STATUSES, threadExists, } from './threadStore.js';
|
|
10
|
+
import { uploadToOfficialStorage } from './fileUpload.js';
|
|
10
11
|
import { agentHomeFolder, ensureAgentHomeFolder, existingWorkspaceFolders, listWorkspaceFolders, resolveWorkspaceFolder, WorkspaceFolderError, } from './workspaceFolders.js';
|
|
11
12
|
export const DEFAULT_NATS_CREDENTIALS_URL = 'https://aisdk.chatgo.pro/api/nats/credentials';
|
|
12
13
|
const CREDENTIALS_TIMEOUT_MS = 10_000;
|
|
@@ -68,8 +69,13 @@ export class GatewayDeviceHost {
|
|
|
68
69
|
adapter: deps.adapter,
|
|
69
70
|
log: deps.log,
|
|
70
71
|
keepAwake: deps.keepAwake,
|
|
72
|
+
uploadFile: (file) => uploadToOfficialStorage({ db: deps.db, userId: deps.userId, authorization: deps.authorization, fetchImpl: deps.fetchImpl }, file),
|
|
71
73
|
});
|
|
72
74
|
}
|
|
75
|
+
/** A `send_file` from the Dinox tools of one of this gateway's sessions; `null` when the token is another's. */
|
|
76
|
+
sendFile(token, file) {
|
|
77
|
+
return this.deps.adapter.sendFile?.(token, file) ?? null;
|
|
78
|
+
}
|
|
73
79
|
start() {
|
|
74
80
|
this.stopped = false;
|
|
75
81
|
void this.connect();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AbstractPowerSyncDatabase } from '@powersync/common';
|
|
2
|
+
import type { GatewayFileSource, GatewaySentFile } from './types.js';
|
|
3
|
+
export declare const MAX_SENT_FILE_BYTES: number;
|
|
4
|
+
export type OfficialUploadDeps = {
|
|
5
|
+
db: AbstractPowerSyncDatabase;
|
|
6
|
+
userId: string;
|
|
7
|
+
authorization: string;
|
|
8
|
+
fetchImpl?: typeof fetch;
|
|
9
|
+
/** Overrides the API host (tests). */
|
|
10
|
+
apiBaseUrl?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function uploadToOfficialStorage(deps: OfficialUploadDeps, source: GatewayFileSource): Promise<GatewaySentFile>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getTableColumns } from '../commands/storage/repo/configs.js';
|
|
5
|
+
import { generateThumbnailBuffer, loadSharp } from '../commands/storage/repo/remote.js';
|
|
6
|
+
import { OFFICIAL_STORAGE_PROVIDER } from '../commands/storage/repo/shared.js';
|
|
7
|
+
import { buildObjectId, generateThumbnailKey, guessContentType } from '../commands/storage/repo/targets.js';
|
|
8
|
+
import { POWERSYNC_DEFAULT_UPLOAD_BASE_URL } from '../config/serviceEndpoints.js';
|
|
9
|
+
import { toAuthorizationHeaderValue } from '../utils/authToken.js';
|
|
10
|
+
import { generateId } from '../utils/id.js';
|
|
11
|
+
/**
|
|
12
|
+
* Files an agent hands to the user leave this computer through the official
|
|
13
|
+
* Dinox storage, the way the app uploads attachments: the API signs a PUT URL,
|
|
14
|
+
* the bytes go straight to OSS, and a `c_resource` row (synced by PowerSync)
|
|
15
|
+
* records them. The message only carries the public URL, never the bytes.
|
|
16
|
+
*/
|
|
17
|
+
const OFFICIAL_DOMAIN = 'https://dinoai.oss-accelerate.aliyuncs.com';
|
|
18
|
+
const OFFICIAL_ENDPOINT = 'https://oss-accelerate.aliyuncs.com';
|
|
19
|
+
const OFFICIAL_BUCKET = 'dinoai';
|
|
20
|
+
const CODE_OK = '000000';
|
|
21
|
+
const SIGN_TIMEOUT_MS = 20_000;
|
|
22
|
+
const PUT_TIMEOUT_MS = 120_000;
|
|
23
|
+
export const MAX_SENT_FILE_BYTES = 50 * 1024 * 1024;
|
|
24
|
+
const EXTENSION_BY_MEDIA_TYPE = {
|
|
25
|
+
'image/png': 'png',
|
|
26
|
+
'image/jpeg': 'jpg',
|
|
27
|
+
'image/gif': 'gif',
|
|
28
|
+
'image/webp': 'webp',
|
|
29
|
+
'image/svg+xml': 'svg',
|
|
30
|
+
'application/pdf': 'pdf',
|
|
31
|
+
'text/plain': 'txt',
|
|
32
|
+
'text/markdown': 'md',
|
|
33
|
+
};
|
|
34
|
+
function categoryFor(mediaType) {
|
|
35
|
+
if (mediaType.startsWith('image/'))
|
|
36
|
+
return 'images';
|
|
37
|
+
if (mediaType.startsWith('video/'))
|
|
38
|
+
return 'videos';
|
|
39
|
+
if (mediaType.startsWith('audio/'))
|
|
40
|
+
return 'audios';
|
|
41
|
+
return 'files';
|
|
42
|
+
}
|
|
43
|
+
async function readSource(source) {
|
|
44
|
+
if (source.kind === 'data') {
|
|
45
|
+
const bytes = Buffer.from(source.data, 'base64');
|
|
46
|
+
const extension = EXTENSION_BY_MEDIA_TYPE[source.mediaType];
|
|
47
|
+
const name = source.name?.trim() || `file${extension ? `.${extension}` : ''}`;
|
|
48
|
+
if (bytes.byteLength > MAX_SENT_FILE_BYTES)
|
|
49
|
+
throw new Error(`${name} is larger than 50 MB`);
|
|
50
|
+
return { bytes, name, mediaType: source.mediaType };
|
|
51
|
+
}
|
|
52
|
+
const stats = await fsp.stat(source.path).catch(() => null);
|
|
53
|
+
if (!stats?.isFile())
|
|
54
|
+
throw new Error(`Not a file: ${source.path}`);
|
|
55
|
+
if (stats.size > MAX_SENT_FILE_BYTES)
|
|
56
|
+
throw new Error(`${path.basename(source.path)} is larger than 50 MB`);
|
|
57
|
+
return {
|
|
58
|
+
bytes: await fsp.readFile(source.path),
|
|
59
|
+
name: source.name?.trim() || path.basename(source.path),
|
|
60
|
+
mediaType: guessContentType(source.path),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function fetchWithTimeout(fetchImpl, url, init, timeoutMs) {
|
|
64
|
+
return fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
65
|
+
}
|
|
66
|
+
/** A presigned PUT URL for `key` from the Dinox API. */
|
|
67
|
+
async function signPut(deps, key, contentType) {
|
|
68
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
69
|
+
const response = await fetchWithTimeout(fetchImpl, `${deps.apiBaseUrl ?? POWERSYNC_DEFAULT_UPLOAD_BASE_URL}/oss/generatePreSignedUrl`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
accept: 'application/json',
|
|
74
|
+
Authorization: toAuthorizationHeaderValue(deps.authorization),
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify({ objectId: key, contentType }),
|
|
77
|
+
}, SIGN_TIMEOUT_MS);
|
|
78
|
+
const body = (await response.json().catch(() => null));
|
|
79
|
+
const url = body?.data?.url;
|
|
80
|
+
if (!response.ok || body?.code !== CODE_OK || typeof url !== 'string' || !url) {
|
|
81
|
+
throw new Error(`Signing the upload failed (${response.status}${body?.msg ? `: ${String(body.msg)}` : ''})`);
|
|
82
|
+
}
|
|
83
|
+
return url;
|
|
84
|
+
}
|
|
85
|
+
async function putObject(deps, key, bytes, contentType) {
|
|
86
|
+
const signedUrl = await signPut(deps, key, contentType);
|
|
87
|
+
const response = await fetchWithTimeout(deps.fetchImpl ?? fetch, signedUrl, { method: 'PUT', headers: { 'Content-Type': contentType }, body: new Uint8Array(bytes) }, PUT_TIMEOUT_MS);
|
|
88
|
+
if (!response.ok)
|
|
89
|
+
throw new Error(`Uploading failed (${response.status})`);
|
|
90
|
+
return response.headers.get('etag');
|
|
91
|
+
}
|
|
92
|
+
/** The thumbnail the app shows in lists; optional, so a missing image library never fails the upload. */
|
|
93
|
+
async function uploadThumbnail(deps, key, bytes) {
|
|
94
|
+
try {
|
|
95
|
+
const sharp = await loadSharp();
|
|
96
|
+
const thumbnail = await generateThumbnailBuffer(sharp, bytes);
|
|
97
|
+
const thumbnailKey = generateThumbnailKey(key);
|
|
98
|
+
await putObject(deps, thumbnailKey, thumbnail, 'image/webp');
|
|
99
|
+
return `${OFFICIAL_DOMAIN}/${thumbnailKey}`;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function insertResource(db, values) {
|
|
106
|
+
// Only the columns this schema version has, like `dino storage upload`.
|
|
107
|
+
const columns = await getTableColumns(db, 'c_resource');
|
|
108
|
+
const entries = Object.entries(values).filter(([column]) => columns.has(column));
|
|
109
|
+
await db.execute(`INSERT INTO c_resource (${entries.map(([column]) => column).join(', ')}) VALUES (${entries.map(() => '?').join(', ')})`, entries.map(([, value]) => value));
|
|
110
|
+
}
|
|
111
|
+
export async function uploadToOfficialStorage(deps, source) {
|
|
112
|
+
const { bytes, name, mediaType } = await readSource(source);
|
|
113
|
+
const category = categoryFor(mediaType);
|
|
114
|
+
const extension = path.extname(name) || (EXTENSION_BY_MEDIA_TYPE[mediaType] ? `.${EXTENSION_BY_MEDIA_TYPE[mediaType]}` : '');
|
|
115
|
+
const key = buildObjectId(deps.userId, category, `file${extension}`);
|
|
116
|
+
const etag = await putObject(deps, key, bytes, mediaType);
|
|
117
|
+
const url = `${OFFICIAL_DOMAIN}/${key}`;
|
|
118
|
+
const thumbnailUrl = category === 'images' && mediaType !== 'image/svg+xml' ? await uploadThumbnail(deps, key, bytes) : null;
|
|
119
|
+
const id = generateId();
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
// Same row the app writes for an official upload (the thumbnail URL lives in `checksum`).
|
|
122
|
+
await insertResource(deps.db, {
|
|
123
|
+
id,
|
|
124
|
+
user_id: deps.userId,
|
|
125
|
+
name,
|
|
126
|
+
mime_type: mediaType,
|
|
127
|
+
size_bytes: bytes.byteLength,
|
|
128
|
+
storage_config_id: null,
|
|
129
|
+
storage_key: key,
|
|
130
|
+
storage_url: url,
|
|
131
|
+
storage_provider: OFFICIAL_STORAGE_PROVIDER,
|
|
132
|
+
bucket: OFFICIAL_BUCKET,
|
|
133
|
+
is_public: 0,
|
|
134
|
+
created_at: now,
|
|
135
|
+
updated_at: now,
|
|
136
|
+
is_del: 0,
|
|
137
|
+
status: 'uploaded',
|
|
138
|
+
uploaded_at: now,
|
|
139
|
+
etag: etag ?? '',
|
|
140
|
+
checksum: thumbnailUrl ?? '',
|
|
141
|
+
category,
|
|
142
|
+
width: 0,
|
|
143
|
+
height: 0,
|
|
144
|
+
duration: 0,
|
|
145
|
+
endpoint: OFFICIAL_ENDPOINT,
|
|
146
|
+
domain: OFFICIAL_DOMAIN,
|
|
147
|
+
extra_data: '{}',
|
|
148
|
+
deleted_at: null,
|
|
149
|
+
content_sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
|
|
150
|
+
content_md: null,
|
|
151
|
+
summary: null,
|
|
152
|
+
});
|
|
153
|
+
return { resourceId: id, url, name, mediaType, sizeBytes: bytes.byteLength };
|
|
154
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Environment the gateway starts `dino gateway mcp` with, for each agent session. */
|
|
2
|
+
/** Names the session whose turn a `send_file` belongs to. */
|
|
3
|
+
export declare const GATEWAY_SESSION_TOKEN_ENV = "DINOX_GATEWAY_SESSION_TOKEN";
|
|
4
|
+
/** `0` offers only `send_file`: the owner turned the knowledge tools off. */
|
|
5
|
+
export declare const GATEWAY_KNOWLEDGE_TOOLS_ENV = "DINOX_GATEWAY_KNOWLEDGE_TOOLS";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Environment the gateway starts `dino gateway mcp` with, for each agent session. */
|
|
2
|
+
/** Names the session whose turn a `send_file` belongs to. */
|
|
3
|
+
export const GATEWAY_SESSION_TOKEN_ENV = 'DINOX_GATEWAY_SESSION_TOKEN';
|
|
4
|
+
/** `0` offers only `send_file`: the owner turned the knowledge tools off. */
|
|
5
|
+
export const GATEWAY_KNOWLEDGE_TOOLS_ENV = 'DINOX_GATEWAY_KNOWLEDGE_TOOLS';
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
export type DaemonCall = (method: string, params?: unknown
|
|
3
|
-
|
|
2
|
+
export type DaemonCall = (method: string, params?: unknown, options?: {
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
}) => Promise<unknown>;
|
|
5
|
+
export type DinoxMcpOptions = {
|
|
6
|
+
/** Offer the knowledge tools (notes, todos, conversations); default true. */
|
|
7
|
+
knowledgeTools?: boolean;
|
|
8
|
+
/** The session `send_file` belongs to; absent, `send_file` is not offered. */
|
|
9
|
+
sessionToken?: string;
|
|
10
|
+
/** Resolves relative `send_file` paths; default the process directory. */
|
|
11
|
+
cwd?: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function createDinoxMcpServer(call?: DaemonCall, options?: DinoxMcpOptions): McpServer;
|
|
4
14
|
export declare function serveDinoxMcpOverStdio(): Promise<void>;
|
|
@@ -1,25 +1,57 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
4
|
import { z } from 'zod';
|
|
4
5
|
import { sendDaemonRequest } from '../daemon/client.js';
|
|
6
|
+
import { GATEWAY_KNOWLEDGE_TOOLS_ENV, GATEWAY_SESSION_TOKEN_ENV } from './mcpEnv.js';
|
|
5
7
|
import { READ_CONVERSATION_MAX_LIMIT } from './conversationContext.js';
|
|
6
8
|
import { getPackageVersion } from '../utils/version.js';
|
|
7
9
|
/**
|
|
8
|
-
* Dinox
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* from a third-party agent would bypass the app's approval
|
|
10
|
+
* Dinox tools for gateway agents, served over stdio MCP. The agent launches
|
|
11
|
+
* this as a child process (`dino gateway mcp`); every call goes through the
|
|
12
|
+
* daemon, which owns the database. The knowledge tools are read-only by
|
|
13
|
+
* design: writes from a third-party agent would bypass the app's approval
|
|
14
|
+
* flow. `send_file` only adds a file to the reply the agent is writing.
|
|
12
15
|
*/
|
|
13
16
|
const MAX_NOTE_CHARS = 20_000;
|
|
14
17
|
const MAX_RESULTS = 50;
|
|
18
|
+
/** Uploading a large file can take a while; the daemon's default request timeout is 10s. */
|
|
19
|
+
const SEND_FILE_TIMEOUT_MS = 150_000;
|
|
15
20
|
function text(value) {
|
|
16
21
|
return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
|
|
17
22
|
}
|
|
18
23
|
function failure(error) {
|
|
19
24
|
return { ...text(`Dinox is unavailable: ${error instanceof Error ? error.message : String(error)}`), isError: true };
|
|
20
25
|
}
|
|
21
|
-
export function createDinoxMcpServer(call = (method, params) => sendDaemonRequest(method, params)) {
|
|
26
|
+
export function createDinoxMcpServer(call = (method, params, options) => sendDaemonRequest(method, params, options), options = {}) {
|
|
22
27
|
const server = new McpServer({ name: 'dinox', version: getPackageVersion() });
|
|
28
|
+
if (options.sessionToken)
|
|
29
|
+
registerSendFile(server, call, options.sessionToken, options.cwd ?? process.cwd());
|
|
30
|
+
if (options.knowledgeTools !== false)
|
|
31
|
+
registerKnowledgeTools(server, call);
|
|
32
|
+
return server;
|
|
33
|
+
}
|
|
34
|
+
function registerSendFile(server, call, sessionToken, cwd) {
|
|
35
|
+
server.registerTool('send_file', {
|
|
36
|
+
description: 'Show the user a file from this computer (an image you generated, a document, a chart, ...). The user talks to ' +
|
|
37
|
+
'you from the Dinox app and cannot open files on this computer, so a path alone is useless to them: call this ' +
|
|
38
|
+
'for every file they should see, then answer as usual. Images appear in the reply; other files as attachments. ' +
|
|
39
|
+
'Up to 50 MB per file.',
|
|
40
|
+
inputSchema: {
|
|
41
|
+
path: z.string().describe('Absolute path of the file (relative paths resolve from your working directory)'),
|
|
42
|
+
name: z.string().optional().describe('File name to show; defaults to the file name in the path'),
|
|
43
|
+
},
|
|
44
|
+
}, async ({ path: filePath, name }) => {
|
|
45
|
+
try {
|
|
46
|
+
const sent = (await call('gateway.sendFile', { token: sessionToken, path: path.resolve(cwd, filePath), ...(name ? { name } : {}) }, { timeoutMs: SEND_FILE_TIMEOUT_MS }));
|
|
47
|
+
return text({ sent: true, name: sent.name, mediaType: sent.mediaType });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return { ...text(`Sending the file failed: ${error instanceof Error ? error.message : String(error)}`), isError: true };
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function registerKnowledgeTools(server, call) {
|
|
23
55
|
server.registerTool('search_notes', {
|
|
24
56
|
description: "Search the user's Dinox notes by keyword. Returns id, title, summary, tags and folders; use read_note for full content.",
|
|
25
57
|
inputSchema: {
|
|
@@ -129,10 +161,12 @@ export function createDinoxMcpServer(call = (method, params) => sendDaemonReques
|
|
|
129
161
|
return failure(error);
|
|
130
162
|
}
|
|
131
163
|
});
|
|
132
|
-
return server;
|
|
133
164
|
}
|
|
134
165
|
export async function serveDinoxMcpOverStdio() {
|
|
135
|
-
const server = createDinoxMcpServer(
|
|
166
|
+
const server = createDinoxMcpServer(undefined, {
|
|
167
|
+
knowledgeTools: process.env[GATEWAY_KNOWLEDGE_TOOLS_ENV] !== '0',
|
|
168
|
+
sessionToken: process.env[GATEWAY_SESSION_TOKEN_ENV]?.trim() || undefined,
|
|
169
|
+
});
|
|
136
170
|
await server.connect(new StdioServerTransport());
|
|
137
171
|
await new Promise((resolve) => process.stdin.once('close', resolve));
|
|
138
172
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { AbstractPowerSyncDatabase } from '@powersync/common';
|
|
2
2
|
import type { RemoteDeviceRunRequest, RemoteDeviceToolApprovalAckCode } from '../vendor/electron/shared/remote-device-agent.js';
|
|
3
3
|
import type { RunSnapshot } from './streamPublisher.js';
|
|
4
|
-
import type { GatewayAdapter, GatewayDefinition } from './types.js';
|
|
4
|
+
import type { GatewayAdapter, GatewayDefinition, GatewayFileSource, GatewaySentFile } from './types.js';
|
|
5
5
|
/** Turns waiting behind a thread's active one; more are refused as `busy`. */
|
|
6
6
|
export declare const MAX_QUEUED_TURNS = 10;
|
|
7
|
+
/** Files one reply may carry, like the attachments of one message. */
|
|
8
|
+
export declare const MAX_SENT_FILES_PER_TURN = 16;
|
|
7
9
|
/** How long a turn waits for the client's latest messages to sync to this machine. */
|
|
8
10
|
export declare const CLIENT_TAIL_SYNC_TIMEOUT_MS = 5000;
|
|
9
11
|
type Logger = (message: string, details?: Record<string, unknown>) => void;
|
|
@@ -21,9 +23,15 @@ export type RunExecutorDeps = {
|
|
|
21
23
|
clientTailSyncTimeoutMs?: number;
|
|
22
24
|
/** Keeps the computer awake while a turn runs; returns the release. */
|
|
23
25
|
keepAwake?: () => () => void;
|
|
26
|
+
/** Uploads a file the agent sends to the user; absent, sending files fails. */
|
|
27
|
+
uploadFile?: (file: GatewayFileSource) => Promise<GatewaySentFile>;
|
|
24
28
|
};
|
|
25
29
|
/** The user's instructions for the agent, as the start of a new session. */
|
|
26
30
|
export declare function agentInstructionsPreamble(instructions: string): string | undefined;
|
|
31
|
+
/** How the user sees this conversation, so the agent hands over files instead of printing their paths. */
|
|
32
|
+
export declare const SESSION_DELIVERY_NOTE: string;
|
|
33
|
+
/** What a new session starts with: how replies reach the user, then the user's instructions. */
|
|
34
|
+
export declare function sessionPreamble(instructions: string): string;
|
|
27
35
|
/**
|
|
28
36
|
* Runs accepted turns on one gateway. A thread has at most one active run;
|
|
29
37
|
* turns sent meanwhile wait in a per-thread FIFO and, when it ends, run
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import { AssistantProjection, projectPendingApprovals, toTranscriptMessage } from './assistantProjection.js';
|
|
3
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';
|
|
4
|
+
import { commitRunTerminal, enqueueRun, finishMergedRun, startQueuedRuns, TERMINAL_RUN_STATUSES, withdrawQueuedRun, readAgentInstructions, readCurrentTopic, readSessionBinding, readTranscriptPage, resolveTrustedResource, updateRunSnapshot, waitForMessage, withGatewaySession, writeSessionBinding, } from './threadStore.js';
|
|
5
5
|
import { readAgentConfig } from './protocol.js';
|
|
6
6
|
const SNAPSHOT_PERSIST_INTERVAL_MS = 1_000;
|
|
7
7
|
/** Turns waiting behind a thread's active one; more are refused as `busy`. */
|
|
@@ -9,6 +9,8 @@ export const MAX_QUEUED_TURNS = 10;
|
|
|
9
9
|
const APPROVAL_TIMEOUT_MS = 30 * 60 * 1000;
|
|
10
10
|
/** The agent's instructions travel with a new session's first prompt; longer ones are cut. */
|
|
11
11
|
const MAX_INSTRUCTIONS_CHARS = 8_000;
|
|
12
|
+
/** Files one reply may carry, like the attachments of one message. */
|
|
13
|
+
export const MAX_SENT_FILES_PER_TURN = 16;
|
|
12
14
|
/** How long a turn waits for the client's latest messages to sync to this machine. */
|
|
13
15
|
export const CLIENT_TAIL_SYNC_TIMEOUT_MS = 5_000;
|
|
14
16
|
/** Client tails that never synced, so later turns of their thread do not wait for them again. */
|
|
@@ -40,6 +42,17 @@ export function agentInstructionsPreamble(instructions) {
|
|
|
40
42
|
'</dinox_agent_instructions>',
|
|
41
43
|
].join('\n');
|
|
42
44
|
}
|
|
45
|
+
/** How the user sees this conversation, so the agent hands over files instead of printing their paths. */
|
|
46
|
+
export const SESSION_DELIVERY_NOTE = [
|
|
47
|
+
'<dinox_delivery>',
|
|
48
|
+
'The user reads your replies in the Dinox app, usually on another device, and cannot open files on this computer.',
|
|
49
|
+
'To show them a file you made or found (an image, a document, a chart, ...), call the Dinox send_file tool with its path.',
|
|
50
|
+
'</dinox_delivery>',
|
|
51
|
+
].join('\n');
|
|
52
|
+
/** What a new session starts with: how replies reach the user, then the user's instructions. */
|
|
53
|
+
export function sessionPreamble(instructions) {
|
|
54
|
+
return [SESSION_DELIVERY_NOTE, agentInstructionsPreamble(instructions)].filter(Boolean).join('\n\n');
|
|
55
|
+
}
|
|
43
56
|
function withErrorText(message, error) {
|
|
44
57
|
return { ...message, metadata: { ...(message.metadata ?? {}), error } };
|
|
45
58
|
}
|
|
@@ -261,6 +274,30 @@ export class GatewayRunExecutor {
|
|
|
261
274
|
return decision;
|
|
262
275
|
};
|
|
263
276
|
const awaitApproval = registerApproval;
|
|
277
|
+
// Uploads still running when the agent ends its turn finish before the reply is committed.
|
|
278
|
+
const uploads = new Set();
|
|
279
|
+
let filesSent = 0;
|
|
280
|
+
const sendFile = (file) => {
|
|
281
|
+
const upload = this.deps.uploadFile;
|
|
282
|
+
if (!upload)
|
|
283
|
+
return Promise.reject(new Error('Sending files is not available on this computer'));
|
|
284
|
+
if (filesSent >= MAX_SENT_FILES_PER_TURN) {
|
|
285
|
+
return Promise.reject(new Error(`At most ${MAX_SENT_FILES_PER_TURN} files can be sent in one reply`));
|
|
286
|
+
}
|
|
287
|
+
filesSent += 1;
|
|
288
|
+
const pending = upload(file).then((sent) => {
|
|
289
|
+
projection.addFile(sent);
|
|
290
|
+
// Once the turn has ended, the terminal snapshot carries the file.
|
|
291
|
+
if (!TERMINAL_RUN_STATUSES.has(status))
|
|
292
|
+
publish(true);
|
|
293
|
+
return sent;
|
|
294
|
+
});
|
|
295
|
+
uploads.add(pending);
|
|
296
|
+
void pending
|
|
297
|
+
.catch((error) => log('send file failed', { runId: active.runId, error: String(error) }))
|
|
298
|
+
.finally(() => uploads.delete(pending));
|
|
299
|
+
return pending;
|
|
300
|
+
};
|
|
264
301
|
let errorText = null;
|
|
265
302
|
let nextBinding = null;
|
|
266
303
|
try {
|
|
@@ -286,12 +323,13 @@ export class GatewayRunExecutor {
|
|
|
286
323
|
const result = await adapter.runTurn({
|
|
287
324
|
sessionId: continued?.sessionId ?? null,
|
|
288
325
|
cwd,
|
|
289
|
-
sessionPreamble: agentInstructionsPreamble(instructions),
|
|
326
|
+
sessionPreamble: this.deps.uploadFile ? sessionPreamble(instructions) : agentInstructionsPreamble(instructions),
|
|
290
327
|
prompt: withConversationContext(prompt, context, { canReadConversation: gateway.dinoxTools !== false }),
|
|
291
328
|
resources,
|
|
292
329
|
signal: active.controller.signal,
|
|
293
330
|
config: agentConfig,
|
|
294
331
|
awaitApproval,
|
|
332
|
+
sendFile,
|
|
295
333
|
onEvent: (event) => {
|
|
296
334
|
projection.apply(event);
|
|
297
335
|
if (event.type === 'approval-request') {
|
|
@@ -301,6 +339,8 @@ export class GatewayRunExecutor {
|
|
|
301
339
|
publish(event.type === 'approval-request' || event.type === 'approval-resolved');
|
|
302
340
|
},
|
|
303
341
|
});
|
|
342
|
+
// Files still uploading belong to this reply: the turn ends once they are in it.
|
|
343
|
+
await Promise.allSettled([...uploads]);
|
|
304
344
|
nextBinding = {
|
|
305
345
|
sessionId: result.sessionId,
|
|
306
346
|
topicId,
|
|
@@ -326,6 +366,7 @@ export class GatewayRunExecutor {
|
|
|
326
366
|
for (const approval of active.approvals.values())
|
|
327
367
|
clearTimeout(approval.timer);
|
|
328
368
|
}
|
|
369
|
+
await Promise.allSettled([...uploads]);
|
|
329
370
|
projection.finish();
|
|
330
371
|
const assistant = errorText ? withErrorText(projection.snapshot(), errorText) : projection.snapshot();
|
|
331
372
|
try {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AbstractPowerSyncDatabase } from '@powersync/common';
|
|
2
2
|
import { type DeviceHostStatus } from './deviceHost.js';
|
|
3
|
+
import type { GatewayFileSource, GatewaySentFile } from './types.js';
|
|
3
4
|
type Logger = (message: string, details?: Record<string, unknown>) => void;
|
|
4
5
|
export type GatewayServiceDeps = {
|
|
5
6
|
db: AbstractPowerSyncDatabase;
|
|
@@ -25,6 +26,8 @@ export declare class GatewayService {
|
|
|
25
26
|
/** Saves a change made in the app, applies it to the running gateway and syncs the robot row. */
|
|
26
27
|
private updateSettings;
|
|
27
28
|
private robotRegistration;
|
|
29
|
+
/** Routes a `send_file` from an agent's Dinox tools to the turn of the session that started them. */
|
|
30
|
+
sendFile(token: string, file: GatewayFileSource): Promise<GatewaySentFile>;
|
|
28
31
|
status(): DeviceHostStatus[];
|
|
29
32
|
}
|
|
30
33
|
export {};
|
package/dist/gateway/service.js
CHANGED
|
@@ -105,6 +105,15 @@ export class GatewayService {
|
|
|
105
105
|
},
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
|
+
/** Routes a `send_file` from an agent's Dinox tools to the turn of the session that started them. */
|
|
109
|
+
sendFile(token, file) {
|
|
110
|
+
for (const host of this.hosts) {
|
|
111
|
+
const sent = host.sendFile(token, file);
|
|
112
|
+
if (sent)
|
|
113
|
+
return sent;
|
|
114
|
+
}
|
|
115
|
+
return Promise.reject(new Error('This conversation is no longer running on this computer'));
|
|
116
|
+
}
|
|
108
117
|
status() {
|
|
109
118
|
return this.hosts.map((host) => host.status());
|
|
110
119
|
}
|
package/dist/gateway/types.d.ts
CHANGED
|
@@ -93,6 +93,25 @@ export type GatewayConfigOption = {
|
|
|
93
93
|
group: string | null;
|
|
94
94
|
}>;
|
|
95
95
|
};
|
|
96
|
+
/** A file the agent hands to the user: a local path, or bytes it sent inline (base64). */
|
|
97
|
+
export type GatewayFileSource = {
|
|
98
|
+
kind: 'path';
|
|
99
|
+
path: string;
|
|
100
|
+
name?: string;
|
|
101
|
+
} | {
|
|
102
|
+
kind: 'data';
|
|
103
|
+
data: string;
|
|
104
|
+
mediaType: string;
|
|
105
|
+
name?: string;
|
|
106
|
+
};
|
|
107
|
+
/** A file uploaded for the user, as it appears in the reply. */
|
|
108
|
+
export type GatewaySentFile = {
|
|
109
|
+
resourceId: string;
|
|
110
|
+
url: string;
|
|
111
|
+
name: string;
|
|
112
|
+
mediaType: string;
|
|
113
|
+
sizeBytes: number;
|
|
114
|
+
};
|
|
96
115
|
export type GatewayStopReason = 'end_turn' | 'cancelled' | 'max_tokens' | 'refusal' | 'error';
|
|
97
116
|
export type GatewayTurnInput = {
|
|
98
117
|
/** Agent session to continue; absent starts a new one. */
|
|
@@ -114,6 +133,8 @@ export type GatewayTurnInput = {
|
|
|
114
133
|
onEvent: (event: GatewayEvent) => void;
|
|
115
134
|
/** Resolves with the user's decision for an `approval-request` event. */
|
|
116
135
|
awaitApproval: (approvalId: string) => Promise<boolean>;
|
|
136
|
+
/** Uploads a file for the user and adds it to the reply. */
|
|
137
|
+
sendFile: (file: GatewayFileSource) => Promise<GatewaySentFile>;
|
|
117
138
|
};
|
|
118
139
|
export type GatewayTurnResult = {
|
|
119
140
|
sessionId: string;
|
|
@@ -132,5 +153,11 @@ export interface GatewayAdapter {
|
|
|
132
153
|
runTurn(input: GatewayTurnInput): Promise<GatewayTurnResult>;
|
|
133
154
|
/** The agent's session settings (models, modes); absent when it has none. */
|
|
134
155
|
describeConfig?(): Promise<GatewayConfigOption[]>;
|
|
156
|
+
/**
|
|
157
|
+
* A `send_file` call from the Dinox tools of one of this adapter's sessions,
|
|
158
|
+
* named by the token the session's tools were started with; `null` when the
|
|
159
|
+
* token is not this adapter's.
|
|
160
|
+
*/
|
|
161
|
+
sendFile?(token: string, file: GatewayFileSource): Promise<GatewaySentFile> | null;
|
|
135
162
|
dispose(): Promise<void>;
|
|
136
163
|
}
|