Explorar el Código

feat: add bilingual CLI output (zh/en)

Extract all user-facing strings into localization.ts with zh/en
support. Progress text and runtime requirement messages are now
localized based on book language setting.
Ma hace 7 meses
padre
commit
ddf91b0fa6

+ 121 - 0
packages/cli/src/__tests__/localization.test.ts

@@ -0,0 +1,121 @@
+import { describe, expect, it } from "vitest";
+import {
+  formatBookCreateCreating,
+  formatBookCreateCreated,
+  formatBookCreateNextStep,
+  formatImportCanonComplete,
+  formatImportCanonStart,
+  formatImportChaptersComplete,
+  formatImportChaptersDiscovery,
+  formatImportChaptersResume,
+  formatWriteNextComplete,
+  formatWriteNextProgress,
+  formatWriteNextResultLines,
+} from "../localization.js";
+
+describe("CLI localization", () => {
+  it("formats book-create summaries in both languages", () => {
+    expect(formatBookCreateCreating("zh", "山河", "xuanhuan", "tomato"))
+      .toBe('创建书籍 "山河"(xuanhuan / tomato)...');
+    expect(formatBookCreateCreated("zh", "shan-he")).toBe("已创建书籍:shan-he");
+    expect(formatBookCreateNextStep("zh", "shan-he")).toBe("下一步:inkos write next shan-he");
+
+    expect(formatBookCreateCreating("en", "Harbor", "other", "other"))
+      .toBe('Creating book "Harbor" (other / other)...');
+    expect(formatBookCreateCreated("en", "harbor")).toBe("Book created: harbor");
+    expect(formatBookCreateNextStep("en", "harbor")).toBe("Next: inkos write next harbor");
+  });
+
+  it("formats write-next progress and result summaries in both languages", () => {
+    expect(formatWriteNextProgress("zh", 1, 2, "shan-he"))
+      .toBe('[1/2] 为「shan-he」撰写章节...');
+    expect(formatWriteNextComplete("zh")).toBe("完成。");
+    expect(formatWriteNextResultLines("zh", {
+      chapterNumber: 3,
+      title: "风雪夜",
+      wordCount: 3200,
+      status: "ready-for-review",
+      revised: true,
+      issues: [],
+      auditPassed: true,
+    })).toEqual([
+      "  第3章:风雪夜",
+      "  字数:3200字",
+      "  审计:通过",
+      "  自动修正:已执行(已修复关键问题)",
+      "  状态:ready-for-review",
+    ]);
+
+    expect(formatWriteNextProgress("en", 2, 3, "harbor"))
+      .toBe('[2/3] Writing chapter for "harbor"...');
+    expect(formatWriteNextComplete("en")).toBe("Done.");
+    expect(formatWriteNextResultLines("en", {
+      chapterNumber: 4,
+      title: "Cold Harbor",
+      wordCount: 2200,
+      status: "audit-failed",
+      revised: false,
+      issues: [{ severity: "critical", category: "continuity", description: "Mismatch" }],
+      auditPassed: false,
+    })).toEqual([
+      "  Chapter 4: Cold Harbor",
+      "  Length: 2200 words",
+      "  Audit: NEEDS REVIEW",
+      "  Status: audit-failed",
+      "  Issues:",
+      "    [critical] continuity: Mismatch",
+    ]);
+  });
+
+  it("formats import summaries with language-specific units and action hints", () => {
+    expect(formatImportChaptersDiscovery("zh", 12, "shan-he"))
+      .toBe('发现 12 章,准备导入到「shan-he」。');
+    expect(formatImportChaptersResume("zh", 5)).toBe("从第 5 章继续导入。");
+    expect(formatImportChaptersComplete("zh", {
+      importedCount: 8,
+      totalWords: 45678,
+      nextChapter: 13,
+      continueBookId: "shan-he",
+    })).toEqual([
+      "导入完成:",
+      "  已导入章节:8",
+      "  总长度:45678字",
+      "  下一章编号:13",
+      "",
+      '运行 "inkos write next shan-he" 继续写作。',
+    ]);
+
+    expect(formatImportChaptersDiscovery("en", 10, "harbor"))
+      .toBe('Found 10 chapters to import into "harbor".');
+    expect(formatImportChaptersResume("en", 6)).toBe("Resuming from chapter 6.");
+    expect(formatImportChaptersComplete("en", {
+      importedCount: 10,
+      totalWords: 18342,
+      nextChapter: 11,
+      continueBookId: "harbor",
+    })).toEqual([
+      "Import complete:",
+      "  Chapters imported: 10",
+      "  Total length: 18342 words",
+      "  Next chapter number: 11",
+      "",
+      'Run "inkos write next harbor" to continue writing.',
+    ]);
+  });
+
+  it("formats import-canon prompts in both languages", () => {
+    expect(formatImportCanonStart("zh", "parent-book", "target-book"))
+      .toBe('把 "parent-book" 的正典导入到 "target-book"...');
+    expect(formatImportCanonComplete("zh")).toEqual([
+      "正典已导入:story/parent_canon.md",
+      "Writer 和 auditor 会在番外模式下自动识别这个文件。",
+    ]);
+
+    expect(formatImportCanonStart("en", "parent-book", "target-book"))
+      .toBe('Importing canon from "parent-book" into "target-book"...');
+    expect(formatImportCanonComplete("en")).toEqual([
+      "Canon imported: story/parent_canon.md",
+      "Writer and auditor will auto-detect this file for spinoff mode.",
+    ]);
+  });
+});

