engrm 0.4.31 → 0.4.33

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
@@ -226,8 +226,10 @@ The MCP server exposes tools that supported agents can call directly:
226
226
  | `recent_handoffs` | List recent saved handoffs for the current project or workspace |
227
227
  | `load_handoff` | Open a saved handoff as a resume point for a new session |
228
228
  | `refresh_chat_recall` | Rehydrate the separate chat lane from a Claude transcript when a long session feels under-captured |
229
- | `repair_recall` | Rehydrate recent project/session recall from transcript or Claude history fallback when chat feels missing |
230
- | `resume_thread` | Build one clear resume point from handoff, current thread, recent chat, and unified recall |
229
+ | `repair_recall` | Use when continuity feels thin; rehydrate recent recall from transcript or Claude history fallback |
230
+ | `list_recall_items` | Use first when continuity feels fuzzy; list the best current handoffs, threads, chat snippets, and memory entries |
231
+ | `load_recall_item` | Use after `list_recall_items`; load one exact recall item key |
232
+ | `resume_thread` | Use first when you want one direct "where were we?" answer from handoff, current thread, recent chat, and unified recall |
231
233
  | `recent_chat` | Inspect the separate synced chat lane without mixing it into durable memory |
232
234
  | `search_chat` | Search recent chat recall with hybrid lexical + semantic matching, separately from reusable memory observations |
233
235
  | `search_recall` | Search durable memory and chat recall together when you do not want to guess the right lane |
@@ -252,6 +254,10 @@ If you are evaluating Engrm as an MCP server, start with this small set first:
252
254
  - verify which tools are actually producing durable memory and which plugins they exercise
253
255
  - `capture_quality`
254
256
  - check whether raw chronology is healthy across the workspace before judging memory quality
257
+ - `list_recall_items`
258
+ - list the best current handoffs, session threads, chat snippets, and memory entries before opening one exact item
259
+ - `load_recall_item`
260
+ - open one exact recall key from the index without falling back to fuzzy retrieval
255
261
  - `resume_thread`
256
262
  - get one direct “where were we?” resume point with freshness, source, tool trail, and next actions
257
263
  - `repair_recall`
@@ -265,6 +271,22 @@ These are the tools we should be comfortable pointing people to publicly first:
265
271
  - easy local inspection after capture
266
272
  - clear continuity recovery when switching devices or resuming long sessions
267
273
 
274
+ ### Recall Protocol
275
+
276
+ When continuity feels fuzzy, the default path is:
277
+
278
+ 1. `resume_thread`
279
+ 2. `list_recall_items`
280
+ 3. `load_recall_item`
281
+ 4. `repair_recall`
282
+
283
+ How to use it:
284
+
285
+ - `resume_thread` is the fastest "get me back into the live thread" action
286
+ - `list_recall_items` is the deterministic directory-first path when you want to inspect candidates before opening one
287
+ - `load_recall_item` opens an exact handoff, thread, chat, or memory key returned by the index
288
+ - `repair_recall` is the repair step when continuity is still thin, hook-only, or under-captured
289
+
268
290
  ### Thin Tools, Thick Memory
269
291
 
270
292
  Engrm now has a real thin-tool layer, not just a plugin spec.
@@ -381,7 +403,10 @@ What each tool is good for:
381
403
  - `capture_status` tells you whether prompt/tool hooks are live on this machine
382
404
  - `capture_quality` shows whether chat recall is transcript-backed, history-backed, or still hook-only across the workspace
383
405
  - `memory_console` gives the quickest project snapshot, including whether continuity is `fresh`, `thin`, or `cold`
384
- - `resume_thread` is the fastest “get me back into the live thread” path when you want freshness, source, next actions, tool trail, and chat in one place
406
+ - `resume_thread` is the fastest “get me back into the live thread” path when you want freshness, source, next actions, tool trail, chat, and one exact `load_recall_item(...)` suggestion in one place
407
+ - `list_recall_items` is the deterministic directory-first path when you want to inspect the best candidate handoffs/threads before opening one exact item
408
+ - `load_recall_item` completes that protocol by letting agents open one exact recall key directly after listing
409
+ - `memory_console`, `project_memory_index`, and `session_context` now also surface one best exact `load_recall_item(...)` jump, so the workbench can hand you the right deterministic next step instead of only showing recall counts
385
410
  - `memory_console`, `project_memory_index`, and `session_context` now also show whether project chat recall is transcript-backed, history-backed, or only hook-captured
386
411
  - `memory_console`, `project_memory_index`, and `session_context` also expose resume-readiness directly, so you can see whether a repo is `live`, `recent`, or `stale` before drilling deeper
387
412
  - when chat continuity is only partial, the workbench and startup hints now prefer `repair_recall`, and still suggest `refresh_chat_recall` when a single session likely just needs transcript hydration
@@ -3673,6 +3673,14 @@ function formatContextForInjection(context) {
3673
3673
  }
3674
3674
  lines.push("");
3675
3675
  }
