@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/types.ts CHANGED
@@ -1,23 +1,8 @@
1
1
  import type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
2
- import type {
3
- NoteSchemaField,
4
- NoteSchemaRecord,
5
- NoteSchemaPatch,
6
- SchemaMappingKind,
7
- SchemaMappingRecord,
8
- ListMappingsOpts,
9
- } from "./note-schemas.js";
10
2
 
11
3
  // ---- Re-exports ----
12
4
 
13
5
  export type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
14
- export type {
15
- NoteSchemaField,
16
- NoteSchemaRecord,
17
- NoteSchemaPatch,
18
- SchemaMappingKind,
19
- SchemaMappingRecord,
20
- } from "./note-schemas.js";
21
6
 
22
7
  // ---- Note ----
23
8
 
@@ -95,12 +80,14 @@ export interface QueryOpts {
95
80
  // as the common path; specifying both this and `dateFilter` rejects.
96
81
  dateFrom?: string; // ISO date
97
82
  dateTo?: string; // ISO date
98
- // Generalized date range. `field` defaults to `created_at`; any other
99
- // field must be declared `indexed: true` in a tag schema (so the SQL
100
- // hits a real B-tree index, same contract as `metadata` operator
101
- // queries and `orderBy`). Use this to filter on a *content* date an
102
- // email's received date, a meeting's scheduled date rather than the
103
- // ingestion timestamp.
83
+ // Generalized date range. `field` defaults to `created_at`; `updated_at`
84
+ // is also a recognized real column (the incremental-rebuild path
85
+ // vault#285 1.5). Any other field must be declared `indexed: true` in a
86
+ // tag schema (so the SQL hits a real B-tree index, same contract as
87
+ // `metadata` operator queries and `orderBy`). Use this to filter on a
88
+ // *content* date — an email's received date, a meeting's scheduled
89
+ // date — rather than the ingestion timestamp, or on `updated_at` to ask
90
+ // "what changed since X."
104
91
  dateFilter?: {
105
92
  field?: string;
106
93
  from?: string;
@@ -156,6 +143,21 @@ export interface Store {
156
143
  getNoteByPath(path: string): Promise<Note | null>;
157
144
  getNotes(ids: string[]): Promise<Note[]>;
158
145
  updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; if_updated_at?: string }): Promise<Note>;
146
+ /**
147
+ * Set a note's `created_at` and `updated_at` explicitly. Import-only:
148
+ * used by the portable-md round-trip path to restore timestamps from
149
+ * the export bytes. The regular `updateNote` either bumps `updated_at`
150
+ * to wall-clock-now or (with `skipUpdatedAt: true`) leaves it
151
+ * untouched — neither shape lets the importer write a specific
152
+ * historical timestamp. Bypasses hooks. See vault#308 PR 2.
153
+ */
154
+ restoreNoteTimestamps(id: string, createdAt: string, updatedAt: string): Promise<void>;
155
+ /**
156
+ * Sync wikilinks for every note in the vault. Cheap O(n) walk; used
157
+ * after bulk-imports to rebuild link rows from `[[brackets]]` in
158
+ * content. Returns counts for caller logging.
159
+ */
160
+ syncAllWikilinks(): Promise<{ synced: number; totalAdded: number; totalRemoved: number }>;
159
161
  deleteNote(id: string): Promise<void>;
160
162
  queryNotes(opts: QueryOpts): Promise<Note[]>;
161
163
  searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Promise<Note[]>;
@@ -175,7 +177,19 @@ export interface Store {
175
177
  renameTag(
176
178
  oldName: string,
177
179
  newName: string,
178
- ): Promise<{ renamed: number } | { error: "not_found" } | { error: "target_exists" }>;
180
+ ): Promise<
181
+ | {
182
+ renamed: number;
183
+ sub_tags_renamed: number;
184
+ parent_refs_updated: number;
185
+ tokens_updated: number;
186
+ indexed_field_declarers_updated: number;
187
+ notes_rewritten: number;
188
+ paths_renamed: number;
189
+ }
190
+ | { error: "not_found" }
191
+ | { error: "target_exists"; conflicting: string[] }
192
+ >;
179
193
  mergeTags(
180
194
  sources: string[],
181
195
  target: string,
@@ -228,30 +242,26 @@ export interface Store {
228
242
  },
229
243
  ): Promise<TagRecord>;
230
244
 
231
- // Schema validation (post-v15: backed by `note_schemas` + `schema_mappings`
232
- // tables). Returns null when no schema applies to the given note. The
233
- // underlying resolver is in-memory after the first lazy load.
245
+ // Schema validation (post-v17: backed by `tags.fields` only — the
246
+ // standalone note_schemas + schema_mappings subsystem retired in v17, see
247
+ // vault#267). Post vault#270 the resolver walks `parent_names` so a note's
248
+ // effective fields include all ancestors' declarations (first-in-walk wins
249
+ // on conflict, surfaced as `schema_conflict` warnings); a tag named
250
+ // `_default` is the implicit universal parent. Returns null when no
251
+ // ancestor declares any fields. The underlying resolver is in-memory after
252
+ // the first lazy load.
234
253
  validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }): {
235
254
  schemas: string[];
236
- warnings: { field: string; schema: string; reason: "missing_required" | "type_mismatch" | "enum_mismatch"; message: string }[];
255
+ warnings: {
256
+ field: string;
257
+ schema: string;
258
+ reason: "type_mismatch" | "enum_mismatch" | "schema_conflict";
259
+ message: string;
260
+ /** Set only on `schema_conflict` — the tag whose declaration was overridden. */
261
+ loser_schema?: string;
262
+ }[];
237
263
  } | null;
