engrm 0.4.0 → 0.4.3

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.
@@ -2,6 +2,82 @@
2
2
  import { createRequire } from "node:module";
3
3
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
4
 
5
+ // src/sentinel/audit.ts
6
+ async function auditCodeChange(config, _db, toolName, filePath, content) {
7
+ if (shouldSkip(filePath, config.sentinel.skip_patterns)) {
8
+ return { verdict: "PASS", reason: "File matches skip pattern" };
9
+ }
10
+ if (!config.candengo_url || !config.candengo_api_key) {
11
+ return { verdict: "PASS", reason: "Server not configured" };
12
+ }
13
+ const url = `${config.candengo_url.replace(/\/$/, "")}/v1/mem/check`;
14
+ try {
15
+ const response = await fetch(url, {
16
+ method: "POST",
17
+ headers: {
18
+ "Content-Type": "application/json",
19
+ Authorization: `Bearer ${config.candengo_api_key}`
20
+ },
21
+ body: JSON.stringify({
22
+ tool_name: toolName,
23
+ file_path: filePath,
24
+ content: content.slice(0, 8000)
25
+ }),
26
+ signal: AbortSignal.timeout(15000)
27
+ });
28
+ if (!response.ok) {
29
+ return { verdict: "PASS", reason: "Review service unavailable" };
30
+ }
31
+ const data = await response.json();
32
+ return parseServerResponse(data);
33
+ } catch {
34
+ return { verdict: "PASS", reason: "Review service unreachable" };
35
+ }
36
+ }
37
+ function parseServerResponse(data) {
38
+ const verdict = data.verdict;
39
+ if (verdict !== "PASS" && verdict !== "WARN" && verdict !== "BLOCK" && verdict !== "DRIFT") {
40
+ return { verdict: "PASS", reason: "Invalid verdict from server" };
41
+ }
42
+ return {
43
+ verdict,
44
+ reason: data.reason ?? "No reason given",
45
+ rule: data.rule ?? undefined,
46
+ severity: parseSeverity(data.severity)
47
+ };
48
+ }
49
+ function parseSeverity(s) {
50
+ if (s === "critical" || s === "high" || s === "medium" || s === "low")
51
+ return s;
52
+ return;
53
+ }
54
+ function shouldSkip(filePath, patterns) {
55
+ for (const pattern of patterns) {
56
+ if (filePath.includes(pattern))
57
+ return true;
58
+ try {
59
+ if (new RegExp(pattern).test(filePath))
60
+ return true;
61
+ } catch {}
62
+ }
63
+ return false;
64
+ }
65
+ function checkDailyLimit(db, limit) {
66
+ const today = new Date().toISOString().split("T")[0];
67
+ const key = `sentinel_audit_count_${today}`;
68
+ try {
69
+ const current = db.db.query("SELECT value FROM sync_state WHERE key = ?").get(key);
70
+ const count = current ? parseInt(current.value, 10) : 0;
71
+ if (count >= limit)
72
+ return false;
73
+ db.db.query(`INSERT INTO sync_state (key, value) VALUES (?, ?)
74
+ ON CONFLICT(key) DO UPDATE SET value = ?`).run(key, String(count + 1), String(count + 1));
75
+ return true;
76
+ } catch {
77
+ return true;
78
+ }
79
+ }
80
+
5
81
  // src/config.ts
6
82
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
83
  import { homedir, hostname, networkInterfaces } from "node:os";
@@ -699,8 +775,8 @@ class MemDatabase {
699
775
  return this.db.query("SELECT * FROM projects WHERE id = ?").get(id) ?? null;
700
776
  }
