@tekmidian/pai 0.25.1 → 0.26.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/dist/auto-route-BWGvvpcP.mjs +86 -0
  2. package/dist/auto-route-BWGvvpcP.mjs.map +1 -0
  3. package/dist/cli/index.mjs +2 -2
  4. package/dist/cli/program.mjs +2 -2
  5. package/dist/clusters-BdvGIoD-.mjs +201 -0
  6. package/dist/clusters-BdvGIoD-.mjs.map +1 -0
  7. package/dist/daemon/index.mjs +6 -5
  8. package/dist/daemon/index.mjs.map +1 -1
  9. package/dist/daemon-BaBTOB1P.mjs +1356 -0
  10. package/dist/daemon-BaBTOB1P.mjs.map +1 -0
  11. package/dist/detector-DExEQ5cW.mjs +74 -0
  12. package/dist/detector-DExEQ5cW.mjs.map +1 -0
  13. package/dist/factory-CAy0N1xR.mjs +142 -0
  14. package/dist/factory-CAy0N1xR.mjs.map +1 -0
  15. package/dist/indexer-backend-vOJqSE0U.mjs +299 -0
  16. package/dist/indexer-backend-vOJqSE0U.mjs.map +1 -0
  17. package/dist/latent-ideas-DuM_kgkW.mjs +191 -0
  18. package/dist/latent-ideas-DuM_kgkW.mjs.map +1 -0
  19. package/dist/neighborhood-DSkvFMAv.mjs +135 -0
  20. package/dist/neighborhood-DSkvFMAv.mjs.map +1 -0
  21. package/dist/note-context-DrcY4cWm.mjs +126 -0
  22. package/dist/note-context-DrcY4cWm.mjs.map +1 -0
  23. package/dist/pick-DD1viRct.mjs +13289 -0
  24. package/dist/pick-DD1viRct.mjs.map +1 -0
  25. package/dist/pick-FrfZ_iY7.mjs +13299 -0
  26. package/dist/pick-FrfZ_iY7.mjs.map +1 -0
  27. package/dist/pick-ZAMVXMxO.mjs +13282 -0
  28. package/dist/pick-ZAMVXMxO.mjs.map +1 -0
  29. package/dist/postgres-DTyxU4B1.mjs +891 -0
  30. package/dist/postgres-DTyxU4B1.mjs.map +1 -0
  31. package/dist/query-feedback-BX5nSyRm.mjs +76 -0
  32. package/dist/query-feedback-BX5nSyRm.mjs.map +1 -0
  33. package/dist/router-B2xR3gVP.mjs +228 -0
  34. package/dist/router-B2xR3gVP.mjs.map +1 -0
  35. package/dist/sqlite--BBAyXLH.mjs +271 -0
  36. package/dist/sqlite--BBAyXLH.mjs.map +1 -0
  37. package/dist/themes-DICajLf-.mjs +148 -0
  38. package/dist/themes-DICajLf-.mjs.map +1 -0
  39. package/dist/tools-DMAQxlOk.mjs +1939 -0
  40. package/dist/tools-DMAQxlOk.mjs.map +1 -0
  41. package/dist/trace-DfyGmMG_.mjs +137 -0
  42. package/dist/trace-DfyGmMG_.mjs.map +1 -0
  43. package/dist/vault-indexer-Dt8qXP-w.mjs +536 -0
  44. package/dist/vault-indexer-Dt8qXP-w.mjs.map +1 -0
  45. package/dist/work-queue-worker-Bt_UcbMy.mjs +1856 -0
  46. package/dist/work-queue-worker-Bt_UcbMy.mjs.map +1 -0
  47. package/dist/zettelkasten-C8BikWss.mjs +1063 -0
  48. package/dist/zettelkasten-C8BikWss.mjs.map +1 -0
  49. package/package.json +1 -1
