engrm 0.4.28 → 0.4.30

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,6 +226,8 @@ 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
231
  | `recent_chat` | Inspect the separate synced chat lane without mixing it into durable memory |
230
232
  | `search_chat` | Search recent chat recall with hybrid lexical + semantic matching, separately from reusable memory observations |
231
233
  | `search_recall` | Search durable memory and chat recall together when you do not want to guess the right lane |
@@ -328,6 +330,17 @@ For long sessions, Engrm now also supports transcript-backed chat hydration:
328
330
  - fills gaps in the separate chat lane with transcript-backed messages
329
331
  - keeps those rows marked separately from hook-edge chat so recall can prefer the fuller thread
330
332
 
333
+ - `repair_recall`
334
+ - scans recent sessions for the current project
335
+ - rehydrates recall from transcript files when they exist
336
+ - falls back to Claude `history.jsonl` when transcript/session alignment is missing
337
+ - reports whether recovered chat is `transcript-backed`, `history-backed`, or still only `hook-only`
338
+
339
+ - `resume_thread`
340
+ - gives OpenClaw or Claude one direct “where were we?” action
341
+ - combines the best handoff, the current thread, recent outcomes, recent chat, and unified recall
342
+ - makes Engrm usable as the primary live continuity layer instead of forcing agents to choose between low-level recall tools
343
+
331
344
  Before Claude compacts, Engrm now also:
332
345
 
333
346
  - refreshes transcript-backed chat recall for the active session
@@ -358,10 +371,11 @@ Recommended flow:
358
371
  What each tool is good for:
359
372
 
360
373
  - `capture_status` tells you whether prompt/tool hooks are live on this machine
361
- - `capture_quality` shows whether chat recall is transcript-backed or still hook-only across the workspace
374
+ - `capture_quality` shows whether chat recall is transcript-backed, history-backed, or still hook-only across the workspace
362
375
  - `memory_console` gives the quickest project snapshot, including whether continuity is `fresh`, `thin`, or `cold`
363
- - `memory_console`, `project_memory_index`, and `session_context` now also show whether project chat recall is transcript-backed or only hook-captured
364
- - when chat continuity is only hook-captured, the workbench and startup hints now prefer `refresh_chat_recall`
376
+ - `memory_console`, `project_memory_index`, and `session_context` now also show whether project chat recall is transcript-backed, history-backed, or only hook-captured
377
+ - 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
378
+ - `resume_thread` is now the fastest “get me back into the live thread” path when you do not want to think about which continuity lane to inspect
365
379
  - the workbench and startup hints now also prefer `search_recall` as the first “what were we just talking about?” path when recent prompts/chat/observations exist
366
380
  - `search_chat` now uses hybrid lexical + semantic ranking when sqlite-vec and local embeddings are available, so recent conversation recall is less dependent on exact wording
367
381
  - `activity_feed` shows the merged chronology across prompts, tools, chat, handoffs, observations, and summaries
@@ -371,7 +385,7 @@ What each tool is good for:
371
385
  - `session_tool_memory` shows which tool calls in one session turned into reusable memory and which did not
372
386
  - `project_memory_index` shows typed memory by repo, including continuity state and hot files
373
387
  - `workspace_memory_index` shows coverage across all repos on the machine
374
- - `recent_chat` / `search_chat` now report transcript-vs-hook coverage too, and `search_chat` will also mark when semantic ranking was available, so weak OpenClaw recall is easier to diagnose and refresh
388
+ - `recent_chat` / `search_chat` now report transcript-vs-history-vs-hook coverage too, and `search_chat` will also mark when semantic ranking was available, so weak OpenClaw recall is easier to diagnose and repair
375
389
 
376
390
  ### Thin Tool Workflow
377
391
 
@@ -3721,6 +3721,7 @@ function parseJsonArray(value) {
3721
3721
  }
3722
3722
 
3723
3723
  // src/capture/transcript.ts
3724
+ import { createHash as createHash3 } from "node:crypto";
3724
3725
  import { readFileSync as readFileSync4, existsSync as existsSync4 } from "node:fs";
