@openparachute/vault 0.7.3-rc.13 → 0.7.3-rc.2

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.
Files changed (53) hide show
  1. package/README.md +3 -5
  2. package/core/src/conformance.ts +1 -2
  3. package/core/src/content-range.test.ts +0 -127
  4. package/core/src/content-range.ts +0 -100
  5. package/core/src/contract-typed-index.test.ts +3 -4
  6. package/core/src/core.test.ts +4 -521
  7. package/core/src/expand.ts +3 -11
  8. package/core/src/indexed-fields.test.ts +3 -9
  9. package/core/src/indexed-fields.ts +1 -9
  10. package/core/src/mcp.ts +10 -601
  11. package/core/src/notes.ts +4 -201
  12. package/core/src/schema-defaults.ts +1 -85
  13. package/core/src/search-fts-v25.test.ts +1 -9
  14. package/core/src/search-query.test.ts +0 -42
  15. package/core/src/search-query.ts +0 -27
  16. package/core/src/seed-packs.test.ts +1 -117
  17. package/core/src/seed-packs.ts +1 -217
  18. package/core/src/store.ts +3 -59
  19. package/core/src/tag-schemas.ts +12 -27
  20. package/core/src/types.ts +1 -7
  21. package/core/src/vault-projection.ts +0 -55
  22. package/package.json +1 -1
  23. package/src/add-pack.test.ts +0 -32
  24. package/src/auth-hub-jwt.test.ts +1 -118
  25. package/src/auth.ts +0 -64
  26. package/src/cli.ts +1 -5
  27. package/src/contract-errors.test.ts +1 -2
  28. package/src/mcp-http.ts +4 -33
  29. package/src/mcp-tools.ts +14 -61
  30. package/src/oauth-discovery.ts +0 -31
  31. package/src/onboarding-seed.test.ts +0 -64
  32. package/src/routes.ts +95 -92
  33. package/src/routing.test.ts +4 -229
  34. package/src/routing.ts +23 -152
  35. package/src/scopes.ts +0 -22
  36. package/src/server.ts +0 -7
  37. package/src/storage.test.ts +1 -200
  38. package/src/transcription-worker.test.ts +0 -151
  39. package/src/transcription-worker.ts +52 -113
  40. package/src/vault.test.ts +11 -48
  41. package/core/src/attachment/bytes-provider.ts +0 -65
  42. package/core/src/attachment/policy.test.ts +0 -66
  43. package/core/src/attachment/policy.ts +0 -131
  44. package/core/src/attachment/tickets.test.ts +0 -45
  45. package/core/src/attachment/tickets.ts +0 -117
  46. package/core/src/attachment-tickets-tool.test.ts +0 -286
  47. package/core/src/display-title.test.ts +0 -190
  48. package/core/src/lede.test.ts +0 -96
  49. package/core/src/search-title-boost.test.ts +0 -125
  50. package/src/attachment-bytes.ts +0 -68
  51. package/src/attachment-tickets.test.ts +0 -475
  52. package/src/attachment-tickets.ts +0 -340
  53. package/src/read-attachment.test.ts +0 -436
