@openparachute/vault 0.4.0 → 0.4.3

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/core/src/mcp.ts CHANGED
@@ -6,7 +6,6 @@ import * as linkOps from "./links.js";
6
6
  import * as tagSchemaOps from "./tag-schemas.js";
7
7
  import type { TagFieldSchema } from "./tag-schemas.js";
8
8
  import * as indexedFieldOps from "./indexed-fields.js";
9
- import { MAPPING_KINDS, type SchemaMappingKind, type NoteSchemaField } from "./note-schemas.js";
10
9
  import {
11
10
  expandContent,
12
11
  DEFAULT_EXPAND_DEPTH,
@@ -69,7 +68,9 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
69
68
  // ---------------------------------------------------------------------------
70
69
 
71
70
  /**
72
- * Generate the 10 consolidated MCP tools for a vault.
71
+ * Generate the consolidated MCP tools for a vault. Post-v17 surface (9):
72
+ * query-notes, create-note, update-note, delete-note, list-tags, update-tag,
73
+ * delete-tag, find-path, vault-info.
73
74
  */
74
75
  export function generateMcpTools(store: Store): McpToolDef[] {
75
76
  const db: Database = (store as any).db;
@@ -143,11 +144,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
143
144
  date_filter: {
144
145
  type: "object",
145
146
  properties: {
146
- field: { type: "string", description: "Field to filter on. Defaults to `created_at` (vault ingestion time). Any other field must be declared `indexed: true` in a tag schema — same contract as metadata operator queries and `order_by`." },
147
+ field: { type: "string", description: "Field to filter on. Defaults to `created_at` (vault ingestion time). `updated_at` is also recognized as a real column — use it for incremental rebuilds (\"what changed since X\"). Any other field must be declared `indexed: true` in a tag schema — same contract as metadata operator queries and `order_by`." },
147
148
  from: { type: "string", description: "Inclusive lower bound (ISO date)." },
148
149
  to: { type: "string", description: "Exclusive upper bound (ISO date)." },
149
150
  },
150
- description: "Generalized date-range filter. Use this when the date that matters is the *content* date (e.g. an email's received date, a meeting's scheduled date), not the vault ingestion time set `field` to the indexed metadata field that holds it. Mutually exclusive with the top-level `date_from` / `date_to` shorthand.",
151
+ description: "Generalized date-range filter. Use this when the date that matters is the *content* date (e.g. an email's received date, a meeting's scheduled date) rather than the vault ingestion time, or when paging by `updated_at` for incremental rebuilds. Mutually exclusive with the top-level `date_from` / `date_to` shorthand.",
151
152
  },
152
153
  near: {
153
154
  type: "object",
@@ -446,7 +447,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
446
447
 
447
448
  // Re-read after schema-default population so the response reflects the
448
449
  // final on-disk state, then attach `validation_status` from any
449
- // `_schemas/*` config notes that match this note's path or tags.
450
+ // tag's `fields` declaration that applies to this note.
450
451
  const final = created.map((n) => attachValidationStatus(store, db, n));
451
452
  return batch ? final : final[0];
452
453
  },
@@ -467,7 +468,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
467
468
  - \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
468
469
  - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
469
470
  - For batch: pass a \`notes\` array, each with an \`id\` field.
470
- - **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).`,
471
+ - **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).
472
+ - \`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.`,
471
473
  inputSchema: {
472
474
  type: "object",
473
475
  properties: {
@@ -525,6 +527,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
525
527
  },
526
528
  description: "Links to add/remove",
527
529
  },
530
+ include_content: {
531
+ type: "boolean",
532
+ 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.",
533
+ },
528
534
  // Batch
529
535
  notes: {
530
536
  type: "array",
@@ -736,7 +742,19 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
736
742
  throw e;
737
743
  }
738
744
 
739
- const final = updated.map((n) => attachValidationStatus(store, db, n));
745
+ // Response shape: full Note (back-compat default) or lean NoteIndex
746
+ // (#285 friction point 2.response — opt-out for callers making
747
+ // frequent small edits to large notes). `validation_status` from
748
+ // `tags.fields` is preserved across either shape.
749
+ const includeContent = params.include_content !== false;
750
+ const final = updated.map((n) => {
751
+ const validated = attachValidationStatus(store, db, n);
752
+ if (includeContent) return validated;
753
+ const lean: any = noteOps.toNoteIndex(validated);
754
+ const vs = (validated as any).validation_status;
755
+ if (vs !== undefined) lean.validation_status = vs;
756
+ return lean;
757
+ });
740
758
  return batch ? final : final[0];
741
759
  },
