@dinoxx/dinox-cli 1.0.20 → 1.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +72 -139
  2. package/dist/auth/userInfo.d.ts +8 -1
  3. package/dist/auth/userInfo.js +38 -6
  4. package/dist/cli.js +1 -2
  5. package/dist/cliTypes.d.ts +1 -0
  6. package/dist/commands/auth/index.js +11 -4
  7. package/dist/commands/boxes/index.js +274 -54
  8. package/dist/commands/boxes/model.d.ts +34 -0
  9. package/dist/commands/boxes/model.js +78 -0
  10. package/dist/commands/boxes/operations.d.ts +86 -0
  11. package/dist/commands/boxes/operations.js +415 -0
  12. package/dist/commands/boxes/repo.d.ts +13 -6
  13. package/dist/commands/boxes/repo.js +272 -149
  14. package/dist/commands/boxes/store.d.ts +22 -0
  15. package/dist/commands/boxes/store.js +143 -0
  16. package/dist/commands/graph/index.js +11 -38
  17. package/dist/commands/mcp/noteContentTools.d.ts +222 -0
  18. package/dist/commands/mcp/noteContentTools.js +390 -0
  19. package/dist/commands/mcp/server.d.ts +3 -0
  20. package/dist/commands/mcp/server.js +43 -15
  21. package/dist/commands/mcp/tools.js +3 -1
  22. package/dist/commands/mcp/types.d.ts +6 -2
  23. package/dist/commands/meta/index.js +8 -24
  24. package/dist/commands/meta/repo.d.ts +0 -3
  25. package/dist/commands/meta/repo.js +1 -4
  26. package/dist/commands/notes/actions.d.ts +2 -0
  27. package/dist/commands/notes/actions.js +149 -55
  28. package/dist/commands/notes/bulk.d.ts +34 -0
  29. package/dist/commands/notes/bulk.js +204 -0
  30. package/dist/commands/notes/contentEngine.d.ts +95 -0
  31. package/dist/commands/notes/contentEngine.js +652 -0
  32. package/dist/commands/notes/contentPatch.d.ts +84 -0
  33. package/dist/commands/notes/contentPatch.js +337 -0
  34. package/dist/commands/notes/contentPatchCommand.d.ts +3 -0
  35. package/dist/commands/notes/contentPatchCommand.js +126 -0
  36. package/dist/commands/notes/descriptors.js +194 -42
  37. package/dist/commands/notes/export.d.ts +21 -0
  38. package/dist/commands/notes/export.js +224 -0
  39. package/dist/commands/notes/metadataMutation.d.ts +28 -0
  40. package/dist/commands/notes/metadataMutation.js +127 -0
  41. package/dist/commands/notes/repo.d.ts +20 -0
  42. package/dist/commands/notes/repo.js +135 -18
  43. package/dist/commands/notes/runtime.d.ts +3 -12
  44. package/dist/commands/notes/runtime.js +4 -30
  45. package/dist/commands/notes/searchSql.js +1 -1
  46. package/dist/commands/prompt/index.js +17 -47
  47. package/dist/commands/prompt/repo.js +2 -2
  48. package/dist/commands/runtime.d.ts +34 -0
  49. package/dist/commands/runtime.js +110 -0
  50. package/dist/commands/schema/globalOptions.d.ts +1 -0
  51. package/dist/commands/schema/globalOptions.js +1 -0
  52. package/dist/commands/schema/renderSkills.js +21 -7
  53. package/dist/commands/schema/spec.d.ts +1 -2
  54. package/dist/commands/schema/spec.js +2 -3
  55. package/dist/commands/schema/types.d.ts +0 -13
  56. package/dist/commands/storage/index.js +32 -62
  57. package/dist/commands/sync.js +22 -5
  58. package/dist/commands/tags/content.d.ts +32 -0
  59. package/dist/commands/tags/content.js +243 -0
  60. package/dist/commands/tags/index.js +287 -51
  61. package/dist/commands/tags/model.d.ts +38 -0
  62. package/dist/commands/tags/model.js +100 -0
  63. package/dist/commands/tags/operations.d.ts +105 -0
  64. package/dist/commands/tags/operations.js +438 -0
  65. package/dist/commands/tags/repo.d.ts +5 -1
  66. package/dist/commands/tags/repo.js +142 -218
  67. package/dist/commands/tags/store.d.ts +28 -0
  68. package/dist/commands/tags/store.js +166 -0
  69. package/dist/commands/todo/index.js +32 -63
  70. package/dist/commands/writeDryRun.d.ts +25 -0
  71. package/dist/commands/writeDryRun.js +31 -1
  72. package/dist/config/resolve.js +2 -2
  73. package/dist/daemon/index.d.ts +2 -0
  74. package/dist/daemon/index.js +176 -58
  75. package/dist/powersync/runtime.d.ts +1 -0
  76. package/dist/powersync/runtime.js +16 -0
  77. package/dist/powersync/schema/content.d.ts +2 -0
  78. package/dist/powersync/schema/content.js +2 -0
  79. package/dist/powersync/schema/index.d.ts +2 -0
  80. package/dist/powersync/syncGate.d.ts +24 -0
  81. package/dist/powersync/syncGate.js +36 -0
  82. package/dist/skills/check.d.ts +1 -1
  83. package/dist/skills/check.js +27 -2
  84. package/dist/utils/authToken.d.ts +2 -0
  85. package/dist/utils/authToken.js +7 -2
  86. package/dist/utils/noteContentEngine.d.ts +333 -0
  87. package/dist/utils/noteContentEngine.js +740 -0
  88. package/dist/utils/tiptapMarkdownSchema.js +31 -0
  89. package/package.json +4 -8
