laminark 2.21.8 → 2.21.9

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 (52) hide show
  1. package/README.md +36 -71
  2. package/package.json +9 -7
  3. package/plugin/.claude-plugin/plugin.json +2 -2
  4. package/plugin/CLAUDE.md +10 -0
  5. package/plugin/commands/recall.md +55 -0
  6. package/plugin/commands/remember.md +34 -0
  7. package/plugin/commands/resume.md +45 -0
  8. package/plugin/commands/stash.md +34 -0
  9. package/plugin/commands/status.md +33 -0
  10. package/plugin/dist/hooks/handler.d.ts +3 -1
  11. package/plugin/dist/hooks/handler.d.ts.map +1 -1
  12. package/plugin/dist/hooks/handler.js +312 -23
  13. package/plugin/dist/hooks/handler.js.map +1 -1
  14. package/plugin/dist/index.d.ts +3 -1
  15. package/plugin/dist/index.d.ts.map +1 -1
  16. package/plugin/dist/index.js +2111 -525
  17. package/plugin/dist/index.js.map +1 -1
  18. package/plugin/dist/{observations-Ch0nc47i.d.mts → observations-CorAAc1A.d.mts} +23 -1
  19. package/plugin/dist/observations-CorAAc1A.d.mts.map +1 -0
  20. package/plugin/dist/{tool-registry-CZ3mJ4iR.mjs → tool-registry-D8un_AcG.mjs} +932 -13
  21. package/plugin/dist/tool-registry-D8un_AcG.mjs.map +1 -0
  22. package/plugin/hooks/hooks.json +6 -6
  23. package/plugin/laminark.db +0 -0
  24. package/plugin/package.json +17 -0
  25. package/plugin/scripts/README.md +19 -1
  26. package/plugin/scripts/bump-version.sh +24 -19
  27. package/plugin/scripts/dev-sync.sh +58 -0
  28. package/plugin/scripts/ensure-deps.sh +5 -2
  29. package/plugin/scripts/install.sh +115 -39
  30. package/plugin/scripts/local-install.sh +93 -58
  31. package/plugin/scripts/uninstall.sh +76 -38
  32. package/plugin/scripts/update.sh +20 -69
  33. package/plugin/scripts/verify-install.sh +69 -25
  34. package/plugin/ui/activity.js +12 -0
  35. package/plugin/ui/app.js +24 -54
  36. package/plugin/ui/graph.js +413 -186
  37. package/plugin/ui/help/activity-feed.png +0 -0
  38. package/plugin/ui/help/analysis-panel.png +0 -0
  39. package/plugin/ui/help/graph-toolbar.png +0 -0
  40. package/plugin/ui/help/graph-view.png +0 -0
  41. package/plugin/ui/help/settings.png +0 -0
  42. package/plugin/ui/help/timeline.png +0 -0
  43. package/plugin/ui/help.js +876 -172
  44. package/plugin/ui/index.html +506 -242
  45. package/plugin/ui/settings.js +781 -17
  46. package/plugin/ui/styles.css +990 -44
  47. package/plugin/ui/timeline.js +2 -2
  48. package/plugin/ui/tools.js +826 -0
  49. package/.claude-plugin/marketplace.json +0 -15
  50. package/plugin/dist/observations-Ch0nc47i.d.mts.map +0 -1
  51. package/plugin/dist/tool-registry-CZ3mJ4iR.mjs.map +0 -1
  52. package/plugin/scripts/setup-tmpdir.sh +0 -65
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { a as isDebugEnabled, i as getProjectHash, n as getDatabaseConfig, r as getDbPath, t as getConfigDir } from "./config-t8LZeB-u.mjs";
3
- import { C as rowToObservation, D as debug, E as runMigrations, O as debugTimed, S as ObservationRepository, T as MIGRATIONS, _ as SaveGuard, a as ResearchBufferRepository, b as SearchEngine, c as inferToolType, d as getNodeByNameAndType, f as getNodesByType, g as upsertNode, h as traverseFrom, i as NotificationStore, l as countEdgesForNode, m as insertEdge, n as PathRepository, o as extractServerName, p as initGraphSchema, r as initPathSchema, s as inferScope, t as ToolRegistryRepository, u as getEdgesForNode, v as jaccardSimilarity$1, w as openDatabase, x as SessionRepository, y as hybridSearch } from "./tool-registry-CZ3mJ4iR.mjs";
4
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
5
- import { dirname, join } from "node:path";
3
+ import { A as hybridSearch, C as getNodesByType, D as upsertNode, E as traverseFrom, F as openDatabase, I as MIGRATIONS, L as runMigrations, M as SessionRepository, N as ObservationRepository, O as SaveGuard, R as debug, S as getNodeByNameAndType, T as insertEdge, _ as detectStaleness, a as ResearchBufferRepository, b as countEdgesForNode, c as inferScope, d as executePurge, f as findAnalysis, g as saveHygieneConfig, h as resetHygieneConfig, i as NotificationStore, j as SearchEngine, k as jaccardSimilarity$1, l as inferToolType, m as loadHygieneConfig, n as PathRepository, o as BranchRepository, r as initPathSchema, s as extractServerName, t as ToolRegistryRepository, u as analyzeObservations, v as flagStaleObservation, w as initGraphSchema, x as getEdgesForNode, y as initStalenessSchema, z as debugTimed } from "./tool-registry-D8un_AcG.mjs";
4
+ import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { basename, dirname, join } from "node:path";
6
6
  import { randomBytes } from "node:crypto";
7
7
  import { z } from "zod";
8
8
  import path from "path";
9
9
  import { fileURLToPath } from "url";
10
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
+ import { readFile, readdir } from "node:fs/promises";
13
+ import { unstable_v2_createSession } from "@anthropic-ai/claude-agent-sdk";
12
14
  import { Worker } from "node:worker_threads";
13
15
  import { fileURLToPath as fileURLToPath$1 } from "node:url";
14
- import { unstable_v2_createSession } from "@anthropic-ai/claude-agent-sdk";
15
16
  import { Hono } from "hono";
16
17
  import fs from "fs";
17
18
  import { cors } from "hono/cors";
@@ -371,6 +372,44 @@ async function startServer(server) {
371
372
  debug("mcp", "MCP server started on stdio transport");
372
373
  }
373
374
 
375
+ //#endregion
376
+ //#region src/config/cross-access.ts
377
+ /**
378
+ * Cross-Project Access Configuration
379
+ *
380
+ * Per-project config that controls which other projects' memories
381
+ * the current project can read from. Read-only access — no writes
382
+ * cross projects.
383
+ *
384
+ * Config stored at: {configDir}/cross-access-{projectHash}.json
385
+ */
386
+ const DEFAULTS$4 = { readableProjects: [] };
387
+ function getConfigPath(projectHash) {
388
+ return join(getConfigDir(), `cross-access-${projectHash}.json`);
389
+ }
390
+ function loadCrossAccessConfig(projectHash) {
391
+ const configPath = getConfigPath(projectHash);
392
+ try {
393
+ if (!existsSync(configPath)) return { ...DEFAULTS$4 };
394
+ const raw = readFileSync(configPath, "utf-8");
395
+ const parsed = JSON.parse(raw);
396
+ return { readableProjects: Array.isArray(parsed.readableProjects) ? parsed.readableProjects.filter((h) => typeof h === "string") : [] };
397
+ } catch {
398
+ return { ...DEFAULTS$4 };
399
+ }
400
+ }
401
+ function saveCrossAccessConfig(projectHash, config) {
402
+ const configPath = getConfigPath(projectHash);
403
+ const validated = { readableProjects: Array.isArray(config.readableProjects) ? config.readableProjects.filter((h) => typeof h === "string" && h !== projectHash) : [] };
404
+ writeFileSync(configPath, JSON.stringify(validated, null, 2), "utf-8");
405
+ }
406
+ function resetCrossAccessConfig(projectHash) {
407
+ const configPath = getConfigPath(projectHash);
408
+ try {
409
+ if (existsSync(configPath)) unlinkSync(configPath);
410
+ } catch {}
411
+ }
412
+
374
413
  //#endregion
375
414
  //#region src/mcp/token-budget.ts
376
415
  const TOKEN_BUDGET = 2e3;
@@ -399,6 +438,82 @@ function enforceTokenBudget(results, formatResult, budget = TOKEN_BUDGET) {
399
438
  };
400
439
  }
401
440
 
441
+ //#endregion
442
+ //#region src/config/tool-verbosity-config.ts
443
+ /**
444
+ * Tool Response Verbosity Configuration
445
+ *
446
+ * Controls how much detail MCP tool responses include.
447
+ * Three levels:
448
+ * 1 (minimal): Just confirms the tool ran
449
+ * 2 (standard): Shows title/key info (default)
450
+ * 3 (verbose): Full formatted text with all details
451
+ *
452
+ * Configuration is loaded from .laminark/tool-verbosity.json with
453
+ * a 5-second cache to avoid repeated disk reads.
454
+ */
455
+ const DEFAULTS$3 = { level: 2 };
456
+ const CACHE_TTL_MS = 5e3;
457
+ let cachedConfig = null;
458
+ let cachedAt = 0;
459
+ /**
460
+ * Loads tool verbosity configuration from disk with a 5-second cache.
461
+ */
462
+ function loadToolVerbosityConfig() {
463
+ const now = Date.now();
464
+ if (cachedConfig && now - cachedAt < CACHE_TTL_MS) return cachedConfig;
465
+ const configPath = join(getConfigDir(), "tool-verbosity.json");
466
+ try {
467
+ const content = readFileSync(configPath, "utf-8");
468
+ const level = JSON.parse(content).level;
469
+ if (level === 1 || level === 2 || level === 3) cachedConfig = { level };
470
+ else cachedConfig = { ...DEFAULTS$3 };
471
+ debug("config", "Loaded tool verbosity config", { level: cachedConfig.level });
472
+ } catch {
473
+ cachedConfig = { ...DEFAULTS$3 };
474
+ }
475
+ cachedAt = now;
476
+ return cachedConfig;
477
+ }
478
+ /**
479
+ * Saves tool verbosity configuration to disk and invalidates cache.
480
+ */
481
+ function saveToolVerbosityConfig(config) {
482
+ writeFileSync(join(getConfigDir(), "tool-verbosity.json"), JSON.stringify(config, null, 2), "utf-8");
483
+ cachedConfig = config;
484
+ cachedAt = Date.now();
485
+ }
486
+ /**
487
+ * Resets tool verbosity to defaults by invalidating cache.
488
+ */
489
+ function resetToolVerbosityConfig() {
490
+ cachedConfig = null;
491
+ cachedAt = 0;
492
+ return { ...DEFAULTS$3 };
493
+ }
494
+ /**
495
+ * Selects the appropriate response text based on the current verbosity level.
496
+ *
497
+ * Each tool passes three pre-built strings:
498
+ * - minimal: Level 1 — just confirms the tool ran
499
+ * - standard: Level 2 — shows title/key info
500
+ * - verbose: Level 3 — full formatted text
501
+ */
502
+ function formatResponse(level, minimal, standard, verbose) {
503
+ switch (level) {
504
+ case 1: return minimal;
505
+ case 2: return standard;
506
+ case 3: return verbose;
507
+ }
508
+ }
509
+ /**
510
+ * Convenience: loads config and selects the response in one call.
511
+ */
512
+ function verboseResponse(minimal, standard, verbose) {
513
+ const { level } = loadToolVerbosityConfig();
514
+ return formatResponse(level, minimal, standard, verbose);
515
+ }
516
+
402
517
  //#endregion
403
518
  //#region src/mcp/tools/recall.ts
