engrm 0.3.4 → 0.4.1
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 +329 -4
- package/dist/hooks/pre-compact.js +55 -10
- package/dist/hooks/sentinel.js +32 -4
- package/dist/hooks/session-start.js +468 -47
- package/dist/hooks/stop.js +961 -15
- package/dist/server.js +131 -10
- 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
|
}
|
|
@@ -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
|
}
|
|
@@ -2329,6 +2357,247 @@ function extractFilesFromEvent(event) {
|
|
|
2329
2357
|
}
|
|
2330
2358
|
return { files_modified: [filePath] };
|
|
2331
2359
|
}
|
|
2360
|
+
function incrementObserverSaveCount(sessionId) {
|
|
2361
|
+
try {
|
|
2362
|
+
const state = readState(sessionId);
|
|
2363
|
+
if (state) {
|
|
2364
|
+
state.saveCount = (state.saveCount ?? 0) + 1;
|
|
2365
|
+
writeState(sessionId, state);
|
|
2366
|
+
} else {
|
|
2367
|
+
if (!existsSync3(OBSERVER_DIR)) {
|
|
2368
|
+
mkdirSync2(OBSERVER_DIR, { recursive: true });
|
|
2369
|
+
}
|
|
2370
|
+
writeState(sessionId, {
|
|
2371
|
+
observerSessionId: "",
|
|
2372
|
+
eventCount: 0,
|
|
2373
|
+
saveCount: 1
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
} catch {}
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
// src/capture/recall.ts
|
|
2380
|
+
var VEC_DISTANCE_THRESHOLD = 0.25;
|
|
2381
|
+
function extractErrorSignature(output) {
|
|
2382
|
+
if (!output || output.length < 10)
|
|
2383
|
+
return null;
|
|
2384
|
+
const lines = output.split(`
|
|
2385
|
+
`);
|
|
2386
|
+
for (let i = lines.length - 1;i >= 0; i--) {
|
|
2387
|
+
const line = lines[i].trim();
|
|
2388
|
+
if (/^[A-Z]\w*(Error|Exception):\s/.test(line)) {
|
|
2389
|
+
return line.slice(0, 200);
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
for (const line of lines) {
|
|
2393
|
+
const trimmed = line.trim();
|
|
2394
|
+
if (/^(TypeError|ReferenceError|SyntaxError|RangeError|Error):\s/.test(trimmed)) {
|
|
2395
|
+
return trimmed.slice(0, 200);
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
for (const line of lines) {
|
|
2399
|
+
const match = line.match(/panicked at '(.+?)'/);
|
|
2400
|
+
if (match)
|
|
2401
|
+
return `panic: ${match[1].slice(0, 180)}`;
|
|
2402
|
+
}
|
|
2403
|
+
for (const line of lines) {
|
|
2404
|
+
const trimmed = line.trim();
|
|
2405
|
+
if (trimmed.startsWith("panic:"))
|
|
2406
|
+
return trimmed.slice(0, 200);
|
|
2407
|
+
}
|
|
2408
|
+
for (const line of lines) {
|
|
2409
|
+
const trimmed = line.trim();
|
|
2410
|
+
if (/^(error|Error|ERROR)\b[:\[]/.test(trimmed) && trimmed.length > 10) {
|
|
2411
|
+
return trimmed.slice(0, 200);
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
for (const line of lines) {
|
|
2415
|
+
const match = line.match(/(E[A-Z]{2,}): (.+)/);
|
|
2416
|
+
if (match && /^E[A-Z]+$/.test(match[1])) {
|
|
2417
|
+
return `${match[1]}: ${match[2].slice(0, 180)}`;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
for (const line of lines) {
|
|
2421
|
+
const trimmed = line.trim();
|
|
2422
|
+
if (/^fatal:\s/.test(trimmed))
|
|
2423
|
+
return trimmed.slice(0, 200);
|
|
2424
|
+
}
|
|
2425
|
+
return null;
|
|
2426
|
+
}
|
|
2427
|
+
async function recallPastFix(db, errorSignature, projectId) {
|
|
2428
|
+
if (db.vecAvailable) {
|
|
2429
|
+
const embedding = await embedText(errorSignature);
|
|
2430
|
+
if (embedding) {
|
|
2431
|
+
const vecResults = db.searchVec(embedding, null, ["active", "aging", "pinned"], 10);
|
|
2432
|
+
for (const match of vecResults) {
|
|
2433
|
+
if (match.distance > VEC_DISTANCE_THRESHOLD)
|
|
2434
|
+
continue;
|
|
2435
|
+
const obs = db.getObservationById(match.observation_id);
|
|
2436
|
+
if (!obs)
|
|
2437
|
+
continue;
|
|
2438
|
+
if (obs.type !== "bugfix")
|
|
2439
|
+
continue;
|
|
2440
|
+
let projectName;
|
|
2441
|
+
if (projectId != null && obs.project_id !== projectId) {
|
|
2442
|
+
const proj = db.getProjectById(obs.project_id);
|
|
2443
|
+
if (proj)
|
|
2444
|
+
projectName = proj.name;
|
|
2445
|
+
}
|
|
2446
|
+
return {
|
|
2447
|
+
found: true,
|
|
2448
|
+
title: obs.title,
|
|
2449
|
+
narrative: truncateNarrative(obs.narrative, 200),
|
|
2450
|
+
observationId: obs.id,
|
|
2451
|
+
projectName,
|
|
2452
|
+
similarity: 1 - match.distance
|
|
2453
|
+
};
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
const ftsQuery = buildFtsQueryFromError(errorSignature);
|
|
2458
|
+
if (!ftsQuery)
|
|
2459
|
+
return { found: false };
|
|
2460
|
+
const ftsResults = db.searchFts(ftsQuery, null, ["active", "aging", "pinned"], 10);
|
|
2461
|
+
for (const match of ftsResults) {
|
|
2462
|
+
const obs = db.getObservationById(match.id);
|
|
2463
|
+
if (!obs)
|
|
2464
|
+
continue;
|
|
2465
|
+
if (obs.type !== "bugfix")
|
|
2466
|
+
continue;
|
|
2467
|
+
let projectName;
|
|
2468
|
+
if (projectId != null && obs.project_id !== projectId) {
|
|
2469
|
+
const proj = db.getProjectById(obs.project_id);
|
|
2470
|
+
if (proj)
|
|
2471
|
+
projectName = proj.name;
|
|
2472
|
+
}
|
|
2473
|
+
return {
|
|
2474
|
+
found: true,
|
|
2475
|
+
title: obs.title,
|
|
2476
|
+
narrative: truncateNarrative(obs.narrative, 200),
|
|
2477
|
+
observationId: obs.id,
|
|
2478
|
+
projectName
|
|
2479
|
+
};
|
|
2480
|
+
}
|
|
2481
|
+
return { found: false };
|
|
2482
|
+
}
|
|
2483
|
+
function buildFtsQueryFromError(error) {
|
|
2484
|
+
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();
|
|
2485
|
+
const tokens = cleaned.split(" ").filter((t) => t.length >= 3);
|
|
2486
|
+
if (tokens.length === 0)
|
|
2487
|
+
return null;
|
|
2488
|
+
return tokens.slice(0, 5).join(" ");
|
|
2489
|
+
}
|
|
2490
|
+
function truncateNarrative(narrative, maxLen) {
|
|
2491
|
+
if (!narrative)
|
|
2492
|
+
return;
|
|
2493
|
+
if (narrative.length <= maxLen)
|
|
2494
|
+
return narrative;
|
|
2495
|
+
return narrative.slice(0, maxLen - 3) + "...";
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
// src/capture/fatigue.ts
|
|
2499
|
+
var DEBOUNCE_MINUTES = 10;
|
|
2500
|
+
var DEFAULT_AVG_SESSION_MINUTES = 90;
|
|
2501
|
+
var DEFAULT_P90_SESSION_MINUTES = 180;
|
|
2502
|
+
var ERROR_ACCELERATION_THRESHOLD = 2;
|
|
2503
|
+
var RECENT_WINDOW_MINUTES = 30;
|
|
2504
|
+
function computeUserStats(db) {
|
|
2505
|
+
const rows = db.db.query(`SELECT (completed_at_epoch - started_at_epoch) / 60.0 AS duration
|
|
2506
|
+
FROM sessions
|
|
2507
|
+
WHERE status = 'completed'
|
|
2508
|
+
AND started_at_epoch IS NOT NULL
|
|
2509
|
+
AND completed_at_epoch IS NOT NULL
|
|
2510
|
+
AND completed_at_epoch > started_at_epoch
|
|
2511
|
+
ORDER BY duration ASC`).all();
|
|
2512
|
+
if (rows.length < 3) {
|
|
2513
|
+
return {
|
|
2514
|
+
avgDurationMinutes: DEFAULT_AVG_SESSION_MINUTES,
|
|
2515
|
+
p90DurationMinutes: DEFAULT_P90_SESSION_MINUTES
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
const durations = rows.map((r) => r.duration);
|
|
2519
|
+
const sum = durations.reduce((a, b) => a + b, 0);
|
|
2520
|
+
const avg = sum / durations.length;
|
|
2521
|
+
const p90Index = Math.floor(durations.length * 0.9);
|
|
2522
|
+
const p90 = durations[Math.min(p90Index, durations.length - 1)];
|
|
2523
|
+
return {
|
|
2524
|
+
avgDurationMinutes: avg,
|
|
2525
|
+
p90DurationMinutes: p90
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
function computeErrorAcceleration(db, sessionId, nowEpoch) {
|
|
2529
|
+
const recentWindowStart = nowEpoch - RECENT_WINDOW_MINUTES * 60;
|
|
2530
|
+
const sessionRow = db.db.query("SELECT started_at_epoch FROM sessions WHERE session_id = ?").get(sessionId);
|
|
2531
|
+
if (!sessionRow || !sessionRow.started_at_epoch) {
|
|
2532
|
+
return { ratio: 0, recentCount: 0, sessionCount: 0 };
|
|
2533
|
+
}
|
|
2534
|
+
const sessionStartEpoch = sessionRow.started_at_epoch;
|
|
2535
|
+
const sessionMinutes = (nowEpoch - sessionStartEpoch) / 60;
|
|
2536
|
+
if (sessionMinutes < 5) {
|
|
2537
|
+
return { ratio: 0, recentCount: 0, sessionCount: 0 };
|
|
2538
|
+
}
|
|
2539
|
+
const totalRow = db.db.query(`SELECT COUNT(*) as cnt FROM observations
|
|
2540
|
+
WHERE session_id = ? AND type = 'bugfix'`).get(sessionId);
|
|
2541
|
+
const sessionCount = totalRow?.cnt ?? 0;
|
|
2542
|
+
const recentRow = db.db.query(`SELECT COUNT(*) as cnt FROM observations
|
|
2543
|
+
WHERE session_id = ? AND type = 'bugfix' AND created_at_epoch >= ?`).get(sessionId, recentWindowStart);
|
|
2544
|
+
const recentCount = recentRow?.cnt ?? 0;
|
|
2545
|
+
if (sessionCount === 0) {
|
|
2546
|
+
return { ratio: 0, recentCount: 0, sessionCount: 0 };
|
|
2547
|
+
}
|
|
2548
|
+
const sessionRate = sessionCount / sessionMinutes * RECENT_WINDOW_MINUTES;
|
|
2549
|
+
const recentRate = recentCount;
|
|
2550
|
+
if (sessionRate === 0) {
|
|
2551
|
+
return { ratio: 0, recentCount, sessionCount };
|
|
2552
|
+
}
|
|
2553
|
+
return {
|
|
2554
|
+
ratio: recentRate / sessionRate,
|
|
2555
|
+
recentCount,
|
|
2556
|
+
sessionCount
|
|
2557
|
+
};
|
|
2558
|
+
}
|
|
2559
|
+
function checkSessionFatigue(db, sessionId) {
|
|
2560
|
+
const nowEpoch = Math.floor(Date.now() / 1000);
|
|
2561
|
+
const debounceKey = `fatigue_last_check:${sessionId}`;
|
|
2562
|
+
const lastCheck = db.db.query("SELECT value FROM sync_state WHERE key = ?").get(debounceKey);
|
|
2563
|
+
if (lastCheck) {
|
|
2564
|
+
const lastCheckEpoch = parseInt(lastCheck.value, 10);
|
|
2565
|
+
if (nowEpoch - lastCheckEpoch < DEBOUNCE_MINUTES * 60) {
|
|
2566
|
+
return { fatigued: false, sessionMinutes: 0, recentErrorRate: 0 };
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
db.db.query("INSERT OR REPLACE INTO sync_state (key, value) VALUES (?, ?)").run(debounceKey, String(nowEpoch));
|
|
2570
|
+
const sessionRow = db.db.query("SELECT started_at_epoch FROM sessions WHERE session_id = ?").get(sessionId);
|
|
2571
|
+
if (!sessionRow || !sessionRow.started_at_epoch) {
|
|
2572
|
+
return { fatigued: false, sessionMinutes: 0, recentErrorRate: 0 };
|
|
2573
|
+
}
|
|
2574
|
+
const sessionMinutes = (nowEpoch - sessionRow.started_at_epoch) / 60;
|
|
2575
|
+
const acceleration = computeErrorAcceleration(db, sessionId, nowEpoch);
|
|
2576
|
+
const stats = computeUserStats(db);
|
|
2577
|
+
const reasons = [];
|
|
2578
|
+
if (acceleration.ratio >= ERROR_ACCELERATION_THRESHOLD && acceleration.recentCount >= 2) {
|
|
2579
|
+
reasons.push(`your error rate in the last 30 min is ${acceleration.ratio.toFixed(1)}x your session average`);
|
|
2580
|
+
}
|
|
2581
|
+
if (sessionMinutes > stats.p90DurationMinutes) {
|
|
2582
|
+
const hours = Math.floor(sessionMinutes / 60);
|
|
2583
|
+
const mins = Math.round(sessionMinutes % 60);
|
|
2584
|
+
reasons.push(`this session (${hours}h${mins}m) is longer than 90% of your past sessions`);
|
|
2585
|
+
}
|
|
2586
|
+
if (reasons.length === 0) {
|
|
2587
|
+
return {
|
|
2588
|
+
fatigued: false,
|
|
2589
|
+
sessionMinutes,
|
|
2590
|
+
recentErrorRate: acceleration.recentCount
|
|
2591
|
+
};
|
|
2592
|
+
}
|
|
2593
|
+
const message = `Consider taking a break — ${reasons.join(", and ")}.`;
|
|
2594
|
+
return {
|
|
2595
|
+
fatigued: true,
|
|
2596
|
+
message,
|
|
2597
|
+
sessionMinutes,
|
|
2598
|
+
recentErrorRate: acceleration.recentCount
|
|
2599
|
+
};
|
|
2600
|
+
}
|
|
2332
2601
|
|
|
2333
2602
|
// hooks/post-tool-use.ts
|
|
2334
2603
|
async function main() {
|
|
@@ -2357,6 +2626,9 @@ async function main() {
|
|
|
2357
2626
|
}
|
|
2358
2627
|
try {
|
|
2359
2628
|
if (event.session_id) {
|
|
2629
|
+
const detected = detectProject(event.cwd);
|
|
2630
|
+
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
2631
|
+
db.upsertSession(event.session_id, project?.id ?? null, config.user_id, config.device_id);
|
|
2360
2632
|
const metricsIncrement = {
|
|
2361
2633
|
toolCalls: 1
|
|
2362
2634
|
};
|
|
@@ -2402,6 +2674,36 @@ async function main() {
|
|
|
2402
2674
|
});
|
|
2403
2675
|
}
|
|
2404
2676
|
}
|
|
2677
|
+
if (event.tool_name === "Bash" && event.tool_response) {
|
|
2678
|
+
const sig = extractErrorSignature(event.tool_response);
|
|
2679
|
+
if (sig) {
|
|
2680
|
+
try {
|
|
2681
|
+
const detected = detectProject(event.cwd);
|
|
2682
|
+
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
2683
|
+
const recall = await recallPastFix(db, sig, project?.id ?? null);
|
|
2684
|
+
incrementRecallMetrics(event.session_id, recall.found);
|
|
2685
|
+
if (recall.found) {
|
|
2686
|
+
const projectLabel = recall.projectName ? ` (from ${recall.projectName})` : "";
|
|
2687
|
+
console.error(`
|
|
2688
|
+
\uD83D\uDCA1 Engrm: You solved this before${projectLabel}: "${recall.title}"`);
|
|
2689
|
+
if (recall.narrative) {
|
|
2690
|
+
console.error(` ${recall.narrative}`);
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
} catch {}
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
if (event.tool_name === "Bash" && event.tool_response && event.session_id) {
|
|
2697
|
+
if (extractErrorSignature(event.tool_response)) {
|
|
2698
|
+
try {
|
|
2699
|
+
const fatigue = checkSessionFatigue(db, event.session_id);
|
|
2700
|
+
if (fatigue.fatigued && fatigue.message) {
|
|
2701
|
+
console.error(`
|
|
2702
|
+
\uD83D\uDCA1 Engrm: ${fatigue.message}`);
|
|
2703
|
+
}
|
|
2704
|
+
} catch {}
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2405
2707
|
let saved = false;
|
|
2406
2708
|
if (config.observer?.enabled !== false) {
|
|
2407
2709
|
try {
|
|
@@ -2410,6 +2712,7 @@ async function main() {
|
|
|
2410
2712
|
});
|
|
2411
2713
|
if (observed) {
|
|
2412
2714
|
await saveObservation(db, config, observed);
|
|
2715
|
+
incrementObserverSaveCount(event.session_id);
|
|
2413
2716
|
saved = true;
|
|
2414
2717
|
}
|
|
2415
2718
|
} catch {}
|
|
@@ -2426,6 +2729,7 @@ async function main() {
|
|
|
2426
2729
|
session_id: event.session_id,
|
|
2427
2730
|
cwd: event.cwd
|
|
2428
2731
|
});
|
|
2732
|
+
incrementObserverSaveCount(event.session_id);
|
|
2429
2733
|
}
|
|
2430
2734
|
}
|
|
2431
2735
|
} finally {
|
|
@@ -2461,6 +2765,27 @@ function extractScanText(event) {
|
|
|
2461
2765
|
return null;
|
|
2462
2766
|
}
|
|
2463
2767
|
}
|
|
2768
|
+
function incrementRecallMetrics(sessionId, hit) {
|
|
2769
|
+
try {
|
|
2770
|
+
const { existsSync: existsSync4, readFileSync: readFileSync4, writeFileSync: writeFileSync3, mkdirSync: mkdirSync3 } = __require("node:fs");
|
|
2771
|
+
const { join: join4 } = __require("node:path");
|
|
2772
|
+
const { homedir: homedir3 } = __require("node:os");
|
|
2773
|
+
const dir = join4(homedir3(), ".engrm", "observer-sessions");
|
|
2774
|
+
const path = join4(dir, `${sessionId}.json`);
|
|
2775
|
+
let state = {};
|
|
2776
|
+
if (existsSync4(path)) {
|
|
2777
|
+
state = JSON.parse(readFileSync4(path, "utf-8"));
|
|
2778
|
+
} else {
|
|
2779
|
+
if (!existsSync4(dir))
|
|
2780
|
+
mkdirSync3(dir, { recursive: true });
|
|
2781
|
+
}
|
|
2782
|
+
state.recallAttempts = (state.recallAttempts || 0) + 1;
|
|
2783
|
+
if (hit) {
|
|
2784
|
+
state.recallHits = (state.recallHits || 0) + 1;
|
|
2785
|
+
}
|
|
2786
|
+
writeFileSync3(path, JSON.stringify(state), "utf-8");
|
|
2787
|
+
} catch {}
|
|
2788
|
+
}
|
|
2464
2789
|
main().catch(() => {
|
|
2465
2790
|
process.exit(0);
|
|
2466
2791
|
});
|
|
@@ -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
|
}
|
|
@@ -1108,7 +1136,7 @@ function estimateTokens(text) {
|
|
|
1108
1136
|
}
|
|
1109
1137
|
function buildSessionContext(db, cwd, options = {}) {
|
|
1110
1138
|
const opts = typeof options === "number" ? { maxCount: options } : options;
|
|
1111
|
-
const tokenBudget = opts.tokenBudget ??
|
|
1139
|
+
const tokenBudget = opts.tokenBudget ?? 3000;
|
|
1112
1140
|
const maxCount = opts.maxCount;
|
|
1113
1141
|
const detected = detectProject(cwd);
|
|
1114
1142
|
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
@@ -1130,6 +1158,12 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1130
1158
|
AND superseded_by IS NULL
|
|
1131
1159
|
ORDER BY quality DESC, created_at_epoch DESC
|
|
1132
1160
|
LIMIT ?`).all(project.id, MAX_PINNED);
|
|
1161
|
+
const MAX_RECENT = 5;
|
|
1162
|
+
const recent = db.db.query(`SELECT * FROM observations
|
|
1163
|
+
WHERE project_id = ? AND lifecycle IN ('active', 'aging')
|
|
1164
|
+
AND superseded_by IS NULL
|
|
1165
|
+
ORDER BY created_at_epoch DESC
|
|
1166
|
+
LIMIT ?`).all(project.id, MAX_RECENT);
|
|
1133
1167
|
const candidateLimit = maxCount ?? 50;
|
|
1134
1168
|
const candidates = db.db.query(`SELECT * FROM observations
|
|
1135
1169
|
WHERE project_id = ? AND lifecycle IN ('active', 'aging')
|
|
@@ -1157,6 +1191,12 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1157
1191
|
});
|
|
1158
1192
|
}
|
|
1159
1193
|
const seenIds = new Set(pinned.map((o) => o.id));
|
|
1194
|
+
const dedupedRecent = recent.filter((o) => {
|
|
1195
|
+
if (seenIds.has(o.id))
|
|
1196
|
+
return false;
|
|
1197
|
+
seenIds.add(o.id);
|
|
1198
|
+
return true;
|
|
1199
|
+
});
|
|
1160
1200
|
const deduped = candidates.filter((o) => !seenIds.has(o.id));
|
|
1161
1201
|
for (const obs of crossProjectCandidates) {
|
|
1162
1202
|
if (!seenIds.has(obs.id)) {
|
|
@@ -1173,8 +1213,8 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1173
1213
|
return scoreB - scoreA;
|
|
1174
1214
|
});
|
|
1175
1215
|
if (maxCount !== undefined) {
|
|
1176
|
-
const remaining = Math.max(0, maxCount - pinned.length);
|
|
1177
|
-
const all = [...pinned, ...sorted.slice(0, remaining)];
|
|
1216
|
+
const remaining = Math.max(0, maxCount - pinned.length - dedupedRecent.length);
|
|
1217
|
+
const all = [...pinned, ...dedupedRecent, ...sorted.slice(0, remaining)];
|
|
1178
1218
|
return {
|
|
1179
1219
|
project_name: project.name,
|
|
1180
1220
|
canonical_id: project.canonical_id,
|
|
@@ -1190,6 +1230,11 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1190
1230
|
remainingBudget -= cost;
|
|
1191
1231
|
selected.push(obs);
|
|
1192
1232
|
}
|
|
1233
|
+
for (const obs of dedupedRecent) {
|
|
1234
|
+
const cost = estimateObservationTokens(obs, selected.length);
|
|
1235
|
+
remainingBudget -= cost;
|
|
1236
|
+
selected.push(obs);
|
|
1237
|
+
}
|
|
1193
1238
|
for (const obs of sorted) {
|
|
1194
1239
|
const cost = estimateObservationTokens(obs, selected.length);
|
|
1195
1240
|
if (remainingBudget - cost < 0 && selected.length > 0)
|
|
@@ -1197,7 +1242,7 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1197
1242
|
remainingBudget -= cost;
|
|
1198
1243
|
selected.push(obs);
|
|
1199
1244
|
}
|
|
1200
|
-
const summaries = db.getRecentSummaries(project.id,
|
|
1245
|
+
const summaries = db.getRecentSummaries(project.id, 5);
|
|
1201
1246
|
let securityFindings = [];
|
|
1202
1247
|
try {
|
|
1203
1248
|
const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
|
|
@@ -1217,7 +1262,7 @@ function buildSessionContext(db, cwd, options = {}) {
|
|
|
1217
1262
|
};
|
|
1218
1263
|
}
|
|
1219
1264
|
function estimateObservationTokens(obs, index) {
|
|
1220
|
-
const DETAILED_THRESHOLD =
|
|
1265
|
+
const DETAILED_THRESHOLD = 5;
|
|
1221
1266
|
const titleCost = estimateTokens(`- **[${obs.type}]** ${obs.title} (2026-01-01, q=0.5)`);
|
|
1222
1267
|
if (index >= DETAILED_THRESHOLD) {
|
|
1223
1268
|
return titleCost;
|
|
@@ -1229,7 +1274,7 @@ function formatContextForInjection(context) {
|
|
|
1229
1274
|
if (context.observations.length === 0) {
|
|
1230
1275
|
return `Project: ${context.project_name} (no prior observations)`;
|
|
1231
1276
|
}
|
|
1232
|
-
const DETAILED_COUNT =
|
|
1277
|
+
const DETAILED_COUNT = 5;
|
|
1233
1278
|
const lines = [
|
|
1234
1279
|
`## Project Memory: ${context.project_name}`,
|
|
1235
1280
|
`${context.session_count} relevant observation(s) from prior sessions:`,
|
package/dist/hooks/sentinel.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
|
}
|