@openparachute/vault 0.6.0-rc.1 → 0.6.1

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 (99) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +32 -7
  3. package/core/src/content-range.test.ts +374 -0
  4. package/core/src/content-range.ts +185 -0
  5. package/core/src/core.test.ts +279 -26
  6. package/core/src/expand-visibility.test.ts +102 -0
  7. package/core/src/expand.ts +31 -3
  8. package/core/src/indexed-fields.ts +1 -1
  9. package/core/src/link-count.test.ts +301 -0
  10. package/core/src/links.ts +172 -22
  11. package/core/src/mcp.ts +254 -34
  12. package/core/src/notes.ts +172 -48
  13. package/core/src/obsidian-alignment.test.ts +375 -0
  14. package/core/src/obsidian.ts +234 -14
  15. package/core/src/portable-md.test.ts +40 -0
  16. package/core/src/portable-md.ts +142 -16
  17. package/core/src/query-perf-routing.test.ts +208 -0
  18. package/core/src/schema.ts +87 -11
  19. package/core/src/store.ts +69 -22
  20. package/core/src/tag-expand-axis.test.ts +301 -0
  21. package/core/src/tag-hierarchy.ts +80 -0
  22. package/core/src/tag-schemas.ts +61 -46
  23. package/core/src/triggers-store.test.ts +100 -0
  24. package/core/src/triggers-store.ts +165 -0
  25. package/core/src/types.ts +68 -4
  26. package/core/src/vault-projection.ts +20 -0
  27. package/core/src/wikilinks.ts +2 -2
  28. package/package.json +2 -3
  29. package/src/admin-spa.test.ts +100 -10
  30. package/src/admin-spa.ts +48 -3
  31. package/src/auth-hub-jwt.test.ts +8 -1
  32. package/src/auth-status.ts +2 -2
  33. package/src/auth.test.ts +39 -3
  34. package/src/auth.ts +31 -2
  35. package/src/auto-transcribe.test.ts +51 -0
  36. package/src/auto-transcribe.ts +24 -6
  37. package/src/autostart.test.ts +75 -0
  38. package/src/autostart.ts +84 -0
  39. package/src/cli.ts +434 -140
  40. package/src/config.test.ts +109 -0
  41. package/src/config.ts +157 -10
  42. package/src/content-range-routes.test.ts +178 -0
  43. package/src/export-watch.test.ts +23 -0
  44. package/src/export-watch.ts +14 -0
  45. package/src/git-preflight.test.ts +70 -0
  46. package/src/git-preflight.ts +68 -0
  47. package/src/github-device-flow.test.ts +265 -6
  48. package/src/github-device-flow.ts +297 -45
  49. package/src/hub-jwt.test.ts +75 -2
  50. package/src/hub-jwt.ts +43 -6
  51. package/src/init-summary.test.ts +120 -5
  52. package/src/init-summary.ts +67 -25
  53. package/src/live-match.test.ts +198 -0
  54. package/src/live-match.ts +310 -0
  55. package/src/mcp-install.test.ts +93 -0
  56. package/src/mcp-install.ts +106 -0
  57. package/src/mcp-tools.ts +80 -7
  58. package/src/mirror-config.test.ts +14 -0
  59. package/src/mirror-config.ts +11 -0
  60. package/src/mirror-credentials.test.ts +20 -0
  61. package/src/mirror-credentials.ts +6 -2
  62. package/src/mirror-import.test.ts +110 -0
  63. package/src/mirror-import.ts +71 -13
  64. package/src/mirror-manager.test.ts +51 -0
  65. package/src/mirror-manager.ts +73 -11
  66. package/src/mirror-routes.test.ts +1331 -110
  67. package/src/mirror-routes.ts +787 -30
  68. package/src/oauth-discovery.test.ts +55 -0
  69. package/src/oauth-discovery.ts +24 -5
  70. package/src/routes.ts +763 -122
  71. package/src/routing.test.ts +451 -5
  72. package/src/routing.ts +121 -5
  73. package/src/scopes.ts +1 -1
  74. package/src/server.ts +66 -4
  75. package/src/storage.test.ts +162 -0
  76. package/src/subscribe.test.ts +588 -0
  77. package/src/subscribe.ts +248 -0
  78. package/src/subscriptions.ts +295 -0
  79. package/src/tag-expand-routes.test.ts +45 -0
  80. package/src/tag-scope.ts +68 -1
  81. package/src/token-store.ts +7 -7
  82. package/src/transcription-worker.test.ts +471 -5
  83. package/src/transcription-worker.ts +212 -44
  84. package/src/triggers-api.test.ts +533 -0
  85. package/src/triggers-api.ts +295 -0
  86. package/src/triggers.ts +93 -7
  87. package/src/usage.test.ts +362 -0
  88. package/src/usage.ts +318 -0
  89. package/src/vault-create.test.ts +340 -12
  90. package/src/vault-name.test.ts +61 -3
  91. package/src/vault-name.ts +62 -14
  92. package/src/vault-remove.test.ts +187 -0
  93. package/src/vault-store.ts +10 -3
  94. package/src/vault.test.ts +1353 -62
  95. package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
  96. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  99. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -6,10 +6,21 @@