404
519
  function shortId(id) {
@@ -430,19 +545,19 @@ function formatTimelineGroup(date, items) {
430
545
  function formatFullItem(obs) {
431
546
  return `--- ${shortId(obs.id)} | ${obs.title ?? "untitled"} | ${obs.createdAt} ---\n${obs.content}`;
432
547
  }
433
- function prependNotifications$6(notificationStore, projectHash, responseText) {
548
+ function prependNotifications$8(notificationStore, projectHash, responseText) {
434
549
  if (!notificationStore) return responseText;
435
550
  const pending = notificationStore.consumePending(projectHash);
436
551
  if (pending.length === 0) return responseText;
437
552
  return pending.map((n) => `[Laminark] ${n.message}`).join("\n") + "\n\n" + responseText;
438
553
  }
439
- function textResponse$7(text) {
554
+ function textResponse$9(text) {
440
555
  return { content: [{
441
556
  type: "text",
442
557
  text
443
558
  }] };
444
559
  }
445
- function errorResponse$3(text) {
560
+ function errorResponse$4(text) {
446
561
  return {
447
562
  content: [{
448
563
  type: "text",
@@ -451,7 +566,15 @@ function errorResponse$3(text) {
451
566
  isError: true
452
567
  };
453
568
  }
454
- function registerRecall(server, db, projectHash, worker = null, embeddingStore = null, notificationStore = null, statusCache = null) {
569
+ function getProjectNameMap(db) {
570
+ const map = /* @__PURE__ */ new Map();
571
+ try {
572
+ const rows = db.prepare("SELECT project_hash, display_name FROM project_metadata").all();
573
+ for (const row of rows) map.set(row.project_hash, row.display_name ?? row.project_hash.slice(0, 8));
574
+ } catch {}
575
+ return map;
576
+ }
577
+ function registerRecall(server, db, projectHashRef, worker = null, embeddingStore = null, notificationStore = null, statusCache = null) {
455
578
  server.registerTool("recall", {
456
579
  title: "Recall Memories",
457
580
  description: "Search, view, purge, or restore memories. Search first to find matches, then act on specific results by ID.",
@@ -481,13 +604,14 @@ function registerRecall(server, db, projectHash, worker = null, embeddingStore =
481
604
  include_purged: z.boolean().default(false).describe("Include soft-deleted items in results (needed for restore)")
482
605
  }
483
606
  }, async (args) => {
484
- const withNotifications = (text) => textResponse$7(prependNotifications$6(notificationStore, projectHash, text));
607
+ const projectHash = projectHashRef.current;
608
+ const withNotifications = (text) => textResponse$9(prependNotifications$8(notificationStore, projectHash, text));
485
609
  try {
486
610
  const repo = new ObservationRepository(db, projectHash);
487
611
  const searchEngine = new SearchEngine(db, projectHash);
488
612
  const hasSearch = args.query !== void 0 || args.id !== void 0 || args.title !== void 0;
489
- if (args.ids && hasSearch) return errorResponse$3("Provide either a search query or IDs to act on, not both.");
490
- if ((args.action === "purge" || args.action === "restore") && !args.ids && !args.id) return errorResponse$3(`Provide ids array or id to specify which memories to ${args.action}.`);
613
+ if (args.ids && hasSearch) return errorResponse$4("Provide either a search query or IDs to act on, not both.");
614
+ if ((args.action === "purge" || args.action === "restore") && !args.ids && !args.id) return errorResponse$4(`Provide ids array or id to specify which memories to ${args.action}.`);
491
615
  let observations = [];
492
616
  let searchResults = null;
493
617
  if (args.ids) {
@@ -514,6 +638,30 @@ function registerRecall(server, db, projectHash, worker = null, embeddingStore =
514
638
  });
515
639
  else searchResults = searchEngine.searchKeyword(args.query, { limit: args.limit });
516
640
  observations = searchResults.map((r) => r.observation);
641
+ const crossConfig = loadCrossAccessConfig(projectHash);
642
+ if (crossConfig.readableProjects.length > 0) {
643
+ const nameMap = getProjectNameMap(db);
644
+ for (const otherHash of crossConfig.readableProjects) {
645
+ const otherEngine = new SearchEngine(db, otherHash);
646
+ let otherResults;
647
+ if (embeddingStore) otherResults = await hybridSearch({
648
+ searchEngine: otherEngine,
649
+ embeddingStore,
650
+ worker,
651
+ query: args.query,
652
+ db,
653
+ projectHash: otherHash,
654
+ options: { limit: args.limit }
655
+ });
656
+ else otherResults = otherEngine.searchKeyword(args.query, { limit: args.limit });
657
+ if (otherResults.length > 0) {
658
+ const projName = nameMap.get(otherHash) ?? otherHash.slice(0, 8);
659
+ for (const r of otherResults) r.observation.title = `[${projName}] ${r.observation.title ?? "untitled"}`;
660
+ searchResults.push(...otherResults);
661
+ observations.push(...otherResults.map((r) => r.observation));
662
+ }
663
+ }
664
+ }
517
665
  } else if (args.title) observations = repo.getByTitle(args.title, {
518
666
  limit: args.limit,
519
667
  includePurged: args.include_purged
@@ -525,8 +673,21 @@ function registerRecall(server, db, projectHash, worker = null, embeddingStore =
525
673
  if (args.kind && observations.length > 0) observations = observations.filter((obs) => obs.kind === args.kind);
526
674
  if (observations.length === 0) return withNotifications(`No memories found matching '${args.query ?? args.title ?? args.id ?? ""}'. Try broader search terms or check the ID.`);
527
675
  if (args.action === "view") {
676
+ const verbosity = loadToolVerbosityConfig().level;
677
+ if (verbosity === 1) {
678
+ const searchTerm = args.query ?? args.title ?? "query";
679
+ return textResponse$9(prependNotifications$8(notificationStore, projectHash, `Found ${observations.length} memories matching "${searchTerm}"`));
680
+ }
681
+ if (verbosity === 2) {
682
+ const lines = observations.map((obs, i) => {
683
+ const title = obs.title ?? "untitled";
684
+ return `${i + 1}. ${title}`;
685
+ });
686
+ const footer = `\n---\n${observations.length} result(s)`;
687
+ return textResponse$9(prependNotifications$8(notificationStore, projectHash, lines.join("\n") + footer));
688
+ }
528
689
  const originalText = formatViewResponse(observations, searchResults, args.detail, args.id !== void 0).content[0].text;
529
- return textResponse$7(prependNotifications$6(notificationStore, projectHash, originalText));
690
+ return textResponse$9(prependNotifications$8(notificationStore, projectHash, originalText));
530
691
  }
531
692
  if (args.action === "purge") {
532
693
  const targetIds = args.ids ?? (args.id ? [args.id] : []);
@@ -558,11 +719,11 @@ function registerRecall(server, db, projectHash, worker = null, embeddingStore =
558
719
  if (failures.length > 0) msg += ` Not found: ${failures.join(", ")}`;
559
720
  return withNotifications(msg);
560
721
  }
561
- return errorResponse$3(`Unknown action: ${args.action}`);
722
+ return errorResponse$4(`Unknown action: ${args.action}`);
562
723
  } catch (err) {
563
724
  const message = err instanceof Error ? err.message : "Unknown error";
564
725
  debug("mcp", "recall: error", { error: message });
565
- return errorResponse$3(`Recall error: ${message}`);
726
+ return errorResponse$4(`Recall error: ${message}`);
566
727
  }
567
728
  });
568
729
  }
@@ -622,7 +783,7 @@ function formatViewResponse(observations, searchResults, detail, isSingleIdLooku
622
783
  }
623
784
  let footer = `---\n${observations.length} result(s) | ~${tokenEstimate} tokens | detail: ${detail}`;
624
785
  if (truncated) footer += " | truncated (use id for full view)";
625
- return textResponse$7(`${body}\n${footer}`);
786
+ return textResponse$9(`${body}\n${footer}`);
626
787
  }
627
788
  function buildScoreMap(searchResults) {
628
789
  const map = /* @__PURE__ */ new Map();
@@ -648,7 +809,7 @@ function generateTitle(content) {
648
809
  * save_memory persists user-provided text as a new observation with an optional title.
649
810
  * If title is omitted, one is auto-generated from the text content.
650
811
  */
651
- function registerSaveMemory(server, db, projectHash, notificationStore = null, worker = null, embeddingStore = null, statusCache = null) {
812
+ function registerSaveMemory(server, db, projectHashRef, notificationStore = null, worker = null, embeddingStore = null, statusCache = null) {
652
813
  server.registerTool("save_memory", {
653
814
  title: "Save Memory",
654
815
  description: "Save a new memory observation. Provide text content and an optional title. If title is omitted, one is auto-generated from the text.",
@@ -665,6 +826,7 @@ function registerSaveMemory(server, db, projectHash, notificationStore = null, w
665
826
  ]).default("finding").describe("Observation kind: change, reference, finding, decision, or verification")
666
827
  }
667
828
  }, async (args) => {
829
+ const projectHash = projectHashRef.current;
668
830
  try {
669
831
  const repo = new ObservationRepository(db, projectHash);
670
832
  const decision = await new SaveGuard(repo, {
@@ -693,7 +855,7 @@ function registerSaveMemory(server, db, projectHash, notificationStore = null, w
693
855
  title: resolvedTitle
694
856
  });
695
857
  statusCache?.markDirty();
696
- let responseText = `Saved memory "${resolvedTitle}" (id: ${obs.id})`;
858
+ let responseText = verboseResponse("Memory saved.", `Saved "${resolvedTitle}"`, `Saved memory "${resolvedTitle}" (id: ${obs.id})`);
697
859
  if (notificationStore) {
698
860
  const pending = notificationStore.consumePending(projectHash);
699
861
  if (pending.length > 0) responseText = pending.map((n) => `[Laminark] ${n.message}`).join("\n") + "\n\n" + responseText;
@@ -714,6 +876,248 @@ function registerSaveMemory(server, db, projectHash, notificationStore = null, w
714
876
  });
715
877
  }
716
878
 
879
+ //#endregion
880
+ //#region src/ingestion/markdown-parser.ts
881
+ /**
882
+ * Parse a markdown file into discrete sections split on ## headings.
883
+ *
884
+ * - The # (level 1) heading is the doc title, used as prefix: "DocTitle > SectionHeading"
885
+ * - ### subsections stay within their parent ## section (not split separately)
886
+ * - Sections with empty content after trimming are skipped
887
+ * - Content before the first ## heading is skipped
888
+ * - ## inside fenced code blocks are not treated as headings
889
+ */
890
+ function parseMarkdownSections(fileContent, sourceFile) {
891
+ const lines = fileContent.split("\n");
892
+ const sections = [];
893
+ let docTitle = "";
894
+ let currentHeading = "";
895
+ let currentLines = [];
896
+ let sectionIndex = 0;
897
+ let inCodeBlock = false;
898
+ for (const line of lines) {
899
+ if (line.trimStart().startsWith("```")) inCodeBlock = !inCodeBlock;
900
+ if (inCodeBlock) {
901
+ if (currentHeading) currentLines.push(line);
902
+ continue;
903
+ }
904
+ if (/^# (?!#)/.test(line)) {
905
+ docTitle = line.slice(2).trim();
906
+ continue;
907
+ }
908
+ if (/^## (?!#)/.test(line)) {
909
+ if (currentHeading) {
910
+ const content = currentLines.join("\n").trim();
911
+ if (content.length > 0) {
912
+ sections.push({
913
+ title: docTitle ? `${docTitle} > ${currentHeading}` : currentHeading,
914
+ heading: currentHeading,
915
+ content,
916
+ sourceFile,
917
+ sectionIndex
918
+ });
919
+ sectionIndex++;
920
+ }
921
+ }
922
+ currentHeading = line.slice(3).trim();
923
+ currentLines = [];
924
+ continue;
925
+ }
926
+ if (currentHeading) currentLines.push(line);
927
+ }
928
+ if (currentHeading) {
929
+ const content = currentLines.join("\n").trim();
930
+ if (content.length > 0) sections.push({
931
+ title: docTitle ? `${docTitle} > ${currentHeading}` : currentHeading,
932
+ heading: currentHeading,
933
+ content,
934
+ sourceFile,
935
+ sectionIndex
936
+ });
937
+ }
938
+ return sections;
939
+ }
940
+
941
+ //#endregion
942
+ //#region src/ingestion/knowledge-ingester.ts
943
+ /**
944
+ * Ingests markdown files into the knowledge store.
945
+ *
946
+ * Creates one observation per ## section, with idempotent re-ingestion
947
+ * that cleans up stale sections without duplication.
948
+ */
949
+ var KnowledgeIngester = class {
950
+ db;
951
+ projectHash;
952
+ constructor(db, projectHash) {
953
+ this.db = db;
954
+ this.projectHash = projectHash;
955
+ }
956
+ /**
957
+ * Detects the knowledge directory for a project.
958
+ * Checks in order:
959
+ * 1. {projectRoot}/.planning/codebase/ (GSD output)
960
+ * 2. {projectRoot}/.laminark/codebase/
961
+ * Returns the first existing directory, or null if none exist.
962
+ */
963
+ static detectKnowledgeDir(projectRoot) {
964
+ const candidates = [join(projectRoot, ".planning", "codebase"), join(projectRoot, ".laminark", "codebase")];
965
+ for (const candidate of candidates) try {
966
+ if (existsSync(candidate) && statSync(candidate).isDirectory()) return candidate;
967
+ } catch {}
968
+ return null;
969
+ }
970
+ /**
971
+ * Ingests all markdown files from a directory.
972
+ * Reads all files async first, then runs DB operations in a single transaction.
973
+ */
974
+ async ingestDirectory(dirPath) {
975
+ let files;
976
+ try {
977
+ files = await readdir(dirPath);
978
+ } catch {
979
+ return {
980
+ filesProcessed: 0,
981
+ sectionsCreated: 0,
982
+ sectionsRemoved: 0
983
+ };
984
+ }
985
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
986
+ const fileContents = /* @__PURE__ */ new Map();
987
+ for (const file of mdFiles) {
988
+ const filePath = join(dirPath, file);
989
+ try {
990
+ const content = await readFile(filePath, "utf-8");
991
+ fileContents.set(file, content);
992
+ } catch {}
993
+ }
994
+ let totalCreated = 0;
995
+ let totalRemoved = 0;
996
+ for (const [filename, content] of fileContents) {
997
+ const stats = this.ingestFileSync(filename, content);
998
+ totalCreated += stats.sectionsCreated;
999
+ totalRemoved += stats.sectionsRemoved;
1000
+ }
1001
+ return {
1002
+ filesProcessed: fileContents.size,
1003
+ sectionsCreated: totalCreated,
1004
+ sectionsRemoved: totalRemoved
1005
+ };
1006
+ }
1007
+ /**
1008
+ * Ingests a single markdown file.
1009
+ * Wraps async file reading with sync ingestion.
1010
+ */
1011
+ async ingestFile(filePath) {
1012
+ try {
1013
+ const content = await readFile(filePath, "utf-8");
1014
+ const filename = basename(filePath);
1015
+ return this.ingestFileSync(filename, content);
1016
+ } catch {
1017
+ return {
1018
+ filesProcessed: 0,
1019
+ sectionsCreated: 0,
1020
+ sectionsRemoved: 0
1021
+ };
1022
+ }
1023
+ }
1024
+ /**
1025
+ * Internal sync ingestion method (runs within transaction).
1026
+ * Implements idempotent upsert via soft-delete + recreate.
1027
+ */
1028
+ ingestFileSync(filename, fileContent) {
1029
+ const sourceTag = `ingest:${filename}`;
1030
+ const sections = parseMarkdownSections(fileContent, filename);
1031
+ return this.db.transaction(() => {
1032
+ const repo = new ObservationRepository(this.db, this.projectHash);
1033
+ const sectionsRemoved = this.db.prepare(`UPDATE observations
1034
+ SET deleted_at = datetime('now'), updated_at = datetime('now')
1035
+ WHERE project_hash = ? AND source = ? AND deleted_at IS NULL`).run(this.projectHash, sourceTag).changes;
1036
+ let sectionsCreated = 0;
1037
+ for (const section of sections) {
1038
+ repo.createClassified({
1039
+ content: section.content,
1040
+ title: section.title,
1041
+ source: sourceTag,
1042
+ kind: "reference",
1043
+ sessionId: null
1044
+ }, "discovery");
1045
+ sectionsCreated++;
1046
+ }
1047
+ return {
1048
+ filesProcessed: 1,
1049
+ sectionsCreated,
1050
+ sectionsRemoved
1051
+ };
1052
+ })();
1053
+ }
1054
+ };
1055
+
1056
+ //#endregion
1057
+ //#region src/mcp/tools/ingest-knowledge.ts
1058
+ /**
1059
+ * Registers the ingest_knowledge tool on the MCP server.
1060
+ *
1061
+ * ingest_knowledge transforms structured markdown documents into per-project
1062
+ * reference observations. Supports optional directory path; auto-detects
1063
+ * .planning/codebase/ or .laminark/codebase/ from project metadata when
1064
+ * directory is omitted.
1065
+ */
1066
+ function registerIngestKnowledge(server, db, projectHashRef, notificationStore = null, statusCache = null) {
1067
+ server.registerTool("ingest_knowledge", {
1068
+ title: "Ingest Knowledge",
1069
+ description: "Ingest structured markdown documents from a directory into queryable per-project memories. Reads .md files, splits by ## headings, and stores each section as a reference observation. Supports .planning/codebase/ (GSD output) and .laminark/codebase/.",
1070
+ inputSchema: { directory: z.string().optional().describe("Directory containing .md files to ingest. If omitted, auto-detects .planning/codebase/ or .laminark/codebase/ using the project path from project_metadata.") }
1071
+ }, async (args) => {
1072
+ const projectHash = projectHashRef.current;
1073
+ try {
1074
+ let resolvedDir = args.directory;
1075
+ if (!resolvedDir) {
1076
+ const row = db.prepare("SELECT project_path FROM project_metadata WHERE project_hash = ? ORDER BY last_seen_at DESC LIMIT 1").get(projectHash);
1077
+ if (!row) return {
1078
+ content: [{
1079
+ type: "text",
1080
+ text: "Could not determine project path. Please provide the directory parameter explicitly."
1081
+ }],
1082
+ isError: true
1083
+ };
1084
+ const detected = KnowledgeIngester.detectKnowledgeDir(row.project_path);
1085
+ if (!detected) return {
1086
+ content: [{
1087
+ type: "text",
1088
+ text: "No knowledge directory found. Expected .planning/codebase/ or .laminark/codebase/ in the project root. Run /gsd:map-codebase first or provide a directory path."
1089
+ }],
1090
+ isError: true
1091
+ };
1092
+ resolvedDir = detected;
1093
+ }
1094
+ const stats = await new KnowledgeIngester(db, projectHash).ingestDirectory(resolvedDir);
1095
+ debug("mcp", "ingest_knowledge: completed", {
1096
+ directory: resolvedDir,
1097
+ stats
1098
+ });
1099
+ statusCache?.markDirty();
1100
+ let finalResponse = verboseResponse(`Ingested ${stats.filesProcessed} files: ${stats.sectionsCreated} sections created, ${stats.sectionsRemoved} stale sections removed.`, `Ingested ${stats.filesProcessed} file(s): ${stats.sectionsCreated} sections created, ${stats.sectionsRemoved} removed.`, `Ingested ${stats.filesProcessed} file(s) from ${resolvedDir}: ${stats.sectionsCreated} sections created, ${stats.sectionsRemoved} stale sections removed.`);
1101
+ if (notificationStore) {
1102
+ const pending = notificationStore.consumePending(projectHash);
1103
+ if (pending.length > 0) finalResponse = pending.map((n) => `[Laminark] ${n.message}`).join("\n") + "\n\n" + finalResponse;
1104
+ }
1105
+ return { content: [{
1106
+ type: "text",
1107
+ text: finalResponse
1108
+ }] };
1109
+ } catch (err) {
1110
+ return {
1111
+ content: [{
1112
+ type: "text",
1113
+ text: `Failed to ingest knowledge: ${err instanceof Error ? err.message : "Unknown error"}`
1114
+ }],
1115
+ isError: true
1116
+ };
1117
+ }
1118
+ });
1119
+ }
1120
+
717
1121
  //#endregion
718
1122
  //#region src/commands/resume.ts
719
1123
  /**
@@ -785,13 +1189,13 @@ function formatStashes(stashes) {
785
1189
  if (stashes.length <= 8) return formatDetail(stashes);
786
1190
  return formatCompact(stashes);
787
1191
  }
788
- function prependNotifications$5(notificationStore, projectHash, responseText) {
1192
+ function prependNotifications$7(notificationStore, projectHash, responseText) {
789
1193
  if (!notificationStore) return responseText;
790
1194
  const pending = notificationStore.consumePending(projectHash);
791
1195
  if (pending.length === 0) return responseText;
792
1196
  return pending.map((n) => `[Laminark] ${n.message}`).join("\n") + "\n\n" + responseText;
793
1197
  }
794
- function textResponse$6(text) {
1198
+ function textResponse$8(text) {
795
1199
  return { content: [{
796
1200
  type: "text",
797
1201
  text
@@ -803,7 +1207,7 @@ function textResponse$6(text) {
803
1207
  * Shows recently stashed context threads. Used when the user asks
804
1208
  * "where was I?" or wants to see abandoned conversation threads.
805
1209
  */
806
- function registerTopicContext(server, db, projectHash, notificationStore = null) {
1210
+ function registerTopicContext(server, db, projectHashRef, notificationStore = null) {
807
1211
  const stashManager = new StashManager(db);
808
1212
  server.registerTool("topic_context", {
809
1213
  title: "Topic Context",
@@ -813,7 +1217,8 @@ function registerTopicContext(server, db, projectHash, notificationStore = null)
813
1217
  limit: z.number().int().min(1).max(20).default(5).describe("Max threads to return")
814
1218
  }
815
1219
  }, async (args) => {
816
- const withNotifications = (text) => textResponse$6(prependNotifications$5(notificationStore, projectHash, text));
1220
+ const projectHash = projectHashRef.current;
1221
+ const withNotifications = (text) => textResponse$8(prependNotifications$7(notificationStore, projectHash, text));
817
1222
  try {
818
1223
  debug("mcp", "topic_context: request", {
819
1224
  query: args.query,
@@ -825,6 +1230,9 @@ function registerTopicContext(server, db, projectHash, notificationStore = null)
825
1230
  stashes = stashes.filter((s) => s.topicLabel.toLowerCase().includes(q) || s.summary.toLowerCase().includes(q));
826
1231
  }
827
1232
  if (stashes.length === 0) return withNotifications("No stashed context threads found. You're working in a single thread.");
1233
+ const verbosity = loadToolVerbosityConfig().level;
1234
+ if (verbosity === 1) return withNotifications(`${stashes.length} stashed thread(s)`);
1235
+ if (verbosity === 2) return withNotifications(stashes.map((s, i) => `${i + 1}. ${s.topicLabel} (${timeAgo(s.createdAt)})`).join("\n"));
828
1236
  const formatted = formatStashes(stashes);
829
1237
  const footer = `\n---\n${stashes.length} stashed thread(s) | Use /laminark:resume {id} to restore`;
830
1238
  debug("mcp", "topic_context: returning", { count: stashes.length });
@@ -832,7 +1240,7 @@ function registerTopicContext(server, db, projectHash, notificationStore = null)
832
1240
  } catch (err) {
833
1241
  const message = err instanceof Error ? err.message : "Unknown error";
834
1242
  debug("mcp", "topic_context: error", { error: message });
835
- return textResponse$6(`Error retrieving context threads: ${message}`);
1243
+ return textResponse$8(`Error retrieving context threads: ${message}`);
836
1244
  }
837
1245
  });
838
1246
  }
@@ -937,19 +1345,19 @@ function formatAge(isoDate) {
937
1345
  const months = Math.floor(days / 30);
938
1346
  return `${months} month${months !== 1 ? "s" : ""} ago`;
939
1347
  }
940
- function prependNotifications$4(notificationStore, projectHash, responseText) {
1348
+ function prependNotifications$6(notificationStore, projectHash, responseText) {
941
1349
  if (!notificationStore) return responseText;
942
1350
  const pending = notificationStore.consumePending(projectHash);
943
1351
  if (pending.length === 0) return responseText;
944
1352
  return pending.map((n) => `[Laminark] ${n.message}`).join("\n") + "\n\n" + responseText;
945
1353
  }
946
- function textResponse$5(text) {
1354
+ function textResponse$7(text) {
947
1355
  return { content: [{
948
1356
  type: "text",
949
1357
  text
950
1358
  }] };
951
1359
  }
952
- function errorResponse$2(text) {
1360
+ function errorResponse$3(text) {
953
1361
  return {
954
1362
  content: [{
955
1363
  type: "text",
@@ -964,7 +1372,7 @@ function errorResponse$2(text) {
964
1372
  * Allows Claude to search entities by name (exact or fuzzy), filter by type,
965
1373
  * traverse relationships to configurable depth, and see linked observations.
966
1374
  */
967
- function registerQueryGraph(server, db, projectHash, notificationStore = null) {
1375
+ function registerQueryGraph(server, db, projectHashRef, notificationStore = null) {
968
1376
  initGraphSchema(db);
969
1377
  server.registerTool("query_graph", {
970
1378
  title: "Query Knowledge Graph",
@@ -977,17 +1385,18 @@ function registerQueryGraph(server, db, projectHash, notificationStore = null) {
977
1385
  limit: z.number().int().min(1).max(50).default(20).describe("Max root entities to return (default: 20, max: 50)")
978
1386
  }
979
1387
  }, async (args) => {
980
- const withNotifications = (text) => textResponse$5(prependNotifications$4(notificationStore, projectHash, text));
1388
+ const projectHash = projectHashRef.current;
1389
+ const withNotifications = (text) => textResponse$7(prependNotifications$6(notificationStore, projectHash, text));
981
1390
  try {
982
1391
  debug("mcp", "query_graph: request", {
983
1392
  query: args.query,
984
1393
  entity_type: args.entity_type,
985
1394
  depth: args.depth
986
1395
  });
987
- if (args.entity_type !== void 0 && !isEntityType(args.entity_type)) return errorResponse$2(`Invalid entity_type "${args.entity_type}". Valid types: ${ENTITY_TYPES.join(", ")}`);
1396
+ if (args.entity_type !== void 0 && !isEntityType(args.entity_type)) return errorResponse$3(`Invalid entity_type "${args.entity_type}". Valid types: ${ENTITY_TYPES.join(", ")}`);
988
1397
  const entityType = args.entity_type;
989
1398
  if (args.relationship_types) {
990
- for (const rt of args.relationship_types) if (!isRelationshipType(rt)) return errorResponse$2(`Invalid relationship_type "${rt}". Valid types: ${RELATIONSHIP_TYPES.join(", ")}`);
1399
+ for (const rt of args.relationship_types) if (!isRelationshipType(rt)) return errorResponse$3(`Invalid relationship_type "${rt}". Valid types: ${RELATIONSHIP_TYPES.join(", ")}`);
991
1400
  }
992
1401
  const relationshipTypes = args.relationship_types;
993
1402
  const rootNodes = [];
@@ -1048,6 +1457,21 @@ function registerQueryGraph(server, db, projectHash, notificationStore = null) {
1048
1457
  createdAt: row.created_at
1049
1458
  });
1050
1459
  }
1460
+ const verbosity = loadToolVerbosityConfig().level;
1461
+ if (verbosity === 1) {
1462
+ const totalTraversals = [...traversalsByNode.values()].reduce((sum, arr) => sum + arr.length, 0);
1463
+ return withNotifications(`${rootNodes.length} entities, ${totalTraversals} connections found`);
1464
+ }
1465
+ if (verbosity === 2) {
1466
+ const lines = [];
1467
+ lines.push("## Entities Found");
1468
+ lines.push("");
1469
+ for (const node of rootNodes) {
1470
+ const traversals = traversalsByNode.get(node.id) ?? [];
1471
+ lines.push(`- ${formatEntityType(node.type)} ${node.name} (${traversals.length} connections)`);
1472
+ }
1473
+ return withNotifications(lines.join("\n"));
1474
+ }
1051
1475
  const formatted = formatResults(rootNodes, traversalsByNode, observations, args.query);
1052
1476
  debug("mcp", "query_graph: returning", {
1053
1477
  rootNodes: rootNodes.length,
@@ -1058,180 +1482,11 @@ function registerQueryGraph(server, db, projectHash, notificationStore = null) {
1058
1482
  } catch (err) {
1059
1483
  const message = err instanceof Error ? err.message : "Unknown error";
1060
1484
  debug("mcp", "query_graph: error", { error: message });
1061
- return errorResponse$2(`Graph query error: ${message}`);
1485
+ return errorResponse$3(`Graph query error: ${message}`);
1062
1486
  }
1063
1487
  });
1064
1488
  }
1065
1489
 
1066
- //#endregion
1067
- //#region src/graph/staleness.ts
1068
- /**
1069
- * Negation patterns: newer observation negates older one.
1070
- * Matches when newer text contains negation keywords absent in older text
1071
- * and both discuss similar subjects.
1072
- */
1073
- const NEGATION_KEYWORDS = [
1074
- "not",
1075
- "don't",
1076
- "no longer",
1077
- "stopped",
1078
- "never",
1079
- "doesn't",
1080
- "won't",
1081
- "isn't",
1082
- "aren't",
1083
- "discontinued"
1084
- ];
1085
- /**
1086
- * Replacement patterns: newer observation explicitly replaces older approach.
1087
- */
1088
- const REPLACEMENT_PATTERNS = [
1089
- /switched\s+(?:from\s+\S+\s+)?to\b/i,
1090
- /migrated\s+(?:from\s+\S+\s+)?to\b/i,
1091
- /replaced\s+(?:\S+\s+)?with\b/i,
1092
- /changed\s+from\b/i,
1093
- /moved\s+(?:from\s+\S+\s+)?to\b/i,
1094
- /upgraded\s+(?:from\s+\S+\s+)?to\b/i,
1095
- /swapped\s+(?:\S+\s+)?(?:for|with)\b/i
1096
- ];
1097
- /**
1098
- * Status change patterns: newer observation marks something as inactive.
1099
- */
1100
- const STATUS_CHANGE_KEYWORDS = [
1101
- "removed",
1102
- "deleted",
1103
- "deprecated",
1104
- "archived",
1105
- "dropped",
1106
- "disabled",
1107
- "decommissioned",
1108
- "sunset",
1109
- "abandoned"
1110
- ];
1111
- /**
1112
- * Creates the staleness_flags table if it doesn't exist.
1113
- * Uses a separate table rather than modifying the observations table,
1114
- * keeping staleness metadata decoupled from core observation storage.
1115
- */
1116
- function initStalenessSchema(db) {
1117
- db.exec(`
1118
- CREATE TABLE IF NOT EXISTS staleness_flags (
1119
- observation_id TEXT PRIMARY KEY,
1120
- flagged_at TEXT NOT NULL DEFAULT (datetime('now')),
1121
- reason TEXT NOT NULL,
1122
- resolved INTEGER NOT NULL DEFAULT 0
1123
- );
1124
- CREATE INDEX IF NOT EXISTS idx_staleness_resolved ON staleness_flags(resolved);
1125
- `);
1126
- }
1127
- /**
1128
- * Detects potential staleness (contradictions) between observations
1129
- * linked to a specific entity.
1130
- *
1131
- * Compares consecutive observation pairs chronologically and checks for:
1132
- * 1. Negation patterns (newer negates older)
1133
- * 2. Replacement patterns (newer replaces older approach)
1134
- * 3. Status change patterns (newer marks something as inactive)
1135
- *
1136
- * This is DETECTION ONLY -- no data is modified.
1137
- *
1138
- * @param db - better-sqlite3 Database handle
1139
- * @param entityId - Graph node ID to check observations for
1140
- * @returns Array of StalenessReport for each detected contradiction
1141
- */
1142
- function detectStaleness(db, entityId) {
1143
- const node = db.prepare("SELECT id, name, type, observation_ids FROM graph_nodes WHERE id = ?").get(entityId);
1144
- if (!node) return [];
1145
- const obsIds = JSON.parse(node.observation_ids);
1146
- if (obsIds.length < 2) return [];
1147
- const placeholders = obsIds.map(() => "?").join(", ");
1148
- const observations = db.prepare(`SELECT * FROM observations WHERE id IN (${placeholders}) AND deleted_at IS NULL ORDER BY created_at ASC`).all(...obsIds).map(rowToObservation);
1149
- if (observations.length < 2) return [];
1150
- const reports = [];
1151
- const now = (/* @__PURE__ */ new Date()).toISOString();
1152
- for (let i = 0; i < observations.length - 1; i++) {
1153
- const older = observations[i];
1154
- const newer = observations[i + 1];
1155
- const reason = detectContradiction(older.content, newer.content);
1156
- if (reason) reports.push({
1157
- entityId: node.id,
1158
- entityName: node.name,
1159
- entityType: node.type,
1160
- newerObservation: {
1161
- id: newer.id,
1162
- text: newer.content,
1163
- created_at: newer.createdAt
1164
- },
1165
- olderObservation: {
1166
- id: older.id,
1167
- text: older.content,
1168
- created_at: older.createdAt
1169
- },
1170
- reason,
1171
- detectedAt: now
1172
- });
1173
- }
1174
- return reports;
1175
- }
1176
- /**
1177
- * Detects contradiction between two observation texts.
1178
- * Returns a human-readable reason string, or null if no contradiction found.
1179
- */
1180
- function detectContradiction(olderText, newerText) {
1181
- const olderLower = olderText.toLowerCase();
1182
- const newerLower = newerText.toLowerCase();
1183
- const negationResult = detectNegation(olderLower, newerLower);
1184
- if (negationResult) return negationResult;
1185
- const replacementResult = detectReplacement(newerLower);
1186
- if (replacementResult) return replacementResult;
1187
- const statusResult = detectStatusChange(olderLower, newerLower);
1188
- if (statusResult) return statusResult;
1189
- return null;
1190
- }
1191
- /**
1192
- * Detects negation: newer text contains negation keywords that are absent
1193
- * in the older text, suggesting the newer observation contradicts the older.
1194
- */
1195
- function detectNegation(olderLower, newerLower) {
1196
- for (const keyword of NEGATION_KEYWORDS) if (newerLower.includes(keyword) && !olderLower.includes(keyword)) return `Newer observation contains negation ("${keyword}") not present in older observation`;
1197
- return null;
1198
- }
1199
- /**
1200
- * Detects replacement: newer text explicitly mentions switching/replacing.
1201
- */
1202
- function detectReplacement(newerLower) {
1203
- for (const pattern of REPLACEMENT_PATTERNS) {
1204
- const match = newerLower.match(pattern);
1205
- if (match) return `Newer observation indicates replacement ("${match[0].trim()}")`;
1206
- }
1207
- return null;
1208
- }
1209
- /**
1210
- * Detects status change: newer text marks something as removed/deprecated
1211
- * when the older text described it as active/present.
1212
- */
1213
- function detectStatusChange(olderLower, newerLower) {
1214
- for (const keyword of STATUS_CHANGE_KEYWORDS) if (newerLower.includes(keyword) && !olderLower.includes(keyword)) return `Newer observation indicates status change ("${keyword}")`;
1215
- return null;
1216
- }
1217
- /**
1218
- * Flags an observation as stale with an advisory reason.
1219
- *
1220
- * This flag is advisory -- search can use it to deprioritize but never hide
1221
- * the observation. The observation remains fully queryable.
1222
- *
1223
- * Uses INSERT OR REPLACE to allow re-flagging with an updated reason.
1224
- *
1225
- * @param db - better-sqlite3 Database handle
1226
- * @param observationId - ID of the observation to flag
1227
- * @param reason - Human-readable explanation of why it's stale
1228
- */
1229
- function flagStaleObservation(db, observationId, reason) {
1230
- initStalenessSchema(db);
1231
- db.prepare(`INSERT OR REPLACE INTO staleness_flags (observation_id, reason, resolved)
1232
- VALUES (?, ?, 0)`).run(observationId, reason);
1233
- }
1234
-
1235
1490
  //#endregion
1236
1491
  //#region src/mcp/tools/graph-stats.ts
1237
1492
  /**
@@ -1329,13 +1584,13 @@ function formatStats(stats) {
1329
1584
  }
1330
1585
  return lines.join("\n");
1331
1586
  }
1332
- function prependNotifications$3(notificationStore, projectHash, responseText) {
1587
+ function prependNotifications$5(notificationStore, projectHash, responseText) {
1333
1588
  if (!notificationStore) return responseText;
1334
1589
  const pending = notificationStore.consumePending(projectHash);
1335
1590
  if (pending.length === 0) return responseText;
1336
1591
  return pending.map((n) => `[Laminark] ${n.message}`).join("\n") + "\n\n" + responseText;
1337
1592
  }
1338
- function textResponse$4(text) {
1593
+ function textResponse$6(text) {
1339
1594
  return { content: [{
1340
1595
  type: "text",
1341
1596
  text
@@ -1348,13 +1603,14 @@ function textResponse$4(text) {
1348
1603
  * type distribution, degree statistics, hotspot nodes, duplicate candidates,
1349
1604
  * and staleness flags. No input parameters -- dashboard view.
1350
1605
  */
1351
- function registerGraphStats(server, db, projectHash, notificationStore = null) {
1606
+ function registerGraphStats(server, db, projectHashRef, notificationStore = null) {
1352
1607
  initGraphSchema(db);
1353
1608
  server.registerTool("graph_stats", {
1354
1609
  title: "Graph Statistics",
1355
1610
  description: "Get knowledge graph statistics: entity counts, relationship distribution, health metrics. Use to understand the state of accumulated knowledge.",
1356
1611
  inputSchema: {}
1357
1612
  }, async () => {
1613
+ const projectHash = projectHashRef.current;
1358
1614
  try {
1359
1615
  debug("mcp", "graph_stats: request");
1360
1616
  const stats = collectGraphStats(db);
@@ -1363,50 +1619,170 @@ function registerGraphStats(server, db, projectHash, notificationStore = null) {
1363
1619
  nodes: stats.total_nodes,
1364
1620
  edges: stats.total_edges
1365
1621
  });
1366
- return textResponse$4(prependNotifications$3(notificationStore, projectHash, formatted));
1622
+ return textResponse$6(prependNotifications$5(notificationStore, projectHash, formatted));
1367
1623
  } catch (err) {
1368
1624
  const message = err instanceof Error ? err.message : "Unknown error";
1369
1625
  debug("mcp", "graph_stats: error", { error: message });
1370
- return textResponse$4(`Graph stats error: ${message}`);
1626
+ return textResponse$6(`Graph stats error: ${message}`);
1371
1627
  }
1372
1628
  });
1373
1629
  }
1374
1630
 
1375
1631
  //#endregion
1376
- //#region src/mcp/tools/status.ts
1377
- function prependNotifications$2(notificationStore, projectHash, responseText) {
1632
+ //#region src/mcp/tools/hygiene.ts
1633
+ function formatReport(report, mode, tier) {
1634
+ const lines = [];
1635
+ lines.push("## Database Hygiene Report");
1636
+ lines.push(`Analyzed ${report.totalObservations.toLocaleString()} observations`);
1637
+ lines.push("");
1638
+ lines.push("### Summary");
1639
+ lines.push("| Tier | Count | Action |");
1640
+ lines.push("|------|-------|--------|");
1641
+ lines.push(`| High (>= 0.7) | ${report.summary.high} | Safe to purge |`);
1642
+ lines.push(`| Medium (0.5-0.69) | ${report.summary.medium} | Review recommended |`);
1643
+ if (report.summary.low > 0) lines.push(`| Low (< 0.5) | ${report.summary.low} | Kept |`);
1644
+ lines.push(`| Orphan graph nodes | ${report.summary.orphanNodeCount} | Dead references |`);
1645
+ lines.push("");
1646
+ if (report.candidates.length === 0) {
1647
+ lines.push("No candidates found matching the selected tier.");
1648
+ return lines.join("\n");
1649
+ }
1650
+ const bySession = /* @__PURE__ */ new Map();
1651
+ for (const c of report.candidates) {
1652
+ const key = c.sessionId ?? "(no session)";
1653
+ const list = bySession.get(key) ?? [];
1654
+ list.push(c);
1655
+ bySession.set(key, list);
1656
+ }
1657
+ const tierLabel = tier === "all" ? "All" : tier === "medium" ? "Medium+" : "High";
1658
+ lines.push(`### ${tierLabel} Confidence Candidates (showing ${report.candidates.length})`);
1659
+ lines.push("");
1660
+ for (const [sessionId, candidates] of bySession) {
1661
+ const sessionDate = candidates[0]?.createdAt?.substring(0, 10) ?? "";
1662
+ lines.push(`#### Session: ${sessionId.substring(0, 8)} (${sessionDate}, ${candidates.length} obs)`);
1663
+ lines.push("| ID | Kind | Source | Confidence | Signals | Preview |");
1664
+ lines.push("|----|------|--------|------------|---------|---------|");
1665
+ for (const c of candidates) {
1666
+ const signals = [];
1667
+ if (c.signals.orphaned) signals.push("orphaned");
1668
+ if (c.signals.islandNode) signals.push("island");
1669
+ if (c.signals.noiseClassified) signals.push("noise");
1670
+ if (c.signals.shortContent) signals.push("short");
1671
+ if (c.signals.autoCaptured) signals.push("auto");
1672
+ if (c.signals.stale) signals.push("stale");
1673
+ const preview = c.contentPreview.replace(/\|/g, "\\|").replace(/\n/g, " ");
1674
+ lines.push(`| ${c.shortId} | ${c.kind} | ${c.source} | ${c.confidence.toFixed(2)} | ${signals.join(",") || "-"} | ${preview} |`);
1675
+ }
1676
+ lines.push("");
1677
+ }
1678
+ if (mode === "simulate") lines.push(`_Dry run — no data modified. Use \`hygiene(mode="purge", tier="${tier}")\` to execute._`);
1679
+ return lines.join("\n");
1680
+ }
1681
+ function formatPurgeResult(observationsPurged, orphanNodesRemoved, tier) {
1682
+ const lines = [];
1683
+ lines.push("## Hygiene Purge Complete");
1684
+ lines.push(`- Tier: ${tier}`);
1685
+ lines.push(`- Observations soft-deleted: ${observationsPurged}`);
1686
+ lines.push(`- Orphan graph nodes removed: ${orphanNodesRemoved}`);
1687
+ return lines.join("\n");
1688
+ }
1689
+ function prependNotifications$4(notificationStore, projectHash, responseText) {
1378
1690
  if (!notificationStore) return responseText;
1379
1691
  const pending = notificationStore.consumePending(projectHash);
1380
1692
  if (pending.length === 0) return responseText;
1381
1693
  return pending.map((n) => `[Laminark] ${n.message}`).join("\n") + "\n\n" + responseText;
1382
1694
  }
1383
- function textResponse$3(text) {
1695
+ function textResponse$5(text) {
1384
1696
  return { content: [{
1385
1697
  type: "text",
1386
1698
  text
1387
1699
  }] };
1388
1700
  }
1389
- function registerStatus(server, cache, projectHash, notificationStore = null) {
1390
- server.registerTool("status", {
1391
- title: "Laminark Status",
1392
- description: "Show Laminark system status: connection info, memory count, token estimates, and capabilities.",
1393
- inputSchema: {}
1394
- }, async () => {
1701
+ function registerHygiene(server, db, projectHashRef, notificationStore = null) {
1702
+ server.registerTool("hygiene", {
1703
+ title: "Database Hygiene",
1704
+ description: "Analyze observations for deletion candidates with confidence scoring. Simulate mode (default) produces a dry-run report. Purge mode soft-deletes candidates and removes dead orphan graph nodes.",
1705
+ inputSchema: {
1706
+ mode: z.enum(["simulate", "purge"]).default("simulate").describe("simulate = dry-run report, purge = execute deletions"),
1707
+ tier: z.enum([
1708
+ "high",
1709
+ "medium",
1710
+ "all"
1711
+ ]).default("high").describe("Which confidence tier to act on"),
1712
+ session_id: z.string().optional().describe("Optional: scope analysis to a single session"),
1713
+ limit: z.number().int().min(1).max(200).default(50).describe("Max results to return")
1714
+ }
1715
+ }, async (args) => {
1716
+ const projectHash = projectHashRef.current;
1395
1717
  try {
1396
- debug("mcp", "status: request (cached)");
1397
- return textResponse$3(prependNotifications$2(notificationStore, projectHash, cache.getFormatted()));
1718
+ const mode = args.mode ?? "simulate";
1719
+ const tier = args.tier ?? "high";
1720
+ const sessionId = args.session_id;
1721
+ const limit = args.limit ?? 50;
1722
+ debug("hygiene", "Request", {
1723
+ mode,
1724
+ tier,
1725
+ sessionId,
1726
+ limit
1727
+ });
1728
+ const report = analyzeObservations(db, projectHash, {
1729
+ sessionId,
1730
+ limit,
1731
+ minTier: tier === "all" ? "low" : tier
1732
+ });
1733
+ if (mode === "purge") {
1734
+ const result = executePurge(db, projectHash, report, tier);
1735
+ return textResponse$5(prependNotifications$4(notificationStore, projectHash, formatPurgeResult(result.observationsPurged, result.orphanNodesRemoved, tier)));
1736
+ }
1737
+ return textResponse$5(prependNotifications$4(notificationStore, projectHash, formatReport(report, mode, tier)));
1398
1738
  } catch (err) {
1399
1739
  const message = err instanceof Error ? err.message : "Unknown error";
1400
- debug("mcp", "status: error", { error: message });
1401
- return textResponse$3(`Status error: ${message}`);
1740
+ debug("hygiene", "Error", { error: message });
1741
+ return textResponse$5(`Hygiene analysis error: ${message}`);
1402
1742
  }
1403
1743
  });
1404
1744
  }
1405
1745
 
1406
1746
  //#endregion
1407
- //#region src/mcp/status-cache.ts
1408
- function formatUptime(seconds) {
1409
- const h = Math.floor(seconds / 3600);
1747
+ //#region src/mcp/tools/status.ts
1748
+ function prependNotifications$3(notificationStore, projectHash, responseText) {
1749
+ if (!notificationStore) return responseText;
1750
+ const pending = notificationStore.consumePending(projectHash);
1751
+ if (pending.length === 0) return responseText;
1752
+ return pending.map((n) => `[Laminark] ${n.message}`).join("\n") + "\n\n" + responseText;
1753
+ }
1754
+ function textResponse$4(text) {
1755
+ return { content: [{
1756
+ type: "text",
1757
+ text
1758
+ }] };
1759
+ }
1760
+ function registerStatus(server, cache, projectHashRef, notificationStore = null) {
1761
+ server.registerTool("status", {
1762
+ title: "Laminark Status",
1763
+ description: "Show Laminark system status: connection info, memory count, token estimates, and capabilities.",
1764
+ inputSchema: {}
1765
+ }, async () => {
1766
+ const projectHash = projectHashRef.current;
1767
+ try {
1768
+ debug("mcp", "status: request (cached)");
1769
+ const verbosity = loadToolVerbosityConfig().level;
1770
+ if (verbosity === 1) return textResponse$4(prependNotifications$3(notificationStore, projectHash, "Laminark: connected"));
1771
+ const formatted = cache.getFormatted();
1772
+ if (verbosity === 2) return textResponse$4(prependNotifications$3(notificationStore, projectHash, formatted.split("\n").slice(0, 8).join("\n")));
1773
+ return textResponse$4(prependNotifications$3(notificationStore, projectHash, formatted));
1774
+ } catch (err) {
1775
+ const message = err instanceof Error ? err.message : "Unknown error";
1776
+ debug("mcp", "status: error", { error: message });
1777
+ return textResponse$4(`Status error: ${message}`);
1778
+ }
1779
+ });
1780
+ }
1781
+
1782
+ //#endregion
1783
+ //#region src/mcp/status-cache.ts
1784
+ function formatUptime(seconds) {
1785
+ const h = Math.floor(seconds / 3600);
1410
1786
  const m = Math.floor(seconds % 3600 / 60);
1411
1787
  const s = seconds % 60;
1412
1788
  if (h > 0) return `${h}h ${m}m`;
@@ -1415,7 +1791,7 @@ function formatUptime(seconds) {
1415
1791
  }
1416
1792
  var StatusCache = class {
1417
1793
  db;
1418
- projectHash;
1794
+ projectHashRef;
1419
1795
  projectPath;
1420
1796
  hasVectorSupport;
1421
1797
  isWorkerReady;
@@ -1424,9 +1800,9 @@ var StatusCache = class {
1424
1800
  /** Uptime snapshot at the time cachedBody was built. */
1425
1801
  builtAtUptime = 0;
1426
1802
  dirty = false;
1427
- constructor(db, projectHash, projectPath, hasVectorSupport, isWorkerReady) {
1803
+ constructor(db, projectHashRef, projectPath, hasVectorSupport, isWorkerReady) {
1428
1804
  this.db = db;
1429
- this.projectHash = projectHash;
1805
+ this.projectHashRef = projectHashRef;
1430
1806
  this.projectPath = projectPath;
1431
1807
  this.hasVectorSupport = hasVectorSupport;
1432
1808
  this.isWorkerReady = isWorkerReady;
@@ -1453,7 +1829,7 @@ var StatusCache = class {
1453
1829
  }
1454
1830
  rebuild() {
1455
1831
  try {
1456
- const ph = this.projectHash;
1832
+ const ph = this.projectHashRef.current;
1457
1833
  const totalObs = this.db.prepare("SELECT COUNT(*) as cnt FROM observations WHERE project_hash = ? AND deleted_at IS NULL").get(ph).cnt;
1458
1834
  const embeddedObs = this.db.prepare("SELECT COUNT(*) as cnt FROM observations WHERE project_hash = ? AND deleted_at IS NULL AND embedding_model IS NOT NULL").get(ph).cnt;
1459
1835
  const deletedObs = this.db.prepare("SELECT COUNT(*) as cnt FROM observations WHERE project_hash = ? AND deleted_at IS NOT NULL").get(ph).cnt;
@@ -1509,13 +1885,13 @@ var StatusCache = class {
1509
1885
 
1510
1886
  //#endregion
1511
1887
  //#region src/mcp/tools/discover-tools.ts
1512
- function textResponse$2(text) {
1888
+ function textResponse$3(text) {
1513
1889
  return { content: [{
1514
1890
  type: "text",
1515
1891
  text
1516
1892
  }] };
1517
1893
  }
1518
- function errorResponse$1(text) {
1894
+ function errorResponse$2(text) {
1519
1895
  return {
1520
1896
  content: [{
1521
1897
  type: "text",
@@ -1524,7 +1900,7 @@ function errorResponse$1(text) {
1524
1900
  isError: true
1525
1901
  };
1526
1902
  }
1527
- function prependNotifications$1(notificationStore, projectHash, responseText) {
1903
+ function prependNotifications$2(notificationStore, projectHash, responseText) {
1528
1904
  if (!notificationStore) return responseText;
1529
1905
  const pending = notificationStore.consumePending(projectHash);
1530
1906
  if (pending.length === 0) return responseText;
@@ -1545,7 +1921,7 @@ function formatToolResult(result, index) {
1545
1921
  * with optional scope filtering. Returns ranked results with scope, usage count,
1546
1922
  * and last used timestamp metadata.
1547
1923
  */
1548
- function registerDiscoverTools(server, toolRegistry, worker, hasVectorSupport, notificationStore, projectHash) {
1924
+ function registerDiscoverTools(server, toolRegistry, worker, hasVectorSupport, notificationStore, projectHashRef) {
1549
1925
  server.registerTool("discover_tools", {
1550
1926
  title: "Discover Tools",
1551
1927
  description: "Search the tool registry to find available tools by keyword or description. Supports semantic search -- \"file manipulation\" finds tools described as \"read and write files\". Returns scope, usage count, and last used timestamp for each result.",
@@ -1559,7 +1935,8 @@ function registerDiscoverTools(server, toolRegistry, worker, hasVectorSupport, n
1559
1935
  limit: z.number().int().min(1).max(50).default(20).describe("Maximum results to return (default: 20)")
1560
1936
  }
1561
1937
  }, async (args) => {
1562
- const withNotifications = (text) => textResponse$2(prependNotifications$1(notificationStore, projectHash, text));
1938
+ const projectHash = projectHashRef.current;
1939
+ const withNotifications = (text) => textResponse$3(prependNotifications$2(notificationStore, projectHash, text));
1563
1940
  try {
1564
1941
  debug("mcp", "discover_tools: request", {
1565
1942
  query: args.query,
@@ -1597,14 +1974,14 @@ function registerDiscoverTools(server, toolRegistry, worker, hasVectorSupport, n
1597
1974
  } catch (err) {
1598
1975
  const message = err instanceof Error ? err.message : "Unknown error";
1599
1976
  debug("mcp", "discover_tools: error", { error: message });
1600
- return errorResponse$1(`Discover tools error: ${message}`);
1977
+ return errorResponse$2(`Discover tools error: ${message}`);
1601
1978
  }
1602
1979
  });
1603
1980
  }
1604
1981
 
1605
1982
  //#endregion
1606
1983
  //#region src/mcp/tools/report-tools.ts
1607
- function textResponse$1(text) {
1984
+ function textResponse$2(text) {
1608
1985
  return { content: [{
1609
1986
  type: "text",
1610
1987
  text
@@ -1617,7 +1994,7 @@ function textResponse$1(text) {
1617
1994
  * each into the tool registry. Tool type, scope, and server name are inferred
1618
1995
  * from the tool name using the same parser as PostToolUse organic discovery.
1619
1996
  */
1620
- function registerReportTools(server, toolRegistry, projectHash) {
1997
+ function registerReportTools(server, toolRegistry, projectHashRef) {
1621
1998
  server.registerTool("report_available_tools", {
1622
1999
  title: "Report Available Tools",
1623
2000
  description: "Register all tools available in this session with Laminark. Call this once at session start with every tool name you have access to (built-in and MCP). This populates the tool registry for discovery and routing.",
@@ -1626,6 +2003,7 @@ function registerReportTools(server, toolRegistry, projectHash) {
1626
2003
  description: z.string().optional().describe("Brief description of the tool")
1627
2004
  })).min(1).describe("Array of tools available in this session") }
1628
2005
  }, async (args) => {
2006
+ const projectHash = projectHashRef.current;
1629
2007
  try {
1630
2008
  let registered = 0;
1631
2009
  let skipped = 0;
@@ -1644,7 +2022,8 @@ function registerReportTools(server, toolRegistry, projectHash) {
1644
2022
  source: "config:session-report",
1645
2023
  projectHash: scope === "global" ? null : projectHash,
1646
2024
  description: tool.description ?? null,
1647
- serverName
2025
+ serverName,
2026
+ triggerHints: null
1648
2027
  });
1649
2028
  registered++;
1650
2029
  }
@@ -1653,7 +2032,7 @@ function registerReportTools(server, toolRegistry, projectHash) {
1653
2032
  registered,
1654
2033
  skipped
1655
2034
  });
1656
- return textResponse$1(`Registered ${registered} tools in the tool registry.${skipped > 0 ? ` Skipped ${skipped} Laminark tools (already known).` : ""}`);
2035
+ return textResponse$2(`Registered ${registered} tools in the tool registry.${skipped > 0 ? ` Skipped ${skipped} Laminark tools (already known).` : ""}`);
1657
2036
  } catch (err) {
1658
2037
  const message = err instanceof Error ? err.message : "Unknown error";
1659
2038
  debug("mcp", "report_available_tools: error", { error: message });
@@ -1670,19 +2049,19 @@ function registerReportTools(server, toolRegistry, projectHash) {
1670
2049
 
1671
2050
  //#endregion
1672
2051
  //#region src/mcp/tools/debug-paths.ts
1673
- function prependNotifications(notificationStore, projectHash, responseText) {
2052
+ function prependNotifications$1(notificationStore, projectHash, responseText) {
1674
2053
  if (!notificationStore) return responseText;
1675
2054
  const pending = notificationStore.consumePending(projectHash);
1676
2055
  if (pending.length === 0) return responseText;
1677
2056
  return pending.map((n) => `[Laminark] ${n.message}`).join("\n") + "\n\n" + responseText;
1678
2057
  }
1679
- function textResponse(text) {
2058
+ function textResponse$1(text) {
1680
2059
  return { content: [{
1681
2060
  type: "text",
1682
2061
  text
1683
2062
  }] };
1684
2063
  }
1685
- function errorResponse(text) {
2064
+ function errorResponse$1(text) {
1686
2065
  return {
1687
2066
  content: [{
1688
2067
  type: "text",
@@ -1712,125 +2091,882 @@ function formatKissSummary(raw) {
1712
2091
  *
1713
2092
  * Tools: path_start, path_resolve, path_show, path_list
1714
2093
  */
1715
- function registerDebugPathTools(server, pathRepo, pathTracker, notificationStore, projectHash) {
1716
- server.registerTool("path_start", {
1717
- title: "Start Debug Path",
1718
- description: "Explicitly start tracking a debug path. Use when auto-detection hasn't triggered but you're actively debugging.",
1719
- inputSchema: { trigger: z.string().describe("Brief description of the issue being debugged") }
1720
- }, async (args) => {
1721
- const withNotifications = (text) => textResponse(prependNotifications(notificationStore, projectHash, text));
1722
- try {
1723
- debug("mcp", "path_start: request", { trigger: args.trigger });
1724
- const existingPathId = pathTracker.getActivePathId();
1725
- const pathId = pathTracker.startManually(args.trigger);
1726
- if (!pathId) return errorResponse("Failed to start debug path");
1727
- if (existingPathId && existingPathId === pathId) return withNotifications(`Debug path already active: ${pathId}`);
1728
- return withNotifications(`Debug path started: ${pathId}\nTracking: ${args.trigger}`);
1729
- } catch (err) {
1730
- const message = err instanceof Error ? err.message : "Unknown error";
1731
- debug("mcp", "path_start: error", { error: message });
1732
- return errorResponse(`path_start error: ${message}`);
1733
- }
1734
- });
1735
- server.registerTool("path_resolve", {
1736
- title: "Resolve Debug Path",
1737
- description: "Explicitly resolve the active debug path with a resolution summary. Use when auto-detection hasn't detected resolution.",
1738
- inputSchema: { resolution: z.string().describe("What fixed the issue") }
1739
- }, async (args) => {
1740
- const withNotifications = (text) => textResponse(prependNotifications(notificationStore, projectHash, text));
1741
- try {
1742
- debug("mcp", "path_resolve: request", { resolution: args.resolution });
1743
- const pathId = pathTracker.getActivePathId();
1744
- if (!pathId) return errorResponse("No active debug path to resolve");
1745
- pathTracker.resolveManually(args.resolution);
1746
- return withNotifications(`Debug path resolved: ${pathId}\nResolution: ${args.resolution}\nKISS summary generating in background...`);
1747
- } catch (err) {
1748
- const message = err instanceof Error ? err.message : "Unknown error";
1749
- debug("mcp", "path_resolve: error", { error: message });
1750
- return errorResponse(`path_resolve error: ${message}`);
2094
+ function registerDebugPathTools(server, pathRepo, pathTracker, notificationStore, projectHashRef) {
2095
+ server.registerTool("path_start", {
2096
+ title: "Start Debug Path",
2097
+ description: "Explicitly start tracking a debug path. Use when auto-detection hasn't triggered but you're actively debugging.",
2098
+ inputSchema: { trigger: z.string().describe("Brief description of the issue being debugged") }
2099
+ }, async (args) => {
2100
+ const projectHash = projectHashRef.current;
2101
+ const withNotifications = (text) => textResponse$1(prependNotifications$1(notificationStore, projectHash, text));
2102
+ try {
2103
+ debug("mcp", "path_start: request", { trigger: args.trigger });
2104
+ const existingPathId = pathTracker.getActivePathId();
2105
+ const pathId = pathTracker.startManually(args.trigger);
2106
+ if (!pathId) return errorResponse$1("Failed to start debug path");
2107
+ if (existingPathId && existingPathId === pathId) return withNotifications(`Debug path already active: ${pathId}`);
2108
+ return withNotifications(verboseResponse("Debug path started.", `Debug path started: ${pathId}`, `Debug path started: ${pathId}\nTracking: ${args.trigger}`));
2109
+ } catch (err) {
2110
+ const message = err instanceof Error ? err.message : "Unknown error";
2111
+ debug("mcp", "path_start: error", { error: message });
2112
+ return errorResponse$1(`path_start error: ${message}`);
2113
+ }
2114
+ });
2115
+ server.registerTool("path_resolve", {
2116
+ title: "Resolve Debug Path",
2117
+ description: "Explicitly resolve the active debug path with a resolution summary. Use when auto-detection hasn't detected resolution.",
2118
+ inputSchema: { resolution: z.string().describe("What fixed the issue") }
2119
+ }, async (args) => {
2120
+ const projectHash = projectHashRef.current;
2121
+ const withNotifications = (text) => textResponse$1(prependNotifications$1(notificationStore, projectHash, text));
2122
+ try {
2123
+ debug("mcp", "path_resolve: request", { resolution: args.resolution });
2124
+ const pathId = pathTracker.getActivePathId();
2125
+ if (!pathId) return errorResponse$1("No active debug path to resolve");
2126
+ pathTracker.resolveManually(args.resolution);
2127
+ return withNotifications(verboseResponse("Debug path resolved.", `Debug path resolved: ${pathId}`, `Debug path resolved: ${pathId}\nResolution: ${args.resolution}\nKISS summary generating in background...`));
2128
+ } catch (err) {
2129
+ const message = err instanceof Error ? err.message : "Unknown error";
2130
+ debug("mcp", "path_resolve: error", { error: message });
2131
+ return errorResponse$1(`path_resolve error: ${message}`);
2132
+ }
2133
+ });
2134
+ server.registerTool("path_show", {
2135
+ title: "Show Debug Path",
2136
+ description: "Show a debug path with its waypoints and KISS summary.",
2137
+ inputSchema: { path_id: z.string().optional().describe("Path ID to show. Omit for active path.") }
2138
+ }, async (args) => {
2139
+ const projectHash = projectHashRef.current;
2140
+ const withNotifications = (text) => textResponse$1(prependNotifications$1(notificationStore, projectHash, text));
2141
+ try {
2142
+ debug("mcp", "path_show: request", { path_id: args.path_id });
2143
+ let pathData;
2144
+ if (args.path_id) {
2145
+ pathData = pathRepo.getPath(args.path_id);
2146
+ if (!pathData) return errorResponse$1(`Debug path not found: ${args.path_id}`);
2147
+ } else {
2148
+ pathData = pathRepo.getActivePath();
2149
+ if (!pathData) return errorResponse$1("No active debug path");
2150
+ }
2151
+ const verbosity = loadToolVerbosityConfig().level;
2152
+ if (verbosity === 1) return withNotifications(`Showing debug path: ${pathData.status}`);
2153
+ const waypoints = pathRepo.getWaypoints(pathData.id);
2154
+ if (verbosity === 2) {
2155
+ const lines = [];
2156
+ lines.push(`## Debug Path: ${pathData.id}`);
2157
+ lines.push(`**Status:** ${pathData.status} | **Trigger:** ${pathData.trigger_summary}`);
2158
+ lines.push(`Waypoints: ${waypoints.length}`);
2159
+ if (pathData.resolution_summary) lines.push(`Resolution: ${pathData.resolution_summary}`);
2160
+ return withNotifications(lines.join("\n"));
2161
+ }
2162
+ const lines = [];
2163
+ lines.push(`## Debug Path: ${pathData.id}`);
2164
+ lines.push(`Status: ${pathData.status}`);
2165
+ lines.push(`Started: ${pathData.started_at}`);
2166
+ lines.push(`Trigger: ${pathData.trigger_summary}`);
2167
+ lines.push("");
2168
+ lines.push(`### Waypoints (${waypoints.length})`);
2169
+ for (let i = 0; i < waypoints.length; i++) {
2170
+ const wp = waypoints[i];
2171
+ lines.push(`${i + 1}. [${wp.waypoint_type}] ${wp.summary} (${wp.created_at})`);
2172
+ }
2173
+ lines.push("");
2174
+ lines.push("### Resolution");
2175
+ lines.push(pathData.resolution_summary ?? "Still active");
2176
+ lines.push("");
2177
+ lines.push("### KISS Summary");
2178
+ lines.push(formatKissSummary(pathData.kiss_summary));
2179
+ return withNotifications(lines.join("\n"));
2180
+ } catch (err) {
2181
+ const message = err instanceof Error ? err.message : "Unknown error";
2182
+ debug("mcp", "path_show: error", { error: message });
2183
+ return errorResponse$1(`path_show error: ${message}`);
2184
+ }
2185
+ });
2186
+ server.registerTool("path_list", {
2187
+ title: "List Debug Paths",
2188
+ description: "List recent debug paths, optionally filtered by status.",
2189
+ inputSchema: {
2190
+ status: z.enum([
2191
+ "active",
2192
+ "resolved",
2193
+ "abandoned"
2194
+ ]).optional().describe("Filter by status"),
2195
+ limit: z.number().int().min(1).max(50).default(10).describe("Max paths to return")
2196
+ }
2197
+ }, async (args) => {
2198
+ const projectHash = projectHashRef.current;
2199
+ const withNotifications = (text) => textResponse$1(prependNotifications$1(notificationStore, projectHash, text));
2200
+ try {
2201
+ debug("mcp", "path_list: request", {
2202
+ status: args.status,
2203
+ limit: args.limit
2204
+ });
2205
+ let paths = pathRepo.listPaths(args.limit);
2206
+ if (args.status) paths = paths.filter((p) => p.status === args.status);
2207
+ if (paths.length === 0) return withNotifications("No debug paths found");
2208
+ const verbosity = loadToolVerbosityConfig().level;
2209
+ if (verbosity === 1) return withNotifications(`${paths.length} debug paths found`);
2210
+ const lines = [];
2211
+ lines.push("## Debug Paths");
2212
+ lines.push("");
2213
+ if (verbosity === 2) {
2214
+ lines.push("| Status | Trigger |");
2215
+ lines.push("|--------|---------|");
2216
+ for (const p of paths) {
2217
+ const trigger = p.trigger_summary.length > 60 ? p.trigger_summary.slice(0, 60) + "..." : p.trigger_summary;
2218
+ lines.push(`| ${p.status} | ${trigger} |`);
2219
+ }
2220
+ } else {
2221
+ lines.push("| ID (short) | Status | Trigger | Started | Resolved |");
2222
+ lines.push("|------------|--------|---------|---------|----------|");
2223
+ for (const p of paths) {
2224
+ const shortId = p.id.slice(0, 8);
2225
+ const trigger = p.trigger_summary.length > 50 ? p.trigger_summary.slice(0, 50) + "..." : p.trigger_summary;
2226
+ const resolved = p.resolved_at ?? "-";
2227
+ lines.push(`| ${shortId} | ${p.status} | ${trigger} | ${p.started_at} | ${resolved} |`);
2228
+ }
2229
+ }
2230
+ return withNotifications(lines.join("\n"));
2231
+ } catch (err) {
2232
+ const message = err instanceof Error ? err.message : "Unknown error";
2233
+ debug("mcp", "path_list: error", { error: message });
2234
+ return errorResponse$1(`path_list error: ${message}`);
2235
+ }
2236
+ });
2237
+ }
2238
+
2239
+ //#endregion
2240
+ //#region src/mcp/tools/thought-branches.ts
2241
+ function prependNotifications(notificationStore, projectHash, responseText) {
2242
+ if (!notificationStore) return responseText;
2243
+ const pending = notificationStore.consumePending(projectHash);
2244
+ if (pending.length === 0) return responseText;
2245
+ return pending.map((n) => `[Laminark] ${n.message}`).join("\n") + "\n\n" + responseText;
2246
+ }
2247
+ function textResponse(text) {
2248
+ return { content: [{
2249
+ type: "text",
2250
+ text
2251
+ }] };
2252
+ }
2253
+ function errorResponse(text) {
2254
+ return {
2255
+ content: [{
2256
+ type: "text",
2257
+ text
2258
+ }],
2259
+ isError: true
2260
+ };
2261
+ }
2262
+ function registerThoughtBranchTools(server, branchRepo, obsRepo, notificationStore, projectHashRef) {
2263
+ server.registerTool("query_branches", {
2264
+ title: "Query Thought Branches",
2265
+ description: "Search and list thought branches - coherent units of work (investigations, bug fixes, features). Use to see work history and what was investigated, fixed, or built.",
2266
+ inputSchema: {
2267
+ status: z.enum([
2268
+ "active",
2269
+ "completed",
2270
+ "abandoned",
2271
+ "merged"
2272
+ ]).optional().describe("Filter by branch status"),
2273
+ branch_type: z.enum([
2274
+ "investigation",
2275
+ "bug_fix",
2276
+ "feature",
2277
+ "refactor",
2278
+ "research",
2279
+ "unknown"
2280
+ ]).optional().describe("Filter by branch type"),
2281
+ limit: z.number().int().min(1).max(50).default(10).describe("Maximum results to return")
2282
+ }
2283
+ }, async (args) => {
2284
+ const projectHash = projectHashRef.current;
2285
+ const withNotifications = (text) => textResponse(prependNotifications(notificationStore, projectHash, text));
2286
+ try {
2287
+ debug("mcp", "query_branches: request", {
2288
+ status: args.status,
2289
+ branch_type: args.branch_type,
2290
+ limit: args.limit
2291
+ });
2292
+ let branches;
2293
+ if (args.status) branches = branchRepo.listByStatus(args.status, args.limit);
2294
+ else if (args.branch_type) branches = branchRepo.listByType(args.branch_type, args.limit);
2295
+ else branches = branchRepo.listBranches(args.limit);
2296
+ if (branches.length === 0) return withNotifications("No thought branches found");
2297
+ const verbosity = loadToolVerbosityConfig().level;
2298
+ if (verbosity === 1) return withNotifications(`${branches.length} branches found`);
2299
+ const lines = [];
2300
+ lines.push("## Thought Branches");
2301
+ lines.push("");
2302
+ if (verbosity === 2) {
2303
+ lines.push("| Status | Type | Title |");
2304
+ lines.push("|--------|------|-------|");
2305
+ for (const b of branches) {
2306
+ const title = b.title ? b.title.length > 50 ? b.title.slice(0, 50) + "..." : b.title : "-";
2307
+ lines.push(`| ${b.status} | ${b.branch_type} | ${title} |`);
2308
+ }
2309
+ } else {
2310
+ lines.push("| ID (short) | Status | Type | Stage | Title | Observations | Started |");
2311
+ lines.push("|------------|--------|------|-------|-------|-------------|---------|");
2312
+ for (const b of branches) {
2313
+ const shortId = b.id.slice(0, 8);
2314
+ const title = b.title ? b.title.length > 40 ? b.title.slice(0, 40) + "..." : b.title : "-";
2315
+ lines.push(`| ${shortId} | ${b.status} | ${b.branch_type} | ${b.arc_stage} | ${title} | ${b.observation_count} | ${b.started_at} |`);
2316
+ }
2317
+ }
2318
+ return withNotifications(lines.join("\n"));
2319
+ } catch (err) {
2320
+ const message = err instanceof Error ? err.message : "Unknown error";
2321
+ debug("mcp", "query_branches: error", { error: message });
2322
+ return errorResponse(`query_branches error: ${message}`);
2323
+ }
2324
+ });
2325
+ server.registerTool("show_branch", {
2326
+ title: "Show Thought Branch",
2327
+ description: "Show detailed view of a thought branch with observation timeline and arc stage annotations. Trace the full arc of a work unit.",
2328
+ inputSchema: { branch_id: z.string().optional().describe("Branch ID to show. Omit for active branch.") }
2329
+ }, async (args) => {
2330
+ const projectHash = projectHashRef.current;
2331
+ const withNotifications = (text) => textResponse(prependNotifications(notificationStore, projectHash, text));
2332
+ try {
2333
+ debug("mcp", "show_branch: request", { branch_id: args.branch_id });
2334
+ let branch;
2335
+ if (args.branch_id) {
2336
+ branch = branchRepo.getBranch(args.branch_id);
2337
+ if (!branch) return errorResponse(`Branch not found: ${args.branch_id}`);
2338
+ } else {
2339
+ branch = branchRepo.getActiveBranch();
2340
+ if (!branch) return errorResponse("No active thought branch");
2341
+ }
2342
+ const verbosity = loadToolVerbosityConfig().level;
2343
+ const branchTitle = branch.title ?? branch.id.slice(0, 12);
2344
+ if (verbosity === 1) return withNotifications(`Showing "${branchTitle}"`);
2345
+ const observations = branchRepo.getObservations(branch.id);
2346
+ if (verbosity === 2) {
2347
+ const lines = [];
2348
+ lines.push(`## ${branchTitle}`);
2349
+ lines.push(`**Status:** ${branch.status} | **Type:** ${branch.branch_type} | **Stage:** ${branch.arc_stage}`);
2350
+ if (branch.summary) lines.push(branch.summary);
2351
+ lines.push(`Observations: ${observations.length}`);
2352
+ return withNotifications(lines.join("\n"));
2353
+ }
2354
+ const lines = [];
2355
+ lines.push(`## Thought Branch: ${branchTitle}`);
2356
+ lines.push(`**ID:** ${branch.id}`);
2357
+ lines.push(`**Status:** ${branch.status}`);
2358
+ lines.push(`**Type:** ${branch.branch_type}`);
2359
+ lines.push(`**Arc Stage:** ${branch.arc_stage}`);
2360
+ lines.push(`**Started:** ${branch.started_at}`);
2361
+ if (branch.ended_at) lines.push(`**Ended:** ${branch.ended_at}`);
2362
+ if (branch.trigger_source) lines.push(`**Trigger:** ${branch.trigger_source}`);
2363
+ if (branch.linked_debug_path_id) lines.push(`**Linked Debug Path:** ${branch.linked_debug_path_id}`);
2364
+ lines.push("");
2365
+ const tools = Object.entries(branch.tool_pattern).sort(([, a], [, b]) => b - a);
2366
+ if (tools.length > 0) {
2367
+ lines.push("### Tool Usage");
2368
+ for (const [tool, count] of tools) lines.push(`- ${tool}: ${count}`);
2369
+ lines.push("");
2370
+ }
2371
+ if (branch.summary) {
2372
+ lines.push("### Summary");
2373
+ lines.push(branch.summary);
2374
+ lines.push("");
2375
+ }
2376
+ lines.push(`### Observation Timeline (${observations.length})`);
2377
+ for (const bo of observations) {
2378
+ const obs = obsRepo.getById(bo.observation_id);
2379
+ const content = obs ? obs.title ?? obs.content.slice(0, 100) : bo.observation_id.slice(0, 8);
2380
+ const stageTag = bo.arc_stage_at_add ? `[${bo.arc_stage_at_add}]` : "";
2381
+ const toolTag = bo.tool_name ? `(${bo.tool_name})` : "";
2382
+ lines.push(`${bo.sequence_order}. ${stageTag} ${toolTag} ${content}`);
2383
+ }
2384
+ return withNotifications(lines.join("\n"));
2385
+ } catch (err) {
2386
+ const message = err instanceof Error ? err.message : "Unknown error";
2387
+ debug("mcp", "show_branch: error", { error: message });
2388
+ return errorResponse(`show_branch error: ${message}`);
2389
+ }
2390
+ });
2391
+ server.registerTool("branch_summary", {
2392
+ title: "Branch Activity Summary",
2393
+ description: "Summary of recent work activity grouped by time window. Shows what was investigated, fixed, built, and where work left off.",
2394
+ inputSchema: { hours: z.number().int().min(1).max(168).default(24).describe("Time window in hours (default 24)") }
2395
+ }, async (args) => {
2396
+ const projectHash = projectHashRef.current;
2397
+ const withNotifications = (text) => textResponse(prependNotifications(notificationStore, projectHash, text));
2398
+ try {
2399
+ debug("mcp", "branch_summary: request", { hours: args.hours });
2400
+ const branches = branchRepo.listRecentBranches(args.hours);
2401
+ if (branches.length === 0) return withNotifications(`No work branches in the last ${args.hours} hours`);
2402
+ const verbosity = loadToolVerbosityConfig().level;
2403
+ if (verbosity === 1) return withNotifications(`${branches.length} branches in ${args.hours}h`);
2404
+ const active = branches.filter((b) => b.status === "active");
2405
+ const completed = branches.filter((b) => b.status === "completed");
2406
+ const abandoned = branches.filter((b) => b.status === "abandoned");
2407
+ const lines = [];
2408
+ lines.push(`## Work Summary (last ${args.hours}h)`);
2409
+ lines.push(`**Total branches:** ${branches.length}`);
2410
+ lines.push("");
2411
+ if (active.length > 0) {
2412
+ lines.push("### Active");
2413
+ for (const b of active) {
2414
+ const title = b.title ?? b.id.slice(0, 8);
2415
+ lines.push(verbosity === 2 ? `- ${title} (${b.branch_type})` : `- **${title}** (${b.branch_type}, ${b.arc_stage}) — ${b.observation_count} obs`);
2416
+ }
2417
+ lines.push("");
2418
+ }
2419
+ if (completed.length > 0) {
2420
+ lines.push("### Completed");
2421
+ for (const b of completed) {
2422
+ const title = b.title ?? b.id.slice(0, 8);
2423
+ const summary = b.summary ? `: ${b.summary.slice(0, 100)}` : "";
2424
+ lines.push(verbosity === 2 ? `- ${title} (${b.branch_type})` : `- **${title}** (${b.branch_type})${summary}`);
2425
+ }
2426
+ lines.push("");
2427
+ }
2428
+ if (abandoned.length > 0) {
2429
+ lines.push("### Abandoned");
2430
+ for (const b of abandoned) {
2431
+ const title = b.title ?? b.id.slice(0, 8);
2432
+ lines.push(verbosity === 2 ? `- ${title} (${b.branch_type})` : `- **${title}** (${b.branch_type}) — ${b.observation_count} obs`);
2433
+ }
2434
+ lines.push("");
2435
+ }
2436
+ if (verbosity === 3) {
2437
+ const allTools = {};
2438
+ for (const b of branches) for (const [tool, count] of Object.entries(b.tool_pattern)) allTools[tool] = (allTools[tool] ?? 0) + count;
2439
+ const toolEntries = Object.entries(allTools).sort(([, a], [, b]) => b - a);
2440
+ if (toolEntries.length > 0) {
2441
+ lines.push("### Tool Distribution");
2442
+ for (const [tool, count] of toolEntries.slice(0, 10)) lines.push(`- ${tool}: ${count}`);
2443
+ }
2444
+ }
2445
+ return withNotifications(lines.join("\n"));
2446
+ } catch (err) {
2447
+ const message = err instanceof Error ? err.message : "Unknown error";
2448
+ debug("mcp", "branch_summary: error", { error: message });
2449
+ return errorResponse(`branch_summary error: ${message}`);
2450
+ }
2451
+ });
2452
+ }
2453
+
2454
+ //#endregion
2455
+ //#region src/branches/arc-detector.ts
2456
+ const BUILTIN_CATEGORY = {
2457
+ "Read": "investigation",
2458
+ "Glob": "investigation",
2459
+ "Grep": "investigation",
2460
+ "WebSearch": "investigation",
2461
+ "WebFetch": "investigation",
2462
+ "Task": "investigation",
2463
+ "AskUserQuestion": "investigation",
2464
+ "Write": "write",
2465
+ "Edit": "write",
2466
+ "NotebookEdit": "write",
2467
+ "Bash": "verification",
2468
+ "EnterPlanMode": "planning",
2469
+ "ExitPlanMode": "planning",
2470
+ "TaskCreate": "planning",
2471
+ "TaskUpdate": "planning",
2472
+ "TaskList": "planning",
2473
+ "TaskGet": "planning",
2474
+ "Skill": "uncategorized"
2475
+ };
2476
+ /** Keywords matched against tool descriptions (case-insensitive). */
2477
+ const DESCRIPTION_RULES = [
2478
+ {
2479
+ category: "planning",
2480
+ keywords: /\b(plan|todo|task|roadmap|milestone|phase|design|architect)\b/i
2481
+ },
2482
+ {
2483
+ category: "verification",
2484
+ keywords: /\b(run|test|build|execute|evaluate|validate|verify|check|assert|lint|compile)\b/i
2485
+ },
2486
+ {
2487
+ category: "write",
2488
+ keywords: /\b(write|edit|create|update|save|upload|modify|delete|remove|fill|type|click|select|drag|press|submit|install|deploy|push|commit|insert|drop|replace)\b/i
2489
+ },
2490
+ {
2491
+ category: "investigation",
2492
+ keywords: /\b(read|search|query|find|list|get|fetch|browse|snapshot|screenshot|inspect|show|view|discover|status|stats|navigate|hover|recall|monitor|log|trace|debug|profile|measure|analyze|explore)\b/i
2493
+ }
2494
+ ];
2495
+ /**
2496
+ * Classify a tool from its description text.
2497
+ * Returns null if no confident match.
2498
+ */
2499
+ function classifyFromDescription(description) {
2500
+ for (const rule of DESCRIPTION_RULES) if (rule.keywords.test(description)) return rule.category;
2501
+ return null;
2502
+ }
2503
+ const NAME_RULES = [
2504
+ {
2505
+ category: "planning",
2506
+ pattern: /\b(plan|todo|task|roadmap|phase|milestone)\b/i
2507
+ },
2508
+ {
2509
+ category: "verification",
2510
+ pattern: /\b(run|test|build|exec|evaluate|validate|check|verify)\b/i
2511
+ },
2512
+ {
2513
+ category: "write",
2514
+ pattern: /\b(write|edit|create|update|save|upload|fill|type|click|select|drag|press|install)\b/i
2515
+ },
2516
+ {
2517
+ category: "investigation",
2518
+ pattern: /\b(search|query|find|list|get|read|fetch|browse|snapshot|screenshot|inspect|show|view|recall|discover|status|stats|console|network|navigate|tabs|hover)\b/i
2519
+ }
2520
+ ];
2521
+ function classifyFromName(toolName) {
2522
+ const actionPart = toolName.includes("__") ? toolName.substring(toolName.lastIndexOf("__") + 2) : toolName;
2523
+ for (const rule of NAME_RULES) if (rule.pattern.test(actionPart)) return rule.category;
2524
+ if (toolName.includes("laminark")) return "investigation";
2525
+ return "uncategorized";
2526
+ }
2527
+ const classificationCache = /* @__PURE__ */ new Map();
2528
+ let lastRegistryCount = -1;
2529
+ /**
2530
+ * Re-reads the tool_registry table and classifies every tool by its
2531
+ * description. Only rescans when the registry row count has changed.
2532
+ *
2533
+ * Call on startup and periodically (e.g., during BranchTracker maintenance).
2534
+ */
2535
+ function primeFromRegistry(db, projectHash) {
2536
+ try {
2537
+ const currentCount = db.prepare("SELECT COUNT(*) AS cnt FROM tool_registry").get()?.cnt ?? 0;
2538
+ if (currentCount === lastRegistryCount && lastRegistryCount >= 0) return;
2539
+ const rows = db.prepare(`
2540
+ SELECT name, description FROM tool_registry
2541
+ WHERE status = 'active'
2542
+ AND (scope = 'global' OR project_hash IS NULL OR project_hash = ?)
2543
+ `).all(projectHash);
2544
+ let primed = 0;
2545
+ for (const row of rows) {
2546
+ if (BUILTIN_CATEGORY[row.name]) continue;
2547
+ let category = null;
2548
+ if (row.description) category = classifyFromDescription(row.description);
2549
+ if (!category) category = classifyFromName(row.name);
2550
+ classificationCache.set(row.name, category);
2551
+ primed++;
2552
+ }
2553
+ lastRegistryCount = currentCount;
2554
+ debug("branches", "Arc detector cache primed from registry", {
2555
+ registryTools: rows.length,
2556
+ primed
2557
+ });
2558
+ } catch {}
2559
+ }
2560
+ /**
2561
+ * Classify any tool name into an arc category.
2562
+ *
2563
+ * Priority: built-in table > registry-primed cache > name-pattern fallback.
2564
+ */
2565
+ function classifyTool(toolName) {
2566
+ const cached = classificationCache.get(toolName);
2567
+ if (cached) return cached;
2568
+ const builtin = BUILTIN_CATEGORY[toolName];
2569
+ if (builtin) {
2570
+ classificationCache.set(toolName, builtin);
2571
+ return builtin;
2572
+ }
2573
+ const fromName = classifyFromName(toolName);
2574
+ classificationCache.set(toolName, fromName);
2575
+ return fromName;
2576
+ }
2577
+ /**
2578
+ * Infers the current arc stage from tool usage pattern counts.
2579
+ *
2580
+ * Handles all tool types: builtins, MCP tools, plugins, skills, slash commands.
2581
+ * Uncategorized tools are excluded from ratio calculations so they don't
2582
+ * dilute the signal from known tools.
2583
+ *
2584
+ * @param toolPattern - Map of tool name to usage count within the branch
2585
+ * @param classification - Optional dominant observation classification
2586
+ * @returns The inferred arc stage
2587
+ */
2588
+ function inferArcStage(toolPattern, classification) {
2589
+ let investigationCount = 0;
2590
+ let writeCount = 0;
2591
+ let verificationCount = 0;
2592
+ let planningCount = 0;
2593
+ let categorizedCount = 0;
2594
+ for (const [tool, count] of Object.entries(toolPattern)) switch (classifyTool(tool)) {
2595
+ case "investigation":
2596
+ investigationCount += count;
2597
+ categorizedCount += count;
2598
+ break;
2599
+ case "write":
2600
+ writeCount += count;
2601
+ categorizedCount += count;
2602
+ break;
2603
+ case "verification":
2604
+ verificationCount += count;
2605
+ categorizedCount += count;
2606
+ break;
2607
+ case "planning":
2608
+ planningCount += count;
2609
+ categorizedCount += count;
2610
+ break;
2611
+ case "uncategorized": break;
2612
+ }
2613
+ if (categorizedCount === 0) return "investigation";
2614
+ if (verificationCount > 0 && writeCount > 0) {
2615
+ if (verificationCount / categorizedCount > .2) return "verification";
2616
+ }
2617
+ if (writeCount / categorizedCount > .4) return "execution";
2618
+ if (planningCount > 0) {
2619
+ if (planningCount / categorizedCount > .1) return "planning";
2620
+ }
2621
+ if (classification === "problem" && writeCount > 0 && investigationCount > 0) return "diagnosis";
2622
+ return "investigation";
2623
+ }
2624
+
2625
+ //#endregion
2626
+ //#region src/config/haiku-config.ts
2627
+ function loadHaikuConfig() {
2628
+ return {
2629
+ model: "claude-haiku-4-5-20251001",
2630
+ maxTokensPerCall: 1024
2631
+ };
2632
+ }
2633
+
2634
+ //#endregion
2635
+ //#region src/intelligence/haiku-client.ts
2636
+ /**
2637
+ * Shared Haiku client using Claude Agent SDK V2 session.
2638
+ *
2639
+ * Routes Haiku calls through the user's Claude Code subscription
2640
+ * instead of requiring a separate API key. Uses a persistent session
2641
+ * to avoid 12s cold-start overhead on sequential calls.
2642
+ *
2643
+ * Provides the core infrastructure for all Haiku agent modules:
2644
+ * - callHaiku() helper for structured prompt/response calls
2645
+ * - extractJsonFromResponse() for defensive JSON parsing
2646
+ * - Session reuse across batch processing cycles
2647
+ */
2648
+ let _session = null;
2649
+ function getOrCreateSession() {
2650
+ if (!_session) _session = unstable_v2_createSession({
2651
+ model: loadHaikuConfig().model,
2652
+ permissionMode: "bypassPermissions",
2653
+ allowedTools: []
2654
+ });
2655
+ return _session;
2656
+ }
2657
+ /**
2658
+ * Returns whether Haiku enrichment is available.
2659
+ * Always true with subscription auth -- no API key check needed.
2660
+ */
2661
+ function isHaikuEnabled() {
2662
+ return true;
2663
+ }
2664
+ /**
2665
+ * Calls Haiku with a system prompt and user content.
2666
+ * Returns the text content from the response.
2667
+ *
2668
+ * Uses a persistent V2 session to avoid cold-start overhead on sequential calls.
2669
+ * System prompt is embedded in the user message since session-level systemPrompt
2670
+ * is set at creation time and we need different prompts per agent.
2671
+ *
2672
+ * @param systemPrompt - Instructions for the model
2673
+ * @param userContent - The content to process
2674
+ * @param _maxTokens - Kept for signature compatibility (unused -- Agent SDK constrains output via prompts)
2675
+ * @throws Error if the Haiku call fails or session expires
2676
+ */
2677
+ async function callHaiku(systemPrompt, userContent, _maxTokens) {
2678
+ const session = getOrCreateSession();
2679
+ const fullPrompt = `<instructions>\n${systemPrompt}\n</instructions>\n\n${userContent}`;
2680
+ try {
2681
+ await session.send(fullPrompt);
2682
+ for await (const msg of session.stream()) if (msg.type === "result") {
2683
+ if (msg.subtype === "success") return msg.result;
2684
+ const errorMsg = ("errors" in msg ? msg.errors : void 0)?.join(", ") ?? msg.subtype;
2685
+ throw new Error(`Haiku call failed: ${errorMsg}`);
2686
+ }
2687
+ return "";
2688
+ } catch (error) {
2689
+ try {
2690
+ _session?.close();
2691
+ } catch {}
2692
+ _session = null;
2693
+ throw error;
2694
+ }
2695
+ }
2696
+ /**
2697
+ * Defensive JSON extraction from Haiku response text.
2698
+ *
2699
+ * Handles common LLM response quirks:
2700
+ * - Markdown code fences (```json ... ```)
2701
+ * - Explanatory text before/after JSON
2702
+ * - Both array and object JSON shapes
2703
+ *
2704
+ * @throws Error if no JSON structure found in text
2705
+ */
2706
+ function extractJsonFromResponse(text) {
2707
+ const cleaned = text.replace(/```json\s*/g, "").replace(/```\s*/g, "");
2708
+ const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
2709
+ if (arrayMatch) return JSON.parse(arrayMatch[0]);
2710
+ const objMatch = cleaned.match(/\{[\s\S]*\}/);
2711
+ if (objMatch) return JSON.parse(objMatch[0]);
2712
+ throw new Error("No JSON found in Haiku response");
2713
+ }
2714
+
2715
+ //#endregion
2716
+ //#region src/branches/branch-classifier-agent.ts
2717
+ /**
2718
+ * Haiku agent for classifying thought branch type and generating title/summary.
2719
+ *
2720
+ * Uses a single Haiku call to determine:
2721
+ * 1. Branch type (investigation, bug_fix, feature, refactor, research)
2722
+ * 2. A concise title for the branch
2723
+ * 3. An optional summary (for completed branches)
2724
+ *
2725
+ * Follows the same pattern as haiku-classifier-agent.ts.
2726
+ */
2727
+ const ClassifyBranchSchema = z.object({
2728
+ branch_type: z.enum([
2729
+ "investigation",
2730
+ "bug_fix",
2731
+ "feature",
2732
+ "refactor",
2733
+ "research"
2734
+ ]),
2735
+ title: z.string().max(100)
2736
+ });
2737
+ const SummarizeBranchSchema = z.object({ summary: z.string().max(500) });
2738
+ const CLASSIFY_PROMPT = `You classify developer work branches for a knowledge management system.
2739
+
2740
+ Given a sequence of observations from a work session, determine:
2741
+ 1. branch_type: What kind of work is this?
2742
+ - "investigation": Exploring code, reading docs, understanding behavior
2743
+ - "bug_fix": Fixing an error, test failure, or unexpected behavior
2744
+ - "feature": Building new functionality
2745
+ - "refactor": Restructuring existing code without changing behavior
2746
+ - "research": Looking up external resources, comparing approaches
2747
+
2748
+ 2. title: A concise title (3-8 words) describing the work unit. Use imperative form.
2749
+ Examples: "Fix auth token refresh", "Add branch detection system", "Investigate memory leak"
2750
+
2751
+ Return JSON: {"branch_type": "...", "title": "..."}
2752
+ No markdown, no explanation, ONLY the JSON object.`;
2753
+ const SUMMARIZE_PROMPT = `You summarize completed developer work branches for a knowledge management system.
2754
+
2755
+ Given a sequence of observations from a completed work branch, write a concise summary (1-3 sentences) that captures:
2756
+ - What was the goal
2757
+ - What was done
2758
+ - What was the outcome
2759
+
2760
+ Return JSON: {"summary": "..."}
2761
+ No markdown, no explanation, ONLY the JSON object.`;
2762
+ /**
2763
+ * Classifies a branch type and generates a title from observation content.
2764
+ */
2765
+ async function classifyBranchWithHaiku(observationTexts, toolPattern) {
2766
+ const parsed = extractJsonFromResponse(await callHaiku(CLASSIFY_PROMPT, [
2767
+ `Tool usage: ${Object.entries(toolPattern).sort(([, a], [, b]) => b - a).map(([tool, count]) => `${tool}: ${count}`).join(", ")}`,
2768
+ "",
2769
+ "Observations:",
2770
+ ...observationTexts.slice(0, 10).map((t, i) => `${i + 1}. ${t.slice(0, 200)}`)
2771
+ ].join("\n"), 256));
2772
+ return ClassifyBranchSchema.parse(parsed);
2773
+ }
2774
+ /**
2775
+ * Generates a completion summary for a finished branch.
2776
+ */
2777
+ async function summarizeBranchWithHaiku(title, branchType, observationTexts) {
2778
+ const parsed = extractJsonFromResponse(await callHaiku(SUMMARIZE_PROMPT, [
2779
+ `Branch: ${title} (${branchType})`,
2780
+ "",
2781
+ "Observations:",
2782
+ ...observationTexts.slice(0, 15).map((t, i) => `${i + 1}. ${t.slice(0, 200)}`)
2783
+ ].join("\n"), 256));
2784
+ return SummarizeBranchSchema.parse(parsed);
2785
+ }
2786
+
2787
+ //#endregion
2788
+ //#region src/branches/branch-tracker.ts
2789
+ const TIME_GAP_MS = 900 * 1e3;
2790
+ var BranchTracker = class {
2791
+ state = "idle";
2792
+ activeBranchId = null;
2793
+ activeProjectHash = null;
2794
+ activeSessionId = null;
2795
+ lastObservationTime = 0;
2796
+ toolPattern = {};
2797
+ repo;
2798
+ db;
2799
+ projectHash;
2800
+ constructor(repo, db, projectHash) {
2801
+ this.repo = repo;
2802
+ this.db = db;
2803
+ this.projectHash = projectHash;
2804
+ primeFromRegistry(db, projectHash);
2805
+ const activeBranch = repo.findRecentActiveBranch();
2806
+ if (activeBranch) {
2807
+ this.state = "tracking";
2808
+ this.activeBranchId = activeBranch.id;
2809
+ this.activeProjectHash = activeBranch.project_hash;
2810
+ this.activeSessionId = activeBranch.session_id;
2811
+ this.toolPattern = activeBranch.tool_pattern;
2812
+ this.lastObservationTime = new Date(activeBranch.started_at).getTime();
2813
+ debug("branches", "Recovered active branch from DB", { branchId: activeBranch.id });
1751
2814
  }
1752
- });
1753
- server.registerTool("path_show", {
1754
- title: "Show Debug Path",
1755
- description: "Show a debug path with its waypoints and KISS summary.",
1756
- inputSchema: { path_id: z.string().optional().describe("Path ID to show. Omit for active path.") }
1757
- }, async (args) => {
1758
- const withNotifications = (text) => textResponse(prependNotifications(notificationStore, projectHash, text));
1759
- try {
1760
- debug("mcp", "path_show: request", { path_id: args.path_id });
1761
- let pathData;
1762
- if (args.path_id) {
1763
- pathData = pathRepo.getPath(args.path_id);
1764
- if (!pathData) return errorResponse(`Debug path not found: ${args.path_id}`);
1765
- } else {
1766
- pathData = pathRepo.getActivePath();
1767
- if (!pathData) return errorResponse("No active debug path");
1768
- }
1769
- const waypoints = pathRepo.getWaypoints(pathData.id);
1770
- const lines = [];
1771
- lines.push(`## Debug Path: ${pathData.id}`);
1772
- lines.push(`Status: ${pathData.status}`);
1773
- lines.push(`Started: ${pathData.started_at}`);
1774
- lines.push(`Trigger: ${pathData.trigger_summary}`);
1775
- lines.push("");
1776
- lines.push(`### Waypoints (${waypoints.length})`);
1777
- for (let i = 0; i < waypoints.length; i++) {
1778
- const wp = waypoints[i];
1779
- lines.push(`${i + 1}. [${wp.waypoint_type}] ${wp.summary} (${wp.created_at})`);
2815
+ }
2816
+ /**
2817
+ * Process a new observation through the boundary detection state machine.
2818
+ * Called from HaikuProcessor after classification (Step 1.6).
2819
+ */
2820
+ processObservation(obs) {
2821
+ const now = Date.now();
2822
+ const obsTime = new Date(obs.createdAt).getTime();
2823
+ const toolName = this.extractToolName(obs.source);
2824
+ const boundary = this.detectBoundary(obs, obsTime);
2825
+ if (boundary) {
2826
+ if (this.state === "tracking" && this.activeBranchId) this.completeBranch();
2827
+ this.startBranch(boundary, obs);
2828
+ } else if (this.state === "idle") this.startBranch("session_start", obs);
2829
+ if (this.activeBranchId) {
2830
+ const arcStage = inferArcStage(this.toolPattern, obs.classification);
2831
+ if (toolName) {
2832
+ this.toolPattern[toolName] = (this.toolPattern[toolName] ?? 0) + 1;
2833
+ this.repo.updateToolPattern(this.activeBranchId, this.toolPattern);
1780
2834
  }
1781
- lines.push("");
1782
- lines.push("### Resolution");
1783
- lines.push(pathData.resolution_summary ?? "Still active");
1784
- lines.push("");
1785
- lines.push("### KISS Summary");
1786
- lines.push(formatKissSummary(pathData.kiss_summary));
1787
- return withNotifications(lines.join("\n"));
1788
- } catch (err) {
1789
- const message = err instanceof Error ? err.message : "Unknown error";
1790
- debug("mcp", "path_show: error", { error: message });
1791
- return errorResponse(`path_show error: ${message}`);
2835
+ this.repo.addObservation(this.activeBranchId, obs.id, toolName, arcStage);
2836
+ const newStage = inferArcStage(this.toolPattern, obs.classification);
2837
+ this.repo.updateArcStage(this.activeBranchId, newStage);
1792
2838
  }
1793
- });
1794
- server.registerTool("path_list", {
1795
- title: "List Debug Paths",
1796
- description: "List recent debug paths, optionally filtered by status.",
1797
- inputSchema: {
1798
- status: z.enum([
1799
- "active",
1800
- "resolved",
1801
- "abandoned"
1802
- ]).optional().describe("Filter by status"),
1803
- limit: z.number().int().min(1).max(50).default(10).describe("Max paths to return")
2839
+ this.lastObservationTime = obsTime || now;
2840
+ this.activeProjectHash = obs.projectHash;
2841
+ this.activeSessionId = obs.sessionId ?? this.activeSessionId;
2842
+ }
2843
+ /**
2844
+ * Notify the tracker of a topic shift (from TopicShiftHandler).
2845
+ */
2846
+ onTopicShift(observationId) {
2847
+ if (this.state === "tracking" && this.activeBranchId) {
2848
+ this.completeBranch();
2849
+ debug("branches", "Topic shift boundary detected", { observationId });
1804
2850
  }
1805
- }, async (args) => {
1806
- const withNotifications = (text) => textResponse(prependNotifications(notificationStore, projectHash, text));
1807
- try {
1808
- debug("mcp", "path_list: request", {
1809
- status: args.status,
1810
- limit: args.limit
2851
+ }
2852
+ /**
2853
+ * Link the active branch to a debug path (when PathTracker activates).
2854
+ */
2855
+ linkDebugPath(debugPathId) {
2856
+ if (this.activeBranchId) {
2857
+ this.repo.linkDebugPath(this.activeBranchId, debugPathId);
2858
+ debug("branches", "Linked debug path to branch", {
2859
+ branchId: this.activeBranchId,
2860
+ debugPathId
1811
2861
  });
1812
- let paths = pathRepo.listPaths(args.limit);
1813
- if (args.status) paths = paths.filter((p) => p.status === args.status);
1814
- if (paths.length === 0) return withNotifications("No debug paths found");
1815
- const lines = [];
1816
- lines.push("## Debug Paths");
1817
- lines.push("");
1818
- lines.push("| ID (short) | Status | Trigger | Started | Resolved |");
1819
- lines.push("|------------|--------|---------|---------|----------|");
1820
- for (const p of paths) {
1821
- const shortId = p.id.slice(0, 8);
1822
- const trigger = p.trigger_summary.length > 50 ? p.trigger_summary.slice(0, 50) + "..." : p.trigger_summary;
1823
- const resolved = p.resolved_at ?? "-";
1824
- lines.push(`| ${shortId} | ${p.status} | ${trigger} | ${p.started_at} | ${resolved} |`);
2862
+ }
2863
+ }
2864
+ /**
2865
+ * Get the active branch ID (for external callers).
2866
+ */
2867
+ getActiveBranchId() {
2868
+ return this.activeBranchId;
2869
+ }
2870
+ /**
2871
+ * Run periodic maintenance tasks:
2872
+ * - Classify branches with 3+ observations via Haiku
2873
+ * - Generate summaries for recently completed branches
2874
+ * - Auto-abandon stale branches (>24h)
2875
+ * - Link branches to debug paths
2876
+ */
2877
+ async runMaintenance() {
2878
+ try {
2879
+ primeFromRegistry(this.db, this.projectHash);
2880
+ const stale = this.repo.findStaleBranches();
2881
+ for (const branch of stale) {
2882
+ this.repo.abandonBranch(branch.id);
2883
+ if (this.activeBranchId === branch.id) {
2884
+ this.state = "idle";
2885
+ this.activeBranchId = null;
2886
+ this.toolPattern = {};
2887
+ }
2888
+ debug("branches", "Auto-abandoned stale branch", { branchId: branch.id });
2889
+ }
2890
+ if (isHaikuEnabled()) {
2891
+ const unclassified = this.repo.findUnclassifiedBranches(3);
2892
+ for (const branch of unclassified) try {
2893
+ const observations = this.repo.getObservations(branch.id);
2894
+ const obsRepo = new ObservationRepository(this.db, branch.project_hash);
2895
+ const texts = observations.map((bo) => {
2896
+ const obs = obsRepo.getById(bo.observation_id);
2897
+ return obs ? obs.title ? `${obs.title}: ${obs.content}` : obs.content : null;
2898
+ }).filter((t) => t !== null);
2899
+ if (texts.length === 0) continue;
2900
+ const result = await classifyBranchWithHaiku(texts, branch.tool_pattern);
2901
+ this.repo.updateClassification(branch.id, result.branch_type, result.title);
2902
+ debug("branches", "Branch classified", {
2903
+ branchId: branch.id,
2904
+ type: result.branch_type,
2905
+ title: result.title
2906
+ });
2907
+ } catch (err) {
2908
+ const msg = err instanceof Error ? err.message : String(err);
2909
+ debug("branches", "Branch classification failed (non-fatal)", {
2910
+ branchId: branch.id,
2911
+ error: msg
2912
+ });
2913
+ }
2914
+ const unsummarized = this.repo.findRecentCompletedUnsummarized(2);
2915
+ for (const branch of unsummarized) try {
2916
+ const observations = this.repo.getObservations(branch.id);
2917
+ const obsRepo = new ObservationRepository(this.db, branch.project_hash);
2918
+ const texts = observations.map((bo) => {
2919
+ const obs = obsRepo.getById(bo.observation_id);
2920
+ return obs ? obs.title ? `${obs.title}: ${obs.content}` : obs.content : null;
2921
+ }).filter((t) => t !== null);
2922
+ if (texts.length === 0) continue;
2923
+ const result = await summarizeBranchWithHaiku(branch.title ?? "Untitled", branch.branch_type, texts);
2924
+ this.repo.updateSummary(branch.id, result.summary);
2925
+ debug("branches", "Branch summarized", { branchId: branch.id });
2926
+ } catch (err) {
2927
+ const msg = err instanceof Error ? err.message : String(err);
2928
+ debug("branches", "Branch summarization failed (non-fatal)", {
2929
+ branchId: branch.id,
2930
+ error: msg
2931
+ });
2932
+ }
1825
2933
  }
1826
- return withNotifications(lines.join("\n"));
1827
2934
  } catch (err) {
1828
- const message = err instanceof Error ? err.message : "Unknown error";
1829
- debug("mcp", "path_list: error", { error: message });
1830
- return errorResponse(`path_list error: ${message}`);
2935
+ debug("branches", "Maintenance error (non-fatal)", { error: err instanceof Error ? err.message : String(err) });
1831
2936
  }
1832
- });
1833
- }
2937
+ }
2938
+ detectBoundary(obs, obsTime) {
2939
+ if (this.activeProjectHash && obs.projectHash !== this.activeProjectHash) return "project_switch";
2940
+ if (this.activeSessionId && obs.sessionId && obs.sessionId !== this.activeSessionId) return "session_start";
2941
+ if (this.lastObservationTime > 0) {
2942
+ if (obsTime - this.lastObservationTime > TIME_GAP_MS) return "time_gap";
2943
+ }
2944
+ return null;
2945
+ }
2946
+ startBranch(triggerSource, obs) {
2947
+ const branch = this.repo.createBranch(obs.sessionId ?? null, triggerSource, obs.id);
2948
+ this.state = "tracking";
2949
+ this.activeBranchId = branch.id;
2950
+ this.toolPattern = {};
2951
+ debug("branches", "New branch started", {
2952
+ branchId: branch.id,
2953
+ trigger: triggerSource
2954
+ });
2955
+ }
2956
+ completeBranch() {
2957
+ if (!this.activeBranchId) return;
2958
+ this.repo.completeBranch(this.activeBranchId);
2959
+ debug("branches", "Branch completed", { branchId: this.activeBranchId });
2960
+ this.state = "idle";
2961
+ this.activeBranchId = null;
2962
+ this.toolPattern = {};
2963
+ }
2964
+ extractToolName(source) {
2965
+ if (source.startsWith("hook:")) return source.slice(5);
2966
+ if (source.startsWith("mcp:")) return source.slice(4);
2967
+ return null;
2968
+ }
2969
+ };
1834
2970
 
1835
2971
  //#endregion
1836
2972
  //#region src/analysis/worker-bridge.ts
@@ -3512,149 +4648,59 @@ var CurationAgent = class {
3512
4648
  if (this.running) return;
3513
4649
  this.running = true;
3514
4650
  this.timer = setInterval(() => {
3515
- this.runOnce();
3516
- }, this.intervalMs);
3517
- process.stderr.write(`[laminark:curation] Agent started, interval: ${this.intervalMs}ms\n`);
3518
- }
3519
- /**
3520
- * Stop the periodic curation timer.
3521
- */
3522
- stop() {
3523
- if (this.timer) {
3524
- clearInterval(this.timer);
3525
- this.timer = null;
3526
- }
3527
- this.running = false;
3528
- process.stderr.write("[laminark:curation] Agent stopped\n");
3529
- }
3530
- /**
3531
- * Execute one curation cycle. This is the main entry point.
3532
- */
3533
- async runOnce() {
3534
- if (this.cycling) return {
3535
- startedAt: "",
3536
- completedAt: "",
3537
- observationsMerged: 0,
3538
- entitiesDeduplicated: 0,
3539
- stalenessFlagsAdded: 0,
3540
- lowValuePruned: 0,
3541
- temporalDecayUpdated: 0,
3542
- temporalDecayDeleted: 0,
3543
- errors: ["skipped: previous cycle still running"]
3544
- };
3545
- this.cycling = true;
3546
- try {
3547
- const report = await runCuration(this.db, this.graphConfig);
3548
- this.lastRun = report.completedAt;
3549
- if (this.onComplete) this.onComplete(report);
3550
- return report;
3551
- } finally {
3552
- this.cycling = false;
3553
- }
3554
- }
3555
- /**
3556
- * Whether the agent is currently running.
3557
- */
3558
- isRunning() {
3559
- return this.running;
3560
- }
3561
- /**
3562
- * Timestamp of the last completed curation run.
3563
- */
3564
- getLastRun() {
3565
- return this.lastRun;
3566
- }
3567
- };
3568
-
3569
- //#endregion
3570
- //#region src/config/haiku-config.ts
3571
- function loadHaikuConfig() {
3572
- return {
3573
- model: "claude-haiku-4-5-20251001",
3574
- maxTokensPerCall: 1024
3575
- };
3576
- }
3577
-
3578
- //#endregion
3579
- //#region src/intelligence/haiku-client.ts
3580
- /**
3581
- * Shared Haiku client using Claude Agent SDK V2 session.
3582
- *
3583
- * Routes Haiku calls through the user's Claude Code subscription
3584
- * instead of requiring a separate API key. Uses a persistent session
3585
- * to avoid 12s cold-start overhead on sequential calls.
3586
- *
3587
- * Provides the core infrastructure for all Haiku agent modules:
3588
- * - callHaiku() helper for structured prompt/response calls
3589
- * - extractJsonFromResponse() for defensive JSON parsing
3590
- * - Session reuse across batch processing cycles
3591
- */
3592
- let _session = null;
3593
- function getOrCreateSession() {
3594
- if (!_session) _session = unstable_v2_createSession({
3595
- model: loadHaikuConfig().model,
3596
- permissionMode: "bypassPermissions",
3597
- allowedTools: []
3598
- });
3599
- return _session;
3600
- }
3601
- /**
3602
- * Returns whether Haiku enrichment is available.
3603
- * Always true with subscription auth -- no API key check needed.
3604
- */
3605
- function isHaikuEnabled() {
3606
- return true;
3607
- }
3608
- /**
3609
- * Calls Haiku with a system prompt and user content.
3610
- * Returns the text content from the response.
3611
- *
3612
- * Uses a persistent V2 session to avoid cold-start overhead on sequential calls.
3613
- * System prompt is embedded in the user message since session-level systemPrompt
3614
- * is set at creation time and we need different prompts per agent.
3615
- *
3616
- * @param systemPrompt - Instructions for the model
3617
- * @param userContent - The content to process
3618
- * @param _maxTokens - Kept for signature compatibility (unused -- Agent SDK constrains output via prompts)
3619
- * @throws Error if the Haiku call fails or session expires
3620
- */
3621
- async function callHaiku(systemPrompt, userContent, _maxTokens) {
3622
- const session = getOrCreateSession();
3623
- const fullPrompt = `<instructions>\n${systemPrompt}\n</instructions>\n\n${userContent}`;
3624
- try {
3625
- await session.send(fullPrompt);
3626
- for await (const msg of session.stream()) if (msg.type === "result") {
3627
- if (msg.subtype === "success") return msg.result;
3628
- const errorMsg = ("errors" in msg ? msg.errors : void 0)?.join(", ") ?? msg.subtype;
3629
- throw new Error(`Haiku call failed: ${errorMsg}`);
4651
+ this.runOnce();
4652
+ }, this.intervalMs);
4653
+ process.stderr.write(`[laminark:curation] Agent started, interval: ${this.intervalMs}ms\n`);
4654
+ }
4655
+ /**
4656
+ * Stop the periodic curation timer.
4657
+ */
4658
+ stop() {
4659
+ if (this.timer) {
4660
+ clearInterval(this.timer);
4661
+ this.timer = null;
3630
4662
  }
3631
- return "";
3632
- } catch (error) {
4663
+ this.running = false;
4664
+ process.stderr.write("[laminark:curation] Agent stopped\n");
4665
+ }
4666
+ /**
4667
+ * Execute one curation cycle. This is the main entry point.
4668
+ */
4669
+ async runOnce() {
4670
+ if (this.cycling) return {
4671
+ startedAt: "",
4672
+ completedAt: "",
4673
+ observationsMerged: 0,
4674
+ entitiesDeduplicated: 0,
4675
+ stalenessFlagsAdded: 0,
4676
+ lowValuePruned: 0,
4677
+ temporalDecayUpdated: 0,
4678
+ temporalDecayDeleted: 0,
4679
+ errors: ["skipped: previous cycle still running"]
4680
+ };
4681
+ this.cycling = true;
3633
4682
  try {
3634
- _session?.close();
3635
- } catch {}
3636
- _session = null;
3637
- throw error;
4683
+ const report = await runCuration(this.db, this.graphConfig);
4684
+ this.lastRun = report.completedAt;
4685
+ if (this.onComplete) this.onComplete(report);
4686
+ return report;
4687
+ } finally {
4688
+ this.cycling = false;
4689
+ }
3638
4690
  }
3639
- }
3640
- /**
3641
- * Defensive JSON extraction from Haiku response text.
3642
- *
3643
- * Handles common LLM response quirks:
3644
- * - Markdown code fences (```json ... ```)
3645
- * - Explanatory text before/after JSON
3646
- * - Both array and object JSON shapes
3647
- *
3648
- * @throws Error if no JSON structure found in text
3649
- */
3650
- function extractJsonFromResponse(text) {
3651
- const cleaned = text.replace(/```json\s*/g, "").replace(/```\s*/g, "");
3652
- const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
3653
- if (arrayMatch) return JSON.parse(arrayMatch[0]);
3654
- const objMatch = cleaned.match(/\{[\s\S]*\}/);
3655
- if (objMatch) return JSON.parse(objMatch[0]);
3656
- throw new Error("No JSON found in Haiku response");
3657
- }
4691
+ /**
4692
+ * Whether the agent is currently running.
4693
+ */
4694
+ isRunning() {
4695
+ return this.running;
4696
+ }
4697
+ /**
4698
+ * Timestamp of the last completed curation run.
4699
+ */
4700
+ getLastRun() {
4701
+ return this.lastRun;
4702
+ }
4703
+ };
3658
4704
 
3659
4705
  //#endregion
3660
4706
  //#region src/intelligence/haiku-classifier-agent.ts
@@ -3706,10 +4752,10 @@ For each observation, determine:
3706
4752
  - "problem": error, bug, failure, or obstacle encountered
3707
4753
  - "solution": fix, resolution, workaround, or decision that resolved something
3708
4754
 
3709
- 3. debug_signal (always, even for noise): Is this related to debugging?
3710
- - is_error: Does this contain an error message, test failure, build failure, or exception?
4755
+ 3. debug_signal (always, even for noise): Is this related to ACTIVE debugging (the developer hit an actual error)?
4756
+ - is_error: Did an actual error/failure OCCUR in this observation? An error message, stack trace, test failure, or build failure that happened RIGHT NOW. NOT research about errors — searching for "reconnection problems" or reading docs about error handling is NOT is_error. The tool itself must have failed or produced an error.
3711
4757
  - is_resolution: Does this indicate a successful fix, passing test, or resolved error?
3712
- - waypoint_hint: If debug-related, what type? "error" (hit an error), "attempt" (trying a fix), "failure" (fix didn't work), "success" (something passed), "pivot" (changing approach), "revert" (undoing a change), "discovery" (learned something), "resolution" (final fix). null if not debug-related.
4758
+ - waypoint_hint: If debug-related, what type? "error" (an actual error occurred), "attempt" (trying a fix), "failure" (fix didn't work), "success" (something passed), "pivot" (changing approach), "revert" (undoing a change), "discovery" (learned something new), "resolution" (final fix). null if not debug-related. WebSearch/WebFetch/AskUserQuestion are typically "discovery" or null, NOT "error".
3713
4759
  - confidence: 0.0-1.0 how confident this is debug activity
3714
4760
 
3715
4761
  Return JSON: {"signal": "noise"|"signal", "classification": "discovery"|"problem"|"solution"|null, "reason": "brief", "debug_signal": {"is_error": bool, "is_resolution": bool, "waypoint_hint": "type"|null, "confidence": 0.0-1.0}|null}
@@ -4117,6 +5163,7 @@ var HaikuProcessor = class {
4117
5163
  batchSize;
4118
5164
  concurrency;
4119
5165
  pathTracker;
5166
+ branchTracker;
4120
5167
  timer = null;
4121
5168
  constructor(db, projectHash, opts) {
4122
5169
  this.db = db;
@@ -4125,6 +5172,7 @@ var HaikuProcessor = class {
4125
5172
  this.batchSize = opts?.batchSize ?? 10;
4126
5173
  this.concurrency = opts?.concurrency ?? 3;
4127
5174
  this.pathTracker = opts?.pathTracker ?? null;
5175
+ this.branchTracker = opts?.branchTracker ?? null;
4128
5176
  }
4129
5177
  start() {
4130
5178
  if (this.timer) return;
@@ -4148,16 +5196,30 @@ var HaikuProcessor = class {
4148
5196
  }
4149
5197
  async processOnce() {
4150
5198
  if (!isHaikuEnabled()) return;
4151
- const repo = new ObservationRepository(this.db, this.projectHash);
4152
- const unclassified = repo.listUnclassified(this.batchSize);
5199
+ const unclassified = ObservationRepository.listAllUnclassified(this.db, this.batchSize);
4153
5200
  if (unclassified.length === 0) return;
4154
5201
  debug("haiku", "Processing unclassified observations", { count: unclassified.length });
4155
- for (let i = 0; i < unclassified.length; i += this.concurrency) {
4156
- const batch = unclassified.slice(i, i + this.concurrency);
4157
- await Promise.all(batch.map((obs) => this.processOne(obs, repo)));
5202
+ const byProject = /* @__PURE__ */ new Map();
5203
+ for (const obs of unclassified) {
5204
+ const hash = obs.projectHash;
5205
+ if (!byProject.has(hash)) byProject.set(hash, []);
5206
+ byProject.get(hash).push(obs);
5207
+ }
5208
+ for (const [hash, projectObs] of byProject) {
5209
+ const repo = new ObservationRepository(this.db, hash);
5210
+ for (let i = 0; i < projectObs.length; i += this.concurrency) {
5211
+ const batch = projectObs.slice(i, i + this.concurrency);
5212
+ await Promise.all(batch.map((obs) => this.processOne(obs, repo, hash)));
5213
+ }
5214
+ }
5215
+ if (this.branchTracker) try {
5216
+ await this.branchTracker.runMaintenance();
5217
+ } catch (err) {
5218
+ debug("haiku", "Branch maintenance error (non-fatal)", { error: err instanceof Error ? err.message : String(err) });
4158
5219
  }
4159
5220
  }
4160
- async processOne(obs, repo) {
5221
+ async processOne(obs, repo, obsProjectHash) {
5222
+ const projectHash = obsProjectHash ?? this.projectHash;
4161
5223
  try {
4162
5224
  let classification;
4163
5225
  try {
@@ -4171,6 +5233,23 @@ var HaikuProcessor = class {
4171
5233
  error: msg
4172
5234
  });
4173
5235
  }
5236
+ if (this.branchTracker) try {
5237
+ this.branchTracker.processObservation({
5238
+ id: obs.id,
5239
+ content: obs.content,
5240
+ source: obs.source,
5241
+ projectHash: obsProjectHash ?? this.projectHash,
5242
+ sessionId: void 0,
5243
+ classification: result.classification,
5244
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
5245
+ });
5246
+ } catch (branchErr) {
5247
+ const msg = branchErr instanceof Error ? branchErr.message : String(branchErr);
5248
+ debug("haiku", "Branch tracking failed (non-fatal)", {
5249
+ id: obs.id,
5250
+ error: msg
5251
+ });
5252
+ }
4174
5253
  if (result.signal === "noise") {
4175
5254
  repo.updateClassification(obs.id, "noise");
4176
5255
  repo.softDelete(obs.id);
@@ -4212,7 +5291,7 @@ var HaikuProcessor = class {
4212
5291
  name: entity.name,
4213
5292
  metadata: { confidence: entity.confidence },
4214
5293
  observation_ids: [String(obs.id)],
4215
- project_hash: this.projectHash
5294
+ project_hash: projectHash
4216
5295
  });
4217
5296
  persistedNodes.push(node);
4218
5297
  } catch {
@@ -4224,7 +5303,8 @@ var HaikuProcessor = class {
4224
5303
  label: node.name,
4225
5304
  type: node.type,
4226
5305
  observationCount: 1,
4227
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
5306
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5307
+ projectHash
4228
5308
  });
4229
5309
  debug("haiku", "Entities persisted", {
4230
5310
  id: obs.id,
@@ -4249,7 +5329,7 @@ var HaikuProcessor = class {
4249
5329
  type: rel.type,
4250
5330
  weight: rel.confidence,
4251
5331
  metadata: { source: "haiku" },
4252
- project_hash: this.projectHash
5332
+ project_hash: projectHash
4253
5333
  });
4254
5334
  affectedNodeIds.add(sourceNode.id);
4255
5335
  affectedNodeIds.add(targetNode.id);
@@ -4646,7 +5726,7 @@ apiRoutes.get("/graph", (c) => {
4646
5726
  edgeRows = [];
4647
5727
  }
4648
5728
  const nodeIdSet = new Set(nodes.map((n) => n.id));
4649
- const edges = (typeFilter ? edgeRows.filter((e) => nodeIdSet.has(e.source_id) && nodeIdSet.has(e.target_id)) : edgeRows).map((row) => ({
5729
+ const edges = edgeRows.filter((e) => nodeIdSet.has(e.source_id) && nodeIdSet.has(e.target_id)).map((row) => ({
4650
5730
  id: row.id,
4651
5731
  source: row.source_id,
4652
5732
  target: row.target_id,
@@ -5224,6 +6304,236 @@ apiRoutes.get("/paths/:id", (c) => {
5224
6304
  }
5225
6305
  });
5226
6306
  /**
6307
+ * GET /api/tools
6308
+ *
6309
+ * Returns all tools from tool_registry with usage stats.
6310
+ */
6311
+ apiRoutes.get("/tools", (c) => {
6312
+ const db = getDb$1(c);
6313
+ let tools = [];
6314
+ try {
6315
+ tools = db.prepare(`
6316
+ SELECT id, name, tool_type, scope, status, usage_count, server_name, description, last_used_at, discovered_at
6317
+ FROM tool_registry
6318
+ ORDER BY usage_count DESC, discovered_at DESC
6319
+ `).all();
6320
+ } catch {}
6321
+ return c.json({ tools: tools.map((t) => ({
6322
+ id: t.id,
6323
+ name: t.name,
6324
+ toolType: t.tool_type,
6325
+ scope: t.scope,
6326
+ status: t.status,
6327
+ usageCount: t.usage_count,
6328
+ serverName: t.server_name,
6329
+ description: t.description,
6330
+ lastUsedAt: t.last_used_at,
6331
+ discoveredAt: t.discovered_at
6332
+ })) });
6333
+ });
6334
+ /**
6335
+ * GET /api/tools/flows
6336
+ *
6337
+ * Returns edges for the tool topology graph:
6338
+ * 1. Pre-computed routing_patterns (preceding_tools -> target_tool)
6339
+ * 2. Pairwise co-occurrence from tool_usage_events session sequences
6340
+ */
6341
+ apiRoutes.get("/tools/flows", (c) => {
6342
+ const db = getDb$1(c);
6343
+ const projectFilter = getProjectHash$2(c);
6344
+ const edges = [];
6345
+ const edgeKey = /* @__PURE__ */ new Set();
6346
+ try {
6347
+ let sql = "SELECT target_tool, preceding_tools, frequency FROM routing_patterns";
6348
+ const params = [];
6349
+ if (projectFilter) {
6350
+ sql += " WHERE project_hash = ?";
6351
+ params.push(projectFilter);
6352
+ }
6353
+ sql += " ORDER BY frequency DESC LIMIT 200";
6354
+ const rows = db.prepare(sql).all(...params);
6355
+ for (const row of rows) {
6356
+ let preceding;
6357
+ try {
6358
+ preceding = JSON.parse(row.preceding_tools);
6359
+ } catch {
6360
+ preceding = [];
6361
+ }
6362
+ for (const src of preceding) {
6363
+ const key = src + "->" + row.target_tool;
6364
+ if (!edgeKey.has(key)) {
6365
+ edgeKey.add(key);
6366
+ edges.push({
6367
+ source: src,
6368
+ target: row.target_tool,
6369
+ frequency: row.frequency,
6370
+ edgeType: "pattern"
6371
+ });
6372
+ }
6373
+ }
6374
+ }
6375
+ } catch {}
6376
+ try {
6377
+ let sql = `
6378
+ SELECT session_id, tool_name, created_at
6379
+ FROM tool_usage_events
6380
+ WHERE session_id IS NOT NULL
6381
+ `;
6382
+ const params = [];
6383
+ if (projectFilter) {
6384
+ sql += " AND project_hash = ?";
6385
+ params.push(projectFilter);
6386
+ }
6387
+ sql += " ORDER BY session_id, created_at ASC LIMIT 5000";
6388
+ const rows = db.prepare(sql).all(...params);
6389
+ const pairFreq = /* @__PURE__ */ new Map();
6390
+ let prevSession = "";
6391
+ let prevTool = "";
6392
+ for (const row of rows) {
6393
+ if (row.session_id === prevSession && prevTool && prevTool !== row.tool_name) {
6394
+ const key = prevTool + "->" + row.tool_name;
6395
+ pairFreq.set(key, (pairFreq.get(key) || 0) + 1);
6396
+ }
6397
+ prevSession = row.session_id;
6398
+ prevTool = row.tool_name;
6399
+ }
6400
+ for (const [key, freq] of pairFreq) if (!edgeKey.has(key) && freq >= 2) {
6401
+ edgeKey.add(key);
6402
+ const [source, target] = key.split("->");
6403
+ edges.push({
6404
+ source,
6405
+ target,
6406
+ frequency: freq,
6407
+ edgeType: "session"
6408
+ });
6409
+ }
6410
+ } catch {}
6411
+ return c.json({ edges });
6412
+ });
6413
+ /**
6414
+ * GET /api/tools/:name/stats
6415
+ *
6416
+ * Returns detailed stats for a single tool.
6417
+ */
6418
+ apiRoutes.get("/tools/:name/stats", (c) => {
6419
+ const db = getDb$1(c);
6420
+ const toolName = c.req.param("name");
6421
+ const projectFilter = getProjectHash$2(c);
6422
+ let tool;
6423
+ try {
6424
+ tool = db.prepare("SELECT id, name, tool_type, scope, status, usage_count, server_name, description, last_used_at, discovered_at FROM tool_registry WHERE name = ? ORDER BY usage_count DESC LIMIT 1").get(toolName);
6425
+ } catch {}
6426
+ if (!tool) return c.json({ error: "Tool not found" }, 404);
6427
+ let successRate = null;
6428
+ let totalEvents = 0;
6429
+ try {
6430
+ let sql = "SELECT success FROM tool_usage_events WHERE tool_name = ?";
6431
+ const params = [toolName];
6432
+ if (projectFilter) {
6433
+ sql += " AND project_hash = ?";
6434
+ params.push(projectFilter);
6435
+ }
6436
+ sql += " ORDER BY created_at DESC LIMIT 50";
6437
+ const events = db.prepare(sql).all(...params);
6438
+ totalEvents = events.length;
6439
+ if (totalEvents > 0) successRate = events.filter((e) => e.success === 1).length / totalEvents;
6440
+ } catch {}
6441
+ let sessionsUsedIn = 0;
6442
+ try {
6443
+ let sql = "SELECT COUNT(DISTINCT session_id) as cnt FROM tool_usage_events WHERE tool_name = ? AND session_id IS NOT NULL";
6444
+ const params = [toolName];
6445
+ if (projectFilter) {
6446
+ sql += " AND project_hash = ?";
6447
+ params.push(projectFilter);
6448
+ }
6449
+ sessionsUsedIn = db.prepare(sql).get(...params)?.cnt ?? 0;
6450
+ } catch {}
6451
+ let coOccurring = [];
6452
+ try {
6453
+ let sql = `
6454
+ SELECT e2.tool_name as name, COUNT(*) as count
6455
+ FROM tool_usage_events e1
6456
+ JOIN tool_usage_events e2
6457
+ ON e1.session_id = e2.session_id AND e1.tool_name != e2.tool_name
6458
+ WHERE e1.tool_name = ? AND e1.session_id IS NOT NULL
6459
+ `;
6460
+ const params = [toolName];
6461
+ if (projectFilter) {
6462
+ sql += " AND e1.project_hash = ?";
6463
+ params.push(projectFilter);
6464
+ }
6465
+ sql += " GROUP BY e2.tool_name ORDER BY count DESC LIMIT 10";
6466
+ coOccurring = db.prepare(sql).all(...params);
6467
+ } catch {}
6468
+ return c.json({
6469
+ tool: {
6470
+ id: tool.id,
6471
+ name: tool.name,
6472
+ toolType: tool.tool_type,
6473
+ scope: tool.scope,
6474
+ status: tool.status,
6475
+ usageCount: tool.usage_count,
6476
+ serverName: tool.server_name,
6477
+ description: tool.description,
6478
+ lastUsedAt: tool.last_used_at,
6479
+ discoveredAt: tool.discovered_at
6480
+ },
6481
+ successRate,
6482
+ totalEvents,
6483
+ sessionsUsedIn,
6484
+ coOccurring
6485
+ });
6486
+ });
6487
+ /**
6488
+ * GET /api/tools/sessions
6489
+ *
6490
+ * Returns recent session tool sequences for the flow strip.
6491
+ */
6492
+ apiRoutes.get("/tools/sessions", (c) => {
6493
+ const db = getDb$1(c);
6494
+ const projectFilter = getProjectHash$2(c);
6495
+ const limitStr = c.req.query("limit");
6496
+ const limit = limitStr ? Math.min(parseInt(limitStr, 10) || 10, 30) : 10;
6497
+ let sessions = [];
6498
+ try {
6499
+ let sessionSql = `
6500
+ SELECT DISTINCT session_id FROM tool_usage_events
6501
+ WHERE session_id IS NOT NULL
6502
+ `;
6503
+ const sessionParams = [];
6504
+ if (projectFilter) {
6505
+ sessionSql += " AND project_hash = ?";
6506
+ sessionParams.push(projectFilter);
6507
+ }
6508
+ sessionSql += " ORDER BY created_at DESC LIMIT ?";
6509
+ sessionParams.push(limit);
6510
+ const sessionIds = db.prepare(sessionSql).all(...sessionParams);
6511
+ if (sessionIds.length > 0) {
6512
+ const placeholders = sessionIds.map(() => "?").join(", ");
6513
+ const ids = sessionIds.map((s) => s.session_id);
6514
+ const eventRows = db.prepare(`
6515
+ SELECT session_id, tool_name, created_at
6516
+ FROM tool_usage_events
6517
+ WHERE session_id IN (${placeholders})
6518
+ ORDER BY session_id, created_at ASC
6519
+ `).all(...ids);
6520
+ const sessionMap = /* @__PURE__ */ new Map();
6521
+ for (const row of eventRows) {
6522
+ if (!sessionMap.has(row.session_id)) sessionMap.set(row.session_id, []);
6523
+ sessionMap.get(row.session_id).push({
6524
+ name: row.tool_name,
6525
+ time: row.created_at
6526
+ });
6527
+ }
6528
+ sessions = sessionIds.filter((s) => sessionMap.has(s.session_id)).map((s) => ({
6529
+ sessionId: s.session_id,
6530
+ tools: sessionMap.get(s.session_id)
6531
+ }));
6532
+ }
6533
+ } catch {}
6534
+ return c.json({ sessions });
6535
+ });
6536
+ /**
5227
6537
  * Finds connected components in the graph via BFS.
5228
6538
  * Shared by /api/graph/analysis and /api/graph/communities.
5229
6539
  */
@@ -5317,6 +6627,14 @@ function safeParseJson(json) {
5317
6627
  *
5318
6628
  * @module web/routes/admin
5319
6629
  */
6630
+ const __dirname = dirname(fileURLToPath$1(import.meta.url));
6631
+ const LAMINARK_VERSION = (() => {
6632
+ try {
6633
+ return JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8")).version || "unknown";
6634
+ } catch {
6635
+ return "unknown";
6636
+ }
6637
+ })();
5320
6638
  function getDb(c) {
5321
6639
  return c.get("db");
5322
6640
  }
@@ -5391,6 +6709,51 @@ adminRoutes.get("/stats", (c) => {
5391
6709
  });
5392
6710
  });
5393
6711
  /**
6712
+ * GET /api/admin/system
6713
+ *
6714
+ * Returns server-scoped system info (not project-scoped).
6715
+ */
6716
+ adminRoutes.get("/system", (c) => {
6717
+ const db = getDb(c);
6718
+ const mem = process.memoryUsage();
6719
+ let dbSizeBytes = 0;
6720
+ let pageCount = 0;
6721
+ let pageSize = 4096;
6722
+ try {
6723
+ const pc = db.pragma("page_count", { simple: true });
6724
+ const ps = db.pragma("page_size", { simple: true });
6725
+ pageCount = pc;
6726
+ pageSize = ps;
6727
+ dbSizeBytes = pc * ps;
6728
+ } catch {}
6729
+ let walSizeBytes = 0;
6730
+ try {
6731
+ const dbPath = db.name;
6732
+ if (dbPath) {
6733
+ const walPath = dbPath + "-wal";
6734
+ if (existsSync(walPath)) walSizeBytes = statSync(walPath).size;
6735
+ }
6736
+ } catch {}
6737
+ return c.json({
6738
+ laminarkVersion: LAMINARK_VERSION,
6739
+ nodeVersion: process.version,
6740
+ platform: process.platform,
6741
+ arch: process.arch,
6742
+ uptimeSeconds: Math.floor(process.uptime()),
6743
+ memory: {
6744
+ rssBytes: mem.rss,
6745
+ heapUsedBytes: mem.heapUsed,
6746
+ heapTotalBytes: mem.heapTotal
6747
+ },
6748
+ database: {
6749
+ sizeBytes: dbSizeBytes,
6750
+ walSizeBytes,
6751
+ pageCount,
6752
+ pageSize
6753
+ }
6754
+ });
6755
+ });
6756
+ /**
5394
6757
  * POST /api/admin/reset
5395
6758
  *
5396
6759
  * Hard-deletes data by group inside a transaction.
@@ -5495,6 +6858,66 @@ adminRoutes.post("/reset", async (c) => {
5495
6858
  scope: scoped ? "project" : "all"
5496
6859
  });
5497
6860
  });
6861
+ adminRoutes.get("/hygiene", (c) => {
6862
+ const db = getDb(c);
6863
+ const project = getProjectHash$1(c);
6864
+ if (!project) return c.json({ error: "No project context available" }, 400);
6865
+ const tier = c.req.query("tier") || "high";
6866
+ const sessionId = c.req.query("session_id");
6867
+ const limit = parseInt(c.req.query("limit") || "50", 10);
6868
+ const config = loadHygieneConfig();
6869
+ const report = analyzeObservations(db, project, {
6870
+ sessionId,
6871
+ limit,
6872
+ minTier: tier === "all" ? "low" : tier,
6873
+ config
6874
+ });
6875
+ return c.json(report);
6876
+ });
6877
+ adminRoutes.post("/hygiene/purge", async (c) => {
6878
+ const db = getDb(c);
6879
+ const project = getProjectHash$1(c);
6880
+ if (!project) return c.json({ error: "No project context available" }, 400);
6881
+ const tier = (await c.req.json()).tier || "high";
6882
+ const config = loadHygieneConfig();
6883
+ const result = executePurge(db, project, analyzeObservations(db, project, {
6884
+ minTier: tier === "all" ? "low" : tier,
6885
+ limit: 500,
6886
+ config
6887
+ }), tier);
6888
+ return c.json({
6889
+ ok: true,
6890
+ observationsPurged: result.observationsPurged,
6891
+ orphanNodesRemoved: result.orphanNodesRemoved,
6892
+ tier
6893
+ });
6894
+ });
6895
+ adminRoutes.get("/hygiene/find", (c) => {
6896
+ const db = getDb(c);
6897
+ const project = getProjectHash$1(c);
6898
+ if (!project) return c.json({ error: "No project context available" }, 400);
6899
+ const report = findAnalysis(db, project, loadHygieneConfig());
6900
+ return c.json(report);
6901
+ });
6902
+ adminRoutes.get("/config/hygiene", (c) => {
6903
+ return c.json(loadHygieneConfig());
6904
+ });
6905
+ adminRoutes.put("/config/hygiene", async (c) => {
6906
+ const body = await c.req.json();
6907
+ const configPath = join(getConfigDir(), "hygiene.json");
6908
+ if (body && body.__reset === true) {
6909
+ try {
6910
+ if (existsSync(configPath)) unlinkSync(configPath);
6911
+ } catch {}
6912
+ return c.json(resetHygieneConfig());
6913
+ }
6914
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return c.json({ error: "Request body must be a JSON object" }, 400);
6915
+ const { __reset: _, ...data } = body;
6916
+ writeFileSync(configPath, JSON.stringify(data, null, 2), "utf-8");
6917
+ const validated = loadHygieneConfig();
6918
+ saveHygieneConfig(validated);
6919
+ return c.json(validated);
6920
+ });
5498
6921
  adminRoutes.get("/config/topic-detection", (c) => {
5499
6922
  return c.json(loadTopicDetectionConfig());
5500
6923
  });
@@ -5533,6 +6956,121 @@ adminRoutes.put("/config/graph-extraction", async (c) => {
5533
6956
  writeFileSync(configPath, JSON.stringify(validated, null, 2), "utf-8");
5534
6957
  return c.json(validated);
5535
6958
  });
6959
+ adminRoutes.get("/config/cross-access", (c) => {
6960
+ const project = c.req.query("project");
6961
+ if (!project) return c.json({ error: "project query parameter is required" }, 400);
6962
+ return c.json(loadCrossAccessConfig(project));
6963
+ });
6964
+ adminRoutes.put("/config/cross-access", async (c) => {
6965
+ const project = c.req.query("project");
6966
+ if (!project) return c.json({ error: "project query parameter is required" }, 400);
6967
+ const body = await c.req.json();
6968
+ if (body && body.__reset === true) {
6969
+ resetCrossAccessConfig(project);
6970
+ return c.json(loadCrossAccessConfig(project));
6971
+ }
6972
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return c.json({ error: "Request body must be a JSON object" }, 400);
6973
+ saveCrossAccessConfig(project, { readableProjects: body.readableProjects || [] });
6974
+ return c.json(loadCrossAccessConfig(project));
6975
+ });
6976
+ adminRoutes.get("/config/tool-verbosity", (c) => {
6977
+ return c.json(loadToolVerbosityConfig());
6978
+ });
6979
+ adminRoutes.put("/config/tool-verbosity", async (c) => {
6980
+ const body = await c.req.json();
6981
+ if (body && body.__reset === true) {
6982
+ const config = resetToolVerbosityConfig();
6983
+ saveToolVerbosityConfig(config);
6984
+ return c.json(config);
6985
+ }
6986
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return c.json({ error: "Request body must be a JSON object" }, 400);
6987
+ const level = body.level;
6988
+ if (level !== 1 && level !== 2 && level !== 3) return c.json({ error: "level must be 1, 2, or 3" }, 400);
6989
+ saveToolVerbosityConfig({ level });
6990
+ return c.json(loadToolVerbosityConfig());
6991
+ });
6992
+ /**
6993
+ * DELETE /api/admin/projects/:hash?confirm=true
6994
+ *
6995
+ * Permanently deletes all data for the given project.
6996
+ * Requires `?confirm=true` query param as a safety guard.
6997
+ * Cannot delete the currently active (most-recently-seen) project.
6998
+ */
6999
+ adminRoutes.delete("/projects/:hash", (c) => {
7000
+ const db = getDb(c);
7001
+ const projectHash = c.req.param("hash");
7002
+ if (c.req.query("confirm") !== "true") return c.json({ error: "Safety guard: append ?confirm=true to proceed with deletion" }, 400);
7003
+ const activeProject = db.prepare("SELECT project_hash FROM project_metadata ORDER BY last_seen_at DESC LIMIT 1").get();
7004
+ if (activeProject && projectHash === activeProject.project_hash) return c.json({ error: "Cannot delete the currently active project. Switch to a different project first." }, 400);
7005
+ const project = db.prepare("SELECT project_hash, project_path FROM project_metadata WHERE project_hash = ?").get(projectHash);
7006
+ if (!project) return c.json({ error: `No project found with hash '${projectHash}'` }, 404);
7007
+ const run = (sql, params) => {
7008
+ try {
7009
+ db.prepare(sql).run(...params);
7010
+ } catch {}
7011
+ };
7012
+ const exec = (sql) => {
7013
+ try {
7014
+ db.exec(sql);
7015
+ } catch {}
7016
+ };
7017
+ let obsCount = 0;
7018
+ try {
7019
+ obsCount = db.prepare("SELECT COUNT(*) as cnt FROM observations WHERE project_hash = ?").get(projectHash).cnt;
7020
+ } catch {}
7021
+ db.transaction(() => {
7022
+ exec("DROP TRIGGER IF EXISTS observations_ai");
7023
+ exec("DROP TRIGGER IF EXISTS observations_au");
7024
+ exec("DROP TRIGGER IF EXISTS observations_ad");
7025
+ run("DELETE FROM observation_embeddings WHERE observation_id IN (SELECT id FROM observations WHERE project_hash = ?)", [projectHash]);
7026
+ run("DELETE FROM staleness_flags WHERE observation_id IN (SELECT id FROM observations WHERE project_hash = ?)", [projectHash]);
7027
+ run("DELETE FROM observations WHERE project_hash = ?", [projectHash]);
7028
+ exec("INSERT INTO observations_fts(observations_fts) VALUES('rebuild')");
7029
+ exec(`
7030
+ CREATE TRIGGER observations_ai AFTER INSERT ON observations BEGIN
7031
+ INSERT INTO observations_fts(rowid, title, content)
7032
+ VALUES (new.rowid, new.title, new.content);
7033
+ END
7034
+ `);
7035
+ exec(`
7036
+ CREATE TRIGGER observations_au AFTER UPDATE ON observations BEGIN
7037
+ INSERT INTO observations_fts(observations_fts, rowid, title, content)
7038
+ VALUES('delete', old.rowid, old.title, old.content);
7039
+ INSERT INTO observations_fts(rowid, title, content)
7040
+ VALUES (new.rowid, new.title, new.content);
7041
+ END
7042
+ `);
7043
+ exec(`
7044
+ CREATE TRIGGER observations_ad AFTER DELETE ON observations BEGIN
7045
+ INSERT INTO observations_fts(observations_fts, rowid, title, content)
7046
+ VALUES('delete', old.rowid, old.title, old.content);
7047
+ END
7048
+ `);
7049
+ run("DELETE FROM graph_edges WHERE project_hash = ?", [projectHash]);
7050
+ run("DELETE FROM graph_nodes WHERE project_hash = ?", [projectHash]);
7051
+ run("DELETE FROM sessions WHERE project_hash = ?", [projectHash]);
7052
+ run("DELETE FROM shift_decisions WHERE project_id = ?", [projectHash]);
7053
+ run("DELETE FROM threshold_history WHERE project_id = ?", [projectHash]);
7054
+ run("DELETE FROM context_stashes WHERE project_id = ?", [projectHash]);
7055
+ run("DELETE FROM pending_notifications WHERE project_id = ?", [projectHash]);
7056
+ run("DELETE FROM research_buffer WHERE project_hash = ?", [projectHash]);
7057
+ run("DELETE FROM tool_registry WHERE project_hash = ?", [projectHash]);
7058
+ run("DELETE FROM tool_usage_events WHERE project_hash = ?", [projectHash]);
7059
+ run("DELETE FROM debug_paths WHERE project_hash = ?", [projectHash]);
7060
+ run("DELETE FROM thought_branches WHERE project_hash = ?", [projectHash]);
7061
+ run("DELETE FROM routing_patterns WHERE project_hash = ?", [projectHash]);
7062
+ run("DELETE FROM routing_state WHERE project_hash = ?", [projectHash]);
7063
+ run("DELETE FROM project_metadata WHERE project_hash = ?", [projectHash]);
7064
+ })();
7065
+ return c.json({
7066
+ ok: true,
7067
+ deleted: {
7068
+ projectPath: project.project_path,
7069
+ projectHash,
7070
+ observationsRemoved: obsCount
7071
+ }
7072
+ });
7073
+ });
5536
7074
 
5537
7075
  //#endregion
5538
7076
  //#region src/web/server.ts
@@ -5641,14 +7179,46 @@ const noGui = process.argv.includes("--no_gui");
5641
7179
  const db = openDatabase(getDatabaseConfig());
5642
7180
  initGraphSchema(db.db);
5643
7181
  initPathSchema(db.db);
5644
- const projectHash = getProjectHash(process.cwd());
7182
+ var LiveProjectHashRef = class LiveProjectHashRef {
7183
+ _current;
7184
+ _lastChecked = 0;
7185
+ _db;
7186
+ static CHECK_INTERVAL_MS = 2e3;
7187
+ constructor(sqliteDb) {
7188
+ this._db = sqliteDb;
7189
+ this._current = this.resolve();
7190
+ }
7191
+ get current() {
7192
+ const now = Date.now();
7193
+ if (now - this._lastChecked >= LiveProjectHashRef.CHECK_INTERVAL_MS) {
7194
+ this._lastChecked = now;
7195
+ const fresh = this.resolve();
7196
+ if (fresh !== this._current) {
7197
+ debug("mcp", "Project hash refreshed from database", {
7198
+ old: this._current,
7199
+ new: fresh
7200
+ });
7201
+ this._current = fresh;
7202
+ }
7203
+ }
7204
+ return this._current;
7205
+ }
7206
+ resolve() {
7207
+ try {
7208
+ const row = this._db.prepare("SELECT project_hash FROM project_metadata ORDER BY last_seen_at DESC LIMIT 1").get();
7209
+ if (row?.project_hash) return row.project_hash;
7210
+ } catch {}
7211
+ return getProjectHash(process.cwd());
7212
+ }
7213
+ };
7214
+ const projectHashRef = new LiveProjectHashRef(db.db);
5645
7215
  let toolRegistry = null;
5646
7216
  try {
5647
7217
  toolRegistry = new ToolRegistryRepository(db.db);
5648
7218
  } catch {
5649
7219
  debug("mcp", "Tool registry not available (pre-migration-16)");
5650
7220
  }
5651
- const embeddingStore = db.hasVectorSupport ? new EmbeddingStore(db.db, projectHash) : null;
7221
+ const embeddingStore = db.hasVectorSupport ? new EmbeddingStore(db.db, projectHashRef.current) : null;
5652
7222
  const worker = new AnalysisWorker();
5653
7223
  worker.start().catch(() => {
5654
7224
  debug("mcp", "Worker failed to start, keyword-only mode");
@@ -5661,7 +7231,7 @@ const adaptiveManager = new AdaptiveThresholdManager({
5661
7231
  alpha: topicConfig.ewmaAlpha
5662
7232
  });
5663
7233
  applyConfig(topicConfig, detector, adaptiveManager);
5664
- const historicalSeed = new ThresholdStore(db.db).loadHistoricalSeed(projectHash);
7234
+ const historicalSeed = new ThresholdStore(db.db).loadHistoricalSeed(projectHashRef.current);
5665
7235
  if (historicalSeed) {
5666
7236
  adaptiveManager.seedFromHistory(historicalSeed.averageDistance, historicalSeed.averageVariance);
5667
7237
  applyConfig(topicConfig, detector, adaptiveManager);
@@ -5672,7 +7242,7 @@ const notificationStore = new NotificationStore(db.db);
5672
7242
  const topicShiftHandler = new TopicShiftHandler({
5673
7243
  detector,
5674
7244
  stashManager,
5675
- observationStore: new ObservationRepository(db.db, projectHash),
7245
+ observationStore: new ObservationRepository(db.db, projectHashRef.current),
5676
7246
  config: topicConfig,
5677
7247
  decisionLogger,
5678
7248
  adaptiveManager
@@ -5687,7 +7257,8 @@ async function processUnembedded() {
5687
7257
  if (!embeddingStore || !worker.isReady()) return;
5688
7258
  const ids = embeddingStore.findUnembedded(10);
5689
7259
  if (ids.length === 0) return;
5690
- const obsRepo = new ObservationRepository(db.db, projectHash);
7260
+ const currentHash = projectHashRef.current;
7261
+ const obsRepo = new ObservationRepository(db.db, currentHash);
5691
7262
  let shiftDetectedThisCycle = false;
5692
7263
  for (const id of ids) {
5693
7264
  const obs = obsRepo.getById(id);
@@ -5704,24 +7275,26 @@ async function processUnembedded() {
5704
7275
  id,
5705
7276
  text: obs.content.length > 120 ? obs.content.substring(0, 120) + "..." : obs.content,
5706
7277
  sessionId: obs.sessionId ?? null,
5707
- createdAt: obs.createdAt
7278
+ createdAt: obs.createdAt,
7279
+ projectHash: currentHash
5708
7280
  });
5709
7281
  if (topicConfig.enabled && !shiftDetectedThisCycle && TOPIC_SHIFT_SOURCES.has(obs.source)) try {
5710
7282
  const obsWithEmbedding = {
5711
7283
  ...obs,
5712
7284
  embedding
5713
7285
  };
5714
- const result = await topicShiftHandler.handleObservation(obsWithEmbedding, obs.sessionId ?? "unknown", projectHash);
7286
+ const result = await topicShiftHandler.handleObservation(obsWithEmbedding, obs.sessionId ?? "unknown", currentHash);
5715
7287
  if (result.stashed && result.notification) {
5716
7288
  shiftDetectedThisCycle = true;
5717
- notificationStore.add(projectHash, result.notification);
7289
+ notificationStore.add(currentHash, result.notification);
5718
7290
  debug("embed", "Topic shift detected, notification queued", { id });
5719
7291
  broadcast("topic_shift", {
5720
7292
  id: result.notification.substring(0, 32),
5721
7293
  fromTopic: null,
5722
7294
  toTopic: null,
5723
7295
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
5724
- confidence: null
7296
+ confidence: null,
7297
+ projectHash: currentHash
5725
7298
  });
5726
7299
  }
5727
7300
  } catch (topicErr) {
@@ -5732,7 +7305,7 @@ async function processUnembedded() {
5732
7305
  }
5733
7306
  let researchBufferForFlush = null;
5734
7307
  try {
5735
- researchBufferForFlush = new ResearchBufferRepository(db.db, projectHash);
7308
+ researchBufferForFlush = new ResearchBufferRepository(db.db, projectHashRef.current);
5736
7309
  } catch {}
5737
7310
  async function processUnembeddedTools() {
5738
7311
  if (!toolRegistry || !worker.isReady() || !db.hasVectorSupport) return;
@@ -5759,26 +7332,39 @@ const embedTimer = setInterval(() => {
5759
7332
  } catch {}
5760
7333
  statusCache.refreshIfDirty();
5761
7334
  }, 5e3);
5762
- const statusCache = new StatusCache(db.db, projectHash, process.cwd(), db.hasVectorSupport, () => worker.isReady());
7335
+ const statusCache = new StatusCache(db.db, projectHashRef, process.cwd(), db.hasVectorSupport, () => worker.isReady());
5763
7336
  const server = createServer();
5764
- registerSaveMemory(server, db.db, projectHash, notificationStore, worker, embeddingStore, statusCache);
5765
- registerRecall(server, db.db, projectHash, worker, embeddingStore, notificationStore, statusCache);
5766
- registerTopicContext(server, db.db, projectHash, notificationStore);
5767
- registerQueryGraph(server, db.db, projectHash, notificationStore);
5768
- registerGraphStats(server, db.db, projectHash, notificationStore);
5769
- registerStatus(server, statusCache, projectHash, notificationStore);
7337
+ registerSaveMemory(server, db.db, projectHashRef, notificationStore, worker, embeddingStore, statusCache);
7338
+ registerIngestKnowledge(server, db.db, projectHashRef, notificationStore, statusCache);
7339
+ registerRecall(server, db.db, projectHashRef, worker, embeddingStore, notificationStore, statusCache);
7340
+ registerTopicContext(server, db.db, projectHashRef, notificationStore);
7341
+ registerQueryGraph(server, db.db, projectHashRef, notificationStore);
7342
+ registerGraphStats(server, db.db, projectHashRef, notificationStore);
7343
+ registerHygiene(server, db.db, projectHashRef, notificationStore);
7344
+ registerStatus(server, statusCache, projectHashRef, notificationStore);
5770
7345
  if (toolRegistry) {
5771
- registerDiscoverTools(server, toolRegistry, worker, db.hasVectorSupport, notificationStore, projectHash);
5772
- registerReportTools(server, toolRegistry, projectHash);
7346
+ registerDiscoverTools(server, toolRegistry, worker, db.hasVectorSupport, notificationStore, projectHashRef);
7347
+ registerReportTools(server, toolRegistry, projectHashRef);
5773
7348
  }
5774
- const pathRepo = new PathRepository(db.db, projectHash);
7349
+ const pathRepo = new PathRepository(db.db, projectHashRef.current);
5775
7350
  const pathTracker = new PathTracker(pathRepo);
5776
- registerDebugPathTools(server, pathRepo, pathTracker, notificationStore, projectHash);
5777
- const haikuProcessor = new HaikuProcessor(db.db, projectHash, {
7351
+ registerDebugPathTools(server, pathRepo, pathTracker, notificationStore, projectHashRef);
7352
+ let branchRepo = null;
7353
+ let branchTracker;
7354
+ try {
7355
+ branchRepo = new BranchRepository(db.db, projectHashRef.current);
7356
+ branchTracker = new BranchTracker(branchRepo, db.db, projectHashRef.current);
7357
+ const obsRepoForBranches = new ObservationRepository(db.db, projectHashRef.current);
7358
+ registerThoughtBranchTools(server, branchRepo, obsRepoForBranches, notificationStore, projectHashRef);
7359
+ } catch {
7360
+ debug("mcp", "Branch tracking not available (pre-migration-21)");
7361
+ }
7362
+ const haikuProcessor = new HaikuProcessor(db.db, projectHashRef.current, {
5778
7363
  intervalMs: 3e4,
5779
7364
  batchSize: 10,
5780
7365
  concurrency: 3,
5781
- pathTracker
7366
+ pathTracker,
7367
+ branchTracker
5782
7368
  });
5783
7369
  startServer(server).then(() => {
5784
7370
  haikuProcessor.start();
@@ -5793,7 +7379,7 @@ if (!noGui) {
5793
7379
  const __filename = fileURLToPath(import.meta.url);
5794
7380
  const __dirname = path.dirname(__filename);
5795
7381
  const uiRoot = path.resolve(__dirname, "..", "ui");
5796
- startWebServer(createWebServer(db.db, uiRoot, projectHash), webPort);
7382
+ startWebServer(createWebServer(db.db, uiRoot, projectHashRef.current), webPort);
5797
7383
  } else debug("mcp", "Web UI disabled (--no_gui)");
5798
7384
  const curationAgent = new CurationAgent(db.db, {
5799
7385
  intervalMs: 300 * 1e3,