@disco_trooper/apple-notes-mcp 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +104 -24
  2. package/package.json +11 -12
  3. package/src/config/claude.test.ts +47 -0
  4. package/src/config/claude.ts +106 -0
  5. package/src/config/constants.ts +11 -2
  6. package/src/config/paths.test.ts +40 -0
  7. package/src/config/paths.ts +86 -0
  8. package/src/db/arrow-fix.test.ts +101 -0
  9. package/src/db/lancedb.test.ts +254 -2
  10. package/src/db/lancedb.ts +385 -38
  11. package/src/embeddings/cache.test.ts +150 -0
  12. package/src/embeddings/cache.ts +204 -0
  13. package/src/embeddings/index.ts +22 -4
  14. package/src/embeddings/local.ts +57 -17
  15. package/src/embeddings/openrouter.ts +233 -11
  16. package/src/errors/index.test.ts +64 -0
  17. package/src/errors/index.ts +62 -0
  18. package/src/graph/export.test.ts +81 -0
  19. package/src/graph/export.ts +163 -0
  20. package/src/graph/extract.test.ts +90 -0
  21. package/src/graph/extract.ts +52 -0
  22. package/src/graph/queries.test.ts +156 -0
  23. package/src/graph/queries.ts +224 -0
  24. package/src/index.ts +309 -23
  25. package/src/notes/conversion.ts +62 -0
  26. package/src/notes/crud.test.ts +41 -8
  27. package/src/notes/crud.ts +75 -64
  28. package/src/notes/read.test.ts +58 -3
  29. package/src/notes/read.ts +142 -210
  30. package/src/notes/resolve.ts +174 -0
  31. package/src/notes/tables.ts +69 -40
  32. package/src/search/chunk-indexer.test.ts +353 -0
  33. package/src/search/chunk-indexer.ts +207 -0
  34. package/src/search/chunk-search.test.ts +327 -0
  35. package/src/search/chunk-search.ts +298 -0
  36. package/src/search/index.ts +4 -6
  37. package/src/search/indexer.ts +164 -109
  38. package/src/setup.ts +46 -67
  39. package/src/types/index.ts +4 -0
  40. package/src/utils/chunker.test.ts +182 -0
  41. package/src/utils/chunker.ts +170 -0
  42. package/src/utils/content-filter.test.ts +225 -0
  43. package/src/utils/content-filter.ts +275 -0
  44. package/src/utils/debug.ts +0 -2
  45. package/src/utils/runtime.test.ts +70 -0
  46. package/src/utils/runtime.ts +40 -0
  47. package/src/utils/text.test.ts +32 -0
  48. package/CLAUDE.md +0 -56
  49. package/src/server.ts +0 -427
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Typed error classes for better error handling.
3
+ */
4
+
5
+ export class NoteNotFoundError extends Error {
6
+ readonly title: string;
7
+
8
+ constructor(title: string) {
9
+ super(`Note not found: "${title}"`);
10
+ this.name = "NoteNotFoundError";
11
+ this.title = title;
12
+ }
13
+ }
14
+
15
+ export class ReadOnlyModeError extends Error {
16
+ constructor() {
17
+ super("Operation disabled in read-only mode");
18
+ this.name = "ReadOnlyModeError";
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Rich suggestion for duplicate note disambiguation.
24
+ */
25
+ export interface NoteSuggestion {
26
+ id: string;
27
+ folder: string;
28
+ title: string;
29
+ created: string;
30
+ }
31
+
32
+ export class DuplicateNoteError extends Error {
33
+ readonly title: string;
34
+ readonly suggestions: NoteSuggestion[];
35
+
36
+ constructor(title: string, suggestions: NoteSuggestion[]) {
37
+ const suggestionList = suggestions
38
+ .map(s => `id:${s.id} (${s.folder}, created: ${s.created.split("T")[0]})`)
39
+ .join("\n - ");
40
+ super(`Multiple notes found with title "${title}". Use ID prefix:\n - ${suggestionList}`);
41
+ this.name = "DuplicateNoteError";
42
+ this.title = title;
43
+ this.suggestions = suggestions;
44
+ }
45
+ }
46
+
47
+ export class FolderNotFoundError extends Error {
48
+ readonly folder: string;
49
+
50
+ constructor(folder: string) {
51
+ super(`Folder not found: "${folder}"`);
52
+ this.name = "FolderNotFoundError";
53
+ this.folder = folder;
54
+ }
55
+ }
56
+
57
+ export class TableOutOfBoundsError extends Error {
58
+ constructor(message: string) {
59
+ super(message);
60
+ this.name = "TableOutOfBoundsError";
61
+ }
62
+ }
@@ -0,0 +1,81 @@
1
+ // src/graph/export.test.ts
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ import { exportGraph } from "./export.js";
4
+
5
+ // Create a shared mock store instance
6
+ const mockStore = {
7
+ getAll: vi.fn(),
8
+ };
9
+
10
+ vi.mock("../db/lancedb.js", () => ({
11
+ getVectorStore: vi.fn(() => mockStore),
12
+ }));
13
+
14
+ describe("exportGraph", () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+
19
+ describe("JSON format", () => {
20
+ it("exports nodes and edges", async () => {
21
+ mockStore.getAll.mockResolvedValue([
22
+ { id: "1", title: "Note A", folder: "Work", tags: ["project"], outlinks: ["Note B"], vector: [1,0] },
23
+ { id: "2", title: "Note B", folder: "Work", tags: ["project"], outlinks: [], vector: [0,1] },
24
+ ]);
25
+
26
+ const result = await exportGraph({ format: "json" }) as any;
27
+
28
+ expect(result).toHaveProperty("nodes");
29
+ expect(result).toHaveProperty("edges");
30
+ expect(result.nodes).toHaveLength(2);
31
+ expect(result.edges.some((e: any) => e.type === "link")).toBe(true);
32
+ expect(result.edges.some((e: any) => e.type === "tag")).toBe(true);
33
+ });
34
+
35
+ it("filters by folder", async () => {
36
+ mockStore.getAll.mockResolvedValue([
37
+ { id: "1", title: "Note A", folder: "Work", tags: [], outlinks: [], vector: [] },
38
+ { id: "2", title: "Note B", folder: "Personal", tags: [], outlinks: [], vector: [] },
39
+ ]);
40
+
41
+ const result = await exportGraph({ format: "json", folder: "Work" }) as any;
42
+
43
+ expect(result.nodes).toHaveLength(1);
44
+ expect(result.nodes[0].folder).toBe("Work");
45
+ });
46
+ });
47
+
48
+ describe("GraphML format", () => {
49
+ it("exports valid GraphML XML", async () => {
50
+ mockStore.getAll.mockResolvedValue([
51
+ { id: "1", title: "Note A", folder: "Work", tags: [], outlinks: ["Note B"], vector: [] },
52
+ { id: "2", title: "Note B", folder: "Work", tags: [], outlinks: [], vector: [] },
53
+ ]);
54
+
55
+ const result = await exportGraph({ format: "graphml" });
56
+
57
+ expect(typeof result).toBe("string");
58
+ expect(result).toContain('<?xml version="1.0"');
59
+ expect(result).toContain("<graphml");
60
+ expect(result).toContain("<node");
61
+ expect(result).toContain("<edge");
62
+ expect(result).toContain("</graphml>");
63
+ });
64
+
65
+ it("escapes special XML characters in GraphML", async () => {
66
+ mockStore.getAll.mockResolvedValue([
67
+ { id: "1", title: 'Note <with> & "special"', folder: "Work", tags: [], outlinks: [], vector: [] },
68
+ ]);
69
+ const result = await exportGraph({ format: "graphml" }) as string;
70
+ expect(result).toContain("&lt;with&gt;");
71
+ expect(result).toContain("&amp;");
72
+ });
73
+ });
74
+
75
+ describe("Unknown format", () => {
76
+ it("throws for unknown format", async () => {
77
+ mockStore.getAll.mockResolvedValue([]);
78
+ await expect(exportGraph({ format: "unknown" as any })).rejects.toThrow("Unknown format");
79
+ });
80
+ });
81
+ });
@@ -0,0 +1,163 @@
1
+ // src/graph/export.ts
2
+ /**
3
+ * Knowledge graph export to various formats.
4
+ */
5
+
6
+ import { getVectorStore } from "../db/lancedb.js";
7
+ import { createDebugLogger } from "../utils/debug.js";
8
+ import { GRAPH_LINK_WEIGHT, GRAPH_TAG_WEIGHT } from "../config/constants.js";
9
+
10
+ const debug = createDebugLogger("EXPORT");
11
+
12
+ export type GraphFormat = "json" | "graphml";
13
+
14
+ export interface GraphNode {
15
+ id: string;
16
+ label: string;
17
+ folder: string;
18
+ tags: string[];
19
+ }
20
+
21
+ export interface GraphEdge {
22
+ source: string;
23
+ target: string;
24
+ type: "link" | "tag" | "similar";
25
+ weight: number;
26
+ }
27
+
28
+ export interface GraphData {
29
+ nodes: GraphNode[];
30
+ edges: GraphEdge[];
31
+ }
32
+
33
+ export interface ExportOptions {
34
+ format: GraphFormat;
35
+ folder?: string;
36
+ }
37
+
38
+ /**
39
+ * Export knowledge graph to specified format.
40
+ */
41
+ export async function exportGraph(options: ExportOptions): Promise<GraphData | string> {
42
+ const { format, folder } = options;
43
+
44
+ debug(`Exporting graph in ${format} format`);
45
+
46
+ const store = getVectorStore();
47
+ let records = await store.getAll();
48
+
49
+ // Filter by folder if specified
50
+ if (folder) {
51
+ const normalizedFolder = folder.toLowerCase();
52
+ records = records.filter(r => r.folder.toLowerCase() === normalizedFolder);
53
+ }
54
+
55
+ // Build graph data
56
+ const nodes: GraphNode[] = records.map(r => ({
57
+ id: r.id,
58
+ label: r.title,
59
+ folder: r.folder,
60
+ tags: r.tags ?? [],
61
+ }));
62
+
63
+ const edges: GraphEdge[] = [];
64
+ const nodeIds = new Set(records.map(r => r.id));
65
+
66
+ // Add link edges
67
+ for (const record of records) {
68
+ for (const linkTitle of record.outlinks ?? []) {
69
+ const target = records.find(r => r.title.toLowerCase() === linkTitle.toLowerCase());
70
+ if (target && nodeIds.has(target.id)) {
71
+ edges.push({
72
+ source: record.id,
73
+ target: target.id,
74
+ type: "link",
75
+ weight: GRAPH_LINK_WEIGHT,
76
+ });
77
+ }
78
+ }
79
+ }
80
+
81
+ // Add tag edges (notes sharing same tag)
82
+ const tagGroups = new Map<string, string[]>();
83
+ for (const record of records) {
84
+ for (const tag of record.tags ?? []) {
85
+ if (!tagGroups.has(tag)) {
86
+ tagGroups.set(tag, []);
87
+ }
88
+ tagGroups.get(tag)!.push(record.id);
89
+ }
90
+ }
91
+
92
+ const seenTagEdges = new Set<string>();
93
+ for (const [, noteIds] of tagGroups) {
94
+ if (noteIds.length < 2) continue;
95
+ for (let i = 0; i < noteIds.length; i++) {
96
+ for (let j = i + 1; j < noteIds.length; j++) {
97
+ const edgeKey = [noteIds[i], noteIds[j]].sort().join("-");
98
+ if (seenTagEdges.has(edgeKey)) continue;
99
+ seenTagEdges.add(edgeKey);
100
+ edges.push({
101
+ source: noteIds[i],
102
+ target: noteIds[j],
103
+ type: "tag",
104
+ weight: GRAPH_TAG_WEIGHT,
105
+ });
106
+ }
107
+ }
108
+ }
109
+
110
+ const graphData: GraphData = { nodes, edges };
111
+
112
+ if (format === "json") {
113
+ return graphData;
114
+ }
115
+
116
+ if (format === "graphml") {
117
+ return toGraphML(graphData);
118
+ }
119
+
120
+ throw new Error(`Unknown format: ${format}`);
121
+ }
122
+
123
+ function escapeXml(str: string): string {
124
+ return str
125
+ .replace(/&/g, "&amp;")
126
+ .replace(/</g, "&lt;")
127
+ .replace(/>/g, "&gt;")
128
+ .replace(/"/g, "&quot;")
129
+ .replace(/'/g, "&apos;");
130
+ }
131
+
132
+ function toGraphML(data: GraphData): string {
133
+ const lines: string[] = [
134
+ '<?xml version="1.0" encoding="UTF-8"?>',
135
+ '<graphml xmlns="http://graphml.graphdrawing.org/xmlns">',
136
+ ' <key id="label" for="node" attr.name="label" attr.type="string"/>',
137
+ ' <key id="folder" for="node" attr.name="folder" attr.type="string"/>',
138
+ ' <key id="tags" for="node" attr.name="tags" attr.type="string"/>',
139
+ ' <key id="type" for="edge" attr.name="type" attr.type="string"/>',
140
+ ' <key id="weight" for="edge" attr.name="weight" attr.type="double"/>',
141
+ ' <graph id="G" edgedefault="directed">',
142
+ ];
143
+
144
+ for (const node of data.nodes) {
145
+ lines.push(` <node id="${escapeXml(node.id)}">`);
146
+ lines.push(` <data key="label">${escapeXml(node.label)}</data>`);
147
+ lines.push(` <data key="folder">${escapeXml(node.folder)}</data>`);
148
+ lines.push(` <data key="tags">${escapeXml(node.tags.join(","))}</data>`);
149
+ lines.push(" </node>");
150
+ }
151
+
152
+ for (const edge of data.edges) {
153
+ lines.push(` <edge source="${escapeXml(edge.source)}" target="${escapeXml(edge.target)}">`);
154
+ lines.push(` <data key="type">${edge.type}</data>`);
155
+ lines.push(` <data key="weight">${edge.weight}</data>`);
156
+ lines.push(" </edge>");
157
+ }
158
+
159
+ lines.push(" </graph>");
160
+ lines.push("</graphml>");
161
+
162
+ return lines.join("\n");
163
+ }
@@ -0,0 +1,90 @@
1
+ // src/graph/extract.test.ts
2
+ import { describe, it, expect } from "vitest";
3
+ import { extractTags, extractOutlinks, extractMetadata } from "./extract.js";
4
+
5
+ describe("extractTags", () => {
6
+ it("extracts simple hashtags", () => {
7
+ const content = "This is a #project about #coding";
8
+ expect(extractTags(content)).toEqual(["project", "coding"]);
9
+ });
10
+
11
+ it("handles hyphenated tags", () => {
12
+ const content = "Working on #my-project and #some-idea";
13
+ expect(extractTags(content)).toEqual(["my-project", "some-idea"]);
14
+ });
15
+
16
+ it("normalizes to lowercase", () => {
17
+ const content = "#Project #IDEA #Mixed";
18
+ expect(extractTags(content)).toEqual(["project", "idea", "mixed"]);
19
+ });
20
+
21
+ it("deduplicates tags", () => {
22
+ const content = "#project #idea #project";
23
+ expect(extractTags(content)).toEqual(["project", "idea"]);
24
+ });
25
+
26
+ it("returns empty array for no tags", () => {
27
+ expect(extractTags("No tags here")).toEqual([]);
28
+ });
29
+
30
+ it("ignores tags in code blocks", () => {
31
+ const content = "Real #tag\n```\n#not-a-tag\n```\nAnother #real";
32
+ expect(extractTags(content)).toEqual(["tag", "real"]);
33
+ });
34
+
35
+ it("ignores tags in inline code", () => {
36
+ const content = "Real #tag and `#code-tag` should ignore inline";
37
+ expect(extractTags(content)).toEqual(["tag"]);
38
+ });
39
+
40
+ it("ignores hex colors", () => {
41
+ const content = "Color #fff and #000000 and #a1b2c3 are not tags";
42
+ expect(extractTags(content)).toEqual([]);
43
+ });
44
+
45
+ it("keeps tags that contain letters mixed with numbers", () => {
46
+ const content = "#project1 #2024goals #abc123xyz";
47
+ expect(extractTags(content)).toEqual(["project1", "2024goals", "abc123xyz"]);
48
+ });
49
+
50
+ it("extracts tag at string boundaries", () => {
51
+ expect(extractTags("#start of content")).toEqual(["start"]);
52
+ expect(extractTags("end of #content")).toEqual(["content"]);
53
+ });
54
+ });
55
+
56
+ describe("extractOutlinks", () => {
57
+ it("extracts wiki-style links", () => {
58
+ const content = "See [[Meeting Notes]] and [[Project Plan]]";
59
+ expect(extractOutlinks(content)).toEqual(["Meeting Notes", "Project Plan"]);
60
+ });
61
+
62
+ it("handles links with special characters", () => {
63
+ const content = "Check [[Note with / slash]] and [[Note: with colon]]";
64
+ expect(extractOutlinks(content)).toEqual(["Note with / slash", "Note: with colon"]);
65
+ });
66
+
67
+ it("deduplicates links", () => {
68
+ const content = "[[Note]] and [[Other]] and [[Note]]";
69
+ expect(extractOutlinks(content)).toEqual(["Note", "Other"]);
70
+ });
71
+
72
+ it("returns empty array for no links", () => {
73
+ expect(extractOutlinks("No links here")).toEqual([]);
74
+ });
75
+
76
+ it("ignores links in code blocks", () => {
77
+ const content = "Real [[Link]]\n```\n[[not-a-link]]\n```";
78
+ expect(extractOutlinks(content)).toEqual(["Link"]);
79
+ });
80
+ });
81
+
82
+ describe("extractMetadata", () => {
83
+ it("extracts both tags and outlinks", () => {
84
+ const content = "A #project note linking to [[Other Note]]";
85
+ expect(extractMetadata(content)).toEqual({
86
+ tags: ["project"],
87
+ outlinks: ["Other Note"],
88
+ });
89
+ });
90
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Knowledge graph metadata extraction from note content.
3
+ */
4
+
5
+ /** Remove code blocks and inline code to avoid extracting metadata from code. */
6
+ function stripCodeBlocks(content: string): string {
7
+ return content
8
+ .replace(/```[\s\S]*?```/g, "") // fenced code blocks
9
+ .replace(/`[^`]+`/g, ""); // inline code
10
+ }
11
+
12
+ /** Check if a string is a hex color code (e.g., fff, 000000, a1b2c3) */
13
+ function isHexColor(value: string): boolean {
14
+ return /^[0-9a-fA-F]+$/.test(value);
15
+ }
16
+
17
+ /**
18
+ * Extract hashtags from content.
19
+ * Ignores tags inside code blocks and hex color codes.
20
+ */
21
+ export function extractTags(content: string): string[] {
22
+ const matches = stripCodeBlocks(content).match(/#[\w-]+/g) || [];
23
+ const tags = matches
24
+ .map((t) => t.slice(1).toLowerCase())
25
+ .filter((t) => !isHexColor(t));
26
+ return [...new Set(tags)];
27
+ }
28
+
29
+ /**
30
+ * Extract wiki-style [[links]] from content.
31
+ * Ignores links inside code blocks.
32
+ */
33
+ export function extractOutlinks(content: string): string[] {
34
+ const matches = stripCodeBlocks(content).match(/\[\[([^\]]+)\]\]/g) || [];
35
+ const links = matches.map((m) => m.slice(2, -2));
36
+ return [...new Set(links)];
37
+ }
38
+
39
+ export interface NoteMetadata {
40
+ tags: string[];
41
+ outlinks: string[];
42
+ }
43
+
44
+ /**
45
+ * Extract all metadata (tags and outlinks) from content.
46
+ */
47
+ export function extractMetadata(content: string): NoteMetadata {
48
+ return {
49
+ tags: extractTags(content),
50
+ outlinks: extractOutlinks(content),
51
+ };
52
+ }
@@ -0,0 +1,156 @@
1
+ // src/graph/queries.test.ts
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ import { listTags, searchByTag, findRelatedNotes } from "./queries.js";
4
+
5
+ // Create a shared mock store object
6
+ const mockStore = {
7
+ getAll: vi.fn(),
8
+ search: vi.fn(),
9
+ };
10
+
11
+ // Mock the vector store module
12
+ vi.mock("../db/lancedb.js", () => ({
13
+ getVectorStore: () => mockStore,
14
+ }));
15
+
16
+ describe("listTags", () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ });
20
+
21
+ it("aggregates tags with counts", async () => {
22
+ mockStore.getAll.mockResolvedValue([
23
+ { id: "1", title: "Note 1", tags: ["project", "idea"], outlinks: [] },
24
+ { id: "2", title: "Note 2", tags: ["project", "todo"], outlinks: [] },
25
+ { id: "3", title: "Note 3", tags: ["idea"], outlinks: [] },
26
+ ]);
27
+
28
+ const result = await listTags();
29
+
30
+ expect(result).toEqual([
31
+ { tag: "project", count: 2 },
32
+ { tag: "idea", count: 2 },
33
+ { tag: "todo", count: 1 },
34
+ ]);
35
+ });
36
+
37
+ it("returns empty array when no tags", async () => {
38
+ mockStore.getAll.mockResolvedValue([
39
+ { id: "1", title: "Note 1", tags: [], outlinks: [] },
40
+ ]);
41
+
42
+ const result = await listTags();
43
+ expect(result).toEqual([]);
44
+ });
45
+ });
46
+
47
+ describe("searchByTag", () => {
48
+ beforeEach(() => {
49
+ vi.clearAllMocks();
50
+ });
51
+
52
+ it("finds notes with specific tag", async () => {
53
+ mockStore.getAll.mockResolvedValue([
54
+ { id: "1", title: "Note 1", folder: "Work", tags: ["project"], content: "Content 1", modified: "2026-01-01" },
55
+ { id: "2", title: "Note 2", folder: "Personal", tags: ["project", "idea"], content: "Content 2", modified: "2026-01-02" },
56
+ { id: "3", title: "Note 3", folder: "Work", tags: ["todo"], content: "Content 3", modified: "2026-01-03" },
57
+ ]);
58
+
59
+ const result = await searchByTag("project");
60
+
61
+ expect(result).toHaveLength(2);
62
+ expect(result[0].title).toBe("Note 1");
63
+ expect(result[1].title).toBe("Note 2");
64
+ });
65
+
66
+ it("filters by folder", async () => {
67
+ mockStore.getAll.mockResolvedValue([
68
+ { id: "1", title: "Note 1", folder: "Work", tags: ["project"], content: "...", modified: "2026-01-01" },
69
+ { id: "2", title: "Note 2", folder: "Personal", tags: ["project"], content: "...", modified: "2026-01-02" },
70
+ ]);
71
+
72
+ const result = await searchByTag("project", { folder: "Work" });
73
+
74
+ expect(result).toHaveLength(1);
75
+ expect(result[0].folder).toBe("Work");
76
+ });
77
+ });
78
+
79
+ describe("findRelatedNotes", () => {
80
+ beforeEach(() => {
81
+ vi.clearAllMocks();
82
+ });
83
+
84
+ it("finds notes by shared tags", async () => {
85
+ mockStore.getAll.mockResolvedValue([
86
+ { id: "1", title: "Source", folder: "Work", tags: ["project", "idea"], outlinks: [], vector: [1,0,0] },
87
+ { id: "2", title: "Related", folder: "Work", tags: ["project"], outlinks: [], vector: [0,1,0] },
88
+ { id: "3", title: "Unrelated", folder: "Work", tags: ["todo"], outlinks: [], vector: [0,0,1] },
89
+ ]);
90
+
91
+ const result = await findRelatedNotes("1", { types: ["tag"] });
92
+
93
+ expect(result).toHaveLength(1);
94
+ expect(result[0].title).toBe("Related");
95
+ expect(result[0].relationship).toBe("tag");
96
+ });
97
+
98
+ it("finds notes by outlinks", async () => {
99
+ mockStore.getAll.mockResolvedValue([
100
+ { id: "1", title: "Source", folder: "Work", tags: [], outlinks: ["Target"], vector: [1,0,0] },
101
+ { id: "2", title: "Target", folder: "Work", tags: [], outlinks: [], vector: [0,1,0] },
102
+ ]);
103
+
104
+ const result = await findRelatedNotes("1", { types: ["link"] });
105
+
106
+ expect(result).toHaveLength(1);
107
+ expect(result[0].title).toBe("Target");
108
+ expect(result[0].relationship).toBe("link");
109
+ expect(result[0].direction).toBe("outgoing");
110
+ });
111
+
112
+ it("finds backlinks", async () => {
113
+ mockStore.getAll.mockResolvedValue([
114
+ { id: "1", title: "Target", folder: "Work", tags: [], outlinks: [], vector: [1,0,0] },
115
+ { id: "2", title: "Source", folder: "Work", tags: [], outlinks: ["Target"], vector: [0,1,0] },
116
+ ]);
117
+
118
+ const result = await findRelatedNotes("1", { types: ["link"] });
119
+
120
+ expect(result).toHaveLength(1);
121
+ expect(result[0].title).toBe("Source");
122
+ expect(result[0].direction).toBe("incoming");
123
+ });
124
+
125
+ it("throws NoteNotFoundError for non-existent source note", async () => {
126
+ mockStore.getAll.mockResolvedValue([]);
127
+ await expect(findRelatedNotes("nonexistent-id")).rejects.toThrow("Note not found");
128
+ });
129
+
130
+ it("finds notes by semantic similarity", async () => {
131
+ mockStore.getAll.mockResolvedValue([
132
+ { id: "1", title: "Source", folder: "Work", tags: [], outlinks: [], vector: [1,0,0] },
133
+ ]);
134
+ mockStore.search.mockResolvedValue([
135
+ { id: "2", title: "Similar", folder: "Work", content: "...", modified: "2026-01-01", score: 0.95 }
136
+ ]);
137
+
138
+ const result = await findRelatedNotes("1", { types: ["similar"] });
139
+ expect(result).toHaveLength(1);
140
+ expect(result[0].relationship).toBe("similar");
141
+ });
142
+ });
143
+
144
+ describe("searchByTag", () => {
145
+ beforeEach(() => {
146
+ vi.clearAllMocks();
147
+ });
148
+
149
+ it("searches tags case-insensitively", async () => {
150
+ mockStore.getAll.mockResolvedValue([
151
+ { id: "1", title: "Note", folder: "Work", tags: ["project"], content: "...", modified: "2026-01-01" },
152
+ ]);
153
+ const result = await searchByTag("PROJECT");
154
+ expect(result).toHaveLength(1);
155
+ });
156
+ });