@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.
- package/.parachute/module.json +14 -3
- package/README.md +32 -7
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +172 -22
- package/core/src/mcp.ts +254 -34
- package/core/src/notes.ts +172 -48
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +87 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/content-range-routes.test.ts +178 -0
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +1331 -110
- package/src/mirror-routes.ts +787 -30
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +763 -122
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +121 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/.parachute/module.json
CHANGED
|
@@ -6,10 +6,21 @@
|
|
|
6
6
|
"port": 1940,
|
|
7
7
|
"paths": ["/vault/default"],
|
|
8
8
|
"health": "/vault/default/health",
|
|
9
|
-
"managementUrl": "
|
|
10
|
-
"uiUrl": "
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
});
|