@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/README.md +133 -0
- package/core/src/core.test.ts +1171 -518
- package/core/src/mcp.ts +37 -426
- package/core/src/notes.ts +405 -32
- package/core/src/schema-defaults.ts +214 -170
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +90 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +37 -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/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +313 -206
- 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 +875 -297
- 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,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
|
-
|
|
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.
|
|
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-info — get/update vault description + stats
|
|
1435
1049
|
// =====================================================================
|
|
1436
1050
|
{
|
|
1437
1051
|
name: "vault-info",
|
|
1438
|
-
description: "Get vault description and
|
|
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,
|
|
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
|
-
// `
|
|
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
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
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
|
|
1516
|
-
*
|
|
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
|
|
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.
|
|
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,
|