package/README.md CHANGED
@@ -30,7 +30,7 @@ For remote access from Claude Desktop or mobile apps, see [Deployment](#deployme
30
30
 
31
31
  A server on port 1940 with:
32
32
 
33
- - **MCP** — tools for AI agents (notes, tags, graph, vault info, attachments)
33
+ - **MCP** — 9 tools for AI agents (notes, tags, graph, vault info)
34
34
  - **REST API** — Full CRUD for notes, tags, links, full-text search
35
35
  - **Wikilink auto-linking** — `[[wikilinks]]` in note content automatically create links in the graph
36
36
  - **Obsidian import/export** — Bidirectional interop with Obsidian vaults
@@ -252,14 +252,12 @@ parachute-vault backup --schedule daily # hourly | daily | weekly | manua
252
252
  parachute-vault backup status # schedule, last run, destinations, next run
253
253
  ```
254
254
 
255
- ## MCP tools (13 core, + attachments + admin)
255
+ ## MCP tools (9)
256
256
 
257
257
  **Notes**: `query-notes` (universal read — single by ID/path, filter, search, graph neighborhood), `create-note` (single or batch), `update-note` (single or batch — content, tags, links, metadata), `delete-note`
258
- **Tags**: `list-tags` (with optional schema detail), `update-tag` (upsert description + schema fields), `delete-tag`, `rename-tag`, `merge-tags`
258
+ **Tags**: `list-tags` (with optional schema detail), `update-tag` (upsert description + schema fields), `delete-tag`
259
259
  **Graph**: `find-path` (BFS between two notes)
260
260
  **Vault**: `vault-info` (get/update description + stats)
261
- **Admin**: `prune-schema`, `doctor` (taxonomy/metadata integrity scan), `manage-token`
262
- **Attachments** (2, present when the door wires an attachment-ticket provider — always true on this server): `request-attachment-upload`, `request-attachment-download` — mint a short-lived, single-use ticket a runtime's shell spends directly at `/vault/{name}/tickets/{id}`; bytes never pass through the tool call. See [`docs/HTTP_API.md`](./docs/HTTP_API.md#attachment-tickets-vault611--wave-1-bun-only).
263
261
 
264
262
  ### Vault descriptions
265
263
 
@@ -78,8 +78,7 @@ function toStrictSchemaField(spec: TagFieldSchema): SchemaField {
78
78
  spec.type === "integer" ||
79
79
  spec.type === "boolean" ||
80
80
  spec.type === "array" ||
81
- spec.type === "object" ||
82
- spec.type === "date"
81
+ spec.type === "object"
83
82
  ) {
84
83
  out.type = spec.type;
85
84
  }
@@ -23,13 +23,8 @@ import {
23
23
  parseContentRange,
24
24
  sliceContentRange,
25
25
  applyContentRange,
26
- parseAttachmentContentRange,
27
- alignByteWindow,
28
26
  MIN_CONTENT_LENGTH,
29
- DEFAULT_ATTACHMENT_WINDOW_BYTES,
30
- MAX_ATTACHMENT_WINDOW_BYTES,
31
27
  } from "./content-range.js";
32
- import { QueryError } from "./query-operators.js";
33
28
 
34
29
  // ---------------------------------------------------------------------------
35
30
  // 1. parseContentRange
@@ -242,128 +237,6 @@ describe("content range — reassembly property", () => {
242
237
  });
243
238
  });
244
239
 
245
- // ---------------------------------------------------------------------------
246
- // 2c. parseAttachmentContentRange + alignByteWindow (read-attachment)
247
- // ---------------------------------------------------------------------------
248
-
249
- describe("parseAttachmentContentRange", () => {
250
- it("defaults offset=0, length=DEFAULT_ATTACHMENT_WINDOW_BYTES when both are omitted", () => {
251
- expect(parseAttachmentContentRange(undefined, undefined)).toEqual({
252
- offset: 0,
253
- length: DEFAULT_ATTACHMENT_WINDOW_BYTES,
254
- });
255
- });
256
-
257
- it("offset only → length still defaults (never 'read to end' — that's the query-notes shape, not this one)", () => {
258
- expect(parseAttachmentContentRange(1000, undefined)).toEqual({
259
- offset: 1000,
260
- length: DEFAULT_ATTACHMENT_WINDOW_BYTES,
261
- });
262
- });
263
-
264
- it("accepts an explicit length up to the max", () => {
265
- expect(parseAttachmentContentRange(0, MAX_ATTACHMENT_WINDOW_BYTES)).toEqual({
266
- offset: 0,
267
- length: MAX_ATTACHMENT_WINDOW_BYTES,
268
- });
269
- });
270
-
271
- it("rejects a length below MIN_CONTENT_LENGTH", () => {
272
- expect(() => parseAttachmentContentRange(0, 2)).toThrow(QueryError);
273
- });
274
-
275
- it("rejects a length above MAX_ATTACHMENT_WINDOW_BYTES", () => {
276
- expect(() => parseAttachmentContentRange(0, MAX_ATTACHMENT_WINDOW_BYTES + 1)).toThrow(QueryError);
277
- });
278
-
279
- it("rejects a negative offset", () => {
280
- expect(() => parseAttachmentContentRange(-1, undefined)).toThrow(QueryError);
281
- });
282
- });
283
-
284
- describe("alignByteWindow", () => {
285
- /**
286
- * Simulates the real caller: a BOUNDED positional read of
287
- * `[max(0, offset-3), min(total, offset+length))` from `full`, THEN
288
- * alignment — never handing the whole buffer to `alignByteWindow`, so a
289
- * pass here proves the "never load the whole file" contract actually
290
- * holds and isn't just true by accident of the test passing the full
291
- * buffer.
292
- */
293
- function boundedRead(full: Buffer, offset: number, length: number): { raw: Uint8Array; rawStart: number } {
294
- const total = full.byteLength;
295
- const rawStart = Math.max(0, offset - 3);
296
- // +1: alignByteWindow's end-boundary check reads the byte AT the
297
- // (exclusive) window end to decide whether it's a continuation byte —
298
- // that's one byte past `offset + length`, so the read must include it
299
- // (see alignByteWindow's doc comment precondition).
300
- const rawEnd = Math.min(total, offset + length + 1);
301
- return { raw: full.subarray(rawStart, Math.max(rawStart, rawEnd)), rawStart };
302
- }
303
-
304
- it("offset past end → empty slice, complete (mirrors sliceContentRange)", () => {
305
- const full = Buffer.from("abc", "utf8");
306
- const { raw, rawStart } = boundedRead(full, 999, 16);
307
- const r = alignByteWindow(raw, rawStart, { offset: 999, length: 16 }, full.byteLength);
308
- expect(r.content).toBe("");
309
- expect(r.content_offset).toBe(3);
310
- expect(r.content_total_length).toBe(3);
311
- expect(r.content_next_offset).toBeNull();
312
- });
313
-
314
- it("matches sliceContentRange byte-for-byte on a plain ASCII window", () => {
315
- const s = "hello world";
316
- const full = Buffer.from(s, "utf8");
317
- const { raw, rawStart } = boundedRead(full, 0, 5);
318
- const bounded = alignByteWindow(raw, rawStart, { offset: 0, length: 5 }, full.byteLength);
319
- const whole = sliceContentRange(s, { offset: 0, length: 5 });
320
- expect(bounded).toEqual(whole);
321
- });
322
-
323
- it("never splits a codepoint under a bounded read (matches sliceContentRange across a 4-byte emoji)", () => {
324
- const s = "ab\u{1F600}cd"; // emoji occupies bytes 2..5 of 8
325
- const full = Buffer.from(s, "utf8");
326
- for (const [offset, length] of [
327
- [0, 5], // budget cuts mid-emoji → backs off to byte 2
328
- [2, 4], // exact emoji window
329
- [4, 8], // offset lands mid-emoji → aligns down to byte 2
330
- [6, 4], // final ASCII tail
331
- ] as const) {
332
- const { raw, rawStart } = boundedRead(full, offset, length);
333
- const bounded = alignByteWindow(raw, rawStart, { offset, length }, full.byteLength);
334
- const whole = sliceContentRange(s, { offset, length });
335
- expect(bounded).toEqual(whole);
336
- }
337
- });
338
-
339
- it("reassembly property: chaining content_next_offset through BOUNDED reads reproduces the full content", () => {
340
- const rand = mulberry32(0xba5eba11);
341
- const POOL = ["a", "Z", "9", " ", "\n", "é", "ψ", "你", "‱", "\u{1F600}", "\u{1D11E}"];
342
- for (let iter = 0; iter < 40; iter++) {
343
- const charCount = Math.floor(rand() * 100);
344
- let content = "";
345
- for (let i = 0; i < charCount; i++) content += POOL[Math.floor(rand() * POOL.length)]!;
346
- const full = Buffer.from(content, "utf8");
347
- const total = full.byteLength;
348
- const budget = MIN_CONTENT_LENGTH + Math.floor(rand() * 13); // 4..16 bytes
349
-
350
- let offset = 0;
351
- let assembled = "";
352
- for (let step = 0; step <= total + 2; step++) {
353
- const { raw, rawStart } = boundedRead(full, offset, budget);
354
- const slice = alignByteWindow(raw, rawStart, { offset, length: budget }, total);
355
- expect(Buffer.byteLength(slice.content, "utf8")).toBeLessThanOrEqual(budget);
356
- expect(slice.content_total_length).toBe(total);
357
- assembled += slice.content;
358
- if (slice.content_next_offset === null) break;
359
- expect(slice.content_next_offset).toBeGreaterThan(offset);
360
- offset = slice.content_next_offset;
361
- }
362
- expect(assembled).toBe(content);
363
- }
364
- });
365
- });
366
-
367
240
  // ---------------------------------------------------------------------------
368
241
  // 3. MCP face — query-notes
369
242
  // ---------------------------------------------------------------------------
@@ -183,103 +183,3 @@ export function applyContentRange(
183
183
  result.content_total_length = fields.content_total_length;
184
184
  result.content_next_offset = fields.content_next_offset;
185
185
  }
186
-
187
- // ---------------------------------------------------------------------------
188
- // Attachment byte-window reads (`read-attachment`, Wave 2 model lane)
189
- // ---------------------------------------------------------------------------
190
-
191
- /** Default `read-attachment` text window when the caller omits `content_length` — small enough to never nuke a context budget. */
192
- export const DEFAULT_ATTACHMENT_WINDOW_BYTES = 65_536; // 64 KiB
193
-
194
- /** Hard per-call ceiling on `read-attachment`'s `content_length` — a deliberate-big-bite max, not a default. */
195
- export const MAX_ATTACHMENT_WINDOW_BYTES = 262_144; // 256 KiB
196
-
197
- /**
198
- * Parse `read-attachment`'s `content_offset` / `content_length` pair.
199
- * Unlike {@link parseContentRange} (query-notes: omitted params mean "range
200
- * mode off, return everything"), a `read-attachment` call ALWAYS reads a
201
- * bounded window — omitting `content_length` defaults it to
202
- * {@link DEFAULT_ATTACHMENT_WINDOW_BYTES} rather than "the whole file" (an
203
- * attachment can be arbitrarily large; a note can't cheaply be). Returns a
204
- * fully-resolved `{offset, length}` (never the query-notes "null = off"
205
- * shape). Throws `QueryError` (INVALID_QUERY) on a negative/non-integer
206
- * value, a `content_length` below {@link MIN_CONTENT_LENGTH}, or one above
207
- * {@link MAX_ATTACHMENT_WINDOW_BYTES}.
208
- */
209
- export function parseAttachmentContentRange(
210
- offsetRaw: unknown,
211
- lengthRaw: unknown,
212
- ): { offset: number; length: number } {
213
- const offset = toNonNegativeInt(offsetRaw, "content_offset") ?? 0;
214
- const length = toNonNegativeInt(lengthRaw, "content_length");
215
- if (length !== undefined && length < MIN_CONTENT_LENGTH) {
216
- throw new QueryError(
217
- `invalid \`content_length\` value ${JSON.stringify(lengthRaw)} — must be at least ${MIN_CONTENT_LENGTH} bytes (the size of the largest UTF-8 codepoint, so every window makes progress).`,
218
- "INVALID_QUERY",
219
- );
220
- }
221
- if (length !== undefined && length > MAX_ATTACHMENT_WINDOW_BYTES) {
222
- throw new QueryError(
223
- `invalid \`content_length\` value ${JSON.stringify(lengthRaw)} — exceeds the ${MAX_ATTACHMENT_WINDOW_BYTES} byte (256 KiB) per-call max for read-attachment.`,
224
- "INVALID_QUERY",
225
- );
226
- }
227
- return { offset, length: length ?? DEFAULT_ATTACHMENT_WINDOW_BYTES };
228
- }
229
-
230
- /**
231
- * Byte-level counterpart to {@link sliceContentRange} for `read-attachment`'s
232
- * text path, where loading the WHOLE file into memory to slice a string
233
- * (`sliceContentRange`'s approach) is exactly the thing a 500 MB attachment
234
- * forbids. The caller does a BOUNDED positional read first — `raw` is
235
- * whatever bytes it actually fetched, starting at file offset `rawStart`
236
- * — and this function applies the identical alignment rules
237
- * `sliceContentRange` applies to a full in-memory string, operating only on
238
- * that window.
239
- *
240
- * Precondition (caller's responsibility, not re-validated here): `raw`
241
- * covers at least `[max(0, range.offset - 3), min(total, range.offset +
242
- * range.length) + 1)` — i.e. from 3 bytes before the requested offset
243
- * through ONE byte past the requested end, clamped to `total`. The 3-byte
244
- * lookback is enough to find the leading byte of any UTF-8 codepoint the
245
- * requested `offset` might land inside (a codepoint is at most 4 bytes,
246
- * i.e. at most 3 continuation bytes after its leading byte); the 1-byte
247
- * lookahead is what the end-alignment check reads to decide whether the
248
- * budget cut lands mid-codepoint (mirroring `sliceContentRange`, which
249
- * checks `bytes[end]` — the byte AT the exclusive cut point).
250
- */
251
- export function alignByteWindow(
252
- raw: Uint8Array,
253
- rawStart: number,
254
- range: { offset: number; length: number },
255
- total: number,
256
- ): ContentRangeFields {
257
- if (range.offset >= total) {
258
- return {
259
- content: "",
260
- content_offset: total,
261
- content_total_length: total,
262
- content_next_offset: null,
263
- };
264
- }
265
-
266
- const byteAt = (idx: number): number => raw[idx - rawStart]!;
267
-
268
- // Align the start DOWN to the leading byte of the codepoint containing
269
- // `offset` — same rule as sliceContentRange, bounded to what's in `raw`.
270
- let start = range.offset;
271
- while (start > rawStart && isContinuationByte(byteAt(start))) start--;
272
-
273
- // Window end: budget capped at total, then clamped to what's actually in
274
- // `raw` (defense-in-depth — a caller that under-read would otherwise
275
- // index past the buffer). Aligned DOWN so the slice never ends mid-codepoint.
276
- let end = Math.min(start + range.length, total, rawStart + raw.length);
277
- while (end > start && end < total && isContinuationByte(byteAt(end))) end--;
278
-
279
- return {
280
- content: Buffer.from(raw.subarray(start - rawStart, end - rawStart)).toString("utf8"),
281
- content_offset: start,
282
- content_total_length: total,
283
- content_next_offset: end >= total ? null : end,
284
- };
285
- }
@@ -220,15 +220,14 @@ describe("contract: typed indexes — Decision B: explicit-default-only enum bac
220
220
  });
221
221
 
222
222
  describe("contract: typed indexes — Decision C: honest type list (#553, flipped from todo)", () => {
223
- it("the update-tag field-type description clarifies only string/integer/boolean/reference/date are indexable", () => {
223
+ it("the update-tag field-type description clarifies only string/integer/boolean/reference are indexable", () => {
224
224
  const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
225
225
  const typeDesc = (updateTag.inputSchema as any).properties.fields.additionalProperties.properties.type.description as string;
226
226
  // Honest about the full storage/advisory vocabulary AND the indexable subset.
227
227
  expect(typeDesc).toContain("number");
228
228
  // vault#typed-reference-field: `reference` joined the indexable subset
229
- // alongside string/integer/boolean. vault#date-field-type: `date` joined
230
- // the same subset — it stores TEXT (ISO-8601), same as string/reference.
231
- expect(typeDesc).toContain("Only string/integer/boolean/reference/date are INDEXABLE");
229
+ // alongside string/integer/boolean.
230
+ expect(typeDesc).toContain("Only string/integer/boolean/reference are INDEXABLE");
232
231
  });
233
232
 
234
233
  it("declaring indexed:true with an unindexable type (number) is rejected", async () => {