@@ -0,0 +1,390 @@
1
+ import { generateId } from '../../utils/id.js';
2
+ import { nowIsoUtc } from '../../utils/time.js';
3
+ import { NoteContentEditError, applyNoteEditPatch, blockNodeHash, blockTextHash, buildNoteEditContext, getNoteContentReadInstructions, getNoteContentSchemaSummary, parseNodeRangeString, serializeAnchoredMarkdown, serializeAnchoredShorthand, topNodes, } from '../../utils/noteContentEngine.js';
4
+ export const NOTE_CONTENT_READ_TOOL_NAME = 'noteContentRead';
5
+ export const NOTE_CONTENT_EDIT_TOOL_NAME = 'noteContentEdit';
6
+ const DEFAULT_MAX_RANGE_CHARS = 20_000;
7
+ const DEFAULT_READ_PAGE_MAX_NODES = 24;
8
+ const READ_TOKEN_TTL_MS = 30 * 60 * 1000;
9
+ const MAX_READ_TOKENS = 500;
10
+ export const noteContentMcpTools = [
11
+ {
12
+ name: NOTE_CONTENT_READ_TOOL_NAME,
13
+ description: 'Read canonical note content as paginated dinox-shorthand with block hashes, readToken, and preferred edit targets.',
14
+ inputSchema: {
15
+ type: 'object',
16
+ properties: {
17
+ noteId: { type: 'string', description: 'Target note ID.' },
18
+ from: { type: 'number', description: 'Zero-based top-level node index to read from.' },
19
+ cursor: {
20
+ type: 'object',
21
+ description: 'Continuation cursor returned by noteContentRead.',
22
+ properties: {
23
+ from: { type: 'number' },
24
+ contentHash: { type: 'string' },
25
+ },
26
+ },
27
+ nodeRange: { type: 'string', description: 'Top-level node range such as "0:5", "5:", or ":10".' },
28
+ includeJson: { type: 'boolean', description: 'Return local Tiptap JSON for diagnostics. Do not write raw JSON back.' },
29
+ maxChars: { type: 'number', description: 'Maximum characters for the local range text.' },
30
+ maxNodes: { type: 'number', description: 'Maximum top-level nodes to include when nodeRange is not set.' },
31
+ },
32
+ required: ['noteId'],
33
+ },
34
+ },
35
+ {
36
+ name: NOTE_CONTENT_EDIT_TOOL_NAME,
37
+ description: 'Persist structured edits to an existing note after noteContentRead returned a readToken. Use structured blocks, not Markdown strings or raw Tiptap JSON.',
38
+ inputSchema: {
39
+ type: 'object',
40
+ properties: {
41
+ readToken: { type: 'string', description: 'readToken returned by noteContentRead.' },
42
+ operations: {
43
+ type: 'array',
44
+ description: 'Structured edit operations. Prefer preferredTarget and expectedNodeHash/expectedTextHash for destructive edits.',
45
+ items: { type: 'object' },
46
+ },
47
+ },
48
+ required: ['readToken', 'operations'],
49
+ },
50
+ },
51
+ ];
52
+ export class NoteContentMcpService {
53
+ getDatabase;
54
+ now;
55
+ #readTokens = new Map();
56
+ constructor(getDatabase, now = () => Date.now()) {
57
+ this.getDatabase = getDatabase;
58
+ this.now = now;
59
+ }
60
+ async read(input, runtime) {
61
+ const row = await this.#readNote(this.getDatabase(), input.noteId);
62
+ this.#assertUser(row, runtime.userId);
63
+ const context = buildNoteEditContext({
64
+ noteId: row.id,
65
+ title: row.title,
66
+ contentJson: row.content_json,
67
+ updatedAt: row.updated_at,
68
+ version: normalizeVersion(row.version),
69
+ });
70
+ const token = this.#rememberReadToken(context, runtime);
71
+ const page = this.#readPage(context.document, context.blocks, context.contentHash, input);
72
+ return {
73
+ success: true,
74
+ hasError: false,
75
+ docChanged: false,
76
+ document: null,
77
+ noteId: context.noteId,
78
+ title: context.title,
79
+ readToken: token.token,
80
+ readTokenExpiresAt: new Date(token.expiresAtMs).toISOString(),
81
+ base: {
82
+ contentHash: context.contentHash,
83
+ updatedAt: context.updatedAt,
84
+ version: context.version,
85
+ },
86
+ totalNodes: context.blocks.length,
87
+ totalCharCount: context.totalCharCount,
88
+ estimatedTokens: context.estimatedTokens,
89
+ from: page.from,
90
+ nodeRange: page.nodeRangeTuple,
91
+ format: page.format,
92
+ content: page.shorthand,
93
+ canReadMore: Boolean(page.nextCursor),
94
+ nextCursor: page.nextCursor,
95
+ schema: getNoteContentSchemaSummary(),
96
+ readFormatInstructions: getNoteContentReadInstructions(),
97
+ chunks: context.chunks,
98
+ blocks: context.blocks.map(({ text, ...block }) => ({
99
+ ...block,
100
+ preferredTarget: {
101
+ anchorId: block.anchorId,
102
+ nodeRange: block.nodeRange,
103
+ expectedTextHash: block.textHash,
104
+ expectedNodeHash: block.nodeHash,
105
+ },
106
+ })),
107
+ page,
108
+ ...(input.nodeRange ? { range: page } : {}),
109
+ };
110
+ }
111
+ async edit(input, runtime) {
112
+ const token = this.#requireReadToken(input.readToken, runtime);
113
+ const db = this.getDatabase();
114
+ try {
115
+ const result = await runWrite(db, async (tx) => {
116
+ const row = await this.#readNote(tx, token.noteId);
117
+ this.#assertUser(row, runtime.userId);
118
+ this.#assertTokenMatchesRow(token, row);
119
+ const patch = {
120
+ base: {
121
+ contentHash: token.contentHash,
122
+ updatedAt: token.updatedAt,
123
+ version: token.version,
124
+ },
125
+ operations: input.operations,
126
+ };
127
+ const applied = applyNoteEditPatch({
128
+ noteId: row.id,
129
+ title: row.title,
130
+ contentJson: row.content_json,
131
+ updatedAt: row.updated_at,
132
+ version: normalizeVersion(row.version),
133
+ createAnchorId: () => generateId(),
134
+ patch,
135
+ });
136
+ if (!applied.changed)
137
+ return buildSuccess(applied, false);
138
+ const savedAt = nowIsoUtc();
139
+ await updateNoteProjection(tx, row.id, {
140
+ content_json: applied.projection.content_json,
141
+ content_text: applied.projection.content_text,
142
+ content_md: applied.projection.content_md,
143
+ images: applied.projection.images,
144
+ bid_links: applied.projection.bid_links,
145
+ updated_at: savedAt,
146
+ });
147
+ return buildSuccess(applied, true, savedAt, nextVersion(row.version));
148
+ });
149
+ this.#readTokens.delete(token.token);
150
+ return result;
151
+ }
152
+ catch (error) {
153
+ if (error instanceof NoteContentEditError && error.code === 'content_conflict') {
154
+ this.#readTokens.delete(token.token);
155
+ }
156
+ throw error;
157
+ }
158
+ }
159
+ #readPage(document, blocks, contentHash, input) {
160
+ const nodes = topNodes(document);
161
+ const parsed = resolveReadPageRange(input, nodes.length);
162
+ if (input.cursor?.contentHash && input.cursor.contentHash !== contentHash) {
163
+ throw new Error('noteContentRead cursor 已过期,请重新从 from: 0 读取笔记内容');
164
+ }
165
+ const selectedNodes = nodes.slice(parsed.start, parsed.end);
166
+ const maxChars = clampInt(input.maxChars, 1, 100_000, DEFAULT_MAX_RANGE_CHARS);
167
+ const text = truncateContent(selectedNodes.map((node) => blocks[nodes.indexOf(node)]?.text ?? '').join('\n\n').trim(), maxChars);
168
+ const markdown = truncateContent(serializeAnchoredMarkdown(selectedNodes, blocks, parsed.start), maxChars);
169
+ const shorthand = truncateContent(serializeAnchoredShorthand(selectedNodes, blocks, parsed.start), maxChars);
170
+ const textHash = blockTextHash(blocks, parsed.start, parsed.end);
171
+ const nodeHash = blockNodeHash(blocks, parsed.start, parsed.end);
172
+ const nextCursor = parsed.end < nodes.length ? { from: parsed.end, contentHash } : null;
173
+ return {
174
+ nodeRange: `${parsed.start}:${parsed.end}`,
175
+ nodeRangeTuple: [parsed.start, parsed.end],
176
+ format: 'dinox-shorthand',
177
+ from: parsed.start,
178
+ text: text.value,
179
+ textHash,
180
+ nodeHash,
181
+ preferredTarget: {
182
+ nodeRange: `${parsed.start}:${parsed.end}`,
183
+ anchorIds: blocks.slice(parsed.start, parsed.end).map((block) => block.anchorId),
184
+ expectedTextHash: textHash,
185
+ expectedNodeHash: nodeHash,
186
+ },
187
+ shorthand: shorthand.value,
188
+ markdown: markdown.value,
189
+ textWindow: text.window,
190
+ shorthandWindow: shorthand.window,
191
+ markdownWindow: markdown.window,
192
+ isTruncated: text.isTruncated || shorthand.isTruncated || markdown.isTruncated,
193
+ nextCursor,
194
+ ...(input.includeJson ? { json: selectedNodes } : {}),
195
+ };
196
+ }
197
+ async #readNote(db, noteId) {
198
+ const id = normalizeText(noteId);
199
+ if (!id)
200
+ throw new Error('noteId 不能为空');
201
+ const row = await db.getOptional('SELECT * FROM c_note WHERE id = ? LIMIT 1', [id]);
202
+ if (!row || Number(row.is_del ?? 0) === 1)
203
+ throw new Error('笔记不存在或已删除');
204
+ return row;
205
+ }
206
+ #assertUser(row, userId) {
207
+ const owner = normalizeText(row.user_no) || normalizeText(row.user_id);
208
+ const actor = normalizeText(userId);
209
+ if (!actor || !owner || owner !== actor)
210
+ throw new Error('无权更新此笔记');
211
+ }
212
+ #rememberReadToken(context, runtime) {
213
+ this.#pruneReadTokens();
214
+ const token = {
215
+ token: generateId(),
216
+ noteId: context.noteId,
217
+ userId: normalizeText(runtime.userId),
218
+ contentHash: context.contentHash,
219
+ updatedAt: context.updatedAt,
220
+ version: context.version,
221
+ expiresAtMs: this.now() + READ_TOKEN_TTL_MS,
222
+ };
223
+ this.#readTokens.set(token.token, token);
224
+ this.#pruneReadTokens();
225
+ return token;
226
+ }
227
+ #requireReadToken(tokenId, runtime) {
228
+ const id = normalizeText(tokenId);
229
+ if (!id)
230
+ throw new Error(`缺少 readToken,请先调用 ${NOTE_CONTENT_READ_TOOL_NAME} 读取笔记内容`);
231
+ this.#pruneReadTokens();
232
+ const token = this.#readTokens.get(id);
233
+ if (!token)
234
+ throw new Error(`readToken 已失效,请重新调用 ${NOTE_CONTENT_READ_TOOL_NAME} 读取最新笔记内容`);
235
+ if (token.userId !== normalizeText(runtime.userId))
236
+ throw new Error('readToken 与当前用户不匹配,请重新读取笔记内容');
237
+ return token;
238
+ }
239
+ #assertTokenMatchesRow(token, row) {
240
+ if (token.noteId !== row.id)
241
+ throw new NoteContentEditError('content_conflict', 'readToken 与目标笔记不匹配,请重新读取笔记内容');
242
+ if (token.updatedAt !== (row.updated_at ?? null) || token.version !== normalizeVersion(row.version)) {
243
+ throw new NoteContentEditError('content_conflict', '笔记内容已变化,请重新读取后再编辑');
244
+ }
245
+ }
246
+ #pruneReadTokens() {
247
+ const now = this.now();
248
+ for (const [token, record] of this.#readTokens) {
249
+ if (record.expiresAtMs <= now)
250
+ this.#readTokens.delete(token);
251
+ }
252
+ while (this.#readTokens.size > MAX_READ_TOKENS) {
253
+ const oldest = this.#readTokens.keys().next().value;
254
+ if (!oldest)
255
+ break;
256
+ this.#readTokens.delete(oldest);
257
+ }
258
+ }
259
+ }
260
+ export function buildNoteContentToolError(error, operations = []) {
261
+ const message = error instanceof Error ? error.message : String(error);
262
+ const code = error instanceof NoteContentEditError ? error.code : 'unexpectedError';
263
+ return {
264
+ success: false,
265
+ hasError: true,
266
+ docChanged: false,
267
+ document: null,
268
+ reason: mapToolErrorReason(code),
269
+ error: message,
270
+ shouldRetry: isRetryable(code, message),
271
+ operationResults: operations.map((operation, index) => ({
272
+ operationIndex: index,
273
+ type: operation.op,
274
+ success: false,
275
+ target: describeOperationTarget(operation.target),
276
+ nodeRange: null,
277
+ error: message,
278
+ })),
279
+ };
280
+ }
281
+ async function updateNoteProjection(db, noteId, values) {
282
+ const columns = await db.getAll('PRAGMA table_info(c_note)', []);
283
+ const columnNames = new Set(columns.map((row) => normalizeText(row.name)).filter(Boolean));
284
+ for (const required of ['content_json', 'content_text', 'content_md']) {
285
+ if (!columnNames.has(required))
286
+ throw new Error(`c_note table is missing required column: ${required}`);
287
+ }
288
+ const updates = [];
289
+ const params = [];
290
+ for (const [column, value] of Object.entries(values)) {
291
+ if (!columnNames.has(column))
292
+ continue;
293
+ updates.push(`${column} = ?`);
294
+ params.push(value);
295
+ }
296
+ if (columnNames.has('version'))
297
+ updates.push('version = COALESCE(version, 0) + 1');
298
+ params.push(noteId);
299
+ await db.execute(`
300
+ UPDATE c_note
301
+ SET ${updates.join(', ')}
302
+ WHERE id = ?
303
+ `, params);
304
+ }
305
+ async function runWrite(db, callback) {
306
+ if (typeof db.writeTransaction === 'function')
307
+ return db.writeTransaction(callback);
308
+ if (typeof db.writeLock === 'function')
309
+ return db.writeLock(callback);
310
+ return callback(db);
311
+ }
312
+ function buildSuccess(result, saved, updatedAt, version) {
313
+ return {
314
+ success: true,
315
+ hasError: false,
316
+ docChanged: result.changed,
317
+ document: null,
318
+ saved,
319
+ noteId: result.noteId,
320
+ title: result.after.title,
321
+ changed: result.changed,
322
+ appliedOperations: result.appliedOperations,
323
+ operationResults: result.operationResults,
324
+ updatedAt: updatedAt ?? result.after.updatedAt,
325
+ before: { contentHash: result.before.contentHash, totalNodes: result.before.blocks.length },
326
+ after: { contentHash: result.after.contentHash, totalNodes: result.after.blocks.length, version: version ?? result.after.version },
327
+ };
328
+ }
329
+ function resolveReadPageRange(input, total) {
330
+ if (input.nodeRange)
331
+ return parseNodeRangeString(input.nodeRange, total);
332
+ const start = clampInt(input.cursor?.from ?? input.from, 0, total, 0);
333
+ const maxNodes = clampInt(input.maxNodes, 1, 100, DEFAULT_READ_PAGE_MAX_NODES);
334
+ return { start, end: Math.min(total, start + maxNodes) };
335
+ }
336
+ function truncateContent(value, maxChars) {
337
+ const isTruncated = value.length > maxChars;
338
+ const length = isTruncated ? maxChars : value.length;
339
+ return {
340
+ value: isTruncated ? value.slice(0, maxChars) : value,
341
+ isTruncated,
342
+ window: { offset: 0, length, total: value.length, nextOffset: isTruncated ? length : null },
343
+ };
344
+ }
345
+ function clampInt(value, min, max, fallback) {
346
+ const number = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback;
347
+ return Math.max(min, Math.min(max, number));
348
+ }
349
+ function normalizeVersion(value) {
350
+ const number = Number(value);
351
+ return Number.isFinite(number) ? number : null;
352
+ }
353
+ function nextVersion(value) {
354
+ const current = normalizeVersion(value);
355
+ return current == null ? null : current + 1;
356
+ }
357
+ function normalizeText(value) {
358
+ return typeof value === 'string' ? value.trim() : '';
359
+ }
360
+ function mapToolErrorReason(code) {
361
+ if (code === 'invalid_plan' || code === 'invalid_content')
362
+ return 'validationError';
363
+ if (code === 'content_conflict')
364
+ return 'contentConflict';
365
+ if (code === 'target_not_found' || code === 'target_conflict')
366
+ return 'targetError';
367
+ return 'unexpectedError';
368
+ }
369
+ function isRetryable(code, message) {
370
+ return code === 'content_conflict' || code === 'target_not_found' || code === 'target_conflict' || /readToken|cursor|过期|变化|重新读取/i.test(message);
371
+ }
372
+ function describeOperationTarget(target) {
373
+ if (!target)
374
+ return 'unknown';
375
+ if (target.anchorId)
376
+ return target.anchorId;
377
+ if (target.anchorIds?.length)
378
+ return target.anchorIds.join(',');
379
+ if (target.nodeRange)
380
+ return target.nodeRange;
381
+ if (typeof target.nodeIndex === 'number')
382
+ return String(target.nodeIndex);
383
+ if (target.expectedNodeHash)
384
+ return target.expectedNodeHash;
385
+ if (target.expectedTextHash)
386
+ return target.expectedTextHash;
387
+ if (target.textMatch)
388
+ return target.textMatch;
389
+ return 'unknown';
390
+ }
@@ -17,11 +17,14 @@ type MCPResponse = {
17
17
  };
