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.
@@ -3,11 +3,16 @@
3
3
  import { createRequire } from "node:module";
4
4
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
5
5
 
6
+ // hooks/session-start.ts
7
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
8
+ import { join as join7 } from "path";
9
+ import { homedir as homedir4 } from "os";
10
+
6
11
  // src/config.ts
7
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
- import { homedir, hostname } from "node:os";
13
+ import { homedir, hostname, networkInterfaces } from "node:os";
9
14
  import { join } from "node:path";
10
- import { randomBytes } from "node:crypto";
15
+ import { createHash } from "node:crypto";
11
16
  var CONFIG_DIR = join(homedir(), ".engrm");
12
17
  var SETTINGS_PATH = join(CONFIG_DIR, "settings.json");
13
18
  var DB_PATH = join(CONFIG_DIR, "engrm.db");
@@ -16,7 +21,22 @@ function getDbPath() {
16
21
  }
17
22
  function generateDeviceId() {
18
23
  const host = hostname().toLowerCase().replace(/[^a-z0-9-]/g, "");
19
- const suffix = randomBytes(4).toString("hex");
24
+ let mac = "";
25
+ const ifaces = networkInterfaces();
26
+ for (const entries of Object.values(ifaces)) {
27
+ if (!entries)
28
+ continue;
29
+ for (const entry of entries) {
30
+ if (!entry.internal && entry.mac && entry.mac !== "00:00:00:00:00:00") {
31
+ mac = entry.mac;
32
+ break;
33
+ }
34
+ }
35
+ if (mac)
36
+ break;
37
+ }
38
+ const material = `${host}:${mac || "no-mac"}`;
39
+ const suffix = createHash("sha256").update(material).digest("hex").slice(0, 8);
20
40
  return `${host}-${suffix}`;
21
41
  }
22
42
  function createDefaultConfig() {
@@ -58,7 +78,10 @@ function createDefaultConfig() {
58
78
  observer: {
59
79
  enabled: true,
60
80
  mode: "per_event",
61
- model: "haiku"
81
+ model: "sonnet"
82
+ },
83
+ transcript_analysis: {
84
+ enabled: false
62
85
  }
63
86
  };
64
87
  }
@@ -117,6 +140,9 @@ function loadConfig() {
117
140
  enabled: asBool(config["observer"]?.["enabled"], defaults.observer.enabled),
118
141
  mode: asObserverMode(config["observer"]?.["mode"], defaults.observer.mode),
119
142
  model: asString(config["observer"]?.["model"], defaults.observer.model)
143
+ },
144
+ transcript_analysis: {
145
+ enabled: asBool(config["transcript_analysis"]?.["enabled"], defaults.transcript_analysis.enabled)
120
146
  }
121
147
  };
122
148
  }
@@ -1109,7 +1135,7 @@ function estimateTokens(text) {
1109
1135
  }
1110
1136
  function buildSessionContext(db, cwd, options = {}) {
1111
1137
  const opts = typeof options === "number" ? { maxCount: options } : options;
1112
- const tokenBudget = opts.tokenBudget ?? 800;
1138
+ const tokenBudget = opts.tokenBudget ?? 3000;
1113
1139
  const maxCount = opts.maxCount;
1114
1140
  const detected = detectProject(cwd);
1115
1141
  const project = db.getProjectByCanonicalId(detected.canonical_id);
@@ -1131,6 +1157,12 @@ function buildSessionContext(db, cwd, options = {}) {
1131
1157
  AND superseded_by IS NULL
1132
1158
  ORDER BY quality DESC, created_at_epoch DESC
1133
1159
  LIMIT ?`).all(project.id, MAX_PINNED);
1160
+ const MAX_RECENT = 5;
1161
+ const recent = db.db.query(`SELECT * FROM observations
1162
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging')
1163
+ AND superseded_by IS NULL
1164
+ ORDER BY created_at_epoch DESC
1165
+ LIMIT ?`).all(project.id, MAX_RECENT);
1134
1166
  const candidateLimit = maxCount ?? 50;
1135
1167
  const candidates = db.db.query(`SELECT * FROM observations
1136
1168
  WHERE project_id = ? AND lifecycle IN ('active', 'aging')
@@ -1158,6 +1190,12 @@ function buildSessionContext(db, cwd, options = {}) {
1158
1190
  });
1159
1191
  }
