diff --git a/CHANGELOG.md b/CHANGELOG.md index c783b8e2d..c54040e90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,8 @@ Docs: https://docs.openclaw.ai ### Fixes -- Agents/Process: keep staged process-tree termination safe by skipping delayed Windows force-kill when the PID is already gone, and align PTY force-kill wait behavior with actual exit timing to avoid premature "killed" state reporting during grace periods. (#18626) -- Slack: limit forwarded-attachment extraction to explicit shared-message attachments and skip non-Slack forwarded image URLs, preventing non-forward unfurls from polluting inbound agent context. Also adds regression tests for forwarded vs non-forward attachment handling. +- Hooks/Session-memory: recover `/new` conversation summaries when session pointers are reset-path or missing `sessionFile`, and consistently prefer the newest `.jsonl.reset.*` transcript candidate for fallback extraction. (#18088) +- Slack: restrict forwarded-attachment ingestion to explicit shared-message attachments and skip non-Slack forwarded `image_url` fetches, preventing non-forward attachment unfurls from polluting inbound agent context while preserving forwarded message handling. - Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07. - OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky. - iOS/Onboarding: add QR-first onboarding wizard with setup-code deep link support, pairing/auth issue guidance, and device-pair QR generation improvements for Telegram/Web/TUI fallback flows. (#18162) Thanks @mbelinky and @Marvae. diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts index 9eecd14e6..fcc895f89 100644 --- a/src/hooks/bundled/session-memory/handler.test.ts +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; -import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js"; import type { HookHandler } from "../../hooks.js"; +import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js"; import { createHookEvent } from "../../hooks.js"; // Avoid calling the embedded Pi agent (global command lane); keep this unit test deterministic. @@ -300,6 +300,138 @@ describe("session-memory hook", () => { expect(memoryContent).toContain("assistant: Recovered from reset fallback"); }); + it("handles reset-path session pointers from previousSessionEntry", async () => { + const tempDir = await makeTempWorkspace("openclaw-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + const sessionId = "reset-pointer-session"; + const resetSessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: `${sessionId}.jsonl.reset.2026-02-16T22-26-33.000Z`, + content: createMockSessionContent([ + { role: "user", content: "Message from reset pointer" }, + { role: "assistant", content: "Recovered directly from reset file" }, + ]), + }); + + const cfg = { + agents: { defaults: { workspace: tempDir } }, + } satisfies OpenClawConfig; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId, + sessionFile: resetSessionFile, + }, + }); + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + expect(files.length).toBe(1); + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]), "utf-8"); + + expect(memoryContent).toContain("user: Message from reset pointer"); + expect(memoryContent).toContain("assistant: Recovered directly from reset file"); + }); + + it("recovers transcript when previousSessionEntry.sessionFile is missing", async () => { + const tempDir = await makeTempWorkspace("openclaw-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + const sessionId = "missing-session-file"; + await writeWorkspaceFile({ + dir: sessionsDir, + name: `${sessionId}.jsonl`, + content: "", + }); + await writeWorkspaceFile({ + dir: sessionsDir, + name: `${sessionId}.jsonl.reset.2026-02-16T22-26-33.000Z`, + content: createMockSessionContent([ + { role: "user", content: "Recovered with missing sessionFile pointer" }, + { role: "assistant", content: "Recovered by sessionId fallback" }, + ]), + }); + + const cfg = { + agents: { defaults: { workspace: tempDir } }, + } satisfies OpenClawConfig; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId, + }, + }); + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + expect(files.length).toBe(1); + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]), "utf-8"); + + expect(memoryContent).toContain("user: Recovered with missing sessionFile pointer"); + expect(memoryContent).toContain("assistant: Recovered by sessionId fallback"); + }); + + it("prefers the newest reset transcript when multiple reset candidates exist", async () => { + const tempDir = await makeTempWorkspace("openclaw-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + const activeSessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: "", + }); + + await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl.reset.2026-02-16T22-26-33.000Z", + content: createMockSessionContent([ + { role: "user", content: "Older rotated transcript" }, + { role: "assistant", content: "Old summary" }, + ]), + }); + await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl.reset.2026-02-16T22-26-34.000Z", + content: createMockSessionContent([ + { role: "user", content: "Newest rotated transcript" }, + { role: "assistant", content: "Newest summary" }, + ]), + }); + + const cfg = { + agents: { defaults: { workspace: tempDir } }, + } satisfies OpenClawConfig; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile: activeSessionFile, + }, + }); + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + expect(files.length).toBe(1); + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]), "utf-8"); + + expect(memoryContent).toContain("user: Newest rotated transcript"); + expect(memoryContent).toContain("assistant: Newest summary"); + expect(memoryContent).not.toContain("Older rotated transcript"); + }); + it("handles empty session files gracefully", async () => { // Should not throw const { files } = await runNewWithPreviousSession({ sessionContent: "" }); diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index 5b606d377..540aab243 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -8,14 +8,14 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../../config/config.js"; +import type { HookHandler } from "../../hooks.js"; +import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js"; import { resolveStateDir } from "../../../config/paths.js"; import { createSubsystemLogger } from "../../../logging/subsystem.js"; import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; import { hasInterSessionUserProvenance } from "../../../sessions/input-provenance.js"; import { resolveHookConfig } from "../../config.js"; -import type { HookHandler } from "../../hooks.js"; import { generateSlugViaLLM } from "../../llm-slug-generator.js"; const log = createSubsystemLogger("hooks/session-memory"); @@ -107,6 +107,65 @@ async function getRecentSessionContentWithResetFallback( } } +function stripResetSuffix(fileName: string): string { + const resetIndex = fileName.indexOf(".reset."); + return resetIndex === -1 ? fileName : fileName.slice(0, resetIndex); +} + +async function findPreviousSessionFile(params: { + sessionsDir: string; + currentSessionFile?: string; + sessionId?: string; +}): Promise { + try { + const files = await fs.readdir(params.sessionsDir); + const fileSet = new Set(files); + + const baseFromReset = params.currentSessionFile + ? stripResetSuffix(path.basename(params.currentSessionFile)) + : undefined; + if (baseFromReset && fileSet.has(baseFromReset)) { + return path.join(params.sessionsDir, baseFromReset); + } + + const trimmedSessionId = params.sessionId?.trim(); + if (trimmedSessionId) { + const canonicalFile = `${trimmedSessionId}.jsonl`; + if (fileSet.has(canonicalFile)) { + return path.join(params.sessionsDir, canonicalFile); + } + + const topicVariants = files + .filter( + (name) => + name.startsWith(`${trimmedSessionId}-topic-`) && + name.endsWith(".jsonl") && + !name.includes(".reset."), + ) + .toSorted() + .toReversed(); + if (topicVariants.length > 0) { + return path.join(params.sessionsDir, topicVariants[0]); + } + } + + if (!params.currentSessionFile) { + return undefined; + } + + const nonResetJsonl = files + .filter((name) => name.endsWith(".jsonl") && !name.includes(".reset.")) + .toSorted() + .toReversed(); + if (nonResetJsonl.length > 0) { + return path.join(params.sessionsDir, nonResetJsonl[0]); + } + } catch { + // Ignore directory read errors. + } + return undefined; +} + /** * Save session context to memory when /new command is triggered */ @@ -133,12 +192,36 @@ const saveSessionToMemory: HookHandler = async (event) => { const dateStr = now.toISOString().split("T")[0]; // YYYY-MM-DD // Generate descriptive slug from session using LLM + // Prefer previousSessionEntry (old session before /new) over current (which may be empty) const sessionEntry = (context.previousSessionEntry || context.sessionEntry || {}) as Record< string, unknown >; const currentSessionId = sessionEntry.sessionId as string; - const currentSessionFile = sessionEntry.sessionFile as string; + let currentSessionFile = (sessionEntry.sessionFile as string) || undefined; + + // If sessionFile is empty or looks like a new/reset file, try to find the previous session file. + if (!currentSessionFile || currentSessionFile.includes(".reset.")) { + const sessionsDirs = new Set(); + if (currentSessionFile) { + sessionsDirs.add(path.dirname(currentSessionFile)); + } + sessionsDirs.add(path.join(workspaceDir, "sessions")); + + for (const sessionsDir of sessionsDirs) { + const recoveredSessionFile = await findPreviousSessionFile({ + sessionsDir, + currentSessionFile, + sessionId: currentSessionId, + }); + if (!recoveredSessionFile) { + continue; + } + currentSessionFile = recoveredSessionFile; + log.debug("Found previous session file", { file: currentSessionFile }); + break; + } + } log.debug("Session context resolved", { sessionId: currentSessionId,