@openparachute/vault 0.6.0-rc.1 → 0.6.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/.parachute/module.json +14 -3
- package/README.md +7 -7
- 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 +97 -2
- package/core/src/mcp.ts +201 -33
- package/core/src/notes.ts +44 -8
- 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/schema.ts +58 -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/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/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-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 +463 -1
- package/src/mirror-routes.ts +474 -4
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +696 -121
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +113 -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-CGL256oe.js +60 -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
package/core/src/mcp.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Store, Note } from "./types.js";
|
|
|
3
3
|
import * as noteOps from "./notes.js";
|
|
4
4
|
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
5
5
|
import { QueryError } from "./query-operators.js";
|
|
6
|
+
import { TAG_EXPAND_MODES, type TagExpandMode } from "./tag-hierarchy.js";
|
|
6
7
|
import * as linkOps from "./links.js";
|
|
7
8
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
8
9
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
@@ -99,13 +100,41 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
|
|
|
99
100
|
// Tool generation
|
|
100
101
|
// ---------------------------------------------------------------------------
|
|
101
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Options for {@link generateMcpTools}.
|
|
105
|
+
*
|
|
106
|
+
* `expandVisibility` (vault security review) is an OPTIONAL per-note
|
|
107
|
+
* visibility predicate threaded into the wikilink-expansion context for
|
|
108
|
+
* `query-notes`. When provided, `expand_links` inlining leaves any wikilink
|
|
109
|
+
* whose target fails the predicate UNRESOLVED — so a tag-scoped MCP session
|
|
110
|
+
* can't inline out-of-scope note content during expansion (the filtering
|
|
111
|
+
* happens DURING expansion, not after). Core stays scope-unaware: it
|
|
112
|
+
* receives a plain `(note) => boolean` closure and never imports the
|
|
113
|
+
* server's tag-scope module. Omitted (every internal / unscoped caller) →
|
|
114
|
+
* expansion behaves exactly as before.
|
|
115
|
+
*/
|
|
116
|
+
export interface GenerateMcpToolsOpts {
|
|
117
|
+
expandVisibility?: (note: Note) => boolean;
|
|
118
|
+
/**
|
|
119
|
+
* `nearTraversable` (vault#439) is an OPTIONAL per-note predicate threaded
|
|
120
|
+
* into the `near[]` graph BFS. When provided, the traversal refuses to walk
|
|
121
|
+
* THROUGH any note that fails the predicate — making a tag-scoped `near[]`
|
|
122
|
+
* query symmetric with `find-path` (scope is a wall, not a sieve). Core
|
|
123
|
+
* stays scope-unaware: it receives a plain `(noteId) => boolean` closure.
|
|
124
|
+
* Omitted (unscoped / internal callers) → the full graph is walked.
|
|
125
|
+
*/
|
|
126
|
+
nearTraversable?: (noteId: string) => boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
102
129
|
/**
|
|
103
130
|
* Generate the consolidated MCP tools for a vault. Surface (10):
|
|
104
131
|
* query-notes, create-note, update-note, delete-note, list-tags, update-tag,
|
|
105
132
|
* delete-tag, find-path, vault-info, prune-schema (admin).
|
|
106
133
|
*/
|
|
107
|
-
export function generateMcpTools(store: Store): McpToolDef[] {
|
|
108
|
-
const db: Database =
|
|
134
|
+
export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
|
|
135
|
+
const db: Database = store.db;
|
|
136
|
+
const expandVisibility = opts?.expandVisibility;
|
|
137
|
+
const nearTraversable = opts?.nearTraversable;
|
|
109
138
|
|
|
110
139
|
return [
|
|
111
140
|
|
|
@@ -137,6 +166,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
137
166
|
description: "Filter by tag(s)",
|
|
138
167
|
},
|
|
139
168
|
tag_match: { type: "string", enum: ["any", "all"], description: "How to match multiple tags: 'any' (OR, default) or 'all' (AND)" },
|
|
169
|
+
expand: {
|
|
170
|
+
type: "string",
|
|
171
|
+
enum: ["subtypes", "namespace", "both", "exact"],
|
|
172
|
+
description: "How each `tag` expands. 'subtypes' (DEFAULT): the tag plus its declared parent_names descendants — the semantic is-a axis (e.g. tag:entity also matches person/work). 'namespace': the tag plus everything filed under it by NAME (tag:entity also matches entity/archived) — the lexical filing axis. 'both': union of the two. 'exact': only the literal tag, no expansion. Omit for 'subtypes' (current behavior).",
|
|
173
|
+
},
|
|
140
174
|
exclude_tags: {
|
|
141
175
|
oneOf: [
|
|
142
176
|
{ type: "string" },
|
|
@@ -178,7 +212,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
178
212
|
type: "object",
|
|
179
213
|
description: "Filter by metadata values. Each value is either a primitive (exact match, scans JSON) or an operator object: `{eq|ne|gt|gte|lt|lte|in|not_in|exists: value}`. Operator objects require the field to be declared `indexed: true` in a tag schema — they route through the backing B-tree index. Multiple operators on one field AND together (e.g. `{gt: 5, lt: 10}`). `in`/`not_in` take arrays; `exists` takes a boolean.",
|
|
180
214
|
},
|
|
181
|
-
order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Direction is taken from `sort` (default 'asc'); `created_at` is appended as a stable tiebreaker." },
|
|
215
|
+
order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. The special value `link_count` sorts by link DEGREE (both-directions raw row count) — no declaration needed — matching the `include_link_count` field for every note. Direction is taken from `sort` (default 'asc'); `created_at` is appended as a stable tiebreaker." },
|
|
182
216
|
date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
|
|
183
217
|
date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
|
|
184
218
|
date_filter: {
|
|
@@ -217,6 +251,17 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
217
251
|
description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
|
|
218
252
|
},
|
|
219
253
|
include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
|
|
254
|
+
include_link_count: {
|
|
255
|
+
type: "boolean",
|
|
256
|
+
description:
|
|
257
|
+
"Include the note's link DEGREE as a `linkCount` field, without hauling the link objects (default: false). Degree is a raw row count: outbound (source) + inbound (target). A self-loop counts as 2. Cheap COUNT over indexes; batched once per request. For a tag-scoped token, `linkCount` is the raw degree and MAY include edges to notes the token can't see — only the number leaks, not the neighbor.",
|
|
258
|
+
},
|
|
259
|
+
link_count_direction: {
|
|
260
|
+
type: "string",
|
|
261
|
+
enum: ["both", "outbound", "inbound"],
|
|
262
|
+
description:
|
|
263
|
+
"Which edges `include_link_count` counts: both (default), outbound only (source_id), or inbound only (target_id). order_by=link_count always uses the both-directions degree.",
|
|
264
|
+
},
|
|
220
265
|
include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
|
|
221
266
|
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." },
|
|
222
267
|
expand_depth: { type: "number", description: "Recursion depth for link expansion (default 1, max 3). Only meaningful in 'full' mode — 'summary' mode does not recurse." },
|
|
@@ -235,7 +280,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
235
280
|
),
|
|
236
281
|
);
|
|
237
282
|
const expandCtx: ExpandContext | null = expandLinks
|
|
238
|
-
? {
|
|
283
|
+
? {
|
|
284
|
+
db,
|
|
285
|
+
mode: expandMode,
|
|
286
|
+
expanded: new Set(),
|
|
287
|
+
// Tag-scope confidentiality (security review): when a visibility
|
|
288
|
+
// predicate was injected, wikilinks to out-of-scope notes are
|
|
289
|
+
// left unresolved DURING inlining — never embedded. Unscoped
|
|
290
|
+
// callers pass no predicate and inlining is unchanged.
|
|
291
|
+
...(expandVisibility ? { isVisible: expandVisibility } : {}),
|
|
292
|
+
}
|
|
239
293
|
: null;
|
|
240
294
|
|
|
241
295
|
// --- Single note by ID/path ---
|
|
@@ -256,10 +310,27 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
256
310
|
if (params.include_attachments) {
|
|
257
311
|
result.attachments = await store.getAttachments(note.id);
|
|
258
312
|
}
|
|
313
|
+
// linkCount injected after filterMetadata on purpose — same as
|
|
314
|
+
// links/attachments above; filterMetadata only touches `metadata`.
|
|
315
|
+
if (params.include_link_count) {
|
|
316
|
+
const dir = normalizeLinkCountDirection(params.link_count_direction);
|
|
317
|
+
result.linkCount = linkOps.getLinkCounts(db, [note.id], dir).get(note.id) ?? 0;
|
|
318
|
+
}
|
|
259
319
|
return result;
|
|
260
320
|
}
|
|
261
321
|
|
|
262
322
|
// --- Build near-scope (graph-filtered set of allowed IDs) ---
|
|
323
|
+
//
|
|
324
|
+
// Tag-scope policy for `near[]` (vault#439 — hop-guard, symmetric with
|
|
325
|
+
// find-path): when the session is tag-scoped the server injects a
|
|
326
|
+
// `nearTraversable` predicate (mcp-tools.ts), and the BFS refuses to
|
|
327
|
+
// walk THROUGH out-of-scope notes — scope is a wall, not a sieve. So a
|
|
328
|
+
// token scoped to ["work"] can't reach an in-scope note at depth 2 via
|
|
329
|
+
// a #personal intermediary at depth 1. Core stays scope-unaware: it
|
|
330
|
+
// only invokes the injected closure. Unscoped sessions pass no
|
|
331
|
+
// predicate → the FULL graph is walked exactly as before. The
|
|
332
|
+
// `applyTagScopeWrappers` result-filter still runs afterward (defense
|
|
333
|
+
// in depth), but the wall makes it redundant for `near[]`.
|
|
263
334
|
let nearScope: Set<string> | null = null;
|
|
264
335
|
if (params.near) {
|
|
265
336
|
const near = params.near as { note_id: string; depth?: number; relationship?: string };
|
|
@@ -269,6 +340,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
269
340
|
const traversed = linkOps.traverseLinks(db, anchor.id, {
|
|
270
341
|
max_depth: depth,
|
|
271
342
|
relationship: near.relationship,
|
|
343
|
+
isTraversable: nearTraversable,
|
|
272
344
|
});
|
|
273
345
|
nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
|
|
274
346
|
}
|
|
@@ -295,6 +367,18 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
295
367
|
"INVALID_QUERY",
|
|
296
368
|
);
|
|
297
369
|
}
|
|
370
|
+
// Tag-expansion axis (vault tag `expand` axis). Validate loudly so a
|
|
371
|
+
// typo'd value doesn't silently fall back to the default.
|
|
372
|
+
let expand: TagExpandMode | undefined;
|
|
373
|
+
if (params.expand !== undefined && params.expand !== null) {
|
|
374
|
+
if (typeof params.expand !== "string" || !(TAG_EXPAND_MODES as readonly string[]).includes(params.expand)) {
|
|
375
|
+
throw new QueryError(
|
|
376
|
+
`invalid \`expand\` value ${JSON.stringify(params.expand)} — must be one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("subtypes").`,
|
|
377
|
+
"INVALID_QUERY",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
expand = params.expand as TagExpandMode;
|
|
381
|
+
}
|
|
298
382
|
|
|
299
383
|
// --- Full-text search ---
|
|
300
384
|
let results: Note[];
|
|
@@ -310,6 +394,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
310
394
|
results = await store.searchNotes(params.search as string, {
|
|
311
395
|
tags,
|
|
312
396
|
limit: (params.limit as number) ?? 50,
|
|
397
|
+
expand,
|
|
313
398
|
});
|
|
314
399
|
} else {
|
|
315
400
|
// --- Structured query ---
|
|
@@ -329,6 +414,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
329
414
|
const queryOpts = {
|
|
330
415
|
tags,
|
|
331
416
|
tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
417
|
+
expand,
|
|
332
418
|
excludeTags,
|
|
333
419
|
hasTags: params.has_tags as boolean | undefined,
|
|
334
420
|
hasLinks: params.has_links as boolean | undefined,
|
|
@@ -390,6 +476,19 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
390
476
|
output = output.map((n: any) => filterMetadata(n, includeMetadata));
|
|
391
477
|
}
|
|
392
478
|
|
|
479
|
+
// --- Opt-in link degree (vault feedback #4) ---
|
|
480
|
+
// ONE batch count over all result ids (NOT per-note), so the field
|
|
481
|
+
// stays O(2 index scans) per request regardless of page size.
|
|
482
|
+
// Injected on the same objects the enrichment loop copies below.
|
|
483
|
+
// Ordering: runs AFTER the filterMetadata pass above on purpose —
|
|
484
|
+
// filterMetadata only touches the `metadata` key, so linkCount
|
|
485
|
+
// survives. Don't casually swap the order.
|
|
486
|
+
if (params.include_link_count) {
|
|
487
|
+
const dir = normalizeLinkCountDirection(params.link_count_direction);
|
|
488
|
+
const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), dir);
|
|
489
|
+
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
490
|
+
}
|
|
491
|
+
|
|
393
492
|
// --- Hydrate links/attachments per note if requested ---
|
|
394
493
|
if (params.include_links || params.include_attachments) {
|
|
395
494
|
const enrichedOut: any[] = [];
|
|
@@ -512,17 +611,35 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
512
611
|
throw e;
|
|
513
612
|
}
|
|
514
613
|
|
|
515
|
-
// Apply tag schema effects
|
|
614
|
+
// Apply tag schema effects, then re-read the notes whose metadata was
|
|
615
|
+
// actually default-filled so the response reflects the final on-disk
|
|
616
|
+
// state (the `created` entries were read before `applySchemaDefaults`
|
|
617
|
+
// ran, so default-filled metadata isn't on them yet). This mirrors the
|
|
618
|
+
// update-note path, which already re-reads post-defaults. The re-read
|
|
619
|
+
// is batched (`getNotes` = one `WHERE id IN (...)`) and skipped
|
|
620
|
+
// entirely when no defaults were applied, so the common no-defaults
|
|
621
|
+
// path adds zero extra reads.
|
|
622
|
+
const mutatedIds = new Set<string>();
|
|
516
623
|
for (const note of created) {
|
|
517
624
|
if (note.tags && note.tags.length > 0) {
|
|
518
|
-
await applySchemaDefaults(store, db, [note.id], note.tags)
|
|
625
|
+
for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
|
|
626
|
+
mutatedIds.add(id);
|
|
627
|
+
}
|
|
519
628
|
}
|
|
520
629
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
630
|
+
const refreshed =
|
|
631
|
+
mutatedIds.size === 0
|
|
632
|
+
? created
|
|
633
|
+
: (() => {
|
|
634
|
+
const byId = new Map(
|
|
635
|
+
noteOps.getNotes(db, [...mutatedIds]).map((n) => [n.id, n]),
|
|
636
|
+
);
|
|
637
|
+
return created.map((n) => byId.get(n.id) ?? n);
|
|
638
|
+
})();
|
|
639
|
+
|
|
640
|
+
// Attach `validation_status` from any tag's `fields` declaration that
|
|
641
|
+
// applies to this note, against the post-defaults state.
|
|
642
|
+
const final = refreshed.map((n) => attachValidationStatus(store, db, n));
|
|
526
643
|
return batch ? final : final[0];
|
|
527
644
|
},
|
|
528
645
|
},
|
|
@@ -543,7 +660,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
543
660
|
- \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
|
|
544
661
|
- When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
|
|
545
662
|
- For batch: pass a \`notes\` array, each with an \`id\` field.
|
|
546
|
-
- **Optimistic concurrency is required by default.** 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, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
|
|
663
|
+
- **Optimistic concurrency is required by default.** 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, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
|
|
547
664
|
- **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
|
|
548
665
|
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.`,
|
|
549
666
|
inputSchema: {
|
|
@@ -567,7 +684,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
567
684
|
metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
|
|
568
685
|
created_at: { type: "string", description: "New created_at timestamp" },
|
|
569
686
|
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. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
|
|
570
|
-
force: { type: "boolean", description: "
|
|
687
|
+
force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe. Note: this does not override an `if_updated_at` you actually pass — if you supply both, the precondition still applies and a mismatch returns a conflict error." },
|
|
571
688
|
if_missing: { type: "string", enum: ["fail", "create"], description: "What to do when the note (by `id`/path) doesn't exist. `\"fail\"` (default) — error, current behavior. `\"create\"` — create the note from this same payload (content/path/tags/metadata become the create fields; the response carries `created: true`). Skips the `if_updated_at` precondition on the create branch (nothing to conflict with). Idempotent for sync loops that don't know ahead of time whether the note exists. See vault#309." },
|
|
572
689
|
tags: {
|
|
573
690
|
type: "object",
|
|
@@ -609,6 +726,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
609
726
|
type: "boolean",
|
|
610
727
|
description: "Response shape opt-out. Default `true` (returns the full Note with content). Set `false` to receive the lean index shape (drops `content`, adds `byteSize` and a whitespace-collapsed `preview`). `validation_status` is preserved on the lean shape when present. Applies uniformly to single and batch responses.",
|
|
611
728
|
},
|
|
729
|
+
include_links: {
|
|
730
|
+
type: "boolean",
|
|
731
|
+
description: "Echo the note's hydrated inbound + outbound links on the response (vault feedback #8). Links are *also* echoed automatically whenever the update itself mutated links (`links.add`/`links.remove`), so you rarely need to set this — its purpose is to fetch the current link set on an update that didn't touch links. Default: `false` (and absent from the response unless mutated or requested). Mirrors `query-notes`'s `include_links`. This top-level flag applies to the single-note form only; for a batch, set `include_links` on each note object in `notes` (a top-level `include_links` is ignored when `notes` is present).",
|
|
732
|
+
},
|
|
612
733
|
// Batch
|
|
613
734
|
notes: {
|
|
614
735
|
type: "array",
|
|
@@ -632,10 +753,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
632
753
|
metadata: { type: "object" },
|
|
633
754
|
created_at: { type: "string" },
|
|
634
755
|
if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item or the item is `append`/`prepend`-only." },
|
|
635
|
-
force: { type: "boolean", description: "
|
|
756
|
+
force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` for this item. Does not override an `if_updated_at` you actually pass — a supplied precondition still applies and a mismatch conflicts." },
|
|
636
757
|
if_missing: { type: "string", enum: ["fail", "create"], description: "Per-item: see top-level `if_missing` docs. Each batch item carries its own setting." },
|
|
637
758
|
tags: { type: "object" },
|
|
638
759
|
links: { type: "object" },
|
|
760
|
+
include_links: { type: "boolean", description: "Per-item: echo hydrated links on this item's response (vault feedback #8). Also implied when this item mutates links." },
|
|
639
761
|
},
|
|
640
762
|
required: ["id"],
|
|
641
763
|
},
|
|
@@ -657,6 +779,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
657
779
|
// sync-loop caller (Gitcoin Brain et al) reads this to know which
|
|
658
780
|
// path fired without doing a separate query. vault#309.
|
|
659
781
|
const createdIds = new Set<string>();
|
|
782
|
+
// Track which note IDs should echo hydrated links on the response.
|
|
783
|
+
// A note qualifies when this request mutated its links
|
|
784
|
+
// (`links.add`/`links.remove`) OR the caller set `include_links`.
|
|
785
|
+
// vault feedback #8 — previously the update response omitted links
|
|
786
|
+
// entirely, forcing a re-query just to confirm a link the caller had
|
|
787
|
+
// just added/removed. Per-item on batch. Note IDs (not item indices)
|
|
788
|
+
// key this so the create-on-missing branch, which assigns the id
|
|
789
|
+
// late, can register correctly.
|
|
790
|
+
const echoLinkIds = new Set<string>();
|
|
660
791
|
// Wrap multi-item batches in a SQLite transaction so any mid-batch
|
|
661
792
|
// failure (precondition error, content_edit miss, ConflictError, …)
|
|
662
793
|
// rolls back every prior mutation in the batch — see #236.
|
|
@@ -745,6 +876,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
745
876
|
const fresh = noteOps.getNote(db, created.id) ?? created;
|
|
746
877
|
updated.push(fresh);
|
|
747
878
|
createdIds.add(fresh.id);
|
|
879
|
+
// Echo links if this create-on-missing declared `links.add`
|
|
880
|
+
// (the only link op honored on create) or asked explicitly.
|
|
881
|
+
if (linksAdd !== undefined || item.include_links === true) {
|
|
882
|
+
echoLinkIds.add(fresh.id);
|
|
883
|
+
}
|
|
748
884
|
continue;
|
|
749
885
|
}
|
|
750
886
|
// Fallthrough: not-found + no if_missing → existing error
|
|
@@ -907,6 +1043,13 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
907
1043
|
}
|
|
908
1044
|
}
|
|
909
1045
|
|
|
1046
|
+
// Echo links if this update mutated them (`links.add`/`links.remove`)
|
|
1047
|
+
// or the caller asked explicitly. vault feedback #8.
|
|
1048
|
+
const linkMutated = (item.links as any)?.add !== undefined || (item.links as any)?.remove !== undefined;
|
|
1049
|
+
if (linkMutated || item.include_links === true) {
|
|
1050
|
+
echoLinkIds.add(note.id);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
910
1053
|
// Re-read for final state
|
|
911
1054
|
updated.push(noteOps.getNote(db, note.id) ?? result);
|
|
912
1055
|
}
|
|
@@ -929,11 +1072,23 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
929
1072
|
const final = updated.map((n) => {
|
|
930
1073
|
const validated = attachValidationStatus(store, db, n);
|
|
931
1074
|
const created = createdIds.has(n.id);
|
|
932
|
-
|
|
1075
|
+
// Echo hydrated links when this note was flagged for it (mutated
|
|
1076
|
+
// its links or `include_links` was set). Additive key, present only
|
|
1077
|
+
// when triggered — mirrors the GET / query-notes shape exactly via
|
|
1078
|
+
// the shared `linkOps.getLinksHydrated` call. vault feedback #8.
|
|
1079
|
+
const echoLinks = echoLinkIds.has(n.id);
|
|
1080
|
+
if (includeContent) {
|
|
1081
|
+
const full: any = { ...validated, created };
|
|
1082
|
+
if (echoLinks) full.links = linkOps.getLinksHydrated(db, n.id);
|
|
1083
|
+
return full as Note & { created: boolean };
|
|
1084
|
+
}
|
|
933
1085
|
const lean: any = noteOps.toNoteIndex(validated);
|
|
934
1086
|
const vs = (validated as any).validation_status;
|
|
935
1087
|
if (vs !== undefined) lean.validation_status = vs;
|
|
936
1088
|
lean.created = created;
|
|
1089
|
+
// Carry the link echo across the lean conversion — `toNoteIndex`
|
|
1090
|
+
// drops unknown fields.
|
|
1091
|
+
if (echoLinks) lean.links = linkOps.getLinksHydrated(db, n.id);
|
|
937
1092
|
return lean;
|
|
938
1093
|
});
|
|
939
1094
|
return batch ? final : final[0];
|
|
@@ -1029,7 +1184,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1029
1184
|
{
|
|
1030
1185
|
name: "update-tag",
|
|
1031
1186
|
requiredVerb: "write",
|
|
1032
|
-
description: "Create or update a tag's identity row: description, indexed-field schemas,
|
|
1187
|
+
description: "Create or update a tag's identity row: description, indexed-field schemas, relationship-vocabulary map, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-patterns/patterns/tag-data-model.md.",
|
|
1033
1188
|
inputSchema: {
|
|
1034
1189
|
type: "object",
|
|
1035
1190
|
properties: {
|
|
@@ -1051,16 +1206,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1051
1206
|
},
|
|
1052
1207
|
relationships: {
|
|
1053
1208
|
type: "object",
|
|
1054
|
-
description: '
|
|
1055
|
-
additionalProperties:
|
|
1056
|
-
type: "object",
|
|
1057
|
-
properties: {
|
|
1058
|
-
target_tag: { type: "string", description: "Tag the relationship points at" },
|
|
1059
|
-
cardinality: { type: "string", enum: ["one", "optional", "many", "many-required"], description: "How many targets this relationship may have" },
|
|
1060
|
-
description: { type: "string", description: "Why this relationship exists; surfaced to AI clients" },
|
|
1061
|
-
},
|
|
1062
|
-
required: ["target_tag", "cardinality"],
|
|
1063
|
-
},
|
|
1209
|
+
description: 'Opaque relationship-vocabulary map: keys are relationship names, values are arbitrary JSON the declaring app interprets. Vault stores and returns the values verbatim and does NOT enforce any inner shape — only that this is a JSON object (a map), not an array or primitive. Replaces any prior map wholesale when provided; pass null to clear. The historical typed shape { "lives_in": { "target_tag": "place", "cardinality": "one" } } is still a valid value, as is any app-defined shape e.g. { "works-on": { "from": "person", "to": "project" } }.',
|
|
1210
|
+
additionalProperties: true,
|
|
1064
1211
|
},
|
|
1065
1212
|
parent_names: {
|
|
1066
1213
|
type: "array",
|
|
@@ -1124,10 +1271,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1124
1271
|
}
|
|
1125
1272
|
}
|
|
1126
1273
|
|
|
1127
|
-
// ---- relationships: replace wholesale when provided.
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
|
|
1274
|
+
// ---- relationships: replace wholesale when provided. `relationships`
|
|
1275
|
+
// is an opaque vocabulary map (relationship-name → arbitrary JSON the
|
|
1276
|
+
// app interprets). Validate only that it's a JSON object (a map), then
|
|
1277
|
+
// persist verbatim — no inner-shape enforcement.
|
|
1278
|
+
let relationshipsPatch: tagSchemaOps.TagRelationshipMap | null | undefined;
|
|
1131
1279
|
if (params.relationships === null) {
|
|
1132
1280
|
relationshipsPatch = null;
|
|
1133
1281
|
} else if (params.relationships !== undefined) {
|
|
@@ -1296,9 +1444,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1296
1444
|
// Tag schema effects — auto-populate defaults when tags are applied
|
|
1297
1445
|
// ---------------------------------------------------------------------------
|
|
1298
1446
|
|
|
1299
|
-
|
|
1447
|
+
/**
|
|
1448
|
+
* Fill schema-declared default values into the metadata of the given notes
|
|
1449
|
+
* for any field they omitted. Returns the IDs of the notes whose metadata was
|
|
1450
|
+
* actually written — callers use this to re-read ONLY the mutated notes (and
|
|
1451
|
+
* to skip the re-read entirely when nothing changed). The common no-schema /
|
|
1452
|
+
* no-defaults path returns an empty array.
|
|
1453
|
+
*/
|
|
1454
|
+
async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
1300
1455
|
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
1301
|
-
if (Object.keys(schemas).length === 0) return;
|
|
1456
|
+
if (Object.keys(schemas).length === 0) return [];
|
|
1302
1457
|
|
|
1303
1458
|
const defaults: Record<string, unknown> = {};
|
|
1304
1459
|
for (const tag of tags) {
|
|
@@ -1310,8 +1465,9 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
|
|
|
1310
1465
|
}
|
|
1311
1466
|
}
|
|
1312
1467
|
}
|
|
1313
|
-
if (Object.keys(defaults).length === 0) return;
|
|
1468
|
+
if (Object.keys(defaults).length === 0) return [];
|
|
1314
1469
|
|
|
1470
|
+
const mutated: string[] = [];
|
|
1315
1471
|
for (const noteId of noteIds) {
|
|
1316
1472
|
const note = noteOps.getNote(db, noteId);
|
|
1317
1473
|
if (!note) continue;
|
|
@@ -1327,7 +1483,9 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
|
|
|
1327
1483
|
metadata: { ...existing, ...missing },
|
|
1328
1484
|
skipUpdatedAt: true,
|
|
1329
1485
|
});
|
|
1486
|
+
mutated.push(noteId);
|
|
1330
1487
|
}
|
|
1488
|
+
return mutated;
|
|
1331
1489
|
}
|
|
1332
1490
|
|
|
1333
1491
|
function defaultForField(field: { type: string; enum?: string[] }): unknown {
|
|
@@ -1382,6 +1540,16 @@ function normalizeTags(tag: unknown): string[] | undefined {
|
|
|
1382
1540
|
return [tag as string];
|
|
1383
1541
|
}
|
|
1384
1542
|
|
|
1543
|
+
/**
|
|
1544
|
+
* Coerce the `link_count_direction` MCP param to a known value, defaulting
|
|
1545
|
+
* to "both" (matches the REST `parseLinkCountDirection` fallback). A typo
|
|
1546
|
+
* silently degrades to the documented default rather than erroring.
|
|
1547
|
+
*/
|
|
1548
|
+
function normalizeLinkCountDirection(v: unknown): "both" | "outbound" | "inbound" {
|
|
1549
|
+
if (v === "outbound" || v === "inbound") return v;
|
|
1550
|
+
return "both";
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1385
1553
|
// Re-exported for backward compat; defined in notes.ts alongside the
|
|
1386
1554
|
// conditional-UPDATE implementation that raises it. AmbiguousPathError
|
|
1387
1555
|
// joins the set (vault#331 N2) so external callers can `instanceof`
|
package/core/src/notes.ts
CHANGED
|
@@ -83,7 +83,7 @@ export function createNote(
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
export function getNote(db: Database, id: string): Note | null {
|
|
86
|
-
const row = db.prepare("SELECT * FROM notes WHERE id = ?").get(id) as NoteRow |
|
|
86
|
+
const row = db.prepare("SELECT * FROM notes WHERE id = ?").get(id) as NoteRow | null;
|
|
87
87
|
if (!row) return null;
|
|
88
88
|
|
|
89
89
|
const note = rowToNote(row);
|
|
@@ -111,7 +111,7 @@ export function getNoteByPath(db: Database, path: string, extension?: string): N
|
|
|
111
111
|
if (extension !== undefined) {
|
|
112
112
|
const row = db.prepare(
|
|
113
113
|
"SELECT * FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
|
|
114
|
-
).get(path, extension.toLowerCase()) as NoteRow |
|
|
114
|
+
).get(path, extension.toLowerCase()) as NoteRow | null;
|
|
115
115
|
if (!row) return null;
|
|
116
116
|
const note = rowToNote(row);
|
|
117
117
|
note.tags = getNoteTags(db, note.id);
|
|
@@ -459,7 +459,7 @@ export function updateNote(
|
|
|
459
459
|
if (isPathUniqueError(err)) {
|
|
460
460
|
const conflictPath = updates.path !== undefined
|
|
461
461
|
? (normalizePath(updates.path) ?? updates.path)
|
|
462
|
-
: ((db.prepare("SELECT path FROM notes WHERE id = ?").get(id) as { path: string | null } |
|
|
462
|
+
: ((db.prepare("SELECT path FROM notes WHERE id = ?").get(id) as { path: string | null } | null)?.path ?? "<unknown>");
|
|
463
463
|
throw new PathConflictError(conflictPath);
|
|
464
464
|
}
|
|
465
465
|
throw err;
|
|
@@ -475,7 +475,7 @@ export function updateNote(
|
|
|
475
475
|
function throwConflictOrMissing(db: Database, id: string, expected: string): never {
|
|
476
476
|
const row = db.prepare("SELECT updated_at, path FROM notes WHERE id = ?").get(id) as
|
|
477
477
|
| { updated_at: string | null; path: string | null }
|
|
478
|
-
|
|
|
478
|
+
| null;
|
|
479
479
|
if (!row) {
|
|
480
480
|
throw new Error(`Note not found: "${id}"`);
|
|
481
481
|
}
|
|
@@ -736,6 +736,32 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
736
736
|
// be at the mercy of SQLite's row order and the next page could
|
|
737
737
|
// miss or duplicate one.
|
|
738
738
|
orderBy = "n.updated_at ASC, n.id ASC";
|
|
739
|
+
} else if (opts.orderBy === "link_count") {
|
|
740
|
+
// `link_count` is a pseudo-field — like `created_at`/`updated_at` in the
|
|
741
|
+
// dateFilter block above, it bypasses `requireIndexedField` (it's not a
|
|
742
|
+
// metadata column). Sort by link DEGREE using the SAME directional-sum
|
|
743
|
+
// definition as the `linkCount` response field (see `getLinkCounts` in
|
|
744
|
+
// links.ts): two correlated COUNT subqueries summed. This MUST stay a
|
|
745
|
+
// sum of two directional counts — a single
|
|
746
|
+
// `COUNT(*) ... WHERE source_id=n.id OR target_id=n.id` would count a
|
|
747
|
+
// self-loop ONCE (degree 1) and DIVERGE from the field's degree-2. Both
|
|
748
|
+
// subqueries ride the existing `idx_links_source` / `idx_links_target`
|
|
749
|
+
// B-trees. `created_at` stays the stable tiebreaker.
|
|
750
|
+
//
|
|
751
|
+
// Always the both-directions degree — inbound-only ordering is a future
|
|
752
|
+
// extension and is not built here.
|
|
753
|
+
//
|
|
754
|
+
// Perf caveat: these are correlated subqueries, evaluated once per
|
|
755
|
+
// candidate row. At small-to-moderate vault sizes (tens of thousands of
|
|
756
|
+
// notes) that's fine — each subquery is an O(log n) index probe. At very
|
|
757
|
+
// large vault sizes the per-row scan cost grows; the upgrade path is a
|
|
758
|
+
// maintained `link_count` counter column on `notes`, incremented in
|
|
759
|
+
// `createLink` and decremented in `deleteLink`, then ordered directly.
|
|
760
|
+
// NOT built now — flagged so a future contributor sees the lever.
|
|
761
|
+
orderBy =
|
|
762
|
+
`((SELECT COUNT(*) FROM links WHERE source_id = n.id) ` +
|
|
763
|
+
`+ (SELECT COUNT(*) FROM links WHERE target_id = n.id)) ${direction}, ` +
|
|
764
|
+
`n.created_at ${direction}`;
|
|
739
765
|
} else if (opts.orderBy) {
|
|
740
766
|
requireIndexedField(db, opts.orderBy);
|
|
741
767
|
// `orderBy` came from indexed_fields (validated on declaration), so
|
|
@@ -946,7 +972,7 @@ export function listTags(db: Database): { name: string; count: number }[] {
|
|
|
946
972
|
export function deleteTag(db: Database, name: string): { deleted: boolean; notes_untagged: number } {
|
|
947
973
|
const row = db.prepare("SELECT fields FROM tags WHERE name = ?").get(name) as
|
|
948
974
|
| { fields: string | null }
|
|
949
|
-
|
|
|
975
|
+
| null;
|
|
950
976
|
if (!row) return { deleted: false, notes_untagged: 0 };
|
|
951
977
|
|
|
952
978
|
const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
|
|
@@ -1107,7 +1133,7 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1107
1133
|
for (const { from, to } of renames) {
|
|
1108
1134
|
const old = readStmt.get(from) as
|
|
1109
1135
|
| { description: string | null; fields: string | null; relationships: string | null; parent_names: string | null; created_at: string | null }
|
|
1110
|
-
|
|
|
1136
|
+
| null;
|
|
1111
1137
|
insertStmt.run(
|
|
1112
1138
|
to,
|
|
1113
1139
|
old?.description ?? null,
|
|
@@ -1522,11 +1548,11 @@ export function getVaultStats(
|
|
|
1522
1548
|
|
|
1523
1549
|
const earliestRow = db.prepare(
|
|
1524
1550
|
"SELECT id, created_at FROM notes ORDER BY created_at ASC, id ASC LIMIT 1",
|
|
1525
|
-
).get() as { id: string; created_at: string } |
|
|
1551
|
+
).get() as { id: string; created_at: string } | null;
|
|
1526
1552
|
|
|
1527
1553
|
const latestRow = db.prepare(
|
|
1528
1554
|
"SELECT id, created_at FROM notes ORDER BY created_at DESC, id DESC LIMIT 1",
|
|
1529
|
-
).get() as { id: string; created_at: string } |
|
|
1555
|
+
).get() as { id: string; created_at: string } | null;
|
|
1530
1556
|
|
|
1531
1557
|
const monthRows = db.prepare(`
|
|
1532
1558
|
SELECT strftime('%Y-%m', created_at) AS month, COUNT(*) AS count
|
|
@@ -1553,6 +1579,15 @@ export function getVaultStats(
|
|
|
1553
1579
|
const linkCountRow = db.prepare("SELECT COUNT(*) as c FROM links").get() as { c: number };
|
|
1554
1580
|
const linkCount = linkCountRow.c;
|
|
1555
1581
|
|
|
1582
|
+
// Total content bytes. CAST(content AS BLOB) forces SQLite's LENGTH() to
|
|
1583
|
+
// count UTF-8 BYTES rather than characters (bare LENGTH on TEXT returns a
|
|
1584
|
+
// char count, which undercounts multibyte content). COALESCE because SUM
|
|
1585
|
+
// over zero rows is NULL. See VaultStats.contentBytes for the rationale.
|
|
1586
|
+
const contentBytesRow = db
|
|
1587
|
+
.prepare("SELECT COALESCE(SUM(LENGTH(CAST(content AS BLOB))), 0) as b FROM notes")
|
|
1588
|
+
.get() as { b: number };
|
|
1589
|
+
const contentBytes = contentBytesRow.b;
|
|
1590
|
+
|
|
1556
1591
|
return {
|
|
1557
1592
|
totalNotes,
|
|
1558
1593
|
earliestNote: earliestRow
|
|
@@ -1566,6 +1601,7 @@ export function getVaultStats(
|
|
|
1566
1601
|
tagCount,
|
|
1567
1602
|
attachmentCount,
|
|
1568
1603
|
linkCount,
|
|
1604
|
+
contentBytes,
|
|
1569
1605
|
};
|
|
1570
1606
|
}
|
|
1571
1607
|
|