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.
@@ -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
  }
@@ -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 {
@@ -1057,7 +1136,7 @@ function estimateTokens(text) {
1057
1136
  }
1058
1137
  function buildSessionContext(db, cwd, options = {}) {
1059
1138
  const opts = typeof options === "number" ? { maxCount: options } : options;
1060
- const tokenBudget = opts.tokenBudget ?? 800;
1139
+ const tokenBudget = opts.tokenBudget ?? 3000;
1061
1140
  const maxCount = opts.maxCount;
1062
1141
  const detected = detectProject(cwd);
1063
1142
  const project = db.getProjectByCanonicalId(detected.canonical_id);
@@ -1079,6 +1158,12 @@ function buildSessionContext(db, cwd, options = {}) {
1079
1158
  AND superseded_by IS NULL
1080
1159
  ORDER BY quality DESC, created_at_epoch DESC
1081
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);
1082
1167
  const candidateLimit = maxCount ?? 50;
1083
1168
  const candidates = db.db.query(`SELECT * FROM observations
1084
1169
  WHERE project_id = ? AND lifecycle IN ('active', 'aging')
@@ -1106,6 +1191,12 @@ function buildSessionContext(db, cwd, options = {}) {
1106
1191
  });
1107
1192
  }
1108
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
+ });
1109
1200
  const deduped = candidates.filter((o) => !seenIds.has(o.id));
1110
1201
  for (const obs of crossProjectCandidates) {
1111
1202
  if (!seenIds.has(obs.id)) {
@@ -1122,8 +1213,8 @@ function buildSessionContext(db, cwd, options = {}) {
1122
1213
  return scoreB - scoreA;
1123
1214
  });
1124
1215
  if (maxCount !== undefined) {
1125
- const remaining = Math.max(0, maxCount - pinned.length);
1126
- 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)];
1127
1218
  return {
1128
1219
  project_name: project.name,
1129
1220
  canonical_id: project.canonical_id,
@@ -1139,6 +1230,11 @@ function buildSessionContext(db, cwd, options = {}) {
1139
1230
  remainingBudget -= cost;
1140
1231
  selected.push(obs);
1141
1232
  }
1233
+ for (const obs of dedupedRecent) {
1234
+ const cost = estimateObservationTokens(obs, selected.length);
1235
+ remainingBudget -= cost;
1236
+ selected.push(obs);
1237
+ }
1142
1238
  for (const obs of sorted) {
1143
1239
  const cost = estimateObservationTokens(obs, selected.length);
1144
1240
  if (remainingBudget - cost < 0 && selected.length > 0)
@@ -1146,7 +1242,7 @@ function buildSessionContext(db, cwd, options = {}) {
1146
1242
  remainingBudget -= cost;
1147
1243
  selected.push(obs);
1148
1244
  }
1149
- const summaries = db.getRecentSummaries(project.id, 2);
1245
+ const summaries = db.getRecentSummaries(project.id, 5);
1150
1246
  let securityFindings = [];
1151
1247
  try {
1152
1248
  const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
@@ -1166,7 +1262,7 @@ function buildSessionContext(db, cwd, options = {}) {
1166
1262
  };
1167
1263
  }
1168
1264
  function estimateObservationTokens(obs, index) {
1169
- const DETAILED_THRESHOLD = 3;
1265
+ const DETAILED_THRESHOLD = 5;
1170
1266
  const titleCost = estimateTokens(`- **[${obs.type}]** ${obs.title} (2026-01-01, q=0.5)`);
1171
1267
  if (index >= DETAILED_THRESHOLD) {
1172
1268
  return titleCost;
@@ -1178,7 +1274,7 @@ function formatContextForInjection(context) {
1178
1274
  if (context.observations.length === 0) {
1179
1275
  return `Project: ${context.project_name} (no prior observations)`;
1180
1276
  }
1181
- const DETAILED_COUNT = 3;
1277
+ const DETAILED_COUNT = 5;
1182
1278
  const lines = [
1183
1279
  `## Project Memory: ${context.project_name}`,
1184
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
  }
@@ -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 {
@@ -1165,6 +1244,13 @@ async function main() {
1165
1244
  }
1166
1245
  try {
1167
1246
  const filePath = String(event.tool_input["file_path"] ?? "unknown");
1247
+ const defaultSkips = [/migrations?\./, /\.test\./, /\.spec\./, /\.lock$/, /package\.json$/];
1248
+ const customSkips = (config.sentinel.skip_patterns || []).map((p) => new RegExp(p));
1249
+ const allSkips = [...defaultSkips, ...customSkips];
1250
+ if (allSkips.some((re) => re.test(filePath))) {
1251
+ db.close();
1252
+ process.exit(0);
1253
+ }
1168
1254
  const content = event.tool_name === "Write" ? String(event.tool_input["content"] ?? "") : String(event.tool_input["new_string"] ?? "");
1169
1255
  const result = await auditCodeChange(config, db, event.tool_name, filePath, content);
1170
1256
  if (result.verdict === "PASS") {