engrm 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,9 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
4
 
5
5
  // src/config.ts
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
- import { homedir, hostname } from "node:os";
7
+ import { homedir, hostname, networkInterfaces } from "node:os";
8
8
  import { join } from "node:path";
9
- import { randomBytes } from "node:crypto";
9
+ import { createHash } from "node:crypto";
10
10
  var CONFIG_DIR = join(homedir(), ".engrm");
11
11
  var SETTINGS_PATH = join(CONFIG_DIR, "settings.json");
12
12
  var DB_PATH = join(CONFIG_DIR, "engrm.db");
@@ -15,7 +15,22 @@ function getDbPath() {
15
15
  }
16
16
  function generateDeviceId() {
17
17
  const host = hostname().toLowerCase().replace(/[^a-z0-9-]/g, "");
18
- const suffix = randomBytes(4).toString("hex");
18
+ let mac = "";
19
+ const ifaces = networkInterfaces();
20
+ for (const entries of Object.values(ifaces)) {
21
+ if (!entries)
22
+ continue;
23
+ for (const entry of entries) {
24
+ if (!entry.internal && entry.mac && entry.mac !== "00:00:00:00:00:00") {
25
+ mac = entry.mac;
26
+ break;
27
+ }
28
+ }
29
+ if (mac)
30
+ break;
31
+ }
32
+ const material = `${host}:${mac || "no-mac"}`;
33
+ const suffix = createHash("sha256").update(material).digest("hex").slice(0, 8);
19
34
  return `${host}-${suffix}`;
20
35
  }
21
36
  function createDefaultConfig() {
@@ -57,7 +72,10 @@ function createDefaultConfig() {
57
72
  observer: {
58
73
  enabled: true,
59
74
  mode: "per_event",
60
- model: "haiku"
75
+ model: "sonnet"
76
+ },
77
+ transcript_analysis: {
78
+ enabled: false
61
79
  }
62
80
  };
63
81
  }
@@ -116,9 +134,19 @@ function loadConfig() {
116
134
  enabled: asBool(config["observer"]?.["enabled"], defaults.observer.enabled),
117
135
  mode: asObserverMode(config["observer"]?.["mode"], defaults.observer.mode),
118
136
  model: asString(config["observer"]?.["model"], defaults.observer.model)
137
+ },
138
+ transcript_analysis: {
139
+ enabled: asBool(config["transcript_analysis"]?.["enabled"], defaults.transcript_analysis.enabled)
119
140
  }
120
141
  };
121
142
  }
143
+ function saveConfig(config) {
144
+ if (!existsSync(CONFIG_DIR)) {
145
+ mkdirSync(CONFIG_DIR, { recursive: true });
146
+ }
147
+ writeFileSync(SETTINGS_PATH, JSON.stringify(config, null, 2) + `
148
+ `, "utf-8");
149
+ }
122
150
  function configExists() {
123
151
  return existsSync(SETTINGS_PATH);
124
152
  }
@@ -514,6 +542,56 @@ function runMigrations(db) {
514
542
  }
515
543
  }
516
544
  }
545
+ function ensureObservationTypes(db) {
546
+ try {
547
+ db.exec("INSERT INTO observations (session_id, project_id, type, title, user_id, device_id, agent, created_at, created_at_epoch) " + "VALUES ('_typecheck', 1, 'message', '_test', '_test', '_test', '_test', '2000-01-01', 0)");
548
+ db.exec("DELETE FROM observations WHERE session_id = '_typecheck'");
549
+ } catch {
550
+ db.exec("BEGIN TRANSACTION");
551
+ try {
552
+ db.exec(`
553
+ CREATE TABLE observations_repair (
554
+ id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT,
555
+ project_id INTEGER NOT NULL REFERENCES projects(id),
556
+ type TEXT NOT NULL CHECK (type IN (
557
+ 'bugfix','discovery','decision','pattern','change','feature',
558
+ 'refactor','digest','standard','message')),
559
+ title TEXT NOT NULL, narrative TEXT, facts TEXT, concepts TEXT,
560
+ files_read TEXT, files_modified TEXT,
561
+ quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
562
+ lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN ('active','aging','archived','purged','pinned')),
563
+ sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN ('shared','personal','secret')),
564
+ user_id TEXT NOT NULL, device_id TEXT NOT NULL, agent TEXT DEFAULT 'claude-code',
565
+ created_at TEXT NOT NULL, created_at_epoch INTEGER NOT NULL,
566
+ archived_at_epoch INTEGER,
567
+ compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
568
+ superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
569
+ remote_source_id TEXT
570
+ );
571
+ INSERT INTO observations_repair SELECT * FROM observations;
572
+ DROP TABLE observations;
573
+ ALTER TABLE observations_repair RENAME TO observations;
574
+ CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project_id);
575
+ CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
576
+ CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch);
577
+ CREATE INDEX IF NOT EXISTS idx_observations_session ON observations(session_id);
578
+ CREATE INDEX IF NOT EXISTS idx_observations_lifecycle ON observations(lifecycle);
579
+ CREATE INDEX IF NOT EXISTS idx_observations_quality ON observations(quality);
580
+ CREATE INDEX IF NOT EXISTS idx_observations_user ON observations(user_id);
581
+ CREATE INDEX IF NOT EXISTS idx_observations_superseded ON observations(superseded_by);
582
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
583
+ DROP TABLE IF EXISTS observations_fts;
584
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
585
+ title, narrative, facts, concepts, content=observations, content_rowid=id
586
+ );
587
+ INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
588
+ `);
589
+ db.exec("COMMIT");
590
+ } catch (err) {
591
+ db.exec("ROLLBACK");
592
+ }
593
+ }
594
+ }
517
595
  var LATEST_SCHEMA_VERSION = MIGRATIONS.filter((m) => !m.condition).reduce((max, m) => Math.max(max, m.version), 0);