+ 92 - 0
packages/cli/src/__tests__/progress-text.test.ts

@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+import {
+  formatImportCompletionLines,
+  formatImportDiscoveryLine,
+  formatImportResumeLine,
+  formatWriteCompletionLines,
+  formatWriteDoneLine,
+  formatWriteStartLine,
+} from "../progress-text.js";
+
+describe("CLI progress text", () => {
+  it("formats Chinese write progress lines", () => {
+    expect(formatWriteStartLine("zh", 1, 3, "demo-book")).toBe('[1/3] 为「demo-book」撰写章节...');
+    expect(formatWriteCompletionLines("zh", {
+      chapterNumber: 7,
+      title: "潮声夜渡",
+      wordCount: 2345,
+      passedAudit: false,
+      revised: true,
+      status: "audit-failed",
+      issues: [
+        { severity: "warning", category: "continuity", description: "时间线略有跳变" },
+      ],
+    })).toEqual([
+      "  第7章:潮声夜渡",
+      "  字数:2345字",
+      "  审计:需复核",
+      "  自动修正:已执行(已修复关键问题)",
+      "  状态:audit-failed",
+      "  问题:",
+      "    [warning] continuity: 时间线略有跳变",
+      "",
+    ]);
+    expect(formatWriteDoneLine("zh")).toBe("完成。");
+  });
+
+  it("formats English write progress lines", () => {
+    expect(formatWriteStartLine("en", 2, 5, "demo-book")).toBe('[2/5] Writing chapter for "demo-book"...');
+    expect(formatWriteCompletionLines("en", {
+      chapterNumber: 7,
+      title: "Harbor Wake",
+      wordCount: 2310,
+      passedAudit: true,
+      revised: false,
+      status: "ready-for-review",
+      issues: [],
+    })).toEqual([
+      "  Chapter 7: Harbor Wake",
+      "  Length: 2310 words",
+      "  Audit: PASSED",
+      "  Status: ready-for-review",
+      "",
+    ]);
+    expect(formatWriteDoneLine("en")).toBe("Done.");
+  });
+
+  it("formats Chinese import progress lines", () => {
+    expect(formatImportDiscoveryLine("zh", 12, "demo-book")).toBe('发现 12 章,准备导入到「demo-book」。');
+    expect(formatImportResumeLine("zh", 8)).toBe("从第 8 章继续导入。");
+    expect(formatImportCompletionLines("zh", {
+      importedCount: 12,
+      totalCountLabel: "24000字",
+      nextChapter: 13,
+      bookId: "demo-book",
+    })).toEqual([
+      "导入完成:",
+      "  已导入章节:12",
+      "  总长度:24000字",
+      "  下一章编号:13",
+      '',
+      '运行 "inkos write next demo-book" 继续写作。',
+    ]);
+  });
+
+  it("formats English import progress lines", () => {
+    expect(formatImportDiscoveryLine("en", 12, "demo-book")).toBe('Found 12 chapters to import into "demo-book".');
+    expect(formatImportResumeLine("en", 8)).toBe("Resuming from chapter 8.");
+    expect(formatImportCompletionLines("en", {
+      importedCount: 12,
+      totalCountLabel: "24000 words",
+      nextChapter: 13,
+      bookId: "demo-book",
+    })).toEqual([
+      "Import complete:",
+      "  Chapters imported: 12",
+      "  Total length: 24000 words",
+      "  Next chapter number: 13",
+      '',
+      'Run "inkos write next demo-book" to continue writing.',
+    ]);
+  });
+});