742
760
  },
@@ -1002,180 +1020,6 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1002
1020
  },
1003
1021
  },
1004
1022
 
1005
- // =====================================================================
1006
- // 7a. list-note-schemas — read note_schemas + their mappings
1007
- // =====================================================================
1008
- {
1009
- name: "list-note-schemas",
1010
- description: "List note schemas (description, fields, required, timestamps). Pass `name` to get a single schema with its applied mapping rules. Schemas drive the validation_status warnings surfaced on create-note / update-note.",
1011
- inputSchema: {
1012
- type: "object",
1013
- properties: {
1014
- name: { type: "string", description: "Get a single schema by name (with its mappings)" },
1015
- include_mappings: { type: "boolean", description: "When listing all schemas, include each schema's mappings (default: false)" },
1016
- },
1017
- },
1018
- execute: async (params) => {
1019
- const single = params.name as string | undefined;
1020
- if (single) {
1021
- const schema = await store.getNoteSchema(single);
1022
- if (!schema) return null;
1023
- const mappings = await store.listSchemaMappings({ schema_name: single });
1024
- return { ...schema, mappings };
1025
- }
1026
- const schemas = await store.listNoteSchemas();
1027
- if (params.include_mappings) {
1028
- const allMappings = await store.listSchemaMappings();
1029
- const byName = new Map<string, typeof allMappings>();
1030
- for (const m of allMappings) {
1031
- const list = byName.get(m.schema_name) ?? [];
1032
- list.push(m);
1033
- byName.set(m.schema_name, list);
1034
- }
1035
- return schemas.map((s) => ({ ...s, mappings: byName.get(s.name) ?? [] }));
1036
- }
1037
- return schemas;
1038
- },
1039
- },
1040
-
1041
- // =====================================================================
1042
- // 7b. update-note-schema — partial-upsert a schema definition
1043
- // =====================================================================
1044
- {
1045
- name: "update-note-schema",
1046
- description: "Create or update a note schema's definition: description, allowed/expected fields (type + enum + description), and a list of required field names. Auto-creates the schema row if missing. Pass null for description/fields/required to clear that column. Empty `required: []` collapses to null.",
1047
- inputSchema: {
1048
- type: "object",
1049
- properties: {
1050
- name: { type: "string", description: "Schema name (e.g., 'meeting', 'project')" },
1051
- description: { type: "string", description: "Human-readable description of what this schema describes" },
1052
- fields: {
1053
- type: "object",
1054
- description: 'Field declarations. E.g., { "title": { "type": "string" }, "status": { "type": "string", "enum": ["active", "done"] } }. Replaces fields wholesale when provided.',
1055
- additionalProperties: {
1056
- type: "object",
1057
- properties: {
1058
- type: { type: "string", enum: ["string", "number", "boolean", "array", "object"], description: "Expected JS type for this field" },
1059
- enum: { type: "array", items: { type: "string" }, description: "Allowed values (string fields only)" },
1060
- description: { type: "string" },
1061
- },
1062
- },
1063
- },
1064
- required: {
1065
- type: "array",
1066
- items: { type: "string" },
1067
- description: "Field names that must be present on a note matching this schema. Pass [] or null to clear.",
1068
- },
1069
- },
1070
- required: ["name"],
1071
- },
1072
- execute: async (params) => {
1073
- const name = params.name as string;
1074
- const patch: { description?: string | null; fields?: Record<string, NoteSchemaField> | null; required?: string[] | null } = {};
1075
- if (params.description === null) patch.description = null;
1076
- else if (params.description !== undefined) patch.description = params.description as string;
1077
- if (params.fields === null) patch.fields = null;
1078
- else if (params.fields !== undefined) patch.fields = params.fields as Record<string, NoteSchemaField>;
1079
- if (params.required === null) patch.required = null;
1080
- else if (params.required !== undefined) {
1081
- if (!Array.isArray(params.required)) {
1082
- throw new Error("required must be an array of field names");
1083
- }
1084
- patch.required = (params.required as unknown[]).filter((x): x is string => typeof x === "string");
1085
- }
1086
- return await store.upsertNoteSchema(name, patch);
1087
- },
1088
- },
1089
-
1090
- // =====================================================================
1091
- // 7c. delete-note-schema — drop schema + cascade its mappings
1092
- // =====================================================================
1093
- {
1094
- name: "delete-note-schema",
1095
- description: "Delete a note schema. Cascades: any schema_mappings pointing at it are removed via FK ON DELETE CASCADE. Notes themselves are untouched.",
1096
- inputSchema: {
1097
- type: "object",
1098
- properties: {
1099
- name: { type: "string", description: "Schema name to delete" },
1100
- },
1101
- required: ["name"],
1102
- },
1103
- execute: async (params) => {
1104
- const name = params.name as string;
1105
- const deleted = await store.deleteNoteSchema(name);
1106
- return { deleted, name };
1107
- },
1108
- },
1109
-
1110
- // =====================================================================
1111
- // 7d. list-schema-mappings — read mapping rules
1112
- // =====================================================================
1113
- {
1114
- name: "list-schema-mappings",
1115
- description: "List schema mapping rules (path_prefix or tag → schema_name). Optionally filter by `schema_name` or `match_kind`. Mappings decide which schemas apply to a note at validation time.",
1116
- inputSchema: {
1117
- type: "object",
1118
- properties: {
1119
- schema_name: { type: "string", description: "Restrict to mappings for this schema" },
1120
- match_kind: { type: "string", enum: [...MAPPING_KINDS], description: "Restrict to one match kind" },
1121
- },
1122
- },
1123
- execute: async (params) => {
1124
- const opts: { schema_name?: string; match_kind?: SchemaMappingKind } = {};
1125
- if (typeof params.schema_name === "string") opts.schema_name = params.schema_name;
1126
- if (typeof params.match_kind === "string") opts.match_kind = params.match_kind as SchemaMappingKind;
1127
- return await store.listSchemaMappings(opts);
1128
- },
1129
- },
1130
-
1131
- // =====================================================================
1132
- // 7e. set-schema-mapping — add a mapping rule
1133
- // =====================================================================
1134
- {
1135
- name: "set-schema-mapping",
1136
- description: "Bind a schema to a path-prefix or tag. Idempotent — re-setting the same triple is a no-op. The schema must already exist (FK enforced). E.g., {schema_name: 'meeting', match_kind: 'path_prefix', match_value: 'Meetings/'} or {schema_name: 'project', match_kind: 'tag', match_value: 'project'}.",
1137
- inputSchema: {
1138
- type: "object",
1139
- properties: {
1140
- schema_name: { type: "string", description: "Schema name to bind" },
1141
- match_kind: { type: "string", enum: [...MAPPING_KINDS], description: "Match by path prefix or by tag" },
1142
- match_value: { type: "string", description: "The path prefix or tag value to match" },
1143
- },
1144
- required: ["schema_name", "match_kind", "match_value"],
1145
- },
1146
- execute: async (params) => {
1147
- const schema_name = params.schema_name as string;
1148
- const match_kind = params.match_kind as SchemaMappingKind;
1149
- const match_value = params.match_value as string;
1150
- await store.setSchemaMapping(schema_name, match_kind, match_value);
1151
- return { ok: true, schema_name, match_kind, match_value };
1152
- },
1153
- },
1154
-
1155
- // =====================================================================
1156
- // 7f. delete-schema-mapping — remove a mapping rule
1157
- // =====================================================================
1158
- {
1159
- name: "delete-schema-mapping",
1160
- description: "Remove a single schema mapping rule. The schema definition is untouched.",
1161
- inputSchema: {
1162
- type: "object",
1163
- properties: {
1164
- schema_name: { type: "string", description: "Schema name" },
1165
- match_kind: { type: "string", enum: [...MAPPING_KINDS], description: "Match kind" },
1166
- match_value: { type: "string", description: "The path prefix or tag value to remove" },
1167
- },
1168
- required: ["schema_name", "match_kind", "match_value"],
1169
- },
1170
- execute: async (params) => {
1171
- const schema_name = params.schema_name as string;
1172
- const match_kind = params.match_kind as SchemaMappingKind;
1173
- const match_value = params.match_value as string;
1174
- const deleted = await store.deleteSchemaMapping(schema_name, match_kind, match_value);
1175
- return { deleted, schema_name, match_kind, match_value };
1176
- },
1177
- },
1178
-
1179
1023
  // =====================================================================
