engrm 0.4.1 → 0.4.4
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.
- package/README.md +60 -13
- package/dist/cli.js +288 -28
- package/dist/hooks/codex-stop.js +62 -0
- package/dist/hooks/elicitation-result.js +1690 -1637
- package/dist/hooks/post-tool-use.js +284 -231
- package/dist/hooks/pre-compact.js +410 -78
- package/dist/hooks/sentinel.js +150 -103
- package/dist/hooks/session-start.js +2284 -2039
- package/dist/hooks/stop.js +196 -130
- package/dist/server.js +614 -118
- package/package.json +6 -5
- package/bin/build.mjs +0 -97
- package/bin/engrm.mjs +0 -13
|
@@ -2,6 +2,212 @@
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
4
|
|
|
5
|
+
// src/capture/extractor.ts
|
|
6
|
+
var SKIP_TOOLS = new Set([
|
|
7
|
+
"Glob",
|
|
8
|
+
"Grep",
|
|
9
|
+
"Read",
|
|
10
|
+
"WebSearch",
|
|
11
|
+
"WebFetch",
|
|
12
|
+
"Agent"
|
|
13
|
+
]);
|
|
14
|
+
var SKIP_BASH_PATTERNS = [
|
|
15
|
+
/^\s*(ls|pwd|cd|echo|cat|head|tail|wc|which|whoami|date|uname)\b/,
|
|
16
|
+
/^\s*git\s+(status|log|branch|diff|show|remote)\b/,
|
|
17
|
+
/^\s*(node|bun|npm|npx|yarn|pnpm)\s+--?version\b/,
|
|
18
|
+
/^\s*export\s+/,
|
|
19
|
+
/^\s*#/
|
|
20
|
+
];
|
|
21
|
+
var TRIVIAL_RESPONSE_PATTERNS = [
|
|
22
|
+
/^$/,
|
|
23
|
+
/^\s*$/,
|
|
24
|
+
/^Already up to date\.$/
|
|
25
|
+
];
|
|
26
|
+
function extractObservation(event) {
|
|
27
|
+
const { tool_name, tool_input, tool_response } = event;
|
|
28
|
+
if (SKIP_TOOLS.has(tool_name)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
switch (tool_name) {
|
|
32
|
+
case "Edit":
|
|
33
|
+
return extractFromEdit(tool_input, tool_response);
|
|
34
|
+
case "Write":
|
|
35
|
+
return extractFromWrite(tool_input, tool_response);
|
|
36
|
+
case "Bash":
|
|
37
|
+
return extractFromBash(tool_input, tool_response);
|
|
38
|
+
default:
|
|
39
|
+
if (tool_name.startsWith("mcp__")) {
|
|
40
|
+
return extractFromMcpTool(tool_name, tool_input, tool_response);
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function extractFromEdit(input, response) {
|
|
46
|
+
const filePath = input["file_path"];
|
|
47
|
+
if (!filePath)
|
|
48
|
+
return null;
|
|
49
|
+
const oldStr = input["old_string"];
|
|
50
|
+
const newStr = input["new_string"];
|
|
51
|
+
if (!oldStr && !newStr)
|
|
52
|
+
return null;
|
|
53
|
+
if (oldStr && newStr) {
|
|
54
|
+
const oldTrimmed = oldStr.trim();
|
|
55
|
+
const newTrimmed = newStr.trim();
|
|
56
|
+
if (oldTrimmed === newTrimmed)
|
|
57
|
+
return null;
|
|
58
|
+
if (Math.abs(oldTrimmed.length - newTrimmed.length) < 3 && oldTrimmed.length < 20) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const fileName = filePath.split("/").pop() ?? filePath;
|
|
63
|
+
const changeSize = (newStr?.length ?? 0) - (oldStr?.length ?? 0);
|
|
64
|
+
const verb = changeSize > 50 ? "Extended" : changeSize < -50 ? "Reduced" : "Modified";
|
|
65
|
+
return {
|
|
66
|
+
type: "change",
|
|
67
|
+
title: `${verb} ${fileName}`,
|
|
68
|
+
narrative: buildEditNarrative(oldStr, newStr, filePath),
|
|
69
|
+
files_modified: [filePath]
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function extractFromWrite(input, response) {
|
|
73
|
+
const filePath = input["file_path"];
|
|
74
|
+
if (!filePath)
|
|
75
|
+
return null;
|
|
76
|
+
const content = input["content"];
|
|
77
|
+
const fileName = filePath.split("/").pop() ?? filePath;
|
|
78
|
+
if (content === undefined || content.length < 50)
|
|
79
|
+
return null;
|
|
80
|
+
return {
|
|
81
|
+
type: "change",
|
|
82
|
+
title: `Created ${fileName}`,
|
|
83
|
+
narrative: `New file created: ${filePath}`,
|
|
84
|
+
files_modified: [filePath]
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function extractFromBash(input, response) {
|
|
88
|
+
const command = input["command"];
|
|
89
|
+
if (!command)
|
|
90
|
+
return null;
|
|
91
|
+
for (const pattern of SKIP_BASH_PATTERNS) {
|
|
92
|
+
if (pattern.test(command))
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
for (const pattern of TRIVIAL_RESPONSE_PATTERNS) {
|
|
96
|
+
if (pattern.test(response.trim()))
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const hasError = detectError(response);
|
|
100
|
+
const isTestRun = detectTestRun(command);
|
|
101
|
+
if (isTestRun) {
|
|
102
|
+
return extractTestResult(command, response);
|
|
103
|
+
}
|
|
104
|
+
if (hasError) {
|
|
105
|
+
return {
|
|
106
|
+
type: "bugfix",
|
|
107
|
+
title: summariseCommand(command) + " (error)",
|
|
108
|
+
narrative: `Command: ${truncate(command, 200)}
|
|
109
|
+
Error: ${truncate(response, 500)}`
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (/\b(npm|bun|yarn|pnpm)\s+(install|add|remove|uninstall)\b/.test(command)) {
|
|
113
|
+
return {
|
|
114
|
+
type: "change",
|
|
115
|
+
title: `Dependency change: ${summariseCommand(command)}`,
|
|
116
|
+
narrative: `Command: ${truncate(command, 200)}
|
|
117
|
+
Output: ${truncate(response, 300)}`
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (/\b(npm|bun|yarn)\s+(run\s+)?(build|compile|bundle)\b/.test(command)) {
|
|
121
|
+
if (hasError) {
|
|
122
|
+
return {
|
|
123
|
+
type: "bugfix",
|
|
124
|
+
title: `Build failure: ${summariseCommand(command)}`,
|
|
125
|
+
narrative: `Build command failed.
|
|
126
|
+
Command: ${truncate(command, 200)}
|
|
127
|
+
Output: ${truncate(response, 500)}`
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
if (response.length > 200) {
|
|
133
|
+
return {
|
|
134
|
+
type: "change",
|
|
135
|
+
title: summariseCommand(command),
|
|
136
|
+
narrative: `Command: ${truncate(command, 200)}
|
|
137
|
+
Output: ${truncate(response, 300)}`
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
function extractFromMcpTool(toolName, input, response) {
|
|
143
|
+
if (toolName.startsWith("mcp__engrm__"))
|
|
144
|
+
return null;
|
|
145
|
+
if (response.length < 100)
|
|
146
|
+
return null;
|
|
147
|
+
const parts = toolName.split("__");
|
|
148
|
+
const serverName = parts[1] ?? "unknown";
|
|
149
|
+
const toolAction = parts[2] ?? "unknown";
|
|
150
|
+
return {
|
|
151
|
+
type: "change",
|
|
152
|
+
title: `${serverName}: ${toolAction}`,
|
|
153
|
+
narrative: `MCP tool ${toolName} called.
|
|
154
|
+
Response: ${truncate(response, 300)}`
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function detectError(response) {
|
|
158
|
+
const lower = response.toLowerCase();
|
|
159
|
+
return lower.includes("error:") || lower.includes("error[") || lower.includes("failed") || lower.includes("exception") || lower.includes("traceback") || lower.includes("panic:") || lower.includes("fatal:") || /exit code [1-9]/.test(lower);
|
|
160
|
+
}
|
|
161
|
+
function detectTestRun(command) {
|
|
162
|
+
return /\b(test|spec|jest|vitest|mocha|pytest|cargo\s+test|go\s+test|bun\s+test)\b/i.test(command);
|
|
163
|
+
}
|
|
164
|
+
function extractTestResult(command, response) {
|
|
165
|
+
const hasFailure = /[1-9]\d*\s+(fail|failed|failures?)\b/i.test(response) || /\bFAILED\b/.test(response) || /\berror\b/i.test(response);
|
|
166
|
+
const hasPass = /\d+\s+(pass|passed|ok)\b/i.test(response) || /\bPASS\b/.test(response);
|
|
167
|
+
if (hasFailure) {
|
|
168
|
+
return {
|
|
169
|
+
type: "bugfix",
|
|
170
|
+
title: `Test failure: ${summariseCommand(command)}`,
|
|
171
|
+
narrative: `Test run failed.
|
|
172
|
+
Command: ${truncate(command, 200)}
|
|
173
|
+
Output: ${truncate(response, 500)}`
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (hasPass && !hasFailure) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
function buildEditNarrative(oldStr, newStr, filePath) {
|
|
182
|
+
const parts = [`File: ${filePath}`];
|
|
183
|
+
if (oldStr && newStr) {
|
|
184
|
+
const oldLines = oldStr.split(`
|
|
185
|
+
`).length;
|
|
186
|
+
const newLines = newStr.split(`
|
|
187
|
+
`).length;
|
|
188
|
+
if (oldLines !== newLines) {
|
|
189
|
+
parts.push(`Lines: ${oldLines} → ${newLines}`);
|
|
190
|
+
}
|
|
191
|
+
parts.push(`Replaced: ${truncate(oldStr, 100)}`);
|
|
192
|
+
parts.push(`With: ${truncate(newStr, 100)}`);
|
|
193
|
+
} else if (newStr) {
|
|
194
|
+
parts.push(`Added: ${truncate(newStr, 150)}`);
|
|
195
|
+
}
|
|
196
|
+
return parts.join(`
|
|
197
|
+
`);
|
|
198
|
+
}
|
|
199
|
+
function summariseCommand(command) {
|
|
200
|
+
const trimmed = command.trim();
|
|
201
|
+
const firstLine = trimmed.split(`
|
|
202
|
+
`)[0] ?? trimmed;
|
|
203
|
+
return truncate(firstLine, 80);
|
|
204
|
+
}
|
|
205
|
+
function truncate(text, maxLen) {
|
|
206
|
+
if (text.length <= maxLen)
|
|
207
|
+
return text;
|
|
208
|
+
return text.slice(0, maxLen - 3) + "...";
|
|
209
|
+
}
|
|
210
|
+
|
|
5
211
|
// src/config.ts
|
|
6
212
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
213
|
import { homedir, hostname, networkInterfaces } from "node:os";
|
|
@@ -699,8 +905,8 @@ class MemDatabase {
|
|
|
699
905
|
return this.db.query("SELECT * FROM projects WHERE id = ?").get(id) ?? null;
|
|
700
906
|
}
|
|
701
907
|
insertObservation(obs) {
|
|
702
|
-
const now = Math.floor(Date.now() / 1000);
|
|
703
|
-
const createdAt = new Date().toISOString();
|
|
908
|
+
const now = obs.created_at_epoch ?? Math.floor(Date.now() / 1000);
|
|
909
|
+
const createdAt = obs.created_at ?? new Date(now * 1000).toISOString();
|
|
704
910
|
const result = this.db.query(`INSERT INTO observations (
|
|
705
911
|
session_id, project_id, type, title, narrative, facts, concepts,
|
|
706
912
|
files_read, files_modified, quality, lifecycle, sensitivity,
|
|
@@ -717,11 +923,14 @@ class MemDatabase {
|
|
|
717
923
|
getObservationById(id) {
|
|
718
924
|
return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
|
|
719
925
|
}
|
|
720
|
-
getObservationsByIds(ids) {
|
|
926
|
+
getObservationsByIds(ids, userId) {
|
|
721
927
|
if (ids.length === 0)
|
|
722
928
|
return [];
|
|
723
929
|
const placeholders = ids.map(() => "?").join(",");
|
|
724
|
-
|
|
930
|
+
const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
|
|
931
|
+
return this.db.query(`SELECT * FROM observations
|
|
932
|
+
WHERE id IN (${placeholders})${visibilityClause}
|
|
933
|
+
ORDER BY created_at_epoch DESC`).all(...ids, ...userId ? [userId] : []);
|
|
725
934
|
}
|
|
726
935
|
getRecentObservations(projectId, sincEpoch, limit = 50) {
|
|
727
936
|
return this.db.query(`SELECT * FROM observations
|
|
@@ -729,8 +938,9 @@ class MemDatabase {
|
|
|
729
938
|
ORDER BY created_at_epoch DESC
|
|
730
939
|
LIMIT ?`).all(projectId, sincEpoch, limit);
|
|
731
940
|
}
|
|
732
|
-
searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
|
|
941
|
+
searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
|
|
733
942
|
const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
|
|
943
|
+
const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
|
|
734
944
|
if (projectId !== null) {
|
|
735
945
|
return this.db.query(`SELECT o.id, observations_fts.rank
|
|
736
946
|
FROM observations_fts
|
|
@@ -738,33 +948,39 @@ class MemDatabase {
|
|
|
738
948
|
WHERE observations_fts MATCH ?
|
|
739
949
|
AND o.project_id = ?
|
|
740
950
|
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
951
|
+
${visibilityClause}
|
|
741
952
|
ORDER BY observations_fts.rank
|
|
742
|
-
LIMIT ?`).all(query, projectId, ...lifecycles, limit);
|
|
953
|
+
LIMIT ?`).all(query, projectId, ...lifecycles, ...userId ? [userId] : [], limit);
|
|
743
954
|
}
|
|
744
955
|
return this.db.query(`SELECT o.id, observations_fts.rank
|
|
745
956
|
FROM observations_fts
|
|
746
957
|
JOIN observations o ON o.id = observations_fts.rowid
|
|
747
958
|
WHERE observations_fts MATCH ?
|
|
748
959
|
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
960
|
+
${visibilityClause}
|
|
749
961
|
ORDER BY observations_fts.rank
|
|
750
|
-
LIMIT ?`).all(query, ...lifecycles, limit);
|
|
962
|
+
LIMIT ?`).all(query, ...lifecycles, ...userId ? [userId] : [], limit);
|
|
751
963
|
}
|
|
752
|
-
getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3) {
|
|
753
|
-
const
|
|
964
|
+
getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3, userId) {
|
|
965
|
+
const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
|
|
966
|
+
const anchor = this.db.query(`SELECT * FROM observations WHERE id = ?${visibilityClause}`).get(anchorId, ...userId ? [userId] : []) ?? null;
|
|
754
967
|
if (!anchor)
|
|
755
968
|
return [];
|
|
756
969
|
const projectFilter = projectId !== null ? "AND project_id = ?" : "";
|
|
757
970
|
const projectParams = projectId !== null ? [projectId] : [];
|
|
971
|
+
const visibilityParams = userId ? [userId] : [];
|
|
758
972
|
const before = this.db.query(`SELECT * FROM observations
|
|
759
973
|
WHERE created_at_epoch < ? ${projectFilter}
|
|
760
974
|
AND lifecycle IN ('active', 'aging', 'pinned')
|
|
975
|
+
${visibilityClause}
|
|
761
976
|
ORDER BY created_at_epoch DESC
|
|
762
|
-
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthBefore);
|
|
977
|
+
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthBefore);
|
|
763
978
|
const after = this.db.query(`SELECT * FROM observations
|
|
764
979
|
WHERE created_at_epoch > ? ${projectFilter}
|
|
765
980
|
AND lifecycle IN ('active', 'aging', 'pinned')
|
|
981
|
+
${visibilityClause}
|
|
766
982
|
ORDER BY created_at_epoch ASC
|
|
767
|
-
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthAfter);
|
|
983
|
+
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthAfter);
|
|
768
984
|
return [...before.reverse(), anchor, ...after];
|
|
769
985
|
}
|
|
770
986
|
pinObservation(id, pinned) {
|
|
@@ -878,11 +1094,12 @@ class MemDatabase {
|
|
|
878
1094
|
return;
|
|
879
1095
|
this.db.query("DELETE FROM vec_observations WHERE observation_id = ?").run(observationId);
|
|
880
1096
|
}
|
|
881
|
-
searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
|
|
1097
|
+
searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
|
|
882
1098
|
if (!this.vecAvailable)
|
|
883
1099
|
return [];
|
|
884
1100
|
const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
|
|
885
1101
|
const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
|
|
1102
|
+
const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
|
|
886
1103
|
if (projectId !== null) {
|
|
887
1104
|
return this.db.query(`SELECT v.observation_id, v.distance
|
|
888
1105
|
FROM vec_observations v
|
|
@@ -891,7 +1108,7 @@ class MemDatabase {
|
|
|
891
1108
|
AND k = ?
|
|
892
1109
|
AND o.project_id = ?
|
|
893
1110
|
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
894
|
-
AND o.superseded_by IS NULL`).all(embeddingBlob, limit, projectId, ...lifecycles);
|
|
1111
|
+
AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, projectId, ...lifecycles, ...userId ? [userId] : []);
|
|
895
1112
|
}
|
|
896
1113
|
return this.db.query(`SELECT v.observation_id, v.distance
|
|
897
1114
|
FROM vec_observations v
|
|
@@ -899,7 +1116,7 @@ class MemDatabase {
|
|
|
899
1116
|
WHERE v.embedding MATCH ?
|
|
900
1117
|
AND k = ?
|
|
901
1118
|
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
902
|
-
AND o.superseded_by IS NULL`).all(embeddingBlob, limit, ...lifecycles);
|
|
1119
|
+
AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, ...lifecycles, ...userId ? [userId] : []);
|
|
903
1120
|
}
|
|
904
1121
|
getUnembeddedCount() {
|
|
905
1122
|
if (!this.vecAvailable)
|
|
@@ -1018,210 +1235,58 @@ class MemDatabase {
|
|
|
1018
1235
|
}
|
|
1019
1236
|
}
|
|
1020
1237
|
|
|
1021
|
-
// src/
|
|
1022
|
-
var
|
|
1023
|
-
"
|
|
1024
|
-
"
|
|
1025
|
-
"
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
/^\s*(ls|pwd|cd|echo|cat|head|tail|wc|which|whoami|date|uname)\b/,
|
|
1032
|
-
/^\s*git\s+(status|log|branch|diff|show|remote)\b/,
|
|
1033
|
-
/^\s*(node|bun|npm|npx|yarn|pnpm)\s+--?version\b/,
|
|
1034
|
-
/^\s*export\s+/,
|
|
1035
|
-
/^\s*#/
|
|
1036
|
-
];
|
|
1037
|
-
var TRIVIAL_RESPONSE_PATTERNS = [
|
|
1038
|
-
/^$/,
|
|
1039
|
-
/^\s*$/,
|
|
1040
|
-
/^Already up to date\.$/
|
|
1041
|
-
];
|
|
1042
|
-
function extractObservation(event) {
|
|
1043
|
-
const { tool_name, tool_input, tool_response } = event;
|
|
1044
|
-
if (SKIP_TOOLS.has(tool_name)) {
|
|
1045
|
-
return null;
|
|
1046
|
-
}
|
|
1047
|
-
switch (tool_name) {
|
|
1048
|
-
case "Edit":
|
|
1049
|
-
return extractFromEdit(tool_input, tool_response);
|
|
1050
|
-
case "Write":
|
|
1051
|
-
return extractFromWrite(tool_input, tool_response);
|
|
1052
|
-
case "Bash":
|
|
1053
|
-
return extractFromBash(tool_input, tool_response);
|
|
1054
|
-
default:
|
|
1055
|
-
if (tool_name.startsWith("mcp__")) {
|
|
1056
|
-
return extractFromMcpTool(tool_name, tool_input, tool_response);
|
|
1057
|
-
}
|
|
1058
|
-
return null;
|
|
1238
|
+
// src/hooks/common.ts
|
|
1239
|
+
var c = {
|
|
1240
|
+
dim: "\x1B[2m",
|
|
1241
|
+
yellow: "\x1B[33m",
|
|
1242
|
+
reset: "\x1B[0m"
|
|
1243
|
+
};
|
|
1244
|
+
async function readStdin() {
|
|
1245
|
+
const chunks = [];
|
|
1246
|
+
for await (const chunk of process.stdin) {
|
|
1247
|
+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
|
|
1059
1248
|
}
|
|
1249
|
+
return chunks.join("");
|
|
1060
1250
|
}
|
|
1061
|
-
function
|
|
1062
|
-
const
|
|
1063
|
-
if (!
|
|
1251
|
+
async function parseStdinJson() {
|
|
1252
|
+
const raw = await readStdin();
|
|
1253
|
+
if (!raw.trim())
|
|
1064
1254
|
return null;
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1255
|
+
try {
|
|
1256
|
+
return JSON.parse(raw);
|
|
1257
|
+
} catch {
|
|
1068
1258
|
return null;
|
|
1069
|
-
if (oldStr && newStr) {
|
|
1070
|
-
const oldTrimmed = oldStr.trim();
|
|
1071
|
-
const newTrimmed = newStr.trim();
|
|
1072
|
-
if (oldTrimmed === newTrimmed)
|
|
1073
|
-
return null;
|
|
1074
|
-
if (Math.abs(oldTrimmed.length - newTrimmed.length) < 3 && oldTrimmed.length < 20) {
|
|
1075
|
-
return null;
|
|
1076
|
-
}
|
|
1077
1259
|
}
|
|
1078
|
-
const fileName = filePath.split("/").pop() ?? filePath;
|
|
1079
|
-
const changeSize = (newStr?.length ?? 0) - (oldStr?.length ?? 0);
|
|
1080
|
-
const verb = changeSize > 50 ? "Extended" : changeSize < -50 ? "Reduced" : "Modified";
|
|
1081
|
-
return {
|
|
1082
|
-
type: "change",
|
|
1083
|
-
title: `${verb} ${fileName}`,
|
|
1084
|
-
narrative: buildEditNarrative(oldStr, newStr, filePath),
|
|
1085
|
-
files_modified: [filePath]
|
|
1086
|
-
};
|
|
1087
1260
|
}
|
|
1088
|
-
function
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
return null;
|
|
1092
|
-
const content = input["content"];
|
|
1093
|
-
const fileName = filePath.split("/").pop() ?? filePath;
|
|
1094
|
-
if (content === undefined || content.length < 50)
|
|
1095
|
-
return null;
|
|
1096
|
-
return {
|
|
1097
|
-
type: "change",
|
|
1098
|
-
title: `Created ${fileName}`,
|
|
1099
|
-
narrative: `New file created: ${filePath}`,
|
|
1100
|
-
files_modified: [filePath]
|
|
1101
|
-
};
|
|
1102
|
-
}
|
|
1103
|
-
function extractFromBash(input, response) {
|
|
1104
|
-
const command = input["command"];
|
|
1105
|
-
if (!command)
|
|
1106
|
-
return null;
|
|
1107
|
-
for (const pattern of SKIP_BASH_PATTERNS) {
|
|
1108
|
-
if (pattern.test(command))
|
|
1109
|
-
return null;
|
|
1110
|
-
}
|
|
1111
|
-
for (const pattern of TRIVIAL_RESPONSE_PATTERNS) {
|
|
1112
|
-
if (pattern.test(response.trim()))
|
|
1113
|
-
return null;
|
|
1114
|
-
}
|
|
1115
|
-
const hasError = detectError(response);
|
|
1116
|
-
const isTestRun = detectTestRun(command);
|
|
1117
|
-
if (isTestRun) {
|
|
1118
|
-
return extractTestResult(command, response);
|
|
1119
|
-
}
|
|
1120
|
-
if (hasError) {
|
|
1121
|
-
return {
|
|
1122
|
-
type: "bugfix",
|
|
1123
|
-
title: summariseCommand(command) + " (error)",
|
|
1124
|
-
narrative: `Command: ${truncate(command, 200)}
|
|
1125
|
-
Error: ${truncate(response, 500)}`
|
|
1126
|
-
};
|
|
1127
|
-
}
|
|
1128
|
-
if (/\b(npm|bun|yarn|pnpm)\s+(install|add|remove|uninstall)\b/.test(command)) {
|
|
1129
|
-
return {
|
|
1130
|
-
type: "change",
|
|
1131
|
-
title: `Dependency change: ${summariseCommand(command)}`,
|
|
1132
|
-
narrative: `Command: ${truncate(command, 200)}
|
|
1133
|
-
Output: ${truncate(response, 300)}`
|
|
1134
|
-
};
|
|
1135
|
-
}
|
|
1136
|
-
if (/\b(npm|bun|yarn)\s+(run\s+)?(build|compile|bundle)\b/.test(command)) {
|
|
1137
|
-
if (hasError) {
|
|
1138
|
-
return {
|
|
1139
|
-
type: "bugfix",
|
|
1140
|
-
title: `Build failure: ${summariseCommand(command)}`,
|
|
1141
|
-
narrative: `Build command failed.
|
|
1142
|
-
Command: ${truncate(command, 200)}
|
|
1143
|
-
Output: ${truncate(response, 500)}`
|
|
1144
|
-
};
|
|
1145
|
-
}
|
|
1261
|
+
function bootstrapHook(hookName) {
|
|
1262
|
+
if (!configExists()) {
|
|
1263
|
+
warnUser(hookName, "Engrm not configured. Run: npx engrm init");
|
|
1146
1264
|
return null;
|
|
1147
1265
|
}
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
Output: ${truncate(response, 300)}`
|
|
1154
|
-
};
|
|
1155
|
-
}
|
|
1156
|
-
return null;
|
|
1157
|
-
}
|
|
1158
|
-
function extractFromMcpTool(toolName, input, response) {
|
|
1159
|
-
if (toolName.startsWith("mcp__engrm__"))
|
|
1160
|
-
return null;
|
|
1161
|
-
if (response.length < 100)
|
|
1266
|
+
let config;
|
|
1267
|
+
try {
|
|
1268
|
+
config = loadConfig();
|
|
1269
|
+
} catch (err) {
|
|
1270
|
+
warnUser(hookName, `Config error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1162
1271
|
return null;
|
|
1163
|
-
const parts = toolName.split("__");
|
|
1164
|
-
const serverName = parts[1] ?? "unknown";
|
|
1165
|
-
const toolAction = parts[2] ?? "unknown";
|
|
1166
|
-
return {
|
|
1167
|
-
type: "change",
|
|
1168
|
-
title: `${serverName}: ${toolAction}`,
|
|
1169
|
-
narrative: `MCP tool ${toolName} called.
|
|
1170
|
-
Response: ${truncate(response, 300)}`
|
|
1171
|
-
};
|
|
1172
|
-
}
|
|
1173
|
-
function detectError(response) {
|
|
1174
|
-
const lower = response.toLowerCase();
|
|
1175
|
-
return lower.includes("error:") || lower.includes("error[") || lower.includes("failed") || lower.includes("exception") || lower.includes("traceback") || lower.includes("panic:") || lower.includes("fatal:") || /exit code [1-9]/.test(lower);
|
|
1176
|
-
}
|
|
1177
|
-
function detectTestRun(command) {
|
|
1178
|
-
return /\b(test|spec|jest|vitest|mocha|pytest|cargo\s+test|go\s+test|bun\s+test)\b/i.test(command);
|
|
1179
|
-
}
|
|
1180
|
-
function extractTestResult(command, response) {
|
|
1181
|
-
const hasFailure = /[1-9]\d*\s+(fail|failed|failures?)\b/i.test(response) || /\bFAILED\b/.test(response) || /\berror\b/i.test(response);
|
|
1182
|
-
const hasPass = /\d+\s+(pass|passed|ok)\b/i.test(response) || /\bPASS\b/.test(response);
|
|
1183
|
-
if (hasFailure) {
|
|
1184
|
-
return {
|
|
1185
|
-
type: "bugfix",
|
|
1186
|
-
title: `Test failure: ${summariseCommand(command)}`,
|
|
1187
|
-
narrative: `Test run failed.
|
|
1188
|
-
Command: ${truncate(command, 200)}
|
|
1189
|
-
Output: ${truncate(response, 500)}`
|
|
1190
|
-
};
|
|
1191
1272
|
}
|
|
1192
|
-
|
|
1273
|
+
let db;
|
|
1274
|
+
try {
|
|
1275
|
+
db = new MemDatabase(getDbPath());
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
warnUser(hookName, `Database error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1193
1278
|
return null;
|
|
1194
1279
|
}
|
|
1195
|
-
return
|
|
1280
|
+
return { config, db };
|
|
1196
1281
|
}
|
|
1197
|
-
function
|
|
1198
|
-
|
|
1199
|
-
if (oldStr && newStr) {
|
|
1200
|
-
const oldLines = oldStr.split(`
|
|
1201
|
-
`).length;
|
|
1202
|
-
const newLines = newStr.split(`
|
|
1203
|
-
`).length;
|
|
1204
|
-
if (oldLines !== newLines) {
|
|
1205
|
-
parts.push(`Lines: ${oldLines} → ${newLines}`);
|
|
1206
|
-
}
|
|
1207
|
-
parts.push(`Replaced: ${truncate(oldStr, 100)}`);
|
|
1208
|
-
parts.push(`With: ${truncate(newStr, 100)}`);
|
|
1209
|
-
} else if (newStr) {
|
|
1210
|
-
parts.push(`Added: ${truncate(newStr, 150)}`);
|
|
1211
|
-
}
|
|
1212
|
-
return parts.join(`
|
|
1213
|
-
`);
|
|
1214
|
-
}
|
|
1215
|
-
function summariseCommand(command) {
|
|
1216
|
-
const trimmed = command.trim();
|
|
1217
|
-
const firstLine = trimmed.split(`
|
|
1218
|
-
`)[0] ?? trimmed;
|
|
1219
|
-
return truncate(firstLine, 80);
|
|
1282
|
+
function warnUser(hookName, message) {
|
|
1283
|
+
console.error(`${c.yellow}engrm ${hookName}:${c.reset} ${c.dim}${message}${c.reset}`);
|
|
1220
1284
|
}
|
|
1221
|
-
function
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1285
|
+
function runHook(hookName, fn) {
|
|
1286
|
+
fn().catch((err) => {
|
|
1287
|
+
warnUser(hookName, `Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1288
|
+
process.exit(0);
|
|
1289
|
+
});
|
|
1225
1290
|
}
|
|
1226
1291
|
|
|
1227
1292
|
// src/tools/save.ts
|
|
@@ -1389,6 +1454,12 @@ function scoreQuality(input) {
|
|
|
1389
1454
|
case "digest":
|
|
1390
1455
|
score += 0.3;
|
|
1391
1456
|
break;
|
|
1457
|
+
case "standard":
|
|
1458
|
+
score += 0.25;
|
|
1459
|
+
break;
|
|
1460
|
+
case "message":
|
|
1461
|
+
score += 0.1;
|
|
1462
|
+
break;
|
|
1392
1463
|
}
|
|
1393
1464
|
if (input.narrative && input.narrative.length > 50) {
|
|
1394
1465
|
score += 0.15;
|
|
@@ -1709,9 +1780,9 @@ function mergeConceptsFromBoth(obs1, obs2) {
|
|
|
1709
1780
|
try {
|
|
1710
1781
|
const parsed = JSON.parse(obs.concepts);
|
|
1711
1782
|
if (Array.isArray(parsed)) {
|
|
1712
|
-
for (const
|
|
1713
|
-
if (typeof
|
|
1714
|
-
concepts.add(
|
|
1783
|
+
for (const c2 of parsed) {
|
|
1784
|
+
if (typeof c2 === "string")
|
|
1785
|
+
concepts.add(c2);
|
|
1715
1786
|
}
|
|
1716
1787
|
}
|
|
1717
1788
|
} catch {}
|
|
@@ -2601,29 +2672,13 @@ function checkSessionFatigue(db, sessionId) {
|
|
|
2601
2672
|
|
|
2602
2673
|
// hooks/post-tool-use.ts
|
|
2603
2674
|
async function main() {
|
|
2604
|
-
const
|
|
2605
|
-
|
|
2606
|
-
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
|
|
2607
|
-
}
|
|
2608
|
-
const raw = chunks.join("");
|
|
2609
|
-
if (!raw.trim())
|
|
2675
|
+
const event = await parseStdinJson();
|
|
2676
|
+
if (!event)
|
|
2610
2677
|
process.exit(0);
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
event = JSON.parse(raw);
|
|
2614
|
-
} catch {
|
|
2678
|
+
const boot = bootstrapHook("post-tool-use");
|
|
2679
|
+
if (!boot)
|
|
2615
2680
|
process.exit(0);
|
|
2616
|
-
}
|
|
2617
|
-
if (!configExists())
|
|
2618
|
-
process.exit(0);
|
|
2619
|
-
let config;
|
|
2620
|
-
let db;
|
|
2621
|
-
try {
|
|
2622
|
-
config = loadConfig();
|
|
2623
|
-
db = new MemDatabase(getDbPath());
|
|
2624
|
-
} catch {
|
|
2625
|
-
process.exit(0);
|
|
2626
|
-
}
|
|
2681
|
+
const { config, db } = boot;
|
|
2627
2682
|
try {
|
|
2628
2683
|
if (event.session_id) {
|
|
2629
2684
|
const detected = detectProject(event.cwd);
|
|
@@ -2786,6 +2841,4 @@ function incrementRecallMetrics(sessionId, hit) {
|
|
|
2786
2841
|
writeFileSync3(path, JSON.stringify(state), "utf-8");
|
|
2787
2842
|
} catch {}
|
|
2788
2843
|
}
|
|
2789
|
-
main
|
|
2790
|
-
process.exit(0);
|
|
2791
|
-
});
|
|
2844
|
+
runHook("post-tool-use", main);
|