@openparachute/vault 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/CLAUDE.md +2 -2
- package/README.md +289 -44
- package/core/src/core.test.ts +802 -346
- package/core/src/expand.ts +140 -0
- package/core/src/hooks.test.ts +27 -27
- package/core/src/hooks.ts +1 -1
- package/core/src/mcp.ts +102 -39
- package/core/src/notes.ts +82 -4
- package/core/src/obsidian.test.ts +11 -11
- package/core/src/paths.test.ts +46 -46
- package/core/src/schema.ts +18 -2
- package/core/src/store.ts +51 -51
- package/core/src/types.ts +29 -29
- package/core/src/wikilinks.test.ts +61 -61
- package/docs/HTTP_API.md +4 -2
- package/package.json +1 -1
- package/src/auth.test.ts +319 -0
- package/src/backup-launchd.test.ts +90 -0
- package/src/backup-launchd.ts +169 -0
- package/src/backup.test.ts +715 -0
- package/src/backup.ts +699 -0
- package/src/cli.ts +923 -31
- package/src/config.test.ts +173 -0
- package/src/config.ts +345 -15
- package/src/daemon.ts +136 -0
- package/src/doctor.test.ts +356 -0
- package/src/health.test.ts +201 -0
- package/src/health.ts +115 -0
- package/src/launchd.test.ts +91 -0
- package/src/launchd.ts +37 -40
- package/src/mcp-http.ts +1 -1
- package/src/mcp-tools.ts +7 -9
- package/src/oauth.test.ts +289 -8
- package/src/oauth.ts +66 -13
- package/src/published.test.ts +21 -21
- package/src/routes.ts +152 -70
- package/src/routing.test.ts +478 -0
- package/src/routing.ts +413 -0
- package/src/server.ts +7 -278
- package/src/systemd.test.ts +15 -0
- package/src/systemd.ts +18 -11
- package/src/triggers.test.ts +7 -7
- package/src/triggers.ts +6 -6
- package/src/vault-store.ts +20 -3
- package/src/vault.test.ts +356 -262
- package/.claude/settings.local.json +0 -31
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- 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/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -15
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline expansion of [[wikilinks]] in note content.
|
|
3
|
+
*
|
|
4
|
+
* Used by `query-notes` when `expand_links=true`. Replaces wikilink matches
|
|
5
|
+
* with delimited blocks containing the linked note's content (full mode) or
|
|
6
|
+
* metadata summary (summary mode). Deduplicates across the query and guards
|
|
7
|
+
* against cycles via a shared `expanded` set.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Database } from "bun:sqlite";
|
|
11
|
+
import type { Note } from "./types.js";
|
|
12
|
+
import * as noteOps from "./notes.js";
|
|
13
|
+
import { resolveWikilink } from "./wikilinks.js";
|
|
14
|
+
|
|
15
|
+
export type ExpandMode = "full" | "summary";
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_EXPAND_DEPTH = 1;
|
|
18
|
+
export const MAX_EXPAND_DEPTH = 3;
|
|
19
|
+
|
|
20
|
+
export interface ExpandContext {
|
|
21
|
+
db: Database;
|
|
22
|
+
mode: ExpandMode;
|
|
23
|
+
/** Note IDs already expanded in this query. Shared across all expansions. */
|
|
24
|
+
expanded: Set<string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Matches wikilinks the same way the parser does — but retains positions.
|
|
29
|
+
* Embeds (`![[...]]`) are treated as regular links here; the `!` is discarded
|
|
30
|
+
* in the expansion output. If embed-specific rendering is needed later,
|
|
31
|
+
* inspect the first capture group.
|
|
32
|
+
*/
|
|
33
|
+
const WIKILINK_RE = /(!?)\[\[([^\[\]\n]+?)\]\]/g;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Expand wikilinks in `content` up to `remainingDepth` levels deep. Returns
|
|
37
|
+
* content with `<expanded>` blocks replacing each wikilink occurrence.
|
|
38
|
+
*
|
|
39
|
+
* `remainingDepth` counts down: when it reaches 0, no further expansion
|
|
40
|
+
* happens. A call with remainingDepth=1 expands top-level wikilinks only;
|
|
41
|
+
* wikilinks inside those expansions are left as-is.
|
|
42
|
+
*
|
|
43
|
+
* Wikilinks inside fenced or inline code blocks are left untouched — mirrors
|
|
44
|
+
* the behavior of `parseWikilinks` in `wikilinks.ts` so the link graph and
|
|
45
|
+
* the expansion view stay consistent.
|
|
46
|
+
*/
|
|
47
|
+
export function expandContent(
|
|
48
|
+
content: string,
|
|
49
|
+
ctx: ExpandContext,
|
|
50
|
+
remainingDepth: number,
|
|
51
|
+
): string {
|
|
52
|
+
if (remainingDepth <= 0) return content;
|
|
53
|
+
|
|
54
|
+
const codeSkip = codeRanges(content);
|
|
55
|
+
|
|
56
|
+
return content.replace(WIKILINK_RE, (match, _bang: string, inner: string, offset: number) => {
|
|
57
|
+
if (inCodeRange(offset, codeSkip)) return match;
|
|
58
|
+
|
|
59
|
+
const target = parseTarget(inner);
|
|
60
|
+
if (!target) return match;
|
|
61
|
+
|
|
62
|
+
const noteId = resolveWikilink(ctx.db, target);
|
|
63
|
+
if (!noteId) return match; // unresolved or ambiguous — leave as-is
|
|
64
|
+
|
|
65
|
+
if (ctx.expanded.has(noteId)) {
|
|
66
|
+
return `${match} (expanded above)`;
|
|
67
|
+
}
|
|
68
|
+
ctx.expanded.add(noteId);
|
|
69
|
+
|
|
70
|
+
const note = noteOps.getNote(ctx.db, noteId);
|
|
71
|
+
if (!note) return match; // shouldn't happen, but be safe
|
|
72
|
+
|
|
73
|
+
if (ctx.mode === "summary") {
|
|
74
|
+
// Summary mode doesn't recurse: depth > 1 has no additional effect.
|
|
75
|
+
return renderSummary(note);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Full mode — expand nested wikilinks one less level deep.
|
|
79
|
+
const nested = expandContent(note.content, ctx, remainingDepth - 1);
|
|
80
|
+
return renderFull(note, nested);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function codeRanges(content: string): [number, number][] {
|
|
85
|
+
const ranges: [number, number][] = [];
|
|
86
|
+
const fenced = /```[\s\S]*?```/g;
|
|
87
|
+
let m: RegExpExecArray | null;
|
|
88
|
+
while ((m = fenced.exec(content)) !== null) {
|
|
89
|
+
ranges.push([m.index, m.index + m[0].length]);
|
|
90
|
+
}
|
|
91
|
+
const inline = /`[^`\n]+`/g;
|
|
92
|
+
while ((m = inline.exec(content)) !== null) {
|
|
93
|
+
ranges.push([m.index, m.index + m[0].length]);
|
|
94
|
+
}
|
|
95
|
+
return ranges;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function inCodeRange(pos: number, ranges: [number, number][]): boolean {
|
|
99
|
+
for (const [start, end] of ranges) {
|
|
100
|
+
if (pos >= start && pos < end) return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseTarget(inner: string): string | null {
|
|
106
|
+
// Strip display alias: "target|display" → "target"
|
|
107
|
+
const pipeIdx = inner.indexOf("|");
|
|
108
|
+
let targetPart = pipeIdx === -1 ? inner : inner.slice(0, pipeIdx);
|
|
109
|
+
// Strip anchor/block-ref: "target#heading" → "target"
|
|
110
|
+
const hashIdx = targetPart.indexOf("#");
|
|
111
|
+
if (hashIdx !== -1) targetPart = targetPart.slice(0, hashIdx);
|
|
112
|
+
const target = targetPart.trim();
|
|
113
|
+
return target || null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function renderFull(note: Note, content: string): string {
|
|
117
|
+
const pathAttr = escapeAttr(note.path ?? note.id);
|
|
118
|
+
return `<expanded path="${pathAttr}" mode="full">\n${content}\n</expanded>`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function renderSummary(note: Note): string {
|
|
122
|
+
const pathAttr = escapeAttr(note.path ?? note.id);
|
|
123
|
+
const summary = summaryText(note);
|
|
124
|
+
return `<expanded path="${pathAttr}" mode="summary">\n${summary}\n</expanded>`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function summaryText(note: Note): string {
|
|
128
|
+
const meta = note.metadata as Record<string, unknown> | undefined;
|
|
129
|
+
const s = meta?.summary;
|
|
130
|
+
if (typeof s === "string" && s.trim()) return s.trim();
|
|
131
|
+
return "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function escapeAttr(s: string): string {
|
|
135
|
+
return s
|
|
136
|
+
.replace(/&/g, "&")
|
|
137
|
+
.replace(/"/g, """)
|
|
138
|
+
.replace(/</g, "<")
|
|
139
|
+
.replace(/>/g, ">");
|
|
140
|
+
}
|
package/core/src/hooks.test.ts
CHANGED
|
@@ -26,7 +26,7 @@ async function settle(): Promise<void> {
|
|
|
26
26
|
await hooks.drain();
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
describe("HookRegistry", () => {
|
|
29
|
+
describe("HookRegistry", async () => {
|
|
30
30
|
it("fires registered hook on createNote", async () => {
|
|
31
31
|
const fired: string[] = [];
|
|
32
32
|
hooks.onNote({
|
|
@@ -36,7 +36,7 @@ describe("HookRegistry", () => {
|
|
|
36
36
|
},
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
const note = store.createNote("hello");
|
|
39
|
+
const note = await store.createNote("hello");
|
|
40
40
|
expect(fired).toEqual([]); // async — not yet
|
|
41
41
|
await settle();
|
|
42
42
|
expect(fired).toEqual([note.id]);
|
|
@@ -51,11 +51,11 @@ describe("HookRegistry", () => {
|
|
|
51
51
|
},
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
const note = store.createNote("hello");
|
|
54
|
+
const note = await store.createNote("hello");
|
|
55
55
|
await settle();
|
|
56
56
|
expect(fired).toEqual([]); // we only subscribed to updated
|
|
57
57
|
|
|
58
|
-
store.updateNote(note.id, { content: "world" });
|
|
58
|
+
await store.updateNote(note.id, { content: "world" });
|
|
59
59
|
await settle();
|
|
60
60
|
expect(fired).toEqual([{ event: "updated", id: note.id }]);
|
|
61
61
|
});
|
|
@@ -68,7 +68,7 @@ describe("HookRegistry", () => {
|
|
|
68
68
|
},
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
const notes = store.createNotes([
|
|
71
|
+
const notes = await store.createNotes([
|
|
72
72
|
{ content: "a", id: "a1" },
|
|
73
73
|
{ content: "b", id: "b1" },
|
|
74
74
|
{ content: "c", id: "c1" },
|
|
@@ -87,8 +87,8 @@ describe("HookRegistry", () => {
|
|
|
87
87
|
},
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
const skipped = store.createNote("plain", { tags: ["journal"] });
|
|
91
|
-
const matched = store.createNote("reader-note", { tags: ["reader"] });
|
|
90
|
+
const skipped = await store.createNote("plain", { tags: ["journal"] });
|
|
91
|
+
const matched = await store.createNote("reader-note", { tags: ["reader"] });
|
|
92
92
|
await settle();
|
|
93
93
|
|
|
94
94
|
expect(fired).toEqual([matched.id]);
|
|
@@ -103,14 +103,14 @@ describe("HookRegistry", () => {
|
|
|
103
103
|
},
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
const note = store.createNote("one");
|
|
106
|
+
const note = await store.createNote("one");
|
|
107
107
|
await settle();
|
|
108
108
|
expect(fired).toEqual([note.id]);
|
|
109
109
|
|
|
110
110
|
fired.length = 0;
|
|
111
|
-
store.getNote(note.id);
|
|
112
|
-
store.getNotes([note.id]);
|
|
113
|
-
store.queryNotes({});
|
|
111
|
+
await store.getNote(note.id);
|
|
112
|
+
await store.getNotes([note.id]);
|
|
113
|
+
await store.queryNotes({});
|
|
114
114
|
await settle();
|
|
115
115
|
expect(fired).toEqual([]);
|
|
116
116
|
});
|
|
@@ -128,14 +128,14 @@ describe("HookRegistry", () => {
|
|
|
128
128
|
},
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
const note = store.createNote("work me");
|
|
131
|
+
const note = await store.createNote("work me");
|
|
132
132
|
await settle();
|
|
133
133
|
// The handler ran once for "created"; its updateNote triggered an
|
|
134
134
|
// "updated" dispatch, but the predicate excluded it because the
|
|
135
135
|
// marker is now set. So exactly one call.
|
|
136
136
|
expect(handlerCalls).toBe(1);
|
|
137
137
|
|
|
138
|
-
const refreshed = store.getNote(note.id)!;
|
|
138
|
+
const refreshed = (await store.getNote(note.id))!;
|
|
139
139
|
expect(refreshed.metadata?.processed_at).toBeTruthy();
|
|
140
140
|
});
|
|
141
141
|
|
|
@@ -155,10 +155,10 @@ describe("HookRegistry", () => {
|
|
|
155
155
|
},
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
-
const note = localStore.createNote("survive");
|
|
158
|
+
const note = await localStore.createNote("survive");
|
|
159
159
|
expect(note.id).toBeTruthy();
|
|
160
160
|
// Original mutation still persisted
|
|
161
|
-
expect(localStore.getNote(note.id)?.content).toBe("survive");
|
|
161
|
+
expect((await localStore.getNote(note.id))?.content).toBe("survive");
|
|
162
162
|
|
|
163
163
|
await Promise.resolve();
|
|
164
164
|
await Promise.resolve();
|
|
@@ -184,9 +184,9 @@ describe("HookRegistry", () => {
|
|
|
184
184
|
},
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
-
localStore.createNote("a");
|
|
188
|
-
localStore.createNote("b");
|
|
189
|
-
localStore.createNote("c");
|
|
187
|
+
await localStore.createNote("a");
|
|
188
|
+
await localStore.createNote("b");
|
|
189
|
+
await localStore.createNote("c");
|
|
190
190
|
|
|
191
191
|
// Let dispatch microtasks enqueue tasks and the semaphore start one.
|
|
192
192
|
await Promise.resolve();
|
|
@@ -216,12 +216,12 @@ describe("HookRegistry", () => {
|
|
|
216
216
|
},
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
-
store.createNote("first");
|
|
219
|
+
await store.createNote("first");
|
|
220
220
|
await settle();
|
|
221
221
|
expect(fired.length).toBe(1);
|
|
222
222
|
|
|
223
223
|
off();
|
|
224
|
-
store.createNote("second");
|
|
224
|
+
await store.createNote("second");
|
|
225
225
|
await settle();
|
|
226
226
|
expect(fired.length).toBe(1);
|
|
227
227
|
});
|
|
@@ -231,7 +231,7 @@ describe("HookRegistry", () => {
|
|
|
231
231
|
hooks.onNote({ name: "one", handler: () => void order.push("one") });
|
|
232
232
|
hooks.onNote({ name: "two", handler: () => void order.push("two") });
|
|
233
233
|
|
|
234
|
-
store.createNote("both");
|
|
234
|
+
await store.createNote("both");
|
|
235
235
|
await settle();
|
|
236
236
|
expect(order.sort()).toEqual(["one", "two"]);
|
|
237
237
|
});
|
|
@@ -244,7 +244,7 @@ describe("HookRegistry", () => {
|
|
|
244
244
|
done = true;
|
|
245
245
|
},
|
|
246
246
|
});
|
|
247
|
-
store.createNote("slow");
|
|
247
|
+
await store.createNote("slow");
|
|
248
248
|
// Let dispatch schedule
|
|
249
249
|
await Promise.resolve();
|
|
250
250
|
await Promise.resolve();
|
|
@@ -278,7 +278,7 @@ describe("HookRegistry", () => {
|
|
|
278
278
|
},
|
|
279
279
|
});
|
|
280
280
|
|
|
281
|
-
loggingStore.createNote("hi");
|
|
281
|
+
await loggingStore.createNote("hi");
|
|
282
282
|
await Promise.resolve();
|
|
283
283
|
await Promise.resolve();
|
|
284
284
|
await loggingHooks.drain();
|
|
@@ -292,7 +292,7 @@ describe("HookRegistry", () => {
|
|
|
292
292
|
});
|
|
293
293
|
});
|
|
294
294
|
|
|
295
|
-
describe("HookRegistry — HOOK_CONCURRENCY env var parsing", () => {
|
|
295
|
+
describe("HookRegistry — HOOK_CONCURRENCY env var parsing", async () => {
|
|
296
296
|
const original = process.env.HOOK_CONCURRENCY;
|
|
297
297
|
const restore = () => {
|
|
298
298
|
if (original === undefined) delete process.env.HOOK_CONCURRENCY;
|
|
@@ -344,9 +344,9 @@ describe("HookRegistry — HOOK_CONCURRENCY env var parsing", () => {
|
|
|
344
344
|
},
|
|
345
345
|
});
|
|
346
346
|
|
|
347
|
-
s.createNote("a");
|
|
348
|
-
s.createNote("b");
|
|
349
|
-
s.createNote("c");
|
|
347
|
+
await s.createNote("a");
|
|
348
|
+
await s.createNote("b");
|
|
349
|
+
await s.createNote("c");
|
|
350
350
|
await Promise.resolve();
|
|
351
351
|
await Promise.resolve();
|
|
352
352
|
// Release them one at a time and let each drain through the semaphore.
|
package/core/src/hooks.ts
CHANGED
|
@@ -203,7 +203,7 @@ export class HookRegistry {
|
|
|
203
203
|
// Re-read the note so the handler sees the latest state (another
|
|
204
204
|
// handler may have written back in between). If the note was
|
|
205
205
|
// deleted, silently drop.
|
|
206
|
-
const fresh = store.getNote(note.id) ?? note;
|
|
206
|
+
const fresh = (await store.getNote(note.id)) ?? note;
|
|
207
207
|
await hook.handler(fresh, store, event);
|
|
208
208
|
} catch (err) {
|
|
209
209
|
this.logger.error(
|
package/core/src/mcp.ts
CHANGED
|
@@ -4,12 +4,19 @@ import * as noteOps from "./notes.js";
|
|
|
4
4
|
import { filterMetadata } from "./notes.js";
|
|
5
5
|
import * as linkOps from "./links.js";
|
|
6
6
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
7
|
+
import {
|
|
8
|
+
expandContent,
|
|
9
|
+
DEFAULT_EXPAND_DEPTH,
|
|
10
|
+
MAX_EXPAND_DEPTH,
|
|
11
|
+
type ExpandContext,
|
|
12
|
+
type ExpandMode,
|
|
13
|
+
} from "./expand.js";
|
|
7
14
|
|
|
8
15
|
export interface McpToolDef {
|
|
9
16
|
name: string;
|
|
10
17
|
description: string;
|
|
11
18
|
inputSchema: Record<string, unknown>;
|
|
12
|
-
execute: (params: Record<string, unknown>) => unknown
|
|
19
|
+
execute: (params: Record<string, unknown>) => unknown | Promise<unknown>;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
// ---------------------------------------------------------------------------
|
|
@@ -78,7 +85,9 @@ export function generateMcpTools(store: Store): McpToolDef[] {
|
|
|
78
85
|
- **Graph neighborhood**: pass \`near\` to scope results to notes within N hops of an anchor note
|
|
79
86
|
- **No filters**: returns all notes (paginated)
|
|
80
87
|
|
|
81
|
-
Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any"
|
|
88
|
+
Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".
|
|
89
|
+
|
|
90
|
+
Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returned content. Tune with \`expand_depth\` (1–3, default 1) and \`expand_mode\` ("full" inlines full content, "summary" inlines only metadata.summary). Expansions are deduplicated across the query and cycle-guarded.`,
|
|
82
91
|
inputSchema: {
|
|
83
92
|
type: "object",
|
|
84
93
|
properties: {
|
|
@@ -121,21 +130,43 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
121
130
|
},
|
|
122
131
|
include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
|
|
123
132
|
include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
|
|
133
|
+
expand_links: { type: "boolean", description: "Inline [[wikilinks]] in returned content (default: false). Has no effect if content is not included (e.g., default list mode with include_content=false); wikilinks inside fenced or inline code are not expanded." },
|
|
134
|
+
expand_depth: { type: "number", description: "Recursion depth for link expansion (default 1, max 3). Only meaningful in 'full' mode — 'summary' mode does not recurse." },
|
|
135
|
+
expand_mode: { type: "string", enum: ["full", "summary"], description: "Expansion rendering: 'full' inlines the linked note's content, 'summary' inlines only metadata.summary. Default: 'full'." },
|
|
124
136
|
},
|
|
125
137
|
},
|
|
126
|
-
execute: (params) => {
|
|
138
|
+
execute: async (params) => {
|
|
139
|
+
// --- Link expansion config (shared across single + list paths) ---
|
|
140
|
+
const expandLinks = params.expand_links === true;
|
|
141
|
+
const expandMode = (params.expand_mode as ExpandMode) ?? "full";
|
|
142
|
+
const expandDepth = Math.max(
|
|
143
|
+
0,
|
|
144
|
+
Math.min(
|
|
145
|
+
(params.expand_depth as number | undefined) ?? DEFAULT_EXPAND_DEPTH,
|
|
146
|
+
MAX_EXPAND_DEPTH,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
const expandCtx: ExpandContext | null = expandLinks
|
|
150
|
+
? { db, mode: expandMode, expanded: new Set() }
|
|
151
|
+
: null;
|
|
152
|
+
|
|
127
153
|
// --- Single note by ID/path ---
|
|
128
154
|
if (params.id) {
|
|
129
155
|
const note = resolveNote(db, params.id as string);
|
|
130
156
|
if (!note) return { error: "Note not found", id: params.id };
|
|
131
157
|
const includeContent = params.include_content !== false; // default true for single
|
|
132
158
|
let result: any = includeContent ? { ...note } : noteOps.toNoteIndex(note);
|
|
159
|
+
if (expandCtx && includeContent && typeof result.content === "string") {
|
|
160
|
+
// Mark the top-level note as already expanded so it can't recursively inline itself.
|
|
161
|
+
expandCtx.expanded.add(note.id);
|
|
162
|
+
result.content = expandContent(result.content, expandCtx, expandDepth);
|
|
163
|
+
}
|
|
133
164
|
result = filterMetadata(result, params.include_metadata as boolean | string[] | undefined);
|
|
134
165
|
if (params.include_links) {
|
|
135
166
|
result.links = linkOps.getLinksHydrated(db, note.id);
|
|
136
167
|
}
|
|
137
168
|
if (params.include_attachments) {
|
|
138
|
-
result.attachments = store.getAttachments(note.id);
|
|
169
|
+
result.attachments = await store.getAttachments(note.id);
|
|
139
170
|
}
|
|
140
171
|
return result;
|
|
141
172
|
}
|
|
@@ -189,7 +220,18 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
189
220
|
// --- Format output ---
|
|
190
221
|
const includeContent = params.include_content === true; // default false for list
|
|
191
222
|
const includeMetadata = params.include_metadata as boolean | string[] | undefined;
|
|
192
|
-
let output = includeContent ? results : results.map(noteOps.toNoteIndex);
|
|
223
|
+
let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(noteOps.toNoteIndex);
|
|
224
|
+
|
|
225
|
+
// --- Expand wikilinks inline (only meaningful when content is present) ---
|
|
226
|
+
if (expandCtx && includeContent) {
|
|
227
|
+
// Mark all top-level notes as already expanded so they can't inline each other.
|
|
228
|
+
for (const n of output) expandCtx.expanded.add(n.id);
|
|
229
|
+
for (const n of output) {
|
|
230
|
+
if (typeof n.content === "string") {
|
|
231
|
+
n.content = expandContent(n.content, expandCtx, expandDepth);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
193
235
|
|
|
194
236
|
// --- Apply metadata filtering ---
|
|
195
237
|
if (includeMetadata !== undefined && includeMetadata !== true) {
|
|
@@ -198,12 +240,14 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
198
240
|
|
|
199
241
|
// --- Hydrate links/attachments per note if requested ---
|
|
200
242
|
if (params.include_links || params.include_attachments) {
|
|
201
|
-
|
|
202
|
-
|
|
243
|
+
const enrichedOut: any[] = [];
|
|
244
|
+
for (const n of output as any[]) {
|
|
245
|
+
const enriched: any = { ...n };
|
|
203
246
|
if (params.include_links) enriched.links = linkOps.getLinksHydrated(db, n.id);
|
|
204
|
-
if (params.include_attachments) enriched.attachments = store.getAttachments(n.id);
|
|
205
|
-
|
|
206
|
-
}
|
|
247
|
+
if (params.include_attachments) enriched.attachments = await store.getAttachments(n.id);
|
|
248
|
+
enrichedOut.push(enriched);
|
|
249
|
+
}
|
|
250
|
+
return enrichedOut;
|
|
207
251
|
}
|
|
208
252
|
|
|
209
253
|
return output;
|
|
@@ -256,13 +300,13 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
256
300
|
},
|
|
257
301
|
},
|
|
258
302
|
},
|
|
259
|
-
execute: (params) => {
|
|
303
|
+
execute: async (params) => {
|
|
260
304
|
const batch = params.notes as any[] | undefined;
|
|
261
305
|
const items = batch ?? [params];
|
|
262
306
|
|
|
263
307
|
const created: Note[] = [];
|
|
264
308
|
for (const item of items) {
|
|
265
|
-
const note = store.createNote(item.content as string ?? "", {
|
|
309
|
+
const note = await store.createNote(item.content as string ?? "", {
|
|
266
310
|
path: item.path as string | undefined,
|
|
267
311
|
tags: item.tags as string[] | undefined,
|
|
268
312
|
metadata: item.metadata as Record<string, unknown> | undefined,
|
|
@@ -274,7 +318,7 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
274
318
|
for (const link of item.links as { target: string; relationship: string }[]) {
|
|
275
319
|
const target = resolveNote(db, link.target);
|
|
276
320
|
if (target) {
|
|
277
|
-
store.createLink(note.id, target.id, link.relationship);
|
|
321
|
+
await store.createLink(note.id, target.id, link.relationship);
|
|
278
322
|
}
|
|
279
323
|
}
|
|
280
324
|
}
|
|
@@ -285,7 +329,7 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
285
329
|
// Apply tag schema effects
|
|
286
330
|
for (const note of created) {
|
|
287
331
|
if (note.tags && note.tags.length > 0) {
|
|
288
|
-
applySchemaDefaults(store, db, [note.id], note.tags);
|
|
332
|
+
await applySchemaDefaults(store, db, [note.id], note.tags);
|
|
289
333
|
}
|
|
290
334
|
}
|
|
291
335
|
|
|
@@ -303,7 +347,8 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
303
347
|
- \`tags: { add: ["x"], remove: ["y"] }\` — add/remove tags
|
|
304
348
|
- \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
|
|
305
349
|
- When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
|
|
306
|
-
- For batch: pass a \`notes\` array, each with an \`id\` field
|
|
350
|
+
- For batch: pass a \`notes\` array, each with an \`id\` field.
|
|
351
|
+
- Optimistic concurrency: pass \`if_updated_at\` with the \`updated_at\` value you last read. The update is rejected with a conflict error if the note has changed since. Re-read the note, reconcile, and retry.`,
|
|
307
352
|
inputSchema: {
|
|
308
353
|
type: "object",
|
|
309
354
|
properties: {
|
|
@@ -312,6 +357,7 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
312
357
|
path: { type: "string", description: "New path" },
|
|
313
358
|
metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
|
|
314
359
|
created_at: { type: "string", description: "New created_at timestamp" },
|
|
360
|
+
if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since." },
|
|
315
361
|
tags: {
|
|
316
362
|
type: "object",
|
|
317
363
|
properties: {
|
|
@@ -359,6 +405,7 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
359
405
|
path: { type: "string" },
|
|
360
406
|
metadata: { type: "object" },
|
|
361
407
|
created_at: { type: "string" },
|
|
408
|
+
if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since." },
|
|
362
409
|
tags: { type: "object" },
|
|
363
410
|
links: { type: "object" },
|
|
364
411
|
},
|
|
@@ -368,35 +415,37 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
368
415
|
},
|
|
369
416
|
},
|
|
370
417
|
},
|
|
371
|
-
execute: (params) => {
|
|
418
|
+
execute: async (params) => {
|
|
372
419
|
const batch = params.notes as any[] | undefined;
|
|
373
420
|
const items = batch ?? [params];
|
|
374
421
|
|
|
375
422
|
const updated: Note[] = [];
|
|
376
423
|
for (const item of items) {
|
|
377
424
|
const note = requireNote(db, item.id as string);
|
|
378
|
-
let contentOverride = item.content as string | undefined;
|
|
379
425
|
|
|
380
|
-
// ---
|
|
426
|
+
// --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
|
|
427
|
+
// We compute the cleaned content so we can do the core UPDATE first
|
|
428
|
+
// (with if_updated_at atomically) before any link deletions. If the
|
|
429
|
+
// UPDATE fails on a conflict, nothing has been mutated.
|
|
430
|
+
let contentOverride = item.content as string | undefined;
|
|
381
431
|
const linksRemove = (item.links as any)?.remove as { target: string; relationship: string }[] | undefined;
|
|
432
|
+
const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
|
|
382
433
|
if (linksRemove) {
|
|
383
434
|
for (const link of linksRemove) {
|
|
384
435
|
const target = resolveNote(db, link.target);
|
|
385
|
-
if (target)
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
contentOverride = cleaned;
|
|
393
|
-
}
|
|
436
|
+
if (!target) continue;
|
|
437
|
+
resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
|
|
438
|
+
if (link.relationship === "wikilink" && target.path) {
|
|
439
|
+
const currentContent = contentOverride ?? note.content;
|
|
440
|
+
const cleaned = removeWikilinkBrackets(currentContent, target.path);
|
|
441
|
+
if (cleaned !== currentContent) {
|
|
442
|
+
contentOverride = cleaned;
|
|
394
443
|
}
|
|
395
444
|
}
|
|
396
445
|
}
|
|
397
446
|
}
|
|
398
447
|
|
|
399
|
-
// --- Core update (content, path, metadata, created_at) ---
|
|
448
|
+
// --- Core update (content, path, metadata, created_at + concurrency check) ---
|
|
400
449
|
const updates: any = {};
|
|
401
450
|
if (contentOverride !== undefined) updates.content = contentOverride;
|
|
402
451
|
if (item.path !== undefined) updates.path = item.path;
|
|
@@ -406,22 +455,32 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
406
455
|
updates.metadata = { ...existing, ...(item.metadata as Record<string, unknown>) };
|
|
407
456
|
}
|
|
408
457
|
if (item.created_at !== undefined) updates.created_at = item.created_at;
|
|
458
|
+
if (item.if_updated_at !== undefined) updates.if_updated_at = item.if_updated_at as string;
|
|
409
459
|
|
|
410
460
|
let result: Note;
|
|
411
461
|
if (Object.keys(updates).length > 0) {
|
|
412
|
-
|
|
462
|
+
// store.updateNote routes through noteOps.updateNote, which runs
|
|
463
|
+
// the UPDATE (with optional `AND updated_at IS ?`) atomically and
|
|
464
|
+
// throws ConflictError on mismatch. No mutations have happened
|
|
465
|
+
// yet, so a throw here leaves the note untouched.
|
|
466
|
+
result = await store.updateNote(note.id, updates);
|
|
413
467
|
} else {
|
|
414
468
|
result = note;
|
|
415
469
|
}
|
|
416
470
|
|
|
471
|
+
// --- Remove links (after core UPDATE so a conflict leaves them intact) ---
|
|
472
|
+
for (const { targetId, relationship } of resolvedLinksToRemove) {
|
|
473
|
+
await store.deleteLink(note.id, targetId, relationship);
|
|
474
|
+
}
|
|
475
|
+
|
|
417
476
|
// --- Tags ---
|
|
418
477
|
const tagsOp = item.tags as { add?: string[]; remove?: string[] } | undefined;
|
|
419
478
|
if (tagsOp?.add?.length) {
|
|
420
|
-
store.tagNote(note.id, tagsOp.add);
|
|
421
|
-
applySchemaDefaults(store, db, [note.id], tagsOp.add);
|
|
479
|
+
await store.tagNote(note.id, tagsOp.add);
|
|
480
|
+
await applySchemaDefaults(store, db, [note.id], tagsOp.add);
|
|
422
481
|
}
|
|
423
482
|
if (tagsOp?.remove?.length) {
|
|
424
|
-
store.untagNote(note.id, tagsOp.remove);
|
|
483
|
+
await store.untagNote(note.id, tagsOp.remove);
|
|
425
484
|
}
|
|
426
485
|
|
|
427
486
|
// --- Add links ---
|
|
@@ -430,7 +489,7 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
430
489
|
for (const link of linksAdd) {
|
|
431
490
|
const target = resolveNote(db, link.target);
|
|
432
491
|
if (target) {
|
|
433
|
-
store.createLink(note.id, target.id, link.relationship, link.metadata);
|
|
492
|
+
await store.createLink(note.id, target.id, link.relationship, link.metadata);
|
|
434
493
|
}
|
|
435
494
|
}
|
|
436
495
|
}
|
|
@@ -456,9 +515,9 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
456
515
|
},
|
|
457
516
|
required: ["id"],
|
|
458
517
|
},
|
|
459
|
-
execute: (params) => {
|
|
518
|
+
execute: async (params) => {
|
|
460
519
|
const note = requireNote(db, params.id as string);
|
|
461
|
-
store.deleteNote(note.id);
|
|
520
|
+
await store.deleteNote(note.id);
|
|
462
521
|
return { deleted: true, id: note.id };
|
|
463
522
|
},
|
|
464
523
|
},
|
|
@@ -557,11 +616,11 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
557
616
|
},
|
|
558
617
|
required: ["tag"],
|
|
559
618
|
},
|
|
560
|
-
execute: (params) => {
|
|
619
|
+
execute: async (params) => {
|
|
561
620
|
const tag = params.tag as string;
|
|
562
621
|
// Delete schema first (FK cascade would handle it, but be explicit)
|
|
563
622
|
tagSchemaOps.deleteTagSchema(db, tag);
|
|
564
|
-
return store.deleteTag(tag);
|
|
623
|
+
return await store.deleteTag(tag);
|
|
565
624
|
},
|
|
566
625
|
},
|
|
567
626
|
|
|
@@ -617,7 +676,7 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
617
676
|
// Tag schema effects — auto-populate defaults when tags are applied
|
|
618
677
|
// ---------------------------------------------------------------------------
|
|
619
678
|
|
|
620
|
-
function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): void {
|
|
679
|
+
async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<void> {
|
|
621
680
|
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
622
681
|
if (Object.keys(schemas).length === 0) return;
|
|
623
682
|
|
|
@@ -644,7 +703,7 @@ function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags
|
|
|
644
703
|
}
|
|
645
704
|
}
|
|
646
705
|
if (Object.keys(missing).length === 0) continue;
|
|
647
|
-
store.updateNote(noteId, {
|
|
706
|
+
await store.updateNote(noteId, {
|
|
648
707
|
metadata: { ...existing, ...missing },
|
|
649
708
|
skipUpdatedAt: true,
|
|
650
709
|
});
|
|
@@ -670,3 +729,7 @@ function normalizeTags(tag: unknown): string[] | undefined {
|
|
|
670
729
|
return [tag as string];
|
|
671
730
|
}
|
|
672
731
|
|
|
732
|
+
// Re-exported for backward compat; defined in notes.ts alongside the
|
|
733
|
+
// conditional-UPDATE implementation that raises it.
|
|
734
|
+
export { ConflictError } from "./notes.js";
|
|
735
|
+
|