diff --git a/packages/core/src/state/state-bootstrap.ts b/packages/core/src/state/state-bootstrap.ts index 932396f0..f576254e 100644 --- a/packages/core/src/state/state-bootstrap.ts +++ b/packages/core/src/state/state-bootstrap.ts @@ -10,8 +10,24 @@ import { type HookStatus, type StateManifest, } from "../models/runtime-state.js"; -import type { Fact, StoredHook, StoredSummary } from "./memory-db.js"; +import type { Fact, StoredHook } from "./memory-db.js"; import { normalizeHookPayoffTiming } from "../utils/hook-lifecycle.js"; +import { + inferFactSubject, + isCurrentChapterLabel, + isStateTableHeaderRow, + normalizeHookId, + parseChapterSummariesMarkdown, + parseInteger, + parseMarkdownTableRows, +} from "../utils/story-markdown.js"; + +export { + normalizeHookId, + parseChapterSummariesMarkdown, + parseCurrentStateFacts, + parsePendingHooksMarkdown, +} from "../utils/story-markdown.js"; export interface BootstrapStructuredStateResult { readonly createdFiles: ReadonlyArray; @@ -164,69 +180,6 @@ export async function rewriteStructuredStateFromMarkdown(params: { }; } -export function parseChapterSummariesMarkdown(markdown: string): StoredSummary[] { - const rows = parseMarkdownTableRows(markdown) - .filter((row) => /^\d+$/.test(row[0] ?? "")); - - return rows.map((row) => ({ - chapter: parseInt(row[0]!, 10), - title: row[1] ?? "", - characters: row[2] ?? "", - events: row[3] ?? "", - stateChanges: row[4] ?? "", - hookActivity: row[5] ?? "", - mood: row[6] ?? "", - chapterType: row[7] ?? "", - })); -} - -export function parsePendingHooksMarkdown(markdown: string): StoredHook[] { - const tableRows = parseMarkdownTableRows(markdown) - .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id"); - - if (tableRows.length > 0) { - return tableRows - .filter((row) => normalizeHookId(row[0]).length > 0) - .map((row) => { - const legacyShape = row.length < 8; - return { - hookId: normalizeHookId(row[0]), - startChapter: parseInteger(row[1]), - type: row[2] ?? "", - status: row[3] ?? "open", - lastAdvancedChapter: parseInteger(row[4]), - expectedPayoff: row[5] ?? "", - payoffTiming: legacyShape ? undefined : normalizeHookPayoffTiming(row[6]), - notes: legacyShape ? (row[6] ?? "") : (row[7] ?? ""), - }; - }); - } - - return markdown - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("-")) - .map((line) => line.replace(/^-\s*/, "")) - .filter(Boolean) - .map((line, index) => ({ - hookId: `hook-${index + 1}`, - startChapter: 0, - type: "unspecified", - status: "open", - lastAdvancedChapter: 0, - expectedPayoff: "", - payoffTiming: undefined, - notes: line, - })); -} - -export function parseCurrentStateFacts( - markdown: string, - fallbackChapter: number, -): Fact[] { - return parseCurrentStateStateMarkdown(markdown, fallbackChapter, []).facts; -} - async function loadOrBootstrapCurrentState(params: { readonly storyDir: string; readonly statePath: string; @@ -590,24 +543,6 @@ function maxHookChapter(hooks: ReadonlyArray): number { ); } -export function normalizeHookId(value: string | undefined): string { - let normalized = (value ?? "").trim(); - let previous = ""; - while (normalized && normalized !== previous) { - previous = normalized; - normalized = normalized - .replace(/^\[(.+?)\]\([^)]+\)$/u, "$1") - .replace(/^\*\*(.+)\*\*$/u, "$1") - .replace(/^__(.+)__$/u, "$1") - .replace(/^\*(.+)\*$/u, "$1") - .replace(/^_(.+)_$/u, "$1") - .replace(/^`(.+)`$/u, "$1") - .replace(/^~~(.+)~~$/u, "$1") - .trim(); - } - return normalized; -} - function normalizeHookStatus(value: string | undefined, warnings: string[], hookId: string): HookStatus { const normalized = (value ?? "").trim().toLowerCase(); if (!normalized) return "open"; @@ -644,42 +579,6 @@ function parseIntegerWithFallback( return parseInt(match[0], 10); } -function parseMarkdownTableRows(markdown: string): string[][] { - return markdown - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("|")) - .filter((line) => !line.includes("---")) - .map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim())) - .filter((cells) => cells.some(Boolean)); -} - -function isStateTableHeaderRow(row: ReadonlyArray): boolean { - const first = (row[0] ?? "").trim().toLowerCase(); - const second = (row[1] ?? "").trim().toLowerCase(); - return (first === "字段" && second === "值") || (first === "field" && second === "value"); -} - -function isCurrentChapterLabel(label: string): boolean { - return /^(当前章节|current chapter)$/i.test(label.trim()); -} - -function inferFactSubject(label: string): string { - if (/^(当前位置|current location)$/i.test(label)) return "protagonist"; - if (/^(主角状态|protagonist state)$/i.test(label)) return "protagonist"; - if (/^(当前目标|current goal)$/i.test(label)) return "protagonist"; - if (/^(当前限制|current constraint)$/i.test(label)) return "protagonist"; - if (/^(当前敌我|current alliances|current relationships)$/i.test(label)) return "protagonist"; - if (/^(当前冲突|current conflict)$/i.test(label)) return "protagonist"; - return "current_state"; -} - -function parseInteger(value: string | undefined): number { - if (!value) return 0; - const match = value.match(/\d+/); - return match ? parseInt(match[0], 10) : 0; -} - function appendWarning(warnings: string[], warning: string): void { if (!warnings.includes(warning)) { warnings.push(warning); diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index 927b6777..5109430e 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -6,13 +6,8 @@ import { HooksStateSchema, } from "../models/runtime-state.js"; import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js"; -import { bootstrapStructuredStateFromMarkdown, normalizeHookId } from "../state/state-bootstrap.js"; -import { - describeHookLifecycle, - localizeHookPayoffTiming, - resolveHookPayoffTiming, - normalizeHookPayoffTiming, -} from "./hook-lifecycle.js"; +import { bootstrapStructuredStateFromMarkdown } from "../state/state-bootstrap.js"; +import { describeHookLifecycle } from "./hook-lifecycle.js"; import { buildPlannerHookAgenda, filterActiveHooks, @@ -23,12 +18,26 @@ import { resolveRelevantHookStaleLimit, selectAgendaHooksWithTypeSpread, } from "./hook-agenda.js"; +import { + parseChapterSummariesMarkdown, + parseCurrentStateFacts, + parsePendingHooksMarkdown, + renderHookSnapshot, + renderSummarySnapshot, +} from "./story-markdown.js"; export { buildPlannerHookAgenda, isFuturePlannedHook, isHookWithinChapterWindow, isHookWithinLifecycleWindow, } from "./hook-agenda.js"; +export { + parseChapterSummariesMarkdown, + parseCurrentStateFacts, + parsePendingHooksMarkdown, + renderHookSnapshot, + renderSummarySnapshot, +} from "./story-markdown.js"; export interface MemorySelection { readonly summaries: ReadonlyArray; @@ -168,68 +177,6 @@ export function extractQueryTerms(goal: string, outlineNode: string | undefined, ]).slice(0, 12); } -export function renderSummarySnapshot( - summaries: ReadonlyArray, - language: "zh" | "en" = "zh", -): string { - if (summaries.length === 0) return "- none"; - - const headers = language === "en" - ? [ - "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |", - "| --- | --- | --- | --- | --- | --- | --- | --- |", - ] - : [ - "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |", - "| --- | --- | --- | --- | --- | --- | --- | --- |", - ]; - - return [ - ...headers, - ...summaries.map((summary) => [ - summary.chapter, - summary.title, - summary.characters, - summary.events, - summary.stateChanges, - summary.hookActivity, - summary.mood, - summary.chapterType, - ].map(escapeTableCell).join(" | ")).map((row) => `| ${row} |`), - ].join("\n"); -} - -export function renderHookSnapshot( - hooks: ReadonlyArray, - language: "zh" | "en" = "zh", -): string { - if (hooks.length === 0) return "- none"; - - const headers = language === "en" - ? [ - "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |", - "| --- | --- | --- | --- | --- | --- | --- | --- |", - ] - : [ - "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |", - "| --- | --- | --- | --- | --- | --- | --- | --- |", - ]; - - return [ - ...headers, - ...hooks.map((hook) => [ - hook.hookId, - hook.startChapter, - hook.type, - hook.status, - hook.lastAdvancedChapter, - hook.expectedPayoff, - localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language), - hook.notes, - ].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`), - ].join("\n"); -} - function openMemoryDB(bookDir: string): MemoryDB | null { try { return new MemoryDB(bookDir); @@ -336,125 +283,6 @@ function uniqueTerms(terms: ReadonlyArray): string[] { return result; } -export function parseChapterSummariesMarkdown(markdown: string): StoredSummary[] { - const rows = parseMarkdownTableRows(markdown) - .filter((row) => /^\d+$/.test(row[0] ?? "")); - - return rows.map((row) => ({ - chapter: parseInt(row[0]!, 10), - title: row[1] ?? "", - characters: row[2] ?? "", - events: row[3] ?? "", - stateChanges: row[4] ?? "", - hookActivity: row[5] ?? "", - mood: row[6] ?? "", - chapterType: row[7] ?? "", - })); -} - -export function parsePendingHooksMarkdown(markdown: string): StoredHook[] { - const tableRows = parseMarkdownTableRows(markdown) - .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id"); - - if (tableRows.length > 0) { - return tableRows - .filter((row) => normalizeHookId(row[0]).length > 0) - .map((row) => parsePendingHookRow(row)); - } - - return markdown - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("-")) - .map((line) => line.replace(/^-\s*/, "")) - .filter(Boolean) - .map((line, index) => ({ - hookId: `hook-${index + 1}`, - startChapter: 0, - type: "unspecified", - status: "open", - lastAdvancedChapter: 0, - expectedPayoff: "", - payoffTiming: undefined, - notes: line, - })); -} - -function parsePendingHookRow(row: ReadonlyArray): StoredHook { - const legacyShape = row.length < 8; - const payoffTiming = legacyShape ? undefined : normalizeHookPayoffTiming(row[6]); - const notes = legacyShape ? (row[6] ?? "") : (row[7] ?? ""); - - return { - hookId: normalizeHookId(row[0]), - startChapter: parseInteger(row[1]), - type: row[2] ?? "", - status: row[3] ?? "open", - lastAdvancedChapter: parseInteger(row[4]), - expectedPayoff: row[5] ?? "", - payoffTiming, - notes, - }; -} - -export function parseCurrentStateFacts( - markdown: string, - fallbackChapter: number, -): Fact[] { - const tableRows = parseMarkdownTableRows(markdown); - const fieldValueRows = tableRows - .filter((row) => row.length >= 2) - .filter((row) => !isStateTableHeaderRow(row)); - - if (fieldValueRows.length > 0) { - const chapterFromTable = fieldValueRows.find((row) => isCurrentChapterLabel(row[0] ?? "")); - const stateChapter = parseInteger(chapterFromTable?.[1]) || fallbackChapter; - - return fieldValueRows - .filter((row) => !isCurrentChapterLabel(row[0] ?? "")) - .flatMap((row): Fact[] => { - const label = (row[0] ?? "").trim(); - const value = (row[1] ?? "").trim(); - if (!label || !value) return []; - - return [{ - subject: inferFactSubject(label), - predicate: label, - object: value, - validFromChapter: stateChapter, - validUntilChapter: null, - sourceChapter: stateChapter, - }]; - }); - } - - const bulletFacts = markdown - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("-")) - .map((line) => line.replace(/^-\s*/, "")) - .filter(Boolean); - - return bulletFacts.map((line, index) => ({ - subject: "current_state", - predicate: `note_${index + 1}`, - object: line, - validFromChapter: fallbackChapter, - validUntilChapter: null, - sourceChapter: fallbackChapter, - })); -} - -function parseMarkdownTableRows(markdown: string): string[][] { - return markdown - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("|")) - .filter((line) => !line.includes("---")) - .map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim())) - .filter((cells) => cells.some(Boolean)); -} - function parseVolumeSummariesMarkdown(markdown: string): VolumeSummarySelection[] { if (!markdown.trim()) return []; @@ -476,26 +304,6 @@ function parseVolumeSummariesMarkdown(markdown: string): VolumeSummarySelection[ }).filter((section) => section.heading.length > 0 && section.content.length > 0); } -function isStateTableHeaderRow(row: ReadonlyArray): boolean { - const first = (row[0] ?? "").trim().toLowerCase(); - const second = (row[1] ?? "").trim().toLowerCase(); - return (first === "字段" && second === "值") || (first === "field" && second === "value"); -} - -function isCurrentChapterLabel(label: string): boolean { - return /^(当前章节|current chapter)$/i.test(label.trim()); -} - -function inferFactSubject(label: string): string { - if (/^(当前位置|current location)$/i.test(label)) return "protagonist"; - if (/^(主角状态|protagonist state)$/i.test(label)) return "protagonist"; - if (/^(当前目标|current goal)$/i.test(label)) return "protagonist"; - if (/^(当前限制|current constraint)$/i.test(label)) return "protagonist"; - if (/^(当前敌我|current alliances|current relationships)$/i.test(label)) return "protagonist"; - if (/^(当前冲突|current conflict)$/i.test(label)) return "protagonist"; - return "current_state"; -} - function isUnresolvedHook(status: string): boolean { return status.trim().length === 0 || /open|待定|推进|active|progressing/i.test(status); } @@ -678,16 +486,6 @@ function includesTerm(text: string, term: string): boolean { return text.toLowerCase().includes(term.toLowerCase()); } -function parseInteger(value: string | undefined): number { - if (!value) return 0; - const match = value.match(/\d+/); - return match ? parseInt(match[0], 10) : 0; -} - -function escapeTableCell(value: string | number): string { - return String(value).replace(/\|/g, "\\|").trim(); -} - function slugifyAnchor(value: string): string { return value .trim() diff --git a/packages/core/src/utils/story-markdown.ts b/packages/core/src/utils/story-markdown.ts new file mode 100644 index 00000000..31b826f2 --- /dev/null +++ b/packages/core/src/utils/story-markdown.ts @@ -0,0 +1,235 @@ +import type { Fact, StoredHook, StoredSummary } from "../state/memory-db.js"; +import { + localizeHookPayoffTiming, + normalizeHookPayoffTiming, + resolveHookPayoffTiming, +} from "./hook-lifecycle.js"; + +export function renderSummarySnapshot( + summaries: ReadonlyArray, + language: "zh" | "en" = "zh", +): string { + if (summaries.length === 0) return "- none"; + + const headers = language === "en" + ? [ + "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ] + : [ + "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ]; + + return [ + ...headers, + ...summaries.map((summary) => [ + summary.chapter, + summary.title, + summary.characters, + summary.events, + summary.stateChanges, + summary.hookActivity, + summary.mood, + summary.chapterType, + ].map(escapeTableCell).join(" | ")).map((row) => `| ${row} |`), + ].join("\n"); +} + +export function renderHookSnapshot( + hooks: ReadonlyArray, + language: "zh" | "en" = "zh", +): string { + if (hooks.length === 0) return "- none"; + + const headers = language === "en" + ? [ + "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ] + : [ + "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ]; + + return [ + ...headers, + ...hooks.map((hook) => [ + hook.hookId, + hook.startChapter, + hook.type, + hook.status, + hook.lastAdvancedChapter, + hook.expectedPayoff, + localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language), + hook.notes, + ].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`), + ].join("\n"); +} + +export function parseChapterSummariesMarkdown(markdown: string): StoredSummary[] { + const rows = parseMarkdownTableRows(markdown) + .filter((row) => /^\d+$/.test(row[0] ?? "")); + + return rows.map((row) => ({ + chapter: parseInt(row[0]!, 10), + title: row[1] ?? "", + characters: row[2] ?? "", + events: row[3] ?? "", + stateChanges: row[4] ?? "", + hookActivity: row[5] ?? "", + mood: row[6] ?? "", + chapterType: row[7] ?? "", + })); +} + +export function parsePendingHooksMarkdown(markdown: string): StoredHook[] { + const tableRows = parseMarkdownTableRows(markdown) + .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id"); + + if (tableRows.length > 0) { + return tableRows + .filter((row) => normalizeHookId(row[0]).length > 0) + .map((row) => parsePendingHookRow(row)); + } + + return markdown + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("-")) + .map((line) => line.replace(/^-\s*/, "")) + .filter(Boolean) + .map((line, index) => ({ + hookId: `hook-${index + 1}`, + startChapter: 0, + type: "unspecified", + status: "open", + lastAdvancedChapter: 0, + expectedPayoff: "", + payoffTiming: undefined, + notes: line, + })); +} + +export function parseCurrentStateFacts( + markdown: string, + fallbackChapter: number, +): Fact[] { + const tableRows = parseMarkdownTableRows(markdown); + const fieldValueRows = tableRows + .filter((row) => row.length >= 2) + .filter((row) => !isStateTableHeaderRow(row)); + + if (fieldValueRows.length > 0) { + const chapterFromTable = fieldValueRows.find((row) => isCurrentChapterLabel(row[0] ?? "")); + const stateChapter = parseInteger(chapterFromTable?.[1]) || fallbackChapter; + + return fieldValueRows + .filter((row) => !isCurrentChapterLabel(row[0] ?? "")) + .flatMap((row): Fact[] => { + const label = (row[0] ?? "").trim(); + const value = (row[1] ?? "").trim(); + if (!label || !value) return []; + + return [{ + subject: inferFactSubject(label), + predicate: label, + object: value, + validFromChapter: stateChapter, + validUntilChapter: null, + sourceChapter: stateChapter, + }]; + }); + } + + const bulletFacts = markdown + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("-")) + .map((line) => line.replace(/^-\s*/, "")) + .filter(Boolean); + + return bulletFacts.map((line, index) => ({ + subject: "current_state", + predicate: `note_${index + 1}`, + object: line, + validFromChapter: fallbackChapter, + validUntilChapter: null, + sourceChapter: fallbackChapter, + })); +} + +export function parseMarkdownTableRows(markdown: string): string[][] { + return markdown + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("|")) + .filter((line) => !line.includes("---")) + .map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim())) + .filter((cells) => cells.some(Boolean)); +} + +export function isStateTableHeaderRow(row: ReadonlyArray): boolean { + const first = (row[0] ?? "").trim().toLowerCase(); + const second = (row[1] ?? "").trim().toLowerCase(); + return (first === "字段" && second === "值") || (first === "field" && second === "value"); +} + +export function isCurrentChapterLabel(label: string): boolean { + return /^(当前章节|current chapter)$/i.test(label.trim()); +} + +export function inferFactSubject(label: string): string { + if (/^(当前位置|current location)$/i.test(label)) return "protagonist"; + if (/^(主角状态|protagonist state)$/i.test(label)) return "protagonist"; + if (/^(当前目标|current goal)$/i.test(label)) return "protagonist"; + if (/^(当前限制|current constraint)$/i.test(label)) return "protagonist"; + if (/^(当前敌我|current alliances|current relationships)$/i.test(label)) return "protagonist"; + if (/^(当前冲突|current conflict)$/i.test(label)) return "protagonist"; + return "current_state"; +} + +export function parseInteger(value: string | undefined): number { + if (!value) return 0; + const match = value.match(/\d+/); + return match ? parseInt(match[0], 10) : 0; +} + +export function normalizeHookId(value: string | undefined): string { + let normalized = (value ?? "").trim(); + let previous = ""; + while (normalized && normalized !== previous) { + previous = normalized; + normalized = normalized + .replace(/^\[(.+?)\]\([^)]+\)$/u, "$1") + .replace(/^\*\*(.+)\*\*$/u, "$1") + .replace(/^__(.+)__$/u, "$1") + .replace(/^\*(.+)\*$/u, "$1") + .replace(/^_(.+)_$/u, "$1") + .replace(/^`(.+)`$/u, "$1") + .replace(/^~~(.+)~~$/u, "$1") + .trim(); + } + return normalized; +} + +function parsePendingHookRow(row: ReadonlyArray): StoredHook { + const legacyShape = row.length < 8; + const payoffTiming = legacyShape ? undefined : normalizeHookPayoffTiming(row[6]); + const notes = legacyShape ? (row[6] ?? "") : (row[7] ?? ""); + + return { + hookId: normalizeHookId(row[0]), + startChapter: parseInteger(row[1]), + type: row[2] ?? "", + status: row[3] ?? "open", + lastAdvancedChapter: parseInteger(row[4]), + expectedPayoff: row[5] ?? "", + payoffTiming, + notes, + }; +} + +function escapeTableCell(value: string | number): string { + return String(value).replace(/\|/g, "\\|").trim(); +}