6
6
  "port": 1940,
7
7
  "paths": ["/vault/default"],
8
8
  "health": "/vault/default/health",
9
- "managementUrl": "/admin/",
10
- "uiUrl": "/admin/",
9
+ "managementUrl": "admin/",
10
+ "uiUrl": "admin/",
11
+ "configUiUrl": "/vault/admin/",
12
+ "focus": "core",
13
+ "adminCapabilities": ["config", "credentials"],
11
14
  "startCmd": ["parachute-vault", "serve"],
12
15
  "scopes": {
13
16
  "defines": ["vault:read", "vault:write", "vault:admin"]
14
- }
17
+ },
18
+ "events": [
19
+ { "key": "note.created", "title": "A note was created" },
20
+ { "key": "note.updated", "title": "A note was updated" },
21
+ { "key": "note.deleted", "title": "A note was deleted" }
22
+ ],
23
+ "actions": [
24
+ { "key": "note.create", "title": "Create a note", "inputSchema": {} }
25
+ ]
15
26
  }
package/README.md CHANGED
@@ -99,7 +99,7 @@ The daemon binds `0.0.0.0:1940` (or whatever you set in `PORT`) and serves REST,
99
99
 
100
100
  `vault init` asks two explicit questions: (1) install vault as an MCP server in `~/.claude.json`? (2) also surface the access token so you can paste it into other MCP clients (Codex, Goose, OpenCode, Cursor, Zed, Cline), scripts, or `curl`? Both default yes. Pass `--mcp` / `--no-mcp` and `--token` / `--no-token` for non-interactive installs.
101
101
 
