@openparachute/vault 0.6.5-rc.2 → 0.6.5
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 +1 -0
- package/core/src/core.test.ts +7 -7
- package/core/src/do-param-cap.test.ts +161 -0
- package/core/src/links.ts +8 -13
- package/core/src/mcp.ts +1 -1
- package/core/src/notes.ts +33 -15
- package/core/src/onboarding.ts +14 -296
- package/core/src/schema.ts +5 -5
- package/core/src/seed-packs.test.ts +357 -0
- package/core/src/seed-packs.ts +823 -0
- package/core/src/sql-in.test.ts +58 -0
- package/core/src/sql-in.ts +76 -0
- package/core/src/tag-hierarchy.ts +1 -1
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/transcription/provider.ts +141 -0
- package/core/src/types.ts +1 -1
- package/core/src/vault-projection.ts +1 -1
- package/core/src/wikilinks.ts +10 -4
- package/package.json +1 -1
- package/src/add-pack.test.ts +142 -0
- package/src/admin-spa.ts +2 -1
- package/src/auth.ts +1 -1
- package/src/cli.ts +806 -7
- package/src/export-watch.ts +1 -1
- package/src/live-frame-parity.test.ts +201 -0
- package/src/module-manifest.ts +8 -0
- package/src/onboarding-seed.test.ts +188 -40
- package/src/onboarding-seed.ts +41 -46
- package/src/routes.ts +20 -3
- package/src/routing.test.ts +2 -2
- package/src/routing.ts +18 -6
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +133 -31
- package/src/services-manifest.ts +8 -0
- package/src/subscriptions.ts +100 -42
- package/src/tag-scope.ts +3 -3
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/token-store.ts +2 -2
- package/src/transcription/build.test.ts +305 -0
- package/src/transcription/build.ts +332 -0
- package/src/transcription/capability.test.ts +118 -0
- package/src/transcription/capability.ts +96 -0
- package/src/transcription/download.test.ts +101 -0
- package/src/transcription/download.ts +71 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -0
- package/src/transcription/install.test.ts +167 -0
- package/src/transcription/install.ts +296 -0
- package/src/transcription/providers/onnx-asr.test.ts +229 -0
- package/src/transcription/providers/onnx-asr.ts +239 -0
- package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
- package/src/transcription/providers/parakeet-mlx.ts +242 -0
- package/src/transcription/providers/scribe-http.test.ts +195 -0
- package/src/transcription/providers/scribe-http.ts +144 -0
- package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
- package/src/transcription/providers/transcribe-cpp.ts +293 -0
- package/src/transcription/select.test.ts +299 -0
- package/src/transcription/select.ts +397 -0
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/transcription-worker.test.ts +44 -0
- package/src/transcription-worker.ts +57 -122
- package/src/vault-create.test.ts +43 -10
- package/src/vault.test.ts +49 -1
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
- package/src/subscribe.test.ts +0 -609
- package/src/subscribe.ts +0 -248
package/src/export-watch.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* cursor captured at the *start* of the previous cycle. Vault writes are
|
|
11
11
|
* HTTP-mediated and don't surface to filesystem watchers (the bun:sqlite DB
|
|
12
12
|
* is opaque), so polling on `updated_at >= cursor` is the simplest robust
|
|
13
|
-
* detection. See `
|
|
13
|
+
* detection. See `docs/contracts/vault-portable-export.md`.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { ensureGitAvailable } from "./git-preflight.ts";
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-query frame PARITY + the pure WS helpers (WS-hibernation migration,
|
|
3
|
+
* self-host door).
|
|
4
|
+
*
|
|
5
|
+
* The load-bearing CROSS-DOOR conformance check: for the shared frame corpus
|
|
6
|
+
* (mirrored byte-for-byte from the cloud door), the self-host WS message folds
|
|
7
|
+
* the event name into a `type` discriminator (plus the snapshot `done` chunk
|
|
8
|
+
* flag) over an inner payload (`notes` / `note` / `id`) that serializes
|
|
9
|
+
* byte-IDENTICALLY to the corpus. Because the corpus AND the `wsFrame` formatter
|
|
10
|
+
* are byte-shaped identical to cloud's, this transitively proves: self-host WS
|
|
11
|
+
* bytes === corpus === cloud WS bytes. (SSE was the original transport; it was
|
|
12
|
+
* removed in Phase 5 — WebSocket is now the sole live transport, polling the
|
|
13
|
+
* client floor.)
|
|
14
|
+
*
|
|
15
|
+
* Plus unit coverage of the pure helpers that back the Bun.serve integration:
|
|
16
|
+
* snapshot chunking, first-message parsing, verb-rank, and the query rejects.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect } from "bun:test";
|
|
19
|
+
import { wsFrame, WsSink } from "./subscriptions.ts";
|
|
20
|
+
import {
|
|
21
|
+
buildSnapshotFrames,
|
|
22
|
+
parseClientMessage,
|
|
23
|
+
vaultVerbRank,
|
|
24
|
+
sameTagScope,
|
|
25
|
+
validateWsSubscribeQuery,
|
|
26
|
+
subscriptionCapResponse,
|
|
27
|
+
urlFromQuery,
|
|
28
|
+
SNAPSHOT_CHUNK_MAX_NOTES,
|
|
29
|
+
WS_CLOSE,
|
|
30
|
+
} from "./ws-subscribe.ts";
|
|
31
|
+
import { unsupportedSubscriptionReason } from "./live-match.ts";
|
|
32
|
+
import { FRAME_CORPUS, NOTE_A } from "./test-support/live-frame-corpus.ts";
|
|
33
|
+
|
|
34
|
+
describe("live-frame parity — self-host WS bytes === corpus === cloud WS bytes", () => {
|
|
35
|
+
for (const c of FRAME_CORPUS) {
|
|
36
|
+
it(`${c.name}: WS message folds the event into \`type\` over the corpus payload`, () => {
|
|
37
|
+
const ws = wsFrame(c.event, c.data);
|
|
38
|
+
|
|
39
|
+
// WS message is `{ type, ...data }` — the event name folds into `type`.
|
|
40
|
+
const parsedWs = JSON.parse(ws) as Record<string, unknown>;
|
|
41
|
+
expect(parsedWs.type).toBe(c.event);
|
|
42
|
+
const { type, ...rest } = parsedWs;
|
|
43
|
+
void type;
|
|
44
|
+
expect(rest).toEqual(c.data);
|
|
45
|
+
|
|
46
|
+
// BYTE parity of the inner payload: whatever value the event carries
|
|
47
|
+
// (`notes` / `note` / `id`) serializes IDENTICALLY to the corpus
|
|
48
|
+
// serialization (the cross-door invariant).
|
|
49
|
+
const innerKey = c.event === "snapshot" ? "notes" : c.event === "upsert" ? "note" : "id";
|
|
50
|
+
const innerBytes = JSON.stringify((c.data as Record<string, unknown>)[innerKey]);
|
|
51
|
+
expect(ws.includes(innerBytes)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("sink classes render through the ONE formatter", () => {
|
|
57
|
+
it("WsSink.send emits exactly wsFrame(event, data)", () => {
|
|
58
|
+
const sent: string[] = [];
|
|
59
|
+
const sink = new WsSink({ send: (d: string) => sent.push(d), close: () => {} });
|
|
60
|
+
for (const c of FRAME_CORPUS) {
|
|
61
|
+
sink.send(c.event, c.data);
|
|
62
|
+
expect(sent.at(-1)).toBe(wsFrame(c.event, c.data));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("buildSnapshotFrames — chunking + done flag", () => {
|
|
68
|
+
it("empty set → exactly one done:true frame", () => {
|
|
69
|
+
const frames = buildSnapshotFrames([]);
|
|
70
|
+
expect(frames.length).toBe(1);
|
|
71
|
+
expect(JSON.parse(frames[0]!)).toEqual({ type: "snapshot", notes: [], done: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("small set → one done:true frame carrying every note", () => {
|
|
75
|
+
const frames = buildSnapshotFrames([NOTE_A as any, NOTE_A as any]);
|
|
76
|
+
expect(frames.length).toBe(1);
|
|
77
|
+
const f = JSON.parse(frames[0]!);
|
|
78
|
+
expect(f.done).toBe(true);
|
|
79
|
+
expect(f.notes.length).toBe(2);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("> SNAPSHOT_CHUNK_MAX_NOTES → multiple frames, only the last done:true, concat = full set", () => {
|
|
83
|
+
const many = Array.from({ length: SNAPSHOT_CHUNK_MAX_NOTES + 5 }, (_, i) => ({ ...NOTE_A, id: `n-${i}` }));
|
|
84
|
+
const frames = buildSnapshotFrames(many as any);
|
|
85
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
86
|
+
const parsed = frames.map((f) => JSON.parse(f));
|
|
87
|
+
expect(parsed.filter((p) => p.done).length).toBe(1);
|
|
88
|
+
expect(parsed[parsed.length - 1]!.done).toBe(true);
|
|
89
|
+
parsed.slice(0, -1).forEach((p) => expect(p.done).toBe(false));
|
|
90
|
+
const concat = parsed.flatMap((p) => p.notes);
|
|
91
|
+
expect(concat.map((n: any) => n.id)).toEqual(many.map((n) => n.id));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("each frame stays under the WS message ceiling with large notes", () => {
|
|
95
|
+
const big = Array.from({ length: 8 }, (_, i) => ({ ...NOTE_A, id: `big-${i}`, content: "x".repeat(200_000) }));
|
|
96
|
+
const frames = buildSnapshotFrames(big as any);
|
|
97
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
98
|
+
for (const f of frames) expect(new TextEncoder().encode(f).byteLength).toBeLessThan(1_000_000);
|
|
99
|
+
expect(frames.flatMap((f) => JSON.parse(f).notes).length).toBe(8);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("parseClientMessage", () => {
|
|
104
|
+
it("valid auth", () => {
|
|
105
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth", token: "t" }))).toEqual({ kind: "auth", token: "t" });
|
|
106
|
+
});
|
|
107
|
+
it("auth without a token → malformed", () => {
|
|
108
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth" })).kind).toBe("malformed");
|
|
109
|
+
expect(parseClientMessage(JSON.stringify({ type: "auth", token: "" })).kind).toBe("malformed");
|
|
110
|
+
});
|
|
111
|
+
it("non-json / non-object → malformed", () => {
|
|
112
|
+
expect(parseClientMessage("not json").kind).toBe("malformed");
|
|
113
|
+
expect(parseClientMessage(JSON.stringify([1, 2])).kind).toBe("malformed");
|
|
114
|
+
});
|
|
115
|
+
it("other typed message → other", () => {
|
|
116
|
+
expect(parseClientMessage(JSON.stringify({ type: "hello" }))).toEqual({ kind: "other", type: "hello" });
|
|
117
|
+
});
|
|
118
|
+
it("decodes a Uint8Array frame", () => {
|
|
119
|
+
const bytes = new TextEncoder().encode(JSON.stringify({ type: "auth", token: "b" }));
|
|
120
|
+
expect(parseClientMessage(bytes)).toEqual({ kind: "auth", token: "b" });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("vaultVerbRank — narrow-or-equal ordering", () => {
|
|
125
|
+
it("ranks read < write < admin, -1 for none/other-vault", () => {
|
|
126
|
+
expect(vaultVerbRank(["vault:v:read"], "v")).toBe(0);
|
|
127
|
+
expect(vaultVerbRank(["vault:v:write"], "v")).toBe(1);
|
|
128
|
+
expect(vaultVerbRank(["vault:v:admin"], "v")).toBe(2);
|
|
129
|
+
expect(vaultVerbRank(["vault:other:read"], "v")).toBe(-1);
|
|
130
|
+
expect(vaultVerbRank([], "v")).toBe(-1);
|
|
131
|
+
});
|
|
132
|
+
it("a widen is strictly greater (the 4403 trigger)", () => {
|
|
133
|
+
expect(vaultVerbRank(["vault:v:admin"], "v") > vaultVerbRank(["vault:v:read"], "v")).toBe(true);
|
|
134
|
+
expect(vaultVerbRank(["vault:v:read"], "v") > vaultVerbRank(["vault:v:admin"], "v")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("sameTagScope — the WS re-auth tag-scope-delta gate", () => {
|
|
139
|
+
it("both unscoped (null) → same", () => {
|
|
140
|
+
expect(sameTagScope(null, null)).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
it("null vs scoped → differ (a drop/gain of scoping)", () => {
|
|
143
|
+
expect(sameTagScope(null, ["work/eng"])).toBe(false);
|
|
144
|
+
expect(sameTagScope(["work/eng"], null)).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
it("same set (order/dupe-insensitive) → same", () => {
|
|
147
|
+
expect(sameTagScope(["a", "b"], ["b", "a"])).toBe(true);
|
|
148
|
+
expect(sameTagScope(["a", "a", "b"], ["b", "a"])).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
it("different membership → differ", () => {
|
|
151
|
+
expect(sameTagScope(["work/eng"], ["work/sales"])).toBe(false);
|
|
152
|
+
expect(sameTagScope(["a"], ["a", "b"])).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("validateWsSubscribeQuery — same rejects as the SSE route (byte-identical bodies)", () => {
|
|
157
|
+
const reject = async (q: string) => {
|
|
158
|
+
const v = validateWsSubscribeQuery(new URL(`http://x/vault/v/api/subscribe?${q}`));
|
|
159
|
+
expect("error" in v).toBe(true);
|
|
160
|
+
const body = await (v as { error: Response }).error.json();
|
|
161
|
+
expect((v as { error: Response }).error.status).toBe(400);
|
|
162
|
+
expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
163
|
+
};
|
|
164
|
+
it("rejects the URL-reachable unsupported shapes: search / near / cursor / has_links", async () => {
|
|
165
|
+
await reject("search=hi");
|
|
166
|
+
await reject("near[note_id]=abc");
|
|
167
|
+
await reject("cursor=xyz");
|
|
168
|
+
await reject("has_links=true");
|
|
169
|
+
});
|
|
170
|
+
it("accepts a supported query", () => {
|
|
171
|
+
const v = validateWsSubscribeQuery(new URL("http://x/vault/v/api/subscribe?tag=chat&path_prefix=meetings/"));
|
|
172
|
+
expect("queryOpts" in v).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
it("shares the SSE route's queryOpts-level guard (cursor/has_links/date filters)", () => {
|
|
175
|
+
// The belt-and-suspenders layer both doors + the SSE route route through:
|
|
176
|
+
// a date filter isn't expressible as a flat URL param (removed 0.6.4), but
|
|
177
|
+
// if one ever reaches queryOpts it is rejected identically.
|
|
178
|
+
expect(unsupportedSubscriptionReason({ cursor: "x" } as any)).toBeTruthy();
|
|
179
|
+
expect(unsupportedSubscriptionReason({ hasLinks: true } as any)).toBeTruthy();
|
|
180
|
+
expect(unsupportedSubscriptionReason({ dateFrom: "2026-01-01" } as any)).toBeTruthy();
|
|
181
|
+
expect(unsupportedSubscriptionReason({ dateFilter: { field: "created_at" } } as any)).toBeTruthy();
|
|
182
|
+
expect(unsupportedSubscriptionReason({ tags: ["chat"] } as any)).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("misc pure helpers", () => {
|
|
187
|
+
it("subscriptionCapResponse → 503 SUBSCRIPTION_CAP_REACHED", async () => {
|
|
188
|
+
const res = subscriptionCapResponse();
|
|
189
|
+
expect(res.status).toBe(503);
|
|
190
|
+
expect((await res.json()).code).toBe("SUBSCRIPTION_CAP_REACHED");
|
|
191
|
+
});
|
|
192
|
+
it("urlFromQuery reconstructs searchParams from a raw query string", () => {
|
|
193
|
+
expect(urlFromQuery("?tag=chat&a=b").searchParams.get("tag")).toBe("chat");
|
|
194
|
+
expect(urlFromQuery("").search).toBe("");
|
|
195
|
+
});
|
|
196
|
+
it("WS_CLOSE carries the four application close codes", () => {
|
|
197
|
+
expect([WS_CLOSE.PROTOCOL, WS_CLOSE.UNAUTHORIZED, WS_CLOSE.FORBIDDEN, WS_CLOSE.AUTH_TIMEOUT]).toEqual([
|
|
198
|
+
4400, 4401, 4403, 4408,
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
});
|
package/src/module-manifest.ts
CHANGED
|
@@ -48,6 +48,14 @@ export interface VaultModuleManifest {
|
|
|
48
48
|
readonly paths: readonly string[];
|
|
49
49
|
readonly health: string;
|
|
50
50
|
readonly stripPrefix?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* When `true`, vault's daemon accepts WebSocket upgrades (the live-query WS
|
|
53
|
+
* binding) and the hub's ws-bridge forwards `Upgrade: websocket` requests on
|
|
54
|
+
* vault's mounts. DENY BY DEFAULT on the hub side. Carried onto the
|
|
55
|
+
* self-registered services.json row too (`ServiceEntry.websocket`); the hub
|
|
56
|
+
* honors either source. See `parachute-hub/src/ws-bridge.ts`.
|
|
57
|
+
*/
|
|
58
|
+
readonly websocket?: boolean;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
/**
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit tests for the
|
|
2
|
+
* Unit tests for the default-pack seeding (originally demo-prep Workstream A —
|
|
3
|
+
* A1/A2/A3; reshaped for named seed packs).
|
|
3
4
|
*
|
|
4
5
|
* Store-direct (no CLI subprocess) so they're fast. Covers:
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
6
|
+
* - default seed = `welcome` (3-note web + the capture tag) + `getting-started`
|
|
7
|
+
* — and NOT `surface-starter` (out of the default seed, ratified 2026-07-02).
|
|
8
|
+
* - A1: the Getting Started doctrine markers.
|
|
9
|
+
* - A3 (reshaped): the welcome web's wikilinks resolve; Getting Started
|
|
10
|
+
* points at the surface-starter PACK without a dangling wikilink.
|
|
7
11
|
* - idempotency: a re-run never clobbers an edited note.
|
|
12
|
+
* - applySeedPack(surface-starter): the add-pack path works + is idempotent,
|
|
13
|
+
* and the guide carries the surface doctrine (GAP 1/2 markers).
|
|
8
14
|
* - A2: the projection / vault-info pointer steers the AI at Getting Started.
|
|
9
15
|
*/
|
|
10
16
|
|
|
@@ -16,14 +22,33 @@ import { tmpdir } from "os";
|
|
|
16
22
|
import { BunStore } from "./vault-store.ts";
|
|
17
23
|
import { seedOnboardingNotes } from "./onboarding-seed.ts";
|
|
18
24
|
import {
|
|
25
|
+
applySeedPack,
|
|
26
|
+
CAPTURE_ANYTHING_PATH,
|
|
27
|
+
CONNECT_AI_PATH,
|
|
19
28
|
GETTING_STARTED_PATH,
|
|
29
|
+
NOTES_REQUIRED_TAGS,
|
|
30
|
+
SURFACE_STARTER_PACK,
|
|
20
31
|
SURFACE_STARTER_PATH,
|
|
21
|
-
|
|
32
|
+
TAGS_GRAPH_PATH,
|
|
33
|
+
WELCOME_PATH,
|
|
34
|
+
YOURS_TO_KEEP_PATH,
|
|
35
|
+
} from "../core/src/seed-packs.ts";
|
|
22
36
|
import {
|
|
23
37
|
buildVaultProjection,
|
|
24
38
|
projectionToMarkdown,
|
|
25
39
|
} from "../core/src/vault-projection.ts";
|
|
26
40
|
|
|
41
|
+
/** Every note path the default seed writes on a blank vault (the five-guide
|
|
42
|
+
* welcome ring + the AI-facing Getting Started). */
|
|
43
|
+
const DEFAULT_SEED_PATHS = [
|
|
44
|
+
WELCOME_PATH,
|
|
45
|
+
CAPTURE_ANYTHING_PATH,
|
|
46
|
+
TAGS_GRAPH_PATH,
|
|
47
|
+
CONNECT_AI_PATH,
|
|
48
|
+
YOURS_TO_KEEP_PATH,
|
|
49
|
+
GETTING_STARTED_PATH,
|
|
50
|
+
];
|
|
51
|
+
|
|
27
52
|
let db: Database;
|
|
28
53
|
let store: BunStore;
|
|
29
54
|
let tmpDir: string;
|
|
@@ -40,14 +65,15 @@ afterEach(() => {
|
|
|
40
65
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
41
66
|
});
|
|
42
67
|
|
|
43
|
-
describe("seedOnboardingNotes (
|
|
44
|
-
test("seeds
|
|
68
|
+
describe("seedOnboardingNotes — default packs (welcome + getting-started)", () => {
|
|
69
|
+
test("seeds the welcome web + Getting Started on a blank vault — and NOT Surface Starter", async () => {
|
|
45
70
|
const result = await seedOnboardingNotes(store);
|
|
46
|
-
expect(result.seeded.sort()).toEqual(
|
|
47
|
-
[GETTING_STARTED_PATH, SURFACE_STARTER_PATH].sort(),
|
|
48
|
-
);
|
|
71
|
+
expect(result.seeded.sort()).toEqual([...DEFAULT_SEED_PATHS].sort());
|
|
49
72
|
expect(result.skipped).toEqual([]);
|
|
50
73
|
|
|
74
|
+
// Surface Starter is OUT of the default seed (button/CLI only).
|
|
75
|
+
expect(await store.getNoteByPath(SURFACE_STARTER_PATH)).toBeNull();
|
|
76
|
+
|
|
51
77
|
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
52
78
|
expect(gs).not.toBeNull();
|
|
53
79
|
expect(gs!.content).toContain("# Getting Started");
|
|
@@ -56,29 +82,75 @@ describe("seedOnboardingNotes (A1/A3)", () => {
|
|
|
56
82
|
expect(gs!.content).toContain("Tags = types");
|
|
57
83
|
expect(gs!.content).toContain("Paths = organization");
|
|
58
84
|
expect(gs!.content).toContain("Schemas = typed metadata fields");
|
|
59
|
-
|
|
85
|
+
// Data-in leads with the import UI + MCP bridge; the old `parachute-vault
|
|
86
|
+
// import` CLI line (a live bug on cloud) is gone (2026-07-06 rewrite).
|
|
87
|
+
expect(gs!.content).not.toContain("parachute-vault import");
|
|
88
|
+
expect(gs!.content).toContain("Bringing existing notes in");
|
|
60
89
|
expect(gs!.content).toContain("Adapt this note");
|
|
61
90
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
91
|
+
// The welcome ring: five person-voiced guide notes.
|
|
92
|
+
for (const path of [
|
|
93
|
+
WELCOME_PATH,
|
|
94
|
+
CAPTURE_ANYTHING_PATH,
|
|
95
|
+
TAGS_GRAPH_PATH,
|
|
96
|
+
CONNECT_AI_PATH,
|
|
97
|
+
YOURS_TO_KEEP_PATH,
|
|
98
|
+
]) {
|
|
99
|
+
expect(await store.getNoteByPath(path)).not.toBeNull();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
70
102
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
103
|
+
test("seeds the capture tag Notes requires (byte-equal semantics) + the guide tag", async () => {
|
|
104
|
+
const result = await seedOnboardingNotes(store);
|
|
105
|
+
// welcome upserts [capture, guide]; getting-started re-upserts [guide]
|
|
106
|
+
// (converges). applySeedPack reports every declared tag per pack, so the
|
|
107
|
+
// concatenation carries guide twice — deterministic.
|
|
108
|
+
expect(result.tags).toEqual(["capture", "guide", "guide"]);
|
|
74
109
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
110
|
+
for (const decl of NOTES_REQUIRED_TAGS) {
|
|
111
|
+
const record = await store.getTagRecord(decl.name);
|
|
112
|
+
expect(record).not.toBeNull();
|
|
113
|
+
expect(record!.description).toBe(decl.description);
|
|
114
|
+
expect(record!.parent_names ?? []).toEqual(decl.parent_names ?? []);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The guide tag lands with no metadata schema — guides are for you and your
|
|
118
|
+
// AI alike, plain markdown, so no per-guide audience field (written_for dropped).
|
|
119
|
+
const guide = await store.getTagRecord("guide");
|
|
120
|
+
expect(guide).not.toBeNull();
|
|
121
|
+
expect(guide!.fields?.written_for).toBeUndefined();
|
|
122
|
+
// #pinned is NOT seeded (dropped 2026-07-07 — did nothing visible on a fresh
|
|
123
|
+
// vault). Pinning still works when the user pins a note in the app.
|
|
124
|
+
expect(await store.getTagRecord("pinned")).toBeNull();
|
|
125
|
+
|
|
126
|
+
// The retired subtype tags are NOT seeded — entry method is note
|
|
127
|
+
// metadata.source (text|voice), not taxonomy (2026-07-03). Existing
|
|
128
|
+
// vaults carrying these tags stay valid; new vaults don't get them.
|
|
129
|
+
expect(await store.getTagRecord("capture/text")).toBeNull();
|
|
130
|
+
expect(await store.getTagRecord("capture/voice")).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("seeded guides carry #guide (create-time tag write-through)", async () => {
|
|
134
|
+
await seedOnboardingNotes(store);
|
|
135
|
+
|
|
136
|
+
// Welcome guide: tagged guide (no longer pinned).
|
|
137
|
+
const welcome = await store.getNoteByPath(WELCOME_PATH);
|
|
138
|
+
expect([...(welcome!.tags ?? [])].sort()).toEqual(["guide"]);
|
|
139
|
+
|
|
140
|
+
// The four sibling guides: guide only.
|
|
141
|
+
for (const path of [
|
|
142
|
+
CAPTURE_ANYTHING_PATH,
|
|
143
|
+
TAGS_GRAPH_PATH,
|
|
144
|
+
CONNECT_AI_PATH,
|
|
145
|
+
YOURS_TO_KEEP_PATH,
|
|
146
|
+
]) {
|
|
147
|
+
const note = await store.getNoteByPath(path);
|
|
148
|
+
expect(note!.tags).toEqual(["guide"]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The AI-facing Getting Started guide: guide.
|
|
152
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
153
|
+
expect(gs!.tags).toEqual(["guide"]);
|
|
82
154
|
});
|
|
83
155
|
|
|
84
156
|
test("GAP 4: Getting Started shows a concrete update-tag fields example + write gotchas", async () => {
|
|
@@ -98,29 +170,51 @@ describe("seedOnboardingNotes (A1/A3)", () => {
|
|
|
98
170
|
expect(gs!.content).toContain("first listed value");
|
|
99
171
|
});
|
|
100
172
|
|
|
101
|
-
test("A3: Getting Started
|
|
173
|
+
test("A3 (reshaped): the welcome ring resolves to real link edges; Getting Started has no dangling Surface Starter link", async () => {
|
|
102
174
|
await seedOnboardingNotes(store);
|
|
175
|
+
|
|
176
|
+
const welcome = await store.getNoteByPath(WELCOME_PATH);
|
|
177
|
+
const capture = await store.getNoteByPath(CAPTURE_ANYTHING_PATH);
|
|
178
|
+
const tags = await store.getNoteByPath(TAGS_GRAPH_PATH);
|
|
179
|
+
const connectAi = await store.getNoteByPath(CONNECT_AI_PATH);
|
|
180
|
+
const yours = await store.getNoteByPath(YOURS_TO_KEEP_PATH);
|
|
103
181
|
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
104
|
-
expect(gs!.content).toContain("[[Surface Starter]]");
|
|
105
182
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
183
|
+
const outbound = async (id: string) =>
|
|
184
|
+
(await store.getLinks(id, { direction: "outbound" })).map((l) => l.targetId);
|
|
185
|
+
|
|
186
|
+
// Welcome → all four siblings.
|
|
187
|
+
const welcomeOut = await outbound(welcome!.id);
|
|
188
|
+
for (const t of [capture!.id, tags!.id, connectAi!.id, yours!.id]) {
|
|
189
|
+
expect(welcomeOut).toContain(t);
|
|
190
|
+
}
|
|
191
|
+
// The chain forward: capture → tags → connect → yours → welcome.
|
|
192
|
+
expect(await outbound(capture!.id)).toContain(tags!.id);
|
|
193
|
+
expect(await outbound(tags!.id)).toContain(connectAi!.id);
|
|
194
|
+
const connectOut = await outbound(connectAi!.id);
|
|
195
|
+
expect(connectOut).toContain(yours!.id);
|
|
196
|
+
// Connect your AI also links the AI-facing Getting Started note (seeded by
|
|
197
|
+
// the getting-started pack) — the ring's cross-link resolves, no dangle.
|
|
198
|
+
expect(connectOut).toContain(gs!.id);
|
|
199
|
+
expect(await outbound(yours!.id)).toContain(welcome!.id);
|
|
200
|
+
|
|
201
|
+
// Getting Started mentions the surface-starter PACK — not a wikilink to a
|
|
202
|
+
// note that the default seed no longer creates.
|
|
203
|
+
expect(gs!.content).not.toContain("[[Surface Starter]]");
|
|
204
|
+
expect(gs!.content).toContain("add-pack");
|
|
110
205
|
});
|
|
111
206
|
|
|
112
|
-
test("idempotent: a second seed run skips
|
|
207
|
+
test("idempotent: a second seed run skips all notes (does not duplicate)", async () => {
|
|
113
208
|
await seedOnboardingNotes(store);
|
|
114
209
|
const second = await seedOnboardingNotes(store);
|
|
115
210
|
expect(second.seeded).toEqual([]);
|
|
116
|
-
expect(second.skipped.sort()).toEqual(
|
|
117
|
-
[GETTING_STARTED_PATH, SURFACE_STARTER_PATH].sort(),
|
|
118
|
-
);
|
|
211
|
+
expect(second.skipped.sort()).toEqual([...DEFAULT_SEED_PATHS].sort());
|
|
119
212
|
|
|
120
213
|
// Exactly one note per path — no duplicates.
|
|
121
214
|
const all = await store.queryNotes({});
|
|
122
|
-
|
|
123
|
-
|
|
215
|
+
for (const path of DEFAULT_SEED_PATHS) {
|
|
216
|
+
expect(all.filter((n) => n.path === path)).toHaveLength(1);
|
|
217
|
+
}
|
|
124
218
|
});
|
|
125
219
|
|
|
126
220
|
test("idempotent: never clobbers an operator-edited Getting Started", async () => {
|
|
@@ -139,6 +233,60 @@ describe("seedOnboardingNotes (A1/A3)", () => {
|
|
|
139
233
|
});
|
|
140
234
|
});
|
|
141
235
|
|
|
236
|
+
describe("applySeedPack — surface-starter via add-pack", () => {
|
|
237
|
+
test("applies the Surface Starter guide onto a default-seeded vault", async () => {
|
|
238
|
+
await seedOnboardingNotes(store);
|
|
239
|
+
const result = await applySeedPack(store, SURFACE_STARTER_PACK);
|
|
240
|
+
expect(result.pack).toBe("surface-starter");
|
|
241
|
+
expect(result.seededNotes).toEqual([SURFACE_STARTER_PATH]);
|
|
242
|
+
expect(result.skippedNotes).toEqual([]);
|
|
243
|
+
// surface-starter now declares GUIDE_TAG (it's a guide).
|
|
244
|
+
expect(result.tags).toEqual(["guide"]);
|
|
245
|
+
|
|
246
|
+
const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
|
|
247
|
+
expect(ss).not.toBeNull();
|
|
248
|
+
expect(ss!.content).toContain("# Surface Starter");
|
|
249
|
+
// A3: the two surface packages by name.
|
|
250
|
+
expect(ss!.content).toContain("@openparachute/surface-client");
|
|
251
|
+
expect(ss!.content).toContain("@openparachute/surface-render");
|
|
252
|
+
expect(ss!.content).toContain("createVaultSurface");
|
|
253
|
+
expect(ss!.content).toContain("NoteRenderer");
|
|
254
|
+
|
|
255
|
+
// GAP 2: warn that a surface can't be run/developed from this MCP session.
|
|
256
|
+
expect(ss!.content).toContain("not from this");
|
|
257
|
+
expect(ss!.content.toLowerCase()).toContain("browser");
|
|
258
|
+
|
|
259
|
+
// GAP 1: a real, copy-paste end-to-end snippet — every load-bearing symbol
|
|
260
|
+
// present, verified against the surface-client / surface-render source.
|
|
261
|
+
expect(ss!.content).toContain("clientName"); // the one required createVaultSurface option
|
|
262
|
+
expect(ss!.content).toContain("getClient");
|
|
263
|
+
expect(ss!.content).toContain("queryNotes");
|
|
264
|
+
expect(ss!.content).toContain("handleCallback");
|
|
265
|
+
expect(ss!.content).toContain("resolve="); // NoteRenderer wikilink resolver prop
|
|
266
|
+
|
|
267
|
+
// Its [[Getting Started]] wikilink resolves against the default seed.
|
|
268
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
269
|
+
const outbound = await store.getLinks(ss!.id, { direction: "outbound" });
|
|
270
|
+
expect(outbound.some((l) => l.targetId === gs!.id)).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("idempotent: a re-apply skips and never clobbers an edited note", async () => {
|
|
274
|
+
await applySeedPack(store, SURFACE_STARTER_PACK);
|
|
275
|
+
const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
|
|
276
|
+
const edited = "# Surface Starter\n\nWe built ours with Solid.";
|
|
277
|
+
await store.updateNote(ss!.id, { content: edited });
|
|
278
|
+
|
|
279
|
+
const rerun = await applySeedPack(store, SURFACE_STARTER_PACK);
|
|
280
|
+
expect(rerun.seededNotes).toEqual([]);
|
|
281
|
+
expect(rerun.skippedNotes).toEqual([SURFACE_STARTER_PATH]);
|
|
282
|
+
|
|
283
|
+
const after = await store.getNoteByPath(SURFACE_STARTER_PATH);
|
|
284
|
+
expect(after!.content).toBe(edited);
|
|
285
|
+
const all = await store.queryNotes({});
|
|
286
|
+
expect(all.filter((n) => n.path === SURFACE_STARTER_PATH)).toHaveLength(1);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
142
290
|
describe("vault-info / projection pointer (A2)", () => {
|
|
143
291
|
test("projection carries getting_started when the note exists", async () => {
|
|
144
292
|
// Absent before seeding → no pointer.
|
package/src/onboarding-seed.ts
CHANGED
|
@@ -1,74 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Seed the
|
|
2
|
+
* Seed the default packs on vault creation.
|
|
3
3
|
*
|
|
4
|
-
* A freshly-created vault gets
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* A freshly-created vault gets the `welcome` pack (the five-guide welcome ring
|
|
5
|
+
* + the `capture` / `guide` / `pinned` tags the Notes surface expects) and
|
|
6
|
+
* the `getting-started` pack (the AI-legible start-here doctrine) — so the
|
|
7
|
+
* first minute shows a small living graph, and a connected AI can read the
|
|
8
|
+
* guide and help the operator set the vault up.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* a sweep over existing vaults.
|
|
10
|
+
* The `surface-starter` pack is deliberately NOT part of the default seed
|
|
11
|
+
* (ratified 2026-07-02): it's added on demand via
|
|
12
|
+
* `parachute-vault add-pack surface-starter` or a console affordance.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* IDEMPOTENT + create-time only: each note is written ONLY when absent (tag
|
|
15
|
+
* upserts converge on the same rows). A re-init, restart, or re-run never
|
|
16
|
+
* clobbers a note the operator/AI has since edited. Existing vaults are
|
|
17
|
+
* unaffected — this runs on the create path, not as a sweep over existing
|
|
18
|
+
* vaults.
|
|
15
19
|
*
|
|
16
|
-
*
|
|
20
|
+
* Best-effort + non-fatal: seeding must NEVER fail a vault create. Any error
|
|
21
|
+
* is caught and logged; the vault is still usable without the starter content.
|
|
22
|
+
*
|
|
23
|
+
* Pack contents live in core/src/seed-packs.ts (shared with the cloud vault).
|
|
17
24
|
*/
|
|
18
25
|
|
|
19
26
|
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
} from "../core/src/onboarding.ts";
|
|
27
|
+
applySeedPack,
|
|
28
|
+
GETTING_STARTED_PACK,
|
|
29
|
+
welcomePack,
|
|
30
|
+
} from "../core/src/seed-packs.ts";
|
|
25
31
|
import type { Store } from "../core/src/types.ts";
|
|
26
32
|
|
|
27
33
|
interface SeedResult {
|
|
28
|
-
/**
|
|
34
|
+
/** Note paths actually written this run (absent ones). Empty when all pre-existed. */
|
|
29
35
|
seeded: string[];
|
|
30
|
-
/**
|
|
36
|
+
/** Note paths skipped because the note already existed (idempotency). */
|
|
31
37
|
skipped: string[];
|
|
38
|
+
/** Tag names upserted (idempotent by nature). */
|
|
39
|
+
tags: string[];
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
store: Store,
|
|
40
|
-
path: string,
|
|
41
|
-
content: string,
|
|
42
|
-
): Promise<boolean> {
|
|
43
|
-
const existing = await store.getNoteByPath(path);
|
|
44
|
-
if (existing) return false;
|
|
45
|
-
await store.createNote(content, { path });
|
|
46
|
-
return true;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Seed the onboarding notes into `store`, idempotently. Surface Starter is
|
|
51
|
-
* created first so the `[[Surface Starter]]` wikilink in Getting Started
|
|
52
|
-
* resolves immediately (the create path also auto-resolves later, but this
|
|
53
|
-
* keeps the link live from the first write).
|
|
43
|
+
* Seed the default packs (`welcome` + `getting-started`) into `store`,
|
|
44
|
+
* idempotently. Welcome first so the welcome web exists before the guide
|
|
45
|
+
* lands (wikilinks auto-resolve either way; this just keeps the seed's
|
|
46
|
+
* unresolved-link window minimal).
|
|
54
47
|
*/
|
|
55
48
|
export async function seedOnboardingNotes(store: Store): Promise<SeedResult> {
|
|
56
49
|
const seeded: string[] = [];
|
|
57
50
|
const skipped: string[] = [];
|
|
51
|
+
const tags: string[] = [];
|
|
58
52
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
53
|
+
// No consoleOrigin: create-time runs are often pre-expose, and a baked-in
|
|
54
|
+
// loopback origin would go stale — the generic line is the safe default.
|
|
55
|
+
for (const pack of [welcomePack(), GETTING_STARTED_PACK]) {
|
|
56
|
+
const result = await applySeedPack(store, pack);
|
|
57
|
+
seeded.push(...result.seededNotes);
|
|
58
|
+
skipped.push(...result.skippedNotes);
|
|
59
|
+
tags.push(...result.tags);
|
|
65
60
|
}
|
|
66
61
|
|
|
67
|
-
return { seeded, skipped };
|
|
62
|
+
return { seeded, skipped, tags };
|
|
68
63
|
}
|
|
69
64
|
|
|
70
65
|
/**
|
|
71
|
-
* Best-effort wrapper for the create path: seed the
|
|
66
|
+
* Best-effort wrapper for the create path: seed the default packs, swallow
|
|
72
67
|
* and log any error so a seeding failure never fails the vault create.
|
|
73
68
|
*/
|
|
74
69
|
export async function seedOnboardingNotesBestEffort(store: Store): Promise<void> {
|
|
@@ -76,7 +71,7 @@ export async function seedOnboardingNotesBestEffort(store: Store): Promise<void>
|
|
|
76
71
|
await seedOnboardingNotes(store);
|
|
77
72
|
} catch (err) {
|
|
78
73
|
console.error(
|
|
79
|
-
`Note:
|
|
74
|
+
`Note: starter content not seeded (${(err as Error)?.message ?? err}). ` +
|
|
80
75
|
`The vault was still created; you can connect an AI and ask it to set things up.`,
|
|
81
76
|
);
|
|
82
77
|
}
|