@openparachute/vault 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +31 -0
- package/.dockerignore +8 -0
- package/.env.example +9 -0
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
- package/CLAUDE.md +115 -0
- package/Caddyfile +3 -0
- package/Dockerfile +22 -0
- package/LICENSE +661 -0
- package/README.md +356 -0
- package/bun.lock +219 -0
- package/bunfig.toml +2 -0
- package/core/package.json +7 -0
- package/core/src/core.test.ts +940 -0
- package/core/src/hooks.test.ts +361 -0
- package/core/src/hooks.ts +234 -0
- package/core/src/links.ts +352 -0
- package/core/src/mcp.ts +672 -0
- package/core/src/notes.ts +520 -0
- package/core/src/obsidian.test.ts +380 -0
- package/core/src/obsidian.ts +322 -0
- package/core/src/paths.test.ts +197 -0
- package/core/src/paths.ts +53 -0
- package/core/src/schema.ts +331 -0
- package/core/src/store.ts +303 -0
- package/core/src/tag-schemas.ts +104 -0
- package/core/src/test-preload.ts +8 -0
- package/core/src/types.ts +140 -0
- package/core/src/wikilinks.test.ts +277 -0
- package/core/src/wikilinks.ts +402 -0
- package/deploy/parachute-vault.service +20 -0
- package/docker-compose.yml +50 -0
- package/docs/HTTP_API.md +328 -0
- package/fly.toml +24 -0
- package/package.json +32 -0
- package/railway.json +14 -0
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/scripts/migrate-audio-to-opus.test.ts +237 -0
- package/scripts/migrate-audio-to-opus.ts +499 -0
- package/src/auth.ts +170 -0
- package/src/cli.ts +1131 -0
- package/src/config-triggers.test.ts +83 -0
- package/src/config.test.ts +125 -0
- package/src/config.ts +716 -0
- package/src/db.ts +14 -0
- package/src/launchd.ts +109 -0
- package/src/mcp-http.ts +113 -0
- package/src/mcp-tools.ts +155 -0
- package/src/oauth.test.ts +1242 -0
- package/src/oauth.ts +729 -0
- package/src/owner-auth.ts +159 -0
- package/src/prompt.ts +141 -0
- package/src/published.test.ts +214 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/routes.ts +822 -0
- package/src/server.ts +450 -0
- package/src/systemd.ts +84 -0
- package/src/token-store.test.ts +174 -0
- package/src/token-store.ts +241 -0
- package/src/triggers.test.ts +397 -0
- package/src/triggers.ts +412 -0
- package/src/two-factor.test.ts +246 -0
- package/src/two-factor.ts +222 -0
- package/src/vault-store.ts +47 -0
- package/src/vault.test.ts +1309 -0
- package/tsconfig.json +29 -0
- package/web/README.md +73 -0
- package/web/bun.lock +827 -0
- package/web/eslint.config.js +23 -0
- package/web/index.html +15 -0
- package/web/package.json +36 -0
- package/web/public/favicon.svg +1 -0
- package/web/public/icons.svg +24 -0
- package/web/src/App.tsx +149 -0
- package/web/src/Graph.tsx +200 -0
- package/web/src/NoteView.tsx +155 -0
- package/web/src/Sidebar.tsx +186 -0
- package/web/src/api.ts +21 -0
- package/web/src/index.css +50 -0
- package/web/src/main.tsx +10 -0
- package/web/src/types.ts +37 -0
- package/web/src/utils.ts +107 -0
- package/web/tsconfig.app.json +25 -0
- package/web/tsconfig.json +7 -0
- package/web/tsconfig.node.json +24 -0
- package/web/vite.config.ts +15 -0
package/core/src/mcp.ts
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import type { Store, Note } from "./types.js";
|
|
3
|
+
import * as noteOps from "./notes.js";
|
|
4
|
+
import { filterMetadata } from "./notes.js";
|
|
5
|
+
import * as linkOps from "./links.js";
|
|
6
|
+
import * as tagSchemaOps from "./tag-schemas.js";
|
|
7
|
+
|
|
8
|
+
export interface McpToolDef {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
inputSchema: Record<string, unknown>;
|
|
12
|
+
execute: (params: Record<string, unknown>) => unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a note identifier — tries ID first, then case-insensitive path match.
|
|
21
|
+
* Works everywhere a note reference is accepted.
|
|
22
|
+
*/
|
|
23
|
+
function resolveNote(db: Database, idOrPath: string): Note | null {
|
|
24
|
+
// Try ID match first (fast, indexed)
|
|
25
|
+
const byId = noteOps.getNote(db, idOrPath);
|
|
26
|
+
if (byId) return byId;
|
|
27
|
+
// Fallback to path match
|
|
28
|
+
return noteOps.getNoteByPath(db, idOrPath);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function requireNote(db: Database, idOrPath: string): Note {
|
|
32
|
+
const note = resolveNote(db, idOrPath);
|
|
33
|
+
if (!note) throw new Error(`Note not found: "${idOrPath}"`);
|
|
34
|
+
return note;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Remove [[wikilink]] brackets from note content for a specific target.
|
|
39
|
+
* Handles [[Target]], [[Target|alias]], [[Target#section]].
|
|
40
|
+
*/
|
|
41
|
+
function removeWikilinkBrackets(content: string, targetPath: string): string {
|
|
42
|
+
// Match [[TargetPath...]] with optional alias/anchor, replace with display text
|
|
43
|
+
const escaped = targetPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
44
|
+
// [[Target|alias]] → alias
|
|
45
|
+
content = content.replace(
|
|
46
|
+
new RegExp(`\\[\\[${escaped}\\|([^\\]]+)\\]\\]`, "gi"),
|
|
47
|
+
"$1",
|
|
48
|
+
);
|
|
49
|
+
// [[Target#section]] → Target#section (just remove brackets)
|
|
50
|
+
content = content.replace(
|
|
51
|
+
new RegExp(`\\[\\[${escaped}(#[^\\]]+)?\\]\\]`, "gi"),
|
|
52
|
+
`${targetPath}$1`,
|
|
53
|
+
);
|
|
54
|
+
return content;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Tool generation
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Generate the 9 consolidated MCP tools for a vault.
|
|
63
|
+
*/
|
|
64
|
+
export function generateMcpTools(store: Store): McpToolDef[] {
|
|
65
|
+
const db: Database = (store as any).db;
|
|
66
|
+
|
|
67
|
+
return [
|
|
68
|
+
|
|
69
|
+
// =====================================================================
|
|
70
|
+
// 1. query-notes — the universal read tool
|
|
71
|
+
// =====================================================================
|
|
72
|
+
{
|
|
73
|
+
name: "query-notes",
|
|
74
|
+
description: `Query notes. Returns notes matching the given filters.
|
|
75
|
+
|
|
76
|
+
- **Single note**: pass \`id\` (accepts note ID or path, e.g., "Projects/README")
|
|
77
|
+
- **Filter**: pass \`tag\`, \`path\`, \`path_prefix\`, \`search\`, \`metadata\`, date range
|
|
78
|
+
- **Graph neighborhood**: pass \`near\` to scope results to notes within N hops of an anchor note
|
|
79
|
+
- **No filters**: returns all notes (paginated)
|
|
80
|
+
|
|
81
|
+
Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".`,
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
id: { type: "string", description: "Get one note by ID or path" },
|
|
86
|
+
tag: {
|
|
87
|
+
oneOf: [
|
|
88
|
+
{ type: "string" },
|
|
89
|
+
{ type: "array", items: { type: "string" } },
|
|
90
|
+
],
|
|
91
|
+
description: "Filter by tag(s)",
|
|
92
|
+
},
|
|
93
|
+
tag_match: { type: "string", enum: ["any", "all"], description: "How to match multiple tags: 'any' (OR, default) or 'all' (AND)" },
|
|
94
|
+
exclude_tags: { type: "array", items: { type: "string" }, description: "Exclude notes with these tags" },
|
|
95
|
+
path: { type: "string", description: "Exact path match (case-insensitive)" },
|
|
96
|
+
path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
|
|
97
|
+
search: { type: "string", description: "Full-text search query" },
|
|
98
|
+
metadata: { type: "object", description: "Filter by metadata values (exact match per key)" },
|
|
99
|
+
date_from: { type: "string", description: "Start date (ISO, inclusive)" },
|
|
100
|
+
date_to: { type: "string", description: "End date (ISO, exclusive)" },
|
|
101
|
+
near: {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: {
|
|
104
|
+
note_id: { type: "string", description: "Anchor note ID or path" },
|
|
105
|
+
depth: { type: "number", description: "Max hops from anchor (default 2, max 5)" },
|
|
106
|
+
relationship: { type: "string", description: "Only follow links with this relationship" },
|
|
107
|
+
},
|
|
108
|
+
required: ["note_id"],
|
|
109
|
+
description: "Scope results to notes within N hops of an anchor note",
|
|
110
|
+
},
|
|
111
|
+
sort: { type: "string", enum: ["asc", "desc"], description: "Sort by created_at" },
|
|
112
|
+
limit: { type: "number", description: "Max results (default 50)" },
|
|
113
|
+
offset: { type: "number", description: "Pagination offset (default 0)" },
|
|
114
|
+
include_content: { type: "boolean", description: "Include note content (default: true for single, false for list)" },
|
|
115
|
+
include_metadata: {
|
|
116
|
+
oneOf: [
|
|
117
|
+
{ type: "boolean" },
|
|
118
|
+
{ type: "array", items: { type: "string" } },
|
|
119
|
+
],
|
|
120
|
+
description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
|
|
121
|
+
},
|
|
122
|
+
include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
|
|
123
|
+
include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
execute: (params) => {
|
|
127
|
+
// --- Single note by ID/path ---
|
|
128
|
+
if (params.id) {
|
|
129
|
+
const note = resolveNote(db, params.id as string);
|
|
130
|
+
if (!note) return { error: "Note not found", id: params.id };
|
|
131
|
+
const includeContent = params.include_content !== false; // default true for single
|
|
132
|
+
let result: any = includeContent ? { ...note } : noteOps.toNoteIndex(note);
|
|
133
|
+
result = filterMetadata(result, params.include_metadata as boolean | string[] | undefined);
|
|
134
|
+
if (params.include_links) {
|
|
135
|
+
result.links = linkOps.getLinksHydrated(db, note.id);
|
|
136
|
+
}
|
|
137
|
+
if (params.include_attachments) {
|
|
138
|
+
result.attachments = store.getAttachments(note.id);
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- Build near-scope (graph-filtered set of allowed IDs) ---
|
|
144
|
+
let nearScope: Set<string> | null = null;
|
|
145
|
+
if (params.near) {
|
|
146
|
+
const near = params.near as { note_id: string; depth?: number; relationship?: string };
|
|
147
|
+
const anchor = resolveNote(db, near.note_id);
|
|
148
|
+
if (!anchor) return { error: "Anchor note not found", note_id: near.note_id };
|
|
149
|
+
const depth = Math.min(near.depth ?? 2, 5);
|
|
150
|
+
const traversed = linkOps.traverseLinks(db, anchor.id, {
|
|
151
|
+
max_depth: depth,
|
|
152
|
+
relationship: near.relationship,
|
|
153
|
+
});
|
|
154
|
+
nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Full-text search ---
|
|
158
|
+
let results: Note[];
|
|
159
|
+
if (params.search) {
|
|
160
|
+
// Normalize tag param
|
|
161
|
+
const tags = normalizeTags(params.tag);
|
|
162
|
+
results = noteOps.searchNotes(db, params.search as string, {
|
|
163
|
+
tags,
|
|
164
|
+
limit: (params.limit as number) ?? 50,
|
|
165
|
+
});
|
|
166
|
+
} else {
|
|
167
|
+
// --- Structured query ---
|
|
168
|
+
const tags = normalizeTags(params.tag);
|
|
169
|
+
results = noteOps.queryNotes(db, {
|
|
170
|
+
tags,
|
|
171
|
+
tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
172
|
+
excludeTags: params.exclude_tags as string[] | undefined,
|
|
173
|
+
path: params.path as string | undefined,
|
|
174
|
+
pathPrefix: params.path_prefix as string | undefined,
|
|
175
|
+
metadata: params.metadata as Record<string, unknown> | undefined,
|
|
176
|
+
dateFrom: params.date_from as string | undefined,
|
|
177
|
+
dateTo: params.date_to as string | undefined,
|
|
178
|
+
sort: params.sort as "asc" | "desc" | undefined,
|
|
179
|
+
limit: (params.limit as number) ?? 50,
|
|
180
|
+
offset: params.offset as number | undefined,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- Apply near-scope filter ---
|
|
185
|
+
if (nearScope) {
|
|
186
|
+
results = results.filter((n) => nearScope!.has(n.id));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- Format output ---
|
|
190
|
+
const includeContent = params.include_content === true; // default false for list
|
|
191
|
+
const includeMetadata = params.include_metadata as boolean | string[] | undefined;
|
|
192
|
+
let output = includeContent ? results : results.map(noteOps.toNoteIndex);
|
|
193
|
+
|
|
194
|
+
// --- Apply metadata filtering ---
|
|
195
|
+
if (includeMetadata !== undefined && includeMetadata !== true) {
|
|
196
|
+
output = output.map((n: any) => filterMetadata(n, includeMetadata));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --- Hydrate links/attachments per note if requested ---
|
|
200
|
+
if (params.include_links || params.include_attachments) {
|
|
201
|
+
return output.map((n: any) => {
|
|
202
|
+
const enriched = { ...n };
|
|
203
|
+
if (params.include_links) enriched.links = linkOps.getLinksHydrated(db, n.id);
|
|
204
|
+
if (params.include_attachments) enriched.attachments = store.getAttachments(n.id);
|
|
205
|
+
return enriched;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return output;
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
// =====================================================================
|
|
214
|
+
// 2. create-note — single or batch
|
|
215
|
+
// =====================================================================
|
|
216
|
+
{
|
|
217
|
+
name: "create-note",
|
|
218
|
+
description: `Create one or more notes. Pass a single note's fields directly, or pass a \`notes\` array for batch creation. Each note accepts content, path, metadata, tags, links, and created_at.`,
|
|
219
|
+
inputSchema: {
|
|
220
|
+
type: "object",
|
|
221
|
+
properties: {
|
|
222
|
+
// Single note fields
|
|
223
|
+
content: { type: "string", description: "Note content (markdown). Wikilinks like [[Target]] auto-resolve." },
|
|
224
|
+
path: { type: "string", description: "Note path (e.g., 'Projects/README')" },
|
|
225
|
+
metadata: { type: "object", description: "Metadata fields" },
|
|
226
|
+
tags: { type: "array", items: { type: "string" }, description: "Tags to apply" },
|
|
227
|
+
links: {
|
|
228
|
+
type: "array",
|
|
229
|
+
items: {
|
|
230
|
+
type: "object",
|
|
231
|
+
properties: {
|
|
232
|
+
target: { type: "string", description: "Target note ID or path" },
|
|
233
|
+
relationship: { type: "string", description: "Relationship type (e.g., mentions, related-to)" },
|
|
234
|
+
},
|
|
235
|
+
required: ["target", "relationship"],
|
|
236
|
+
},
|
|
237
|
+
description: "Links to create from this note",
|
|
238
|
+
},
|
|
239
|
+
created_at: { type: "string", description: "ISO timestamp (defaults to now)" },
|
|
240
|
+
// Batch
|
|
241
|
+
notes: {
|
|
242
|
+
type: "array",
|
|
243
|
+
items: {
|
|
244
|
+
type: "object",
|
|
245
|
+
properties: {
|
|
246
|
+
content: { type: "string" },
|
|
247
|
+
path: { type: "string" },
|
|
248
|
+
metadata: { type: "object" },
|
|
249
|
+
tags: { type: "array", items: { type: "string" } },
|
|
250
|
+
links: { type: "array" },
|
|
251
|
+
created_at: { type: "string" },
|
|
252
|
+
},
|
|
253
|
+
required: ["content"],
|
|
254
|
+
},
|
|
255
|
+
description: "Array of notes for batch creation",
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
execute: (params) => {
|
|
260
|
+
const batch = params.notes as any[] | undefined;
|
|
261
|
+
const items = batch ?? [params];
|
|
262
|
+
|
|
263
|
+
const created: Note[] = [];
|
|
264
|
+
for (const item of items) {
|
|
265
|
+
const note = store.createNote(item.content as string ?? "", {
|
|
266
|
+
path: item.path as string | undefined,
|
|
267
|
+
tags: item.tags as string[] | undefined,
|
|
268
|
+
metadata: item.metadata as Record<string, unknown> | undefined,
|
|
269
|
+
created_at: item.created_at as string | undefined,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Create explicit links (not wikilinks — those are automatic)
|
|
273
|
+
if (item.links) {
|
|
274
|
+
for (const link of item.links as { target: string; relationship: string }[]) {
|
|
275
|
+
const target = resolveNote(db, link.target);
|
|
276
|
+
if (target) {
|
|
277
|
+
store.createLink(note.id, target.id, link.relationship);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
created.push(noteOps.getNote(db, note.id) ?? note);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Apply tag schema effects
|
|
286
|
+
for (const note of created) {
|
|
287
|
+
if (note.tags && note.tags.length > 0) {
|
|
288
|
+
applySchemaDefaults(store, db, [note.id], note.tags);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return batch ? created : created[0];
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
// =====================================================================
|
|
297
|
+
// 3. update-note — single or batch, absorbs tag/untag + link add/remove
|
|
298
|
+
// =====================================================================
|
|
299
|
+
{
|
|
300
|
+
name: "update-note",
|
|
301
|
+
description: `Update one or more notes. Accepts ID or path. Supports content, path, metadata updates plus tag and link mutations.
|
|
302
|
+
|
|
303
|
+
- \`tags: { add: ["x"], remove: ["y"] }\` — add/remove tags
|
|
304
|
+
- \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
|
|
305
|
+
- When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
|
|
306
|
+
- For batch: pass a \`notes\` array, each with an \`id\` field.`,
|
|
307
|
+
inputSchema: {
|
|
308
|
+
type: "object",
|
|
309
|
+
properties: {
|
|
310
|
+
id: { type: "string", description: "Note ID or path" },
|
|
311
|
+
content: { type: "string", description: "New content" },
|
|
312
|
+
path: { type: "string", description: "New path" },
|
|
313
|
+
metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
|
|
314
|
+
created_at: { type: "string", description: "New created_at timestamp" },
|
|
315
|
+
tags: {
|
|
316
|
+
type: "object",
|
|
317
|
+
properties: {
|
|
318
|
+
add: { type: "array", items: { type: "string" } },
|
|
319
|
+
remove: { type: "array", items: { type: "string" } },
|
|
320
|
+
},
|
|
321
|
+
description: "Tags to add/remove",
|
|
322
|
+
},
|
|
323
|
+
links: {
|
|
324
|
+
type: "object",
|
|
325
|
+
properties: {
|
|
326
|
+
add: {
|
|
327
|
+
type: "array",
|
|
328
|
+
items: {
|
|
329
|
+
type: "object",
|
|
330
|
+
properties: {
|
|
331
|
+
target: { type: "string", description: "Target note ID or path" },
|
|
332
|
+
relationship: { type: "string" },
|
|
333
|
+
},
|
|
334
|
+
required: ["target", "relationship"],
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
remove: {
|
|
338
|
+
type: "array",
|
|
339
|
+
items: {
|
|
340
|
+
type: "object",
|
|
341
|
+
properties: {
|
|
342
|
+
target: { type: "string", description: "Target note ID or path" },
|
|
343
|
+
relationship: { type: "string" },
|
|
344
|
+
},
|
|
345
|
+
required: ["target", "relationship"],
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
description: "Links to add/remove",
|
|
350
|
+
},
|
|
351
|
+
// Batch
|
|
352
|
+
notes: {
|
|
353
|
+
type: "array",
|
|
354
|
+
items: {
|
|
355
|
+
type: "object",
|
|
356
|
+
properties: {
|
|
357
|
+
id: { type: "string" },
|
|
358
|
+
content: { type: "string" },
|
|
359
|
+
path: { type: "string" },
|
|
360
|
+
metadata: { type: "object" },
|
|
361
|
+
created_at: { type: "string" },
|
|
362
|
+
tags: { type: "object" },
|
|
363
|
+
links: { type: "object" },
|
|
364
|
+
},
|
|
365
|
+
required: ["id"],
|
|
366
|
+
},
|
|
367
|
+
description: "Array of note updates for batch",
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
execute: (params) => {
|
|
372
|
+
const batch = params.notes as any[] | undefined;
|
|
373
|
+
const items = batch ?? [params];
|
|
374
|
+
|
|
375
|
+
const updated: Note[] = [];
|
|
376
|
+
for (const item of items) {
|
|
377
|
+
const note = requireNote(db, item.id as string);
|
|
378
|
+
let contentOverride = item.content as string | undefined;
|
|
379
|
+
|
|
380
|
+
// --- Remove links (before content update, so bracket removal applies) ---
|
|
381
|
+
const linksRemove = (item.links as any)?.remove as { target: string; relationship: string }[] | undefined;
|
|
382
|
+
if (linksRemove) {
|
|
383
|
+
for (const link of linksRemove) {
|
|
384
|
+
const target = resolveNote(db, link.target);
|
|
385
|
+
if (target) {
|
|
386
|
+
store.deleteLink(note.id, target.id, link.relationship);
|
|
387
|
+
// Remove [[brackets]] from content if this was a wikilink
|
|
388
|
+
if (link.relationship === "wikilink" && target.path) {
|
|
389
|
+
const currentContent = contentOverride ?? note.content;
|
|
390
|
+
const cleaned = removeWikilinkBrackets(currentContent, target.path);
|
|
391
|
+
if (cleaned !== currentContent) {
|
|
392
|
+
contentOverride = cleaned;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// --- Core update (content, path, metadata, created_at) ---
|
|
400
|
+
const updates: any = {};
|
|
401
|
+
if (contentOverride !== undefined) updates.content = contentOverride;
|
|
402
|
+
if (item.path !== undefined) updates.path = item.path;
|
|
403
|
+
if (item.metadata !== undefined) {
|
|
404
|
+
// Merge metadata (don't replace wholesale)
|
|
405
|
+
const existing = (note.metadata as Record<string, unknown>) ?? {};
|
|
406
|
+
updates.metadata = { ...existing, ...(item.metadata as Record<string, unknown>) };
|
|
407
|
+
}
|
|
408
|
+
if (item.created_at !== undefined) updates.created_at = item.created_at;
|
|
409
|
+
|
|
410
|
+
let result: Note;
|
|
411
|
+
if (Object.keys(updates).length > 0) {
|
|
412
|
+
result = store.updateNote(note.id, updates);
|
|
413
|
+
} else {
|
|
414
|
+
result = note;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// --- Tags ---
|
|
418
|
+
const tagsOp = item.tags as { add?: string[]; remove?: string[] } | undefined;
|
|
419
|
+
if (tagsOp?.add?.length) {
|
|
420
|
+
store.tagNote(note.id, tagsOp.add);
|
|
421
|
+
applySchemaDefaults(store, db, [note.id], tagsOp.add);
|
|
422
|
+
}
|
|
423
|
+
if (tagsOp?.remove?.length) {
|
|
424
|
+
store.untagNote(note.id, tagsOp.remove);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// --- Add links ---
|
|
428
|
+
const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
429
|
+
if (linksAdd) {
|
|
430
|
+
for (const link of linksAdd) {
|
|
431
|
+
const target = resolveNote(db, link.target);
|
|
432
|
+
if (target) {
|
|
433
|
+
store.createLink(note.id, target.id, link.relationship, link.metadata);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Re-read for final state
|
|
439
|
+
updated.push(noteOps.getNote(db, note.id) ?? result);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return batch ? updated : updated[0];
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
// =====================================================================
|
|
447
|
+
// 4. delete-note
|
|
448
|
+
// =====================================================================
|
|
449
|
+
{
|
|
450
|
+
name: "delete-note",
|
|
451
|
+
description: "Permanently delete a note and all its tags and links. Accepts ID or path.",
|
|
452
|
+
inputSchema: {
|
|
453
|
+
type: "object",
|
|
454
|
+
properties: {
|
|
455
|
+
id: { type: "string", description: "Note ID or path" },
|
|
456
|
+
},
|
|
457
|
+
required: ["id"],
|
|
458
|
+
},
|
|
459
|
+
execute: (params) => {
|
|
460
|
+
const note = requireNote(db, params.id as string);
|
|
461
|
+
store.deleteNote(note.id);
|
|
462
|
+
return { deleted: true, id: note.id };
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
// =====================================================================
|
|
467
|
+
// 5. list-tags — with optional single-tag detail + schema
|
|
468
|
+
// =====================================================================
|
|
469
|
+
{
|
|
470
|
+
name: "list-tags",
|
|
471
|
+
description: `List tags with usage counts. Pass \`tag\` to get a single tag's details including its schema (description + fields). Pass \`include_schema: true\` to include schemas for all tags.`,
|
|
472
|
+
inputSchema: {
|
|
473
|
+
type: "object",
|
|
474
|
+
properties: {
|
|
475
|
+
tag: { type: "string", description: "Get details for a single tag" },
|
|
476
|
+
include_schema: { type: "boolean", description: "Include schema (description + fields) for each tag (default: false)" },
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
execute: (params) => {
|
|
480
|
+
const singleTag = params.tag as string | undefined;
|
|
481
|
+
|
|
482
|
+
if (singleTag) {
|
|
483
|
+
// Single tag detail
|
|
484
|
+
const allTags = noteOps.listTags(db);
|
|
485
|
+
const found = allTags.find((t) => t.name === singleTag);
|
|
486
|
+
const schema = tagSchemaOps.getTagSchema(db, singleTag);
|
|
487
|
+
return {
|
|
488
|
+
name: singleTag,
|
|
489
|
+
count: found?.count ?? 0,
|
|
490
|
+
description: schema?.description ?? null,
|
|
491
|
+
fields: schema?.fields ?? null,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// All tags
|
|
496
|
+
const tags = noteOps.listTags(db);
|
|
497
|
+
if (params.include_schema) {
|
|
498
|
+
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
499
|
+
return tags.map((t) => ({
|
|
500
|
+
...t,
|
|
501
|
+
description: schemas[t.name]?.description ?? null,
|
|
502
|
+
fields: schemas[t.name]?.fields ?? null,
|
|
503
|
+
}));
|
|
504
|
+
}
|
|
505
|
+
return tags;
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
// =====================================================================
|
|
510
|
+
// 6. update-tag — create/update tag description + schema fields
|
|
511
|
+
// =====================================================================
|
|
512
|
+
{
|
|
513
|
+
name: "update-tag",
|
|
514
|
+
description: "Create or update a tag's description and schema fields. If the tag doesn't exist, it's created. Fields are merged — new keys are added, existing keys are replaced.",
|
|
515
|
+
inputSchema: {
|
|
516
|
+
type: "object",
|
|
517
|
+
properties: {
|
|
518
|
+
tag: { type: "string", description: "Tag name" },
|
|
519
|
+
description: { type: "string", description: "Human-readable description of what this tag means" },
|
|
520
|
+
fields: {
|
|
521
|
+
type: "object",
|
|
522
|
+
description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"] } }',
|
|
523
|
+
additionalProperties: {
|
|
524
|
+
type: "object",
|
|
525
|
+
properties: {
|
|
526
|
+
type: { type: "string", description: "Field type: string, boolean, integer" },
|
|
527
|
+
description: { type: "string" },
|
|
528
|
+
enum: { type: "array", items: { type: "string" }, description: "Allowed values (first is default)" },
|
|
529
|
+
},
|
|
530
|
+
required: ["type"],
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
required: ["tag"],
|
|
535
|
+
},
|
|
536
|
+
execute: (params) => {
|
|
537
|
+
const tag = params.tag as string;
|
|
538
|
+
const existing = tagSchemaOps.getTagSchema(db, tag);
|
|
539
|
+
const mergedFields = { ...existing?.fields, ...(params.fields as any) };
|
|
540
|
+
return tagSchemaOps.upsertTagSchema(db, tag, {
|
|
541
|
+
description: (params.description as string | undefined) ?? existing?.description,
|
|
542
|
+
fields: Object.keys(mergedFields).length > 0 ? mergedFields : undefined,
|
|
543
|
+
});
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
|
|
547
|
+
// =====================================================================
|
|
548
|
+
// 7. delete-tag — delete tag + schema from all notes
|
|
549
|
+
// =====================================================================
|
|
550
|
+
{
|
|
551
|
+
name: "delete-tag",
|
|
552
|
+
description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged.",
|
|
553
|
+
inputSchema: {
|
|
554
|
+
type: "object",
|
|
555
|
+
properties: {
|
|
556
|
+
tag: { type: "string", description: "Tag name to delete" },
|
|
557
|
+
},
|
|
558
|
+
required: ["tag"],
|
|
559
|
+
},
|
|
560
|
+
execute: (params) => {
|
|
561
|
+
const tag = params.tag as string;
|
|
562
|
+
// Delete schema first (FK cascade would handle it, but be explicit)
|
|
563
|
+
tagSchemaOps.deleteTagSchema(db, tag);
|
|
564
|
+
return store.deleteTag(tag);
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
// =====================================================================
|
|
569
|
+
// 8. find-path — BFS between two notes
|
|
570
|
+
// =====================================================================
|
|
571
|
+
{
|
|
572
|
+
name: "find-path",
|
|
573
|
+
description: "Find the shortest path between two notes in the link graph. Accepts IDs or paths. Returns the chain of note IDs and relationships, or null if no path exists.",
|
|
574
|
+
inputSchema: {
|
|
575
|
+
type: "object",
|
|
576
|
+
properties: {
|
|
577
|
+
source: { type: "string", description: "Starting note ID or path" },
|
|
578
|
+
target: { type: "string", description: "Destination note ID or path" },
|
|
579
|
+
max_depth: { type: "number", description: "Max path length (default 5)" },
|
|
580
|
+
},
|
|
581
|
+
required: ["source", "target"],
|
|
582
|
+
},
|
|
583
|
+
execute: (params) => {
|
|
584
|
+
const source = requireNote(db, params.source as string);
|
|
585
|
+
const target = requireNote(db, params.target as string);
|
|
586
|
+
return linkOps.findPath(db, source.id, target.id, {
|
|
587
|
+
max_depth: Math.min((params.max_depth as number) ?? 5, 10),
|
|
588
|
+
});
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
|
|
592
|
+
// =====================================================================
|
|
593
|
+
// 9. vault-info — get/update vault description + stats
|
|
594
|
+
// =====================================================================
|
|
595
|
+
{
|
|
596
|
+
name: "vault-info",
|
|
597
|
+
description: "Get vault description and optionally stats (note/tag/link counts, distribution). Pass `description` to update the vault description (changes how AI agents behave in future sessions).",
|
|
598
|
+
inputSchema: {
|
|
599
|
+
type: "object",
|
|
600
|
+
properties: {
|
|
601
|
+
include_stats: { type: "boolean", description: "Include note count, tag count, distribution by month (default: false)" },
|
|
602
|
+
description: { type: "string", description: "If provided, updates the vault description" },
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
// execute is overridden in mcp-tools.ts where vault config is available
|
|
606
|
+
execute: () => {
|
|
607
|
+
// This is a placeholder — vault-info needs access to vault config,
|
|
608
|
+
// which is only available in the server layer (mcp-tools.ts).
|
|
609
|
+
return { error: "vault-info must be configured by the server layer" };
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
|
|
613
|
+
];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
// Tag schema effects — auto-populate defaults when tags are applied
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): void {
|
|
621
|
+
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
622
|
+
if (Object.keys(schemas).length === 0) return;
|
|
623
|
+
|
|
624
|
+
const defaults: Record<string, unknown> = {};
|
|
625
|
+
for (const tag of tags) {
|
|
626
|
+
const schema = schemas[tag];
|
|
627
|
+
if (!schema?.fields) continue;
|
|
628
|
+
for (const [field, fieldSchema] of Object.entries(schema.fields)) {
|
|
629
|
+
if (!(field in defaults)) {
|
|
630
|
+
defaults[field] = defaultForField(fieldSchema);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (Object.keys(defaults).length === 0) return;
|
|
635
|
+
|
|
636
|
+
for (const noteId of noteIds) {
|
|
637
|
+
const note = noteOps.getNote(db, noteId);
|
|
638
|
+
if (!note) continue;
|
|
639
|
+
const existing = (note.metadata as Record<string, unknown>) ?? {};
|
|
640
|
+
const missing: Record<string, unknown> = {};
|
|
641
|
+
for (const [field, value] of Object.entries(defaults)) {
|
|
642
|
+
if (!(field in existing)) {
|
|
643
|
+
missing[field] = value;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (Object.keys(missing).length === 0) continue;
|
|
647
|
+
store.updateNote(noteId, {
|
|
648
|
+
metadata: { ...existing, ...missing },
|
|
649
|
+
skipUpdatedAt: true,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function defaultForField(field: { type: string; enum?: string[] }): unknown {
|
|
655
|
+
if (field.enum && field.enum.length > 0) return field.enum[0];
|
|
656
|
+
switch (field.type) {
|
|
657
|
+
case "boolean": return false;
|
|
658
|
+
case "integer": return 0;
|
|
659
|
+
default: return "";
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
// Helpers
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
function normalizeTags(tag: unknown): string[] | undefined {
|
|
668
|
+
if (!tag) return undefined;
|
|
669
|
+
if (Array.isArray(tag)) return tag;
|
|
670
|
+
return [tag as string];
|
|
671
|
+
}
|
|
672
|
+
|