engrm 0.3.4 → 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
  }
@@ -1254,6 +1282,13 @@ class VectorClient {
1254
1282
  async sendTelemetry(beacon) {
1255
1283
  await this.request("POST", "/v1/mem/telemetry", beacon);
1256
1284
  }
1285
+ async fetchSettings() {
1286
+ try {
1287
+ return await this.request("GET", "/v1/mem/user-settings");
1288
+ } catch {
1289
+ return null;
1290
+ }
1291
+ }
1257
1292
  async health() {
1258
1293
  try {
1259
1294
  await this.request("GET", "/health");
@@ -1353,6 +1388,7 @@ function buildVectorDocument(obs, config, project) {
1353
1388
  project_name: project.name,
1354
1389
  user_id: obs.user_id,
1355
1390
  device_id: obs.device_id,
1391
+ device_name: __require("node:os").hostname(),
1356
1392
  agent: obs.agent,
1357
1393
  title: obs.title,
1358
1394
  narrative: obs.narrative,
@@ -1481,6 +1517,105 @@ async function pushOutbox(db, client, config, batchSize = 50) {
1481
1517
  return { pushed, failed, skipped };
1482
1518
  }
1483
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
+
1484
1619
  // src/sync/push-once.ts
1485
1620
  async function pushOnce(db, config) {
1486
1621
  if (!config.sync.enabled)
@@ -1490,6 +1625,7 @@ async function pushOnce(db, config) {
1490
1625
  try {
1491
1626
  const client = new VectorClient(config);
1492
1627
  const result = await pushOutbox(db, client, config, config.sync.batch_size);
1628
+ await pullSettings(client, config);
1493
1629
  return result.pushed;
1494
1630
  } catch {
1495
1631
  return 0;
@@ -1719,6 +1855,702 @@ function detectProject(directory) {
1719
1855
  };
1720
1856
  }
1721
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
+
1722
2554
  // hooks/stop.ts
1723
2555
  function printRetrospective(summary) {
1724
2556
  const lines = [];
@@ -1828,6 +2660,22 @@ async function main() {
1828
2660
  createSessionDigest(db, event.session_id, event.cwd);
1829
2661
  } catch {}
1830
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
+ }
1831
2679
  await pushOnce(db, config);
1832
2680
  try {
1833
2681
  if (event.session_id) {