@drewpayment/mink 0.1.0 → 0.2.1

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.
@@ -1,6 +1,6 @@
1
1
  import { watch, type FSWatcher } from "fs";
2
2
  import { existsSync } from "fs";
3
- import { basename, join, extname } from "path";
3
+ import { basename, dirname, join, extname } from "path";
4
4
  import { projectDir, designCapturesDir } from "./paths";
5
5
  import {
6
6
  loadOverview,
@@ -17,6 +17,7 @@ import {
17
17
  } from "./dashboard-api";
18
18
  import { listRegisteredProjects, getProjectMeta } from "./project-registry";
19
19
  import { generateProjectId } from "./project-id";
20
+ import { runtimeFile, runtimeServe, runtimeSpawn } from "./runtime";
20
21
  import type { StateFileId, StateChangeEvent } from "../types/dashboard";
21
22
  import type { RegisteredProject } from "./project-registry";
22
23
 
@@ -262,10 +263,10 @@ export interface DashboardServer {
262
263
  close(): void;
263
264
  }
264
265
 
265
- export function startDashboardServer(
266
+ export async function startDashboardServer(
266
267
  cwd: string,
267
268
  options: { port?: number; hostname?: string; open?: boolean } = {}
268
- ): DashboardServer {
269
+ ): Promise<DashboardServer> {
269
270
  const port = options.port ?? 4040;
270
271
  const hostname = options.hostname ?? "127.0.0.1";
271
272
 
@@ -295,13 +296,15 @@ export function startDashboardServer(
295
296
  });
296
297
 
297
298
  // Resolve the Next.js static build directory
298
- const dashboardOutDir = join(
299
- import.meta.dir,
300
- "..",
301
- "..",
302
- "dashboard",
303
- "out"
304
- );
299
+ // Walk up from import.meta.url to find the package root (where package.json lives).
300
+ // From source: src/core/ → ../../ From compiled bundle: dist/ → ../
301
+ const __dir = dirname(new URL(import.meta.url).pathname);
302
+ let pkgRoot = __dir;
303
+ while (pkgRoot !== dirname(pkgRoot)) {
304
+ if (existsSync(join(pkgRoot, "package.json"))) break;
305
+ pkgRoot = dirname(pkgRoot);
306
+ }
307
+ const dashboardOutDir = join(pkgRoot, "dashboard", "out");
305
308
  const dashboardBuilt = existsSync(join(dashboardOutDir, "index.html"));
306
309
  let clientIdCounter = 0;
307
310
 
@@ -311,7 +314,20 @@ export function startDashboardServer(
311
314
  );
312
315
  }
313
316
 
314
- const server = Bun.serve({
317
+ async function serveFile(
318
+ filePath: string,
319
+ contentType: string
320
+ ): Promise<Response | null> {
321
+ const file = runtimeFile(filePath);
322
+ if (await file.exists()) {
323
+ return new Response(await file.bytes() as unknown as BodyInit, {
324
+ headers: { "Content-Type": contentType },
325
+ });
326
+ }
327
+ return null;
328
+ }
329
+
330
+ const server = await runtimeServe({
315
331
  port,
316
332
  hostname,
317
333
  idleTimeout: 0, // Disable idle timeout — SSE connections are long-lived
@@ -343,30 +359,24 @@ export function startDashboardServer(
343
359
  return jsonResponse({ error: "Forbidden" }, 403);
344
360
  }
345
361
 
346
- const file = Bun.file(filePath);
347
- if (await file.exists()) {
348
- const ext = extname(filePath);
349
- const contentType = MIME_TYPES[ext] || "application/octet-stream";
350
- return new Response(file, {
351
- headers: { "Content-Type": contentType },
352
- });
353
- }
362
+ const ext = extname(filePath);
363
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
364
+ const served = await serveFile(filePath, contentType);
365
+ if (served) return served;
354
366
 
355
367
  // Client-side routing fallback: try {pathname}.html then index.html
356
- const htmlFile = Bun.file(filePath + ".html");
357
- if (await htmlFile.exists()) {
358
- return new Response(htmlFile, {
359
- headers: { "Content-Type": "text/html; charset=utf-8" },
360
- });
361
- }
368
+ const htmlServed = await serveFile(
369
+ filePath + ".html",
370
+ "text/html; charset=utf-8"
371
+ );
372
+ if (htmlServed) return htmlServed;
362
373
 
363
374
  // SPA fallback — serve index.html for unmatched routes
364
- const indexFile = Bun.file(join(dashboardOutDir, "index.html"));
365
- if (await indexFile.exists()) {
366
- return new Response(indexFile, {
367
- headers: { "Content-Type": "text/html; charset=utf-8" },
368
- });
369
- }
375
+ const indexServed = await serveFile(
376
+ join(dashboardOutDir, "index.html"),
377
+ "text/html; charset=utf-8"
378
+ );
379
+ if (indexServed) return indexServed;
370
380
  }
371
381
  }
372
382
 
@@ -376,8 +386,6 @@ export function startDashboardServer(
376
386
  const stream = new ReadableStream<Uint8Array>({
377
387
  start(controller) {
378
388
  sseManager.addClient(clientId, controller);
379
- // Send initial comment immediately to establish the stream
380
- // and prevent Bun from treating it as idle/complete
381
389
  controller.enqueue(encoder.encode(": connected\nretry: 3000\n\n"));
382
390
  },
383
391
  cancel() {
@@ -435,15 +443,11 @@ export function startDashboardServer(
435
443
  return jsonResponse({ error: "Invalid filename" }, 400);
436
444
  }
437
445
  const imgPath = join(designCapturesDir(resolvedCwd), filename);
438
- const file = Bun.file(imgPath);
439
- if (await file.exists()) {
440
- return new Response(file, {
441
- headers: {
442
- "Content-Type": "image/jpeg",
443
- "Cache-Control": "public, max-age=60",
444
- "Access-Control-Allow-Origin": "*",
445
- },
446
- });
446
+ const served = await serveFile(imgPath, "image/jpeg");
447
+ if (served) {
448
+ served.headers.set("Cache-Control", "public, max-age=60");
449
+ served.headers.set("Access-Control-Allow-Origin", "*");
450
+ return served;
447
451
  }
448
452
  return jsonResponse({ error: "Image not found" }, 404);
449
453
  }
@@ -558,12 +562,7 @@ export function startDashboardServer(
558
562
  : platform === "win32"
559
563
  ? ["cmd", "/c", "start", serverUrl]
560
564
  : ["xdg-open", serverUrl];
561
- const proc = Bun.spawn(cmd, {
562
- stdout: "ignore",
563
- stderr: "ignore",
564
- stdin: "ignore",
565
- });
566
- proc.unref();
565
+ runtimeSpawn(cmd).unref();
567
566
  } catch {
568
567
  // Browser open is best-effort
569
568
  }
@@ -0,0 +1,262 @@
1
+ import { join } from "path";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
3
+ import { atomicWriteJson, safeReadJson } from "./fs-utils";
4
+ import { vaultIndexPath, resolveVaultPath } from "./vault";
5
+ import type { VaultIndex, VaultIndexEntry, NoteCategory } from "../types/note";
6
+
7
+ export function createEmptyVaultIndex(): VaultIndex {
8
+ return {
9
+ lastScanTimestamp: "",
10
+ totalNotes: 0,
11
+ entries: {},
12
+ };
13
+ }
14
+
15
+ export function loadVaultIndex(): VaultIndex {
16
+ const raw = safeReadJson(vaultIndexPath());
17
+ if (raw === null || typeof raw !== "object") return createEmptyVaultIndex();
18
+ const obj = raw as Record<string, unknown>;
19
+ if (typeof obj.entries !== "object" || obj.entries === null) {
20
+ return createEmptyVaultIndex();
21
+ }
22
+ return raw as VaultIndex;
23
+ }
24
+
25
+ export function saveVaultIndex(index: VaultIndex): void {
26
+ atomicWriteJson(vaultIndexPath(), index);
27
+ }
28
+
29
+ export function updateVaultEntry(
30
+ index: VaultIndex,
31
+ entry: VaultIndexEntry
32
+ ): void {
33
+ index.entries[entry.filePath] = entry;
34
+ index.totalNotes = Object.keys(index.entries).length;
35
+ }
36
+
37
+ export function removeVaultEntry(
38
+ index: VaultIndex,
39
+ filePath: string
40
+ ): void {
41
+ delete index.entries[filePath];
42
+ index.totalNotes = Object.keys(index.entries).length;
43
+ }
44
+
45
+ export function extractNoteTitle(content: string): string {
46
+ // Try first heading
47
+ const match = content.match(/^#\s+(.+)$/m);
48
+ if (match) return match[1].trim();
49
+ // Try frontmatter title
50
+ const fmMatch = content.match(/^title:\s*["']?(.+?)["']?\s*$/m);
51
+ if (fmMatch) return fmMatch[1].trim();
52
+ // First non-empty line after frontmatter
53
+ const lines = content.split("\n");
54
+ let pastFrontmatter = !content.startsWith("---");
55
+ let fmDashCount = 0;
56
+ for (const line of lines) {
57
+ if (!pastFrontmatter) {
58
+ if (line.trim() === "---") fmDashCount++;
59
+ if (fmDashCount >= 2) pastFrontmatter = true;
60
+ continue;
61
+ }
62
+ const trimmed = line.trim();
63
+ if (trimmed && !trimmed.startsWith("#")) return trimmed;
64
+ }
65
+ return "Untitled";
66
+ }
67
+
68
+ export function extractNoteTags(content: string): string[] {
69
+ // Parse tags from frontmatter
70
+ const fmMatch = content.match(/^tags:\s*\[(.+)\]/m);
71
+ if (fmMatch) {
72
+ return fmMatch[1]
73
+ .split(",")
74
+ .map((t) => t.trim().replace(/["']/g, ""))
75
+ .filter(Boolean);
76
+ }
77
+ // Try multiline tags
78
+ const lines = content.split("\n");
79
+ const tagsIdx = lines.findIndex((l) => l.startsWith("tags:"));
80
+ if (tagsIdx === -1) return [];
81
+ const tags: string[] = [];
82
+ for (let i = tagsIdx + 1; i < lines.length; i++) {
83
+ const line = lines[i];
84
+ if (line.match(/^\s+-\s+/)) {
85
+ tags.push(line.replace(/^\s+-\s+/, "").trim().replace(/["']/g, ""));
86
+ } else {
87
+ break;
88
+ }
89
+ }
90
+ return tags;
91
+ }
92
+
93
+ export function extractNoteCategory(content: string): NoteCategory {
94
+ const match = content.match(/^category:\s*(.+)$/m);
95
+ if (match) {
96
+ const cat = match[1].trim().replace(/["']/g, "") as NoteCategory;
97
+ if (
98
+ ["inbox", "projects", "areas", "resources", "archives"].includes(cat)
99
+ ) {
100
+ return cat;
101
+ }
102
+ }
103
+ return "inbox";
104
+ }
105
+
106
+ function estimateTokens(content: string): number {
107
+ return Math.ceil(content.length / 3.75);
108
+ }
109
+
110
+ export function buildEntryFromContent(
111
+ filePath: string,
112
+ content: string,
113
+ lastModified: string
114
+ ): VaultIndexEntry {
115
+ const title = extractNoteTitle(content);
116
+ const tags = extractNoteTags(content);
117
+ const category = extractNoteCategory(content);
118
+ // Description: first non-heading, non-frontmatter line
119
+ let description = "";
120
+ const lines = content.split("\n");
121
+ let pastFrontmatter = !content.startsWith("---");
122
+ let seenFmEnd = false;
123
+ for (const line of lines) {
124
+ if (!pastFrontmatter) {
125
+ if (line === "---" && seenFmEnd) pastFrontmatter = true;
126
+ if (line === "---") seenFmEnd = true;
127
+ continue;
128
+ }
129
+ const trimmed = line.trim();
130
+ if (trimmed && !trimmed.startsWith("#")) {
131
+ description = trimmed.slice(0, 120);
132
+ break;
133
+ }
134
+ }
135
+
136
+ return {
137
+ filePath,
138
+ title,
139
+ description,
140
+ tags,
141
+ category,
142
+ estimatedTokens: estimateTokens(content),
143
+ lastModified,
144
+ };
145
+ }
146
+
147
+ export function updateVaultIndexForFile(
148
+ filePath: string,
149
+ content: string
150
+ ): void {
151
+ const index = loadVaultIndex();
152
+ const root = resolveVaultPath();
153
+ const relativePath = filePath.startsWith(root)
154
+ ? filePath.slice(root.length + 1)
155
+ : filePath;
156
+ const entry = buildEntryFromContent(
157
+ relativePath,
158
+ content,
159
+ new Date().toISOString()
160
+ );
161
+ updateVaultEntry(index, entry);
162
+ index.lastScanTimestamp = new Date().toISOString();
163
+ saveVaultIndex(index);
164
+ }
165
+
166
+ export function rebuildVaultIndex(): VaultIndex {
167
+ const root = resolveVaultPath();
168
+ const index = createEmptyVaultIndex();
169
+ const files = collectAllMarkdown(root);
170
+
171
+ for (const file of files) {
172
+ try {
173
+ const content = readFileSync(file.absolutePath, "utf-8");
174
+ const entry = buildEntryFromContent(
175
+ file.relativePath,
176
+ content,
177
+ new Date(file.mtimeMs).toISOString()
178
+ );
179
+ updateVaultEntry(index, entry);
180
+ } catch {
181
+ // Skip unreadable files
182
+ }
183
+ }
184
+
185
+ index.lastScanTimestamp = new Date().toISOString();
186
+ saveVaultIndex(index);
187
+ return index;
188
+ }
189
+
190
+ export function searchVaultIndex(
191
+ term: string
192
+ ): VaultIndexEntry[] {
193
+ const index = loadVaultIndex();
194
+ const lower = term.toLowerCase();
195
+ return Object.values(index.entries).filter(
196
+ (e) =>
197
+ e.title.toLowerCase().includes(lower) ||
198
+ e.description.toLowerCase().includes(lower) ||
199
+ e.tags.some((t) => t.toLowerCase().includes(lower)) ||
200
+ e.filePath.toLowerCase().includes(lower)
201
+ );
202
+ }
203
+
204
+ export function getVaultTags(): string[] {
205
+ const index = loadVaultIndex();
206
+ const tags = new Set<string>();
207
+ for (const entry of Object.values(index.entries)) {
208
+ for (const tag of entry.tags) {
209
+ tags.add(tag);
210
+ }
211
+ }
212
+ return [...tags].sort();
213
+ }
214
+
215
+ export function getRecentNotes(n: number): VaultIndexEntry[] {
216
+ const index = loadVaultIndex();
217
+ return Object.values(index.entries)
218
+ .sort((a, b) => b.lastModified.localeCompare(a.lastModified))
219
+ .slice(0, n);
220
+ }
221
+
222
+ interface ScannedMarkdown {
223
+ absolutePath: string;
224
+ relativePath: string;
225
+ mtimeMs: number;
226
+ }
227
+
228
+ const VAULT_EXCLUDES = new Set([
229
+ ".obsidian",
230
+ ".git",
231
+ ".mink-vault.json",
232
+ ".mink-index.json",
233
+ "node_modules",
234
+ ]);
235
+
236
+ function collectAllMarkdown(rootPath: string): ScannedMarkdown[] {
237
+ const files: ScannedMarkdown[] = [];
238
+ function walk(dir: string) {
239
+ try {
240
+ const entries = readdirSync(dir, { withFileTypes: true });
241
+ for (const entry of entries) {
242
+ if (VAULT_EXCLUDES.has(entry.name)) continue;
243
+ if (entry.name.startsWith(".")) continue;
244
+ const fullPath = join(dir, entry.name);
245
+ if (entry.isDirectory()) {
246
+ walk(fullPath);
247
+ } else if (entry.name.endsWith(".md")) {
248
+ const stat = statSync(fullPath);
249
+ files.push({
250
+ absolutePath: fullPath,
251
+ relativePath: fullPath.slice(rootPath.length + 1),
252
+ mtimeMs: stat.mtimeMs,
253
+ });
254
+ }
255
+ }
256
+ } catch {
257
+ // Skip unreadable dirs
258
+ }
259
+ }
260
+ walk(rootPath);
261
+ return files;
262
+ }
@@ -0,0 +1,161 @@
1
+ import { join } from "path";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
3
+ import { atomicWriteText, safeAppendText } from "./fs-utils";
4
+ import { vaultRoot, vaultMasterIndexPath } from "./vault";
5
+ import type { VaultIndex } from "../types/note";
6
+
7
+ const WIKILINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
8
+
9
+ export function extractWikilinks(content: string): string[] {
10
+ const links: string[] = [];
11
+ let match: RegExpExecArray | null;
12
+ const re = new RegExp(WIKILINK_RE.source, "g");
13
+ while ((match = re.exec(content)) !== null) {
14
+ links.push(match[1].trim());
15
+ }
16
+ return [...new Set(links)];
17
+ }
18
+
19
+ export function insertWikilinks(
20
+ content: string,
21
+ targets: string[]
22
+ ): string {
23
+ let result = content;
24
+ for (const target of targets) {
25
+ // Don't insert if already a wikilink
26
+ if (result.includes(`[[${target}]]`)) continue;
27
+ // Don't insert inside frontmatter
28
+ const fmEnd = findFrontmatterEnd(result);
29
+ const body = result.slice(fmEnd);
30
+ // Replace first occurrence of the target text (case-insensitive, word boundary)
31
+ const re = new RegExp(`\\b(${escapeRegex(target)})\\b`, "i");
32
+ const replaced = body.replace(re, `[[$1]]`);
33
+ if (replaced !== body) {
34
+ result = result.slice(0, fmEnd) + replaced;
35
+ }
36
+ }
37
+ return result;
38
+ }
39
+
40
+ export function addBacklink(
41
+ targetNotePath: string,
42
+ sourceTitle: string
43
+ ): void {
44
+ if (!existsSync(targetNotePath)) return;
45
+ const content = readFileSync(targetNotePath, "utf-8");
46
+
47
+ // Don't add duplicate backlinks
48
+ if (content.includes(`[[${sourceTitle}]]`)) return;
49
+
50
+ const backlinkSection = "\n\n## Backlinks\n";
51
+ const backlinkEntry = `- [[${sourceTitle}]]\n`;
52
+
53
+ if (content.includes("## Backlinks")) {
54
+ // Append to existing backlinks section
55
+ const idx = content.indexOf("## Backlinks");
56
+ const sectionEnd = content.indexOf("\n## ", idx + 1);
57
+ const insertAt = sectionEnd === -1 ? content.length : sectionEnd;
58
+ const updated =
59
+ content.slice(0, insertAt).trimEnd() +
60
+ "\n" +
61
+ backlinkEntry +
62
+ (sectionEnd === -1 ? "" : content.slice(sectionEnd));
63
+ atomicWriteText(targetNotePath, updated);
64
+ } else {
65
+ safeAppendText(targetNotePath, backlinkSection + backlinkEntry);
66
+ }
67
+ }
68
+
69
+ export function updateMasterIndex(vaultRootPath: string): void {
70
+ const now = new Date().toISOString().split("T")[0];
71
+ const sections: string[] = [
72
+ `---`,
73
+ `updated: "${new Date().toISOString()}"`,
74
+ `---`,
75
+ ``,
76
+ `# Knowledge Base`,
77
+ ``,
78
+ `> Last updated: ${now}`,
79
+ ``,
80
+ ];
81
+
82
+ const categories = [
83
+ { name: "Inbox", dir: "inbox", emoji: "" },
84
+ { name: "Projects", dir: "projects", emoji: "" },
85
+ { name: "Areas", dir: "areas", emoji: "" },
86
+ { name: "Resources", dir: "resources", emoji: "" },
87
+ { name: "Archives", dir: "archives", emoji: "" },
88
+ { name: "Patterns", dir: "patterns", emoji: "" },
89
+ ];
90
+
91
+ for (const cat of categories) {
92
+ const dirPath = join(vaultRootPath, cat.dir);
93
+ if (!existsSync(dirPath)) continue;
94
+
95
+ const files = collectMarkdownFiles(dirPath, vaultRootPath);
96
+ if (files.length === 0 && cat.dir !== "inbox") continue;
97
+
98
+ sections.push(`## ${cat.name}`);
99
+ sections.push("");
100
+
101
+ if (files.length === 0) {
102
+ sections.push("*No notes yet.*");
103
+ } else {
104
+ // Show up to 20 most recent
105
+ const sorted = files
106
+ .sort((a, b) => b.mtime - a.mtime)
107
+ .slice(0, 20);
108
+ for (const file of sorted) {
109
+ sections.push(`- [[${file.title}]]`);
110
+ }
111
+ if (files.length > 20) {
112
+ sections.push(`- *...and ${files.length - 20} more*`);
113
+ }
114
+ }
115
+ sections.push("");
116
+ }
117
+
118
+ const indexPath = vaultMasterIndexPath();
119
+ atomicWriteText(indexPath, sections.join("\n"));
120
+ }
121
+
122
+ interface CollectedFile {
123
+ title: string;
124
+ relativePath: string;
125
+ mtime: number;
126
+ }
127
+
128
+ function collectMarkdownFiles(
129
+ dirPath: string,
130
+ rootPath: string
131
+ ): CollectedFile[] {
132
+ const files: CollectedFile[] = [];
133
+ try {
134
+ const entries = readdirSync(dirPath, { withFileTypes: true });
135
+ for (const entry of entries) {
136
+ const fullPath = join(dirPath, entry.name);
137
+ if (entry.isDirectory()) {
138
+ files.push(...collectMarkdownFiles(fullPath, rootPath));
139
+ } else if (entry.name.endsWith(".md") && !entry.name.startsWith("_")) {
140
+ const stat = statSync(fullPath);
141
+ const title = entry.name.replace(/\.md$/, "");
142
+ const relativePath = fullPath.slice(rootPath.length + 1);
143
+ files.push({ title, relativePath, mtime: stat.mtimeMs });
144
+ }
145
+ }
146
+ } catch {
147
+ // Directory might not exist or be readable
148
+ }
149
+ return files;
150
+ }
151
+
152
+ function findFrontmatterEnd(content: string): number {
153
+ if (!content.startsWith("---")) return 0;
154
+ const endIdx = content.indexOf("---", 3);
155
+ if (endIdx === -1) return 0;
156
+ return endIdx + 3;
157
+ }
158
+
159
+ function escapeRegex(str: string): string {
160
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
161
+ }