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.
- package/README.md +49 -5
- 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 +326 -231
- package/dist/hooks/pre-compact.js +410 -78
- package/dist/hooks/sentinel.js +150 -103
- package/dist/hooks/session-start.js +2311 -1983
- package/dist/hooks/stop.js +302 -147
- package/dist/server.js +634 -118
- package/package.json +6 -5
- package/bin/build.mjs +0 -97
- package/bin/engrm.mjs +0 -13
package/README.md
CHANGED
|
@@ -2,17 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
**Cross-device memory for AI coding agents.** Every session remembers what you learned yesterday — on any machine, for every team member.
|
|
4
4
|
|
|
5
|
+
For npm users, Engrm runs on Node.js 18+ and does not require Bun to be installed.
|
|
6
|
+
|
|
5
7
|
```
|
|
6
8
|
npx engrm init
|
|
7
9
|
```
|
|
8
10
|
|
|
11
|
+
Public beta. The current source of truth for agent capability differences is [AGENT_SUPPORT.md](AGENT_SUPPORT.md).
|
|
12
|
+
|
|
9
13
|
---
|
|
10
14
|
|
|
11
15
|
## What It Does
|
|
12
16
|
|
|
13
17
|
Your AI agent forgets everything between sessions. Engrm fixes that.
|
|
14
18
|
|
|
15
|
-
- **Captures automatically** — hooks into Claude Code
|
|
19
|
+
- **Captures automatically** — hooks into Claude Code, and into Codex where its public hook surface allows
|
|
16
20
|
- **Remembers across devices** — fix a bug on your laptop, continue on your desktop with full context
|
|
17
21
|
- **Shares with your team** — one developer's hard-won insight becomes everyone's knowledge
|
|
18
22
|
- **Works offline** — local SQLite is the source of truth; syncs when connected
|
|
@@ -30,7 +34,7 @@ Visit [engrm.dev](https://engrm.dev) and create an account.
|
|
|
30
34
|
npx engrm init
|
|
31
35
|
```
|
|
32
36
|
|
|
33
|
-
This opens your browser for authentication, writes config to `~/.engrm/`, and registers
|
|
37
|
+
This opens your browser for authentication, writes config to `~/.engrm/`, and registers Engrm in both Claude Code and Codex when those configs are available. Takes about 30 seconds.
|
|
34
38
|
|
|
35
39
|
**Alternative methods:**
|
|
36
40
|
```bash
|
|
@@ -44,12 +48,12 @@ npx engrm init --url=https://vector.internal.company.com
|
|
|
44
48
|
npx engrm init --manual
|
|
45
49
|
```
|
|
46
50
|
|
|
47
|
-
### 3. Use
|
|
51
|
+
### 3. Use your agent normally
|
|
48
52
|
|
|
49
53
|
That's it. Engrm works in the background:
|
|
50
54
|
|
|
51
55
|
- **Session start** — injects relevant project memory into context
|
|
52
|
-
- **While you work** — captures observations from tool use
|
|
56
|
+
- **While you work** — captures observations from tool use where the agent exposes that hook surface
|
|
53
57
|
- **Session end** — generates a session digest, syncs to cloud, shows summary
|
|
54
58
|
|
|
55
59
|
```
|
|
@@ -79,7 +83,9 @@ Engrm Status
|
|
|
79
83
|
Plan: Pro ($15/mo)
|
|
80
84
|
Candengo: https://www.candengo.com
|
|
81
85
|
MCP server: registered
|
|
86
|
+
Codex MCP: registered
|
|
82
87
|
Hooks: registered (6 hooks)
|
|
88
|
+
Codex hooks: registered (2 hooks)
|
|
83
89
|
|
|
84
90
|
Observations: 1,247 active
|
|
85
91
|
By type: change: 412, discovery: 289, bugfix: 187, ...
|
|
@@ -117,6 +123,28 @@ Claude Code session
|
|
|
117
123
|
Available on all your devices + team members
|
|
118
124
|
```
|
|
119
125
|
|
|
126
|
+
```
|
|
127
|
+
Codex session
|
|
128
|
+
│
|
|
129
|
+
├─ SessionStart hook ──→ inject relevant memory into context
|
|
130
|
+
│
|
|
131
|
+
├─ MCP tools ──────────→ search, save, inspect, message, stats
|
|
132
|
+
│
|
|
133
|
+
└─ Stop hook ──────────→ session digest + sync + summary
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Agent Support
|
|
137
|
+
|
|
138
|
+
| Capability | Claude Code | Codex |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| MCP server tools | ✓ | ✓ |
|
|
141
|
+
| Session-start context injection | ✓ | ✓ |
|
|
142
|
+
| Stop/session summary hook | ✓ | ✓ |
|
|
143
|
+
| Per-tool automatic capture | ✓ | Partial via MCP/manual flows only |
|
|
144
|
+
| Pre-write Sentinel hook | ✓ | Not yet exposed by Codex public hooks |
|
|
145
|
+
| Pre-compact reinjection | ✓ | Not exposed |
|
|
146
|
+
| ElicitationResult capture | ✓ | Not exposed |
|
|
147
|
+
|
|
120
148
|
### MCP Tools
|
|
121
149
|
|
|
122
150
|
The MCP server exposes tools that Claude can call directly:
|
|
@@ -128,6 +156,9 @@ The MCP server exposes tools that Claude can call directly:
|
|
|
128
156
|
| `get_observations` | Fetch full details by ID |
|
|
129
157
|
| `save_observation` | Manually save something worth remembering |
|
|
130
158
|
| `install_pack` | Load a curated knowledge pack for your stack |
|
|
159
|
+
| `send_message` | Leave a cross-device or team note |
|
|
160
|
+
| `recent_activity` | Inspect what Engrm captured most recently |
|
|
161
|
+
| `memory_stats` | View high-level capture and sync health |
|
|
131
162
|
|
|
132
163
|
### Observation Types
|
|
133
164
|
|
|
@@ -227,11 +258,13 @@ Place in your project root to override project identity for non-git projects:
|
|
|
227
258
|
}
|
|
228
259
|
```
|
|
229
260
|
|
|
230
|
-
###
|
|
261
|
+
### Agent integration
|
|
231
262
|
|
|
232
263
|
Engrm auto-registers in:
|
|
233
264
|
- `~/.claude.json` — MCP server (`engrm`)
|
|
234
265
|
- `~/.claude/settings.json` — 6 lifecycle hooks
|
|
266
|
+
- `~/.codex/config.toml` — MCP server (`engrm`) + `codex_hooks` feature flag
|
|
267
|
+
- `~/.codex/hooks.json` — `SessionStart` and `Stop` hooks
|
|
235
268
|
|
|
236
269
|
---
|
|
237
270
|
|
|
@@ -259,4 +292,15 @@ See [LICENSE](LICENSE) for full terms.
|
|
|
259
292
|
|
|
260
293
|
---
|
|
261
294
|
|
|
295
|
+
## Project
|
|
296
|
+
|
|
297
|
+
- Architecture: [ARCHITECTURE.md](ARCHITECTURE.md)
|
|
298
|
+
- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
299
|
+
- Security: [SECURITY.md](SECURITY.md)
|
|
300
|
+
- Roadmap: [ROADMAP.md](ROADMAP.md)
|
|
301
|
+
|
|
302
|
+
Maintainers: run `node scripts/check-public-docs.mjs` to verify the repo only contains the approved public docs set at the root.
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
262
306
|
Built by the [Engrm](https://engrm.dev) team, powered by [Candengo Vector](https://www.candengo.com).
|
package/dist/cli.js
CHANGED
|
@@ -20,8 +20,9 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
|
20
20
|
// src/cli.ts
|
|
21
21
|
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync6, statSync } from "fs";
|
|
22
22
|
import { hostname as hostname2, homedir as homedir3, networkInterfaces as networkInterfaces2 } from "os";
|
|
23
|
-
import { join as join6 } from "path";
|
|
23
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
24
24
|
import { createHash as createHash2 } from "crypto";
|
|
25
|
+
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
25
26
|
|
|
26
27
|
// src/config.ts
|
|
27
28
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -726,8 +727,8 @@ class MemDatabase {
|
|
|
726
727
|
return this.db.query("SELECT * FROM projects WHERE id = ?").get(id) ?? null;
|
|
727
728
|
}
|
|
728
729
|
insertObservation(obs) {
|
|
729
|
-
const now = Math.floor(Date.now() / 1000);
|
|
730
|
-
const createdAt = new Date().toISOString();
|
|
730
|
+
const now = obs.created_at_epoch ?? Math.floor(Date.now() / 1000);
|
|
731
|
+
const createdAt = obs.created_at ?? new Date(now * 1000).toISOString();
|
|
731
732
|
const result = this.db.query(`INSERT INTO observations (
|
|
732
733
|
session_id, project_id, type, title, narrative, facts, concepts,
|
|
733
734
|
files_read, files_modified, quality, lifecycle, sensitivity,
|
|
@@ -744,11 +745,14 @@ class MemDatabase {
|
|
|
744
745
|
getObservationById(id) {
|
|
745
746
|
return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
|
|
746
747
|
}
|
|
747
|
-
getObservationsByIds(ids) {
|
|
748
|
+
getObservationsByIds(ids, userId) {
|
|
748
749
|
if (ids.length === 0)
|
|
749
750
|
return [];
|
|
750
751
|
const placeholders = ids.map(() => "?").join(",");
|
|
751
|
-
|
|
752
|
+
const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
|
|
753
|
+
return this.db.query(`SELECT * FROM observations
|
|
754
|
+
WHERE id IN (${placeholders})${visibilityClause}
|
|
755
|
+
ORDER BY created_at_epoch DESC`).all(...ids, ...userId ? [userId] : []);
|
|
752
756
|
}
|
|
753
757
|
getRecentObservations(projectId, sincEpoch, limit = 50) {
|
|
754
758
|
return this.db.query(`SELECT * FROM observations
|
|
@@ -756,8 +760,9 @@ class MemDatabase {
|
|
|
756
760
|
ORDER BY created_at_epoch DESC
|
|
757
761
|
LIMIT ?`).all(projectId, sincEpoch, limit);
|
|
758
762
|
}
|
|
759
|
-
searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
|
|
763
|
+
searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
|
|
760
764
|
const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
|
|
765
|
+
const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
|
|
761
766
|
if (projectId !== null) {
|
|
762
767
|
return this.db.query(`SELECT o.id, observations_fts.rank
|
|
763
768
|
FROM observations_fts
|
|
@@ -765,33 +770,39 @@ class MemDatabase {
|
|
|
765
770
|
WHERE observations_fts MATCH ?
|
|
766
771
|
AND o.project_id = ?
|
|
767
772
|
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
773
|
+
${visibilityClause}
|
|
768
774
|
ORDER BY observations_fts.rank
|
|
769
|
-
LIMIT ?`).all(query, projectId, ...lifecycles, limit);
|
|
775
|
+
LIMIT ?`).all(query, projectId, ...lifecycles, ...userId ? [userId] : [], limit);
|
|
770
776
|
}
|
|
771
777
|
return this.db.query(`SELECT o.id, observations_fts.rank
|
|
772
778
|
FROM observations_fts
|
|
773
779
|
JOIN observations o ON o.id = observations_fts.rowid
|
|
774
780
|
WHERE observations_fts MATCH ?
|
|
775
781
|
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
782
|
+
${visibilityClause}
|
|
776
783
|
ORDER BY observations_fts.rank
|
|
777
|
-
LIMIT ?`).all(query, ...lifecycles, limit);
|
|
784
|
+
LIMIT ?`).all(query, ...lifecycles, ...userId ? [userId] : [], limit);
|
|
778
785
|
}
|
|
779
|
-
getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3) {
|
|
780
|
-
const
|
|
786
|
+
getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3, userId) {
|
|
787
|
+
const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
|
|
788
|
+
const anchor = this.db.query(`SELECT * FROM observations WHERE id = ?${visibilityClause}`).get(anchorId, ...userId ? [userId] : []) ?? null;
|
|
781
789
|
if (!anchor)
|
|
782
790
|
return [];
|
|
783
791
|
const projectFilter = projectId !== null ? "AND project_id = ?" : "";
|
|
784
792
|
const projectParams = projectId !== null ? [projectId] : [];
|
|
793
|
+
const visibilityParams = userId ? [userId] : [];
|
|
785
794
|
const before = this.db.query(`SELECT * FROM observations
|
|
786
795
|
WHERE created_at_epoch < ? ${projectFilter}
|
|
787
796
|
AND lifecycle IN ('active', 'aging', 'pinned')
|
|
797
|
+
${visibilityClause}
|
|
788
798
|
ORDER BY created_at_epoch DESC
|
|
789
|
-
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthBefore);
|
|
799
|
+
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthBefore);
|
|
790
800
|
const after = this.db.query(`SELECT * FROM observations
|
|
791
801
|
WHERE created_at_epoch > ? ${projectFilter}
|
|
792
802
|
AND lifecycle IN ('active', 'aging', 'pinned')
|
|
803
|
+
${visibilityClause}
|
|
793
804
|
ORDER BY created_at_epoch ASC
|
|
794
|
-
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthAfter);
|
|
805
|
+
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthAfter);
|
|
795
806
|
return [...before.reverse(), anchor, ...after];
|
|
796
807
|
}
|
|
797
808
|
pinObservation(id, pinned) {
|
|
@@ -905,11 +916,12 @@ class MemDatabase {
|
|
|
905
916
|
return;
|
|
906
917
|
this.db.query("DELETE FROM vec_observations WHERE observation_id = ?").run(observationId);
|
|
907
918
|
}
|
|
908
|
-
searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
|
|
919
|
+
searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
|
|
909
920
|
if (!this.vecAvailable)
|
|
910
921
|
return [];
|
|
911
922
|
const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
|
|
912
923
|
const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
|
|
924
|
+
const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
|
|
913
925
|
if (projectId !== null) {
|
|
914
926
|
return this.db.query(`SELECT v.observation_id, v.distance
|
|
915
927
|
FROM vec_observations v
|
|
@@ -918,7 +930,7 @@ class MemDatabase {
|
|
|
918
930
|
AND k = ?
|
|
919
931
|
AND o.project_id = ?
|
|
920
932
|
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
921
|
-
AND o.superseded_by IS NULL`).all(embeddingBlob, limit, projectId, ...lifecycles);
|
|
933
|
+
AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, projectId, ...lifecycles, ...userId ? [userId] : []);
|
|
922
934
|
}
|
|
923
935
|
return this.db.query(`SELECT v.observation_id, v.distance
|
|
924
936
|
FROM vec_observations v
|
|
@@ -926,7 +938,7 @@ class MemDatabase {
|
|
|
926
938
|
WHERE v.embedding MATCH ?
|
|
927
939
|
AND k = ?
|
|
928
940
|
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
929
|
-
AND o.superseded_by IS NULL`).all(embeddingBlob, limit, ...lifecycles);
|
|
941
|
+
AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, ...lifecycles, ...userId ? [userId] : []);
|
|
930
942
|
}
|
|
931
943
|
getUnembeddedCount() {
|
|
932
944
|
if (!this.vecAvailable)
|
|
@@ -1580,6 +1592,9 @@ import { join as join2, dirname } from "node:path";
|
|
|
1580
1592
|
import { fileURLToPath } from "node:url";
|
|
1581
1593
|
var CLAUDE_JSON = join2(homedir2(), ".claude.json");
|
|
1582
1594
|
var CLAUDE_SETTINGS = join2(homedir2(), ".claude", "settings.json");
|
|
1595
|
+
var CODEX_CONFIG = join2(homedir2(), ".codex", "config.toml");
|
|
1596
|
+
var CODEX_HOOKS = join2(homedir2(), ".codex", "hooks.json");
|
|
1597
|
+
var LEGACY_CODEX_SERVER_NAME = `candengo-${"mem"}`;
|
|
1583
1598
|
function isBuiltDist() {
|
|
1584
1599
|
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
1585
1600
|
return thisDir.endsWith("/dist") || thisDir.endsWith("\\dist");
|
|
@@ -1626,6 +1641,15 @@ function writeJsonFile(path, data) {
|
|
|
1626
1641
|
writeFileSync2(path, JSON.stringify(data, null, 2) + `
|
|
1627
1642
|
`, "utf-8");
|
|
1628
1643
|
}
|
|
1644
|
+
function ensureParentDir(path) {
|
|
1645
|
+
const dir = dirname(path);
|
|
1646
|
+
if (!existsSync2(dir)) {
|
|
1647
|
+
mkdirSync2(dir, { recursive: true });
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
function escapeTomlString(value) {
|
|
1651
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
1652
|
+
}
|
|
1629
1653
|
function registerMcpServer() {
|
|
1630
1654
|
const runtime = findRuntime();
|
|
1631
1655
|
const root = findPackageRoot();
|
|
@@ -1642,6 +1666,123 @@ function registerMcpServer() {
|
|
|
1642
1666
|
writeJsonFile(CLAUDE_JSON, config);
|
|
1643
1667
|
return { path: CLAUDE_JSON, added: true };
|
|
1644
1668
|
}
|
|
1669
|
+
function upsertCodexMcpServerConfig(existing, entry) {
|
|
1670
|
+
const lines = existing.length > 0 ? existing.split(/\r?\n/) : [];
|
|
1671
|
+
const targetHeaders = new Set([
|
|
1672
|
+
`[mcp_servers.${entry.name}]`,
|
|
1673
|
+
`[mcp_servers.${LEGACY_CODEX_SERVER_NAME}]`
|
|
1674
|
+
]);
|
|
1675
|
+
const output = [];
|
|
1676
|
+
let skipping = false;
|
|
1677
|
+
let inFeatures = false;
|
|
1678
|
+
let featuresInserted = false;
|
|
1679
|
+
for (const line of lines) {
|
|
1680
|
+
const trimmed = line.trim();
|
|
1681
|
+
if (/^\[.*\]$/.test(trimmed)) {
|
|
1682
|
+
if (inFeatures && !featuresInserted) {
|
|
1683
|
+
output.push(`codex_hooks = true`);
|
|
1684
|
+
featuresInserted = true;
|
|
1685
|
+
}
|
|
1686
|
+
skipping = targetHeaders.has(trimmed);
|
|
1687
|
+
inFeatures = trimmed === "[features]";
|
|
1688
|
+
if (!skipping)
|
|
1689
|
+
output.push(line);
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1692
|
+
if (!skipping) {
|
|
1693
|
+
if (inFeatures && trimmed.startsWith("codex_hooks")) {
|
|
1694
|
+
if (!featuresInserted) {
|
|
1695
|
+
output.push(`codex_hooks = true`);
|
|
1696
|
+
featuresInserted = true;
|
|
1697
|
+
}
|
|
1698
|
+
continue;
|
|
1699
|
+
}
|
|
1700
|
+
output.push(line);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
if (inFeatures && !featuresInserted) {
|
|
1704
|
+
output.push(`codex_hooks = true`);
|
|
1705
|
+
featuresInserted = true;
|
|
1706
|
+
}
|
|
1707
|
+
while (output.length > 0 && output[output.length - 1] === "") {
|
|
1708
|
+
output.pop();
|
|
1709
|
+
}
|
|
1710
|
+
if (output.length > 0) {
|
|
1711
|
+
output.push("");
|
|
1712
|
+
}
|
|
1713
|
+
output.push(`[mcp_servers.${entry.name}]`);
|
|
1714
|
+
output.push(`enabled = true`);
|
|
1715
|
+
output.push(`command = "${escapeTomlString(entry.command)}"`);
|
|
1716
|
+
output.push(`args = [${entry.args.map((arg) => `"${escapeTomlString(arg)}"`).join(", ")}]`);
|
|
1717
|
+
output.push(`startup_timeout_sec = 15`);
|
|
1718
|
+
output.push(`tool_timeout_sec = 30`);
|
|
1719
|
+
output.push("");
|
|
1720
|
+
if (!featuresInserted) {
|
|
1721
|
+
output.push(`[features]`);
|
|
1722
|
+
output.push(`codex_hooks = true`);
|
|
1723
|
+
output.push("");
|
|
1724
|
+
}
|
|
1725
|
+
return output.join(`
|
|
1726
|
+
`);
|
|
1727
|
+
}
|
|
1728
|
+
function registerCodexMcpServer() {
|
|
1729
|
+
const runtime = findRuntime();
|
|
1730
|
+
const root = findPackageRoot();
|
|
1731
|
+
const dist = isBuiltDist();
|
|
1732
|
+
const serverPath = dist ? join2(root, "dist", "server.js") : join2(root, "src", "server.ts");
|
|
1733
|
+
const args = dist ? [serverPath] : ["run", serverPath];
|
|
1734
|
+
const existing = existsSync2(CODEX_CONFIG) ? readFileSync2(CODEX_CONFIG, "utf-8") : "";
|
|
1735
|
+
const updated = upsertCodexMcpServerConfig(existing, {
|
|
1736
|
+
name: "engrm",
|
|
1737
|
+
command: runtime,
|
|
1738
|
+
args
|
|
1739
|
+
});
|
|
1740
|
+
ensureParentDir(CODEX_CONFIG);
|
|
1741
|
+
writeFileSync2(CODEX_CONFIG, updated, "utf-8");
|
|
1742
|
+
return { path: CODEX_CONFIG, added: true };
|
|
1743
|
+
}
|
|
1744
|
+
function buildCodexHooksConfig(sessionStartCommand, stopCommand) {
|
|
1745
|
+
return JSON.stringify({
|
|
1746
|
+
hooks: {
|
|
1747
|
+
SessionStart: [
|
|
1748
|
+
{
|
|
1749
|
+
hooks: [
|
|
1750
|
+
{
|
|
1751
|
+
type: "command",
|
|
1752
|
+
command: sessionStartCommand,
|
|
1753
|
+
statusMessage: "loading Engrm context"
|
|
1754
|
+
}
|
|
1755
|
+
]
|
|
1756
|
+
}
|
|
1757
|
+
],
|
|
1758
|
+
Stop: [
|
|
1759
|
+
{
|
|
1760
|
+
hooks: [
|
|
1761
|
+
{
|
|
1762
|
+
type: "command",
|
|
1763
|
+
command: stopCommand,
|
|
1764
|
+
statusMessage: "saving Engrm session summary"
|
|
1765
|
+
}
|
|
1766
|
+
]
|
|
1767
|
+
}
|
|
1768
|
+
]
|
|
1769
|
+
}
|
|
1770
|
+
}, null, 2) + `
|
|
1771
|
+
`;
|
|
1772
|
+
}
|
|
1773
|
+
function registerCodexHooks() {
|
|
1774
|
+
const runtime = findRuntime();
|
|
1775
|
+
const root = findPackageRoot();
|
|
1776
|
+
const dist = isBuiltDist();
|
|
1777
|
+
const hooksDir = dist ? join2(root, "dist", "hooks") : join2(root, "hooks");
|
|
1778
|
+
const ext = dist ? ".js" : ".ts";
|
|
1779
|
+
const runArg = dist ? [] : ["run"];
|
|
1780
|
+
const commandFor = (name) => [runtime, ...runArg, join2(hooksDir, `${name}${ext}`)].join(" ");
|
|
1781
|
+
const content = buildCodexHooksConfig(commandFor("session-start"), commandFor("codex-stop"));
|
|
1782
|
+
ensureParentDir(CODEX_HOOKS);
|
|
1783
|
+
writeFileSync2(CODEX_HOOKS, content, "utf-8");
|
|
1784
|
+
return { path: CODEX_HOOKS, added: true };
|
|
1785
|
+
}
|
|
1645
1786
|
function registerHooks() {
|
|
1646
1787
|
const runtime = findRuntime();
|
|
1647
1788
|
const root = findPackageRoot();
|
|
@@ -1686,9 +1827,27 @@ function replaceEngrmHook(existing, newEntry, hookFilename) {
|
|
|
1686
1827
|
return [...others, newEntry];
|
|
1687
1828
|
}
|
|
1688
1829
|
function registerAll() {
|
|
1830
|
+
let mcp = { path: CLAUDE_JSON, added: false };
|
|
1831
|
+
let hooks = { path: CLAUDE_SETTINGS, added: false };
|
|
1832
|
+
let codex = { path: CODEX_CONFIG, added: false };
|
|
1833
|
+
let codexHooks = { path: CODEX_HOOKS, added: false };
|
|
1834
|
+
try {
|
|
1835
|
+
mcp = registerMcpServer();
|
|
1836
|
+
} catch {}
|
|
1837
|
+
try {
|
|
1838
|
+
hooks = registerHooks();
|
|
1839
|
+
} catch {}
|
|
1840
|
+
try {
|
|
1841
|
+
codex = registerCodexMcpServer();
|
|
1842
|
+
} catch {}
|
|
1843
|
+
try {
|
|
1844
|
+
codexHooks = registerCodexHooks();
|
|
1845
|
+
} catch {}
|
|
1689
1846
|
return {
|
|
1690
|
-
mcp
|
|
1691
|
-
hooks
|
|
1847
|
+
mcp,
|
|
1848
|
+
hooks,
|
|
1849
|
+
codex,
|
|
1850
|
+
codexHooks
|
|
1692
1851
|
};
|
|
1693
1852
|
}
|
|
1694
1853
|
|
|
@@ -1862,6 +2021,12 @@ function scoreQuality(input) {
|
|
|
1862
2021
|
case "digest":
|
|
1863
2022
|
score += 0.3;
|
|
1864
2023
|
break;
|
|
2024
|
+
case "standard":
|
|
2025
|
+
score += 0.25;
|
|
2026
|
+
break;
|
|
2027
|
+
case "message":
|
|
2028
|
+
score += 0.1;
|
|
2029
|
+
break;
|
|
1865
2030
|
}
|
|
1866
2031
|
if (input.narrative && input.narrative.length > 50) {
|
|
1867
2032
|
score += 0.15;
|
|
@@ -2543,8 +2708,11 @@ async function installRulePacks(db, config, packNames) {
|
|
|
2543
2708
|
}
|
|
2544
2709
|
|
|
2545
2710
|
// src/cli.ts
|
|
2711
|
+
var LEGACY_CODEX_SERVER_NAME2 = `candengo-${"mem"}`;
|
|
2546
2712
|
var args = process.argv.slice(2);
|
|
2547
2713
|
var command = args[0];
|
|
2714
|
+
var THIS_DIR = dirname4(fileURLToPath4(import.meta.url));
|
|
2715
|
+
var IS_BUILT_DIST = THIS_DIR.endsWith("/dist") || THIS_DIR.endsWith("\\dist");
|
|
2548
2716
|
switch (command) {
|
|
2549
2717
|
case "init":
|
|
2550
2718
|
await handleInit(args.slice(1));
|
|
@@ -2957,9 +3125,15 @@ function handleStatus() {
|
|
|
2957
3125
|
console.log(` Sync: ${config.sync.enabled ? "enabled" : "disabled"}`);
|
|
2958
3126
|
const claudeJson = join6(homedir3(), ".claude.json");
|
|
2959
3127
|
const claudeSettings = join6(homedir3(), ".claude", "settings.json");
|
|
3128
|
+
const codexConfig = join6(homedir3(), ".codex", "config.toml");
|
|
3129
|
+
const codexHooks = join6(homedir3(), ".codex", "hooks.json");
|
|
2960
3130
|
const mcpRegistered = existsSync6(claudeJson) && readFileSync6(claudeJson, "utf-8").includes('"engrm"');
|
|
2961
3131
|
const settingsContent = existsSync6(claudeSettings) ? readFileSync6(claudeSettings, "utf-8") : "";
|
|
3132
|
+
const codexContent = existsSync6(codexConfig) ? readFileSync6(codexConfig, "utf-8") : "";
|
|
3133
|
+
const codexHooksContent = existsSync6(codexHooks) ? readFileSync6(codexHooks, "utf-8") : "";
|
|
2962
3134
|
const hooksRegistered = settingsContent.includes("engrm") || settingsContent.includes("session-start");
|
|
3135
|
+
const codexRegistered = codexContent.includes("[mcp_servers.engrm]") || codexContent.includes(`[mcp_servers.${LEGACY_CODEX_SERVER_NAME2}]`);
|
|
3136
|
+
const codexHooksRegistered = codexHooksContent.includes('"SessionStart"') && codexHooksContent.includes('"Stop"');
|
|
2963
3137
|
let hookCount = 0;
|
|
2964
3138
|
if (hooksRegistered) {
|
|
2965
3139
|
try {
|
|
@@ -2978,7 +3152,9 @@ function handleStatus() {
|
|
|
2978
3152
|
} catch {}
|
|
2979
3153
|
}
|
|
2980
3154
|
console.log(` MCP server: ${mcpRegistered ? "registered" : "not registered"}`);
|
|
3155
|
+
console.log(` Codex MCP: ${codexRegistered ? "registered" : "not registered"}`);
|
|
2981
3156
|
console.log(` Hooks: ${hooksRegistered ? `registered (${hookCount || "?"} hooks)` : "not registered"}`);
|
|
3157
|
+
console.log(` Codex hooks: ${codexHooksRegistered ? "registered (2 hooks)" : "not registered"}`);
|
|
2982
3158
|
if (config.sentinel?.enabled) {
|
|
2983
3159
|
console.log(`
|
|
2984
3160
|
Sentinel`);
|
|
@@ -3071,6 +3247,8 @@ function handleStatus() {
|
|
|
3071
3247
|
Files`);
|
|
3072
3248
|
console.log(` Config: ${getSettingsPath()}`);
|
|
3073
3249
|
console.log(` Database: ${getDbPath()}`);
|
|
3250
|
+
console.log(` Codex config: ${join6(homedir3(), ".codex", "config.toml")}`);
|
|
3251
|
+
console.log(` Codex hooks: ${join6(homedir3(), ".codex", "hooks.json")}`);
|
|
3074
3252
|
}
|
|
3075
3253
|
function formatTimeAgo(epoch) {
|
|
3076
3254
|
const ago = Math.floor(Date.now() / 1000) - epoch;
|
|
@@ -3203,12 +3381,14 @@ function handleUpdate() {
|
|
|
3203
3381
|
try {
|
|
3204
3382
|
execSync2("npm install -g engrm@latest", { stdio: "inherit" });
|
|
3205
3383
|
console.log(`
|
|
3206
|
-
Update complete. Re-registering
|
|
3384
|
+
Update complete. Re-registering integrations...`);
|
|
3207
3385
|
const result = registerAll();
|
|
3208
|
-
console.log(` MCP
|
|
3209
|
-
console.log(`
|
|
3386
|
+
console.log(` Claude MCP registered \u2192 ${result.mcp.path}`);
|
|
3387
|
+
console.log(` Claude hooks registered \u2192 ${result.hooks.path}`);
|
|
3388
|
+
console.log(` Codex MCP registered \u2192 ${result.codex.path}`);
|
|
3389
|
+
console.log(` Codex hooks registered \u2192 ${result.codexHooks.path}`);
|
|
3210
3390
|
console.log(`
|
|
3211
|
-
Restart Claude Code to use the new version.`);
|
|
3391
|
+
Restart Claude Code or Codex to use the new version.`);
|
|
3212
3392
|
} catch (error) {
|
|
3213
3393
|
console.error("Update failed. Try manually: npm install -g engrm@latest");
|
|
3214
3394
|
}
|
|
@@ -3299,6 +3479,36 @@ async function handleDoctor() {
|
|
|
3299
3479
|
} catch {
|
|
3300
3480
|
warn("Could not check hooks registration");
|
|
3301
3481
|
}
|
|
3482
|
+
const codexConfig = join6(homedir3(), ".codex", "config.toml");
|
|
3483
|
+
try {
|
|
3484
|
+
if (existsSync6(codexConfig)) {
|
|
3485
|
+
const content = readFileSync6(codexConfig, "utf-8");
|
|
3486
|
+
if (content.includes("[mcp_servers.engrm]") || content.includes(`[mcp_servers.${LEGACY_CODEX_SERVER_NAME2}]`)) {
|
|
3487
|
+
pass("MCP server registered in Codex");
|
|
3488
|
+
} else {
|
|
3489
|
+
warn("MCP server not registered in Codex");
|
|
3490
|
+
}
|
|
3491
|
+
} else {
|
|
3492
|
+
warn("Codex config not found (~/.codex/config.toml)");
|
|
3493
|
+
}
|
|
3494
|
+
} catch {
|
|
3495
|
+
warn("Could not check Codex MCP registration");
|
|
3496
|
+
}
|
|
3497
|
+
const codexHooks = join6(homedir3(), ".codex", "hooks.json");
|
|
3498
|
+
try {
|
|
3499
|
+
if (existsSync6(codexHooks)) {
|
|
3500
|
+
const content = readFileSync6(codexHooks, "utf-8");
|
|
3501
|
+
if (content.includes('"SessionStart"') && content.includes('"Stop"')) {
|
|
3502
|
+
pass("Hooks registered in Codex");
|
|
3503
|
+
} else {
|
|
3504
|
+
warn("Codex hooks config found, but Engrm hooks are missing");
|
|
3505
|
+
}
|
|
3506
|
+
} else {
|
|
3507
|
+
warn("Codex hooks config not found (~/.codex/hooks.json)");
|
|
3508
|
+
}
|
|
3509
|
+
} catch {
|
|
3510
|
+
warn("Could not check Codex hooks registration");
|
|
3511
|
+
}
|
|
3302
3512
|
if (config.candengo_url) {
|
|
3303
3513
|
try {
|
|
3304
3514
|
const controller = new AbortController;
|
|
@@ -3440,16 +3650,23 @@ async function checkDeviceLimits(baseUrl, apiKey) {
|
|
|
3440
3650
|
}
|
|
3441
3651
|
function printPostInit() {
|
|
3442
3652
|
console.log(`
|
|
3443
|
-
Registering with Claude Code...`);
|
|
3653
|
+
Registering with Claude Code and Codex...`);
|
|
3444
3654
|
try {
|
|
3445
3655
|
const result = registerAll();
|
|
3446
|
-
console.log(` MCP
|
|
3447
|
-
console.log(`
|
|
3656
|
+
console.log(` Claude MCP registered \u2192 ${result.mcp.path}`);
|
|
3657
|
+
console.log(` Claude hooks registered \u2192 ${result.hooks.path}`);
|
|
3658
|
+
console.log(` Codex MCP registered \u2192 ${result.codex.path}`);
|
|
3659
|
+
console.log(` Codex hooks registered \u2192 ${result.codexHooks.path}`);
|
|
3448
3660
|
console.log(`
|
|
3449
|
-
Engrm is ready! Start a new Claude Code session to use memory.`);
|
|
3661
|
+
Engrm is ready! Start a new Claude Code or Codex session to use memory.`);
|
|
3450
3662
|
} catch (error) {
|
|
3663
|
+
const packageRoot = join6(THIS_DIR, "..");
|
|
3664
|
+
const runtime = IS_BUILT_DIST ? process.execPath : "bun";
|
|
3665
|
+
const serverArgs = IS_BUILT_DIST ? [join6(packageRoot, "dist", "server.js")] : ["run", join6(packageRoot, "src", "server.ts")];
|
|
3666
|
+
const sessionStartCommand = IS_BUILT_DIST ? `${process.execPath} ${join6(packageRoot, "dist", "hooks", "session-start.js")}` : `bun run ${join6(packageRoot, "hooks", "session-start.ts")}`;
|
|
3667
|
+
const codexStopCommand = IS_BUILT_DIST ? `${process.execPath} ${join6(packageRoot, "dist", "hooks", "codex-stop.js")}` : `bun run ${join6(packageRoot, "hooks", "codex-stop.ts")}`;
|
|
3451
3668
|
console.log(`
|
|
3452
|
-
Could not auto-register with Claude Code.`);
|
|
3669
|
+
Could not auto-register with Claude Code and Codex.`);
|
|
3453
3670
|
console.log(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
3454
3671
|
console.log(`
|
|
3455
3672
|
Manual setup \u2014 add to ~/.claude.json:`);
|
|
@@ -3458,13 +3675,56 @@ Manual setup \u2014 add to ~/.claude.json:`);
|
|
|
3458
3675
|
"mcpServers": {
|
|
3459
3676
|
"engrm": {
|
|
3460
3677
|
"type": "stdio",
|
|
3461
|
-
"command": "
|
|
3462
|
-
"args":
|
|
3678
|
+
"command": "${runtime}",
|
|
3679
|
+
"args": ${JSON.stringify(serverArgs)}
|
|
3463
3680
|
}
|
|
3464
3681
|
}
|
|
3465
3682
|
}`);
|
|
3683
|
+
console.log(`
|
|
3684
|
+
And add to ~/.codex/config.toml:`);
|
|
3685
|
+
console.log(`
|
|
3686
|
+
[mcp_servers.engrm]
|
|
3687
|
+
enabled = true
|
|
3688
|
+
command = "${runtime}"
|
|
3689
|
+
args = ${formatTomlArray(serverArgs)}
|
|
3690
|
+
startup_timeout_sec = 15
|
|
3691
|
+
tool_timeout_sec = 30
|
|
3692
|
+
[features]
|
|
3693
|
+
codex_hooks = true
|
|
3694
|
+
`);
|
|
3695
|
+
console.log(`
|
|
3696
|
+
And add to ~/.codex/hooks.json:`);
|
|
3697
|
+
console.log(`
|
|
3698
|
+
{
|
|
3699
|
+
"hooks": {
|
|
3700
|
+
"SessionStart": [
|
|
3701
|
+
{
|
|
3702
|
+
"hooks": [
|
|
3703
|
+
{
|
|
3704
|
+
"type": "command",
|
|
3705
|
+
"command": "${sessionStartCommand}"
|
|
3706
|
+
}
|
|
3707
|
+
]
|
|
3708
|
+
}
|
|
3709
|
+
],
|
|
3710
|
+
"Stop": [
|
|
3711
|
+
{
|
|
3712
|
+
"hooks": [
|
|
3713
|
+
{
|
|
3714
|
+
"type": "command",
|
|
3715
|
+
"command": "${codexStopCommand}"
|
|
3716
|
+
}
|
|
3717
|
+
]
|
|
3718
|
+
}
|
|
3719
|
+
]
|
|
3466
3720
|
}
|
|
3467
3721
|
}
|
|
3722
|
+
`);
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
function formatTomlArray(values) {
|
|
3726
|
+
return `[${values.map((value) => JSON.stringify(value)).join(", ")}]`;
|
|
3727
|
+
}
|
|
3468
3728
|
function printUsage() {
|
|
3469
3729
|
console.log(`Engrm \u2014 Memory layer for AI coding agents
|
|
3470
3730
|
`);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// hooks/codex-stop.ts
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
var thisDir = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
var delegatePath = join(thisDir, "stop.ts");
|
|
9
|
+
async function readStdin() {
|
|
10
|
+
const chunks = [];
|
|
11
|
+
for await (const chunk of process.stdin) {
|
|
12
|
+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
|
|
13
|
+
}
|
|
14
|
+
return chunks.join("");
|
|
15
|
+
}
|
|
16
|
+
async function main() {
|
|
17
|
+
const input = await readStdin();
|
|
18
|
+
const isBun = process.execPath.endsWith("bun");
|
|
19
|
+
const childArgs = isBun ? ["run", delegatePath] : [delegatePath];
|
|
20
|
+
const child = spawn(process.execPath, childArgs, {
|
|
21
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
22
|
+
});
|
|
23
|
+
if (input.length > 0) {
|
|
24
|
+
child.stdin.write(input);
|
|
25
|
+
}
|
|
26
|
+
child.stdin.end();
|
|
27
|
+
let stdout = "";
|
|
28
|
+
let stderr = "";
|
|
29
|
+
child.stdout.on("data", (chunk) => {
|
|
30
|
+
stdout += chunk.toString();
|
|
31
|
+
});
|
|
32
|
+
child.stderr.on("data", (chunk) => {
|
|
33
|
+
stderr += chunk.toString();
|
|
34
|
+
});
|
|
35
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
36
|
+
child.on("error", reject);
|
|
37
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
38
|
+
});
|
|
39
|
+
const messages = [stdout.trim(), stderr.trim()].filter(Boolean);
|
|
40
|
+
const systemMessage = messages.length > 0 ? messages.join(`
|
|
41
|
+
`) : null;
|
|
42
|
+
if (exitCode === 0) {
|
|
43
|
+
console.log(JSON.stringify({
|
|
44
|
+
continue: true,
|
|
45
|
+
...systemMessage ? { systemMessage } : {}
|
|
46
|
+
}));
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
console.log(JSON.stringify({
|
|
50
|
+
continue: true,
|
|
51
|
+
...systemMessage ? { systemMessage: `Engrm stop hook failed:
|
|
52
|
+
${systemMessage}` } : {}
|
|
53
|
+
}));
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
main().catch((error) => {
|
|
57
|
+
console.log(JSON.stringify({
|
|
58
|
+
continue: true,
|
|
59
|
+
systemMessage: `Engrm stop hook failed: ${error instanceof Error ? error.message : String(error)}`
|
|
60
|
+
}));
|
|
61
|
+
process.exit(0);
|
|
62
|
+
});
|