engrm 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +631 -11
- package/dist/hooks/elicitation-result.js +32 -4
- package/dist/hooks/post-tool-use.js +287 -4
- package/dist/hooks/pre-compact.js +55 -10
- package/dist/hooks/sentinel.js +32 -4
- package/dist/hooks/session-start.js +291 -11
- package/dist/hooks/stop.js +852 -4
- package/dist/server.js +102 -10
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -2330,6 +2358,229 @@ function extractFilesFromEvent(event) {
|
|
|
2330
2358
|
return { files_modified: [filePath] };
|
|
2331
2359
|
}
|
|
2332
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
|
+
|
|
2333
2584
|
// hooks/post-tool-use.ts
|
|
2334
2585
|
async function main() {
|
|
2335
2586
|
const chunks = [];
|
|
@@ -2357,6 +2608,9 @@ async function main() {
|
|
|
2357
2608
|
}
|
|
2358
2609
|
try {
|
|
2359
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);
|
|
2360
2614
|
const metricsIncrement = {
|
|
2361
2615
|
toolCalls: 1
|
|
2362
2616
|
};
|
|
@@ -2402,6 +2656,35 @@ async function main() {
|
|
|
2402
2656
|
});
|
|
2403
2657
|
}
|
|
2404
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
|
+
}
|
|
2405
2688
|
let saved = false;
|
|
2406
2689
|
if (config.observer?.enabled !== false) {
|
|
2407
2690
|
try {
|
|
@@ -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
|
}
|