mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-31 01:42:58 +08:00
feat(pipeline): add semantic hook lifecycle guidance
This commit is contained in:
@@ -458,4 +458,67 @@ describe("ComposerAgent", () => {
|
||||
expect(parentCanonEntry?.excerpt).toContain("archive fire");
|
||||
expect(fanficCanonEntry?.excerpt).toContain("oath debt logic");
|
||||
});
|
||||
|
||||
it("emits hook debt briefs for agenda-targeted hooks", async () => {
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(storyDir, "pending_hooks.md"),
|
||||
[
|
||||
"# Pending Hooks",
|
||||
"",
|
||||
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
"| mentor-oath | 8 | relationship | progressing | 9 | 揭开师债为何断裂 | 慢烧 | 师债需要跨更大弧线回收 |",
|
||||
"| guild-route | 1 | mystery | open | 2 | 查清商会路线背后的买家 | 近期 | 商会路线仍在旁支干扰 |",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
),
|
||||
writeFile(
|
||||
join(storyDir, "chapter_summaries.md"),
|
||||
[
|
||||
"# Chapter Summaries",
|
||||
"",
|
||||
"| 7 | Broken Letter | Lin Yue | A torn letter mentions the mentor | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
|
||||
"| 8 | River Camp | Lin Yue | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
|
||||
"| 9 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
),
|
||||
]);
|
||||
|
||||
const composer = new ComposerAgent({
|
||||
client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
|
||||
model: "test-model",
|
||||
projectRoot: root,
|
||||
bookId: book.id,
|
||||
});
|
||||
|
||||
const result = await composer.composeChapter({
|
||||
book,
|
||||
bookDir,
|
||||
chapterNumber: 10,
|
||||
plan: {
|
||||
...plan,
|
||||
intent: {
|
||||
...plan.intent,
|
||||
chapter: 10,
|
||||
goal: "Bring the focus back to the mentor oath conflict.",
|
||||
hookAgenda: {
|
||||
mustAdvance: ["mentor-oath"],
|
||||
eligibleResolve: [],
|
||||
staleDebt: [],
|
||||
avoidNewHookFamilies: ["relationship"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hookDebtEntry = result.contextPackage.selectedContext.find((entry) => entry.source === "runtime/hook_debt#mentor-oath");
|
||||
expect(hookDebtEntry).toBeDefined();
|
||||
expect(hookDebtEntry?.excerpt).toContain("mentor-oath");
|
||||
expect(hookDebtEntry?.excerpt).toContain("River Camp");
|
||||
expect(hookDebtEntry?.excerpt).toContain("Trial Echo");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1085,4 +1085,62 @@ describe("parsePendingHooksMarkdown", () => {
|
||||
|
||||
expect(hooks.map((hook) => hook.hookId)).toEqual(["H009", "H010"]);
|
||||
});
|
||||
|
||||
it("parses semantic payoff timing from extended pending hooks tables", () => {
|
||||
const hooks = memoryRetrieval.parsePendingHooksMarkdown([
|
||||
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
"| oath-debt | 8 | relationship | open | 12 | Reveal why the mentor broke the oath | slow-burn | Long-buried debt stays unresolved |",
|
||||
"| kiln-key | 15 | mystery | open | 15 | Find out what the kiln key opens next chapter | immediate | Fresh key with a fast local payoff |",
|
||||
"",
|
||||
].join("\n"));
|
||||
|
||||
expect(hooks).toEqual([
|
||||
expect.objectContaining({
|
||||
hookId: "oath-debt",
|
||||
payoffTiming: "slow-burn",
|
||||
notes: "Long-buried debt stays unresolved",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
hookId: "kiln-key",
|
||||
payoffTiming: "immediate",
|
||||
notes: "Fresh key with a fast local payoff",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps slow-burn hooks out of early resolve slots while still advancing them", () => {
|
||||
const agenda = memoryRetrieval.buildPlannerHookAgenda({
|
||||
chapterNumber: 18,
|
||||
hooks: [
|
||||
{
|
||||
hookId: "slow-oath",
|
||||
startChapter: 10,
|
||||
type: "relationship",
|
||||
status: "progressing",
|
||||
lastAdvancedChapter: 17,
|
||||
expectedPayoff: "Reveal why the mentor buried the oath debt",
|
||||
payoffTiming: "slow-burn",
|
||||
notes: "The debt should simmer across the wider arc.",
|
||||
},
|
||||
{
|
||||
hookId: "ready-packet",
|
||||
startChapter: 14,
|
||||
type: "mystery",
|
||||
status: "progressing",
|
||||
lastAdvancedChapter: 17,
|
||||
expectedPayoff: "Open the missing packet and expose the inside hand",
|
||||
payoffTiming: "near-term",
|
||||
notes: "The local sequence is ready for a concrete payoff.",
|
||||
},
|
||||
] as never,
|
||||
maxMustAdvance: 2,
|
||||
maxEligibleResolve: 2,
|
||||
targetChapters: 40,
|
||||
} as never);
|
||||
|
||||
expect(agenda.mustAdvance).toContain("slow-oath");
|
||||
expect(agenda.eligibleResolve).toContain("ready-packet");
|
||||
expect(agenda.eligibleResolve).not.toContain("slow-oath");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1041,7 +1041,7 @@ describe("PlannerAgent", () => {
|
||||
});
|
||||
|
||||
const intentMarkdown = await readFile(result.runtimePath, "utf-8");
|
||||
expect(intentMarkdown).toContain("| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |");
|
||||
expect(intentMarkdown).toContain("| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |");
|
||||
expect(intentMarkdown).toContain("| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |");
|
||||
expect(intentMarkdown).not.toContain("| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |");
|
||||
expect(intentMarkdown).not.toContain("| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |");
|
||||
|
||||
@@ -205,6 +205,11 @@ describe("WriterAgent", () => {
|
||||
reason: "Carry forward unresolved hook.",
|
||||
excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
|
||||
},
|
||||
{
|
||||
source: "runtime/hook_debt#mentor-oath",
|
||||
reason: "Explicit hook debt brief for the agenda target.",
|
||||
excerpt: "mentor-oath | cadence: slow-burn | seed: ch8 River Camp - Mentor debt becomes personal | latest: ch99 Locked Gate - Lin Yue chooses the mentor line over the guild line | unpaid: reveal why the mentor broke the oath",
|
||||
},
|
||||
],
|
||||
},
|
||||
ruleStack: {
|
||||
@@ -224,7 +229,9 @@ describe("WriterAgent", () => {
|
||||
expect(settlePrompt).toContain("## 本章控制输入");
|
||||
expect(settlePrompt).toContain("story/chapter_summaries.md#99");
|
||||
expect(settlePrompt).toContain("| 99 | Locked Gate |");
|
||||
expect(settlePrompt).toContain("| stale-ledger | 14 | mystery | open | 70 | 120 | Old ledger debt is dormant but unresolved |");
|
||||
expect(settlePrompt).toContain("## Hook Debt Briefs");
|
||||
expect(settlePrompt).toContain("mentor-oath | cadence: slow-burn");
|
||||
expect(settlePrompt).toContain("| stale-ledger | 14 | mystery | open | 70 | 120 | 中程 | Old ledger debt is dormant but unresolved |");
|
||||
expect(settlePrompt).not.toContain("| 1 | Guild Trail |");
|
||||
expect(settlePrompt).not.toContain("old-seal");
|
||||
expect(settlePrompt).not.toContain("Guildmaster Ren");
|
||||
|
||||
@@ -195,18 +195,20 @@ enableFullCastTracking: false
|
||||
|
||||
const pendingHooksPrompt = resolvedLanguage === "en"
|
||||
? `Initial hook pool (Markdown table):
|
||||
| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |
|
||||
| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes |
|
||||
|
||||
Rules for the hook table:
|
||||
- Column 5 must be a pure chapter number, never natural-language description
|
||||
- During book creation, all planned hooks are still unapplied, so last_advanced_chapter = 0
|
||||
- Column 7 must be one of: immediate / near-term / mid-arc / slow-burn / endgame
|
||||
- If you want to describe the initial clue/signal, put it in notes instead of column 5`
|
||||
: `初始伏笔池(Markdown表格):
|
||||
| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |
|
||||
| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |
|
||||
|
||||
伏笔表规则:
|
||||
- 第5列必须是纯数字章节号,不能写自然语言描述
|
||||
- 建书阶段所有伏笔都还没正式推进,所以第5列统一填 0
|
||||
- 第7列必须填写:立即 / 近期 / 中程 / 慢烧 / 终局 之一
|
||||
- 如果要说明“初始线索/最初信号”,写进备注,不要写进第5列`;
|
||||
|
||||
const finalRequirementsPrompt = resolvedLanguage === "en"
|
||||
@@ -489,9 +491,9 @@ enableFullCastTracking: false
|
||||
|
||||
const pendingHooksPrompt = resolvedLanguage === "en"
|
||||
? `Identify all active hooks from the source text (Markdown table):
|
||||
| hook_id | start_chapter | type | status | latest_progress | expected_payoff | notes |`
|
||||
| hook_id | start_chapter | type | status | latest_progress | expected_payoff | payoff_timing | notes |`
|
||||
: `从正文中识别的所有伏笔(Markdown表格):
|
||||
| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |`;
|
||||
| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |`;
|
||||
|
||||
const keyPrinciplesPrompt = resolvedLanguage === "en"
|
||||
? `## Key Principles
|
||||
@@ -727,7 +729,8 @@ prohibitions:
|
||||
status: row[3] ?? "open",
|
||||
lastAdvancedChapter: normalizedProgress,
|
||||
expectedPayoff: row[5] ?? "",
|
||||
notes,
|
||||
payoffTiming: row.length >= 8 ? row[6] ?? "" : "",
|
||||
notes: row.length >= 8 ? this.mergeHookNotes(row[7] ?? "", seedNote, language) : notes,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ Updated state card as a Markdown table reflecting the end-of-chapter state:
|
||||
|
||||
=== UPDATED_HOOKS ===
|
||||
Updated hooks pool as a Markdown table with the latest status of every known hook:
|
||||
| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |
|
||||
| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes |
|
||||
|
||||
=== CHAPTER_SUMMARY ===
|
||||
Single Markdown table row:
|
||||
@@ -372,7 +372,7 @@ ${bookRulesBody ? `## 本书规则\n\n${bookRulesBody}` : ""}
|
||||
|
||||
=== UPDATED_HOOKS ===
|
||||
更新后的伏笔池(Markdown表格),包含所有已知伏笔的最新状态:
|
||||
| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |
|
||||
| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |
|
||||
|
||||
=== CHAPTER_SUMMARY ===
|
||||
本章摘要(Markdown表格行):
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
parseChapterSummariesMarkdown,
|
||||
retrieveMemorySelection,
|
||||
} from "../utils/memory-retrieval.js";
|
||||
import {
|
||||
localizeHookPayoffTiming,
|
||||
resolveHookPayoffTiming,
|
||||
} from "../utils/hook-lifecycle.js";
|
||||
|
||||
export interface ComposeChapterInput {
|
||||
readonly book: BookConfig;
|
||||
@@ -43,7 +47,11 @@ export class ComposerAgent extends BaseAgent {
|
||||
const runtimeDir = join(storyDir, "runtime");
|
||||
await mkdir(runtimeDir, { recursive: true });
|
||||
|
||||
const selectedContext = await this.collectSelectedContext(storyDir, input.plan);
|
||||
const selectedContext = await this.collectSelectedContext(
|
||||
storyDir,
|
||||
input.plan,
|
||||
input.book.language ?? "zh",
|
||||
);
|
||||
const contextPackage = ContextPackageSchema.parse({
|
||||
chapter: input.chapterNumber,
|
||||
selectedContext,
|
||||
@@ -103,7 +111,11 @@ export class ComposerAgent extends BaseAgent {
|
||||
};
|
||||
}
|
||||
|
||||
private async collectSelectedContext(storyDir: string, plan: PlanChapterOutput): Promise<ContextPackage["selectedContext"]> {
|
||||
private async collectSelectedContext(
|
||||
storyDir: string,
|
||||
plan: PlanChapterOutput,
|
||||
language: "zh" | "en",
|
||||
): Promise<ContextPackage["selectedContext"]> {
|
||||
const entries = await Promise.all([
|
||||
this.maybeContextSource(storyDir, "current_focus.md", "Current task focus for this chapter."),
|
||||
this.maybeContextSource(
|
||||
@@ -145,6 +157,12 @@ export class ComposerAgent extends BaseAgent {
|
||||
outlineNode: planningAnchor,
|
||||
mustKeep: plan.intent.mustKeep,
|
||||
});
|
||||
const hookDebtEntries = await this.buildHookDebtEntries(
|
||||
storyDir,
|
||||
plan,
|
||||
memorySelection.activeHooks,
|
||||
language,
|
||||
);
|
||||
|
||||
const summaryEntries = memorySelection.summaries.map((summary) => ({
|
||||
source: `story/chapter_summaries.md#${summary.chapter}`,
|
||||
@@ -161,7 +179,7 @@ export class ComposerAgent extends BaseAgent {
|
||||
const hookEntries = memorySelection.hooks.map((hook) => ({
|
||||
source: `story/pending_hooks.md#${hook.hookId}`,
|
||||
reason: "Carry forward unresolved hooks that match the chapter focus.",
|
||||
excerpt: [hook.type, hook.status, hook.expectedPayoff, hook.notes]
|
||||
excerpt: [hook.type, hook.status, hook.expectedPayoff, hook.payoffTiming, hook.notes]
|
||||
.filter(Boolean)
|
||||
.join(" | "),
|
||||
}));
|
||||
@@ -174,6 +192,7 @@ export class ComposerAgent extends BaseAgent {
|
||||
return [
|
||||
...entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
|
||||
...trailEntries,
|
||||
...hookDebtEntries,
|
||||
...factEntries,
|
||||
...summaryEntries,
|
||||
...volumeSummaryEntries,
|
||||
@@ -226,6 +245,64 @@ export class ComposerAgent extends BaseAgent {
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async buildHookDebtEntries(
|
||||
storyDir: string,
|
||||
plan: PlanChapterOutput,
|
||||
activeHooks: ReadonlyArray<{
|
||||
readonly hookId: string;
|
||||
readonly startChapter: number;
|
||||
readonly type: string;
|
||||
readonly status: string;
|
||||
readonly lastAdvancedChapter: number;
|
||||
readonly expectedPayoff: string;
|
||||
readonly payoffTiming?: string;
|
||||
readonly notes: string;
|
||||
}>,
|
||||
language: "zh" | "en",
|
||||
): Promise<ContextPackage["selectedContext"]> {
|
||||
const targetHookIds = [...new Set([
|
||||
...plan.intent.hookAgenda.eligibleResolve,
|
||||
...plan.intent.hookAgenda.mustAdvance,
|
||||
...plan.intent.hookAgenda.staleDebt,
|
||||
])];
|
||||
if (targetHookIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const summaries = parseChapterSummariesMarkdown(
|
||||
await this.readFileOrDefault(join(storyDir, "chapter_summaries.md")),
|
||||
);
|
||||
|
||||
return targetHookIds.flatMap((hookId) => {
|
||||
const hook = activeHooks.find((entry) => entry.hookId === hookId);
|
||||
if (!hook) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seedSummary = this.findHookSummary(summaries, hook.hookId, hook.startChapter, "seed");
|
||||
const latestSummary = this.findHookSummary(summaries, hook.hookId, hook.lastAdvancedChapter, "latest");
|
||||
const cadence = localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language);
|
||||
const role = this.describeHookAgendaRole(plan, hook.hookId, language);
|
||||
const promise = hook.expectedPayoff || (language === "en" ? "(unspecified)" : "(未写明)");
|
||||
const seedBeat = seedSummary
|
||||
? this.renderHookDebtBeat(seedSummary)
|
||||
: (hook.notes || promise);
|
||||
const latestBeat = latestSummary
|
||||
? this.renderHookDebtBeat(latestSummary)
|
||||
: (hook.notes || promise);
|
||||
|
||||
return [{
|
||||
source: `runtime/hook_debt#${hook.hookId}`,
|
||||
reason: language === "en"
|
||||
? "Narrative debt brief for an explicit hook agenda target."
|
||||
: "显式 hook agenda 目标的叙事债务简报。",
|
||||
excerpt: language === "en"
|
||||
? `${hook.hookId} | role: ${role} | cadence: ${cadence} | promise: ${promise} | seed: ${seedBeat} | latest: ${latestBeat}`
|
||||
: `${hook.hookId} | 角色: ${role} | 节奏: ${cadence} | 承诺: ${promise} | 种子: ${seedBeat} | 最近推进: ${latestBeat}`,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
private async maybeContextSource(
|
||||
storyDir: string,
|
||||
fileName: string,
|
||||
@@ -270,4 +347,55 @@ export class ComposerAgent extends BaseAgent {
|
||||
return "(文件尚未创建)";
|
||||
}
|
||||
}
|
||||
|
||||
private describeHookAgendaRole(
|
||||
plan: PlanChapterOutput,
|
||||
hookId: string,
|
||||
language: "zh" | "en",
|
||||
): string {
|
||||
if (plan.intent.hookAgenda.eligibleResolve.includes(hookId)) {
|
||||
return language === "en" ? "payoff candidate" : "本章优先兑现";
|
||||
}
|
||||
if (plan.intent.hookAgenda.staleDebt.includes(hookId)) {
|
||||
return language === "en" ? "stale debt" : "高压旧债";
|
||||
}
|
||||
return language === "en" ? "must advance" : "本章必须推进";
|
||||
}
|
||||
|
||||
private findHookSummary(
|
||||
summaries: ReadonlyArray<ReturnType<typeof parseChapterSummariesMarkdown>[number]>,
|
||||
hookId: string,
|
||||
chapter: number,
|
||||
mode: "seed" | "latest",
|
||||
) {
|
||||
const directChapterHit = summaries.find((summary) => summary.chapter === chapter);
|
||||
const hookMentions = summaries.filter((summary) => this.summaryMentionsHook(summary, hookId));
|
||||
if (mode === "seed") {
|
||||
return hookMentions.find((summary) => summary.chapter === chapter)
|
||||
?? hookMentions.at(0)
|
||||
?? directChapterHit;
|
||||
}
|
||||
|
||||
return [...hookMentions].reverse().find((summary) => summary.chapter === chapter)
|
||||
?? hookMentions.at(-1)
|
||||
?? directChapterHit;
|
||||
}
|
||||
|
||||
private summaryMentionsHook(
|
||||
summary: ReturnType<typeof parseChapterSummariesMarkdown>[number],
|
||||
hookId: string,
|
||||
): boolean {
|
||||
return [
|
||||
summary.title,
|
||||
summary.events,
|
||||
summary.stateChanges,
|
||||
summary.hookActivity,
|
||||
].some((text) => text.includes(hookId));
|
||||
}
|
||||
|
||||
private renderHookDebtBeat(
|
||||
summary: ReturnType<typeof parseChapterSummariesMarkdown>[number],
|
||||
): string {
|
||||
return `ch${summary.chapter} ${summary.title} - ${summary.events || summary.hookActivity || summary.stateChanges || "(none)"}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ export class PlannerAgent extends BaseAgent {
|
||||
const hookAgenda = buildPlannerHookAgenda({
|
||||
hooks: memorySelection.activeHooks,
|
||||
chapterNumber: input.chapterNumber,
|
||||
targetChapters: input.book.targetChapters,
|
||||
});
|
||||
const directives = this.buildStructuredDirectives({
|
||||
chapterNumber: input.chapterNumber,
|
||||
|
||||
@@ -22,8 +22,9 @@ export function buildSettlerSystemPrompt(
|
||||
- 提及伏笔:已有伏笔在本章被提到,但没有新增信息、没有改变读者或角色对该问题的理解 → 放入 mention 数组,不要更新最近推进
|
||||
- 推进伏笔:已有伏笔在本章出现了新的事实、证据、关系变化、风险升级或范围收缩 → **必须**更新"最近推进"列为当前章节号,更新状态和备注
|
||||
- 回收伏笔:伏笔在本章被明确揭示、解决、或不再成立 → 状态改为"已回收",备注回收方式
|
||||
- 延后伏笔:超过5章未推进 → 标注"延后",备注原因
|
||||
- 延后伏笔:只有当正文明确显示该线被主动搁置、转入后台、或被剧情压后时,才标注"延后";不要因为“已经过了几章”就机械延后
|
||||
- brand-new unresolved thread:不要直接发明新的 hookId。把候选放进 newHookCandidates,由系统决定它是映射到旧 hook、变成真正新 hook,还是被拒绝为重述
|
||||
- payoffTiming 使用语义节奏,不用硬写章节号:只允许 immediate / near-term / mid-arc / slow-burn / endgame
|
||||
- **铁律**:不要把“再次提到”“换个说法重述”“抽象复盘”当成推进。只有状态真的变了,才更新最近推进。只是出现过的旧 hook,放进 mention 数组。`;
|
||||
|
||||
const fullCastBlock = bookRules?.enableFullCastTracking
|
||||
@@ -114,6 +115,7 @@ function buildSettlerOutputFormat(gp: GenreProfile): string {
|
||||
"status": "progressing",
|
||||
"lastAdvancedChapter": 12,
|
||||
"expectedPayoff": "揭开师债真相",
|
||||
"payoffTiming": "slow-burn",
|
||||
"notes": "本章为何推进/延后/回收"
|
||||
}
|
||||
],
|
||||
@@ -125,6 +127,7 @@ function buildSettlerOutputFormat(gp: GenreProfile): string {
|
||||
{
|
||||
"type": "mystery",
|
||||
"expectedPayoff": "新伏笔未来要回收到哪里",
|
||||
"payoffTiming": "near-term",
|
||||
"notes": "本章为什么会形成新的未解问题"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -105,6 +105,9 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s
|
||||
- When the runtime rule stack records an active L4 -> L3 override, follow the current task over local planning.
|
||||
- Keep hard guardrails compact: canon, continuity facts, and explicit prohibitions still win.
|
||||
- If an English Variance Brief is provided, obey it: avoid the listed phrase/opening/ending patterns and satisfy the scene obligation.
|
||||
- If Hook Debt Briefs are provided, treat them as the active memory of what the reader is still owed: preserve the original promise and change the on-page situation.
|
||||
- When the explicit hook agenda names an eligible resolve target, land a concrete payoff beat instead of merely mentioning the old thread.
|
||||
- When stale debt is present, do not open sibling hooks casually; clear pressure from old promises before minting fresh debt.
|
||||
- In multi-character scenes, include at least one resistance-bearing exchange instead of reducing the beat to summary or explanation.`;
|
||||
}
|
||||
|
||||
@@ -115,6 +118,9 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s
|
||||
- 当 runtime rule stack 明确记录了 L4 -> L3 的 active override 时,优先执行当前任务意图,再局部调整规划层。
|
||||
- 真正不能突破的只有硬护栏:世界设定、连续性事实、显式禁令。
|
||||
- 如果提供了 English Variance Brief,必须主动避开其中列出的高频短语、重复开头和重复结尾模式,并完成 scene obligation。
|
||||
- 如果提供了 Hook Debt 简报,把它当成读者仍在等待兑现的承诺记忆:保留原始 promise,并让本章在页上发生真实变化。
|
||||
- 如果显式 hook agenda 里出现了可回收目标,本章必须写出具体兑现片段,不能只是重新提一句旧线索。
|
||||
- 如果存在 stale debt,先消化旧承诺的压力,再决定是否开新坑;同类 sibling hook 不得随手再开。
|
||||
- 多角色场景里,至少给出一轮带阻力的直接交锋,不要把人物关系写成纯解释或纯总结。`;
|
||||
}
|
||||
|
||||
|
||||
@@ -910,6 +910,7 @@ ${lengthRequirementBlock}
|
||||
blocks.titleHistoryBlock,
|
||||
blocks.moodTrailBlock,
|
||||
blocks.canonBlock,
|
||||
blocks.hookDebtBlock,
|
||||
blocks.hooksBlock,
|
||||
blocks.summariesBlock,
|
||||
blocks.volumeSummariesBlock,
|
||||
|
||||
@@ -16,6 +16,15 @@ export type StateManifest = z.infer<typeof StateManifestSchema>;
|
||||
export const HookStatusSchema = z.enum(["open", "progressing", "deferred", "resolved"]);
|
||||
export type HookStatus = z.infer<typeof HookStatusSchema>;
|
||||
|
||||
export const HookPayoffTimingSchema = z.enum([
|
||||
"immediate",
|
||||
"near-term",
|
||||
"mid-arc",
|
||||
"slow-burn",
|
||||
"endgame",
|
||||
]);
|
||||
export type HookPayoffTiming = z.infer<typeof HookPayoffTimingSchema>;
|
||||
|
||||
export const HookRecordSchema = z.object({
|
||||
hookId: z.string().min(1),
|
||||
startChapter: z.number().int().min(0),
|
||||
@@ -23,6 +32,7 @@ export const HookRecordSchema = z.object({
|
||||
status: HookStatusSchema,
|
||||
lastAdvancedChapter: z.number().int().min(0),
|
||||
expectedPayoff: z.string().default(""),
|
||||
payoffTiming: HookPayoffTimingSchema.optional(),
|
||||
notes: z.string().default(""),
|
||||
});
|
||||
|
||||
@@ -94,6 +104,7 @@ export type HookOps = z.infer<typeof HookOpsSchema>;
|
||||
export const NewHookCandidateSchema = z.object({
|
||||
type: z.string().min(1),
|
||||
expectedPayoff: z.string().default(""),
|
||||
payoffTiming: HookPayoffTimingSchema.optional(),
|
||||
notes: z.string().default(""),
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface StoredHook {
|
||||
readonly status: string;
|
||||
readonly lastAdvancedChapter: number;
|
||||
readonly expectedPayoff: string;
|
||||
readonly payoffTiming?: string;
|
||||
readonly notes: string;
|
||||
}
|
||||
|
||||
@@ -99,6 +100,7 @@ export class MemoryDB {
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
last_advanced_chapter INTEGER NOT NULL DEFAULT 0,
|
||||
expected_payoff TEXT NOT NULL DEFAULT '',
|
||||
payoff_timing TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
@@ -108,6 +110,16 @@ export class MemoryDB {
|
||||
CREATE INDEX IF NOT EXISTS idx_hooks_status ON hooks(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_hooks_last_advanced ON hooks(last_advanced_chapter);
|
||||
`);
|
||||
|
||||
this.ensureColumn("hooks", "payoff_timing", "TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
|
||||
private ensureColumn(table: string, column: string, definition: string): void {
|
||||
try {
|
||||
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
} catch {
|
||||
// Column already exists on existing databases.
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -289,8 +301,8 @@ export class MemoryDB {
|
||||
|
||||
upsertHook(hook: StoredHook): void {
|
||||
this.db.prepare(
|
||||
`INSERT OR REPLACE INTO hooks (hook_id, start_chapter, type, status, last_advanced_chapter, expected_payoff, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT OR REPLACE INTO hooks (hook_id, start_chapter, type, status, last_advanced_chapter, expected_payoff, payoff_timing, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
hook.hookId,
|
||||
hook.startChapter,
|
||||
@@ -298,6 +310,7 @@ export class MemoryDB {
|
||||
hook.status,
|
||||
hook.lastAdvancedChapter,
|
||||
hook.expectedPayoff,
|
||||
hook.payoffTiming ?? "",
|
||||
hook.notes,
|
||||
);
|
||||
}
|
||||
@@ -318,6 +331,7 @@ export class MemoryDB {
|
||||
status,
|
||||
last_advanced_chapter AS lastAdvancedChapter,
|
||||
expected_payoff AS expectedPayoff,
|
||||
payoff_timing AS payoffTiming,
|
||||
notes
|
||||
FROM hooks
|
||||
WHERE lower(status) NOT IN ('resolved', 'closed', '已回收', '已解决')
|
||||
|
||||
@@ -109,15 +109,16 @@ export async function loadNarrativeMemorySeed(bookDir: string): Promise<Narrativ
|
||||
mood: row.mood,
|
||||
chapterType: row.chapterType,
|
||||
})),
|
||||
hooks: snapshot.hooks.hooks.map((hook) => ({
|
||||
hookId: hook.hookId,
|
||||
startChapter: hook.startChapter,
|
||||
type: hook.type,
|
||||
status: hook.status,
|
||||
lastAdvancedChapter: hook.lastAdvancedChapter,
|
||||
expectedPayoff: hook.expectedPayoff,
|
||||
notes: hook.notes,
|
||||
})),
|
||||
hooks: snapshot.hooks.hooks.map((hook) => ({
|
||||
hookId: hook.hookId,
|
||||
startChapter: hook.startChapter,
|
||||
type: hook.type,
|
||||
status: hook.status,
|
||||
lastAdvancedChapter: hook.lastAdvancedChapter,
|
||||
expectedPayoff: hook.expectedPayoff,
|
||||
payoffTiming: hook.payoffTiming,
|
||||
notes: hook.notes,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type StateManifest,
|
||||
} from "../models/runtime-state.js";
|
||||
import type { Fact, StoredHook, StoredSummary } from "./memory-db.js";
|
||||
import { normalizeHookPayoffTiming } from "../utils/hook-lifecycle.js";
|
||||
|
||||
export interface BootstrapStructuredStateResult {
|
||||
readonly createdFiles: ReadonlyArray<string>;
|
||||
@@ -186,15 +187,19 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
|
||||
if (tableRows.length > 0) {
|
||||
return tableRows
|
||||
.filter((row) => normalizeHookId(row[0]).length > 0)
|
||||
.map((row) => ({
|
||||
hookId: normalizeHookId(row[0]),
|
||||
startChapter: parseInteger(row[1]),
|
||||
type: row[2] ?? "",
|
||||
status: row[3] ?? "open",
|
||||
lastAdvancedChapter: parseInteger(row[4]),
|
||||
expectedPayoff: row[5] ?? "",
|
||||
notes: row[6] ?? "",
|
||||
}));
|
||||
.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
|
||||
@@ -210,6 +215,7 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
|
||||
status: "open",
|
||||
lastAdvancedChapter: 0,
|
||||
expectedPayoff: "",
|
||||
payoffTiming: undefined,
|
||||
notes: line,
|
||||
}));
|
||||
}
|
||||
@@ -333,6 +339,7 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) {
|
||||
.filter((row) => normalizeHookId(row[0]).length > 0)
|
||||
.map((row) => {
|
||||
const hookId = normalizeHookId(row[0]);
|
||||
const legacyShape = row.length < 8;
|
||||
return {
|
||||
hookId,
|
||||
startChapter: parseIntegerWithWarning(row[1], warnings, `${hookId}:startChapter`),
|
||||
@@ -340,7 +347,8 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) {
|
||||
status: normalizeHookStatus(row[3], warnings, hookId),
|
||||
lastAdvancedChapter: parseIntegerWithWarning(row[4], warnings, `${hookId}:lastAdvancedChapter`),
|
||||
expectedPayoff: row[5] ?? "",
|
||||
notes: row[6] ?? "",
|
||||
payoffTiming: legacyShape ? undefined : normalizeHookPayoffTiming(row[6]),
|
||||
notes: legacyShape ? (row[6] ?? "") : (row[7] ?? ""),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -360,6 +368,7 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) {
|
||||
status: "open" as HookStatus,
|
||||
lastAdvancedChapter: 0,
|
||||
expectedPayoff: "",
|
||||
payoffTiming: undefined,
|
||||
notes: line,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -3,6 +3,10 @@ import type {
|
||||
CurrentStateState,
|
||||
HooksState,
|
||||
} from "../models/runtime-state.js";
|
||||
import {
|
||||
localizeHookPayoffTiming,
|
||||
resolveHookPayoffTiming,
|
||||
} from "../utils/hook-lifecycle.js";
|
||||
|
||||
export function renderHooksProjection(
|
||||
state: HooksState,
|
||||
@@ -11,12 +15,12 @@ export function renderHooksProjection(
|
||||
const title = language === "en" ? "# Pending Hooks" : "# 伏笔池";
|
||||
const headers = language === "en"
|
||||
? [
|
||||
"| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||
"| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
: [
|
||||
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
];
|
||||
|
||||
const rows = [...state.hooks]
|
||||
@@ -33,6 +37,7 @@ export function renderHooksProjection(
|
||||
hook.status,
|
||||
hook.lastAdvancedChapter,
|
||||
hook.expectedPayoff,
|
||||
localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language),
|
||||
hook.notes,
|
||||
].map(escapeTableCell).join(" | ")
|
||||
} |`);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type StateManifest,
|
||||
} from "../models/runtime-state.js";
|
||||
import { evaluateHookAdmission } from "../utils/hook-governance.js";
|
||||
import { resolveHookPayoffTiming } from "../utils/hook-lifecycle.js";
|
||||
import { validateRuntimeState } from "./state-validator.js";
|
||||
|
||||
export interface RuntimeStateSnapshot {
|
||||
@@ -153,6 +154,11 @@ function mergeDuplicateHookFamily(existing: HookRecord, incoming: HookRecord): H
|
||||
: existing.status,
|
||||
lastAdvancedChapter: advanced,
|
||||
expectedPayoff,
|
||||
payoffTiming: resolveHookPayoffTiming({
|
||||
payoffTiming: incoming.payoffTiming ?? existing.payoffTiming,
|
||||
expectedPayoff,
|
||||
notes,
|
||||
}),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export function buildGovernedMemoryEvidenceBlocks(
|
||||
contextPackage: ContextPackage,
|
||||
language?: "zh" | "en",
|
||||
): {
|
||||
readonly hookDebtBlock?: string;
|
||||
readonly hooksBlock?: string;
|
||||
readonly summariesBlock?: string;
|
||||
readonly volumeSummariesBlock?: string;
|
||||
@@ -15,6 +16,9 @@ export function buildGovernedMemoryEvidenceBlocks(
|
||||
const hookEntries = contextPackage.selectedContext.filter((entry) =>
|
||||
entry.source.startsWith("story/pending_hooks.md#"),
|
||||
);
|
||||
const hookDebtEntries = contextPackage.selectedContext.filter((entry) =>
|
||||
entry.source.startsWith("runtime/hook_debt#"),
|
||||
);
|
||||
const summaryEntries = contextPackage.selectedContext.filter((entry) =>
|
||||
entry.source.startsWith("story/chapter_summaries.md#"),
|
||||
);
|
||||
@@ -33,6 +37,12 @@ export function buildGovernedMemoryEvidenceBlocks(
|
||||
);
|
||||
|
||||
return {
|
||||
hookDebtBlock: hookDebtEntries.length > 0
|
||||
? renderHookDebtBlock(
|
||||
resolvedLanguage === "en" ? "Hook Debt Briefs" : "Hook Debt Briefs",
|
||||
hookDebtEntries,
|
||||
)
|
||||
: undefined,
|
||||
hooksBlock: hookEntries.length > 0
|
||||
? renderEvidenceBlock(
|
||||
resolvedLanguage === "en" ? "Selected Hook Evidence" : "已选伏笔证据",
|
||||
@@ -72,6 +82,13 @@ export function buildGovernedMemoryEvidenceBlocks(
|
||||
};
|
||||
}
|
||||
|
||||
function renderHookDebtBlock(
|
||||
heading: string,
|
||||
entries: ContextPackage["selectedContext"],
|
||||
): string {
|
||||
return `\n## ${heading}\n${entries.map((entry) => `- ${entry.excerpt ?? entry.reason}`).join("\n")}\n`;
|
||||
}
|
||||
|
||||
function renderEvidenceBlock(
|
||||
heading: string,
|
||||
entries: ContextPackage["selectedContext"],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type RuntimeStateDelta,
|
||||
} from "../models/runtime-state.js";
|
||||
import { evaluateHookAdmission } from "./hook-governance.js";
|
||||
import { resolveHookPayoffTiming } from "./hook-lifecycle.js";
|
||||
|
||||
export interface HookArbiterDecision {
|
||||
readonly action: "created" | "mapped" | "mentioned" | "rejected";
|
||||
@@ -154,6 +155,11 @@ function mergeCandidateIntoExistingHook(
|
||||
status: existing.status === "resolved" ? "resolved" : "progressing",
|
||||
lastAdvancedChapter: Math.max(existing.lastAdvancedChapter, chapter),
|
||||
expectedPayoff: preferRicherText(existing.expectedPayoff, candidate.expectedPayoff),
|
||||
payoffTiming: resolveHookPayoffTiming({
|
||||
payoffTiming: candidate.payoffTiming ?? existing.payoffTiming,
|
||||
expectedPayoff: preferRicherText(existing.expectedPayoff, candidate.expectedPayoff),
|
||||
notes: preferRicherText(existing.notes, candidate.notes),
|
||||
}),
|
||||
notes: preferRicherText(existing.notes, candidate.notes),
|
||||
};
|
||||
}
|
||||
@@ -170,6 +176,7 @@ function createCanonicalHook(params: {
|
||||
status: "open",
|
||||
lastAdvancedChapter: params.chapter,
|
||||
expectedPayoff: params.candidate.expectedPayoff.trim(),
|
||||
payoffTiming: resolveHookPayoffTiming(params.candidate),
|
||||
notes: params.candidate.notes.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js";
|
||||
import { describeHookLifecycle } from "./hook-lifecycle.js";
|
||||
|
||||
export type HookDisposition = "none" | "mention" | "advance" | "resolve" | "defer";
|
||||
|
||||
export interface HookAdmissionCandidate {
|
||||
readonly type: string;
|
||||
readonly expectedPayoff?: string;
|
||||
readonly payoffTiming?: string;
|
||||
readonly notes?: string;
|
||||
}
|
||||
|
||||
@@ -17,15 +19,30 @@ export interface HookAdmissionDecision {
|
||||
export function collectStaleHookDebt(params: {
|
||||
readonly hooks: ReadonlyArray<HookRecord>;
|
||||
readonly chapterNumber: number;
|
||||
readonly targetChapters?: number;
|
||||
readonly staleAfterChapters?: number;
|
||||
}): HookRecord[] {
|
||||
const staleAfterChapters = params.staleAfterChapters ?? 10;
|
||||
const staleCutoff = params.chapterNumber - staleAfterChapters;
|
||||
|
||||
return params.hooks
|
||||
.filter((hook) => hook.status !== "resolved" && hook.status !== "deferred")
|
||||
.filter((hook) => hook.startChapter <= params.chapterNumber)
|
||||
.filter((hook) => hook.lastAdvancedChapter <= staleCutoff)
|
||||
.filter((hook) => {
|
||||
const lifecycle = describeHookLifecycle({
|
||||
payoffTiming: hook.payoffTiming,
|
||||
expectedPayoff: hook.expectedPayoff,
|
||||
notes: hook.notes,
|
||||
startChapter: hook.startChapter,
|
||||
lastAdvancedChapter: hook.lastAdvancedChapter,
|
||||
status: hook.status,
|
||||
chapterNumber: params.chapterNumber,
|
||||
targetChapters: params.targetChapters,
|
||||
});
|
||||
|
||||
if (params.staleAfterChapters !== undefined) {
|
||||
return hook.lastAdvancedChapter <= params.chapterNumber - params.staleAfterChapters;
|
||||
}
|
||||
|
||||
return lifecycle.stale || lifecycle.overdue;
|
||||
})
|
||||
.sort((left, right) => (
|
||||
left.lastAdvancedChapter - right.lastAdvancedChapter
|
||||
|| left.startChapter - right.startChapter
|
||||
@@ -60,6 +77,7 @@ export function evaluateHookAdmission(params: {
|
||||
const candidateNormalized = normalizeText([
|
||||
params.candidate.type,
|
||||
params.candidate.expectedPayoff ?? "",
|
||||
params.candidate.payoffTiming ?? "",
|
||||
params.candidate.notes ?? "",
|
||||
].join(" "));
|
||||
const candidateTerms = extractTerms(candidateNormalized);
|
||||
@@ -69,6 +87,7 @@ export function evaluateHookAdmission(params: {
|
||||
const activeNormalized = normalizeText([
|
||||
hook.type,
|
||||
hook.expectedPayoff,
|
||||
hook.payoffTiming ?? "",
|
||||
hook.notes,
|
||||
].join(" "));
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { HookPayoffTiming } from "../models/runtime-state.js";
|
||||
|
||||
type HookPhase = "opening" | "middle" | "late";
|
||||
|
||||
interface LifecycleProfile {
|
||||
readonly earliestResolveAge: number;
|
||||
readonly staleDormancy: number;
|
||||
readonly overdueAge: number;
|
||||
readonly minimumPhase: HookPhase;
|
||||
readonly resolveBias: number;
|
||||
}
|
||||
|
||||
const TIMING_PROFILES: Record<HookPayoffTiming, LifecycleProfile> = {
|
||||
immediate: {
|
||||
earliestResolveAge: 1,
|
||||
staleDormancy: 1,
|
||||
overdueAge: 3,
|
||||
minimumPhase: "opening",
|
||||
resolveBias: 5,
|
||||
},
|
||||
"near-term": {
|
||||
earliestResolveAge: 1,
|
||||
staleDormancy: 2,
|
||||
overdueAge: 5,
|
||||
minimumPhase: "opening",
|
||||
resolveBias: 4,
|
||||
},
|
||||
"mid-arc": {
|
||||
earliestResolveAge: 2,
|
||||
staleDormancy: 4,
|
||||
overdueAge: 8,
|
||||
minimumPhase: "opening",
|
||||
resolveBias: 3,
|
||||
},
|
||||
"slow-burn": {
|
||||
earliestResolveAge: 4,
|
||||
staleDormancy: 5,
|
||||
overdueAge: 12,
|
||||
minimumPhase: "middle",
|
||||
resolveBias: 2,
|
||||
},
|
||||
endgame: {
|
||||
earliestResolveAge: 6,
|
||||
staleDormancy: 6,
|
||||
overdueAge: 16,
|
||||
minimumPhase: "late",
|
||||
resolveBias: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const PHASE_WEIGHT: Record<HookPhase, number> = {
|
||||
opening: 0,
|
||||
middle: 1,
|
||||
late: 2,
|
||||
};
|
||||
|
||||
const LABELS: Record<"zh" | "en", Record<HookPayoffTiming, string>> = {
|
||||
en: {
|
||||
immediate: "immediate",
|
||||
"near-term": "near-term",
|
||||
"mid-arc": "mid-arc",
|
||||
"slow-burn": "slow-burn",
|
||||
endgame: "endgame",
|
||||
},
|
||||
zh: {
|
||||
immediate: "立即",
|
||||
"near-term": "近期",
|
||||
"mid-arc": "中程",
|
||||
"slow-burn": "慢烧",
|
||||
endgame: "终局",
|
||||
},
|
||||
};
|
||||
|
||||
const TIMING_ALIASES: Array<[HookPayoffTiming, RegExp]> = [
|
||||
["immediate", /^(?:立即|马上|当章|本章|下一章|immediate|instant|next(?:\s+chapter|\s+beat)?|right\s+away)$/i],
|
||||
["near-term", /^(?:近期|近几章|短线|soon|short(?:\s+run)?|near(?:\s*-\s*|\s+)term|current\s+sequence)$/i],
|
||||
["mid-arc", /^(?:中程|中期|卷中|mid(?:\s*-\s*|\s+)arc|mid(?:\s*-\s*|\s+)book|middle)$/i],
|
||||
["slow-burn", /^(?:慢烧|长线|后续|later|late(?:r)?|long(?:\s*-\s*|\s+)arc|slow(?:\s*-\s*|\s+)burn)$/i],
|
||||
["endgame", /^(?:终局|终章|大结局|最终|climax|finale|endgame|late\s+book)$/i],
|
||||
];
|
||||
|
||||
const SIGNAL_PATTERNS: Array<[HookPayoffTiming, RegExp]> = [
|
||||
["endgame", /(终局|终章|大结局|最终揭晓|最终摊牌|climax|finale|endgame|final reveal|last act)/i],
|
||||
["immediate", /(当章|本章|下一章|马上|立刻|即刻|immediate|next chapter|right away|at once)/i],
|
||||
["near-term", /(近期|近几章|很快|短线|soon|near-term|short run|current sequence)/i],
|
||||
["mid-arc", /(中期|卷中|本卷中段|mid-book|mid arc|middle of the arc)/i],
|
||||
["slow-burn", /(长线|慢烧|后续发酵|慢慢揭开|later|slow burn|long arc|long tail)/i],
|
||||
];
|
||||
|
||||
export function normalizeHookPayoffTiming(value: string | undefined | null): HookPayoffTiming | undefined {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) return undefined;
|
||||
|
||||
for (const [timing, pattern] of TIMING_ALIASES) {
|
||||
if (pattern.test(normalized)) {
|
||||
return timing;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function inferHookPayoffTiming(params: {
|
||||
readonly expectedPayoff?: string;
|
||||
readonly notes?: string;
|
||||
}): HookPayoffTiming {
|
||||
const combined = [params.expectedPayoff, params.notes]
|
||||
.filter((value): value is string => Boolean(value && value.trim()))
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (!combined) return "mid-arc";
|
||||
|
||||
for (const [timing, pattern] of SIGNAL_PATTERNS) {
|
||||
if (pattern.test(combined)) {
|
||||
return timing;
|
||||
}
|
||||
}
|
||||
|
||||
return "mid-arc";
|
||||
}
|
||||
|
||||
export function resolveHookPayoffTiming(params: {
|
||||
readonly payoffTiming?: string | null;
|
||||
readonly expectedPayoff?: string;
|
||||
readonly notes?: string;
|
||||
}): HookPayoffTiming {
|
||||
return normalizeHookPayoffTiming(params.payoffTiming)
|
||||
?? inferHookPayoffTiming({
|
||||
expectedPayoff: params.expectedPayoff,
|
||||
notes: params.notes,
|
||||
});
|
||||
}
|
||||
|
||||
export function localizeHookPayoffTiming(
|
||||
timing: HookPayoffTiming,
|
||||
language: "zh" | "en",
|
||||
): string {
|
||||
return LABELS[language][timing];
|
||||
}
|
||||
|
||||
export function describeHookLifecycle(params: {
|
||||
readonly payoffTiming?: string | null;
|
||||
readonly expectedPayoff?: string;
|
||||
readonly notes?: string;
|
||||
readonly startChapter: number;
|
||||
readonly lastAdvancedChapter: number;
|
||||
readonly status: string;
|
||||
readonly chapterNumber: number;
|
||||
readonly targetChapters?: number;
|
||||
}): {
|
||||
readonly timing: HookPayoffTiming;
|
||||
readonly phase: HookPhase;
|
||||
readonly age: number;
|
||||
readonly dormancy: number;
|
||||
readonly readyToResolve: boolean;
|
||||
readonly stale: boolean;
|
||||
readonly overdue: boolean;
|
||||
readonly advancePressure: number;
|
||||
readonly resolvePressure: number;
|
||||
} {
|
||||
const timing = resolveHookPayoffTiming(params);
|
||||
const profile = TIMING_PROFILES[timing];
|
||||
const phase = resolveHookPhase(params.chapterNumber, params.targetChapters);
|
||||
const age = Math.max(0, params.chapterNumber - Math.max(1, params.startChapter));
|
||||
const lastTouchChapter = Math.max(params.startChapter, params.lastAdvancedChapter);
|
||||
const dormancy = Math.max(0, params.chapterNumber - Math.max(1, lastTouchChapter));
|
||||
const explicitProgressing = /^(progressing|advanced|重大推进|持续推进)$/i.test(params.status.trim());
|
||||
const phaseReady = PHASE_WEIGHT[phase] >= PHASE_WEIGHT[profile.minimumPhase];
|
||||
const recentlyTouched = dormancy <= 1;
|
||||
const overdue = phaseReady && age >= profile.overdueAge;
|
||||
const cadenceReady = timing === "slow-burn"
|
||||
? phase === "late" || overdue
|
||||
: timing === "endgame"
|
||||
? phase === "late"
|
||||
: true;
|
||||
const momentum = explicitProgressing || recentlyTouched;
|
||||
const stale = phaseReady && (
|
||||
dormancy >= profile.staleDormancy
|
||||
|| (overdue && !momentum)
|
||||
);
|
||||
const readyToResolve = phaseReady
|
||||
&& cadenceReady
|
||||
&& age >= profile.earliestResolveAge
|
||||
&& (momentum || (overdue && explicitProgressing));
|
||||
|
||||
return {
|
||||
timing,
|
||||
phase,
|
||||
age,
|
||||
dormancy,
|
||||
readyToResolve,
|
||||
stale,
|
||||
overdue,
|
||||
advancePressure: age + dormancy + (stale ? 8 : 0) + (overdue ? 6 : 0),
|
||||
resolvePressure: readyToResolve
|
||||
? profile.resolveBias * 10 + (explicitProgressing ? 5 : 0) + Math.min(12, dormancy * 2) + (overdue ? 10 : 0)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveHookPhase(chapterNumber: number, targetChapters?: number): HookPhase {
|
||||
if (targetChapters && targetChapters > 0) {
|
||||
const progress = chapterNumber / targetChapters;
|
||||
if (progress >= 0.72) return "late";
|
||||
if (progress >= 0.33) return "middle";
|
||||
return "opening";
|
||||
}
|
||||
|
||||
if (chapterNumber >= 24) return "late";
|
||||
if (chapterNumber >= 8) return "middle";
|
||||
return "opening";
|
||||
}
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
} 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 { collectStaleHookDebt } from "./hook-governance.js";
|
||||
import {
|
||||
describeHookLifecycle,
|
||||
localizeHookPayoffTiming,
|
||||
resolveHookPayoffTiming,
|
||||
normalizeHookPayoffTiming,
|
||||
} from "./hook-lifecycle.js";
|
||||
|
||||
export interface MemorySelection {
|
||||
readonly summaries: ReadonlyArray<StoredSummary>;
|
||||
@@ -191,12 +196,12 @@ export function renderHookSnapshot(
|
||||
|
||||
const headers = language === "en"
|
||||
? [
|
||||
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
: [
|
||||
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
];
|
||||
|
||||
return [
|
||||
@@ -208,6 +213,7 @@ export function renderHookSnapshot(
|
||||
hook.status,
|
||||
hook.lastAdvancedChapter,
|
||||
hook.expectedPayoff,
|
||||
localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language),
|
||||
hook.notes,
|
||||
].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`),
|
||||
].join("\n");
|
||||
@@ -216,6 +222,7 @@ export function renderHookSnapshot(
|
||||
export function buildPlannerHookAgenda(params: {
|
||||
readonly hooks: ReadonlyArray<StoredHook>;
|
||||
readonly chapterNumber: number;
|
||||
readonly targetChapters?: number;
|
||||
readonly maxMustAdvance?: number;
|
||||
readonly maxEligibleResolve?: number;
|
||||
readonly maxStaleDebt?: number;
|
||||
@@ -224,31 +231,55 @@ export function buildPlannerHookAgenda(params: {
|
||||
.map(normalizeStoredHook)
|
||||
.filter((hook) => !isFuturePlannedHook(hook, params.chapterNumber, 0))
|
||||
.filter((hook) => hook.status !== "resolved" && hook.status !== "deferred");
|
||||
const mustAdvanceHooks = agendaHooks
|
||||
const lifecycleEntries = agendaHooks.map((hook) => ({
|
||||
hook,
|
||||
lifecycle: describeHookLifecycle({
|
||||
payoffTiming: hook.payoffTiming,
|
||||
expectedPayoff: hook.expectedPayoff,
|
||||
notes: hook.notes,
|
||||
startChapter: hook.startChapter,
|
||||
lastAdvancedChapter: hook.lastAdvancedChapter,
|
||||
status: hook.status,
|
||||
chapterNumber: params.chapterNumber,
|
||||
targetChapters: params.targetChapters,
|
||||
}),
|
||||
}));
|
||||
const staleDebtHooks = lifecycleEntries
|
||||
.filter((entry) => entry.lifecycle.stale)
|
||||
.sort((left, right) => (
|
||||
Number(right.lifecycle.overdue) - Number(left.lifecycle.overdue)
|
||||
|| right.lifecycle.advancePressure - left.lifecycle.advancePressure
|
||||
|| left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter
|
||||
|| left.hook.startChapter - right.hook.startChapter
|
||||
|| left.hook.hookId.localeCompare(right.hook.hookId)
|
||||
))
|
||||
.slice(0, params.maxStaleDebt ?? 2)
|
||||
.map((entry) => entry.hook);
|
||||
const mustAdvanceHooks = lifecycleEntries
|
||||
.slice()
|
||||
.sort((left, right) => (
|
||||
left.lastAdvancedChapter - right.lastAdvancedChapter
|
||||
|| left.startChapter - right.startChapter
|
||||
|| left.hookId.localeCompare(right.hookId)
|
||||
Number(right.lifecycle.stale) - Number(left.lifecycle.stale)
|
||||
|| right.lifecycle.advancePressure - left.lifecycle.advancePressure
|
||||
|| left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter
|
||||
|| left.hook.startChapter - right.hook.startChapter
|
||||
|| left.hook.hookId.localeCompare(right.hook.hookId)
|
||||
))
|
||||
.slice(0, params.maxMustAdvance ?? 2);
|
||||
const staleDebtHooks = collectStaleHookDebt({
|
||||
hooks: agendaHooks,
|
||||
chapterNumber: params.chapterNumber,
|
||||
})
|
||||
.slice(0, params.maxStaleDebt ?? 2);
|
||||
const eligibleResolveHooks = agendaHooks
|
||||
.filter((hook) => hook.startChapter <= params.chapterNumber - 3)
|
||||
.filter((hook) => hook.lastAdvancedChapter >= params.chapterNumber - 2)
|
||||
.slice(0, params.maxMustAdvance ?? 2)
|
||||
.map((entry) => entry.hook);
|
||||
const eligibleResolveHooks = lifecycleEntries
|
||||
.filter((entry) => entry.lifecycle.readyToResolve)
|
||||
.sort((left, right) => (
|
||||
left.startChapter - right.startChapter
|
||||
|| right.lastAdvancedChapter - left.lastAdvancedChapter
|
||||
|| left.hookId.localeCompare(right.hookId)
|
||||
right.lifecycle.resolvePressure - left.lifecycle.resolvePressure
|
||||
|| Number(right.lifecycle.stale) - Number(left.lifecycle.stale)
|
||||
|| left.hook.startChapter - right.hook.startChapter
|
||||
|| left.hook.hookId.localeCompare(right.hook.hookId)
|
||||
))
|
||||
.slice(0, params.maxEligibleResolve ?? 1);
|
||||
.slice(0, params.maxEligibleResolve ?? 1)
|
||||
.map((entry) => entry.hook);
|
||||
const avoidNewHookFamilies = [...new Set([
|
||||
...staleDebtHooks.map((hook) => hook.type.trim()).filter(Boolean),
|
||||
...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean),
|
||||
...eligibleResolveHooks.map((hook) => hook.type.trim()).filter(Boolean),
|
||||
])].slice(0, 3);
|
||||
|
||||
return {
|
||||
@@ -388,15 +419,7 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
|
||||
if (tableRows.length > 0) {
|
||||
return tableRows
|
||||
.filter((row) => normalizeHookId(row[0]).length > 0)
|
||||
.map((row) => ({
|
||||
hookId: normalizeHookId(row[0]),
|
||||
startChapter: parseInteger(row[1]),
|
||||
type: row[2] ?? "",
|
||||
status: row[3] ?? "open",
|
||||
lastAdvancedChapter: parseInteger(row[4]),
|
||||
expectedPayoff: row[5] ?? "",
|
||||
notes: row[6] ?? "",
|
||||
}));
|
||||
.map((row) => parsePendingHookRow(row));
|
||||
}
|
||||
|
||||
return markdown
|
||||
@@ -412,10 +435,28 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
|
||||
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,
|
||||
@@ -553,9 +594,9 @@ function selectRelevantHooks(
|
||||
const ranked = hooks
|
||||
.map((hook) => ({
|
||||
hook,
|
||||
score: scoreHook(hook, queryTerms),
|
||||
score: scoreHook(hook, queryTerms, chapterNumber),
|
||||
matched: matchesAny(
|
||||
[hook.hookId, hook.type, hook.expectedPayoff, hook.notes].join(" "),
|
||||
[hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" "),
|
||||
queryTerms,
|
||||
),
|
||||
}))
|
||||
@@ -665,8 +706,12 @@ function scoreSummary(summary: StoredSummary, chapterNumber: number, queryTerms:
|
||||
return recencyScore + termScore;
|
||||
}
|
||||
|
||||
function scoreHook(hook: StoredHook, queryTerms: ReadonlyArray<string>): number {
|
||||
const text = [hook.hookId, hook.type, hook.expectedPayoff, hook.notes].join(" ");
|
||||
function scoreHook(
|
||||
hook: StoredHook,
|
||||
queryTerms: ReadonlyArray<string>,
|
||||
_chapterNumber: number,
|
||||
): number {
|
||||
const text = [hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" ");
|
||||
const freshness = Math.max(0, hook.lastAdvancedChapter);
|
||||
const termScore = queryTerms.reduce((score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0), 0);
|
||||
return termScore + freshness;
|
||||
@@ -680,6 +725,7 @@ function normalizeStoredHook(hook: StoredHook): HookRecord {
|
||||
status: normalizeStoredHookStatus(hook.status),
|
||||
lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter),
|
||||
expectedPayoff: hook.expectedPayoff,
|
||||
payoffTiming: resolveHookPayoffTiming(hook),
|
||||
notes: hook.notes,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user