Просмотр исходного кода

feat(pipeline): make hook retrieval lifecycle-aware

Ma 7 месяцев назад
Родитель
Сommit
3f8528798f

+ 102 - 0
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");

+ 94 - 19
packages/core/src/utils/memory-retrieval.ts

@@ -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);
 }