refactor(state): share story markdown parsers

This commit is contained in:
Ma
2026-04-01 17:22:11 +08:00
parent eb288b31f8
commit 06070ca4be
3 changed files with 268 additions and 336 deletions
+17 -118
View File
@@ -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<string>;
@@ -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<StoredHook>): 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<string>): 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);
+16 -218
View File
@@ -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<StoredSummary>;
@@ -168,68 +177,6 @@ export function extractQueryTerms(goal: string, outlineNode: string | undefined,
]).slice(0, 12);
}
export function renderSummarySnapshot(
summaries: ReadonlyArray<StoredSummary>,
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<StoredHook>,
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>): 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<string | undefined>): 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<string>): 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()
+235
View File
@@ -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<StoredSummary>,
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<StoredHook>,
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<string>): 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<string | undefined>): 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();
}