+ 89 - 0
packages/cli/src/__tests__/runtime-requirements.test.ts

@@ -0,0 +1,89 @@
+import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+  ensureNodeRuntimePinFiles,
+  evaluateSqliteMemorySupport,
+  formatSqliteMemorySupportWarning,
+  inspectNodeRuntimePinFiles,
+  parseNodeMajor,
+} from "../runtime-requirements.js";
+
+let tempRoot: string;
+
+describe("runtime requirements", () => {
+  beforeEach(async () => {
+    tempRoot = await mkdtemp(join(tmpdir(), "inkos-runtime-requirements-"));
+  });
+
+  afterEach(async () => {
+    await rm(tempRoot, { recursive: true, force: true });
+  });
+
+  it("parses Node major versions", () => {
+    expect(parseNodeMajor("v20.17.0")).toBe(20);
+    expect(parseNodeMajor("v22.19.0")).toBe(22);
+  });
+
+  it("marks sqlite memory acceleration unavailable below Node 22", () => {
+    const result = evaluateSqliteMemorySupport({
+      nodeVersion: "v20.17.0",
+      hasNodeSqlite: false,
+    });
+
+    expect(result.ok).toBe(false);
+    expect(result.detail).toContain("Node 22+");
+    expect(result.detail).toContain("v20.17.0");
+  });
+
+  it("marks sqlite memory acceleration available on supported runtimes", () => {
+    const result = evaluateSqliteMemorySupport({
+      nodeVersion: "v22.19.0",
+      hasNodeSqlite: true,
+    });
+
+    expect(result.ok).toBe(true);
+    expect(result.detail).toContain("v22.19.0");
+  });
+
+  it("formats an early warning for unsupported sqlite memory runtimes", () => {
+    const warning = formatSqliteMemorySupportWarning({
+      nodeVersion: "v20.17.0",
+      hasNodeSqlite: false,
+    });
+
+    expect(warning).toContain("v20.17.0");
+    expect(warning).toContain("Node 22+");
+    expect(warning).toContain("memory.db live sync");
+  });
+
+  it("does not format a warning on supported runtimes", () => {
+    const warning = formatSqliteMemorySupportWarning({
+      nodeVersion: "v22.19.0",
+      hasNodeSqlite: true,
+    });
+
+    expect(warning).toBeNull();
+  });
+
+  it("reports missing node runtime pin files", async () => {
+    const status = await inspectNodeRuntimePinFiles(tempRoot);
+
+    expect(status.ok).toBe(false);
+    expect(status.detail).toContain(".nvmrc");
+    expect(status.detail).toContain(".node-version");
+  });
+
+  it("writes node runtime pin files for old projects", async () => {
+    const repair = await ensureNodeRuntimePinFiles(tempRoot);
+
+    expect(repair.updated).toBe(true);
+    expect(repair.written).toEqual([".nvmrc", ".node-version"]);
+    await expect(readFile(join(tempRoot, ".nvmrc"), "utf-8")).resolves.toBe("22\n");
+    await expect(readFile(join(tempRoot, ".node-version"), "utf-8")).resolves.toBe("22\n");
+
+    const status = await inspectNodeRuntimePinFiles(tempRoot);
+    expect(status.ok).toBe(true);
+  });
+});

+ 222 - 0
packages/cli/src/localization.ts

