feat(pipeline): make hook retrieval lifecycle-aware

This commit is contained in:
Ma
2026-04-01 13:15:38 +08:00
parent 198cc94859
commit 3f8528798f
2 changed files with 196 additions and 19 deletions
@@ -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");
+94 -19
View File
@@ -390,7 +390,10 @@ function resolveAgendaLimit(params: {
}
function selectAgendaHooksWithTypeSpread<T extends {
readonly hook: ReturnType<typeof normalizeStoredHook>;
readonly hook: {
readonly hookId: string;
readonly type: string;
};
readonly lifecycle: ReturnType<typeof describeHookLifecycle>;
}>(params: {
readonly entries: ReadonlyArray<T>;
@@ -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<typeof describeHookLifecycle>;
}>): 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<typeof describeHookLifecycle>;
}>,
selectedIds: ReadonlySet<string>,
): 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<typeof describeHookLifecycle>,
): 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<typeof describeHookLifecycle>,
): 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);
}