102
- If you said yes to (2), the hub-issued JWT is printed prominently at the end — it's the same token baked into `~/.claude.json` (if you also said yes to (1)). It's not stored anywhere retrievable — save it if you need it for `curl`, cron, or any other script. Lost it? Mint a fresh one with `parachute auth mint-token --scope vault:<name>:<verb>` (or rewire an MCP client with `parachute-vault mcp-install`, or use the admin SPA Tokens page). As of vault 0.6.0 (vault#282 Stage 2) vault no longer mints its own `pvt_*` tokens — minting is the hub's job.
102
+ If you said yes to (2), the hub-issued JWT is printed prominently at the end — it's the same token baked into `~/.claude.json` (if you also said yes to (1)). It's not stored anywhere retrievable — save it if you need it for `curl`, cron, or any other script. Lost it? Mint a fresh one with `parachute auth mint-token --scope vault:<name>:<verb>` (or rewire an MCP client with `parachute-vault mcp-install`, or use the admin SPA Tokens page). As of vault 0.5.0 (vault#282 Stage 2) vault no longer mints its own `pvt_*` tokens — minting is the hub's job.
103
103
 
104
104
  ### OAuth lives on the hub
105
105
 
@@ -122,7 +122,7 @@ Two ways to authenticate — pick based on the client, not the deployment:
122
122
  | **OAuth 2.1 + PKCE (browser flow, via hub)** | Claude Desktop, Parachute Daily, any third-party MCP client set up interactively | Click "Add integration", enter the vault MCP URL, a browser opens to the **hub's** consent page, sign in with hub credentials, done — no token ever touches your clipboard |
123
123
  | **Bearer token (hub JWT)** | Claude Code (auto-wired by `vault init`), CLI scripts, cron jobs, any non-interactive caller | `curl -H "Authorization: Bearer <hub-jwt>"` — mint one with `parachute-vault mcp-install` (MCP clients) or `parachute auth mint-token --scope vault:<name>:<verb>` (scripts) |
124
124
 
125
- As of 0.6.0 (vault#282 Stage 2) vault is a **pure hub resource-server**: both paths use a hub-signed JWT that vault validates against the hub's JWKS. (The OAuth path is the interactive browser handshake; the bearer path mints the same kind of JWT non-interactively.) The old vault-local `pvt_*` opaque token was dropped — vault no longer mints or accepts it. The server-wide `VAULT_AUTH_TOKEN` operator bearer remains for the no-granular-auth / cross-container path.
125
+ As of 0.5.0 (vault#282 Stage 2) vault is a **pure hub resource-server**: both paths use a hub-signed JWT that vault validates against the hub's JWKS. (The OAuth path is the interactive browser handshake; the bearer path mints the same kind of JWT non-interactively.) The old vault-local `pvt_*` opaque token was dropped — vault no longer mints or accepts it. The server-wide `VAULT_AUTH_TOKEN` operator bearer remains for the no-granular-auth / cross-container path.
126
126
 
127
127
  ### Claude Code
128
128
 
@@ -219,7 +219,7 @@ parachute-vault 2fa backup-codes # regenerate backup codes
219
219
  # Tokens — vault#282 Stage 2: vault no longer mints its own tokens. Mint a
220
220
  # hub JWT with `parachute-vault mcp-install` (MCP clients) or
221
221
  # `parachute auth mint-token --scope vault:<name>:<verb>` (scripts).
222
- parachute-vault tokens # list any vestigial pre-0.6.0 token rows (all vaults)
222
+ parachute-vault tokens # list any vestigial pre-0.5.0 token rows (all vaults)
223
223
  parachute-vault tokens revoke <token-id> # revoke a vestigial row (default vault; add --vault to target)
224
224
 
225
225
  # Obsidian
@@ -233,6 +233,14 @@ parachute-vault config # show current configuration
233
233
  parachute-vault config set KEY value # set an env var (e.g. PORT=1940)
234
234
  parachute-vault config unset KEY # remove an env var
235
235
  parachute-vault restart # apply config changes (bounces the daemon)
236
+ # Env vars live in ~/.parachute/vault/.env. Notable ones:
237
+ # PORT — server port (default 1940)
238
+ # PARACHUTE_GITHUB_CLIENT_ID +
239
+ # PARACHUTE_GITHUB_APP_SLUG — bring-your-own GitHub App for the mirror
240
+ # "Back up to GitHub" flow (defaults to the
241
+ # shared Parachute app). Set BOTH or NEITHER:
242
+ # the pair must name the same app — mixing
243
+ # apps breaks the install probe.
236
244
 
237
245
  # Server
238
246
  parachute-vault serve # run the server in the foreground (no daemon)
@@ -522,6 +530,23 @@ curl -H "Authorization: Bearer $VAULT_TOKEN" \
522
530
 
523
531
  Caller-tunable preview length is a future enhancement — file an issue if 120 chars isn't enough.
524
532
 
533
+ ### Read a large note in chunks (content range)
534
+
535
+ A 100KB+ transcript won't fit in one MCP response. Pass `content_offset` / `content_length` (UTF-8 bytes) for a bounded read — the response carries the slice plus `content_total_length` and `content_next_offset` (`null` when complete). Loop, feeding `content_next_offset` back in as `content_offset`; concatenating the slices reconstructs the content byte-for-byte. Slices end on a codepoint boundary within the budget (never over `content_length`, at most 3 bytes under).
536
+
537
+ ```bash
538
+ curl -H "Authorization: Bearer $VAULT_TOKEN" \
539
+ "http://localhost:1940/vault/default/api/notes/Meetings%2F2026-06-09?content_offset=0&content_length=65536"
540
+ # → { ..., "content": "<first ≤64KB>", "content_total_length": 118034, "content_next_offset": 65530 }
541
+ ```
542
+
543
+ ```jsonc
544
+ // MCP — same params on query-notes; works per-note on lists with include_content: true
545
+ { "name": "query-notes", "arguments": { "id": "Meetings/2026-06-09", "content_offset": 0, "content_length": 65536 } }
546
+ ```
547
+
548
+ Range params require content in the response — with `include_content=false` (or a list query left on its lean default) they error rather than silently no-op. Full semantics in [docs/HTTP_API.md](./docs/HTTP_API.md) ("Content range — bounded reads for large notes").
549
+
525
550
  ### Incremental rebuilds: "what changed since X"
526
551
 
527
552
  The SSG / sync pattern. Two equivalent forms — bracket-style is canonical going forward; the flat form is the same shape that ships through the REST/MCP date filter today.
@@ -531,7 +556,7 @@ The SSG / sync pattern. Two equivalent forms — bracket-style is canonical goin
531
556
  curl -H "Authorization: Bearer $VAULT_TOKEN" \
532
557
  "http://localhost:1940/vault/default/api/notes?meta[updated_at][gte]=2026-04-01T00:00:00Z"
533
558
 
534
- # Flat form (DEPRECATED in 0.4.3; planned removal 0.6.0 per vault#288)
559
+ # Flat form (DEPRECATED in 0.4.3; planned removal in a later 0.x per vault#288)
535
560
  curl -H "Authorization: Bearer $VAULT_TOKEN" \
536
561
  "http://localhost:1940/vault/default/api/notes?date_field=updated_at&date_from=2026-04-01T00:00:00Z"
537
562
  ```
@@ -704,13 +729,13 @@ For wiring up an AI client (Claude Code, Claude Desktop, Parachute Daily), see [
704
729
 
705
730
  ### Passing the key
706
731
 
707
- As of 0.6.0 (vault#282 Stage 2) vault accepts these bearers at every authenticated endpoint:
732
+ As of 0.5.0 (vault#282 Stage 2) vault accepts these bearers at every authenticated endpoint:
708
733
 
709
734
  - **Hub-issued JWT** (`eyJ...`) — the user-credential path; what OAuth issues and what `parachute-vault mcp-install` / `parachute auth mint-token` produce. Audience-bound to `vault.<name>`, scope-narrowed (`vault:<name>:<verb>`).
710
735
  - **`VAULT_AUTH_TOKEN`** — the server-wide operator bearer (env var; full-admin against any vault on the server).
711
736
  - **`pvk_...`** — legacy global API keys from `config.yaml` / per-vault `vault.yaml` (still honored for existing deployments).
712
737
 
713
- The old vault-local `pvt_*` opaque token was **dropped at 0.6.0** — vault no longer mints or accepts it.
738
+ The old vault-local `pvt_*` opaque token was **dropped at 0.5.0** — vault no longer mints or accepts it.
714
739
 
715
740
  ```bash
716
741
  # Header (preferred)
@@ -742,7 +767,7 @@ Two permission levels carry through the JWT scope verb:
742
767
  | `read` | Query, list, find-path, vault-info only |
743
768
 
744
769
  `parachute-vault tokens list` / `tokens revoke` remain only to clean up any
745
- vestigial pre-0.6.0 rows. Legacy `pvk_...` keys from config.yaml still work at
770
+ vestigial pre-0.5.0 rows. Legacy `pvk_...` keys from config.yaml still work at
746
771
  runtime; the `vault keys` CLI commands were removed long ago.
747
772
 
748
773
  ### Public endpoints
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Content range / pagination tests — bounded reads for large notes.
3
+ *
4
+ * Three layers:
5
+ * 1. Unit tests of the parse + slice helpers (boundary cases: offset
6
+ * past end, sub-minimum budget, multi-byte codepoints at the cut).
7
+ * 2. Property test of the reassembly invariant: walking a string from
8
+ * offset 0 via `content_next_offset` and concatenating the slices is
9
+ * byte-identical to the full content, for arbitrary unicode content
10
+ * and budgets — and no slice ever exceeds the byte budget.
11
+ * 3. MCP face (`query-notes` execute): single + list shapes, the
12
+ * include_content interaction, and the no-params regression
13
+ * (response shape byte-identical to pre-pagination behavior).
14
+ *
15
+ * The REST face is exercised in src/content-range-routes.test.ts.
16
+ */
17
+
18
+ import { describe, it, expect, beforeEach } from "bun:test";
19
+ import { Database } from "bun:sqlite";
20
+ import { SqliteStore } from "./store.js";
21
+ import { generateMcpTools } from "./mcp.js";
22
+ import {
23
+ parseContentRange,
24
+ sliceContentRange,
25
+ applyContentRange,
26
+ MIN_CONTENT_LENGTH,
27
+ } from "./content-range.js";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // 1. parseContentRange
31
+ // ---------------------------------------------------------------------------
32
+
33
+ describe("parseContentRange", () => {
34
+ it("returns null when neither param is present (range mode off)", () => {
35
+ expect(parseContentRange(undefined, undefined)).toBeNull();
36
+ expect(parseContentRange(null, null)).toBeNull();
37
+ });
38
+
39
+ it("treats empty strings as absent (REST `?content_offset=`)", () => {
40
+ expect(parseContentRange("", "")).toBeNull();
41
+ });
42
+
43
+ it("offset only → length omitted (read to end)", () => {
44
+ expect(parseContentRange(10, undefined)).toEqual({ offset: 10 });
45
+ });
46
+
47
+ it("length only → offset defaults to 0", () => {
48
+ expect(parseContentRange(undefined, 64)).toEqual({ offset: 0, length: 64 });
49
+ });
50
+
51
+ it("accepts decimal strings (REST query params)", () => {
52
+ expect(parseContentRange("5", "1024")).toEqual({ offset: 5, length: 1024 });
53
+ });
54
+
55
+ it("rejects negative offset", () => {
56
+ expect(() => parseContentRange(-1, undefined)).toThrow(/content_offset/);
57
+ });
58
+
59
+ it("rejects non-integer values", () => {
60
+ expect(() => parseContentRange(1.5, undefined)).toThrow(/content_offset/);
61
+ expect(() => parseContentRange(undefined, 7.2)).toThrow(/content_length/);
62
+ expect(() => parseContentRange("abc", undefined)).toThrow(/content_offset/);
63
+ expect(() => parseContentRange(undefined, "-4")).toThrow(/content_length/);
64
+ });
65
+
66
+ it(`rejects zero / negative / sub-minimum budget (< ${MIN_CONTENT_LENGTH})`, () => {
67
+ expect(() => parseContentRange(undefined, 0)).toThrow(/content_length/);
68
+ expect(() => parseContentRange(undefined, -8)).toThrow(/content_length/);
69
+ expect(() => parseContentRange(undefined, MIN_CONTENT_LENGTH - 1)).toThrow(/content_length/);
70
+ // The minimum itself is fine.
71
+ expect(parseContentRange(undefined, MIN_CONTENT_LENGTH)).toEqual({
72
+ offset: 0,
73
+ length: MIN_CONTENT_LENGTH,
74
+ });
75
+ });
76
+
77
+ it("throws QueryError with INVALID_QUERY code", () => {
78
+ try {
79
+ parseContentRange(undefined, 2);
80
+ throw new Error("should have thrown");
81
+ } catch (e: any) {
82
+ expect(e.name).toBe("QueryError");
83
+ expect(e.code).toBe("INVALID_QUERY");
84
+ }
85
+ });
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // 2. sliceContentRange — boundary cases
90
+ // ---------------------------------------------------------------------------
91
+
92
+ describe("sliceContentRange", () => {
93
+ it("plain ASCII window", () => {
94
+ const r = sliceContentRange("hello world", { offset: 0, length: 5 });
95
+ expect(r.content).toBe("hello");
96
+ expect(r.content_offset).toBe(0);
97
+ expect(r.content_total_length).toBe(11);
98
+ expect(r.content_next_offset).toBe(5);
99
+ });
100
+
101
+ it("continuation window reaches the end → next_offset null", () => {
102
+ const r = sliceContentRange("hello world", { offset: 5, length: 100 });
103
+ expect(r.content).toBe(" world");
104
+ expect(r.content_next_offset).toBeNull();
105
+ });
106
+
107
+ it("offset with no length reads to the end", () => {
108
+ const r = sliceContentRange("hello world", { offset: 6 });
109
+ expect(r.content).toBe("world");
110
+ expect(r.content_total_length).toBe(11);
111
+ expect(r.content_next_offset).toBeNull();
112
+ });
113
+
114
+ it("offset exactly at end → empty slice, complete", () => {
115
+ const r = sliceContentRange("abc", { offset: 3 });
116
+ expect(r.content).toBe("");
117
+ expect(r.content_offset).toBe(3);
118
+ expect(r.content_total_length).toBe(3);
119
+ expect(r.content_next_offset).toBeNull();
120
+ });
121
+
122
+ it("offset past end → empty slice, complete (graceful loop termination)", () => {
123
+ const r = sliceContentRange("abc", { offset: 999, length: 16 });
124
+ expect(r.content).toBe("");
125
+ expect(r.content_offset).toBe(3); // clamped to total
126
+ expect(r.content_total_length).toBe(3);
127
+ expect(r.content_next_offset).toBeNull();
128
+ });
129
+
130
+ it("empty content → empty slice, total 0", () => {
131
+ const r = sliceContentRange("", { offset: 0, length: 16 });
132
+ expect(r.content).toBe("");
133
+ expect(r.content_total_length).toBe(0);
134
+ expect(r.content_next_offset).toBeNull();
135
+ });
136
+
137
+ it("budget cutting mid-codepoint backs off to the boundary (never over budget)", () => {
138
+ // "ab😀cd" — bytes: a=0, b=1, 😀=2..5 (4 bytes), c=6, d=7; total 8.
139
+ const s = "ab\u{1F600}cd";
140
+ expect(Buffer.byteLength(s, "utf8")).toBe(8);
141
+
142
+ // Budget 5 would cut mid-emoji → slice backs off to byte 2.
143
+ const r1 = sliceContentRange(s, { offset: 0, length: 5 });
144
+ expect(r1.content).toBe("ab");
145
+ expect(Buffer.byteLength(r1.content, "utf8")).toBeLessThanOrEqual(5);
146
+ expect(r1.content_next_offset).toBe(2);
147
+
148
+ // Next window picks up the whole emoji.
149
+ const r2 = sliceContentRange(s, { offset: 2, length: 4 });
150
+ expect(r2.content).toBe("\u{1F600}");
151
+ expect(r2.content_next_offset).toBe(6);
152
+
153
+ // Final window.
154
+ const r3 = sliceContentRange(s, { offset: 6, length: 4 });
155
+ expect(r3.content).toBe("cd");
156
+ expect(r3.content_next_offset).toBeNull();
157
+ });
158
+
159
+ it("offset landing mid-codepoint aligns DOWN (no bytes skipped) and echoes the effective offset", () => {
160
+ const s = "ab\u{1F600}cd"; // emoji occupies bytes 2..5
161
+ const r = sliceContentRange(s, { offset: 4, length: 8 });
162
+ expect(r.content_offset).toBe(2); // aligned down to the emoji's lead byte
163
+ expect(r.content).toBe("\u{1F600}cd");
164
+ expect(r.content_next_offset).toBeNull();
165
+ });
166
+
167
+ it("minimum budget always makes progress, even on a 4-byte codepoint", () => {
168
+ const s = "\u{1F600}\u{1F601}"; // two 4-byte emoji
169
+ const r = sliceContentRange(s, { offset: 0, length: MIN_CONTENT_LENGTH });
170
+ expect(r.content).toBe("\u{1F600}");
171
+ expect(r.content_next_offset).toBe(4);
172
+ });
173
+
174
+ it("applyContentRange mutates the shaped result in place", () => {
175
+ const result: any = { id: "n1", content: "hello world", tags: ["x"] };
176
+ applyContentRange(result, { offset: 0, length: 5 });
177
+ expect(result.content).toBe("hello");
178
+ expect(result.content_offset).toBe(0);
179
+ expect(result.content_total_length).toBe(11);
180
+ expect(result.content_next_offset).toBe(5);
181
+ expect(result.tags).toEqual(["x"]); // untouched
182
+ });
183
+ });
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // 2b. Property test — reassembly invariant
187
+ // ---------------------------------------------------------------------------
188
+
189
+ /** Deterministic PRNG (mulberry32) so failures reproduce. */
190
+ function mulberry32(seed: number): () => number {
191
+ let a = seed >>> 0;
192
+ return () => {
193
+ a |= 0;
194
+ a = (a + 0x6d2b79f5) | 0;
195
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
196
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
197
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
198
+ };
199
+ }
200
+
201
+ describe("content range — reassembly property", () => {
202
+ // Mixed-width pool: 1-byte ASCII, 2-byte (é, ψ), 3-byte (你, ‱), 4-byte
203
+ // (😀, 𝄞) — plus whitespace, so windows land on every codepoint width.
204
+ const POOL = ["a", "Z", "9", " ", "\n", "é", "ψ", "你", "‱", "\u{1F600}", "\u{1D11E}"];
205
+
206
+ it("concatenating all slices is byte-identical to the full content; no slice exceeds the budget", () => {
207
+ const rand = mulberry32(0xc0ffee);
208
+ for (let iter = 0; iter < 60; iter++) {
209
+ const charCount = Math.floor(rand() * 120); // includes 0 (empty content)
210
+ let content = "";
211
+ for (let i = 0; i < charCount; i++) {
212
+ content += POOL[Math.floor(rand() * POOL.length)]!;
213
+ }
214
+ const budget = MIN_CONTENT_LENGTH + Math.floor(rand() * 13); // 4..16 bytes
215
+ const totalBytes = Buffer.byteLength(content, "utf8");
216
+
217
+ let offset = 0;
218
+ let assembled = "";
219
+ let lastTotal: number | null = null;
220
+ // Hard ceiling on iterations: every window must advance by >= 1 byte.
221
+ for (let step = 0; step <= totalBytes + 2; step++) {
222
+ const slice = sliceContentRange(content, { offset, length: budget });
223
+ expect(Buffer.byteLength(slice.content, "utf8")).toBeLessThanOrEqual(budget);
224
+ expect(slice.content_total_length).toBe(totalBytes);
225
+ lastTotal = slice.content_total_length;
226
+ assembled += slice.content;
227
+ if (slice.content_next_offset === null) break;
228
+ // Progress guarantee — next offset strictly advances.
229
+ expect(slice.content_next_offset).toBeGreaterThan(offset);
230
+ offset = slice.content_next_offset;
231
+ }
232
+
233
+ expect(assembled).toBe(content);
234
+ expect(Buffer.from(assembled, "utf8").equals(Buffer.from(content, "utf8"))).toBe(true);
235
+ expect(lastTotal).toBe(totalBytes);
236
+ }
237
+ });
238
+ });
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // 3. MCP face — query-notes
242
+ // ---------------------------------------------------------------------------
243
+
244
+ describe("MCP query-notes — content range", () => {
245
+ let db: Database;
246
+ let store: SqliteStore;
247
+
248
+ beforeEach(() => {
249
+ db = new Database(":memory:");
250
+ store = new SqliteStore(db);
251
+ });
252
+
253
+ function queryTool() {
254
+ const tools = generateMcpTools(store);
255
+ return tools.find((t) => t.name === "query-notes")!;
256
+ }
257
+
258
+ it("single note: paged read loop reassembles the full content", async () => {
259
+ // Mixed-width content so windows hit multi-byte boundaries.
260
+ const content = ("section \u{1F600} 你好 " .repeat(40)).trim();
261
+ const note = await store.createNote(content);
262
+ const query = queryTool();
263
+
264
+ let offset = 0;
265
+ let assembled = "";
266
+ for (;;) {
267
+ const r: any = await query.execute({ id: note.id, content_offset: offset, content_length: 64 });
268
+ expect(r.content_total_length).toBe(Buffer.byteLength(content, "utf8"));
269
+ assembled += r.content;
270
+ if (r.content_next_offset === null) break;
271
+ offset = r.content_next_offset;
272
+ }
273
+ expect(assembled).toBe(content);
274
+ });
275
+
276
+ it("single note: response carries the range fields and the slice", async () => {
277
+ const note = await store.createNote("0123456789");
278
+ const r: any = await queryTool().execute({ id: note.id, content_length: 4 });
279
+ expect(r.content).toBe("0123");
280
+ expect(r.content_offset).toBe(0);
281
+ expect(r.content_total_length).toBe(10);
282
+ expect(r.content_next_offset).toBe(4);
283
+ expect(r.id).toBe(note.id); // rest of the note shape intact
284
+ });
285
+
286
+ it("single note, no range params → byte-identical to today (regression)", async () => {
287
+ const note = await store.createNote("full body here");
288
+ const r: any = await queryTool().execute({ id: note.id });
289
+ expect(r.content).toBe("full body here");
290
+ expect("content_total_length" in r).toBe(false);
291
+ expect("content_next_offset" in r).toBe(false);
292
+ expect("content_offset" in r).toBe(false);
293
+ });
294
+
295
+ it("single note: content_offset past end → empty slice, complete", async () => {
296
+ const note = await store.createNote("abc");
297
+ const r: any = await queryTool().execute({ id: note.id, content_offset: 999 });
298
+ expect(r.content).toBe("");
299
+ expect(r.content_total_length).toBe(3);
300
+ expect(r.content_next_offset).toBeNull();
301
+ });
302
+
303
+ it("single note: include_content=false + range params → loud error", async () => {
304
+ const note = await store.createNote("abc");
305
+ expect(
306
+ queryTool().execute({ id: note.id, include_content: false, content_length: 8 }),
307
+ ).rejects.toThrow(/include_content/);
308
+ });
309
+
310
+ it("rejects sub-minimum / invalid budgets before any query work", async () => {
311
+ const note = await store.createNote("abc");
312
+ expect(queryTool().execute({ id: note.id, content_length: 0 })).rejects.toThrow(/content_length/);
313
+ expect(queryTool().execute({ id: note.id, content_length: -5 })).rejects.toThrow(/content_length/);
314
+ expect(queryTool().execute({ id: note.id, content_length: 2 })).rejects.toThrow(/content_length/);
315
+ expect(queryTool().execute({ id: note.id, content_offset: -1 })).rejects.toThrow(/content_offset/);
316
+ });
317
+
318
+ it("list query: include_content=true applies the window per note", async () => {
319
+ await store.createNote("alpha alpha alpha", { tags: ["big"] });
320
+ await store.createNote("beta beta beta beta", { tags: ["big"] });
321
+ const out: any[] = (await queryTool().execute({
322
+ tag: "big",
323
+ include_content: true,
324
+ content_length: 5,
325
+ })) as any[];
326
+ expect(out.length).toBe(2);
327
+ for (const n of out) {
328
+ expect(Buffer.byteLength(n.content, "utf8")).toBeLessThanOrEqual(5);
329
+ expect(typeof n.content_total_length).toBe("number");
330
+ expect(n.content_next_offset).toBe(5);
331
+ }
332
+ });
333
+
334
+ it("list query: lean default (no include_content) + range params → loud error", async () => {
335
+ await store.createNote("alpha", { tags: ["big"] });
336
+ expect(queryTool().execute({ tag: "big", content_length: 8 })).rejects.toThrow(
337
+ /include_content/,
338
+ );
339
+ });
340
+
341
+ it("list query, no range params → no range fields injected (regression)", async () => {
342
+ await store.createNote("alpha", { tags: ["big"] });
343
+ const out: any[] = (await queryTool().execute({ tag: "big", include_content: true })) as any[];
344
+ expect(out.length).toBe(1);
345
+ expect("content_total_length" in out[0]).toBe(false);
346
+ expect("content_next_offset" in out[0]).toBe(false);
347
+ });
348
+
349
+ it("expand_links: the range applies to the EXPANDED content", async () => {
350
+ await store.createNote("inlined body of B", { path: "B" });
351
+ const a = await store.createNote("A says: [[B]]", { path: "A" });
352
+ const query = queryTool();
353
+
354
+ const unpaged: any = await query.execute({ id: a.id, expand_links: true });
355
+ const paged: any = await query.execute({
356
+ id: a.id,
357
+ expand_links: true,
358
+ content_offset: 0,
359
+ content_length: 100000,
360
+ });
361
+ expect(paged.content).toBe(unpaged.content);
362
+ expect(paged.content_total_length).toBe(Buffer.byteLength(unpaged.content, "utf8"));
363
+ expect(paged.content_next_offset).toBeNull();
364
+ });
365
+
366
+ it("query-notes schema advertises the params (MCP discovery)", () => {
367
+ const query = queryTool();
368
+ const props = (query.inputSchema as any).properties;
369
+ expect(props.content_offset).toBeDefined();
370
+ expect(props.content_length).toBeDefined();
371
+ expect(query.description).toContain("content_offset");
372
+ expect(query.description).toContain("content_next_offset");
373
+ });
374
+ });