@@ -0,0 +1,1063 @@
1
+ import { a as generateEmbedding, n as cosineSimilarity, r as deserializeEmbedding } from "./embeddings-Bn86ssxR.mjs";
2
+ import { t as zettelThemes } from "./themes-DICajLf-.mjs";
3
+ import { n as saveQueryResult } from "./query-feedback-BX5nSyRm.mjs";
4
+ import { basename, dirname } from "node:path";
5
+
6
+ //#region src/zettelkasten/explore.ts
7
+ function classifyEdge(source, target) {
8
+ return dirname(source) === dirname(target) ? "sequential" : "associative";
9
+ }
10
+ async function resolveStart(backend, startNote) {
11
+ const files = await backend.getVaultFilesByPaths([startNote]);
12
+ if (files.length > 0) return files[0].vaultPath;
13
+ const alias = await backend.getVaultAlias(startNote);
14
+ if (!alias) return null;
15
+ const canonical = await backend.getVaultFilesByPaths([alias.canonicalPath]);
16
+ return canonical.length > 0 ? canonical[0].vaultPath : null;
17
+ }
18
+ async function getForwardNeighbors(backend, path) {
19
+ return (await backend.getLinksFromSource(path)).filter((l) => l.targetPath !== null).map((l) => l.targetPath);
20
+ }
21
+ async function getBackwardNeighbors(backend, path) {
22
+ return (await backend.getLinksToTarget(path)).map((l) => l.sourcePath);
23
+ }
24
+ async function getFileInfo(backend, path) {
25
+ const [files, health] = await Promise.all([backend.getVaultFilesByPaths([path]), backend.getVaultHealth(path)]);
26
+ return {
27
+ title: files[0]?.title ?? null,
28
+ inbound: health?.inboundCount ?? 0,
29
+ outbound: health?.outboundCount ?? 0
30
+ };
31
+ }
32
+ /**
33
+ * Traverse the Zettelkasten link graph using BFS, following chains of thought
34
+ * from a starting note up to a configurable depth.
35
+ */
36
+ async function zettelExplore(backend, opts) {
37
+ const depth = Math.min(Math.max(opts.depth ?? 3, 1), 10);
38
+ const direction = opts.direction ?? "both";
39
+ const mode = opts.mode ?? "all";
40
+ const root = await resolveStart(backend, opts.startNote);
41
+ if (!root) return {
42
+ root: opts.startNote,
43
+ nodes: [],
44
+ edges: [],
45
+ branchingPoints: [],
46
+ maxDepthReached: false
47
+ };
48
+ const visited = new Set([root]);
49
+ const nodes = [];
50
+ const edges = [];
51
+ let maxDepthReached = false;
52
+ const queue = [{
53
+ path: root,
54
+ depth: 0
55
+ }];
56
+ while (queue.length > 0) {
57
+ const current = queue.shift();
58
+ if (current.depth >= depth) {
59
+ maxDepthReached = true;
60
+ continue;
61
+ }
62
+ const neighbors = [];
63
+ if (direction === "forward" || direction === "both") for (const n of await getForwardNeighbors(backend, current.path)) neighbors.push({
64
+ neighbor: n,
65
+ from: current.path,
66
+ to: n
67
+ });
68
+ if (direction === "backward" || direction === "both") for (const n of await getBackwardNeighbors(backend, current.path)) neighbors.push({
69
+ neighbor: n,
70
+ from: n,
71
+ to: current.path
72
+ });
73
+ for (const { neighbor, from, to } of neighbors) {
74
+ const edgeType = classifyEdge(from, to);
75
+ if (mode !== "all" && edgeType !== mode) continue;
76
+ if (!edges.some((e) => e.from === from && e.to === to)) edges.push({
77
+ from,
78
+ to,
79
+ type: edgeType
80
+ });
81
+ if (!visited.has(neighbor)) {
82
+ visited.add(neighbor);
83
+ const info = await getFileInfo(backend, neighbor);
84
+ nodes.push({
85
+ path: neighbor,
86
+ title: info.title,
87
+ depth: current.depth + 1,
88
+ linkType: edgeType,
89
+ inbound: info.inbound,
90
+ outbound: info.outbound
91
+ });
92
+ queue.push({
93
+ path: neighbor,
94
+ depth: current.depth + 1
95
+ });
96
+ }
97
+ }
98
+ }
99
+ const branchingPoints = nodes.filter((n) => n.outbound > 2).map((n) => n.path);
100
+ if ((await getFileInfo(backend, root)).outbound > 2) branchingPoints.unshift(root);
101
+ return {
102
+ root,
103
+ nodes,
104
+ edges,
105
+ branchingPoints,
106
+ maxDepthReached
107
+ };
108
+ }
109
+
110
+ //#endregion
111
+ //#region src/zettelkasten/surprise.ts
112
+ const MAX_CHUNKS$1 = 5e3;
113
+ const BFS_HOP_CAP = 20;
114
+ async function getFileEmbeddings(backend, projectId) {
115
+ const rows = await backend.getChunksWithEmbeddings(projectId, MAX_CHUNKS$1);
116
+ const byPath = /* @__PURE__ */ new Map();
117
+ for (const row of rows) {
118
+ const vec = deserializeEmbedding(row.embedding);
119
+ const entry = byPath.get(row.path);
120
+ if (!entry) byPath.set(row.path, {
121
+ sum: new Float32Array(vec),
122
+ count: 1,
123
+ text: row.text
124
+ });
125
+ else {
126
+ for (let i = 0; i < vec.length; i++) entry.sum[i] += vec[i];
127
+ entry.count++;
128
+ }
129
+ }
130
+ const result = /* @__PURE__ */ new Map();
131
+ for (const [path, { sum, count, text }] of byPath) {
132
+ const avg = new Float32Array(sum.length);
133
+ for (let i = 0; i < sum.length; i++) avg[i] = sum[i] / count;
134
+ result.set(path, {
135
+ embedding: avg,
136
+ text
137
+ });
138
+ }
139
+ return result;
140
+ }
141
+ async function getReferenceEmbedding(backend, projectId, path) {
142
+ const rows = await backend.getChunksForPath(projectId, path);
143
+ if (rows.length === 0) return {
144
+ embedding: new Float32Array(0),
145
+ found: false
146
+ };
147
+ const embRows = rows.filter((r) => r.embedding !== null);
148
+ if (embRows.length === 0) return {
149
+ embedding: new Float32Array(0),
150
+ found: false
151
+ };
152
+ const dim = deserializeEmbedding(embRows[0].embedding).length;
153
+ const sum = new Float32Array(dim);
154
+ for (const row of embRows) {
155
+ const vec = deserializeEmbedding(row.embedding);
156
+ for (let i = 0; i < dim; i++) sum[i] += vec[i];
157
+ }
158
+ const avg = new Float32Array(dim);
159
+ for (let i = 0; i < dim; i++) avg[i] = sum[i] / embRows.length;
160
+ return {
161
+ embedding: avg,
162
+ found: true
163
+ };
164
+ }
165
+ async function bfsGraphDistance(backend, source, target) {
166
+ if (source === target) return 0;
167
+ const visited = new Set([source]);
168
+ const queue = [{
169
+ path: source,
170
+ hops: 0
171
+ }];
172
+ while (queue.length > 0) {
173
+ const { path, hops } = queue.shift();
174
+ if (hops >= BFS_HOP_CAP) continue;
175
+ const [forwardLinks, backwardLinks] = await Promise.all([backend.getLinksFromSource(path), backend.getLinksToTarget(path)]);
176
+ const neighbors = [...forwardLinks.filter((l) => l.targetPath !== null).map((l) => l.targetPath), ...backwardLinks.map((l) => l.sourcePath)];
177
+ for (const neighbor of neighbors) {
178
+ if (neighbor === target) return hops + 1;
179
+ if (!visited.has(neighbor)) {
180
+ visited.add(neighbor);
181
+ queue.push({
182
+ path: neighbor,
183
+ hops: hops + 1
184
+ });
185
+ }
186
+ }
187
+ }
188
+ return Infinity;
189
+ }
190
+ function getBestChunkText(chunkRows, refEmbedding) {
191
+ const rows = chunkRows.filter((r) => r.embedding !== null);
192
+ if (rows.length === 0) return "";
193
+ let bestText = rows[0].text;
194
+ let bestSim = -Infinity;
195
+ for (const row of rows) {
196
+ const sim = cosineSimilarity(refEmbedding, deserializeEmbedding(row.embedding));
197
+ if (sim > bestSim) {
198
+ bestSim = sim;
199
+ bestText = row.text;
200
+ }
201
+ }
202
+ return bestText.trim().slice(0, 200);
203
+ }
204
+ /**
205
+ * Find notes that are semantically similar to a reference note but graph-distant —
206
+ * revealing surprising conceptual connections across unrelated areas of the Zettelkasten.
207
+ */
208
+ async function zettelSurprise(backend, opts) {
209
+ const limit = opts.limit ?? 10;
210
+ const minSimilarity = opts.minSimilarity ?? .3;
211
+ const minGraphDistance = opts.minGraphDistance ?? 3;
212
+ let { embedding: refEmbedding, found } = await getReferenceEmbedding(backend, opts.vaultProjectId, opts.referencePath);
213
+ if (!found) refEmbedding = await generateEmbedding((await backend.getVaultFilesByPaths([opts.referencePath]))[0]?.title ?? opts.referencePath, true);
214
+ const allFileEmbeddings = await getFileEmbeddings(backend, opts.vaultProjectId);
215
+ allFileEmbeddings.delete(opts.referencePath);
216
+ const semanticCandidates = [];
217
+ for (const [path, { embedding }] of allFileEmbeddings) {
218
+ const sim = cosineSimilarity(refEmbedding, embedding);
219
+ if (sim >= minSimilarity) semanticCandidates.push({
220
+ path,
221
+ sim
222
+ });
223
+ }
224
+ const results = [];
225
+ for (const { path, sim } of semanticCandidates) {
226
+ const graphDistance = await bfsGraphDistance(backend, opts.referencePath, path);
227
+ const effectiveDistance = isFinite(graphDistance) ? graphDistance : BFS_HOP_CAP;
228
+ if (effectiveDistance < minGraphDistance) continue;
229
+ const files = await backend.getVaultFilesByPaths([path]);
230
+ const chunkRows = await backend.getChunksForPath(opts.vaultProjectId, path, 20);
231
+ const surpriseScore = sim * Math.log2(effectiveDistance + 1);
232
+ const sharedSnippet = getBestChunkText(chunkRows, refEmbedding);
233
+ results.push({
234
+ path,
235
+ title: files[0]?.title ?? null,
236
+ cosineSimilarity: sim,
237
+ graphDistance: isFinite(graphDistance) ? graphDistance : Infinity,
238
+ surpriseScore,
239
+ sharedSnippet
240
+ });
241
+ }
242
+ results.sort((a, b) => b.surpriseScore - a.surpriseScore);
243
+ return results.slice(0, limit);
244
+ }
245
+
246
+ //#endregion
247
+ //#region src/zettelkasten/converse.ts
248
+ /** Extract the top-level folder from a vault path (first path segment). */
249
+ function extractDomain(vaultPath) {
250
+ const slash = vaultPath.indexOf("/");
251
+ return slash === -1 ? vaultPath : vaultPath.slice(0, slash);
252
+ }
253
+ /**
254
+ * Expand one level of graph neighbors for a set of paths.
255
+ * Returns all outbound and inbound neighbor paths (excluding already-visited).
256
+ */
257
+ async function expandNeighbors(backend, paths) {
258
+ if (paths.size === 0) return [];
259
+ const pathList = Array.from(paths);
260
+ const [forwardLinks, backwardLinks] = await Promise.all([backend.getVaultLinksFromPaths(pathList), Promise.all(pathList.map((p) => backend.getLinksToTarget(p)))]);
261
+ const neighbors = [];
262
+ for (const link of forwardLinks) if (link.targetPath) neighbors.push(link.targetPath);
263
+ for (const linkList of backwardLinks) for (const link of linkList) neighbors.push(link.sourcePath);
264
+ return neighbors;
265
+ }
266
+ /**
267
+ * Hybrid search combining keyword + semantic results using the StorageBackend.
268
+ */
269
+ async function hybridSearch(backend, query, queryEmbedding, opts) {
270
+ const maxResults = opts.maxResults ?? 10;
271
+ const kw = .5;
272
+ const sw = .5;
273
+ const [keywordResults, semanticResults] = await Promise.all([backend.searchKeyword(query, {
274
+ ...opts,
275
+ maxResults: 50
276
+ }), backend.searchSemantic(queryEmbedding, {
277
+ ...opts,
278
+ maxResults: 50
279
+ })]);
280
+ if (keywordResults.length === 0 && semanticResults.length === 0) return [];
281
+ const keyFor = (r) => `${r.projectId}:${r.path}:${r.startLine}:${r.endLine}`;
282
+ function minMaxNormalize(scores) {
283
+ const min = Math.min(...scores);
284
+ const range = Math.max(...scores) - min;
285
+ if (range === 0) return scores.map(() => 1);
286
+ return scores.map((s) => (s - min) / range);
287
+ }
288
+ const kwNorm = minMaxNormalize(keywordResults.map((r) => r.score));
289
+ const semNorm = minMaxNormalize(semanticResults.map((r) => r.score));
290
+ const combined = /* @__PURE__ */ new Map();
291
+ for (let i = 0; i < keywordResults.length; i++) {
292
+ const r = keywordResults[i];
293
+ const k = keyFor(r);
294
+ combined.set(k, {
295
+ ...r,
296
+ combinedScore: kw * kwNorm[i]
297
+ });
298
+ }
299
+ for (let i = 0; i < semanticResults.length; i++) {
300
+ const r = semanticResults[i];
301
+ const k = keyFor(r);
302
+ const existing = combined.get(k);
303
+ if (existing) existing.combinedScore += sw * semNorm[i];
304
+ else combined.set(k, {
305
+ ...r,
306
+ combinedScore: sw * semNorm[i]
307
+ });
308
+ }
309
+ return Array.from(combined.values()).sort((a, b) => b.combinedScore - a.combinedScore).slice(0, maxResults).map((r) => ({
310
+ ...r,
311
+ score: r.combinedScore
312
+ }));
313
+ }
314
+ /**
315
+ * Let the vault "talk back" — find notes relevant to a question, expand
316
+ * through the link graph, identify cross-domain connections, and return a
317
+ * structured result including a synthesis prompt for an AI to generate insights.
318
+ */
319
+ async function zettelConverse(backend, opts) {
320
+ const depth = Math.max(opts.depth ?? 2, 0);
321
+ const limit = Math.max(opts.limit ?? 15, 1);
322
+ const candidateLimit = 20;
323
+ const queryEmbedding = await generateEmbedding(opts.question, true);
324
+ const searchResults = await hybridSearch(backend, opts.question, queryEmbedding, {
325
+ projectIds: [opts.vaultProjectId],
326
+ maxResults: candidateLimit
327
+ });
328
+ const searchHits = /* @__PURE__ */ new Map();
329
+ for (const r of searchResults) {
330
+ const existing = searchHits.get(r.path);
331
+ if (!existing || r.score > existing.score) searchHits.set(r.path, {
332
+ score: r.score,
333
+ snippet: r.snippet
334
+ });
335
+ }
336
+ const allPaths = new Set(searchHits.keys());
337
+ let frontier = new Set(searchHits.keys());
338
+ for (let d = 0; d < depth; d++) {
339
+ const neighbors = await expandNeighbors(backend, frontier);
340
+ const newFrontier = /* @__PURE__ */ new Set();
341
+ for (const n of neighbors) if (!allPaths.has(n)) {
342
+ allPaths.add(n);
343
+ newFrontier.add(n);
344
+ }
345
+ if (newFrontier.size === 0) break;
346
+ frontier = newFrontier;
347
+ }
348
+ const searchRanked = Array.from(searchHits.entries()).sort((a, b) => b[1].score - a[1].score).map(([path, info]) => ({
349
+ path,
350
+ ...info,
351
+ isSearchResult: true
352
+ }));
353
+ const neighborPaths = Array.from(allPaths).filter((p) => !searchHits.has(p));
354
+ const neighborHealthRows = await Promise.all(neighborPaths.map((p) => backend.getVaultHealth(p)));
355
+ const neighborRanked = neighborPaths.map((path, idx) => ({
356
+ path,
357
+ score: 0,
358
+ snippet: "",
359
+ inbound: neighborHealthRows[idx]?.inboundCount ?? 0,
360
+ isSearchResult: false
361
+ })).sort((a, b) => b.inbound - a.inbound);
362
+ const budgetForNeighbors = Math.max(limit - searchRanked.length, 0);
363
+ const selectedNeighbors = neighborRanked.slice(0, budgetForNeighbors);
364
+ const selectedSearchPaths = searchRanked.slice(0, limit);
365
+ const selectedPaths = new Set([...selectedSearchPaths.map((r) => r.path), ...selectedNeighbors.map((r) => r.path)]);
366
+ const allSelectedPaths = Array.from(selectedPaths);
367
+ const fileRows = await backend.getVaultFilesByPaths(allSelectedPaths);
368
+ const titleMap = new Map(fileRows.map((f) => [f.vaultPath, f.title]));
369
+ const relevantNotes = [];
370
+ for (const r of selectedSearchPaths) {
371
+ if (!selectedPaths.has(r.path)) continue;
372
+ relevantNotes.push({
373
+ path: r.path,
374
+ title: titleMap.get(r.path) ?? null,
375
+ snippet: r.snippet,
376
+ score: r.score,
377
+ domain: extractDomain(r.path)
378
+ });
379
+ }
380
+ for (const r of selectedNeighbors) relevantNotes.push({
381
+ path: r.path,
382
+ title: titleMap.get(r.path) ?? null,
383
+ snippet: r.snippet,
384
+ score: 0,
385
+ domain: extractDomain(r.path)
386
+ });
387
+ let connections = [];
388
+ if (selectedPaths.size > 0) {
389
+ const pathList = Array.from(selectedPaths);
390
+ const pathSet = new Set(pathList);
391
+ const linkRows = await backend.getVaultLinksFromPaths(pathList);
392
+ const edgeCounts = /* @__PURE__ */ new Map();
393
+ for (const link of linkRows) if (link.targetPath && pathSet.has(link.targetPath)) {
394
+ const key = `${link.sourcePath}|||${link.targetPath}`;
395
+ edgeCounts.set(key, (edgeCounts.get(key) ?? 0) + 1);
396
+ }
397
+ for (const [key, cnt] of edgeCounts) {
398
+ const [sourcePath, targetPath] = key.split("|||");
399
+ connections.push({
400
+ fromPath: sourcePath,
401
+ toPath: targetPath,
402
+ fromDomain: extractDomain(sourcePath),
403
+ toDomain: extractDomain(targetPath),
404
+ strength: cnt
405
+ });
406
+ }
407
+ }
408
+ const domainSet = new Set(relevantNotes.map((n) => n.domain));
409
+ const domains = Array.from(domainSet).sort();
410
+ const crossDomainConnections = connections.filter((c) => c.fromDomain !== c.toDomain);
411
+ const notesSummary = relevantNotes.map((n, i) => {
412
+ const title = n.title ? `"${n.title}"` : "(untitled)";
413
+ const domain = n.domain;
414
+ const scoreLabel = n.score > 0 ? ` [relevance: ${n.score.toFixed(3)}]` : " [context]";
415
+ const snippet = n.snippet.trim().slice(0, 300);
416
+ return `${i + 1}. [${domain}] ${title}${scoreLabel}\n Path: ${n.path}\n "${snippet}"`;
417
+ }).join("\n\n");
418
+ const connectionSummary = crossDomainConnections.length > 0 ? crossDomainConnections.map((c) => `- "${c.fromPath}" (${c.fromDomain}) → "${c.toPath}" (${c.toDomain}) [strength: ${c.strength}]`).join("\n") : "(no cross-domain connections found)";
419
+ const domainList = domains.join(", ");
420
+ return {
421
+ relevantNotes,
422
+ connections: crossDomainConnections,
423
+ domains,
424
+ synthesisPrompt: `You are a Zettelkasten research assistant. The vault has surfaced the following notes in response to this question:
425
+
426
+ QUESTION: ${opts.question}
427
+
428
+ ---
429
+
430
+ RELEVANT NOTES (${relevantNotes.length} notes across ${domains.length} domain(s): ${domainList}):
431
+
432
+ ${notesSummary}
433
+
434
+ ---
435
+
436
+ CROSS-DOMAIN CONNECTIONS (links bridging different knowledge areas):
437
+
438
+ ${connectionSummary}
439
+
440
+ ---
441
+
442
+ SYNTHESIS TASK:
443
+
444
+ Based on these notes and the connections between them, please:
445
+
446
+ 1. Identify the key insights that emerge in direct response to the question.
447
+ 2. Highlight any unexpected connections between notes from different domains (${domainList}).
448
+ 3. Point out tensions, contradictions, or open questions the vault raises but does not resolve.
449
+ 4. Suggest what is notably absent — what the vault does NOT yet contain that would strengthen the understanding of this topic.
450
+ 5. Propose 2-3 new notes that would meaningfully extend this knowledge cluster.
451
+
452
+ Think like a scholar who has deeply internalized these ideas and is now synthesizing them for the first time.`
453
+ };
454
+ }
455
+
456
+ //#endregion
457
+ //#region src/zettelkasten/health.ts
458
+ function countComponents(nodes, edges) {
459
+ if (nodes.length === 0) return 0;
460
+ const parent = /* @__PURE__ */ new Map();
461
+ const rank = /* @__PURE__ */ new Map();
462
+ for (const n of nodes) {
463
+ parent.set(n, n);
464
+ rank.set(n, 0);
465
+ }
466
+ function find(x) {
467
+ let root = x;
468
+ while (parent.get(root) !== root) root = parent.get(root);
469
+ let current = x;
470
+ while (current !== root) {
471
+ const next = parent.get(current);
472
+ parent.set(current, root);
473
+ current = next;
474
+ }
475
+ return root;
476
+ }
477
+ function union(a, b) {
478
+ const ra = find(a);
479
+ const rb = find(b);
480
+ if (ra === rb) return;
481
+ const rankA = rank.get(ra) ?? 0;
482
+ const rankB = rank.get(rb) ?? 0;
483
+ if (rankA < rankB) parent.set(ra, rb);
484
+ else if (rankA > rankB) parent.set(rb, ra);
485
+ else {
486
+ parent.set(rb, ra);
487
+ rank.set(ra, rankA + 1);
488
+ }
489
+ }
490
+ for (const { source, target } of edges) if (parent.has(source) && parent.has(target)) union(source, target);
491
+ const roots = /* @__PURE__ */ new Set();
492
+ for (const n of nodes) roots.add(find(n));
493
+ return roots.size;
494
+ }
495
+ /**
496
+ * Audit the structural health of the Zettelkasten vault using graph metrics.
497
+ */
498
+ async function zettelHealth(backend, opts) {
499
+ const options = opts ?? {};
500
+ const scope = options.scope ?? "full";
501
+ const include = options.include ?? [
502
+ "dead_links",
503
+ "orphans",
504
+ "disconnected",
505
+ "low_connectivity"
506
+ ];
507
+ const computedAt = Date.now();
508
+ let totalFiles = 0;
509
+ if (scope === "full") totalFiles = await backend.countVaultFiles();
510
+ else if (scope === "project") {
511
+ const prefix = options.projectPath ?? "";
512
+ totalFiles = await backend.countVaultFilesWithPrefix(prefix);
513
+ } else {
514
+ const cutoff = computedAt - (options.recentDays ?? 30) * 864e5;
515
+ totalFiles = await backend.countVaultFilesAfter(cutoff);
516
+ }
517
+ let totalLinks = 0;
518
+ if (scope === "full") totalLinks = (await backend.getVaultLinkGraph()).length;
519
+ else if (scope === "project") {
520
+ const prefix = options.projectPath ?? "";
521
+ totalLinks = await backend.countVaultLinksWithPrefix(prefix);
522
+ } else {
523
+ const cutoff = computedAt - (options.recentDays ?? 30) * 864e5;
524
+ totalLinks = await backend.countVaultLinksAfter(cutoff);
525
+ }
526
+ let deadLinks = [];
527
+ if (include.includes("dead_links")) if (scope === "full") deadLinks = await backend.getDeadLinksWithLineNumbers();
528
+ else if (scope === "project") {
529
+ const prefix = options.projectPath ?? "";
530
+ deadLinks = await backend.getDeadLinksWithPrefix(prefix);
531
+ } else {
532
+ const cutoff = computedAt - (options.recentDays ?? 30) * 864e5;
533
+ deadLinks = await backend.getDeadLinksAfter(cutoff);
534
+ }
535
+ let orphans = [];
536
+ if (include.includes("orphans")) if (scope === "full") orphans = (await backend.getOrphans()).map((r) => r.vaultPath);
537
+ else if (scope === "project") {
538
+ const prefix = options.projectPath ?? "";
539
+ orphans = await backend.getOrphansWithPrefix(prefix);
540
+ } else {
541
+ const cutoff = computedAt - (options.recentDays ?? 30) * 864e5;
542
+ orphans = await backend.getOrphansAfter(cutoff);
543
+ }
544
+ let disconnectedClusters = 1;
545
+ if (include.includes("disconnected")) {
546
+ let allNodes;
547
+ let allEdges;
548
+ if (scope === "full") [allNodes, allEdges] = await Promise.all([backend.getAllVaultFilePaths(), backend.getVaultLinkEdges()]);
549
+ else if (scope === "project") {
550
+ const prefix = options.projectPath ?? "";
551
+ [allNodes, allEdges] = await Promise.all([backend.getVaultFilePathsWithPrefix(prefix), backend.getVaultLinkEdgesWithPrefix(prefix)]);
552
+ } else {
553
+ const cutoff = computedAt - (options.recentDays ?? 30) * 864e5;
554
+ [allNodes, allEdges] = await Promise.all([backend.getVaultFilePathsAfter(cutoff), backend.getVaultLinkEdgesAfter(cutoff)]);
555
+ }
556
+ disconnectedClusters = countComponents(allNodes, allEdges);
557
+ }
558
+ let lowConnectivity = [];
559
+ if (include.includes("low_connectivity")) if (scope === "full") lowConnectivity = await backend.getLowConnectivity();
560
+ else if (scope === "project") {
561
+ const prefix = options.projectPath ?? "";
562
+ lowConnectivity = await backend.getLowConnectivityWithPrefix(prefix);
563
+ } else {
564
+ const cutoff = computedAt - (options.recentDays ?? 30) * 864e5;
565
+ lowConnectivity = await backend.getLowConnectivityAfter(cutoff);
566
+ }
567
+ let linkConfidence;
568
+ if (scope === "full") try {
569
+ const samplePaths = await backend.getAllVaultFilePaths();
570
+ const sampleSize = Math.min(samplePaths.length, 200);
571
+ const sampled = samplePaths.slice(0, sampleSize);
572
+ let extracted = 0;
573
+ let inferred = 0;
574
+ let ambiguous = 0;
575
+ for (const path of sampled) {
576
+ const links = await backend.getLinksFromSource(path);
577
+ for (const link of links) {
578
+ const c = link.confidence ?? "EXTRACTED";
579
+ if (c === "EXTRACTED") extracted++;
580
+ else if (c === "INFERRED") inferred++;
581
+ else ambiguous++;
582
+ }
583
+ }
584
+ const factor = samplePaths.length > 0 ? samplePaths.length / sampleSize : 1;
585
+ linkConfidence = {
586
+ extracted: Math.round(extracted * factor),
587
+ inferred: Math.round(inferred * factor),
588
+ ambiguous: Math.round(ambiguous * factor)
589
+ };
590
+ } catch {}
591
+ const deadRatio = totalLinks > 0 ? deadLinks.length / totalLinks : 0;
592
+ const orphanRatio = totalFiles > 0 ? orphans.length / totalFiles : 0;
593
+ const lowConnRatio = totalFiles > 0 ? lowConnectivity.length / totalFiles : 0;
594
+ const healthScore = Math.round(100 * (1 - deadRatio) * (1 - orphanRatio * .5) * (1 - lowConnRatio * .3));
595
+ return {
596
+ totalFiles,
597
+ totalLinks,
598
+ deadLinks,
599
+ orphans,
600
+ disconnectedClusters,
601
+ lowConnectivity,
602
+ healthScore,
603
+ computedAt,
604
+ linkConfidence
605
+ };
606
+ }
607
+
608
+ //#endregion
609
+ //#region src/zettelkasten/suggest.ts
610
+ const MAX_CHUNKS = 5e3;
611
+ const SEMANTIC_WEIGHT = .5;
612
+ const TAG_WEIGHT = .2;
613
+ const NEIGHBOR_WEIGHT = .3;
614
+ function extractTagsFromChunkTexts(texts) {
615
+ const tags = /* @__PURE__ */ new Set();
616
+ for (const text of texts) {
617
+ const match = text.match(/^tags:\s*\n((?:[ \t]*-[ \t]*.+\n?)*)/m);
618
+ if (!match) continue;
619
+ const lines = match[1].split("\n");
620
+ for (const line of lines) {
621
+ const tagMatch = line.match(/^[ \t]*-[ \t]*(.+)/);
622
+ if (tagMatch) {
623
+ const tag = tagMatch[1].trim().toLowerCase();
624
+ if (tag) tags.add(tag);
625
+ }
626
+ }
627
+ }
628
+ return tags;
629
+ }
630
+ function jaccardSimilarity(a, b) {
631
+ if (a.size === 0 && b.size === 0) return 0;
632
+ let intersection = 0;
633
+ for (const tag of a) if (b.has(tag)) intersection++;
634
+ const union = a.size + b.size - intersection;
635
+ return union === 0 ? 0 : intersection / union;
636
+ }
637
+ function buildReason(semanticScore, tagScore, neighborScore, neighborCount) {
638
+ const signals = [
639
+ {
640
+ label: `Semantically similar (${semanticScore.toFixed(2)})`,
641
+ value: semanticScore * SEMANTIC_WEIGHT
642
+ },
643
+ {
644
+ label: `Shared tags (${tagScore.toFixed(2)} Jaccard)`,
645
+ value: tagScore * TAG_WEIGHT
646
+ },
647
+ {
648
+ label: `Linked by ${neighborCount} mutual connection${neighborCount !== 1 ? "s" : ""}`,
649
+ value: neighborScore * NEIGHBOR_WEIGHT
650
+ }
651
+ ];
652
+ signals.sort((a, b) => b.value - a.value);
653
+ return signals[0].label;
654
+ }
655
+ function suggestedWikilink(vaultPath) {
656
+ const base = basename(vaultPath);
657
+ return `[[${base.endsWith(".md") ? base.slice(0, -3) : base}]]`;
658
+ }
659
+ /**
660
+ * Proactively find notes worth linking to a given note, combining semantic similarity,
661
+ * shared tags, and graph-neighborhood signals into a ranked list of suggestions.
662
+ */
663
+ async function zettelSuggest(backend, opts) {
664
+ const limit = opts.limit ?? 5;
665
+ const excludeLinked = opts.excludeLinked ?? true;
666
+ const outboundLinks = await backend.getLinksFromSource(opts.notePath);
667
+ const linkedPaths = new Set(outboundLinks.filter((l) => l.targetPath !== null).map((l) => l.targetPath));
668
+ const chunkRows = await backend.getChunksWithEmbeddings(opts.vaultProjectId, MAX_CHUNKS);
669
+ const byPath = /* @__PURE__ */ new Map();
670
+ for (const row of chunkRows) {
671
+ const vec = deserializeEmbedding(row.embedding);
672
+ const entry = byPath.get(row.path);
673
+ if (!entry) byPath.set(row.path, {
674
+ sum: new Float32Array(vec),
675
+ count: 1
676
+ });
677
+ else {
678
+ for (let i = 0; i < vec.length; i++) entry.sum[i] += vec[i];
679
+ entry.count++;
680
+ }
681
+ }
682
+ const allEmbeddings = /* @__PURE__ */ new Map();
683
+ for (const [path, { sum, count }] of byPath) {
684
+ const avg = new Float32Array(sum.length);
685
+ for (let i = 0; i < sum.length; i++) avg[i] = sum[i] / count;
686
+ allEmbeddings.set(path, avg);
687
+ }
688
+ allEmbeddings.delete(opts.notePath);
689
+ const sourceEmbedding = allEmbeddings.get(opts.notePath) ?? null;
690
+ const sourceTags = extractTagsFromChunkTexts((await backend.getChunksForPath(opts.vaultProjectId, opts.notePath, 5)).map((r) => r.text));
691
+ const directTargets = (await backend.getLinksFromSource(opts.notePath)).filter((l) => l.targetPath !== null).map((l) => l.targetPath);
692
+ const friendLinkCounts = /* @__PURE__ */ new Map();
693
+ for (const target of directTargets) {
694
+ const friendLinks = await backend.getLinksFromSource(target);
695
+ for (const link of friendLinks) if (link.targetPath && link.targetPath !== opts.notePath) friendLinkCounts.set(link.targetPath, (friendLinkCounts.get(link.targetPath) ?? 0) + 1);
696
+ }
697
+ const maxFriendLinks = Math.max(1, ...friendLinkCounts.values());
698
+ const allFiles = await backend.getAllVaultFiles();
699
+ const suggestions = [];
700
+ for (const fileRow of allFiles) {
701
+ const vault_path = fileRow.vaultPath;
702
+ const title = fileRow.title;
703
+ if (vault_path === opts.notePath) continue;
704
+ if (excludeLinked && linkedPaths.has(vault_path)) continue;
705
+ let semanticScore = 0;
706
+ if (sourceEmbedding) {
707
+ const candidateEmbedding = allEmbeddings.get(vault_path);
708
+ if (candidateEmbedding) semanticScore = Math.max(0, cosineSimilarity(sourceEmbedding, candidateEmbedding));
709
+ }
710
+ let tagScore = 0;
711
+ if (allEmbeddings.has(vault_path)) tagScore = jaccardSimilarity(sourceTags, extractTagsFromChunkTexts((await backend.getChunksForPath(opts.vaultProjectId, vault_path, 5)).map((r) => r.text)));
712
+ const friendCount = friendLinkCounts.get(vault_path) ?? 0;
713
+ const neighborScore = friendCount / maxFriendLinks;
714
+ const score = SEMANTIC_WEIGHT * semanticScore + TAG_WEIGHT * tagScore + NEIGHBOR_WEIGHT * neighborScore;
715
+ if (score <= 0) continue;
716
+ const reason = buildReason(semanticScore, tagScore, neighborScore, friendCount);
717
+ suggestions.push({
718
+ path: vault_path,
719
+ title,
720
+ score,
721
+ semanticScore,
722
+ tagScore,
723
+ neighborScore,
724
+ reason,
725
+ suggestedWikilink: suggestedWikilink(vault_path)
726
+ });
727
+ }
728
+ suggestions.sort((a, b) => b.score - a.score);
729
+ return suggestions.slice(0, limit);
730
+ }
731
+
732
+ //#endregion
733
+ //#region src/zettelkasten/god-notes.ts
734
+ /** Patterns that identify structural/meta pages rather than concept notes. */
735
+ const STRUCTURAL_PATTERNS = [
736
+ /\bindex\b/i,
737
+ /\bMOC\b/,
738
+ /\bmaster\b/i,
739
+ /\btag\s*page/i,
740
+ /\bhome\b/i,
741
+ /\bdashboard\b/i,
742
+ /\btemplate/i,
743
+ /^_/
744
+ ];
745
+ function isStructuralNote(title, path) {
746
+ const text = title ?? path;
747
+ return STRUCTURAL_PATTERNS.some((pattern) => pattern.test(text));
748
+ }
749
+ /**
750
+ * Find hub/"god" notes in the vault — notes with the highest inbound link counts,
751
+ * excluding structural pages.
752
+ */
753
+ async function zettelGodNotes(backend, opts) {
754
+ const limit = opts?.limit ?? 20;
755
+ const minInbound = opts?.minInbound ?? 3;
756
+ const linkGraph = await backend.getVaultLinkGraph();
757
+ const inboundCounts = /* @__PURE__ */ new Map();
758
+ const outboundCounts = /* @__PURE__ */ new Map();
759
+ for (const { source_path, target_path } of linkGraph) {
760
+ inboundCounts.set(target_path, (inboundCounts.get(target_path) ?? 0) + 1);
761
+ outboundCounts.set(source_path, (outboundCounts.get(source_path) ?? 0) + 1);
762
+ }
763
+ const allFiles = await backend.getAllVaultFiles();
764
+ const titleMap = /* @__PURE__ */ new Map();
765
+ for (const f of allFiles) titleMap.set(f.vaultPath, f.title);
766
+ const totalVaultFiles = allFiles.length;
767
+ const allInbounds = allFiles.map((f) => inboundCounts.get(f.vaultPath) ?? 0);
768
+ allInbounds.sort((a, b) => a - b);
769
+ const medianInbound = allInbounds.length > 0 ? allInbounds[Math.floor(allInbounds.length / 2)] : 0;
770
+ const candidates = [];
771
+ for (const [path, inbound] of inboundCounts) {
772
+ if (inbound < minInbound) continue;
773
+ const title = titleMap.get(path) ?? null;
774
+ if (isStructuralNote(title, path)) continue;
775
+ const outbound = outboundCounts.get(path) ?? 0;
776
+ const totalDegree = inbound + outbound;
777
+ const inboundRatio = totalDegree > 0 ? inbound / totalDegree : 0;
778
+ candidates.push({
779
+ path,
780
+ title,
781
+ inboundCount: inbound,
782
+ outboundCount: outbound,
783
+ inboundRatio: Math.round(inboundRatio * 1e3) / 1e3
784
+ });
785
+ }
786
+ candidates.sort((a, b) => b.inboundCount - a.inboundCount);
787
+ return {
788
+ godNotes: candidates.slice(0, limit),
789
+ totalVaultFiles,
790
+ medianInbound
791
+ };
792
+ }
793
+
794
+ //#endregion
795
+ //#region src/zettelkasten/communities.ts
796
+ function buildUndirectedGraph(edges) {
797
+ const adj = /* @__PURE__ */ new Map();
798
+ const nodeSet = /* @__PURE__ */ new Set();
799
+ function getOrCreate(node) {
800
+ let m = adj.get(node);
801
+ if (!m) {
802
+ m = /* @__PURE__ */ new Map();
803
+ adj.set(node, m);
804
+ }
805
+ nodeSet.add(node);
806
+ return m;
807
+ }
808
+ let totalWeight = 0;
809
+ for (const { source_path, target_path } of edges) {
810
+ if (source_path === target_path) continue;
811
+ const aMap = getOrCreate(source_path);
812
+ const bMap = getOrCreate(target_path);
813
+ aMap.set(target_path, (aMap.get(target_path) ?? 0) + 1);
814
+ bMap.set(source_path, (bMap.get(source_path) ?? 0) + 1);
815
+ totalWeight += 1;
816
+ }
817
+ const degree = /* @__PURE__ */ new Map();
818
+ for (const [node, neighbors] of adj) {
819
+ let d = 0;
820
+ for (const w of neighbors.values()) d += w;
821
+ degree.set(node, d);
822
+ }
823
+ return {
824
+ nodes: Array.from(nodeSet),
825
+ adj,
826
+ totalWeight,
827
+ degree
828
+ };
829
+ }
830
+ /**
831
+ * Run one pass of Phase 1: local node movement.
832
+ * Returns true if any node changed community.
833
+ */
834
+ function louvainPhase1(graph, community, resolution) {
835
+ const m2 = 2 * graph.totalWeight;
836
+ if (m2 === 0) return false;
837
+ const communityDegreeSum = /* @__PURE__ */ new Map();
838
+ const communityInternalWeight = /* @__PURE__ */ new Map();
839
+ for (const node of graph.nodes) {
840
+ const c = community.get(node);
841
+ communityDegreeSum.set(c, (communityDegreeSum.get(c) ?? 0) + (graph.degree.get(node) ?? 0));
842
+ }
843
+ for (const [node, neighbors] of graph.adj) {
844
+ const nc = community.get(node);
845
+ for (const [neighbor, weight] of neighbors) if (community.get(neighbor) === nc) communityInternalWeight.set(nc, (communityInternalWeight.get(nc) ?? 0) + weight);
846
+ }
847
+ for (const [c, w] of communityInternalWeight) communityInternalWeight.set(c, w / 2);
848
+ let improved = false;
849
+ const shuffled = [...graph.nodes];
850
+ for (let i = shuffled.length - 1; i > 0; i--) {
851
+ const j = Math.floor(Math.random() * (i + 1));
852
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
853
+ }
854
+ for (const node of shuffled) {
855
+ const currentComm = community.get(node);
856
+ const ki = graph.degree.get(node) ?? 0;
857
+ const neighbors = graph.adj.get(node) ?? /* @__PURE__ */ new Map();
858
+ const weightToComm = /* @__PURE__ */ new Map();
859
+ for (const [neighbor, weight] of neighbors) {
860
+ const nc = community.get(neighbor);
861
+ weightToComm.set(nc, (weightToComm.get(nc) ?? 0) + weight);
862
+ }
863
+ communityDegreeSum.set(currentComm, (communityDegreeSum.get(currentComm) ?? 0) - ki);
864
+ const weightToCurrent = weightToComm.get(currentComm) ?? 0;
865
+ communityInternalWeight.set(currentComm, (communityInternalWeight.get(currentComm) ?? 0) - weightToCurrent);
866
+ let bestComm = currentComm;
867
+ let bestDelta = 0;
868
+ for (const [candidateComm, weightToCandidate] of weightToComm) {
869
+ const delta = weightToCandidate - resolution * (ki * (communityDegreeSum.get(candidateComm) ?? 0)) / m2;
870
+ if (delta > bestDelta) {
871
+ bestDelta = delta;
872
+ bestComm = candidateComm;
873
+ }
874
+ }
875
+ community.set(node, bestComm);
876
+ communityDegreeSum.set(bestComm, (communityDegreeSum.get(bestComm) ?? 0) + ki);
877
+ const weightToBest = weightToComm.get(bestComm) ?? 0;
878
+ communityInternalWeight.set(bestComm, (communityInternalWeight.get(bestComm) ?? 0) + weightToBest);
879
+ if (bestComm !== currentComm) improved = true;
880
+ }
881
+ return improved;
882
+ }
883
+ /**
884
+ * Compute modularity for a given partition.
885
+ */
886
+ function computeModularity(graph, community, resolution) {
887
+ const m2 = 2 * graph.totalWeight;
888
+ if (m2 === 0) return 0;
889
+ let q = 0;
890
+ for (const [node, neighbors] of graph.adj) {
891
+ const ci = community.get(node);
892
+ const ki = graph.degree.get(node) ?? 0;
893
+ for (const [neighbor, weight] of neighbors) if (community.get(neighbor) === ci) q += weight - resolution * (ki * (graph.degree.get(neighbor) ?? 0)) / m2;
894
+ }
895
+ return q / m2;
896
+ }
897
+ /**
898
+ * Run Louvain community detection on the vault link graph.
899
+ */
900
+ function runLouvain(graph, resolution) {
901
+ const community = /* @__PURE__ */ new Map();
902
+ let nextComm = 0;
903
+ for (const node of graph.nodes) community.set(node, nextComm++);
904
+ const MAX_ITERATIONS = 20;
905
+ for (let iter = 0; iter < MAX_ITERATIONS; iter++) if (!louvainPhase1(graph, community, resolution)) break;
906
+ const commRemap = /* @__PURE__ */ new Map();
907
+ let remapIdx = 0;
908
+ for (const [, c] of community) if (!commRemap.has(c)) commRemap.set(c, remapIdx++);
909
+ for (const [node, c] of community) community.set(node, commRemap.get(c));
910
+ return community;
911
+ }
912
+ const STOP_WORDS = new Set([
913
+ "the",
914
+ "and",
915
+ "for",
916
+ "are",
917
+ "but",
918
+ "not",
919
+ "you",
920
+ "all",
921
+ "can",
922
+ "her",
923
+ "was",
924
+ "one",
925
+ "our",
926
+ "out",
927
+ "has",
928
+ "had",
929
+ "how",
930
+ "its",
931
+ "may",
932
+ "new",
933
+ "now",
934
+ "old",
935
+ "see",
936
+ "way",
937
+ "who",
938
+ "did",
939
+ "get",
940
+ "let",
941
+ "say",
942
+ "she",
943
+ "too",
944
+ "use",
945
+ "from",
946
+ "with",
947
+ "this",
948
+ "that",
949
+ "will",
950
+ "been",
951
+ "have",
952
+ "each",
953
+ "make",
954
+ "like",
955
+ "long",
956
+ "look",
957
+ "many",
958
+ "them",
959
+ "then",
960
+ "what",
961
+ "when",
962
+ "some",
963
+ "time",
964
+ "very",
965
+ "your",
966
+ "about",
967
+ "could",
968
+ "into",
969
+ "just",
970
+ "more",
971
+ "note",
972
+ "notes",
973
+ "than",
974
+ "over"
975
+ ]);
976
+ function generateCommunityLabel(titles) {
977
+ const wordCounts = /* @__PURE__ */ new Map();
978
+ for (const title of titles) {
979
+ if (!title) continue;
980
+ const words = title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w));
981
+ for (const word of words) wordCounts.set(word, (wordCounts.get(word) ?? 0) + 1);
982
+ }
983
+ return [...wordCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([w]) => w).join(" / ") || "unnamed";
984
+ }
985
+ function getTopFolder(path) {
986
+ const slash = path.indexOf("/");
987
+ return slash === -1 ? path : path.slice(0, slash);
988
+ }
989
+ /**
990
+ * Detect communities in the vault link graph using the Louvain algorithm.
991
+ * Returns clusters of densely connected notes with labels and cohesion scores.
992
+ */
993
+ async function zettelCommunities(backend, opts) {
994
+ const minSize = opts?.minSize ?? 3;
995
+ const maxCommunities = opts?.maxCommunities ?? 20;
996
+ const resolution = opts?.resolution ?? 1;
997
+ const graph = buildUndirectedGraph(await backend.getVaultLinkGraph());
998
+ if (graph.nodes.length === 0) return {
999
+ communities: [],
1000
+ totalNodes: 0,
1001
+ totalEdges: 0,
1002
+ modularity: 0
1003
+ };
1004
+ const communityMap = runLouvain(graph, resolution);
1005
+ const modularity = computeModularity(graph, communityMap, resolution);
1006
+ const groups = /* @__PURE__ */ new Map();
1007
+ for (const [node, comm] of communityMap) {
1008
+ const arr = groups.get(comm);
1009
+ if (arr) arr.push(node);
1010
+ else groups.set(comm, [node]);
1011
+ }
1012
+ const allFiles = await backend.getAllVaultFiles();
1013
+ const titleMap = /* @__PURE__ */ new Map();
1014
+ for (const f of allFiles) titleMap.set(f.vaultPath, f.title);
1015
+ const communities = [];
1016
+ let commId = 0;
1017
+ for (const [, members] of groups) {
1018
+ if (members.length < minSize) continue;
1019
+ const memberSet = new Set(members);
1020
+ const label = generateCommunityLabel(members.map((p) => titleMap.get(p) ?? null));
1021
+ const nodes = members.map((path) => {
1022
+ const neighbors = graph.adj.get(path) ?? /* @__PURE__ */ new Map();
1023
+ let internalDeg = 0;
1024
+ for (const [neighbor, weight] of neighbors) if (memberSet.has(neighbor)) internalDeg += weight;
1025
+ return {
1026
+ path,
1027
+ title: titleMap.get(path) ?? null,
1028
+ internalDegree: internalDeg
1029
+ };
1030
+ });
1031
+ nodes.sort((a, b) => b.internalDegree - a.internalDegree);
1032
+ let internalEdges = 0;
1033
+ for (const node of nodes) internalEdges += node.internalDegree;
1034
+ internalEdges /= 2;
1035
+ const possibleEdges = members.length * (members.length - 1) / 2;
1036
+ const cohesion = possibleEdges > 0 ? Math.round(internalEdges / possibleEdges * 1e3) / 1e3 : 0;
1037
+ const folderCounts = /* @__PURE__ */ new Map();
1038
+ for (const path of members) {
1039
+ const folder = getTopFolder(path);
1040
+ folderCounts.set(folder, (folderCounts.get(folder) ?? 0) + 1);
1041
+ }
1042
+ const topFolders = [...folderCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([f]) => f);
1043
+ communities.push({
1044
+ id: commId++,
1045
+ label,
1046
+ nodes,
1047
+ size: members.length,
1048
+ cohesion,
1049
+ topFolders
1050
+ });
1051
+ }
1052
+ communities.sort((a, b) => b.size - a.size);
1053
+ return {
1054
+ communities: communities.slice(0, maxCommunities),
1055
+ totalNodes: graph.nodes.length,
1056
+ totalEdges: graph.totalWeight,
1057
+ modularity: Math.round(modularity * 1e4) / 1e4
1058
+ };
1059
+ }
1060
+
1061
+ //#endregion
1062
+ export { zettelCommunities, zettelConverse, zettelExplore, zettelGodNotes, zettelHealth, zettelSuggest, zettelSurprise, zettelThemes };
1063
+ //# sourceMappingURL=zettelkasten-C8BikWss.mjs.map