3725
3726
  import { join as join4 } from "node:path";
3726
3727
  import { homedir as homedir3 } from "node:os";
@@ -3774,23 +3775,109 @@ function readTranscript(sessionId, cwd, transcriptPath) {
3774
3775
  }
3775
3776
  return messages;
3776
3777
  }
3778
+ function resolveHistoryPath(historyPath) {
3779
+ if (historyPath)
3780
+ return historyPath;
3781
+ const override = process.env["ENGRM_CLAUDE_HISTORY_PATH"];
3782
+ if (override)
3783
+ return override;
3784
+ return join4(homedir3(), ".claude", "history.jsonl");
3785
+ }
3786
+ function readHistoryFallback(sessionId, cwd, opts) {
3787
+ const path = resolveHistoryPath(opts?.historyPath);
3788
+ if (!existsSync4(path))
3789
+ return [];
3790
+ let raw;
3791
+ try {
3792
+ raw = readFileSync4(path, "utf-8");
3793
+ } catch {
3794
+ return [];
3795
+ }
3796
+ const targetCanonical = detectProject(cwd).canonical_id;
3797
+ const windowStart = Math.max(0, (opts?.startedAtEpoch ?? Math.floor(Date.now() / 1000) - 6 * 3600) - 600);
3798
+ const windowEnd = (opts?.completedAtEpoch ?? Math.floor(Date.now() / 1000)) + 600;
3799
+ const entries = [];
3800
+ for (const line of raw.split(`
3801
+ `)) {
3802
+ if (!line.trim())
3803
+ continue;
3804
+ let entry;
3805
+ try {
3806
+ entry = JSON.parse(line);
3807
+ } catch {
3808
+ continue;
3809
+ }
3810
+ if (typeof entry?.display !== "string" || typeof entry?.timestamp !== "number")
3811
+ continue;
3812
+ const createdAtEpoch = Math.floor(entry.timestamp / 1000);
3813
+ entries.push({
3814
+ display: entry.display.trim(),
3815
+ project: typeof entry.project === "string" ? entry.project : "",
3816
+ sessionId: typeof entry.sessionId === "string" ? entry.sessionId : "",
3817
+ timestamp: createdAtEpoch
3818
+ });
3819
+ }
3820
+ const bySession = entries.filter((entry) => entry.display.length > 0 && entry.sessionId === sessionId).sort((a, b) => a.timestamp - b.timestamp);
3821
+ if (bySession.length > 0) {
3822
+ return dedupeHistoryMessages(bySession.map((entry) => ({
3823
+ role: "user",
3824
+ text: entry.display,
3825
+ createdAtEpoch: entry.timestamp
3826
+ })));
3827
+ }
3828
+ const byProjectAndWindow = entries.filter((entry) => {
3829
+ if (entry.display.length === 0)
3830
+ return false;
3831
+ if (entry.timestamp < windowStart || entry.timestamp > windowEnd)
3832
+ return false;
3833
+ if (!entry.project)
3834
+ return false;
3835
+ return detectProject(entry.project).canonical_id === targetCanonical;
3836
+ }).sort((a, b) => a.timestamp - b.timestamp);
3837
+ return dedupeHistoryMessages(byProjectAndWindow.map((entry) => ({
3838
+ role: "user",
3839
+ text: entry.display,
3840
+ createdAtEpoch: entry.timestamp
3841
+ })));
3842
+ }
3777
3843
  async function syncTranscriptChat(db, config, sessionId, cwd, transcriptPath) {
3778
- const messages = readTranscript(sessionId, cwd, transcriptPath).map((message) => ({
3844
+ const session = db.getSessionById(sessionId);
3845
+ const transcriptMessages = readTranscript(sessionId, cwd, transcriptPath).map((message) => ({
3779
3846
  ...message,
3780
3847
  text: message.text.trim()
3781
3848
  })).filter((message) => message.text.length > 0);
3849
+ const messages = transcriptMessages.length > 0 ? transcriptMessages.map((message, index) => ({
3850
+ ...message,
3851
+ sourceKind: "transcript",
3852
+ transcriptIndex: index + 1,
3853
+ createdAtEpoch: null,
3854
+ remoteSourceId: null
3855
+ })) : readHistoryFallback(sessionId, cwd, {
3856
+ startedAtEpoch: session?.started_at_epoch ?? null,
3857
+ completedAtEpoch: session?.completed_at_epoch ?? null
3858
+ }).map((message) => ({
3859
+ role: message.role,
3860
+ text: message.text,
3861
+ sourceKind: "hook",
3862
+ transcriptIndex: null,
3863
+ createdAtEpoch: message.createdAtEpoch,
3864
+ remoteSourceId: buildHistorySourceId(sessionId, message.createdAtEpoch, message.text)
3865
+ }));
3782
3866
  if (messages.length === 0)
3783
3867
  return { imported: 0, total: 0 };
3784
- const session = db.getSessionById(sessionId);
3785
3868
  const projectId = session?.project_id ?? null;
3786
3869
  const now = Math.floor(Date.now() / 1000);
3787
3870
  let imported = 0;
3788
3871
  for (let index = 0;index < messages.length; index++) {
3789
- const transcriptIndex = index + 1;
3790
- if (db.getTranscriptChatMessage(sessionId, transcriptIndex))
3791
- continue;
3792
3872
  const message = messages[index];
3793
- const createdAtEpoch = Math.max(0, now - (messages.length - transcriptIndex));
3873
+ const transcriptIndex = message.transcriptIndex ?? index + 1;
3874
+ if (message.sourceKind === "transcript" && db.getTranscriptChatMessage(sessionId, transcriptIndex)) {
3875
+ continue;
3876
+ }
3877
+ if (message.remoteSourceId && db.getChatMessageByRemoteSourceId(message.remoteSourceId)) {
3878
+ continue;
3879
+ }
3880
+ const createdAtEpoch = message.createdAtEpoch ?? Math.max(0, now - (messages.length - transcriptIndex));
3794
3881
  const row = db.insertChatMessage({
3795
3882
  session_id: sessionId,
3796
3883
  project_id: projectId,
@@ -3800,10 +3887,23 @@ async function syncTranscriptChat(db, config, sessionId, cwd, transcriptPath) {
3800
3887
  device_id: config.device_id,
3801
3888
  agent: "claude-code",
3802
3889
  created_at_epoch: createdAtEpoch,
3803
- source_kind: "transcript",
3804
- transcript_index: transcriptIndex
3890
+ remote_source_id: message.remoteSourceId,
3891
+ source_kind: message.sourceKind,
3892
+ transcript_index: message.transcriptIndex
3805
3893
  });
3806
3894
  db.addToOutbox("chat_message", row.id);
3895
+ if (message.role === "user") {
3896
+ db.insertUserPrompt({
3897
+ session_id: sessionId,
3898
+ project_id: projectId,
3899
+ prompt: message.text,
3900
+ cwd,
3901
+ user_id: config.user_id,
3902
+ device_id: config.device_id,
3903
+ agent: "claude-code",
3904
+ created_at_epoch: createdAtEpoch
3905
+ });
3906
+ }
3807
3907
  if (db.vecAvailable) {
3808
3908
  const embedding = await embedText(composeChatEmbeddingText(message.text));
3809
3909
  if (embedding) {
@@ -3814,6 +3914,23 @@ async function syncTranscriptChat(db, config, sessionId, cwd, transcriptPath) {
3814
3914
  }
3815
3915
  return { imported, total: messages.length };
3816
3916
  }
3917
+ function dedupeHistoryMessages(messages) {
3918
+ const deduped = [];
3919
+ for (const message of messages) {
3920
+ const compact = message.text.replace(/\s+/g, " ").trim();
3921
+ if (!compact)
3922
+ continue;
3923
+ const previous = deduped[deduped.length - 1];
3924
+ if (previous && previous.text.replace(/\s+/g, " ").trim() === compact)
3925
+ continue;
3926
+ deduped.push({ ...message, text: compact });
3927
+ }
3928
+ return deduped;
3929
+ }
3930
+ function buildHistorySourceId(sessionId, createdAtEpoch, text) {
3931
+ const digest = createHash3("sha1").update(text).digest("hex").slice(0, 12);
3932
+ return `history:${sessionId}:${createdAtEpoch}:${digest}`;
3933
+ }
3817
3934
  function truncateTranscript(messages, maxBytes = 50000) {
3818
3935
  const lines = [];
3819
3936
  for (const msg of messages) {
@@ -3889,6 +4006,32 @@ async function saveTranscriptResults(db, config, results, sessionId, cwd) {
3889
4006
  return saved;
3890
4007
  }
3891
4008
 
4009
+ // src/tools/recent-chat.ts
4010
+ function summarizeChatSources(messages) {
4011
+ return messages.reduce((summary, message) => {
4012
+ summary[getChatCaptureOrigin(message)] += 1;
4013
+ return summary;
4014
+ }, { transcript: 0, history: 0, hook: 0 });
4015
+ }
4016
+ function getChatCoverageState(messagesOrSummary) {
4017
+ const summary = Array.isArray(messagesOrSummary) ? summarizeChatSources(messagesOrSummary) : messagesOrSummary;
4018
+ if (summary.transcript > 0)
4019
+ return "transcript-backed";
4020
+ if (summary.history > 0)
4021
+ return "history-backed";
4022
+ if (summary.hook > 0)
4023
+ return "hook-only";
4024
+ return "none";
4025
+ }
4026
+ function getChatCaptureOrigin(message) {
4027
+ if (message.source_kind === "transcript")
4028
+ return "transcript";
4029
+ if (typeof message.remote_source_id === "string" && message.remote_source_id.startsWith("history:")) {
4030
+ return "history";
4031
+ }
4032
+ return "hook";
4033
+ }
4034
+
3892
4035
  // src/tools/session-story.ts
3893
4036
  function getSessionStory(db, input) {
3894
4037
  const session = db.getSessionById(input.session_id);
@@ -3911,7 +4054,7 @@ function getSessionStory(db, input) {
3911
4054
  prompts,
3912
4055
  chat_messages: chatMessages,
3913
4056
  chat_source_summary: summarizeChatSources(chatMessages),
3914
- chat_coverage_state: chatMessages.some((message) => message.source_kind === "transcript") ? "transcript-backed" : chatMessages.length > 0 ? "hook-only" : "none",
4057
+ chat_coverage_state: getChatCoverageState(chatMessages),
3915
4058
  tool_events: toolEvents,
3916
4059
  observations,
3917
4060
  handoffs,
@@ -4009,12 +4152,6 @@ function collectProvenanceSummary(observations) {
4009
4152
  }
4010
4153
  return Array.from(counts.entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
4011
4154
  }
4012
- function summarizeChatSources(messages) {
4013
- return messages.reduce((summary, message) => {
4014
- summary[message.source_kind] += 1;
4015
- return summary;
4016
- }, { transcript: 0, hook: 0 });
4017
- }
4018
4155
 
4019
4156
  // src/tools/handoffs.ts
4020
4157
  async function upsertRollingHandoff(db, config, input) {
@@ -2203,6 +2203,32 @@ function computeObservationPriority(obs, nowEpoch) {
2203
2203
  return computeBlendedScore(obs.quality, obs.created_at_epoch, nowEpoch) + observationTypeBoost(obs.type);
2204
2204
  }
2205
2205
 
2206
+ // src/tools/recent-chat.ts
2207
+ function summarizeChatSources(messages) {
2208
+ return messages.reduce((summary, message) => {
2209
+ summary[getChatCaptureOrigin(message)] += 1;
2210
+ return summary;
2211
+ }, { transcript: 0, history: 0, hook: 0 });
2212
+ }
2213
+ function getChatCoverageState(messagesOrSummary) {
2214
+ const summary = Array.isArray(messagesOrSummary) ? summarizeChatSources(messagesOrSummary) : messagesOrSummary;
2215
+ if (summary.transcript > 0)
2216
+ return "transcript-backed";
2217
+ if (summary.history > 0)
2218
+ return "history-backed";
2219
+ if (summary.hook > 0)
2220
+ return "hook-only";
2221
+ return "none";
2222
+ }
2223
+ function getChatCaptureOrigin(message) {
2224
+ if (message.source_kind === "transcript")
2225
+ return "transcript";
2226
+ if (typeof message.remote_source_id === "string" && message.remote_source_id.startsWith("history:")) {
2227
+ return "history";
2228
+ }
2229
+ return "hook";
2230
+ }
2231
+
2206
2232
  // src/tools/session-story.ts
2207
2233
  function getSessionStory(db, input) {
2208
2234
  const session = db.getSessionById(input.session_id);
@@ -2225,7 +2251,7 @@ function getSessionStory(db, input) {
2225
2251
  prompts,
2226
2252
  chat_messages: chatMessages,
2227
2253
  chat_source_summary: summarizeChatSources(chatMessages),
2228
- chat_coverage_state: chatMessages.some((message) => message.source_kind === "transcript") ? "transcript-backed" : chatMessages.length > 0 ? "hook-only" : "none",
2254
+ chat_coverage_state: getChatCoverageState(chatMessages),
2229
2255
  tool_events: toolEvents,
2230
2256
  observations,
2231
2257
  handoffs,
@@ -2323,12 +2349,6 @@ function collectProvenanceSummary(observations) {
2323
2349
  }
2324
2350
  return Array.from(counts.entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
2325
2351
  }
2326
- function summarizeChatSources(messages) {
2327
- return messages.reduce((summary, message) => {
2328
- summary[message.source_kind] += 1;
2329
- return summary;
2330
- }, { transcript: 0, hook: 0 });
2331
- }
2332
2352
 
2333
2353
  // src/tools/save.ts
2334
2354
  import { relative, isAbsolute } from "node:path";
@@ -3974,6 +3994,7 @@ function getRecentOutcomes(db, projectId, userId, recentSessions) {
3974
3994
  }
3975
3995
 
3976
3996
  // src/capture/transcript.ts
3997
+ import { createHash as createHash3 } from "node:crypto";
3977
3998
  import { readFileSync as readFileSync3, existsSync as existsSync3 } from "node:fs";
3978
3999
  import { join as join3 } from "node:path";
3979
4000
  import { homedir as homedir2 } from "node:os";
@@ -4027,23 +4048,109 @@ function readTranscript(sessionId, cwd, transcriptPath) {
4027
4048
  }
4028
4049
  return messages;
4029
4050
  }
4051
+ function resolveHistoryPath(historyPath) {
4052
+ if (historyPath)
4053
+ return historyPath;
4054
+ const override = process.env["ENGRM_CLAUDE_HISTORY_PATH"];
4055
+ if (override)
4056
+ return override;
4057
+ return join3(homedir2(), ".claude", "history.jsonl");
4058
+ }
4059
+ function readHistoryFallback(sessionId, cwd, opts) {
4060
+ const path = resolveHistoryPath(opts?.historyPath);
4061
+ if (!existsSync3(path))
4062
+ return [];
4063
+ let raw;
4064
+ try {
4065
+ raw = readFileSync3(path, "utf-8");
4066
+ } catch {
4067
+ return [];
4068
+ }
4069
+ const targetCanonical = detectProject(cwd).canonical_id;
4070
+ const windowStart = Math.max(0, (opts?.startedAtEpoch ?? Math.floor(Date.now() / 1000) - 6 * 3600) - 600);
4071
+ const windowEnd = (opts?.completedAtEpoch ?? Math.floor(Date.now() / 1000)) + 600;
4072
+ const entries = [];
4073
+ for (const line of raw.split(`
4074
+ `)) {
4075
+ if (!line.trim())
4076
+ continue;
4077
+ let entry;
4078
+ try {
4079
+ entry = JSON.parse(line);
4080
+ } catch {
4081
+ continue;
4082
+ }
4083
+ if (typeof entry?.display !== "string" || typeof entry?.timestamp !== "number")
4084
+ continue;
4085
+ const createdAtEpoch = Math.floor(entry.timestamp / 1000);
4086
+ entries.push({
4087
+ display: entry.display.trim(),
4088
+ project: typeof entry.project === "string" ? entry.project : "",
4089
+ sessionId: typeof entry.sessionId === "string" ? entry.sessionId : "",
4090
+ timestamp: createdAtEpoch
4091
+ });
4092
+ }
4093
+ const bySession = entries.filter((entry) => entry.display.length > 0 && entry.sessionId === sessionId).sort((a, b) => a.timestamp - b.timestamp);
4094
+ if (bySession.length > 0) {
4095
+ return dedupeHistoryMessages(bySession.map((entry) => ({
4096
+ role: "user",
4097
+ text: entry.display,
4098
+ createdAtEpoch: entry.timestamp
4099
+ })));
4100
+ }
4101
+ const byProjectAndWindow = entries.filter((entry) => {
4102
+ if (entry.display.length === 0)
4103
+ return false;
4104
+ if (entry.timestamp < windowStart || entry.timestamp > windowEnd)
4105
+ return false;
4106
+ if (!entry.project)
4107
+ return false;
4108
+ return detectProject(entry.project).canonical_id === targetCanonical;
4109
+ }).sort((a, b) => a.timestamp - b.timestamp);
4110
+ return dedupeHistoryMessages(byProjectAndWindow.map((entry) => ({
4111
+ role: "user",
4112
+ text: entry.display,
4113
+ createdAtEpoch: entry.timestamp
4114
+ })));
4115
+ }
4030
4116
  async function syncTranscriptChat(db, config, sessionId, cwd, transcriptPath) {
4031
- const messages = readTranscript(sessionId, cwd, transcriptPath).map((message) => ({
4117
+ const session = db.getSessionById(sessionId);
4118
+ const transcriptMessages = readTranscript(sessionId, cwd, transcriptPath).map((message) => ({
4032
4119
  ...message,
4033
4120
  text: message.text.trim()
4034
4121
  })).filter((message) => message.text.length > 0);
4122
+ const messages = transcriptMessages.length > 0 ? transcriptMessages.map((message, index) => ({
4123
+ ...message,
4124
+ sourceKind: "transcript",
4125
+ transcriptIndex: index + 1,
4126
+ createdAtEpoch: null,
4127
+ remoteSourceId: null
4128
+ })) : readHistoryFallback(sessionId, cwd, {
4129
+ startedAtEpoch: session?.started_at_epoch ?? null,
4130
+ completedAtEpoch: session?.completed_at_epoch ?? null
4131
+ }).map((message) => ({
4132
+ role: message.role,
4133
+ text: message.text,
4134
+ sourceKind: "hook",
4135
+ transcriptIndex: null,
4136
+ createdAtEpoch: message.createdAtEpoch,
4137
+ remoteSourceId: buildHistorySourceId(sessionId, message.createdAtEpoch, message.text)
4138
+ }));
4035
4139
  if (messages.length === 0)
4036
4140
  return { imported: 0, total: 0 };
4037
- const session = db.getSessionById(sessionId);
4038
4141
  const projectId = session?.project_id ?? null;
4039
4142
  const now = Math.floor(Date.now() / 1000);
4040
4143
  let imported = 0;
4041
4144
  for (let index = 0;index < messages.length; index++) {
4042
- const transcriptIndex = index + 1;
4043
- if (db.getTranscriptChatMessage(sessionId, transcriptIndex))
4044
- continue;
4045
4145
  const message = messages[index];
4046
- const createdAtEpoch = Math.max(0, now - (messages.length - transcriptIndex));
4146
+ const transcriptIndex = message.transcriptIndex ?? index + 1;
4147
+ if (message.sourceKind === "transcript" && db.getTranscriptChatMessage(sessionId, transcriptIndex)) {
4148
+ continue;
4149
+ }
4150
+ if (message.remoteSourceId && db.getChatMessageByRemoteSourceId(message.remoteSourceId)) {
4151
+ continue;
4152
+ }
4153
+ const createdAtEpoch = message.createdAtEpoch ?? Math.max(0, now - (messages.length - transcriptIndex));
4047
4154
  const row = db.insertChatMessage({
4048
4155
  session_id: sessionId,
4049
4156
  project_id: projectId,
@@ -4053,10 +4160,23 @@ async function syncTranscriptChat(db, config, sessionId, cwd, transcriptPath) {
4053
4160
  device_id: config.device_id,
4054
4161
  agent: "claude-code",
4055
4162
  created_at_epoch: createdAtEpoch,
4056
- source_kind: "transcript",
4057
- transcript_index: transcriptIndex
4163
+ remote_source_id: message.remoteSourceId,
4164
+ source_kind: message.sourceKind,
4165
+ transcript_index: message.transcriptIndex
4058
4166
  });
4059
4167
  db.addToOutbox("chat_message", row.id);
4168
+ if (message.role === "user") {
4169
+ db.insertUserPrompt({
4170
+ session_id: sessionId,
4171
+ project_id: projectId,
4172
+ prompt: message.text,
4173
+ cwd,
4174
+ user_id: config.user_id,
4175
+ device_id: config.device_id,
4176
+ agent: "claude-code",
4177
+ created_at_epoch: createdAtEpoch
4178
+ });
4179
+ }
4060
4180
  if (db.vecAvailable) {
4061
4181
  const embedding = await embedText(composeChatEmbeddingText(message.text));
4062
4182
  if (embedding) {
@@ -4067,6 +4187,23 @@ async function syncTranscriptChat(db, config, sessionId, cwd, transcriptPath) {
4067
4187
  }
4068
4188
  return { imported, total: messages.length };
4069
4189
  }
4190
+ function dedupeHistoryMessages(messages) {
4191
+ const deduped = [];
4192
+ for (const message of messages) {
4193
+ const compact = message.text.replace(/\s+/g, " ").trim();
4194
+ if (!compact)
4195
+ continue;
4196
+ const previous = deduped[deduped.length - 1];
4197
+ if (previous && previous.text.replace(/\s+/g, " ").trim() === compact)
4198
+ continue;
4199
+ deduped.push({ ...message, text: compact });
4200
+ }
4201
+ return deduped;
4202
+ }
4203
+ function buildHistorySourceId(sessionId, createdAtEpoch, text) {
4204
+ const digest = createHash3("sha1").update(text).digest("hex").slice(0, 12);
4205
+ return `history:${sessionId}:${createdAtEpoch}:${digest}`;
4206
+ }
4070
4207
  function truncateTranscript(messages, maxBytes = 50000) {
4071
4208
  const lines = [];
4072
4209
  for (const msg of messages) {
@@ -473,6 +473,32 @@ function normalizeItem(value) {
473
473
  return value.toLowerCase().replace(/\*+/g, "").replace(/\s+/g, " ").trim();
474
474
  }
475
475
 
476
+ // src/tools/recent-chat.ts
477
+ function summarizeChatSources(messages) {
478
+ return messages.reduce((summary, message) => {
479
+ summary[getChatCaptureOrigin(message)] += 1;
480
+ return summary;
481
+ }, { transcript: 0, history: 0, hook: 0 });
482
+ }
483
+ function getChatCoverageState(messagesOrSummary) {
484
+ const summary = Array.isArray(messagesOrSummary) ? summarizeChatSources(messagesOrSummary) : messagesOrSummary;
485
+ if (summary.transcript > 0)
486
+ return "transcript-backed";
487
+ if (summary.history > 0)
488
+ return "history-backed";
489
+ if (summary.hook > 0)
490
+ return "hook-only";
491
+ return "none";
492
+ }
493
+ function getChatCaptureOrigin(message) {
494
+ if (message.source_kind === "transcript")
495
+ return "transcript";
496
+ if (typeof message.remote_source_id === "string" && message.remote_source_id.startsWith("history:")) {
497
+ return "history";
498
+ }
499
+ return "hook";
500
+ }
501
+
476
502
  // src/tools/session-story.ts
477
503
  function getSessionStory(db, input) {
478
504
  const session = db.getSessionById(input.session_id);
@@ -495,7 +521,7 @@ function getSessionStory(db, input) {
495
521
  prompts,
496
522
  chat_messages: chatMessages,
497
523
  chat_source_summary: summarizeChatSources(chatMessages),
498
- chat_coverage_state: chatMessages.some((message) => message.source_kind === "transcript") ? "transcript-backed" : chatMessages.length > 0 ? "hook-only" : "none",
524
+ chat_coverage_state: getChatCoverageState(chatMessages),
499
525
  tool_events: toolEvents,
500
526
  observations,
501
527
  handoffs,
@@ -593,12 +619,6 @@ function collectProvenanceSummary(observations) {
593
619
  }
594
620
  return Array.from(counts.entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
595
621
  }
596
- function summarizeChatSources(messages) {
597
- return messages.reduce((summary, message) => {
598
- summary[message.source_kind] += 1;
599
- return summary;
600
- }, { transcript: 0, hook: 0 });
601
- }
602
622
 
603
623
  // src/tools/save.ts
604
624
  import { relative, isAbsolute } from "node:path";
@@ -3060,7 +3080,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync
3060
3080
  import { join as join3 } from "node:path";
3061
3081
  import { homedir } from "node:os";
3062
3082
  var STATE_PATH = join3(homedir(), ".engrm", "config-fingerprint.json");
3063
- var CLIENT_VERSION = "0.4.28";
3083
+ var CLIENT_VERSION = "0.4.30";
3064
3084
  function hashFile(filePath) {
3065
3085
  try {
3066
3086
  if (!existsSync3(filePath))
@@ -5743,6 +5763,7 @@ function formatInspectHints(context, visibleObservationIds = []) {
5743
5763
  hints.push("activity_feed");
5744
5764
  }
5745
5765
  if ((context.recentPrompts?.length ?? 0) > 0 || (context.recentChatMessages?.length ?? 0) > 0 || context.observations.length > 0) {
5766
+ hints.push("resume_thread");
5746
5767
  hints.push("search_recall");
5747
5768
  }
5748
5769
  if (context.observations.length > 0) {
@@ -5755,8 +5776,9 @@ function formatInspectHints(context, visibleObservationIds = []) {
5755
5776
  if ((context.recentChatMessages?.length ?? 0) > 0) {
5756
5777
  hints.push("recent_chat");
5757
5778
  }
5758
- if (hasHookOnlyRecentChat(context)) {
5779
+ if (hasNonTranscriptRecentChat(context)) {
5759
5780
  hints.push("refresh_chat_recall");
5781
+ hints.push("repair_recall");
5760
5782
  }
5761
5783
  if (continuityState !== "fresh") {
5762
5784
  hints.push("recent_chat");
@@ -6230,7 +6252,7 @@ function hasFreshContinuitySignal(context) {
6230
6252
  function getStartupContinuityState(context) {
6231
6253
  return classifyContinuityState(context.recentPrompts?.length ?? 0, context.recentToolEvents?.length ?? 0, context.recentHandoffs?.length ?? 0, context.recentChatMessages?.length ?? 0, context.recentSessions ?? [], context.recentOutcomes?.length ?? 0);
6232
6254
  }
6233
- function hasHookOnlyRecentChat(context) {
6255
+ function hasNonTranscriptRecentChat(context) {
6234
6256
  const recentChat = context.recentChatMessages ?? [];
6235
6257
  return recentChat.length > 0 && !recentChat.some((message) => message.source_kind === "transcript");
6236
6258
  }