@@ -0,0 +1,222 @@
+import { formatLengthCount, resolveLengthCountingMode } from "@actalk/inkos-core";
+
+export type CliLanguage = "zh" | "en";
+
+type WriteIssue = {
+  readonly severity: string;
+  readonly category: string;
+  readonly description: string;
+};
+
+type WriteResultShape = {
+  readonly chapterNumber: number;
+  readonly title: string;
+  readonly wordCount: number;
+  readonly status: string;
+  readonly revised: boolean;
+  readonly issues: ReadonlyArray<WriteIssue>;
+  readonly auditPassed?: boolean;
+  readonly passedAudit?: boolean;
+};
+
+type ImportResultShape = {
+  readonly importedCount: number;
+  readonly totalWords: number;
+  readonly nextChapter: number;
+  readonly continueBookId: string;
+};
+
+function localize(language: CliLanguage, messages: { zh: string; en: string }): string {
+  return language === "en" ? messages.en : messages.zh;
+}
+
+export function resolveCliLanguage(language?: string): CliLanguage {
+  return language === "en" ? "en" : "zh";
+}
+
+export function formatBookCreateResume(language: CliLanguage, bookId: string): string {
+  return localize(language, {
+    zh: `继续未完成的书籍创建:「${bookId}」...`,
+    en: `Resuming incomplete book creation for "${bookId}"...`,
+  });
+}
+
+export function formatBookCreateCreating(
+  language: CliLanguage,
+  title: string,
+  genre: string,
+  platform: string,
+): string {
+  return localize(language, {
+    zh: `创建书籍 "${title}"(${genre} / ${platform})...`,
+    en: `Creating book "${title}" (${genre} / ${platform})...`,
+  });
+}
+
+export function formatBookCreateCreated(language: CliLanguage, bookId: string): string {
+  return localize(language, {
+    zh: `已创建书籍:${bookId}`,
+    en: `Book created: ${bookId}`,
+  });
+}
+
+export function formatBookCreateLocation(language: CliLanguage, bookId: string): string {
+  return localize(language, {
+    zh: `  位置:books/${bookId}/`,
+    en: `  Location: books/${bookId}/`,
+  });
+}
+
+export function formatBookCreateFoundationReady(language: CliLanguage): string {
+  return localize(language, {
+    zh: "  故事圣经、大纲和书籍规则已生成。",
+    en: "  Story bible, outline, book rules generated.",
+  });
+}
+
+export function formatBookCreateNextStep(language: CliLanguage, bookId: string): string {
+  return localize(language, {
+    zh: `下一步:inkos write next ${bookId}`,
+    en: `Next: inkos write next ${bookId}`,
+  });
+}
+
+export function formatWriteNextProgress(
+  language: CliLanguage,
+  current: number,
+  total: number,
+  bookId: string,
+): string {
+  return localize(language, {
+    zh: `[${current}/${total}] 为「${bookId}」撰写章节...`,
+    en: `[${current}/${total}] Writing chapter for "${bookId}"...`,
+  });
+}
+
+export function formatWriteNextResultLines(
+  language: CliLanguage,
+  result: WriteResultShape,
+): string[] {
+  const auditPassed = result.auditPassed ?? result.passedAudit ?? false;
+  const lengthLabel = formatLengthCount(result.wordCount, resolveLengthCountingMode(language));
+  const lines = [
+    localize(language, {
+      zh: `  第${result.chapterNumber}章:${result.title}`,
+      en: `  Chapter ${result.chapterNumber}: ${result.title}`,
+    }),
+    localize(language, {
+      zh: `  字数:${lengthLabel}`,
+      en: `  Length: ${lengthLabel}`,
+    }),
+    localize(language, {
+      zh: `  审计:${auditPassed ? "通过" : "需复核"}`,
+      en: `  Audit: ${auditPassed ? "PASSED" : "NEEDS REVIEW"}`,
+    }),
+  ];
+
+  if (result.revised) {
+    lines.push(localize(language, {
+      zh: "  自动修正:已执行(已修复关键问题)",
+      en: "  Auto-revised: YES (critical issues were fixed)",
+    }));
+  }
+
+  lines.push(localize(language, {
+    zh: `  状态:${result.status}`,
+    en: `  Status: ${result.status}`,
+  }));
+
+  if (result.issues.length > 0) {
+    lines.push(localize(language, {
+      zh: "  问题:",
+      en: "  Issues:",
+    }));
+    for (const issue of result.issues) {
+      lines.push(`    [${issue.severity}] ${issue.category}: ${issue.description}`);
+    }
+  }
+
+  return lines;
+}
+
+export function formatWriteNextComplete(language: CliLanguage): string {
+  return localize(language, {
+    zh: "完成。",
+    en: "Done.",
+  });
+}
+
+export function formatImportChaptersDiscovery(
+  language: CliLanguage,
+  chapterCount: number,
+  bookId: string,
+): string {
+  return localize(language, {
+    zh: `发现 ${chapterCount} 章,准备导入到「${bookId}」。`,
+    en: `Found ${chapterCount} chapters to import into "${bookId}".`,
+  });
+}
+
+export function formatImportChaptersResume(
+  language: CliLanguage,
+  resumeFrom: number,
+): string {
+  return localize(language, {
+    zh: `从第 ${resumeFrom} 章继续导入。`,
+    en: `Resuming from chapter ${resumeFrom}.`,
+  });
+}
+
+export function formatImportChaptersComplete(
+  language: CliLanguage,
+  result: ImportResultShape,
+): string[] {
+  const lengthLabel = formatLengthCount(result.totalWords, resolveLengthCountingMode(language));
+  return [
+    localize(language, {
+      zh: "导入完成:",
+      en: "Import complete:",
+    }),
+    localize(language, {
+      zh: `  已导入章节:${result.importedCount}`,
+      en: `  Chapters imported: ${result.importedCount}`,
+    }),
+    localize(language, {
+      zh: `  总长度:${lengthLabel}`,
+      en: `  Total length: ${lengthLabel}`,
+    }),
+    localize(language, {
+      zh: `  下一章编号:${result.nextChapter}`,
+      en: `  Next chapter number: ${result.nextChapter}`,
+    }),
+    "",
+    localize(language, {
+      zh: `运行 "inkos write next ${result.continueBookId}" 继续写作。`,
+      en: `Run "inkos write next ${result.continueBookId}" to continue writing.`,
+    }),
+  ];
+}
+
+export function formatImportCanonStart(
+  language: CliLanguage,
+  parentBookId: string,
+  targetBookId: string,
+): string {
+  return localize(language, {
+    zh: `把 "${parentBookId}" 的正典导入到 "${targetBookId}"...`,
+    en: `Importing canon from "${parentBookId}" into "${targetBookId}"...`,
+  });
+}
+
+export function formatImportCanonComplete(language: CliLanguage): string[] {
+  return [
+    localize(language, {
+      zh: "正典已导入:story/parent_canon.md",
+      en: "Canon imported: story/parent_canon.md",
+    }),
+    localize(language, {
+      zh: "Writer 和 auditor 会在番外模式下自动识别这个文件。",
+      en: "Writer and auditor will auto-detect this file for spinoff mode.",
+    }),
+  ];
+}