701
777
  insertObservation(obs) {
702
- const now = Math.floor(Date.now() / 1000);
703
- const createdAt = new Date().toISOString();
778
+ const now = obs.created_at_epoch ?? Math.floor(Date.now() / 1000);
779
+ const createdAt = obs.created_at ?? new Date(now * 1000).toISOString();
704
780
  const result = this.db.query(`INSERT INTO observations (
705
781
  session_id, project_id, type, title, narrative, facts, concepts,
706
782
  files_read, files_modified, quality, lifecycle, sensitivity,
@@ -717,11 +793,14 @@ class MemDatabase {
717
793
  getObservationById(id) {
718
794
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
719
795
  }
720
- getObservationsByIds(ids) {
796
+ getObservationsByIds(ids, userId) {
721
797
  if (ids.length === 0)
722
798
  return [];
723
799
  const placeholders = ids.map(() => "?").join(",");
724
- return this.db.query(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch DESC`).all(...ids);
800
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
801
+ return this.db.query(`SELECT * FROM observations
802
+ WHERE id IN (${placeholders})${visibilityClause}
803
+ ORDER BY created_at_epoch DESC`).all(...ids, ...userId ? [userId] : []);
725
804
  }
726
805
  getRecentObservations(projectId, sincEpoch, limit = 50) {
727
806
  return this.db.query(`SELECT * FROM observations
@@ -729,8 +808,9 @@ class MemDatabase {
729
808
  ORDER BY created_at_epoch DESC
730
809
  LIMIT ?`).all(projectId, sincEpoch, limit);
731
810
  }
732
- searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
811
+ searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
733
812
  const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
813
+ const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
734
814
  if (projectId !== null) {
735
815
  return this.db.query(`SELECT o.id, observations_fts.rank
736
816
  FROM observations_fts
@@ -738,33 +818,39 @@ class MemDatabase {
738
818
  WHERE observations_fts MATCH ?
739
819
  AND o.project_id = ?
740
820
  AND o.lifecycle IN (${lifecyclePlaceholders})
821
+ ${visibilityClause}
741
822
  ORDER BY observations_fts.rank
742
- LIMIT ?`).all(query, projectId, ...lifecycles, limit);
823
+ LIMIT ?`).all(query, projectId, ...lifecycles, ...userId ? [userId] : [], limit);
743
824
  }
744
825
  return this.db.query(`SELECT o.id, observations_fts.rank
745
826
  FROM observations_fts
746
827
  JOIN observations o ON o.id = observations_fts.rowid
747
828
  WHERE observations_fts MATCH ?
748
829
  AND o.lifecycle IN (${lifecyclePlaceholders})
830
+ ${visibilityClause}
749
831
  ORDER BY observations_fts.rank
750
- LIMIT ?`).all(query, ...lifecycles, limit);
832
+ LIMIT ?`).all(query, ...lifecycles, ...userId ? [userId] : [], limit);
751
833
  }
752
- getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3) {
753
- const anchor = this.getObservationById(anchorId);
834
+ getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3, userId) {
835
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
836
+ const anchor = this.db.query(`SELECT * FROM observations WHERE id = ?${visibilityClause}`).get(anchorId, ...userId ? [userId] : []) ?? null;
754
837
  if (!anchor)
755
838
  return [];
756
839
  const projectFilter = projectId !== null ? "AND project_id = ?" : "";
757
840
  const projectParams = projectId !== null ? [projectId] : [];
841
+ const visibilityParams = userId ? [userId] : [];
758
842
  const before = this.db.query(`SELECT * FROM observations
759
843
  WHERE created_at_epoch < ? ${projectFilter}
760
844
  AND lifecycle IN ('active', 'aging', 'pinned')
845
+ ${visibilityClause}
761
846
  ORDER BY created_at_epoch DESC
762
- LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthBefore);
847
+ LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthBefore);
763
848
  const after = this.db.query(`SELECT * FROM observations
764
849
  WHERE created_at_epoch > ? ${projectFilter}
765
850
  AND lifecycle IN ('active', 'aging', 'pinned')
851
+ ${visibilityClause}
766
852
  ORDER BY created_at_epoch ASC
767
- LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthAfter);
853
+ LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthAfter);
768
854
  return [...before.reverse(), anchor, ...after];
769
855
  }
