feat: Archivierte Session-Versionen (OpenClaw .reset.* Files) in Diagnostic
OpenClaw resettet Sessions beim ersten chat.send nach Container-Restart (wenn abortedLastRun / systemSent Inkonsistenz erkannt wurde) und benennt die alte .jsonl in .jsonl.reset.<timestamp>.Z um. Der Inhalt war also gar nicht verloren, nur unsichtbar. Diagnostic: - handleListSessions scannt jetzt auch *.jsonl.reset.* Files - Reset-Files bekommen archived:true + resetAt-Timestamp - Neue UI-Sektion "Archivierte Versionen" (collapsible <details>) mit Export-Button, zeigt aufklappbar alle gesicherten alten Sessions - Aktivieren ist fuer Archive deaktiviert (zerstoert aktive Session) - Loeschen + Export stehen zur Verfuegung tools/export-jsonl-to-md.js: - Standalone Node-Script zum Konvertieren beliebiger .jsonl (auch reset-Files) - Nutzbar via stdin, exakt gleiche Export-Logik wie Diagnostic - Fuer Rettungsaktionen direkt auf der VM Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
87deede078
commit
76d72a1eef
|
|
@ -1703,33 +1703,60 @@
|
||||||
: '<div style="color:#555570;padding:8px;text-align:center;">Keine Sessions gefunden</div>';
|
: '<div style="color:#555570;padding:8px;text-align:center;">Keine Sessions gefunden</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = '<table style="width:100%;border-collapse:collapse;">';
|
|
||||||
html += '<tr style="color:#8888AA;font-size:10px;text-align:left;border-bottom:1px solid #1E1E2E;">'
|
const active = data.sessions.filter(s => !s.archived);
|
||||||
|
const archives = data.sessions.filter(s => s.archived);
|
||||||
|
|
||||||
|
const headerRow = '<tr style="color:#8888AA;font-size:10px;text-align:left;border-bottom:1px solid #1E1E2E;">'
|
||||||
+ '<th style="padding:4px 6px;">Session</th>'
|
+ '<th style="padding:4px 6px;">Session</th>'
|
||||||
+ '<th style="padding:4px 6px;">Msgs</th>'
|
+ '<th style="padding:4px 6px;">Msgs</th>'
|
||||||
+ '<th style="padding:4px 6px;">Zuletzt</th>'
|
+ '<th style="padding:4px 6px;">Zuletzt</th>'
|
||||||
+ '<th style="padding:4px 6px;"></th></tr>';
|
+ '<th style="padding:4px 6px;"></th></tr>';
|
||||||
for (const s of data.sessions) {
|
|
||||||
|
const rowFor = (s, opts) => {
|
||||||
const date = s.modified ? new Date(s.modified * 1000).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '?';
|
const date = s.modified ? new Date(s.modified * 1000).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '?';
|
||||||
const key = escapeHtml(s.sessionKey || s.path.split('/').pop());
|
const key = escapeHtml(s.sessionKey || s.path.split('/').pop());
|
||||||
const orphanBadge = s.orphan ? ' <span style="background:#FF3B30;color:#fff;font-size:9px;padding:1px 4px;border-radius:3px;">verwaist</span>' : '';
|
const orphanBadge = s.orphan ? ' <span style="background:#FF3B30;color:#fff;font-size:9px;padding:1px 4px;border-radius:3px;">verwaist</span>' : '';
|
||||||
|
const archivedBadge = s.archived ? ' <span style="background:#555570;color:#fff;font-size:9px;padding:1px 4px;border-radius:3px;">archiv</span>' : '';
|
||||||
const modelBadge = s.model ? `<div style="font-size:9px;color:#555570;">${escapeHtml(s.model)}</div>` : '';
|
const modelBadge = s.model ? `<div style="font-size:9px;color:#555570;">${escapeHtml(s.model)}</div>` : '';
|
||||||
const isActive = (s.sessionKey === currentActiveSession);
|
const isActive = (s.sessionKey === currentActiveSession) && !s.archived;
|
||||||
const keyColor = isActive ? '#34C759' : (s.orphan ? '#555570' : '#E0E0F0');
|
const keyColor = isActive ? '#34C759' : (s.archived || s.orphan ? '#8888AA' : '#E0E0F0');
|
||||||
const activeBadge = isActive ? ' <span style="background:#34C759;color:#000;font-size:9px;padding:1px 4px;border-radius:3px;">aktiv</span>' : '';
|
const activeBadge = isActive ? ' <span style="background:#34C759;color:#000;font-size:9px;padding:1px 4px;border-radius:3px;">aktiv</span>' : '';
|
||||||
const rowBg = isActive ? 'background:rgba(52,199,89,0.08);' : '';
|
const rowBg = isActive ? 'background:rgba(52,199,89,0.08);' : (s.archived ? 'background:rgba(136,136,170,0.04);' : '');
|
||||||
html += `<tr style="border-bottom:1px solid #0D0D1A;cursor:pointer;${rowBg}" onmouseover="this.style.background='#1E1E2E'" onmouseout="this.style.background='${isActive ? 'rgba(52,199,89,0.08)' : ''}'">`
|
|
||||||
|
let actions = '';
|
||||||
|
if (s.archived) {
|
||||||
|
// Archive: nur Export + Loeschen (kein Aktivieren — wuerde aktive Session ueberschreiben)
|
||||||
|
actions = `<button class="btn secondary" onclick="event.stopPropagation();deleteSession('${escapeHtml(s.path)}')" style="padding:2px 6px;font-size:10px;color:#FF6B6B;margin-right:2px;" title="Archiv endgueltig loeschen">X</button>`
|
||||||
|
+ `<button class="btn secondary" onclick="event.stopPropagation();exportSession('${escapeHtml(s.path)}','${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#8888AA;" title="Als Markdown exportieren">⬇</button>`;
|
||||||
|
} else {
|
||||||
|
actions = (isActive ? '' : `<button class="btn secondary" onclick="event.stopPropagation();activateSession('${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#34C759;margin-right:2px;" title="Aktivieren">▶</button>`)
|
||||||
|
+ `<button class="btn secondary" onclick="event.stopPropagation();deleteSession('${escapeHtml(s.path)}')" style="padding:2px 6px;font-size:10px;color:#FF6B6B;margin-right:2px;" title="Loeschen">X</button>`
|
||||||
|
+ `<button class="btn secondary" onclick="event.stopPropagation();exportSession('${escapeHtml(s.path)}','${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#8888AA;" title="Als Markdown exportieren">⬇</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<tr style="border-bottom:1px solid #0D0D1A;cursor:pointer;${rowBg}" onmouseover="this.style.background='#1E1E2E'" onmouseout="this.style.background='${isActive ? 'rgba(52,199,89,0.08)' : (s.archived ? 'rgba(136,136,170,0.04)' : '')}'">`
|
||||||
+ `<td style="padding:4px 6px;" onclick="viewSession('${escapeHtml(s.path)}')">`
|
+ `<td style="padding:4px 6px;" onclick="viewSession('${escapeHtml(s.path)}')">`
|
||||||
+ `<div style="color:${keyColor};">${key}${activeBadge}${orphanBadge}</div>${modelBadge}</td>`
|
+ `<div style="color:${keyColor};">${key}${activeBadge}${orphanBadge}${archivedBadge}</div>${modelBadge}</td>`
|
||||||
+ `<td style="padding:4px 6px;color:#8888AA;">${s.lines}</td>`
|
+ `<td style="padding:4px 6px;color:#8888AA;">${s.lines}</td>`
|
||||||
+ `<td style="padding:4px 6px;color:#8888AA;font-size:10px;">${date}</td>`
|
+ `<td style="padding:4px 6px;color:#8888AA;font-size:10px;">${date}</td>`
|
||||||
+ `<td style="padding:4px 6px;white-space:nowrap;">`
|
+ `<td style="padding:4px 6px;white-space:nowrap;">${actions}</td></tr>`;
|
||||||
+ (isActive ? '' : `<button class="btn secondary" onclick="event.stopPropagation();activateSession('${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#34C759;margin-right:2px;" title="Aktivieren">▶</button>`)
|
};
|
||||||
+ `<button class="btn secondary" onclick="event.stopPropagation();deleteSession('${escapeHtml(s.path)}')" style="padding:2px 6px;font-size:10px;color:#FF6B6B;margin-right:2px;" title="Loeschen">X</button>`
|
|
||||||
+ `<button class="btn secondary" onclick="event.stopPropagation();exportSession('${escapeHtml(s.path)}','${escapeHtml(s.sessionKey)}')" style="padding:2px 6px;font-size:10px;color:#8888AA;" title="Als Markdown exportieren">⬇</button>`
|
let html = '<table style="width:100%;border-collapse:collapse;">' + headerRow;
|
||||||
+ `</td></tr>`;
|
for (const s of active) html += rowFor(s);
|
||||||
}
|
|
||||||
html += '</table>';
|
html += '</table>';
|
||||||
|
|
||||||
|
if (archives.length > 0) {
|
||||||
|
html += `<details style="margin-top:12px;" ${archives.length <= 5 ? 'open' : ''}>`
|
||||||
|
+ `<summary style="color:#8888AA;font-size:11px;cursor:pointer;padding:4px 0;">`
|
||||||
|
+ `Archivierte Versionen (${archives.length}) — von OpenClaw beim Session-Reset gesichert`
|
||||||
|
+ `</summary>`
|
||||||
|
+ `<table style="width:100%;border-collapse:collapse;margin-top:6px;">` + headerRow;
|
||||||
|
for (const s of archives) html += rowFor(s);
|
||||||
|
html += '</table></details>';
|
||||||
|
}
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1575,14 +1575,13 @@ async function handleListSessions(clientWs) {
|
||||||
try {
|
try {
|
||||||
log("info", "server", "Lade Sessions aus aria-core...");
|
log("info", "server", "Lade Sessions aus aria-core...");
|
||||||
|
|
||||||
// sessions.json als Index lesen + Datei-Details holen
|
// sessions.json als Index lesen + Datei-Details holen (inkl. .reset.* Archive)
|
||||||
const raw = await dockerExec("aria-core", `
|
const raw = await dockerExec("aria-core", `
|
||||||
cat ${SESSIONS_DIR}/sessions.json 2>/dev/null || echo '{}' &&
|
cat ${SESSIONS_DIR}/sessions.json 2>/dev/null || echo '{}' &&
|
||||||
echo '===FILE_DETAILS===' &&
|
echo '===FILE_DETAILS===' &&
|
||||||
for f in ${SESSIONS_DIR}/*.jsonl; do
|
for f in ${SESSIONS_DIR}/*.jsonl ${SESSIONS_DIR}/*.jsonl.reset.*; do
|
||||||
[ -f "$f" ] || continue
|
[ -f "$f" ] || continue
|
||||||
name=$(basename "$f")
|
name=$(basename "$f")
|
||||||
# Nur echte User/Assistant Messages zaehlen — nicht Tool-Calls, Events etc.
|
|
||||||
msgs=$(grep -cE '"role":"(user|assistant)"' "$f" 2>/dev/null || echo 0)
|
msgs=$(grep -cE '"role":"(user|assistant)"' "$f" 2>/dev/null || echo 0)
|
||||||
size=$(du -h "$f" 2>/dev/null | cut -f1)
|
size=$(du -h "$f" 2>/dev/null | cut -f1)
|
||||||
modified=$(stat -c '%Y' "$f" 2>/dev/null || echo 0)
|
modified=$(stat -c '%Y' "$f" 2>/dev/null || echo 0)
|
||||||
|
|
@ -1641,8 +1640,29 @@ async function handleListSessions(clientWs) {
|
||||||
delete fileDetails[filename];
|
delete fileDetails[filename];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dateien die nicht im Index stehen (Waisen / Reset-Files)
|
// Dateien die nicht im Index stehen (Waisen ODER Reset-Archive)
|
||||||
for (const [filename, details] of Object.entries(fileDetails)) {
|
for (const [filename, details] of Object.entries(fileDetails)) {
|
||||||
|
// .jsonl.reset.<ISO-Timestamp>.Z → archivierte Session (OpenClaw-Reset)
|
||||||
|
const resetMatch = filename.match(/^([a-f0-9-]+)\.jsonl\.reset\.(.+)\.Z$/);
|
||||||
|
if (resetMatch) {
|
||||||
|
const id = resetMatch[1];
|
||||||
|
// Timestamp ISO-8601 parsen (in Dateinamen: : durch - ersetzt)
|
||||||
|
// z.B. 2026-04-18T09-49-44.814 → 2026-04-18T09:49:44.814Z
|
||||||
|
const tsStr = resetMatch[2].replace(/T(\d{2})-(\d{2})-(\d{2})/, "T$1:$2:$3");
|
||||||
|
const resetAt = Math.floor(new Date(tsStr + "Z").getTime() / 1000) || parseInt(details.MODIFIED) || 0;
|
||||||
|
sessions.push({
|
||||||
|
path: `${SESSIONS_DIR}/${filename}`,
|
||||||
|
sessionKey: id.slice(0, 8) + "… (archiv)",
|
||||||
|
sessionId: id,
|
||||||
|
lines: parseInt(details.LINES) || 0,
|
||||||
|
size: details.SIZE || "?",
|
||||||
|
modified: resetAt,
|
||||||
|
archived: true,
|
||||||
|
resetAt,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Echte Waisen (UUID.jsonl ohne Eintrag in sessions.json)
|
||||||
const id = filename.replace(".jsonl", "");
|
const id = filename.replace(".jsonl", "");
|
||||||
sessions.push({
|
sessions.push({
|
||||||
path: `${SESSIONS_DIR}/${filename}`,
|
path: `${SESSIONS_DIR}/${filename}`,
|
||||||
|
|
|
||||||
2
issue.md
2
issue.md
|
|
@ -39,6 +39,8 @@
|
||||||
- [x] App: Audio-Aufnahme explizit 16kHz mono (spart Resample, optimal fuer Whisper)
|
- [x] App: Audio-Aufnahme explizit 16kHz mono (spart Resample, optimal fuer Whisper)
|
||||||
- [x] Gespraechsmodus: Speech-Gate strenger (-28dB / 500ms) — keine Umgebungsgeraeusche mehr
|
- [x] Gespraechsmodus: Speech-Gate strenger (-28dB / 500ms) — keine Umgebungsgeraeusche mehr
|
||||||
- [x] Gespraechsmodus: Max-Dauer 30s pro Aufnahme, Cache-Cleanup alter Files, Messages-Array gekappt (500)
|
- [x] Gespraechsmodus: Max-Dauer 30s pro Aufnahme, Cache-Cleanup alter Files, Messages-Array gekappt (500)
|
||||||
|
- [x] Diagnostic: Archivierte Session-Versionen (.reset.*) werden angezeigt + exportierbar — OpenClaw resettet Sessions bei erster Nutzung nach Container-Restart, Inhalt ist aber in .reset.<timestamp> Dateien gesichert
|
||||||
|
- [x] tools/export-jsonl-to-md.js: CLI-Konverter fuer beliebige Session-JSONL zu Markdown
|
||||||
|
|
||||||
## Offen
|
## Offen
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Exportiert ein OpenClaw Session-JSONL (auch .reset.*) als Markdown.
|
||||||
|
*
|
||||||
|
* Nutzung:
|
||||||
|
* node export-jsonl-to-md.js <input.jsonl> [output.md]
|
||||||
|
*
|
||||||
|
* Oder direkt aus dem aria-core Container:
|
||||||
|
* docker exec aria-core cat /home/node/.openclaw/agents/main/sessions/<ID>.jsonl.reset.<TS> \
|
||||||
|
* | node export-jsonl-to-md.js - > output.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const inputArg = process.argv[2];
|
||||||
|
const outputArg = process.argv[3];
|
||||||
|
|
||||||
|
if (!inputArg) {
|
||||||
|
console.error("Usage: export-jsonl-to-md.js <input.jsonl|-> [output.md]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = inputArg === "-" ? fs.readFileSync(0, "utf-8") : fs.readFileSync(inputArg, "utf-8");
|
||||||
|
const lines = raw.split("\n").filter(l => l.trim());
|
||||||
|
|
||||||
|
const blocks = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
let obj;
|
||||||
|
try { obj = JSON.parse(line); } catch { continue; }
|
||||||
|
if (obj.type !== "message" || !obj.message) continue;
|
||||||
|
const role = obj.message.role;
|
||||||
|
if (role !== "user" && role !== "assistant") continue;
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
const content = obj.message.content;
|
||||||
|
if (typeof content === "string") text = content;
|
||||||
|
else if (Array.isArray(content)) text = content.filter(c => c.type === "text").map(c => c.text || "").join("\n");
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
if (role === "user") {
|
||||||
|
text = text.replace(/^Sender \(untrusted metadata\):[\s\S]*?```[\s\S]*?```\s*\n*/m, "").trim();
|
||||||
|
text = text.replace(/^\[.*?\]\s*/, "").trim();
|
||||||
|
} else {
|
||||||
|
text = text.replace(/^\[\[reply_to_\w+\]\]\s*/g, "").trim();
|
||||||
|
}
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
const ts = obj.message.timestamp || obj.timestamp || 0;
|
||||||
|
const when = ts ? new Date(ts).toISOString().replace("T", " ").slice(0, 19) : "";
|
||||||
|
const heading = role === "user" ? "## 🧑 User" : "## 🤖 ARIA";
|
||||||
|
blocks.push(`${heading}${when ? ` — ${when}` : ""}\n\n${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportedAt = new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||||
|
const title = inputArg === "-" ? "Session" : inputArg.split("/").pop().replace(/\.jsonl.*/, "");
|
||||||
|
const md = [
|
||||||
|
`# Session: ${title}`,
|
||||||
|
``,
|
||||||
|
`Exportiert: ${exportedAt} `,
|
||||||
|
`Quelle: ${inputArg === "-" ? "stdin" : inputArg}`,
|
||||||
|
`Nachrichten: ${blocks.length}`,
|
||||||
|
``,
|
||||||
|
`---`,
|
||||||
|
``,
|
||||||
|
blocks.join("\n\n---\n\n"),
|
||||||
|
``,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
if (outputArg) {
|
||||||
|
fs.writeFileSync(outputArg, md);
|
||||||
|
console.error(`OK: ${blocks.length} Nachrichten → ${outputArg}`);
|
||||||
|
} else {
|
||||||
|
process.stdout.write(md);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue