engrm 0.4.15 → 0.4.17

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
@@ -228,6 +228,28 @@ The MCP server exposes tools that supported agents can call directly:
228
228
  | `capture_repo_scan` | Run a lightweight repo scan and save reduced findings as memory |
229
229
  | `capture_openclaw_content` | Save OpenClaw content, research, and follow-up work as plugin memory |
230
230
 
231
+ ### Public MCP Starter Set
232
+
233
+ If you are evaluating Engrm as an MCP server, start with this small set first:
234
+
235
+ - `capture_git_worktree`
236
+ - save a meaningful local git diff before it disappears into commit history
237
+ - `capture_repo_scan`
238
+ - capture a quick architecture, implementation, or risk scan as reusable memory
239
+ - `capture_openclaw_content`
240
+ - save posted content, research, outcomes, and next actions from OpenClaw work
241
+ - `tool_memory_index`
242
+ - verify which tools are actually producing durable memory and which plugins they exercise
243
+ - `capture_quality`
244
+ - check whether raw chronology is healthy across the workspace before judging memory quality
245
+
246
+ These are the tools we should be comfortable pointing people to publicly first:
247
+
248
+ - thin input surface
249
+ - local-first execution
250
+ - durable memory output instead of raw transcript dumping
251
+ - easy local inspection after capture
252
+
231
253
  ### Thin Tools, Thick Memory
232
254
 
233
255
  Engrm now has a real thin-tool layer, not just a plugin spec.
@@ -301,6 +323,18 @@ That lets you:
301
323
  - capture the current repo state with a thin tool
302
324
  - verify whether that tool produced reusable memory
303
325
 
326
+ ### MCP Examples
327
+
328
+ These are the kinds of prompts Engrm's current MCP slice is designed for:
329
+
330
+ - "Capture this current git worktree as memory before I switch tasks."
331
+ - "Run a lightweight repo scan focused on auth and validation."
332
+ - "Show which tools are actually creating durable memory in this repo."
333
+ - "Tell me whether raw prompt/tool capture is healthy on this machine."
334
+ - "Save this OpenClaw research/posting run as reusable memory."
335
+
336
+ For concrete example flows and reducer outputs, see [MCP_EXAMPLES.md](/Volumes/Data/devs/candengo-mem/MCP_EXAMPLES.md).
337
+
304
338
  ### Observation Types
305
339
 
306
340
  | Type | What it captures |
@@ -1154,7 +1154,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync
1154
1154
  import { join as join3 } from "node:path";
1155
1155
  import { homedir } from "node:os";
1156
1156
  var STATE_PATH = join3(homedir(), ".engrm", "config-fingerprint.json");