770
856
  pinObservation(id, pinned) {
@@ -878,11 +964,12 @@ class MemDatabase {
878
964
  return;
879
965
  this.db.query("DELETE FROM vec_observations WHERE observation_id = ?").run(observationId);
880
966
  }
881
- searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
967
+ searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
882
968
  if (!this.vecAvailable)
883
969
  return [];
884
970
  const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
885
971
  const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
972
+ const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
886
973
  if (projectId !== null) {
887
974
  return this.db.query(`SELECT v.observation_id, v.distance
888
975
  FROM vec_observations v
@@ -891,7 +978,7 @@ class MemDatabase {
891
978
  AND k = ?
892
979
  AND o.project_id = ?
893
980
  AND o.lifecycle IN (${lifecyclePlaceholders})
894
- AND o.superseded_by IS NULL`).all(embeddingBlob, limit, projectId, ...lifecycles);
981
+ AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, projectId, ...lifecycles, ...userId ? [userId] : []);
895
982
  }
896
983
  return this.db.query(`SELECT v.observation_id, v.distance
897
984
  FROM vec_observations v
@@ -899,7 +986,7 @@ class MemDatabase {
899
986
  WHERE v.embedding MATCH ?
900
987
  AND k = ?
901
988
  AND o.lifecycle IN (${lifecyclePlaceholders})
902
- AND o.superseded_by IS NULL`).all(embeddingBlob, limit, ...lifecycles);
989
+ AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, ...lifecycles, ...userId ? [userId] : []);
903
990
  }
904
991
  getUnembeddedCount() {
905
992
  if (!this.vecAvailable)
@@ -1018,80 +1105,58 @@ class MemDatabase {
1018
1105
  }
1019
1106
  }
1020
1107
 
