@openparachute/vault 0.4.0 → 0.4.4-rc.11

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,9 @@ 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
+ - **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.
473
+ - \`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
474
  inputSchema: {
472
475
  type: "object",
473
476
  properties: {
@@ -489,6 +492,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
489
492
  created_at: { type: "string", description: "New created_at timestamp" },
490
493
  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." },
491
494
  force: { type: "boolean", description: "Override the required `if_updated_at` check and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe." },
495
+ 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." },
492
496
  tags: {
493
497
  type: "object",
494
498
  properties: {
@@ -525,6 +529,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
525
529
  },
526
530
  description: "Links to add/remove",
527
531
  },
532
+ include_content: {
533
+ type: "boolean",
534
+ 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.",
535
+ },
528
536
  // Batch
529
537
  notes: {
530
538
  type: "array",
@@ -548,6 +556,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
548
556
  created_at: { type: "string" },
549
557
  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." },
550
558
  force: { type: "boolean", description: "Override the required `if_updated_at` check for this item." },
559
+ if_missing: { type: "string", enum: ["fail", "create"], description: "Per-item: see top-level `if_missing` docs. Each batch item carries its own setting." },
551
560
  tags: { type: "object" },
552
561
  links: { type: "object" },
553
562
  },
@@ -566,6 +575,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
566
575
  }
567
576
 
568
577
  const updated: Note[] = [];
578
+ // Track which note IDs were freshly created via `if_missing: "create"`
579
+ // so the response can carry `created: true|false` per-note. The
580
+ // sync-loop caller (Gitcoin Brain et al) reads this to know which
581
+ // path fired without doing a separate query. vault#309.
582
+ const createdIds = new Set<string>();
569
583
  // Wrap multi-item batches in a SQLite transaction so any mid-batch
570
584
  // failure (precondition error, content_edit miss, ConflictError, …)
571
585
  // rolls back every prior mutation in the batch — see #236.
@@ -575,7 +589,77 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
575
589
  if (batched) db.exec("BEGIN");
576
590
  try {
577
591
  for (const item of items) {
578
- const note = requireNote(db, item.id as string);
592
+ // Try ID-then-path resolve. If not found AND
593
+ // `if_missing: "create"` is set, fall through to the create
594
+ // branch using this same item's payload. Otherwise mirror the
595
+ // existing `requireNote` behavior (throw "Note not found").
596
+ // vault#309.
597
+ const resolved = resolveNote(db, item.id as string);
598
+ if (!resolved) {
599
+ if (item.if_missing === "create") {
600
+ // Treat the update payload as a create payload. Minimum:
601
+ // content OR a path/id (something the createNote-empty-row
602
+ // invariant accepts). createNote enforces its own
603
+ // not-both-empty check — we leave that to the Store and
604
+ // surface any error to the caller verbatim.
605
+ //
606
+ // Field mapping (mirrors the create-note tool surface):
607
+ // - `item.id` → both the note's `id` AND a fallback
608
+ // `path` when `item.path` isn't set. Treating `id` as
609
+ // the path-or-id lookup key matches Gitcoin's nightly
610
+ // sync shape where the canonical key is a path string
611
+ // like "Inbox/2026-05-13-meeting". If the caller
612
+ // supplied an opaque ULID as `id` and no `path`, we
613
+ // still create with that as `id` (path stays null).
614
+ // - `item.content` / `item.path` / `item.tags` /
615
+ // `item.metadata` / `item.created_at` → forwarded.
616
+ // - `if_updated_at` / `force` / `content_edit` /
617
+ // `append` / `prepend` / `links` are
618
+ // update-only — silently ignored on the create branch.
619
+ // (Content-edit on a non-existent note is a nonsense
620
+ // combination; the caller's intent on missing-note is
621
+ // "create the row", not "patch in this section".)
622
+ const idOrPath = item.id as string;
623
+ // Heuristic: if `path` isn't set AND the `id` looks like a
624
+ // path (contains "/" or doesn't match a typical opaque-id
625
+ // shape), use it as the path too. Otherwise treat it as a
626
+ // pure id. The shared `id` field for update is ID-or-path
627
+ // already (see `resolveNote`), so this preserves the
628
+ // caller's intent.
629
+ const idLooksLikePath = idOrPath.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPath);
630
+ const explicitPath = typeof item.path === "string" ? item.path as string : undefined;
631
+ const createOpts: Parameters<Store["createNote"]>[1] = {
632
+ ...(idLooksLikePath ? { path: explicitPath ?? idOrPath } : { id: idOrPath, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
633
+ ...(item.tags && Array.isArray((item.tags as any).add)
634
+ ? { tags: (item.tags as any).add as string[] }
635
+ : Array.isArray(item.tags)
636
+ ? { tags: item.tags as string[] }
637
+ : {}),
638
+ ...(item.metadata !== undefined ? { metadata: item.metadata as Record<string, unknown> } : {}),
639
+ ...(item.created_at !== undefined ? { created_at: item.created_at as string } : {}),
640
+ };
641
+ const content = (item.content as string | undefined) ?? "";
642
+ const created = await store.createNote(content, createOpts);
643
+ await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
644
+ // Apply links.add if the caller declared any.
645
+ const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
646
+ if (linksAdd) {
647
+ for (const link of linksAdd) {
648
+ const target = resolveNote(db, link.target);
649
+ if (target) await store.createLink(created.id, target.id, link.relationship, link.metadata);
650
+ }
651
+ }
652
+ const fresh = noteOps.getNote(db, created.id) ?? created;
653
+ updated.push(fresh);
654
+ createdIds.add(fresh.id);
655
+ continue;
656
+ }
657
+ // Fallthrough: not-found + no if_missing → existing error
658
+ // contract. Match `requireNote`'s message shape so existing
659
+ // callers see no behavior change.
660
+ throw new Error(`Note not found: "${item.id}"`);
661
+ }
662
+ const note = resolved;
579
663
 
580
664
  // --- Validate mutual exclusion of content modes ---
581
665
  const hasContent = item.content !== undefined;
@@ -736,7 +820,26 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
736
820
  throw e;
737
821
  }
738
822
 
739
- const final = updated.map((n) => attachValidationStatus(store, db, n));
823
+ // Response shape: full Note (back-compat default) or lean NoteIndex
824
+ // (#285 friction point 2.response — opt-out for callers making
825
+ // frequent small edits to large notes). `validation_status` from
826
+ // `tags.fields` is preserved across either shape. `created: true|false`
827
+ // (vault#309) is attached to every response so callers using
828
+ // `if_missing: "create"` can tell which branch fired without a
829
+ // separate query. `false` for the (overwhelmingly common) update
830
+ // path; `true` only when this call took the create-on-missing
831
+ // branch.
832
+ const includeContent = params.include_content !== false;
833
+ const final = updated.map((n) => {
834
+ const validated = attachValidationStatus(store, db, n);
835
+ const created = createdIds.has(n.id);
836
+ if (includeContent) return { ...validated, created } as Note & { created: boolean };
837
+ const lean: any = noteOps.toNoteIndex(validated);
838
+ const vs = (validated as any).validation_status;
839
+ if (vs !== undefined) lean.validation_status = vs;
840
+ lean.created = created;
841
+ return lean;
842
+ });
740
843
  return batch ? final : final[0];
741
844
  },
742
845
  },
@@ -1002,180 +1105,6 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1002
1105
  },
1003
1106
  },
1004
1107
 
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
1108
  // =====================================================================
1180
1109
  // 8. find-path — BFS between two notes
1181
1110
  // =====================================================================
@@ -1201,245 +1130,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1201
1130
  },
1202
1131
 
1203
1132
  // =====================================================================
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
1133
+ // 9. vault-infoget/update vault description + stats
1435
1134
  // =====================================================================
1436
1135
  {
1437
1136
  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).",
1137
+ 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
1138
  inputSchema: {
1440
1139
  type: "object",
1441
1140
  properties: {
1442
- include_stats: { type: "boolean", description: "Include note count, tag count, distribution by month (default: false)" },
1141
+ include_stats: { type: "boolean", description: "Include note count, tag count, attachment/link counts, and the monthly note distribution (default: false)" },
1443
1142
  description: { type: "string", description: "If provided, updates the vault description" },
1444
1143
  },
1445
1144
  },
@@ -1502,24 +1201,26 @@ function defaultForField(field: { type: string; enum?: string[] }): unknown {
1502
1201
  }
1503
1202
 
1504
1203
  // ---------------------------------------------------------------------------
1505
- // `_schemas/*` validation — surface validation_status on create/update
1204
+ // `tags.fields` validation — surface validation_status on create/update
1506
1205
  // ---------------------------------------------------------------------------
1507
1206
 
1508
1207
  /**
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.
1208
+ * Attach a `validation_status` field to the response when at least one tag
1209
+ * on the note declares `fields` on its `tags` row. Validation is advisory
1210
+ * only — writes are never blocked. The agent receives warnings (type
1211
+ * mismatch, enum mismatch) so it can self-correct on the next turn.
1212
+ *
1213
+ * Returns the note unchanged when no tag declares fields, so callers
1214
+ * without any tag schemas see no behavior change.
1514
1215
  *
1515
- * Returns the note unchanged when no schemas apply, so callers without
1516
- * `_schemas/*` config see no behavior change.
1216
+ * Exported so both transports (MCP `update-note` here, HTTP `PATCH
1217
+ * /api/notes/:id` in `src/routes.ts`) attach the same status field by
1218
+ * the same recipe — see vault#287 for the asymmetry that motivated
1219
+ * exposing it.
1517
1220
  */
1518
- 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.
1221
+ export function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
1222
+ // Short-circuit cheaply: when no tag declares fields, the resolver
1223
+ // returns null without us paying a re-read of the note.
1523
1224
  const status = store.validateNoteAgainstSchemas({
1524
1225
  path: note.path,
1525
1226
  tags: note.tags,