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.
@@ -5,9 +5,9 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
5
5
 
6
6
  // src/config.ts
7
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
- import { homedir, hostname } from "node:os";
8
+ import { homedir, hostname, networkInterfaces } from "node:os";
9
9
  import { join } from "node:path";
10
- import { randomBytes } from "node:crypto";
10
+ import { createHash } from "node:crypto";
11
11
  var CONFIG_DIR = join(homedir(), ".engrm");
12
12
  var SETTINGS_PATH = join(CONFIG_DIR, "settings.json");
13
13
  var DB_PATH = join(CONFIG_DIR, "engrm.db");
@@ -16,7 +16,22 @@ function getDbPath() {
16
16
  }
17
17
  function generateDeviceId() {
18
18
  const host = hostname().toLowerCase().replace(/[^a-z0-9-]/g, "");
19
- const suffix = randomBytes(4).toString("hex");
19
+ let mac = "";
20
+ const ifaces = networkInterfaces();
21
+ for (const entries of Object.values(ifaces)) {
22
+ if (!entries)
23
+ continue;
24
+ for (const entry of entries) {
25
+ if (!entry.internal && entry.mac && entry.mac !== "00:00:00:00:00:00") {
26
+ mac = entry.mac;
27
+ break;
28
+ }
29
+ }
30
+ if (mac)
31
+ break;
32
+ }
33
+ const material = `${host}:${mac || "no-mac"}`;
34
+ const suffix = createHash("sha256").update(material).digest("hex").slice(0, 8);
20
35
  return `${host}-${suffix}`;
21
36
  }
22
37
  function createDefaultConfig() {
@@ -58,7 +73,10 @@ function createDefaultConfig() {
58
73
  observer: {
59
74
  enabled: true,
60
75
  mode: "per_event",
61
- model: "haiku"
76
+ model: "sonnet"
77
+ },
78
+ transcript_analysis: {
79
+ enabled: false
62
80
  }
63
81
  };
64
82
  }
@@ -117,6 +135,9 @@ function loadConfig() {
117
135
  enabled: asBool(config["observer"]?.["enabled"], defaults.observer.enabled),
118
136
  mode: asObserverMode(config["observer"]?.["mode"], defaults.observer.mode),
119
137
  model: asString(config["observer"]?.["model"], defaults.observer.model)
138
+ },
139
+ transcript_analysis: {
140
+ enabled: asBool(config["transcript_analysis"]?.["enabled"], defaults.transcript_analysis.enabled)
120
141
  }
121
142
  };
122
143
  }
@@ -1109,7 +1130,7 @@ function estimateTokens(text) {
1109
1130
  }
1110
1131
  function buildSessionContext(db, cwd, options = {}) {
1111
1132
  const opts = typeof options === "number" ? { maxCount: options } : options;
1112
- const tokenBudget = opts.tokenBudget ?? 800;
1133
+ const tokenBudget = opts.tokenBudget ?? 3000;
1113
1134
  const maxCount = opts.maxCount;
1114
1135
  const detected = detectProject(cwd);
1115
1136
  const project = db.getProjectByCanonicalId(detected.canonical_id);
@@ -1131,6 +1152,12 @@ function buildSessionContext(db, cwd, options = {}) {
1131
1152
  AND superseded_by IS NULL
1132
1153
  ORDER BY quality DESC, created_at_epoch DESC
1133
1154
  LIMIT ?`).all(project.id, MAX_PINNED);
1155
+ const MAX_RECENT = 5;
1156
+ const recent = db.db.query(`SELECT * FROM observations
1157
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging')
1158
+ AND superseded_by IS NULL
1159
+ ORDER BY created_at_epoch DESC
1160
+ LIMIT ?`).all(project.id, MAX_RECENT);
1134
1161
  const candidateLimit = maxCount ?? 50;
1135
1162
  const candidates = db.db.query(`SELECT * FROM observations
1136
1163
  WHERE project_id = ? AND lifecycle IN ('active', 'aging')
@@ -1158,6 +1185,12 @@ function buildSessionContext(db, cwd, options = {}) {
1158
1185
  });
1159
1186
  }
