Skip to content

Commit f3ac874

Browse files
authored
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.
1 parent ba5244c commit f3ac874

3 files changed

Lines changed: 129 additions & 5 deletions

File tree

skills/session-logs/SKILL.md

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ Use the `agent=<id>` value from the system prompt Runtime line.
4444

4545
- **`sessions.json`** - Index mapping session keys to session IDs
4646
- **`<session-id>.jsonl`** - Full conversation transcript per session
47+
- **`<session-id>.jsonl.reset.<timestamp>Z`** - Transcript archived by `/new` or `/reset`
48+
- **`<session-id>.jsonl.deleted.<timestamp>Z`** - Transcript archived when a session was deleted
49+
50+
When searching history, include the archived (`.reset.*`, `.deleted.*`) variants too — they
51+
still contain real conversation content. The plain-glob examples below only catch the
52+
active `*.jsonl` files; use the "Include archived transcripts" snippet when you need
53+
full recall.
4754

4855
## Structure
4956

@@ -57,6 +64,34 @@ Each `.jsonl` file contains messages with:
5764

5865
## Common Queries
5966

67+
### Include archived transcripts (`.reset.*`, `.deleted.*`)
68+
69+
```bash
70+
# Bash helper that emits every searchable transcript path — active and archived.
71+
# Saves and restores `nullglob` locally so callers' shell options aren't disturbed.
72+
AGENT_ID="<agentId>"
73+
SESSION_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/agents/$AGENT_ID/sessions"
74+
list_session_transcripts() {
75+
local _nullglob_state
76+
_nullglob_state=$(shopt -p nullglob 2>/dev/null)
77+
shopt -s nullglob
78+
for f in "$SESSION_DIR"/*.jsonl \
79+
"$SESSION_DIR"/*.jsonl.reset.*Z \
80+
"$SESSION_DIR"/*.jsonl.deleted.*Z; do
81+
[ -f "$f" ] && printf '%s\n' "$f"
82+
done
83+
eval "$_nullglob_state"
84+
}
85+
```
86+
87+
Use `list_session_transcripts` (or an equivalent `find` invocation) wherever the
88+
plain `*.jsonl` glob is shown below if you need to include archived sessions:
89+
90+
```bash
91+
find "$SESSION_DIR" -maxdepth 1 -type f \
92+
\( -name '*.jsonl' -o -name '*.jsonl.reset.*Z' -o -name '*.jsonl.deleted.*Z' \) -print
93+
```
94+
6095
### List all sessions by date and size
6196

6297
```bash
@@ -69,6 +104,19 @@ for f in "$SESSION_DIR"/*.jsonl; do
69104
done | sort -r
70105
```
71106

107+
_Tip:_ swap the `for f in ...` line for a `while`-read over
108+
`list_session_transcripts` (see snippet above) when you also want archived
109+
`.reset` / `.deleted` files in the listing. The `while`-read pattern is safe
110+
for paths with spaces or other IFS characters:
111+
112+
```bash
113+
while IFS= read -r f; do
114+
date=$(head -1 "$f" | jq -r '.timestamp' | cut -dT -f1)
115+
size=$(ls -lh "$f" | awk '{print $5}')
116+
echo "$date $size $(basename "$f")"
117+
done < <(list_session_transcripts) | sort -r
118+
```
119+
72120
### Find sessions from a specific day
73121

