@openparachute/vault 0.2.4 → 0.3.0-rc.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/.claude/settings.local.json +2 -25
- package/CHANGELOG.md +64 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +591 -19
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +153 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +92 -0
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +28 -1
- package/docs/HTTP_API.md +105 -1
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/cli.ts +179 -121
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +136 -0
- package/src/scopes.ts +105 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +57 -5
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +583 -0
- package/src/transcription-worker.ts +346 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- 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/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -16
package/core/src/schema.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { normalizePath } from "./paths.js";
|
|
3
|
+
import { rebuildIndexes } from "./indexed-fields.js";
|
|
3
4
|
|
|
4
|
-
export const SCHEMA_VERSION =
|
|
5
|
+
export const SCHEMA_VERSION = 12;
|
|
5
6
|
|
|
6
7
|
export const SCHEMA_SQL = `
|
|
7
8
|
-- Notes: the universal record
|
|
@@ -53,11 +54,32 @@ CREATE TABLE IF NOT EXISTS tag_schemas (
|
|
|
53
54
|
fields TEXT -- JSON: { "field_name": { "type": "string", "description": "..." }, ... }
|
|
54
55
|
);
|
|
55
56
|
|
|
56
|
-
--
|
|
57
|
+
-- Indexed fields: SSOT for generated columns and indexes on notes derived
|
|
58
|
+
-- from tag-declared fields with indexed=true. One row per indexed metadata
|
|
59
|
+
-- field; declarer_tags is a JSON array of tags that currently declare it.
|
|
60
|
+
-- See core/src/indexed-fields.ts.
|
|
61
|
+
CREATE TABLE IF NOT EXISTS indexed_fields (
|
|
62
|
+
field TEXT PRIMARY KEY,
|
|
63
|
+
sqlite_type TEXT NOT NULL,
|
|
64
|
+
declarer_tags TEXT NOT NULL DEFAULT '[]'
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
-- Tokens: API authentication with OAuth-standard scopes.
|
|
68
|
+
--
|
|
69
|
+
-- scopes is a whitespace-separated list of granted scopes (OAuth 2.0 §3.3)
|
|
70
|
+
-- — e.g. "vault:read vault:write". Introduced in v12 alongside enforcement;
|
|
71
|
+
-- NULL rows are pre-v12 tokens which fall back to deriving scopes from the
|
|
72
|
+
-- legacy permission column (see src/scopes.ts). permission is kept for the
|
|
73
|
+
-- one-release-cycle back-compat window and will be dropped in a future
|
|
74
|
+
-- migration.
|
|
75
|
+
--
|
|
76
|
+
-- scope_tag / scope_path_prefix are deprecated Phase-0 columns — never
|
|
77
|
+
-- enforced at runtime, kept only for schema stability.
|
|
57
78
|
CREATE TABLE IF NOT EXISTS tokens (
|
|
58
79
|
token_hash TEXT PRIMARY KEY,
|
|
59
80
|
label TEXT NOT NULL,
|
|
60
81
|
permission TEXT NOT NULL DEFAULT 'admin',
|
|
82
|
+
scopes TEXT,
|
|
61
83
|
scope_tag TEXT,
|
|
62
84
|
scope_path_prefix TEXT,
|
|
63
85
|
expires_at TEXT,
|
|
@@ -163,6 +185,19 @@ export function initSchema(db: Database): void {
|
|
|
163
185
|
// Migrate v8 → v9: add vault_name column to oauth_codes
|
|
164
186
|
migrateToV9(db);
|
|
165
187
|
|
|
188
|
+
// Migrate v9 → v10: indexed_fields table (created by SCHEMA_SQL above).
|
|
189
|
+
migrateToV10(db);
|
|
190
|
+
|
|
191
|
+
// Migrate v10 → v11: backfill updated_at = created_at for legacy rows.
|
|
192
|
+
migrateToV11(db);
|
|
193
|
+
|
|
194
|
+
// Migrate v11 → v12: add `scopes` column to tokens for Phase 2 enforcement.
|
|
195
|
+
migrateToV12(db);
|
|
196
|
+
|
|
197
|
+
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
198
|
+
// No-op for a fresh vault; idempotent on existing vaults.
|
|
199
|
+
rebuildIndexes(db);
|
|
200
|
+
|
|
166
201
|
// Record schema version
|
|
167
202
|
db.prepare("INSERT OR REPLACE INTO schema_version (version, applied_at) VALUES (?, ?)").run(
|
|
168
203
|
SCHEMA_VERSION,
|
|
@@ -270,6 +305,38 @@ function migrateToV9(db: Database): void {
|
|
|
270
305
|
}
|
|
271
306
|
}
|
|
272
307
|
|
|
308
|
+
function migrateToV10(db: Database): void {
|
|
309
|
+
// SCHEMA_SQL's CREATE TABLE IF NOT EXISTS covers fresh vaults; this
|
|
310
|
+
// ensures indexed_fields exists on vaults created before v10. No data
|
|
311
|
+
// migration — rebuildIndexes() downstream handles column/index creation
|
|
312
|
+
// if any rows are already present.
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Migrate v10 → v11: backfill `updated_at = created_at` for notes that never
|
|
317
|
+
* received an update. Pre-v11 inserts left `updated_at` NULL, which broke
|
|
318
|
+
* optimistic concurrency for clients that fall back to `createdAt` (the
|
|
319
|
+
* common `updatedAt ?? createdAt` pattern) — the `updated_at IS ?` guard
|
|
320
|
+
* never matched. From v11 onward, `createNote` sets both columns at insert.
|
|
321
|
+
* Idempotent — safe to run on every boot.
|
|
322
|
+
*/
|
|
323
|
+
function migrateToV11(db: Database): void {
|
|
324
|
+
if (!hasTable(db, "notes")) return;
|
|
325
|
+
db.exec("UPDATE notes SET updated_at = created_at WHERE updated_at IS NULL");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Migrate v11 → v12: add `scopes` column to tokens. Existing rows get NULL
|
|
330
|
+
* and fall back to legacy `permission` → scopes derivation at read time
|
|
331
|
+
* (see src/scopes.ts:legacyPermissionToScopes). New tokens minted on v12+
|
|
332
|
+
* populate the column explicitly.
|
|
333
|
+
*/
|
|
334
|
+
function migrateToV12(db: Database): void {
|
|
335
|
+
if (hasTable(db, "tokens") && !hasColumn(db, "tokens", "scopes")) {
|
|
336
|
+
db.exec("ALTER TABLE tokens ADD COLUMN scopes TEXT");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
273
340
|
function hasTable(db: Database, name: string): boolean {
|
|
274
341
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
275
342
|
return !!row;
|
package/core/src/store.ts
CHANGED
|
@@ -151,6 +151,17 @@ export class BunSqliteStore implements Store {
|
|
|
151
151
|
return noteOps.deleteTag(this.db, name);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
async renameTag(oldName: string, newName: string): Promise<noteOps.RenameTagResult> {
|
|
155
|
+
return noteOps.renameTag(this.db, oldName, newName);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async mergeTags(
|
|
159
|
+
sources: string[],
|
|
160
|
+
target: string,
|
|
161
|
+
): Promise<{ merged: Record<string, number>; target: string }> {
|
|
162
|
+
return noteOps.mergeTags(this.db, sources, target);
|
|
163
|
+
}
|
|
164
|
+
|
|
154
165
|
// ---- Vault Stats ----
|
|
155
166
|
|
|
156
167
|
async getVaultStats(opts?: { topTagsLimit?: number }) {
|
|
@@ -291,6 +302,87 @@ export class BunSqliteStore implements Store {
|
|
|
291
302
|
};
|
|
292
303
|
});
|
|
293
304
|
}
|
|
305
|
+
|
|
306
|
+
async deleteAttachment(
|
|
307
|
+
noteId: string,
|
|
308
|
+
attachmentId: string,
|
|
309
|
+
): Promise<{ deleted: boolean; path: string | null; orphaned: boolean }> {
|
|
310
|
+
// Scope by noteId so a token authorized for note A can't delete note B's attachments.
|
|
311
|
+
const row = this.db.prepare(
|
|
312
|
+
"SELECT path FROM attachments WHERE id = ? AND note_id = ?",
|
|
313
|
+
).get(attachmentId, noteId) as { path: string } | undefined;
|
|
314
|
+
if (!row) return { deleted: false, path: null, orphaned: false };
|
|
315
|
+
|
|
316
|
+
this.db.prepare("DELETE FROM attachments WHERE id = ? AND note_id = ?").run(attachmentId, noteId);
|
|
317
|
+
|
|
318
|
+
// Orphan check: caller uses this to decide whether to unlink the file on disk.
|
|
319
|
+
const other = this.db.prepare(
|
|
320
|
+
"SELECT 1 FROM attachments WHERE path = ? LIMIT 1",
|
|
321
|
+
).get(row.path);
|
|
322
|
+
return { deleted: true, path: row.path, orphaned: !other };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async getAttachment(attachmentId: string): Promise<Attachment | null> {
|
|
326
|
+
const row = this.db.prepare(
|
|
327
|
+
"SELECT * FROM attachments WHERE id = ?",
|
|
328
|
+
).get(attachmentId) as { id: string; note_id: string; path: string; mime_type: string; metadata: string | null; created_at: string } | undefined;
|
|
329
|
+
if (!row) return null;
|
|
330
|
+
let metadata: Record<string, unknown> | undefined;
|
|
331
|
+
if (row.metadata && row.metadata !== "{}") {
|
|
332
|
+
try { metadata = JSON.parse(row.metadata); } catch {}
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
id: row.id,
|
|
336
|
+
noteId: row.note_id,
|
|
337
|
+
path: row.path,
|
|
338
|
+
mimeType: row.mime_type,
|
|
339
|
+
metadata,
|
|
340
|
+
createdAt: row.created_at,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Replace the attachment's metadata JSON blob. The caller passes the full
|
|
346
|
+
* merged object — this is a set, not a patch, so partial-field updates
|
|
347
|
+
* don't silently drop other keys.
|
|
348
|
+
*/
|
|
349
|
+
async setAttachmentMetadata(attachmentId: string, metadata: Record<string, unknown>): Promise<void> {
|
|
350
|
+
const json = JSON.stringify(metadata);
|
|
351
|
+
this.db.prepare("UPDATE attachments SET metadata = ? WHERE id = ?").run(json, attachmentId);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Return attachments whose metadata.transcribe_status matches the given
|
|
356
|
+
* status, oldest first (FIFO). Used by the transcription worker to drain
|
|
357
|
+
* the queue. `status = "pending"` is the queue; `"failed"` feeds a retry
|
|
358
|
+
* sweep; `"done"` is only useful for tests and diagnostics.
|
|
359
|
+
*/
|
|
360
|
+
async listAttachmentsByTranscribeStatus(
|
|
361
|
+
status: "pending" | "failed" | "done",
|
|
362
|
+
limit = 50,
|
|
363
|
+
): Promise<Attachment[]> {
|
|
364
|
+
const rows = this.db.prepare(
|
|
365
|
+
`SELECT * FROM attachments
|
|
366
|
+
WHERE json_extract(metadata, '$.transcribe_status') = ?
|
|
367
|
+
ORDER BY created_at ASC
|
|
368
|
+
LIMIT ?`,
|
|
369
|
+
).all(status, limit) as { id: string; note_id: string; path: string; mime_type: string; metadata: string | null; created_at: string }[];
|
|
370
|
+
|
|
371
|
+
return rows.map((r) => {
|
|
372
|
+
let metadata: Record<string, unknown> | undefined;
|
|
373
|
+
if (r.metadata && r.metadata !== "{}") {
|
|
374
|
+
try { metadata = JSON.parse(r.metadata); } catch {}
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
id: r.id,
|
|
378
|
+
noteId: r.note_id,
|
|
379
|
+
path: r.path,
|
|
380
|
+
mimeType: r.mime_type,
|
|
381
|
+
metadata,
|
|
382
|
+
createdAt: r.created_at,
|
|
383
|
+
};
|
|
384
|
+
});
|
|
385
|
+
}
|
|
294
386
|
}
|
|
295
387
|
|
|
296
388
|
/** @deprecated Renamed to `BunSqliteStore` to make the runtime split explicit. Kept as an alias for backward compatibility. */
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -16,6 +16,11 @@ export interface TagFieldSchema {
|
|
|
16
16
|
type: string;
|
|
17
17
|
description?: string;
|
|
18
18
|
enum?: string[];
|
|
19
|
+
// When true, a generated column + index are maintained on `notes` for this
|
|
20
|
+
// field, making it available for operator queries and `order_by`. Global
|
|
21
|
+
// across declarers — all tags declaring this field must agree on both
|
|
22
|
+
// `type` and `indexed`. See core/src/indexed-fields.ts for lifecycle.
|
|
23
|
+
indexed?: boolean;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
export interface TagSchema {
|
package/core/src/types.ts
CHANGED
|
@@ -50,12 +50,27 @@ export interface QueryOpts {
|
|
|
50
50
|
tags?: string[];
|
|
51
51
|
tagMatch?: "all" | "any"; // "all" = must have ALL tags (default), "any" = must have ANY tag
|
|
52
52
|
excludeTags?: string[];
|
|
53
|
+
// Presence filters. `true` → has at least one; `false` → has none.
|
|
54
|
+
// When `tags` is also set, `hasTags` is ignored (the tag filter already constrains the set).
|
|
55
|
+
// `hasLinks` checks both directions — inbound or outbound counts as "has links".
|
|
56
|
+
hasTags?: boolean;
|
|
57
|
+
hasLinks?: boolean;
|
|
53
58
|
path?: string; // exact path match (case-insensitive)
|
|
54
59
|
pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
|
|
55
|
-
metadata
|
|
60
|
+
// Per-field metadata filter. Each value is either a primitive (exact
|
|
61
|
+
// match, today's behavior) or an operator object — `{ eq, ne, gt, gte, lt,
|
|
62
|
+
// lte, in, not_in, exists }` — which routes through the generated column
|
|
63
|
+
// for the field. Operator queries require the field to be declared
|
|
64
|
+
// `indexed: true` in a tag schema; undeclared fields error loudly.
|
|
65
|
+
metadata?: Record<string, unknown>;
|
|
56
66
|
dateFrom?: string; // ISO date
|
|
57
67
|
dateTo?: string; // ISO date
|
|
58
68
|
sort?: "asc" | "desc";
|
|
69
|
+
// Sort by an indexed metadata field instead of `created_at`. Must be
|
|
70
|
+
// declared `indexed: true`; errors loudly otherwise. Direction is taken
|
|
71
|
+
// from `sort` (default "asc") and `created_at` is appended as a stable
|
|
72
|
+
// tiebreaker.
|
|
73
|
+
orderBy?: string;
|
|
59
74
|
limit?: number;
|
|
60
75
|
offset?: number;
|
|
61
76
|
}
|
|
@@ -109,6 +124,14 @@ export interface Store {
|
|
|
109
124
|
untagNote(noteId: string, tags: string[]): Promise<void>;
|
|
110
125
|
listTags(): Promise<{ name: string; count: number }[]>;
|
|
111
126
|
deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }>;
|
|
127
|
+
renameTag(
|
|
128
|
+
oldName: string,
|
|
129
|
+
newName: string,
|
|
130
|
+
): Promise<{ renamed: number } | { error: "not_found" } | { error: "target_exists" }>;
|
|
131
|
+
mergeTags(
|
|
132
|
+
sources: string[],
|
|
133
|
+
target: string,
|
|
134
|
+
): Promise<{ merged: Record<string, number>; target: string }>;
|
|
112
135
|
|
|
113
136
|
// Vault stats (aggregate, read-only)
|
|
114
137
|
getVaultStats(opts?: { topTagsLimit?: number }): Promise<VaultStats>;
|
|
@@ -138,4 +161,8 @@ export interface Store {
|
|
|
138
161
|
// Attachments
|
|
139
162
|
addAttachment(noteId: string, path: string, mimeType: string, metadata?: Record<string, unknown>): Promise<Attachment>;
|
|
140
163
|
getAttachments(noteId: string): Promise<Attachment[]>;
|
|
164
|
+
getAttachment(attachmentId: string): Promise<Attachment | null>;
|
|
165
|
+
setAttachmentMetadata(attachmentId: string, metadata: Record<string, unknown>): Promise<void>;
|
|
166
|
+
deleteAttachment(noteId: string, attachmentId: string): Promise<{ deleted: boolean; path: string | null; orphaned: boolean }>;
|
|
167
|
+
listAttachmentsByTranscribeStatus(status: "pending" | "failed" | "done", limit?: number): Promise<Attachment[]>;
|
|
141
168
|
}
|
package/docs/HTTP_API.md
CHANGED
|
@@ -204,11 +204,42 @@ Returns `{deleted: true}`.
|
|
|
204
204
|
Body: `{"tags": ["a", "b"]}`.
|
|
205
205
|
|
|
206
206
|
#### `POST /notes/{id}/attachments`
|
|
207
|
-
Body: `{"path": "files/a.png", "mimeType": "image/png"}`.
|
|
207
|
+
Body: `{"path": "files/a.png", "mimeType": "image/png", "transcribe"?: boolean}`.
|
|
208
|
+
|
|
209
|
+
When `transcribe: true` and the file is audio, the server queues a
|
|
210
|
+
transcription job: `attachment.metadata.transcribe_status = "pending"` is
|
|
211
|
+
set, and `note.metadata.transcribe_stub = true` is written as the opt-in to
|
|
212
|
+
overwrite content when the transcript lands. A background worker (enabled
|
|
213
|
+
by setting `SCRIBE_URL` on the server) drains the queue FIFO, one at a
|
|
214
|
+
time, calling `${SCRIBE_URL}/v1/audio/transcriptions` with the audio as
|
|
215
|
+
multipart `file` and expecting `{ text: string }` back.
|
|
216
|
+
|
|
217
|
+
On success:
|
|
218
|
+
- If `note.metadata.transcribe_stub === true`, the worker replaces the
|
|
219
|
+
literal `_Transcript pending._` placeholder in the note body with the
|
|
220
|
+
transcript, or the whole body if the placeholder is absent. The stub
|
|
221
|
+
marker is cleared. A user edit clearing `transcribe_stub` before the
|
|
222
|
+
transcript arrives opts out of the overwrite.
|
|
223
|
+
- `attachment.metadata.transcribe_status` becomes `"done"` and
|
|
224
|
+
`transcript` + `transcribe_done_at` are recorded on the attachment even
|
|
225
|
+
when the note opted out, so the transcript is always addressable.
|
|
226
|
+
|
|
227
|
+
On failure, the worker retries with exponential backoff up to three
|
|
228
|
+
attempts before setting `transcribe_status = "failed"` and capturing
|
|
229
|
+
`transcribe_error`.
|
|
230
|
+
|
|
231
|
+
The queue lives in the DB (`attachments` table), so a server restart
|
|
232
|
+
resumes pending work without replay.
|
|
208
233
|
|
|
209
234
|
#### `GET /notes/{id}/attachments`
|
|
210
235
|
Returns `Attachment[]`.
|
|
211
236
|
|
|
237
|
+
#### `DELETE /notes/{id}/attachments/{attId}`
|
|
238
|
+
Returns `204 No Content`. The attachment record is removed and the underlying
|
|
239
|
+
storage file is unlinked when no other attachment still references the same
|
|
240
|
+
path (orphan-check). Returns `404` if the attachment doesn't exist or belongs
|
|
241
|
+
to a different note. Idempotent: a second delete of the same id returns `404`.
|
|
242
|
+
|
|
212
243
|
### Links
|
|
213
244
|
|
|
214
245
|
#### `GET /links`
|
|
@@ -288,11 +319,84 @@ Query params:
|
|
|
288
319
|
#### `GET /tags`
|
|
289
320
|
Returns `[{name, count}]`.
|
|
290
321
|
|
|
322
|
+
#### `POST /tags/{name}/rename`
|
|
323
|
+
Body: `{ "new_name": string }`. Atomically renames the tag across `tags`,
|
|
324
|
+
`note_tags`, and `tag_schemas` in a single transaction.
|
|
325
|
+
|
|
326
|
+
Returns `{ "renamed": number }` on success — the number of note-tag rows
|
|
327
|
+
rewritten.
|
|
328
|
+
|
|
329
|
+
Errors:
|
|
330
|
+
- `404 { "error": "not_found" }` — source tag does not exist.
|
|
331
|
+
- `409 { "error": "target_exists", "target": string, "message": "..." }` —
|
|
332
|
+
`new_name` is already a tag. The client should call `POST /tags/merge`
|
|
333
|
+
instead if combining the two tags is the intent.
|
|
334
|
+
|
|
335
|
+
#### `POST /tags/merge`
|
|
336
|
+
Body: `{ "sources": string[], "target": string }`. Retags every note carrying
|
|
337
|
+
any of the `sources` tags with `target`, then drops the source tags (and
|
|
338
|
+
their schemas) in a single transaction. `target`'s own schema is preserved.
|
|
339
|
+
|
|
340
|
+
`target` is created if it doesn't exist yet. Sources that don't exist are
|
|
341
|
+
recorded with count `0`. Duplicate sources are deduped; `target` appearing
|
|
342
|
+
in `sources` is a no-op for that entry.
|
|
343
|
+
|
|
344
|
+
Returns `{ "merged": { [source]: count }, "target": string }`.
|
|
345
|
+
|
|
291
346
|
### Vault stats
|
|
292
347
|
|
|
293
348
|
You usually want `GET /vaults/{name}` which bundles stats with vault metadata.
|
|
294
349
|
If you only need the stats, call `GET /vaults/{name}` and read `.stats`.
|
|
295
350
|
|
|
351
|
+
### Vault config
|
|
352
|
+
|
|
353
|
+
#### `GET /api/vault`
|
|
354
|
+
Returns the vault's identity plus a nested `config` block for mutable
|
|
355
|
+
settings.
|
|
356
|
+
|
|
357
|
+
```json
|
|
358
|
+
{
|
|
359
|
+
"name": "default",
|
|
360
|
+
"description": "My knowledge graph",
|
|
361
|
+
"config": {
|
|
362
|
+
"audio_retention": "keep"
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`?include_stats=true` folds the same `VaultStats` shape into the response
|
|
368
|
+
under `stats`.
|
|
369
|
+
|
|
370
|
+
#### `PATCH /api/vault`
|
|
371
|
+
Update the description and/or nested `config` fields. Only the fields you
|
|
372
|
+
pass are changed; omitted fields are left alone.
|
|
373
|
+
|
|
374
|
+
```json
|
|
375
|
+
{
|
|
376
|
+
"description": "new description",
|
|
377
|
+
"config": { "audio_retention": "until_transcribed" }
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Response echoes the full vault payload (same shape as `GET /api/vault`).
|
|
382
|
+
|
|
383
|
+
##### `config.audio_retention`
|
|
384
|
+
|
|
385
|
+
Controls what the transcription worker does with the audio file on disk
|
|
386
|
+
once it reaches a terminal state. The attachment row (including any
|
|
387
|
+
recorded transcript) is always preserved — only the file on disk is
|
|
388
|
+
affected.
|
|
389
|
+
|
|
390
|
+
| Value | Behavior |
|
|
391
|
+
|---|---|
|
|
392
|
+
| `"keep"` (default) | Never unlink. The original audio stays on disk indefinitely. |
|
|
393
|
+
| `"until_transcribed"` | Unlink on successful transcription. On failure the file is kept so you can retry or re-upload. |
|
|
394
|
+
| `"never"` | Unlink on any terminal state — **including failure**. Users who opt in accept that losing a bad transcription also loses the source audio. |
|
|
395
|
+
|
|
396
|
+
Validation: `audio_retention` must be exactly one of those three strings.
|
|
397
|
+
Any other value returns `400 { "error": "invalid_audio_retention" }`.
|
|
398
|
+
Vaults created before this setting existed read back as `"keep"`.
|
|
399
|
+
|
|
296
400
|
### Storage
|
|
297
401
|
|
|
298
402
|
#### `POST /storage/upload`
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openparachute/vault",
|
|
3
|
+
"version": "0.2.4",
|
|
4
|
+
"description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
|
|
5
|
+
"module": "src/cli.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"parachute-vault": "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/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-rc.1",
|
|
4
4
|
"description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
|
|
5
5
|
"module": "src/cli.ts",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"parachute": "src/cli.ts"
|
|
8
|
+
"parachute-vault": "src/cli.ts"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "bun src/server.ts",
|