@openparachute/vault 0.7.9-rc.1 → 0.7.9-rc.3
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/core/src/core.test.ts +546 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/mcp-manifest.ts +9 -5
- package/core/src/mcp.ts +113 -38
- package/core/src/notes.ts +57 -4
- package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
- package/core/src/schema.ts +29 -2
- package/core/src/store.ts +180 -57
- package/core/src/txn.test.ts +33 -1
- package/core/src/txn.ts +80 -7
- package/core/src/types.ts +14 -1
- package/core/src/vault-projection.ts +8 -1
- package/core/src/wikilinks.ts +806 -44
- package/package.json +2 -2
- package/src/aggregate-routes.test.ts +76 -0
- package/src/attribution-threading.test.ts +295 -5
- package/src/auth.ts +107 -3
- package/src/config.ts +8 -2
- package/src/mcp-http.test.ts +51 -1
- package/src/mcp-tools.ts +120 -13
- package/src/mirror-routes.test.ts +47 -0
- package/src/release-plan.test.ts +302 -0
- package/src/routes.ts +215 -61
- package/src/routing.test.ts +67 -0
- package/src/routing.ts +8 -3
- package/src/tag-scope-query-tag.test.ts +374 -0
- package/src/tag-scope.ts +116 -0
- package/src/vault.test.ts +685 -0
- package/src/write-warnings-scope.test.ts +344 -0
- package/src/ws-server.ts +16 -3
- package/src/ws-subscribe.test.ts +76 -2
package/core/src/core.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import * as indexedFieldOps from "./indexed-fields.js";
|
|
|
9
9
|
import { resolveLinkTarget } from "./wikilinks.js";
|
|
10
10
|
import { generateUlid, ULID_REGEX } from "./ulid.js";
|
|
11
11
|
import { getVaultMap, extractH1Title, findNotesByTitle, getNoteByTitle, validatePath, PathValidationError } from "./notes.js";
|
|
12
|
+
import { transactionAsync } from "./txn.js";
|
|
12
13
|
|
|
13
14
|
let store: SqliteStore;
|
|
14
15
|
let db: Database;
|
|
@@ -63,6 +64,51 @@ describe("notes", async () => {
|
|
|
63
64
|
expect(updated.updatedAt).toBeTruthy();
|
|
64
65
|
});
|
|
65
66
|
|
|
67
|
+
it("rolls back the note row when wikilink re-indexing fails mid-update", async () => {
|
|
68
|
+
const source = await store.createNote("before", { path: "Source" });
|
|
69
|
+
await store.createNote("target", { path: "Target" });
|
|
70
|
+
db.exec(`
|
|
71
|
+
CREATE TRIGGER fail_link_insert
|
|
72
|
+
BEFORE INSERT ON links
|
|
73
|
+
BEGIN
|
|
74
|
+
SELECT RAISE(ABORT, 'forced mid-update failure');
|
|
75
|
+
END;
|
|
76
|
+
`);
|
|
77
|
+
|
|
78
|
+
await expect(
|
|
79
|
+
store.updateNote(source.id, { content: "after [[Target]]" }),
|
|
80
|
+
).rejects.toThrow("forced mid-update failure");
|
|
81
|
+
|
|
82
|
+
const unchanged = await store.getNote(source.id);
|
|
83
|
+
expect(unchanged?.content).toBe("before");
|
|
84
|
+
expect(unchanged?.updatedAt).toBe(source.updatedAt);
|
|
85
|
+
expect(await store.getLinks(source.id)).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does not dispatch update hooks for an outer batch that rolls back", async () => {
|
|
89
|
+
const first = await store.createNote("first");
|
|
90
|
+
const second = await store.createNote("second");
|
|
91
|
+
const fired: string[] = [];
|
|
92
|
+
store.hooks.onNote({
|
|
93
|
+
event: "updated",
|
|
94
|
+
handler: (note) => { fired.push(note.id); },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await expect(transactionAsync(db, async () => {
|
|
98
|
+
await store.updateNote(first.id, { content: "changed first" });
|
|
99
|
+
await store.updateNote(second.id, { content: "changed second" });
|
|
100
|
+
throw new Error("forced outer rollback");
|
|
101
|
+
})).rejects.toThrow("forced outer rollback");
|
|
102
|
+
// Let any incorrectly queued dispatches reach the registry before drain.
|
|
103
|
+
await Promise.resolve();
|
|
104
|
+
await Promise.resolve();
|
|
105
|
+
await store.hooks.drain();
|
|
106
|
+
|
|
107
|
+
expect(fired).toEqual([]);
|
|
108
|
+
expect((await store.getNote(first.id))?.content).toBe("first");
|
|
109
|
+
expect((await store.getNote(second.id))?.content).toBe("second");
|
|
110
|
+
});
|
|
111
|
+
|
|
66
112
|
it("updates note path", async () => {
|
|
67
113
|
const note = await store.createNote("Test");
|
|
68
114
|
const updated = await store.updateNote(note.id, { path: "Notes/Test" });
|
|
@@ -1049,6 +1095,33 @@ describe("mergeTags", async () => {
|
|
|
1049
1095
|
expect(result.merged).toEqual({ v1: 1 });
|
|
1050
1096
|
expect((await store.getNote(note.id))!.tags).toEqual(["voice"]);
|
|
1051
1097
|
});
|
|
1098
|
+
|
|
1099
|
+
it("bumps updated_at on notes whose tags actually changed (vault#567)", async () => {
|
|
1100
|
+
const affected = await store.createNote("A", { tags: ["v1"] });
|
|
1101
|
+
const both = await store.createNote("B", { tags: ["v1", "voice"] });
|
|
1102
|
+
const untouched = await store.createNote("C", { tags: ["voice"] });
|
|
1103
|
+
const unrelated = await store.createNote("D", { tags: ["other"] });
|
|
1104
|
+
const affectedAt = affected.updatedAt;
|
|
1105
|
+
const bothAt = both.updatedAt;
|
|
1106
|
+
const untouchedAt = untouched.updatedAt;
|
|
1107
|
+
const unrelatedAt = unrelated.updatedAt;
|
|
1108
|
+
|
|
1109
|
+
// Wall-clock ISO timestamps are millisecond-resolution; wait so the
|
|
1110
|
+
// bump cannot collide with the create timestamp.
|
|
1111
|
+
await Bun.sleep(5);
|
|
1112
|
+
await store.mergeTags(["v1"], "voice");
|
|
1113
|
+
|
|
1114
|
+
const affectedAfter = (await store.getNote(affected.id))!;
|
|
1115
|
+
const bothAfter = (await store.getNote(both.id))!;
|
|
1116
|
+
const untouchedAfter = (await store.getNote(untouched.id))!;
|
|
1117
|
+
const unrelatedAfter = (await store.getNote(unrelated.id))!;
|
|
1118
|
+
expect(affectedAfter.updatedAt > affectedAt).toBe(true);
|
|
1119
|
+
expect(bothAfter.updatedAt > bothAt).toBe(true);
|
|
1120
|
+
expect(untouchedAfter.updatedAt).toBe(untouchedAt);
|
|
1121
|
+
expect(unrelatedAfter.updatedAt).toBe(unrelatedAt);
|
|
1122
|
+
expect(affectedAfter.tags).toEqual(["voice"]);
|
|
1123
|
+
expect(bothAfter.tags).toEqual(["voice"]);
|
|
1124
|
+
});
|
|
1052
1125
|
});
|
|
1053
1126
|
|
|
1054
1127
|
// ---- Vault Stats ----
|
|
@@ -4235,6 +4308,461 @@ describe("MCP tools", async () => {
|
|
|
4235
4308
|
expect(byId.get("mq-list-c")).toEqual([]);
|
|
4236
4309
|
});
|
|
4237
4310
|
|
|
4311
|
+
// ---- has_ambiguous_links / include_ambiguous_links (vault#581) ----
|
|
4312
|
+
//
|
|
4313
|
+
// Symmetric with the has_broken_links block above. Before #581 an
|
|
4314
|
+
// ambiguous target was visible ONLY in the transient write-time
|
|
4315
|
+
// `ambiguous_link` warning — nothing was persisted, so `has_broken_links`
|
|
4316
|
+
// did not match the note (it was classified CLEAN by
|
|
4317
|
+
// `has_broken_links: false`) and a later audit could not find it.
|
|
4318
|
+
|
|
4319
|
+
it("query-notes has_ambiguous_links=true surfaces only notes whose wikilink matched two notes", async () => {
|
|
4320
|
+
await store.createNote("first", { id: "mq-amb-c1", path: "one/Dup" });
|
|
4321
|
+
await store.createNote("second", { id: "mq-amb-c2", path: "two/Dup" });
|
|
4322
|
+
await store.createNote("see [[Dup]]", { id: "mq-amb-src", path: "mq-amb-src" });
|
|
4323
|
+
await store.createNote("no links here", { id: "mq-amb-clean", path: "mq-amb-clean" });
|
|
4324
|
+
|
|
4325
|
+
const tools = generateMcpTools(store);
|
|
4326
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4327
|
+
const result = await query.execute({ has_ambiguous_links: true, include_content: true }) as any[];
|
|
4328
|
+
expect(result.map((n) => n.path)).toEqual(["mq-amb-src"]);
|
|
4329
|
+
});
|
|
4330
|
+
|
|
4331
|
+
it("query-notes has_ambiguous_links=false excludes notes with an ambiguous link", async () => {
|
|
4332
|
+
await store.createNote("first", { path: "a/Twin" });
|
|
4333
|
+
await store.createNote("second", { path: "b/Twin" });
|
|
4334
|
+
await store.createNote("see [[Twin]]", { id: "mq-amb-src2", path: "mq-amb-src2" });
|
|
4335
|
+
await store.createNote("clean", { id: "mq-amb-clean2", path: "mq-amb-clean2" });
|
|
4336
|
+
|
|
4337
|
+
const tools = generateMcpTools(store);
|
|
4338
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4339
|
+
const result = await query.execute({ has_ambiguous_links: false, include_content: true }) as any[];
|
|
4340
|
+
expect(result.map((n) => n.path).sort()).toEqual(["a/Twin", "b/Twin", "mq-amb-clean2"]);
|
|
4341
|
+
});
|
|
4342
|
+
|
|
4343
|
+
it("an ambiguous link is NOT counted as a broken link (the two filters are disjoint)", async () => {
|
|
4344
|
+
await store.createNote("first", { path: "x/Both" });
|
|
4345
|
+
await store.createNote("second", { path: "y/Both" });
|
|
4346
|
+
await store.createNote("see [[Both]]", { id: "mq-amb-disjoint", path: "mq-amb-disjoint" });
|
|
4347
|
+
|
|
4348
|
+
const tools = generateMcpTools(store);
|
|
4349
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4350
|
+
expect((await query.execute({ has_broken_links: true }) as any[]).map((n: any) => n.id)).toEqual([]);
|
|
4351
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).map((n: any) => n.id)).toEqual(["mq-amb-disjoint"]);
|
|
4352
|
+
});
|
|
4353
|
+
|
|
4354
|
+
it("query-notes has_ambiguous_links is safe on a vault where no link has ever been ambiguous", async () => {
|
|
4355
|
+
// Fresh store, beforeEach — the ambiguous_wikilinks table has never been
|
|
4356
|
+
// created. true should match nothing (not throw); false should be a
|
|
4357
|
+
// no-op (matches everything). Mirrors the has_broken_links pin above.
|
|
4358
|
+
await store.createNote("plain note", { id: "mq-amb-none", path: "mq-amb-none" });
|
|
4359
|
+
const tools = generateMcpTools(store);
|
|
4360
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4361
|
+
const truthy = await query.execute({ has_ambiguous_links: true, include_content: true }) as any[];
|
|
4362
|
+
expect(truthy).toEqual([]);
|
|
4363
|
+
const falsy = await query.execute({ has_ambiguous_links: false, include_content: true }) as any[];
|
|
4364
|
+
expect(falsy.map((n) => n.path)).toEqual(["mq-amb-none"]);
|
|
4365
|
+
});
|
|
4366
|
+
|
|
4367
|
+
it("query-notes include_ambiguous_links surfaces {target, relationship, candidate_count} for a single note", async () => {
|
|
4368
|
+
await store.createNote("first", { path: "p/Ghost" });
|
|
4369
|
+
await store.createNote("second", { path: "q/Ghost" });
|
|
4370
|
+
await store.createNote("see [[Ghost]]", { id: "mq-amb-single", path: "mq-amb-single" });
|
|
4371
|
+
|
|
4372
|
+
const tools = generateMcpTools(store);
|
|
4373
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4374
|
+
const result = await query.execute({ id: "mq-amb-single", include_ambiguous_links: true }) as any;
|
|
4375
|
+
expect(result.ambiguous_links).toEqual([{ target: "Ghost", relationship: "wikilink", candidate_count: 2 }]);
|
|
4376
|
+
});
|
|
4377
|
+
|
|
4378
|
+
it("query-notes include_ambiguous_links surfaces a structured link's ambiguous_link entry too", async () => {
|
|
4379
|
+
await store.createNote("first", { path: "s1/Fork" });
|
|
4380
|
+
await store.createNote("second", { path: "s2/Fork" });
|
|
4381
|
+
const created = await generateMcpTools(store).find((t) => t.name === "create-note")!.execute({
|
|
4382
|
+
content: "body",
|
|
4383
|
+
path: "mq-amb-structured",
|
|
4384
|
+
links: [{ target: "Fork", relationship: "depends-on" }],
|
|
4385
|
+
}) as any;
|
|
4386
|
+
expect(created.warnings?.[0]?.code).toBe("ambiguous_link");
|
|
4387
|
+
|
|
4388
|
+
const tools = generateMcpTools(store);
|
|
4389
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4390
|
+
const result = await query.execute({ id: created.id, include_ambiguous_links: true }) as any;
|
|
4391
|
+
expect(result.ambiguous_links).toEqual([{ target: "Fork", relationship: "depends-on", candidate_count: 2 }]);
|
|
4392
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).map((n: any) => n.id)).toEqual([created.id]);
|
|
4393
|
+
});
|
|
4394
|
+
|
|
4395
|
+
it("query-notes include_ambiguous_links is [] for a note with no ambiguous links", async () => {
|
|
4396
|
+
await store.createNote("clean", { id: "mq-amb-clean-single", path: "mq-amb-clean-single" });
|
|
4397
|
+
const tools = generateMcpTools(store);
|
|
4398
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4399
|
+
const result = await query.execute({ id: "mq-amb-clean-single", include_ambiguous_links: true }) as any;
|
|
4400
|
+
expect(result.ambiguous_links).toEqual([]);
|
|
4401
|
+
});
|
|
4402
|
+
|
|
4403
|
+
it("query-notes include_ambiguous_links works in list mode, batched across the page", async () => {
|
|
4404
|
+
await store.createNote("first", { path: "l1/Echo" });
|
|
4405
|
+
await store.createNote("second", { path: "l2/Echo" });
|
|
4406
|
+
await store.createNote("see [[Echo]]", { id: "mq-amb-list-a", path: "mq-amb-list-a" });
|
|
4407
|
+
await store.createNote("also [[Echo]]", { id: "mq-amb-list-b", path: "mq-amb-list-b" });
|
|
4408
|
+
await store.createNote("clean", { id: "mq-amb-list-c", path: "mq-amb-list-c" });
|
|
4409
|
+
|
|
4410
|
+
const tools = generateMcpTools(store);
|
|
4411
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4412
|
+
const result = await query.execute({ include_ambiguous_links: true, include_content: true }) as any[];
|
|
4413
|
+
const byId = new Map(result.map((n: any) => [n.id, n.ambiguous_links]));
|
|
4414
|
+
expect(byId.get("mq-amb-list-a")).toEqual([{ target: "Echo", relationship: "wikilink", candidate_count: 2 }]);
|
|
4415
|
+
expect(byId.get("mq-amb-list-b")).toEqual([{ target: "Echo", relationship: "wikilink", candidate_count: 2 }]);
|
|
4416
|
+
expect(byId.get("mq-amb-list-c")).toEqual([]);
|
|
4417
|
+
});
|
|
4418
|
+
|
|
4419
|
+
// ---- self-healing: ambiguity must resolve when it stops being ambiguous ----
|
|
4420
|
+
|
|
4421
|
+
it("deleting one colliding candidate resolves the ambiguity — link created, note drops out of has_ambiguous_links", async () => {
|
|
4422
|
+
const keep = await store.createNote("first", { path: "keep/Heal" });
|
|
4423
|
+
const drop = await store.createNote("second", { path: "drop/Heal" });
|
|
4424
|
+
const src = await store.createNote("see [[Heal]]", { id: "mq-amb-heal", path: "mq-amb-heal" });
|
|
4425
|
+
|
|
4426
|
+
const tools = generateMcpTools(store);
|
|
4427
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4428
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(1);
|
|
4429
|
+
|
|
4430
|
+
await store.deleteNote(drop.id);
|
|
4431
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
|
|
4432
|
+
const links = await query.execute({ id: src.id, include_links: true }) as any;
|
|
4433
|
+
expect(links.links.map((l: any) => l.targetId)).toEqual([keep.id]);
|
|
4434
|
+
});
|
|
4435
|
+
|
|
4436
|
+
it("renaming one colliding candidate out of the way resolves a structured link's ambiguity", async () => {
|
|
4437
|
+
// Structured links (and typed `reference` fields) are the clean rename
|
|
4438
|
+
// case: unlike a content [[wikilink]], nothing rewrites the stored
|
|
4439
|
+
// target string, so the sweep is the only thing that can heal it.
|
|
4440
|
+
const keep = await store.createNote("first", { path: "keep/Fork" });
|
|
4441
|
+
const moved = await store.createNote("second", { path: "move/Fork" });
|
|
4442
|
+
const created = await generateMcpTools(store).find((t) => t.name === "create-note")!.execute({
|
|
4443
|
+
content: "body", path: "mq-amb-rename", links: [{ target: "Fork", relationship: "depends-on" }],
|
|
4444
|
+
}) as any;
|
|
4445
|
+
|
|
4446
|
+
const tools = generateMcpTools(store);
|
|
4447
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4448
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).map((n: any) => n.id)).toEqual([created.id]);
|
|
4449
|
+
|
|
4450
|
+
await store.updateNote(moved.id, { path: "move/Elsewhere" });
|
|
4451
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
|
|
4452
|
+
const links = await query.execute({ id: created.id, include_links: true }) as any;
|
|
4453
|
+
expect(links.links.map((l: any) => [l.targetId, l.relationship])).toEqual([[keep.id, "depends-on"]]);
|
|
4454
|
+
});
|
|
4455
|
+
|
|
4456
|
+
it("renaming a colliding candidate also clears a content wikilink's ambiguity (via cascadeRename)", async () => {
|
|
4457
|
+
// vault#708 — an AMBIGUOUS `[[Rename]]` never pointed at the renamed
|
|
4458
|
+
// note (it pointed at nothing: two candidates, no `links` row), so the
|
|
4459
|
+
// cascade must leave the text alone and let the vault#581 sweep heal it.
|
|
4460
|
+
// With `move/Rename` moved away, `keep/Rename` is the only candidate
|
|
4461
|
+
// left, so the bracket resolves to KEEP. Before vault#708 the cascade
|
|
4462
|
+
// rewrote the text to `[[Elsewhere]]` on a basename match and the link
|
|
4463
|
+
// followed the note that moved away — the bug this pin used to record.
|
|
4464
|
+
const keep = await store.createNote("first", { path: "keep/Rename" });
|
|
4465
|
+
const moved = await store.createNote("second", { path: "move/Rename" });
|
|
4466
|
+
const src = await store.createNote("see [[Rename]]", { id: "mq-amb-rename2", path: "mq-amb-rename2" });
|
|
4467
|
+
|
|
4468
|
+
const tools = generateMcpTools(store);
|
|
4469
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4470
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(1);
|
|
4471
|
+
|
|
4472
|
+
await store.updateNote(moved.id, { path: "move/Elsewhere" });
|
|
4473
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
|
|
4474
|
+
expect((await store.getNote(src.id))!.content).toBe("see [[Rename]]");
|
|
4475
|
+
const links = await query.execute({ id: src.id, include_links: true }) as any;
|
|
4476
|
+
expect(links.links.map((l: any) => l.targetId)).toEqual([keep.id]);
|
|
4477
|
+
});
|
|
4478
|
+
|
|
4479
|
+
// ---- vault#239: the visibility model reaches the BROKEN-links surface ----
|
|
4480
|
+
//
|
|
4481
|
+
// vault#581's auth review made the AMBIGUOUS surface answer on a scoped
|
|
4482
|
+
// reader's own sub-vault (>=2 VISIBLE candidates). The same question has a
|
|
4483
|
+
// second answer core must give: 0 visible candidates means the reference is
|
|
4484
|
+
// BROKEN for that reader — whichever table its row currently lives in.
|
|
4485
|
+
//
|
|
4486
|
+
// Without this, `refreshAmbiguousLinks`' delete-time demotion into
|
|
4487
|
+
// `unresolved_wikilinks` is the ONLY thing that makes such a note "broken",
|
|
4488
|
+
// so the answer flips purely because notes the reader can't see were
|
|
4489
|
+
// deleted. Core stays scope-unaware: it just invokes the injected
|
|
4490
|
+
// `ambiguityVisible` closure, exactly as the ambiguity surface does.
|
|
4491
|
+
|
|
4492
|
+
/** `[[Dup]]` with two candidates, neither of which `visibleIds` contains. */
|
|
4493
|
+
async function hiddenCandidates(prefix: string) {
|
|
4494
|
+
const h1 = await store.createNote("hidden one", { path: `${prefix}-p1/Dup` });
|
|
4495
|
+
const h2 = await store.createNote("hidden two", { path: `${prefix}-p2/Dup` });
|
|
4496
|
+
const src = await store.createNote("see [[Dup]]", { id: `${prefix}-src`, path: `${prefix}-src` });
|
|
4497
|
+
return { h1, h2, src, hidden: new Set([h1.id, h2.id]) };
|
|
4498
|
+
}
|
|
4499
|
+
|
|
4500
|
+
it("include_broken_links reports a target whose only candidates fail the visibility predicate", async () => {
|
|
4501
|
+
const { src, hidden } = await hiddenCandidates("mq-vis-brk");
|
|
4502
|
+
const tools = generateMcpTools(store, { ambiguityVisible: (id: string) => !hidden.has(id) });
|
|
4503
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4504
|
+
|
|
4505
|
+
const result = await query.execute({ id: src.id, include_broken_links: true }) as any;
|
|
4506
|
+
expect(result.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
|
|
4507
|
+
// ...and it is NOT also reported as ambiguous (the surfaces stay disjoint).
|
|
4508
|
+
expect((await query.execute({ id: src.id, include_ambiguous_links: true }) as any).ambiguous_links)
|
|
4509
|
+
.toEqual([]);
|
|
4510
|
+
});
|
|
4511
|
+
|
|
4512
|
+
it("has_broken_links answers on the visible sub-vault and does not move when a hidden candidate is deleted", async () => {
|
|
4513
|
+
const { src, h1, h2, hidden } = await hiddenCandidates("mq-vis-heal");
|
|
4514
|
+
const tools = generateMcpTools(store, { ambiguityVisible: (id: string) => !hidden.has(id) });
|
|
4515
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4516
|
+
const snapshot = async () => ({
|
|
4517
|
+
truthy: ((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id),
|
|
4518
|
+
falsy: ((await query.execute({ has_broken_links: false })) as any[]).map((n: any) => n.id),
|
|
4519
|
+
broken: (await query.execute({ id: src.id, include_broken_links: true }) as any).broken_links,
|
|
4520
|
+
});
|
|
4521
|
+
|
|
4522
|
+
const before = await snapshot();
|
|
4523
|
+
expect(before.truthy).toContain(src.id);
|
|
4524
|
+
expect(before.falsy).not.toContain(src.id);
|
|
4525
|
+
|
|
4526
|
+
await store.deleteNote(h1.id);
|
|
4527
|
+
await store.deleteNote(h2.id);
|
|
4528
|
+
|
|
4529
|
+
const after = await snapshot();
|
|
4530
|
+
expect(after.truthy).toContain(src.id);
|
|
4531
|
+
expect(after.falsy).not.toContain(src.id);
|
|
4532
|
+
expect(after.broken).toEqual(before.broken);
|
|
4533
|
+
});
|
|
4534
|
+
|
|
4535
|
+
it("a target with ONE visible candidate is neither broken nor ambiguous (negative control)", async () => {
|
|
4536
|
+
// Guards against a fix that just reports every ambiguous row as broken:
|
|
4537
|
+
// one candidate survives the predicate, so the reference RESOLVES in the
|
|
4538
|
+
// reader's sub-vault.
|
|
4539
|
+
const keep = await store.createNote("visible", { path: "mq-vis-one-a/Dup" });
|
|
4540
|
+
const hide = await store.createNote("hidden", { path: "mq-vis-one-b/Dup" });
|
|
4541
|
+
const src = await store.createNote("see [[Dup]]", { id: "mq-vis-one-src", path: "mq-vis-one-src" });
|
|
4542
|
+
|
|
4543
|
+
const tools = generateMcpTools(store, { ambiguityVisible: (id: string) => id !== hide.id });
|
|
4544
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4545
|
+
const result = await query.execute({
|
|
4546
|
+
id: src.id, include_broken_links: true, include_ambiguous_links: true,
|
|
4547
|
+
}) as any;
|
|
4548
|
+
expect(result.broken_links).toEqual([]);
|
|
4549
|
+
expect(result.ambiguous_links).toEqual([]);
|
|
4550
|
+
expect(((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id))
|
|
4551
|
+
.not.toContain(src.id);
|
|
4552
|
+
expect(keep.id).toBeTruthy();
|
|
4553
|
+
});
|
|
4554
|
+
|
|
4555
|
+
it("a STALE ambiguous row that resolves cleanly again is not reported broken", async () => {
|
|
4556
|
+
// A `WikilinkResolution` that RESOLVED carries an empty `candidates`
|
|
4557
|
+
// array, which a naive "count the visible candidates" check would read
|
|
4558
|
+
// as 0 == broken. The row goes stale through the documented vault#581
|
|
4559
|
+
// gap: ambiguity via the H1-TITLE fallback, cleared by editing one
|
|
4560
|
+
// title, which does not re-run the sweep (only a path change does).
|
|
4561
|
+
await store.createNote("# Twin\n\nfirst", { id: "mq-vis-stale-a", path: "mq-vis-stale-a" });
|
|
4562
|
+
const b = await store.createNote("# Twin\n\nsecond", { id: "mq-vis-stale-b", path: "mq-vis-stale-b" });
|
|
4563
|
+
const src = await store.createNote("see [[Twin]]", { id: "mq-vis-stale-src", path: "mq-vis-stale-src" });
|
|
4564
|
+
|
|
4565
|
+
const unscoped = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
4566
|
+
expect((await unscoped.execute({ id: src.id, include_ambiguous_links: true }) as any).ambiguous_links)
|
|
4567
|
+
.toEqual([{ target: "Twin", relationship: "wikilink", candidate_count: 2 }]);
|
|
4568
|
+
|
|
4569
|
+
await store.updateNote(b.id, { content: "# Other\n\nsecond" });
|
|
4570
|
+
|
|
4571
|
+
// Row still says "ambiguous"; the target now resolves to the ONE
|
|
4572
|
+
// remaining, visible note — so neither surface may report it.
|
|
4573
|
+
const query = generateMcpTools(store, { ambiguityVisible: () => true })
|
|
4574
|
+
.find((t) => t.name === "query-notes")!;
|
|
4575
|
+
const result = await query.execute({
|
|
4576
|
+
id: src.id, include_broken_links: true, include_ambiguous_links: true,
|
|
4577
|
+
}) as any;
|
|
4578
|
+
expect(result.broken_links).toEqual([]);
|
|
4579
|
+
expect(result.ambiguous_links).toEqual([]);
|
|
4580
|
+
});
|
|
4581
|
+
|
|
4582
|
+
it("NO predicate injected → broken-links answers from the persisted tables only (regression)", async () => {
|
|
4583
|
+
const { src, h1, h2 } = await hiddenCandidates("mq-vis-unscoped");
|
|
4584
|
+
const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
4585
|
+
const read = async () => await query.execute({
|
|
4586
|
+
id: src.id, include_broken_links: true, include_ambiguous_links: true,
|
|
4587
|
+
}) as any;
|
|
4588
|
+
|
|
4589
|
+
const before = await read();
|
|
4590
|
+
expect(before.broken_links).toEqual([]);
|
|
4591
|
+
expect(before.ambiguous_links)
|
|
4592
|
+
.toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
|
|
4593
|
+
expect(((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id))
|
|
4594
|
+
.not.toContain(src.id);
|
|
4595
|
+
|
|
4596
|
+
await store.deleteNote(h1.id);
|
|
4597
|
+
await store.deleteNote(h2.id);
|
|
4598
|
+
|
|
4599
|
+
const after = await read();
|
|
4600
|
+
expect(after.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
|
|
4601
|
+
expect(after.ambiguous_links).toEqual([]);
|
|
4602
|
+
});
|
|
4603
|
+
|
|
4604
|
+
// ---- vault#708: the rename cascade follows resolution, not basenames ----
|
|
4605
|
+
|
|
4606
|
+
it("cascadeRename leaves an unambiguous full-path bracket aimed at the OTHER same-named note alone", async () => {
|
|
4607
|
+
const keep = await store.createNote("first", { path: "keep/Rename" });
|
|
4608
|
+
const moved = await store.createNote("second", { path: "move/Rename" });
|
|
4609
|
+
const src = await store.createNote("see [[keep/Rename]]", { id: "c708-other", path: "c708-other" });
|
|
4610
|
+
|
|
4611
|
+
await store.updateNote(moved.id, { path: "move/Elsewhere" });
|
|
4612
|
+
|
|
4613
|
+
expect((await store.getNote(src.id))!.content).toBe("see [[keep/Rename]]");
|
|
4614
|
+
const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
4615
|
+
const links = await query.execute({ id: src.id, include_links: true }) as any;
|
|
4616
|
+
expect(links.links.map((l: any) => l.targetId)).toEqual([keep.id]);
|
|
4617
|
+
});
|
|
4618
|
+
|
|
4619
|
+
it("cascadeRename follows an unambiguous BASENAME bracket and keeps it a basename", async () => {
|
|
4620
|
+
const moved = await store.createNote("only one", { path: "move/Rename" });
|
|
4621
|
+
const src = await store.createNote("see [[Rename]]", { id: "c708-basename", path: "c708-basename" });
|
|
4622
|
+
|
|
4623
|
+
await store.updateNote(moved.id, { path: "move/Elsewhere" });
|
|
4624
|
+
|
|
4625
|
+
expect((await store.getNote(src.id))!.content).toBe("see [[Elsewhere]]");
|
|
4626
|
+
const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
4627
|
+
const links = await query.execute({ id: src.id, include_links: true }) as any;
|
|
4628
|
+
expect(links.links.map((l: any) => l.targetId)).toEqual([moved.id]);
|
|
4629
|
+
});
|
|
4630
|
+
|
|
4631
|
+
it("cascadeRename widens a basename bracket to the full path when the NEW basename is ambiguous", async () => {
|
|
4632
|
+
const moved = await store.createNote("first", { path: "a/Foo" });
|
|
4633
|
+
await store.createNote("collides with the new name", { path: "b/Bar" });
|
|
4634
|
+
const src = await store.createNote("see [[Foo]]", { id: "c708-widen", path: "c708-widen" });
|
|
4635
|
+
|
|
4636
|
+
await store.updateNote(moved.id, { path: "a/Bar" });
|
|
4637
|
+
|
|
4638
|
+
// `[[Bar]]` would now match two notes, so the bracket widens rather than
|
|
4639
|
+
// becoming ambiguous.
|
|
4640
|
+
expect((await store.getNote(src.id))!.content).toBe("see [[a/Bar]]");
|
|
4641
|
+
const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
4642
|
+
const links = await query.execute({ id: src.id, include_links: true }) as any;
|
|
4643
|
+
expect(links.links.map((l: any) => l.targetId)).toEqual([moved.id]);
|
|
4644
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
|
|
4645
|
+
});
|
|
4646
|
+
|
|
4647
|
+
it("cascadeRename rewrites alias / anchor / block-ref / embed / full-path / .ext forms, and skips code fences", async () => {
|
|
4648
|
+
const moved = await store.createNote("only one", { path: "move/Rename" });
|
|
4649
|
+
const src = await store.createNote(
|
|
4650
|
+
"a [[Rename|shown]] b [[Rename#Heading]] c [[Rename#^blk]] d [[move/Rename]] e [[move/Rename.md]] f ![[Rename]]\n\n```\ng [[Rename]]\n```\n",
|
|
4651
|
+
{ id: "c708-forms", path: "c708-forms" },
|
|
4652
|
+
);
|
|
4653
|
+
|
|
4654
|
+
await store.updateNote(moved.id, { path: "move/Elsewhere" });
|
|
4655
|
+
|
|
4656
|
+
expect((await store.getNote(src.id))!.content).toBe(
|
|
4657
|
+
"a [[Elsewhere|shown]] b [[Elsewhere#Heading]] c [[Elsewhere#^blk]] d [[move/Elsewhere]] e [[move/Elsewhere.md]] f ![[Elsewhere]]\n\n```\ng [[Rename]]\n```\n",
|
|
4658
|
+
);
|
|
4659
|
+
});
|
|
4660
|
+
|
|
4661
|
+
it("cascadeRename rewrites an explicit-extension bracket on a non-md note", async () => {
|
|
4662
|
+
const moved = await store.createNote("csv", { path: "move/Data", extension: "csv" });
|
|
4663
|
+
const src = await store.createNote("see [[move/Data.csv]]", { id: "c708-ext", path: "c708-ext" });
|
|
4664
|
+
|
|
4665
|
+
await store.updateNote(moved.id, { path: "move/Info" });
|
|
4666
|
+
|
|
4667
|
+
expect((await store.getNote(src.id))!.content).toBe("see [[move/Info.csv]]");
|
|
4668
|
+
const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
4669
|
+
const links = await query.execute({ id: src.id, include_links: true }) as any;
|
|
4670
|
+
expect(links.links.map((l: any) => l.targetId)).toEqual([moved.id]);
|
|
4671
|
+
});
|
|
4672
|
+
|
|
4673
|
+
it("cascadeRename rewrites the renamed note's OWN self-referencing bracket", async () => {
|
|
4674
|
+
// Self-links are deliberately never given a `links` row, so the note
|
|
4675
|
+
// itself has to be seeded into the cascade's source set.
|
|
4676
|
+
const moved = await store.createNote("about [[move/Rename]] itself", { path: "move/Rename" });
|
|
4677
|
+
await store.updateNote(moved.id, { path: "move/Elsewhere" });
|
|
4678
|
+
expect((await store.getNote(moved.id))!.content).toBe("about [[move/Elsewhere]] itself");
|
|
4679
|
+
});
|
|
4680
|
+
|
|
4681
|
+
it("cascadeRename leaves a bracket that resolved through the H1-title fallback alone", async () => {
|
|
4682
|
+
// A title-fallback link doesn't depend on the path, so a repath must not
|
|
4683
|
+
// touch it — and it keeps resolving afterwards.
|
|
4684
|
+
const moved = await store.createNote("# Distinct Title\n\nbody", { path: "move/Rename" });
|
|
4685
|
+
const src = await store.createNote("see [[Distinct Title]]", { id: "c708-title", path: "c708-title" });
|
|
4686
|
+
|
|
4687
|
+
await store.updateNote(moved.id, { path: "move/Elsewhere" });
|
|
4688
|
+
|
|
4689
|
+
expect((await store.getNote(src.id))!.content).toBe("see [[Distinct Title]]");
|
|
4690
|
+
const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
4691
|
+
const links = await query.execute({ id: src.id, include_links: true }) as any;
|
|
4692
|
+
expect(links.links.map((l: any) => l.targetId)).toEqual([moved.id]);
|
|
4693
|
+
});
|
|
4694
|
+
|
|
4695
|
+
it("deleting BOTH colliding candidates turns the ambiguous link into a broken one", async () => {
|
|
4696
|
+
const a = await store.createNote("first", { path: "d1/Gone" });
|
|
4697
|
+
const b = await store.createNote("second", { path: "d2/Gone" });
|
|
4698
|
+
await store.createNote("see [[Gone]]", { id: "mq-amb-to-broken", path: "mq-amb-to-broken" });
|
|
4699
|
+
|
|
4700
|
+
const tools = generateMcpTools(store);
|
|
4701
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4702
|
+
await store.deleteNote(a.id);
|
|
4703
|
+
await store.deleteNote(b.id);
|
|
4704
|
+
|
|
4705
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
|
|
4706
|
+
expect((await query.execute({ has_broken_links: true }) as any[]).map((n: any) => n.id)).toEqual(["mq-amb-to-broken"]);
|
|
4707
|
+
const single = await query.execute({ id: "mq-amb-to-broken", include_broken_links: true }) as any;
|
|
4708
|
+
expect(single.broken_links).toEqual([{ target: "Gone", relationship: "wikilink" }]);
|
|
4709
|
+
});
|
|
4710
|
+
|
|
4711
|
+
it("editing the wikilink out of the content clears the persisted ambiguity", async () => {
|
|
4712
|
+
await store.createNote("first", { path: "e1/Vanish" });
|
|
4713
|
+
await store.createNote("second", { path: "e2/Vanish" });
|
|
4714
|
+
const src = await store.createNote("see [[Vanish]]", { id: "mq-amb-edit", path: "mq-amb-edit" });
|
|
4715
|
+
|
|
4716
|
+
const tools = generateMcpTools(store);
|
|
4717
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4718
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(1);
|
|
4719
|
+
|
|
4720
|
+
await store.updateNote(src.id, { content: "no links now" });
|
|
4721
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
|
|
4722
|
+
});
|
|
4723
|
+
|
|
4724
|
+
it("deleting the SOURCE note cascades its ambiguous rows away", async () => {
|
|
4725
|
+
await store.createNote("first", { path: "c1/Casc" });
|
|
4726
|
+
await store.createNote("second", { path: "c2/Casc" });
|
|
4727
|
+
const src = await store.createNote("see [[Casc]]", { id: "mq-amb-cascade", path: "mq-amb-cascade" });
|
|
4728
|
+
|
|
4729
|
+
const tools = generateMcpTools(store);
|
|
4730
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4731
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(1);
|
|
4732
|
+
await store.deleteNote(src.id);
|
|
4733
|
+
expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
|
|
4734
|
+
});
|
|
4735
|
+
|
|
4736
|
+
it("a THIRD colliding note bumps the persisted candidate_count", async () => {
|
|
4737
|
+
await store.createNote("first", { path: "t1/Trio" });
|
|
4738
|
+
await store.createNote("second", { path: "t2/Trio" });
|
|
4739
|
+
await store.createNote("see [[Trio]]", { id: "mq-amb-count", path: "mq-amb-count" });
|
|
4740
|
+
|
|
4741
|
+
const tools = generateMcpTools(store);
|
|
4742
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4743
|
+
let single = await query.execute({ id: "mq-amb-count", include_ambiguous_links: true }) as any;
|
|
4744
|
+
expect(single.ambiguous_links).toEqual([{ target: "Trio", relationship: "wikilink", candidate_count: 2 }]);
|
|
4745
|
+
|
|
4746
|
+
await store.createNote("third", { path: "t3/Trio" });
|
|
4747
|
+
single = await query.execute({ id: "mq-amb-count", include_ambiguous_links: true }) as any;
|
|
4748
|
+
expect(single.ambiguous_links).toEqual([{ target: "Trio", relationship: "wikilink", candidate_count: 3 }]);
|
|
4749
|
+
});
|
|
4750
|
+
|
|
4751
|
+
it("has_ambiguous_links is bound into the cursor query-hash", async () => {
|
|
4752
|
+
await store.createNote("first", { path: "cur1/Hash" });
|
|
4753
|
+
await store.createNote("second", { path: "cur2/Hash" });
|
|
4754
|
+
await store.createNote("see [[Hash]]", { id: "mq-amb-cursor", path: "mq-amb-cursor" });
|
|
4755
|
+
|
|
4756
|
+
const tools = generateMcpTools(store);
|
|
4757
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4758
|
+
const page = await query.execute({ cursor: "", limit: 1, has_ambiguous_links: true }) as any;
|
|
4759
|
+
expect(page.notes.map((n: any) => n.id)).toEqual(["mq-amb-cursor"]);
|
|
4760
|
+
// Flipping the filter must invalidate the cursor rather than silently
|
|
4761
|
+
// continuing the prior filter's watermark.
|
|
4762
|
+
expect(query.execute({ cursor: page.next_cursor, limit: 1, has_ambiguous_links: false }))
|
|
4763
|
+
.rejects.toThrow(/cursor was minted for a different query/);
|
|
4764
|
+
});
|
|
4765
|
+
|
|
4238
4766
|
it("query-notes metadata operator query routes through the indexed column", async () => {
|
|
4239
4767
|
const { declareField } = await import("./indexed-fields.js");
|
|
4240
4768
|
declareField(db, "priority", "INTEGER", "project");
|
|
@@ -8817,6 +9345,24 @@ describe("vault projection (vault#271)", async () => {
|
|
|
8817
9345
|
expect(md).toContain("Querying");
|
|
8818
9346
|
});
|
|
8819
9347
|
|
|
9348
|
+
it("markdown brief skips a non-string description instead of throwing (vault#669 recovery)", async () => {
|
|
9349
|
+
const { buildVaultProjection, projectionToMarkdown } = await import(
|
|
9350
|
+
"./vault-projection.ts"
|
|
9351
|
+
);
|
|
9352
|
+
|
|
9353
|
+
const projection = buildVaultProjection(db, { includeStats: true });
|
|
9354
|
+
const md = projectionToMarkdown({
|
|
9355
|
+
vaultName: "poisoned",
|
|
9356
|
+
// A write door that skipped the type guard used to persist a number
|
|
9357
|
+
// and take down initialize via `description.trim is not a function`.
|
|
9358
|
+
description: 123 as unknown as string,
|
|
9359
|
+
projection,
|
|
9360
|
+
});
|
|
9361
|
+
|
|
9362
|
+
expect(md).toContain('Parachute Vault "poisoned"');
|
|
9363
|
+
expect(md).not.toContain("123");
|
|
9364
|
+
});
|
|
9365
|
+
|
|
8820
9366
|
it("markdown brief stays under ~5K tokens for a 50-tags-with-schemas vault", async () => {
|
|
8821
9367
|
const { buildVaultProjection, projectionToMarkdown } = await import(
|
|
8822
9368
|
"./vault-projection.ts"
|
package/core/src/cursor.ts
CHANGED
package/core/src/mcp-manifest.ts
CHANGED
|
@@ -65,6 +65,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
65
65
|
|
|
66
66
|
Broken links (vault#555): a \`[[wikilink]]\` or structured \`links\` target that never resolved to a note used to be invisible — silently dropped from the response with no signal it existed. Pass \`has_broken_links: true\`/\`false\` to filter notes by whether they have any dangling outbound link, and/or \`include_broken_links: true\` to attach each note's pending targets as \`broken_links: [{target, relationship}]\` (empty array when none). Both read the vault's pending-resolution table — the same source \`create-note\`/\`update-note\`'s \`unresolved_link\` warning draws from; a target created later (this session or any future one) backfills the edge automatically and the note drops out of \`has_broken_links: true\`.
|
|
67
67
|
|
|
68
|
+
Ambiguous links (vault#581): the twin of the above, for a target that matched TOO MANY notes rather than none — \`[[Dup]]\` when two notes share that basename or H1 title. No link is created and none is guessed at; before #581 that was visible only in the write-time \`ambiguous_link\` warning, so a later audit couldn't find it. Pass \`has_ambiguous_links: true\`/\`false\` to filter, and/or \`include_ambiguous_links: true\` to attach \`ambiguous_links: [{target, relationship, candidate_count}]\` (empty array when none). Disjoint from \`has_broken_links\`: dangling = matched nothing, ambiguous = matched several. The record is persisted, so it survives restarts — and it self-heals: delete or rename one of the colliding notes and the link resolves for real (the note drops out of \`has_ambiguous_links: true\`); delete them ALL and it demotes to an ordinary broken link. (For a tag-scoped session that demotion has already happened at read time — see \`has_broken_links\`.)
|
|
69
|
+
|
|
68
70
|
Response shape (vault#550 — three variants, pick by what you passed):
|
|
69
71
|
- Default (no \`cursor\`, no warnings): a bare array of notes.
|
|
70
72
|
- Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
|
|
@@ -120,7 +122,8 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
120
122
|
},
|
|
121
123
|
has_tags: { type: "boolean", description: "Presence filter: true = only notes with at least one tag; false = only untagged notes. Ignored when `tag` is set." },
|
|
122
124
|
has_links: { type: "boolean", description: "Presence filter: true = only notes with at least one inbound or outbound link; false = only orphaned notes (no links in either direction)." },
|
|
123
|
-
has_broken_links: { type: "boolean", description: "Presence filter (vault#555): true = only notes with at least one dangling outbound link — a [[wikilink]] or structured `links` target that never resolved to a note; false = only notes with none. Backed by the unresolved_wikilinks table (same data `doctor`/list-unresolved surfaces); safe on a vault where no link has ever gone unresolved (true matches nothing, false is a no-op)." },
|
|
125
|
+
has_broken_links: { type: "boolean", description: "Presence filter (vault#555): true = only notes with at least one dangling outbound link — a [[wikilink]] or structured `links` target that never resolved to a note; false = only notes with none. Backed by the unresolved_wikilinks table (same data `doctor`/list-unresolved surfaces); safe on a vault where no link has ever gone unresolved (true matches nothing, false is a no-op). For a TAG-SCOPED session both polarities are answered on the notes the session can see (vault#239): a target whose candidates are ALL out of scope matches nothing in that session's sub-vault, so it counts as broken there even though the vault-wide record calls it ambiguous — otherwise the note would only become broken once the last invisible candidate was deleted. Decided after the page is drawn, so a scoped page may come back shorter than `limit` while more results remain." },
|
|
126
|
+
has_ambiguous_links: { type: "boolean", description: "Presence filter (vault#581): true = only notes with at least one AMBIGUOUS outbound link — a [[wikilink]] or structured `links` target that matched TWO OR MORE notes, so no link was created and none was guessed at; false = only notes with none. Disjoint from `has_broken_links` (dangling = matched nothing; ambiguous = matched too much). Backed by the ambiguous_wikilinks table — the same source `create-note`/`update-note`'s `ambiguous_link` warning draws from; safe on a vault where no link has ever been ambiguous (true matches nothing, false is a no-op). For a TAG-SCOPED session both polarities are answered on the notes the session can see — a collision between a visible and an invisible note is neither reported by `true` nor excluded by `false` — so a scoped page may come back shorter than `limit` while more results remain." },
|
|
124
127
|
path: { type: "string", description: "Exact path match (case-insensitive)" },
|
|
125
128
|
path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
|
|
126
129
|
exclude_path_prefix: {
|
|
@@ -171,8 +174,8 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
171
174
|
},
|
|
172
175
|
created_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose FIRST write was attributed to this principal (a JWT subject, or an operator/token label). Exact match; indexed. Legacy/unattributed notes (NULL) never match." },
|
|
173
176
|
last_updated_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write was attributed to this principal. Exact match; indexed." },
|
|
174
|
-
created_via: { type: "string", description: "Write-attribution filter (vault#298): only notes FIRST written through this interface/channel — e.g. `mcp`, `surface:<name>`, `agent:<id>`, `operator`, `api`. Exact match; indexed." },
|
|
175
|
-
last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel. Exact match; indexed." },
|
|
177
|
+
created_via: { type: "string", description: "Write-attribution filter (vault#298): only notes FIRST written through this interface/channel — e.g. `mcp`, `surface:<name>`, `agent:<id>`, `nostr:<64-hex-pubkey>`, `operator`, `api`. `nostr:<pubkey>` (vault#698) is the Nostr key that SIGNED the request, and is the axis that tells two agents apart when they share one hub user (`created_by`). Emitted by BOTH doors — self-hosted hub (parachute-hub#937) and cloud (parachute-cloud#277). Exact match; indexed." },
|
|
178
|
+
last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel — same vocabulary as `created_via`, including `nostr:<64-hex-pubkey>` for the signing key. Exact match; indexed." },
|
|
176
179
|
order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Two special values need no declaration: `link_count` sorts by link DEGREE (both-directions raw row count), matching the `include_link_count` field for every note; `updated_at` (vault#585) sorts on the integer `updated_at_ms` mirror column — correct on non-canonical/imported timestamps — with `id` as the tiebreaker. Direction is taken from `sort` (default 'asc'); for other fields `created_at` is appended as a stable tiebreaker." },
|
|
177
180
|
date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
|
|
178
181
|
date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
|
|
@@ -237,7 +240,8 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
237
240
|
description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
|
|
238
241
|
},
|
|
239
242
|
include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
|
|
240
|
-
include_broken_links: { type: "boolean", description: "Include each note's dangling outbound links as `broken_links: [{target, relationship}]` (default: false; vault#555). `target` is the unresolved path/title the [[wikilink]] or structured `links` entry named; `relationship` is \"wikilink\" for content-parsed links or the caller's own relationship string for a structured link. Empty array when the note has none. One batched query per request regardless of page size — mirrors `has_broken_links` (same backing table) and `include_links`." },
|
|
243
|
+
include_broken_links: { type: "boolean", description: "Include each note's dangling outbound links as `broken_links: [{target, relationship}]` (default: false; vault#555). `target` is the unresolved path/title the [[wikilink]] or structured `links` entry named; `relationship` is \"wikilink\" for content-parsed links or the caller's own relationship string for a structured link. Empty array when the note has none. One batched query per request regardless of page size — mirrors `has_broken_links` (same backing table) and `include_links`. For a TAG-SCOPED session this also lists a target whose candidates are all out of scope, which is broken in that session's sub-vault (vault#239)." },
|
|
244
|
+
include_ambiguous_links: { type: "boolean", description: "Include each note's ambiguous outbound links as `ambiguous_links: [{target, relationship, candidate_count}]` (default: false; vault#581). `target` is the path/title the [[wikilink]] or structured `links` entry named; `relationship` is \"wikilink\" for content-parsed links or the caller's own relationship string; `candidate_count` is how many notes it matched. For a TAG-SCOPED session the candidates are re-counted within the session's scope, and a target with fewer than two visible candidates is omitted — a scoped reader gets exactly what an unscoped one would get on a vault holding only the notes it can see. Empty array when the note has none. One batched query per request regardless of page size — mirrors `has_ambiguous_links` (same backing table) and `include_broken_links`." },
|
|
241
245
|
include_link_count: {
|
|
242
246
|
type: "boolean",
|
|
243
247
|
description:
|
|
@@ -341,7 +345,7 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
341
345
|
- **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
|
|
342
346
|
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`, \`displayTitle\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present. \`displayTitle\` is the note's first non-empty content line (heading markers stripped, ~120 chars max), \`null\` when content is empty — never stored, computed fresh from content already in hand.
|
|
343
347
|
|
|
344
|
-
Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via
|
|
348
|
+
Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via\`. When the write was signed with a Nostr key (the hub's NIP-98 door), the \`*Via\` value is \`nostr:<64-hex-pubkey>\` — \`createdBy\` stays the hub USER, so the pubkey is what distinguishes two agents sharing that user.`,
|
|
345
349
|
inputSchema: {
|
|
346
350
|
type: "object",
|
|
347
351
|
properties: {
|