fleet-memory/hindsight-integrations/openclaw/src/backfill-lib.ts
YUAN TIANJIAN 72fd3d59db
feat(openclaw): add config-aware history backfill CLI (#878)
* Add OpenClaw history backfill CLI

* Fix backfill resume and local daemon behavior

* Fix backfill checkpoint finalization semantics

* Fix symlinked backfill CLI entrypoint detection

* fix(ci): skip PR status write for fork approvals

---------

Co-authored-by: Nicolò Boschi <boschi1997@gmail.com>
2026-04-09 10:44:19 +02:00

294 lines
9 KiB
TypeScript

import { homedir } from 'os';
import { dirname, join, resolve } from 'path';
import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync } from 'fs';
import { deriveBankId, prepareRetentionTranscript } from './index.js';
import type { PluginConfig, PluginHookAgentContext } from './types.js';
export interface BackfillCliOptions {
openclawRoot: string;
includeArchive: boolean;
selectedAgents?: Set<string>;
limit?: number;
bankStrategy: 'mirror-config' | 'agent' | 'fixed';
fixedBank?: string;
}
export interface SessionMessage {
role: 'user' | 'assistant' | 'system' | 'tool';
content: string | Array<{ type?: string; text?: string }>;
}
export interface ParsedSessionFile {
filePath: string;
agentId: string;
sessionId: string;
sessionKey?: string;
startedAt?: string;
messages: SessionMessage[];
}
export interface BackfillPlanEntry {
filePath: string;
agentId: string;
sessionId: string;
startedAt?: string;
bankId: string;
documentId: string;
transcript: string;
messageCount: number;
}
export interface BackfillCheckpointEntry {
status: 'enqueued' | 'completed' | 'failed';
bankId: string;
filePath: string;
sessionId: string;
updatedAt: string;
error?: string;
}
export interface BackfillCheckpoint {
version: 1;
entries: Record<string, BackfillCheckpointEntry>;
}
interface RawBackfillCheckpointEntry extends Omit<BackfillCheckpointEntry, 'status'> {
status: BackfillCheckpointEntry['status'] | 'queued';
}
interface RawBackfillCheckpoint {
version: 1;
entries: Record<string, RawBackfillCheckpointEntry>;
}
interface SessionDirectory {
agentId: string;
path: string;
}
const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
dynamicBankId: true,
retainRoles: ['user', 'assistant'],
};
export function defaultOpenClawRoot(): string {
return resolve(join(homedir(), '.openclaw'));
}
export function defaultCheckpointPath(openclawRoot: string): string {
return join(openclawRoot, 'data', 'hindsight-backfill-checkpoint.json');
}
export function loadPluginConfigFromOpenClawRoot(openclawRoot: string): PluginConfig {
const configPath = join(openclawRoot, 'openclaw.json');
const raw = JSON.parse(readFileSync(configPath, 'utf8')) as {
plugins?: { entries?: Record<string, { config?: PluginConfig }> };
};
return {
...DEFAULT_PLUGIN_CONFIG,
...(raw.plugins?.entries?.['hindsight-openclaw']?.config || {}),
};
}
function extractTextContent(content: unknown): string {
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content
.filter((block): block is { type?: string; text?: string } => !!block && typeof block === 'object')
.filter((block) => block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text || '')
.join('\n');
}
return '';
}
function readJsonLines(filePath: string): unknown[] {
const content = readFileSync(filePath, 'utf8');
return content
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line));
}
export function parseSessionFile(filePath: string, agentId: string): ParsedSessionFile {
const records = readJsonLines(filePath) as Array<Record<string, any>>;
let sessionId = filePath.split('/').pop()?.replace(/\.jsonl$/, '') || 'session';
let sessionKey: string | undefined;
let startedAt: string | undefined;
const messages: SessionMessage[] = [];
for (const record of records) {
if (record.type === 'session') {
sessionId = typeof record.id === 'string' ? record.id : sessionId;
startedAt = typeof record.timestamp === 'string' ? record.timestamp : startedAt;
sessionKey = typeof record.sessionKey === 'string' ? record.sessionKey : sessionKey;
continue;
}
if (record.type !== 'message' || !record.message || typeof record.message !== 'object') {
continue;
}
const message = record.message as Record<string, unknown>;
const role = message.role;
if (role !== 'user' && role !== 'assistant' && role !== 'system' && role !== 'tool') {
continue;
}
const text = extractTextContent(message.content);
if (!text.trim()) {
continue;
}
messages.push({
role,
content: typeof message.content === 'string' ? message.content : [{ type: 'text', text }],
});
if (!sessionKey && typeof record.sessionKey === 'string') {
sessionKey = record.sessionKey;
}
}
return {
filePath,
agentId,
sessionId,
sessionKey,
startedAt,
messages,
};
}
function sessionDirectories(openclawRoot: string, includeArchive: boolean): SessionDirectory[] {
const agentsRoot = join(openclawRoot, 'agents');
if (!existsSync(agentsRoot)) {
return [];
}
const result: SessionDirectory[] = [];
for (const entry of readdirSync(agentsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const agentId = entry.name;
const sessionsDir = join(agentsRoot, agentId, 'sessions');
if (existsSync(sessionsDir)) {
result.push({ agentId, path: sessionsDir });
}
if (includeArchive) {
const archiveDir = join(agentsRoot, agentId, 'sessions-archive-from-migration_backup');
if (existsSync(archiveDir)) {
result.push({ agentId, path: archiveDir });
}
}
}
return result.sort((a, b) => a.agentId.localeCompare(b.agentId) || a.path.localeCompare(b.path));
}
export function discoverSessionFiles(openclawRoot: string, includeArchive: boolean): Array<{ agentId: string; filePath: string }> {
const sessions: Array<{ agentId: string; filePath: string }> = [];
for (const dir of sessionDirectories(openclawRoot, includeArchive)) {
for (const entry of readdirSync(dir.path, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
sessions.push({
agentId: dir.agentId,
filePath: join(dir.path, entry.name),
});
}
}
return sessions.sort((a, b) => a.agentId.localeCompare(b.agentId) || a.filePath.localeCompare(b.filePath));
}
function backfillContextForSession(session: ParsedSessionFile): PluginHookAgentContext {
return {
agentId: session.agentId,
sessionKey: session.sessionKey,
};
}
function deriveTargetBank(
session: ParsedSessionFile,
pluginConfig: PluginConfig,
bankStrategy: BackfillCliOptions['bankStrategy'],
fixedBank?: string,
): string {
if (bankStrategy === 'agent') {
return session.agentId;
}
if (bankStrategy === 'fixed') {
if (!fixedBank) {
throw new Error('fixed bank strategy requires --fixed-bank');
}
return fixedBank;
}
return deriveBankId(backfillContextForSession(session), pluginConfig);
}
export function stableDocumentId(session: ParsedSessionFile, bankId: string): string {
return `backfill::${bankId}::${session.agentId}::${session.sessionId}`;
}
export function buildBackfillPlan(
pluginConfig: PluginConfig,
opts: BackfillCliOptions,
): { entries: BackfillPlanEntry[]; discoveredSessions: number; skippedEmpty: number } {
const entries: BackfillPlanEntry[] = [];
let discoveredSessions = 0;
let skippedEmpty = 0;
for (const candidate of discoverSessionFiles(opts.openclawRoot, opts.includeArchive)) {
if (opts.selectedAgents && !opts.selectedAgents.has(candidate.agentId)) {
continue;
}
discoveredSessions += 1;
const parsed = parseSessionFile(candidate.filePath, candidate.agentId);
const retention = prepareRetentionTranscript(parsed.messages, pluginConfig, true);
if (!retention) {
skippedEmpty += 1;
continue;
}
const bankId = deriveTargetBank(parsed, pluginConfig, opts.bankStrategy, opts.fixedBank);
entries.push({
filePath: parsed.filePath,
agentId: parsed.agentId,
sessionId: parsed.sessionId,
startedAt: parsed.startedAt,
bankId,
documentId: stableDocumentId(parsed, bankId),
transcript: retention.transcript,
messageCount: retention.messageCount,
});
if (opts.limit && entries.length >= opts.limit) {
break;
}
}
return { entries, discoveredSessions, skippedEmpty };
}
export function loadCheckpoint(checkpointPath: string): BackfillCheckpoint {
if (!existsSync(checkpointPath)) {
return { version: 1, entries: {} };
}
const raw = JSON.parse(readFileSync(checkpointPath, 'utf8')) as RawBackfillCheckpoint;
if (raw.version !== 1 || !raw.entries || typeof raw.entries !== 'object') {
return { version: 1, entries: {} };
}
return {
version: 1,
entries: Object.fromEntries(
Object.entries(raw.entries).map(([key, entry]) => [
key,
{
...entry,
status: entry.status === 'queued' ? 'enqueued' : entry.status,
},
]),
) as Record<string, BackfillCheckpointEntry>,
};
}
export function saveCheckpoint(checkpointPath: string, checkpoint: BackfillCheckpoint): void {
mkdirSync(dirname(checkpointPath), { recursive: true });
writeFileSync(checkpointPath, JSON.stringify(checkpoint, null, 2) + '\n', 'utf8');
}
export function checkpointKey(entry: Pick<BackfillPlanEntry, 'bankId' | 'documentId'>): string {
return `${entry.bankId}::${entry.documentId}`;
}