18
18
  export declare class MCPServer {
19
19
  private db;
20
+ private readonly noteContent;
20
21
  initialize(): Promise<void>;
21
22
  handleRequest(req: MCPRequest): Promise<MCPResponse | null>;
22
23
  private handleInitialize;
23
24
  private handleToolsList;
24
25
  private handleToolsCall;
26
+ private requireDb;
27
+ private getRuntime;
25
28
  close(): Promise<void>;
26
29
  }
27
30
  export declare function startMCPServer(port?: number, host?: string, authToken?: string): Promise<{
@@ -2,6 +2,7 @@ import { createServer } from 'node:http';
2
2
  import { DEFAULT_MCP_PORT } from './types.js';
3
3
  import { mcpTools } from './tools.js';
4
4
  import { isAuthorizedMCPRequest } from './auth.js';
5
+ import { NOTE_CONTENT_EDIT_TOOL_NAME, NOTE_CONTENT_READ_TOOL_NAME, NoteContentMcpService, buildNoteContentToolError, } from './noteContentTools.js';
5
6
  import { resolveAuthIdentity } from '../../auth/userInfo.js';
6
7
  import { loadConfig } from '../../config/store.js';
7
8
  import { resolveConfig } from '../../config/resolve.js';
@@ -141,6 +142,7 @@ async function readJsonBody(req) {
141
142
  }
142
143
  export class MCPServer {
143
144
  db = null;
145
+ noteContent = new NoteContentMcpService(() => this.requireDb());
144
146
  async initialize() {
145
147
  const config = resolveConfig(await loadConfig());
146
148
  const { db } = await connectPowerSync({ config, offline: false, timeoutMs: config.sync.timeoutMs });
@@ -204,9 +206,7 @@ export class MCPServer {
204
206
  return buildResponse(req, { result: { tools: mcpTools } });
205
207
  }
206
208
  async handleToolsCall(req) {
207
- if (!this.db) {
208
- throw new Error('Database not initialized');
209
- }
209
+ const db = this.requireDb();
210
210
  const name = req.params?.name;
211
211
  const args = req.params?.arguments ?? {};
212
212
  if (typeof name !== 'string') {
@@ -218,7 +218,7 @@ export class MCPServer {
218
218
  const tagsExpression = typeof args.tags === 'string' ? args.tags.trim() : '';
219
219
  const rawBoxes = args.boxes ?? args.box;
220
220
  const { names: boxNames, matchEmpty: matchEmptyBoxes } = parseBoxFilter(rawBoxes);
221
- const zettelBoxIds = await resolveZettelBoxIdsForSearch(this.db, boxNames);
221
+ const zettelBoxIds = await resolveZettelBoxIdsForSearch(db, boxNames);
222
222
  const daysInput = typeof args.days === 'number' ? String(args.days) : args.days;
223
223
  const { createdAtFrom, createdAtTo } = parseCreatedAtRange({
224
224
  from: args.from,
@@ -228,7 +228,7 @@ export class MCPServer {
228
228
  const limit = normalizeLimit(args.limit);
229
229
  const offset = normalizeOffset(args.offset);
230
230
  const fields = parseNoteSearchFields(args.fields, 'dinox_search_notes.fields');
231
- const rows = await searchNotes(this.db, keyword, {
231
+ const rows = await searchNotes(db, keyword, {
232
232
  includeDeleted: false,
233
233
  tagsExpression: tagsExpression || undefined,
234
234
  createdAtFrom,
@@ -256,7 +256,7 @@ export class MCPServer {
256
256
  if (typeof args.id !== 'string' || !args.id.trim()) {
257
257
  throw new Error('dinox_get_note: id is required');
258
258
  }
259
- const notes = await getNoteDetails(this.db, [args.id.trim()]);
259
+ const notes = await getNoteDetails(db, [args.id.trim()]);
260
260
  const text = JSON.stringify(notes[0] ?? null);
261
261
  return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
262
262
  }
@@ -272,12 +272,12 @@ export class MCPServer {
272
272
  const rawConfig = await loadConfig();
273
273
  const config = resolveConfig(rawConfig);
274
274
  const identity = await resolveAuthIdentity(rawConfig, config);
275
- const canonicalContentMd = await canonicalizeBidlinksInMarkdown(this.db, contentMd, identity.userId);
275
+ const canonicalContentMd = await canonicalizeBidlinksInMarkdown(db, contentMd, identity.userId);
276
276
  const { contentJson, contentText } = convertMarkdownToNoteContent(canonicalContentMd);
277
- const tags = await validateTagsForCreate(this.db, normalizeStringArray(args.tags));
278
- const zettelBoxIds = await resolveZettelBoxIdsForCreate(this.db, normalizeStringArray(args.boxes));
277
+ const tags = await validateTagsForCreate(db, normalizeStringArray(args.tags));
278
+ const zettelBoxIds = await resolveZettelBoxIdsForCreate(db, normalizeStringArray(args.boxes));
279
279
  const id = generateId();
280
- await createNote(this.db, {
280
+ await createNote(db, {
281
281
  id,
282
282
  title,
283
283
  noteType: 'crawl',
@@ -295,7 +295,7 @@ export class MCPServer {
295
295
  if (typeof args.id !== 'string' || !args.id.trim()) {
296
296
  throw new Error('dinox_graph_backlinks: id is required');
297
297
  }
298
- const result = await getBacklinks(this.db, args.id.trim());
298
+ const result = await getBacklinks(db, args.id.trim());
299
299
  const text = JSON.stringify(result);
300
300
  return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
301
301
  }
@@ -303,7 +303,7 @@ export class MCPServer {
303
303
  if (typeof args.id !== 'string' || !args.id.trim()) {
304
304
  throw new Error('dinox_graph_outlinks: id is required');
305
305
  }
306
- const result = await getOutlinks(this.db, args.id.trim());
306
+ const result = await getOutlinks(db, args.id.trim());
307
307
  const text = JSON.stringify(result);
308
308
  return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
309
309
  }
@@ -315,12 +315,12 @@ export class MCPServer {
315
315
  ? Math.trunc(args.depth)
316
316
  : 2;
317
317
  const depth = Math.min(Math.max(depthRaw, 1), 5);
318
- const result = await getRelatedNotes(this.db, args.id.trim(), depth);
318
+ const result = await getRelatedNotes(db, args.id.trim(), depth);
319
319
  const text = JSON.stringify(result);
320
320
  return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
321
321
  }
322
322
  case 'dinox_graph_stats': {
323
- const result = await getGraphStats(this.db);
323
+ const result = await getGraphStats(db);
324
324
  const text = JSON.stringify(result);
325
325
  return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
326
326
  }
@@ -330,7 +330,7 @@ export class MCPServer {
330
330
  ? args.status
331
331
  : 'all';
332
332
  const limit = normalizeLimit(args.limit);
333
- const result = await searchTodoTasks(this.db, query, {
333
+ const result = await searchTodoTasks(db, query, {
334
334
  includeDeleted: false,
335
335
  taskStatus: status || 'all',
336
336
  taskTagFilter: normalizeStringArray(args.tags),
@@ -340,10 +340,38 @@ export class MCPServer {
340
340
  const text = JSON.stringify(result);
341
341
  return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
342
342
  }
343
+ case NOTE_CONTENT_READ_TOOL_NAME: {
344
+ const runtime = await this.getRuntime();
345
+ const result = await this.noteContent.read(args, runtime).catch((error) => buildNoteContentToolError(error));
346
+ const text = JSON.stringify(result);
347
+ return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
348
+ }
349
+ case NOTE_CONTENT_EDIT_TOOL_NAME: {
350
+ const runtime = await this.getRuntime();
351
+ const operations = Array.isArray(args.operations) ? args.operations : [];
352
+ const result = await this.noteContent.edit({
353
+ readToken: args.readToken,
354
+ operations,
355
+ }, runtime).catch((error) => buildNoteContentToolError(error, operations));
356
+ const text = JSON.stringify(result);
357
+ return buildResponse(req, { result: { content: [{ type: 'text', text }] } });
358
+ }
343
359
  default:
344
360
  throw new Error(`Unknown tool: ${name}`);
345
361
  }
346
362
  }
363
+ requireDb() {
364
+ if (!this.db) {
365
+ throw new Error('Database not initialized');
366
+ }
367
+ return this.db;
368
+ }
369
+ async getRuntime() {
370
+ const rawConfig = await loadConfig();
371
+ const config = resolveConfig(rawConfig);
372
+ const identity = await resolveAuthIdentity(rawConfig, config);
373
+ return { userId: identity.userId };
374
+ }
347
375
  async close() {
348
376
  if (this.db) {
349
377
  await this.db.close().catch(() => undefined);
@@ -1,4 +1,5 @@
1
1
  import { listMcpCommandSchemas } from '../schema/spec.js';
2
+ import { noteContentMcpTools } from './noteContentTools.js';
2
3
  function toMcpProperty(property) {
3
4
  if (property.type === 'string[]') {
4
5
  return {
@@ -14,7 +15,7 @@ function toMcpProperty(property) {
14
15
  ...(property.enum ? { enum: property.enum } : {}),
15
16
  };
16
17
  }
17
- export const mcpTools = listMcpCommandSchemas().map((schema) => {
18
+ const schemaBackedMcpTools = listMcpCommandSchemas().map((schema) => {
18
19
  const mcp = schema.mcp;
19
20
  const properties = Object.fromEntries(mcp.properties.map((property) => [property.name, toMcpProperty(property)]));
20
21
  const required = mcp.properties.filter((property) => property.required).map((property) => property.name);
@@ -28,3 +29,4 @@ export const mcpTools = listMcpCommandSchemas().map((schema) => {
28
29
  },
29
30
  };
30
31
  });
32
+ export const mcpTools = [...schemaBackedMcpTools, ...noteContentMcpTools];
@@ -4,12 +4,16 @@ export type MCPTool = {
4
4
  inputSchema: {
5
5
  type: 'object';
6
6
  properties: Record<string, {
7
- type: 'string' | 'number' | 'boolean' | 'array';
7
+ type: 'string' | 'number' | 'boolean' | 'array' | 'object';
8
8
  description?: string;
9
9
  enum?: string[];
10
10
  items?: {
11
- type: 'string';
11
+ type: 'string' | 'object';
12
12
  };
13
+ properties?: Record<string, {
14
+ type: 'string' | 'number' | 'boolean' | 'array' | 'object';
15
+ description?: string;
16
+ }>;
13
17
  }>;
14
18
  required?: string[];
15
19
  };
@@ -1,32 +1,16 @@
1
- import { loadConfig } from '../../config/store.js';
2
- import { resolveConfig } from '../../config/resolve.js';
3
- import { connectPowerSync, waitForIdleDataFlow } from '../../powersync/runtime.js';
4
1
  import { registerCommandGroup } from '../descriptor/register.js';
5
- import { OFFLINE_OPTIONS, SYNC_TIMEOUT_OPTIONS } from '../schema/globalOptions.js';
2
+ import { OFFLINE_OPTIONS, REQUIRE_SYNC_OPTIONS, SYNC_TIMEOUT_OPTIONS } from '../schema/globalOptions.js';
6
3
  import { printSuccess } from '../../utils/output.js';
7
- import { parseSyncTimeoutMs } from '../../utils/syncTimeout.js';
4
+ import { commandStale, syncBeforeCommand, withCommandRuntime } from '../runtime.js';
8
5
  import { getMetaSchema, getMetaStats } from './repo.js';
9
- async function syncBeforeMetaCommand(db, offline, timeoutMs) {
10
- if (offline) {
11
- return;
12
- }
13
- await waitForIdleDataFlow(db, Math.min(timeoutMs, 10_000));
14
- }
15
6
  async function runMetaStats(context) {
16
- const config = resolveConfig(await loadConfig());
17
- const offline = Boolean(context.globals.offline);
18
- const timeoutMs = parseSyncTimeoutMs(context.globals.syncTimeout) ?? config.sync.timeoutMs;
19
- const { db, stale } = await connectPowerSync({ config, offline, timeoutMs });
20
- try {
21
- await syncBeforeMetaCommand(db, offline, timeoutMs);
7
+ await withCommandRuntime(context, async (runtime) => {
8
+ await syncBeforeCommand(runtime);
22
9
  printSuccess({
23
- stale: offline ? true : stale,
24
- stats: await getMetaStats(db),
10
+ stale: commandStale(runtime),
11
+ stats: await getMetaStats(runtime.db),
25
12
  });
26
- }
27
- finally {
28
- await db.close().catch(() => undefined);
29
- }
13
+ });
30
14
  }
31
15
  async function runMetaSchema() {
32
16
  printSuccess(getMetaSchema());
@@ -42,7 +26,7 @@ export const metaCommandGroup = {
42
26
  path: 'meta.stats',
43
27
  risk: 'read',
44
28
  auth: 'required',
45
- globalOptions: [...OFFLINE_OPTIONS, ...SYNC_TIMEOUT_OPTIONS],
29
+ globalOptions: [...OFFLINE_OPTIONS, ...REQUIRE_SYNC_OPTIONS, ...SYNC_TIMEOUT_OPTIONS],
46
30
  output: {
47
31
  type: 'structured',
48
32
  summary: 'Metadata stats payload',
@@ -24,9 +24,6 @@ export type MetaSchema = {
24
24
  command_schema: {
25
25
  fields: string[];
26
26
  };
27
- mcp_tool: {
28
- fields: string[];
29
- };
30
27
  };
31
28
  };
32
29
  export declare function getMetaStats(db: AbstractPowerSyncDatabase): Promise<MetaStats>;
@@ -67,10 +67,7 @@ export function getMetaSchema() {
67
67
  fields: ['notes_count', 'tags_count', 'boxes_count', 'latest_note_updated_at'],
68
68
  },
69
69
  command_schema: {
70
- fields: ['path', 'command', 'summary', 'risk', 'auth', 'globalOptions', 'arguments', 'options', 'output', 'examples', 'mcp'],
71
- },
72
- mcp_tool: {
73
- fields: ['name', 'description', 'inputSchema'],
70
+ fields: ['path', 'command', 'summary', 'risk', 'auth', 'globalOptions', 'arguments', 'options', 'output', 'examples'],
74
71
  },
75
72
  },
76
73
  };
@@ -6,5 +6,7 @@ export declare function runNoteDetail({ command, options, args }: CommandExecuti
6
6
  export declare function runNoteCreate({ command, options }: CommandExecutionContext): Promise<void>;
7
7
  export declare function runNoteDelete({ command, options, args }: CommandExecutionContext): Promise<void>;
8
8
  export declare function runNoteUpdate({ command, options, args }: CommandExecutionContext): Promise<void>;
9
+ export declare function runNoteTag({ command, options, args }: CommandExecutionContext): Promise<void>;
10
+ export declare function runNoteMove({ command, options, args }: CommandExecutionContext): Promise<void>;
9
11
  export declare function runNoteStar({ command, options, args }: CommandExecutionContext): Promise<void>;
10
12
  export declare function runNoteUnstar({ command, options, args }: CommandExecutionContext): Promise<void>;