@openparachute/vault 0.1.0
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/.claude/settings.local.json +31 -0
- package/.dockerignore +8 -0
- package/.env.example +9 -0
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
- package/CLAUDE.md +115 -0
- package/Caddyfile +3 -0
- package/Dockerfile +22 -0
- package/LICENSE +661 -0
- package/README.md +356 -0
- package/bun.lock +219 -0
- package/bunfig.toml +2 -0
- package/core/package.json +7 -0
- package/core/src/core.test.ts +940 -0
- package/core/src/hooks.test.ts +361 -0
- package/core/src/hooks.ts +234 -0
- package/core/src/links.ts +352 -0
- package/core/src/mcp.ts +672 -0
- package/core/src/notes.ts +520 -0
- package/core/src/obsidian.test.ts +380 -0
- package/core/src/obsidian.ts +322 -0
- package/core/src/paths.test.ts +197 -0
- package/core/src/paths.ts +53 -0
- package/core/src/schema.ts +331 -0
- package/core/src/store.ts +303 -0
- package/core/src/tag-schemas.ts +104 -0
- package/core/src/test-preload.ts +8 -0
- package/core/src/types.ts +140 -0
- package/core/src/wikilinks.test.ts +277 -0
- package/core/src/wikilinks.ts +402 -0
- package/deploy/parachute-vault.service +20 -0
- package/docker-compose.yml +50 -0
- package/docs/HTTP_API.md +328 -0
- package/fly.toml +24 -0
- package/package.json +32 -0
- package/railway.json +14 -0
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/scripts/migrate-audio-to-opus.test.ts +237 -0
- package/scripts/migrate-audio-to-opus.ts +499 -0
- package/src/auth.ts +170 -0
- package/src/cli.ts +1131 -0
- package/src/config-triggers.test.ts +83 -0
- package/src/config.test.ts +125 -0
- package/src/config.ts +716 -0
- package/src/db.ts +14 -0
- package/src/launchd.ts +109 -0
- package/src/mcp-http.ts +113 -0
- package/src/mcp-tools.ts +155 -0
- package/src/oauth.test.ts +1242 -0
- package/src/oauth.ts +729 -0
- package/src/owner-auth.ts +159 -0
- package/src/prompt.ts +141 -0
- package/src/published.test.ts +214 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/routes.ts +822 -0
- package/src/server.ts +450 -0
- package/src/systemd.ts +84 -0
- package/src/token-store.test.ts +174 -0
- package/src/token-store.ts +241 -0
- package/src/triggers.test.ts +397 -0
- package/src/triggers.ts +412 -0
- package/src/two-factor.test.ts +246 -0
- package/src/two-factor.ts +222 -0
- package/src/vault-store.ts +47 -0
- package/src/vault.test.ts +1309 -0
- package/tsconfig.json +29 -0
- package/web/README.md +73 -0
- package/web/bun.lock +827 -0
- package/web/eslint.config.js +23 -0
- package/web/index.html +15 -0
- package/web/package.json +36 -0
- package/web/public/favicon.svg +1 -0
- package/web/public/icons.svg +24 -0
- package/web/src/App.tsx +149 -0
- package/web/src/Graph.tsx +200 -0
- package/web/src/NoteView.tsx +155 -0
- package/web/src/Sidebar.tsx +186 -0
- package/web/src/api.ts +21 -0
- package/web/src/index.css +50 -0
- package/web/src/main.tsx +10 -0
- package/web/src/types.ts +37 -0
- package/web/src/utils.ts +107 -0
- package/web/tsconfig.app.json +25 -0
- package/web/tsconfig.json +7 -0
- package/web/tsconfig.node.json +24 -0
- package/web/vite.config.ts +15 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Parachute Vault — self-hosted deployment
|
|
2
|
+
#
|
|
3
|
+
# Quick start:
|
|
4
|
+
# 1. Copy .env.example to .env and fill in your values
|
|
5
|
+
# 2. docker compose up -d
|
|
6
|
+
#
|
|
7
|
+
# For HTTPS (required for MCP from Claude Desktop):
|
|
8
|
+
# Set VAULT_DOMAIN in .env, ensure DNS points to this server.
|
|
9
|
+
# Caddy handles Let's Encrypt automatically.
|
|
10
|
+
#
|
|
11
|
+
# Without HTTPS (local/development):
|
|
12
|
+
# Leave VAULT_DOMAIN empty — Caddy proxies on port 80 only.
|
|
13
|
+
|
|
14
|
+
services:
|
|
15
|
+
vault:
|
|
16
|
+
build: .
|
|
17
|
+
restart: unless-stopped
|
|
18
|
+
volumes:
|
|
19
|
+
- vault-data:/data
|
|
20
|
+
env_file: .env
|
|
21
|
+
environment:
|
|
22
|
+
- PARACHUTE_HOME=/data
|
|
23
|
+
expose:
|
|
24
|
+
- "1940"
|
|
25
|
+
healthcheck:
|
|
26
|
+
test: ["CMD", "wget", "-qO-", "http://localhost:1940/health"]
|
|
27
|
+
interval: 30s
|
|
28
|
+
timeout: 3s
|
|
29
|
+
start_period: 5s
|
|
30
|
+
|
|
31
|
+
caddy:
|
|
32
|
+
image: caddy:2-alpine
|
|
33
|
+
restart: unless-stopped
|
|
34
|
+
ports:
|
|
35
|
+
- "80:80"
|
|
36
|
+
- "443:443"
|
|
37
|
+
volumes:
|
|
38
|
+
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
39
|
+
- caddy-data:/data
|
|
40
|
+
- caddy-config:/config
|
|
41
|
+
environment:
|
|
42
|
+
- VAULT_DOMAIN=${VAULT_DOMAIN:-localhost}
|
|
43
|
+
depends_on:
|
|
44
|
+
vault:
|
|
45
|
+
condition: service_healthy
|
|
46
|
+
|
|
47
|
+
volumes:
|
|
48
|
+
vault-data:
|
|
49
|
+
caddy-data:
|
|
50
|
+
caddy-config:
|
package/docs/HTTP_API.md
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# Parachute Vault HTTP API
|
|
2
|
+
|
|
3
|
+
A flat reference for the Parachute Vault HTTP surface. Intended for humans *and*
|
|
4
|
+
agents building tools that read or write a vault over HTTP.
|
|
5
|
+
|
|
6
|
+
All endpoints serve JSON. The same vault is reachable at two roots:
|
|
7
|
+
|
|
8
|
+
- `/api/...` — the server's default vault
|
|
9
|
+
- `/vaults/{name}/api/...` — any named vault on this server
|
|
10
|
+
|
|
11
|
+
Use whichever is convenient. Examples below use the default `/api` root.
|
|
12
|
+
|
|
13
|
+
## Quick start — render a graph in 5 lines
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
const res = await fetch("http://localhost:1940/api/graph", {
|
|
17
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
18
|
+
});
|
|
19
|
+
const { notes, links, tags, meta } = await res.json();
|
|
20
|
+
// notes: lightweight NoteIndex[] — id, path, tags, createdAt, byteSize, preview
|
|
21
|
+
// links: Link[] — sourceId, targetId, relationship, metadata
|
|
22
|
+
// Hand this to d3-force, cytoscape, sigma.js, etc.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
That's the whole happy path. Everything else in this doc is detail.
|
|
26
|
+
|
|
27
|
+
## Conventions
|
|
28
|
+
|
|
29
|
+
- **Response payloads are camelCase**: `createdAt`, `sourceId`, `mimeType`,
|
|
30
|
+
`totalNotes`.
|
|
31
|
+
- **Request payloads are camelCase**: you `POST {sourceId, targetId, ...}` and
|
|
32
|
+
get the same shape back.
|
|
33
|
+
- **Query params are snake_case**: `?include_content=true`, `?tag_match=any`,
|
|
34
|
+
`?date_from=2025-01-01`. This matches the MCP tool-arg convention, so one
|
|
35
|
+
concept ports cleanly between HTTP and MCP.
|
|
36
|
+
- **Timestamps are ISO-8601** UTC strings (e.g. `2026-04-07T15:30:00.000Z`).
|
|
37
|
+
- **No envelope**. Responses are the data itself (`{...}` or `[...]`), not
|
|
38
|
+
wrapped in `{data: ...}`. Error responses are `{error: "...", message?: "..."}`.
|
|
39
|
+
|
|
40
|
+
## Authentication
|
|
41
|
+
|
|
42
|
+
Pass your API key as either:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
Authorization: Bearer <key>
|
|
46
|
+
X-API-Key: <key>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Requests from localhost bypass auth (you can hit the server directly without a
|
|
50
|
+
key for local dev).
|
|
51
|
+
|
|
52
|
+
Keys have a **scope**:
|
|
53
|
+
|
|
54
|
+
- `write` — full access
|
|
55
|
+
- `read` — `GET`/`HEAD`/`OPTIONS` only; writes return `403 Forbidden`
|
|
56
|
+
|
|
57
|
+
A read-only key is the right thing to hand to a visualizer or static-site
|
|
58
|
+
generator.
|
|
59
|
+
|
|
60
|
+
## The shapes
|
|
61
|
+
|
|
62
|
+
### `Note`
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
{
|
|
66
|
+
id: string;
|
|
67
|
+
content: string;
|
|
68
|
+
path?: string;
|
|
69
|
+
metadata?: Record<string, unknown>;
|
|
70
|
+
createdAt: string;
|
|
71
|
+
updatedAt?: string;
|
|
72
|
+
tags?: string[];
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### `NoteIndex` (lean shape)
|
|
77
|
+
|
|
78
|
+
Returned by list endpoints by default. Same as `Note` minus `content`, plus
|
|
79
|
+
`byteSize` and a one-line `preview` (120 code points, whitespace collapsed).
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
{
|
|
83
|
+
id: string;
|
|
84
|
+
path?: string;
|
|
85
|
+
createdAt: string;
|
|
86
|
+
updatedAt?: string;
|
|
87
|
+
tags?: string[];
|
|
88
|
+
metadata?: Record<string, unknown>;
|
|
89
|
+
byteSize: number; // UTF-8 bytes of the full content
|
|
90
|
+
preview: string; // first ~120 chars, single line
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### `Link`
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
{
|
|
98
|
+
sourceId: string;
|
|
99
|
+
targetId: string;
|
|
100
|
+
relationship: string;
|
|
101
|
+
metadata?: Record<string, unknown>;
|
|
102
|
+
createdAt: string;
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `VaultStats`
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
{
|
|
110
|
+
totalNotes: number;
|
|
111
|
+
earliestNote: { id: string; createdAt: string } | null;
|
|
112
|
+
latestNote: { id: string; createdAt: string } | null;
|
|
113
|
+
notesByMonth: { month: string; count: number }[]; // e.g. "2026-04"
|
|
114
|
+
topTags: { tag: string; count: number }[];
|
|
115
|
+
tagCount: number;
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Defaults: lean lists, fat point reads
|
|
120
|
+
|
|
121
|
+
- **List endpoints** (`GET /notes`, `GET /graph`) default to `NoteIndex`. The
|
|
122
|
+
common case is viz/listing, which doesn't need the full body of every note.
|
|
123
|
+
- **Point reads** (`GET /notes/:id`) default to the full `Note`. If you asked
|
|
124
|
+
for one specific thing by ID, you probably want its content.
|
|
125
|
+
|
|
126
|
+
Both shapes can be forced either way with `?include_content=true|false`.
|
|
127
|
+
|
|
128
|
+
## Endpoints
|
|
129
|
+
|
|
130
|
+
### Server-level
|
|
131
|
+
|
|
132
|
+
#### `GET /health`
|
|
133
|
+
Returns `{status: "ok", vaults: string[]}`. No auth required.
|
|
134
|
+
|
|
135
|
+
#### `GET /vaults`
|
|
136
|
+
List all vaults on the server.
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"vaults": [
|
|
140
|
+
{ "name": "default", "description": "...", "created_at": "..." }
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
#### `GET /vaults/{name}`
|
|
146
|
+
Single-vault landing payload — name, description, createdAt, and stats in one
|
|
147
|
+
round trip. Useful for a viz site's home page.
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"name": "default",
|
|
151
|
+
"description": "My knowledge graph",
|
|
152
|
+
"createdAt": "2026-01-01T00:00:00.000Z",
|
|
153
|
+
"stats": { "totalNotes": 617, "topTags": [...], "notesByMonth": [...], ... }
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Notes
|
|
158
|
+
|
|
159
|
+
#### `GET /notes`
|
|
160
|
+
Query notes. Returns `NoteIndex[]` by default.
|
|
161
|
+
|
|
162
|
+
Query params:
|
|
163
|
+
- `include_content=true` — return full `Note[]` instead.
|
|
164
|
+
- `ids=a,b,c` — fetch specific notes by ID. Practical limit ~50 IDs due to
|
|
165
|
+
URL length; for larger batches call multiple times.
|
|
166
|
+
- `tag=foo&tag=bar` — filter by tags (repeat param to pass multiple).
|
|
167
|
+
- `tag_match=all|any` — default `all`.
|
|
168
|
+
- `exclude_tag=foo` — exclude notes with this tag.
|
|
169
|
+
- `date_from=ISO` — inclusive lower bound on `createdAt`.
|
|
170
|
+
- `date_to=ISO` — exclusive upper bound.
|
|
171
|
+
- `sort=asc|desc` — by `createdAt`. Default `asc`.
|
|
172
|
+
- `limit=N` — default 100.
|
|
173
|
+
- `offset=N` — default 0.
|
|
174
|
+
|
|
175
|
+
#### `POST /notes`
|
|
176
|
+
Create a note. Body:
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"content": "...", // required
|
|
180
|
+
"id": "optional-client-id",
|
|
181
|
+
"path": "Projects/Foo",
|
|
182
|
+
"tags": ["a", "b"],
|
|
183
|
+
"metadata": { "status": "draft" },
|
|
184
|
+
"createdAt": "2026-04-07T..."
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
Returns the created `Note`, `201 Created`.
|
|
188
|
+
|
|
189
|
+
#### `GET /notes/{id}`
|
|
190
|
+
Returns the full `Note`. `?include_content=false` returns a `NoteIndex`.
|
|
191
|
+
|
|
192
|
+
#### `PATCH /notes/{id}`
|
|
193
|
+
Update content, path, or metadata. Body:
|
|
194
|
+
```json
|
|
195
|
+
{ "content": "new body", "path": "new/path", "metadata": {...} }
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
#### `DELETE /notes/{id}`
|
|
199
|
+
Returns `{deleted: true}`.
|
|
200
|
+
|
|
201
|
+
#### `POST /notes/{id}/tags`, `DELETE /notes/{id}/tags`
|
|
202
|
+
Body: `{"tags": ["a", "b"]}`.
|
|
203
|
+
|
|
204
|
+
#### `POST /notes/{id}/attachments`
|
|
205
|
+
Body: `{"path": "files/a.png", "mimeType": "image/png"}`.
|
|
206
|
+
|
|
207
|
+
#### `GET /notes/{id}/attachments`
|
|
208
|
+
Returns `Attachment[]`.
|
|
209
|
+
|
|
210
|
+
### Links
|
|
211
|
+
|
|
212
|
+
#### `GET /links`
|
|
213
|
+
List edges. Polymorphic — filters compose freely.
|
|
214
|
+
|
|
215
|
+
Query params:
|
|
216
|
+
- `note_id=abc` — only edges touching this note.
|
|
217
|
+
- `direction=outbound|inbound|both` — only meaningful with `note_id`.
|
|
218
|
+
Default `both`.
|
|
219
|
+
- `relationship=cites` — only edges of this type.
|
|
220
|
+
|
|
221
|
+
Returns bare `Link[]` — no hydration. If you need the connected notes'
|
|
222
|
+
details, pair the result with `GET /notes?ids=...`.
|
|
223
|
+
|
|
224
|
+
Examples:
|
|
225
|
+
```
|
|
226
|
+
GET /links # everything
|
|
227
|
+
GET /links?note_id=abc # all edges touching note abc
|
|
228
|
+
GET /links?note_id=abc&direction=outbound
|
|
229
|
+
GET /links?relationship=cites # vault-wide, by type
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
#### `POST /links`
|
|
233
|
+
Body:
|
|
234
|
+
```json
|
|
235
|
+
{ "sourceId": "a", "targetId": "b", "relationship": "cites", "metadata": {...} }
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
#### `DELETE /links`
|
|
239
|
+
Body:
|
|
240
|
+
```json
|
|
241
|
+
{ "sourceId": "a", "targetId": "b", "relationship": "cites" }
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Graph
|
|
245
|
+
|
|
246
|
+
#### `GET /graph`
|
|
247
|
+
One-shot knowledge graph payload for visualization.
|
|
248
|
+
|
|
249
|
+
```json
|
|
250
|
+
{
|
|
251
|
+
"notes": [ /* NoteIndex[] by default, Note[] if include_content=true */ ],
|
|
252
|
+
"links": [ /* Link[] */ ],
|
|
253
|
+
"tags": [ { "name": "...", "count": 12 } ],
|
|
254
|
+
"meta": {
|
|
255
|
+
"totalNotes": 617,
|
|
256
|
+
"totalLinks": 1234,
|
|
257
|
+
"filteredNotes": 617,
|
|
258
|
+
"filteredLinks": 1234,
|
|
259
|
+
"includeContent": false
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Query params:
|
|
265
|
+
- `include_content=true` — fatten each note to include full content.
|
|
266
|
+
- `tag=foo&tag=bar` — filter to a subgraph (only notes with these tags, and
|
|
267
|
+
only links where **both** endpoints are in the subset).
|
|
268
|
+
- `tag_match=all|any` — default `all`.
|
|
269
|
+
- `exclude_tag=foo`.
|
|
270
|
+
|
|
271
|
+
`meta.totalNotes` / `meta.totalLinks` always reflect the full vault;
|
|
272
|
+
`filteredNotes` / `filteredLinks` reflect the response.
|
|
273
|
+
|
|
274
|
+
### Search
|
|
275
|
+
|
|
276
|
+
#### `GET /search?q=query`
|
|
277
|
+
Full-text search. Returns `Note[]` (full shape).
|
|
278
|
+
|
|
279
|
+
Query params:
|
|
280
|
+
- `q=...` — required.
|
|
281
|
+
- `tag=foo` — optional tag filter (repeatable).
|
|
282
|
+
- `limit=N` — default 50.
|
|
283
|
+
|
|
284
|
+
### Tags
|
|
285
|
+
|
|
286
|
+
#### `GET /tags`
|
|
287
|
+
Returns `[{name, count}]`.
|
|
288
|
+
|
|
289
|
+
### Vault stats
|
|
290
|
+
|
|
291
|
+
You usually want `GET /vaults/{name}` which bundles stats with vault metadata.
|
|
292
|
+
If you only need the stats, call `GET /vaults/{name}` and read `.stats`.
|
|
293
|
+
|
|
294
|
+
### Storage
|
|
295
|
+
|
|
296
|
+
#### `POST /storage/upload`
|
|
297
|
+
Multipart form:
|
|
298
|
+
- `file` — required, audio/image, ≤100MB
|
|
299
|
+
|
|
300
|
+
Returns `{path, size, mimeType}`.
|
|
301
|
+
|
|
302
|
+
#### `GET /storage/{date}/{filename}`
|
|
303
|
+
Serves the uploaded file.
|
|
304
|
+
|
|
305
|
+
## CORS
|
|
306
|
+
|
|
307
|
+
The server sends permissive CORS headers (`Access-Control-Allow-Origin: *`)
|
|
308
|
+
so a static site on any origin can fetch the API. Writes still require a
|
|
309
|
+
valid API key.
|
|
310
|
+
|
|
311
|
+
## Pairing with MCP
|
|
312
|
+
|
|
313
|
+
Every read endpoint here has a matching MCP tool over `/mcp`
|
|
314
|
+
(unified) or `/vaults/{name}/mcp` (scoped):
|
|
315
|
+
|
|
316
|
+
| HTTP | MCP tool |
|
|
317
|
+
|---------------------------|---------------------|
|
|
318
|
+
| `GET /notes` | `read-notes` |
|
|
319
|
+
| `GET /notes?ids=...` | `get-note` (ids) |
|
|
320
|
+
| `GET /notes/{id}` | `get-note` |
|
|
321
|
+
| `GET /links` | `get-links` |
|
|
322
|
+
| `GET /graph` | `get-graph` |
|
|
323
|
+
| `GET /vaults/{name}` | `get-vault-stats` + `get-vault-description` |
|
|
324
|
+
| `GET /tags` | `list-tags` |
|
|
325
|
+
| `GET /search?q=` | `search-notes` |
|
|
326
|
+
|
|
327
|
+
The MCP tools use the same lean-vs-fat convention (`include_content: true|false`)
|
|
328
|
+
and the same snake_case arg names as the HTTP query params.
|
package/fly.toml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
app = "parachute-vault"
|
|
2
|
+
primary_region = "iad"
|
|
3
|
+
|
|
4
|
+
[build]
|
|
5
|
+
dockerfile = "Dockerfile"
|
|
6
|
+
|
|
7
|
+
[env]
|
|
8
|
+
PORT = "1940"
|
|
9
|
+
PARACHUTE_HOME = "/data"
|
|
10
|
+
|
|
11
|
+
[http_service]
|
|
12
|
+
internal_port = 1940
|
|
13
|
+
force_https = true
|
|
14
|
+
auto_stop_machines = "stop"
|
|
15
|
+
auto_start_machines = true
|
|
16
|
+
min_machines_running = 0
|
|
17
|
+
|
|
18
|
+
[[vm]]
|
|
19
|
+
size = "shared-cpu-1x"
|
|
20
|
+
memory = "512mb"
|
|
21
|
+
|
|
22
|
+
[[mounts]]
|
|
23
|
+
source = "vault_data"
|
|
24
|
+
destination = "/data"
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openparachute/vault",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
|
|
5
|
+
"module": "src/cli.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"parachute": "src/cli.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "bun src/server.ts",
|
|
12
|
+
"cli": "bun src/cli.ts",
|
|
13
|
+
"test": "bun test src/",
|
|
14
|
+
"test:core": "cd core && node --experimental-vm-modules node_modules/vitest/dist/cli.js run"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
18
|
+
"otpauth": "^9.5.0",
|
|
19
|
+
"qrcode-terminal": "^0.12.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/bun": "latest"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"typescript": "^5"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/ParachuteComputer/parachute-vault.git"
|
|
30
|
+
},
|
|
31
|
+
"license": "AGPL-3.0"
|
|
32
|
+
}
|
package/railway.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://railway.com/railway.schema.json",
|
|
3
|
+
"build": {
|
|
4
|
+
"builder": "DOCKERFILE",
|
|
5
|
+
"dockerfilePath": "Dockerfile"
|
|
6
|
+
},
|
|
7
|
+
"deploy": {
|
|
8
|
+
"startCommand": "bun src/server.ts",
|
|
9
|
+
"healthcheckPath": "/health",
|
|
10
|
+
"healthcheckTimeout": 5,
|
|
11
|
+
"restartPolicyType": "ON_FAILURE",
|
|
12
|
+
"restartPolicyMaxRetries": 3
|
|
13
|
+
}
|
|
14
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/religions-v1.png
ADDED
|
Binary file
|
package/religions-v2.png
ADDED
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for scripts/migrate-audio-to-opus.ts.
|
|
3
|
+
*
|
|
4
|
+
* Spins up a temp vault layout under a fresh PARACHUTE_HOME, seeds a WAV
|
|
5
|
+
* attachment, runs the script in dry-run then real mode, asserts the DB
|
|
6
|
+
* row was rewritten, the .ogg file exists, and the original was unlinked.
|
|
7
|
+
*
|
|
8
|
+
* Uses the real ffmpeg binary (matches src/audio-encoding.test.ts).
|
|
9
|
+
*
|
|
10
|
+
* Requires `@openparachute/narrate` which is not a vault dependency.
|
|
11
|
+
* Install manually (`bun add @openparachute/narrate`) to run these tests.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
15
|
+
import { Database } from "bun:sqlite";
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { tmpdir } from "os";
|
|
19
|
+
import { SCHEMA_SQL } from "../core/src/schema.ts";
|
|
20
|
+
|
|
21
|
+
// @openparachute/narrate is not a vault dependency — dynamically import
|
|
22
|
+
// so the test file can at least be parsed without the optional package.
|
|
23
|
+
let runMigration: typeof import("./migrate-audio-to-opus.ts").runMigration;
|
|
24
|
+
let hasDep = false;
|
|
25
|
+
try {
|
|
26
|
+
({ runMigration } = await import("./migrate-audio-to-opus.ts"));
|
|
27
|
+
hasDep = true;
|
|
28
|
+
} catch {
|
|
29
|
+
// dependency missing — tests will be skipped below
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildSilentWav(samples: number): Buffer {
|
|
33
|
+
const sampleRate = 8000;
|
|
34
|
+
const numChannels = 1;
|
|
35
|
+
const bitsPerSample = 16;
|
|
36
|
+
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
|
|
37
|
+
const blockAlign = (numChannels * bitsPerSample) / 8;
|
|
38
|
+
const dataSize = samples * blockAlign;
|
|
39
|
+
const chunkSize = 36 + dataSize;
|
|
40
|
+
const buf = Buffer.alloc(44 + dataSize);
|
|
41
|
+
let off = 0;
|
|
42
|
+
buf.write("RIFF", off); off += 4;
|
|
43
|
+
buf.writeUInt32LE(chunkSize, off); off += 4;
|
|
44
|
+
buf.write("WAVE", off); off += 4;
|
|
45
|
+
buf.write("fmt ", off); off += 4;
|
|
46
|
+
buf.writeUInt32LE(16, off); off += 4;
|
|
47
|
+
buf.writeUInt16LE(1, off); off += 2;
|
|
48
|
+
buf.writeUInt16LE(numChannels, off); off += 2;
|
|
49
|
+
buf.writeUInt32LE(sampleRate, off); off += 4;
|
|
50
|
+
buf.writeUInt32LE(byteRate, off); off += 4;
|
|
51
|
+
buf.writeUInt16LE(blockAlign, off); off += 2;
|
|
52
|
+
buf.writeUInt16LE(bitsPerSample, off); off += 2;
|
|
53
|
+
buf.write("data", off); off += 4;
|
|
54
|
+
buf.writeUInt32LE(dataSize, off); off += 4;
|
|
55
|
+
return buf;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let tmpHome: string;
|
|
59
|
+
let prevHome: string | undefined;
|
|
60
|
+
let prevAssets: string | undefined;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
tmpHome = join(
|
|
64
|
+
tmpdir(),
|
|
65
|
+
`migrate-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
66
|
+
);
|
|
67
|
+
mkdirSync(tmpHome, { recursive: true });
|
|
68
|
+
prevHome = process.env.PARACHUTE_HOME;
|
|
69
|
+
prevAssets = process.env.ASSETS_DIR;
|
|
70
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
71
|
+
delete process.env.ASSETS_DIR; // use default per-vault assets dir
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
76
|
+
else process.env.PARACHUTE_HOME = prevHome;
|
|
77
|
+
if (prevAssets === undefined) delete process.env.ASSETS_DIR;
|
|
78
|
+
else process.env.ASSETS_DIR = prevAssets;
|
|
79
|
+
try {
|
|
80
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
81
|
+
} catch {
|
|
82
|
+
// ignore
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
interface SeedResult {
|
|
87
|
+
vault: string;
|
|
88
|
+
dbPath: string;
|
|
89
|
+
assetsBase: string;
|
|
90
|
+
noteId: string;
|
|
91
|
+
attachmentId: string;
|
|
92
|
+
relWavPath: string;
|
|
93
|
+
absWavPath: string;
|
|
94
|
+
relOggPath: string;
|
|
95
|
+
absOggPath: string;
|
|
96
|
+
noteUpdatedAt: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function seedVaultWithWav(vaultName: string): SeedResult {
|
|
100
|
+
const vaultDir = join(tmpHome, "vaults", vaultName);
|
|
101
|
+
mkdirSync(vaultDir, { recursive: true });
|
|
102
|
+
const dbPath = join(vaultDir, "vault.db");
|
|
103
|
+
const assetsBase = join(vaultDir, "assets");
|
|
104
|
+
mkdirSync(assetsBase, { recursive: true });
|
|
105
|
+
|
|
106
|
+
const db = new Database(dbPath);
|
|
107
|
+
db.exec(SCHEMA_SQL);
|
|
108
|
+
|
|
109
|
+
const noteId = "n_" + Math.random().toString(36).slice(2, 10);
|
|
110
|
+
const attachmentId = "a_" + Math.random().toString(36).slice(2, 10);
|
|
111
|
+
const now = new Date().toISOString();
|
|
112
|
+
|
|
113
|
+
db.prepare(
|
|
114
|
+
"INSERT INTO notes (id, content, path, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
115
|
+
).run(noteId, "hello reader", null, "{}", now, now);
|
|
116
|
+
|
|
117
|
+
const relWavPath = `tts/2026-04-08/${noteId}-123.wav`;
|
|
118
|
+
const absWavPath = join(assetsBase, relWavPath);
|
|
119
|
+
mkdirSync(join(assetsBase, "tts", "2026-04-08"), { recursive: true });
|
|
120
|
+
// Write ~1s of silence WAV. ffmpeg handles this fine.
|
|
121
|
+
writeFileSync(absWavPath, buildSilentWav(8000));
|
|
122
|
+
|
|
123
|
+
db.prepare(
|
|
124
|
+
"INSERT INTO attachments (id, note_id, path, mime_type, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
125
|
+
).run(attachmentId, noteId, relWavPath, "audio/wav", "{}", now);
|
|
126
|
+
|
|
127
|
+
db.close();
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
vault: vaultName,
|
|
131
|
+
dbPath,
|
|
132
|
+
assetsBase,
|
|
133
|
+
noteId,
|
|
134
|
+
attachmentId,
|
|
135
|
+
relWavPath,
|
|
136
|
+
absWavPath,
|
|
137
|
+
relOggPath: relWavPath.replace(/\.wav$/, ".ogg"),
|
|
138
|
+
absOggPath: absWavPath.replace(/\.wav$/, ".ogg"),
|
|
139
|
+
noteUpdatedAt: now,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe.skipIf(!hasDep)("migrate-audio-to-opus", () => {
|
|
144
|
+
test("dry-run reports candidates without touching anything", async () => {
|
|
145
|
+
const seed = seedVaultWithWav("default");
|
|
146
|
+
|
|
147
|
+
const logs: string[] = [];
|
|
148
|
+
const origLog = console.log;
|
|
149
|
+
console.log = (...args: unknown[]) => {
|
|
150
|
+
logs.push(args.map((a) => String(a)).join(" "));
|
|
151
|
+
};
|
|
152
|
+
try {
|
|
153
|
+
const summaries = await runMigration(["--vault", "default", "--dry-run"]);
|
|
154
|
+
expect(summaries.length).toBe(1);
|
|
155
|
+
expect(summaries[0].dryRunCandidates).toBe(1);
|
|
156
|
+
expect(summaries[0].converted).toBe(0);
|
|
157
|
+
expect(summaries[0].errors).toBe(0);
|
|
158
|
+
} finally {
|
|
159
|
+
console.log = origLog;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
expect(logs.some((l) => l.includes("DRY-RUN convert") && l.includes(seed.relWavPath))).toBe(
|
|
163
|
+
true,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Nothing moved.
|
|
167
|
+
expect(existsSync(seed.absWavPath)).toBe(true);
|
|
168
|
+
expect(existsSync(seed.absOggPath)).toBe(false);
|
|
169
|
+
|
|
170
|
+
const db = new Database(seed.dbPath);
|
|
171
|
+
try {
|
|
172
|
+
const row = db
|
|
173
|
+
.prepare("SELECT path, mime_type FROM attachments WHERE id = ?")
|
|
174
|
+
.get(seed.attachmentId) as { path: string; mime_type: string };
|
|
175
|
+
expect(row.path).toBe(seed.relWavPath);
|
|
176
|
+
expect(row.mime_type).toBe("audio/wav");
|
|
177
|
+
} finally {
|
|
178
|
+
db.close();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("full run converts WAV to Opus, updates DB, unlinks original, no updated_at bump", async () => {
|
|
183
|
+
const seed = seedVaultWithWav("default");
|
|
184
|
+
|
|
185
|
+
const origLog = console.log;
|
|
186
|
+
console.log = () => {};
|
|
187
|
+
try {
|
|
188
|
+
const summaries = await runMigration(["--vault", "default"]);
|
|
189
|
+
expect(summaries[0].converted).toBe(1);
|
|
190
|
+
expect(summaries[0].errors).toBe(0);
|
|
191
|
+
expect(summaries[0].bytesAfter).toBeGreaterThan(0);
|
|
192
|
+
} finally {
|
|
193
|
+
console.log = origLog;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Original .wav removed, .ogg exists and has OggS magic bytes.
|
|
197
|
+
expect(existsSync(seed.absWavPath)).toBe(false);
|
|
198
|
+
expect(existsSync(seed.absOggPath)).toBe(true);
|
|
199
|
+
const oggBytes = readFileSync(seed.absOggPath);
|
|
200
|
+
expect(oggBytes.toString("ascii", 0, 4)).toBe("OggS");
|
|
201
|
+
|
|
202
|
+
// DB row rewritten.
|
|
203
|
+
const db = new Database(seed.dbPath);
|
|
204
|
+
try {
|
|
205
|
+
const row = db
|
|
206
|
+
.prepare("SELECT path, mime_type FROM attachments WHERE id = ?")
|
|
207
|
+
.get(seed.attachmentId) as { path: string; mime_type: string };
|
|
208
|
+
expect(row.path).toBe(seed.relOggPath);
|
|
209
|
+
expect(row.mime_type).toBe("audio/ogg");
|
|
210
|
+
|
|
211
|
+
// Note's updated_at must NOT have changed — this is a storage
|
|
212
|
+
// migration, not a content edit.
|
|
213
|
+
const note = db
|
|
214
|
+
.prepare("SELECT updated_at FROM notes WHERE id = ?")
|
|
215
|
+
.get(seed.noteId) as { updated_at: string };
|
|
216
|
+
expect(note.updated_at).toBe(seed.noteUpdatedAt);
|
|
217
|
+
} finally {
|
|
218
|
+
db.close();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("re-running after a successful migration is a no-op (idempotent)", async () => {
|
|
223
|
+
seedVaultWithWav("default");
|
|
224
|
+
|
|
225
|
+
const origLog = console.log;
|
|
226
|
+
console.log = () => {};
|
|
227
|
+
try {
|
|
228
|
+
await runMigration(["--vault", "default"]);
|
|
229
|
+
const second = await runMigration(["--vault", "default"]);
|
|
230
|
+
expect(second[0].converted).toBe(0);
|
|
231
|
+
expect(second[0].skipped).toBe(1);
|
|
232
|
+
expect(second[0].errors).toBe(0);
|
|
233
|
+
} finally {
|
|
234
|
+
console.log = origLog;
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
});
|