1160
1192
  const seenIds = new Set(pinned.map((o) => o.id));
1193
+ const dedupedRecent = recent.filter((o) => {
1194
+ if (seenIds.has(o.id))
1195
+ return false;
1196
+ seenIds.add(o.id);
1197
+ return true;
1198
+ });
1161
1199
  const deduped = candidates.filter((o) => !seenIds.has(o.id));
1162
1200
  for (const obs of crossProjectCandidates) {
1163
1201
  if (!seenIds.has(obs.id)) {
@@ -1174,8 +1212,8 @@ function buildSessionContext(db, cwd, options = {}) {
1174
1212
  return scoreB - scoreA;
1175
1213
  });
1176
1214
  if (maxCount !== undefined) {
1177
- const remaining = Math.max(0, maxCount - pinned.length);
1178
- const all = [...pinned, ...sorted.slice(0, remaining)];
1215
+ const remaining = Math.max(0, maxCount - pinned.length - dedupedRecent.length);
1216
+ const all = [...pinned, ...dedupedRecent, ...sorted.slice(0, remaining)];
1179
1217
  return {
1180
1218
  project_name: project.name,
1181
1219
  canonical_id: project.canonical_id,
@@ -1191,6 +1229,11 @@ function buildSessionContext(db, cwd, options = {}) {
1191
1229
  remainingBudget -= cost;
1192
1230
  selected.push(obs);
1193
1231
  }
1232
+ for (const obs of dedupedRecent) {
1233
+ const cost = estimateObservationTokens(obs, selected.length);
1234
+ remainingBudget -= cost;
1235
+ selected.push(obs);
1236
+ }
1194
1237
  for (const obs of sorted) {
1195
1238
  const cost = estimateObservationTokens(obs, selected.length);
1196
1239
  if (remainingBudget - cost < 0 && selected.length > 0)
@@ -1198,7 +1241,7 @@ function buildSessionContext(db, cwd, options = {}) {
1198
1241
  remainingBudget -= cost;
1199
1242
  selected.push(obs);
1200
1243
  }
1201
- const summaries = db.getRecentSummaries(project.id, 2);
1244
+ const summaries = db.getRecentSummaries(project.id, 5);
1202
1245
  let securityFindings = [];
1203
1246
  try {
1204
1247
  const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
@@ -1218,7 +1261,7 @@ function buildSessionContext(db, cwd, options = {}) {
1218
1261
  };
1219
1262
  }
1220
1263
  function estimateObservationTokens(obs, index) {
1221
- const DETAILED_THRESHOLD = 3;
1264
+ const DETAILED_THRESHOLD = 5;
1222
1265
  const titleCost = estimateTokens(`- **[${obs.type}]** ${obs.title} (2026-01-01, q=0.5)`);
1223
1266
  if (index >= DETAILED_THRESHOLD) {
1224
1267
  return titleCost;
@@ -1230,7 +1273,7 @@ function formatContextForInjection(context) {
1230
1273
  if (context.observations.length === 0) {
1231
1274
  return `Project: ${context.project_name} (no prior observations)`;
1232
1275
  }
1233
- const DETAILED_COUNT = 3;
1276
+ const DETAILED_COUNT = 5;
1234
1277
  const lines = [
1235
1278
  `## Project Memory: ${context.project_name}`,
1236
1279
  `${context.session_count} relevant observation(s) from prior sessions:`,
@@ -1455,9 +1498,83 @@ function detectStacksFromProject(projectRoot, filePaths = []) {
1455
1498
  return { stacks: sorted, primary };
1456
1499
  }
1457
1500
 
1501
+ // src/telemetry/config-fingerprint.ts
1502
+ import { createHash as createHash2 } from "node:crypto";
1503
+ import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2, readdirSync, mkdirSync as mkdirSync2 } from "node:fs";
1504
+ import { join as join4 } from "node:path";
1505
+ import { homedir as homedir2 } from "node:os";
1506
+ var STATE_PATH = join4(homedir2(), ".engrm", "config-fingerprint.json");
1507
+ var CLIENT_VERSION = "0.4.0";
1508
+ function hashFile(filePath) {
1509
+ try {
1510
+ if (!existsSync4(filePath))
1511
+ return null;
1512
+ const content = readFileSync3(filePath, "utf-8");
1513
+ return createHash2("sha256").update(content).digest("hex");
1514
+ } catch {
1515
+ return null;
1516
+ }
1517
+ }
1518
+ function countMemoryFiles(memoryDir) {
1519
+ try {
1520
+ if (!existsSync4(memoryDir))
1521
+ return 0;
1522
+ return readdirSync(memoryDir).filter((f) => f.endsWith(".md")).length;
1523
+ } catch {
1524
+ return 0;
1525
+ }
1526
+ }
1527
+ function readPreviousFingerprint() {
1528
+ try {
1529
+ if (!existsSync4(STATE_PATH))
1530
+ return null;
1531
+ return JSON.parse(readFileSync3(STATE_PATH, "utf-8"));
1532
+ } catch {
1533
+ return null;
1534
+ }
1535
+ }
1536
+ function saveFingerprint(fp) {
1537
+ try {
1538
+ const dir = join4(homedir2(), ".engrm");
1539
+ if (!existsSync4(dir))
1540
+ mkdirSync2(dir, { recursive: true });
1541
+ writeFileSync2(STATE_PATH, JSON.stringify(fp, null, 2) + `
1542
+ `, "utf-8");
1543
+ } catch {}
1544
+ }
1545
+ function computeAndSaveFingerprint(cwd) {
1546
+ const claudeMdHash = hashFile(join4(cwd, "CLAUDE.md"));
1547
+ const engrmJsonHash = hashFile(join4(cwd, ".engrm.json"));
1548
+ const slug = cwd.replace(/\//g, "-");
1549
+ const memoryDir = join4(homedir2(), ".claude", "projects", slug, "memory");
1550
+ const memoryMdHash = hashFile(join4(memoryDir, "MEMORY.md"));
1551
+ const memoryFileCount = countMemoryFiles(memoryDir);
1552
+ const material = [
1553
+ claudeMdHash ?? "null",
1554
+ memoryMdHash ?? "null",
1555
+ engrmJsonHash ?? "null",
1556
+ String(memoryFileCount),
1557
+ CLIENT_VERSION
1558
+ ].join("+");
1559
+ const configHash = createHash2("sha256").update(material).digest("hex");
1560
+ const previous = readPreviousFingerprint();
1561
+ const configChanged = previous !== null && previous.config_hash !== configHash;
1562
+ const fingerprint = {
1563
+ config_hash: configHash,
1564
+ config_changed: configChanged,
1565
+ claude_md_hash: claudeMdHash,
1566
+ memory_md_hash: memoryMdHash,
1567
+ engrm_json_hash: engrmJsonHash,
1568
+ memory_file_count: memoryFileCount,
1569
+ client_version: CLIENT_VERSION
1570
+ };
1571
+ saveFingerprint(fingerprint);
1572
+ return fingerprint;
1573
+ }
1574
+
1458
1575
  // src/packs/recommender.ts
1459
- import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3 } from "node:fs";
1460
- import { join as join4, basename as basename3, dirname } from "node:path";
1576
+ import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync4 } from "node:fs";
1577
+ import { join as join5, basename as basename3, dirname } from "node:path";
1461
1578
  import { fileURLToPath } from "node:url";
1462
1579
  var STACK_PACK_MAP = {
1463
1580
  typescript: ["typescript-patterns"],
@@ -1470,20 +1587,20 @@ var STACK_PACK_MAP = {
1470
1587
  };
1471
1588
  function getPacksDir() {
1472
1589
  const thisDir = dirname(fileURLToPath(import.meta.url));
1473
- return join4(thisDir, "../../packs");
1590
+ return join5(thisDir, "../../packs");
1474
1591
  }
1475
1592
  function listAvailablePacks() {
1476
1593
  const dir = getPacksDir();
1477
- if (!existsSync4(dir))
1594
+ if (!existsSync5(dir))
1478
1595
  return [];
1479
- return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => basename3(f, ".json"));
1596
+ return readdirSync2(dir).filter((f) => f.endsWith(".json")).map((f) => basename3(f, ".json"));
1480
1597
  }
1481
1598
  function loadPack(name) {
1482
- const packPath = join4(getPacksDir(), `${name}.json`);
1483
- if (!existsSync4(packPath))
1599
+ const packPath = join5(getPacksDir(), `${name}.json`);
1600
+ if (!existsSync5(packPath))
1484
1601
  return null;
1485
1602
  try {
1486
- const raw = readFileSync3(packPath, "utf-8");
1603
+ const raw = readFileSync4(packPath, "utf-8");
1487
1604
  return JSON.parse(raw);
1488
1605
  } catch {
1489
1606
  return null;
@@ -1517,6 +1634,202 @@ function recommendPacks(stacks, installedPacks) {
1517
1634
  return recommendations;
1518
1635
  }
1519
1636
 
1637
+ // src/config.ts
1638
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "node:fs";
1639
+ import { homedir as homedir3, hostname as hostname2, networkInterfaces as networkInterfaces2 } from "node:os";
1640
+ import { join as join6 } from "node:path";
1641
+ import { createHash as createHash3 } from "node:crypto";
1642
+ var CONFIG_DIR2 = join6(homedir3(), ".engrm");
1643
+ var SETTINGS_PATH2 = join6(CONFIG_DIR2, "settings.json");
1644
+ var DB_PATH2 = join6(CONFIG_DIR2, "engrm.db");
1645
+ function getDbPath2() {
1646
+ return DB_PATH2;
1647
+ }
1648
+ function generateDeviceId2() {
1649
+ const host = hostname2().toLowerCase().replace(/[^a-z0-9-]/g, "");
1650
+ let mac = "";
1651
+ const ifaces = networkInterfaces2();
1652
+ for (const entries of Object.values(ifaces)) {
1653
+ if (!entries)
1654
+ continue;
1655
+ for (const entry of entries) {
1656
+ if (!entry.internal && entry.mac && entry.mac !== "00:00:00:00:00:00") {
1657
+ mac = entry.mac;
1658
+ break;
1659
+ }
1660
+ }
1661
+ if (mac)
1662
+ break;
1663
+ }
1664
+ const material = `${host}:${mac || "no-mac"}`;
1665
+ const suffix = createHash3("sha256").update(material).digest("hex").slice(0, 8);
1666
+ return `${host}-${suffix}`;
1667
+ }
1668
+ function createDefaultConfig2() {
1669
+ return {
1670
+ candengo_url: "",
1671
+ candengo_api_key: "",
1672
+ site_id: "",
1673
+ namespace: "",
1674
+ user_id: "",
1675
+ user_email: "",
1676
+ device_id: generateDeviceId2(),
1677
+ teams: [],
1678
+ sync: {
1679
+ enabled: true,
1680
+ interval_seconds: 30,
1681
+ batch_size: 50
1682
+ },
1683
+ search: {
1684
+ default_limit: 10,
1685
+ local_boost: 1.2,
1686
+ scope: "all"
1687
+ },
1688
+ scrubbing: {
1689
+ enabled: true,
1690
+ custom_patterns: [],
1691
+ default_sensitivity: "shared"
1692
+ },
1693
+ sentinel: {
1694
+ enabled: false,
1695
+ mode: "advisory",
1696
+ provider: "openai",
1697
+ model: "gpt-4o-mini",
1698
+ api_key: "",
1699
+ base_url: "",
1700
+ skip_patterns: [],
1701
+ daily_limit: 100,
1702
+ tier: "free"
1703
+ },
1704
+ observer: {
1705
+ enabled: true,
1706
+ mode: "per_event",
1707
+ model: "sonnet"
1708
+ },
1709
+ transcript_analysis: {
1710
+ enabled: false
1711
+ }
1712
+ };
1713
+ }
1714
+ function loadConfig2() {
1715
+ if (!existsSync6(SETTINGS_PATH2)) {
1716
+ throw new Error(`Config not found at ${SETTINGS_PATH2}. Run 'engrm init --manual' to configure.`);
1717
+ }
1718
+ const raw = readFileSync5(SETTINGS_PATH2, "utf-8");
1719
+ let parsed;
1720
+ try {
1721
+ parsed = JSON.parse(raw);
1722
+ } catch {
1723
+ throw new Error(`Invalid JSON in ${SETTINGS_PATH2}`);
1724
+ }
1725
+ if (typeof parsed !== "object" || parsed === null) {
1726
+ throw new Error(`Config at ${SETTINGS_PATH2} is not a JSON object`);
1727
+ }
1728
+ const config = parsed;
1729
+ const defaults = createDefaultConfig2();
1730
+ return {
1731
+ candengo_url: asString2(config["candengo_url"], defaults.candengo_url),
1732
+ candengo_api_key: asString2(config["candengo_api_key"], defaults.candengo_api_key),
1733
+ site_id: asString2(config["site_id"], defaults.site_id),
1734
+ namespace: asString2(config["namespace"], defaults.namespace),
1735
+ user_id: asString2(config["user_id"], defaults.user_id),
1736
+ user_email: asString2(config["user_email"], defaults.user_email),
1737
+ device_id: asString2(config["device_id"], defaults.device_id),
1738
+ teams: asTeams2(config["teams"], defaults.teams),
1739
+ sync: {
1740
+ enabled: asBool2(config["sync"]?.["enabled"], defaults.sync.enabled),
1741
+ interval_seconds: asNumber2(config["sync"]?.["interval_seconds"], defaults.sync.interval_seconds),
1742
+ batch_size: asNumber2(config["sync"]?.["batch_size"], defaults.sync.batch_size)
1743
+ },
1744
+ search: {
1745
+ default_limit: asNumber2(config["search"]?.["default_limit"], defaults.search.default_limit),
1746
+ local_boost: asNumber2(config["search"]?.["local_boost"], defaults.search.local_boost),
1747
+ scope: asScope2(config["search"]?.["scope"], defaults.search.scope)
1748
+ },
1749
+ scrubbing: {
1750
+ enabled: asBool2(config["scrubbing"]?.["enabled"], defaults.scrubbing.enabled),
1751
+ custom_patterns: asStringArray2(config["scrubbing"]?.["custom_patterns"], defaults.scrubbing.custom_patterns),
1752
+ default_sensitivity: asSensitivity2(config["scrubbing"]?.["default_sensitivity"], defaults.scrubbing.default_sensitivity)
1753
+ },
1754
+ sentinel: {
1755
+ enabled: asBool2(config["sentinel"]?.["enabled"], defaults.sentinel.enabled),
1756
+ mode: asSentinelMode2(config["sentinel"]?.["mode"], defaults.sentinel.mode),
1757
+ provider: asLlmProvider2(config["sentinel"]?.["provider"], defaults.sentinel.provider),
1758
+ model: asString2(config["sentinel"]?.["model"], defaults.sentinel.model),
1759
+ api_key: asString2(config["sentinel"]?.["api_key"], defaults.sentinel.api_key),
1760
+ base_url: asString2(config["sentinel"]?.["base_url"], defaults.sentinel.base_url),
1761
+ skip_patterns: asStringArray2(config["sentinel"]?.["skip_patterns"], defaults.sentinel.skip_patterns),
1762
+ daily_limit: asNumber2(config["sentinel"]?.["daily_limit"], defaults.sentinel.daily_limit),
1763
+ tier: asTier2(config["sentinel"]?.["tier"], defaults.sentinel.tier)
1764
+ },
1765
+ observer: {
1766
+ enabled: asBool2(config["observer"]?.["enabled"], defaults.observer.enabled),
1767
+ mode: asObserverMode2(config["observer"]?.["mode"], defaults.observer.mode),
1768
+ model: asString2(config["observer"]?.["model"], defaults.observer.model)
1769
+ },
1770
+ transcript_analysis: {
1771
+ enabled: asBool2(config["transcript_analysis"]?.["enabled"], defaults.transcript_analysis.enabled)
1772
+ }
1773
+ };
1774
+ }
1775
+ function saveConfig(config) {
1776
+ if (!existsSync6(CONFIG_DIR2)) {
1777
+ mkdirSync3(CONFIG_DIR2, { recursive: true });
1778
+ }
1779
+ writeFileSync3(SETTINGS_PATH2, JSON.stringify(config, null, 2) + `
1780
+ `, "utf-8");
1781
+ }
1782
+ function configExists2() {
1783
+ return existsSync6(SETTINGS_PATH2);
1784
+ }
1785
+ function asString2(value, fallback) {
1786
+ return typeof value === "string" ? value : fallback;
1787
+ }
1788
+ function asNumber2(value, fallback) {
1789
+ return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
1790
+ }
1791
+ function asBool2(value, fallback) {
1792
+ return typeof value === "boolean" ? value : fallback;
1793
+ }
1794
+ function asStringArray2(value, fallback) {
1795
+ return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : fallback;
1796
+ }
1797
+ function asScope2(value, fallback) {
1798
+ if (value === "personal" || value === "team" || value === "all")
1799
+ return value;
1800
+ return fallback;
1801
+ }
1802
+ function asSensitivity2(value, fallback) {
1803
+ if (value === "shared" || value === "personal" || value === "secret")
1804
+ return value;
1805
+ return fallback;
1806
+ }
1807
+ function asSentinelMode2(value, fallback) {
1808
+ if (value === "advisory" || value === "blocking")
1809
+ return value;
1810
+ return fallback;
1811
+ }
1812
+ function asLlmProvider2(value, fallback) {
1813
+ if (value === "openai" || value === "anthropic" || value === "ollama" || value === "custom")
1814
+ return value;
1815
+ return fallback;
1816
+ }
1817
+ function asTier2(value, fallback) {
1818
+ if (value === "free" || value === "vibe" || value === "solo" || value === "pro" || value === "team" || value === "enterprise")
1819
+ return value;
1820
+ return fallback;
1821
+ }
1822
+ function asObserverMode2(value, fallback) {
1823
+ if (value === "per_event" || value === "per_session")
1824
+ return value;
1825
+ return fallback;
1826
+ }
1827
+ function asTeams2(value, fallback) {
1828
+ if (!Array.isArray(value))
1829
+ return fallback;
1830
+ return value.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string" && typeof t.name === "string" && typeof t.namespace === "string");
1831
+ }
1832
+
1520
1833
  // src/sync/auth.ts
1521
1834
  function getApiKey(config) {
1522
1835
  const envKey = process.env.ENGRM_TOKEN;
@@ -1702,6 +2015,44 @@ function extractNarrative(content) {
1702
2015
  `).trim();
1703
2016
  return narrative.length > 0 ? narrative : null;
1704
2017
  }
2018
+ async function pullSettings(client, config) {
2019
+ try {
2020
+ const settings = await client.fetchSettings();
2021
+ if (!settings)
2022
+ return false;
2023
+ let changed = false;
2024
+ if (settings.transcript_analysis !== undefined) {
2025
+ const ta = settings.transcript_analysis;
2026
+ if (typeof ta === "object" && ta !== null) {
2027
+ const taObj = ta;
2028
+ if (taObj.enabled !== undefined && taObj.enabled !== config.transcript_analysis.enabled) {
2029
+ config.transcript_analysis.enabled = !!taObj.enabled;
2030
+ changed = true;
2031
+ }
2032
+ }
2033
+ }
2034
+ if (settings.observer !== undefined) {
2035
+ const obs = settings.observer;
2036
+ if (typeof obs === "object" && obs !== null) {
2037
+ const obsObj = obs;
2038
+ if (obsObj.enabled !== undefined && obsObj.enabled !== config.observer.enabled) {
2039
+ config.observer.enabled = !!obsObj.enabled;
2040
+ changed = true;
2041
+ }
2042
+ if (obsObj.model !== undefined && typeof obsObj.model === "string" && obsObj.model !== config.observer.model) {
2043
+ config.observer.model = obsObj.model;
2044
+ changed = true;
2045
+ }
2046
+ }
2047
+ }
2048
+ if (changed) {
2049
+ saveConfig(config);
2050
+ }
2051
+ return changed;
2052
+ } catch {
2053
+ return false;
2054
+ }
2055
+ }
1705
2056
 
1706
2057
  // src/sync/client.ts
1707
2058
  class VectorClient {
@@ -1756,6 +2107,13 @@ class VectorClient {
1756
2107
  async sendTelemetry(beacon) {
1757
2108
  await this.request("POST", "/v1/mem/telemetry", beacon);
1758
2109
  }
2110
+ async fetchSettings() {
2111
+ try {
2112
+ return await this.request("GET", "/v1/mem/user-settings");
2113
+ } catch {
2114
+ return null;
2115
+ }
2116
+ }
1759
2117
  async health() {
1760
2118
  try {
1761
2119
  await this.request("GET", "/health");
@@ -1824,57 +2182,120 @@ async function main() {
1824
2182
  } catch {
1825
2183
  process.exit(0);
1826
2184
  }
2185
+ let syncedCount = 0;
1827
2186
  try {
1828
2187
  if (config.sync.enabled && config.candengo_api_key) {
1829
2188
  try {
1830
- const client = new VectorClient(config.candengo_url, config.candengo_api_key, config.site_id, config.namespace);
2189
+ const client = new VectorClient(config);
1831
2190
  const pullResult = await pullFromVector(db, client, config, 50);
1832
- if (pullResult.merged > 0) {
1833
- console.error(`Engrm: synced ${pullResult.merged} observation(s) from server`);
1834
- }
2191
+ syncedCount = pullResult.merged;
2192
+ await pullSettings(client, config);
1835
2193
  } catch {}
1836
2194
  }
2195
+ try {
2196
+ computeAndSaveFingerprint(event.cwd);
2197
+ } catch {}
1837
2198
  const context = buildSessionContext(db, event.cwd, {
1838
2199
  tokenBudget: 800,
1839
2200
  scope: config.search.scope
1840
2201
  });
2202
+ if (context) {
2203
+ try {
2204
+ const dir = join7(homedir4(), ".engrm");
2205
+ if (!existsSync7(dir))
2206
+ mkdirSync4(dir, { recursive: true });
2207
+ writeFileSync4(join7(dir, "hook-session-metrics.json"), JSON.stringify({
2208
+ contextObsInjected: context.observations.length,
2209
+ contextTotalAvailable: context.total_active
2210
+ }), "utf-8");
2211
+ } catch {}
2212
+ }
1841
2213
  if (context && context.observations.length > 0) {
1842
- console.log(formatContextForInjection(context));
1843
- const parts = [];
1844
- parts.push(`${context.session_count} observation(s)`);
1845
- if (context.securityFindings && context.securityFindings.length > 0) {
1846
- parts.push(`${context.securityFindings.length} security finding(s)`);
1847
- }
1848
2214
  const remaining = context.total_active - context.session_count;
1849
- if (remaining > 0) {
1850
- parts.push(`${remaining} more searchable`);
1851
- }
2215
+ let msgCount = 0;
1852
2216
  try {
1853
2217
  const readKey = `messages_read_${config.device_id}`;
1854
2218
  const lastReadId = parseInt(db.getSyncState(readKey) ?? "0", 10);
1855
- const msgCount = db.db.query("SELECT COUNT(*) as c FROM observations WHERE type = 'message' AND id > ? AND lifecycle IN ('active', 'pinned')").get(lastReadId)?.c ?? 0;
1856
- if (msgCount > 0) {
1857
- parts.push(`${msgCount} unread message(s)`);
2219
+ msgCount = db.db.query("SELECT COUNT(*) as c FROM observations WHERE type = 'message' AND id > ? AND lifecycle IN ('active', 'pinned')").get(lastReadId)?.c ?? 0;
2220
+ } catch {}
2221
+ const splash = formatSplashScreen({
2222
+ projectName: context.project_name,
2223
+ loaded: context.session_count,
2224
+ available: remaining,
2225
+ securityFindings: context.securityFindings?.length ?? 0,
2226
+ unreadMessages: msgCount,
2227
+ synced: syncedCount
2228
+ });
2229
+ let packLine = "";
2230
+ try {
2231
+ const { stacks } = detectStacksFromProject(event.cwd);
2232
+ if (stacks.length > 0) {
2233
+ const installed = db.getInstalledPacks();
2234
+ const recs = recommendPacks(stacks, installed);
2235
+ if (recs.length > 0) {
2236
+ const names = recs.map((r) => `\`${r.name}\``).join(", ");
2237
+ packLine = `
2238
+ Help packs available for your stack: ${names}. ` + `Use the install_pack tool to load curated observations.`;
2239
+ }
1858
2240
  }
1859
2241
  } catch {}
1860
- console.error(`Engrm: ${parts.join(" \xB7 ")} \u2014 memory loaded`);
2242
+ console.log(JSON.stringify({
2243
+ hookSpecificOutput: {
2244
+ hookEventName: "SessionStart",
2245
+ additionalContext: formatContextForInjection(context) + packLine
2246
+ },
2247
+ systemMessage: splash
2248
+ }));
1861
2249
  }
1862
- try {
1863
- const { stacks } = detectStacksFromProject(event.cwd);
1864
- if (stacks.length > 0) {
1865
- const installed = db.getInstalledPacks();
1866
- const recs = recommendPacks(stacks, installed);
1867
- if (recs.length > 0) {
1868
- const names = recs.map((r) => `\`${r.name}\``).join(", ");
1869
- console.log(`
1870
- Help packs available for your stack: ${names}. ` + `Use the install_pack tool to load curated observations.`);
1871
- }
1872
- }
1873
- } catch {}
1874
2250
  } finally {
1875
2251
  db.close();
1876
2252
  }
1877
2253
  }
2254
+ var c = {
2255
+ reset: "\x1B[0m",
2256
+ dim: "\x1B[2m",
2257
+ bold: "\x1B[1m",
2258
+ cyan: "\x1B[36m",
2259
+ green: "\x1B[32m",
2260
+ yellow: "\x1B[33m",
2261
+ magenta: "\x1B[35m",
2262
+ white: "\x1B[37m"
2263
+ };
2264
+ function formatSplashScreen(data) {
2265
+ const lines = [];
2266
+ lines.push("");
2267
+ lines.push(`${c.cyan}${c.bold} ______ ____ _ ______ _____ ____ __${c.reset}`);
2268
+ lines.push(`${c.cyan}${c.bold} | ___|| \\ | || ___|| | | \\ / |${c.reset}`);
2269
+ lines.push(`${c.cyan}${c.bold} | ___|| \\| || | || \\ | \\/ |${c.reset}`);
2270
+ lines.push(`${c.cyan}${c.bold} |______||__/\\____||______||__|\\__\\|__/\\__/|__|${c.reset}`);
2271
+ lines.push(`${c.dim} memory layer for AI agents${c.reset}`);
2272
+ lines.push("");
2273
+ const dot = `${c.dim} \xB7 ${c.reset}`;
2274
+ const statParts = [];
2275
+ statParts.push(`${c.green}${data.loaded}${c.reset} loaded`);
2276
+ if (data.available > 0) {
2277
+ statParts.push(`${c.dim}${data.available.toLocaleString()} searchable${c.reset}`);
2278
+ }
2279
+ if (data.synced > 0) {
2280
+ statParts.push(`${c.cyan}${data.synced} synced${c.reset}`);
2281
+ }
2282
+ lines.push(` ${c.white}${c.bold}engrm${c.reset}${dot}${statParts.join(dot)}`);
2283
+ const alerts = [];
2284
+ if (data.securityFindings > 0) {
2285
+ alerts.push(`${c.yellow}${data.securityFindings} security finding${data.securityFindings !== 1 ? "s" : ""}${c.reset}`);
2286
+ }
2287
+ if (data.unreadMessages > 0) {
2288
+ alerts.push(`${c.magenta}${data.unreadMessages} unread message${data.unreadMessages !== 1 ? "s" : ""}${c.reset}`);
2289
+ }
2290
+ if (alerts.length > 0) {
2291
+ lines.push(` ${alerts.join(dot)}`);
2292
+ }
2293
+ lines.push("");
2294
+ lines.push(` ${c.dim}Dashboard: https://engrm.dev/dashboard${c.reset}`);
2295
+ lines.push("");
2296
+ return lines.join(`
2297
+ `);
2298
+ }
1878
2299
  main().catch(() => {
1879
2300
  process.exit(0);
1880
2301
  });