74122
```bash
@@ -132,15 +180,27 @@ jq -r '.message.content[]? | select(.type == "toolCall") | .name' <session>.json
132180
```bash
133181
AGENT_ID="<agentId>"
134182
SESSION_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/agents/$AGENT_ID/sessions"
183+
184+
# Active sessions only:
135185
rg -l "phrase" "$SESSION_DIR"/*.jsonl
186+
187+
# Active + archived (`.reset.*`, `.deleted.*`) — use this when checking for
188+
# content that may have been compacted/reset/deleted:
189+
rg -l "phrase" "$SESSION_DIR"/*.jsonl \
190+
"$SESSION_DIR"/*.jsonl.reset.*Z \
191+
"$SESSION_DIR"/*.jsonl.deleted.*Z 2>/dev/null
136192
```
137193

138194
## Tips
139195

140196
- Sessions are append-only JSONL (one JSON object per line)
141197
- Large sessions can be several MB - use `head`/`tail` for sampling
142198
- The `sessions.json` index maps chat providers (discord, whatsapp, etc.) to session IDs
143-
- Deleted sessions have `.deleted.<timestamp>` suffix
199+
- **Reset/compacted sessions** have `.jsonl.reset.<timestamp>Z` suffix — still contain
200+
full transcripts and are searchable.
201+
- **Deleted sessions** have `.jsonl.deleted.<timestamp>Z` suffix — also still searchable.
202+
- A plain `*.jsonl` glob will _miss_ both archived forms. Include them explicitly
203+
(see the "Include archived transcripts" snippet above) when you need full history.
144204

145205
## Fast text-only hint (low noise)
146206

src/hooks/bundled/session-memory/handler.test.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -669,9 +669,9 @@ describe("session-memory hook", () => {
669669
currentSessionFile: resetSessionFile,
670670
sessionId,
671671
});
672-
expect(previousSessionFile).toBeUndefined();
672+
expect(previousSessionFile).toBe(resetSessionFile);
673673

674-
const memoryContent = await getRecentSessionContentWithResetFallback(resetSessionFile);
674+
const memoryContent = await getRecentSessionContentWithResetFallback(previousSessionFile!);
675675
expect(memoryContent).toContain("user: Message from reset pointer");
676676
expect(memoryContent).toContain("assistant: Recovered directly from reset file");
677677
});
@@ -705,6 +705,40 @@ describe("session-memory hook", () => {
705705
expect(memoryContent).toContain("assistant: Recovered by sessionId fallback");
706706
});
707707

708+
it("falls back to latest reset transcript when only archived copies remain", async () => {
709+
const { sessionsDir } = await createSessionMemoryWorkspace();
710+
711+
const sessionId = "reset-only-session";
712+
const olderResetFile = await writeWorkspaceFile({
713+
dir: sessionsDir,
714+
name: `${sessionId}.jsonl.reset.2026-02-16T22-26-33.000Z`,
715+
content: createMockSessionContent([
716+
{ role: "user", content: "Older archived session" },
717+
{ role: "assistant", content: "Older archived summary" },
718+
]),
719+
});
720+
const newerResetFile = await writeWorkspaceFile({
721+
dir: sessionsDir,
722+
name: `${sessionId}.jsonl.reset.2026-02-16T22-26-34.000Z`,
723+
content: createMockSessionContent([
724+
{ role: "user", content: "Newest archived session" },
725+
{ role: "assistant", content: "Newest archived summary" },
726+
]),
727+
});
728+
729+
const previousSessionFile = await findPreviousSessionFile({
730+
sessionsDir,
731+
sessionId,
732+
});
733+
expect(previousSessionFile).toBe(newerResetFile);
734+
expect(previousSessionFile).not.toBe(olderResetFile);
735+
736+
const memoryContent = await getRecentSessionContentWithResetFallback(previousSessionFile!);
737+
expect(memoryContent).toContain("user: Newest archived session");
738+
expect(memoryContent).toContain("assistant: Newest archived summary");
739+
expect(memoryContent).not.toContain("Older archived session");
740+
});
741+
708742
it("prefers the newest reset transcript when multiple reset candidates exist", async () => {
709743
const { sessionsDir, activeSessionFile } = await createSessionMemoryWorkspace({
710744
activeSession: { name: "test-session.jsonl", content: "" },

src/hooks/bundled/session-memory/transcript.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,16 @@ export async function findPreviousSessionFile(params: {
155155
const files = await fs.readdir(params.sessionsDir);
156156
const fileSet = new Set(files);
157157

158-
const baseFromReset = params.currentSessionFile
159-
? stripResetSuffix(path.basename(params.currentSessionFile))
158+
const currentBaseName = params.currentSessionFile
159+
? path.basename(params.currentSessionFile)
160160
: undefined;
161+
const baseFromReset = currentBaseName ? stripResetSuffix(currentBaseName) : undefined;
161162
if (baseFromReset && fileSet.has(baseFromReset)) {
162163
return path.join(params.sessionsDir, baseFromReset);
163164
}
165+
if (currentBaseName?.includes(".reset.") && fileSet.has(currentBaseName)) {
166+
return path.join(params.sessionsDir, currentBaseName);
167+
}
164168

165169
const trimmedSessionId = params.sessionId?.trim();
166170
if (trimmedSessionId) {
@@ -169,6 +173,14 @@ export async function findPreviousSessionFile(params: {
169173
return path.join(params.sessionsDir, canonicalFile);
170174
}
171175

176+
const canonicalResetVariants = files
177+
.filter((name) => name.startsWith(`${canonicalFile}.reset.`))
178+
.toSorted()
179+
.toReversed();
180+
if (canonicalResetVariants.length > 0) {
181+
return path.join(params.sessionsDir, canonicalResetVariants[0]);
182+
}
183+
172184
const topicVariants = files
173185
.filter(
174186
(name) =>
@@ -181,6 +193,16 @@ export async function findPreviousSessionFile(params: {
181193
if (topicVariants.length > 0) {
182194
return path.join(params.sessionsDir, topicVariants[0]);
183195
}
196+
197+
const topicResetVariants = files
198+
.filter(
199+
(name) => name.startsWith(`${trimmedSessionId}-topic-`) && name.includes(".jsonl.reset."),
200+
)
201+
.toSorted()
202+
.toReversed();
203+
if (topicResetVariants.length > 0) {
204+
return path.join(params.sessionsDir, topicResetVariants[0]);
205+
}
184206
}
185207

186208
if (!params.currentSessionFile) {
@@ -194,6 +216,14 @@ export async function findPreviousSessionFile(params: {
194216
if (nonResetJsonl.length > 0) {
195217
return path.join(params.sessionsDir, nonResetJsonl[0]);
196218
}
219+
220+
const resetJsonl = files
221+
.filter((name) => name.includes(".jsonl.reset."))
222+
.toSorted()
223+
.toReversed();
224+
if (resetJsonl.length > 0) {
225+
return path.join(params.sessionsDir, resetJsonl[0]);
226+
}
197227
} catch {
198228
// Ignore directory read errors.
199229
}

0 commit comments

Comments
 (0)