1180
1024
  // 8. find-path — BFS between two notes
1181
1025
  // =====================================================================
@@ -1201,245 +1045,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1201
1045
  },
1202
1046
 
1203
1047
  // =====================================================================
1204
- // 9. synthesize-notesgather a coherent neighborhood for a topic
1205
- // =====================================================================
1206
- {
1207
- name: "synthesize-notes",
1208
- description: `Gather the notes that, taken together, tell the story of a topic — for the agent to read and synthesize.
1209
-
1210
- This is the graph-aware sibling of \`query-notes\`. Where \`query-notes\` returns flat matches, \`synthesize-notes\` pulls a *neighborhood*: anchor + linked notes + search hits + tag distribution + an oldest-first timeline, so the agent can write a coherent narrative without needing 4 separate calls.
1211
-
1212
- **Inputs.** Pass at least one of \`anchor\` (note ID/path to seed graph traversal) or \`query\` (FTS search string). Optionally narrow with \`scope.tags\` / \`scope.path\` (path prefix). \`depth\` (1–3, default 2) caps anchor traversal hops. \`limit\` (default 25, max 50) caps the returned note count.
1213
-
1214
- **What you get back.**
1215
- - \`notes\`: ranked candidates with \`sources\` (which seed brought them in: \`anchor\` / \`neighbor\` / \`search\`), \`distance\` (hops from anchor), and a short \`snippet\`. Pass \`include_content: true\` to inline the full note body.
1216
- - \`connections\`: direct links between notes in the result set — the edge structure of the neighborhood.
1217
- - \`tags\`: tag distribution across the result set (count desc) — quickly shows the conceptual axes.
1218
- - \`timeline\`: the same notes sorted oldest → newest by \`created_at\` — surfaces evolution of the topic.
1219
- - \`truncated\`: true when more candidates were available than \`limit\` allowed.
1220
-
1221
- **Synthesis is the caller's job.** The vault returns *what to read*; the agent writes the narrative. No LLM call is made server-side.`,
1222
- inputSchema: {
1223
- type: "object",
1224
- properties: {
1225
- anchor: { type: "string", description: "Note ID or path to seed graph traversal. Optional if `query` is set." },
1226
- query: { type: "string", description: "Full-text search query. Optional if `anchor` is set." },
1227
- scope: {
1228
- type: "object",
1229
- properties: {
1230
- tags: { type: "array", items: { type: "string" }, description: "Restrict to notes carrying any of these tags." },
1231
- path: { type: "string", description: "Restrict to notes whose path starts with this prefix (case-insensitive)." },
1232
- },
1233
- description: "Optional filters applied after seeding.",
1234
- },
1235
- depth: { type: "number", description: "Max graph hops from anchor (1–3, default 2). Ignored when no anchor is set." },
1236
- limit: { type: "number", description: "Max notes returned (default 25, hard cap 50)." },
1237
- include_content: { type: "boolean", description: "Inline full note content (default false — only a short snippet is included)." },
1238
- },
1239
- },
1240
- execute: async (params) => {
1241
- const anchorParam = typeof params.anchor === "string" && params.anchor.trim() ? params.anchor.trim() : null;
1242
- const queryParam = typeof params.query === "string" && params.query.trim() ? params.query.trim() : null;
1243
- if (!anchorParam && !queryParam) {
1244
- return { error: "synthesize-notes requires at least one of `anchor` or `query`." };
1245
- }
1246
-
1247
- const depth = Math.max(1, Math.min((params.depth as number | undefined) ?? 2, 3));
1248
- const limit = Math.max(1, Math.min((params.limit as number | undefined) ?? 25, 50));
1249
- const includeContent = params.include_content === true;
1250
- const scope = (params.scope as { tags?: string[]; path?: string } | undefined) ?? {};
1251
- const scopeTags = Array.isArray(scope.tags) && scope.tags.length > 0 ? scope.tags : null;
1252
- const scopePathPrefix = typeof scope.path === "string" && scope.path.trim() ? scope.path.trim().toLowerCase() : null;
1253
-
1254
- // Pre-resolve the anchor so a bad ID/path errors out cheaply.
1255
- let anchorNote: Note | null = null;
1256
- if (anchorParam) {
1257
- anchorNote = resolveNote(db, anchorParam);
1258
- if (!anchorNote) {
1259
- return { error: "Anchor note not found", anchor: anchorParam };
1260
- }
1261
- }
1262
-
1263
- // ----- Candidate seeding -----
1264
- // Each candidate tracks every signal that surfaced it (so the agent
1265
- // can see whether a note came from the search hit, the graph, or
1266
- // both) plus enough provenance to score it.
1267
- type Candidate = {
1268
- sources: Set<"anchor" | "neighbor" | "search">;
1269
- distance: number | null; // hops from anchor; null if not on the graph
1270
- ftsRank: number | null; // 0 = best FTS hit; null if not a search hit
1271
- };
1272
- const candidates = new Map<string, Candidate>();
1273
-
1274
- const upsert = (id: string, patch: Partial<Candidate> & { source: "anchor" | "neighbor" | "search" }): void => {
1275
- const existing = candidates.get(id);
1276
- if (!existing) {
1277
- candidates.set(id, {
1278
- sources: new Set([patch.source]),
1279
- distance: patch.distance ?? null,
1280
- ftsRank: patch.ftsRank ?? null,
1281
- });
1282
- return;
1283
- }
1284
- existing.sources.add(patch.source);
1285
- if (patch.distance !== undefined && patch.distance !== null) {
1286
- existing.distance = existing.distance === null ? patch.distance : Math.min(existing.distance, patch.distance);
1287
- }
1288
- if (patch.ftsRank !== undefined && patch.ftsRank !== null) {
1289
- existing.ftsRank = existing.ftsRank === null ? patch.ftsRank : Math.min(existing.ftsRank, patch.ftsRank);
1290
- }
1291
- };
1292
-
1293
- if (anchorNote) {
1294
- upsert(anchorNote.id, { source: "anchor", distance: 0 });
1295
- const traversed = linkOps.traverseLinks(db, anchorNote.id, { max_depth: depth });
1296
- for (const t of traversed) upsert(t.noteId, { source: "neighbor", distance: t.depth });
1297
- }
1298
-
1299
- if (queryParam) {
1300
- // Cap the FTS pull at 2× limit so the post-scope filter still leaves
1301
- // enough headroom to fill the result set with real hits.
1302
- // Direct noteOps.searchNotes (no tag-hierarchy expansion) is intentional
1303
- // here — synthesize-notes uses the FTS result only as a candidate seed,
1304
- // and scope filtering happens post-hydration. Don't route through the
1305
- // store.searchNotes wrapper for this specific tool.
1306
- const searchHits = noteOps.searchNotes(db, queryParam, { limit: Math.min(limit * 2, 100) });
1307
- searchHits.forEach((n, idx) => upsert(n.id, { source: "search", ftsRank: idx }));
1308
- }
1309
-
1310
- // ----- Hydrate + scope filter -----
1311
- const ids = [...candidates.keys()];
1312
- const noteMap = new Map<string, Note>();
1313
- for (const id of ids) {
1314
- const n = noteOps.getNote(db, id);
1315
- if (n) noteMap.set(id, n);
1316
- }
1317
-
1318
- const passesScope = (note: Note): boolean => {
1319
- if (scopeTags) {
1320
- const tags = note.tags ?? [];
1321
- if (!scopeTags.some((t) => tags.includes(t))) return false;
1322
- }
1323
- if (scopePathPrefix) {
1324
- const p = (note.path ?? "").toLowerCase();
1325
- if (!p.startsWith(scopePathPrefix)) return false;
1326
- }
1327
- return true;
1328
- };
1329
-
1330
- const inScope: { id: string; note: Note; cand: Candidate }[] = [];
1331
- for (const [id, cand] of candidates) {
1332
- const note = noteMap.get(id);
1333
- if (!note) continue;
1334
- if (!passesScope(note)) continue;
1335
- inScope.push({ id, note, cand });
1336
- }
1337
-
1338
- // ----- Score + rank -----
1339
- // Heuristic: anchor wins outright (5), search hits decay with FTS rank
1340
- // toward 0 (max ≈ 3), graph proximity contributes 0–3 (1 hop = 2,
1341
- // 2 hops = 1). Multi-source notes naturally rise — both axes add up.
1342
- const scoreOf = (c: Candidate): number => {
1343
- let s = 0;
1344
- if (c.sources.has("anchor")) s += 5;
1345
- if (c.sources.has("search") && c.ftsRank !== null) {
1346
- const decay = Math.max(0, 1 - c.ftsRank / 50);
1347
- s += 3 * decay;
1348
- }
1349
- if (c.sources.has("neighbor") && c.distance !== null) {
1350
- s += Math.max(0, 3 - c.distance);
1351
- }
1352
- return s;
1353
- };
1354
-
1355
- inScope.sort((a, b) => {
1356
- const sa = scoreOf(a.cand);
1357
- const sb = scoreOf(b.cand);
1358
- if (sb !== sa) return sb - sa;
1359
- // Tie-break on recency so the agent surfaces the freshest take.
1360
- return (b.note.updatedAt ?? b.note.createdAt).localeCompare(a.note.updatedAt ?? a.note.createdAt);
1361
- });
1362
-
1363
- const truncated = inScope.length > limit;
1364
- const top = inScope.slice(0, limit);
1365
-
1366
- // ----- Snippet (cheap: first ~200 chars of content, single-line) -----
1367
- const snippetOf = (content: string): string => {
1368
- const flat = content.replace(/\s+/g, " ").trim();
1369
- return flat.length > 200 ? `${flat.slice(0, 197)}...` : flat;
1370
- };
1371
-
1372
- const notesOut = top.map(({ id, note, cand }) => {
1373
- const out: Record<string, unknown> = {
1374
- id,
1375
- path: note.path ?? null,
1376
- tags: note.tags ?? [],
1377
- created_at: note.createdAt,
1378
- updated_at: note.updatedAt ?? null,
1379
- sources: [...cand.sources],
1380
- score: Number(scoreOf(cand).toFixed(3)),
1381
- };
1382
- if (cand.distance !== null) out.distance = cand.distance;
1383
- if (cand.ftsRank !== null) out.fts_rank = cand.ftsRank;
1384
- if (includeContent) {
1385
- out.content = note.content;
1386
- } else {
1387
- out.snippet = snippetOf(note.content);
1388
- }
1389
- return out;
1390
- });
1391
-
1392
- // ----- Connections (direct links among returned notes only) -----
1393
- const idSet = new Set(top.map((t) => t.id));
1394
- const connections: { source: string; target: string; relationship: string }[] = [];
1395
- if (idSet.size > 1) {
1396
- const placeholders = [...idSet].map(() => "?").join(",");
1397
- const rows = db.prepare(
1398
- `SELECT source_id, target_id, relationship FROM links
1399
- WHERE source_id IN (${placeholders}) AND target_id IN (${placeholders})`,
1400
- ).all(...idSet, ...idSet) as { source_id: string; target_id: string; relationship: string }[];
1401
- for (const r of rows) {
1402
- connections.push({ source: r.source_id, target: r.target_id, relationship: r.relationship });
1403
- }
1404
- }
1405
-
1406
- // ----- Tag distribution + timeline -----
1407
- const tagCounts = new Map<string, number>();
1408
- for (const { note } of top) {
1409
- for (const t of note.tags ?? []) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1);
1410
- }
1411
- const tags = [...tagCounts.entries()]
1412
- .map(([name, count]) => ({ name, count }))
1413
- .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
1414
-
1415
- const timeline = [...top]
1416
- .sort((a, b) => a.note.createdAt.localeCompare(b.note.createdAt))
1417
- .map(({ id, note }) => ({ id, created_at: note.createdAt }));
1418
-
1419
- return {
1420
- topic: {
1421
- ...(anchorNote ? { anchor: { id: anchorNote.id, path: anchorNote.path ?? null } } : {}),
1422
- ...(queryParam ? { query: queryParam } : {}),
1423
- },
1424
- notes: notesOut,
1425
- connections,
1426
- tags,
1427
- timeline,
1428
- truncated,
1429
- };
1430
- },
1431
- },
1432
-
1433
- // =====================================================================
1434
- // 10. vault-info — get/update vault description + stats
1048
+ // 9. vault-infoget/update vault description + stats
1435
1049
  // =====================================================================
