From 3f8528798fd76b823f98b7a149d4a09f669e0f8a Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 13:15:38 +0800 Subject: [PATCH] feat(pipeline): make hook retrieval lifecycle-aware --- .../src/__tests__/memory-retrieval.test.ts | 102 ++++++++++++++++ packages/core/src/utils/memory-retrieval.ts | 113 +++++++++++++++--- 2 files changed, 196 insertions(+), 19 deletions(-) diff --git a/packages/core/src/__tests__/memory-retrieval.test.ts b/packages/core/src/__tests__/memory-retrieval.test.ts index 6de2f98d..98b28cf1 100644 --- a/packages/core/src/__tests__/memory-retrieval.test.ts +++ b/packages/core/src/__tests__/memory-retrieval.test.ts @@ -896,6 +896,108 @@ describe("retrieveMemorySelection", () => { expect(result.hooks.map((hook) => hook.hookId)).not.toContain("stale-resolved"); }); + it("surfaces multiple stale hook families when debt pressure clusters instead of only one stale extra", async () => { + root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-stale-cluster-test-")); + const bookDir = join(root, "book"); + const storyDir = join(bookDir, "story"); + const stateDir = join(storyDir, "state"); + await mkdir(stateDir, { recursive: true }); + + await Promise.all([ + writeFile( + join(stateDir, "manifest.json"), + JSON.stringify({ + schemaVersion: 2, + language: "en", + lastAppliedChapter: 50, + projectionVersion: 1, + migrationWarnings: [], + }, null, 2), + "utf-8", + ), + writeFile( + join(stateDir, "current_state.json"), + JSON.stringify({ + chapter: 50, + facts: [], + }, null, 2), + "utf-8", + ), + writeFile( + join(stateDir, "chapter_summaries.json"), + JSON.stringify({ + rows: [], + }, null, 2), + "utf-8", + ), + writeFile( + join(stateDir, "hooks.json"), + JSON.stringify({ + hooks: [ + { + hookId: "recent-route", + startChapter: 47, + type: "route", + status: "open", + lastAdvancedChapter: 49, + expectedPayoff: "Recent route payoff", + notes: "Recent route remains active.", + }, + { + hookId: "recent-guild", + startChapter: 46, + type: "politics", + status: "progressing", + lastAdvancedChapter: 48, + expectedPayoff: "Guild payoff", + notes: "Recent guild pressure remains active.", + }, + { + hookId: "recent-token", + startChapter: 45, + type: "artifact", + status: "open", + lastAdvancedChapter: 47, + expectedPayoff: "Token payoff", + notes: "Recent token route remains active.", + }, + { + hookId: "stale-omega", + startChapter: 6, + type: "relationship", + status: "open", + lastAdvancedChapter: 12, + expectedPayoff: "Old relic payoff", + notes: "Dormant unresolved relationship line.", + }, + { + hookId: "stale-sable", + startChapter: 8, + type: "mystery", + status: "open", + lastAdvancedChapter: 14, + expectedPayoff: "Archive payoff", + notes: "Dormant unresolved mystery line.", + }, + ], + }, null, 2), + "utf-8", + ), + ]); + + const result = await retrieveMemorySelection({ + bookDir, + chapterNumber: 51, + goal: "Keep the chapter on the debt cluster and route pressure together.", + mustKeep: ["The old debt cluster must stay legible."], + }); + + expect(result.hooks.map((hook) => hook.hookId)).toEqual(expect.arrayContaining([ + "stale-omega", + "stale-sable", + ])); + }); + it("does not surface far-future unstarted hooks in early chapter retrieval", async () => { root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-future-hook-gate-test-")); const bookDir = join(root, "book"); diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index bc6df45c..eea31864 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -390,7 +390,10 @@ function resolveAgendaLimit(params: { } function selectAgendaHooksWithTypeSpread; + readonly hook: { + readonly hookId: string; + readonly type: string; + }; readonly lifecycle: ReturnType; }>(params: { readonly entries: ReadonlyArray; @@ -455,6 +458,65 @@ function normalizeHookType(type: string): string { return type.trim().toLowerCase() || "hook"; } +function resolveRelevantHookPrimaryLimit(entries: ReadonlyArray<{ + readonly hook: { + readonly type: string; + }; + readonly lifecycle: ReturnType; +}>): number { + const pressuredCount = entries.filter((entry) => + entry.lifecycle.readyToResolve + || entry.lifecycle.stale + || entry.lifecycle.overdue, + ).length; + return pressuredCount >= 4 ? 4 : 3; +} + +function resolveRelevantHookStaleLimit( + entries: ReadonlyArray<{ + readonly hook: { + readonly hookId: string; + readonly type: string; + }; + readonly lifecycle: ReturnType; + }>, + selectedIds: ReadonlySet, +): number { + const staleCandidates = entries.filter((entry) => + !selectedIds.has(entry.hook.hookId) + && (entry.lifecycle.stale || entry.lifecycle.overdue), + ); + if (staleCandidates.length === 0) { + return 0; + } + + const staleFamilies = new Set( + staleCandidates.map((entry) => normalizeHookType(entry.hook.type)), + ).size; + const overdueCount = staleCandidates.filter((entry) => entry.lifecycle.overdue).length; + if (overdueCount >= 2 || staleFamilies >= 2) { + return Math.min(2, staleCandidates.length); + } + + return 1; +} + +function isHookWithinLifecycleWindow( + hook: StoredHook, + chapterNumber: number, + lifecycle: ReturnType, +): boolean { + const recentWindow = lifecycle.timing === "endgame" + ? 10 + : lifecycle.timing === "slow-burn" + ? 8 + : lifecycle.timing === "mid-arc" + ? 6 + : 5; + + return isHookWithinChapterWindow(hook, chapterNumber, recentWindow); +} + function isMustAdvanceCandidate( lifecycle: ReturnType, ): boolean { @@ -890,6 +952,15 @@ function selectRelevantHooks( const ranked = hooks .map((hook) => ({ hook, + lifecycle: describeHookLifecycle({ + payoffTiming: hook.payoffTiming, + expectedPayoff: hook.expectedPayoff, + notes: hook.notes, + startChapter: Math.max(0, hook.startChapter), + lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter), + status: hook.status, + chapterNumber, + }), score: scoreHook(hook, queryTerms, chapterNumber), matched: matchesAny( [hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" "), @@ -898,26 +969,30 @@ function selectRelevantHooks( })) .filter((entry) => entry.matched || isUnresolvedHook(entry.hook.status)); - const recentCutoff = Math.max(0, chapterNumber - 5); - const staleCutoff = Math.max(0, chapterNumber - 10); - const primary = ranked - .filter((entry) => ( - entry.matched - || isHookWithinChapterWindow(entry.hook, chapterNumber, 5) - )) - .sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter) - .slice(0, 3); + const primary = selectAgendaHooksWithTypeSpread({ + entries: ranked + .filter((entry) => ( + entry.matched + || isHookWithinLifecycleWindow(entry.hook, chapterNumber, entry.lifecycle) + )) + .sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter), + limit: resolveRelevantHookPrimaryLimit(ranked), + forceInclude: (entry) => entry.matched && entry.lifecycle.overdue, + }); const selectedIds = new Set(primary.map((entry) => entry.hook.hookId)); - const stale = ranked - .filter((entry) => ( - !selectedIds.has(entry.hook.hookId) - && !isFuturePlannedHook(entry.hook, chapterNumber) - && entry.hook.lastAdvancedChapter <= staleCutoff - && isUnresolvedHook(entry.hook.status) - )) - .sort((left, right) => left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || right.score - left.score) - .slice(0, 1); + const stale = selectAgendaHooksWithTypeSpread({ + entries: ranked + .filter((entry) => ( + !selectedIds.has(entry.hook.hookId) + && !isFuturePlannedHook(entry.hook, chapterNumber) + && (entry.lifecycle.stale || entry.lifecycle.overdue) + && isUnresolvedHook(entry.hook.status) + )) + .sort((left, right) => left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || right.score - left.score), + limit: resolveRelevantHookStaleLimit(ranked, selectedIds), + forceInclude: (entry) => entry.lifecycle.overdue, + }); return [...primary, ...stale].map((entry) => entry.hook); }