mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
ba5244c189
commit
f3ac874fd2
3 changed files with 129 additions and 5 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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: "" },
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue