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.
- package/dist/cli.js +684 -12
- package/dist/hooks/elicitation-result.js +85 -5
- package/dist/hooks/post-tool-use.js +340 -5
- package/dist/hooks/pre-compact.js +106 -10
- package/dist/hooks/sentinel.js +90 -4
- package/dist/hooks/session-start.js +342 -11
- package/dist/hooks/stop.js +903 -4
- package/dist/server.js +155 -11
- 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
|
}
|
|
@@ -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) {
|