@disco_trooper/apple-notes-mcp 1.2.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.
@@ -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
+ });
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Knowledge graph query operations.
3
+ */
4
+
5
+ import { getVectorStore } from "../db/lancedb.js";
6
+ import { createDebugLogger } from "../utils/debug.js";
7
+ import { NoteNotFoundError } from "../errors/index.js";
8
+ import { generatePreview } from "../search/index.js";
9
+ import {
10
+ DEFAULT_SEARCH_LIMIT,
11
+ DEFAULT_RELATED_NOTES_LIMIT,
12
+ GRAPH_TAG_WEIGHT,
13
+ GRAPH_LINK_WEIGHT,
14
+ GRAPH_SIMILAR_WEIGHT,
15
+ } from "../config/constants.js";
16
+ import type { SearchResult } from "../types/index.js";
17
+
18
+ const debug = createDebugLogger("GRAPH");
19
+
20
+ export interface TagCount {
21
+ tag: string;
22
+ count: number;
23
+ }
24
+
25
+ /**
26
+ * List all tags with occurrence counts.
27
+ * Sorted by count descending.
28
+ */
29
+ export async function listTags(): Promise<TagCount[]> {
30
+ debug("Listing all tags");
31
+
32
+ const store = getVectorStore();
33
+ const records = await store.getAll();
34
+
35
+ // Aggregate tag counts
36
+ const counts = new Map<string, number>();
37
+ for (const record of records) {
38
+ for (const tag of record.tags ?? []) {
39
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
40
+ }
41
+ }
42
+
43
+ // Sort by count descending
44
+ const result = Array.from(counts.entries())
45
+ .map(([tag, count]) => ({ tag, count }))
46
+ .sort((a, b) => b.count - a.count);
47
+
48
+ debug(`Found ${result.length} unique tags`);
49
+ return result;
50
+ }
51
+
52
+ export interface SearchByTagOptions {
53
+ folder?: string;
54
+ limit?: number;
55
+ }
56
+
57
+ /**
58
+ * Find notes with a specific tag.
59
+ */
60
+ export async function searchByTag(
61
+ tag: string,
62
+ options: SearchByTagOptions = {}
63
+ ): Promise<SearchResult[]> {
64
+ const { folder, limit = DEFAULT_SEARCH_LIMIT } = options;
65
+
66
+ debug(`Searching for tag: ${tag}`);
67
+
68
+ const store = getVectorStore();
69
+ const records = await store.getAll();
70
+
71
+ // Filter by tag (case-insensitive)
72
+ let matches = records.filter((r) =>
73
+ (r.tags ?? []).includes(tag.toLowerCase())
74
+ );
75
+
76
+ // Filter by folder if specified
77
+ if (folder) {
78
+ const normalizedFolder = folder.toLowerCase();
79
+ matches = matches.filter(
80
+ (r) => r.folder.toLowerCase() === normalizedFolder
81
+ );
82
+ }
83
+
84
+ // Transform to SearchResult and limit
85
+ const results: SearchResult[] = matches.slice(0, limit).map((r, i) => ({
86
+ id: r.id,
87
+ title: r.title,
88
+ folder: r.folder,
89
+ preview: generatePreview(r.content),
90
+ modified: r.modified,
91
+ score: 1 / (1 + i),
92
+ }));
93
+
94
+ debug(`Found ${results.length} notes with tag: ${tag}`);
95
+ return results;
96
+ }
97
+
98
+ export type RelationshipType = "tag" | "link" | "similar";
99
+
100
+ export interface RelatedNote {
101
+ id: string;
102
+ title: string;
103
+ folder: string;
104
+ relationship: RelationshipType;
105
+ score: number;
106
+ sharedTags?: string[];
107
+ direction?: "outgoing" | "incoming";
108
+ }
109
+
110
+ export interface FindRelatedOptions {
111
+ types?: RelationshipType[];
112
+ limit?: number;
113
+ }
114
+
115
+ /**
116
+ * Find notes related to a source note by tags, links, or semantic similarity.
117
+ */
118
+ export async function findRelatedNotes(
119
+ sourceId: string,
120
+ options: FindRelatedOptions = {}
121
+ ): Promise<RelatedNote[]> {
122
+ const { types = ["tag", "link", "similar"], limit = DEFAULT_RELATED_NOTES_LIMIT } = options;
123
+
124
+ debug(`Finding related notes for: ${sourceId}`);
125
+
126
+ const store = getVectorStore();
127
+ const allRecords = await store.getAll();
128
+
129
+ // Find source note
130
+ const source = allRecords.find((r) => r.id === sourceId);
131
+ if (!source) {
132
+ throw new NoteNotFoundError(sourceId);
133
+ }
134
+
135
+ const results: RelatedNote[] = [];
136
+ const seen = new Set<string>();
137
+
138
+ // Find by shared tags
139
+ if (types.includes("tag") && source.tags?.length > 0) {
140
+ for (const record of allRecords) {
141
+ if (record.id === sourceId || seen.has(record.id)) continue;
142
+
143
+ const shared = (record.tags ?? []).filter((t) => source.tags.includes(t));
144
+ if (shared.length > 0) {
145
+ results.push({
146
+ id: record.id,
147
+ title: record.title,
148
+ folder: record.folder,
149
+ relationship: "tag",
150
+ score: GRAPH_TAG_WEIGHT * (shared.length / source.tags.length),
151
+ sharedTags: shared,
152
+ });
153
+ seen.add(record.id);
154
+ }
155
+ }
156
+ }
157
+
158
+ // Find by outlinks (notes this note links to)
159
+ if (types.includes("link")) {
160
+ for (const linkTitle of source.outlinks ?? []) {
161
+ const linked = allRecords.find(
162
+ (r) =>
163
+ r.title.toLowerCase() === linkTitle.toLowerCase() &&
164
+ r.id !== sourceId &&
165
+ !seen.has(r.id)
166
+ );
167
+ if (linked) {
168
+ results.push({
169
+ id: linked.id,
170
+ title: linked.title,
171
+ folder: linked.folder,
172
+ relationship: "link",
173
+ score: GRAPH_LINK_WEIGHT,
174
+ direction: "outgoing",
175
+ });
176
+ seen.add(linked.id);
177
+ }
178
+ }
179
+
180
+ // Find backlinks (notes that link to this note)
181
+ for (const record of allRecords) {
182
+ if (record.id === sourceId || seen.has(record.id)) continue;
183
+
184
+ const linksToSource = (record.outlinks ?? []).some(
185
+ (l) => l.toLowerCase() === source.title.toLowerCase()
186
+ );
187
+ if (linksToSource) {
188
+ results.push({
189
+ id: record.id,
190
+ title: record.title,
191
+ folder: record.folder,
192
+ relationship: "link",
193
+ score: GRAPH_LINK_WEIGHT,
194
+ direction: "incoming",
195
+ });
196
+ seen.add(record.id);
197
+ }
198
+ }
199
+ }
200
+
201
+ // Find by semantic similarity
202
+ if (types.includes("similar") && source.vector?.length > 0) {
203
+ const similarResults = await store.search(source.vector, limit + 1);
204
+
205
+ for (const similar of similarResults) {
206
+ if (similar.id === sourceId || seen.has(similar.id ?? "")) continue;
207
+
208
+ results.push({
209
+ id: similar.id ?? "",
210
+ title: similar.title,
211
+ folder: similar.folder,
212
+ relationship: "similar",
213
+ score: GRAPH_SIMILAR_WEIGHT * similar.score,
214
+ });
215
+ seen.add(similar.id ?? "");
216
+ }
217
+ }
218
+
219
+ // Sort by score and limit
220
+ results.sort((a, b) => b.score - a.score);
221
+
222
+ debug(`Found ${results.length} related notes`);
223
+ return results.slice(0, limit);
224
+ }