1021
- // src/sentinel/audit.ts
1022
- async function auditCodeChange(config, _db, toolName, filePath, content) {
1023
- if (shouldSkip(filePath, config.sentinel.skip_patterns)) {
1024
- return { verdict: "PASS", reason: "File matches skip pattern" };
1025
- }
1026
- if (!config.candengo_url || !config.candengo_api_key) {
1027
- return { verdict: "PASS", reason: "Server not configured" };
1108
+ // src/hooks/common.ts
1109
+ var c = {
1110
+ dim: "\x1B[2m",
1111
+ yellow: "\x1B[33m",
1112
+ reset: "\x1B[0m"
1113
+ };
1114
+ async function readStdin() {
1115
+ const chunks = [];
1116
+ for await (const chunk of process.stdin) {
1117
+ chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
1028
1118
  }
1029
- const url = `${config.candengo_url.replace(/\/$/, "")}/v1/mem/check`;
1119
+ return chunks.join("");
1120
+ }
1121
+ async function parseStdinJson() {
1122
+ const raw = await readStdin();
1123
+ if (!raw.trim())
1124
+ return null;
1030
1125
  try {
1031
- const response = await fetch(url, {
1032
- method: "POST",
1033
- headers: {
1034
- "Content-Type": "application/json",
1035
- Authorization: `Bearer ${config.candengo_api_key}`
1036
- },
1037
- body: JSON.stringify({
1038
- tool_name: toolName,
1039
- file_path: filePath,
1040
- content: content.slice(0, 8000)
1041
- }),
1042
- signal: AbortSignal.timeout(15000)
1043
- });
1044
- if (!response.ok) {
1045
- return { verdict: "PASS", reason: "Review service unavailable" };
1046
- }
1047
- const data = await response.json();
1048
- return parseServerResponse(data);
1126
+ return JSON.parse(raw);
1049
1127
  } catch {
1050
- return { verdict: "PASS", reason: "Review service unreachable" };
1128
+ return null;
1051
1129
  }
1052
1130
  }
1053
- function parseServerResponse(data) {
1054
- const verdict = data.verdict;
1055
- if (verdict !== "PASS" && verdict !== "WARN" && verdict !== "BLOCK" && verdict !== "DRIFT") {
1056
- return { verdict: "PASS", reason: "Invalid verdict from server" };
1131
+ function bootstrapHook(hookName) {
1132
+ if (!configExists()) {
1133
+ warnUser(hookName, "Engrm not configured. Run: npx engrm init");
1134
+ return null;
1057
1135
  }
1058
- return {
1059
- verdict,
1060
- reason: data.reason ?? "No reason given",
1061
- rule: data.rule ?? undefined,
1062
- severity: parseSeverity(data.severity)
1063
- };
1064
- }
1065
- function parseSeverity(s) {
1066
- if (s === "critical" || s === "high" || s === "medium" || s === "low")
1067
- return s;
1068
- return;
1069
- }
1070
- function shouldSkip(filePath, patterns) {
1071
- for (const pattern of patterns) {
1072
- if (filePath.includes(pattern))
1073
- return true;
1074
- try {
1075
- if (new RegExp(pattern).test(filePath))
1076
- return true;
1077
- } catch {}
1136
+ let config;
1137
+ try {
1138
+ config = loadConfig();
1139
+ } catch (err) {
1140
+ warnUser(hookName, `Config error: ${err instanceof Error ? err.message : String(err)}`);
1141
+ return null;
1078
1142
  }
1079
- return false;
1080
- }
1081
- function checkDailyLimit(db, limit) {
1082
- const today = new Date().toISOString().split("T")[0];
1083
- const key = `sentinel_audit_count_${today}`;
1143
+ let db;
1084
1144
  try {
1085
- const current = db.db.query("SELECT value FROM sync_state WHERE key = ?").get(key);
1086
- const count = current ? parseInt(current.value, 10) : 0;
1087
- if (count >= limit)
1088
- return false;
1089
- db.db.query(`INSERT INTO sync_state (key, value) VALUES (?, ?)
1090
- ON CONFLICT(key) DO UPDATE SET value = ?`).run(key, String(count + 1), String(count + 1));
1091
- return true;
1092
- } catch {
1093
- return true;
1145
+ db = new MemDatabase(getDbPath());
1146
+ } catch (err) {
1147
+ warnUser(hookName, `Database error: ${err instanceof Error ? err.message : String(err)}`);
1148
+ return null;
1094
1149
  }
1150
+ return { config, db };
1151
+ }
1152
+ function warnUser(hookName, message) {
1153
+ console.error(`${c.yellow}engrm ${hookName}:${c.reset} ${c.dim}${message}${c.reset}`);
1154
+ }
1155
+ function runHook(hookName, fn) {
1156
+ fn().catch((err) => {
1157
+ warnUser(hookName, `Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
1158
+ process.exit(0);
1159
+ });
1095
1160
  }
1096
1161
 
1097
1162
  // src/storage/projects.ts
@@ -1200,32 +1265,16 @@ function detectProject(directory) {
1200
1265
 
1201
1266
  // hooks/sentinel.ts
1202
1267
  async function main() {
1203
- const chunks = [];
1204
- for await (const chunk of process.stdin) {
1205
- chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
1206
- }
1207
- const raw = chunks.join("");
1208
- if (!raw.trim())
1209
- process.exit(0);
1210
- let event;
1211
- try {
1212
- event = JSON.parse(raw);
1213
- } catch {
1268
+ const event = await parseStdinJson();
1269
+ if (!event)
1214
1270
  process.exit(0);
1215
- }
1216
1271
  if (event.tool_name !== "Write" && event.tool_name !== "Edit") {
1217
1272
  process.exit(0);
1218
1273
  }
1219
- if (!configExists())
1274
+ const boot = bootstrapHook("sentinel");
1275
+ if (!boot)
1220
1276
  process.exit(0);
1221
- let config;
1222
- let db;
1223
- try {
1224
- config = loadConfig();
1225
- db = new MemDatabase(getDbPath());
1226
- } catch {
1227
- process.exit(0);
1228
- }
1277
+ const { config, db } = boot;
1229
1278
  if (!config.sentinel.enabled) {
1230
1279
  db.close();
1231
1280
  process.exit(0);
@@ -1306,6 +1355,4 @@ async function main() {
1306
1355
  }
1307
1356
  process.exit(0);
1308
1357
  }
1309
- main().catch(() => {
1310
- process.exit(0);
1311
- });
1358
+ runHook("sentinel", main);