518
596
 
519
597
  // src/storage/sqlite.ts
@@ -580,6 +658,7 @@ class MemDatabase {
580
658
  this.db.exec("PRAGMA foreign_keys = ON");
581
659
  this.vecAvailable = this.loadVecExtension();
582
660
  runMigrations(this.db);
661
+ ensureObservationTypes(this.db);
583
662
  }
584
663
  loadVecExtension() {
585
664
  try {
@@ -1203,6 +1282,13 @@ class VectorClient {
1203
1282
  async sendTelemetry(beacon) {
1204
1283
  await this.request("POST", "/v1/mem/telemetry", beacon);
1205
1284
  }
1285
+ async fetchSettings() {
1286
+ try {
1287
+ return await this.request("GET", "/v1/mem/user-settings");
1288
+ } catch {
1289
+ return null;
1290
+ }
1291
+ }
1206
1292
  async health() {
1207
1293
  try {
1208
1294
  await this.request("GET", "/health");
@@ -1302,6 +1388,7 @@ function buildVectorDocument(obs, config, project) {
1302
1388
  project_name: project.name,
1303
1389
  user_id: obs.user_id,
1304
1390
  device_id: obs.device_id,
1391
+ device_name: __require("node:os").hostname(),
1305
1392
  agent: obs.agent,
1306
1393
  title: obs.title,
1307
1394
  narrative: obs.narrative,
@@ -1430,6 +1517,105 @@ async function pushOutbox(db, client, config, batchSize = 50) {
1430
1517
  return { pushed, failed, skipped };
1431
1518
  }
1432
1519
 
1520
+ // src/embeddings/embedder.ts
1521
+ var _available = null;
1522
+ var _pipeline = null;
1523
+ var MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
1524
+ async function embedText(text) {
1525
+ const pipe = await getPipeline();
1526
+ if (!pipe)
1527
+ return null;
1528
+ try {
1529
+ const output = await pipe(text, { pooling: "mean", normalize: true });
1530
+ return new Float32Array(output.data);
1531
+ } catch {
1532
+ return null;
1533
+ }
1534
+ }
1535
+ function composeEmbeddingText(obs) {
1536
+ const parts = [obs.title];
1537
+ if (obs.narrative)
1538
+ parts.push(obs.narrative);
1539
+ if (obs.facts) {
1540
+ try {
1541
+ const facts = JSON.parse(obs.facts);
1542
+ if (Array.isArray(facts) && facts.length > 0) {
1543
+ parts.push(facts.map((f) => `- ${f}`).join(`
1544
+ `));
1545
+ }
1546
+ } catch {
1547
+ parts.push(obs.facts);
1548
+ }
1549
+ }
1550
+ if (obs.concepts) {
1551
+ try {
1552
+ const concepts = JSON.parse(obs.concepts);
1553
+ if (Array.isArray(concepts) && concepts.length > 0) {
1554
+ parts.push(concepts.join(", "));
1555
+ }
1556
+ } catch {}
1557
+ }
1558
+ return parts.join(`
1559
+
1560
+ `);
1561
+ }
1562
+ async function getPipeline() {
1563
+ if (_pipeline)
1564
+ return _pipeline;
1565
+ if (_available === false)
1566
+ return null;
1567
+ try {
1568
+ const { pipeline } = await import("@xenova/transformers");
1569
+ _pipeline = await pipeline("feature-extraction", MODEL_NAME);
1570
+ _available = true;
1571
+ return _pipeline;
1572
+ } catch (err) {
1573
+ _available = false;
1574
+ console.error(`[engrm] Local embedding model unavailable: ${err instanceof Error ? err.message : String(err)}`);
1575
+ return null;
1576
+ }
1577
+ }
1578
+
1579
+ // src/sync/pull.ts
1580
+ async function pullSettings(client, config) {
1581
+ try {
1582
+ const settings = await client.fetchSettings();
1583
+ if (!settings)
1584
+ return false;
1585
+ let changed = false;
1586
+ if (settings.transcript_analysis !== undefined) {
1587
+ const ta = settings.transcript_analysis;
1588
+ if (typeof ta === "object" && ta !== null) {
1589
+ const taObj = ta;
1590
+ if (taObj.enabled !== undefined && taObj.enabled !== config.transcript_analysis.enabled) {
1591
+ config.transcript_analysis.enabled = !!taObj.enabled;
1592
+ changed = true;
1593
+ }
1594
+ }
1595
+ }
1596
+ if (settings.observer !== undefined) {
1597
+ const obs = settings.observer;
1598
+ if (typeof obs === "object" && obs !== null) {
1599
+ const obsObj = obs;
1600
+ if (obsObj.enabled !== undefined && obsObj.enabled !== config.observer.enabled) {
1601
+ config.observer.enabled = !!obsObj.enabled;
1602
+ changed = true;
1603
+ }
1604
+ if (obsObj.model !== undefined && typeof obsObj.model === "string" && obsObj.model !== config.observer.model) {
1605
+ config.observer.model = obsObj.model;
1606
+ changed = true;
1607
+ }
1608
+ }
1609
+ }
1610
+ if (changed) {
1611
+ saveConfig(config);
1612
+ }
1613
+ return changed;
1614
+ } catch {
1615
+ return false;
1616
+ }
1617
+ }
1618
+
1433
1619
  // src/sync/push-once.ts
1434
1620
  async function pushOnce(db, config) {
1435
1621
  if (!config.sync.enabled)
@@ -1439,6 +1625,7 @@ async function pushOnce(db, config) {
1439
1625
  try {
1440
1626
  const client = new VectorClient(config);
1441
1627
  const result = await pushOutbox(db, client, config, config.sync.batch_size);
1628
+ await pullSettings(client, config);
1442
1629
  return result.pushed;
1443
1630
  } catch {
1444
1631
  return 0;
@@ -1668,6 +1855,702 @@ function detectProject(directory) {
1668
1855
  };
1669
1856
  }
1670
1857
 
1858
+ // src/capture/transcript.ts
1859
+ import { readFileSync as readFileSync3, existsSync as existsSync3 } from "node:fs";
1860
+ import { join as join4 } from "node:path";
1861
+ import { homedir as homedir2 } from "node:os";
1862
+
1863
+ // src/tools/save.ts
1864
+ import { relative, isAbsolute } from "node:path";
1865
+
1866
+ // src/capture/scrubber.ts
1867
+ var DEFAULT_PATTERNS = [
1868
+ {
1869
+ source: "sk-[a-zA-Z0-9]{20,}",
1870
+ flags: "g",
1871
+ replacement: "[REDACTED_API_KEY]",
1872
+ description: "OpenAI API keys",
1873
+ category: "api_key",
1874
+ severity: "critical"
1875
+ },
1876
+ {
1877
+ source: "Bearer [a-zA-Z0-9\\-._~+/]+=*",
1878
+ flags: "g",
1879
+ replacement: "[REDACTED_BEARER]",
1880
+ description: "Bearer auth tokens",
1881
+ category: "token",
1882
+ severity: "medium"
1883
+ },
1884
+ {
1885
+ source: "password[=:]\\s*\\S+",
1886
+ flags: "gi",
1887
+ replacement: "password=[REDACTED]",
1888
+ description: "Passwords in config",
1889
+ category: "password",
1890
+ severity: "high"
1891
+ },
1892
+ {
1893
+ source: "postgresql://[^\\s]+",
1894
+ flags: "g",
1895
+ replacement: "[REDACTED_DB_URL]",
1896
+ description: "PostgreSQL connection strings",
1897
+ category: "db_url",
1898
+ severity: "high"
1899
+ },
1900
+ {
1901
+ source: "mongodb://[^\\s]+",
1902
+ flags: "g",
1903
+ replacement: "[REDACTED_DB_URL]",
1904
+ description: "MongoDB connection strings",
1905
+ category: "db_url",
1906
+ severity: "high"
1907
+ },
1908
+ {
1909
+ source: "mysql://[^\\s]+",
1910
+ flags: "g",
1911
+ replacement: "[REDACTED_DB_URL]",
1912
+ description: "MySQL connection strings",
1913
+ category: "db_url",
1914
+ severity: "high"
1915
+ },
1916
+ {
1917
+ source: "AKIA[A-Z0-9]{16}",
1918
+ flags: "g",
1919
+ replacement: "[REDACTED_AWS_KEY]",
1920
+ description: "AWS access keys",
1921
+ category: "api_key",
1922
+ severity: "critical"
1923
+ },
1924
+ {
1925
+ source: "ghp_[a-zA-Z0-9]{36}",
1926
+ flags: "g",
1927
+ replacement: "[REDACTED_GH_TOKEN]",
1928
+ description: "GitHub personal access tokens",
1929
+ category: "token",
1930
+ severity: "high"
1931
+ },
1932
+ {
1933
+ source: "gho_[a-zA-Z0-9]{36}",
1934
+ flags: "g",
1935
+ replacement: "[REDACTED_GH_TOKEN]",
1936
+ description: "GitHub OAuth tokens",
1937
+ category: "token",
1938
+ severity: "high"
1939
+ },
1940
+ {
1941
+ source: "github_pat_[a-zA-Z0-9_]{22,}",
1942
+ flags: "g",
1943
+ replacement: "[REDACTED_GH_TOKEN]",
1944
+ description: "GitHub fine-grained PATs",
1945
+ category: "token",
1946
+ severity: "high"
1947
+ },
1948
+ {
1949
+ source: "cvk_[a-f0-9]{64}",
1950
+ flags: "g",
1951
+ replacement: "[REDACTED_CANDENGO_KEY]",
1952
+ description: "Candengo API keys",
1953
+ category: "api_key",
1954
+ severity: "critical"
1955
+ },
1956
+ {
1957
+ source: "xox[bpras]-[a-zA-Z0-9\\-]+",
1958
+ flags: "g",
1959
+ replacement: "[REDACTED_SLACK_TOKEN]",
1960
+ description: "Slack tokens",
1961
+ category: "token",
1962
+ severity: "high"
1963
+ }
1964
+ ];
1965
+ function compileCustomPatterns(patterns) {
1966
+ const compiled = [];
1967
+ for (const pattern of patterns) {
1968
+ try {
1969
+ new RegExp(pattern);
1970
+ compiled.push({
1971
+ source: pattern,
1972
+ flags: "g",
1973
+ replacement: "[REDACTED_CUSTOM]",
1974
+ description: `Custom pattern: ${pattern}`,
1975
+ category: "custom",
1976
+ severity: "medium"
1977
+ });
1978
+ } catch {}
1979
+ }
1980
+ return compiled;
1981
+ }
1982
+ function scrubSecrets(text, customPatterns = []) {
1983
+ let result = text;
1984
+ const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
1985
+ for (const pattern of allPatterns) {
1986
+ result = result.replace(new RegExp(pattern.source, pattern.flags), pattern.replacement);
1987
+ }
1988
+ return result;
1989
+ }
1990
+ function containsSecrets(text, customPatterns = []) {
1991
+ const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
1992
+ for (const pattern of allPatterns) {
1993
+ if (new RegExp(pattern.source, pattern.flags).test(text))
1994
+ return true;
1995
+ }
1996
+ return false;
1997
+ }
1998
+
1999
+ // src/capture/quality.ts
2000
+ var QUALITY_THRESHOLD = 0.1;
2001
+ function scoreQuality(input) {
2002
+ let score = 0;
2003
+ switch (input.type) {
2004
+ case "bugfix":
2005
+ score += 0.3;
2006
+ break;
2007
+ case "decision":
2008
+ score += 0.3;
2009
+ break;
2010
+ case "discovery":
2011
+ score += 0.2;
2012
+ break;
2013
+ case "pattern":
2014
+ score += 0.2;
2015
+ break;
2016
+ case "feature":
2017
+ score += 0.15;
2018
+ break;
2019
+ case "refactor":
2020
+ score += 0.15;
2021
+ break;
2022
+ case "change":
2023
+ score += 0.05;
2024
+ break;
2025
+ case "digest":
2026
+ score += 0.3;
2027
+ break;
2028
+ }
2029
+ if (input.narrative && input.narrative.length > 50) {
2030
+ score += 0.15;
2031
+ }
2032
+ if (input.facts) {
2033
+ try {
2034
+ const factsArray = JSON.parse(input.facts);
2035
+ if (factsArray.length >= 2)
2036
+ score += 0.15;
2037
+ else if (factsArray.length === 1)
2038
+ score += 0.05;
2039
+ } catch {
2040
+ if (input.facts.length > 20)
2041
+ score += 0.05;
2042
+ }
2043
+ }
2044
+ if (input.concepts) {
2045
+ try {
2046
+ const conceptsArray = JSON.parse(input.concepts);
2047
+ if (conceptsArray.length >= 1)
2048
+ score += 0.1;
2049
+ } catch {
2050
+ if (input.concepts.length > 10)
2051
+ score += 0.05;
2052
+ }
2053
+ }
2054
+ const modifiedCount = input.filesModified?.length ?? 0;
2055
+ if (modifiedCount >= 3)
2056
+ score += 0.2;
2057
+ else if (modifiedCount >= 1)
2058
+ score += 0.1;
2059
+ if (input.isDuplicate) {
2060
+ score -= 0.3;
2061
+ }
2062
+ return Math.max(0, Math.min(1, score));
2063
+ }
2064
+ function meetsQualityThreshold(input) {
2065
+ return scoreQuality(input) >= QUALITY_THRESHOLD;
2066
+ }
2067
+
2068
+ // src/capture/dedup.ts
2069
+ function tokenise(text) {
2070
+ const cleaned = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
2071
+ const tokens = cleaned.split(/\s+/).filter((t) => t.length > 0);
2072
+ return new Set(tokens);
2073
+ }
2074
+ function jaccardSimilarity(a, b) {
2075
+ const tokensA = tokenise(a);
2076
+ const tokensB = tokenise(b);
2077
+ if (tokensA.size === 0 && tokensB.size === 0)
2078
+ return 1;
2079
+ if (tokensA.size === 0 || tokensB.size === 0)
2080
+ return 0;
2081
+ let intersectionSize = 0;
2082
+ for (const token of tokensA) {
2083
+ if (tokensB.has(token))
2084
+ intersectionSize++;
2085
+ }
2086
+ const unionSize = tokensA.size + tokensB.size - intersectionSize;
2087
+ if (unionSize === 0)
2088
+ return 0;
2089
+ return intersectionSize / unionSize;
2090
+ }
2091
+ var DEDUP_THRESHOLD = 0.8;
2092
+ function findDuplicate(newTitle, candidates) {
2093
+ let bestMatch = null;
2094
+ let bestScore = 0;
2095
+ for (const candidate of candidates) {
2096
+ const similarity = jaccardSimilarity(newTitle, candidate.title);
2097
+ if (similarity > DEDUP_THRESHOLD && similarity > bestScore) {
2098
+ bestScore = similarity;
2099
+ bestMatch = candidate;
2100
+ }
2101
+ }
2102
+ return bestMatch;
2103
+ }
2104
+
2105
+ // src/capture/recurrence.ts
2106
+ var DISTANCE_THRESHOLD = 0.15;
2107
+ async function detectRecurrence(db, config, observation) {
2108
+ if (observation.type !== "bugfix") {
2109
+ return { patternCreated: false };
2110
+ }
2111
+ if (!db.vecAvailable) {
2112
+ return { patternCreated: false };
2113
+ }
2114
+ const text = composeEmbeddingText(observation);
2115
+ const embedding = await embedText(text);
2116
+ if (!embedding) {
2117
+ return { patternCreated: false };
2118
+ }
2119
+ const vecResults = db.searchVec(embedding, null, ["active", "aging", "pinned"], 10);
2120
+ for (const match of vecResults) {
2121
+ if (match.observation_id === observation.id)
2122
+ continue;
2123
+ if (match.distance > DISTANCE_THRESHOLD)
2124
+ continue;
2125
+ const matched = db.getObservationById(match.observation_id);
2126
+ if (!matched)
2127
+ continue;
2128
+ if (matched.type !== "bugfix")
2129
+ continue;
2130
+ if (matched.session_id === observation.session_id)
2131
+ continue;
2132
+ if (await patternAlreadyExists(db, observation, matched))
2133
+ continue;
2134
+ let matchedProjectName;
2135
+ if (matched.project_id !== observation.project_id) {
2136
+ const proj = db.getProjectById(matched.project_id);
2137
+ if (proj)
2138
+ matchedProjectName = proj.name;
2139
+ }
2140
+ const similarity = 1 - match.distance;
2141
+ const result = await saveObservation(db, config, {
2142
+ type: "pattern",
2143
+ title: `Recurring bugfix: ${observation.title}`,
2144
+ narrative: `This bug pattern has appeared in multiple sessions. Original: "${matched.title}" (session ${matched.session_id?.slice(0, 8) ?? "unknown"}). Latest: "${observation.title}". Similarity: ${(similarity * 100).toFixed(0)}%. Consider addressing the root cause.`,
2145
+ facts: [
2146
+ `First seen: ${matched.created_at.split("T")[0]}`,
2147
+ `Recurred: ${observation.created_at.split("T")[0]}`,
2148
+ `Similarity: ${(similarity * 100).toFixed(0)}%`
2149
+ ],
2150
+ concepts: mergeConceptsFromBoth(observation, matched),
2151
+ cwd: process.cwd(),
2152
+ session_id: observation.session_id ?? undefined
2153
+ });
2154
+ if (result.success && result.observation_id) {
2155
+ return {
2156
+ patternCreated: true,
2157
+ patternId: result.observation_id,
2158
+ matchedObservationId: matched.id,
2159
+ matchedProjectName,
2160
+ matchedTitle: matched.title,
2161
+ similarity
2162
+ };
2163
+ }
2164
+ }
2165
+ return { patternCreated: false };
2166
+ }
2167
+ async function patternAlreadyExists(db, obs1, obs2) {
2168
+ const recentPatterns = db.db.query(`SELECT * FROM observations
2169
+ WHERE type = 'pattern' AND lifecycle IN ('active', 'aging', 'pinned')
2170
+ AND title LIKE ?
2171
+ ORDER BY created_at_epoch DESC LIMIT 5`).all(`%${obs1.title.slice(0, 30)}%`);
2172
+ for (const p of recentPatterns) {
2173
+ if (p.narrative?.includes(obs2.title.slice(0, 30)))
2174
+ return true;
2175
+ }
2176
+ return false;
2177
+ }
2178
+ function mergeConceptsFromBoth(obs1, obs2) {
2179
+ const concepts = new Set;
2180
+ for (const obs of [obs1, obs2]) {
2181
+ if (obs.concepts) {
2182
+ try {
2183
+ const parsed = JSON.parse(obs.concepts);
2184
+ if (Array.isArray(parsed)) {
2185
+ for (const c of parsed) {
2186
+ if (typeof c === "string")
2187
+ concepts.add(c);
2188
+ }
2189
+ }
2190
+ } catch {}
2191
+ }
2192
+ }
2193
+ return [...concepts];
2194
+ }
2195
+
2196
+ // src/capture/conflict.ts
2197
+ var SIMILARITY_THRESHOLD = 0.25;
2198
+ async function detectDecisionConflict(db, observation) {
2199
+ if (observation.type !== "decision") {
2200
+ return { hasConflict: false };
2201
+ }
2202
+ if (!observation.narrative || observation.narrative.trim().length < 20) {
2203
+ return { hasConflict: false };
2204
+ }
2205
+ if (db.vecAvailable) {
2206
+ return detectViaVec(db, observation);
2207
+ }
2208
+ return detectViaFts(db, observation);
2209
+ }
2210
+ async function detectViaVec(db, observation) {
2211
+ const text = composeEmbeddingText(observation);
2212
+ const embedding = await embedText(text);
2213
+ if (!embedding)
2214
+ return { hasConflict: false };
2215
+ const results = db.searchVec(embedding, observation.project_id, ["active", "aging", "pinned"], 10);
2216
+ for (const match of results) {
2217
+ if (match.observation_id === observation.id)
2218
+ continue;
2219
+ if (match.distance > SIMILARITY_THRESHOLD)
2220
+ continue;
2221
+ const existing = db.getObservationById(match.observation_id);
2222
+ if (!existing)
2223
+ continue;
2224
+ if (existing.type !== "decision")
2225
+ continue;
2226
+ if (!existing.narrative)
2227
+ continue;
2228
+ const conflict = narrativesConflict(observation.narrative, existing.narrative);
2229
+ if (conflict) {
2230
+ return {
2231
+ hasConflict: true,
2232
+ conflictingId: existing.id,
2233
+ conflictingTitle: existing.title,
2234
+ reason: conflict
2235
+ };
2236
+ }
2237
+ }
2238
+ return { hasConflict: false };
2239
+ }
2240
+ async function detectViaFts(db, observation) {
2241
+ const keywords = observation.title.split(/\s+/).filter((w) => w.length > 3).slice(0, 5).join(" ");
2242
+ if (!keywords)
2243
+ return { hasConflict: false };
2244
+ const ftsResults = db.searchFts(keywords, observation.project_id, ["active", "aging", "pinned"], 10);
2245
+ for (const match of ftsResults) {
2246
+ if (match.id === observation.id)
2247
+ continue;
2248
+ const existing = db.getObservationById(match.id);
2249
+ if (!existing)
2250
+ continue;
2251
+ if (existing.type !== "decision")
2252
+ continue;
2253
+ if (!existing.narrative)
2254
+ continue;
2255
+ const conflict = narrativesConflict(observation.narrative, existing.narrative);
2256
+ if (conflict) {
2257
+ return {
2258
+ hasConflict: true,
2259
+ conflictingId: existing.id,
2260
+ conflictingTitle: existing.title,
2261
+ reason: conflict
2262
+ };
2263
+ }
2264
+ }
2265
+ return { hasConflict: false };
2266
+ }
2267
+ function narrativesConflict(narrative1, narrative2) {
2268
+ const n1 = narrative1.toLowerCase();
2269
+ const n2 = narrative2.toLowerCase();
2270
+ const opposingPairs = [
2271
+ [["should use", "decided to use", "chose", "prefer", "went with"], ["should not", "decided against", "avoid", "rejected", "don't use"]],
2272
+ [["enable", "turn on", "activate", "add"], ["disable", "turn off", "deactivate", "remove"]],
2273
+ [["increase", "more", "higher", "scale up"], ["decrease", "less", "lower", "scale down"]],
2274
+ [["keep", "maintain", "preserve"], ["replace", "migrate", "switch from", "deprecate"]]
2275
+ ];
2276
+ for (const [positive, negative] of opposingPairs) {
2277
+ const n1HasPositive = positive.some((w) => n1.includes(w));
2278
+ const n1HasNegative = negative.some((w) => n1.includes(w));
2279
+ const n2HasPositive = positive.some((w) => n2.includes(w));
2280
+ const n2HasNegative = negative.some((w) => n2.includes(w));
2281
+ if (n1HasPositive && n2HasNegative || n1HasNegative && n2HasPositive) {
2282
+ return "Narratives suggest opposing conclusions on a similar topic";
2283
+ }
2284
+ }
2285
+ return null;
2286
+ }
2287
+
2288
+ // src/tools/save.ts
2289
+ var VALID_TYPES = [
2290
+ "bugfix",
2291
+ "discovery",
2292
+ "decision",
2293
+ "pattern",
2294
+ "change",
2295
+ "feature",
2296
+ "refactor",
2297
+ "digest",
2298
+ "standard",
2299
+ "message"
2300
+ ];
2301
+ async function saveObservation(db, config, input) {
2302
+ if (!VALID_TYPES.includes(input.type)) {
2303
+ return {
2304
+ success: false,
2305
+ reason: `Invalid type '${input.type}'. Must be one of: ${VALID_TYPES.join(", ")}`
2306
+ };
2307
+ }
2308
+ if (!input.title || input.title.trim().length === 0) {
2309
+ return { success: false, reason: "Title is required" };
2310
+ }
2311
+ const cwd = input.cwd ?? process.cwd();
2312
+ const detected = detectProject(cwd);
2313
+ const project = db.upsertProject({
2314
+ canonical_id: detected.canonical_id,
2315
+ name: detected.name,
2316
+ local_path: detected.local_path,
2317
+ remote_url: detected.remote_url
2318
+ });
2319
+ const customPatterns = config.scrubbing.enabled ? config.scrubbing.custom_patterns : [];
2320
+ const title = config.scrubbing.enabled ? scrubSecrets(input.title, customPatterns) : input.title;
2321
+ const narrative = input.narrative ? config.scrubbing.enabled ? scrubSecrets(input.narrative, customPatterns) : input.narrative : null;
2322
+ const factsJson = input.facts ? config.scrubbing.enabled ? scrubSecrets(JSON.stringify(input.facts), customPatterns) : JSON.stringify(input.facts) : null;
2323
+ const conceptsJson = input.concepts ? JSON.stringify(input.concepts) : null;
2324
+ const filesRead = input.files_read ? input.files_read.map((f) => toRelativePath(f, cwd)) : null;
2325
+ const filesModified = input.files_modified ? input.files_modified.map((f) => toRelativePath(f, cwd)) : null;
2326
+ const filesReadJson = filesRead ? JSON.stringify(filesRead) : null;
2327
+ const filesModifiedJson = filesModified ? JSON.stringify(filesModified) : null;
2328
+ let sensitivity = input.sensitivity ?? config.scrubbing.default_sensitivity;
2329
+ if (config.scrubbing.enabled && containsSecrets([input.title, input.narrative, JSON.stringify(input.facts)].filter(Boolean).join(" "), customPatterns)) {
2330
+ if (sensitivity === "shared") {
2331
+ sensitivity = "personal";
2332
+ }
2333
+ }
2334
+ const oneDayAgo = Math.floor(Date.now() / 1000) - 86400;
2335
+ const recentObs = db.getRecentObservations(project.id, oneDayAgo);
2336
+ const candidates = recentObs.map((o) => ({
2337
+ id: o.id,
2338
+ title: o.title
2339
+ }));
2340
+ const duplicate = findDuplicate(title, candidates);
2341
+ const qualityInput = {
2342
+ type: input.type,
2343
+ title,
2344
+ narrative,
2345
+ facts: factsJson,
2346
+ concepts: conceptsJson,
2347
+ filesRead,
2348
+ filesModified,
2349
+ isDuplicate: duplicate !== null
2350
+ };
2351
+ const qualityScore = scoreQuality(qualityInput);
2352
+ if (!meetsQualityThreshold(qualityInput)) {
2353
+ return {
2354
+ success: false,
2355
+ quality_score: qualityScore,
2356
+ reason: `Quality score ${qualityScore.toFixed(2)} below threshold`
2357
+ };
2358
+ }
2359
+ if (duplicate) {
2360
+ return {
2361
+ success: true,
2362
+ merged_into: duplicate.id,
2363
+ quality_score: qualityScore,
2364
+ reason: `Merged into existing observation #${duplicate.id}`
2365
+ };
2366
+ }
2367
+ const obs = db.insertObservation({
2368
+ session_id: input.session_id ?? null,
2369
+ project_id: project.id,
2370
+ type: input.type,
2371
+ title,
2372
+ narrative,
2373
+ facts: factsJson,
2374
+ concepts: conceptsJson,
2375
+ files_read: filesReadJson,
2376
+ files_modified: filesModifiedJson,
2377
+ quality: qualityScore,
2378
+ lifecycle: "active",
2379
+ sensitivity,
2380
+ user_id: config.user_id,
2381
+ device_id: config.device_id,
2382
+ agent: input.agent ?? "claude-code"
2383
+ });
2384
+ db.addToOutbox("observation", obs.id);
2385
+ if (db.vecAvailable) {
2386
+ try {
2387
+ const text = composeEmbeddingText(obs);
2388
+ const embedding = await embedText(text);
2389
+ if (embedding) {
2390
+ db.vecInsert(obs.id, embedding);
2391
+ }
2392
+ } catch {}
2393
+ }
2394
+ let recallHint;
2395
+ if (input.type === "bugfix") {
2396
+ try {
2397
+ const recurrence = await detectRecurrence(db, config, obs);
2398
+ if (recurrence.patternCreated && recurrence.matchedTitle) {
2399
+ const projectLabel = recurrence.matchedProjectName ? ` in ${recurrence.matchedProjectName}` : "";
2400
+ recallHint = `You solved a similar issue${projectLabel}: "${recurrence.matchedTitle}"`;
2401
+ }
2402
+ } catch {}
2403
+ }
2404
+ let conflictWarning;
2405
+ if (input.type === "decision") {
2406
+ try {
2407
+ const conflict = await detectDecisionConflict(db, obs);
2408
+ if (conflict.hasConflict && conflict.conflictingTitle) {
2409
+ conflictWarning = `Potential conflict with existing decision: "${conflict.conflictingTitle}" — ${conflict.reason}`;
2410
+ }
2411
+ } catch {}
2412
+ }
2413
+ return {
2414
+ success: true,
2415
+ observation_id: obs.id,
2416
+ quality_score: qualityScore,
2417
+ recall_hint: recallHint,
2418
+ conflict_warning: conflictWarning
2419
+ };
2420
+ }
2421
+ function toRelativePath(filePath, projectRoot) {
2422
+ if (!isAbsolute(filePath))
2423
+ return filePath;
2424
+ const rel = relative(projectRoot, filePath);
2425
+ if (rel.startsWith(".."))
2426
+ return filePath;
2427
+ return rel;
2428
+ }
2429
+
2430
+ // src/capture/transcript.ts
2431
+ function resolveTranscriptPath(sessionId, cwd) {
2432
+ const encodedCwd = cwd.replace(/\//g, "-");
2433
+ return join4(homedir2(), ".claude", "projects", encodedCwd, `${sessionId}.jsonl`);
2434
+ }
2435
+ function readTranscript(sessionId, cwd) {
2436
+ const path = resolveTranscriptPath(sessionId, cwd);
2437
+ if (!existsSync3(path))
2438
+ return [];
2439
+ let raw;
2440
+ try {
2441
+ raw = readFileSync3(path, "utf-8");
2442
+ } catch {
2443
+ return [];
2444
+ }
2445
+ const messages = [];
2446
+ for (const line of raw.split(`
2447
+ `)) {
2448
+ if (!line.trim())
2449
+ continue;
2450
+ let entry;
2451
+ try {
2452
+ entry = JSON.parse(line);
2453
+ } catch {
2454
+ continue;
2455
+ }
2456
+ const role = entry.role;
2457
+ if (role !== "user" && role !== "assistant")
2458
+ continue;
2459
+ const content = entry.content;
2460
+ if (typeof content === "string") {
2461
+ messages.push({ role, text: content });
2462
+ continue;
2463
+ }
2464
+ if (Array.isArray(content)) {
2465
+ const textParts = [];
2466
+ for (const block of content) {
2467
+ if (block.type === "text" && typeof block.text === "string") {
2468
+ textParts.push(block.text);
2469
+ }
2470
+ }
2471
+ if (textParts.length > 0) {
2472
+ messages.push({ role, text: textParts.join(`
2473
+ `) });
2474
+ }
2475
+ }
2476
+ }
2477
+ return messages;
2478
+ }
2479
+ function truncateTranscript(messages, maxBytes = 50000) {
2480
+ const lines = [];
2481
+ for (const msg of messages) {
2482
+ lines.push(`[${msg.role}]: ${msg.text}`);
2483
+ }
2484
+ const full = lines.join(`
2485
+ `);
2486
+ if (Buffer.byteLength(full, "utf-8") <= maxBytes)
2487
+ return full;
2488
+ let result = "";
2489
+ for (let i = lines.length - 1;i >= 0; i--) {
2490
+ const candidate = lines[i] + `
2491
+ ` + result;
2492
+ if (Buffer.byteLength(candidate, "utf-8") > maxBytes)
2493
+ break;
2494
+ result = candidate;
2495
+ }
2496
+ return result.trim();
2497
+ }
2498
+ async function analyzeTranscript(config, transcript, sessionId) {
2499
+ if (!config.candengo_url || !config.candengo_api_key)
2500
+ return null;
2501
+ const url = `${config.candengo_url}/v1/mem/transcript-analysis`;
2502
+ const controller = new AbortController;
2503
+ const timeout = setTimeout(() => controller.abort(), 30000);
2504
+ try {
2505
+ const response = await fetch(url, {
2506
+ method: "POST",
2507
+ headers: {
2508
+ "Content-Type": "application/json",
2509
+ Authorization: `Bearer ${config.candengo_api_key}`
2510
+ },
2511
+ body: JSON.stringify({
2512
+ transcript,
2513
+ session_id: sessionId
2514
+ }),
2515
+ signal: controller.signal
2516
+ });
2517
+ if (!response.ok)
2518
+ return null;
2519
+ const data = await response.json();
2520
+ if (!Array.isArray(data.plans) || !Array.isArray(data.decisions) || !Array.isArray(data.insights)) {
2521
+ return null;
2522
+ }
2523
+ return data;
2524
+ } catch {
2525
+ return null;
2526
+ } finally {
2527
+ clearTimeout(timeout);
2528
+ }
2529
+ }
2530
+ async function saveTranscriptResults(db, config, results, sessionId, cwd) {
2531
+ let saved = 0;
2532
+ const items = [
2533
+ ...results.plans.map((item) => ({ item, type: "decision" })),
2534
+ ...results.decisions.map((item) => ({ item, type: "decision" })),
2535
+ ...results.insights.map((item) => ({ item, type: "discovery" }))
2536
+ ];
2537
+ for (const { item, type } of items) {
2538
+ if (!item.title || item.title.trim().length === 0)
2539
+ continue;
2540
+ const result = await saveObservation(db, config, {
2541
+ type,
2542
+ title: item.title.slice(0, 80),
2543
+ narrative: item.narrative,
2544
+ concepts: item.concepts,
2545
+ session_id: sessionId,
2546
+ cwd
2547
+ });
2548
+ if (result.success)
2549
+ saved++;
2550
+ }
2551
+ return saved;
2552
+ }
2553
+
1671
2554
  // hooks/stop.ts
1672
2555
  function printRetrospective(summary) {
1673
2556
  const lines = [];
@@ -1777,6 +2660,22 @@ async function main() {
1777
2660
  createSessionDigest(db, event.session_id, event.cwd);
1778
2661
  } catch {}
1779
2662
  }
2663
+ if (config.transcript_analysis?.enabled && event.session_id) {
2664
+ try {
2665
+ const messages = readTranscript(event.session_id, event.cwd);
2666
+ if (messages.length > 10) {
2667
+ const transcript = truncateTranscript(messages);
2668
+ const results = await analyzeTranscript(config, transcript, event.session_id);
2669
+ if (results) {
2670
+ const saved = await saveTranscriptResults(db, config, results, event.session_id, event.cwd);
2671
+ if (saved > 0) {
2672
+ console.error(`
2673
+ \uD83D\uDCA1 Engrm: Extracted ${saved} insight(s) from session transcript.`);
2674
+ }
2675
+ }
2676
+ }
2677
+ } catch {}
2678
+ }
1780
2679
  await pushOnce(db, config);
1781
2680
  try {
1782
2681
  if (event.session_id) {