@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.
- package/README.md +72 -139
- package/dist/auth/userInfo.d.ts +8 -1
- package/dist/auth/userInfo.js +38 -6
- package/dist/cli.js +1 -2
- package/dist/cliTypes.d.ts +1 -0
- package/dist/commands/auth/index.js +11 -4
- package/dist/commands/boxes/index.js +274 -54
- package/dist/commands/boxes/model.d.ts +34 -0
- package/dist/commands/boxes/model.js +78 -0
- package/dist/commands/boxes/operations.d.ts +86 -0
- package/dist/commands/boxes/operations.js +415 -0
- package/dist/commands/boxes/repo.d.ts +13 -6
- package/dist/commands/boxes/repo.js +272 -149
- package/dist/commands/boxes/store.d.ts +22 -0
- package/dist/commands/boxes/store.js +143 -0
- package/dist/commands/graph/index.js +11 -38
- package/dist/commands/mcp/noteContentTools.d.ts +222 -0
- package/dist/commands/mcp/noteContentTools.js +390 -0
- package/dist/commands/mcp/server.d.ts +3 -0
- package/dist/commands/mcp/server.js +43 -15
- package/dist/commands/mcp/tools.js +3 -1
- package/dist/commands/mcp/types.d.ts +6 -2
- package/dist/commands/meta/index.js +8 -24
- package/dist/commands/meta/repo.d.ts +0 -3
- package/dist/commands/meta/repo.js +1 -4
- package/dist/commands/notes/actions.d.ts +2 -0
- package/dist/commands/notes/actions.js +149 -55
- package/dist/commands/notes/bulk.d.ts +34 -0
- package/dist/commands/notes/bulk.js +204 -0
- package/dist/commands/notes/contentEngine.d.ts +95 -0
- package/dist/commands/notes/contentEngine.js +652 -0
- package/dist/commands/notes/contentPatch.d.ts +84 -0
- package/dist/commands/notes/contentPatch.js +337 -0
- package/dist/commands/notes/contentPatchCommand.d.ts +3 -0
- package/dist/commands/notes/contentPatchCommand.js +126 -0
- package/dist/commands/notes/descriptors.js +194 -42
- package/dist/commands/notes/export.d.ts +21 -0
- package/dist/commands/notes/export.js +224 -0
- package/dist/commands/notes/metadataMutation.d.ts +28 -0
- package/dist/commands/notes/metadataMutation.js +127 -0
- package/dist/commands/notes/repo.d.ts +20 -0
- package/dist/commands/notes/repo.js +135 -18
- package/dist/commands/notes/runtime.d.ts +3 -12
- package/dist/commands/notes/runtime.js +4 -30
- package/dist/commands/notes/searchSql.js +1 -1
- package/dist/commands/prompt/index.js +17 -47
- package/dist/commands/prompt/repo.js +2 -2
- package/dist/commands/runtime.d.ts +34 -0
- package/dist/commands/runtime.js +110 -0
- package/dist/commands/schema/globalOptions.d.ts +1 -0
- package/dist/commands/schema/globalOptions.js +1 -0
- package/dist/commands/schema/renderSkills.js +21 -7
- package/dist/commands/schema/spec.d.ts +1 -2
- package/dist/commands/schema/spec.js +2 -3
- package/dist/commands/schema/types.d.ts +0 -13
- package/dist/commands/storage/index.js +32 -62
- package/dist/commands/sync.js +22 -5
- package/dist/commands/tags/content.d.ts +32 -0
- package/dist/commands/tags/content.js +243 -0
- package/dist/commands/tags/index.js +287 -51
- package/dist/commands/tags/model.d.ts +38 -0
- package/dist/commands/tags/model.js +100 -0
- package/dist/commands/tags/operations.d.ts +105 -0
- package/dist/commands/tags/operations.js +438 -0
- package/dist/commands/tags/repo.d.ts +5 -1
- package/dist/commands/tags/repo.js +142 -218
- package/dist/commands/tags/store.d.ts +28 -0
- package/dist/commands/tags/store.js +166 -0
- package/dist/commands/todo/index.js +32 -63
- package/dist/commands/writeDryRun.d.ts +25 -0
- package/dist/commands/writeDryRun.js +31 -1
- package/dist/config/resolve.js +2 -2
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.js +176 -58
- package/dist/powersync/runtime.d.ts +1 -0
- package/dist/powersync/runtime.js +16 -0
- package/dist/powersync/schema/content.d.ts +2 -0
- package/dist/powersync/schema/content.js +2 -0
- package/dist/powersync/schema/index.d.ts +2 -0
- package/dist/powersync/syncGate.d.ts +24 -0
- package/dist/powersync/syncGate.js +36 -0
- package/dist/skills/check.d.ts +1 -1
- package/dist/skills/check.js +27 -2
- package/dist/utils/authToken.d.ts +2 -0
- package/dist/utils/authToken.js +7 -2
- package/dist/utils/noteContentEngine.d.ts +333 -0
- package/dist/utils/noteContentEngine.js +740 -0
- package/dist/utils/tiptapMarkdownSchema.js +31 -0
- 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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
275
|
+
const canonicalContentMd = await canonicalizeBidlinksInMarkdown(db, contentMd, identity.userId);
|
|
276
276
|
const { contentJson, contentText } = convertMarkdownToNoteContent(canonicalContentMd);
|
|
277
|
-
const tags = await validateTagsForCreate(
|
|
278
|
-
const zettelBoxIds = await resolveZettelBoxIdsForCreate(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
17
|
-
|
|
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:
|
|
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',
|
|
@@ -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'
|
|
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>;
|