3676
+ const recallIndexLines = buildRecallIndexLines(context);
3677
+ if (recallIndexLines.length > 0) {
3678
+ lines.push("## Recall Index");
3679
+ for (const line of recallIndexLines) {
3680
+ lines.push(line);
3681
+ }
3682
+ lines.push("");
3683
+ }
3676
3684
  if (context.recentChatMessages && context.recentChatMessages.length > 0) {
3677
3685
  lines.push("## Recent Chat");
3678
3686
  for (const message of context.recentChatMessages.slice(0, 4).reverse()) {
@@ -3775,6 +3783,25 @@ function formatContextForInjection(context) {
3775
3783
  return lines.join(`
3776
3784
  `);
3777
3785
  }
3786
+ function buildRecallIndexLines(context) {
3787
+ const lines = [];
3788
+ for (const handoff of context.recentHandoffs?.slice(0, 2) ?? []) {
3789
+ const title = handoff.title.replace(/^Handoff(?: Draft)?:\s*/i, "").replace(/\s+·\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}Z$/, "").trim();
3790
+ if (!title)
3791
+ continue;
3792
+ lines.push(`- handoff:${handoff.id} — ${truncateText(title, 140)}`);
3793
+ }
3794
+ for (const session of context.recentSessions?.slice(0, 2) ?? []) {
3795
+ const title = session.current_thread ?? session.request ?? session.completed ?? session.session_id;
3796
+ if (!title)
3797
+ continue;
3798
+ lines.push(`- session:${session.session_id} — ${truncateText(title.replace(/\s+/g, " ").trim(), 140)}`);
3799
+ }
3800
+ for (const message of context.recentChatMessages?.slice(0, 2) ?? []) {
3801
+ lines.push(`- chat:${message.id} — ${truncateText(message.content.replace(/\s+/g, " ").trim(), 140)}`);
3802
+ }
3803
+ return Array.from(new Set(lines)).slice(0, 5);
3804
+ }
3778
3805
  function formatSessionBrief(summary) {
3779
3806
  const lines = [];
3780
3807
  const heading = summary.request ? `### ${truncateText(summary.request, 120)}` : `### Session ${summary.session_id.slice(0, 8)}`;
@@ -1943,6 +1943,14 @@ function formatContextForInjection(context) {
1943
1943
  }
1944
1944
  lines.push("");
1945
1945
  }
1946
+ const recallIndexLines = buildRecallIndexLines(context);
1947
+ if (recallIndexLines.length > 0) {
1948
+ lines.push("## Recall Index");
1949
+ for (const line of recallIndexLines) {
1950
+ lines.push(line);
1951
+ }
1952
+ lines.push("");
1953
+ }
1946
1954
  if (context.recentChatMessages && context.recentChatMessages.length > 0) {
1947
1955
  lines.push("## Recent Chat");
1948
1956
  for (const message of context.recentChatMessages.slice(0, 4).reverse()) {
@@ -2045,6 +2053,25 @@ function formatContextForInjection(context) {
2045
2053
  return lines.join(`
2046
2054
  `);
2047
2055
  }
2056
+ function buildRecallIndexLines(context) {
2057
+ const lines = [];
2058
+ for (const handoff of context.recentHandoffs?.slice(0, 2) ?? []) {
2059
+ const title = handoff.title.replace(/^Handoff(?: Draft)?:\s*/i, "").replace(/\s+·\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}Z$/, "").trim();
2060
+ if (!title)
2061
+ continue;
2062
+ lines.push(`- handoff:${handoff.id} — ${truncateText(title, 140)}`);
2063
+ }
2064
+ for (const session of context.recentSessions?.slice(0, 2) ?? []) {
2065
+ const title = session.current_thread ?? session.request ?? session.completed ?? session.session_id;
2066
+ if (!title)
2067
+ continue;
2068
+ lines.push(`- session:${session.session_id} — ${truncateText(title.replace(/\s+/g, " ").trim(), 140)}`);
2069
+ }
2070
+ for (const message of context.recentChatMessages?.slice(0, 2) ?? []) {
2071
+ lines.push(`- chat:${message.id} — ${truncateText(message.content.replace(/\s+/g, " ").trim(), 140)}`);
2072
+ }
2073
+ return Array.from(new Set(lines)).slice(0, 5);
2074
+ }
2048
2075
  function formatSessionBrief(summary) {
2049
2076
  const lines = [];
2050
2077
  const heading = summary.request ? `### ${truncateText(summary.request, 120)}` : `### Session ${summary.session_id.slice(0, 8)}`;
@@ -2618,6 +2645,14 @@ function formatContextForInjection2(context) {
2618
2645
  }
2619
2646
  lines.push("");
2620
2647
  }
2648
+ const recallIndexLines = buildRecallIndexLines2(context);
2649
+ if (recallIndexLines.length > 0) {
2650
+ lines.push("## Recall Index");
2651
+ for (const line of recallIndexLines) {
2652
+ lines.push(line);
2653
+ }
2654
+ lines.push("");
2655
+ }
2621
2656
  if (context.recentChatMessages && context.recentChatMessages.length > 0) {
2622
2657
  lines.push("## Recent Chat");
2623
2658
  for (const message of context.recentChatMessages.slice(0, 4).reverse()) {
@@ -2720,6 +2755,25 @@ function formatContextForInjection2(context) {
2720
2755
  return lines.join(`
2721
2756
  `);
2722
2757
  }
2758
+ function buildRecallIndexLines2(context) {
2759
+ const lines = [];
2760
+ for (const handoff of context.recentHandoffs?.slice(0, 2) ?? []) {
2761
+ const title = handoff.title.replace(/^Handoff(?: Draft)?:\s*/i, "").replace(/\s+·\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}Z$/, "").trim();
2762
+ if (!title)
2763
+ continue;
2764
+ lines.push(`- handoff:${handoff.id} — ${truncateText2(title, 140)}`);
2765
+ }
2766
+ for (const session of context.recentSessions?.slice(0, 2) ?? []) {
2767
+ const title = session.current_thread ?? session.request ?? session.completed ?? session.session_id;
2768
+ if (!title)
2769
+ continue;
2770
+ lines.push(`- session:${session.session_id} — ${truncateText2(title.replace(/\s+/g, " ").trim(), 140)}`);
2771
+ }
2772
+ for (const message of context.recentChatMessages?.slice(0, 2) ?? []) {
2773
+ lines.push(`- chat:${message.id} — ${truncateText2(message.content.replace(/\s+/g, " ").trim(), 140)}`);
2774
+ }
2775
+ return Array.from(new Set(lines)).slice(0, 5);
2776
+ }
2723
2777
  function formatSessionBrief2(summary) {
2724
2778
  const lines = [];
2725
2779
  const heading = summary.request ? `### ${truncateText2(summary.request, 120)}` : `### Session ${summary.session_id.slice(0, 8)}`;
@@ -3090,7 +3144,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync
3090
3144
  import { join as join3 } from "node:path";
3091
3145
  import { homedir } from "node:os";
3092
3146
  var STATE_PATH = join3(homedir(), ".engrm", "config-fingerprint.json");
3093
- var CLIENT_VERSION = "0.4.31";
3147
+ var CLIENT_VERSION = "0.4.33";
3094
3148
  function hashFile(filePath) {
3095
3149
  try {
3096
3150
  if (!existsSync3(filePath))
@@ -5563,7 +5617,15 @@ function formatSplashScreen(data) {
5563
5617
  lines.push(` ${line}`);
5564
5618
  }
5565
5619
  }
5566
- const inspectHints = formatInspectHints(data.context, contextIndex.observationIds);
5620
+ const recallItems = buildStartupRecallItems(data.context);
5621
+ const recallPreview = formatStartupRecallPreview(recallItems);
5622
+ if (recallPreview.length > 0) {
5623
+ lines.push("");
5624
+ for (const line of recallPreview) {
5625
+ lines.push(` ${line}`);
5626
+ }
5627
+ }
5628
+ const inspectHints = formatInspectHints(data.context, contextIndex.observationIds, recallItems);
5567
5629
  if (inspectHints.length > 0) {
5568
5630
  lines.push("");
5569
5631
  for (const line of inspectHints) {
@@ -5782,7 +5844,7 @@ function formatContextIndex(context, shownItems) {
5782
5844
  observationIds: selected.map((obs) => obs.id)
5783
5845
  };
5784
5846
  }
5785
- function formatInspectHints(context, visibleObservationIds = []) {
5847
+ function formatInspectHints(context, visibleObservationIds = [], recallItems = []) {
5786
5848
  const hints = [];
5787
5849
  const continuityState = getStartupContinuityState(context);
5788
5850
  if ((context.recentSessions?.length ?? 0) > 0) {
@@ -5794,6 +5856,8 @@ function formatInspectHints(context, visibleObservationIds = []) {
5794
5856
  hints.push("activity_feed");
5795
5857
  }
5796
5858
  if ((context.recentPrompts?.length ?? 0) > 0 || (context.recentChatMessages?.length ?? 0) > 0 || context.observations.length > 0) {
5859
+ hints.push("list_recall_items");
5860
+ hints.push("load_recall_item");
5797
5861
  hints.push("resume_thread");
5798
5862
  hints.push("search_recall");
5799
5863
  }
@@ -5819,12 +5883,90 @@ function formatInspectHints(context, visibleObservationIds = []) {
5819
5883
  if (unique.length === 0)
5820
5884
  return [];
5821
5885
  const ids = visibleObservationIds.slice(0, 5);
5886
+ const openNowItem = recallItems.find((item) => item.kind !== "memory") ?? null;
5822
5887
  const fetchHint = ids.length > 0 ? `get_observations([${ids.join(", ")}])` : null;
5823
5888
  return [
5824
5889
  `${c2.dim}Next look:${c2.reset} ${unique.join(" \xB7 ")}`,
5890
+ ...openNowItem ? [`${c2.dim}Open now:${c2.reset} load_recall_item("${openNowItem.key}")`] : [],
5825
5891
  ...fetchHint ? [`${c2.dim}Pull detail:${c2.reset} ${fetchHint}`] : []
5826
5892
  ];
5827
5893
  }
5894
+ function formatStartupRecallPreview(recallItems) {
5895
+ const items = recallItems.slice(0, 3);
5896
+ if (items.length === 0)
5897
+ return [];
5898
+ return [
5899
+ `${c2.dim}Recall preview:${c2.reset} exact keys you can open now`,
5900
+ ...items.map((item) => `${item.key} [${item.kind} \xB7 ${item.freshness}] ${truncateInline(item.title, 110)}`)
5901
+ ];
5902
+ }
5903
+ function buildStartupRecallItems(context) {
5904
+ const items = [];
5905
+ for (const handoff of context.recentHandoffs?.slice(0, 2) ?? []) {
5906
+ const title = handoff.title.replace(/^Handoff(?: Draft)?:\s*/i, "").replace(/\s+\u00B7\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}Z$/, "").trim();
5907
+ if (!title)
5908
+ continue;
5909
+ const freshness = classifyResumeFreshness(handoff.created_at_epoch);
5910
+ items.push({
5911
+ key: `handoff:${handoff.id}`,
5912
+ kind: "handoff",
5913
+ freshness,
5914
+ title,
5915
+ score: freshnessScore(freshness) + 40
5916
+ });
5917
+ }
5918
+ for (const session of context.recentSessions?.slice(0, 2) ?? []) {
5919
+ const title = chooseMeaningfulSessionSummary(session.request, session.completed);
5920
+ if (!title)
5921
+ continue;
5922
+ const sourceEpoch = session.completed_at_epoch ?? session.started_at_epoch ?? null;
5923
+ const freshness = classifyResumeFreshness(sourceEpoch);
5924
+ items.push({
5925
+ key: `session:${session.session_id}`,
5926
+ kind: "thread",
5927
+ freshness,
5928
+ title,
5929
+ score: freshnessScore(freshness) + 30
5930
+ });
5931
+ }
5932
+ for (const message of context.recentChatMessages?.slice(-2) ?? []) {
5933
+ if (!message.content.trim())
5934
+ continue;
5935
+ const freshness = classifyResumeFreshness(message.created_at_epoch);
5936
+ items.push({
5937
+ key: `chat:${message.id}`,
5938
+ kind: "chat",
5939
+ freshness,
5940
+ title: `[${message.role}] ${message.content.replace(/\s+/g, " ").trim()}`,
5941
+ score: freshnessScore(freshness) + 20
5942
+ });
5943
+ }
5944
+ for (const obs of pickContextIndexObservations(context).slice(0, 2)) {
5945
+ const createdAtEpoch = Math.floor(new Date(obs.created_at).getTime() / 1000);
5946
+ const freshness = classifyResumeFreshness(Number.isFinite(createdAtEpoch) ? createdAtEpoch : null);
5947
+ items.push({
5948
+ key: `obs:${obs.id}`,
5949
+ kind: "memory",
5950
+ freshness,
5951
+ title: obs.title,
5952
+ score: freshnessScore(freshness) + 10
5953
+ });
5954
+ }
5955
+ const seen = new Set;
5956
+ return items.sort((a, b) => b.score - a.score || a.key.localeCompare(b.key)).filter((item) => {
5957
+ if (seen.has(item.key))
5958
+ return false;
5959
+ seen.add(item.key);
5960
+ return true;
5961
+ }).map(({ score: _score, ...item }) => item);
5962
+ }
5963
+ function freshnessScore(freshness) {
5964
+ if (freshness === "live")
5965
+ return 3;
5966
+ if (freshness === "recent")
5967
+ return 2;
5968
+ return 1;
5969
+ }
5828
5970
  function rememberShownItem(shown, value) {
5829
5971
  if (!value)
5830
5972
  return;
@@ -3082,7 +3082,7 @@ function buildBeacon(db, config, sessionId, metrics) {
3082
3082
  sentinel_used: valueSignals.security_findings_count > 0,
3083
3083
  risk_score: riskScore,
3084
3084
  stacks_detected: stacks,
3085
- client_version: "0.4.31",
3085
+ client_version: "0.4.33",
3086
3086
  context_observations_injected: metrics?.contextObsInjected ?? 0,
3087
3087
  context_total_available: metrics?.contextTotalAvailable ?? 0,
3088
3088
  recall_attempts: metrics?.recallAttempts ?? 0,
package/dist/server.js CHANGED
@@ -17976,6 +17976,14 @@ function formatContextForInjection(context) {
17976
17976
  }
17977
17977
  lines.push("");
17978
17978
  }
17979
+ const recallIndexLines = buildRecallIndexLines(context);
17980
+ if (recallIndexLines.length > 0) {
17981
+ lines.push("## Recall Index");
17982
+ for (const line of recallIndexLines) {
17983
+ lines.push(line);
17984
+ }
17985
+ lines.push("");
17986
+ }
17979
17987
  if (context.recentChatMessages && context.recentChatMessages.length > 0) {
17980
17988
  lines.push("## Recent Chat");
17981
17989
  for (const message of context.recentChatMessages.slice(0, 4).reverse()) {
@@ -18078,6 +18086,25 @@ function formatContextForInjection(context) {
18078
18086
  return lines.join(`
18079
18087
  `);
18080
18088
  }
18089
+ function buildRecallIndexLines(context) {
18090
+ const lines = [];
18091
+ for (const handoff of context.recentHandoffs?.slice(0, 2) ?? []) {
18092
+ const title = handoff.title.replace(/^Handoff(?: Draft)?:\s*/i, "").replace(/\s+·\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}Z$/, "").trim();
18093
+ if (!title)
18094
+ continue;
18095
+ lines.push(`- handoff:${handoff.id} — ${truncateText(title, 140)}`);
18096
+ }
18097
+ for (const session of context.recentSessions?.slice(0, 2) ?? []) {
18098
+ const title = session.current_thread ?? session.request ?? session.completed ?? session.session_id;
18099
+ if (!title)
18100
+ continue;
18101
+ lines.push(`- session:${session.session_id} — ${truncateText(title.replace(/\s+/g, " ").trim(), 140)}`);
18102
+ }
18103
+ for (const message of context.recentChatMessages?.slice(0, 2) ?? []) {
18104
+ lines.push(`- chat:${message.id} — ${truncateText(message.content.replace(/\s+/g, " ").trim(), 140)}`);
18105
+ }
18106
+ return Array.from(new Set(lines)).slice(0, 5);
18107
+ }
18081
18108
  function formatSessionBrief(summary) {
18082
18109
  const lines = [];
18083
18110
  const heading = summary.request ? `### ${truncateText(summary.request, 120)}` : `### Session ${summary.session_id.slice(0, 8)}`;
@@ -18296,6 +18323,187 @@ function getRecentOutcomes(db, projectId, userId, recentSessions) {
18296
18323
  return picked;
18297
18324
  }
18298
18325
 
18326
+ // src/tools/list-recall-items.ts
18327
+ function listRecallItems(db, input) {
18328
+ const limit = Math.max(3, Math.min(input.limit ?? 12, 30));
18329
+ const projectScoped = input.project_scoped !== false;
18330
+ let projectName;
18331
+ if (projectScoped) {
18332
+ const cwd = input.cwd ?? process.cwd();
18333
+ const detected = detectProject(cwd);
18334
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
18335
+ projectName = project?.name;
18336
+ }
18337
+ const handoffs = getRecentHandoffs(db, {
18338
+ cwd: input.cwd,
18339
+ project_scoped: projectScoped,
18340
+ user_id: input.user_id,
18341
+ current_device_id: input.current_device_id,
18342
+ limit
18343
+ }).handoffs;
18344
+ const sessions = getRecentSessions(db, {
18345
+ cwd: input.cwd,
18346
+ project_scoped: projectScoped,
18347
+ user_id: input.user_id,
18348
+ limit: Math.min(limit, 8)
18349
+ }).sessions;
18350
+ const chat = getRecentChat(db, {
18351
+ cwd: input.cwd,
18352
+ project_scoped: projectScoped,
18353
+ user_id: input.user_id,
18354
+ limit: Math.min(limit * 2, 20)
18355
+ }).messages;
18356
+ const observations = getRecentActivity(db, {
18357
+ cwd: input.cwd,
18358
+ project_scoped: projectScoped,
18359
+ user_id: input.user_id,
18360
+ limit: Math.min(limit * 2, 24)
18361
+ }).observations;
18362
+ const items = [
18363
+ ...handoffs.map((handoff) => ({
18364
+ key: `handoff:${handoff.id}`,
18365
+ kind: "handoff",
18366
+ title: stripHandoffPrefix(handoff.title),
18367
+ detail: summarizeHandoffDetail(handoff.narrative),
18368
+ created_at_epoch: handoff.created_at_epoch,
18369
+ freshness: classifyFreshness(handoff.created_at_epoch),
18370
+ session_id: handoff.session_id,
18371
+ source_device_id: handoff.device_id ?? null
18372
+ })),
18373
+ ...sessions.filter((session) => Boolean(session.request || session.completed || session.current_thread)).map((session) => ({
18374
+ key: `session:${session.session_id}`,
18375
+ kind: "thread",
18376
+ title: session.current_thread ?? session.request ?? session.completed ?? session.session_id,
18377
+ detail: buildSessionDetail(session),
18378
+ created_at_epoch: session.completed_at_epoch ?? session.started_at_epoch ?? 0,
18379
+ freshness: classifyFreshness(session.completed_at_epoch ?? session.started_at_epoch ?? null),
18380
+ session_id: session.session_id,
18381
+ source_device_id: session.device_id ?? null
18382
+ })),
18383
+ ...dedupeChatIndex(chat).map((message) => {
18384
+ const origin = getChatCaptureOrigin(message);
18385
+ return {
18386
+ key: `chat:${message.id}`,
18387
+ kind: "chat",
18388
+ title: `${message.role} [${origin}]`,
18389
+ detail: truncateInline(message.content.replace(/\s+/g, " ").trim(), 180),
18390
+ created_at_epoch: message.created_at_epoch,
18391
+ freshness: classifyFreshness(message.created_at_epoch),
18392
+ session_id: message.session_id,
18393
+ source_device_id: message.device_id ?? null
18394
+ };
18395
+ }),
18396
+ ...observations.filter((obs) => obs.type !== "message").filter((obs) => !looksLikeFileOperationTitle3(obs.title)).map((obs) => ({
18397
+ key: `obs:${obs.id}`,
18398
+ kind: "memory",
18399
+ title: `[${obs.type}] ${obs.title}`,
18400
+ detail: truncateInline(firstNonEmpty2(obs.narrative, previewFacts(obs.facts), obs.project_name ?? "") ?? obs.type, 180),
18401
+ created_at_epoch: obs.created_at_epoch,
18402
+ freshness: classifyFreshness(obs.created_at_epoch),
18403
+ session_id: obs.session_id ?? null,
18404
+ source_device_id: obs.device_id ?? null
18405
+ }))
18406
+ ];
18407
+ const deduped = dedupeRecallItems(items).sort((a, b) => compareRecallItems(a, b, input.current_device_id)).slice(0, limit);
18408
+ return {
18409
+ project: projectName,
18410
+ continuity_mode: deduped.some((item) => item.kind === "handoff" || item.kind === "thread") ? "direct" : "indexed",
18411
+ items: deduped
18412
+ };
18413
+ }
18414
+ function compareRecallItems(a, b, currentDeviceId) {
18415
+ const priority = (item) => {
18416
+ const freshness = item.freshness === "live" ? 0 : item.freshness === "recent" ? 1 : 2;
18417
+ const kind = item.kind === "handoff" ? 0 : item.kind === "thread" ? 1 : item.kind === "chat" ? 2 : 3;
18418
+ const remoteBoost = currentDeviceId && item.source_device_id && item.source_device_id !== currentDeviceId ? -0.5 : 0;
18419
+ const draftPenalty = item.kind === "handoff" && /draft/i.test(item.title) ? 0.25 : 0;
18420
+ return freshness * 10 + kind + remoteBoost + draftPenalty;
18421
+ };
18422
+ const priorityDiff = priority(a) - priority(b);
18423
+ if (priorityDiff !== 0)
18424
+ return priorityDiff;
18425
+ return b.created_at_epoch - a.created_at_epoch;
18426
+ }
18427
+ function dedupeRecallItems(items) {
18428
+ const best = new Map;
18429
+ for (const item of items) {
18430
+ const key = `${item.kind}::${normalize(item.title)}::${normalize(item.detail)}`;
18431
+ const existing = best.get(key);
18432
+ if (!existing || compareRecallItems(item, existing) < 0) {
18433
+ best.set(key, item);
18434
+ }
18435
+ }
18436
+ return Array.from(best.values());
18437
+ }
18438
+ function dedupeChatIndex(messages) {
18439
+ const byKey = new Map;
18440
+ for (const message of messages) {
18441
+ const key = `${message.session_id}::${message.role}::${normalize(message.content)}`;
18442
+ const existing = byKey.get(key);
18443
+ if (!existing || message.created_at_epoch > existing.created_at_epoch) {
18444
+ byKey.set(key, message);
18445
+ }
18446
+ }
18447
+ return Array.from(byKey.values());
18448
+ }
18449
+ function summarizeHandoffDetail(narrative) {
18450
+ if (!narrative)
18451
+ return "";
18452
+ const line = narrative.split(/\n+/).map((item) => item.trim()).find((item) => /^Current thread:|^Completed:|^Next Steps:/i.test(item));
18453
+ return line ? truncateInline(line.replace(/^(Current thread:|Completed:|Next Steps:)\s*/i, ""), 180) : truncateInline(narrative.replace(/\s+/g, " ").trim(), 180);
18454
+ }
18455
+ function buildSessionDetail(session) {
18456
+ const pieces = [
18457
+ session.request,
18458
+ session.completed,
18459
+ session.current_thread
18460
+ ].filter((item) => Boolean(item && item.trim())).map((item) => item.replace(/\s+/g, " ").trim());
18461
+ return truncateInline(pieces[0] ?? `prompts ${session.prompt_count}, tools ${session.tool_event_count}`, 180);
18462
+ }
18463
+ function stripHandoffPrefix(value) {
18464
+ return value.replace(/^Handoff(?: Draft)?:\s*/i, "").replace(/\s+·\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}Z$/, "").trim();
18465
+ }
18466
+ function classifyFreshness(createdAtEpoch) {
18467
+ if (!createdAtEpoch)
18468
+ return "stale";
18469
+ const ageMs = Date.now() - createdAtEpoch * 1000;
18470
+ if (ageMs <= 15 * 60 * 1000)
18471
+ return "live";
18472
+ if (ageMs <= 3 * 24 * 60 * 60 * 1000)
18473
+ return "recent";
18474
+ return "stale";
18475
+ }
18476
+ function normalize(value) {
18477
+ return value.toLowerCase().replace(/\s+/g, " ").trim();
18478
+ }
18479
+ function truncateInline(text, maxLen) {
18480
+ if (text.length <= maxLen)
18481
+ return text;
18482
+ return `${text.slice(0, maxLen - 1).trimEnd()}…`;
18483
+ }
18484
+ function firstNonEmpty2(...values) {
18485
+ for (const value of values) {
18486
+ if (value && value.trim())
18487
+ return value.trim();
18488
+ }
18489
+ return null;
18490
+ }
18491
+ function previewFacts(facts) {
18492
+ if (!facts)
18493
+ return null;
18494
+ try {
18495
+ const parsed = JSON.parse(facts);
18496
+ if (!Array.isArray(parsed) || parsed.length === 0)
18497
+ return null;
18498
+ return parsed.filter((item) => typeof item === "string" && item.trim().length > 0).slice(0, 2).join(" | ");
18499
+ } catch {
18500
+ return facts;
18501
+ }
18502
+ }
18503
+ function looksLikeFileOperationTitle3(value) {
18504
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
18505
+ }
18506
+
18299
18507
  // src/tools/project-memory-index.ts
18300
18508
  function getProjectMemoryIndex(db, input) {
18301
18509
  const cwd = input.cwd ?? process.cwd();
@@ -18371,9 +18579,15 @@ function getProjectMemoryIndex(db, input) {
18371
18579
  limit: 20
18372
18580
  });
18373
18581
  const recentChatCount = recentChat.messages.length;
18582
+ const recallIndex = listRecallItems(db, {
18583
+ cwd,
18584
+ project_scoped: true,
18585
+ user_id: input.user_id,
18586
+ limit: 10
18587
+ });
18374
18588
  const latestSession = recentSessions[0] ?? null;
18375
18589
  const latestSummary = latestSession ? db.getSessionSummary(latestSession.session_id) : null;
18376
- const recentOutcomes = observations.filter((obs) => ["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type)).map((obs) => obs.title.trim()).filter((title) => title.length > 0 && !looksLikeFileOperationTitle3(title)).slice(0, 8);
18590
+ const recentOutcomes = observations.filter((obs) => ["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type)).map((obs) => obs.title.trim()).filter((title) => title.length > 0 && !looksLikeFileOperationTitle4(title)).slice(0, 8);
18377
18591
  const captureSummary = summarizeCaptureState(recentSessions);
18378
18592
  const topTypes = Object.entries(counts).map(([type, count]) => ({ type, count })).sort((a, b) => b.count - a.count || a.type.localeCompare(b.type)).slice(0, 5);
18379
18593
  const suggestedTools = buildSuggestedTools(recentSessions, recentRequestsCount, recentToolsCount, observations.length, recentChatCount, recentChat.coverage_state);
@@ -18388,11 +18602,23 @@ function getProjectMemoryIndex(db, input) {
18388
18602
  `));
18389
18603
  const continuityState = classifyContinuityState(recentRequestsCount, recentToolsCount, recentHandoffsCount.length, recentChatCount, recentSessions, recentOutcomes.length);
18390
18604
  const sourceTimestamp = pickResumeSourceTimestamp(latestSession, recentChat.messages);
18605
+ const bestRecallItem = pickBestRecallItem(recallIndex.items);
18391
18606
  return {
18392
18607
  project: project.name,
18393
18608
  canonical_id: project.canonical_id,
18394
18609
  continuity_state: continuityState,
18395
18610
  continuity_summary: describeContinuityState(continuityState),
18611
+ recall_mode: recallIndex.continuity_mode,
18612
+ recall_items_ready: recallIndex.items.length,
18613
+ recall_index_preview: recallIndex.items.slice(0, 3).map((item) => ({
18614
+ key: item.key,
18615
+ kind: item.kind,
18616
+ freshness: item.freshness,
18617
+ title: item.title
18618
+ })),
18619
+ best_recall_key: bestRecallItem?.key ?? null,
18620
+ best_recall_title: bestRecallItem?.title ?? null,
18621
+ best_recall_kind: bestRecallItem?.kind ?? null,
18396
18622
  resume_freshness: classifyResumeFreshness(sourceTimestamp),
18397
18623
  resume_source_session_id: latestSession?.session_id ?? null,
18398
18624
  resume_source_device_id: latestSession?.device_id ?? null,
@@ -18421,6 +18647,9 @@ function getProjectMemoryIndex(db, input) {
18421
18647
  suggested_tools: suggestedTools
18422
18648
  };
18423
18649
  }
18650
+ function pickBestRecallItem(items) {
18651
+ return items.find((item) => item.kind !== "memory") ?? items[0] ?? null;
18652
+ }
18424
18653
  function pickResumeSourceTimestamp(latestSession, messages) {
18425
18654
  const latestChatEpoch = messages.length > 0 ? messages[messages.length - 1]?.created_at_epoch ?? null : null;
18426
18655
  return latestChatEpoch ?? latestSession?.completed_at_epoch ?? latestSession?.started_at_epoch ?? null;
@@ -18475,7 +18704,7 @@ function extractPaths(value) {
18475
18704
  return [];
18476
18705
  }
18477
18706
  }
18478
- function looksLikeFileOperationTitle3(value) {
18707
+ function looksLikeFileOperationTitle4(value) {
18479
18708
  return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
18480
18709
  }
18481
18710
  function summarizeCaptureState(sessions) {
@@ -18512,6 +18741,8 @@ function buildSuggestedTools(sessions, requestCount, toolCount, observationCount
18512
18741
  suggested.push("activity_feed");
18513
18742
  }
18514
18743
  if (requestCount > 0 || recentChatCount > 0 || observationCount > 0) {
18744
+ suggested.push("list_recall_items");
18745
+ suggested.push("load_recall_item");
18515
18746
  suggested.push("resume_thread");
18516
18747
  suggested.push("search_recall");
18517
18748
  }
@@ -18577,6 +18808,12 @@ function getMemoryConsole(db, input) {
18577
18808
  user_id: input.user_id,
18578
18809
  limit: 6
18579
18810
  });
18811
+ const recallIndex = listRecallItems(db, {
18812
+ cwd,
18813
+ project_scoped: projectScoped,
18814
+ user_id: input.user_id,
18815
+ limit: 10
18816
+ });
18580
18817
  const projectIndex = projectScoped ? getProjectMemoryIndex(db, {
18581
18818
  cwd,
18582
18819
  user_id: input.user_id
@@ -18587,6 +18824,17 @@ function getMemoryConsole(db, input) {
18587
18824
  capture_mode: requests.length > 0 || tools.length > 0 ? "rich" : "observations-only",
18588
18825
  continuity_state: continuityState,
18589
18826
  continuity_summary: projectIndex?.continuity_summary ?? describeContinuityState(continuityState),
18827
+ recall_mode: projectIndex?.recall_mode ?? recallIndex.continuity_mode,
18828
+ recall_items_ready: projectIndex?.recall_items_ready ?? recallIndex.items.length,
18829
+ recall_index_preview: projectIndex?.recall_index_preview ?? recallIndex.items.slice(0, 3).map((item) => ({
18830
+ key: item.key,
18831
+ kind: item.kind,
18832
+ freshness: item.freshness,
18833
+ title: item.title
18834
+ })),
18835
+ best_recall_key: projectIndex?.best_recall_key ?? (recallIndex.items.find((item) => item.kind !== "memory") ?? recallIndex.items[0] ?? null)?.key ?? null,
18836
+ best_recall_title: projectIndex?.best_recall_title ?? (recallIndex.items.find((item) => item.kind !== "memory") ?? recallIndex.items[0] ?? null)?.title ?? null,
18837
+ best_recall_kind: projectIndex?.best_recall_kind ?? (recallIndex.items.find((item) => item.kind !== "memory") ?? recallIndex.items[0] ?? null)?.kind ?? null,
18590
18838
  resume_freshness: projectIndex?.resume_freshness ?? "stale",
18591
18839
  resume_source_session_id: projectIndex?.resume_source_session_id ?? sessions[0]?.session_id ?? null,
18592
18840
  resume_source_device_id: projectIndex?.resume_source_device_id ?? sessions[0]?.device_id ?? null,
@@ -18620,7 +18868,9 @@ function buildFallbackSuggestedTools(sessionCount, requestCount, toolCount, obse
18620
18868
  if (requestCount > 0 || toolCount > 0)
18621
18869
  suggested.push("activity_feed");
18622
18870
  if (requestCount > 0 || chatCount > 0 || observationCount > 0)
18623
- suggested.push("resume_thread", "search_recall");
18871
+ suggested.push("load_recall_item", "resume_thread", "search_recall");
18872
+ if (requestCount > 0 || chatCount > 0 || observationCount > 0)
18873
+ suggested.unshift("list_recall_items");
18624
18874
  if ((sessionCount > 0 || chatCount > 0) && chatCoverageState !== "transcript-backed")
18625
18875
  suggested.push("repair_recall");
18626
18876
  if (observationCount > 0)
@@ -19467,6 +19717,13 @@ function getSessionContext(db, input) {
19467
19717
  limit: 8
19468
19718
  });
19469
19719
  const recentChatMessages = recentChat.messages.length;
19720
+ const recallIndex = listRecallItems(db, {
19721
+ cwd,
19722
+ project_scoped: true,
19723
+ user_id: input.user_id,
19724
+ current_device_id: input.current_device_id,
19725
+ limit: 10
19726
+ });
19470
19727
  const latestSession = context.recentSessions?.[0] ?? null;
19471
19728
  const latestSummary = latestSession ? db.getSessionSummary(latestSession.session_id) : null;
19472
19729
  const captureState = recentRequests > 0 && recentTools > 0 ? "rich" : recentRequests > 0 || recentTools > 0 ? "partial" : "summary-only";
@@ -19474,11 +19731,23 @@ function getSessionContext(db, input) {
19474
19731
  const continuityState = classifyContinuityState(recentRequests, recentTools, recentHandoffs, recentChatMessages, context.recentSessions ?? [], (context.recentOutcomes ?? []).length);
19475
19732
  const latestChatEpoch = recentChat.messages.length > 0 ? recentChat.messages[recentChat.messages.length - 1]?.created_at_epoch ?? null : null;
19476
19733
  const resumeTimestamp = latestChatEpoch ?? latestSession?.completed_at_epoch ?? latestSession?.started_at_epoch ?? null;
19734
+ const bestRecallItem = recallIndex.items.find((item) => item.kind !== "memory") ?? recallIndex.items[0] ?? null;
19477
19735
  return {
19478
19736
  project_name: context.project_name,
19479
19737
  canonical_id: context.canonical_id,
19480
19738
  continuity_state: continuityState,
19481
19739
  continuity_summary: describeContinuityState(continuityState),
19740
+ recall_mode: recallIndex.continuity_mode,
19741
+ recall_items_ready: recallIndex.items.length,
19742
+ recall_index_preview: recallIndex.items.slice(0, 3).map((item) => ({
19743
+ key: item.key,
19744
+ kind: item.kind,
19745
+ freshness: item.freshness,
19746
+ title: item.title
19747
+ })),
19748
+ best_recall_key: bestRecallItem?.key ?? null,
19749
+ best_recall_title: bestRecallItem?.title ?? null,
19750
+ best_recall_kind: bestRecallItem?.kind ?? null,
19482
19751
  resume_freshness: classifyResumeFreshness(resumeTimestamp),
19483
19752
  resume_source_session_id: latestSession?.session_id ?? null,
19484
19753
  resume_source_device_id: latestSession?.device_id ?? null,
@@ -19541,6 +19810,8 @@ function buildSuggestedTools2(context, chatCoverageState) {
19541
19810
  tools.push("activity_feed");
19542
19811
  }
19543
19812
  if ((context.recentPrompts?.length ?? 0) > 0 || (context.recentChatMessages?.length ?? 0) > 0 || context.observations.length > 0) {
19813
+ tools.push("list_recall_items");
19814
+ tools.push("load_recall_item");
19544
19815
  tools.push("resume_thread");
19545
19816
  tools.push("search_recall");
19546
19817
  }
@@ -19565,6 +19836,139 @@ function buildSuggestedTools2(context, chatCoverageState) {
19565
19836
  return Array.from(new Set(tools)).slice(0, 5);
19566
19837
  }
19567
19838
 
19839
+ // src/tools/load-recall-item.ts
19840
+ function loadRecallItem(db, input) {
19841
+ const [kind, rawId] = input.key.split(":", 2);
19842
+ if (!kind || !rawId) {
19843
+ return {
19844
+ kind: "unknown",
19845
+ key: input.key,
19846
+ title: null,
19847
+ detail: "Malformed recall key",
19848
+ session_id: null,
19849
+ source_device_id: null,
19850
+ payload: null
19851
+ };
19852
+ }
19853
+ if (kind === "handoff") {
19854
+ const id = Number.parseInt(rawId, 10);
19855
+ const result = loadHandoff(db, {
19856
+ id,
19857
+ cwd: input.cwd,
19858
+ user_id: input.user_id,
19859
+ current_device_id: input.current_device_id
19860
+ });
19861
+ if (!result.handoff) {
19862
+ return missing(input.key, "handoff");
19863
+ }
19864
+ return {
19865
+ kind: "handoff",
19866
+ key: input.key,
19867
+ title: result.handoff.title,
19868
+ detail: summarizeNarrative(result.handoff.narrative),
19869
+ session_id: result.handoff.session_id ?? null,
19870
+ source_device_id: result.handoff.device_id ?? null,
19871
+ payload: {
19872
+ type: "handoff",
19873
+ handoff_id: result.handoff.id,
19874
+ narrative: result.handoff.narrative ?? null
19875
+ }
19876
+ };
19877
+ }
19878
+ if (kind === "session") {
19879
+ const story = getSessionStory(db, { session_id: rawId });
19880
+ if (!story.session) {
19881
+ return missing(input.key, "thread");
19882
+ }
19883
+ return {
19884
+ kind: "thread",
19885
+ key: input.key,
19886
+ title: story.summary?.current_thread ?? story.latest_request ?? story.summary?.completed ?? story.session.session_id,
19887
+ detail: story.summary?.next_steps ?? story.summary?.completed ?? null,
19888
+ session_id: story.session.session_id,
19889
+ source_device_id: story.session.device_id ?? null,
19890
+ payload: {
19891
+ type: "thread",
19892
+ latest_request: story.latest_request,
19893
+ current_thread: story.summary?.current_thread ?? null,
19894
+ recent_outcomes: story.recent_outcomes,
19895
+ hot_files: story.hot_files
19896
+ }
19897
+ };
19898
+ }
19899
+ if (kind === "chat") {
19900
+ const id = Number.parseInt(rawId, 10);
19901
+ const messages = getRecentChat(db, {
19902
+ cwd: input.cwd,
19903
+ project_scoped: false,
19904
+ user_id: input.user_id,
19905
+ limit: 200
19906
+ }).messages;
19907
+ const message = messages.find((item) => item.id === id);
19908
+ if (!message) {
19909
+ return missing(input.key, "chat");
19910
+ }
19911
+ const source = message.source_kind === "transcript" ? "transcript" : message.remote_source_id?.startsWith("history:") ? "history" : "hook";
19912
+ return {
19913
+ kind: "chat",
19914
+ key: input.key,
19915
+ title: `${message.role} [${source}]`,
19916
+ detail: message.content,
19917
+ session_id: message.session_id,
19918
+ source_device_id: message.device_id ?? null,
19919
+ payload: {
19920
+ type: "chat",
19921
+ role: message.role,
19922
+ content: message.content,
19923
+ source
19924
+ }
19925
+ };
19926
+ }
19927
+ if (kind === "obs") {
19928
+ const id = Number.parseInt(rawId, 10);
19929
+ const result = getObservations(db, {
19930
+ ids: [id],
19931
+ user_id: input.user_id
19932
+ });
19933
+ const obs = result.observations[0];
19934
+ if (!obs) {
19935
+ return missing(input.key, "memory");
19936
+ }
19937
+ return {
19938
+ kind: "memory",
19939
+ key: input.key,
19940
+ title: obs.title,
19941
+ detail: obs.narrative ?? obs.facts ?? null,
19942
+ session_id: obs.session_id ?? null,
19943
+ source_device_id: obs.device_id ?? null,
19944
+ payload: {
19945
+ type: "memory",
19946
+ observation_id: obs.id,
19947
+ observation_type: obs.type,
19948
+ narrative: obs.narrative ?? null,
19949
+ facts: obs.facts ?? null
19950
+ }
19951
+ };
19952
+ }
19953
+ return missing(input.key, "unknown");
19954
+ }
19955
+ function summarizeNarrative(value) {
19956
+ if (!value)
19957
+ return null;
19958
+ return value.split(/\n+/).map((line) => line.trim()).find(Boolean) ?? null;
19959
+ }
19960
+ function missing(key, kind) {
19961
+ return {
19962
+ kind,
19963
+ key,
19964
+ title: null,
19965
+ detail: "Recall item not found",
19966
+ session_id: null,
19967
+ source_device_id: null,
19968
+ payload: null
19969
+ };
19970
+ }
19971
+
19568
19972
  // src/tools/capture-git-worktree.ts
19569
19973
  import { execSync as execSync2 } from "node:child_process";
19570
19974
  import { existsSync as existsSync4 } from "node:fs";
@@ -20000,6 +20404,7 @@ async function resumeThread(db, config2, input = {}) {
20000
20404
  }
20001
20405
  }
20002
20406
  const { context, handoff, recentChat, recentSessions, recall } = snapshot;
20407
+ const bestRecallItem = pickBestRecallItem2(snapshot.recallIndex.items);
20003
20408
  const latestSession = recentSessions[0] ?? null;
20004
20409
  const latestSummary = latestSession ? db.getSessionSummary(latestSession.session_id) : null;
20005
20410
  const inferredRequest = latestSession?.request?.trim() || null;
@@ -20028,6 +20433,7 @@ async function resumeThread(db, config2, input = {}) {
20028
20433
  currentThread
20029
20434
  });
20030
20435
  const suggestedTools = Array.from(new Set([
20436
+ ...bestRecallItem ? ["load_recall_item"] : [],
20031
20437
  "search_recall",
20032
20438
  ...recentChat.coverage_state !== "transcript-backed" && recentChat.messages.length > 0 ? ["repair_recall", "refresh_chat_recall"] : [],
20033
20439
  ...handoff ? ["load_handoff"] : [],
@@ -20042,6 +20448,9 @@ async function resumeThread(db, config2, input = {}) {
20042
20448
  resume_source_device_id: handoff?.device_id ?? latestSession?.device_id ?? null,
20043
20449
  resume_confidence: resumeConfidence,
20044
20450
  resume_basis: resumeBasis,
20451
+ best_recall_key: bestRecallItem?.key ?? null,
20452
+ best_recall_title: bestRecallItem?.title ?? null,
20453
+ best_recall_kind: bestRecallItem?.kind ?? null,
20045
20454
  repair_attempted: shouldRepair,
20046
20455
  repair_result: repairResult ? {
20047
20456
  imported_chat_messages: repairResult.imported_chat_messages,
@@ -20100,14 +20509,25 @@ async function buildResumeSnapshot(db, cwd, userId, currentDeviceId, limit) {
20100
20509
  user_id: userId,
20101
20510
  limit
20102
20511
  });
20512
+ const recallIndex = listRecallItems(db, {
20513
+ cwd,
20514
+ project_scoped: true,
20515
+ user_id: userId,
20516
+ current_device_id: currentDeviceId,
20517
+ limit
20518
+ });
20103
20519
  return {
20104
20520
  context,
20105
20521
  handoff: handoffResult.handoff,
20106
20522
  recentChat,
20107
20523
  recentSessions,
20108
- recall
20524
+ recall,
20525
+ recallIndex
20109
20526
  };
20110
20527
  }
20528
+ function pickBestRecallItem2(items) {
20529
+ return items.find((item) => item.kind !== "memory") ?? items[0] ?? null;
20530
+ }
20111
20531
  function extractCurrentThread(handoff) {
20112
20532
  const narrative = handoff?.narrative ?? "";
20113
20533
  const match = narrative.match(/Current thread:\s*(.+)/i);
@@ -21613,7 +22033,7 @@ var REFACTOR_HINTS = [
21613
22033
  "extract",
21614
22034
  "move"
21615
22035
  ];
21616
- function normalize(text) {
22036
+ function normalize2(text) {
21617
22037
  return text.toLowerCase().replace(/\s+/g, " ").trim();
21618
22038
  }
21619
22039
  function uniq(items) {
@@ -21642,7 +22062,7 @@ function countLinesByPrefix(diff, prefix) {
21642
22062
  `).filter((line) => line.startsWith(prefix) && !line.startsWith(`${prefix}${prefix}${prefix}`)).length;
21643
22063
  }
21644
22064
  function detectType(summary, diff, files) {
21645
- const corpus = normalize([summary ?? "", diff, files.join(" ")].join(" "));
22065
+ const corpus = normalize2([summary ?? "", diff, files.join(" ")].join(" "));
21646
22066
  if (BUGFIX_HINTS.some((hint) => corpus.includes(hint)))
21647
22067
  return "bugfix";
21648
22068
  if (DECISION_HINTS.some((hint) => corpus.includes(hint)))
@@ -21702,7 +22122,7 @@ function buildFacts(diff, files) {
21702
22122
  if (added > 0 || removed > 0) {
21703
22123
  facts.push(`Diff footprint: +${added} / -${removed}`);
21704
22124
  }
21705
- const lower = normalize(diff);
22125
+ const lower = normalize2(diff);
21706
22126
  if (lower.includes("auth") || lower.includes("token") || lower.includes("oauth")) {
21707
22127
  facts.push("Touches authentication or credential flow");
21708
22128
  }
@@ -22039,7 +22459,7 @@ process.on("SIGTERM", () => {
22039
22459
  });
22040
22460
  var server = new McpServer({
22041
22461
  name: "engrm",
22042
- version: "0.4.31"
22462
+ version: "0.4.33"
22043
22463
  });
22044
22464
  server.tool("save_observation", "Save an observation to memory", {
22045
22465
  type: exports_external.enum([
@@ -22484,7 +22904,138 @@ ${rows}`
22484
22904
  ]
22485
22905
  };
22486
22906
  });
22487
- server.tool("resume_thread", "Build a clear resume point for the current project by combining handoff, live recall, current thread, and recent chat continuity.", {
22907
+ server.tool("list_recall_items", "USE FIRST when continuity feels fuzzy. List the best current handoffs, session threads, chat snippets, and memory entries before opening one exact item.", {
22908
+ cwd: exports_external.string().optional().describe("Optional cwd override for project-scoped recall"),
22909
+ project_scoped: exports_external.boolean().optional().describe("Scope to project (default: true)"),
22910
+ user_id: exports_external.string().optional().describe("Optional user override"),
22911
+ limit: exports_external.number().optional().describe("Max recall items to list")
22912
+ }, async (params) => {
22913
+ const result = listRecallItems(db, {
22914
+ cwd: params.cwd ?? process.cwd(),
22915
+ project_scoped: params.project_scoped,
22916
+ user_id: params.user_id ?? config2.user_id,
22917
+ current_device_id: config2.device_id,
22918
+ limit: params.limit
22919
+ });
22920
+ if (result.items.length === 0) {
22921
+ return {
22922
+ content: [
22923
+ {
22924
+ type: "text",
22925
+ text: result.project ? `No recall items found yet for project ${result.project}` : "No recall items found yet."
22926
+ }
22927
+ ]
22928
+ };
22929
+ }
22930
+ const projectLine = result.project ? `Project: ${result.project}
22931
+ ` : "";
22932
+ const rows = result.items.map((item) => `- ${item.key} [${item.kind} · ${item.freshness}] ${item.title}${item.source_device_id ? ` (${item.source_device_id})` : ""}
22933
+ ${item.detail}`).join(`
22934
+ `);
22935
+ return {
22936
+ content: [
22937
+ {
22938
+ type: "text",
22939
+ text: `${projectLine}Recall index (${result.continuity_mode} mode):
22940
+ ` + `${rows}
22941
+
22942
+ ` + `Suggested next step: use load_handoff for handoff:* items, get_observations([...]) for obs:* items, or resume_thread when you want one merged resume point.`
22943
+ }
22944
+ ]
22945
+ };
22946
+ });
22947
+ server.tool("load_recall_item", "USE AFTER list_recall_items. Load one exact recall item key so you can inspect a specific handoff, thread, chat message, or memory entry without fuzzy recall guessing.", {
22948
+ key: exports_external.string().describe("Exact recall key from list_recall_items, such as handoff:12, session:sess-1, chat:55, or obs:402"),
22949
+ cwd: exports_external.string().optional().describe("Optional cwd override"),
22950
+ user_id: exports_external.string().optional().describe("Optional user override")
22951
+ }, async (params) => {
22952
+ const result = loadRecallItem(db, {
22953
+ key: params.key,
22954
+ cwd: params.cwd ?? process.cwd(),
22955
+ user_id: params.user_id ?? config2.user_id,
22956
+ current_device_id: config2.device_id
22957
+ });
22958
+ if (!result.payload) {
22959
+ return {
22960
+ content: [
22961
+ {
22962
+ type: "text",
22963
+ text: `No recall item found for ${params.key}`
22964
+ }
22965
+ ]
22966
+ };
22967
+ }
22968
+ if (result.payload.type === "handoff") {
22969
+ return {
22970
+ content: [
22971
+ {
22972
+ type: "text",
22973
+ text: `Recall item ${result.key} [handoff]
22974
+ ` + `Title: ${result.title}
22975
+ ` + `Session: ${result.session_id ?? "(unknown)"}
22976
+ ` + `Source: ${result.source_device_id ?? "(unknown)"}
22977
+
22978
+ ` + `${result.payload.narrative ?? "(no narrative)"}`
22979
+ }
22980
+ ]
22981
+ };
22982
+ }
22983
+ if (result.payload.type === "thread") {
22984
+ const outcomes = result.payload.recent_outcomes.length > 0 ? result.payload.recent_outcomes.map((item) => `- ${item}`).join(`
22985
+ `) : "- (none)";
22986
+ const hotFiles = result.payload.hot_files.length > 0 ? result.payload.hot_files.map((item) => `- ${item.path}${item.count > 1 ? ` (${item.count})` : ""}`).join(`
22987
+ `) : "- (none)";
22988
+ return {
22989
+ content: [
22990
+ {
22991
+ type: "text",
22992
+ text: `Recall item ${result.key} [thread]
22993
+ ` + `Title: ${result.title}
22994
+ ` + `Session: ${result.session_id ?? "(unknown)"}
22995
+ ` + `Source: ${result.source_device_id ?? "(unknown)"}
22996
+ ` + `Latest request: ${result.payload.latest_request ?? "(none)"}
22997
+ ` + `Current thread: ${result.payload.current_thread ?? "(none)"}
22998
+
22999
+ ` + `Recent outcomes:
23000
+ ${outcomes}
23001
+
23002
+ ` + `Hot files:
23003
+ ${hotFiles}`
23004
+ }
23005
+ ]
23006
+ };
23007
+ }
23008
+ if (result.payload.type === "chat") {
23009
+ return {
23010
+ content: [
23011
+ {
23012
+ type: "text",
23013
+ text: `Recall item ${result.key} [chat]
23014
+ ` + `Title: ${result.title}
23015
+ ` + `Session: ${result.session_id ?? "(unknown)"}
23016
+ ` + `Source: ${result.source_device_id ?? "(unknown)"}
23017
+
23018
+ ` + `${result.payload.content}`
23019
+ }
23020
+ ]
23021
+ };
23022
+ }
23023
+ return {
23024
+ content: [
23025
+ {
23026
+ type: "text",
23027
+ text: `Recall item ${result.key} [memory]
23028
+ ` + `Title: ${result.title}
23029
+ ` + `Session: ${result.session_id ?? "(unknown)"}
23030
+ ` + `Source: ${result.source_device_id ?? "(unknown)"}
23031
+ ` + `Type: ${result.payload.observation_type}
23032
+
23033
+ ` + `${result.payload.narrative ?? result.payload.facts ?? "(no detail)"}`
23034
+ }
23035
+ ]
23036
+ };
23037
+ });
23038
+ server.tool("resume_thread", "USE FIRST when you want one direct 'where were we?' answer. Build a clear resume point for the current project by combining handoff, live recall, current thread, and recent chat continuity.", {
22488
23039
  cwd: exports_external.string().optional().describe("Optional cwd override for the project to resume"),
22489
23040
  limit: exports_external.number().optional().describe("Max recall hits/chat snippets to include"),
22490
23041
  user_id: exports_external.string().optional().describe("Optional user override"),
@@ -22502,6 +23053,8 @@ server.tool("resume_thread", "Build a clear resume point for the current project
22502
23053
  const handoffLine = result.handoff ? `Handoff: #${result.handoff.id} ${result.handoff.title}${result.handoff.source ? ` (${result.handoff.source})` : ""}
22503
23054
  ` : `Handoff: (none)
22504
23055
  `;
23056
+ const openExactLine = result.best_recall_key ? `Open exact: load_recall_item("${result.best_recall_key}")${result.best_recall_title ? ` # ${result.best_recall_title}` : ""}
23057
+ ` : "";
22505
23058
  const basisLines = result.resume_basis.length > 0 ? result.resume_basis.map((item) => `- ${item}`).join(`
22506
23059
  `) : "- (none)";
22507
23060
  const toolTrailLines = result.tool_trail.length > 0 ? result.tool_trail.map((item) => `- ${item}`).join(`
@@ -22536,7 +23089,7 @@ server.tool("resume_thread", "Build a clear resume point for the current project
22536
23089
  ` + `Freshness: ${result.resume_freshness}
22537
23090
  ` + `Source: ${result.resume_source_session_id ?? "(unknown session)"}${result.resume_source_device_id ? ` (${result.resume_source_device_id})` : ""}
22538
23091
  ` + `Resume confidence: ${result.resume_confidence}
22539
- ` + repairLine + `Current thread: ${result.current_thread ?? "(unknown)"}
23092
+ ` + repairLine + openExactLine + `Current thread: ${result.current_thread ?? "(unknown)"}
22540
23093
  ` + `Latest request: ${result.latest_request ?? "(none)"}
22541
23094
  ` + `${handoffLine}` + `Chat recall: ${result.chat_coverage_state}
22542
23095
  ` + `Suggested tools: ${result.suggested_tools.join(", ") || "(none)"}
@@ -22872,6 +23425,10 @@ server.tool("memory_console", "Show a high-signal local overview of what Engrm c
22872
23425
  `) : "- (none)";
22873
23426
  const topTypes = result.top_types.length > 0 ? result.top_types.map((item) => `- ${item.type}: ${item.count}`).join(`
22874
23427
  `) : "- (none)";
23428
+ const recallPreviewLines = result.recall_index_preview.length > 0 ? result.recall_index_preview.map((item) => `- ${item.key} [${item.kind} · ${item.freshness}] ${item.title}`).join(`
23429
+ `) : "- (none)";
23430
+ const openExactLine = result.best_recall_key ? `Open exact: load_recall_item("${result.best_recall_key}")${result.best_recall_title ? ` # ${result.best_recall_title}` : ""}
23431
+ ` : "";
22875
23432
  const projectLine = result.project ? `Project: ${result.project}
22876
23433
 
22877
23434
  ` : "";
@@ -22885,6 +23442,7 @@ server.tool("memory_console", "Show a high-signal local overview of what Engrm c
22885
23442
  {
22886
23443
  type: "text",
22887
23444
  text: `${projectLine}` + `${captureLine}` + `Continuity: ${result.continuity_state} — ${result.continuity_summary}
23445
+ ` + `Recall index: ${result.recall_mode} · ${result.recall_items_ready} items ready
22888
23446
  ` + `Resume readiness: ${result.resume_freshness} · ${result.resume_source_session_id ?? "(unknown session)"}${result.resume_source_device_id ? ` (${result.resume_source_device_id})` : ""}
22889
23447
  ` + `Chat recall: ${result.chat_coverage_state} · ${result.recent_chat.length} messages across ${result.recent_chat_sessions} sessions (transcript ${result.chat_source_summary.transcript}, history ${result.chat_source_summary.history}, hook ${result.chat_source_summary.hook})
22890
23448
  ` + `${typeof result.assistant_checkpoint_count === "number" ? `Assistant checkpoints: ${result.assistant_checkpoint_count}
@@ -22892,6 +23450,9 @@ server.tool("memory_console", "Show a high-signal local overview of what Engrm c
22892
23450
  ` + `${typeof result.estimated_read_tokens === "number" ? `Estimated read cost: ~${result.estimated_read_tokens}t
22893
23451
  ` : ""}` + `Suggested tools: ${result.suggested_tools.join(", ") || "(none)"}
22894
23452
 
23453
+ ` + openExactLine + `Recall preview:
23454
+ ${recallPreviewLines}
23455
+
22895
23456
  ` + `Next actions:
22896
23457
  ${result.resume_next_actions.length > 0 ? result.resume_next_actions.map((item) => `- ${item}`).join(`
22897
23458
  `) : "- (none)"}
@@ -23089,6 +23650,8 @@ server.tool("session_context", "Preview the exact project memory context Engrm w
23089
23650
  text: `Project: ${result.project_name}
23090
23651
  ` + `Canonical ID: ${result.canonical_id}
23091
23652
  ` + `Continuity: ${result.continuity_state} — ${result.continuity_summary}
23653
+ ` + `Recall index: ${result.recall_mode} · ${result.recall_items_ready} items ready
23654
+ ` + `Open exact: ${result.best_recall_key ? `load_recall_item("${result.best_recall_key}")` : "(none)"}
23092
23655
  ` + `Resume readiness: ${result.resume_freshness} · ${result.resume_source_session_id ?? "(unknown session)"}${result.resume_source_device_id ? ` (${result.resume_source_device_id})` : ""}
23093
23656
  ` + `Loaded observations: ${result.session_count}
23094
23657
  ` + `Searchable total: ${result.total_active}
@@ -23101,6 +23664,7 @@ server.tool("session_context", "Preview the exact project memory context Engrm w
23101
23664
  ` + `Chat recall: ${result.chat_coverage_state} · ${result.recent_chat_sessions} sessions (transcript ${result.chat_source_summary.transcript}, history ${result.chat_source_summary.history}, hook ${result.chat_source_summary.hook})
23102
23665
  ` + `Latest handoff: ${result.latest_handoff_title ?? "(none)"}
23103
23666
  ` + `Next actions: ${result.resume_next_actions.length > 0 ? result.resume_next_actions.join(" | ") : "(none)"}
23667
+ ` + `Recall preview: ${result.recall_index_preview.length > 0 ? result.recall_index_preview.map((item) => item.key).join(", ") : "(none)"}
23104
23668
  ` + `Raw chronology active: ${result.raw_capture_active ? "yes" : "no"}
23105
23669
 
23106
23670
  ` + result.preview
@@ -23166,6 +23730,10 @@ server.tool("project_memory_index", "Show a typed local memory index for the cur
23166
23730
  `) : "- (none)";
23167
23731
  const topTitles = result.top_titles.length > 0 ? result.top_titles.map((item) => `- #${item.id} [${item.type}] ${item.title}`).join(`
23168
23732
  `) : "- (none)";
23733
+ const recallPreviewLines = result.recall_index_preview.length > 0 ? result.recall_index_preview.map((item) => `- ${item.key} [${item.kind} · ${item.freshness}] ${item.title}`).join(`
23734
+ `) : "- (none)";
23735
+ const openExactLine = result.best_recall_key ? `Open exact: load_recall_item("${result.best_recall_key}")${result.best_recall_title ? ` # ${result.best_recall_title}` : ""}
23736
+ ` : "";
23169
23737
  return {
23170
23738
  content: [
23171
23739
  {
@@ -23173,6 +23741,7 @@ server.tool("project_memory_index", "Show a typed local memory index for the cur
23173
23741
  text: `Project: ${result.project}
23174
23742
  ` + `Canonical ID: ${result.canonical_id}
23175
23743
  ` + `Continuity: ${result.continuity_state} — ${result.continuity_summary}
23744
+ ` + `Recall index: ${result.recall_mode} · ${result.recall_items_ready} items ready
23176
23745
  ` + `Resume readiness: ${result.resume_freshness} · ${result.resume_source_session_id ?? "(unknown session)"}${result.resume_source_device_id ? ` (${result.resume_source_device_id})` : ""}
23177
23746
  ` + `Recent requests captured: ${result.recent_requests_count}
23178
23747
  ` + `Recent tools captured: ${result.recent_tools_count}
@@ -23188,6 +23757,9 @@ server.tool("project_memory_index", "Show a typed local memory index for the cur
23188
23757
  ` + `Estimated read cost: ~${result.estimated_read_tokens}t
23189
23758
  ` + `Suggested tools: ${result.suggested_tools.join(", ") || "(none)"}
23190
23759
 
23760
+ ` + openExactLine + `Recall preview:
23761
+ ${recallPreviewLines}
23762
+
23191
23763
  ` + `Next actions:
23192
23764
  ${result.resume_next_actions.length > 0 ? result.resume_next_actions.map((item) => `- ${item}`).join(`
23193
23765
  `) : "- (none)"}
@@ -23368,7 +23940,7 @@ Transcript messages seen: ${result.total}`
23368
23940
  ]
23369
23941
  };
23370
23942
  });
23371
- server.tool("repair_recall", "Rehydrate recent session recall for the current project from Claude transcripts or history fallback when chat feels missing or under-captured", {
23943
+ server.tool("repair_recall", "USE WHEN recall feels thin or under-captured. Rehydrate recent session recall for the current project from Claude transcripts or history fallback before resuming.", {
23372
23944
  session_id: exports_external.string().optional().describe("Optional single session ID to repair instead of scanning recent project sessions"),
23373
23945
  cwd: exports_external.string().optional().describe("Project directory used to resolve project sessions and Claude history/transcript files"),
23374
23946
  limit: exports_external.number().optional().describe("How many recent sessions to inspect when repairing a project"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engrm",
3
- "version": "0.4.31",
3
+ "version": "0.4.33",
4
4
  "description": "Shared memory across devices, sessions, and agents, with thin MCP tools for durable capture and live continuity",
5
5
  "mcpName": "io.github.dr12hes/engrm",
6
6
  "type": "module",