1436
1050
  {
1437
1051
  name: "vault-info",
1438
- description: "Get vault description and optionally stats (note/tag/link counts, distribution). Pass `description` to update the vault description (changes how AI agents behave in future sessions).",
1052
+ description: "Get a comprehensive vault projection: name, description, tags-with-schemas (own + effective parents/fields per #270 inheritance), indexed metadata fields catalog, and query hints. Pass `include_stats: true` to add note/tag/link counts and the monthly distribution. Pass `description` to update the vault description (changes how AI agents behave in future sessions). Call this anytime mid-session to refresh schema context.",
1439
1053
  inputSchema: {
1440
1054
  type: "object",
1441
1055
  properties: {
1442
- include_stats: { type: "boolean", description: "Include note count, tag count, distribution by month (default: false)" },
1056
+ include_stats: { type: "boolean", description: "Include note count, tag count, attachment/link counts, and the monthly note distribution (default: false)" },
1443
1057
  description: { type: "string", description: "If provided, updates the vault description" },
1444
1058
  },
1445
1059
  },
@@ -1502,24 +1116,21 @@ function defaultForField(field: { type: string; enum?: string[] }): unknown {
1502
1116
  }
1503
1117
 
1504
1118
  // ---------------------------------------------------------------------------
1505
- // `_schemas/*` validation — surface validation_status on create/update
1119
+ // `tags.fields` validation — surface validation_status on create/update
1506
1120
  // ---------------------------------------------------------------------------
1507
1121
 
1508
1122
  /**
1509
- * Attach a `validation_status` field to the response when one or more
1510
- * `_schemas/*` config notes match this note's path or tags. Validation is
1511
- * advisory only — writes are never blocked. The agent receives warnings
1512
- * (missing required, type mismatch, enum mismatch) so it can self-correct
1513
- * on the next turn.
1123
+ * Attach a `validation_status` field to the response when at least one tag
1124
+ * on the note declares `fields` on its `tags` row. Validation is advisory
1125
+ * only — writes are never blocked. The agent receives warnings (type
1126
+ * mismatch, enum mismatch) so it can self-correct on the next turn.
1514
1127
  *
1515
- * Returns the note unchanged when no schemas apply, so callers without
1516
- * `_schemas/*` config see no behavior change.
1128
+ * Returns the note unchanged when no tag declares fields, so callers
1129
+ * without any tag schemas see no behavior change.
1517
1130
  */
1518
1131
  function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
1519
- // Short-circuit cheaply: when no `_schemas/*` notes are configured, the
1520
- // resolver returns null without us paying a re-read of the note. The
1521
- // re-read used to happen up-front and was wasteful on every write in
1522
- // vaults that don't use schemas at all.
1132
+ // Short-circuit cheaply: when no tag declares fields, the resolver
1133
+ // returns null without us paying a re-read of the note.
1523
1134
  const status = store.validateNoteAgainstSchemas({
1524
1135
  path: note.path,
1525
1136
  tags: note.tags,