@openparachute/vault 0.7.2 → 0.7.3-rc.10
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/README.md +5 -3
- package/core/src/attachment/policy.test.ts +66 -0
- package/core/src/attachment/policy.ts +131 -0
- package/core/src/attachment/tickets.test.ts +45 -0
- package/core/src/attachment/tickets.ts +117 -0
- package/core/src/attachment-tickets-tool.test.ts +286 -0
- package/core/src/conformance.ts +2 -1
- package/core/src/contract-typed-index.test.ts +4 -3
- package/core/src/core.test.ts +607 -11
- package/core/src/display-title.test.ts +190 -0
- package/core/src/embedding/chunker.test.ts +97 -0
- package/core/src/embedding/chunker.ts +180 -0
- package/core/src/embedding/provider.ts +108 -0
- package/core/src/embedding/staleness.test.ts +87 -0
- package/core/src/embedding/staleness.ts +83 -0
- package/core/src/embedding/vector-codec.test.ts +99 -0
- package/core/src/embedding/vector-codec.ts +60 -0
- package/core/src/embedding/vectors.test.ts +163 -0
- package/core/src/embedding/vectors.ts +135 -0
- package/core/src/expand.ts +11 -3
- package/core/src/indexed-fields.test.ts +9 -3
- package/core/src/indexed-fields.ts +9 -1
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-semantic-search.test.ts +160 -0
- package/core/src/mcp.ts +405 -10
- package/core/src/notes.semantic-search.test.ts +131 -0
- package/core/src/notes.ts +319 -7
- package/core/src/query-warnings.ts +40 -0
- package/core/src/schema-defaults.ts +85 -1
- package/core/src/schema-v27-note-vectors.test.ts +178 -0
- package/core/src/schema.ts +81 -1
- package/core/src/search-fts-v25.test.ts +9 -1
- package/core/src/search-query.test.ts +42 -0
- package/core/src/search-query.ts +27 -0
- package/core/src/search-title-boost.test.ts +125 -0
- package/core/src/seed-packs.test.ts +117 -1
- package/core/src/seed-packs.ts +217 -1
- package/core/src/store.semantic-search.test.ts +236 -0
- package/core/src/store.ts +162 -5
- package/core/src/tag-schemas.ts +27 -12
- package/core/src/types.ts +63 -1
- package/core/src/vault-projection.ts +48 -0
- package/package.json +6 -1
- package/src/add-pack.test.ts +32 -0
- package/src/attachment-tickets.test.ts +350 -0
- package/src/attachment-tickets.ts +264 -0
- package/src/auth.ts +8 -2
- package/src/cli.ts +5 -1
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-honest-queries.test.ts +88 -0
- package/src/contract-search.test.ts +41 -0
- package/src/embedding/capability.test.ts +33 -0
- package/src/embedding/capability.ts +34 -0
- package/src/embedding/external-api.test.ts +154 -0
- package/src/embedding/external-api.ts +144 -0
- package/src/embedding/onnx-transformers.test.ts +113 -0
- package/src/embedding/onnx-transformers.ts +141 -0
- package/src/embedding/select.test.ts +99 -0
- package/src/embedding/select.ts +92 -0
- package/src/embedding-worker.test.ts +300 -0
- package/src/embedding-worker.ts +226 -0
- package/src/mcp-http.ts +13 -1
- package/src/mcp-tools.ts +49 -14
- package/src/onboarding-seed.test.ts +64 -0
- package/src/routes.ts +173 -92
- package/src/routing.ts +17 -0
- package/src/scribe-discovery.test.ts +76 -1
- package/src/scribe-discovery.ts +28 -9
- package/src/semantic-search-routes.test.ts +161 -0
- package/src/server.ts +21 -2
- package/src/vault-embeddings-capability.test.ts +82 -0
- package/src/vault-store-embedding-wiring.test.ts +69 -0
- package/src/vault-store.ts +53 -2
- package/src/vault.test.ts +62 -13
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embed-on-write worker (semantic search MVP — EXPERIMENTAL).
|
|
3
|
+
*
|
|
4
|
+
* Event-driven happy path + a sweep safety net — structurally the same
|
|
5
|
+
* shape as `transcription-worker.ts`, simplified (no retry/backoff state
|
|
6
|
+
* machine; a failed embed just leaves the chunk stale for the next edit
|
|
7
|
+
* or sweep pass to retry):
|
|
8
|
+
*
|
|
9
|
+
* - **Event path (hot):** a note create/update dispatches the
|
|
10
|
+
* `HookRegistry`'s "created"/"updated" event. `registerEmbeddingHook`
|
|
11
|
+
* calls `worker.kick(vault, note)`, which chunks the note
|
|
12
|
+
* (`chunkNoteContent`), diffs against existing `note_vectors` rows
|
|
13
|
+
* (`planStaleness`), prunes anything obsolete (the note shrank, or the
|
|
14
|
+
* model changed), and embeds ONLY the stale chunks. A no-op edit
|
|
15
|
+
* (identical content, same model) makes ZERO provider calls — the
|
|
16
|
+
* freshness gate short-circuits before the network/CPU work.
|
|
17
|
+
* - **Sweep path (backfill + safety net):** every `sweepIntervalMs`,
|
|
18
|
+
* walks each vault's notes lacking a fresh vector for the active
|
|
19
|
+
* model (`getNotesPendingEmbedding`) and embeds them. This is what
|
|
20
|
+
* actually drains the v27 migration's implicit backfill (the
|
|
21
|
+
* migration itself does no embedding — see `schema.ts`'s
|
|
22
|
+
* `migrateToV27` doc comment) and catches anything a dropped hook
|
|
23
|
+
* dispatch or a mid-backfill restart left behind.
|
|
24
|
+
*
|
|
25
|
+
* Concurrency: the event path rides `HookRegistry`'s own
|
|
26
|
+
* `HOOK_CONCURRENCY` cap for free (the handler IS the capped unit of
|
|
27
|
+
* work). The sweep processes its candidate list SEQUENTIALLY — simple,
|
|
28
|
+
* safe, and matches the real per-note embed latency the P0 eval measured
|
|
29
|
+
* (roughly 1–5 notes/sec depending on model); a bulk backfill of a few
|
|
30
|
+
* thousand notes finishes in minutes, not hours, and never spawns
|
|
31
|
+
* unbounded parallel provider calls.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { Store, Note } from "../core/src/types.ts";
|
|
35
|
+
import type { HookRegistry, NoteHookPayload } from "../core/src/hooks.ts";
|
|
36
|
+
import type { EmbeddingProvider } from "../core/src/embedding/provider.ts";
|
|
37
|
+
import { chunkNoteContent } from "../core/src/embedding/chunker.ts";
|
|
38
|
+
import { planStaleness, contentHash } from "../core/src/embedding/staleness.ts";
|
|
39
|
+
import { getNoteVectorRows, deleteObsoleteVectorRows, upsertNoteVector, getNotesPendingEmbedding } from "../core/src/embedding/vectors.ts";
|
|
40
|
+
import { normalize } from "../core/src/embedding/vector-codec.ts";
|
|
41
|
+
|
|
42
|
+
/** Mirrors transcription-worker's safety-net cadence. */
|
|
43
|
+
const DEFAULT_SWEEP_MS = 30_000;
|
|
44
|
+
|
|
45
|
+
export interface EmbeddingWorkerOpts {
|
|
46
|
+
/**
|
|
47
|
+
* `undefined` when `EMBEDDINGS_ENABLED=false` (the off switch — see
|
|
48
|
+
* `src/embedding/select.ts`). The worker is still constructed and
|
|
49
|
+
* started either way (simpler wiring in `server.ts`, always one
|
|
50
|
+
* lifecycle to manage) — `embedNote`/`sweepOnce` just no-op when there's
|
|
51
|
+
* no provider to invoke, which is the entire "drain no-ops" contract.
|
|
52
|
+
*/
|
|
53
|
+
provider: EmbeddingProvider | undefined;
|
|
54
|
+
/** Every vault name currently open (mirrors the transcription worker's `vaultList`). */
|
|
55
|
+
vaultList: () => string[];
|
|
56
|
+
/** Resolve (or open) a vault's `Store` by name. */
|
|
57
|
+
getStore: (name: string) => Store;
|
|
58
|
+
/** Sweep cadence (ms). Default 30s. */
|
|
59
|
+
sweepIntervalMs?: number;
|
|
60
|
+
logger?: { error: (...args: unknown[]) => void; log?: (...args: unknown[]) => void };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class EmbeddingWorker {
|
|
64
|
+
private readonly provider: EmbeddingProvider | undefined;
|
|
65
|
+
private readonly vaultList: () => string[];
|
|
66
|
+
private readonly getStore: (name: string) => Store;
|
|
67
|
+
private readonly sweepIntervalMs: number;
|
|
68
|
+
private readonly logger: { error: (...args: unknown[]) => void; log?: (...args: unknown[]) => void };
|
|
69
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
70
|
+
private sweeping = false;
|
|
71
|
+
|
|
72
|
+
constructor(opts: EmbeddingWorkerOpts) {
|
|
73
|
+
this.provider = opts.provider;
|
|
74
|
+
this.vaultList = opts.vaultList;
|
|
75
|
+
this.getStore = opts.getStore;
|
|
76
|
+
this.sweepIntervalMs = opts.sweepIntervalMs ?? DEFAULT_SWEEP_MS;
|
|
77
|
+
this.logger = opts.logger ?? console;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* (Re-)embed one note's stale chunks. Cheap-first: chunking + the
|
|
82
|
+
* staleness diff are pure CPU, no provider call; obsolete-row pruning is
|
|
83
|
+
* a plain DELETE. Only when at least one chunk is genuinely stale does
|
|
84
|
+
* this reach the provider — and if the provider reports itself
|
|
85
|
+
* unavailable, this returns without erroring (the chunk stays stale;
|
|
86
|
+
* the next edit or sweep pass retries once a provider is configured).
|
|
87
|
+
*
|
|
88
|
+
* No provider at all (`EMBEDDINGS_ENABLED=false`) → no-op, immediately —
|
|
89
|
+
* the drain contract's off switch (see `EmbeddingWorkerOpts.provider`).
|
|
90
|
+
*
|
|
91
|
+
* Empty-content chunks (a blank note, or a note whose content is only
|
|
92
|
+
* whitespace) are filtered out of the staleness plan before it reaches
|
|
93
|
+
* the provider — an empty chunk is never meaningfully embeddable, and
|
|
94
|
+
* without this filter a blank note would re-enter `getNotesPendingEmbedding`
|
|
95
|
+
* every sweep forever (nothing to write makes it "pending" again next
|
|
96
|
+
* pass), hammering a provider that may reject empty input outright.
|
|
97
|
+
*/
|
|
98
|
+
async embedNote(store: Store, note: Note): Promise<void> {
|
|
99
|
+
if (!this.provider) return; // off switch — see EmbeddingWorkerOpts.provider
|
|
100
|
+
if (typeof note.content !== "string") return;
|
|
101
|
+
const db = store.db;
|
|
102
|
+
const chunks = chunkNoteContent(note.content).filter((c) => c.text.trim().length > 0);
|
|
103
|
+
const existing = getNoteVectorRows(db, note.id);
|
|
104
|
+
const plan = planStaleness(existing, chunks, this.provider.model);
|
|
105
|
+
|
|
106
|
+
// Cheap sync write — prunes ghost chunks from a shrunk note or a model
|
|
107
|
+
// change BEFORE any embed call, so a concurrent semantic search never
|
|
108
|
+
// ranks against stale content. See core/src/embedding/staleness.ts.
|
|
109
|
+
// An empty note's chunks were just filtered to [] above, so this also
|
|
110
|
+
// prunes any leftover rows from before the note went empty.
|
|
111
|
+
deleteObsoleteVectorRows(db, note.id, plan.obsoleteIxs);
|
|
112
|
+
|
|
113
|
+
if (plan.stale.length === 0) return; // no-op edit (or nothing embeddable) — zero provider calls
|
|
114
|
+
|
|
115
|
+
const avail = await this.provider.available();
|
|
116
|
+
if (!avail.ok) return; // not configured/ready — leave stale, retried later
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const { vectors } = await this.provider.embed({ texts: plan.stale.map((c) => c.text) });
|
|
120
|
+
for (let i = 0; i < plan.stale.length; i++) {
|
|
121
|
+
const chunk = plan.stale[i]!;
|
|
122
|
+
const vector = vectors[i];
|
|
123
|
+
if (!vector) continue; // defensive: provider returned fewer vectors than requested
|
|
124
|
+
upsertNoteVector(db, note.id, chunk, normalize(vector), this.provider.model, contentHash(chunk.text));
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
this.logger.error(`[embedding] embed failed for note ${note.id}:`, err);
|
|
128
|
+
// Leave stale — idempotent: the next edit (or the sweep) re-derives
|
|
129
|
+
// the same plan and retries.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Event-path entry point — called from the registered `onNote` hook. */
|
|
134
|
+
async kick(vaultName: string, note: Note): Promise<void> {
|
|
135
|
+
const store = this.getStore(vaultName);
|
|
136
|
+
await this.embedNote(store, note);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One backfill/safety-net pass across every open vault. Returns counts
|
|
141
|
+
* for caller logging. Re-entrancy guarded (`sweeping`) — a slow pass
|
|
142
|
+
* (a big vault, a slow provider) never overlaps with the next timer tick.
|
|
143
|
+
* No provider at all → no-op immediately (same off switch as `embedNote`).
|
|
144
|
+
*
|
|
145
|
+
* L1 (reviewer): no per-note lock between this sweep and a concurrent
|
|
146
|
+
* `embedNote` triggered by the event-path hook for the SAME note — by
|
|
147
|
+
* design, matching `HookRegistry`'s own documented "no per-note
|
|
148
|
+
* serialization, last write wins" contract. Worst case is a transient
|
|
149
|
+
* window where a stored vector reflects a slightly-stale version; it
|
|
150
|
+
* self-heals on the NEXT edit (or the next sweep pass), since
|
|
151
|
+
* `planStaleness` re-diffs against the note's CURRENT content_hash every
|
|
152
|
+
* time, never trusting a prior run's result.
|
|
153
|
+
*/
|
|
154
|
+
async sweepOnce(): Promise<{ processed: number; vaults: number }> {
|
|
155
|
+
if (!this.provider) return { processed: 0, vaults: 0 }; // off switch
|
|
156
|
+
if (this.sweeping) return { processed: 0, vaults: 0 };
|
|
157
|
+
this.sweeping = true;
|
|
158
|
+
try {
|
|
159
|
+
let processed = 0;
|
|
160
|
+
const vaults = this.vaultList();
|
|
161
|
+
for (const vaultName of vaults) {
|
|
162
|
+
const store = this.getStore(vaultName);
|
|
163
|
+
const pending = getNotesPendingEmbedding(store.db, this.provider.model);
|
|
164
|
+
for (const row of pending) {
|
|
165
|
+
const note = await store.getNote(row.id);
|
|
166
|
+
if (!note) continue; // deleted between the scan and now
|
|
167
|
+
await this.embedNote(store, note);
|
|
168
|
+
processed++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { processed, vaults: vaults.length };
|
|
172
|
+
} finally {
|
|
173
|
+
this.sweeping = false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Start the recurring sweep. No-op if already started. */
|
|
178
|
+
start(): void {
|
|
179
|
+
if (this.timer) return;
|
|
180
|
+
this.timer = setInterval(() => {
|
|
181
|
+
void this.sweepOnce().catch((err) => this.logger.error("[embedding] sweep failed:", err));
|
|
182
|
+
}, this.sweepIntervalMs);
|
|
183
|
+
this.timer.unref?.();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Stop the recurring sweep. */
|
|
187
|
+
stop(): void {
|
|
188
|
+
if (this.timer) {
|
|
189
|
+
clearInterval(this.timer);
|
|
190
|
+
this.timer = null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Register the embed-on-write hook. No `when` predicate (unlike
|
|
197
|
+
* `registerTranscriptionHook`'s `transcribe_status === "pending"` check)
|
|
198
|
+
* — a `NoteHook` predicate only receives the note payload, not the store,
|
|
199
|
+
* so it has no way to cheaply consult `note_vectors`. Instead every
|
|
200
|
+
* create/update dispatches the handler, and `embedNote`'s OWN
|
|
201
|
+
* content-hash diff (checked first, before any provider call) is what
|
|
202
|
+
* makes a no-op edit cost nothing — the same end result, just checked one
|
|
203
|
+
* layer in. `HookRegistry`'s concurrency cap still bounds how many of
|
|
204
|
+
* these run at once.
|
|
205
|
+
*/
|
|
206
|
+
export function registerEmbeddingHook(
|
|
207
|
+
registry: HookRegistry,
|
|
208
|
+
worker: EmbeddingWorker,
|
|
209
|
+
resolveVault: (store: Store) => string | undefined,
|
|
210
|
+
logger: { error: (...args: unknown[]) => void } = console,
|
|
211
|
+
): () => void {
|
|
212
|
+
return registry.onNote({
|
|
213
|
+
name: "embedding-on-write",
|
|
214
|
+
event: ["created", "updated"],
|
|
215
|
+
handler: async (payload: NoteHookPayload, store: Store) => {
|
|
216
|
+
const vault = resolveVault(store);
|
|
217
|
+
if (!vault) {
|
|
218
|
+
logger.error(
|
|
219
|
+
`[embedding] could not resolve vault for note ${(payload as Note).id}; sweep will pick it up`,
|
|
220
|
+
);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
await worker.kick(vault, payload as Note);
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
package/src/mcp-http.ts
CHANGED
|
@@ -109,7 +109,7 @@ export async function handleScopedMcp(
|
|
|
109
109
|
const instruction = await getServerInstruction(vaultName, auth);
|
|
110
110
|
return handleMcp(
|
|
111
111
|
req,
|
|
112
|
-
() => generateScopedMcpTools(vaultName, auth, callerBearer ?? null),
|
|
112
|
+
() => generateScopedMcpTools(vaultName, auth, callerBearer ?? null, req),
|
|
113
113
|
`parachute-vault/${vaultName}`,
|
|
114
114
|
vaultName,
|
|
115
115
|
auth,
|
|
@@ -229,6 +229,8 @@ export async function handleMcp(
|
|
|
229
229
|
tag?: string;
|
|
230
230
|
cycle?: unknown;
|
|
231
231
|
referencing_tags?: unknown;
|
|
232
|
+
/** Attachment-tickets design (§2c "errors as JIT docs") — a short, imperative next-step, distinct from the older free-form `hint`. */
|
|
233
|
+
how_to?: string;
|
|
232
234
|
};
|
|
233
235
|
// Honest-queries validation errors (vault#550) — `limit`/`offset`/date
|
|
234
236
|
// values that are structurally invalid rather than merely "no
|
|
@@ -399,6 +401,16 @@ export async function handleMcp(
|
|
|
399
401
|
error_type: e.error_type,
|
|
400
402
|
field: e.field,
|
|
401
403
|
hint: e.hint,
|
|
404
|
+
// Forwarded when present (undefined keys are fine — JSON-RPC
|
|
405
|
+
// `data` just omits them) so a `structuredError()` call site that
|
|
406
|
+
// stamps extra fields — `limit`/`got`/`extension` (size/type
|
|
407
|
+
// refusals) or `how_to` (attachment-tickets' JIT-docs field, §2c)
|
|
408
|
+
// — isn't silently truncated down to error_type/field/hint by
|
|
409
|
+
// this backstop.
|
|
410
|
+
...(e.limit !== undefined ? { limit: e.limit } : {}),
|
|
411
|
+
...(e.got !== undefined ? { got: e.got } : {}),
|
|
412
|
+
...(e.extension !== undefined ? { extension: e.extension } : {}),
|
|
413
|
+
...(e.how_to !== undefined ? { how_to: e.how_to } : {}),
|
|
402
414
|
});
|
|
403
415
|
}
|
|
404
416
|
return {
|
package/src/mcp-tools.ts
CHANGED
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
import { chooseHubOrigin, mintHubJwt, revokeHubJwt } from "./mcp-install.ts";
|
|
44
44
|
import { looksLikeJwt } from "./hub-jwt.ts";
|
|
45
45
|
import { readGlobalConfig, DEFAULT_PORT } from "./config.ts";
|
|
46
|
+
import { getBaseUrl } from "./oauth-discovery.ts";
|
|
47
|
+
import { getSharedAttachmentTicketProvider } from "./attachment-tickets.ts";
|
|
46
48
|
|
|
47
49
|
/**
|
|
48
50
|
* Filter a vault projection to entries an in-scope tag contributes to.
|
|
@@ -110,6 +112,10 @@ export async function getServerInstruction(
|
|
|
110
112
|
description: config?.description ?? null,
|
|
111
113
|
projection,
|
|
112
114
|
coordinates: resolveVaultCoordinates(),
|
|
115
|
+
// Bun always wires an in-process AttachmentTicketProvider (see
|
|
116
|
+
// `generateScopedMcpTools` below) — the connect-time brief can
|
|
117
|
+
// unconditionally teach the ticket tools on this door.
|
|
118
|
+
attachments: { ticketsEnabled: true },
|
|
113
119
|
});
|
|
114
120
|
}
|
|
115
121
|
|
|
@@ -143,6 +149,16 @@ export function generateScopedMcpTools(
|
|
|
143
149
|
vaultName: string,
|
|
144
150
|
auth?: AuthResult,
|
|
145
151
|
callerBearer?: string | null,
|
|
152
|
+
/**
|
|
153
|
+
* The incoming MCP request, when available (omitted by the many
|
|
154
|
+
* test-only call sites that construct tools without a live request).
|
|
155
|
+
* Threaded through ONLY so the attachment-ticket tools can resolve a
|
|
156
|
+
* request-accurate `urlBase` (honoring `X-Forwarded-Host`/proto, same as
|
|
157
|
+
* `getBaseUrl`) — falls back to `resolveVaultCoordinates()`'s configured/
|
|
158
|
+
* expose-state origin when omitted, so ticket tools are still present
|
|
159
|
+
* (just less precisely origined) in that case.
|
|
160
|
+
*/
|
|
161
|
+
req?: Request,
|
|
146
162
|
): McpToolDef[] {
|
|
147
163
|
const store = getVaultStore(vaultName);
|
|
148
164
|
|
|
@@ -225,20 +241,39 @@ export function generateScopedMcpTools(
|
|
|
225
241
|
? (info) => logStrictBypass(info)
|
|
226
242
|
: undefined;
|
|
227
243
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
244
|
+
// Attachment tickets (Wave 1): bun always wires the in-process provider
|
|
245
|
+
// (see src/attachment-tickets.ts) — unlike the tag-scope predicates
|
|
246
|
+
// above, this isn't conditional on `scoped` because the tools should be
|
|
247
|
+
// listed for every session, scoped or not. Tag-scope confidentiality is
|
|
248
|
+
// still enforced (`noteVisible`, awaited inline inside the ticket tools'
|
|
249
|
+
// own async execute — see its doc comment in generateMcpTools for why
|
|
250
|
+
// this doesn't need the shared allowedHolder machinery the OTHER
|
|
251
|
+
// predicates above do).
|
|
252
|
+
const ticketNoteVisible = scoped
|
|
253
|
+
? async (note: Note) => {
|
|
254
|
+
const allowed = await expandTokenTagScope(store, rawTags);
|
|
255
|
+
return noteWithinTagScope(note, allowed, rawTags);
|
|
256
|
+
}
|
|
257
|
+
: undefined;
|
|
258
|
+
const ticketUrlBase = req
|
|
259
|
+
? `${getBaseUrl(req).replace(/\/$/, "")}/vault/${vaultName}`
|
|
260
|
+
: `${resolveVaultCoordinates().hubOrigin.replace(/\/$/, "")}/vault/${vaultName}`;
|
|
261
|
+
|
|
262
|
+
const tools = generateMcpTools(store, {
|
|
263
|
+
...(expandVisibility ? { expandVisibility } : {}),
|
|
264
|
+
...(nearTraversable ? { nearTraversable } : {}),
|
|
265
|
+
...(ifExistsVisible ? { ifExistsVisible } : {}),
|
|
266
|
+
...(aggregateVisibility ? { aggregateVisibility } : {}),
|
|
267
|
+
...(writeContext ? { writeContext } : {}),
|
|
268
|
+
...(strictBypass ? { strictBypass } : {}),
|
|
269
|
+
...(onStrictBypass ? { onStrictBypass } : {}),
|
|
270
|
+
attachmentTickets: {
|
|
271
|
+
provider: getSharedAttachmentTicketProvider(),
|
|
272
|
+
vaultName,
|
|
273
|
+
urlBase: ticketUrlBase,
|
|
274
|
+
...(ticketNoteVisible ? { noteVisible: ticketNoteVisible } : {}),
|
|
275
|
+
},
|
|
276
|
+
});
|
|
242
277
|
|
|
243
278
|
overrideVaultInfo(tools, vaultName, auth);
|
|
244
279
|
applyTagDependencyGuards(tools, vaultName);
|
|
@@ -23,14 +23,17 @@ import { BunStore } from "./vault-store.ts";
|
|
|
23
23
|
import { seedOnboardingNotes } from "./onboarding-seed.ts";
|
|
24
24
|
import {
|
|
25
25
|
applySeedPack,
|
|
26
|
+
ARCHIVED_TAG,
|
|
26
27
|
CAPTURE_ANYTHING_PATH,
|
|
27
28
|
CONNECT_AI_PATH,
|
|
28
29
|
GETTING_STARTED_PATH,
|
|
29
30
|
NOTES_REQUIRED_TAGS,
|
|
31
|
+
STARTER_ONTOLOGY_PACK,
|
|
30
32
|
SURFACE_STARTER_PACK,
|
|
31
33
|
SURFACE_STARTER_PATH,
|
|
32
34
|
TAGS_GRAPH_PATH,
|
|
33
35
|
WELCOME_PATH,
|
|
36
|
+
welcomePack,
|
|
34
37
|
YOURS_TO_KEEP_PATH,
|
|
35
38
|
} from "../core/src/seed-packs.ts";
|
|
36
39
|
import {
|
|
@@ -290,6 +293,67 @@ describe("applySeedPack — surface-starter via add-pack", () => {
|
|
|
290
293
|
});
|
|
291
294
|
});
|
|
292
295
|
|
|
296
|
+
describe("applySeedPack — tag description preservation on re-apply (Aaron-ratified 2026-07-17)", () => {
|
|
297
|
+
test("fresh apply: a brand-new tag gets the pack's description, reported as touched not preserved", async () => {
|
|
298
|
+
const result = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
|
|
299
|
+
expect(result.tags).toContain(ARCHIVED_TAG.name);
|
|
300
|
+
expect(result.preservedTagDescriptions).toEqual([]);
|
|
301
|
+
|
|
302
|
+
const record = await store.getTagRecord(ARCHIVED_TAG.name);
|
|
303
|
+
expect(record!.description).toBe(ARCHIVED_TAG.description);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("re-apply unmodified: description is still the pack's text — upserted, not preserved", async () => {
|
|
307
|
+
await applySeedPack(store, STARTER_ONTOLOGY_PACK);
|
|
308
|
+
const rerun = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
|
|
309
|
+
expect(rerun.preservedTagDescriptions).toEqual([]);
|
|
310
|
+
|
|
311
|
+
const record = await store.getTagRecord(ARCHIVED_TAG.name);
|
|
312
|
+
expect(record!.description).toBe(ARCHIVED_TAG.description);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("re-apply after a user edit: the hand-tuned description survives and is reported preserved", async () => {
|
|
316
|
+
await applySeedPack(store, STARTER_ONTOLOGY_PACK);
|
|
317
|
+
const edited = "My own house rules for archiving — nothing like the default.";
|
|
318
|
+
await store.upsertTagRecord(ARCHIVED_TAG.name, { description: edited });
|
|
319
|
+
|
|
320
|
+
const rerun = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
|
|
321
|
+
expect(rerun.preservedTagDescriptions).toContain(ARCHIVED_TAG.name);
|
|
322
|
+
// Still listed in `tags` — the tag itself was touched (fields/parent_names
|
|
323
|
+
// upsert unconditionally); only the description write was skipped.
|
|
324
|
+
expect(rerun.tags).toContain(ARCHIVED_TAG.name);
|
|
325
|
+
|
|
326
|
+
const record = await store.getTagRecord(ARCHIVED_TAG.name);
|
|
327
|
+
expect(record!.description).toBe(edited);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("capture byte-identity anchor (NOTES_REQUIRED_TAGS) holds under either apply order", async () => {
|
|
331
|
+
// welcome, then starter-ontology.
|
|
332
|
+
await applySeedPack(store, welcomePack());
|
|
333
|
+
expect((await store.getTagRecord("capture"))!.description).toBe(
|
|
334
|
+
NOTES_REQUIRED_TAGS[0]!.description,
|
|
335
|
+
);
|
|
336
|
+
const afterOntology = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
|
|
337
|
+
expect(afterOntology.preservedTagDescriptions).not.toContain("capture");
|
|
338
|
+
expect((await store.getTagRecord("capture"))!.description).toBe(
|
|
339
|
+
NOTES_REQUIRED_TAGS[0]!.description,
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("capture byte-identity anchor holds in the reverse order too", async () => {
|
|
344
|
+
// starter-ontology, then welcome.
|
|
345
|
+
await applySeedPack(store, STARTER_ONTOLOGY_PACK);
|
|
346
|
+
expect((await store.getTagRecord("capture"))!.description).toBe(
|
|
347
|
+
NOTES_REQUIRED_TAGS[0]!.description,
|
|
348
|
+
);
|
|
349
|
+
const afterWelcome = await applySeedPack(store, welcomePack());
|
|
350
|
+
expect(afterWelcome.preservedTagDescriptions).not.toContain("capture");
|
|
351
|
+
expect((await store.getTagRecord("capture"))!.description).toBe(
|
|
352
|
+
NOTES_REQUIRED_TAGS[0]!.description,
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
293
357
|
describe("vault-info / projection pointer (A2)", () => {
|
|
294
358
|
test("projection carries getting_started when the note exists", async () => {
|
|
295
359
|
// Absent before seeding → no pointer.
|