@openparachute/vault 0.6.0-rc.1 → 0.6.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/.parachute/module.json +14 -3
- package/README.md +32 -7
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +172 -22
- package/core/src/mcp.ts +254 -34
- package/core/src/notes.ts +172 -48
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +87 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/content-range-routes.test.ts +178 -0
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +1331 -110
- package/src/mirror-routes.ts +787 -30
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +763 -122
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +121 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content range / pagination — bounded reads for large notes.
|
|
3
|
+
*
|
|
4
|
+
* MCP responses are size-limited: a 100KB+ transcript can't come back in
|
|
5
|
+
* one `query-notes` call, and a remote MCP client has no `curl | head -c`
|
|
6
|
+
* escape hatch. These helpers let a caller read note content in byte
|
|
7
|
+
* windows:
|
|
8
|
+
*
|
|
9
|
+
* request: `content_offset` (default 0) + `content_length` (byte budget)
|
|
10
|
+
* response: `content` (the slice) + `content_offset` (effective start)
|
|
11
|
+
* + `content_total_length` + `content_next_offset`
|
|
12
|
+
* (`null` when the slice reaches the end)
|
|
13
|
+
*
|
|
14
|
+
* Unit is **UTF-8 bytes** — the same unit as `byteSize` on the lean
|
|
15
|
+
* NoteIndex shape, and the natural unit for budgeting response size. But
|
|
16
|
+
* naive byte-slicing can split a multi-byte codepoint, which would corrupt
|
|
17
|
+
* the JSON string. So slices always end on a codepoint boundary *within*
|
|
18
|
+
* the budget: a slice never exceeds `content_length` bytes but may come up
|
|
19
|
+
* to 3 bytes short when a multi-byte character straddles the cut. A
|
|
20
|
+
* `content_offset` that lands mid-codepoint (only possible when the caller
|
|
21
|
+
* computes offsets by hand — chained `content_next_offset` values are
|
|
22
|
+
* always boundary-aligned) is aligned DOWN to the codepoint's leading byte
|
|
23
|
+
* so no bytes are ever skipped; the effective start is echoed back as
|
|
24
|
+
* `content_offset` on the response.
|
|
25
|
+
*
|
|
26
|
+
* Reassembly invariant (pinned by content-range.test.ts): starting at
|
|
27
|
+
* offset 0 and following `content_next_offset` until `null`, the
|
|
28
|
+
* concatenation of slices is byte-identical to the full content.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { QueryError } from "./query-operators.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Minimum accepted `content_length`. A UTF-8 codepoint is at most 4 bytes,
|
|
35
|
+
* so any budget >= 4 is guaranteed to make progress (the codepoint at the
|
|
36
|
+
* window start always fits). Budgets 1–3 could stall forever on a 4-byte
|
|
37
|
+
* emoji (empty slice, next_offset == offset); rejecting them up front is
|
|
38
|
+
* deterministic and simpler than a runtime "no progress" error.
|
|
39
|
+
*/
|
|
40
|
+
export const MIN_CONTENT_LENGTH = 4;
|
|
41
|
+
|
|
42
|
+
export interface ContentRange {
|
|
43
|
+
/** Byte offset (UTF-8) to start reading from. */
|
|
44
|
+
offset: number;
|
|
45
|
+
/** Max bytes to return. Absent = read to the end. */
|
|
46
|
+
length?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ContentRangeFields {
|
|
50
|
+
content: string;
|
|
51
|
+
/** Effective start (requested offset aligned down to a codepoint boundary). */
|
|
52
|
+
content_offset: number;
|
|
53
|
+
/** Full content size in UTF-8 bytes. */
|
|
54
|
+
content_total_length: number;
|
|
55
|
+
/** Byte offset to resume from, or null when the slice reaches the end. */
|
|
56
|
+
content_next_offset: number | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toNonNegativeInt(raw: unknown, name: string): number | undefined {
|
|
60
|
+
if (raw === undefined || raw === null) return undefined;
|
|
61
|
+
let n: number;
|
|
62
|
+
if (typeof raw === "number") {
|
|
63
|
+
n = raw;
|
|
64
|
+
} else if (typeof raw === "string") {
|
|
65
|
+
if (raw.trim() === "") return undefined; // `?content_offset=` — treat empty as absent
|
|
66
|
+
if (!/^\d+$/.test(raw.trim())) {
|
|
67
|
+
throw new QueryError(
|
|
68
|
+
`invalid \`${name}\` value ${JSON.stringify(raw)} — must be a non-negative integer (UTF-8 byte count).`,
|
|
69
|
+
"INVALID_QUERY",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
n = Number(raw.trim());
|
|
73
|
+
} else {
|
|
74
|
+
throw new QueryError(
|
|
75
|
+
`invalid \`${name}\` value — must be a non-negative integer (UTF-8 byte count).`,
|
|
76
|
+
"INVALID_QUERY",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!Number.isSafeInteger(n) || n < 0) {
|
|
80
|
+
throw new QueryError(
|
|
81
|
+
`invalid \`${name}\` value ${JSON.stringify(raw)} — must be a non-negative integer (UTF-8 byte count).`,
|
|
82
|
+
"INVALID_QUERY",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return n;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse the `content_offset` / `content_length` pair. Returns `null` when
|
|
90
|
+
* neither is present (range mode off — response shape byte-identical to
|
|
91
|
+
* the no-pagination behavior). Throws `QueryError` (INVALID_QUERY) on
|
|
92
|
+
* negative / non-integer values or a `content_length` below
|
|
93
|
+
* {@link MIN_CONTENT_LENGTH}.
|
|
94
|
+
*
|
|
95
|
+
* Accepts numbers (MCP params) and decimal strings (REST query params);
|
|
96
|
+
* empty strings count as absent.
|
|
97
|
+
*/
|
|
98
|
+
export function parseContentRange(offsetRaw: unknown, lengthRaw: unknown): ContentRange | null {
|
|
99
|
+
const offset = toNonNegativeInt(offsetRaw, "content_offset");
|
|
100
|
+
const length = toNonNegativeInt(lengthRaw, "content_length");
|
|
101
|
+
if (offset === undefined && length === undefined) return null;
|
|
102
|
+
if (length !== undefined && length < MIN_CONTENT_LENGTH) {
|
|
103
|
+
throw new QueryError(
|
|
104
|
+
`invalid \`content_length\` value ${JSON.stringify(lengthRaw)} — must be at least ${MIN_CONTENT_LENGTH} bytes (the size of the largest UTF-8 codepoint, so every window makes progress).`,
|
|
105
|
+
"INVALID_QUERY",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return { offset: offset ?? 0, ...(length !== undefined ? { length } : {}) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The error both faces raise when range params are combined with a
|
|
113
|
+
* response shape that excludes content (`include_content=false`, or a
|
|
114
|
+
* list query left on its lean default). Centralized so MCP and REST emit
|
|
115
|
+
* the same message.
|
|
116
|
+
*/
|
|
117
|
+
export function contentRangeRequiresContent(): QueryError {
|
|
118
|
+
return new QueryError(
|
|
119
|
+
`content_offset/content_length apply to note content, but content is not included in this response shape. Pass include_content=true (lists default to false) or drop the range params.`,
|
|
120
|
+
"INVALID_QUERY",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** True for UTF-8 continuation bytes (0b10xxxxxx). */
|
|
125
|
+
function isContinuationByte(b: number): boolean {
|
|
126
|
+
return (b & 0xc0) === 0x80;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Slice `content` to the requested byte window, never splitting a UTF-8
|
|
131
|
+
* codepoint and never exceeding `range.length` bytes. See module doc for
|
|
132
|
+
* the alignment rules.
|
|
133
|
+
*/
|
|
134
|
+
export function sliceContentRange(content: string, range: ContentRange): ContentRangeFields {
|
|
135
|
+
const bytes = Buffer.from(content, "utf8");
|
|
136
|
+
const total = bytes.byteLength;
|
|
137
|
+
|
|
138
|
+
// At/past the end: empty slice, complete. Graceful (not an error) so a
|
|
139
|
+
// pagination loop that overshoots — e.g. the note shrank between calls —
|
|
140
|
+
// terminates cleanly on `content_next_offset: null`.
|
|
141
|
+
if (range.offset >= total) {
|
|
142
|
+
return {
|
|
143
|
+
content: "",
|
|
144
|
+
content_offset: total,
|
|
145
|
+
content_total_length: total,
|
|
146
|
+
content_next_offset: null,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Align the start DOWN to the leading byte of the codepoint containing
|
|
151
|
+
// `offset` — never skip bytes (a forward-align would drop them from
|
|
152
|
+
// every window and break reassembly).
|
|
153
|
+
let start = range.offset;
|
|
154
|
+
while (start > 0 && isContinuationByte(bytes[start]!)) start--;
|
|
155
|
+
|
|
156
|
+
// Window end: budget capped at total. Align DOWN so the slice doesn't
|
|
157
|
+
// end mid-codepoint — under the budget, never over.
|
|
158
|
+
let end = range.length === undefined ? total : Math.min(start + range.length, total);
|
|
159
|
+
while (end > start && end < total && isContinuationByte(bytes[end]!)) end--;
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
content: bytes.subarray(start, end).toString("utf8"),
|
|
163
|
+
content_offset: start,
|
|
164
|
+
content_total_length: total,
|
|
165
|
+
content_next_offset: end >= total ? null : end,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Apply a content range to a shaped response object in place: replaces
|
|
171
|
+
* `content` with the slice and adds `content_offset`,
|
|
172
|
+
* `content_total_length`, `content_next_offset`. No-op fields are never
|
|
173
|
+
* added when range mode is off — callers only invoke this with a parsed
|
|
174
|
+
* (non-null) range.
|
|
175
|
+
*/
|
|
176
|
+
export function applyContentRange(
|
|
177
|
+
result: { content?: unknown; [key: string]: unknown },
|
|
178
|
+
range: ContentRange,
|
|
179
|
+
): void {
|
|
180
|
+
const fields = sliceContentRange(typeof result.content === "string" ? result.content : "", range);
|
|
181
|
+
result.content = fields.content;
|
|
182
|
+
result.content_offset = fields.content_offset;
|
|
183
|
+
result.content_total_length = fields.content_total_length;
|
|
184
|
+
result.content_next_offset = fields.content_next_offset;
|
|
185
|
+
}
|
package/core/src/core.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { SqliteStore } from "./store.js";
|
|
|
4
4
|
import { generateMcpTools } from "./mcp.js";
|
|
5
5
|
import { initSchema } from "./schema.js";
|
|
6
6
|
import { decodeCursor } from "./cursor.js";
|
|
7
|
+
import { traverseLinks } from "./links.js";
|
|
7
8
|
|
|
8
9
|
let store: SqliteStore;
|
|
9
10
|
let db: Database;
|
|
@@ -972,6 +973,33 @@ describe("vault stats", async () => {
|
|
|
972
973
|
expect(stats.topTags).toEqual([]);
|
|
973
974
|
expect(stats.tagCount).toBe(0);
|
|
974
975
|
expect(stats.linkCount).toBe(0);
|
|
976
|
+
expect(stats.contentBytes).toBe(0);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it("contentBytes sums ASCII content as raw byte length", async () => {
|
|
980
|
+
// "hello" = 5 bytes, "world!" = 6 bytes — for pure ASCII, bytes == chars.
|
|
981
|
+
await store.createNote("hello");
|
|
982
|
+
await store.createNote("world!");
|
|
983
|
+
const stats = await store.getVaultStats();
|
|
984
|
+
expect(stats.contentBytes).toBe(11);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it("contentBytes counts UTF-8 BYTES, not characters (multibyte)", async () => {
|
|
988
|
+
// The whole point of CAST(content AS BLOB): SQLite's bare LENGTH() would
|
|
989
|
+
// return the CHARACTER count, undercounting multibyte content.
|
|
990
|
+
// - "é" is U+00E9 → 2 bytes in UTF-8 (1 char)
|
|
991
|
+
// - "你好" is 2 CJK chars → 6 bytes (3 bytes each)
|
|
992
|
+
// - "😀" is 1 grapheme / 2 UTF-16 code units → 4 bytes in UTF-8
|
|
993
|
+
const content = "é你好😀";
|
|
994
|
+
const expectedBytes = Buffer.byteLength(content, "utf8"); // 2 + 6 + 4 = 12
|
|
995
|
+
expect(expectedBytes).toBe(12);
|
|
996
|
+
// And it's strictly MORE than the JS character count — proves we're not
|
|
997
|
+
// accidentally counting chars.
|
|
998
|
+
expect(expectedBytes).toBeGreaterThan([...content].length);
|
|
999
|
+
|
|
1000
|
+
await store.createNote(content);
|
|
1001
|
+
const stats = await store.getVaultStats();
|
|
1002
|
+
expect(stats.contentBytes).toBe(expectedBytes);
|
|
975
1003
|
});
|
|
976
1004
|
|
|
977
1005
|
it("counts total notes and tagCount", async () => {
|
|
@@ -1041,6 +1069,7 @@ describe("vault stats", async () => {
|
|
|
1041
1069
|
expect(stats).toHaveProperty("topTags");
|
|
1042
1070
|
expect(stats).toHaveProperty("tagCount");
|
|
1043
1071
|
expect(stats).toHaveProperty("linkCount");
|
|
1072
|
+
expect(stats).toHaveProperty("contentBytes");
|
|
1044
1073
|
});
|
|
1045
1074
|
|
|
1046
1075
|
it("counts resolved wikilinks in linkCount", async () => {
|
|
@@ -1866,6 +1895,59 @@ describe("links", async () => {
|
|
|
1866
1895
|
const links = await store.getLinks("a");
|
|
1867
1896
|
expect(links.filter((l) => l.relationship === "mentions")).toHaveLength(1);
|
|
1868
1897
|
});
|
|
1898
|
+
|
|
1899
|
+
// vault#439 — traverseLinks isTraversable predicate (wall, not sieve).
|
|
1900
|
+
// Topology: a -> b(blocked) -> c. A predicate that blocks `b` must make
|
|
1901
|
+
// `c` unreachable (the BFS can't walk THROUGH b), not merely filtered out.
|
|
1902
|
+
it("traverseLinks: isTraversable predicate is a wall (can't reach past a blocked hop)", async () => {
|
|
1903
|
+
await store.createNote("A", { id: "a" });
|
|
1904
|
+
await store.createNote("B", { id: "b" });
|
|
1905
|
+
await store.createNote("C", { id: "c" });
|
|
1906
|
+
await store.createLink("a", "b", "relates");
|
|
1907
|
+
await store.createLink("b", "c", "relates");
|
|
1908
|
+
|
|
1909
|
+
const blocked = traverseLinks(db, "a", {
|
|
1910
|
+
max_depth: 5,
|
|
1911
|
+
isTraversable: (id) => id !== "b",
|
|
1912
|
+
});
|
|
1913
|
+
const ids = blocked.map((t) => t.noteId);
|
|
1914
|
+
expect(ids).not.toContain("b"); // blocked hop is excluded from results
|
|
1915
|
+
expect(ids).not.toContain("c"); // and unreachable beyond it
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
it("traverseLinks: no predicate walks the full graph (unchanged)", async () => {
|
|
1919
|
+
await store.createNote("A", { id: "a" });
|
|
1920
|
+
await store.createNote("B", { id: "b" });
|
|
1921
|
+
await store.createNote("C", { id: "c" });
|
|
1922
|
+
await store.createLink("a", "b", "relates");
|
|
1923
|
+
await store.createLink("b", "c", "relates");
|
|
1924
|
+
|
|
1925
|
+
const all = traverseLinks(db, "a", { max_depth: 5 });
|
|
1926
|
+
const ids = all.map((t) => t.noteId);
|
|
1927
|
+
expect(ids).toContain("b");
|
|
1928
|
+
expect(ids).toContain("c");
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
it("traverseLinks: an allowed alternate path still reaches the far node", async () => {
|
|
1932
|
+
// a -> b(blocked) -> d ; a -> c(allowed) -> d. d reachable via c.
|
|
1933
|
+
await store.createNote("A", { id: "a" });
|
|
1934
|
+
await store.createNote("B", { id: "b" });
|
|
1935
|
+
await store.createNote("C", { id: "c" });
|
|
1936
|
+
await store.createNote("D", { id: "d" });
|
|
1937
|
+
await store.createLink("a", "b", "relates");
|
|
1938
|
+
await store.createLink("b", "d", "relates");
|
|
1939
|
+
await store.createLink("a", "c", "relates");
|
|
1940
|
+
await store.createLink("c", "d", "relates");
|
|
1941
|
+
|
|
1942
|
+
const res = traverseLinks(db, "a", {
|
|
1943
|
+
max_depth: 5,
|
|
1944
|
+
isTraversable: (id) => id !== "b",
|
|
1945
|
+
});
|
|
1946
|
+
const ids = res.map((t) => t.noteId);
|
|
1947
|
+
expect(ids).not.toContain("b");
|
|
1948
|
+
expect(ids).toContain("c");
|
|
1949
|
+
expect(ids).toContain("d"); // reachable via the allowed c-path
|
|
1950
|
+
});
|
|
1869
1951
|
});
|
|
1870
1952
|
|
|
1871
1953
|
// ---- Attachments ----
|
|
@@ -1985,6 +2067,41 @@ describe("MCP tools", async () => {
|
|
|
1985
2067
|
expect(result[1].tags).toContain("doc");
|
|
1986
2068
|
});
|
|
1987
2069
|
|
|
2070
|
+
// vault#316 — the create-note tool re-reads each note AFTER
|
|
2071
|
+
// `applySchemaDefaults` runs, so the response reflects the post-defaults
|
|
2072
|
+
// on-disk state (matching the update-note path). Before the fix the
|
|
2073
|
+
// response mapped over the pre-defaults in-memory objects, so a
|
|
2074
|
+
// schema-default-filled field was missing from the returned note even
|
|
2075
|
+
// though it had just been written to disk.
|
|
2076
|
+
it("create-note response reflects post-applySchemaDefaults state (vault#316)", async () => {
|
|
2077
|
+
await store.upsertTagSchema("task", {
|
|
2078
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
2079
|
+
});
|
|
2080
|
+
const tools = generateMcpTools(store);
|
|
2081
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2082
|
+
|
|
2083
|
+
// Single: default lands in the returned metadata.
|
|
2084
|
+
const single = await createNote.execute({
|
|
2085
|
+
content: "do the thing",
|
|
2086
|
+
path: "Inbox/task-1",
|
|
2087
|
+
tags: ["task"],
|
|
2088
|
+
}) as any;
|
|
2089
|
+
expect(single.metadata?.priority).toBe("high"); // first enum value
|
|
2090
|
+
// Disk and response agree.
|
|
2091
|
+
const onDisk = await store.getNoteByPath("Inbox/task-1");
|
|
2092
|
+
expect((onDisk!.metadata as any)?.priority).toBe("high");
|
|
2093
|
+
|
|
2094
|
+
// Batch: each entry is re-read post-defaults too.
|
|
2095
|
+
const batch = await createNote.execute({
|
|
2096
|
+
notes: [
|
|
2097
|
+
{ content: "a", path: "Inbox/task-2", tags: ["task"] },
|
|
2098
|
+
{ content: "b", path: "Inbox/task-3", tags: ["task"] },
|
|
2099
|
+
],
|
|
2100
|
+
}) as any[];
|
|
2101
|
+
expect(batch[0].metadata?.priority).toBe("high");
|
|
2102
|
+
expect(batch[1].metadata?.priority).toBe("high");
|
|
2103
|
+
});
|
|
2104
|
+
|
|
1988
2105
|
it("create-note accepts extension field (vault#328)", async () => {
|
|
1989
2106
|
const tools = generateMcpTools(store);
|
|
1990
2107
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
@@ -2131,6 +2248,113 @@ describe("MCP tools", async () => {
|
|
|
2131
2248
|
expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
|
|
2132
2249
|
});
|
|
2133
2250
|
|
|
2251
|
+
// vault feedback #8 — echo hydrated links on the update response when the
|
|
2252
|
+
// request mutated links OR `include_links` is set, so callers don't re-query.
|
|
2253
|
+
it("update-note echoes hydrated links when the update mutates links", async () => {
|
|
2254
|
+
await store.createNote("A", { id: "a" });
|
|
2255
|
+
await store.createNote("B", { id: "b", path: "beta", tags: ["t"] });
|
|
2256
|
+
const tools = generateMcpTools(store);
|
|
2257
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2258
|
+
const result = await updateNote.execute({
|
|
2259
|
+
id: "a",
|
|
2260
|
+
links: { add: [{ target: "b", relationship: "mentions" }] },
|
|
2261
|
+
force: true,
|
|
2262
|
+
}) as any;
|
|
2263
|
+
expect(Array.isArray(result.links)).toBe(true);
|
|
2264
|
+
expect(result.links).toHaveLength(1);
|
|
2265
|
+
// Hydrated shape matches query-notes' include_links output.
|
|
2266
|
+
const link = result.links[0];
|
|
2267
|
+
expect(link.sourceId).toBe("a");
|
|
2268
|
+
expect(link.targetId).toBe("b");
|
|
2269
|
+
expect(link.relationship).toBe("mentions");
|
|
2270
|
+
expect(link.targetNote.path).toBe("beta");
|
|
2271
|
+
expect(link.targetNote.tags).toEqual(["t"]);
|
|
2272
|
+
});
|
|
2273
|
+
|
|
2274
|
+
it("update-note echoes links on removal (post-removal set)", async () => {
|
|
2275
|
+
await store.createNote("A", { id: "a" });
|
|
2276
|
+
await store.createNote("B", { id: "b" });
|
|
2277
|
+
await store.createNote("C", { id: "c" });
|
|
2278
|
+
await store.createLink("a", "b", "mentions");
|
|
2279
|
+
await store.createLink("a", "c", "mentions");
|
|
2280
|
+
const tools = generateMcpTools(store);
|
|
2281
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2282
|
+
const result = await updateNote.execute({
|
|
2283
|
+
id: "a",
|
|
2284
|
+
links: { remove: [{ target: "b", relationship: "mentions" }] },
|
|
2285
|
+
force: true,
|
|
2286
|
+
}) as any;
|
|
2287
|
+
expect(result.links).toHaveLength(1);
|
|
2288
|
+
expect(result.links[0].targetId).toBe("c");
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
it("update-note with include_links echoes links even without a mutation", async () => {
|
|
2292
|
+
await store.createNote("A", { id: "a" });
|
|
2293
|
+
await store.createNote("B", { id: "b" });
|
|
2294
|
+
await store.createLink("a", "b", "mentions");
|
|
2295
|
+
const tools = generateMcpTools(store);
|
|
2296
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2297
|
+
const result = await updateNote.execute({
|
|
2298
|
+
id: "a",
|
|
2299
|
+
content: "updated",
|
|
2300
|
+
include_links: true,
|
|
2301
|
+
force: true,
|
|
2302
|
+
}) as any;
|
|
2303
|
+
expect(result.content).toBe("updated");
|
|
2304
|
+
expect(result.links).toHaveLength(1);
|
|
2305
|
+
expect(result.links[0].targetId).toBe("b");
|
|
2306
|
+
});
|
|
2307
|
+
|
|
2308
|
+
it("update-note without a link mutation or flag does NOT echo links", async () => {
|
|
2309
|
+
await store.createNote("A", { id: "a" });
|
|
2310
|
+
await store.createNote("B", { id: "b" });
|
|
2311
|
+
await store.createLink("a", "b", "mentions");
|
|
2312
|
+
const tools = generateMcpTools(store);
|
|
2313
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2314
|
+
const result = await updateNote.execute({ id: "a", content: "updated", force: true }) as any;
|
|
2315
|
+
expect(result.content).toBe("updated");
|
|
2316
|
+
expect(result).not.toHaveProperty("links");
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
it("update-note batch echoes links per-item (only on the mutated/flagged item)", async () => {
|
|
2320
|
+
await store.createNote("A", { id: "a" });
|
|
2321
|
+
await store.createNote("B", { id: "b" });
|
|
2322
|
+
await store.createNote("C", { id: "c" });
|
|
2323
|
+
const tools = generateMcpTools(store);
|
|
2324
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2325
|
+
const result = await updateNote.execute({
|
|
2326
|
+
notes: [
|
|
2327
|
+
// Item 0 mutates links → echoes.
|
|
2328
|
+
{ id: "a", links: { add: [{ target: "b", relationship: "mentions" }] }, force: true },
|
|
2329
|
+
// Item 1 only edits content, no flag → no links key.
|
|
2330
|
+
{ id: "c", content: "C updated", force: true },
|
|
2331
|
+
],
|
|
2332
|
+
}) as any[];
|
|
2333
|
+
expect(result).toHaveLength(2);
|
|
2334
|
+
expect(result[0].links).toHaveLength(1);
|
|
2335
|
+
expect(result[0].links[0].targetId).toBe("b");
|
|
2336
|
+
expect(result[1]).not.toHaveProperty("links");
|
|
2337
|
+
});
|
|
2338
|
+
|
|
2339
|
+
it("update-note if_missing=create with include_links echoes links (no link mutation)", async () => {
|
|
2340
|
+
const tools = generateMcpTools(store);
|
|
2341
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2342
|
+
// The created note has no links yet and the payload declares none, so the
|
|
2343
|
+
// echo is driven purely by the explicit `include_links` flag — closing the
|
|
2344
|
+
// create-on-missing × flag-only matrix gap. Hydrated `links` key is present
|
|
2345
|
+
// (empty array), not absent.
|
|
2346
|
+
const result = await updateNote.execute({
|
|
2347
|
+
id: "fresh-note",
|
|
2348
|
+
content: "brand new",
|
|
2349
|
+
if_missing: "create",
|
|
2350
|
+
include_links: true,
|
|
2351
|
+
}) as any;
|
|
2352
|
+
expect(result.created).toBe(true);
|
|
2353
|
+
expect(result.content).toBe("brand new");
|
|
2354
|
+
expect(Array.isArray(result.links)).toBe(true);
|
|
2355
|
+
expect(result.links).toHaveLength(0);
|
|
2356
|
+
});
|
|
2357
|
+
|
|
2134
2358
|
it("update-note removes wikilink brackets when removing wikilink-type link", async () => {
|
|
2135
2359
|
await store.createNote("Target", { id: "target", path: "People/Alice" });
|
|
2136
2360
|
const source = await store.createNote("See [[People/Alice]] for details", { id: "source" });
|
|
@@ -4932,43 +5156,65 @@ describe("tag record API (patterns/tag-data-model.md)", async () => {
|
|
|
4932
5156
|
expect(idxZebra).toBeGreaterThan(idxAlpha);
|
|
4933
5157
|
});
|
|
4934
5158
|
|
|
4935
|
-
|
|
5159
|
+
// ---- relationships is an opaque vocabulary map (vault#428) ----
|
|
5160
|
+
// Vault no longer enforces the historical { target_tag, cardinality }
|
|
5161
|
+
// shape. Apps (the Weaver / structural-link picker) declare a freeform
|
|
5162
|
+
// vocabulary; vault stores + returns it verbatim. The old typed shape is
|
|
5163
|
+
// still a valid value, so the loosening is a backwards-compatible superset.
|
|
5164
|
+
|
|
5165
|
+
it("update-tag MCP persists the opaque relationship-vocabulary map verbatim (vault#428)", async () => {
|
|
4936
5166
|
const tools = generateMcpTools(store);
|
|
4937
5167
|
const update = tools.find((t) => t.name === "update-tag")!;
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
5168
|
+
const vocab = {
|
|
5169
|
+
"works-on": { from: "person", to: "project" },
|
|
5170
|
+
"member-of": { from: "person", to: "organization" },
|
|
5171
|
+
"partner-of": { from: "person", to: "person" },
|
|
5172
|
+
"based-at": { from: "project", to: "place" },
|
|
5173
|
+
};
|
|
5174
|
+
await update.execute({ tag: "person", relationships: vocab });
|
|
5175
|
+
const r = await store.getTagRecord("person");
|
|
5176
|
+
expect(r?.relationships).toEqual(vocab);
|
|
4946
5177
|
});
|
|
4947
5178
|
|
|
4948
|
-
it("update-tag MCP
|
|
5179
|
+
it("update-tag MCP round-trips nested arbitrary relationship values verbatim", async () => {
|
|
4949
5180
|
const tools = generateMcpTools(store);
|
|
4950
5181
|
const update = tools.find((t) => t.name === "update-tag")!;
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
5182
|
+
const vocab = {
|
|
5183
|
+
rel: { from: "a", to: "b", note: "freeform", weight: 3, optional: true, tags: ["x", "y"] },
|
|
5184
|
+
};
|
|
5185
|
+
await update.execute({ tag: "thing", relationships: vocab });
|
|
5186
|
+
const r = await store.getTagRecord("thing");
|
|
5187
|
+
expect(r?.relationships).toEqual(vocab);
|
|
5188
|
+
});
|
|
5189
|
+
|
|
5190
|
+
it("update-tag MCP still accepts the historical typed shape (backwards-compat)", async () => {
|
|
5191
|
+
const tools = generateMcpTools(store);
|
|
5192
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
5193
|
+
const typed = { owned_by: { target_tag: "person", cardinality: "one", description: "DRI" } };
|
|
5194
|
+
await update.execute({ tag: "project", relationships: typed });
|
|
5195
|
+
const r = await store.getTagRecord("project");
|
|
5196
|
+
expect(r?.relationships).toEqual(typed);
|
|
5197
|
+
});
|
|
5198
|
+
|
|
5199
|
+
it("update-tag MCP accepts what used to be rejected (no inner-shape enforcement)", async () => {
|
|
5200
|
+
const tools = generateMcpTools(store);
|
|
5201
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
5202
|
+
// Formerly rejected: non-vocabulary cardinality + missing target_tag.
|
|
5203
|
+
const formerlyInvalid = {
|
|
5204
|
+
a: { target_tag: "person", cardinality: "bogus" },
|
|
5205
|
+
b: { cardinality: "one" },
|
|
5206
|
+
};
|
|
5207
|
+
await update.execute({ tag: "loose", relationships: formerlyInvalid });
|
|
5208
|
+
const r = await store.getTagRecord("loose");
|
|
5209
|
+
expect(r?.relationships).toEqual(formerlyInvalid);
|
|
4961
5210
|
});
|
|
4962
5211
|
|
|
4963
|
-
it("update-tag MCP rejects a
|
|
5212
|
+
it("update-tag MCP rejects a top-level array for relationships (must be a map)", async () => {
|
|
4964
5213
|
const tools = generateMcpTools(store);
|
|
4965
5214
|
const update = tools.find((t) => t.name === "update-tag")!;
|
|
4966
5215
|
await expect(
|
|
4967
|
-
update.execute({
|
|
4968
|
-
|
|
4969
|
-
relationships: { owned_by: { cardinality: "one" } },
|
|
4970
|
-
}),
|
|
4971
|
-
).rejects.toThrow(/target_tag/);
|
|
5216
|
+
update.execute({ tag: "project", relationships: ["not", "a", "map"] as unknown as Record<string, unknown> }),
|
|
5217
|
+
).rejects.toThrow();
|
|
4972
5218
|
});
|
|
4973
5219
|
|
|
4974
5220
|
it("update-tag MCP sets parent_names and the hierarchy invalidates", async () => {
|
|
@@ -5609,6 +5855,13 @@ describe("vault projection (vault#271)", async () => {
|
|
|
5609
5855
|
expect(md).toContain("#person");
|
|
5610
5856
|
expect(md).toContain("vault-info");
|
|
5611
5857
|
expect(md).toContain("list-tags { include_schema: true }");
|
|
5858
|
+
// Scripting pointer (closes the "points nowhere" gap): the brief routes an
|
|
5859
|
+
// agent to the HTTP API + the public guide, with the vault name baked into
|
|
5860
|
+
// the copy-paste mint command.
|
|
5861
|
+
expect(md).toContain("## Scripting & automation (beyond this session)");
|
|
5862
|
+
expect(md).toContain("https://parachute.computer/scripting/");
|
|
5863
|
+
expect(md).toContain("parachute auth mint-token --scope vault:test:read --ephemeral");
|
|
5864
|
+
expect(md).toContain("vault/test/api");
|
|
5612
5865
|
});
|
|
5613
5866
|
|
|
5614
5867
|
it("markdown brief degrades gracefully when no schemas declared", async () => {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { initSchema } from "./schema.js";
|
|
4
|
+
import { createNote } from "./notes.js";
|
|
5
|
+
import { expandContent, type ExpandContext } from "./expand.js";
|
|
6
|
+
import type { Note } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Security regression (vault security review — tag-scope confidentiality).
|
|
10
|
+
*
|
|
11
|
+
* `expandContent`'s optional `isVisible` predicate is the seam that keeps
|
|
12
|
+
* core scope-unaware while letting the server enforce tag scope during
|
|
13
|
+
* wikilink inlining. These tests pin the load-bearing invariant: when the
|
|
14
|
+
* predicate rejects a target, the wikilink is left UNRESOLVED — byte-for-byte
|
|
15
|
+
* identical to a genuinely-missing target, so the response can't reveal that
|
|
16
|
+
* the out-of-scope note exists. They MUST fail if the predicate is removed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
let db: Database;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
db = new Database(":memory:");
|
|
23
|
+
initSchema(db);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function ctx(over: Partial<ExpandContext> = {}): ExpandContext {
|
|
27
|
+
return { db, mode: "full", expanded: new Set<string>(), ...over };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("expandContent isVisible predicate (tag-scope confidentiality)", () => {
|
|
31
|
+
it("inlines an in-scope target's content when no predicate is set (baseline)", () => {
|
|
32
|
+
createNote(db, "SECRET BODY", { path: "Secret", tags: ["personal"] });
|
|
33
|
+
const out = expandContent("see [[Secret]]", ctx(), 1);
|
|
34
|
+
expect(out).toContain("SECRET BODY");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("leaves an out-of-scope wikilink unresolved — no content inlined", () => {
|
|
38
|
+
createNote(db, "SECRET BODY", { path: "Secret", tags: ["personal"] });
|
|
39
|
+
// Predicate: only #work notes are visible. The target is #personal.
|
|
40
|
+
const isVisible = (n: Note) => (n.tags ?? []).includes("work");
|
|
41
|
+
const out = expandContent("see [[Secret]]", ctx({ isVisible }), 1);
|
|
42
|
+
expect(out).not.toContain("SECRET BODY");
|
|
43
|
+
// The wikilink stays literal.
|
|
44
|
+
expect(out).toBe("see [[Secret]]");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("out-of-scope target is INDISTINGUISHABLE from a missing target", () => {
|
|
48
|
+
// Case A: target exists but is out of scope.
|
|
49
|
+
createNote(db, "SECRET BODY", { path: "Secret", tags: ["personal"] });
|
|
50
|
+
const isVisible = (n: Note) => (n.tags ?? []).includes("work");
|
|
51
|
+
const outOfScope = expandContent("see [[Secret]]", ctx({ isVisible }), 1);
|
|
52
|
+
|
|
53
|
+
// Case B: target genuinely does not exist (fresh db, same predicate).
|
|
54
|
+
const db2 = new Database(":memory:");
|
|
55
|
+
initSchema(db2);
|
|
56
|
+
const missing = expandContent("see [[Secret]]", { db: db2, mode: "full", expanded: new Set(), isVisible }, 1);
|
|
57
|
+
|
|
58
|
+
// Byte-identical output → existence is not leaked.
|
|
59
|
+
expect(outOfScope).toBe(missing);
|
|
60
|
+
expect(outOfScope).toBe("see [[Secret]]");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("a second reference to an out-of-scope note does NOT render `(expanded above)`", () => {
|
|
64
|
+
// Pins that an out-of-scope note never enters the `expanded` set — else a
|
|
65
|
+
// repeat reference would render `(expanded above)` and leak existence via
|
|
66
|
+
// a different output than a missing target.
|
|
67
|
+
createNote(db, "SECRET BODY", { path: "Secret", tags: ["personal"] });
|
|
68
|
+
const isVisible = (n: Note) => (n.tags ?? []).includes("work");
|
|
69
|
+
const out = expandContent("[[Secret]] and again [[Secret]]", ctx({ isVisible }), 1);
|
|
70
|
+
expect(out).toBe("[[Secret]] and again [[Secret]]");
|
|
71
|
+
expect(out).not.toContain("expanded above");
|
|
72
|
+
expect(out).not.toContain("SECRET BODY");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("multi-hop (depth>1) does not leak out-of-scope content at any depth", () => {
|
|
76
|
+
// in-scope #work note → wikilinks an out-of-scope #personal note.
|
|
77
|
+
createNote(db, "DEEP SECRET", { path: "Deep", tags: ["personal"] });
|
|
78
|
+
createNote(db, "intro [[Deep]]", { path: "Mid", tags: ["work"] });
|
|
79
|
+
createNote(db, "top [[Mid]]", { path: "Top", tags: ["work"] });
|
|
80
|
+
|
|
81
|
+
const isVisible = (n: Note) => (n.tags ?? []).includes("work");
|
|
82
|
+
// Depth 3 — would walk Top → Mid → Deep without the predicate.
|
|
83
|
+
const out = expandContent("[[Top]]", ctx({ isVisible }), 3);
|
|
84
|
+
// The in-scope Mid IS inlined; the out-of-scope Deep is NOT.
|
|
85
|
+
expect(out).toContain("intro");
|
|
86
|
+
expect(out).not.toContain("DEEP SECRET");
|
|
87
|
+
// The Deep wikilink stays literal inside Mid's inlined content.
|
|
88
|
+
expect(out).toContain("[[Deep]]");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("predicate is also honored in summary mode", () => {
|
|
92
|
+
createNote(db, "x", {
|
|
93
|
+
path: "Secret",
|
|
94
|
+
tags: ["personal"],
|
|
95
|
+
metadata: { summary: "SECRET SUMMARY" },
|
|
96
|
+
});
|
|
97
|
+
const isVisible = (n: Note) => (n.tags ?? []).includes("work");
|
|
98
|
+
const out = expandContent("see [[Secret]]", ctx({ mode: "summary", isVisible }), 1);
|
|
99
|
+
expect(out).not.toContain("SECRET SUMMARY");
|
|
100
|
+
expect(out).toBe("see [[Secret]]");
|
|
101
|
+
});
|
|
102
|
+
});
|