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.
This commit is contained in:
Chris Anderson 2026-07-01 02:07:58 -07:00 committed by GitHub
parent ba5244c189
commit f3ac874fd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 129 additions and 5 deletions

View file

@ -44,6 +44,13 @@ Use the `agent=<id>` value from the system prompt Runtime line.
- **`sessions.json`** - Index mapping session keys to session IDs
- **`<session-id>.jsonl`** - Full conversation transcript per session
- **`<session-id>.jsonl.reset.<timestamp>Z`** - Transcript archived by `/new` or `/reset`
- **`<session-id>.jsonl.deleted.<timestamp>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="<agentId>"
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' <session>.json
```bash
AGENT_ID="<agentId>"
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.<timestamp>` suffix
- **Reset/compacted sessions** have `.jsonl.reset.<timestamp>Z` suffix — still contain
full transcripts and are searchable.
- **Deleted sessions** have `.jsonl.deleted.<timestamp>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)

View file

@ -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: "" },

View file

@ -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.
}