Files
Hy-Memory/plugins/openclaw/cli/import-from-openclaw.ts
2026-10-16 16:26:27 +08:00

236 lines
8.2 KiB
TypeScript

/**
* import-from-openclaw — CLI orchestration.
*
* Wires the dependency-free core (import-core.ts) to the interactive prompt
* (@clack/prompts), a zero-dependency progress bar, server pre-flight checks
* and workspace resolution. Dynamically imported by index.ts so the main
* plugin bundle stays lean.
*/
import { homedir } from "node:os";
import { isAbsolute, resolve } from "node:path";
import { intro, outro, confirm, isCancel, cancel, note } from "@clack/prompts";
import type { ImportProvider, ImportSummary, ProgressState } from "./import-core.js";
import {
DEFAULT_IMPORT_SESSION_ID,
expandHome,
resolveSourcesFromConfig,
runImport,
scanUnits,
} from "./import-core.js";
export interface ImportCliOptions {
/** Explicit OpenClaw agent workspace dir (auto-detected from config if omitted). */
workspace?: string;
/** OpenClaw agent id (used to locate workspace + sessions). */
agent: string;
/** OpenClaw state dir holding openclaw.json (default: ~/.openclaw). */
stateDir?: string;
/** Target HY Memory user id (default: cfg.userId). */
userId?: string;
dryRun?: boolean;
force?: boolean;
/** Retries for transient errors (429 / rate-limit / 5xx). */
maxRetries?: number;
/** Base backoff in ms (exponential + jitter). */
retryBaseMs?: number;
/** Skip the confirmation prompt. */
yes?: boolean;
}
export interface ImportCliDeps {
provider: ImportProvider & { healthCheck(): Promise<boolean> };
cfg: { userId: string; agentId: string; serverUrl: string; serverPort: number; pythonPath: string };
}
// ============================================================================
// Zero-dependency progress bar
// ============================================================================
function truncate(s: string, n: number): string {
return s.length <= n ? s : s.slice(0, n - 1) + "…";
}
function fmtSecs(s: number): string {
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
return `${m}m${String(s % 60).padStart(2, "0")}s`;
}
function createProgressBar(total: number, stream: NodeJS.WriteStream = process.stderr) {
const isTTY = Boolean(stream.isTTY);
let lastLen = 0;
let lastDone = -1;
const render = (s: ProgressState) => {
const pct = s.total ? Math.floor((s.done / s.total) * 100) : 100;
const width = 24;
const ratio = s.total ? s.done / s.total : 1;
const filled = Math.min(width, Math.round(width * ratio));
const bar = "█".repeat(filled) + "░".repeat(width - filled);
const elapsed = (Date.now() - s.startedAt) / 1000;
const eta =
s.done > 0 && s.done < s.total ? Math.round((elapsed / s.done) * (s.total - s.done)) : 0;
const tail = s.current ? truncate(s.current, 34) : "";
return ` [${bar}] ${s.done}/${s.total} ${pct}% | ok:${s.ok} skip:${s.skipped} err:${s.failed} | ETA ${fmtSecs(eta)} | ${tail}`;
};
return {
update(s: ProgressState) {
if (isTTY) {
const line = render(s);
const pad = line.length < lastLen ? " ".repeat(lastLen - line.length) : "";
stream.write("\r" + line + pad);
lastLen = line.length;
} else if (s.done !== lastDone) {
// Non-interactive: one concise line per completed unit.
lastDone = s.done;
if (s.done > 0) stream.write(render(s).trim() + "\n");
}
},
finish(s: ProgressState) {
if (isTTY) {
const line = render(s);
const pad = line.length < lastLen ? " ".repeat(lastLen - line.length) : "";
stream.write("\r" + line + pad + "\n");
}
},
};
}
// ============================================================================
// Source resolution
// ============================================================================
async function resolveSources(opts: ImportCliOptions): Promise<{
workspaceDir: string;
sessionsDirs: string[];
}> {
const stateDir = opts.stateDir ? expandHome(opts.stateDir) : undefined;
const fromCfg = await resolveSourcesFromConfig({ stateDir, agentId: opts.agent });
let workspaceDir = fromCfg.workspaceDir;
if (opts.workspace) {
const expanded = expandHome(opts.workspace);
workspaceDir = isAbsolute(expanded) ? expanded : resolve(process.cwd(), expanded);
}
return { workspaceDir, sessionsDirs: fromCfg.sessionsDirs };
}
// ============================================================================
// Command entry
// ============================================================================
export async function runImportFromOpenclaw(
opts: ImportCliOptions,
deps: ImportCliDeps,
): Promise<ImportSummary | null> {
intro("🧠 HY Memory — Import from OpenClaw memory-core");
const userId = opts.userId || deps.cfg.userId;
const { workspaceDir, sessionsDirs } = await resolveSources(opts);
note(
`Workspace: ${workspaceDir}\n` +
`Sessions: ${sessionsDirs.join(", ") || "(none)"}\n` +
`User ID: ${userId}`,
"Source",
);
// ── Pre-flight: server must be reachable (unless dry-run) ────────────────
if (!opts.dryRun) {
const healthy = await deps.provider.healthCheck();
if (!healthy) {
cancel(
`HY Memory server not reachable at ${deps.cfg.serverUrl}.\n` +
` Start it first (e.g. \`openclaw gateway restart\`), or run with --dry-run to preview.`,
);
return null;
}
}
// ── Plan ─────────────────────────────────────────────────────────────────
// Scope is automatic: MEMORY.md + daily notes; sessions only fill days that
// have no daily note; dreaming is not imported. (No user-facing "mode".)
const units = await scanUnits({ workspaceDir, sessionsDirs });
if (units.length === 0) {
outro("No memory files found — nothing to import.");
return {
total: 0,
ok: 0,
skipped: 0,
failed: 0,
failures: [],
elapsedMs: 0,
dryRun: Boolean(opts.dryRun),
};
}
const byKind = units.reduce<Record<string, number>>((acc, u) => {
acc[u.kind] = (acc[u.kind] ?? 0) + 1;
return acc;
}, {});
note(
Object.entries(byKind)
.map(([k, n]) => `${k}: ${n}`)
.join("\n") + `\n\nTotal units: ${units.length}`,
"Plan",
);
if (!opts.dryRun && !opts.yes) {
const ok = await confirm({ message: `Import ${units.length} unit(s) into HY Memory (user: ${userId})?` });
if (isCancel(ok) || !ok) {
cancel("Cancelled.");
return null;
}
}
// ── Run with progress bar ────────────────────────────────────────────────
const bar = createProgressBar(units.length);
let lastState: ProgressState | null = null;
const summary = await runImport(
{
workspaceDir,
sessionsDirs,
userId,
agentId: deps.cfg.agentId,
sessionId: DEFAULT_IMPORT_SESSION_ID,
dryRun: opts.dryRun,
force: opts.force,
maxRetries: opts.maxRetries,
retryBaseMs: opts.retryBaseMs,
onProgress: (s) => {
lastState = s;
bar.update(s);
},
},
opts.dryRun ? null : deps.provider,
);
if (lastState) bar.finish(lastState);
// ── Summary ──────────────────────────────────────────────────────────────
const verb = opts.dryRun ? "Would import" : "Imported";
const lines = [
`${verb}: ${summary.ok}`,
`Skipped (unchanged/empty): ${summary.skipped}`,
`Failed: ${summary.failed}`,
`Elapsed: ${fmtSecs(Math.round(summary.elapsedMs / 1000))}`,
];
if (summary.failures.length > 0) {
lines.push("", "Failures:");
for (const f of summary.failures.slice(0, 10)) lines.push(` - ${f.key}: ${f.error}`);
if (summary.failures.length > 10) lines.push(` … and ${summary.failures.length - 10} more`);
}
note(lines.join("\n"), opts.dryRun ? "Dry-run result" : "Import complete");
outro(
summary.failed > 0
? "Done with errors. Re-run to retry failed units (idempotent)."
: opts.dryRun
? "Dry-run only — no memories written. Re-run without --dry-run to import."
: "All done. ✓",
);
return summary;
}