mirror of
https://github.com/obra/episodic-memory.git
synced 2026-09-14 13:43:14 +08:00
f156416c49
Claude-Session: https://claude.ai/code/session_0112vdwZphiWzfCYfaXMes4C
192 lines
6.3 KiB
JavaScript
192 lines
6.3 KiB
JavaScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import Database from 'better-sqlite3';
|
|
import { getOpencodeDbPath, getOpencodeTranscriptDir } from './paths.js';
|
|
function safeParseJson(value) {
|
|
if (!value)
|
|
return undefined;
|
|
try {
|
|
return JSON.parse(value);
|
|
}
|
|
catch {
|
|
return value;
|
|
}
|
|
}
|
|
function safeSegment(value) {
|
|
const trimmed = value.trim();
|
|
if (!trimmed)
|
|
return 'unknown';
|
|
return trimmed.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown';
|
|
}
|
|
function projectNameFromDirectory(directory) {
|
|
return safeSegment(path.basename(directory) || directory);
|
|
}
|
|
function dateFromMillis(value) {
|
|
const millis = Number.isFinite(value) ? value : Date.now();
|
|
const date = new Date(millis);
|
|
return Number.isNaN(date.getTime()) ? new Date() : date;
|
|
}
|
|
function tableExists(db, table) {
|
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
return Boolean(row);
|
|
}
|
|
export function getOpencodeTranscriptFilePath(transcriptDir, input) {
|
|
const project = projectNameFromDirectory(input.directory);
|
|
return path.join(transcriptDir, project, `opencode-${safeSegment(input.sessionId)}.jsonl`);
|
|
}
|
|
function shouldExportSession(filePath, sessionUpdatedMs) {
|
|
if (!fs.existsSync(filePath))
|
|
return true;
|
|
const stat = fs.statSync(filePath);
|
|
return Math.floor(stat.mtimeMs) < Math.floor(sessionUpdatedMs);
|
|
}
|
|
function writeSessionTranscript(db, session, filePath) {
|
|
const messages = db.prepare(`
|
|
SELECT id, session_id, time_created, time_updated, data
|
|
FROM message
|
|
WHERE session_id = ?
|
|
ORDER BY time_created, id
|
|
`).all(session.id);
|
|
const partsByMessage = new Map();
|
|
if (messages.length > 0) {
|
|
const parts = db.prepare(`
|
|
SELECT id, message_id, session_id, time_created, time_updated, data
|
|
FROM part
|
|
WHERE session_id = ?
|
|
ORDER BY time_created, id
|
|
`).all(session.id);
|
|
for (const part of parts) {
|
|
const list = partsByMessage.get(part.message_id) || [];
|
|
list.push(part);
|
|
partsByMessage.set(part.message_id, list);
|
|
}
|
|
}
|
|
const lines = [];
|
|
lines.push(JSON.stringify({
|
|
type: 'opencode_session',
|
|
session: {
|
|
id: session.id,
|
|
projectID: session.project_id,
|
|
slug: session.slug,
|
|
directory: session.directory,
|
|
title: session.title,
|
|
version: session.version,
|
|
agent: session.agent || undefined,
|
|
model: safeParseJson(session.model),
|
|
time: {
|
|
created: session.time_created,
|
|
updated: session.time_updated,
|
|
},
|
|
},
|
|
project: {
|
|
name: session.project_name || undefined,
|
|
worktree: session.project_worktree || undefined,
|
|
},
|
|
}));
|
|
for (const message of messages) {
|
|
const parsedMessage = safeParseJson(message.data);
|
|
const messageData = parsedMessage && typeof parsedMessage === 'object'
|
|
? parsedMessage
|
|
: { raw: parsedMessage };
|
|
const parts = (partsByMessage.get(message.id) || []).map(part => {
|
|
const parsedPart = safeParseJson(part.data);
|
|
const partData = parsedPart && typeof parsedPart === 'object'
|
|
? parsedPart
|
|
: { raw: parsedPart };
|
|
return {
|
|
...partData,
|
|
id: part.id,
|
|
sessionID: part.session_id,
|
|
messageID: part.message_id,
|
|
timeCreated: part.time_created,
|
|
timeUpdated: part.time_updated,
|
|
};
|
|
});
|
|
lines.push(JSON.stringify({
|
|
type: 'opencode_message',
|
|
sessionID: message.session_id,
|
|
message: {
|
|
...messageData,
|
|
id: message.id,
|
|
sessionID: message.session_id,
|
|
timeCreated: message.time_created,
|
|
timeUpdated: message.time_updated,
|
|
},
|
|
parts,
|
|
}));
|
|
}
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
const tempPath = `${filePath}.tmp.${process.pid}`;
|
|
fs.writeFileSync(tempPath, `${lines.join('\n')}\n`, 'utf-8');
|
|
fs.renameSync(tempPath, filePath);
|
|
const mtime = dateFromMillis(session.time_updated);
|
|
fs.utimesSync(filePath, mtime, mtime);
|
|
}
|
|
export function exportOpencodeSessions(options = {}) {
|
|
const dbPath = options.dbPath || getOpencodeDbPath();
|
|
const transcriptDir = options.transcriptDir || getOpencodeTranscriptDir();
|
|
const result = {
|
|
exported: 0,
|
|
skipped: 0,
|
|
errors: [],
|
|
dbPath,
|
|
transcriptDir,
|
|
};
|
|
if (!fs.existsSync(dbPath)) {
|
|
return result;
|
|
}
|
|
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
try {
|
|
if (!tableExists(db, 'session') || !tableExists(db, 'message') || !tableExists(db, 'part')) {
|
|
return result;
|
|
}
|
|
const sessions = db.prepare(`
|
|
SELECT
|
|
s.id,
|
|
s.project_id,
|
|
s.slug,
|
|
s.directory,
|
|
s.title,
|
|
s.version,
|
|
s.time_created,
|
|
s.time_updated,
|
|
s.agent,
|
|
s.model,
|
|
p.name AS project_name,
|
|
p.worktree AS project_worktree
|
|
FROM session s
|
|
LEFT JOIN project p ON p.id = s.project_id
|
|
ORDER BY s.time_updated, s.id
|
|
`).all();
|
|
for (const session of sessions) {
|
|
try {
|
|
const filePath = getOpencodeTranscriptFilePath(transcriptDir, {
|
|
sessionId: session.id,
|
|
directory: session.directory,
|
|
});
|
|
if (!shouldExportSession(filePath, session.time_updated)) {
|
|
result.skipped++;
|
|
continue;
|
|
}
|
|
writeSessionTranscript(db, session, filePath);
|
|
result.exported++;
|
|
}
|
|
catch (error) {
|
|
result.errors.push({
|
|
sessionId: session.id,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
catch (error) {
|
|
result.errors.push({
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
finally {
|
|
db.close();
|
|
}
|
|
return result;
|
|
}
|