@drewpayment/mink 0.5.1 → 0.6.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 (125) hide show
  1. package/README.md +21 -0
  2. package/dashboard/out/404.html +1 -1
  3. package/dashboard/out/_next/static/Dw8C--0lGz5BIGsnG-e5H/_buildManifest.js +1 -0
  4. package/dashboard/out/_next/static/chunks/189-fe789442321eb5eb.js +1 -0
  5. package/dashboard/out/_next/static/chunks/255-6b79f309a27fb98b.js +1 -0
  6. package/dashboard/out/_next/static/chunks/4bd1b696-c023c6e3521b1417.js +1 -0
  7. package/dashboard/out/_next/static/chunks/738-11c31dcbdbb98d77.js +1 -0
  8. package/dashboard/out/_next/static/chunks/926-6421b9e9b03abc7b.js +1 -0
  9. package/dashboard/out/_next/static/chunks/app/(panels)/action-log/page-1f507b433af52d16.js +1 -0
  10. package/dashboard/out/_next/static/chunks/app/(panels)/activity/page-096a97ba539d5323.js +1 -0
  11. package/dashboard/out/_next/static/chunks/app/(panels)/bugs/page-449d31c133432458.js +1 -0
  12. package/dashboard/out/_next/static/chunks/app/(panels)/capture/page-c6617aa0a8a7333e.js +1 -0
  13. package/dashboard/out/_next/static/chunks/app/(panels)/config/page-aa0a0623b3fdd0d8.js +1 -0
  14. package/dashboard/out/_next/static/chunks/app/(panels)/daemon/page-7cd3fac2f5d87a0d.js +1 -0
  15. package/dashboard/out/_next/static/chunks/app/(panels)/design/page-5304675c96b6793b.js +1 -0
  16. package/dashboard/out/_next/static/chunks/app/(panels)/discord/page-9940dde80ba2a69e.js +1 -0
  17. package/dashboard/out/_next/static/chunks/app/(panels)/file-index/page-ecd8a753614e981e.js +1 -0
  18. package/dashboard/out/_next/static/chunks/app/(panels)/insights/page-7909d8beb8d8ef7a.js +1 -0
  19. package/dashboard/out/_next/static/chunks/app/(panels)/learning/page-b766adc79099adb4.js +1 -0
  20. package/dashboard/out/_next/static/chunks/app/(panels)/overview/page-7a9e86dcde67d6a9.js +1 -0
  21. package/dashboard/out/_next/static/chunks/app/(panels)/scheduler/page-a88f93204c9742a1.js +1 -0
  22. package/dashboard/out/_next/static/chunks/app/(panels)/sync/page-8a9ad4c36aa6cb65.js +1 -0
  23. package/dashboard/out/_next/static/chunks/app/(panels)/tokens/page-8dac7d50d4db2756.js +1 -0
  24. package/dashboard/out/_next/static/chunks/app/(panels)/waste/page-bcf56144faf7d133.js +1 -0
  25. package/dashboard/out/_next/static/chunks/app/(panels)/wiki/page-a32fdbd0bf58b30b.js +1 -0
  26. package/dashboard/out/_next/static/chunks/app/_not-found/page-dc2312ec30d73c4e.js +1 -0
  27. package/dashboard/out/_next/static/chunks/app/layout-782cd26e0ccc4514.js +1 -0
  28. package/dashboard/out/_next/static/chunks/app/page-6aca8457abc5d313.js +1 -0
  29. package/dashboard/out/_next/static/chunks/framework-050c1f32293f7182.js +1 -0
  30. package/dashboard/out/_next/static/chunks/main-app-c2dc0acf542ec1c6.js +1 -0
  31. package/dashboard/out/_next/static/chunks/main-ed79d05490604b83.js +1 -0
  32. package/dashboard/out/_next/static/chunks/pages/_app-7d307437aca18ad4.js +1 -0
  33. package/dashboard/out/_next/static/chunks/pages/_error-cb2a52f75f2162e2.js +1 -0
  34. package/dashboard/out/_next/static/chunks/webpack-4e3139a490df1cfe.js +1 -0
  35. package/dashboard/out/_next/static/css/5e43917ea49c5b3e.css +1 -0
  36. package/dashboard/out/_next/static/media/0aa834ed78bf6d07-s.woff2 +0 -0
  37. package/dashboard/out/_next/static/media/19cfc7226ec3afaa-s.woff2 +0 -0
  38. package/dashboard/out/_next/static/media/21350d82a1f187e9-s.woff2 +0 -0
  39. package/dashboard/out/_next/static/media/67957d42bae0796d-s.woff2 +0 -0
  40. package/dashboard/out/_next/static/media/886030b0b59bc5a7-s.woff2 +0 -0
  41. package/dashboard/out/_next/static/media/8e9860b6e62d6359-s.woff2 +0 -0
  42. package/dashboard/out/_next/static/media/939c4f875ee75fbb-s.woff2 +0 -0
  43. package/dashboard/out/_next/static/media/ba9851c3c22cd980-s.woff2 +0 -0
  44. package/dashboard/out/_next/static/media/bb3ef058b751a6ad-s.p.woff2 +0 -0
  45. package/dashboard/out/_next/static/media/c5fe6dc8356a8c31-s.woff2 +0 -0
  46. package/dashboard/out/_next/static/media/df0a9ae256c0569c-s.woff2 +0 -0
  47. package/dashboard/out/_next/static/media/e4af272ccee01ff0-s.p.woff2 +0 -0
  48. package/dashboard/out/_next/static/media/f911b923c6adde36-s.woff2 +0 -0
  49. package/dashboard/out/action-log.html +1 -1
  50. package/dashboard/out/action-log.txt +23 -22
  51. package/dashboard/out/activity.html +1 -1
  52. package/dashboard/out/activity.txt +23 -22
  53. package/dashboard/out/bugs.html +1 -1
  54. package/dashboard/out/bugs.txt +23 -22
  55. package/dashboard/out/capture.html +1 -0
  56. package/dashboard/out/capture.txt +24 -0
  57. package/dashboard/out/config.html +1 -0
  58. package/dashboard/out/config.txt +24 -0
  59. package/dashboard/out/daemon.html +1 -0
  60. package/dashboard/out/daemon.txt +24 -0
  61. package/dashboard/out/design.html +1 -1
  62. package/dashboard/out/design.txt +23 -22
  63. package/dashboard/out/discord.html +1 -0
  64. package/dashboard/out/discord.txt +24 -0
  65. package/dashboard/out/file-index.html +1 -1
  66. package/dashboard/out/file-index.txt +23 -22
  67. package/dashboard/out/index.html +1 -1
  68. package/dashboard/out/index.txt +23 -22
  69. package/dashboard/out/insights.html +1 -1
  70. package/dashboard/out/insights.txt +23 -22
  71. package/dashboard/out/learning.html +1 -1
  72. package/dashboard/out/learning.txt +23 -22
  73. package/dashboard/out/overview.html +1 -1
  74. package/dashboard/out/overview.txt +23 -22
  75. package/dashboard/out/scheduler.html +1 -1
  76. package/dashboard/out/scheduler.txt +23 -22
  77. package/dashboard/out/sync.html +1 -0
  78. package/dashboard/out/sync.txt +24 -0
  79. package/dashboard/out/tokens.html +1 -1
  80. package/dashboard/out/tokens.txt +23 -22
  81. package/dashboard/out/waste.html +1 -0
  82. package/dashboard/out/waste.txt +24 -0
  83. package/dashboard/out/wiki.html +1 -0
  84. package/dashboard/out/wiki.txt +24 -0
  85. package/dist/cli.js +1695 -892
  86. package/package.json +1 -1
  87. package/src/core/daemon.ts +5 -3
  88. package/src/core/dashboard-api.ts +764 -1
  89. package/src/core/dashboard-server.ts +270 -0
  90. package/src/core/runtime.ts +7 -4
  91. package/src/core/vault.ts +4 -4
  92. package/src/types/config.ts +9 -1
  93. package/src/types/dashboard.ts +84 -1
  94. package/dashboard/out/_next/static/1wsj8DdMTS0IF2Rk9fkxV/_buildManifest.js +0 -1
  95. package/dashboard/out/_next/static/chunks/160-3e240a3c66269b3f.js +0 -1
  96. package/dashboard/out/_next/static/chunks/212-0f603e7affd3ee2a.js +0 -4
  97. package/dashboard/out/_next/static/chunks/255-b047925426c4e4ba.js +0 -1
  98. package/dashboard/out/_next/static/chunks/4bd1b696-409494caf8c83275.js +0 -1
  99. package/dashboard/out/_next/static/chunks/55-079130502e972d37.js +0 -1
  100. package/dashboard/out/_next/static/chunks/692-9c8d44014aa1813d.js +0 -1
  101. package/dashboard/out/_next/static/chunks/717-3d920b69f8c31178.js +0 -1
  102. package/dashboard/out/_next/static/chunks/752-8a8e79b1a2a3b489.js +0 -1
  103. package/dashboard/out/_next/static/chunks/83-ddf005b52ec7bd16.js +0 -1
  104. package/dashboard/out/_next/static/chunks/845-bed7bdd2fed7ff76.js +0 -1
  105. package/dashboard/out/_next/static/chunks/app/(panels)/action-log/page-c52cf3851d9d92d5.js +0 -1
  106. package/dashboard/out/_next/static/chunks/app/(panels)/activity/page-25a84ac9ddbbc077.js +0 -1
  107. package/dashboard/out/_next/static/chunks/app/(panels)/bugs/page-7de0b71150a91377.js +0 -1
  108. package/dashboard/out/_next/static/chunks/app/(panels)/design/page-a2153b4f18533187.js +0 -1
  109. package/dashboard/out/_next/static/chunks/app/(panels)/file-index/page-b37bdd2344d3f81c.js +0 -1
  110. package/dashboard/out/_next/static/chunks/app/(panels)/insights/page-b9cea601167e83d1.js +0 -1
  111. package/dashboard/out/_next/static/chunks/app/(panels)/learning/page-faa4afcf1473c9e5.js +0 -1
  112. package/dashboard/out/_next/static/chunks/app/(panels)/overview/page-354f274bc72789a8.js +0 -1
  113. package/dashboard/out/_next/static/chunks/app/(panels)/scheduler/page-fc11b7fbbd04a4d7.js +0 -1
  114. package/dashboard/out/_next/static/chunks/app/(panels)/tokens/page-c08c102aa883500a.js +0 -1
  115. package/dashboard/out/_next/static/chunks/app/_not-found/page-88217a29323e3700.js +0 -1
  116. package/dashboard/out/_next/static/chunks/app/layout-93083f9f140da937.js +0 -1
  117. package/dashboard/out/_next/static/chunks/app/page-0e7a5cffcfa1d064.js +0 -1
  118. package/dashboard/out/_next/static/chunks/framework-1a308e28e19f1a97.js +0 -1
  119. package/dashboard/out/_next/static/chunks/main-7c6d83fa0f7c5e5b.js +0 -1
  120. package/dashboard/out/_next/static/chunks/main-app-2f83d483d67187ef.js +0 -1
  121. package/dashboard/out/_next/static/chunks/pages/_app-5addca2b3b969fde.js +0 -1
  122. package/dashboard/out/_next/static/chunks/pages/_error-022e4ac7bbb9914f.js +0 -1
  123. package/dashboard/out/_next/static/chunks/webpack-ee71d9ae9be3609d.js +0 -1
  124. package/dashboard/out/_next/static/css/cfd58816f9a2afb9.css +0 -1
  125. /package/dashboard/out/_next/static/{1wsj8DdMTS0IF2Rk9fkxV → Dw8C--0lGz5BIGsnG-e5H}/_ssgManifest.js +0 -0
@@ -18,9 +18,42 @@ import { loadLedger } from "./token-ledger";
18
18
  import { parseLearningMemory } from "./learning-memory";
19
19
  import { loadBugMemory } from "./bug-memory";
20
20
  import { safeReadLog, parseLogSessions } from "./action-log";
21
- import { getDaemonStatus } from "./daemon";
21
+ import { getDaemonStatus, startDaemon, stopDaemon } from "./daemon";
22
22
  import { loadManifest, removeFromDeadLetter, saveManifest } from "./scheduler";
23
23
  import { getBuiltInTasks, executeTask } from "./task-registry";
24
+ import {
25
+ resolveAllConfig,
26
+ setConfigValue,
27
+ resetConfigKey,
28
+ resetAllConfig,
29
+ } from "./global-config";
30
+ import {
31
+ getSyncStatus,
32
+ syncPull,
33
+ syncPush,
34
+ disconnectSync,
35
+ isSyncInitialized,
36
+ } from "./sync";
37
+ import {
38
+ getChannelStatus,
39
+ getChannelLogs,
40
+ startChannelProcess,
41
+ stopChannelProcess,
42
+ isChannelRunning,
43
+ } from "./channel-process";
44
+ import { resolveConfigValue } from "./global-config";
45
+ import { resolveVaultPath, isVaultInitialized } from "./vault";
46
+ import type { ChannelPlatform } from "../types/channel";
47
+ import { loadVaultIndex, getRecentNotes, updateVaultIndexForFile } from "./note-index";
48
+ import { extractWikilinks } from "./note-linker";
49
+ import { createNote, appendToDaily, ingestFile } from "./note-writer";
50
+ import { readdirSync, readFileSync as readFileSyncFS, existsSync as fsExistsSync } from "fs";
51
+ import { join, resolve, normalize, sep } from "path";
52
+ import type { VaultIndexEntry, NoteCategory } from "../types/note";
53
+ import { minkRoot } from "./paths";
54
+ import { execSync } from "child_process";
55
+ import { isValidConfigKey, CONFIG_KEYS } from "../types/config";
56
+ import type { ConfigKey } from "../types/config";
24
57
  import type {
25
58
  OverviewPayload,
26
59
  TokenLedgerPayload,
@@ -31,12 +64,37 @@ import type {
31
64
  ActionResult,
32
65
  FileStatus,
33
66
  DesignPayload,
67
+ ConfigPanelPayload,
68
+ ConfigEntry,
69
+ ConfigValueSource,
70
+ ConfigValueType,
71
+ SyncPanelPayload,
72
+ SyncPendingChange,
73
+ ChannelPanelPayload,
74
+ ChannelLogLine,
75
+ WikiPanelPayload,
76
+ WikiNotePayload,
77
+ WikiTreeNode,
34
78
  } from "../types/dashboard";
35
79
  import { isDesignEvalReport } from "../types/design-eval";
36
80
  import type { DesignEvalReport } from "../types/design-eval";
37
81
  import type { FileIndex, FileIndexEntry } from "../types/file-index";
38
82
  import type { LearningMemory } from "../types/learning-memory";
39
83
 
84
+ // ── Secret Masking ─────────────────────────────────────────────────────────
85
+
86
+ const SECRET_KEY_PATTERNS = [/token/i, /secret/i, /password/i, /api[-_]?key/i];
87
+
88
+ export function isSecretKey(key: string): boolean {
89
+ return SECRET_KEY_PATTERNS.some((re) => re.test(key));
90
+ }
91
+
92
+ export function maskSecret(value: string, showLast: number = 4): string {
93
+ if (!value) return "";
94
+ if (value.length <= showLast) return "••••";
95
+ return "••••" + value.slice(-showLast);
96
+ }
97
+
40
98
  // ── File Status Checks ─────────────────────────────────────────────────────
41
99
 
42
100
  function checkJsonFile(
@@ -215,6 +273,374 @@ export function loadDesignPanel(cwd: string): DesignPayload {
215
273
  };
216
274
  }
217
275
 
276
+ // ── Config Panel ───────────────────────────────────────────────────────────
277
+
278
+ const BOOLEAN_VALUES = new Set(["true", "false"]);
279
+ const GROUP_LABELS: Record<string, string> = {
280
+ wiki: "Wiki",
281
+ notes: "Notes",
282
+ sync: "Sync",
283
+ channel: "Channels",
284
+ };
285
+
286
+ function groupFromKey(key: string): string {
287
+ const prefix = key.split(".")[0] ?? "other";
288
+ return GROUP_LABELS[prefix] ?? prefix.charAt(0).toUpperCase() + prefix.slice(1);
289
+ }
290
+
291
+ function inferType(defaultValue: string, currentValue: string): ConfigValueType {
292
+ const candidate = currentValue || defaultValue;
293
+ if (BOOLEAN_VALUES.has(candidate)) return "boolean";
294
+ if (candidate !== "" && !Number.isNaN(Number(candidate)) && /^-?\d+(\.\d+)?$/.test(candidate)) {
295
+ return "number";
296
+ }
297
+ return "string";
298
+ }
299
+
300
+ function mapSource(
301
+ source: "default" | "config file" | "environment variable",
302
+ scope: "shared" | "local",
303
+ ): ConfigValueSource {
304
+ if (source === "environment variable") return "env";
305
+ if (source === "default") return "default";
306
+ return scope;
307
+ }
308
+
309
+ export function loadConfigPanel(): ConfigPanelPayload {
310
+ const resolved = resolveAllConfig();
311
+
312
+ const entries: ConfigEntry[] = resolved.map((r) => {
313
+ const meta = CONFIG_KEYS.find((k) => k.key === r.key)!;
314
+ const isSecret = isSecretKey(r.key);
315
+ return {
316
+ key: r.key,
317
+ value: isSecret ? maskSecret(r.value) : r.value,
318
+ source: mapSource(r.source, r.scope),
319
+ type: inferType(meta.default, r.value),
320
+ group: groupFromKey(r.key),
321
+ scope: r.scope,
322
+ description: meta.description,
323
+ isSecret,
324
+ };
325
+ });
326
+
327
+ return { entries };
328
+ }
329
+
330
+ // ── Sync Panel ─────────────────────────────────────────────────────────────
331
+
332
+ function parsePorcelain(output: string): SyncPendingChange[] {
333
+ const changes: SyncPendingChange[] = [];
334
+ for (const rawLine of output.split("\n")) {
335
+ if (!rawLine.trim()) continue;
336
+ // Porcelain v1 format: `XY file` where X = staged, Y = unstaged, and
337
+ // untracked files are prefixed with "??".
338
+ const xy = rawLine.slice(0, 2);
339
+ const file = rawLine.slice(3);
340
+ let op: SyncPendingChange["op"];
341
+ if (xy === "??") op = "?";
342
+ else if (xy.includes("D")) op = "D";
343
+ else if (xy.includes("A") || xy.includes("?")) op = "A";
344
+ else op = "M";
345
+ changes.push({ op, file });
346
+ }
347
+ return changes;
348
+ }
349
+
350
+ function getAheadBehind(branch: string): { ahead: number; behind: number } {
351
+ if (!branch) return { ahead: 0, behind: 0 };
352
+ try {
353
+ const raw = execSync(
354
+ `git rev-list --left-right --count origin/${branch}...${branch}`,
355
+ { cwd: minkRoot(), timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
356
+ )
357
+ .toString()
358
+ .trim();
359
+ const [behindStr, aheadStr] = raw.split(/\s+/);
360
+ return {
361
+ behind: Number(behindStr) || 0,
362
+ ahead: Number(aheadStr) || 0,
363
+ };
364
+ } catch {
365
+ return { ahead: 0, behind: 0 };
366
+ }
367
+ }
368
+
369
+ function getPendingChanges(): SyncPendingChange[] {
370
+ try {
371
+ const raw = execSync("git status --porcelain", {
372
+ cwd: minkRoot(),
373
+ timeout: 5000,
374
+ stdio: ["pipe", "pipe", "pipe"],
375
+ })
376
+ .toString();
377
+ return parsePorcelain(raw);
378
+ } catch {
379
+ return [];
380
+ }
381
+ }
382
+
383
+ export function loadSyncPanel(): SyncPanelPayload {
384
+ const status = getSyncStatus();
385
+ const initialized = isSyncInitialized();
386
+ const pending = status.gitInitialized ? getPendingChanges() : [];
387
+ const { ahead, behind } = status.gitInitialized
388
+ ? getAheadBehind(status.branch)
389
+ : { ahead: 0, behind: 0 };
390
+
391
+ return {
392
+ initialized,
393
+ enabled: status.enabled,
394
+ branch: status.branch,
395
+ remote: status.remoteUrl,
396
+ ahead,
397
+ behind,
398
+ lastPush: status.lastPush,
399
+ lastPull: status.lastPull,
400
+ pending,
401
+ };
402
+ }
403
+
404
+ // ── Channel Panel ──────────────────────────────────────────────────────────
405
+
406
+ const CHANNEL_LOG_LIMIT = 120;
407
+ const TIMESTAMP_PREFIX = /^\[?(\d{1,2}:\d{2}(?::\d{2})?)\]?\s*/;
408
+
409
+ function parseChannelLogs(raw: string | null): ChannelLogLine[] {
410
+ if (!raw) return [];
411
+ const lines = raw.split("\n").map((l) => l.replace(/\u001b\[[0-9;]*m/g, "").trim()).filter(Boolean);
412
+ const parsed: ChannelLogLine[] = lines.map((line) => {
413
+ const match = line.match(TIMESTAMP_PREFIX);
414
+ if (match) {
415
+ return { t: match[1], m: line.slice(match[0].length) };
416
+ }
417
+ return { t: "", m: line };
418
+ });
419
+ return parsed.slice(-CHANNEL_LOG_LIMIT);
420
+ }
421
+
422
+ function parseAllowlist(raw: string): string[] {
423
+ if (!raw) return [];
424
+ return raw
425
+ .split(",")
426
+ .map((entry) => entry.trim())
427
+ .filter(Boolean);
428
+ }
429
+
430
+ export function loadChannelPanel(): ChannelPanelPayload {
431
+ const running = isChannelRunning();
432
+ const status = running ? getChannelStatus() : null;
433
+ const rawLogs = running ? getChannelLogs() : null;
434
+ const logs = parseChannelLogs(rawLogs);
435
+
436
+ const token = resolveConfigValue("channel.discord.bot-token").value;
437
+ const allowlistRaw = resolveConfigValue("channel.discord.allowlist").value;
438
+ const autoStart = resolveConfigValue("channel.discord.enabled").value === "true";
439
+
440
+ return {
441
+ status: running ? "running" : "stopped",
442
+ platform: status?.platform ?? null,
443
+ session: status?.session ?? "",
444
+ startedAt: status?.startedAt ?? "",
445
+ uptimeSec: status?.uptime ?? 0,
446
+ autoStart,
447
+ tokenMasked: maskSecret(token),
448
+ allowlist: parseAllowlist(allowlistRaw),
449
+ logs,
450
+ };
451
+ }
452
+
453
+ // ── Wiki Panel ─────────────────────────────────────────────────────────────
454
+
455
+ const WIKI_TREE_MAX_DEPTH = 2;
456
+ const WIKI_TREE_EXCLUDES = new Set([
457
+ ".obsidian",
458
+ ".git",
459
+ ".mink-vault.json",
460
+ ".mink-index.json",
461
+ "node_modules",
462
+ ]);
463
+ const DEFAULT_RECENT_LIMIT = 25;
464
+
465
+ function countMarkdownIn(dir: string): number {
466
+ let count = 0;
467
+ try {
468
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
469
+ if (WIKI_TREE_EXCLUDES.has(entry.name) || entry.name.startsWith(".")) continue;
470
+ const fullPath = join(dir, entry.name);
471
+ if (entry.isDirectory()) {
472
+ count += countMarkdownIn(fullPath);
473
+ } else if (entry.name.endsWith(".md") && !entry.name.startsWith("_")) {
474
+ count += 1;
475
+ }
476
+ }
477
+ } catch {
478
+ // Unreadable dir — return zero.
479
+ }
480
+ return count;
481
+ }
482
+
483
+ function buildVaultTree(root: string): WikiTreeNode[] {
484
+ const nodes: WikiTreeNode[] = [];
485
+ function walk(dir: string, depth: number) {
486
+ if (depth > WIKI_TREE_MAX_DEPTH) return;
487
+ let entries: { name: string; isDir: boolean }[] = [];
488
+ try {
489
+ entries = readdirSync(dir, { withFileTypes: true })
490
+ .filter((e) => !WIKI_TREE_EXCLUDES.has(e.name) && !e.name.startsWith("."))
491
+ .map((e) => ({ name: e.name, isDir: e.isDirectory() }));
492
+ } catch {
493
+ return;
494
+ }
495
+ entries.sort((a, b) => {
496
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
497
+ return a.name.localeCompare(b.name);
498
+ });
499
+ for (const entry of entries) {
500
+ if (!entry.isDir) continue;
501
+ const fullPath = join(dir, entry.name);
502
+ const relPath = fullPath.slice(root.length + 1);
503
+ const count = countMarkdownIn(fullPath);
504
+ nodes.push({ name: entry.name, path: relPath, count, depth });
505
+ walk(fullPath, depth + 1);
506
+ }
507
+ }
508
+ walk(root, 0);
509
+ return nodes;
510
+ }
511
+
512
+ function tallyTags(entries: VaultIndexEntry[]): Array<[string, number]> {
513
+ const counts = new Map<string, number>();
514
+ for (const entry of entries) {
515
+ for (const tag of entry.tags) {
516
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
517
+ }
518
+ }
519
+ return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
520
+ }
521
+
522
+ interface WikiPanelOptions {
523
+ limit?: number;
524
+ category?: NoteCategory | "all";
525
+ }
526
+
527
+ export function loadWikiPanel(opts: WikiPanelOptions = {}): WikiPanelPayload {
528
+ const initialized = isVaultInitialized();
529
+ const vaultPath = resolveVaultPath();
530
+
531
+ if (!initialized) {
532
+ return {
533
+ initialized: false,
534
+ vaultPath,
535
+ totalNotes: 0,
536
+ inboxCount: 0,
537
+ recent: [],
538
+ tags: [],
539
+ tree: [],
540
+ };
541
+ }
542
+
543
+ const index = loadVaultIndex();
544
+ const allEntries = Object.values(index.entries);
545
+ const limit = Math.max(1, Math.min(opts.limit ?? DEFAULT_RECENT_LIMIT, 200));
546
+ let recent = getRecentNotes(limit);
547
+ if (opts.category && opts.category !== "all") {
548
+ recent = recent.filter((e) => e.category === opts.category);
549
+ }
550
+ const inboxCount = allEntries.filter((e) => e.category === "inbox").length;
551
+ const tags = tallyTags(allEntries);
552
+ const tree = buildVaultTree(vaultPath);
553
+
554
+ return {
555
+ initialized: true,
556
+ vaultPath,
557
+ totalNotes: index.totalNotes || allEntries.length,
558
+ inboxCount,
559
+ recent,
560
+ tags,
561
+ tree,
562
+ };
563
+ }
564
+
565
+ function parseFrontmatter(content: string): { frontmatter: Record<string, unknown>; body: string } {
566
+ if (!content.startsWith("---")) return { frontmatter: {}, body: content };
567
+ const end = content.indexOf("\n---", 3);
568
+ if (end === -1) return { frontmatter: {}, body: content };
569
+ const raw = content.slice(3, end).trim();
570
+ const body = content.slice(end + 4).replace(/^\n/, "");
571
+ const frontmatter: Record<string, unknown> = {};
572
+ // Minimal YAML parser — supports key: value and key: [a, b] — good enough for note FM.
573
+ for (const rawLine of raw.split("\n")) {
574
+ const line = rawLine.trim();
575
+ if (!line || line.startsWith("#")) continue;
576
+ const colonIdx = line.indexOf(":");
577
+ if (colonIdx === -1) continue;
578
+ const key = line.slice(0, colonIdx).trim();
579
+ const valRaw = line.slice(colonIdx + 1).trim();
580
+ if (valRaw.startsWith("[") && valRaw.endsWith("]")) {
581
+ frontmatter[key] = valRaw
582
+ .slice(1, -1)
583
+ .split(",")
584
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
585
+ .filter(Boolean);
586
+ } else {
587
+ frontmatter[key] = valRaw.replace(/^["']|["']$/g, "");
588
+ }
589
+ }
590
+ return { frontmatter, body };
591
+ }
592
+
593
+ function resolveVaultRelativePath(relPath: string): string | null {
594
+ if (!relPath || relPath.includes("\0")) return null;
595
+ const root = resolveVaultPath();
596
+ const absolute = resolve(root, relPath);
597
+ const normalizedRoot = normalize(root) + sep;
598
+ if (!absolute.startsWith(normalizedRoot) && absolute !== normalize(root)) {
599
+ return null;
600
+ }
601
+ return absolute;
602
+ }
603
+
604
+ export function loadWikiNote(relPath: string): WikiNotePayload | null {
605
+ const absolute = resolveVaultRelativePath(relPath);
606
+ if (!absolute) return null;
607
+ let content: string;
608
+ try {
609
+ content = readFileSyncFS(absolute, "utf-8");
610
+ } catch {
611
+ return null;
612
+ }
613
+ const { frontmatter, body } = parseFrontmatter(content);
614
+
615
+ // Backlinks: look for wikilinks referencing this note's title or filename.
616
+ const index = loadVaultIndex();
617
+ const thisEntry = index.entries[relPath];
618
+ const targetTitle = thisEntry?.title ?? relPath.replace(/\.md$/, "");
619
+ const targetBasename = relPath.replace(/\.md$/, "").split("/").pop() ?? "";
620
+
621
+ const backlinks: Array<{ path: string; title: string }> = [];
622
+ for (const entry of Object.values(index.entries)) {
623
+ if (entry.filePath === relPath) continue;
624
+ const absSource = resolveVaultRelativePath(entry.filePath);
625
+ if (!absSource) continue;
626
+ let sourceContent: string;
627
+ try {
628
+ sourceContent = readFileSyncFS(absSource, "utf-8");
629
+ } catch {
630
+ continue;
631
+ }
632
+ const links = extractWikilinks(sourceContent);
633
+ const matches = links.some(
634
+ (l) => l === targetTitle || l === targetBasename || l === relPath,
635
+ );
636
+ if (matches) {
637
+ backlinks.push({ path: entry.filePath, title: entry.title });
638
+ }
639
+ }
640
+
641
+ return { path: relPath, frontmatter, body, backlinks };
642
+ }
643
+
218
644
  // ── Action Triggers ────────────────────────────────────────────────────────
219
645
 
220
646
  export async function triggerTask(
@@ -278,3 +704,340 @@ export async function triggerRescan(cwd: string): Promise<ActionResult> {
278
704
  };
279
705
  }
280
706
  }
707
+
708
+ export async function triggerDaemonStart(cwd: string): Promise<ActionResult> {
709
+ try {
710
+ startDaemon(cwd);
711
+ return { success: true };
712
+ } catch (err) {
713
+ return {
714
+ success: false,
715
+ error: err instanceof Error ? err.message : String(err),
716
+ };
717
+ }
718
+ }
719
+
720
+ export async function triggerDaemonStop(): Promise<ActionResult> {
721
+ try {
722
+ await stopDaemon();
723
+ return { success: true };
724
+ } catch (err) {
725
+ return {
726
+ success: false,
727
+ error: err instanceof Error ? err.message : String(err),
728
+ };
729
+ }
730
+ }
731
+
732
+ export async function triggerDaemonRestart(cwd: string): Promise<ActionResult> {
733
+ try {
734
+ await stopDaemon();
735
+ startDaemon(cwd);
736
+ return { success: true };
737
+ } catch (err) {
738
+ return {
739
+ success: false,
740
+ error: err instanceof Error ? err.message : String(err),
741
+ };
742
+ }
743
+ }
744
+
745
+ export async function triggerConfigSet(
746
+ key: string,
747
+ value: string,
748
+ ): Promise<ActionResult> {
749
+ try {
750
+ if (!isValidConfigKey(key)) {
751
+ return { success: false, error: `Unknown config key: ${key}` };
752
+ }
753
+ if (typeof value !== "string") {
754
+ return { success: false, error: "Config value must be a string" };
755
+ }
756
+ setConfigValue(key as ConfigKey, value);
757
+ return { success: true };
758
+ } catch (err) {
759
+ return {
760
+ success: false,
761
+ error: err instanceof Error ? err.message : String(err),
762
+ };
763
+ }
764
+ }
765
+
766
+ export async function triggerSyncPull(): Promise<ActionResult> {
767
+ try {
768
+ if (!isSyncInitialized()) {
769
+ return { success: false, error: "Sync is not initialized" };
770
+ }
771
+ const errors: string[] = [];
772
+ syncPull((msg) => errors.push(msg));
773
+ if (errors.length > 0) {
774
+ return { success: false, error: errors.join("\n") };
775
+ }
776
+ return { success: true };
777
+ } catch (err) {
778
+ return {
779
+ success: false,
780
+ error: err instanceof Error ? err.message : String(err),
781
+ };
782
+ }
783
+ }
784
+
785
+ export async function triggerSyncPush(): Promise<ActionResult> {
786
+ try {
787
+ if (!isSyncInitialized()) {
788
+ return { success: false, error: "Sync is not initialized" };
789
+ }
790
+ const errors: string[] = [];
791
+ syncPush((msg) => errors.push(msg));
792
+ if (errors.length > 0) {
793
+ return { success: false, error: errors.join("\n") };
794
+ }
795
+ return { success: true };
796
+ } catch (err) {
797
+ return {
798
+ success: false,
799
+ error: err instanceof Error ? err.message : String(err),
800
+ };
801
+ }
802
+ }
803
+
804
+ export async function triggerChannelStart(): Promise<ActionResult> {
805
+ try {
806
+ if (!isVaultInitialized()) {
807
+ return {
808
+ success: false,
809
+ error: "Vault is not initialized. Run `mink wiki init` first.",
810
+ };
811
+ }
812
+ const platform = (resolveConfigValue("channel.default-platform").value as ChannelPlatform) || "discord";
813
+ const token = resolveConfigValue("channel.discord.bot-token").value;
814
+ if (!token) {
815
+ return {
816
+ success: false,
817
+ error: "No bot token configured. Set channel.discord.bot-token first.",
818
+ };
819
+ }
820
+ const skipPermissions = resolveConfigValue("channel.skip-permissions").value === "true";
821
+ const vaultPath = resolveVaultPath();
822
+ await startChannelProcess({ vaultPath, platform, token, skipPermissions });
823
+ return { success: true };
824
+ } catch (err) {
825
+ return {
826
+ success: false,
827
+ error: err instanceof Error ? err.message : String(err),
828
+ };
829
+ }
830
+ }
831
+
832
+ export async function triggerChannelStop(): Promise<ActionResult> {
833
+ try {
834
+ await stopChannelProcess();
835
+ return { success: true };
836
+ } catch (err) {
837
+ return {
838
+ success: false,
839
+ error: err instanceof Error ? err.message : String(err),
840
+ };
841
+ }
842
+ }
843
+
844
+ export async function triggerChannelRestart(): Promise<ActionResult> {
845
+ const stop = await triggerChannelStop();
846
+ if (!stop.success) return stop;
847
+ return triggerChannelStart();
848
+ }
849
+
850
+ export async function triggerSyncDisconnect(): Promise<ActionResult> {
851
+ try {
852
+ disconnectSync();
853
+ return { success: true };
854
+ } catch (err) {
855
+ return {
856
+ success: false,
857
+ error: err instanceof Error ? err.message : String(err),
858
+ };
859
+ }
860
+ }
861
+
862
+ const VALID_CATEGORIES: NoteCategory[] = ["inbox", "projects", "areas", "resources", "archives"];
863
+
864
+ function isValidCategory(cat: unknown): cat is NoteCategory {
865
+ return typeof cat === "string" && (VALID_CATEGORIES as string[]).includes(cat);
866
+ }
867
+
868
+ function firstNonEmptyLine(s: string): string {
869
+ for (const line of s.split("\n")) {
870
+ const trimmed = line.trim().replace(/^#+\s*/, "");
871
+ if (trimmed) return trimmed;
872
+ }
873
+ return "";
874
+ }
875
+
876
+ function deriveQuickTitle(body: string): string {
877
+ const first = firstNonEmptyLine(body);
878
+ if (!first) return `quick-capture-${new Date().toISOString().slice(0, 10)}`;
879
+ return first.slice(0, 80);
880
+ }
881
+
882
+ // In-memory idempotency tracker: maps dedup key -> created filePath.
883
+ // Keys are TTL'd to cap memory (10 min window is generous for UI double-submits).
884
+ const DEDUP_TTL_MS = 10 * 60 * 1000;
885
+ const dedupCache = new Map<string, { filePath: string; expiresAt: number }>();
886
+
887
+ function checkDedup(key: string | undefined): { filePath: string } | null {
888
+ if (!key) return null;
889
+ const now = Date.now();
890
+ // Sweep expired entries lazily.
891
+ for (const [k, v] of dedupCache) {
892
+ if (v.expiresAt < now) dedupCache.delete(k);
893
+ }
894
+ const hit = dedupCache.get(key);
895
+ return hit && hit.expiresAt >= now ? { filePath: hit.filePath } : null;
896
+ }
897
+
898
+ function recordDedup(key: string | undefined, filePath: string): void {
899
+ if (!key) return;
900
+ dedupCache.set(key, { filePath, expiresAt: Date.now() + DEDUP_TTL_MS });
901
+ }
902
+
903
+ export interface CaptureNoteRequest {
904
+ mode: "quick" | "structured";
905
+ title?: string;
906
+ category?: string;
907
+ body: string;
908
+ tags?: string[];
909
+ dedupKey?: string;
910
+ }
911
+
912
+ export async function triggerCreateNote(
913
+ req: CaptureNoteRequest,
914
+ ): Promise<ActionResult & { filePath?: string }> {
915
+ try {
916
+ if (!isVaultInitialized()) {
917
+ return { success: false, error: "Vault is not initialized. Run `mink wiki init` first." };
918
+ }
919
+ if (typeof req.body !== "string" || !req.body.trim()) {
920
+ return { success: false, error: "Body is required" };
921
+ }
922
+
923
+ const existing = checkDedup(req.dedupKey);
924
+ if (existing) return { success: true, filePath: existing.filePath };
925
+
926
+ const category: NoteCategory = isValidCategory(req.category)
927
+ ? req.category
928
+ : "inbox";
929
+ const title = (req.title?.trim() || "") || deriveQuickTitle(req.body);
930
+ const tags = (req.tags ?? []).map((t) => t.trim()).filter(Boolean);
931
+ const now = new Date().toISOString();
932
+
933
+ const result = createNote({
934
+ title,
935
+ category,
936
+ tags,
937
+ created: now,
938
+ updated: now,
939
+ body: req.body,
940
+ });
941
+
942
+ updateVaultIndexForFile(result.filePath, result.content);
943
+ recordDedup(req.dedupKey, result.filePath);
944
+ return { success: true, filePath: result.filePath };
945
+ } catch (err) {
946
+ return {
947
+ success: false,
948
+ error: err instanceof Error ? err.message : String(err),
949
+ };
950
+ }
951
+ }
952
+
953
+ export async function triggerAppendDaily(
954
+ content: string,
955
+ dedupKey?: string,
956
+ ): Promise<ActionResult & { filePath?: string }> {
957
+ try {
958
+ if (!isVaultInitialized()) {
959
+ return { success: false, error: "Vault is not initialized." };
960
+ }
961
+ if (typeof content !== "string" || !content.trim()) {
962
+ return { success: false, error: "Content is required" };
963
+ }
964
+
965
+ const existing = checkDedup(dedupKey);
966
+ if (existing) return { success: true, filePath: existing.filePath };
967
+
968
+ const today = new Date().toISOString().slice(0, 10);
969
+ const filePath = appendToDaily(today, content);
970
+ const updated = readFileSyncFS(filePath, "utf-8");
971
+ updateVaultIndexForFile(filePath, updated);
972
+ recordDedup(dedupKey, filePath);
973
+ return { success: true, filePath };
974
+ } catch (err) {
975
+ return {
976
+ success: false,
977
+ error: err instanceof Error ? err.message : String(err),
978
+ };
979
+ }
980
+ }
981
+
982
+ export async function triggerIngestFile(
983
+ sourcePath: string,
984
+ category: string,
985
+ tags?: string[],
986
+ dedupKey?: string,
987
+ ): Promise<ActionResult & { filePath?: string }> {
988
+ try {
989
+ if (!isVaultInitialized()) {
990
+ return { success: false, error: "Vault is not initialized." };
991
+ }
992
+ if (!sourcePath) {
993
+ return { success: false, error: "sourcePath is required" };
994
+ }
995
+ if (!isValidCategory(category)) {
996
+ return { success: false, error: `Invalid category: ${category}` };
997
+ }
998
+ const expanded = sourcePath.startsWith("~/")
999
+ ? join(process.env.HOME ?? "", sourcePath.slice(2))
1000
+ : sourcePath;
1001
+ if (!fsExistsSync(expanded)) {
1002
+ return { success: false, error: `Source file not found: ${sourcePath}` };
1003
+ }
1004
+
1005
+ const existing = checkDedup(dedupKey);
1006
+ if (existing) return { success: true, filePath: existing.filePath };
1007
+
1008
+ const result = ingestFile(expanded, { category, tags });
1009
+ updateVaultIndexForFile(result.filePath, result.content);
1010
+ recordDedup(dedupKey, result.filePath);
1011
+ return { success: true, filePath: result.filePath };
1012
+ } catch (err) {
1013
+ return {
1014
+ success: false,
1015
+ error: err instanceof Error ? err.message : String(err),
1016
+ };
1017
+ }
1018
+ }
1019
+
1020
+ export async function triggerConfigReset(
1021
+ key?: string,
1022
+ all?: boolean,
1023
+ ): Promise<ActionResult> {
1024
+ try {
1025
+ if (all) {
1026
+ resetAllConfig();
1027
+ return { success: true };
1028
+ }
1029
+ if (!key) {
1030
+ return { success: false, error: "Missing key (or set all: true)" };
1031
+ }
1032
+ if (!isValidConfigKey(key)) {
1033
+ return { success: false, error: `Unknown config key: ${key}` };
1034
+ }
1035
+ resetConfigKey(key as ConfigKey);
1036
+ return { success: true };
1037
+ } catch (err) {
1038
+ return {
1039
+ success: false,
1040
+ error: err instanceof Error ? err.message : String(err),
1041
+ };
1042
+ }
1043
+ }