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 +18 -4
- package/dist/hooks/post-tool-use.js +152 -15
- package/dist/hooks/pre-compact.js +152 -15
- package/dist/hooks/session-start.js +32 -10
- package/dist/hooks/stop.js +153 -16
- package/dist/hooks/user-prompt-submit.js +152 -15
- package/dist/server.js +717 -209
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
3804
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
4057
|
-
|
|
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
|
|
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.
|
|
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 (
|
|
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
|
|
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
|
}
|