+ 85 - 0
packages/cli/src/progress-text.ts

@@ -0,0 +1,85 @@
+import {
+  formatImportChaptersComplete,
+  formatImportChaptersDiscovery,
+  formatImportChaptersResume,
+  formatWriteNextComplete,
+  formatWriteNextProgress,
+  formatWriteNextResultLines,
+  type CliLanguage,
+} from "./localization.js";
+
+export { type CliLanguage };
+
+export function formatWriteStartLine(
+  language: CliLanguage,
+  current: number,
+  total: number,
+  bookId: string,
+): string {
+  return formatWriteNextProgress(language, current, total, bookId);
+}
+
+export function formatWriteCompletionLines(
+  language: CliLanguage,
+  result: {
+    readonly chapterNumber: number;
+    readonly title: string;
+    readonly wordCount: number;
+    readonly passedAudit: boolean;
+    readonly revised: boolean;
+    readonly status: string;
+    readonly issues: ReadonlyArray<{
+      readonly severity: string;
+      readonly category: string;
+      readonly description: string;
+    }>;
+  },
+): string[] {
+  return [...formatWriteNextResultLines(language, result), ""];
+}
+
+export function formatWriteDoneLine(language: CliLanguage): string {
+  return formatWriteNextComplete(language);
+}
+
+export function formatImportDiscoveryLine(
+  language: CliLanguage,
+  chapterCount: number,
+  bookId: string,
+): string {
+  return formatImportChaptersDiscovery(language, chapterCount, bookId);
+}
+
+export function formatImportResumeLine(
+  language: CliLanguage,
+  resumeFrom: number,
+): string {
+  return formatImportChaptersResume(language, resumeFrom);
+}
+
+export function formatImportCompletionLines(
+  language: CliLanguage,
+  result: {
+    readonly importedCount: number;
+    readonly totalCountLabel: string;
+    readonly nextChapter: number;
+    readonly bookId: string;
+  },
+): string[] {
+  return [
+    language === "en" ? "Import complete:" : "导入完成:",
+    language === "en"
+      ? `  Chapters imported: ${result.importedCount}`
+      : `  已导入章节:${result.importedCount}`,
+    language === "en"
+      ? `  Total length: ${result.totalCountLabel}`
+      : `  总长度:${result.totalCountLabel}`,
+    language === "en"
+      ? `  Next chapter number: ${result.nextChapter}`
+      : `  下一章编号:${result.nextChapter}`,
+    "",
+    language === "en"
+      ? `Run "inkos write next ${result.bookId}" to continue writing.`
+      : `运行 "inkos write next ${result.bookId}" 继续写作。`,
+  ];
+}

+ 135 - 0
packages/cli/src/runtime-requirements.ts

