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
|
@@ -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 {
|
|
@@ -1537,7 +1616,8 @@ var VALID_TYPES = [
|
|
|
1537
1616
|
"feature",
|
|
1538
1617
|
"refactor",
|
|
1539
1618
|
"digest",
|
|
1540
|
-
"standard"
|
|
1619
|
+
"standard",
|
|
1620
|
+
"message"
|
|
1541
1621
|
];
|
|
1542
1622
|
async function saveObservation(db, config, input) {
|
|
1543
1623
|
if (!VALID_TYPES.includes(input.type)) {
|
|
@@ -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 {
|
|
@@ -1743,7 +1822,8 @@ var VALID_TYPES = [
|
|
|
1743
1822
|
"feature",
|
|
1744
1823
|
"refactor",
|
|
1745
1824
|
"digest",
|
|
1746
|
-
"standard"
|
|
1825
|
+
"standard",
|
|
1826
|
+
"message"
|
|
1747
1827
|
];
|
|
1748
1828
|
async function saveObservation(db, config, input) {
|
|
1749
1829
|
if (!VALID_TYPES.includes(input.type)) {
|
|
@@ -2278,6 +2358,229 @@ function extractFilesFromEvent(event) {
|
|
|
2278
2358
|
return { files_modified: [filePath] };
|
|
2279
2359
|
}
|
|
2280
2360
|
|
|
2361
|
+
// src/capture/recall.ts
|
|
2362
|
+
var VEC_DISTANCE_THRESHOLD = 0.25;
|
|
2363
|
+
function extractErrorSignature(output) {
|
|
2364
|
+
if (!output || output.length < 10)
|
|
2365
|
+
return null;
|
|
2366
|
+
const lines = output.split(`
|
|
2367
|
+
`);
|
|
2368
|
+
for (let i = lines.length - 1;i >= 0; i--) {
|
|
2369
|
+
const line = lines[i].trim();
|
|
2370
|
+
if (/^[A-Z]\w*(Error|Exception):\s/.test(line)) {
|
|
2371
|
+
return line.slice(0, 200);
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
for (const line of lines) {
|
|
2375
|
+
const trimmed = line.trim();
|
|
2376
|
+
if (/^(TypeError|ReferenceError|SyntaxError|RangeError|Error):\s/.test(trimmed)) {
|
|
2377
|
+
return trimmed.slice(0, 200);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
for (const line of lines) {
|
|
2381
|
+
const match = line.match(/panicked at '(.+?)'/);
|
|
2382
|
+
if (match)
|
|
2383
|
+
return `panic: ${match[1].slice(0, 180)}`;
|
|
2384
|
+
}
|
|
2385
|
+
for (const line of lines) {
|
|
2386
|
+
const trimmed = line.trim();
|
|
2387
|
+
if (trimmed.startsWith("panic:"))
|
|
2388
|
+
return trimmed.slice(0, 200);
|
|
2389
|
+
}
|
|
2390
|
+
for (const line of lines) {
|
|
2391
|
+
const trimmed = line.trim();
|
|
2392
|
+
if (/^(error|Error|ERROR)\b[:\[]/.test(trimmed) && trimmed.length > 10) {
|
|
2393
|
+
return trimmed.slice(0, 200);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
for (const line of lines) {
|
|
2397
|
+
const match = line.match(/(E[A-Z]{2,}): (.+)/);
|
|
2398
|
+
if (match && /^E[A-Z]+$/.test(match[1])) {
|
|
2399
|
+
return `${match[1]}: ${match[2].slice(0, 180)}`;
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
for (const line of lines) {
|
|
2403
|
+
const trimmed = line.trim();
|
|
2404
|
+
if (/^fatal:\s/.test(trimmed))
|
|
2405
|
+
return trimmed.slice(0, 200);
|
|
2406
|
+
}
|
|
2407
|
+
return null;
|
|
2408
|
+
}
|
|
2409
|
+
async function recallPastFix(db, errorSignature, projectId) {
|
|
2410
|
+
if (db.vecAvailable) {
|
|
2411
|
+
const embedding = await embedText(errorSignature);
|
|
2412
|
+
if (embedding) {
|
|
2413
|
+
const vecResults = db.searchVec(embedding, null, ["active", "aging", "pinned"], 10);
|
|
2414
|
+
for (const match of vecResults) {
|
|
2415
|
+
if (match.distance > VEC_DISTANCE_THRESHOLD)
|
|
2416
|
+
continue;
|
|
2417
|
+
const obs = db.getObservationById(match.observation_id);
|
|
2418
|
+
if (!obs)
|
|
2419
|
+
continue;
|
|
2420
|
+
if (obs.type !== "bugfix")
|
|
2421
|
+
continue;
|
|
2422
|
+
let projectName;
|
|
2423
|
+
if (projectId != null && obs.project_id !== projectId) {
|
|
2424
|
+
const proj = db.getProjectById(obs.project_id);
|
|
2425
|
+
if (proj)
|
|
2426
|
+
projectName = proj.name;
|
|
2427
|
+
}
|
|
2428
|
+
return {
|
|
2429
|
+
found: true,
|
|
2430
|
+
title: obs.title,
|
|
2431
|
+
narrative: truncateNarrative(obs.narrative, 200),
|
|
2432
|
+
observationId: obs.id,
|
|
2433
|
+
projectName,
|
|
2434
|
+
similarity: 1 - match.distance
|
|
2435
|
+
};
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
const ftsQuery = buildFtsQueryFromError(errorSignature);
|
|
2440
|
+
if (!ftsQuery)
|
|
2441
|
+
return { found: false };
|
|
2442
|
+
const ftsResults = db.searchFts(ftsQuery, null, ["active", "aging", "pinned"], 10);
|
|
2443
|
+
for (const match of ftsResults) {
|
|
2444
|
+
const obs = db.getObservationById(match.id);
|
|
2445
|
+
if (!obs)
|
|
2446
|
+
continue;
|
|
2447
|
+
if (obs.type !== "bugfix")
|
|
2448
|
+
continue;
|
|
2449
|
+
let projectName;
|
|
2450
|
+
if (projectId != null && obs.project_id !== projectId) {
|
|
2451
|
+
const proj = db.getProjectById(obs.project_id);
|
|
2452
|
+
if (proj)
|
|
2453
|
+
projectName = proj.name;
|
|
2454
|
+
}
|
|
2455
|
+
return {
|
|
2456
|
+
found: true,
|
|
2457
|
+
title: obs.title,
|
|
2458
|
+
narrative: truncateNarrative(obs.narrative, 200),
|
|
2459
|
+
observationId: obs.id,
|
|
2460
|
+
projectName
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
2463
|
+
return { found: false };
|
|
2464
|
+
}
|
|
2465
|
+
function buildFtsQueryFromError(error) {
|
|
2466
|
+
const cleaned = error.replace(/[{}()[\]^~*:'"`,.<>\/\\|]/g, " ").replace(/\b(at|in|of|the|is|to|from|for|with|a|an|and|or|not|no|on)\b/gi, " ").replace(/\b\d+\b/g, " ").replace(/\s+/g, " ").trim();
|
|
2467
|
+
const tokens = cleaned.split(" ").filter((t) => t.length >= 3);
|
|
2468
|
+
if (tokens.length === 0)
|
|
2469
|
+
return null;
|
|
2470
|
+
return tokens.slice(0, 5).join(" ");
|
|
2471
|
+
}
|
|
2472
|
+
function truncateNarrative(narrative, maxLen) {
|
|
2473
|
+
if (!narrative)
|
|
2474
|
+
return;
|
|
2475
|
+
if (narrative.length <= maxLen)
|
|
2476
|
+
return narrative;
|
|
2477
|
+
return narrative.slice(0, maxLen - 3) + "...";
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
// src/capture/fatigue.ts
|
|
2481
|
+
var DEBOUNCE_MINUTES = 10;
|
|
2482
|
+
var DEFAULT_AVG_SESSION_MINUTES = 90;
|
|
2483
|
+
var DEFAULT_P90_SESSION_MINUTES = 180;
|
|
2484
|
+
var ERROR_ACCELERATION_THRESHOLD = 2;
|
|
2485
|
+
var RECENT_WINDOW_MINUTES = 30;
|
|
2486
|
+
function computeUserStats(db) {
|
|
2487
|
+
const rows = db.db.query(`SELECT (completed_at_epoch - started_at_epoch) / 60.0 AS duration
|
|
2488
|
+
FROM sessions
|
|
2489
|
+
WHERE status = 'completed'
|
|
2490
|
+
AND started_at_epoch IS NOT NULL
|
|
2491
|
+
AND completed_at_epoch IS NOT NULL
|
|
2492
|
+
AND completed_at_epoch > started_at_epoch
|
|
2493
|
+
ORDER BY duration ASC`).all();
|
|
2494
|
+
if (rows.length < 3) {
|
|
2495
|
+
return {
|
|
2496
|
+
avgDurationMinutes: DEFAULT_AVG_SESSION_MINUTES,
|
|
2497
|
+
p90DurationMinutes: DEFAULT_P90_SESSION_MINUTES
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
const durations = rows.map((r) => r.duration);
|
|
2501
|
+
const sum = durations.reduce((a, b) => a + b, 0);
|
|
2502
|
+
const avg = sum / durations.length;
|
|
2503
|
+
const p90Index = Math.floor(durations.length * 0.9);
|
|
2504
|
+
const p90 = durations[Math.min(p90Index, durations.length - 1)];
|
|
2505
|
+
return {
|
|
2506
|
+
avgDurationMinutes: avg,
|
|
2507
|
+
p90DurationMinutes: p90
|
|
2508
|
+
};
|
|
2509
|
+
}
|
|
2510
|
+
function computeErrorAcceleration(db, sessionId, nowEpoch) {
|
|
2511
|
+
const recentWindowStart = nowEpoch - RECENT_WINDOW_MINUTES * 60;
|
|
2512
|
+
const sessionRow = db.db.query("SELECT started_at_epoch FROM sessions WHERE session_id = ?").get(sessionId);
|
|
2513
|
+
if (!sessionRow || !sessionRow.started_at_epoch) {
|
|
2514
|
+
return { ratio: 0, recentCount: 0, sessionCount: 0 };
|
|
2515
|
+
}
|
|
2516
|
+
const sessionStartEpoch = sessionRow.started_at_epoch;
|
|
2517
|
+
const sessionMinutes = (nowEpoch - sessionStartEpoch) / 60;
|
|
2518
|
+
if (sessionMinutes < 5) {
|
|
2519
|
+
return { ratio: 0, recentCount: 0, sessionCount: 0 };
|
|
2520
|
+
}
|
|
2521
|
+
const totalRow = db.db.query(`SELECT COUNT(*) as cnt FROM observations
|
|
2522
|
+
WHERE session_id = ? AND type = 'bugfix'`).get(sessionId);
|
|
2523
|
+
const sessionCount = totalRow?.cnt ?? 0;
|
|
2524
|
+
const recentRow = db.db.query(`SELECT COUNT(*) as cnt FROM observations
|
|
2525
|
+
WHERE session_id = ? AND type = 'bugfix' AND created_at_epoch >= ?`).get(sessionId, recentWindowStart);
|
|
2526
|
+
const recentCount = recentRow?.cnt ?? 0;
|
|
2527
|
+
if (sessionCount === 0) {
|
|
2528
|
+
return { ratio: 0, recentCount: 0, sessionCount: 0 };
|
|
2529
|
+
}
|
|
2530
|
+
const sessionRate = sessionCount / sessionMinutes * RECENT_WINDOW_MINUTES;
|
|
2531
|
+
const recentRate = recentCount;
|
|
2532
|
+
if (sessionRate === 0) {
|
|
2533
|
+
return { ratio: 0, recentCount, sessionCount };
|
|
2534
|
+
}
|
|
2535
|
+
return {
|
|
2536
|
+
ratio: recentRate / sessionRate,
|
|
2537
|
+
recentCount,
|
|
2538
|
+
sessionCount
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2541
|
+
function checkSessionFatigue(db, sessionId) {
|
|
2542
|
+
const nowEpoch = Math.floor(Date.now() / 1000);
|
|
2543
|
+
const debounceKey = `fatigue_last_check:${sessionId}`;
|
|
2544
|
+
const lastCheck = db.db.query("SELECT value FROM sync_state WHERE key = ?").get(debounceKey);
|
|
2545
|
+
if (lastCheck) {
|
|
2546
|
+
const lastCheckEpoch = parseInt(lastCheck.value, 10);
|
|
2547
|
+
if (nowEpoch - lastCheckEpoch < DEBOUNCE_MINUTES * 60) {
|
|
2548
|
+
return { fatigued: false, sessionMinutes: 0, recentErrorRate: 0 };
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
db.db.query("INSERT OR REPLACE INTO sync_state (key, value) VALUES (?, ?)").run(debounceKey, String(nowEpoch));
|
|
2552
|
+
const sessionRow = db.db.query("SELECT started_at_epoch FROM sessions WHERE session_id = ?").get(sessionId);
|
|
2553
|
+
if (!sessionRow || !sessionRow.started_at_epoch) {
|
|
2554
|
+
return { fatigued: false, sessionMinutes: 0, recentErrorRate: 0 };
|
|
2555
|
+
}
|
|
2556
|
+
const sessionMinutes = (nowEpoch - sessionRow.started_at_epoch) / 60;
|
|
2557
|
+
const acceleration = computeErrorAcceleration(db, sessionId, nowEpoch);
|
|
2558
|
+
const stats = computeUserStats(db);
|
|
2559
|
+
const reasons = [];
|
|
2560
|
+
if (acceleration.ratio >= ERROR_ACCELERATION_THRESHOLD && acceleration.recentCount >= 2) {
|
|
2561
|
+
reasons.push(`your error rate in the last 30 min is ${acceleration.ratio.toFixed(1)}x your session average`);
|
|
2562
|
+
}
|
|
2563
|
+
if (sessionMinutes > stats.p90DurationMinutes) {
|
|
2564
|
+
const hours = Math.floor(sessionMinutes / 60);
|
|
2565
|
+
const mins = Math.round(sessionMinutes % 60);
|
|
2566
|
+
reasons.push(`this session (${hours}h${mins}m) is longer than 90% of your past sessions`);
|
|
2567
|
+
}
|
|
2568
|
+
if (reasons.length === 0) {
|
|
2569
|
+
return {
|
|
2570
|
+
fatigued: false,
|
|
2571
|
+
sessionMinutes,
|
|
2572
|
+
recentErrorRate: acceleration.recentCount
|
|
2573
|
+
};
|
|
2574
|
+
}
|
|
2575
|
+
const message = `Consider taking a break — ${reasons.join(", and ")}.`;
|
|
2576
|
+
return {
|
|
2577
|
+
fatigued: true,
|
|
2578
|
+
message,
|
|
2579
|
+
sessionMinutes,
|
|
2580
|
+
recentErrorRate: acceleration.recentCount
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2281
2584
|
// hooks/post-tool-use.ts
|
|
2282
2585
|
async function main() {
|
|
2283
2586
|
const chunks = [];
|
|
@@ -2305,6 +2608,9 @@ async function main() {
|
|
|
2305
2608
|
}
|
|
2306
2609
|
try {
|
|
2307
2610
|
if (event.session_id) {
|
|
2611
|
+
const detected = detectProject(event.cwd);
|
|
2612
|
+
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
2613
|
+
db.upsertSession(event.session_id, project?.id ?? null, config.user_id, config.device_id);
|
|
2308
2614
|
const metricsIncrement = {
|
|
2309
2615
|
toolCalls: 1
|
|
2310
2616
|
};
|
|
@@ -2350,6 +2656,35 @@ async function main() {
|
|
|
2350
2656
|
});
|
|
2351
2657
|
}
|
|
2352
2658
|
}
|
|
2659
|
+
if (event.tool_name === "Bash" && event.tool_response) {
|
|
2660
|
+
const sig = extractErrorSignature(event.tool_response);
|
|
2661
|
+
if (sig) {
|
|
2662
|
+
try {
|
|
2663
|
+
const detected = detectProject(event.cwd);
|
|
2664
|
+
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
2665
|
+
const recall = await recallPastFix(db, sig, project?.id ?? null);
|
|
2666
|
+
if (recall.found) {
|
|
2667
|
+
const projectLabel = recall.projectName ? ` (from ${recall.projectName})` : "";
|
|
2668
|
+
console.error(`
|
|
2669
|
+
\uD83D\uDCA1 Engrm: You solved this before${projectLabel}: "${recall.title}"`);
|
|
2670
|
+
if (recall.narrative) {
|
|
2671
|
+
console.error(` ${recall.narrative}`);
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
} catch {}
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
if (event.tool_name === "Bash" && event.tool_response && event.session_id) {
|
|
2678
|
+
if (extractErrorSignature(event.tool_response)) {
|
|
2679
|
+
try {
|
|
2680
|
+
const fatigue = checkSessionFatigue(db, event.session_id);
|
|
2681
|
+
if (fatigue.fatigued && fatigue.message) {
|
|
2682
|
+
console.error(`
|
|
2683
|
+
\uD83D\uDCA1 Engrm: ${fatigue.message}`);
|
|
2684
|
+
}
|
|
2685
|
+
} catch {}
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2353
2688
|
let saved = false;
|
|
2354
2689
|
if (config.observer?.enabled !== false) {
|
|
2355
2690
|
try {
|