1157
- var CLIENT_VERSION = "0.4.15";
1157
+ var CLIENT_VERSION = "0.4.17";
1158
1158
  function hashFile(filePath) {
1159
1159
  try {
1160
1160
  if (!existsSync3(filePath))
@@ -3133,11 +3133,13 @@ function formatSplashScreen(data) {
3133
3133
  lines.push("");
3134
3134
  lines.push(` ${c2.dim}Dashboard: https://engrm.dev/dashboard${c2.reset}`);
3135
3135
  const brief = formatVisibleStartupBrief(data.context);
3136
+ const handoffShownItems = new Set;
3136
3137
  if (brief.length > 0) {
3137
3138
  lines.push("");
3138
3139
  lines.push(` ${c2.bold}Handoff${c2.reset}`);
3139
3140
  for (const line of brief) {
3140
3141
  lines.push(` ${line}`);
3142
+ rememberShownItem(handoffShownItems, line);
3141
3143
  }
3142
3144
  }
3143
3145
  const economics = formatContextEconomics(data);
@@ -3154,7 +3156,7 @@ function formatSplashScreen(data) {
3154
3156
  lines.push(` ${line}`);
3155
3157
  }
3156
3158
  }
3157
- const contextIndex = formatContextIndex(data.context);
3159
+ const contextIndex = formatContextIndex(data.context, handoffShownItems);
3158
3160
  if (contextIndex.length > 0) {
3159
3161
  lines.push("");
3160
3162
  for (const line of contextIndex) {
@@ -3297,8 +3299,8 @@ function formatLegend() {
3297
3299
  `${c2.dim}Legend:${c2.reset} #id | \u25A0 bugfix | \u25B2 feature | \u2248 refactor | \u25CF change | \u25A1 discovery | \u25C7 decision`
3298
3300
  ];
3299
3301
  }
3300
- function formatContextIndex(context) {
3301
- const rows = context.observations.filter((obs) => obs.type !== "digest").slice(0, 6).map((obs) => {
3302
+ function formatContextIndex(context, shownItems) {
3303
+ const rows = pickContextIndexObservations(context, shownItems).map((obs) => {
3302
3304
  const icon = observationIcon(obs.type);
3303
3305
  const fileHint = extractPrimaryFileHint(obs);
3304
3306
  return `${icon} #${obs.id} ${truncateInline(obs.title, 110)}${fileHint ? ` ${c2.dim}(${fileHint})${c2.reset}` : ""}`;
@@ -3496,6 +3498,69 @@ function extractPrimaryFileHint(obs) {
3496
3498
  const firstModified = parseJsonArraySafe(obs.files_modified)[0];
3497
3499
  return firstModified ?? firstRead ?? null;
3498
3500
  }
3501
+ function pickContextIndexObservations(context, shownItems) {
3502
+ const now = Date.now();
3503
+ const hidden = shownItems ?? new Set;
3504
+ const picked = [];
3505
+ const scoreObservation = (obs) => {
3506
+ let score = 0;
3507
+ const ageMs = Math.max(0, now - new Date(obs.created_at).getTime());
3508
+ const ageDays = ageMs / 86400000;
3509
+ score += Math.max(0, 30 - ageDays) * 0.2;
3510
+ score += obs.quality * 2;
3511
+ switch (obs.type) {
3512
+ case "bugfix":
3513
+ score += 2.4;
3514
+ break;
3515
+ case "feature":
3516
+ score += 2.2;
3517
+ break;
3518
+ case "change":
3519
+ score += 1.6;
3520
+ break;
3521
+ case "discovery":
3522
+ score += 1.4;
3523
+ break;
3524
+ case "refactor":
3525
+ score += 1.2;
3526
+ break;
3527
+ case "decision":
3528
+ if (ageDays <= 7)
3529
+ score += 1.1;
3530
+ else if (ageDays <= 21)
3531
+ score += 0.2;
3532
+ else if (ageDays <= 45)
3533
+ score -= 1.2;
3534
+ else
3535
+ score -= 2.8;
3536
+ break;
3537
+ default:
3538
+ score += 0.4;
3539
+ break;
3540
+ }
3541
+ if (extractPrimaryFileHint(obs))
3542
+ score += 0.4;
3543
+ if (context.recentOutcomes?.some((item) => titlesRoughlyMatch(item, obs.title)))
3544
+ score += 2.5;
3545
+ return score;
3546
+ };
3547
+ for (const obs of context.observations.filter((obs2) => obs2.type !== "digest").filter((obs2) => {
3548
+ const normalized = normalizeStartupItem(obs2.title);
3549
+ return normalized && !hidden.has(normalized);
3550
+ }).sort((a, b) => {
3551
+ const scoreDiff = scoreObservation(b) - scoreObservation(a);
3552
+ if (scoreDiff !== 0)
3553
+ return scoreDiff;
3554
+ return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
3555
+ })) {
3556
+ if (picked.some((existing) => titlesRoughlyMatch(existing.title, obs.title)))
3557
+ continue;
3558
+ picked.push(obs);
3559
+ if (picked.length >= 6)
3560
+ break;
3561
+ }
3562
+ return picked;
3563
+ }
3499
3564
  function parseJsonArraySafe(value) {
3500
3565
  if (!value)
3501
3566
  return [];
@@ -3566,7 +3631,24 @@ function hasRequestSection(lines) {
3566
3631
  return lines.some((line) => line.includes("Request:"));
3567
3632
  }
3568
3633
  function normalizeStartupItem(value) {
3569
- return stripInlineSectionLabel2(value).replace(/^#?\d+:\s*/, "").replace(/^-\s*/, "").toLowerCase().replace(/\s+/g, " ").trim();
3634
+ return stripInlineSectionLabel2(value).replace(/^#?\d+:\s*/, "").replace(/^-\s*/, "").replace(/\([^)]*\)/g, " ").replace(/[^a-z0-9\s]/gi, " ").toLowerCase().replace(/\s+/g, " ").trim();
3635
+ }
3636
+ function titlesRoughlyMatch(left, right) {
3637
+ const a = normalizeStartupItem(left ?? "");
3638
+ const b = normalizeStartupItem(right ?? "");
3639
+ if (!a || !b)
3640
+ return false;
3641
+ if (a === b)
3642
+ return true;
3643
+ if (a.includes(b) || b.includes(a))
3644
+ return true;
3645
+ const aTokens = a.split(" ").filter((token) => token.length >= 4);
3646
+ const bTokens = b.split(" ").filter((token) => token.length >= 4);
3647
+ if (!aTokens.length || !bTokens.length)
3648
+ return false;
3649
+ const shared = aTokens.filter((token) => bTokens.includes(token));
3650
+ const minSize = Math.min(aTokens.length, bTokens.length);
3651
+ return shared.length >= Math.max(3, Math.ceil(minSize * 0.6));
3570
3652
  }
3571
3653
  function isMeaningfulPrompt2(value) {
3572
3654
  if (!value)
@@ -2535,7 +2535,7 @@ function buildBeacon(db, config, sessionId, metrics) {
2535
2535
  sentinel_used: valueSignals.security_findings_count > 0,
2536
2536
  risk_score: riskScore,
2537
2537
  stacks_detected: stacks,
2538
- client_version: "0.4.15",
2538
+ client_version: "0.4.17",
2539
2539
  context_observations_injected: metrics?.contextObsInjected ?? 0,
2540
2540
  context_total_available: metrics?.contextTotalAvailable ?? 0,
2541
2541
  recall_attempts: metrics?.recallAttempts ?? 0,
@@ -3558,42 +3558,40 @@ async function main() {
3558
3558
  try {
3559
3559
  if (event.session_id) {
3560
3560
  db.completeSession(event.session_id);
3561
+ if (event.last_assistant_message) {
3562
+ try {
3563
+ createAssistantCheckpoint(db, event.session_id, event.cwd, event.last_assistant_message);
3564
+ } catch {}
3565
+ }
3561
3566
  const existing = db.getSessionSummary(event.session_id);
3562
3567
  if (!existing) {
3563
3568
  const observations = db.getObservationsBySession(event.session_id);
3564
- if (observations.length > 0) {
3565
- const session = db.getSessionMetrics(event.session_id);
3566
- const summary = extractRetrospective(observations, event.session_id, session?.project_id ?? null, config.user_id);
3567
- if (summary) {
3568
- const row = db.insertSessionSummary(summary);
3569
- db.addToOutbox("summary", row.id);
3570
- let securityFindings = [];
3571
- try {
3572
- if (session?.project_id) {
3573
- securityFindings = db.getSecurityFindings(session.project_id, { limit: 100 }).filter((f) => f.session_id === event.session_id);
3574
- }
3575
- } catch {}
3576
- const riskResult = computeRiskScore({
3577
- observations,
3578
- securityFindings,
3579
- filesTouchedCount: session?.files_touched_count ?? 0,
3580
- toolCallsCount: session?.tool_calls_count ?? 0
3581
- });
3582
- try {
3583
- db.setSessionRiskScore(event.session_id, riskResult.score);
3584
- } catch {}
3585
- printRetrospective(summary);
3586
- console.log(formatRiskTrafficLight(riskResult));
3587
- }
3569
+ const session = db.getSessionMetrics(event.session_id);
3570
+ const summary = extractRetrospective(observations, event.session_id, session?.project_id ?? null, config.user_id) ?? buildFallbackSessionSummary(db, event.session_id, session?.project_id ?? null, config.user_id, event.last_assistant_message);
3571
+ if (summary) {
3572
+ const row = db.insertSessionSummary(summary);
3573
+ db.addToOutbox("summary", row.id);
3574
+ let securityFindings = [];
3575
+ try {
3576
+ if (session?.project_id) {
3577
+ securityFindings = db.getSecurityFindings(session.project_id, { limit: 100 }).filter((f) => f.session_id === event.session_id);
3578
+ }
3579
+ } catch {}
3580
+ const riskResult = computeRiskScore({
3581
+ observations,
3582
+ securityFindings,
3583
+ filesTouchedCount: session?.files_touched_count ?? 0,
3584
+ toolCallsCount: session?.tool_calls_count ?? 0
3585
+ });
3586
+ try {
3587
+ db.setSessionRiskScore(event.session_id, riskResult.score);
3588
+ } catch {}
3589
+ printRetrospective(summary);
3590
+ console.log(formatRiskTrafficLight(riskResult));
3588
3591
  }
3589
3592
  }
3590
3593
  }
3591
3594
  if (event.last_assistant_message) {
3592
- if (event.session_id) {
3593
- try {
3594
- createAssistantCheckpoint(db, event.session_id, event.cwd, event.last_assistant_message);
3595
- } catch {}
3596
- }
3597
3595
  const unsaved = detectUnsavedPlans(event.last_assistant_message);
3598
3596
  if (unsaved.length > 0) {
3599
3597
  console.error("");
@@ -3640,6 +3638,44 @@ async function main() {
3640
3638
  }
3641
3639
  process.exit(0);
3642
3640
  }
3641
+ function buildFallbackSessionSummary(db, sessionId, projectId, userId, lastAssistantMessage) {
3642
+ const prompts = db.getSessionUserPrompts(sessionId, 10).filter((prompt) => isMeaningfulSummaryPrompt(prompt));
3643
+ const checkpoint = lastAssistantMessage ? extractAssistantCheckpoint(lastAssistantMessage) : null;
3644
+ const request = selectFallbackRequest(prompts);
3645
+ const completed = checkpoint ? buildCheckpointCompleted(checkpoint) : null;
3646
+ if (!request && !completed)
3647
+ return null;
3648
+ return {
3649
+ session_id: sessionId,
3650
+ project_id: projectId,
3651
+ user_id: userId,
3652
+ request,
3653
+ investigated: null,
3654
+ learned: null,
3655
+ completed,
3656
+ next_steps: null
3657
+ };
3658
+ }
3659
+ function selectFallbackRequest(prompts) {
3660
+ const preferred = [...prompts].reverse().find((prompt) => !/^\[;ease$/i.test(prompt.prompt.trim()));
3661
+ return preferred?.prompt?.replace(/\s+/g, " ").trim() ?? null;
3662
+ }
3663
+ function isMeaningfulSummaryPrompt(prompt) {
3664
+ const compact = prompt.prompt.replace(/\s+/g, " ").trim();
3665
+ if (compact.length < 8)
3666
+ return false;
3667
+ if (/^\[;ease$/i.test(compact))
3668
+ return false;
3669
+ return /[a-z]{3,}/i.test(compact);
3670
+ }
3671
+ function buildCheckpointCompleted(checkpoint) {
3672
+ const lines = [`- ${checkpoint.title}`];
3673
+ for (const fact of checkpoint.facts.slice(0, 2)) {
3674
+ lines.push(` - ${fact}`);
3675
+ }
3676
+ return lines.join(`
3677
+ `);
3678
+ }
3643
3679
  function createSessionDigest(db, sessionId, cwd) {
3644
3680
  const observations = db.getObservationsBySession(sessionId);
3645
3681
  if (observations.length < 2)
package/dist/server.js CHANGED
@@ -19764,7 +19764,7 @@ process.on("SIGTERM", () => {
19764
19764
  });
19765
19765
  var server = new McpServer({
19766
19766
  name: "engrm",
19767
- version: "0.4.15"
19767
+ version: "0.4.17"
19768
19768
  });
19769
19769
  server.tool("save_observation", "Save an observation to memory", {
19770
19770
  type: exports_external.enum([
@@ -19940,11 +19940,11 @@ Facts: ${reduced.facts.join("; ")}` : "";
19940
19940
  ]
19941
19941
  };
19942
19942
  });
19943
- server.tool("capture_git_worktree", "Read the current git worktree diff, reduce it into durable memory, and save it with plugin provenance", {
19944
- cwd: exports_external.string().optional().describe("Git repo path; defaults to the current working directory"),
19945
- staged: exports_external.boolean().optional().describe("Capture staged changes instead of unstaged worktree changes"),
19946
- summary: exports_external.string().optional().describe("Optional human summary or commit-style title"),
19947
- session_id: exports_external.string().optional()
19943
+ server.tool("capture_git_worktree", "Capture the current git worktree as durable memory. Best for saving a meaningful local diff before context is lost.", {
19944
+ cwd: exports_external.string().optional().describe("Git repo path. Defaults to the current working directory."),
19945
+ staged: exports_external.boolean().optional().describe("If true, capture staged changes instead of unstaged worktree changes."),
19946
+ summary: exports_external.string().optional().describe("Optional human summary or commit-style title to steer the saved memory."),
19947
+ session_id: exports_external.string().optional().describe("Optional session ID to link this capture to active work.")
19948
19948
  }, async (params) => {
19949
19949
  let worktree;
19950
19950
  try {
@@ -20000,12 +20000,12 @@ server.tool("capture_git_worktree", "Read the current git worktree diff, reduce
20000
20000
  ]
20001
20001
  };
20002
20002
  });
20003
- server.tool("capture_repo_scan", "Run a lightweight repository scan, reduce the findings into durable memory, and save it with plugin provenance", {
20004
- cwd: exports_external.string().optional().describe("Repo path to scan; defaults to the current working directory"),
20005
- focus: exports_external.array(exports_external.string()).optional().describe("Optional extra topics to search for, like 'billing' or 'validation'"),
20006
- max_findings: exports_external.number().optional().describe("Maximum findings to keep"),
20007
- summary: exports_external.string().optional().describe("Optional human summary for the scan"),
20008
- session_id: exports_external.string().optional()
20003
+ server.tool("capture_repo_scan", "Run a lightweight repository scan and save reduced findings as durable memory. Best for quick architecture, risk, or implementation scans.", {
20004
+ cwd: exports_external.string().optional().describe("Repo path to scan. Defaults to the current working directory."),
20005
+ focus: exports_external.array(exports_external.string()).optional().describe("Optional topics to bias the scan toward, for example 'billing', 'auth', or 'validation'."),
20006
+ max_findings: exports_external.number().optional().describe("Maximum findings to keep before reduction."),
20007
+ summary: exports_external.string().optional().describe("Optional human summary for the saved memory."),
20008
+ session_id: exports_external.string().optional().describe("Optional session ID to link this scan to active work.")
20009
20009
  }, async (params) => {
20010
20010
  let scan;
20011
20011
  try {
@@ -20063,15 +20063,15 @@ Findings: ${findingSummary}` : ""}`
20063
20063
  ]
20064
20064
  };
20065
20065
  });
20066
- server.tool("capture_openclaw_content", "Reduce OpenClaw content/research activity into durable memory and save it with plugin provenance", {
20067
- title: exports_external.string().optional().describe("Short content or campaign title"),
20068
- posted: exports_external.array(exports_external.string()).optional().describe("Concrete posted items or shipped content outcomes"),
20069
- researched: exports_external.array(exports_external.string()).optional().describe("Research or discovery items"),
20070
- outcomes: exports_external.array(exports_external.string()).optional().describe("Meaningful outcomes from the run"),
20071
- next_actions: exports_external.array(exports_external.string()).optional().describe("Real follow-up actions"),
20072
- links: exports_external.array(exports_external.string()).optional().describe("Thread or source URLs"),
20073
- session_id: exports_external.string().optional(),
20074
- cwd: exports_external.string().optional()
20066
+ server.tool("capture_openclaw_content", "Capture OpenClaw content, research, and follow-up work as durable memory. Best for preserving posted outcomes, discoveries, and next actions.", {
20067
+ title: exports_external.string().optional().describe("Short content, campaign, or research title."),
20068
+ posted: exports_external.array(exports_external.string()).optional().describe("Concrete posted items or shipped content outcomes."),
20069
+ researched: exports_external.array(exports_external.string()).optional().describe("Research or discovery items worth retaining."),
20070
+ outcomes: exports_external.array(exports_external.string()).optional().describe("Meaningful outcomes from the run."),
20071
+ next_actions: exports_external.array(exports_external.string()).optional().describe("Real follow-up actions that remain."),
20072
+ links: exports_external.array(exports_external.string()).optional().describe("Thread or source URLs tied to the work."),
20073
+ session_id: exports_external.string().optional().describe("Optional session ID to link this memory to active work."),
20074
+ cwd: exports_external.string().optional().describe("Optional project path for attribution.")
20075
20075
  }, async (params) => {
20076
20076
  const reduced = reduceOpenClawContentToMemory({
20077
20077
  ...params,
@@ -20536,9 +20536,9 @@ server.tool("capture_status", "Show whether Engrm hook registration and recent p
20536
20536
  ]
20537
20537
  };
20538
20538
  });
20539
- server.tool("capture_quality", "Show workspace-wide capture richness, checkpoints, and provenance across projects", {
20540
- limit: exports_external.number().optional(),
20541
- user_id: exports_external.string().optional()
20539
+ server.tool("capture_quality", "Show how healthy Engrm capture is across the workspace: raw chronology coverage, checkpoints, and provenance by tool.", {
20540
+ limit: exports_external.number().optional().describe("Maximum projects to include in the top-projects section."),
20541
+ user_id: exports_external.string().optional().describe("Optional user override; defaults to the configured user.")
20542
20542
  }, async (params) => {
20543
20543
  const result = getCaptureQuality(db, {
20544
20544
  limit: params.limit,
@@ -20575,11 +20575,11 @@ ${projectLines}`
20575
20575
  ]
20576
20576
  };
20577
20577
  });
20578
- server.tool("tool_memory_index", "Show which source tools are producing durable memory and what kinds of memory they create", {
20579
- cwd: exports_external.string().optional(),
20580
- project_scoped: exports_external.boolean().optional(),
20581
- limit: exports_external.number().optional(),
20582
- user_id: exports_external.string().optional()
20578
+ server.tool("tool_memory_index", "Show which tools are actually producing durable memory, which plugins they exercise, and what memory types they create.", {
20579
+ cwd: exports_external.string().optional().describe("Project path to inspect. Defaults to the current working directory."),
20580
+ project_scoped: exports_external.boolean().optional().describe("If true, limit results to the current project instead of the whole workspace."),
20581
+ limit: exports_external.number().optional().describe("Maximum tools to include."),
20582
+ user_id: exports_external.string().optional().describe("Optional user override; defaults to the configured user.")
20583
20583
  }, async (params) => {
20584
20584
  const result = getToolMemoryIndex(db, {
20585
20585
  cwd: params.cwd ?? process.cwd(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engrm",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "description": "Shared memory across devices, sessions, and coding agents",
5
5
  "mcpName": "io.github.dr12hes/engrm",
6
6
  "type": "module",