engrm 0.4.4 → 0.4.6

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 CHANGED
@@ -1,41 +1,79 @@
1
1
  # Engrm
2
2
 
3
- **Shared memory and delivery review for AI coding agents.** Engrm keeps context, decisions, and project state moving across your machines, your team, and the agents you switch between.
3
+ [![License: FSL-1.1-ALv2](https://img.shields.io/badge/license-FSL--1.1--ALv2-blue)](./LICENSE)
4
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-green)](https://nodejs.org)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue)](https://www.typescriptlang.org/)
6
+ [![MCP Compatible](https://img.shields.io/badge/MCP-compatible-purple)](https://modelcontextprotocol.io)
4
7
 
5
- For npm users, Engrm runs on Node.js 18+ and does not require Bun to be installed.
8
+ **The only AI memory that syncs across devices and agents.**
6
9
 
7
- ```
8
- npx engrm init
9
- ```
10
+ Cross-device persistent memory for OpenClaw, Claude Code, Codex, and any MCP-compatible agent. Start free with 2 devices.
11
+
12
+ [Get Started](https://engrm.dev) • [Documentation](https://engrm.dev/developers) • [Blog](https://engrm.dev/blog)
13
+
14
+ ---
15
+
16
+ ## Why Engrm?
17
+
18
+ - **Cross-device sync** — Fix a bug on your laptop, continue on your desktop. No other memory tool does this.
19
+ - **Cross-agent compatible** — Works with OpenClaw, Claude Code, Codex, Cursor, Windsurf, Cline, Zed
20
+ - **Free tier** — 2 devices, 5,000 observations, full sync. £0 forever.
21
+ - **Offline-first** — Local SQLite + sqlite-vec. <50ms search. Works on a plane.
22
+ - **Delivery Review** — Compare what was promised vs what shipped
23
+ - **Sentinel** — Real-time code audit before changes land
24
+ - **Team memory** — Share insights across your whole team (Team plan)
25
+
26
+ ---
27
+
28
+ ## vs Other Memory Tools
29
+
30
+ | Feature | Engrm Free | Supermemory Pro | mem0 |
31
+ |---------|------------|-----------------|------|
32
+ | **Cost** | £0 | $20/mo | ~$2/mo + usage |
33
+ | **Cross-device** | ✅ 2 devices | ❌ Single device | ❌ Single device |
34
+ | **OpenClaw plugin** | ✅ Native | ✅ (Pro required) | ✅ (usage costs) |
35
+ | **Works with Claude/Codex** | ✅ | ❌ | ❌ |
36
+ | **Delivery Review** | ✅ | ❌ | ❌ |
37
+ | **Sentinel** | ✅ (Vibe+) | ❌ | ❌ |
10
38
 
11
- Public beta. Engrm is built for Claude Code, Codex, OpenClaw skills, and other MCP-native coding workflows. The current source of truth for agent capability differences is [AGENT_SUPPORT.md](AGENT_SUPPORT.md).
39
+ [Read the full comparison ](https://engrm.dev/blog/engrm-openclaw-cross-device-memory)
12
40
 
13
41
  ---
14
42
 
15
- ## What It Does
43
+ ## Installation
16
44
 
17
- Your AI agent forgets everything between sessions. Engrm fixes that, and helps you check whether the work really matched the brief.
45
+ ### For OpenClaw Users
18
46
 
19
- - **Works across agents** — Claude Code and Codex integrate directly, and OpenClaw can use Engrm through published skill bundles
20
- - **Remembers across devices** — fix a bug on your laptop, continue on your desktop with full context
21
- - **Shares with your team** — one developer's hard-won insight becomes everyone's knowledge
22
- - **Reviews delivery** — tie plans, decisions, and sessions together so you can see what actually shipped
23
- - **Works offline** — local SQLite is the source of truth; syncs when connected
24
- - **Guards your code** Sentinel audits changes in real-time before they land
47
+ ```bash
48
+ # 1. Install the plugin
49
+ openclaw plugins install engrm-openclaw-plugin
50
+
51
+ # 2. Restart OpenClaw
52
+ # Quit and reopen, or restart gateway
25
53
 
26
- ## Quick Start
54
+ # 3. Connect Engrm in chat
55
+ /engrm connect
56
+
57
+ # 4. Verify
58
+ /engrm status
59
+ ```
27
60
 
28
- ### 1. Sign up
61
+ **What works:**
62
+ - ✅ Session startup memory injection
63
+ - ✅ Automatic session capture
64
+ - ✅ Cross-device sync (unique to Engrm)
65
+ - ✅ `/engrm` slash commands
66
+ - ✅ Sentinel advisory mode (Vibe+ plans)
29
67
 
30
- Visit [engrm.dev](https://engrm.dev) and create an account.
68
+ **Blog:** [Engrm Now Supports OpenClaw →](https://engrm.dev/blog/engrm-openclaw-cross-device-memory)
31
69
 
32
- ### 2. Install
70
+ ### For Claude Code / Codex
33
71
 
34
72
  ```bash
35
73
  npx engrm init
36
74
  ```
37
75
 
38
- This opens your browser for authentication, writes config to `~/.engrm/`, and registers Engrm in Claude Code and Codex when those configs are available. OpenClaw support is provided through the packaged skills in [`openclaw/`](openclaw/). Takes about 30 seconds.
76
+ This auto-configures MCP servers and hooks in `~/.claude.json` and `~/.codex/config.toml`.
39
77
 
40
78
  **Alternative methods:**
41
79
  ```bash
@@ -49,9 +87,15 @@ npx engrm init --url=https://vector.internal.company.com
49
87
  npx engrm init --manual
50
88
  ```
51
89
 
52
- ### 3. Use your agent normally
90
+ For npm users, Engrm runs on Node.js 18+ and does not require Bun to be installed.
53
91
 
54
- That's it. Engrm works in the background:
92
+ ---
93
+
94
+ ## How It Works
95
+
96
+ ### Background Operation
97
+
98
+ Engrm works automatically:
55
99
 
56
100
  - **Session start** — injects relevant project memory into context
57
101
  - **While you work** — captures observations from tool use where the agent exposes that hook surface
@@ -69,7 +113,7 @@ That's it. Engrm works in the background:
69
113
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
70
114
  ```
71
115
 
72
- ### 4. Check status
116
+ ### Check Status
73
117
 
74
118
  ```bash
75
119
  npx engrm status
@@ -95,49 +139,47 @@ Engrm Status
95
139
  Security: 3 findings (1 high, 2 medium)
96
140
  ```
97
141
 
98
- ---
99
-
100
- ## How It Works
142
+ ### Architecture
101
143
 
144
+ **Claude Code session:**
102
145
  ```
103
- Claude Code session
104
-
105
- ├─ SessionStart hook ──→ inject relevant memory into context
106
-
107
- ├─ PreToolUse hook ────→ Sentinel audits Edit/Write (optional)
108
-
109
- ├─ PostToolUse hook ───→ extract observations from tool results
110
-
111
- ├─ PreCompact hook ────→ re-inject memory before context compression
112
-
113
- ├─ ElicitationResult ──→ capture MCP form submissions
114
-
115
- └─ Stop hook ──────────→ session digest + sync + summary
116
-
117
-
118
- Local SQLite (FTS5 + sqlite-vec)
119
-
120
- (sync every 30s)
121
- Candengo Vector (cloud)
122
-
123
-
124
- Available on all your devices + team members
146
+
147
+ ├─ SessionStart hook ──→ inject relevant memory into context
148
+
149
+ ├─ PreToolUse hook ────→ Sentinel audits Edit/Write (optional)
150
+
151
+ ├─ PostToolUse hook ───→ extract observations from tool results
152
+
153
+ ├─ PreCompact hook ────→ re-inject memory before context compression
154
+
155
+ ├─ ElicitationResult ──→ capture MCP form submissions
156
+
157
+ └─ Stop hook ──────────→ session digest + sync + summary
158
+
159
+
160
+ Local SQLite (FTS5 + sqlite-vec)
161
+
162
+ ▼ (sync every 30s)
163
+ Candengo Vector (cloud)
164
+
165
+
166
+ Available on all your devices + team members
125
167
  ```
126
168
 
169
+ **Codex session:**
127
170
  ```
128
- Codex session
129
-
130
- ├─ SessionStart hook ──→ inject relevant memory into context
131
-
132
- ├─ MCP tools ──────────→ search, save, inspect, message, stats
133
-
134
- └─ Stop hook ──────────→ session digest + sync + summary
171
+
172
+ ├─ SessionStart hook ──→ inject relevant memory into context
173
+
174
+ ├─ MCP tools ──────────→ search, save, inspect, message, stats
175
+
176
+ └─ Stop hook ──────────→ session digest + sync + summary
135
177
  ```
136
178
 
137
- ### Agent Support
179
+ ### Agent Capability Matrix
138
180
 
139
181
  | Capability | Claude Code | Codex | OpenClaw |
140
- |---|---|---|---|
182
+ |-----------|-------------|-------|----------|
141
183
  | MCP server tools | ✓ | ✓ | Via skills / MCP |
142
184
  | Session-start context injection | ✓ | ✓ | Via skill-guided workflow |
143
185
  | Stop/session summary hook | ✓ | ✓ | Via skill-guided workflow |
@@ -146,7 +188,11 @@ Codex session
146
188
  | Pre-compact reinjection | ✓ | Not exposed | Not exposed |
147
189
  | ElicitationResult capture | ✓ | Not exposed | Not exposed |
148
190
 
149
- OpenClaw support is packaged in [`openclaw/`](openclaw/) as `engrm-memory`, `engrm-delivery-review`, and `engrm-sentinel` skills for ClawHub-style distribution.
191
+ See [AGENT_SUPPORT.md](AGENT_SUPPORT.md) for detailed comparison.
192
+
193
+ ---
194
+
195
+ ## Features
150
196
 
151
197
  ### MCP Tools
152
198
 
@@ -166,7 +212,7 @@ The MCP server exposes tools that supported agents can call directly:
166
212
  ### Observation Types
167
213
 
168
214
  | Type | What it captures |
169
- |------|-----------------|
215
+ |------|------------------|
170
216
  | `discovery` | Learning about existing systems or codebases |
171
217
  | `bugfix` | Something was broken, now fixed |
172
218
  | `decision` | Architectural or design choice with rationale |
@@ -176,50 +222,55 @@ The MCP server exposes tools that supported agents can call directly:
176
222
  | `pattern` | Recurring issue or technique |
177
223
  | `digest` | Session summary (auto-generated) |
178
224
 
179
- ---
180
-
181
- ## Features
182
-
183
225
  ### Hybrid Search
226
+
184
227
  Local FTS5 + sqlite-vec (all-MiniLM-L6-v2, 384 dims) combined with Candengo Vector's BGE-M3 semantic search. Results merged via Reciprocal Rank Fusion.
185
228
 
186
- ### Sentinel — Real-Time Code Audit
187
- LLM-powered review of every Edit/Write before it executes. Catches security issues, anti-patterns, and drift from team decisions.
229
+ ### Sentinel
230
+
231
+ LLM-powered review of every `Edit`/`Write` before it executes. Catches security issues, anti-patterns, and drift from team decisions.
188
232
 
189
233
  ```
190
- ⚠️ Sentinel: SQL query uses string concatenation instead of parameterized query
234
+ ⚠️ Sentinel: SQL query uses string concatenation instead of parameterized query
191
235
  Rule: sql-injection
192
236
  (Advisory mode — change allowed)
193
237
  ```
194
238
 
195
- 5 built-in rule packs: `security`, `auth`, `api`, `react`, `database`.
239
+ **Built-in rule packs:** security, auth, api, react, database.
196
240
 
197
241
  ```bash
198
- npx engrm sentinel init-rules # Install all rule packs
199
- npx engrm sentinel rules # List available packs
242
+ npx engrm sentinel init-rules # Install all rule packs
243
+ npx engrm sentinel rules # List available packs
200
244
  ```
201
245
 
202
- ### Starter Packs
246
+ ### Knowledge Packs
247
+
203
248
  Pre-loaded knowledge for your tech stack. Detected automatically on session start.
204
249
 
205
- Available: `typescript-patterns`, `nextjs-patterns`, `node-security`, `python-django`, `react-gotchas`, `api-best-practices`, `web-security`
250
+ **Available:** typescript-patterns, nextjs-patterns, node-security, python-django, react-gotchas, api-best-practices, web-security
206
251
 
207
252
  ```bash
208
253
  npx engrm install-pack typescript-patterns
209
254
  ```
210
255
 
211
256
  ### Secret Scrubbing
257
+
212
258
  Multi-layer regex scanning for API keys, passwords, tokens, and credentials. Sensitive content is redacted before storage and sync. Custom patterns configurable in `~/.engrm/settings.json`.
213
259
 
214
- ### Observation Lifecycle
260
+ ### Retention & Aging
261
+
215
262
  Observations age gracefully: **active** (30 days, full weight) → **aging** (0.7x search weight) → **archived** (compacted into digests) → **purged** (after 12 months). Pinned observations never age.
216
263
 
217
264
  ---
218
265
 
219
266
  ## Pricing
220
267
 
268
+ **Free tier stays free forever.** No bait-and-switch.
269
+
270
+ Start with 2 devices and 5,000 observations. Upgrade when you need more.
271
+
221
272
  | | Free | Vibe | Pro | Team |
222
- |---|---|---|---|---|
273
+ |---|------|------|-----|------|
223
274
  | **Price** | £0 | £5.99/mo | £9.99/mo | £12.99/seat/mo |
224
275
  | **Observations** | 5,000 | 25,000 | 100,000 | Unlimited |
225
276
  | **Devices** | 2 | 3 | 5 | Unlimited |
@@ -232,7 +283,7 @@ Sign up at [engrm.dev](https://engrm.dev).
232
283
 
233
284
  ---
234
285
 
235
- ## Self-Hosting
286
+ ## Self-Hosted
236
287
 
237
288
  Point Engrm at your own [Candengo Vector](https://www.candengo.com) instance:
238
289
 
@@ -246,11 +297,11 @@ Candengo Vector provides the backend: BGE-M3 hybrid search, multi-tenant namespa
246
297
 
247
298
  ## Configuration
248
299
 
249
- ### User config: `~/.engrm/settings.json`
300
+ ### `~/.engrm/settings.json`
250
301
 
251
302
  Created by `engrm init`. Contains API credentials, sync settings, search preferences, secret scrubbing patterns, and Sentinel configuration.
252
303
 
253
- ### Project config: `.engrm.json` (optional)
304
+ ### `.engrm-project.json`
254
305
 
255
306
  Place in your project root to override project identity for non-git projects:
256
307
 
@@ -261,24 +312,25 @@ Place in your project root to override project identity for non-git projects:
261
312
  }
262
313
  ```
263
314
 
264
- ### Agent integration
315
+ ### Agent Auto-Registration
265
316
 
266
317
  Engrm auto-registers in:
318
+
267
319
  - `~/.claude.json` — MCP server (`engrm`)
268
320
  - `~/.claude/settings.json` — 6 lifecycle hooks
269
321
  - `~/.codex/config.toml` — MCP server (`engrm`) + `codex_hooks` feature flag
270
- - `~/.codex/hooks.json` — `SessionStart` and `Stop` hooks
322
+ - `~/.codex/hooks.json` — SessionStart and Stop hooks
271
323
 
272
324
  ---
273
325
 
274
- ## Tech Stack
326
+ ## Technical Stack
275
327
 
276
- - **Runtime**: TypeScript, runs on Bun (dev) or Node.js 18+ (npm)
277
- - **Local storage**: SQLite via better-sqlite3, FTS5 full-text search, sqlite-vec for embeddings
278
- - **Embeddings**: all-MiniLM-L6-v2 via @xenova/transformers (384 dims, ~23MB)
279
- - **Remote backend**: Candengo Vector (BGE-M3, Qdrant, hybrid dense+sparse search)
280
- - **MCP**: @modelcontextprotocol/sdk (stdio transport)
281
- - **AI extraction**: @anthropic-ai/claude-agent-sdk (optional, for richer observations)
328
+ - **Runtime:** TypeScript, runs on Bun (dev) or Node.js 18+ (npm)
329
+ - **Local storage:** SQLite via `better-sqlite3`, FTS5 full-text search, `sqlite-vec` for embeddings
330
+ - **Embeddings:** all-MiniLM-L6-v2 via `@xenova/transformers` (384 dims, ~23MB)
331
+ - **Remote backend:** Candengo Vector (BGE-M3, Qdrant, hybrid dense+sparse search)
332
+ - **MCP:** `@modelcontextprotocol/sdk` (stdio transport)
333
+ - **AI extraction:** `@anthropic-ai/claude-agent-sdk` (optional, for richer observations)
282
334
 
283
335
  ---
284
336
 
@@ -286,23 +338,41 @@ Engrm auto-registers in:
286
338
 
287
339
  **FSL-1.1-ALv2** (Functional Source License) — part of the [Fair Source](https://fair.io) movement.
288
340
 
289
- - Free to use, modify, and self-host
290
- - You cannot offer this as a competing hosted service
291
- - Each version converts to Apache 2.0 after 2 years
292
- - Sentinel is a separate proprietary product
341
+ - Free to use, modify, and self-host
342
+ - You cannot offer this as a competing hosted service
343
+ - Each version converts to Apache 2.0 after 2 years
344
+ - ⚠️ Sentinel is a separate proprietary product
293
345
 
294
346
  See [LICENSE](LICENSE) for full terms.
295
347
 
296
348
  ---
297
349
 
298
- ## Project
350
+ ## Documentation
299
351
 
300
352
  - Architecture: [ARCHITECTURE.md](ARCHITECTURE.md)
301
353
  - Contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
302
354
  - Security: [SECURITY.md](SECURITY.md)
303
355
  - Roadmap: [ROADMAP.md](ROADMAP.md)
304
356
 
305
- Maintainers: run `node scripts/check-public-docs.mjs` to verify the repo only contains the approved public docs set at the root.
357
+ **Maintainers:** run `node scripts/check-public-docs.mjs` to verify the repo only contains the approved public docs set at the root.
358
+
359
+ ---
360
+
361
+ ## Resources
362
+
363
+ - [Documentation](https://engrm.dev/developers)
364
+ - [Blog](https://engrm.dev/blog)
365
+ - [Pricing](https://engrm.dev/pricing)
366
+ - [Sentinel](https://engrm.dev/sentinel)
367
+
368
+ ## Community
369
+
370
+ - [Twitter/X](https://twitter.com/engrm_dev)
371
+ - [GitHub Issues](https://github.com/dr12hes/engrm/issues)
372
+
373
+ ---
374
+
375
+ **Found this useful?** ⭐ Star this repo to help other developers discover Engrm.
306
376
 
307
377
  ---
308
378
 
@@ -2496,41 +2496,233 @@ function formatSplashScreen(data) {
2496
2496
  }
2497
2497
  function formatVisibleStartupBrief(context) {
2498
2498
  const lines = [];
2499
- const latest = context.summaries?.[0];
2499
+ const latest = pickBestSummary(context);
2500
+ const observationFallbacks = buildObservationFallbacks(context);
2500
2501
  if (latest) {
2501
2502
  const sections = [
2502
- ["Investigated", latest.investigated],
2503
- ["Learned", latest.learned],
2504
- ["Completed", latest.completed],
2505
- ["Next Steps", latest.next_steps]
2503
+ ["Request", chooseRequest(latest.request, observationFallbacks.request), 1],
2504
+ ["Investigated", chooseSection(latest.investigated, observationFallbacks.investigated, "Investigated"), 2],
2505
+ ["Learned", latest.learned, 2],
2506
+ ["Completed", chooseSection(latest.completed, observationFallbacks.completed, "Completed"), 2],
2507
+ ["Next Steps", latest.next_steps, 2]
2506
2508
  ];
2507
- for (const [label, value] of sections) {
2508
- const formatted = toSplashBullet(value, label === "Next Steps" ? 140 : 180);
2509
- if (formatted) {
2510
- lines.push(`${c2.cyan}${label}:${c2.reset} ${formatted}`);
2509
+ for (const [label, value, maxItems] of sections) {
2510
+ const formatted = toSplashLines(value, maxItems ?? 2);
2511
+ if (formatted.length > 0) {
2512
+ lines.push(`${c2.cyan}${label}:${c2.reset}`);
2513
+ for (const item of formatted) {
2514
+ lines.push(` ${item}`);
2515
+ }
2511
2516
  }
2512
2517
  }
2513
2518
  }
2514
- if (context.staleDecisions && context.staleDecisions.length > 0) {
2515
- const stale = context.staleDecisions[0];
2519
+ const stale = pickRelevantStaleDecision(context, latest);
2520
+ if (stale) {
2516
2521
  lines.push(`${c2.yellow}Watch:${c2.reset} ${truncateInline(`Decision still looks unfinished: ${stale.title}`, 170)}`);
2517
2522
  }
2518
2523
  if (lines.length === 0 && context.observations.length > 0) {
2519
- const top = context.observations.slice(0, 2);
2524
+ const top = context.observations.filter((obs) => !looksLikeFileOperationTitle(obs.title)).slice(0, 2);
2520
2525
  for (const obs of top) {
2521
2526
  lines.push(`${c2.cyan}${capitalize(obs.type)}:${c2.reset} ${truncateInline(obs.title, 170)}`);
2522
2527
  }
2523
2528
  }
2524
- return lines.slice(0, 5);
2529
+ return lines.slice(0, 10);
2525
2530
  }
2526
- function toSplashBullet(value, maxLen) {
2531
+ function toSplashLines(value, maxItems) {
2527
2532
  if (!value)
2533
+ return [];
2534
+ const lines = value.split(`
2535
+ `).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "")).map((line) => dedupeFragments(line)).filter(Boolean).sort((a, b) => scoreSplashLine(b) - scoreSplashLine(a)).slice(0, maxItems).map((line) => `- ${truncateInline(line, 140)}`);
2536
+ return dedupeFragmentsInLines(lines);
2537
+ }
2538
+ function pickBestSummary(context) {
2539
+ const summaries = context.summaries || [];
2540
+ if (!summaries.length)
2528
2541
  return null;
2529
- const cleaned = value.split(`
2530
- `).map((line) => line.trim().replace(/^[-*]\s*/, "")).filter(Boolean).join("; ");
2531
- if (!cleaned)
2542
+ return [...summaries].sort((a, b) => scoreSummary(b) - scoreSummary(a))[0] ?? null;
2543
+ }
2544
+ function scoreSummary(summary) {
2545
+ let score = 0;
2546
+ if (summary.request)
2547
+ score += 3;
2548
+ if (summary.investigated)
2549
+ score += 4;
2550
+ if (summary.learned)
2551
+ score += 5;
2552
+ if (summary.completed)
2553
+ score += 5;
2554
+ if (summary.next_steps)
2555
+ score += 4;
2556
+ score += Math.min(4, sectionItemCount(summary.completed) + sectionItemCount(summary.learned));
2557
+ return score;
2558
+ }
2559
+ function sectionItemCount(value) {
2560
+ if (!value)
2561
+ return 0;
2562
+ return value.split(`
2563
+ `).map((line) => line.trim()).filter(Boolean).length;
2564
+ }
2565
+ function dedupeFragments(text) {
2566
+ const parts = text.split(";").map((part) => part.trim()).filter(Boolean);
2567
+ const seen = new Set;
2568
+ const deduped = [];
2569
+ for (const part of parts) {
2570
+ const normalized = part.toLowerCase();
2571
+ if (seen.has(normalized))
2572
+ continue;
2573
+ seen.add(normalized);
2574
+ deduped.push(part);
2575
+ }
2576
+ return deduped.join("; ");
2577
+ }
2578
+ function dedupeFragmentsInLines(lines) {
2579
+ const seen = new Set;
2580
+ const deduped = [];
2581
+ for (const line of lines) {
2582
+ const normalized = line.toLowerCase().replace(/\s+/g, " ").trim();
2583
+ if (!normalized || seen.has(normalized))
2584
+ continue;
2585
+ seen.add(normalized);
2586
+ deduped.push(line);
2587
+ }
2588
+ return deduped;
2589
+ }
2590
+ function chooseRequest(primary, fallback) {
2591
+ if (primary && !looksLikeFileOperationTitle(primary))
2592
+ return primary;
2593
+ return fallback;
2594
+ }
2595
+ function chooseSection(primary, fallback, label) {
2596
+ if (!primary)
2597
+ return fallback;
2598
+ if (label === "Completed" && isWeakCompletedSection(primary))
2599
+ return fallback || primary;
2600
+ if (label === "Investigated" && sectionItemCount(primary) === 0)
2601
+ return fallback;
2602
+ return primary;
2603
+ }
2604
+ function isWeakCompletedSection(value) {
2605
+ const items = value.split(`
2606
+ `).map((line) => line.trim().replace(/^[-*]\s*/, "")).filter(Boolean);
2607
+ if (!items.length)
2608
+ return true;
2609
+ const weakCount = items.filter((item) => looksLikeFileOperationTitle(item)).length;
2610
+ return weakCount === items.length;
2611
+ }
2612
+ function looksLikeFileOperationTitle(value) {
2613
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
2614
+ }
2615
+ function scoreSplashLine(value) {
2616
+ let score = 0;
2617
+ if (!looksLikeFileOperationTitle(value))
2618
+ score += 2;
2619
+ if (/[:;]/.test(value))
2620
+ score += 1;
2621
+ if (value.length > 30)
2622
+ score += 0.5;
2623
+ return score;
2624
+ }
2625
+ function buildObservationFallbacks(context) {
2626
+ const request = context.observations.find((obs) => !looksLikeFileOperationTitle(obs.title))?.title ?? null;
2627
+ const investigated = collectObservationTitles(context, (obs) => obs.type === "discovery", 2);
2628
+ const completed = collectObservationTitles(context, (obs) => ["feature", "refactor", "change", "digest"].includes(obs.type) && !looksLikeFileOperationTitle(obs.title), 2);
2629
+ return {
2630
+ request,
2631
+ investigated,
2632
+ completed
2633
+ };
2634
+ }
2635
+ function collectObservationTitles(context, predicate, limit) {
2636
+ const seen = new Set;
2637
+ const picked = [];
2638
+ for (const obs of context.observations) {
2639
+ if (!predicate(obs))
2640
+ continue;
2641
+ const normalized = obs.title.toLowerCase().replace(/\s+/g, " ").trim();
2642
+ if (!normalized || seen.has(normalized))
2643
+ continue;
2644
+ seen.add(normalized);
2645
+ picked.push(`- ${obs.title}`);
2646
+ if (picked.length >= limit)
2647
+ break;
2648
+ }
2649
+ return picked.length ? picked.join(`
2650
+ `) : null;
2651
+ }
2652
+ function pickRelevantStaleDecision(context, summary) {
2653
+ const stale = context.staleDecisions || [];
2654
+ if (!stale.length)
2532
2655
  return null;
2533
- return truncateInline(cleaned, maxLen);
2656
+ const summaryText = [
2657
+ summary?.request,
2658
+ summary?.investigated,
2659
+ summary?.learned,
2660
+ summary?.completed,
2661
+ summary?.next_steps
2662
+ ].filter(Boolean).join(" ");
2663
+ let best = null;
2664
+ let bestScore = 0;
2665
+ for (const item of stale) {
2666
+ if ((item.days_ago ?? 999) > 21)
2667
+ continue;
2668
+ const overlap = keywordOverlap(item.title || "", summaryText);
2669
+ const similarity = item.best_match_similarity ?? 0;
2670
+ const score = overlap * 4 + similarity;
2671
+ if (score > bestScore && overlap > 0) {
2672
+ best = item;
2673
+ bestScore = score;
2674
+ }
2675
+ }
2676
+ return best;
2677
+ }
2678
+ function keywordOverlap(a, b) {
2679
+ if (!a || !b)
2680
+ return 0;
2681
+ const stop = new Set([
2682
+ "the",
2683
+ "and",
2684
+ "for",
2685
+ "with",
2686
+ "from",
2687
+ "into",
2688
+ "this",
2689
+ "that",
2690
+ "was",
2691
+ "were",
2692
+ "have",
2693
+ "has",
2694
+ "had",
2695
+ "but",
2696
+ "not",
2697
+ "you",
2698
+ "your",
2699
+ "our",
2700
+ "their",
2701
+ "about",
2702
+ "added",
2703
+ "fixed",
2704
+ "created",
2705
+ "updated",
2706
+ "modified",
2707
+ "changed",
2708
+ "investigate",
2709
+ "next",
2710
+ "steps",
2711
+ "decision",
2712
+ "still",
2713
+ "looks",
2714
+ "unfinished"
2715
+ ]);
2716
+ const wordsA = new Set(a.toLowerCase().match(/[a-z0-9_+-]{4,}/g)?.filter((w) => !stop.has(w)) || []);
2717
+ const wordsB = new Set(b.toLowerCase().match(/[a-z0-9_+-]{4,}/g)?.filter((w) => !stop.has(w)) || []);
2718
+ if (!wordsA.size || !wordsB.size)
2719
+ return 0;
2720
+ let overlap = 0;
2721
+ for (const word of wordsA) {
2722
+ if (wordsB.has(word))
2723
+ overlap++;
2724
+ }
2725
+ return overlap / Math.max(1, Math.min(wordsA.size, wordsB.size));
2534
2726
  }
2535
2727
  function truncateInline(text, maxLen) {
2536
2728
  const compact = text.replace(/\s+/g, " ").trim();
@@ -35,35 +35,34 @@ function extractInvestigated(observations) {
35
35
  const discoveries = observations.filter((o) => o.type === "discovery");
36
36
  if (discoveries.length === 0)
37
37
  return null;
38
- return discoveries.slice(0, 5).map((o) => {
39
- const facts = extractTopFacts(o, 2);
40
- return facts ? `- ${o.title}
41
- ${facts}` : `- ${o.title}`;
42
- }).join(`
43
- `);
38
+ return formatObservationGroup(discoveries, {
39
+ limit: 4,
40
+ factsPerItem: 2
41
+ });
44
42
  }
45
43
  function extractLearned(observations) {
46
44
  const learnTypes = new Set(["bugfix", "decision", "pattern"]);
47
45
  const learned = observations.filter((o) => learnTypes.has(o.type));
48
46
  if (learned.length === 0)
49
47
  return null;
50
- return learned.slice(0, 5).map((o) => {
51
- const facts = extractTopFacts(o, 2);
52
- return facts ? `- ${o.title}
53
- ${facts}` : `- ${o.title}`;
54
- }).join(`
55
- `);
48
+ return formatObservationGroup(learned, {
49
+ limit: 4,
50
+ factsPerItem: 2
51
+ });
56
52
  }
57
53
  function extractCompleted(observations) {
58
54
  const completeTypes = new Set(["change", "feature", "refactor"]);
59
55
  const completed = observations.filter((o) => completeTypes.has(o.type));
60
56
  if (completed.length === 0)
61
57
  return null;
62
- return completed.slice(0, 5).map((o) => {
63
- const files = o.files_modified ? parseJsonArray(o.files_modified) : [];
64
- const fileCtx = files.length > 0 ? ` (${files.slice(0, 2).map((f) => f.split("/").pop()).join(", ")})` : "";
65
- return `- ${o.title}${fileCtx}`;
66
- }).join(`
58
+ const prioritized = dedupeObservationsByTitle(completed).sort((a, b) => scoreCompletedObservation(b) - scoreCompletedObservation(a)).slice(0, 4);
59
+ const lines = prioritized.map((o) => {
60
+ const title = normalizeCompletedTitle(o.title, o.files_modified);
61
+ const facts = extractTopFacts(o, 1);
62
+ return facts ? `- ${title}
63
+ ${facts}` : `- ${title}`;
64
+ });
65
+ return dedupeBulletLines(lines).join(`
67
66
  `);
68
67
  }
69
68
  function extractNextSteps(observations) {
@@ -77,11 +76,78 @@ function extractNextSteps(observations) {
77
76
  return unresolved.map((o) => `- Investigate: ${o.title}`).slice(0, 3).join(`
78
77
  `);
79
78
  }
79
+ function formatObservationGroup(observations, options) {
80
+ const lines = dedupeObservationsByTitle(observations).slice(0, options.limit).map((o) => {
81
+ const facts = extractTopFacts(o, options.factsPerItem);
82
+ return facts ? `- ${o.title}
83
+ ${facts}` : `- ${o.title}`;
84
+ });
85
+ const deduped = dedupeBulletLines(lines);
86
+ return deduped.length ? deduped.join(`
87
+ `) : null;
88
+ }
89
+ function dedupeBulletLines(lines) {
90
+ const seen = new Set;
91
+ const deduped = [];
92
+ for (const line of lines) {
93
+ const normalized = line.toLowerCase().replace(/\([^)]*\)/g, "").replace(/\s+/g, " ").trim();
94
+ if (!normalized || seen.has(normalized))
95
+ continue;
96
+ seen.add(normalized);
97
+ deduped.push(line);
98
+ }
99
+ return deduped;
100
+ }
101
+ function dedupeObservationsByTitle(observations) {
102
+ const seen = new Set;
103
+ const deduped = [];
104
+ for (const obs of observations) {
105
+ const normalized = normalizeObservationKey(obs.title);
106
+ if (!normalized || seen.has(normalized))
107
+ continue;
108
+ seen.add(normalized);
109
+ deduped.push(obs);
110
+ }
111
+ return deduped;
112
+ }
113
+ function scoreCompletedObservation(obs) {
114
+ let score = obs.quality || 0;
115
+ if (obs.type === "feature")
116
+ score += 0.5;
117
+ if (obs.type === "refactor")
118
+ score += 0.2;
119
+ if (hasMeaningfulFacts(obs))
120
+ score += 0.4;
121
+ if (looksLikeFileOperation(obs.title))
122
+ score -= 0.6;
123
+ if (obs.narrative && obs.narrative.length > 80)
124
+ score += 0.2;
125
+ return score;
126
+ }
127
+ function hasMeaningfulFacts(obs) {
128
+ return parseJsonArray(obs.facts).some((fact) => fact.trim().length > 20);
129
+ }
130
+ function looksLikeFileOperation(title) {
131
+ return /^(modified|updated|edited|touched|changed)\s+[A-Za-z0-9_.\-\/]+$/i.test(title.trim());
132
+ }
133
+ function normalizeCompletedTitle(title, filesModified) {
134
+ const trimmed = title.trim();
135
+ if (!trimmed)
136
+ return "Completed work";
137
+ if (!looksLikeFileOperation(trimmed))
138
+ return trimmed;
139
+ const files = parseJsonArray(filesModified);
140
+ const filename = files[0]?.split("/").pop();
141
+ if (filename) {
142
+ return `Updated implementation in ${filename}`;
143
+ }
144
+ return trimmed;
145
+ }
80
146
  function extractTopFacts(obs, n) {
81
- const facts = parseJsonArray(obs.facts);
147
+ const facts = parseJsonArray(obs.facts).filter((fact) => isUsefulFact(fact, obs.title)).slice(0, n);
82
148
  if (facts.length === 0)
83
149
  return null;
84
- return facts.slice(0, n).map((f) => ` ${f}`).join(`
150
+ return facts.map((f) => ` ${f}`).join(`
85
151
  `);
86
152
  }
87
153
  function parseJsonArray(json) {
@@ -95,6 +161,23 @@ function parseJsonArray(json) {
95
161
  } catch {}
96
162
  return [];
97
163
  }
164
+ function isUsefulFact(fact, title) {
165
+ const cleaned = fact.trim();
166
+ if (!cleaned)
167
+ return false;
168
+ const normalizedFact = normalizeObservationKey(cleaned);
169
+ const normalizedTitle = normalizeObservationKey(title);
170
+ if (normalizedFact && normalizedFact === normalizedTitle)
171
+ return false;
172
+ if (/^[A-Za-z0-9_.\-\/]+\.[A-Za-z0-9]+$/.test(cleaned))
173
+ return false;
174
+ if (/^\(?[A-Za-z0-9_.\-\/]+\.[A-Za-z0-9]+\)?$/.test(cleaned))
175
+ return false;
176
+ return cleaned.length > 16 || /[:;]/.test(cleaned);
177
+ }
178
+ function normalizeObservationKey(value) {
179
+ return value.toLowerCase().replace(/\([^)]*\)/g, "").replace(/\b(modified|updated|edited|touched|changed)\b/g, "").replace(/\s+/g, " ").trim();
180
+ }
98
181
 
99
182
  // src/config.ts
100
183
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
package/dist/server.js CHANGED
@@ -16947,7 +16947,7 @@ process.on("SIGTERM", () => {
16947
16947
  });
16948
16948
  var server = new McpServer({
16949
16949
  name: "engrm",
16950
- version: "0.1.0"
16950
+ version: "0.4.6"
16951
16951
  });
16952
16952
  server.tool("save_observation", "Save an observation to memory", {
16953
16953
  type: exports_external.enum([
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "engrm",
3
- "version": "0.4.4",
4
- "description": "Cross-device, team-shared memory layer for AI coding agents",
3
+ "version": "0.4.6",
4
+ "description": "Shared memory across devices, sessions, and coding agents",
5
+ "mcpName": "io.github.dr12hes/engrm",
5
6
  "type": "module",
6
7
  "main": "dist/server.js",
7
8
  "bin": {