@dinoxx/dinox-cli 2.12.0 → 2.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -947,7 +947,7 @@ dino gateway reload
947
947
  dino gateway remove Hermes
948
948
  ```
949
949
 
950
- `detect` 会在 `PATH` 上查找 ACP agent,并用真实握手确认能否启动。覆盖范围与 [Paseo 的 ACP catalog](https://github.com/getpaseo/paseo/blob/main/packages/app/src/data/acp-provider-catalog.ts) 对齐,另外包含 Pi(`pi-acp`)、Antigravity(`agy-acp`)、Muse Code(`muse-code-acp`)和 OMP(`omp acp`)。只认本机已经装好的命令,不会为了探测去 `npx -y` 下载。每个 agent 一握手完就打印,不用等最慢的那个。某个启动方式一直不应答时,不再换参数重试。`--add` 把已确认的 agent 写入当前账号的 gateway 配置,并由 daemon 托管。`gateway list` 只显示环境变量名,不显示值。`dino gateway mcp` 由这些 agent 拉起,对外提供只读的 Dinox 知识工具。
950
+ `detect` 会在 `PATH` 上查找 ACP agent,并用真实握手确认能否启动。覆盖范围与 [Paseo 的 ACP catalog](https://github.com/getpaseo/paseo/blob/main/packages/app/src/data/acp-provider-catalog.ts) 对齐,另外包含 Pi(`pi-acp`)、Antigravity(`agy-acp`)、Muse Code(`muse-code-acp`)和 OMP(`omp acp`)。只认本机已经装好的命令,不会为了探测去 `npx -y` 下载。每个 agent 一握手完就打印,不用等最慢的那个。某个启动方式一直不应答时,不再换参数重试。`--add` 把已确认的 agent 写入当前账号的 gateway 配置,并由 daemon 托管。`gateway list` 只显示环境变量名,不显示值。`dino gateway mcp` 由这些 agent 拉起,对外提供笔记、待办、标签、卡片盒等读写工具,以及把本机文件放进回复的 `send_file`。账号、同步、守护进程和 gateway 自身的命令不在这套工具里。
951
951
 
952
952
  ### 6.16 `info`:查看版本与技能仓库地址
953
953
 
@@ -5,8 +5,6 @@ import { resolveConfig } from '../../config/resolve.js';
5
5
  import { restoreSuspendedDaemonLocked, suspendDaemonForExclusiveAccessLocked } from '../../daemon/lifecycle.js';
6
6
  import { acquireDaemonStartLock } from '../../daemon/pid.js';
7
7
  import { fetchCredentialsWithAuthorization } from '../../powersync/connector.js';
8
- import { closePowerSyncAndReleaseOwner, connectPowerSync, getStatusSnapshot } from '../../powersync/runtime.js';
9
- import { runSingleSyncPass } from '../../powersync/syncPass.js';
10
8
  import type { CommandGroupDescriptor } from '../descriptor/types.js';
11
9
  import { printHumanReadableWarning } from '../../utils/output.js';
12
10
  export declare function releaseOwnerLeases(releases: ReadonlyArray<() => Promise<void>>): Promise<unknown[]>;
@@ -17,10 +15,6 @@ type AuthLoginDependencies = {
17
15
  fetchCredentials: typeof fetchCredentialsWithAuthorization;
18
16
  fetchUserInfo: typeof fetchUserInfoWithAuthorization;
19
17
  suspendDaemon: typeof suspendDaemonForExclusiveAccessLocked;
20
- connectPowerSync: typeof connectPowerSync;
21
- runSingleSyncPass: typeof runSingleSyncPass;
22
- getStatusSnapshot: typeof getStatusSnapshot;
23
- closePowerSyncAndReleaseOwner: typeof closePowerSyncAndReleaseOwner;
24
18
  updateConfig: typeof updateConfig;
25
19
  restoreDaemon: typeof restoreSuspendedDaemonLocked;
26
20
  printWarning: typeof printHumanReadableWarning;
@@ -13,8 +13,7 @@ import { autoInstallDaemonService, describeDaemonServiceAutoInstall } from '../.
13
13
  import { acquireDaemonStartLock } from '../../daemon/pid.js';
14
14
  import { fetchCredentialsWithAuthorization } from '../../powersync/connector.js';
15
15
  import { withExclusivePowerSync } from '../../powersync/exclusiveRuntime.js';
16
- import { acquirePowerSyncOwnerLease, closePowerSyncAndReleaseOwner, connectPowerSync, getStatusSnapshot, } from '../../powersync/runtime.js';
17
- import { runSingleSyncPass } from '../../powersync/syncPass.js';
16
+ import { acquirePowerSyncOwnerLease, getStatusSnapshot, } from '../../powersync/runtime.js';
18
17
  import { registerCommandGroup } from '../descriptor/register.js';
19
18
  import { FORMAT_OPTIONS, OFFLINE_OPTIONS, SYNC_TIMEOUT_OPTIONS } from '../schema/globalOptions.js';
20
19
  import { normalizeAuthToken, readEnvAuthToken } from '../../utils/authToken.js';
@@ -106,10 +105,6 @@ const authLoginDependencies = {
106
105
  fetchCredentials: fetchCredentialsWithAuthorization,
107
106
  fetchUserInfo: fetchUserInfoWithAuthorization,
108
107
  suspendDaemon: suspendDaemonForExclusiveAccessLocked,
109
- connectPowerSync,
110
- runSingleSyncPass,
111
- getStatusSnapshot,
112
- closePowerSyncAndReleaseOwner,
113
108
  updateConfig,
114
109
  restoreDaemon: restoreSuspendedDaemonLocked,
115
110
  printWarning: printHumanReadableWarning,
@@ -122,7 +117,6 @@ export async function performAuthLogin(rawToken, syncTimeout, overrides = {}) {
122
117
  const releaseStartLock = await dependencies.acquireDaemonStartLock(parseSyncTimeoutMs(syncTimeout) ?? 10_000);
123
118
  let previousConfig = null;
124
119
  let suspended = null;
125
- let restoreAllowed = true;
126
120
  let lifecycleLockReleaseError;
127
121
  let outcome;
128
122
  try {
@@ -145,37 +139,6 @@ export async function performAuthLogin(rawToken, syncTimeout, overrides = {}) {
145
139
  const verifiedConfig = mergeAuthIdentityIntoConfig(previousConfig, rawToken, identity);
146
140
  const verifiedResolved = dependencies.resolveConfig(verifiedConfig);
147
141
  suspended = await dependencies.suspendDaemon(previousResolved, timeoutMs, { forceStop: true });
148
- const { db, dbPath, stale, releaseOwner } = await dependencies.connectPowerSync({
149
- config: verifiedResolved,
150
- offline: false,
151
- timeoutMs,
152
- });
153
- let syncOutcome;
154
- try {
155
- await dependencies.runSingleSyncPass(db, timeoutMs);
156
- syncOutcome = {
157
- ok: true,
158
- lastSyncedAt: dependencies.getStatusSnapshot(db).lastSyncedAt,
159
- };
160
- }
161
- catch (error) {
162
- syncOutcome = { ok: false, error };
163
- }
164
- let cleanupError;
165
- try {
166
- await dependencies.closePowerSyncAndReleaseOwner(db, releaseOwner);
167
- }
168
- catch (error) {
169
- cleanupError = error;
170
- restoreAllowed = false;
171
- dependencies.printWarning(`warning: failed to close the login verification runtime safely: ${authLoginErrorMessage(error)}`);
172
- }
173
- if (!syncOutcome.ok) {
174
- throw syncOutcome.error;
175
- }
176
- if (cleanupError) {
177
- throw cleanupError;
178
- }
179
142
  await dependencies.updateConfig((current) => {
180
143
  if (!persistedAuthStateMatches(current, previousConfig)) {
181
144
  throw new DinoxError('Authentication changed while login was being verified.', {
@@ -192,16 +155,16 @@ export async function performAuthLogin(rawToken, syncTimeout, overrides = {}) {
192
155
  ok: true,
193
156
  verified: true,
194
157
  endpoint: verifiedResolved.powersync.endpoint,
195
- dbPath,
196
- stale,
197
- lastSyncedAt: syncOutcome.lastSyncedAt,
158
+ dbPath: getPowerSyncDbPath(identity.userId),
159
+ stale: true,
160
+ lastSyncedAt: null,
198
161
  userId: identity.userId,
199
162
  },
200
163
  };
201
164
  }
202
165
  catch (error) {
203
166
  outcome = { ok: false, error };
204
- if (previousConfig && suspended && restoreAllowed) {
167
+ if (previousConfig && suspended) {
205
168
  try {
206
169
  const current = await dependencies.loadConfig();
207
170
  if (persistedAuthStateMatches(current, previousConfig)) {
@@ -315,12 +278,7 @@ async function runAuthLogin(context) {
315
278
  printHumanReadableLine(`- endpoint: ${result.endpoint}`);
316
279
  printHumanReadableLine(`- db: ${result.dbPath}`);
317
280
  printHumanReadableLine(`- userId: ${result.userId}`);
318
- if (result.lastSyncedAt) {
319
- printHumanReadableLine(`- lastSyncedAt: ${result.lastSyncedAt}`);
320
- }
321
- if (result.stale) {
322
- printHumanReadableLine('- warning: initial sync timed out; local cache may be stale');
323
- }
281
+ printHumanReadableLine('- initial sync continues in the background; run `dino sync` to wait for it');
324
282
  const serviceNote = describeDaemonServiceAutoInstall(daemonService);
325
283
  if (serviceNote)
326
284
  printHumanReadableWarning(serviceNote);
@@ -567,7 +525,7 @@ export const authCommandGroup = {
567
525
  commands: [
568
526
  {
569
527
  name: 'login',
570
- description: 'Log in by scanning a QR code, or save a token and verify PowerSync connectivity',
528
+ description: 'Log in by scanning a QR code or with a token; the initial sync continues in the background',
571
529
  arguments: [{ syntax: '[token]', description: 'Login token (omit to scan a QR code, or when using --token-stdin)' }],
572
530
  options: [{ flags: '--token-stdin', description: 'Read the login token from piped stdin instead of argv', type: 'boolean' }],
573
531
  schema: {
@@ -577,7 +535,7 @@ export const authCommandGroup = {
577
535
  globalOptions: AUTH_LOGIN_GLOBAL_OPTIONS,
578
536
  output: {
579
537
  type: 'structured',
580
- summary: 'Login verification result',
538
+ summary: 'Login verification result (initial sync is not awaited)',
581
539
  fields: ['verified', 'endpoint', 'dbPath', 'stale', 'lastSyncedAt', 'userId', 'daemonService'],
582
540
  },
583
541
  examples: [
@@ -5,7 +5,6 @@ import { autoInstallDaemonService, describeDaemonServiceAutoInstall } from '../.
5
5
  import { loadConfig } from '../../config/store.js';
6
6
  import { addGateway, adoptDetectedAgent, getGatewaysFilePath, loadGateways, removeGateway, } from '../../gateway/config.js';
7
7
  import { detectAcpAgents, inferAgentId } from '../../gateway/detect.js';
8
- import { serveDinoxMcpOverStdio } from '../../gateway/mcpServer.js';
9
8
  import { DinoxError } from '../../utils/errors.js';
10
9
  import { getRequestedStructuredOutputFormat, printHumanReadableLine, printHumanReadableWarning, printSuccess, } from '../../utils/output.js';
11
10
  import { registerCommandGroup } from '../descriptor/register.js';
@@ -307,7 +306,7 @@ export const gatewayCommandGroup = {
307
306
  },
308
307
  {
309
308
  name: 'mcp',
310
- description: 'Serve Dinox tools over stdio MCP: read-only knowledge tools and send_file (started by gateway agents)',
309
+ description: 'Serve Dinox tools over stdio MCP: full read/write knowledge tools and send_file (started by gateway agents)',
311
310
  schema: {
312
311
  path: 'gateway.mcp',
313
312
  risk: 'read',
@@ -316,7 +315,10 @@ export const gatewayCommandGroup = {
316
315
  output: { type: 'structured', summary: 'MCP JSON-RPC over stdio until stdin closes', fields: [] },
317
316
  examples: ['dino gateway mcp'],
318
317
  },
319
- action: serveDinoxMcpOverStdio,
318
+ action: async () => {
319
+ const { serveDinoxMcpOverStdio } = await import('../../gateway/mcpServer.js');
320
+ await serveDinoxMcpOverStdio();
321
+ },
320
322
  },
321
323
  {
322
324
  name: 'reload',
@@ -1,4 +1,4 @@
1
- import { GATEWAY_KNOWLEDGE_TOOLS_ENV, GATEWAY_SESSION_TOKEN_ENV } from '../mcpEnv.js';
1
+ import { DINOX_MCP_SERVER_NAME, GATEWAY_KNOWLEDGE_TOOLS_ENV, GATEWAY_SESSION_TOKEN_ENV } from '../mcpEnv.js';
2
2
  import { AcpGatewayAdapter } from './acp.js';
3
3
  import { EchoGatewayAdapter } from './echo.js';
4
4
  /**
@@ -15,7 +15,7 @@ export function dinoxMcpServerConfig(options) {
15
15
  entry[0] !== GATEWAY_KNOWLEDGE_TOOLS_ENV &&
16
16
  typeof entry[1] === 'string');
17
17
  return {
18
- name: 'dinox',
18
+ name: DINOX_MCP_SERVER_NAME,
19
19
  command: process.execPath,
20
20
  args: [...process.execArgv, entrypoint, 'gateway', 'mcp'],
21
21
  env: [
@@ -0,0 +1,43 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import type { CommandSchema } from '../commands/schema/types.js';
4
+ /**
5
+ * One MCP tool per command group, generated from the command schemas. Each runs
6
+ * the real CLI as a child process without a shell, preserving the CLI's own
7
+ * validation, dry-run and confirmation guards. Approval belongs to the agent's
8
+ * permission flow, guided by the tool annotations.
9
+ */
10
+ /** Command groups exposed as MCP tools. */
11
+ export declare const MCP_COMMAND_GROUPS: readonly ["note", "tag", "box", "todo", "view", "prop", "template", "prompt", "graph", "storage", "meta.stats"];
12
+ /** Commands deliberately excluded from the MCP surface. */
13
+ export declare const MCP_COMMAND_EXCLUSIONS: readonly ["auth.*", "config.*", "daemon.*", "gateway.*", "doctor", "skills.*", "update", "sync", "info", "schema", "meta.schema"];
14
+ /** Runs a CLI invocation and captures its output. */
15
+ export type CommandRunner = (argv: string[]) => Promise<{
16
+ stdout: string;
17
+ stderr: string;
18
+ }>;
19
+ /** Runs the current CLI entry point without a shell. */
20
+ export declare const runCliCommand: CommandRunner;
21
+ /** Validates tool inputs and builds CLI arguments in schema order. */
22
+ export declare function commandArgv(schema: CommandSchema, input: Record<string, unknown>): string[];
23
+ /** Builds grouped MCP tool definitions from the command schemas. */
24
+ export declare function commandToolDefinitions(): {
25
+ name: string;
26
+ commands: CommandSchema[];
27
+ config: {
28
+ description: string;
29
+ annotations: {
30
+ openWorldHint: boolean;
31
+ destructiveHint?: boolean | undefined;
32
+ readOnlyHint: boolean;
33
+ };
34
+ inputSchema: {
35
+ action: z.ZodEnum<{
36
+ [x: string]: string;
37
+ }>;
38
+ input: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
39
+ };
40
+ };
41
+ }[];
42
+ /** Registers command tools using the real CLI or an injected runner. */
43
+ export declare function registerCommandTools(server: McpServer, runner?: CommandRunner): void;
@@ -0,0 +1,179 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { z } from 'zod';
4
+ import { listCommandSchemas } from '../commands/schema/spec.js';
5
+ /**
6
+ * One MCP tool per command group, generated from the command schemas. Each runs
7
+ * the real CLI as a child process without a shell, preserving the CLI's own
8
+ * validation, dry-run and confirmation guards. Approval belongs to the agent's
9
+ * permission flow, guided by the tool annotations.
10
+ */
11
+ /** Command groups exposed as MCP tools. */
12
+ export const MCP_COMMAND_GROUPS = [
13
+ 'note', 'tag', 'box', 'todo', 'view', 'prop', 'template', 'prompt', 'graph', 'storage', 'meta.stats',
14
+ ];
15
+ /** Commands deliberately excluded from the MCP surface. */
16
+ export const MCP_COMMAND_EXCLUSIONS = [
17
+ 'auth.*', 'config.*', 'daemon.*', 'gateway.*', 'doctor', 'skills.*', 'update', 'sync', 'info', 'schema', 'meta.schema',
18
+ ];
19
+ const execute = promisify(execFile);
20
+ /** Runs the current CLI entry point without a shell. */
21
+ export const runCliCommand = (argv) => {
22
+ const entryPoint = process.argv[1];
23
+ if (entryPoint === undefined)
24
+ throw new Error('Cannot run CLI command: process.argv[1] is undefined');
25
+ return execute(process.execPath, [...process.execArgv, entryPoint, ...argv], {
26
+ env: process.env,
27
+ timeout: 120_000,
28
+ maxBuffer: 32 * 1024 * 1024,
29
+ });
30
+ };
31
+ function key(name) {
32
+ return name
33
+ .replace(/^[<[.]+|[>\].]+$/g, '')
34
+ .replace(/[-_]+([a-zA-Z0-9])/g, (_match, letter) => letter.toUpperCase());
35
+ }
36
+ function flag(name) {
37
+ return name.match(/--[\w-]+/)[0];
38
+ }
39
+ function optionKey(name) {
40
+ return key(flag(name).replace(/^--(?:no-)?/, ''));
41
+ }
42
+ function text(value) {
43
+ return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
44
+ }
45
+ function parse(value) {
46
+ try {
47
+ return JSON.parse(value);
48
+ }
49
+ catch {
50
+ return value;
51
+ }
52
+ }
53
+ function actionName(schema) {
54
+ return schema.path.split('.').slice(1).join('.');
55
+ }
56
+ function optionType(option) {
57
+ return option.type ?? (/[<[]/.test(option.name) ? 'string' : 'boolean');
58
+ }
59
+ function describeAction(schema) {
60
+ const args = (schema.arguments ?? []).map((arg) => `${key(arg.name)}:string${arg.required ? '!' : '?'}`);
61
+ const options = (schema.options ?? []).map((option) => {
62
+ const required = option.required ? '!' : '?';
63
+ const choices = option.enum ? `(${option.enum.join('|')})` : '';
64
+ return `${optionKey(option.name)}:${optionType(option)}${required}${choices}`;
65
+ });
66
+ const inputs = [...args, ...options].join(' ') || '(no input)';
67
+ return `${actionName(schema)} [${schema.risk}]: ${schema.summary}\n${inputs}`;
68
+ }
69
+ function toolAnnotations(commands) {
70
+ const readOnly = commands.every((schema) => schema.risk === 'read');
71
+ return {
72
+ readOnlyHint: readOnly,
73
+ ...(!readOnly ? { destructiveHint: commands.some((schema) => schema.risk === 'high-risk-write') } : {}),
74
+ };
75
+ }
76
+ /** Validates tool inputs and builds CLI arguments in schema order. */
77
+ export function commandArgv(schema, input) {
78
+ const args = schema.arguments ?? [];
79
+ const options = schema.options ?? [];
80
+ const allowed = new Set([...args.map((arg) => key(arg.name)), ...options.map((option) => optionKey(option.name))]);
81
+ for (const name of Object.keys(input)) {
82
+ if (!allowed.has(name))
83
+ throw new Error(`Unknown input key: ${name}`);
84
+ }
85
+ const positionals = [];
86
+ let omitted = false;
87
+ for (const arg of args) {
88
+ const value = input[key(arg.name)];
89
+ if (value === undefined) {
90
+ if (arg.required)
91
+ throw new Error(`Missing required argument: ${key(arg.name)}`);
92
+ omitted = true;
93
+ }
94
+ else {
95
+ if (typeof value !== 'string' || omitted)
96
+ throw new Error(`Invalid positional argument: ${key(arg.name)}`);
97
+ positionals.push(value);
98
+ }
99
+ }
100
+ const flags = [];
101
+ for (const option of options) {
102
+ const name = optionKey(option.name);
103
+ const value = input[name];
104
+ if (value === undefined) {
105
+ if (option.required)
106
+ throw new Error(`Missing required option: ${name}`);
107
+ continue;
108
+ }
109
+ const type = optionType(option);
110
+ const valid = type === 'string[]'
111
+ ? Array.isArray(value) && value.every((item) => typeof item === 'string')
112
+ : typeof value === type && (type !== 'number' || Number.isFinite(value));
113
+ if (!valid || (option.enum && !option.enum.includes(String(value))))
114
+ throw new Error(`Invalid value for ${name} (${type})`);
115
+ const long = flag(option.name);
116
+ if (type === 'boolean') {
117
+ if (long.startsWith('--no-')) {
118
+ if (value === false)
119
+ flags.push(long);
120
+ }
121
+ else if (value === true) {
122
+ flags.push(long);
123
+ }
124
+ }
125
+ else if (Array.isArray(value)) {
126
+ // --fields is CSV; the other array descriptors use repeatable collectors.
127
+ if (long === '--fields') {
128
+ flags.push(`${long}=${value.join(',')}`);
129
+ }
130
+ else {
131
+ for (const item of value)
132
+ flags.push(`${long}=${String(item)}`);
133
+ }
134
+ }
135
+ else {
136
+ flags.push(`${long}=${String(value)}`);
137
+ }
138
+ }
139
+ return [...schema.path.split('.'), ...flags, '--format', 'json', '--', ...positionals];
140
+ }
141
+ /** Builds grouped MCP tool definitions from the command schemas. */
142
+ export function commandToolDefinitions() {
143
+ return MCP_COMMAND_GROUPS.map((group) => {
144
+ const commands = listCommandSchemas().filter((schema) => group === 'meta.stats' ? schema.path === group : schema.path.startsWith(`${group}.`));
145
+ const actions = commands.map(actionName);
146
+ const description = 'Dinox CLI commands. search_notes/read_note/search_todos/related_notes are the fast daemon-backed path for plain search/read. ' +
147
+ 'High-risk writes need dryRun: true first, then the CLI confirmation options listed below (e.g. confirm/expectedCount); ' +
148
+ 'CLI guards enforce them. Input keys are camelCase.\n' +
149
+ commands.map(describeAction).join('\n');
150
+ return {
151
+ name: group === 'meta.stats' ? 'dinox_stats' : `dinox_${group}`,
152
+ commands,
153
+ config: {
154
+ description,
155
+ annotations: { ...toolAnnotations(commands), openWorldHint: group === 'storage' },
156
+ inputSchema: { action: z.enum(actions), input: z.record(z.string(), z.unknown()).optional() },
157
+ },
158
+ };
159
+ });
160
+ }
161
+ /** Registers command tools using the real CLI or an injected runner. */
162
+ export function registerCommandTools(server, runner = runCliCommand) {
163
+ for (const tool of commandToolDefinitions()) {
164
+ server.registerTool(tool.name, tool.config, async ({ action, input }) => {
165
+ try {
166
+ const schema = tool.commands.find((command) => actionName(command) === action);
167
+ if (!schema)
168
+ throw new Error(`Unknown action: ${action}`);
169
+ const result = await runner(commandArgv(schema, input ?? {}));
170
+ return text(parse(result.stdout));
171
+ }
172
+ catch (error) {
173
+ const failure = error;
174
+ const message = failure.stdout?.trim() || failure.stderr?.trim() || failure.message || String(error);
175
+ return { ...text(parse(message)), isError: true };
176
+ }
177
+ });
178
+ }
179
+ }
@@ -3,3 +3,5 @@
3
3
  export declare const GATEWAY_SESSION_TOKEN_ENV = "DINOX_GATEWAY_SESSION_TOKEN";
4
4
  /** `0` offers only `send_file`: the owner turned the knowledge tools off. */
5
5
  export declare const GATEWAY_KNOWLEDGE_TOOLS_ENV = "DINOX_GATEWAY_KNOWLEDGE_TOOLS";
6
+ /** Must not collide with MCP servers the user already registered with the agent. */
7
+ export declare const DINOX_MCP_SERVER_NAME = "dinox-gateway";
@@ -3,3 +3,5 @@
3
3
  export const GATEWAY_SESSION_TOKEN_ENV = 'DINOX_GATEWAY_SESSION_TOKEN';
4
4
  /** `0` offers only `send_file`: the owner turned the knowledge tools off. */
5
5
  export const GATEWAY_KNOWLEDGE_TOOLS_ENV = 'DINOX_GATEWAY_KNOWLEDGE_TOOLS';
6
+ /** Must not collide with MCP servers the user already registered with the agent. */
7
+ export const DINOX_MCP_SERVER_NAME = 'dinox-gateway';
@@ -1,3 +1,4 @@
1
+ import { type CommandRunner } from './mcpCommandTools.js';
1
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  export type DaemonCall = (method: string, params?: unknown, options?: {
3
4
  timeoutMs?: number;
@@ -5,6 +6,7 @@ export type DaemonCall = (method: string, params?: unknown, options?: {
5
6
  export type DinoxMcpOptions = {
6
7
  /** Offer the knowledge tools (notes, todos, conversations); default true. */
7
8
  knowledgeTools?: boolean;
9
+ commandRunner?: CommandRunner;
8
10
  /** The session `send_file` belongs to; absent, `send_file` is not offered. */
9
11
  sessionToken?: string;
10
12
  /** Resolves relative `send_file` paths; default the process directory. */
@@ -1,17 +1,18 @@
1
1
  import path from 'node:path';
2
+ import { registerCommandTools } from './mcpCommandTools.js';
2
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
5
  import { z } from 'zod';
5
6
  import { sendDaemonRequest } from '../daemon/client.js';
6
- import { GATEWAY_KNOWLEDGE_TOOLS_ENV, GATEWAY_SESSION_TOKEN_ENV } from './mcpEnv.js';
7
+ import { DINOX_MCP_SERVER_NAME, GATEWAY_KNOWLEDGE_TOOLS_ENV, GATEWAY_SESSION_TOKEN_ENV } from './mcpEnv.js';
7
8
  import { READ_CONVERSATION_MAX_LIMIT } from './conversationContext.js';
8
9
  import { getPackageVersion } from '../utils/version.js';
9
10
  /**
10
11
  * 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
+ * this as a child process (`dino gateway mcp`). Curated reads use the daemon;
13
+ * command-group tools run the CLI and allow writes. Approval belongs to the
14
+ * agent's permission flow. Destructive actions are flagged by annotations and
15
+ * retain the CLI's dry-run/confirmation guards. `send_file` adds reply files.
15
16
  */
16
17
  const MAX_NOTE_CHARS = 20_000;
17
18
  const MAX_RESULTS = 50;
@@ -24,15 +25,18 @@ function failure(error) {
24
25
  return { ...text(`Dinox is unavailable: ${error instanceof Error ? error.message : String(error)}`), isError: true };
25
26
  }
26
27
  export function createDinoxMcpServer(call = (method, params, options) => sendDaemonRequest(method, params, options), options = {}) {
27
- const server = new McpServer({ name: 'dinox', version: getPackageVersion() });
28
+ const server = new McpServer({ name: DINOX_MCP_SERVER_NAME, version: getPackageVersion() });
28
29
  if (options.sessionToken)
29
30
  registerSendFile(server, call, options.sessionToken, options.cwd ?? process.cwd());
30
- if (options.knowledgeTools !== false)
31
+ if (options.knowledgeTools !== false) {
31
32
  registerKnowledgeTools(server, call);
33
+ registerCommandTools(server, options.commandRunner);
34
+ }
32
35
  return server;
33
36
  }
34
37
  function registerSendFile(server, call, sessionToken, cwd) {
35
38
  server.registerTool('send_file', {
39
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
36
40
  description: 'Show the user a file from this computer (an image you generated, a document, a chart, ...). The user talks to ' +
37
41
  'you from the Dinox app and cannot open files on this computer, so a path alone is useless to them: call this ' +
38
42
  'for every file they should see, then answer as usual. Images appear in the reply; other files as attachments. ' +
@@ -53,15 +57,18 @@ function registerSendFile(server, call, sessionToken, cwd) {
53
57
  }
54
58
  function registerKnowledgeTools(server, call) {
55
59
  server.registerTool('search_notes', {
60
+ annotations: { readOnlyHint: true, openWorldHint: false },
56
61
  description: "Search the user's Dinox notes by keyword. Returns id, title, summary, tags and folders; use read_note for full content.",
57
62
  inputSchema: {
58
63
  query: z.string().describe('Keywords; empty string lists recent notes'),
64
+ offset: z.number().int().min(0).optional().describe('Results to skip; use nextOffset from the previous page'),
65
+ boxes: z.array(z.string()).optional().describe('Card box (folder) names or ids; notes in any of them match'),
59
66
  tags: z.string().optional().describe('Tag filter expression, e.g. "work AND !draft"'),
60
67
  createdFrom: z.string().optional().describe('ISO date lower bound'),
61
68
  createdTo: z.string().optional().describe('ISO date upper bound'),
62
69
  limit: z.number().int().min(1).max(MAX_RESULTS).optional(),
63
70
  },
64
- }, async ({ query, tags, createdFrom, createdTo, limit }) => {
71
+ }, async ({ query, tags, createdFrom, createdTo, limit, offset = 0, boxes }) => {
65
72
  try {
66
73
  const result = (await call('note.search', {
67
74
  keyword: query,
@@ -69,26 +76,34 @@ function registerKnowledgeTools(server, call) {
69
76
  tagsExpression: tags || undefined,
70
77
  createdAtFrom: createdFrom,
71
78
  createdAtTo: createdTo,
72
- boxValues: [],
79
+ boxValues: boxes ?? [],
73
80
  matchEmptyZettelBoxes: false,
74
81
  limit: limit ?? 10,
75
- offset: 0,
82
+ offset,
76
83
  }));
77
- return text({ totalCount: result.totalCount, notes: result.rows });
84
+ const nextOffset = offset + result.rows.length;
85
+ const hasMore = nextOffset < result.totalCount;
86
+ return text({ totalCount: result.totalCount, notes: result.rows, hasMore, ...(hasMore ? { nextOffset } : {}), stale: result.stale === true });
78
87
  }
79
88
  catch (error) {
80
89
  return failure(error);
81
90
  }
82
91
  });
83
92
  server.registerTool('read_note', {
84
- description: 'Read one Dinox note as Markdown, with title, tags, folders and properties.',
85
- inputSchema: { id: z.string().describe('Note id from search_notes') },
86
- }, async ({ id }) => {
93
+ annotations: { readOnlyHint: true, openWorldHint: false },
94
+ description: 'Read one Dinox note as Markdown, with title, tags, folders and properties. Use nextOffset to page through truncated content.',
95
+ inputSchema: {
96
+ id: z.string().describe('Note id from search_notes'),
97
+ offset: z.number().int().min(0).optional().describe('Character offset into the Markdown content; use nextOffset from a truncated result'),
98
+ },
99
+ }, async ({ id, offset = 0 }) => {
87
100
  try {
88
- const { note } = (await call('note.detail', { id }));
101
+ const result = (await call('note.detail', { id }));
102
+ const { note } = result;
89
103
  if (!note)
90
104
  return { ...text(`Note not found: ${id}`), isError: true };
91
105
  const markdown = String(note.content_md ?? note.content_text ?? '');
106
+ const truncated = offset + MAX_NOTE_CHARS < markdown.length;
92
107
  return text({
93
108
  id: note.id,
94
109
  title: note.title,
@@ -97,8 +112,11 @@ function registerKnowledgeTools(server, call) {
97
112
  properties: note.properties,
98
113
  createdAt: note.created_at,
99
114
  updatedAt: note.updated_at,
100
- truncated: markdown.length > MAX_NOTE_CHARS,
101
- content: markdown.slice(0, MAX_NOTE_CHARS),
115
+ totalChars: markdown.length,
116
+ truncated,
117
+ ...(truncated ? { nextOffset: offset + MAX_NOTE_CHARS } : {}),
118
+ content: markdown.slice(offset, offset + MAX_NOTE_CHARS),
119
+ stale: result.stale === true,
102
120
  });
103
121
  }
104
122
  catch (error) {
@@ -106,6 +124,7 @@ function registerKnowledgeTools(server, call) {
106
124
  }
107
125
  });
108
126
  server.registerTool('search_todos', {
127
+ annotations: { readOnlyHint: true, openWorldHint: false },
109
128
  description: "Search the user's Dinox todo items.",
110
129
  inputSchema: {
111
130
  query: z.string().describe('Keywords; empty string lists all'),
@@ -129,6 +148,7 @@ function registerKnowledgeTools(server, call) {
129
148
  }
130
149
  });
131
150
  server.registerTool('related_notes', {
151
+ annotations: { readOnlyHint: true, openWorldHint: false },
132
152
  description: 'Notes linked to a note (backlinks and outlinks, up to the given depth).',
133
153
  inputSchema: {
134
154
  id: z.string().describe('Note id'),
@@ -143,6 +163,7 @@ function registerKnowledgeTools(server, call) {
143
163
  }
144
164
  });
145
165
  server.registerTool('read_conversation', {
166
+ annotations: { readOnlyHint: true, openWorldHint: false },
146
167
  description: 'Read messages of a Dinox conversation, oldest first: who said what (the user, or which agent answered). ' +
147
168
  'Use it when the conversation context you were given says earlier messages are not shown. Returns the newest ' +
148
169
  'messages before `before`; pass the first returned id as `before` to page further back.',
@@ -30,8 +30,13 @@ export type RunExecutorDeps = {
30
30
  export declare function agentInstructionsPreamble(instructions: string): string | undefined;
31
31
  /** How the user sees this conversation, so the agent hands over files instead of printing their paths. */
32
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;
33
+ /** The knowledge tools available to the agent in Dinox. */
34
+ export declare const DINOX_KNOWLEDGE_NOTE: string;
35
+ /** What a new session starts with: available tools, then the user's instructions. */
36
+ export declare function sessionPreamble(instructions: string, options: {
37
+ delivery: boolean;
38
+ knowledge: boolean;
39
+ }): string | undefined;
35
40
  /**
36
41
  * Runs accepted turns on one gateway. A thread has at most one active run;
37
42
  * turns sent meanwhile wait in a per-thread FIFO and, when it ends, run
@@ -49,9 +49,15 @@ export const SESSION_DELIVERY_NOTE = [
49
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
50
  '</dinox_delivery>',
51
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');
52
+ /** The knowledge tools available to the agent in Dinox. */
53
+ export const DINOX_KNOWLEDGE_NOTE = [
54
+ '<dinox_knowledge>',
55
+ "The Dinox tools can search and read the user's Dinox notes, todos and conversations (search_notes, read_note, search_todos, related_notes, read_conversation). The dinox_* tools can also create, update and organize Dinox data. Confirm with the user before writes they did not request, and dry-run high-risk actions first. Use them when the request may involve what the user has written down in Dinox.",
56
+ '</dinox_knowledge>',
57
+ ].join('\n');
58
+ /** What a new session starts with: available tools, then the user's instructions. */
59
+ export function sessionPreamble(instructions, options) {
60
+ return [options.delivery && SESSION_DELIVERY_NOTE, options.knowledge && DINOX_KNOWLEDGE_NOTE, agentInstructionsPreamble(instructions)].filter(Boolean).join('\n\n') || undefined;
55
61
  }
56
62
  function withErrorText(message, error) {
57
63
  return { ...message, metadata: { ...(message.metadata ?? {}), error } };
@@ -323,7 +329,7 @@ export class GatewayRunExecutor {
323
329
  const result = await adapter.runTurn({
324
330
  sessionId: continued?.sessionId ?? null,
325
331
  cwd,
326
- sessionPreamble: this.deps.uploadFile ? sessionPreamble(instructions) : agentInstructionsPreamble(instructions),
332
+ sessionPreamble: sessionPreamble(instructions, { delivery: !!this.deps.uploadFile, knowledge: gateway.dinoxTools !== false }),
327
333
  prompt: withConversationContext(prompt, context, { canReadConversation: gateway.dinoxTools !== false }),
328
334
  resources,
329
335
  signal: active.controller.signal,
@@ -36,7 +36,7 @@ export type GatewayDefinition = {
36
36
  /** Extra environment for the agent process (secrets stay on this machine). */
37
37
  env?: Record<string, string>;
38
38
  enabled: boolean;
39
- /** Offer the read-only Dinox knowledge tools (MCP) to the agent; default true. */
39
+ /** Offer the Dinox knowledge tools (MCP) to the agent; default true. */
40
40
  dinoxTools?: boolean;
41
41
  /** Default `ask`. */
42
42
  approval?: GatewayApprovalMode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dinoxx/dinox-cli",
3
- "version": "2.12.0",
3
+ "version": "2.13.1",
4
4
  "description": "Dinox CLI",
5
5
  "main": "dist/dinox.js",
6
6
  "scripts": {