238
264
 
239
- // Note schemas (post-v15 — the writable surface that drives validation).
240
- listNoteSchemas(): Promise<NoteSchemaRecord[]>;
241
- getNoteSchema(name: string): Promise<NoteSchemaRecord | null>;
242
- /**
243
- * Partial-upsert. Auto-creates the row if missing. Any patch field left
244
- * undefined is preserved; pass null to clear. Empty `required: []`
245
- * collapses to null.
246
- */
247
- upsertNoteSchema(name: string, patch: NoteSchemaPatch): Promise<NoteSchemaRecord>;
248
- deleteNoteSchema(name: string): Promise<boolean>;
249
-
250
- // Schema mappings (post-v15 — replaces the singleton `_schema_defaults`).
251
- listSchemaMappings(opts?: ListMappingsOpts): Promise<SchemaMappingRecord[]>;
252
- setSchemaMapping(schema_name: string, match_kind: SchemaMappingKind, match_value: string): Promise<void>;
253
- deleteSchemaMapping(schema_name: string, match_kind: SchemaMappingKind, match_value: string): Promise<boolean>;
254
-
255
265
  // Attachments
256
266
  addAttachment(noteId: string, path: string, mimeType: string, metadata?: Record<string, unknown>): Promise<Attachment>;
257
267
  getAttachments(noteId: string): Promise<Attachment[]>;
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Vault projection — computes a comprehensive description of the vault
3
+ * (tags-with-schemas + effective inheritance + indexed-field catalog +
4
+ * query hints) shared by two consumers:
5
+ *
6
+ * - `vault-info` MCP tool — returns the full projection as a JSON object
7
+ * so an agent can request a refresh mid-session.
8
+ * - `getServerInstruction` (MCP `initialize` response) — renders the
9
+ * same projection as a terse markdown brief sent once at connect.
10
+ *
11
+ * The projection lives outside the per-note path: it describes what kinds
12
+ * of notes and queries are available, not the contents. Nothing here
13
+ * depends on auth/scopes — both consumers compose this with vault config
14
+ * (name/description) and any policy-driven framing on top.
15
+ *
16
+ * See vault#271 for design notes.
17
+ */
18
+
19
+ import { Database } from "bun:sqlite";
20
+ import {
21
+ loadSchemaConfig,
22
+ resolveNoteSchemas,
23
+ walkAncestors,
24
+ type ResolvedSchemas,
25
+ type SchemaField,
26
+ } from "./schema-defaults.ts";
27
+ import { listIndexedFields } from "./indexed-fields.ts";
28
+ import {
29
+ listTagRecords,
30
+ type TagFieldSchema,
31
+ type TagRecord,
32
+ } from "./tag-schemas.ts";
33
+ import { DEFAULT_TAG_NAME } from "./tag-hierarchy.ts";
34
+ import * as noteOps from "./notes.ts";
35
+ import type { VaultStats } from "./types.ts";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Types
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export interface ProjectionTag {
42
+ name: string;
43
+ description: string | null;
44
+ /** Direct parents declared in `tags.parent_names` (verbatim, no walk). */
45
+ parents: string[];
46
+ /**
47
+ * Walk-order ancestor closure (parents → grandparents → …) including the
48
+ * implicit `_default` universal parent when present, with the tag itself
49
+ * excluded. Empty when the tag has no parents and no `_default` exists.
50
+ */
51
+ effective_parents: string[];
52
+ /**
53
+ * Own field declarations (verbatim from `tags.fields`). Carries the full
54
+ * `TagFieldSchema` shape — `type` (string), optional `description`,
55
+ * `enum`, `indexed`. Width matches the on-disk row.
56
+ */
57
+ fields: Record<string, TagFieldSchema> | null;
58
+ /**
59
+ * Merged field map = own ∪ inherited (first-in-walk wins, matching
60
+ * `resolveNoteSchemas`). Uses the `SchemaField` view returned by the
61
+ * resolver (narrower `type` enum). Empty when no ancestor — including
62
+ * the tag itself — declared anything.
63
+ */
64
+ effective_fields: Record<string, SchemaField>;
65
+ relationships: TagRecord["relationships"] | null;
66
+ }
67
+
68
+ export interface ProjectionIndexedField {
69
+ name: string;
70
+ /** User-facing field type ("string" | "integer" | "boolean") drawn from the first declarer. */
71
+ type: string;
72
+ tags: string[];
73
+ }
74
+
75
+ export interface VaultProjection {
76
+ tags: ProjectionTag[];
77
+ indexed_fields: ProjectionIndexedField[];
78
+ query_hints: string[];
79
+ /** Included when the caller requests stats; omitted otherwise. */
80
+ stats?: VaultStats;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Inheritance helpers (built on the #272 resolver)
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * Resolve a single tag's effective inheritance.
89
+ *
90
+ * Built on top of `resolveNoteSchemas({ tags: [tag] })` so the walk order
91
+ * and conflict precedence match the runtime validator exactly. Returns:
92
+ *
93
+ * - `effective_parents`: walk-order ancestor list with the tag itself
94
+ * excluded. Includes `_default` when a `_default` row exists, regardless
95
+ * of whether the tag declares it (universal-parent semantics).
96
+ * - `effective_fields`: merged field map (first-in-walk wins). When no
97
+ * ancestor contributes, this equals the tag's own `fields`.
98
+ */
99
+ export function resolveTagInheritance(
100
+ resolved: ResolvedSchemas,
101
+ tag: string,
102
+ ): { effective_parents: string[]; effective_fields: Record<string, SchemaField> } {
103
+ const resolution = resolveNoteSchemas(resolved, { tags: [tag] });
104
+
105
+ // resolveNoteSchemas returns effectiveTags (only fields-contributing tags).
106
+ // We need the full walk for effective_parents — replay using the same
107
+ // resolver helper so walk-order semantics stay in lockstep with #270.
108
+ const visited = new Set<string>();
109
+ const order: string[] = [];
110
+ walkAncestors(tag, resolved, visited, order);
111
+ if (resolved.allTags.has(DEFAULT_TAG_NAME) && !visited.has(DEFAULT_TAG_NAME)) {
112
+ walkAncestors(DEFAULT_TAG_NAME, resolved, visited, order);
113
+ }
114
+ const effective_parents = order.filter((t) => t !== tag);
115
+
116
+ const effective_fields: Record<string, SchemaField> = {};
117
+ for (const [field, { spec }] of resolution.mergedFields) {
118
+ effective_fields[field] = spec;
119
+ }
120
+
121
+ return { effective_parents, effective_fields };
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Projection
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Static query-hint catalog. Sent verbatim in both vault-info JSON and the
130
+ * connect-time markdown projection so an agent can self-orient without
131
+ * reading source. Edit here when query semantics change.
132
+ */
133
+ export const QUERY_HINTS: readonly string[] = [
134
+ "query-notes { tag: \"X\" } — all notes with tag X (includes descendants per inheritance)",
135
+ "query-notes { tag: \"X\", metadata: { field: { op: value } } } — operator queries on indexed fields (eq/ne/gt/gte/lt/lte/in/not_in/exists)",
136
+ "query-notes { search: \"...\" } — full-text search across content",
137
+ "query-notes { near: { id: \"...\" }, depth: 2 } — graph neighborhood within N hops",
138
+ "query-notes { id: \"<note-id-or-path>\" } — fetch a single note by ID or path",
139
+ ] as const;
140
+
141
+ /**
142
+ * Build the comprehensive vault projection. Pure read; no caches mutated.
143
+ *
144
+ * Shape rules:
145
+ *
146
+ * - `tags`: only tags carrying their own `description` or `fields`. A
147
+ * hierarchy-only tag (parent_names but no own schema) is omitted from
148
+ * the catalog — its semantics live in whichever ancestor contributes
149
+ * fields. `effective_fields` still surfaces the merged spec when the
150
+ * tag *does* appear (because it has its own description/fields).
151
+ *
152
+ * - `indexed_fields`: one entry per row in the `indexed_fields` table.
153
+ * The user-facing `type` is drawn from the first declarer's spec —
154
+ * declarers must agree on type (enforced at write time) so picking the
155
+ * first is unambiguous. `tags` lists every declarer, sorted.
156
+ *
157
+ * - `stats`: included when `opts.includeStats === true`. Uses the
158
+ * existing `getVaultStats` shape unchanged — camelCase keys, full
159
+ * monthly distribution.
160
+ */
161
+ export function buildVaultProjection(
162
+ db: Database,
163
+ opts?: { includeStats?: boolean },
164
+ ): VaultProjection {
165
+ const resolved = loadSchemaConfig(db);
166
+ const records = listTagRecords(db);
167
+
168
+ const tags: ProjectionTag[] = [];
169
+ for (const r of records) {
170
+ const hasOwnSchema =
171
+ (r.description !== undefined && r.description !== null) ||
172
+ (r.fields !== undefined && r.fields !== null && Object.keys(r.fields).length > 0);
173
+ if (!hasOwnSchema) continue;
174
+
175
+ const { effective_parents, effective_fields } = resolveTagInheritance(resolved, r.tag);
176
+
177
+ tags.push({
178
+ name: r.tag,
179
+ description: r.description ?? null,
180
+ parents: r.parent_names ?? [],
181
+ effective_parents,
182
+ fields: r.fields ?? null,
183
+ effective_fields,
184
+ relationships: r.relationships ?? null,
185
+ });
186
+ }
187
+
188
+ const indexedRows = listIndexedFields(db);
189
+ const indexed_fields: ProjectionIndexedField[] = indexedRows.map((row) => {
190
+ const declarers = [...row.declarerTags].sort();
191
+ // Recover the user-facing type from the first declarer's spec. Falls
192
+ // back to a sqlite-derived label if the declarer's row is gone (race;
193
+ // shouldn't happen because release drops the indexed_fields row, but
194
+ // robust against drift).
195
+ let userType = sqliteToUserType(row.sqliteType);
196
+ for (const t of declarers) {
197
+ const fields = resolved.tagToFields.get(t);
198
+ const declared = fields?.[row.field]?.type;
199
+ if (typeof declared === "string" && declared.length > 0) {
200
+ userType = declared;
201
+ break;
202
+ }
203
+ }
204
+ return { name: row.field, type: userType, tags: declarers };
205
+ });
206
+
207
+ const projection: VaultProjection = {
208
+ tags,
209
+ indexed_fields,
210
+ query_hints: [...QUERY_HINTS],
211
+ };
212
+
213
+ if (opts?.includeStats) {
214
+ projection.stats = noteOps.getVaultStats(db);
215
+ }
216
+
217
+ return projection;
218
+ }
219
+
220
+ function sqliteToUserType(t: string): string {
221
+ if (t === "TEXT") return "string";
222
+ if (t === "INTEGER") return "integer";
223
+ return t.toLowerCase();
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Markdown rendering — for getServerInstruction
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /**
231
+ * Render a vault projection as a terse markdown brief for the MCP
232
+ * `initialize` response. Keep dense — agents see this once at connect, and
233
+ * the goal is "everything an agent needs to start using the vault sensibly,
234
+ * with explicit pointers for refresh."
235
+ *
236
+ * Token budget guideline: ~600 tokens for a small vault (Aaron's, ~4 tags-
237
+ * with-schemas) and under ~5K tokens at 50 tags-with-schemas. Listing all
238
+ * tags-with-schemas inline is the default; cap heuristics can be added if
239
+ * a real test shape demands it.
240
+ */
241
+ export function projectionToMarkdown(args: {
242
+ vaultName: string;
243
+ description?: string | null;
244
+ projection: VaultProjection;
245
+ }): string {
246
+ const { vaultName, description, projection } = args;
247
+ const stats = projection.stats;
248
+
249
+ const lines: string[] = [];
250
+ lines.push(`You are connected to Parachute Vault "${vaultName}".`);
251
+ if (description && description.trim().length > 0) {
252
+ lines.push("");
253
+ lines.push(description.trim());
254
+ }
255
+
256
+ lines.push("");
257
+ lines.push("## Quick orientation (call `vault-info` for full schema)");
258
+ lines.push("");
259
+
260
+ if (stats) {
261
+ // Two distinct counts surface here so an agent doesn't conflate
262
+ // them (vault#274): `tagCount` is "tags ANY note uses" — driven by
263
+ // note_tags rows. `projection.tags.length` is "tags carrying a
264
+ // schema declaration" — strictly smaller and the relevant denominator
265
+ // for the schema-bearing list a few lines down. Showing only one
266
+ // hid the gap (e.g., 100 tags but only 5 with schemas read as
267
+ // "100 tags with schemas").
268
+ const noteCount = stats.totalNotes;
269
+ const tagCount = stats.tagCount;
270
+ const withSchemas = projection.tags.length;
271
+ const noteLabel = noteCount === 1 ? "note" : "notes";
272
+ const tagLabel = tagCount === 1 ? "tag" : "tags";
273
+ const tagSuffix = withSchemas > 0 ? `, ${withSchemas} with schemas` : "";
274
+ lines.push(`- ${noteCount} ${noteLabel}, ${tagCount} ${tagLabel} total${tagSuffix}`);
275
+ } else {
276
+ lines.push(`- (call \`vault-info { include_stats: true }\` for note/tag counts)`);
277
+ }
278
+
279
+ if (projection.tags.length === 0) {
280
+ lines.push(`- No tag schemas declared yet — every note is freeform.`);
281
+ } else {
282
+ const names = projection.tags.map((t) => t.name).join(", ");
283
+ lines.push(`- ${projection.tags.length} tag${projection.tags.length === 1 ? "" : "s"} with schemas: ${names}`);
284
+ }
285
+
286
+ if (projection.indexed_fields.length > 0) {
287
+ lines.push(`- Indexed metadata fields (queryable with operators):`);
288
+ for (const f of projection.indexed_fields) {
289
+ const declarers = f.tags.map((t) => `#${t}`).join(", ");
290
+ lines.push(` - ${f.name} (${f.type}; from ${declarers})`);
291
+ }
292
+ } else {
293
+ lines.push(`- No indexed metadata fields.`);
294
+ }
295
+
296
+ lines.push("");
297
+ lines.push("## Querying");
298
+ lines.push("");
299
+ for (const hint of projection.query_hints) {
300
+ lines.push(`- \`${hint}\``);
301
+ }
302
+
303
+ lines.push("");
304
+ lines.push("## Refreshing context");
305
+ lines.push("");
306
+ lines.push("If schema or tags change during this session, call `vault-info` to refresh the full projection. Call `list-tags { include_schema: true }` for tag-only details.");
307
+
308
+ return lines.join("\n");
309
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.0",
3
+ "version": "0.4.4-rc.11",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@modelcontextprotocol/sdk": "^1.12.1",
26
- "@openparachute/scope-guard": "^0.1.0",
26
+ "@openparachute/scope-guard": "^0.2.0",
27
27
  "jose": "^6.2.2",
28
28
  "otpauth": "^9.5.0",
29
29
  "qrcode-terminal": "^0.12.0"