first commit
This commit is contained in:
24
HOOK.md
Normal file
24
HOOK.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: session-memory-cus
|
||||
description: "Save full session context on /new or /reset with slugged filenames in user timezone"
|
||||
metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "🧠",
|
||||
"events": ["command:new", "command:reset"],
|
||||
"requires": { "config": ["workspace.dir"] },
|
||||
},
|
||||
}
|
||||
---
|
||||
|
||||
# Session Memory Custom Hook
|
||||
|
||||
Writes full user/assistant transcript context from the previous session into
|
||||
`<workspace>/memory/YYYY-MM-DD-slug.md` when `/new` or `/reset` runs.
|
||||
|
||||
Differences from bundled session-memory:
|
||||
|
||||
- Uses full session context in output file
|
||||
- Uses `agents.defaults.userTimezone` (fallback host timezone) for date/time
|
||||
- Uses reduced excerpt only for slug generation
|
||||
302
handler.ts
Normal file
302
handler.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
type AnyObj = Record<string, unknown>;
|
||||
|
||||
function asObj(v: unknown): AnyObj {
|
||||
return v && typeof v === "object" ? (v as AnyObj) : {};
|
||||
}
|
||||
|
||||
function extractTextMessageContent(content: unknown): string | undefined {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return;
|
||||
|
||||
const chunks: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const candidate = block as { type?: unknown; text?: unknown };
|
||||
if (candidate.type === "text" && typeof candidate.text === "string") {
|
||||
chunks.push(candidate.text);
|
||||
}
|
||||
}
|
||||
|
||||
const out = chunks.join("\n").trim();
|
||||
return out.length ? out : undefined;
|
||||
}
|
||||
|
||||
function stripResetSuffix(fileName: string): string {
|
||||
const i = fileName.indexOf(".reset.");
|
||||
return i === -1 ? fileName : fileName.slice(0, i);
|
||||
}
|
||||
|
||||
async function findPreviousSessionFile(params: {
|
||||
sessionsDir: string;
|
||||
currentSessionFile?: string;
|
||||
sessionId?: string;
|
||||
}): Promise<string | undefined> {
|
||||
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."),
|
||||
)
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
if (topicVariants.length > 0) {
|
||||
return path.join(params.sessionsDir, topicVariants[0]!);
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.currentSessionFile) return;
|
||||
|
||||
const nonResetJsonl = files
|
||||
.filter((name) => name.endsWith(".jsonl") && !name.includes(".reset."))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
if (nonResetJsonl.length > 0) {
|
||||
return path.join(params.sessionsDir, nonResetJsonl[0]!);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function resolveSessionFile(params: {
|
||||
workspaceDir: string;
|
||||
currentSessionFile?: string;
|
||||
sessionId?: string;
|
||||
}): Promise<string | undefined> {
|
||||
let current = params.currentSessionFile;
|
||||
|
||||
if (current && !current.includes(".reset.")) return current;
|
||||
|
||||
const dirs = new Set<string>();
|
||||
if (current) dirs.add(path.dirname(current));
|
||||
dirs.add(path.join(params.workspaceDir, "sessions"));
|
||||
|
||||
for (const sessionsDir of dirs) {
|
||||
const recovered = await findPreviousSessionFile({
|
||||
sessionsDir,
|
||||
currentSessionFile: current,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
if (recovered) return recovered;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
async function getFullSessionContent(sessionFilePath: string): Promise<string | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(sessionFilePath, "utf-8");
|
||||
const lines = raw.trim().split("\n");
|
||||
const allMessages: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line) as AnyObj;
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
const message = asObj(entry.message);
|
||||
const role = message.role;
|
||||
if (role !== "user" && role !== "assistant") continue;
|
||||
|
||||
const text = extractTextMessageContent(message.content);
|
||||
if (!text) continue;
|
||||
if (text.startsWith("/")) continue;
|
||||
|
||||
allMessages.push(`${String(role)}: ${text}`);
|
||||
} catch {
|
||||
// ignore broken line
|
||||
}
|
||||
}
|
||||
|
||||
if (allMessages.length === 0) return null;
|
||||
return allMessages.join("\n\n");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTimezone(cfg: AnyObj): string {
|
||||
const raw = asObj(asObj(cfg.agents).defaults).userTimezone;
|
||||
const tz = typeof raw === "string" ? raw.trim() : "";
|
||||
|
||||
if (tz) {
|
||||
try {
|
||||
Intl.DateTimeFormat("en-US", { timeZone: tz }).format(new Date());
|
||||
return tz;
|
||||
} catch {
|
||||
// ignore invalid timezone
|
||||
}
|
||||
}
|
||||
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
}
|
||||
|
||||
function formatDatePartsInTimezone(date: Date, timeZone: string): { dateStr: string; timeStr: string } {
|
||||
const dtf = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
hourCycle: "h23",
|
||||
});
|
||||
|
||||
const parts = dtf.formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value ?? "00";
|
||||
|
||||
const dateStr = `${get("year")}-${get("month")}-${get("day")}`;
|
||||
const timeStr = `${get("hour")}:${get("minute")}:${get("second")}`;
|
||||
return { dateStr, timeStr };
|
||||
}
|
||||
|
||||
function slugifyLocal(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, " ")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function buildSlugExcerpt(full: string): string {
|
||||
const lines = full
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const tail = lines.slice(-24).join("\n");
|
||||
return tail.slice(-3000);
|
||||
}
|
||||
|
||||
async function generateSlug(params: {
|
||||
excerpt: string;
|
||||
cfg: AnyObj;
|
||||
fallbackDate: Date;
|
||||
timeZone: string;
|
||||
}): Promise<string> {
|
||||
const { excerpt, cfg, fallbackDate, timeZone } = params;
|
||||
|
||||
if (excerpt.trim()) {
|
||||
try {
|
||||
const mod = await import("/root/.nvm/versions/node/v25.8.1/lib/node_modules/openclaw/dist/llm-slug-generator.js");
|
||||
const fn = (mod as { generateSlugViaLLM?: (p: { sessionContent: string; cfg: unknown }) => Promise<string | null> })
|
||||
.generateSlugViaLLM;
|
||||
if (typeof fn === "function") {
|
||||
const slug = await fn({ sessionContent: excerpt, cfg });
|
||||
if (slug && typeof slug === "string" && slug.trim()) return slugifyLocal(slug);
|
||||
}
|
||||
} catch {
|
||||
// fall through to deterministic fallback
|
||||
}
|
||||
|
||||
const det = slugifyLocal(excerpt.split("\n").join(" "));
|
||||
if (det) return det.split("-").slice(0, 8).join("-");
|
||||
}
|
||||
|
||||
const { timeStr } = formatDatePartsInTimezone(fallbackDate, timeZone);
|
||||
return timeStr.replace(/:/g, "").slice(0, 4);
|
||||
}
|
||||
|
||||
const handler = async (event: {
|
||||
type: string;
|
||||
action: string;
|
||||
sessionKey: string;
|
||||
timestamp: Date;
|
||||
context?: AnyObj;
|
||||
}) => {
|
||||
if (event.type !== "command" || (event.action !== "new" && event.action !== "reset")) return;
|
||||
|
||||
try {
|
||||
const context = asObj(event.context);
|
||||
const cfg = asObj(context.cfg);
|
||||
|
||||
const workspaceDirRaw = context.workspaceDir;
|
||||
const workspaceDir =
|
||||
typeof workspaceDirRaw === "string" && workspaceDirRaw.trim().length > 0
|
||||
? workspaceDirRaw
|
||||
: "/root/.openclaw/workspace";
|
||||
|
||||
const sessionEntry = asObj(context.previousSessionEntry ?? context.sessionEntry);
|
||||
const sessionId = typeof sessionEntry.sessionId === "string" ? sessionEntry.sessionId : "unknown";
|
||||
const initialSessionFile =
|
||||
typeof sessionEntry.sessionFile === "string" && sessionEntry.sessionFile.trim().length > 0
|
||||
? sessionEntry.sessionFile
|
||||
: undefined;
|
||||
|
||||
const sessionFile = await resolveSessionFile({
|
||||
workspaceDir,
|
||||
currentSessionFile: initialSessionFile,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!sessionFile) return;
|
||||
|
||||
const fullContext = await getFullSessionContent(sessionFile);
|
||||
if (!fullContext) return;
|
||||
|
||||
const timeZone = resolveTimezone(cfg);
|
||||
const now = new Date(event.timestamp);
|
||||
const { dateStr, timeStr } = formatDatePartsInTimezone(now, timeZone);
|
||||
|
||||
const excerpt = buildSlugExcerpt(fullContext);
|
||||
const slug = await generateSlug({ excerpt, cfg, fallbackDate: now, timeZone });
|
||||
|
||||
const memoryDir = path.join(workspaceDir, "memory");
|
||||
await fs.mkdir(memoryDir, { recursive: true });
|
||||
|
||||
const filename = `${dateStr}-${slug}.md`;
|
||||
const memoryFilePath = path.join(memoryDir, filename);
|
||||
|
||||
const source = typeof context.commandSource === "string" ? context.commandSource : "unknown";
|
||||
|
||||
const out = [
|
||||
`# Session: ${dateStr} ${timeStr} (${timeZone})`,
|
||||
"",
|
||||
`- **Session Key**: ${event.sessionKey}`,
|
||||
`- **Session ID**: ${sessionId}`,
|
||||
`- **Source**: ${source}`,
|
||||
"",
|
||||
"## Full Session Context",
|
||||
"",
|
||||
fullContext,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
await fs.writeFile(memoryFilePath, out, "utf-8");
|
||||
console.log(`[session-memory-cus] Saved session context: ${memoryFilePath}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
||||
console.error(`[session-memory-cus] Failed: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
||||
Reference in New Issue
Block a user