@openparachute/vault 0.6.3-rc.1 → 0.6.3-rc.4

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.
@@ -91,6 +91,48 @@ schemas.** Start minimal — invent tags as real notes need them, declare a
91
91
  schema only when a query demands it. Over-designing an empty vault is the
92
92
  common mistake.
93
93
 
94
+ Declaring a schema is one \`update-tag\` call — the \`fields\` object maps each
95
+ field name to \`{ type, enum?, indexed? }\` (\`type\` is \`"string"\`, \`"boolean"\`,
96
+ or \`"integer"\`):
97
+
98
+ \`\`\`
99
+ update-tag {
100
+ tag: "meeting",
101
+ description: "A meeting with notes",
102
+ fields: {
103
+ held_on: { type: "string", indexed: true }, // queryable with operators
104
+ status: { type: "string", enum: ["scheduled", "done"] }, // first enum value is the default
105
+ rating: { type: "integer" }
106
+ }
107
+ }
108
+ \`\`\`
109
+
110
+ \`fields\` is **merged** (new keys added, existing replaced); \`parent_names\` and
111
+ \`relationships\` are replaced wholesale when passed. Only \`indexed: true\` fields
112
+ support operator queries (\`metadata: { held_on: { gte: "..." } }\`) and
113
+ \`order_by\`; all tags declaring the same field must agree on its \`type\` and
114
+ \`indexed\` flag.
115
+
116
+ ## Write gotchas
117
+
118
+ A few behaviors worth knowing before you write at scale:
119
+
120
+ - **\`update-note\` requires optimistic concurrency by default.** Pass
121
+ \`if_updated_at\` with the \`updated_at\` you last read; a mismatch returns a
122
+ conflict error (re-read, reconcile, retry). For bulk/scripted writes where
123
+ concurrency is known-safe, pass \`force: true\` to waive the *requirement to
124
+ supply* it. \`append\`/\`prepend\`-only updates are exempt (no-conflict-by-design).
125
+ - **A schema field's default is filled in on write, so it shows up even when you
126
+ didn't set it.** When a note gets a tag whose schema declares a field, the
127
+ missing field is back-filled: an \`enum\` field → its **first listed value**, an
128
+ \`integer\` → \`0\`, a \`boolean\` → \`false\`, a plain string → \`""\`. So a
129
+ \`rating: { type: "integer" }\` reads as \`0\` on notes nobody rated — that \`0\`
130
+ is "unset," not "rated zero." Order an \`enum\`'s values so the first is a sane
131
+ default, and don't read a back-filled \`0\`/\`""\`/\`false\` as a real value.
132
+ - **Validation is advisory, never blocking.** A type/enum mismatch comes back as
133
+ a \`validation_status\` warning on the write response — the write still lands.
134
+ Read those warnings and self-correct on the next turn.
135
+
94
136
  (Full design guide, with copy-paste examples: https://parachute.computer/scripting/)
95
137
 
96
138
  ## Importing existing notes
@@ -146,6 +188,17 @@ single-purpose tool. This note is a living starter for building one *with the
146
188
  operator*. Update it as you settle on a stack, conventions, or a deployed
147
189
  surface for this vault.
148
190
 
191
+ ## ⚠️ Build a surface in your editor, not from this session
192
+
193
+ A surface runs **in a browser**: it needs a real OAuth round-trip (a redirect to
194
+ the hub's consent screen and back), a dev server to serve the app, and a CORS
195
+ origin the hub trusts. **None of that exists in this MCP/chat session** — there's
196
+ no browser, no redirect, no dev server. So **don't try to "run" a surface from
197
+ the vault session.** Build it in **Claude Code (or your editor)** against a local
198
+ dev server (\`vite\`/\`bun dev\`), sign in through the browser there, and iterate.
199
+ From *this* session you design the vault structure the surface will consume and
200
+ write the code — you can't exercise the OAuth/render loop here.
201
+
149
202
  ## Don't hand-roll the plumbing
150
203
 
151
204
  Two published packages do the heavy lifting — import them instead of writing
@@ -158,10 +211,72 @@ OAuth, the vault API client, or note rendering by hand:
158
211
  content (Markdown, wikilinks, embeds) the way the rest of the ecosystem does,
159
212
  so your surface looks native without re-implementing the renderer.
160
213
 
214
+ ## Minimal end-to-end (config → sign-in → query → render)
215
+
216
+ A React sketch wiring all four steps. \`createVaultSurface\` is the only required
217
+ config (its \`clientName\` is the sole required option; \`hubUrl\` defaults to the
218
+ page origin, \`vaultName\` to \`"default"\`, \`scope\` to \`"vault:read vault:write"\`).
219
+ \`getClient()\` returns a \`VaultClient\` (or \`null\` until signed in) whose
220
+ \`queryNotes()\` takes the same query grammar you use over MCP. See
221
+ [[Getting Started]] / \`vault-info\` for this vault's NAME and hub origin.
222
+
223
+ \`\`\`tsx
224
+ import { useEffect, useState } from "react";
225
+ import { createVaultSurface, type Note } from "@openparachute/surface-client";
226
+ import { NoteRenderer } from "@openparachute/surface-render";
227
+
228
+ // One surface per (hub, vault) config. clientName shows on the consent screen.
229
+ const surface = createVaultSurface({
230
+ clientName: "My Vault Surface",
231
+ hubUrl: "https://your-hub.example", // omit to default to window.location.origin
232
+ vaultName: "default", // this vault's name (see vault-info)
233
+ });
234
+
235
+ export function App() {
236
+ const [notes, setNotes] = useState<Note[] | null>(null);
237
+
238
+ useEffect(() => {
239
+ (async () => {
240
+ // OAuth: finish a redirect callback if we're on it, else send the browser
241
+ // off to sign in. handleCallback() needs BOTH code + state, so guard on
242
+ // both. (Real apps route /oauth/callback to its own component.)
243
+ const q = new URLSearchParams(location.search);
244
+ if (q.get("code") && q.get("state")) await surface.handleCallback();
245
+ // getClient() builds a FRESH VaultClient on each call — fine here (one-shot
246
+ // effect); in a real component keep it in state/ref, don't call it per render.
247
+ const client = surface.getClient(); // VaultClient | null (null = not signed in)
248
+ if (!client) return void surface.login();
249
+ setNotes(await client.queryNotes({ tag: "note", limit: 20 }));
250
+ })();
251
+ }, []);
252
+
253
+ if (!notes) return <p>Connecting…</p>;
254
+ return (
255
+ <>
256
+ {notes.map((n) => (
257
+ // resolve maps a [[wikilink]] target → { href, exists } (or null = inert).
258
+ // You own this href's trust boundary — keep it a fragment (or validate the
259
+ // target). Don't build a raw passthrough href: a vault note could carry a
260
+ // javascript: target.
261
+ <NoteRenderer
262
+ key={n.id}
263
+ note={n}
264
+ resolve={(target) => ({ href: \`#/n/\${encodeURIComponent(target)}\`, exists: true })}
265
+ />
266
+ ))}
267
+ </>
268
+ );
269
+ }
270
+ \`\`\`
271
+
272
+ That's the whole spine. \`<NoteRenderer>\` also takes \`linkComponent\` (your
273
+ router's \`<Link>\`) and \`fetchBlob\` (\`(url) => Promise<Blob>\`, for auth'd
274
+ image/audio embeds) when you need them — both optional.
275
+
161
276
  ## Build order
162
277
 
163
278
  1. **Auth + data first.** Stand up \`createVaultSurface\` pointed at this vault;
164
- confirm you can sign in and \`query-notes\` round-trips before any UI polish.
279
+ confirm you can sign in and \`queryNotes\` round-trips before any UI polish.
165
280
  2. **Render next.** Drop in \`<NoteRenderer>\` to display note content; wire
166
281
  wikilink/embed resolution through the package, not by hand.
167
282
  3. **UX last.** Layout, navigation, and the surface's actual purpose — now that
@@ -273,8 +273,19 @@ export function projectionToMarkdown(args: {
273
273
  vaultName: string;
274
274
  description?: string | null;
275
275
  projection: VaultProjection;
276
+ /**
277
+ * This vault's own public coordinates, surfaced so a surface-builder never has
278
+ * to guess the vault NAME or reconstruct the REST/MCP URLs from the connector
279
+ * config. When `hubOrigin` is a known absolute origin (configured
280
+ * `PARACHUTE_HUB_ORIGIN` or an active expose-state FQDN), the absolute base
281
+ * URLs are rendered; when it's only the loopback fallback (no public origin
282
+ * known to the vault), relative path templates are rendered with a note that
283
+ * the origin is whatever the client connected through. The NAME is always
284
+ * known and always surfaced. See `chooseHubOrigin` (src/mcp-install.ts).
285
+ */
286
+ coordinates?: { hubOrigin: string; hubOriginKnown: boolean };
276
287
  }): string {
277
- const { vaultName, description, projection } = args;
288
+ const { vaultName, description, projection, coordinates } = args;
278
289
  const stats = projection.stats;
279
290
 
280
291
  const lines: string[] = [];
@@ -284,6 +295,27 @@ export function projectionToMarkdown(args: {
284
295
  lines.push(description.trim());
285
296
  }
286
297
 
298
+ // GAP 3 / vault coordinates: state the vault's own NAME + REST/MCP URLs so a
299
+ // surface-builder (or any scripting client) doesn't have to learn them from
300
+ // the MCP connector config. Absolute when the vault knows its public origin;
301
+ // relative templates otherwise.
302
+ if (coordinates) {
303
+ lines.push("");
304
+ lines.push("## This vault's coordinates");
305
+ lines.push("");
306
+ lines.push(`- Name: \`${vaultName}\``);
307
+ if (coordinates.hubOriginKnown) {
308
+ const base = coordinates.hubOrigin.replace(/\/$/, "");
309
+ lines.push(`- REST API base: \`${base}/vault/${vaultName}/api/...\``);
310
+ lines.push(`- MCP endpoint: \`${base}/vault/${vaultName}/mcp\``);
311
+ } else {
312
+ lines.push(
313
+ `- REST API: \`<hub-origin>/vault/${vaultName}/api/...\` — \`<hub-origin>\` is the origin you connected through (the vault has no public origin configured to render here).`,
314
+ );
315
+ lines.push(`- MCP endpoint: \`<hub-origin>/vault/${vaultName}/mcp\``);
316
+ }
317
+ }
318
+
287
319
  // A2: surface the seeded onboarding guide so a connected AI can discover it
288
320
  // when setting up or orienting. A pointer only — the AI fetches the body on
289
321
  // demand. NOT a mandate to re-read every session; it's a start-here guide.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.3-rc.1",
3
+ "version": "0.6.3-rc.4",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/mcp-tools.ts CHANGED
@@ -100,9 +100,23 @@ export async function getServerInstruction(
100
100
  vaultName,
101
101
  description: config?.description ?? null,
102
102
  projection,
103
+ coordinates: resolveVaultCoordinates(),
103
104
  });
104
105
  }
105
106
 
107
+ /**
108
+ * Resolve this vault's own public coordinates for the projection. The vault
109
+ * always knows its NAME; its hub origin comes from `resolveHubOrigin()`
110
+ * (PARACHUTE_HUB_ORIGIN → expose-state FQDN → loopback). A loopback source
111
+ * means no public origin is configured — flagged via `hubOriginKnown: false`
112
+ * so the projection renders relative path templates rather than a loopback URL
113
+ * a remote surface-builder can't use.
114
+ */
115
+ function resolveVaultCoordinates(): { hubOrigin: string; hubOriginKnown: boolean } {
116
+ const { url, source } = resolveHubOrigin();
117
+ return { hubOrigin: url, hubOriginKnown: source !== "loopback" };
118
+ }
119
+
106
120
  /**
107
121
  * Generate MCP tools scoped to a single vault.
108
122
  *
@@ -482,9 +496,24 @@ function overrideVaultInfo(
482
496
  const includeStats = Boolean(params.include_stats);
483
497
  const projection = buildVaultProjection(store.db, { includeStats });
484
498
 
499
+ // GAP 3 / vault coordinates: surface the vault's own NAME + REST/MCP URL
500
+ // templates so a surface-builder doesn't have to learn them from the MCP
501
+ // connector config. `base_url` is absolute when the vault knows its public
502
+ // origin (PARACHUTE_HUB_ORIGIN / expose-state); null on a loopback-only
503
+ // box, where `rest_api` / `mcp` carry `<hub-origin>` placeholder templates
504
+ // resolved against whatever origin the client connected through.
505
+ const coords = resolveVaultCoordinates();
506
+ const coordBase = coords.hubOriginKnown ? coords.hubOrigin.replace(/\/$/, "") : "<hub-origin>";
507
+
485
508
  const result: Record<string, unknown> = {
486
509
  name: config.name,
487
510
  description: config.description ?? null,
511
+ coordinates: {
512
+ name: config.name,
513
+ base_url: coords.hubOriginKnown ? `${coordBase}/vault/${config.name}` : null,
514
+ rest_api: `${coordBase}/vault/${config.name}/api`,
515
+ mcp: `${coordBase}/vault/${config.name}/mcp`,
516
+ },
488
517
  tags: projection.tags,
489
518
  indexed_fields: projection.indexed_fields,
490
519
  query_hints: projection.query_hints,
@@ -67,6 +67,35 @@ describe("seedOnboardingNotes (A1/A3)", () => {
67
67
  expect(ss!.content).toContain("@openparachute/surface-render");
68
68
  expect(ss!.content).toContain("createVaultSurface");
69
69
  expect(ss!.content).toContain("NoteRenderer");
70
+
71
+ // GAP 2: warn that a surface can't be run/developed from this MCP session.
72
+ expect(ss!.content).toContain("not from this");
73
+ expect(ss!.content.toLowerCase()).toContain("browser");
74
+
75
+ // GAP 1: a real, copy-paste end-to-end snippet — every load-bearing symbol
76
+ // present, verified against the surface-client / surface-render source.
77
+ expect(ss!.content).toContain("clientName"); // the one required createVaultSurface option
78
+ expect(ss!.content).toContain("getClient");
79
+ expect(ss!.content).toContain("queryNotes");
80
+ expect(ss!.content).toContain("handleCallback");
81
+ expect(ss!.content).toContain("resolve="); // NoteRenderer wikilink resolver prop
82
+ });
83
+
84
+ test("GAP 4: Getting Started shows a concrete update-tag fields example + write gotchas", async () => {
85
+ await seedOnboardingNotes(store);
86
+ const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
87
+
88
+ // (a) a literal update-tag fields object: field name → { type, enum?, indexed? }.
89
+ expect(gs!.content).toContain("update-tag {");
90
+ expect(gs!.content).toContain('{ type: "string", indexed: true }');
91
+ expect(gs!.content).toContain('enum: ["scheduled", "done"]');
92
+
93
+ // (b) the write-gotchas subsection — each gotcha verified against mcp.ts.
94
+ expect(gs!.content).toContain("## Write gotchas");
95
+ expect(gs!.content).toContain("if_updated_at"); // optimistic concurrency
96
+ expect(gs!.content).toContain("force: true");
97
+ // schema-default back-fill: enum→first value, integer→0.
98
+ expect(gs!.content).toContain("first listed value");
70
99
  });
71
100
 
72
101
  test("A3: Getting Started links to Surface Starter via a resolved wikilink", async () => {
@@ -148,3 +177,46 @@ describe("vault-info / projection pointer (A2)", () => {
148
177
  expect(md).not.toContain("## Start here");
149
178
  });
150
179
  });
180
+
181
+ describe("vault coordinates in the projection (GAP 3)", () => {
182
+ test("renders absolute REST + MCP URLs when the hub origin is known", () => {
183
+ const projection = buildVaultProjection(db, { includeStats: true });
184
+ const md = projectionToMarkdown({
185
+ vaultName: "research",
186
+ description: null,
187
+ projection,
188
+ coordinates: { hubOrigin: "https://hub.example.com", hubOriginKnown: true },
189
+ });
190
+
191
+ expect(md).toContain("## This vault's coordinates");
192
+ expect(md).toContain("Name: `research`");
193
+ expect(md).toContain("https://hub.example.com/vault/research/api/...");
194
+ expect(md).toContain("https://hub.example.com/vault/research/mcp");
195
+ });
196
+
197
+ test("renders relative templates + connected-origin note when origin is unknown", () => {
198
+ const projection = buildVaultProjection(db, { includeStats: true });
199
+ const md = projectionToMarkdown({
200
+ vaultName: "research",
201
+ description: null,
202
+ projection,
203
+ coordinates: { hubOrigin: "http://127.0.0.1:1940", hubOriginKnown: false },
204
+ });
205
+
206
+ expect(md).toContain("## This vault's coordinates");
207
+ expect(md).toContain("Name: `research`");
208
+ // The loopback URL is NOT leaked; relative templates carry a placeholder.
209
+ expect(md).not.toContain("127.0.0.1");
210
+ expect(md).toContain("<hub-origin>/vault/research/api/...");
211
+ expect(md).toContain("<hub-origin>/vault/research/mcp");
212
+ });
213
+
214
+ test("no coordinates block when coordinates are omitted; name still in the opening line", () => {
215
+ const projection = buildVaultProjection(db, { includeStats: true });
216
+ const md = projectionToMarkdown({ vaultName: "research", description: null, projection });
217
+ // Without coordinates the dedicated block is absent; the vault NAME is still
218
+ // stated in the pre-existing opening line (the always-known coordinate).
219
+ expect(md).toContain('Parachute Vault "research"');
220
+ expect(md).not.toContain("## This vault's coordinates");
221
+ });
222
+ });
package/src/vault.test.ts CHANGED
@@ -578,6 +578,13 @@ describe("scoped MCP wrapper", async () => {
578
578
  const { writeVaultConfig } = await import("./config.ts");
579
579
  const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
580
580
 
581
+ // The GAP-3 coordinates block reads PARACHUTE_HUB_ORIGIN; another test FILE
582
+ // in a shared `bun test ./src` run may have left it set (cross-file env
583
+ // pollution — e.g. hub-jwt.test.ts). Pin loopback so the coordinates
584
+ // assertion below is deterministic regardless of file order; restore after.
585
+ const prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
586
+ delete process.env.PARACHUTE_HUB_ORIGIN;
587
+
581
588
  const vaultName = `proj-${Date.now()}`;
582
589
  writeVaultConfig({
583
590
  name: vaultName,
@@ -641,12 +648,24 @@ describe("scoped MCP wrapper", async () => {
641
648
  expect(Array.isArray(result.query_hints)).toBe(true);
642
649
  expect((result.query_hints as string[]).length).toBeGreaterThan(0);
643
650
 
651
+ // GAP 3 — coordinates block: the vault states its own NAME + REST/MCP URL
652
+ // templates so a surface-builder doesn't reconstruct them from the
653
+ // connector config. No public hub origin in this test fixture → loopback →
654
+ // `<hub-origin>` placeholder templates, `base_url` null.
655
+ const coords = result.coordinates as Record<string, unknown>;
656
+ expect(coords.name).toBe(vaultName);
657
+ expect(coords.rest_api).toBe(`<hub-origin>/vault/${vaultName}/api`);
658
+ expect(coords.mcp).toBe(`<hub-origin>/vault/${vaultName}/mcp`);
659
+ expect(coords.base_url).toBeNull();
660
+
644
661
  // stats omitted unless requested
645
662
  expect(result.stats).toBeUndefined();
646
663
 
647
664
  const withStats = await vaultInfo.execute({ include_stats: true }) as any;
648
665
  expect(withStats.stats).toBeTruthy();
649
666
 
667
+ if (prevHubOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
668
+ else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
650
669
  closeAllStores();
651
670
  });
652
671