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.
- package/README.md +36 -71
- package/package.json +9 -7
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +10 -0
- package/plugin/commands/recall.md +55 -0
- package/plugin/commands/remember.md +34 -0
- package/plugin/commands/resume.md +45 -0
- package/plugin/commands/stash.md +34 -0
- package/plugin/commands/status.md +33 -0
- package/plugin/dist/hooks/handler.d.ts +3 -1
- package/plugin/dist/hooks/handler.d.ts.map +1 -1
- package/plugin/dist/hooks/handler.js +312 -23
- package/plugin/dist/hooks/handler.js.map +1 -1
- package/plugin/dist/index.d.ts +3 -1
- package/plugin/dist/index.d.ts.map +1 -1
- package/plugin/dist/index.js +2111 -525
- package/plugin/dist/index.js.map +1 -1
- package/plugin/dist/{observations-Ch0nc47i.d.mts → observations-CorAAc1A.d.mts} +23 -1
- package/plugin/dist/observations-CorAAc1A.d.mts.map +1 -0
- package/plugin/dist/{tool-registry-CZ3mJ4iR.mjs → tool-registry-D8un_AcG.mjs} +932 -13
- package/plugin/dist/tool-registry-D8un_AcG.mjs.map +1 -0
- package/plugin/hooks/hooks.json +6 -6
- package/plugin/laminark.db +0 -0
- package/plugin/package.json +17 -0
- package/plugin/scripts/README.md +19 -1
- package/plugin/scripts/bump-version.sh +24 -19
- package/plugin/scripts/dev-sync.sh +58 -0
- package/plugin/scripts/ensure-deps.sh +5 -2
- package/plugin/scripts/install.sh +115 -39
- package/plugin/scripts/local-install.sh +93 -58
- package/plugin/scripts/uninstall.sh +76 -38
- package/plugin/scripts/update.sh +20 -69
- package/plugin/scripts/verify-install.sh +69 -25
- package/plugin/ui/activity.js +12 -0
- package/plugin/ui/app.js +24 -54
- package/plugin/ui/graph.js +413 -186
- package/plugin/ui/help/activity-feed.png +0 -0
- package/plugin/ui/help/analysis-panel.png +0 -0
- package/plugin/ui/help/graph-toolbar.png +0 -0
- package/plugin/ui/help/graph-view.png +0 -0
- package/plugin/ui/help/settings.png +0 -0
- package/plugin/ui/help/timeline.png +0 -0
- package/plugin/ui/help.js +876 -172
- package/plugin/ui/index.html +506 -242
- package/plugin/ui/settings.js +781 -17
- package/plugin/ui/styles.css +990 -44
- package/plugin/ui/timeline.js +2 -2
- package/plugin/ui/tools.js +826 -0
- package/.claude-plugin/marketplace.json +0 -15
- package/plugin/dist/observations-Ch0nc47i.d.mts.map +0 -1
- package/plugin/dist/tool-registry-CZ3mJ4iR.mjs.map +0 -1
- package/plugin/scripts/setup-tmpdir.sh +0 -65
package/plugin/dist/index.js
CHANGED
|
@@ -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
|
|
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$
|
|
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$
|
|
554
|
+
function textResponse$9(text) {
|
|
440
555
|
return { content: [{
|
|
441
556
|
type: "text",
|
|
442
557
|
text
|
|
443
558
|
}] };
|
|
444
559
|
}
|
|
445
|
-
function errorResponse$
|
|
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
|
|
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
|
|
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$
|
|
490
|
-
if ((args.action === "purge" || args.action === "restore") && !args.ids && !args.id) return errorResponse$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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,
|
|
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$
|
|
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$
|
|
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,
|
|
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
|
|
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$
|
|
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$
|
|
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$
|
|
1354
|
+
function textResponse$7(text) {
|
|
947
1355
|
return { content: [{
|
|
948
1356
|
type: "text",
|
|
949
1357
|
text
|
|
950
1358
|
}] };
|
|
951
1359
|
}
|
|
952
|
-
function errorResponse$
|
|
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,
|
|
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
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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,
|
|
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$
|
|
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$
|
|
1626
|
+
return textResponse$6(`Graph stats error: ${message}`);
|
|
1371
1627
|
}
|
|
1372
1628
|
});
|
|
1373
1629
|
}
|
|
1374
1630
|
|
|
1375
1631
|
//#endregion
|
|
1376
|
-
//#region src/mcp/tools/
|
|
1377
|
-
function
|
|
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$
|
|
1695
|
+
function textResponse$5(text) {
|
|
1384
1696
|
return { content: [{
|
|
1385
1697
|
type: "text",
|
|
1386
1698
|
text
|
|
1387
1699
|
}] };
|
|
1388
1700
|
}
|
|
1389
|
-
function
|
|
1390
|
-
server.registerTool("
|
|
1391
|
-
title: "
|
|
1392
|
-
description: "
|
|
1393
|
-
inputSchema: {
|
|
1394
|
-
|
|
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
|
-
|
|
1397
|
-
|
|
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("
|
|
1401
|
-
return textResponse$
|
|
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
|
|
1408
|
-
function
|
|
1409
|
-
|
|
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
|
-
|
|
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,
|
|
1803
|
+
constructor(db, projectHashRef, projectPath, hasVectorSupport, isWorkerReady) {
|
|
1428
1804
|
this.db = db;
|
|
1429
|
-
this.
|
|
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.
|
|
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$
|
|
1888
|
+
function textResponse$3(text) {
|
|
1513
1889
|
return { content: [{
|
|
1514
1890
|
type: "text",
|
|
1515
1891
|
text
|
|
1516
1892
|
}] };
|
|
1517
1893
|
}
|
|
1518
|
-
function errorResponse$
|
|
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$
|
|
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,
|
|
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
|
|
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$
|
|
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$
|
|
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,
|
|
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$
|
|
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,
|
|
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
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
const
|
|
1726
|
-
|
|
1727
|
-
if (
|
|
1728
|
-
return withNotifications(`Debug path
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
pathTracker.
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
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
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
const
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
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
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
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
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
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
|
-
}
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
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
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3632
|
-
|
|
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
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
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
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
*
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
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:
|
|
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" (
|
|
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
|
|
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
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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,
|
|
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
|
|
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",
|
|
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(
|
|
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,
|
|
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,
|
|
7335
|
+
const statusCache = new StatusCache(db.db, projectHashRef, process.cwd(), db.hasVectorSupport, () => worker.isReady());
|
|
5763
7336
|
const server = createServer();
|
|
5764
|
-
registerSaveMemory(server, db.db,
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
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,
|
|
5772
|
-
registerReportTools(server, toolRegistry,
|
|
7346
|
+
registerDiscoverTools(server, toolRegistry, worker, db.hasVectorSupport, notificationStore, projectHashRef);
|
|
7347
|
+
registerReportTools(server, toolRegistry, projectHashRef);
|
|
5773
7348
|
}
|
|
5774
|
-
const pathRepo = new PathRepository(db.db,
|
|
7349
|
+
const pathRepo = new PathRepository(db.db, projectHashRef.current);
|
|
5775
7350
|
const pathTracker = new PathTracker(pathRepo);
|
|
5776
|
-
registerDebugPathTools(server, pathRepo, pathTracker, notificationStore,
|
|
5777
|
-
|
|
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,
|
|
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,
|