@@ -0,0 +1,135 @@
+import { readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { createRequire } from "node:module";
+
+const require = createRequire(import.meta.url);
+
+export const SQLITE_MEMORY_MIN_NODE_MAJOR = 22;
+export const SQLITE_MEMORY_PIN_VERSION = String(SQLITE_MEMORY_MIN_NODE_MAJOR);
+export const SQLITE_MEMORY_PIN_FILES = [".nvmrc", ".node-version"] as const;
+
+export interface SqliteMemorySupportResult {
+  readonly ok: boolean;
+  readonly detail: string;
+}
+
+export interface NodeRuntimePinStatus {
+  readonly ok: boolean;
+  readonly detail: string;
+  readonly missing: ReadonlyArray<string>;
+}
+
+export interface NodeRuntimePinRepairResult {
+  readonly updated: boolean;
+  readonly written: ReadonlyArray<string>;
+}
+
+export function formatSqliteMemorySupportWarning(options?: {
+  readonly nodeVersion?: string;
+  readonly hasNodeSqlite?: boolean;
+}): string | null {
+  const nodeVersion = options?.nodeVersion ?? process.version;
+  const result = evaluateSqliteMemorySupport({
+    nodeVersion,
+    hasNodeSqlite: options?.hasNodeSqlite,
+  });
+  if (result.ok) return null;
+
+  return `Node ${nodeVersion} does not support SQLite memory index; memory.db live sync will fall back to Markdown. Use Node 22+ or run 'inkos doctor'.`;
+}
+
+export async function inspectNodeRuntimePinFiles(root: string): Promise<NodeRuntimePinStatus> {
+  const missing: string[] = [];
+
+  for (const file of SQLITE_MEMORY_PIN_FILES) {
+    try {
+      const content = await readFile(join(root, file), "utf-8");
+      if (content.trim() !== SQLITE_MEMORY_PIN_VERSION) {
+        missing.push(file);
+      }
+    } catch {
+      missing.push(file);
+    }
+  }
+
+  if (missing.length === 0) {
+    return {
+      ok: true,
+      detail: `Pinned to Node ${SQLITE_MEMORY_PIN_VERSION} via ${SQLITE_MEMORY_PIN_FILES.join(", ")}.`,
+      missing,
+    };
+  }
+
+  return {
+    ok: false,
+    detail: `Missing or outdated: ${missing.join(", ")}. Run 'inkos doctor --repair-node-runtime'.`,
+    missing,
+  };
+}
+
+export async function ensureNodeRuntimePinFiles(root: string): Promise<NodeRuntimePinRepairResult> {
+  const written: string[] = [];
+
+  for (const file of SQLITE_MEMORY_PIN_FILES) {
+    const path = join(root, file);
+    let content = "";
+    try {
+      content = await readFile(path, "utf-8");
+    } catch {
+      content = "";
+    }
+
+    if (content.trim() === SQLITE_MEMORY_PIN_VERSION) {
+      continue;
+    }
+
+    await writeFile(path, `${SQLITE_MEMORY_PIN_VERSION}\n`, "utf-8");
+    written.push(file);
+  }
+
+  return {
+    updated: written.length > 0,
+    written,
+  };
+}
+
+export function parseNodeMajor(version: string): number {
+  return parseInt(version.replace(/^v/i, "").split(".")[0] ?? "0", 10);
+}
+
+function hasNodeSqliteBuiltin(): boolean {
+  try {
+    require("node:sqlite");
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+export function evaluateSqliteMemorySupport(options?: {
+  readonly nodeVersion?: string;
+  readonly hasNodeSqlite?: boolean;
+}): SqliteMemorySupportResult {
+  const nodeVersion = options?.nodeVersion ?? process.version;
+  const major = parseNodeMajor(nodeVersion);
+
+  if (major < SQLITE_MEMORY_MIN_NODE_MAJOR) {
+    return {
+      ok: false,
+      detail: `Unavailable on ${nodeVersion}. Long-book memory.db acceleration requires Node ${SQLITE_MEMORY_MIN_NODE_MAJOR}+.`,
+    };
+  }
+
+  const hasNodeSqlite = options?.hasNodeSqlite ?? hasNodeSqliteBuiltin();
+  if (!hasNodeSqlite) {
+    return {
+      ok: false,
+      detail: `${nodeVersion} detected, but node:sqlite is unavailable on this runtime. memory.db acceleration will stay disabled.`,
+    };
+  }
+
+  return {
+    ok: true,
+    detail: `Available on ${nodeVersion}.`,
+  };
+}