@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/README.md +191 -2
- package/core/src/core.test.ts +1295 -526
- package/core/src/mcp.ts +129 -428
- package/core/src/notes.ts +405 -32
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +233 -171
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +103 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +52 -42
- package/core/src/vault-projection.ts +309 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +142 -13
- package/src/auth.ts +29 -0
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- package/src/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +330 -207
- package/src/routing.test.ts +12 -12
- package/src/routing.ts +0 -2
- package/src/tokens-routes.test.ts +11 -4
- package/src/vault.test.ts +1052 -333
- package/core/src/note-schemas.ts +0 -232
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
|
|
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)
|
|
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
|
-
// `
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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-info — get/update vault description + stats
|
|
1435
1134
|
// =====================================================================
|
|
1436
1135
|
{
|
|
1437
1136
|
name: "vault-info",
|
|
1438
|
-
description: "Get vault description and
|
|
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,
|
|
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
|
-
// `
|
|
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
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
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
|
-
*
|
|
1516
|
-
* `
|
|
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
|
|
1520
|
-
//
|
|
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,
|