Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
927
extensions/memory-wiki/src/chatgpt-import.ts
Normal file
927
extensions/memory-wiki/src/chatgpt-import.ts
Normal file
@@ -0,0 +1,927 @@
|
||||
// Memory Wiki plugin module implements chatgpt import behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
replaceManagedMarkdownBlock,
|
||||
withTrailingNewline,
|
||||
} from "openclaw/plugin-sdk/memory-host-markdown";
|
||||
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { compileMemoryWikiVault } from "./compile.js";
|
||||
import type { ResolvedMemoryWikiConfig } from "./config.js";
|
||||
import {
|
||||
countMemoryWikiImportRunStateRows,
|
||||
MEMORY_WIKI_IMPORT_RUN_STATE_MAX_ENTRIES,
|
||||
readMemoryWikiImportRunRecord,
|
||||
resolveMemoryWikiImportRunsDir,
|
||||
type ChatGptImportRunRecord,
|
||||
writeMemoryWikiImportRunRecord,
|
||||
} from "./import-runs-state.js";
|
||||
import { appendMemoryWikiLog } from "./log.js";
|
||||
import {
|
||||
parseWikiMarkdown,
|
||||
renderWikiMarkdown,
|
||||
WIKI_RELATED_END_MARKER,
|
||||
WIKI_RELATED_START_MARKER,
|
||||
} from "./markdown.js";
|
||||
import { resolveMemoryWikiTimestamp } from "./time.js";
|
||||
import { initializeMemoryWikiVault } from "./vault.js";
|
||||
|
||||
const CHATGPT_PREFERENCE_SIGNAL_RE =
|
||||
/\b(prefer|prefers|preference|want|wants|need|needs|avoid|avoids|hate|hates|love|loves|default to|should default to|always use|don't want|does not want|likes|dislikes)\b/i;
|
||||
const HUMAN_START_MARKER = "<!-- openclaw:human:start -->";
|
||||
const HUMAN_END_MARKER = "<!-- openclaw:human:end -->";
|
||||
|
||||
const CHATGPT_RISK_RULES: Array<{ label: string; pattern: RegExp }> = [
|
||||
{
|
||||
label: "relationships",
|
||||
pattern:
|
||||
/\b(relationship|dating|breakup|jealous|sex|intimacy|partner|apology|trust|boyfriend|girlfriend|husband|wife)\b/i,
|
||||
},
|
||||
{
|
||||
label: "health",
|
||||
pattern:
|
||||
/\b(supplement|medication|diagnosis|symptom|therapy|depression|anxiety|mri|migraine|injury|pain|cortisol|sleep)\b/i,
|
||||
},
|
||||
{
|
||||
label: "legal_tax",
|
||||
pattern:
|
||||
/\b(contract|tax|legal|law|lawsuit|visa|immigration|license|insurance|claim|non-residence|residency)\b/i,
|
||||
},
|
||||
{
|
||||
label: "finance",
|
||||
pattern:
|
||||
/\b(investment|invest|portfolio|dividend|yield|coupon|valuation|mortgage|loan|crypto|covered call|call option|put option)\b/i,
|
||||
},
|
||||
{
|
||||
label: "drugs",
|
||||
pattern: /\b(vape|weed|cannabis|nicotine|opioid|ketamine)\b/i,
|
||||
},
|
||||
];
|
||||
|
||||
type ChatGptMessage = {
|
||||
role: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ChatGptRiskAssessment = {
|
||||
level: "low" | "medium" | "high";
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
type ChatGptConversationRecord = {
|
||||
conversationId: string;
|
||||
title: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
sourcePath: string;
|
||||
pageId: string;
|
||||
pagePath: string;
|
||||
labels: string[];
|
||||
risk: ChatGptRiskAssessment;
|
||||
userMessageCount: number;
|
||||
assistantMessageCount: number;
|
||||
preferenceSignals: string[];
|
||||
firstUserLine?: string;
|
||||
lastUserLine?: string;
|
||||
transcript: ChatGptMessage[];
|
||||
};
|
||||
|
||||
type ChatGptImportOperation = "create" | "update" | "skip";
|
||||
|
||||
type ChatGptImportAction = {
|
||||
conversationId: string;
|
||||
title: string;
|
||||
pagePath: string;
|
||||
operation: ChatGptImportOperation;
|
||||
riskLevel: ChatGptRiskAssessment["level"];
|
||||
labels: string[];
|
||||
userMessageCount: number;
|
||||
assistantMessageCount: number;
|
||||
preferenceSignals: string[];
|
||||
};
|
||||
|
||||
export type ChatGptImportResult = {
|
||||
dryRun: boolean;
|
||||
exportPath: string;
|
||||
sourcePath: string;
|
||||
conversationCount: number;
|
||||
createdCount: number;
|
||||
updatedCount: number;
|
||||
skippedCount: number;
|
||||
actions: ChatGptImportAction[];
|
||||
pagePaths: string[];
|
||||
runId?: string;
|
||||
indexUpdatedFiles: string[];
|
||||
};
|
||||
|
||||
export type ChatGptRollbackResult = {
|
||||
runId: string;
|
||||
removedCount: number;
|
||||
restoredCount: number;
|
||||
pagePaths: string[];
|
||||
indexUpdatedFiles: string[];
|
||||
alreadyRolledBack: boolean;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function isMissingConversationPageError(error: unknown): boolean {
|
||||
return asRecord(error)?.code === "ENOENT";
|
||||
}
|
||||
|
||||
async function readExistingConversationPage(absolutePath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.readFile(absolutePath, "utf8");
|
||||
} catch {
|
||||
try {
|
||||
return await fs.readFile(absolutePath, "utf8");
|
||||
} catch (retryError) {
|
||||
if (isMissingConversationPageError(retryError)) {
|
||||
return "";
|
||||
}
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConversationSourcePath(exportInputPath: string): {
|
||||
exportPath: string;
|
||||
conversationsPath: string;
|
||||
} {
|
||||
const resolved = path.resolve(exportInputPath);
|
||||
const conversationsPath = resolved.endsWith(".json")
|
||||
? resolved
|
||||
: path.join(resolved, "conversations.json");
|
||||
return {
|
||||
exportPath: resolved,
|
||||
conversationsPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadConversations(exportInputPath: string): Promise<{
|
||||
exportPath: string;
|
||||
conversationsPath: string;
|
||||
conversations: Record<string, unknown>[];
|
||||
}> {
|
||||
const { exportPath, conversationsPath } = resolveConversationSourcePath(exportInputPath);
|
||||
const raw = await fs.readFile(conversationsPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
return {
|
||||
exportPath,
|
||||
conversationsPath,
|
||||
conversations: parsed.filter(
|
||||
(entry): entry is Record<string, unknown> => asRecord(entry) !== null,
|
||||
),
|
||||
};
|
||||
}
|
||||
const record = asRecord(parsed);
|
||||
if (record) {
|
||||
for (const value of Object.values(record)) {
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
exportPath,
|
||||
conversationsPath,
|
||||
conversations: value.filter(
|
||||
(entry): entry is Record<string, unknown> => asRecord(entry) !== null,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Unrecognized ChatGPT conversations export format: ${conversationsPath}`);
|
||||
}
|
||||
|
||||
function isoFromUnix(raw: unknown): string | undefined {
|
||||
if (typeof raw !== "number" && typeof raw !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const numeric = Number(raw);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return undefined;
|
||||
}
|
||||
return timestampMsToIsoString(numeric * 1000);
|
||||
}
|
||||
|
||||
function cleanMessageText(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (
|
||||
(trimmed.includes("asset_pointer") ||
|
||||
trimmed.includes("image_asset_pointer") ||
|
||||
trimmed.includes("dalle") ||
|
||||
trimmed.includes("file_service")) &&
|
||||
trimmed.length > 40
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
if (
|
||||
trimmed.startsWith("{") &&
|
||||
trimmed.length > 80 &&
|
||||
(trimmed.includes(":") || trimmed.includes("content_type"))
|
||||
) {
|
||||
const textMatch = trimmed.match(/["']text["']\s*:\s*(["'])(.+?)\1/s);
|
||||
return textMatch?.[2] ? normalizeWhitespace(textMatch[2]) : "";
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function extractMessageText(message: Record<string, unknown>): string {
|
||||
const content = asRecord(message.content);
|
||||
if (content) {
|
||||
const parts = content.parts;
|
||||
if (Array.isArray(parts)) {
|
||||
const collected: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (typeof part === "string") {
|
||||
const cleaned = cleanMessageText(part);
|
||||
if (cleaned) {
|
||||
collected.push(cleaned);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const partRecord = asRecord(part);
|
||||
if (partRecord && typeof partRecord.text === "string" && partRecord.text.trim()) {
|
||||
collected.push(partRecord.text.trim());
|
||||
}
|
||||
}
|
||||
return collected.join("\n").trim();
|
||||
}
|
||||
if (typeof content.text === "string") {
|
||||
return cleanMessageText(content.text);
|
||||
}
|
||||
}
|
||||
return typeof message.text === "string" ? cleanMessageText(message.text) : "";
|
||||
}
|
||||
|
||||
function activeBranchMessages(conversation: Record<string, unknown>): ChatGptMessage[] {
|
||||
const mapping = asRecord(conversation.mapping);
|
||||
if (!mapping) {
|
||||
return [];
|
||||
}
|
||||
let currentNode =
|
||||
typeof conversation.current_node === "string" ? conversation.current_node : undefined;
|
||||
const seen = new Set<string>();
|
||||
const chain: ChatGptMessage[] = [];
|
||||
while (currentNode && !seen.has(currentNode)) {
|
||||
seen.add(currentNode);
|
||||
const node = asRecord(mapping[currentNode]);
|
||||
if (!node) {
|
||||
break;
|
||||
}
|
||||
const message = asRecord(node.message);
|
||||
if (message) {
|
||||
const author = asRecord(message.author);
|
||||
const role = typeof author?.role === "string" ? author.role : "unknown";
|
||||
const text = extractMessageText(message);
|
||||
if (text) {
|
||||
chain.push({ role, text });
|
||||
}
|
||||
}
|
||||
currentNode = typeof node.parent === "string" ? node.parent : undefined;
|
||||
}
|
||||
return chain.toReversed();
|
||||
}
|
||||
|
||||
function inferRisk(title: string, sampleText: string): ChatGptRiskAssessment {
|
||||
const blob = `${title}\n${sampleText}`.toLowerCase();
|
||||
const reasons = CHATGPT_RISK_RULES.filter((rule) => rule.pattern.test(blob)).map(
|
||||
(rule) => rule.label,
|
||||
);
|
||||
if (reasons.length > 0) {
|
||||
return { level: "high", reasons: uniqueStrings(reasons) };
|
||||
}
|
||||
if (/\b(career|job|salary|interview|offer|resume|cover letter)\b/i.test(blob)) {
|
||||
return { level: "medium", reasons: ["work_career"] };
|
||||
}
|
||||
return { level: "low", reasons: [] };
|
||||
}
|
||||
|
||||
function inferLabels(title: string, sampleText: string): string[] {
|
||||
const blob = `${title}\n${sampleText}`.toLowerCase();
|
||||
const labels = new Set<string>(["domain/personal"]);
|
||||
const addAreaTopic = (area: string, topics: string[]) => {
|
||||
labels.add(area);
|
||||
for (const topic of topics) {
|
||||
labels.add(topic);
|
||||
}
|
||||
};
|
||||
const hasTranslation =
|
||||
/\b(translate|translation|traduc\w*|traducc\w*|traduç\w*|traducci[oó]n|traduccio|traducció|traduzione)\b/i.test(
|
||||
blob,
|
||||
);
|
||||
const hasLearning =
|
||||
/\b(anki|flashcards?|grammar|conjugat\w*|declension|pronunciation|vocab(?:ular(?:y|io))?|lesson|tutor|teacher|jlpt|kanji|hiragana|katakana|study|learn|practice)\b/i.test(
|
||||
blob,
|
||||
);
|
||||
const hasLanguageName =
|
||||
/\b(japanese|portuguese|catalan|castellano|espa[nñ]ol|franc[eé]s|french|italian|german|spanish)\b/i.test(
|
||||
blob,
|
||||
);
|
||||
if (hasTranslation) {
|
||||
labels.add("topic/translation");
|
||||
}
|
||||
if (
|
||||
hasLearning ||
|
||||
(hasLanguageName && /\b(learn|study|practice|lesson|tutor|grammar)\b/i.test(blob))
|
||||
) {
|
||||
addAreaTopic("area/language-learning", ["topic/language-learning"]);
|
||||
}
|
||||
if (
|
||||
/\b(hike|trail|hotel|flight|trip|travel|airport|itinerary|booking|airbnb|train|stay)\b/i.test(
|
||||
blob,
|
||||
)
|
||||
) {
|
||||
labels.add("area/travel");
|
||||
labels.add("topic/travel");
|
||||
}
|
||||
if (
|
||||
/\b(recipe|cook|cooking|bread|sourdough|pizza|espresso|coffee|mousse|cast iron|meatballs?)\b/i.test(
|
||||
blob,
|
||||
)
|
||||
) {
|
||||
addAreaTopic("area/cooking", ["topic/cooking"]);
|
||||
}
|
||||
if (
|
||||
/\b(garden|orchard|plant|soil|compost|agroforestry|permaculture|mulch|beds?|irrigation|seeds?)\b/i.test(
|
||||
blob,
|
||||
)
|
||||
) {
|
||||
addAreaTopic("area/gardening", ["topic/gardening"]);
|
||||
}
|
||||
if (/\b(dating|relationship|partner|jealous|breakup|trust)\b/i.test(blob)) {
|
||||
addAreaTopic("area/relationships", ["topic/relationships"]);
|
||||
}
|
||||
if (
|
||||
/\b(investment|invest|portfolio|dividend|yield|coupon|valuation|return|mortgage|loan|kraken|crypto|covered call|call option|put option|option chain|bond|stocks?)\b/i.test(
|
||||
blob,
|
||||
)
|
||||
) {
|
||||
addAreaTopic("area/finance", ["topic/finance"]);
|
||||
}
|
||||
if (
|
||||
/\b(contract|mou|tax|impuesto|legal|law|lawsuit|visa|immigration|license|licencia|dispute|claim|insurance|non-residence|residency)\b/i.test(
|
||||
blob,
|
||||
)
|
||||
) {
|
||||
addAreaTopic("area/legal-tax", ["topic/legal-tax"]);
|
||||
}
|
||||
if (
|
||||
/\b(supplement|medication|diagnos(?:is|e)|symptom|therapy|depress(?:ion|ed)|anxiet(?:y|ies)|mri|migraine|injur(?:y|ies)|pain|cortisol|sleep|dentist|dermatolog(?:ist|y))\b/i.test(
|
||||
blob,
|
||||
)
|
||||
) {
|
||||
addAreaTopic("area/health", ["topic/health"]);
|
||||
}
|
||||
if (
|
||||
/\b(book (an )?appointment|rebook|open (a )?new account|driving test|exam|gestor(?:a)?|itv)\b/i.test(
|
||||
blob,
|
||||
)
|
||||
) {
|
||||
addAreaTopic("area/life-admin", ["topic/life-admin"]);
|
||||
}
|
||||
if (/\b(frc|robot|robotics|wpilib|limelight|chiefdelphi)\b/i.test(blob)) {
|
||||
addAreaTopic("area/work", ["topic/robotics"]);
|
||||
} else if (
|
||||
/\b(docker|git|python|node|npm|pip|sql|postgres|api|bug|stack trace|permission denied)\b/i.test(
|
||||
blob,
|
||||
)
|
||||
) {
|
||||
addAreaTopic("area/work", ["topic/software"]);
|
||||
} else if (/\b(job|interview|cover letter|resume|cv)\b/i.test(blob)) {
|
||||
addAreaTopic("area/work", ["topic/career"]);
|
||||
}
|
||||
if (/\b(wifi|wi-fi|starlink|router|mesh|network|orbi|milesight|coverage)\b/i.test(blob)) {
|
||||
addAreaTopic("area/home", ["topic/home-infrastructure"]);
|
||||
}
|
||||
if (
|
||||
/\b(p38|range rover|porsche|bmw|bobcat|excavator|auger|trailer|chainsaw|stihl)\b/i.test(blob)
|
||||
) {
|
||||
addAreaTopic("area/vehicles", ["topic/vehicles"]);
|
||||
}
|
||||
if (![...labels].some((label) => label.startsWith("area/"))) {
|
||||
labels.add("area/other");
|
||||
}
|
||||
return [...labels];
|
||||
}
|
||||
|
||||
function collectPreferenceSignals(userTexts: string[]): string[] {
|
||||
const signals: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const text of userTexts.slice(0, 25)) {
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = normalizeWhitespace(rawLine);
|
||||
if (!line || !CHATGPT_PREFERENCE_SIGNAL_RE.test(line)) {
|
||||
continue;
|
||||
}
|
||||
const key = line.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
signals.push(line);
|
||||
if (signals.length >= 10) {
|
||||
return signals;
|
||||
}
|
||||
}
|
||||
}
|
||||
return signals;
|
||||
}
|
||||
|
||||
function buildTranscript(messages: ChatGptMessage[]): string {
|
||||
if (messages.length === 0) {
|
||||
return "_No active-branch transcript could be reconstructed._";
|
||||
}
|
||||
return messages
|
||||
.flatMap((message) => [
|
||||
`### ${message.role[0]?.toUpperCase() ?? "U"}${message.role.slice(1)}`,
|
||||
"",
|
||||
message.text,
|
||||
"",
|
||||
])
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function resolveConversationPagePath(record: { conversationId: string; createdAt?: string }): {
|
||||
pageId: string;
|
||||
pagePath: string;
|
||||
} {
|
||||
const conversationSlug = record.conversationId.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
||||
const pageId = `source.chatgpt.${conversationSlug || createHash("sha1").update(record.conversationId).digest("hex").slice(0, 12)}`;
|
||||
const datePrefix = record.createdAt?.slice(0, 10) ?? "undated";
|
||||
const shortId = conversationSlug.slice(0, 8) || "export";
|
||||
return {
|
||||
pageId,
|
||||
pagePath: path
|
||||
.join("sources", `chatgpt-${datePrefix}-${conversationSlug || shortId}.md`)
|
||||
.replace(/\\/g, "/"),
|
||||
};
|
||||
}
|
||||
|
||||
function toConversationRecord(
|
||||
conversation: Record<string, unknown>,
|
||||
sourcePath: string,
|
||||
): ChatGptConversationRecord | null {
|
||||
const conversationId =
|
||||
typeof conversation.conversation_id === "string" ? conversation.conversation_id.trim() : "";
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
const title =
|
||||
typeof conversation.title === "string" && conversation.title.trim()
|
||||
? conversation.title.trim()
|
||||
: "Untitled conversation";
|
||||
const transcript = activeBranchMessages(conversation);
|
||||
const userTexts = transcript.filter((entry) => entry.role === "user").map((entry) => entry.text);
|
||||
const assistantTexts = transcript.filter((entry) => entry.role === "assistant");
|
||||
const sampleText = userTexts.slice(0, 6).join("\n");
|
||||
const risk = inferRisk(title, sampleText);
|
||||
const labels = inferLabels(title, sampleText);
|
||||
const { pageId, pagePath } = resolveConversationPagePath({
|
||||
conversationId,
|
||||
createdAt: isoFromUnix(conversation.create_time),
|
||||
});
|
||||
return {
|
||||
conversationId,
|
||||
title,
|
||||
createdAt: isoFromUnix(conversation.create_time),
|
||||
updatedAt: isoFromUnix(conversation.update_time) ?? isoFromUnix(conversation.create_time),
|
||||
sourcePath,
|
||||
pageId,
|
||||
pagePath,
|
||||
labels,
|
||||
risk,
|
||||
userMessageCount: userTexts.length,
|
||||
assistantMessageCount: assistantTexts.length,
|
||||
preferenceSignals: risk.level === "low" ? collectPreferenceSignals(userTexts) : [],
|
||||
firstUserLine: userTexts[0]?.split(/\r?\n/)[0]?.trim(),
|
||||
lastUserLine: userTexts.at(-1)?.split(/\r?\n/)[0]?.trim(),
|
||||
transcript,
|
||||
};
|
||||
}
|
||||
|
||||
function renderConversationPage(record: ChatGptConversationRecord): string {
|
||||
const autoDigestLines =
|
||||
record.risk.level === "low"
|
||||
? [
|
||||
`- User messages: ${record.userMessageCount}`,
|
||||
`- Assistant messages: ${record.assistantMessageCount}`,
|
||||
...(record.firstUserLine ? [`- First user line: ${record.firstUserLine}`] : []),
|
||||
...(record.lastUserLine ? [`- Last user line: ${record.lastUserLine}`] : []),
|
||||
...(record.preferenceSignals.length > 0
|
||||
? ["- Preference signals:", ...record.preferenceSignals.map((line) => ` - ${line}`)]
|
||||
: ["- Preference signals: none detected"]),
|
||||
]
|
||||
: [
|
||||
"- Auto digest withheld from durable-candidate generation until reviewed.",
|
||||
`- Risk reasons: ${record.risk.reasons.length > 0 ? record.risk.reasons.join(", ") : "none recorded"}`,
|
||||
];
|
||||
return renderWikiMarkdown({
|
||||
frontmatter: {
|
||||
pageType: "source",
|
||||
id: record.pageId,
|
||||
title: `ChatGPT Export: ${record.title}`,
|
||||
sourceType: "chatgpt-export",
|
||||
sourceSystem: "chatgpt",
|
||||
sourcePath: record.sourcePath,
|
||||
conversationId: record.conversationId,
|
||||
riskLevel: record.risk.level,
|
||||
riskReasons: record.risk.reasons,
|
||||
labels: record.labels,
|
||||
status: "draft",
|
||||
...(record.createdAt ? { createdAt: record.createdAt } : {}),
|
||||
...(record.updatedAt ? { updatedAt: record.updatedAt } : {}),
|
||||
},
|
||||
body: [
|
||||
`# ChatGPT Export: ${record.title}`,
|
||||
"",
|
||||
"## Source",
|
||||
`- Conversation id: \`${record.conversationId}\``,
|
||||
`- Export file: \`${record.sourcePath}\``,
|
||||
...(record.createdAt ? [`- Created: ${record.createdAt}`] : []),
|
||||
...(record.updatedAt ? [`- Updated: ${record.updatedAt}`] : []),
|
||||
"",
|
||||
"## Auto Triage",
|
||||
`- Risk level: \`${record.risk.level}\``,
|
||||
`- Labels: ${record.labels.join(", ")}`,
|
||||
`- Active-branch messages: ${record.transcript.length}`,
|
||||
"",
|
||||
"## Auto Digest",
|
||||
...autoDigestLines,
|
||||
"",
|
||||
"## Active Branch Transcript",
|
||||
buildTranscript(record.transcript),
|
||||
"",
|
||||
"## Notes",
|
||||
HUMAN_START_MARKER,
|
||||
HUMAN_END_MARKER,
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
function replaceSimpleManagedBlock(params: {
|
||||
original: string;
|
||||
startMarker: string;
|
||||
endMarker: string;
|
||||
replacement: string;
|
||||
}): string {
|
||||
const escapedStart = params.startMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const escapedEnd = params.endMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const blockPattern = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`);
|
||||
return params.original.replace(blockPattern, () => params.replacement);
|
||||
}
|
||||
|
||||
function extractSimpleManagedBlock(params: {
|
||||
body: string;
|
||||
startMarker: string;
|
||||
endMarker: string;
|
||||
}): string | null {
|
||||
const escapedStart = params.startMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const escapedEnd = params.endMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const blockPattern = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`);
|
||||
return params.body.match(blockPattern)?.[0] ?? null;
|
||||
}
|
||||
|
||||
function extractManagedBlockBody(params: {
|
||||
body: string;
|
||||
startMarker: string;
|
||||
endMarker: string;
|
||||
}): string | null {
|
||||
const escapedStart = params.startMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const escapedEnd = params.endMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const blockPattern = new RegExp(`${escapedStart}\\n?([\\s\\S]*?)\\n?${escapedEnd}`);
|
||||
const captured = params.body.match(blockPattern)?.[1];
|
||||
return typeof captured === "string" ? captured.trim() : null;
|
||||
}
|
||||
|
||||
function preserveExistingPageBlocks(rendered: string, existing: string): string {
|
||||
if (!existing.trim()) {
|
||||
return withTrailingNewline(rendered);
|
||||
}
|
||||
const parsedExisting = parseWikiMarkdown(existing);
|
||||
const parsedRendered = parseWikiMarkdown(rendered);
|
||||
let nextBody = parsedRendered.body;
|
||||
|
||||
const humanBlock = extractSimpleManagedBlock({
|
||||
body: parsedExisting.body,
|
||||
startMarker: HUMAN_START_MARKER,
|
||||
endMarker: HUMAN_END_MARKER,
|
||||
});
|
||||
if (humanBlock) {
|
||||
nextBody = replaceSimpleManagedBlock({
|
||||
original: nextBody,
|
||||
startMarker: HUMAN_START_MARKER,
|
||||
endMarker: HUMAN_END_MARKER,
|
||||
replacement: humanBlock,
|
||||
});
|
||||
}
|
||||
|
||||
const relatedBody = extractManagedBlockBody({
|
||||
body: parsedExisting.body,
|
||||
startMarker: WIKI_RELATED_START_MARKER,
|
||||
endMarker: WIKI_RELATED_END_MARKER,
|
||||
});
|
||||
if (relatedBody) {
|
||||
nextBody = replaceManagedMarkdownBlock({
|
||||
original: nextBody,
|
||||
heading: "## Related",
|
||||
startMarker: WIKI_RELATED_START_MARKER,
|
||||
endMarker: WIKI_RELATED_END_MARKER,
|
||||
body: relatedBody,
|
||||
});
|
||||
}
|
||||
|
||||
return withTrailingNewline(
|
||||
renderWikiMarkdown({
|
||||
frontmatter: parsedRendered.frontmatter,
|
||||
body: nextBody,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function buildRunId(exportPath: string, nowIso: string): string {
|
||||
const seed = `${exportPath}:${nowIso}:${Math.random()}`;
|
||||
return `chatgpt-${createHash("sha1").update(seed).digest("hex").slice(0, 12)}`;
|
||||
}
|
||||
|
||||
function normalizeConversationActions(
|
||||
records: ChatGptConversationRecord[],
|
||||
operations: Map<string, ChatGptImportOperation>,
|
||||
): ChatGptImportAction[] {
|
||||
return records.map((record) => ({
|
||||
conversationId: record.conversationId,
|
||||
title: record.title,
|
||||
pagePath: record.pagePath,
|
||||
operation: operations.get(record.pagePath) ?? "skip",
|
||||
riskLevel: record.risk.level,
|
||||
labels: record.labels,
|
||||
userMessageCount: record.userMessageCount,
|
||||
assistantMessageCount: record.assistantMessageCount,
|
||||
preferenceSignals: record.preferenceSignals,
|
||||
}));
|
||||
}
|
||||
|
||||
async function writeImportRunRecord(
|
||||
vaultRoot: string,
|
||||
record: ChatGptImportRunRecord,
|
||||
): Promise<void> {
|
||||
await writeMemoryWikiImportRunRecord(vaultRoot, record);
|
||||
}
|
||||
|
||||
async function readImportRunRecord(
|
||||
vaultRoot: string,
|
||||
runId: string,
|
||||
): Promise<ChatGptImportRunRecord> {
|
||||
const record = await readMemoryWikiImportRunRecord(vaultRoot, runId);
|
||||
if (!record) {
|
||||
throw new Error(`Memory Wiki import run not found: ${runId}`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async function writeTrackedImportPage(params: {
|
||||
vaultRoot: string;
|
||||
runDir: string;
|
||||
relativePath: string;
|
||||
existing: string;
|
||||
rendered: string;
|
||||
record: ChatGptImportRunRecord;
|
||||
}): Promise<ChatGptImportOperation> {
|
||||
const absolutePath = path.join(params.vaultRoot, params.relativePath);
|
||||
if (params.existing === params.rendered) {
|
||||
return "skip";
|
||||
}
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
if (!params.existing) {
|
||||
await fs.writeFile(absolutePath, params.rendered, "utf8");
|
||||
params.record.createdPaths.push(params.relativePath);
|
||||
return "create";
|
||||
}
|
||||
const snapshotHash = createHash("sha1").update(params.relativePath).digest("hex").slice(0, 12);
|
||||
const snapshotRelativePath = path.join("snapshots", `${snapshotHash}.md`).replace(/\\/g, "/");
|
||||
const snapshotAbsolutePath = path.join(params.runDir, snapshotRelativePath);
|
||||
await fs.mkdir(path.dirname(snapshotAbsolutePath), { recursive: true });
|
||||
await fs.writeFile(snapshotAbsolutePath, params.existing, "utf8");
|
||||
await fs.writeFile(absolutePath, params.rendered, "utf8");
|
||||
params.record.updatedPaths.push({
|
||||
path: params.relativePath,
|
||||
snapshotPath: snapshotRelativePath,
|
||||
});
|
||||
return "update";
|
||||
}
|
||||
|
||||
export async function importChatGptConversations(params: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
exportPath: string;
|
||||
dryRun?: boolean;
|
||||
nowMs?: number;
|
||||
}): Promise<ChatGptImportResult> {
|
||||
await initializeMemoryWikiVault(params.config, { nowMs: params.nowMs });
|
||||
const { exportPath, conversationsPath, conversations } = await loadConversations(
|
||||
params.exportPath,
|
||||
);
|
||||
const records = conversations
|
||||
.map((conversation) => toConversationRecord(conversation, conversationsPath))
|
||||
.filter((entry): entry is ChatGptConversationRecord => entry !== null)
|
||||
.toSorted((left, right) => left.pagePath.localeCompare(right.pagePath));
|
||||
|
||||
const operations = new Map<string, ChatGptImportOperation>();
|
||||
let createdCount = 0;
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let runId: string | undefined;
|
||||
const nowIso = resolveMemoryWikiTimestamp(params.nowMs);
|
||||
const importPlans: Array<{
|
||||
relativePath: string;
|
||||
existing: string;
|
||||
rendered: string;
|
||||
operation: ChatGptImportOperation;
|
||||
}> = [];
|
||||
|
||||
for (const record of records) {
|
||||
const rendered = renderConversationPage(record);
|
||||
const absolutePath = path.join(params.config.vault.path, record.pagePath);
|
||||
const existing = await readExistingConversationPage(absolutePath);
|
||||
const stabilized = preserveExistingPageBlocks(rendered, existing);
|
||||
const operation: ChatGptImportOperation =
|
||||
existing === stabilized ? "skip" : existing ? "update" : "create";
|
||||
operations.set(record.pagePath, operation);
|
||||
if (operation === "create") {
|
||||
createdCount += 1;
|
||||
} else if (operation === "update") {
|
||||
updatedCount += 1;
|
||||
} else {
|
||||
skippedCount += 1;
|
||||
}
|
||||
importPlans.push({
|
||||
relativePath: record.pagePath,
|
||||
existing,
|
||||
rendered: stabilized,
|
||||
operation,
|
||||
});
|
||||
}
|
||||
|
||||
let importRunRecord: ChatGptImportRunRecord | undefined;
|
||||
const changedCount = createdCount + updatedCount;
|
||||
|
||||
if (!params.dryRun && changedCount > 0) {
|
||||
const requiredStateRows = 1 + changedCount;
|
||||
const existingStateRows = await countMemoryWikiImportRunStateRows();
|
||||
const projectedStateRows = existingStateRows + requiredStateRows;
|
||||
if (projectedStateRows > MEMORY_WIKI_IMPORT_RUN_STATE_MAX_ENTRIES) {
|
||||
throw new Error(
|
||||
`Memory Wiki ChatGPT import exceeds SQLite import-run entry limit (${projectedStateRows}/${MEMORY_WIKI_IMPORT_RUN_STATE_MAX_ENTRIES})`,
|
||||
);
|
||||
}
|
||||
|
||||
runId = buildRunId(exportPath, nowIso);
|
||||
const importRunDir = path.join(resolveMemoryWikiImportRunsDir(params.config.vault.path), runId);
|
||||
importRunRecord = {
|
||||
version: 1,
|
||||
runId,
|
||||
importType: "chatgpt",
|
||||
exportPath,
|
||||
sourcePath: conversationsPath,
|
||||
appliedAt: nowIso,
|
||||
conversationCount: records.length,
|
||||
createdCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
createdPaths: [],
|
||||
updatedPaths: [],
|
||||
};
|
||||
|
||||
for (const plan of importPlans) {
|
||||
if (plan.operation === "skip") {
|
||||
continue;
|
||||
}
|
||||
await writeTrackedImportPage({
|
||||
vaultRoot: params.config.vault.path,
|
||||
runDir: importRunDir,
|
||||
relativePath: plan.relativePath,
|
||||
existing: plan.existing,
|
||||
rendered: plan.rendered,
|
||||
record: importRunRecord,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let indexUpdatedFiles: string[] = [];
|
||||
if (!params.dryRun && importRunRecord) {
|
||||
if (importRunRecord.createdPaths.length > 0 || importRunRecord.updatedPaths.length > 0) {
|
||||
const compile = await compileMemoryWikiVault(params.config);
|
||||
indexUpdatedFiles = compile.updatedFiles;
|
||||
await writeImportRunRecord(params.config.vault.path, importRunRecord);
|
||||
await appendMemoryWikiLog(params.config.vault.path, {
|
||||
type: "ingest",
|
||||
timestamp: nowIso,
|
||||
details: {
|
||||
sourceType: "chatgpt-export",
|
||||
runId: importRunRecord.runId,
|
||||
exportPath,
|
||||
sourcePath: conversationsPath,
|
||||
conversationCount: records.length,
|
||||
createdCount: importRunRecord.createdPaths.length,
|
||||
updatedCount: importRunRecord.updatedPaths.length,
|
||||
skippedCount,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
runId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dryRun: Boolean(params.dryRun),
|
||||
exportPath,
|
||||
sourcePath: conversationsPath,
|
||||
conversationCount: records.length,
|
||||
createdCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
actions: normalizeConversationActions(records, operations),
|
||||
pagePaths: records.map((record) => record.pagePath),
|
||||
...(runId ? { runId } : {}),
|
||||
indexUpdatedFiles,
|
||||
};
|
||||
}
|
||||
|
||||
export async function rollbackChatGptImportRun(params: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
runId: string;
|
||||
}): Promise<ChatGptRollbackResult> {
|
||||
await initializeMemoryWikiVault(params.config);
|
||||
const record = await readImportRunRecord(params.config.vault.path, params.runId);
|
||||
if (record.rolledBackAt) {
|
||||
return {
|
||||
runId: record.runId,
|
||||
removedCount: 0,
|
||||
restoredCount: 0,
|
||||
pagePaths: [
|
||||
...record.createdPaths,
|
||||
...record.updatedPaths.map((entry) => entry.path),
|
||||
].toSorted((left, right) => left.localeCompare(right)),
|
||||
indexUpdatedFiles: [],
|
||||
alreadyRolledBack: true,
|
||||
};
|
||||
}
|
||||
let removedCount = 0;
|
||||
for (const relativePath of record.createdPaths) {
|
||||
await fs
|
||||
.rm(path.join(params.config.vault.path, relativePath), { force: true })
|
||||
.catch(() => undefined);
|
||||
removedCount += 1;
|
||||
}
|
||||
let restoredCount = 0;
|
||||
const runDir = path.join(resolveMemoryWikiImportRunsDir(params.config.vault.path), record.runId);
|
||||
for (const entry of record.updatedPaths) {
|
||||
if (!entry.snapshotPath) {
|
||||
continue;
|
||||
}
|
||||
const snapshotPath = path.join(runDir, entry.snapshotPath);
|
||||
const snapshot = await fs.readFile(snapshotPath, "utf8");
|
||||
const targetPath = path.join(params.config.vault.path, entry.path);
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.writeFile(targetPath, snapshot, "utf8");
|
||||
restoredCount += 1;
|
||||
}
|
||||
const compile = await compileMemoryWikiVault(params.config);
|
||||
record.rolledBackAt = new Date().toISOString();
|
||||
await writeImportRunRecord(params.config.vault.path, record);
|
||||
await appendMemoryWikiLog(params.config.vault.path, {
|
||||
type: "ingest",
|
||||
timestamp: record.rolledBackAt,
|
||||
details: {
|
||||
sourceType: "chatgpt-export",
|
||||
runId: record.runId,
|
||||
rollback: true,
|
||||
removedCount,
|
||||
restoredCount,
|
||||
},
|
||||
});
|
||||
return {
|
||||
runId: record.runId,
|
||||
removedCount,
|
||||
restoredCount,
|
||||
pagePaths: [...record.createdPaths, ...record.updatedPaths.map((entry) => entry.path)].toSorted(
|
||||
(left, right) => left.localeCompare(right),
|
||||
),
|
||||
indexUpdatedFiles: compile.updatedFiles,
|
||||
alreadyRolledBack: false,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user