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.
@@ -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 { randomBytes } from "node:crypto";
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
- const suffix = randomBytes(4).toString("hex");
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: "haiku"
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 { randomBytes } from "node:crypto";
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
- const suffix = randomBytes(4).toString("hex");
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: "haiku"
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 { randomBytes } from "node:crypto";
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
- const suffix = randomBytes(4).toString("hex");
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: "haiku"
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 ?? 800;
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, 2);
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 = 3;
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 = 3;
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:`,
@@ -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 { randomBytes } from "node:crypto";
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
- const suffix = randomBytes(4).toString("hex");
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: "haiku"
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
  }