Files

323 lines
9.1 KiB
TypeScript

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 getUniqueFilePath(dir: string, filename: string): Promise<string> {
const candidate = path.join(dir, filename);
try {
await fs.access(candidate);
} catch {
return candidate;
}
const parsed = path.parse(filename);
for (let i = 1; ; i += 1) {
const name = `${parsed.name}-${i}${parsed.ext}`;
const p = path.join(dir, name);
try {
await fs.access(p);
} catch {
return p;
}
}
}
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 = await getUniqueFilePath(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;