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
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { a as isDebugEnabled } from "./config-t8LZeB-u.mjs";
|
|
1
|
+
import { a as isDebugEnabled, t as getConfigDir } from "./config-t8LZeB-u.mjs";
|
|
2
2
|
import Database from "better-sqlite3";
|
|
3
3
|
import * as sqliteVec from "sqlite-vec";
|
|
4
|
-
import { mkdirSync } from "node:fs";
|
|
5
|
-
import { dirname } from "node:path";
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
6
|
import { randomBytes } from "node:crypto";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
|
|
@@ -81,6 +81,8 @@ function debugTimed(category, message, fn) {
|
|
|
81
81
|
* Migration 018: Tool registry FTS5 + vec0 tables for hybrid search on tool descriptions.
|
|
82
82
|
* Migration 019: Add status column (active/stale/demoted) to tool_registry for staleness management.
|
|
83
83
|
* Migration 020: Debug path tables (debug_paths + path_waypoints) for resolution path tracking.
|
|
84
|
+
* Migration 021: Thought branch tables for coherent work unit tracking.
|
|
85
|
+
* Migration 022: Add trigger_hints column to tool_registry for proactive suggestion matching.
|
|
84
86
|
*/
|
|
85
87
|
const MIGRATIONS = [
|
|
86
88
|
{
|
|
@@ -577,6 +579,60 @@ const MIGRATIONS = [
|
|
|
577
579
|
|
|
578
580
|
CREATE INDEX IF NOT EXISTS idx_path_waypoints_path_order
|
|
579
581
|
ON path_waypoints(path_id, sequence_order);
|
|
582
|
+
`
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
version: 21,
|
|
586
|
+
name: "create_thought_branch_tables",
|
|
587
|
+
up: `
|
|
588
|
+
CREATE TABLE IF NOT EXISTS thought_branches (
|
|
589
|
+
id TEXT PRIMARY KEY,
|
|
590
|
+
project_hash TEXT NOT NULL,
|
|
591
|
+
session_id TEXT,
|
|
592
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
593
|
+
CHECK(status IN ('active', 'completed', 'abandoned', 'merged')),
|
|
594
|
+
branch_type TEXT NOT NULL DEFAULT 'unknown'
|
|
595
|
+
CHECK(branch_type IN ('investigation', 'bug_fix', 'feature', 'refactor', 'research', 'unknown')),
|
|
596
|
+
arc_stage TEXT NOT NULL DEFAULT 'investigation'
|
|
597
|
+
CHECK(arc_stage IN ('investigation', 'diagnosis', 'planning', 'execution', 'verification', 'completed')),
|
|
598
|
+
title TEXT,
|
|
599
|
+
summary TEXT,
|
|
600
|
+
parent_branch_id TEXT REFERENCES thought_branches(id),
|
|
601
|
+
linked_debug_path_id TEXT,
|
|
602
|
+
trigger_source TEXT,
|
|
603
|
+
trigger_observation_id TEXT,
|
|
604
|
+
observation_count INTEGER NOT NULL DEFAULT 0,
|
|
605
|
+
tool_pattern TEXT DEFAULT '{}',
|
|
606
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
607
|
+
ended_at TEXT,
|
|
608
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
CREATE TABLE IF NOT EXISTS branch_observations (
|
|
612
|
+
branch_id TEXT NOT NULL REFERENCES thought_branches(id) ON DELETE CASCADE,
|
|
613
|
+
observation_id TEXT NOT NULL,
|
|
614
|
+
sequence_order INTEGER NOT NULL,
|
|
615
|
+
tool_name TEXT,
|
|
616
|
+
arc_stage_at_add TEXT,
|
|
617
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
618
|
+
PRIMARY KEY (branch_id, observation_id)
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
CREATE INDEX IF NOT EXISTS idx_thought_branches_project_status
|
|
622
|
+
ON thought_branches(project_hash, status);
|
|
623
|
+
CREATE INDEX IF NOT EXISTS idx_thought_branches_session
|
|
624
|
+
ON thought_branches(session_id);
|
|
625
|
+
CREATE INDEX IF NOT EXISTS idx_thought_branches_started
|
|
626
|
+
ON thought_branches(started_at DESC);
|
|
627
|
+
CREATE INDEX IF NOT EXISTS idx_branch_observations_obs
|
|
628
|
+
ON branch_observations(observation_id);
|
|
629
|
+
`
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
version: 22,
|
|
633
|
+
name: "add_tool_registry_trigger_hints",
|
|
634
|
+
up: `
|
|
635
|
+
ALTER TABLE tool_registry ADD COLUMN trigger_hints TEXT;
|
|
580
636
|
`
|
|
581
637
|
}
|
|
582
638
|
];
|
|
@@ -805,11 +861,32 @@ var ObservationRepository = class {
|
|
|
805
861
|
return rowToObservation(row);
|
|
806
862
|
}
|
|
807
863
|
/**
|
|
864
|
+
* Resolves a full or prefix ID to the full 32-char ID.
|
|
865
|
+
* Observation IDs are 32-char hex strings. Search results display only the
|
|
866
|
+
* first 8 chars via shortId(). This method allows callers to pass either
|
|
867
|
+
* a full ID or an 8-char (or any-length) prefix and get the full ID back.
|
|
868
|
+
* Returns null if no unique match is found.
|
|
869
|
+
*/
|
|
870
|
+
resolveId(id) {
|
|
871
|
+
if (id.length === 32) return id;
|
|
872
|
+
const rows = this.db.prepare("SELECT id FROM observations WHERE project_hash = ? AND id LIKE ? ESCAPE '\\' LIMIT 2").all(this.projectHash, id.replace(/[%_\\]/g, "\\$&") + "%");
|
|
873
|
+
if (rows.length === 1) return rows[0].id;
|
|
874
|
+
if (rows.length > 1) debug("obs", "Ambiguous ID prefix - multiple matches", {
|
|
875
|
+
prefix: id,
|
|
876
|
+
count: rows.length
|
|
877
|
+
});
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
808
881
|
* Gets an observation by ID, scoped to this project.
|
|
882
|
+
* Accepts full 32-char IDs or shorter prefix strings (e.g. the 8-char
|
|
883
|
+
* display IDs shown in search results).
|
|
809
884
|
* Returns null if not found or soft-deleted.
|
|
810
885
|
*/
|
|
811
886
|
getById(id) {
|
|
812
|
-
const
|
|
887
|
+
const resolvedId = this.resolveId(id);
|
|
888
|
+
if (!resolvedId) return null;
|
|
889
|
+
const row = this.stmtGetById.get(resolvedId, this.projectHash);
|
|
813
890
|
return row ? rowToObservation(row) : null;
|
|
814
891
|
}
|
|
815
892
|
/**
|
|
@@ -878,20 +955,26 @@ var ObservationRepository = class {
|
|
|
878
955
|
}
|
|
879
956
|
/**
|
|
880
957
|
* Soft-deletes an observation by setting deleted_at.
|
|
958
|
+
* Accepts full 32-char IDs or shorter prefix strings (e.g. the 8-char
|
|
959
|
+
* display IDs shown in search results).
|
|
881
960
|
* Returns true if the observation was found and deleted.
|
|
882
961
|
*/
|
|
883
962
|
softDelete(id) {
|
|
884
963
|
debug("obs", "Soft-deleting observation", { id });
|
|
885
|
-
const
|
|
886
|
-
|
|
964
|
+
const resolvedId = this.resolveId(id) ?? id;
|
|
965
|
+
const result = this.stmtSoftDelete.run(resolvedId, this.projectHash);
|
|
966
|
+
debug("obs", result.changes > 0 ? "Observation soft-deleted" : "Observation not found for delete", { id: resolvedId });
|
|
887
967
|
return result.changes > 0;
|
|
888
968
|
}
|
|
889
969
|
/**
|
|
890
970
|
* Restores a soft-deleted observation by clearing deleted_at.
|
|
971
|
+
* Accepts full 32-char IDs or shorter prefix strings (e.g. the 8-char
|
|
972
|
+
* display IDs shown in search results).
|
|
891
973
|
* Returns true if the observation was found and restored.
|
|
892
974
|
*/
|
|
893
975
|
restore(id) {
|
|
894
|
-
|
|
976
|
+
const resolvedId = this.resolveId(id) ?? id;
|
|
977
|
+
return this.stmtRestore.run(resolvedId, this.projectHash).changes > 0;
|
|
895
978
|
}
|
|
896
979
|
/**
|
|
897
980
|
* Updates the classification of an observation.
|
|
@@ -928,6 +1011,18 @@ var ObservationRepository = class {
|
|
|
928
1011
|
ORDER BY created_at ASC
|
|
929
1012
|
LIMIT ?
|
|
930
1013
|
`).all(this.projectHash, limit).map(rowToObservation);
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Lists unclassified observations across ALL projects.
|
|
1017
|
+
* Used by HaikuProcessor to avoid missing observations from other projects.
|
|
1018
|
+
*/
|
|
1019
|
+
static listAllUnclassified(db, limit = 20) {
|
|
1020
|
+
return db.prepare(`
|
|
1021
|
+
SELECT * FROM observations
|
|
1022
|
+
WHERE classification IS NULL AND deleted_at IS NULL
|
|
1023
|
+
ORDER BY created_at ASC
|
|
1024
|
+
LIMIT ?
|
|
1025
|
+
`).all(limit).map(rowToObservation);
|
|
931
1026
|
}
|
|
932
1027
|
/**
|
|
933
1028
|
* Fetches observations surrounding a given timestamp for classification context.
|
|
@@ -962,11 +1057,28 @@ var ObservationRepository = class {
|
|
|
962
1057
|
}
|
|
963
1058
|
/**
|
|
964
1059
|
* Gets an observation by ID, including soft-deleted observations.
|
|
1060
|
+
* Accepts full 32-char IDs or shorter prefix strings (e.g. the 8-char
|
|
1061
|
+
* display IDs shown in search results).
|
|
965
1062
|
* Used by the recall tool for restore operations (must find purged items).
|
|
966
1063
|
*/
|
|
967
1064
|
getByIdIncludingDeleted(id) {
|
|
968
1065
|
debug("obs", "Getting observation including deleted", { id });
|
|
969
|
-
const
|
|
1066
|
+
const FULL_ID_LENGTH = 32;
|
|
1067
|
+
let resolvedId;
|
|
1068
|
+
if (id.length === FULL_ID_LENGTH) resolvedId = id;
|
|
1069
|
+
else {
|
|
1070
|
+
const rows = this.db.prepare("SELECT id FROM observations WHERE project_hash = ? AND id LIKE ? ESCAPE '\\' LIMIT 2").all(this.projectHash, id.replace(/[%_\\]/g, "\\$&") + "%");
|
|
1071
|
+
if (rows.length === 0) return null;
|
|
1072
|
+
if (rows.length > 1) {
|
|
1073
|
+
debug("obs", "Ambiguous ID prefix for getByIdIncludingDeleted", {
|
|
1074
|
+
prefix: id,
|
|
1075
|
+
count: rows.length
|
|
1076
|
+
});
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
resolvedId = rows[0].id;
|
|
1080
|
+
}
|
|
1081
|
+
const row = this.stmtGetByIdIncludingDeleted.get(resolvedId, this.projectHash);
|
|
970
1082
|
return row ? rowToObservation(row) : null;
|
|
971
1083
|
}
|
|
972
1084
|
/**
|
|
@@ -1745,6 +1857,570 @@ function insertEdge(db, edge) {
|
|
|
1745
1857
|
return rowToEdge(db.prepare("SELECT * FROM graph_edges WHERE source_id = ? AND target_id = ? AND type = ?").get(edge.source_id, edge.target_id, edge.type));
|
|
1746
1858
|
}
|
|
1747
1859
|
|
|
1860
|
+
//#endregion
|
|
1861
|
+
//#region src/graph/staleness.ts
|
|
1862
|
+
/**
|
|
1863
|
+
* Negation patterns: newer observation negates older one.
|
|
1864
|
+
* Matches when newer text contains negation keywords absent in older text
|
|
1865
|
+
* and both discuss similar subjects.
|
|
1866
|
+
*/
|
|
1867
|
+
const NEGATION_KEYWORDS = [
|
|
1868
|
+
"not",
|
|
1869
|
+
"don't",
|
|
1870
|
+
"no longer",
|
|
1871
|
+
"stopped",
|
|
1872
|
+
"never",
|
|
1873
|
+
"doesn't",
|
|
1874
|
+
"won't",
|
|
1875
|
+
"isn't",
|
|
1876
|
+
"aren't",
|
|
1877
|
+
"discontinued"
|
|
1878
|
+
];
|
|
1879
|
+
/**
|
|
1880
|
+
* Replacement patterns: newer observation explicitly replaces older approach.
|
|
1881
|
+
*/
|
|
1882
|
+
const REPLACEMENT_PATTERNS = [
|
|
1883
|
+
/switched\s+(?:from\s+\S+\s+)?to\b/i,
|
|
1884
|
+
/migrated\s+(?:from\s+\S+\s+)?to\b/i,
|
|
1885
|
+
/replaced\s+(?:\S+\s+)?with\b/i,
|
|
1886
|
+
/changed\s+from\b/i,
|
|
1887
|
+
/moved\s+(?:from\s+\S+\s+)?to\b/i,
|
|
1888
|
+
/upgraded\s+(?:from\s+\S+\s+)?to\b/i,
|
|
1889
|
+
/swapped\s+(?:\S+\s+)?(?:for|with)\b/i
|
|
1890
|
+
];
|
|
1891
|
+
/**
|
|
1892
|
+
* Status change patterns: newer observation marks something as inactive.
|
|
1893
|
+
*/
|
|
1894
|
+
const STATUS_CHANGE_KEYWORDS = [
|
|
1895
|
+
"removed",
|
|
1896
|
+
"deleted",
|
|
1897
|
+
"deprecated",
|
|
1898
|
+
"archived",
|
|
1899
|
+
"dropped",
|
|
1900
|
+
"disabled",
|
|
1901
|
+
"decommissioned",
|
|
1902
|
+
"sunset",
|
|
1903
|
+
"abandoned"
|
|
1904
|
+
];
|
|
1905
|
+
/**
|
|
1906
|
+
* Creates the staleness_flags table if it doesn't exist.
|
|
1907
|
+
* Uses a separate table rather than modifying the observations table,
|
|
1908
|
+
* keeping staleness metadata decoupled from core observation storage.
|
|
1909
|
+
*/
|
|
1910
|
+
function initStalenessSchema(db) {
|
|
1911
|
+
db.exec(`
|
|
1912
|
+
CREATE TABLE IF NOT EXISTS staleness_flags (
|
|
1913
|
+
observation_id TEXT PRIMARY KEY,
|
|
1914
|
+
flagged_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1915
|
+
reason TEXT NOT NULL,
|
|
1916
|
+
resolved INTEGER NOT NULL DEFAULT 0
|
|
1917
|
+
);
|
|
1918
|
+
CREATE INDEX IF NOT EXISTS idx_staleness_resolved ON staleness_flags(resolved);
|
|
1919
|
+
`);
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Detects potential staleness (contradictions) between observations
|
|
1923
|
+
* linked to a specific entity.
|
|
1924
|
+
*
|
|
1925
|
+
* Compares consecutive observation pairs chronologically and checks for:
|
|
1926
|
+
* 1. Negation patterns (newer negates older)
|
|
1927
|
+
* 2. Replacement patterns (newer replaces older approach)
|
|
1928
|
+
* 3. Status change patterns (newer marks something as inactive)
|
|
1929
|
+
*
|
|
1930
|
+
* This is DETECTION ONLY -- no data is modified.
|
|
1931
|
+
*
|
|
1932
|
+
* @param db - better-sqlite3 Database handle
|
|
1933
|
+
* @param entityId - Graph node ID to check observations for
|
|
1934
|
+
* @returns Array of StalenessReport for each detected contradiction
|
|
1935
|
+
*/
|
|
1936
|
+
function detectStaleness(db, entityId) {
|
|
1937
|
+
const node = db.prepare("SELECT id, name, type, observation_ids FROM graph_nodes WHERE id = ?").get(entityId);
|
|
1938
|
+
if (!node) return [];
|
|
1939
|
+
const obsIds = JSON.parse(node.observation_ids);
|
|
1940
|
+
if (obsIds.length < 2) return [];
|
|
1941
|
+
const placeholders = obsIds.map(() => "?").join(", ");
|
|
1942
|
+
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);
|
|
1943
|
+
if (observations.length < 2) return [];
|
|
1944
|
+
const reports = [];
|
|
1945
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1946
|
+
for (let i = 0; i < observations.length - 1; i++) {
|
|
1947
|
+
const older = observations[i];
|
|
1948
|
+
const newer = observations[i + 1];
|
|
1949
|
+
const reason = detectContradiction(older.content, newer.content);
|
|
1950
|
+
if (reason) reports.push({
|
|
1951
|
+
entityId: node.id,
|
|
1952
|
+
entityName: node.name,
|
|
1953
|
+
entityType: node.type,
|
|
1954
|
+
newerObservation: {
|
|
1955
|
+
id: newer.id,
|
|
1956
|
+
text: newer.content,
|
|
1957
|
+
created_at: newer.createdAt
|
|
1958
|
+
},
|
|
1959
|
+
olderObservation: {
|
|
1960
|
+
id: older.id,
|
|
1961
|
+
text: older.content,
|
|
1962
|
+
created_at: older.createdAt
|
|
1963
|
+
},
|
|
1964
|
+
reason,
|
|
1965
|
+
detectedAt: now
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
return reports;
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* Detects contradiction between two observation texts.
|
|
1972
|
+
* Returns a human-readable reason string, or null if no contradiction found.
|
|
1973
|
+
*/
|
|
1974
|
+
function detectContradiction(olderText, newerText) {
|
|
1975
|
+
const olderLower = olderText.toLowerCase();
|
|
1976
|
+
const newerLower = newerText.toLowerCase();
|
|
1977
|
+
const negationResult = detectNegation(olderLower, newerLower);
|
|
1978
|
+
if (negationResult) return negationResult;
|
|
1979
|
+
const replacementResult = detectReplacement(newerLower);
|
|
1980
|
+
if (replacementResult) return replacementResult;
|
|
1981
|
+
const statusResult = detectStatusChange(olderLower, newerLower);
|
|
1982
|
+
if (statusResult) return statusResult;
|
|
1983
|
+
return null;
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Detects negation: newer text contains negation keywords that are absent
|
|
1987
|
+
* in the older text, suggesting the newer observation contradicts the older.
|
|
1988
|
+
*/
|
|
1989
|
+
function detectNegation(olderLower, newerLower) {
|
|
1990
|
+
for (const keyword of NEGATION_KEYWORDS) if (newerLower.includes(keyword) && !olderLower.includes(keyword)) return `Newer observation contains negation ("${keyword}") not present in older observation`;
|
|
1991
|
+
return null;
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Detects replacement: newer text explicitly mentions switching/replacing.
|
|
1995
|
+
*/
|
|
1996
|
+
function detectReplacement(newerLower) {
|
|
1997
|
+
for (const pattern of REPLACEMENT_PATTERNS) {
|
|
1998
|
+
const match = newerLower.match(pattern);
|
|
1999
|
+
if (match) return `Newer observation indicates replacement ("${match[0].trim()}")`;
|
|
2000
|
+
}
|
|
2001
|
+
return null;
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
2004
|
+
* Detects status change: newer text marks something as removed/deprecated
|
|
2005
|
+
* when the older text described it as active/present.
|
|
2006
|
+
*/
|
|
2007
|
+
function detectStatusChange(olderLower, newerLower) {
|
|
2008
|
+
for (const keyword of STATUS_CHANGE_KEYWORDS) if (newerLower.includes(keyword) && !olderLower.includes(keyword)) return `Newer observation indicates status change ("${keyword}")`;
|
|
2009
|
+
return null;
|
|
2010
|
+
}
|
|
2011
|
+
/**
|
|
2012
|
+
* Flags an observation as stale with an advisory reason.
|
|
2013
|
+
*
|
|
2014
|
+
* This flag is advisory -- search can use it to deprioritize but never hide
|
|
2015
|
+
* the observation. The observation remains fully queryable.
|
|
2016
|
+
*
|
|
2017
|
+
* Uses INSERT OR REPLACE to allow re-flagging with an updated reason.
|
|
2018
|
+
*
|
|
2019
|
+
* @param db - better-sqlite3 Database handle
|
|
2020
|
+
* @param observationId - ID of the observation to flag
|
|
2021
|
+
* @param reason - Human-readable explanation of why it's stale
|
|
2022
|
+
*/
|
|
2023
|
+
function flagStaleObservation(db, observationId, reason) {
|
|
2024
|
+
initStalenessSchema(db);
|
|
2025
|
+
db.prepare(`INSERT OR REPLACE INTO staleness_flags (observation_id, reason, resolved)
|
|
2026
|
+
VALUES (?, ?, 0)`).run(observationId, reason);
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
//#endregion
|
|
2030
|
+
//#region src/config/hygiene-config.ts
|
|
2031
|
+
/**
|
|
2032
|
+
* Database Hygiene Configuration
|
|
2033
|
+
*
|
|
2034
|
+
* Controls signal weights and tier thresholds used by the hygiene
|
|
2035
|
+
* analyzer to score observations for deletion candidacy.
|
|
2036
|
+
*
|
|
2037
|
+
* Configuration is loaded from .laminark/hygiene.json with
|
|
2038
|
+
* a 5-second cache to avoid repeated disk reads.
|
|
2039
|
+
*/
|
|
2040
|
+
const DEFAULT_AUTO_CLEANUP = {
|
|
2041
|
+
enabled: true,
|
|
2042
|
+
tier: "high",
|
|
2043
|
+
maxOrphanNodes: 500
|
|
2044
|
+
};
|
|
2045
|
+
const DEFAULTS = {
|
|
2046
|
+
signalWeights: {
|
|
2047
|
+
orphaned: .3,
|
|
2048
|
+
islandNode: .25,
|
|
2049
|
+
noiseClassified: .25,
|
|
2050
|
+
shortContent: .1,
|
|
2051
|
+
autoCaptured: .1,
|
|
2052
|
+
stale: .1
|
|
2053
|
+
},
|
|
2054
|
+
tierThresholds: {
|
|
2055
|
+
high: .7,
|
|
2056
|
+
medium: .5
|
|
2057
|
+
},
|
|
2058
|
+
shortContentThreshold: 50,
|
|
2059
|
+
autoCleanup: { ...DEFAULT_AUTO_CLEANUP }
|
|
2060
|
+
};
|
|
2061
|
+
const CACHE_TTL_MS = 5e3;
|
|
2062
|
+
let cachedConfig = null;
|
|
2063
|
+
let cachedAt = 0;
|
|
2064
|
+
function clamp(value, min, max) {
|
|
2065
|
+
return Math.max(min, Math.min(max, value));
|
|
2066
|
+
}
|
|
2067
|
+
function validate(raw) {
|
|
2068
|
+
const config = { ...DEFAULTS };
|
|
2069
|
+
if (raw.signalWeights && typeof raw.signalWeights === "object" && !Array.isArray(raw.signalWeights)) {
|
|
2070
|
+
const sw = raw.signalWeights;
|
|
2071
|
+
const weights = { ...DEFAULTS.signalWeights };
|
|
2072
|
+
for (const key of Object.keys(DEFAULTS.signalWeights)) if (typeof sw[key] === "number") weights[key] = clamp(sw[key], 0, 1);
|
|
2073
|
+
config.signalWeights = weights;
|
|
2074
|
+
}
|
|
2075
|
+
if (raw.tierThresholds && typeof raw.tierThresholds === "object" && !Array.isArray(raw.tierThresholds)) {
|
|
2076
|
+
const tt = raw.tierThresholds;
|
|
2077
|
+
let high = typeof tt.high === "number" ? clamp(tt.high, 0, 1) : DEFAULTS.tierThresholds.high;
|
|
2078
|
+
let medium = typeof tt.medium === "number" ? clamp(tt.medium, 0, 1) : DEFAULTS.tierThresholds.medium;
|
|
2079
|
+
if (medium >= high) medium = Math.max(0, high - .1);
|
|
2080
|
+
config.tierThresholds = {
|
|
2081
|
+
high,
|
|
2082
|
+
medium
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
if (typeof raw.shortContentThreshold === "number") config.shortContentThreshold = Math.max(0, Math.round(raw.shortContentThreshold));
|
|
2086
|
+
if (raw.autoCleanup && typeof raw.autoCleanup === "object" && !Array.isArray(raw.autoCleanup)) {
|
|
2087
|
+
const ac = raw.autoCleanup;
|
|
2088
|
+
const cleanup = { ...DEFAULT_AUTO_CLEANUP };
|
|
2089
|
+
if (typeof ac.enabled === "boolean") cleanup.enabled = ac.enabled;
|
|
2090
|
+
if (ac.tier === "high" || ac.tier === "medium" || ac.tier === "all") cleanup.tier = ac.tier;
|
|
2091
|
+
if (typeof ac.maxOrphanNodes === "number") cleanup.maxOrphanNodes = Math.max(0, Math.round(ac.maxOrphanNodes));
|
|
2092
|
+
config.autoCleanup = cleanup;
|
|
2093
|
+
}
|
|
2094
|
+
return config;
|
|
2095
|
+
}
|
|
2096
|
+
/**
|
|
2097
|
+
* Loads hygiene configuration from disk with a 5-second cache.
|
|
2098
|
+
*/
|
|
2099
|
+
function loadHygieneConfig() {
|
|
2100
|
+
const now = Date.now();
|
|
2101
|
+
if (cachedConfig && now - cachedAt < CACHE_TTL_MS) return cachedConfig;
|
|
2102
|
+
const configPath = join(getConfigDir(), "hygiene.json");
|
|
2103
|
+
try {
|
|
2104
|
+
const content = readFileSync(configPath, "utf-8");
|
|
2105
|
+
cachedConfig = validate(JSON.parse(content));
|
|
2106
|
+
debug("config", "Loaded hygiene config", { ...cachedConfig });
|
|
2107
|
+
} catch {
|
|
2108
|
+
cachedConfig = {
|
|
2109
|
+
...DEFAULTS,
|
|
2110
|
+
signalWeights: { ...DEFAULTS.signalWeights },
|
|
2111
|
+
tierThresholds: { ...DEFAULTS.tierThresholds }
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
cachedAt = now;
|
|
2115
|
+
return cachedConfig;
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Saves hygiene configuration to disk and invalidates cache.
|
|
2119
|
+
*/
|
|
2120
|
+
function saveHygieneConfig(config) {
|
|
2121
|
+
writeFileSync(join(getConfigDir(), "hygiene.json"), JSON.stringify(config, null, 2), "utf-8");
|
|
2122
|
+
cachedConfig = config;
|
|
2123
|
+
cachedAt = Date.now();
|
|
2124
|
+
}
|
|
2125
|
+
/**
|
|
2126
|
+
* Resets hygiene config to defaults by invalidating cache.
|
|
2127
|
+
*/
|
|
2128
|
+
function resetHygieneConfig() {
|
|
2129
|
+
cachedConfig = null;
|
|
2130
|
+
cachedAt = 0;
|
|
2131
|
+
return {
|
|
2132
|
+
...DEFAULTS,
|
|
2133
|
+
signalWeights: { ...DEFAULTS.signalWeights },
|
|
2134
|
+
tierThresholds: { ...DEFAULTS.tierThresholds },
|
|
2135
|
+
autoCleanup: { ...DEFAULT_AUTO_CLEANUP }
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
//#endregion
|
|
2140
|
+
//#region src/graph/hygiene-analyzer.ts
|
|
2141
|
+
function buildSignalLookups(db) {
|
|
2142
|
+
const linkedObsIds = /* @__PURE__ */ new Set();
|
|
2143
|
+
const islandObsIds = /* @__PURE__ */ new Set();
|
|
2144
|
+
const allNodes = db.prepare("SELECT id, type, name, observation_ids FROM graph_nodes").all();
|
|
2145
|
+
const edgeCounts = /* @__PURE__ */ new Map();
|
|
2146
|
+
const edgeRows = db.prepare(`SELECT source_id AS nid, COUNT(*) AS cnt FROM graph_edges GROUP BY source_id
|
|
2147
|
+
UNION ALL
|
|
2148
|
+
SELECT target_id AS nid, COUNT(*) AS cnt FROM graph_edges GROUP BY target_id`).all();
|
|
2149
|
+
for (const row of edgeRows) edgeCounts.set(row.nid, (edgeCounts.get(row.nid) ?? 0) + row.cnt);
|
|
2150
|
+
for (const node of allNodes) {
|
|
2151
|
+
let obsIds;
|
|
2152
|
+
try {
|
|
2153
|
+
obsIds = JSON.parse(node.observation_ids);
|
|
2154
|
+
} catch {
|
|
2155
|
+
continue;
|
|
2156
|
+
}
|
|
2157
|
+
const degree = edgeCounts.get(node.id) ?? 0;
|
|
2158
|
+
for (const oid of obsIds) {
|
|
2159
|
+
linkedObsIds.add(oid);
|
|
2160
|
+
if (degree === 0) islandObsIds.add(oid);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
const staleIds = /* @__PURE__ */ new Set();
|
|
2164
|
+
try {
|
|
2165
|
+
initStalenessSchema(db);
|
|
2166
|
+
const staleRows = db.prepare("SELECT observation_id FROM staleness_flags WHERE resolved = 0").all();
|
|
2167
|
+
for (const row of staleRows) staleIds.add(row.observation_id);
|
|
2168
|
+
} catch {}
|
|
2169
|
+
return {
|
|
2170
|
+
linkedObsIds,
|
|
2171
|
+
islandObsIds,
|
|
2172
|
+
staleIds,
|
|
2173
|
+
allNodes,
|
|
2174
|
+
edgeCounts
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
function scoreObservation(obs, lookups, config) {
|
|
2178
|
+
const weights = config.signalWeights;
|
|
2179
|
+
const thresholds = config.tierThresholds;
|
|
2180
|
+
const signals = {
|
|
2181
|
+
orphaned: !lookups.linkedObsIds.has(obs.id),
|
|
2182
|
+
islandNode: lookups.islandObsIds.has(obs.id),
|
|
2183
|
+
noiseClassified: obs.classification === "noise",
|
|
2184
|
+
shortContent: obs.content.length < config.shortContentThreshold,
|
|
2185
|
+
autoCaptured: obs.source.startsWith("hook:"),
|
|
2186
|
+
stale: lookups.staleIds.has(obs.id)
|
|
2187
|
+
};
|
|
2188
|
+
const confidence = (signals.orphaned ? weights.orphaned : 0) + (signals.islandNode ? weights.islandNode : 0) + (signals.noiseClassified ? weights.noiseClassified : 0) + (signals.shortContent ? weights.shortContent : 0) + (signals.autoCaptured ? weights.autoCaptured : 0) + (signals.stale ? weights.stale : 0);
|
|
2189
|
+
const tier = confidence >= thresholds.high ? "high" : confidence >= thresholds.medium ? "medium" : "low";
|
|
2190
|
+
return {
|
|
2191
|
+
signals,
|
|
2192
|
+
confidence: Math.round(confidence * 100) / 100,
|
|
2193
|
+
tier
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
/**
|
|
2197
|
+
* Analyzes all active observations and scores each on deletion signals.
|
|
2198
|
+
* Pure read-only — no data is modified.
|
|
2199
|
+
*/
|
|
2200
|
+
function analyzeObservations(db, projectHash, opts) {
|
|
2201
|
+
const limit = opts?.limit ?? 50;
|
|
2202
|
+
const minTier = opts?.minTier ?? "medium";
|
|
2203
|
+
const config = opts?.config ?? loadHygieneConfig();
|
|
2204
|
+
debug("hygiene", "Starting analysis", {
|
|
2205
|
+
projectHash,
|
|
2206
|
+
sessionId: opts?.sessionId
|
|
2207
|
+
});
|
|
2208
|
+
let obsSql = `
|
|
2209
|
+
SELECT id, content, title, source, kind, session_id, classification, created_at
|
|
2210
|
+
FROM observations
|
|
2211
|
+
WHERE project_hash = ? AND deleted_at IS NULL
|
|
2212
|
+
`;
|
|
2213
|
+
const obsParams = [projectHash];
|
|
2214
|
+
if (opts?.sessionId) {
|
|
2215
|
+
obsSql += " AND session_id = ?";
|
|
2216
|
+
obsParams.push(opts.sessionId);
|
|
2217
|
+
}
|
|
2218
|
+
obsSql += " ORDER BY created_at DESC";
|
|
2219
|
+
const observations = db.prepare(obsSql).all(...obsParams);
|
|
2220
|
+
const lookups = buildSignalLookups(db);
|
|
2221
|
+
const allCandidates = [];
|
|
2222
|
+
for (const obs of observations) {
|
|
2223
|
+
const { signals, confidence, tier } = scoreObservation(obs, lookups, config);
|
|
2224
|
+
if (minTier === "high" && tier !== "high") continue;
|
|
2225
|
+
if (minTier === "medium" && tier === "low") continue;
|
|
2226
|
+
const preview = obs.content.length > 80 ? obs.content.substring(0, 80) + "..." : obs.content;
|
|
2227
|
+
allCandidates.push({
|
|
2228
|
+
id: obs.id,
|
|
2229
|
+
shortId: obs.id.substring(0, 8),
|
|
2230
|
+
sessionId: obs.session_id,
|
|
2231
|
+
kind: obs.kind,
|
|
2232
|
+
source: obs.source,
|
|
2233
|
+
contentPreview: preview,
|
|
2234
|
+
createdAt: obs.created_at,
|
|
2235
|
+
signals,
|
|
2236
|
+
confidence,
|
|
2237
|
+
tier
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
allCandidates.sort((a, b) => b.confidence - a.confidence);
|
|
2241
|
+
const activeObsIds = new Set(observations.map((o) => o.id));
|
|
2242
|
+
const orphanNodes = [];
|
|
2243
|
+
for (const node of lookups.allNodes) {
|
|
2244
|
+
if ((lookups.edgeCounts.get(node.id) ?? 0) > 0) continue;
|
|
2245
|
+
let obsIds;
|
|
2246
|
+
try {
|
|
2247
|
+
obsIds = JSON.parse(node.observation_ids);
|
|
2248
|
+
} catch {
|
|
2249
|
+
continue;
|
|
2250
|
+
}
|
|
2251
|
+
const allDead = obsIds.length === 0 || obsIds.every((oid) => !activeObsIds.has(oid));
|
|
2252
|
+
orphanNodes.push({
|
|
2253
|
+
id: node.id,
|
|
2254
|
+
type: node.type,
|
|
2255
|
+
name: node.name,
|
|
2256
|
+
reason: allDead ? "zero edges, dead observation refs" : "zero edges (island node)"
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
const limited = allCandidates.slice(0, limit);
|
|
2260
|
+
const highCount = allCandidates.filter((c) => c.tier === "high").length;
|
|
2261
|
+
const mediumCount = allCandidates.filter((c) => c.tier === "medium").length;
|
|
2262
|
+
const lowCount = allCandidates.filter((c) => c.tier === "low").length;
|
|
2263
|
+
debug("hygiene", "Analysis complete", {
|
|
2264
|
+
total: observations.length,
|
|
2265
|
+
high: highCount,
|
|
2266
|
+
medium: mediumCount,
|
|
2267
|
+
orphanNodes: orphanNodes.length
|
|
2268
|
+
});
|
|
2269
|
+
return {
|
|
2270
|
+
analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2271
|
+
totalObservations: observations.length,
|
|
2272
|
+
candidates: limited,
|
|
2273
|
+
orphanNodes: orphanNodes.slice(0, limit),
|
|
2274
|
+
summary: {
|
|
2275
|
+
high: highCount,
|
|
2276
|
+
medium: mediumCount,
|
|
2277
|
+
low: lowCount,
|
|
2278
|
+
orphanNodeCount: orphanNodes.length
|
|
2279
|
+
}
|
|
2280
|
+
};
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Produces a score distribution report across all observations.
|
|
2284
|
+
* Shows signal counts, confidence histogram, and island node summary
|
|
2285
|
+
* so users can tune thresholds to catch the right candidates.
|
|
2286
|
+
*/
|
|
2287
|
+
function findAnalysis(db, projectHash, config) {
|
|
2288
|
+
const cfg = config ?? loadHygieneConfig();
|
|
2289
|
+
const observations = db.prepare(`
|
|
2290
|
+
SELECT id, content, title, source, kind, session_id, classification, created_at
|
|
2291
|
+
FROM observations
|
|
2292
|
+
WHERE project_hash = ? AND deleted_at IS NULL
|
|
2293
|
+
ORDER BY created_at DESC
|
|
2294
|
+
`).all(projectHash);
|
|
2295
|
+
const lookups = buildSignalLookups(db);
|
|
2296
|
+
const bySignal = {
|
|
2297
|
+
orphaned: 0,
|
|
2298
|
+
islandNode: 0,
|
|
2299
|
+
noiseClassified: 0,
|
|
2300
|
+
shortContent: 0,
|
|
2301
|
+
autoCaptured: 0,
|
|
2302
|
+
stale: 0
|
|
2303
|
+
};
|
|
2304
|
+
const buckets = new Array(10).fill(0);
|
|
2305
|
+
const islandConfidences = [];
|
|
2306
|
+
for (const obs of observations) {
|
|
2307
|
+
const { signals, confidence } = scoreObservation(obs, lookups, cfg);
|
|
2308
|
+
if (signals.orphaned) bySignal.orphaned++;
|
|
2309
|
+
if (signals.islandNode) bySignal.islandNode++;
|
|
2310
|
+
if (signals.noiseClassified) bySignal.noiseClassified++;
|
|
2311
|
+
if (signals.shortContent) bySignal.shortContent++;
|
|
2312
|
+
if (signals.autoCaptured) bySignal.autoCaptured++;
|
|
2313
|
+
if (signals.stale) bySignal.stale++;
|
|
2314
|
+
const bucketIdx = Math.min(Math.floor(confidence * 10), 9);
|
|
2315
|
+
buckets[bucketIdx]++;
|
|
2316
|
+
if (signals.islandNode) islandConfidences.push(confidence);
|
|
2317
|
+
}
|
|
2318
|
+
const distribution = buckets.map((count, i) => ({
|
|
2319
|
+
range: `${(i / 10).toFixed(1)}-${((i + 1) / 10).toFixed(1)}`,
|
|
2320
|
+
count
|
|
2321
|
+
}));
|
|
2322
|
+
islandConfidences.sort((a, b) => a - b);
|
|
2323
|
+
const islandTotal = islandConfidences.length;
|
|
2324
|
+
const minConf = islandTotal > 0 ? islandConfidences[0] : 0;
|
|
2325
|
+
const maxConf = islandTotal > 0 ? islandConfidences[islandTotal - 1] : 0;
|
|
2326
|
+
const medianConf = islandTotal > 0 ? islandConfidences[Math.floor(islandTotal / 2)] : 0;
|
|
2327
|
+
const capturedHigh = islandConfidences.filter((c) => c >= cfg.tierThresholds.high).length;
|
|
2328
|
+
const capturedMedium = islandConfidences.filter((c) => c >= cfg.tierThresholds.medium).length;
|
|
2329
|
+
return {
|
|
2330
|
+
total: observations.length,
|
|
2331
|
+
bySignal,
|
|
2332
|
+
distribution,
|
|
2333
|
+
islandNodes: {
|
|
2334
|
+
total: islandTotal,
|
|
2335
|
+
minConfidence: Math.round(minConf * 100) / 100,
|
|
2336
|
+
maxConfidence: Math.round(maxConf * 100) / 100,
|
|
2337
|
+
medianConfidence: Math.round(medianConf * 100) / 100,
|
|
2338
|
+
capturedAtCurrentThresholds: {
|
|
2339
|
+
high: capturedHigh,
|
|
2340
|
+
medium: capturedMedium,
|
|
2341
|
+
all: islandTotal
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
/**
|
|
2347
|
+
* Runs automatic hygiene cleanup at session end.
|
|
2348
|
+
*
|
|
2349
|
+
* Analyzes observations and purges candidates matching the configured tier.
|
|
2350
|
+
* Orphan graph node removal is capped by autoCleanup.maxOrphanNodes.
|
|
2351
|
+
* Safe to call on every session end — skips quickly if disabled.
|
|
2352
|
+
*/
|
|
2353
|
+
function runAutoCleanup(db, projectHash, config) {
|
|
2354
|
+
const cfg = config ?? loadHygieneConfig();
|
|
2355
|
+
const auto = cfg.autoCleanup;
|
|
2356
|
+
if (!auto.enabled) return {
|
|
2357
|
+
skipped: true,
|
|
2358
|
+
reason: "disabled",
|
|
2359
|
+
observationsPurged: 0,
|
|
2360
|
+
orphanNodesRemoved: 0
|
|
2361
|
+
};
|
|
2362
|
+
debug("hygiene", "Auto-cleanup starting", {
|
|
2363
|
+
tier: auto.tier,
|
|
2364
|
+
maxOrphanNodes: auto.maxOrphanNodes
|
|
2365
|
+
});
|
|
2366
|
+
const report = analyzeObservations(db, projectHash, {
|
|
2367
|
+
limit: 200,
|
|
2368
|
+
minTier: auto.tier === "all" ? "low" : auto.tier,
|
|
2369
|
+
config: cfg
|
|
2370
|
+
});
|
|
2371
|
+
if (report.orphanNodes.length > auto.maxOrphanNodes) report.orphanNodes = report.orphanNodes.slice(0, auto.maxOrphanNodes);
|
|
2372
|
+
if (report.candidates.length + report.orphanNodes.length === 0) {
|
|
2373
|
+
debug("hygiene", "Auto-cleanup: nothing to clean");
|
|
2374
|
+
return {
|
|
2375
|
+
skipped: false,
|
|
2376
|
+
observationsPurged: 0,
|
|
2377
|
+
orphanNodesRemoved: 0
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
const result = executePurge(db, projectHash, report, auto.tier);
|
|
2381
|
+
debug("hygiene", "Auto-cleanup complete", {
|
|
2382
|
+
observationsPurged: result.observationsPurged,
|
|
2383
|
+
orphanNodesRemoved: result.orphanNodesRemoved
|
|
2384
|
+
});
|
|
2385
|
+
return {
|
|
2386
|
+
skipped: false,
|
|
2387
|
+
...result
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
function executePurge(db, projectHash, report, tier) {
|
|
2391
|
+
const candidateIds = report.candidates.filter((c) => {
|
|
2392
|
+
if (tier === "high") return c.tier === "high";
|
|
2393
|
+
if (tier === "medium") return c.tier === "high" || c.tier === "medium";
|
|
2394
|
+
return true;
|
|
2395
|
+
}).map((c) => c.id);
|
|
2396
|
+
debug("hygiene", "Executing purge", {
|
|
2397
|
+
tier,
|
|
2398
|
+
candidates: candidateIds.length
|
|
2399
|
+
});
|
|
2400
|
+
let observationsPurged = 0;
|
|
2401
|
+
const softDeleteStmt = db.prepare(`
|
|
2402
|
+
UPDATE observations
|
|
2403
|
+
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
|
2404
|
+
WHERE id = ? AND project_hash = ? AND deleted_at IS NULL
|
|
2405
|
+
`);
|
|
2406
|
+
return db.transaction(() => {
|
|
2407
|
+
for (const id of candidateIds) {
|
|
2408
|
+
const result = softDeleteStmt.run(id, projectHash);
|
|
2409
|
+
observationsPurged += result.changes;
|
|
2410
|
+
}
|
|
2411
|
+
let orphanNodesRemoved = 0;
|
|
2412
|
+
const deleteNodeStmt = db.prepare("DELETE FROM graph_nodes WHERE id = ?");
|
|
2413
|
+
for (const node of report.orphanNodes) {
|
|
2414
|
+
const result = deleteNodeStmt.run(node.id);
|
|
2415
|
+
orphanNodesRemoved += result.changes;
|
|
2416
|
+
}
|
|
2417
|
+
return {
|
|
2418
|
+
observationsPurged,
|
|
2419
|
+
orphanNodesRemoved
|
|
2420
|
+
};
|
|
2421
|
+
})();
|
|
2422
|
+
}
|
|
2423
|
+
|
|
1748
2424
|
//#endregion
|
|
1749
2425
|
//#region src/hooks/tool-name-parser.ts
|
|
1750
2426
|
/**
|
|
@@ -1790,6 +2466,248 @@ function extractServerName(toolName) {
|
|
|
1790
2466
|
return null;
|
|
1791
2467
|
}
|
|
1792
2468
|
|
|
2469
|
+
//#endregion
|
|
2470
|
+
//#region src/branches/branch-repository.ts
|
|
2471
|
+
var BranchRepository = class {
|
|
2472
|
+
db;
|
|
2473
|
+
projectHash;
|
|
2474
|
+
stmtCreate;
|
|
2475
|
+
stmtComplete;
|
|
2476
|
+
stmtAbandon;
|
|
2477
|
+
stmtGetActive;
|
|
2478
|
+
stmtGetById;
|
|
2479
|
+
stmtList;
|
|
2480
|
+
stmtListByStatus;
|
|
2481
|
+
stmtListByType;
|
|
2482
|
+
stmtUpdateArcStage;
|
|
2483
|
+
stmtUpdateToolPattern;
|
|
2484
|
+
stmtUpdateClassification;
|
|
2485
|
+
stmtUpdateSummary;
|
|
2486
|
+
stmtIncrementObsCount;
|
|
2487
|
+
stmtLinkDebugPath;
|
|
2488
|
+
stmtAddObservation;
|
|
2489
|
+
stmtGetObservations;
|
|
2490
|
+
stmtMaxSequence;
|
|
2491
|
+
stmtFindStale;
|
|
2492
|
+
stmtFindUnclassified;
|
|
2493
|
+
stmtFindRecentCompleted;
|
|
2494
|
+
stmtFindRecentActive;
|
|
2495
|
+
stmtListRecent;
|
|
2496
|
+
constructor(db, projectHash) {
|
|
2497
|
+
this.db = db;
|
|
2498
|
+
this.projectHash = projectHash;
|
|
2499
|
+
this.stmtCreate = db.prepare(`
|
|
2500
|
+
INSERT INTO thought_branches
|
|
2501
|
+
(id, project_hash, session_id, status, trigger_source, trigger_observation_id, started_at)
|
|
2502
|
+
VALUES (?, ?, ?, 'active', ?, ?, datetime('now'))
|
|
2503
|
+
`);
|
|
2504
|
+
this.stmtComplete = db.prepare(`
|
|
2505
|
+
UPDATE thought_branches
|
|
2506
|
+
SET status = 'completed', arc_stage = 'completed', ended_at = datetime('now')
|
|
2507
|
+
WHERE id = ? AND project_hash = ?
|
|
2508
|
+
`);
|
|
2509
|
+
this.stmtAbandon = db.prepare(`
|
|
2510
|
+
UPDATE thought_branches
|
|
2511
|
+
SET status = 'abandoned', ended_at = datetime('now')
|
|
2512
|
+
WHERE id = ? AND project_hash = ?
|
|
2513
|
+
`);
|
|
2514
|
+
this.stmtGetActive = db.prepare(`
|
|
2515
|
+
SELECT * FROM thought_branches
|
|
2516
|
+
WHERE project_hash = ? AND status = 'active'
|
|
2517
|
+
ORDER BY started_at DESC
|
|
2518
|
+
LIMIT 1
|
|
2519
|
+
`);
|
|
2520
|
+
this.stmtGetById = db.prepare(`
|
|
2521
|
+
SELECT * FROM thought_branches
|
|
2522
|
+
WHERE id = ? AND project_hash = ?
|
|
2523
|
+
`);
|
|
2524
|
+
this.stmtList = db.prepare(`
|
|
2525
|
+
SELECT * FROM thought_branches
|
|
2526
|
+
WHERE project_hash = ?
|
|
2527
|
+
ORDER BY started_at DESC
|
|
2528
|
+
LIMIT ?
|
|
2529
|
+
`);
|
|
2530
|
+
this.stmtListByStatus = db.prepare(`
|
|
2531
|
+
SELECT * FROM thought_branches
|
|
2532
|
+
WHERE project_hash = ? AND status = ?
|
|
2533
|
+
ORDER BY started_at DESC
|
|
2534
|
+
LIMIT ?
|
|
2535
|
+
`);
|
|
2536
|
+
this.stmtListByType = db.prepare(`
|
|
2537
|
+
SELECT * FROM thought_branches
|
|
2538
|
+
WHERE project_hash = ? AND branch_type = ?
|
|
2539
|
+
ORDER BY started_at DESC
|
|
2540
|
+
LIMIT ?
|
|
2541
|
+
`);
|
|
2542
|
+
this.stmtUpdateArcStage = db.prepare(`
|
|
2543
|
+
UPDATE thought_branches SET arc_stage = ? WHERE id = ? AND project_hash = ?
|
|
2544
|
+
`);
|
|
2545
|
+
this.stmtUpdateToolPattern = db.prepare(`
|
|
2546
|
+
UPDATE thought_branches SET tool_pattern = ? WHERE id = ? AND project_hash = ?
|
|
2547
|
+
`);
|
|
2548
|
+
this.stmtUpdateClassification = db.prepare(`
|
|
2549
|
+
UPDATE thought_branches SET branch_type = ?, title = ? WHERE id = ? AND project_hash = ?
|
|
2550
|
+
`);
|
|
2551
|
+
this.stmtUpdateSummary = db.prepare(`
|
|
2552
|
+
UPDATE thought_branches SET summary = ? WHERE id = ? AND project_hash = ?
|
|
2553
|
+
`);
|
|
2554
|
+
this.stmtIncrementObsCount = db.prepare(`
|
|
2555
|
+
UPDATE thought_branches SET observation_count = observation_count + 1 WHERE id = ? AND project_hash = ?
|
|
2556
|
+
`);
|
|
2557
|
+
this.stmtLinkDebugPath = db.prepare(`
|
|
2558
|
+
UPDATE thought_branches SET linked_debug_path_id = ? WHERE id = ? AND project_hash = ?
|
|
2559
|
+
`);
|
|
2560
|
+
this.stmtAddObservation = db.prepare(`
|
|
2561
|
+
INSERT OR IGNORE INTO branch_observations
|
|
2562
|
+
(branch_id, observation_id, sequence_order, tool_name, arc_stage_at_add)
|
|
2563
|
+
VALUES (?, ?, ?, ?, ?)
|
|
2564
|
+
`);
|
|
2565
|
+
this.stmtGetObservations = db.prepare(`
|
|
2566
|
+
SELECT * FROM branch_observations
|
|
2567
|
+
WHERE branch_id = ?
|
|
2568
|
+
ORDER BY sequence_order ASC
|
|
2569
|
+
`);
|
|
2570
|
+
this.stmtMaxSequence = db.prepare(`
|
|
2571
|
+
SELECT COALESCE(MAX(sequence_order), 0) AS max_seq FROM branch_observations
|
|
2572
|
+
WHERE branch_id = ?
|
|
2573
|
+
`);
|
|
2574
|
+
this.stmtFindStale = db.prepare(`
|
|
2575
|
+
SELECT * FROM thought_branches
|
|
2576
|
+
WHERE project_hash = ? AND status = 'active'
|
|
2577
|
+
AND started_at < datetime('now', '-24 hours')
|
|
2578
|
+
`);
|
|
2579
|
+
this.stmtFindUnclassified = db.prepare(`
|
|
2580
|
+
SELECT * FROM thought_branches
|
|
2581
|
+
WHERE project_hash = ? AND branch_type = 'unknown'
|
|
2582
|
+
AND observation_count >= 3
|
|
2583
|
+
ORDER BY started_at DESC
|
|
2584
|
+
LIMIT ?
|
|
2585
|
+
`);
|
|
2586
|
+
this.stmtFindRecentCompleted = db.prepare(`
|
|
2587
|
+
SELECT * FROM thought_branches
|
|
2588
|
+
WHERE project_hash = ? AND status = 'completed' AND summary IS NULL
|
|
2589
|
+
AND ended_at > datetime('now', '-1 hour')
|
|
2590
|
+
ORDER BY ended_at DESC
|
|
2591
|
+
LIMIT ?
|
|
2592
|
+
`);
|
|
2593
|
+
this.stmtFindRecentActive = db.prepare(`
|
|
2594
|
+
SELECT * FROM thought_branches
|
|
2595
|
+
WHERE project_hash = ? AND status = 'active'
|
|
2596
|
+
AND started_at > datetime('now', '-24 hours')
|
|
2597
|
+
ORDER BY started_at DESC
|
|
2598
|
+
LIMIT 1
|
|
2599
|
+
`);
|
|
2600
|
+
this.stmtListRecent = db.prepare(`
|
|
2601
|
+
SELECT * FROM thought_branches
|
|
2602
|
+
WHERE project_hash = ?
|
|
2603
|
+
AND started_at > datetime('now', ? || ' hours')
|
|
2604
|
+
ORDER BY started_at DESC
|
|
2605
|
+
`);
|
|
2606
|
+
}
|
|
2607
|
+
createBranch(sessionId, triggerSource, triggerObservationId) {
|
|
2608
|
+
const id = randomBytes(16).toString("hex");
|
|
2609
|
+
this.stmtCreate.run(id, this.projectHash, sessionId, triggerSource, triggerObservationId ?? null);
|
|
2610
|
+
return this.getBranch(id);
|
|
2611
|
+
}
|
|
2612
|
+
completeBranch(branchId) {
|
|
2613
|
+
this.stmtComplete.run(branchId, this.projectHash);
|
|
2614
|
+
}
|
|
2615
|
+
abandonBranch(branchId) {
|
|
2616
|
+
this.stmtAbandon.run(branchId, this.projectHash);
|
|
2617
|
+
}
|
|
2618
|
+
getActiveBranch() {
|
|
2619
|
+
const row = this.stmtGetActive.get(this.projectHash);
|
|
2620
|
+
return row ? rowToBranch(row) : null;
|
|
2621
|
+
}
|
|
2622
|
+
getBranch(branchId) {
|
|
2623
|
+
const row = this.stmtGetById.get(branchId, this.projectHash);
|
|
2624
|
+
return row ? rowToBranch(row) : null;
|
|
2625
|
+
}
|
|
2626
|
+
listBranches(limit = 20) {
|
|
2627
|
+
return this.stmtList.all(this.projectHash, limit).map(rowToBranch);
|
|
2628
|
+
}
|
|
2629
|
+
listByStatus(status, limit = 20) {
|
|
2630
|
+
return this.stmtListByStatus.all(this.projectHash, status, limit).map(rowToBranch);
|
|
2631
|
+
}
|
|
2632
|
+
listByType(branchType, limit = 20) {
|
|
2633
|
+
return this.stmtListByType.all(this.projectHash, branchType, limit).map(rowToBranch);
|
|
2634
|
+
}
|
|
2635
|
+
updateArcStage(branchId, stage) {
|
|
2636
|
+
this.stmtUpdateArcStage.run(stage, branchId, this.projectHash);
|
|
2637
|
+
}
|
|
2638
|
+
updateToolPattern(branchId, pattern) {
|
|
2639
|
+
this.stmtUpdateToolPattern.run(JSON.stringify(pattern), branchId, this.projectHash);
|
|
2640
|
+
}
|
|
2641
|
+
updateClassification(branchId, branchType, title) {
|
|
2642
|
+
this.stmtUpdateClassification.run(branchType, title, branchId, this.projectHash);
|
|
2643
|
+
}
|
|
2644
|
+
updateSummary(branchId, summary) {
|
|
2645
|
+
this.stmtUpdateSummary.run(summary, branchId, this.projectHash);
|
|
2646
|
+
}
|
|
2647
|
+
linkDebugPath(branchId, debugPathId) {
|
|
2648
|
+
this.stmtLinkDebugPath.run(debugPathId, branchId, this.projectHash);
|
|
2649
|
+
}
|
|
2650
|
+
addObservation(branchId, observationId, toolName, arcStage) {
|
|
2651
|
+
const { max_seq } = this.stmtMaxSequence.get(branchId);
|
|
2652
|
+
this.stmtAddObservation.run(branchId, observationId, max_seq + 1, toolName, arcStage);
|
|
2653
|
+
this.stmtIncrementObsCount.run(branchId, this.projectHash);
|
|
2654
|
+
}
|
|
2655
|
+
getObservations(branchId) {
|
|
2656
|
+
return this.stmtGetObservations.all(branchId).map(rowToBranchObservation);
|
|
2657
|
+
}
|
|
2658
|
+
findStaleBranches() {
|
|
2659
|
+
return this.stmtFindStale.all(this.projectHash).map(rowToBranch);
|
|
2660
|
+
}
|
|
2661
|
+
findUnclassifiedBranches(limit = 5) {
|
|
2662
|
+
return this.stmtFindUnclassified.all(this.projectHash, limit).map(rowToBranch);
|
|
2663
|
+
}
|
|
2664
|
+
findRecentCompletedUnsummarized(limit = 3) {
|
|
2665
|
+
return this.stmtFindRecentCompleted.all(this.projectHash, limit).map(rowToBranch);
|
|
2666
|
+
}
|
|
2667
|
+
findRecentActiveBranch() {
|
|
2668
|
+
const row = this.stmtFindRecentActive.get(this.projectHash);
|
|
2669
|
+
return row ? rowToBranch(row) : null;
|
|
2670
|
+
}
|
|
2671
|
+
listRecentBranches(hours) {
|
|
2672
|
+
return this.stmtListRecent.all(this.projectHash, `-${hours}`).map(rowToBranch);
|
|
2673
|
+
}
|
|
2674
|
+
};
|
|
2675
|
+
function rowToBranch(row) {
|
|
2676
|
+
let toolPattern = {};
|
|
2677
|
+
try {
|
|
2678
|
+
toolPattern = JSON.parse(row.tool_pattern);
|
|
2679
|
+
} catch {}
|
|
2680
|
+
return {
|
|
2681
|
+
id: row.id,
|
|
2682
|
+
project_hash: row.project_hash,
|
|
2683
|
+
session_id: row.session_id,
|
|
2684
|
+
status: row.status,
|
|
2685
|
+
branch_type: row.branch_type,
|
|
2686
|
+
arc_stage: row.arc_stage,
|
|
2687
|
+
title: row.title,
|
|
2688
|
+
summary: row.summary,
|
|
2689
|
+
parent_branch_id: row.parent_branch_id,
|
|
2690
|
+
linked_debug_path_id: row.linked_debug_path_id,
|
|
2691
|
+
trigger_source: row.trigger_source,
|
|
2692
|
+
trigger_observation_id: row.trigger_observation_id,
|
|
2693
|
+
observation_count: row.observation_count,
|
|
2694
|
+
tool_pattern: toolPattern,
|
|
2695
|
+
started_at: row.started_at,
|
|
2696
|
+
ended_at: row.ended_at,
|
|
2697
|
+
created_at: row.created_at
|
|
2698
|
+
};
|
|
2699
|
+
}
|
|
2700
|
+
function rowToBranchObservation(row) {
|
|
2701
|
+
return {
|
|
2702
|
+
branch_id: row.branch_id,
|
|
2703
|
+
observation_id: row.observation_id,
|
|
2704
|
+
sequence_order: row.sequence_order,
|
|
2705
|
+
tool_name: row.tool_name,
|
|
2706
|
+
arc_stage_at_add: row.arc_stage_at_add,
|
|
2707
|
+
created_at: row.created_at
|
|
2708
|
+
};
|
|
2709
|
+
}
|
|
2710
|
+
|
|
1793
2711
|
//#endregion
|
|
1794
2712
|
//#region src/storage/research-buffer.ts
|
|
1795
2713
|
/**
|
|
@@ -2173,11 +3091,12 @@ var ToolRegistryRepository = class {
|
|
|
2173
3091
|
this.db = db;
|
|
2174
3092
|
try {
|
|
2175
3093
|
this.stmtUpsert = db.prepare(`
|
|
2176
|
-
INSERT INTO tool_registry (name, tool_type, scope, source, project_hash, description, server_name, discovered_at)
|
|
2177
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
3094
|
+
INSERT INTO tool_registry (name, tool_type, scope, source, project_hash, description, server_name, trigger_hints, discovered_at)
|
|
3095
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
2178
3096
|
ON CONFLICT (name, COALESCE(project_hash, ''))
|
|
2179
3097
|
DO UPDATE SET
|
|
2180
3098
|
description = COALESCE(excluded.description, tool_registry.description),
|
|
3099
|
+
trigger_hints = COALESCE(excluded.trigger_hints, tool_registry.trigger_hints),
|
|
2181
3100
|
source = excluded.source,
|
|
2182
3101
|
status = 'active',
|
|
2183
3102
|
updated_at = datetime('now')
|
|
@@ -2308,7 +3227,7 @@ var ToolRegistryRepository = class {
|
|
|
2308
3227
|
*/
|
|
2309
3228
|
upsert(tool) {
|
|
2310
3229
|
try {
|
|
2311
|
-
this.stmtUpsert.run(tool.name, tool.toolType, tool.scope, tool.source, tool.projectHash, tool.description, tool.serverName);
|
|
3230
|
+
this.stmtUpsert.run(tool.name, tool.toolType, tool.scope, tool.source, tool.projectHash, tool.description, tool.serverName, tool.triggerHints);
|
|
2312
3231
|
debug("tool-registry", "Upserted tool", {
|
|
2313
3232
|
name: tool.name,
|
|
2314
3233
|
scope: tool.scope
|
|
@@ -2651,5 +3570,5 @@ var ToolRegistryRepository = class {
|
|
|
2651
3570
|
};
|
|
2652
3571
|
|
|
2653
3572
|
//#endregion
|
|
2654
|
-
export {
|
|
2655
|
-
//# sourceMappingURL=tool-registry-
|
|
3573
|
+
export { hybridSearch as A, getNodesByType as C, upsertNode as D, traverseFrom as E, openDatabase as F, MIGRATIONS as I, runMigrations as L, SessionRepository as M, ObservationRepository as N, SaveGuard as O, rowToObservation as P, debug as R, getNodeByNameAndType as S, insertEdge as T, detectStaleness as _, ResearchBufferRepository as a, countEdgesForNode as b, inferScope as c, executePurge as d, findAnalysis as f, saveHygieneConfig as g, resetHygieneConfig as h, NotificationStore as i, SearchEngine as j, jaccardSimilarity as k, inferToolType as l, loadHygieneConfig as m, PathRepository as n, BranchRepository as o, runAutoCleanup as p, initPathSchema as r, extractServerName as s, ToolRegistryRepository as t, analyzeObservations as u, flagStaleObservation as v, initGraphSchema as w, getEdgesForNode as x, initStalenessSchema as y, debugTimed as z };
|
|
3574
|
+
//# sourceMappingURL=tool-registry-D8un_AcG.mjs.map
|