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.
- package/dist/cli.js +631 -11
- package/dist/hooks/elicitation-result.js +32 -4
- package/dist/hooks/post-tool-use.js +287 -4
- package/dist/hooks/pre-compact.js +55 -10
- package/dist/hooks/sentinel.js +32 -4
- package/dist/hooks/session-start.js +291 -11
- package/dist/hooks/stop.js +852 -4
- package/dist/server.js +102 -10
- package/package.json +1 -1
package/dist/hooks/stop.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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: "
|
|
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) {
|