From f3ac874fd2154fd8769c1a4e29e57f671f2ecda6 Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Wed, 1 Jul 2026 02:07:58 -0700 Subject: [PATCH] Recover archived (.reset) session transcripts in memory hook + session-logs skill (#71537) * fix(session-memory): recover archived reset transcripts * docs(session-logs): include archived transcripts in skill examples Plain .jsonl globs miss .jsonl.reset.*Z and .jsonl.deleted.*Z files, which still contain real transcript content. Add explicit guidance and a list_session_transcripts helper so searches can opt in to full history when needed. Pairs with the session-memory recover-from-archive fix in the same branch. * docs(session-logs): scope nullglob and use while-read in helper Address Greptile review feedback on PR #71537: * nullglob is now saved and restored inside list_session_transcripts so the helper does not change the caller shell environment when sourced. * Replace 'for f in $(list_session_transcripts)' tip with a 'while IFS= read -r' loop so paths with spaces or other IFS characters are handled correctly. Include a worked example using the same date+size listing the surrounding section already documents. Doc only, no runtime behavior change. --- skills/session-logs/SKILL.md | 62 ++++++++++++++++++- .../bundled/session-memory/handler.test.ts | 38 +++++++++++- .../bundled/session-memory/transcript.ts | 34 +++++++++- 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/skills/session-logs/SKILL.md b/skills/session-logs/SKILL.md index 58904caed2f..c2154765052 100644 --- a/skills/session-logs/SKILL.md +++ b/skills/session-logs/SKILL.md @@ -44,6 +44,13 @@ Use the `agent=` value from the system prompt Runtime line. - **`sessions.json`** - Index mapping session keys to session IDs - **`.jsonl`** - Full conversation transcript per session +- **`.jsonl.reset.Z`** - Transcript archived by `/new` or `/reset` +- **`.jsonl.deleted.Z`** - Transcript archived when a session was deleted + +When searching history, include the archived (`.reset.*`, `.deleted.*`) variants too — they +still contain real conversation content. The plain-glob examples below only catch the +active `*.jsonl` files; use the "Include archived transcripts" snippet when you need +full recall. ## Structure @@ -57,6 +64,34 @@ Each `.jsonl` file contains messages with: ## Common Queries +### Include archived transcripts (`.reset.*`, `.deleted.*`) + +```bash +# Bash helper that emits every searchable transcript path — active and archived. +# Saves and restores `nullglob` locally so callers' shell options aren't disturbed. +AGENT_ID="" +SESSION_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/agents/$AGENT_ID/sessions" +list_session_transcripts() { + local _nullglob_state + _nullglob_state=$(shopt -p nullglob 2>/dev/null) + shopt -s nullglob + for f in "$SESSION_DIR"/*.jsonl \ + "$SESSION_DIR"/*.jsonl.reset.*Z \ + "$SESSION_DIR"/*.jsonl.deleted.*Z; do + [ -f "$f" ] && printf '%s\n' "$f" + done + eval "$_nullglob_state" +} +``` + +Use `list_session_transcripts` (or an equivalent `find` invocation) wherever the +plain `*.jsonl` glob is shown below if you need to include archived sessions: + +```bash +find "$SESSION_DIR" -maxdepth 1 -type f \ + \( -name '*.jsonl' -o -name '*.jsonl.reset.*Z' -o -name '*.jsonl.deleted.*Z' \) -print +``` + ### List all sessions by date and size ```bash @@ -69,6 +104,19 @@ for f in "$SESSION_DIR"/*.jsonl; do done | sort -r ``` +_Tip:_ swap the `for f in ...` line for a `while`-read over +`list_session_transcripts` (see snippet above) when you also want archived +`.reset` / `.deleted` files in the listing. The `while`-read pattern is safe +for paths with spaces or other IFS characters: + +```bash +while IFS= read -r f; do + date=$(head -1 "$f" | jq -r '.timestamp' | cut -dT -f1) + size=$(ls -lh "$f" | awk '{print $5}') + echo "$date $size $(basename "$f")" +done < <(list_session_transcripts) | sort -r +``` + ### Find sessions from a specific day ```bash @@ -132,7 +180,15 @@ jq -r '.message.content[]? | select(.type == "toolCall") | .name' .json ```bash AGENT_ID="" SESSION_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/agents/$AGENT_ID/sessions" + +# Active sessions only: rg -l "phrase" "$SESSION_DIR"/*.jsonl + +# Active + archived (`.reset.*`, `.deleted.*`) — use this when checking for +# content that may have been compacted/reset/deleted: +rg -l "phrase" "$SESSION_DIR"/*.jsonl \ + "$SESSION_DIR"/*.jsonl.reset.*Z \ + "$SESSION_DIR"/*.jsonl.deleted.*Z 2>/dev/null ``` ## Tips @@ -140,7 +196,11 @@ rg -l "phrase" "$SESSION_DIR"/*.jsonl - Sessions are append-only JSONL (one JSON object per line) - Large sessions can be several MB - use `head`/`tail` for sampling - The `sessions.json` index maps chat providers (discord, whatsapp, etc.) to session IDs -- Deleted sessions have `.deleted.` suffix +- **Reset/compacted sessions** have `.jsonl.reset.Z` suffix — still contain + full transcripts and are searchable. +- **Deleted sessions** have `.jsonl.deleted.Z` suffix — also still searchable. +- A plain `*.jsonl` glob will _miss_ both archived forms. Include them explicitly + (see the "Include archived transcripts" snippet above) when you need full history. ## Fast text-only hint (low noise) diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts index 42f62e411bc..123eccc99fe 100644 --- a/src/hooks/bundled/session-memory/handler.test.ts +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -669,9 +669,9 @@ describe("session-memory hook", () => { currentSessionFile: resetSessionFile, sessionId, }); - expect(previousSessionFile).toBeUndefined(); + expect(previousSessionFile).toBe(resetSessionFile); - const memoryContent = await getRecentSessionContentWithResetFallback(resetSessionFile); + const memoryContent = await getRecentSessionContentWithResetFallback(previousSessionFile!); expect(memoryContent).toContain("user: Message from reset pointer"); expect(memoryContent).toContain("assistant: Recovered directly from reset file"); }); @@ -705,6 +705,40 @@ describe("session-memory hook", () => { expect(memoryContent).toContain("assistant: Recovered by sessionId fallback"); }); + it("falls back to latest reset transcript when only archived copies remain", async () => { + const { sessionsDir } = await createSessionMemoryWorkspace(); + + const sessionId = "reset-only-session"; + const olderResetFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: `${sessionId}.jsonl.reset.2026-02-16T22-26-33.000Z`, + content: createMockSessionContent([ + { role: "user", content: "Older archived session" }, + { role: "assistant", content: "Older archived summary" }, + ]), + }); + const newerResetFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: `${sessionId}.jsonl.reset.2026-02-16T22-26-34.000Z`, + content: createMockSessionContent([ + { role: "user", content: "Newest archived session" }, + { role: "assistant", content: "Newest archived summary" }, + ]), + }); + + const previousSessionFile = await findPreviousSessionFile({ + sessionsDir, + sessionId, + }); + expect(previousSessionFile).toBe(newerResetFile); + expect(previousSessionFile).not.toBe(olderResetFile); + + const memoryContent = await getRecentSessionContentWithResetFallback(previousSessionFile!); + expect(memoryContent).toContain("user: Newest archived session"); + expect(memoryContent).toContain("assistant: Newest archived summary"); + expect(memoryContent).not.toContain("Older archived session"); + }); + it("prefers the newest reset transcript when multiple reset candidates exist", async () => { const { sessionsDir, activeSessionFile } = await createSessionMemoryWorkspace({ activeSession: { name: "test-session.jsonl", content: "" }, diff --git a/src/hooks/bundled/session-memory/transcript.ts b/src/hooks/bundled/session-memory/transcript.ts index 6df02a367c2..e5f91133237 100644 --- a/src/hooks/bundled/session-memory/transcript.ts +++ b/src/hooks/bundled/session-memory/transcript.ts @@ -155,12 +155,16 @@ export async function findPreviousSessionFile(params: { const files = await fs.readdir(params.sessionsDir); const fileSet = new Set(files); - const baseFromReset = params.currentSessionFile - ? stripResetSuffix(path.basename(params.currentSessionFile)) + const currentBaseName = params.currentSessionFile + ? path.basename(params.currentSessionFile) : undefined; + const baseFromReset = currentBaseName ? stripResetSuffix(currentBaseName) : undefined; if (baseFromReset && fileSet.has(baseFromReset)) { return path.join(params.sessionsDir, baseFromReset); } + if (currentBaseName?.includes(".reset.") && fileSet.has(currentBaseName)) { + return path.join(params.sessionsDir, currentBaseName); + } const trimmedSessionId = params.sessionId?.trim(); if (trimmedSessionId) { @@ -169,6 +173,14 @@ export async function findPreviousSessionFile(params: { return path.join(params.sessionsDir, canonicalFile); } + const canonicalResetVariants = files + .filter((name) => name.startsWith(`${canonicalFile}.reset.`)) + .toSorted() + .toReversed(); + if (canonicalResetVariants.length > 0) { + return path.join(params.sessionsDir, canonicalResetVariants[0]); + } + const topicVariants = files .filter( (name) => @@ -181,6 +193,16 @@ export async function findPreviousSessionFile(params: { if (topicVariants.length > 0) { return path.join(params.sessionsDir, topicVariants[0]); } + + const topicResetVariants = files + .filter( + (name) => name.startsWith(`${trimmedSessionId}-topic-`) && name.includes(".jsonl.reset."), + ) + .toSorted() + .toReversed(); + if (topicResetVariants.length > 0) { + return path.join(params.sessionsDir, topicResetVariants[0]); + } } if (!params.currentSessionFile) { @@ -194,6 +216,14 @@ export async function findPreviousSessionFile(params: { if (nonResetJsonl.length > 0) { return path.join(params.sessionsDir, nonResetJsonl[0]); } + + const resetJsonl = files + .filter((name) => name.includes(".jsonl.reset.")) + .toSorted() + .toReversed(); + if (resetJsonl.length > 0) { + return path.join(params.sessionsDir, resetJsonl[0]); + } } catch { // Ignore directory read errors. }