323 lines
9.1 KiB
TypeScript
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;
|