1160
1187
  const seenIds = new Set(pinned.map((o) => o.id));
1188
+ const dedupedRecent = recent.filter((o) => {
1189
+ if (seenIds.has(o.id))
1190
+ return false;
1191
+ seenIds.add(o.id);
1192
+ return true;
1193
+ });
1161
1194
  const deduped = candidates.filter((o) => !seenIds.has(o.id));
1162
1195
  for (const obs of crossProjectCandidates) {
1163
1196
  if (!seenIds.has(obs.id)) {
@@ -1174,8 +1207,8 @@ function buildSessionContext(db, cwd, options = {}) {
1174
1207
  return scoreB - scoreA;
1175
1208
  });
1176
1209
  if (maxCount !== undefined) {
1177
- const remaining = Math.max(0, maxCount - pinned.length);
1178
- const all = [...pinned, ...sorted.slice(0, remaining)];
1210
+ const remaining = Math.max(0, maxCount - pinned.length - dedupedRecent.length);
1211
+ const all = [...pinned, ...dedupedRecent, ...sorted.slice(0, remaining)];
1179
1212
  return {
1180
1213
  project_name: project.name,
1181
1214
  canonical_id: project.canonical_id,
@@ -1191,6 +1224,11 @@ function buildSessionContext(db, cwd, options = {}) {
1191
1224
  remainingBudget -= cost;
1192
1225
  selected.push(obs);
1193
1226
  }
1227
+ for (const obs of dedupedRecent) {
1228
+ const cost = estimateObservationTokens(obs, selected.length);
1229
+ remainingBudget -= cost;
1230
+ selected.push(obs);
1231
+ }
1194
1232
  for (const obs of sorted) {
1195
1233
  const cost = estimateObservationTokens(obs, selected.length);
1196
1234
  if (remainingBudget - cost < 0 && selected.length > 0)
@@ -1198,7 +1236,7 @@ function buildSessionContext(db, cwd, options = {}) {
1198
1236
  remainingBudget -= cost;
1199
1237
  selected.push(obs);
1200
1238
  }
1201
- const summaries = db.getRecentSummaries(project.id, 2);
1239
+ const summaries = db.getRecentSummaries(project.id, 5);
1202
1240
  let securityFindings = [];
1203
1241
  try {
1204
1242
  const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
@@ -1218,7 +1256,7 @@ function buildSessionContext(db, cwd, options = {}) {
1218
1256
  };
1219
1257
  }
1220
1258
  function estimateObservationTokens(obs, index) {
1221
- const DETAILED_THRESHOLD = 3;
1259
+ const DETAILED_THRESHOLD = 5;
1222
1260
  const titleCost = estimateTokens(`- **[${obs.type}]** ${obs.title} (2026-01-01, q=0.5)`);
1223
1261
  if (index >= DETAILED_THRESHOLD) {
1224
1262
  return titleCost;
@@ -1230,7 +1268,7 @@ function formatContextForInjection(context) {
1230
1268
  if (context.observations.length === 0) {
1231
1269
  return `Project: ${context.project_name} (no prior observations)`;
1232
1270
  }
1233
- const DETAILED_COUNT = 3;
1271
+ const DETAILED_COUNT = 5;
1234
1272
  const lines = [
1235
1273
  `## Project Memory: ${context.project_name}`,
1236
1274
  `${context.session_count} relevant observation(s) from prior sessions:`,
@@ -1517,6 +1555,202 @@ function recommendPacks(stacks, installedPacks) {
1517
1555
  return recommendations;
1518
1556
  }
1519
1557
 
1558
+ // src/config.ts
1559
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
1560
+ import { homedir as homedir2, hostname as hostname2, networkInterfaces as networkInterfaces2 } from "node:os";
1561
+ import { join as join5 } from "node:path";
1562
+ import { createHash as createHash2 } from "node:crypto";
1563
+ var CONFIG_DIR2 = join5(homedir2(), ".engrm");
1564
+ var SETTINGS_PATH2 = join5(CONFIG_DIR2, "settings.json");
1565
+ var DB_PATH2 = join5(CONFIG_DIR2, "engrm.db");
1566
+ function getDbPath2() {
1567
+ return DB_PATH2;
1568
+ }
1569
+ function generateDeviceId2() {
1570
+ const host = hostname2().toLowerCase().replace(/[^a-z0-9-]/g, "");
1571
+ let mac = "";
1572
+ const ifaces = networkInterfaces2();
1573
+ for (const entries of Object.values(ifaces)) {
1574
+ if (!entries)
1575
+ continue;
1576
+ for (const entry of entries) {
1577
+ if (!entry.internal && entry.mac && entry.mac !== "00:00:00:00:00:00") {
1578
+ mac = entry.mac;
1579
+ break;
1580
+ }
1581
+ }
1582
+ if (mac)
1583
+ break;
1584
+ }
1585
+ const material = `${host}:${mac || "no-mac"}`;
1586
+ const suffix = createHash2("sha256").update(material).digest("hex").slice(0, 8);
1587
+ return `${host}-${suffix}`;
1588
+ }
1589
+ function createDefaultConfig2() {
1590
+ return {
1591
+ candengo_url: "",
1592
+ candengo_api_key: "",
1593
+ site_id: "",
1594
+ namespace: "",
1595
+ user_id: "",
1596
+ user_email: "",
1597
+ device_id: generateDeviceId2(),
1598
+ teams: [],
1599
+ sync: {
1600
+ enabled: true,
1601
+ interval_seconds: 30,
1602
+ batch_size: 50
1603
+ },
1604
+ search: {
1605
+ default_limit: 10,
1606
+ local_boost: 1.2,
1607
+ scope: "all"
1608
+ },
1609
+ scrubbing: {
1610
+ enabled: true,
1611
+ custom_patterns: [],
1612
+ default_sensitivity: "shared"
1613
+ },
1614
+ sentinel: {
1615
+ enabled: false,
1616
+ mode: "advisory",
1617
+ provider: "openai",
1618
+ model: "gpt-4o-mini",
1619
+ api_key: "",
1620
+ base_url: "",
1621
+ skip_patterns: [],
1622
+ daily_limit: 100,
1623
+ tier: "free"
1624
+ },
1625
+ observer: {
1626
+ enabled: true,
1627
+ mode: "per_event",
1628
+ model: "sonnet"
1629
+ },
1630
+ transcript_analysis: {
1631
+ enabled: false
1632
+ }
1633
+ };
1634
+ }
1635
+ function loadConfig2() {
1636
+ if (!existsSync5(SETTINGS_PATH2)) {
1637
+ throw new Error(`Config not found at ${SETTINGS_PATH2}. Run 'engrm init --manual' to configure.`);
1638
+ }
1639
+ const raw = readFileSync4(SETTINGS_PATH2, "utf-8");
1640
+ let parsed;
1641
+ try {
1642
+ parsed = JSON.parse(raw);
1643
+ } catch {
1644
+ throw new Error(`Invalid JSON in ${SETTINGS_PATH2}`);
1645
+ }
1646
+ if (typeof parsed !== "object" || parsed === null) {
1647
+ throw new Error(`Config at ${SETTINGS_PATH2} is not a JSON object`);
1648
+ }
1649
+ const config = parsed;
1650
+ const defaults = createDefaultConfig2();
1651
+ return {
1652
+ candengo_url: asString2(config["candengo_url"], defaults.candengo_url),
1653
+ candengo_api_key: asString2(config["candengo_api_key"], defaults.candengo_api_key),
1654
+ site_id: asString2(config["site_id"], defaults.site_id),
1655
+ namespace: asString2(config["namespace"], defaults.namespace),
1656
+ user_id: asString2(config["user_id"], defaults.user_id),
1657
+ user_email: asString2(config["user_email"], defaults.user_email),
1658
+ device_id: asString2(config["device_id"], defaults.device_id),
1659
+ teams: asTeams2(config["teams"], defaults.teams),
1660
+ sync: {
1661
+ enabled: asBool2(config["sync"]?.["enabled"], defaults.sync.enabled),
1662
+ interval_seconds: asNumber2(config["sync"]?.["interval_seconds"], defaults.sync.interval_seconds),
1663
+ batch_size: asNumber2(config["sync"]?.["batch_size"], defaults.sync.batch_size)
1664
+ },
1665
+ search: {
1666
+ default_limit: asNumber2(config["search"]?.["default_limit"], defaults.search.default_limit),
1667
+ local_boost: asNumber2(config["search"]?.["local_boost"], defaults.search.local_boost),
1668
+ scope: asScope2(config["search"]?.["scope"], defaults.search.scope)
1669
+ },
1670
+ scrubbing: {
1671
+ enabled: asBool2(config["scrubbing"]?.["enabled"], defaults.scrubbing.enabled),
1672
+ custom_patterns: asStringArray2(config["scrubbing"]?.["custom_patterns"], defaults.scrubbing.custom_patterns),
1673
+ default_sensitivity: asSensitivity2(config["scrubbing"]?.["default_sensitivity"], defaults.scrubbing.default_sensitivity)
1674
+ },
1675
+ sentinel: {
1676
+ enabled: asBool2(config["sentinel"]?.["enabled"], defaults.sentinel.enabled),
1677
+ mode: asSentinelMode2(config["sentinel"]?.["mode"], defaults.sentinel.mode),
1678
+ provider: asLlmProvider2(config["sentinel"]?.["provider"], defaults.sentinel.provider),
1679
+ model: asString2(config["sentinel"]?.["model"], defaults.sentinel.model),
1680
+ api_key: asString2(config["sentinel"]?.["api_key"], defaults.sentinel.api_key),
1681
+ base_url: asString2(config["sentinel"]?.["base_url"], defaults.sentinel.base_url),
1682
+ skip_patterns: asStringArray2(config["sentinel"]?.["skip_patterns"], defaults.sentinel.skip_patterns),
1683
+ daily_limit: asNumber2(config["sentinel"]?.["daily_limit"], defaults.sentinel.daily_limit),
1684
+ tier: asTier2(config["sentinel"]?.["tier"], defaults.sentinel.tier)
1685
+ },
1686
+ observer: {
1687
+ enabled: asBool2(config["observer"]?.["enabled"], defaults.observer.enabled),
1688
+ mode: asObserverMode2(config["observer"]?.["mode"], defaults.observer.mode),
1689
+ model: asString2(config["observer"]?.["model"], defaults.observer.model)
1690
+ },
1691
+ transcript_analysis: {
1692
+ enabled: asBool2(config["transcript_analysis"]?.["enabled"], defaults.transcript_analysis.enabled)
1693
+ }
1694
+ };
1695
+ }
1696
+ function saveConfig(config) {
1697
+ if (!existsSync5(CONFIG_DIR2)) {
1698
+ mkdirSync2(CONFIG_DIR2, { recursive: true });
1699
+ }
1700
+ writeFileSync2(SETTINGS_PATH2, JSON.stringify(config, null, 2) + `
1701
+ `, "utf-8");
1702
+ }
1703
+ function configExists2() {
1704
+ return existsSync5(SETTINGS_PATH2);
1705
+ }
1706
+ function asString2(value, fallback) {
1707
+ return typeof value === "string" ? value : fallback;
1708
+ }
1709
+ function asNumber2(value, fallback) {
1710
+ return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
1711
+ }
1712
+ function asBool2(value, fallback) {
1713
+ return typeof value === "boolean" ? value : fallback;
1714
+ }
1715
+ function asStringArray2(value, fallback) {
1716
+ return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : fallback;
1717
+ }
1718
+ function asScope2(value, fallback) {
1719
+ if (value === "personal" || value === "team" || value === "all")
1720
+ return value;
1721
+ return fallback;
1722
+ }
1723
+ function asSensitivity2(value, fallback) {
1724
+ if (value === "shared" || value === "personal" || value === "secret")
1725
+ return value;
1726
+ return fallback;
1727
+ }
1728
+ function asSentinelMode2(value, fallback) {
1729
+ if (value === "advisory" || value === "blocking")
1730
+ return value;
1731
+ return fallback;
1732
+ }
1733
+ function asLlmProvider2(value, fallback) {
1734
+ if (value === "openai" || value === "anthropic" || value === "ollama" || value === "custom")
1735
+ return value;
1736
+ return fallback;
1737
+ }
1738
+ function asTier2(value, fallback) {
1739
+ if (value === "free" || value === "vibe" || value === "solo" || value === "pro" || value === "team" || value === "enterprise")
1740
+ return value;
1741
+ return fallback;
1742
+ }
1743
+ function asObserverMode2(value, fallback) {
1744
+ if (value === "per_event" || value === "per_session")
1745
+ return value;
1746
+ return fallback;
1747
+ }
1748
+ function asTeams2(value, fallback) {
1749
+ if (!Array.isArray(value))
1750
+ return fallback;
1751
+ return value.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string" && typeof t.name === "string" && typeof t.namespace === "string");
1752
+ }
1753
+
1520
1754
  // src/sync/auth.ts
1521
1755
  function getApiKey(config) {
1522
1756
  const envKey = process.env.ENGRM_TOKEN;
@@ -1702,6 +1936,44 @@ function extractNarrative(content) {
1702
1936
  `).trim();
1703
1937
  return narrative.length > 0 ? narrative : null;
1704
1938
  }
1939
+ async function pullSettings(client, config) {
1940
+ try {
1941
+ const settings = await client.fetchSettings();
1942
+ if (!settings)
1943
+ return false;
1944
+ let changed = false;
1945
+ if (settings.transcript_analysis !== undefined) {
1946
+ const ta = settings.transcript_analysis;
1947
+ if (typeof ta === "object" && ta !== null) {
1948
+ const taObj = ta;
1949
+ if (taObj.enabled !== undefined && taObj.enabled !== config.transcript_analysis.enabled) {
1950
+ config.transcript_analysis.enabled = !!taObj.enabled;
1951
+ changed = true;
1952
+ }
1953
+ }
1954
+ }
1955
+ if (settings.observer !== undefined) {
1956
+ const obs = settings.observer;
1957
+ if (typeof obs === "object" && obs !== null) {
1958
+ const obsObj = obs;
1959
+ if (obsObj.enabled !== undefined && obsObj.enabled !== config.observer.enabled) {
1960
+ config.observer.enabled = !!obsObj.enabled;
1961
+ changed = true;
1962
+ }
1963
+ if (obsObj.model !== undefined && typeof obsObj.model === "string" && obsObj.model !== config.observer.model) {
1964
+ config.observer.model = obsObj.model;
1965
+ changed = true;
1966
+ }
1967
+ }
1968
+ }
1969
+ if (changed) {
1970
+ saveConfig(config);
1971
+ }
1972
+ return changed;
1973
+ } catch {
1974
+ return false;
1975
+ }
1976
+ }
1705
1977
 
1706
1978
  // src/sync/client.ts
1707
1979
  class VectorClient {
@@ -1756,6 +2028,13 @@ class VectorClient {
1756
2028
  async sendTelemetry(beacon) {
1757
2029
  await this.request("POST", "/v1/mem/telemetry", beacon);
1758
2030
  }
2031
+ async fetchSettings() {
2032
+ try {
2033
+ return await this.request("GET", "/v1/mem/user-settings");
2034
+ } catch {
2035
+ return null;
2036
+ }
2037
+ }
1759
2038
  async health() {
1760
2039
  try {
1761
2040
  await this.request("GET", "/health");
@@ -1827,11 +2106,12 @@ async function main() {
1827
2106
  try {
1828
2107
  if (config.sync.enabled && config.candengo_api_key) {
1829
2108
  try {
1830
- const client = new VectorClient(config.candengo_url, config.candengo_api_key, config.site_id, config.namespace);
2109
+ const client = new VectorClient(config);
1831
2110
  const pullResult = await pullFromVector(db, client, config, 50);
1832
2111
  if (pullResult.merged > 0) {
1833
2112
  console.error(`Engrm: synced ${pullResult.merged} observation(s) from server`);
1834
2113
  }
2114
+ await pullSettings(client, config);
1835
2115
  } catch {}
1836
2116
  }
1837
2117
  const context = buildSessionContext(db, event.cwd, {