@disco_trooper/apple-notes-mcp 1.2.0 → 1.4.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.
- package/README.md +136 -24
- package/package.json +13 -9
- package/src/config/claude.test.ts +47 -0
- package/src/config/claude.ts +106 -0
- package/src/config/constants.ts +11 -2
- package/src/config/paths.test.ts +40 -0
- package/src/config/paths.ts +86 -0
- package/src/db/arrow-fix.test.ts +101 -0
- package/src/db/lancedb.test.ts +209 -2
- package/src/db/lancedb.ts +373 -7
- package/src/embeddings/cache.test.ts +150 -0
- package/src/embeddings/cache.ts +204 -0
- package/src/embeddings/index.ts +21 -2
- package/src/embeddings/local.ts +61 -10
- package/src/embeddings/openrouter.ts +233 -11
- package/src/graph/export.test.ts +81 -0
- package/src/graph/export.ts +163 -0
- package/src/graph/extract.test.ts +90 -0
- package/src/graph/extract.ts +52 -0
- package/src/graph/queries.test.ts +156 -0
- package/src/graph/queries.ts +224 -0
- package/src/index.ts +376 -10
- package/src/notes/crud.test.ts +148 -3
- package/src/notes/crud.ts +250 -5
- package/src/notes/read.ts +83 -68
- package/src/search/chunk-indexer.test.ts +353 -0
- package/src/search/chunk-indexer.ts +254 -0
- package/src/search/chunk-search.test.ts +327 -0
- package/src/search/chunk-search.ts +298 -0
- package/src/search/indexer.ts +151 -109
- package/src/search/refresh.test.ts +173 -0
- package/src/search/refresh.ts +151 -0
- package/src/setup.ts +46 -67
- package/src/utils/chunker.test.ts +182 -0
- package/src/utils/chunker.ts +170 -0
- package/src/utils/content-filter.test.ts +225 -0
- package/src/utils/content-filter.ts +275 -0
- package/src/utils/runtime.test.ts +70 -0
- package/src/utils/runtime.ts +40 -0
|
@@ -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, "&")
|
|
126
|
+
.replace(/</g, "<")
|
|
127
|
+
.replace(/>/g, ">")
|
|
128
|
+
.replace(/"/g, """)
|
|
129
|
+
.replace(/'/g, "'");
|
|
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
|
+
});
|
|
@@ -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
|
+
}
|