Bladeren bron

feat(foreshadowing): 修复摄取匹配并新增清理维护工具

修正常见解析漏判与脏数据回流,支持扫描重复/噪声/失效,合并或删除,失效标记 abandoned,并提供噪声与失效一键清理。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 maand geleden
bovenliggende
commit
0d60d38327

+ 11 - 0
src/App.tsx

@@ -141,6 +141,17 @@ function App() {
 
     if (!isCurrentProject(proj)) return
 
+    try {
+      const {
+        restoreForeshadowingCleanupQueue,
+      } = await import("@/lib/foreshadowing-cleanup-queue")
+      await restoreForeshadowingCleanupQueue(proj.id, proj.path)
+    } catch (err) {
+      console.error("恢复伏笔清理队列失败:", err)
+    }
+
+    if (!isCurrentProject(proj)) return
+
     try {
       const { startProjectFileSync, stopProjectFileSync } = await import("@/lib/project-file-sync")
       const config = await loadSourceWatchConfig(proj.id, proj.path)

+ 26 - 1
src/components/novel/foreshadowing-panel.tsx

@@ -8,12 +8,14 @@ const STATUS_LABELS: Record<string, string> = {
   planted: "已埋设",
   advanced: "推进中",
   resolved: "已回收",
+  abandoned: "已放弃",
 }
 
 const STATUS_COLORS: Record<string, string> = {
   planted: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
   advanced: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
   resolved: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
+  abandoned: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300",
 }
 
 export function ForeshadowingPanel() {
@@ -31,8 +33,9 @@ export function ForeshadowingPanel() {
       .finally(() => setLoading(false))
   }, [project])
 
-  const unresolved = store?.items.filter(f => f.status !== "resolved") ?? []
+  const unresolved = store?.items.filter(f => f.status !== "resolved" && f.status !== "abandoned") ?? []
   const resolved = store?.items.filter(f => f.status === "resolved") ?? []
+  const abandoned = store?.items.filter(f => f.status === "abandoned") ?? []
 
   if (!project) return null
 
@@ -110,6 +113,28 @@ export function ForeshadowingPanel() {
                 </div>
               </div>
             )}
+            {abandoned.length > 0 && (
+              <div>
+                <h3 className="mb-2 text-xs font-semibold text-zinc-500">
+                  {t("novel.foreshadowing.abandoned", { defaultValue: "已放弃" })} ({abandoned.length})
+                </h3>
+                <div className="space-y-2 opacity-50">
+                  {abandoned.map((f) => (
+                    <div key={f.id} className="rounded-md border p-2 text-sm">
+                      <div className="flex items-center justify-between">
+                        <span className="font-medium">{f.name}</span>
+                        <span className={`rounded px-1.5 py-0.5 text-xs ${STATUS_COLORS[f.status]}`}>
+                          {STATUS_LABELS[f.status]}
+                        </span>
+                      </div>
+                      {f.notes && (
+                        <p className="mt-1 text-xs text-muted-foreground">{f.notes}</p>
+                      )}
+                    </div>
+                  ))}
+                </div>
+              </div>
+            )}
           </div>
         )}
       </div>

+ 1398 - 0
src/components/settings/sections/foreshadowing-cleanup-tool.tsx

@@ -0,0 +1,1398 @@
+import { useCallback, useEffect, useMemo, useState } from "react"
+import { useTranslation } from "react-i18next"
+import {
+  Lightbulb,
+  Loader2,
+  AlertTriangle,
+  CheckCircle2,
+  XCircle,
+  Trash2,
+  RotateCcw,
+  Clock,
+  HelpCircle,
+  ChevronDown,
+  ChevronUp,
+  RefreshCw,
+} from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Label } from "@/components/ui/label"
+import { ChatModelSelector } from "@/components/chat/chat-model-selector"
+import { useWikiStore } from "@/stores/wiki-store"
+import { getFirstAvailableModelKey, getEffectiveSavedModels } from "@/lib/llm-model-keys"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
+import { normalizePath } from "@/lib/path-utils"
+import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
+import {
+  buildOverview,
+  type CleanupApplyAction,
+  type CleanupIssue,
+  type CleanupIssueKind,
+} from "@/lib/foreshadowing-cleanup"
+import {
+  runForeshadowingCleanupScan,
+  rebuildForeshadowingFromSnapshots,
+  listInvalidSnapshots,
+  deleteInvalidSnapshots,
+  executeBulkNoiseAndStaleCleanup,
+  type ForeshadowingCleanupScanProgress,
+  type InvalidSnapshotInfo,
+} from "@/lib/foreshadowing-cleanup-runner"
+import {
+  loadForeshadowingCleanupScanCache,
+  saveForeshadowingCleanupScanCache,
+  loadForeshadowingCleanupModelPrefs,
+  saveForeshadowingCleanupModelPrefs,
+  addForeshadowingKeep,
+  removeIssuesFromForeshadowingScanCache,
+  type ForeshadowingCleanupScanEntry,
+} from "@/lib/foreshadowing-cleanup-cache"
+import {
+  enqueueForeshadowingCleanup,
+  cancelForeshadowingCleanupTask,
+  retryForeshadowingCleanupTask,
+  getForeshadowingCleanupQueue,
+  getForeshadowingCleanupProgress,
+  getForeshadowingCleanupLogs,
+  foreshadowingCleanupIssueKey,
+  ensureForeshadowingCleanupQueueActive,
+  onForeshadowingCleanupComplete,
+  type ForeshadowingCleanupTask,
+} from "@/lib/foreshadowing-cleanup-queue"
+import {
+  loadForeshadowingTracker,
+  type Foreshadowing,
+} from "@/lib/novel/foreshadowing-tracker"
+import { toast } from "@/lib/toast"
+import type { WikiProject } from "@/types/wiki"
+
+type ItemLookup = Record<string, Foreshadowing>
+
+function statusLabelZh(status: string): string {
+  if (status === "planted") return "已埋设"
+  if (status === "advanced") return "推进中"
+  if (status === "resolved") return "已回收"
+  if (status === "abandoned") return "已放弃"
+  return status
+}
+
+function itemTitle(item: Foreshadowing | undefined, id: string): string {
+  if (!item) return id
+  const text = (item.name || item.description || "").trim()
+  return text || id
+}
+
+function itemSubtitle(item: Foreshadowing | undefined): string {
+  if (!item) return ""
+  const parts = [
+    `第${item.plantedChapter}章埋设`,
+    statusLabelZh(item.status),
+  ]
+  if (item.advancedChapters?.length) {
+    parts.push(`推进${item.advancedChapters.length}次`)
+  }
+  return parts.join(" · ")
+}
+
+function itemDetail(item: Foreshadowing | undefined): string {
+  if (!item) return ""
+  const desc = (item.description || "").trim()
+  const name = (item.name || "").trim()
+  if (desc && desc !== name) return desc
+  return ""
+}
+
+function toItemLookup(items: readonly Foreshadowing[]): ItemLookup {
+  const map: ItemLookup = {}
+  for (const item of items) map[item.id] = item
+  return map
+}
+
+interface IssueUiEntry {
+  issue: CleanupIssue
+  canonicalId: string
+  skipped: boolean
+}
+
+interface ScanState {
+  projectId: string | null
+  projectPath: string | null
+  scanning: boolean
+  scanError: string | null
+  issues: IssueUiEntry[]
+  scanCompleted: boolean
+  scannedItemCount: number | null
+  currentChapter: number | null
+  overview: ReturnType<typeof buildOverview> | null
+}
+
+const emptyScanState: ScanState = {
+  projectId: null,
+  projectPath: null,
+  scanning: false,
+  scanError: null,
+  issues: [],
+  scanCompleted: false,
+  scannedItemCount: null,
+  currentChapter: null,
+  overview: null,
+}
+
+function normalizeProjectPath(path: string): string {
+  return normalizePath(path).replace(/\/+$/, "")
+}
+
+function scanBelongsToProject(
+  state: Pick<ScanState, "projectId" | "projectPath">,
+  project: WikiProject,
+): boolean {
+  if (state.projectId && state.projectId === project.id) return true
+  if (!state.projectPath) return false
+  return normalizeProjectPath(state.projectPath) === normalizeProjectPath(project.path)
+}
+
+function confidenceRank(c: CleanupIssue["confidence"]): number {
+  if (c === "high") return 0
+  if (c === "medium") return 1
+  return 2
+}
+
+function kindOrder(k: CleanupIssueKind): number {
+  if (k === "duplicate") return 0
+  if (k === "noise") return 1
+  return 2
+}
+
+export function ForeshadowingCleanupTool() {
+  const { t } = useTranslation()
+  const llmConfig = useWikiStore((s) => s.llmConfig)
+  const providerConfigs = useWikiStore((s) => s.providerConfigs)
+  const novelConfig = useWikiStore((s) => s.novelConfig)
+  const defaultLlmModel = novelConfig.defaultLlmModel
+  const aiChatModel = useWikiStore((s) => s.aiChatModel)
+  const project = useWikiStore((s) => s.project)
+
+  const [detectModelId, setDetectModelId] = useState("")
+  const [applyModelId, setApplyModelId] = useState("")
+  const [modelsHydrated, setModelsHydrated] = useState(false)
+  const [scanState, setScanState] = useState<ScanState>(emptyScanState)
+  const [scanProgress, setScanProgress] = useState<ForeshadowingCleanupScanProgress | null>(null)
+  const [scanLogs, setScanLogs] = useState<string[]>([])
+  const [helpOpen, setHelpOpen] = useState(false)
+  const [rebuildBusy, setRebuildBusy] = useState(false)
+  const [rebuildLogs, setRebuildLogs] = useState<string[]>([])
+  const [invalidSnaps, setInvalidSnaps] = useState<InvalidSnapshotInfo[]>([])
+  const [invalidBusy, setInvalidBusy] = useState(false)
+  const [bulkBusy, setBulkBusy] = useState(false)
+  const [bulkLogs, setBulkLogs] = useState<string[]>([])
+  const [overview, setOverview] = useState<ReturnType<typeof buildOverview> | null>(null)
+  const [itemById, setItemById] = useState<ItemLookup>({})
+
+  const [tasks, setTasks] = useState<readonly ForeshadowingCleanupTask[]>([])
+  const [applyProgress, setApplyProgress] = useState<{
+    taskId: string
+    stage: string
+  } | null>(null)
+  const [applyLogs, setApplyLogs] = useState<readonly string[]>([])
+  const [enqueueingKey, setEnqueueingKey] = useState<string | null>(null)
+
+  const projectReady = !!project
+  const hasAvailableModels = useMemo(() => {
+    for (const key of Object.keys(providerConfigs)) {
+      const config = providerConfigs[key]
+      if (key.startsWith("custom-")) {
+        if (config.enabled === false) continue
+      } else {
+        const hasConfig =
+          config.enabled === true ||
+          Boolean(
+            (config.apiKey || config.savedModels?.length) &&
+              (config.model || config.savedModels?.length),
+          )
+        if (!hasConfig) continue
+      }
+      if (getEffectiveSavedModels(config).length > 0) return true
+    }
+    return false
+  }, [providerConfigs])
+
+  const detectLlmConfig = useMemo(() => {
+    if (!detectModelId.trim()) return resolveDefaultModel(llmConfig)
+    return resolveModelConfig(detectModelId, llmConfig, providerConfigs)
+  }, [detectModelId, llmConfig, providerConfigs])
+
+  const detectLlmReady = hasUsableLlm(detectLlmConfig, providerConfigs)
+  const scanning = scanState.scanning
+
+  useEffect(() => {
+    if (!project) {
+      setModelsHydrated(false)
+      setScanState(emptyScanState)
+      setOverview(null)
+      setItemById({})
+      setInvalidSnaps([])
+      return
+    }
+    let cancelled = false
+    setModelsHydrated(false)
+    void (async () => {
+      const [cached, prefs, store, invalid] = await Promise.all([
+        loadForeshadowingCleanupScanCache(project.path),
+        loadForeshadowingCleanupModelPrefs(project.path),
+        loadForeshadowingTracker(project.path),
+        listInvalidSnapshots(project.path),
+      ])
+      if (cancelled) return
+      setOverview(buildOverview(store))
+      setItemById(toItemLookup(store.items))
+      setInvalidSnaps(invalid)
+      if (prefs?.detectModelId) setDetectModelId(prefs.detectModelId)
+      if (prefs?.applyModelId) setApplyModelId(prefs.applyModelId)
+      if (cached && cached.projectId === project.id) {
+        setScanState({
+          projectId: project.id,
+          projectPath: normalizeProjectPath(project.path),
+          scanning: false,
+          scanError: null,
+          issues: cached.issues,
+          scanCompleted: true,
+          scannedItemCount: cached.scannedItemCount,
+          currentChapter: cached.currentChapter,
+          overview: buildOverview(store),
+        })
+      }
+      setModelsHydrated(true)
+    })()
+    return () => {
+      cancelled = true
+    }
+  }, [project?.id, project?.path])
+
+  useEffect(() => {
+    if (!modelsHydrated) return
+    const preferred = defaultLlmModel.trim() || aiChatModel.trim()
+    const fallback = preferred || getFirstAvailableModelKey(providerConfigs)
+    setDetectModelId((c) => c.trim() || fallback)
+    setApplyModelId((c) => c.trim() || fallback)
+  }, [modelsHydrated, defaultLlmModel, aiChatModel, providerConfigs])
+
+  useEffect(() => {
+    if (!project || !modelsHydrated) return
+    if (!detectModelId.trim() && !applyModelId.trim()) return
+    void saveForeshadowingCleanupModelPrefs(project.path, {
+      detectModelId: detectModelId.trim() || undefined,
+      applyModelId: applyModelId.trim() || undefined,
+    }).catch((err) => {
+      console.error("[ForeshadowingCleanup] save model prefs failed:", err)
+    })
+  }, [project?.id, project?.path, modelsHydrated, detectModelId, applyModelId])
+
+  useEffect(() => {
+    if (!project) return
+    void ensureForeshadowingCleanupQueueActive(project.id, project.path)
+    const tick = () => {
+      setTasks([...getForeshadowingCleanupQueue()])
+      setApplyProgress(getForeshadowingCleanupProgress())
+      setApplyLogs([...getForeshadowingCleanupLogs()])
+    }
+    tick()
+    const id = window.setInterval(tick, 1000)
+    return () => window.clearInterval(id)
+  }, [project?.id, project?.path])
+
+  useEffect(() => {
+    return onForeshadowingCleanupComplete(() => {
+      if (!project) return
+      void loadForeshadowingTracker(project.path).then((store) => {
+        setOverview(buildOverview(store))
+        setItemById(toItemLookup(store.items))
+      })
+      void loadForeshadowingCleanupScanCache(project.path).then((cached) => {
+        if (!cached || cached.projectId !== project.id) return
+        setScanState((prev) => {
+          if (!scanBelongsToProject(prev, project)) return prev
+          return { ...prev, issues: cached.issues }
+        })
+      })
+    })
+  }, [project?.id, project?.path])
+
+  const handleScan = useCallback(async () => {
+    if (!project) return
+    if (!detectLlmReady) {
+      toast.error(
+        t("settings.sections.maintenance.foreshadowing.selectDetectModel", {
+          defaultValue: "请先选择检测模型。",
+        }),
+      )
+      return
+    }
+    setScanLogs([])
+    setScanProgress({ stage: "loading", percent: 0 })
+    setScanState({
+      projectId: project.id,
+      projectPath: normalizeProjectPath(project.path),
+      scanning: true,
+      scanError: null,
+      issues: [],
+      scanCompleted: false,
+      scannedItemCount: null,
+      currentChapter: null,
+      overview: null,
+    })
+    try {
+      const result = await runForeshadowingCleanupScan(project.path, detectLlmConfig, {
+        onProgress: setScanProgress,
+        onLog: (msg) =>
+          setScanLogs((prev) => [
+            ...prev,
+            `${new Date().toLocaleTimeString()}  ${msg}`,
+          ]),
+      })
+      const entries: IssueUiEntry[] = result.issues
+        .map((issue) => ({
+          issue,
+          canonicalId: issue.canonicalId || issue.ids[0],
+          skipped: false,
+        }))
+        .sort(
+          (a, b) =>
+            kindOrder(a.issue.kind) - kindOrder(b.issue.kind) ||
+            confidenceRank(a.issue.confidence) - confidenceRank(b.issue.confidence),
+        )
+      const next: ScanState = {
+        projectId: project.id,
+        projectPath: normalizeProjectPath(project.path),
+        scanning: false,
+        scanError: null,
+        issues: entries,
+        scanCompleted: true,
+        scannedItemCount: result.scannedItemCount,
+        currentChapter: result.currentChapter,
+        overview: result.overview,
+      }
+      setScanState(next)
+      setOverview(result.overview)
+      setItemById(toItemLookup(result.store.items))
+      await saveForeshadowingCleanupScanCache(project.path, {
+        version: 1,
+        projectId: project.id,
+        scannedAt: Date.now(),
+        scannedItemCount: result.scannedItemCount,
+        currentChapter: result.currentChapter,
+        modelId: detectModelId.trim() || undefined,
+        applyModelId: applyModelId.trim() || undefined,
+        issues: entries as ForeshadowingCleanupScanEntry[],
+      })
+    } catch (err) {
+      const message = err instanceof Error ? err.message : String(err)
+      setScanState((prev) => ({
+        ...prev,
+        scanning: false,
+        scanError: message,
+        scanCompleted: true,
+      }))
+      toast.error(message)
+    } finally {
+      setScanProgress(null)
+    }
+  }, [
+    project,
+    detectLlmReady,
+    detectLlmConfig,
+    detectModelId,
+    applyModelId,
+    t,
+  ])
+
+  const persistIssues = useCallback(
+    async (issues: IssueUiEntry[]) => {
+      if (!project) return
+      await saveForeshadowingCleanupScanCache(project.path, {
+        version: 1,
+        projectId: project.id,
+        scannedAt: Date.now(),
+        scannedItemCount: scanState.scannedItemCount,
+        currentChapter: scanState.currentChapter,
+        modelId: detectModelId.trim() || undefined,
+        applyModelId: applyModelId.trim() || undefined,
+        issues: issues as ForeshadowingCleanupScanEntry[],
+      })
+    },
+    [project, scanState.scannedItemCount, scanState.currentChapter, detectModelId, applyModelId],
+  )
+
+  const handleEnqueue = useCallback(
+    async (entry: IssueUiEntry, action?: CleanupApplyAction) => {
+      if (!project) return
+      const resolvedAction =
+        action ??
+        (entry.issue.kind === "duplicate"
+          ? "merge"
+          : entry.issue.kind === "noise"
+            ? "delete"
+            : "abandon")
+      if (resolvedAction === "delete") {
+        const ok = window.confirm(
+          t("settings.sections.maintenance.foreshadowing.deleteAllConfirm", {
+            defaultValue: `将永久删除这 ${entry.issue.ids.length} 条伏笔(先备份)。是否继续?`,
+            count: entry.issue.ids.length,
+          }),
+        )
+        if (!ok) return
+      }
+      const key = foreshadowingCleanupIssueKey(entry.issue) + ":" + resolvedAction
+      setEnqueueingKey(key)
+      try {
+        await enqueueForeshadowingCleanup(project.id, entry.issue, {
+          canonicalId:
+            resolvedAction === "merge" ? entry.canonicalId : undefined,
+          modelId: applyModelId.trim() || undefined,
+          action: resolvedAction,
+        })
+        toast.success(
+          t("settings.sections.maintenance.foreshadowing.enqueued", {
+            defaultValue: "已加入清理队列",
+          }),
+        )
+      } catch (err) {
+        toast.error(err instanceof Error ? err.message : String(err))
+      } finally {
+        setEnqueueingKey(null)
+      }
+    },
+    [project, applyModelId, t],
+  )
+
+  const handleKeep = useCallback(
+    async (idx: number) => {
+      if (!project) return
+      const entry = scanState.issues[idx]
+      if (!entry) return
+      await addForeshadowingKeep(project.path, entry.issue.ids)
+      const next = scanState.issues.map((e, i) =>
+        i === idx ? { ...e, skipped: true } : e,
+      )
+      setScanState((prev) => ({ ...prev, issues: next }))
+      await persistIssues(next)
+      toast.success(
+        t("settings.sections.maintenance.foreshadowing.kept", {
+          defaultValue: "已标记为保留,下次扫描将跳过",
+        }),
+      )
+    },
+    [project, scanState.issues, persistIssues, t],
+  )
+
+  const handleRebuild = useCallback(async () => {
+    if (!project) return
+    if (
+      !window.confirm(
+        t("settings.sections.maintenance.foreshadowing.rebuildConfirm", {
+          defaultValue:
+            "将从全部章节快照重新生成伏笔追踪器(会先备份)。修完解析问题后重建,可自动纠正错误的「未回收」状态。是否继续?",
+        }),
+      )
+    ) {
+      return
+    }
+    setRebuildBusy(true)
+    setRebuildLogs([])
+    try {
+      await rebuildForeshadowingFromSnapshots(project.path, {
+        onLog: (msg) =>
+          setRebuildLogs((prev) => [
+            ...prev,
+            `${new Date().toLocaleTimeString()}  ${msg}`,
+          ]),
+      })
+      const store = await loadForeshadowingTracker(project.path)
+      setOverview(buildOverview(store))
+      setItemById(toItemLookup(store.items))
+      toast.success(
+        t("settings.sections.maintenance.foreshadowing.rebuildDone", {
+          defaultValue: "重建完成",
+        }),
+      )
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : String(err))
+    } finally {
+      setRebuildBusy(false)
+    }
+  }, [project, t])
+
+  const handleListInvalid = useCallback(async () => {
+    if (!project) return
+    setInvalidBusy(true)
+    try {
+      const list = await listInvalidSnapshots(project.path)
+      setInvalidSnaps(list)
+      if (list.length === 0) {
+        toast.success(
+          t("settings.sections.maintenance.foreshadowing.noInvalidSnapshots", {
+            defaultValue: "未发现异常快照",
+          }),
+        )
+      }
+    } finally {
+      setInvalidBusy(false)
+    }
+  }, [project, t])
+
+  const handleDeleteInvalid = useCallback(async () => {
+    if (!project || invalidSnaps.length === 0) return
+    if (
+      !window.confirm(
+        t("settings.sections.maintenance.foreshadowing.deleteInvalidConfirm", {
+          defaultValue: `将删除 ${invalidSnaps.length} 个 chapterNumber≤0 的异常快照文件。是否继续?`,
+          count: invalidSnaps.length,
+        }),
+      )
+    ) {
+      return
+    }
+    setInvalidBusy(true)
+    try {
+      const n = await deleteInvalidSnapshots(
+        project.path,
+        invalidSnaps.map((s) => s.path),
+        {
+          onLog: (msg) =>
+            setRebuildLogs((prev) => [
+              ...prev,
+              `${new Date().toLocaleTimeString()}  ${msg}`,
+            ]),
+        },
+      )
+      setInvalidSnaps([])
+      toast.success(
+        t("settings.sections.maintenance.foreshadowing.deleteInvalidDone", {
+          defaultValue: `已删除 ${n} 个异常快照`,
+          count: n,
+        }),
+      )
+    } finally {
+      setInvalidBusy(false)
+    }
+  }, [project, invalidSnaps, t])
+
+  const visibleIssues = useMemo(
+    () => scanState.issues.filter((e) => !e.skipped),
+    [scanState.issues],
+  )
+
+  const noiseIssues = useMemo(
+    () => visibleIssues.filter((e) => e.issue.kind === "noise"),
+    [visibleIssues],
+  )
+  const staleIssues = useMemo(
+    () => visibleIssues.filter((e) => e.issue.kind === "stale"),
+    [visibleIssues],
+  )
+
+  const handleBulkCleanNoiseAndStale = useCallback(
+    async (mode: "noise" | "stale" | "both") => {
+      if (!project) return
+      const noise = mode === "stale" ? [] : noiseIssues
+      const stale = mode === "noise" ? [] : staleIssues
+      if (noise.length === 0 && stale.length === 0) return
+
+      const deleteIds = noise.flatMap((e) => e.issue.ids)
+      const abandonIds = stale.flatMap((e) => e.issue.ids)
+      const ok = window.confirm(
+        t("settings.sections.maintenance.foreshadowing.bulkCleanConfirm", {
+          defaultValue:
+            "将删除噪声 {{noise}} 条,并把失效 {{stale}} 条标记为已放弃(先备份,一次写入)。是否继续?",
+          noise: deleteIds.length,
+          stale: abandonIds.length,
+        }),
+      )
+      if (!ok) return
+
+      setBulkBusy(true)
+      setBulkLogs([])
+      try {
+        const result = await executeBulkNoiseAndStaleCleanup(project.path, {
+          deleteIds,
+          abandonIds,
+          currentChapter: scanState.currentChapter ?? undefined,
+          onLog: (msg) =>
+            setBulkLogs((prev) => [
+              ...prev,
+              `${new Date().toLocaleTimeString()}  ${msg}`,
+            ]),
+        })
+        const cleared = [...noise, ...stale].map((e) => e.issue)
+        await removeIssuesFromForeshadowingScanCache(project.path, cleared)
+        setScanState((prev) => ({
+          ...prev,
+          issues: prev.issues.filter(
+            (e) =>
+              !(
+                (mode !== "stale" && e.issue.kind === "noise" && !e.skipped) ||
+                (mode !== "noise" && e.issue.kind === "stale" && !e.skipped)
+              ),
+          ),
+        }))
+        const store = await loadForeshadowingTracker(project.path)
+        setOverview(buildOverview(store))
+        setItemById(toItemLookup(store.items))
+        toast.success(
+          t("settings.sections.maintenance.foreshadowing.bulkCleanDone", {
+            defaultValue: "已删除 {{deleted}} 条噪声,放弃 {{abandoned}} 条失效",
+            deleted: result.deleted,
+            abandoned: result.abandoned,
+          }),
+        )
+      } catch (err) {
+        toast.error(err instanceof Error ? err.message : String(err))
+      } finally {
+        setBulkBusy(false)
+      }
+    },
+    [project, noiseIssues, staleIssues, scanState.currentChapter, t],
+  )
+
+  const pendingPositionByTaskId = useMemo(() => {
+    const pending = tasks
+      .filter((t) => t.status === "pending")
+      .sort((a, b) => a.addedAt - b.addedAt)
+    const map = new Map<string, number>()
+    pending.forEach((t, i) => map.set(t.id, i + 1))
+    return map
+  }, [tasks])
+
+  const displayOverview = overview || scanState.overview
+
+  return (
+    <div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-4">
+      <div className="flex items-center gap-2">
+        <Lightbulb className="h-4 w-4 text-amber-500" />
+        <h3 className="text-sm font-semibold">
+          {t("settings.sections.maintenance.foreshadowing.title", {
+            defaultValue: "清理伏笔",
+          })}
+        </h3>
+      </div>
+      <p className="text-xs leading-relaxed text-muted-foreground">
+        {t("settings.sections.maintenance.foreshadowing.description", {
+          defaultValue:
+            "扫描伏笔追踪器,找出重复线索、噪声条目(状态播报/剧情预告)和长期失效伏笔。每条需你确认后才会合并、删除或标记为已放弃。建议先「从快照重建」再扫描。",
+        })}
+      </p>
+
+      <button
+        type="button"
+        className="flex w-full items-center gap-1.5 text-left text-xs text-muted-foreground hover:text-foreground"
+        onClick={() => setHelpOpen((v) => !v)}
+      >
+        <HelpCircle className="h-3.5 w-3.5" />
+        <span>
+          {t("settings.sections.maintenance.foreshadowing.helpTitle", {
+            defaultValue: "三类问题分别怎么处理?",
+          })}
+        </span>
+        {helpOpen ? (
+          <ChevronUp className="ml-auto h-3.5 w-3.5" />
+        ) : (
+          <ChevronDown className="ml-auto h-3.5 w-3.5" />
+        )}
+      </button>
+      {helpOpen && (
+        <div className="space-y-1.5 rounded border border-border/50 bg-background/60 px-3 py-2 text-[11px] leading-relaxed text-muted-foreground">
+          <p>
+            <strong>重复</strong>:同一线索被反复「新增」→ 合并为一条,保留最早埋设与最长说明。
+          </p>
+          <p>
+            <strong>噪声</strong>:不是伏笔的状态播报/剧情预告 → 直接删除。
+          </p>
+          <p>
+            <strong>失效</strong>:真伏笔但故事方向已变 → 标记为「已放弃」,保留记录但不进写作上下文。
+          </p>
+          <p>
+            若近期修过摄取解析,先点「从快照重建」:可把大量假「未回收」自动纠正为「已回收」。
+          </p>
+        </div>
+      )}
+
+      {!projectReady && (
+        <p className="text-xs text-amber-700 dark:text-amber-400">
+          {t("settings.sections.maintenance.noProject", {
+            defaultValue: "请先打开一个项目。",
+          })}
+        </p>
+      )}
+
+      {projectReady && displayOverview && (
+        <div className="flex flex-wrap gap-x-3 gap-y-1 rounded border border-border/50 bg-background/50 px-3 py-2 text-[11px] text-muted-foreground">
+          <span>
+            {t("settings.sections.maintenance.foreshadowing.overviewTotal", {
+              defaultValue: "总计 {{n}}",
+              n: displayOverview.total,
+            })}
+          </span>
+          <span>
+            {t("settings.sections.maintenance.foreshadowing.overviewActive", {
+              defaultValue: "活跃 {{n}}",
+              n: displayOverview.active,
+            })}
+          </span>
+          <span>
+            {t("settings.sections.maintenance.foreshadowing.overviewResolved", {
+              defaultValue: "已回收 {{n}}",
+              n: displayOverview.resolved,
+            })}
+          </span>
+          <span>
+            {t("settings.sections.maintenance.foreshadowing.overviewAbandoned", {
+              defaultValue: "已放弃 {{n}}",
+              n: displayOverview.abandoned,
+            })}
+          </span>
+          <span>
+            {t("settings.sections.maintenance.foreshadowing.overviewAvg", {
+              defaultValue: "平均 {{n}} 条/章",
+              n: displayOverview.avgPerChapter,
+            })}
+          </span>
+        </div>
+      )}
+
+      {projectReady && (
+        <div className="flex flex-wrap gap-2">
+          <Button
+            size="sm"
+            variant="outline"
+            disabled={rebuildBusy}
+            onClick={() => void handleRebuild()}
+          >
+            {rebuildBusy ? (
+              <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
+            ) : (
+              <RefreshCw className="mr-1.5 h-3.5 w-3.5" />
+            )}
+            {t("settings.sections.maintenance.foreshadowing.rebuildButton", {
+              defaultValue: "从快照重建追踪器",
+            })}
+          </Button>
+          <Button
+            size="sm"
+            variant="outline"
+            disabled={invalidBusy}
+            onClick={() => void handleListInvalid()}
+          >
+            {t("settings.sections.maintenance.foreshadowing.scanInvalidButton", {
+              defaultValue: "扫描异常快照",
+            })}
+          </Button>
+          {invalidSnaps.length > 0 && (
+            <Button
+              size="sm"
+              variant="destructive"
+              disabled={invalidBusy}
+              onClick={() => void handleDeleteInvalid()}
+            >
+              <Trash2 className="mr-1.5 h-3.5 w-3.5" />
+              {t("settings.sections.maintenance.foreshadowing.deleteInvalidButton", {
+                defaultValue: "删除 {{n}} 个异常快照",
+                n: invalidSnaps.length,
+              })}
+            </Button>
+          )}
+        </div>
+      )}
+
+      {invalidSnaps.length > 0 && (
+        <ul className="max-h-28 overflow-auto rounded border border-border/50 bg-background/50 px-2 py-1.5 text-[11px] text-muted-foreground">
+          {invalidSnaps.map((s) => (
+            <li key={s.path}>
+              {s.fileName}(chapterNumber={s.chapterNumber},伏笔变化 {s.foreshadowingChangeCount})
+            </li>
+          ))}
+        </ul>
+      )}
+
+      {(rebuildLogs.length > 0 || rebuildBusy) && (
+        <ProcessLog
+          title={t("settings.sections.maintenance.foreshadowing.rebuildLogTitle", {
+            defaultValue: "重建 / 清理日志",
+          })}
+          lines={rebuildLogs}
+          live={rebuildBusy}
+        />
+      )}
+
+      {projectReady && hasAvailableModels && (
+        <div className="grid gap-3 sm:grid-cols-2">
+          <div className="space-y-1.5">
+            <Label className="text-xs">
+              {t("settings.sections.maintenance.foreshadowing.detectModelLabel", {
+                defaultValue: "检测模型",
+              })}
+            </Label>
+            <ChatModelSelector
+              value={detectModelId}
+              onChange={setDetectModelId}
+              disabled={scanning}
+            />
+          </div>
+          <div className="space-y-1.5">
+            <Label className="text-xs">
+              {t("settings.sections.maintenance.foreshadowing.applyModelLabel", {
+                defaultValue: "清理模型(可选,执行阶段不用 LLM)",
+              })}
+            </Label>
+            <ChatModelSelector
+              value={applyModelId}
+              onChange={setApplyModelId}
+              disabled={scanning}
+            />
+          </div>
+        </div>
+      )}
+
+      {projectReady && (
+        <Button
+          size="sm"
+          disabled={!hasAvailableModels || !detectLlmReady || scanning}
+          onClick={() => void handleScan()}
+        >
+          {scanning ? (
+            <>
+              <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
+              {t("settings.sections.maintenance.foreshadowing.scanning", {
+                defaultValue: "扫描中...",
+              })}
+            </>
+          ) : (
+            t("settings.sections.maintenance.foreshadowing.scanButton", {
+              defaultValue: "开始扫描伏笔问题",
+            })
+          )}
+        </Button>
+      )}
+
+      {scanning && (
+        <div className="space-y-1.5">
+          <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
+            <span>
+              {scanProgress?.stage === "loading"
+                ? t("settings.sections.maintenance.foreshadowing.scanStageLoading", {
+                    defaultValue: "正在读取伏笔追踪器…",
+                  })
+                : scanProgress?.batch
+                  ? t("settings.sections.maintenance.foreshadowing.scanBatchProgress", {
+                      defaultValue: "正在分析第 {{current}}/{{total}} 批…",
+                      current: scanProgress.batch.current,
+                      total: scanProgress.batch.total,
+                    })
+                  : t("settings.sections.maintenance.foreshadowing.scanStageDetecting", {
+                      defaultValue: "正在调用模型分析…",
+                    })}
+            </span>
+            <span className="tabular-nums shrink-0">
+              {Math.min(100, Math.max(0, scanProgress?.percent ?? 0))}%
+            </span>
+          </div>
+          <div
+            className="h-2 overflow-hidden rounded-full bg-muted"
+            role="progressbar"
+            aria-valuemin={0}
+            aria-valuemax={100}
+            aria-valuenow={Math.min(100, Math.max(0, scanProgress?.percent ?? 0))}
+          >
+            <div
+              className="h-full rounded-full bg-primary transition-[width] duration-300 ease-out"
+              style={{
+                width: `${Math.min(100, Math.max(0, scanProgress?.percent ?? 0))}%`,
+              }}
+            />
+          </div>
+          {scanProgress?.batch && (
+            <p className="text-[11px] text-muted-foreground">
+              {t("settings.sections.maintenance.foreshadowing.scanBatchDetail", {
+                defaultValue: "本批 {{batchSize}} 条 · 活跃共 {{activeCount}} 条(分批调用模型,较慢属正常)",
+                batchSize: scanProgress.batch.batchSize,
+                activeCount: scanProgress.batch.activeCount,
+              })}
+            </p>
+          )}
+        </div>
+      )}
+
+      {scanLogs.length > 0 && (
+        <ProcessLog
+          title={t("settings.sections.maintenance.foreshadowing.processLogTitle", {
+            defaultValue: "扫描过程日志",
+          })}
+          lines={scanLogs}
+          live={scanning}
+        />
+      )}
+
+      {scanState.scanError && (
+        <p className="flex items-center gap-1.5 text-xs text-destructive">
+          <XCircle className="h-3.5 w-3.5" />
+          {scanState.scanError}
+        </p>
+      )}
+
+      {scanState.scanCompleted && !scanState.scanError && (
+        <div className="space-y-2">
+          <p className="text-xs text-muted-foreground">
+            {visibleIssues.length > 0
+              ? t("settings.sections.maintenance.foreshadowing.issuesFound", {
+                  defaultValue: "发现 {{count}} 个问题候选,请确认后处理。",
+                  count: visibleIssues.length,
+                })
+              : t("settings.sections.maintenance.foreshadowing.noneFound", {
+                  defaultValue: "未发现明显问题。",
+                })}
+          </p>
+          {(noiseIssues.length > 0 || staleIssues.length > 0) && (
+            <div className="flex flex-wrap gap-2">
+              {noiseIssues.length > 0 && staleIssues.length > 0 && (
+                <Button
+                  size="sm"
+                  variant="destructive"
+                  disabled={bulkBusy}
+                  onClick={() => void handleBulkCleanNoiseAndStale("both")}
+                >
+                  {bulkBusy ? (
+                    <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
+                  ) : (
+                    <Trash2 className="mr-1.5 h-3.5 w-3.5" />
+                  )}
+                  {t("settings.sections.maintenance.foreshadowing.bulkCleanBoth", {
+                    defaultValue: "一键清理噪声与失效(删 {{noise}} / 弃 {{stale}})",
+                    noise: noiseIssues.length,
+                    stale: staleIssues.length,
+                  })}
+                </Button>
+              )}
+              {noiseIssues.length > 0 && (
+                <Button
+                  size="sm"
+                  variant="outline"
+                  disabled={bulkBusy}
+                  onClick={() => void handleBulkCleanNoiseAndStale("noise")}
+                >
+                  {t("settings.sections.maintenance.foreshadowing.bulkCleanNoise", {
+                    defaultValue: "一键删除全部噪声({{n}})",
+                    n: noiseIssues.length,
+                  })}
+                </Button>
+              )}
+              {staleIssues.length > 0 && (
+                <Button
+                  size="sm"
+                  variant="outline"
+                  disabled={bulkBusy}
+                  onClick={() => void handleBulkCleanNoiseAndStale("stale")}
+                >
+                  {t("settings.sections.maintenance.foreshadowing.bulkCleanStale", {
+                    defaultValue: "一键放弃全部失效({{n}})",
+                    n: staleIssues.length,
+                  })}
+                </Button>
+              )}
+            </div>
+          )}
+          {(bulkLogs.length > 0 || bulkBusy) && (
+            <ProcessLog
+              title={t("settings.sections.maintenance.foreshadowing.bulkLogTitle", {
+                defaultValue: "一键清理日志",
+              })}
+              lines={bulkLogs}
+              live={bulkBusy}
+            />
+          )}
+        </div>
+      )}
+
+      {tasks.length > 0 && (
+        <CleanupQueuePanel
+          tasks={tasks}
+          itemById={itemById}
+          applyProgress={applyProgress}
+          pendingPositionByTaskId={pendingPositionByTaskId}
+          onCancel={(id) => void cancelForeshadowingCleanupTask(id)}
+          onRetry={(id) => void retryForeshadowingCleanupTask(id)}
+        />
+      )}
+
+      {applyLogs.length > 0 && (
+        <ProcessLog
+          title={t("settings.sections.maintenance.foreshadowing.applyLogTitle", {
+            defaultValue: "清理过程日志",
+          })}
+          lines={applyLogs}
+          live={!!applyProgress}
+        />
+      )}
+
+      {visibleIssues.map((entry) => {
+        const key = foreshadowingCleanupIssueKey(entry.issue)
+        const idx = scanState.issues.findIndex(
+          (e) => foreshadowingCleanupIssueKey(e.issue) === key,
+        )
+        const task = tasks.find(
+          (tk) => foreshadowingCleanupIssueKey(tk.issue) === key,
+        )
+        return (
+          <IssueCard
+            key={key}
+            entry={entry}
+            itemById={itemById}
+            task={task}
+            enqueueing={
+              enqueueingKey === key ||
+              enqueueingKey === `${key}:merge` ||
+              enqueueingKey === `${key}:delete` ||
+              enqueueingKey === `${key}:abandon`
+            }
+            pendingPosition={
+              task && task.status === "pending"
+                ? pendingPositionByTaskId.get(task.id) ?? 0
+                : 0
+            }
+            applyProgress={applyProgress}
+            onCanonicalChange={(id) => {
+              setScanState((prev) => {
+                const issues = prev.issues.map((e, i) =>
+                  i === idx ? { ...e, canonicalId: id } : e,
+                )
+                void persistIssues(issues)
+                return { ...prev, issues }
+              })
+            }}
+            onEnqueue={() => void handleEnqueue(entry)}
+            onDeleteAll={
+              entry.issue.kind === "duplicate"
+                ? () => void handleEnqueue(entry, "delete")
+                : undefined
+            }
+            onKeep={() => void handleKeep(idx)}
+            onCancel={() => task && void cancelForeshadowingCleanupTask(task.id)}
+            onRetry={() => task && void retryForeshadowingCleanupTask(task.id)}
+          />
+        )
+      })}
+    </div>
+  )
+}
+
+function ProcessLog({
+  title,
+  lines,
+  live,
+}: {
+  title: string
+  lines: readonly string[]
+  live?: boolean
+}) {
+  return (
+    <div className="space-y-1.5 rounded border border-border/60 bg-background/80 px-2 py-1.5">
+      <div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
+        {live ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
+        <span>{title}</span>
+      </div>
+      <pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-foreground/90">
+        {lines.length > 0 ? lines.join("\n") : "…"}
+      </pre>
+    </div>
+  )
+}
+
+function CleanupQueuePanel({
+  tasks,
+  itemById,
+  applyProgress,
+  pendingPositionByTaskId,
+  onCancel,
+  onRetry,
+}: {
+  tasks: readonly ForeshadowingCleanupTask[]
+  itemById: ItemLookup
+  applyProgress: { taskId: string; stage: string } | null
+  pendingPositionByTaskId: Map<string, number>
+  onCancel: (id: string) => void
+  onRetry: (id: string) => void
+}) {
+  const { t } = useTranslation()
+  return (
+    <div className="space-y-2 rounded-lg border border-border/60 bg-muted/10 p-3">
+      <h4 className="text-xs font-semibold">
+        {t("settings.sections.maintenance.foreshadowing.queueTitle", {
+          defaultValue: "清理任务队列",
+        })}
+      </h4>
+      <ul className="space-y-1.5">
+        {tasks.map((task) => {
+          const labelIds =
+            task.issue.kind === "duplicate"
+              ? task.canonicalId || task.issue.ids[0]
+              : task.issue.ids[0]
+          const title = itemTitle(itemById[labelIds], labelIds)
+          return (
+          <li
+            key={task.id}
+            className="flex items-center gap-2 rounded border border-border/50 bg-background/70 px-2 py-1.5 text-[11px]"
+          >
+            {task.status === "processing" ? (
+              <Loader2 className="h-3 w-3 shrink-0 animate-spin text-blue-500" />
+            ) : task.status === "failed" ? (
+              <XCircle className="h-3 w-3 shrink-0 text-destructive" />
+            ) : (
+              <Clock className="h-3 w-3 shrink-0 text-muted-foreground" />
+            )}
+            <span className="min-w-0 flex-1 truncate">
+              [{kindLabel(task.issue.kind, t)}
+              {task.action === "delete" ? "/删" : ""}] {title}
+              {task.issue.kind === "duplicate" && task.issue.ids.length > 1
+                ? `(${task.issue.ids.length} 条)`
+                : ""}
+              {task.status === "processing" && applyProgress?.taskId === task.id
+                ? ` · ${applyProgress.stage}`
+                : task.status === "pending"
+                  ? ` · #${pendingPositionByTaskId.get(task.id) ?? "?"}`
+                  : task.status === "failed"
+                    ? ` · ${task.error || "failed"}`
+                    : ""}
+            </span>
+            {task.status === "failed" && (
+              <Button size="sm" variant="ghost" className="h-6 px-1.5" onClick={() => onRetry(task.id)}>
+                <RotateCcw className="h-3 w-3" />
+              </Button>
+            )}
+            <Button size="sm" variant="ghost" className="h-6 px-1.5" onClick={() => onCancel(task.id)}>
+              <Trash2 className="h-3 w-3" />
+            </Button>
+          </li>
+          )
+        })}
+      </ul>
+    </div>
+  )
+}
+
+function kindLabel(kind: CleanupIssueKind, t: (k: string, o?: Record<string, unknown>) => string): string {
+  if (kind === "duplicate") {
+    return t("settings.sections.maintenance.foreshadowing.kindDuplicate", {
+      defaultValue: "重复",
+    })
+  }
+  if (kind === "noise") {
+    return t("settings.sections.maintenance.foreshadowing.kindNoise", {
+      defaultValue: "噪声",
+    })
+  }
+  return t("settings.sections.maintenance.foreshadowing.kindStale", {
+    defaultValue: "失效",
+  })
+}
+
+function actionLabel(kind: CleanupIssueKind, t: (k: string, o?: Record<string, unknown>) => string): string {
+  if (kind === "duplicate") {
+    return t("settings.sections.maintenance.foreshadowing.actionMerge", {
+      defaultValue: "合并",
+    })
+  }
+  if (kind === "noise") {
+    return t("settings.sections.maintenance.foreshadowing.actionDelete", {
+      defaultValue: "删除",
+    })
+  }
+  return t("settings.sections.maintenance.foreshadowing.actionAbandon", {
+    defaultValue: "标记放弃",
+  })
+}
+
+function IssueCard({
+  entry,
+  itemById,
+  task,
+  enqueueing,
+  pendingPosition,
+  applyProgress,
+  onCanonicalChange,
+  onEnqueue,
+  onDeleteAll,
+  onKeep,
+  onCancel,
+  onRetry,
+}: {
+  entry: IssueUiEntry
+  itemById: ItemLookup
+  task?: ForeshadowingCleanupTask
+  enqueueing: boolean
+  pendingPosition: number
+  applyProgress: { taskId: string; stage: string } | null
+  onCanonicalChange: (id: string) => void
+  onEnqueue: () => void
+  onDeleteAll?: () => void
+  onKeep: () => void
+  onCancel: () => void
+  onRetry: () => void
+}) {
+  const { t } = useTranslation()
+  const { issue } = entry
+  const busy = task?.status === "processing" || enqueueing
+  const canonicalTitle = itemTitle(itemById[entry.canonicalId], entry.canonicalId)
+
+  return (
+    <div className="space-y-2 rounded-lg border border-border/60 bg-background/70 p-3">
+      <div className="flex items-start justify-between gap-2">
+        <div className="space-y-1">
+          <div className="flex flex-wrap items-center gap-1.5 text-xs">
+            <span className="rounded bg-muted px-1.5 py-0.5 font-medium">
+              {kindLabel(issue.kind, t)}
+            </span>
+            <span className="rounded bg-muted/60 px-1.5 py-0.5 text-muted-foreground">
+              {issue.confidence}
+            </span>
+            {issue.confidence === "low" && (
+              <AlertTriangle className="h-3.5 w-3.5 text-amber-500" />
+            )}
+          </div>
+          <p className="text-xs text-muted-foreground">{issue.reason}</p>
+          {issue.kind === "duplicate" && (
+            <p className="text-[11px] text-muted-foreground">
+              {t("settings.sections.maintenance.foreshadowing.canonicalHint", {
+                defaultValue: "选中要保留的主条目,其余会合并进它。",
+              })}
+            </p>
+          )}
+        </div>
+        {task?.status === "processing" && applyProgress?.taskId === task.id ? (
+          <span className="flex items-center gap-1 text-[11px] text-blue-600">
+            <Loader2 className="h-3 w-3 animate-spin" />
+            {applyProgress.stage}
+          </span>
+        ) : task?.status === "pending" ? (
+          <span className="text-[11px] text-muted-foreground">
+            #{pendingPosition}
+          </span>
+        ) : task?.status === "failed" ? (
+          <span className="flex items-center gap-1 text-[11px] text-destructive">
+            <XCircle className="h-3 w-3" />
+            failed
+          </span>
+        ) : null}
+      </div>
+
+      <ul className="space-y-2 text-xs">
+        {issue.ids.map((id) => {
+          const item = itemById[id]
+          const title = itemTitle(item, id)
+          const subtitle = itemSubtitle(item)
+          const detail = itemDetail(item)
+          const selected = entry.canonicalId === id
+          const body = (
+            <div className="min-w-0 flex-1">
+              <div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
+                <span className="font-medium text-foreground">{title}</span>
+                <code className="rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
+                  {id}
+                </code>
+              </div>
+              {subtitle && (
+                <p className="mt-0.5 text-[11px] text-muted-foreground">{subtitle}</p>
+              )}
+              {detail && (
+                <p className="mt-0.5 line-clamp-2 text-[11px] leading-relaxed text-muted-foreground/90">
+                  {detail}
+                </p>
+              )}
+              {!item && (
+                <p className="mt-0.5 text-[11px] text-amber-600 dark:text-amber-400">
+                  {t("settings.sections.maintenance.foreshadowing.itemMissing", {
+                    defaultValue: "追踪器中已找不到这条(可能已处理)",
+                  })}
+                </p>
+              )}
+            </div>
+          )
+
+          return (
+            <li
+              key={id}
+              className={`rounded border px-2 py-1.5 ${
+                issue.kind === "duplicate" && selected
+                  ? "border-primary/40 bg-primary/5"
+                  : "border-border/50 bg-muted/20"
+              }`}
+            >
+              {issue.kind === "duplicate" ? (
+                <label className="flex cursor-pointer items-start gap-2">
+                  <input
+                    type="radio"
+                    className="mt-1"
+                    name={`canon-${foreshadowingCleanupIssueKey(issue)}`}
+                    checked={selected}
+                    onChange={() => onCanonicalChange(id)}
+                    disabled={busy || !!task}
+                  />
+                  {body}
+                </label>
+              ) : (
+                body
+              )}
+            </li>
+          )
+        })}
+      </ul>
+
+      <div className="flex flex-wrap gap-2">
+        {!task && (
+          <>
+            <Button size="sm" disabled={busy} onClick={onEnqueue}>
+              {enqueueing ? (
+                <Loader2 className="mr-1 h-3 w-3 animate-spin" />
+              ) : (
+                <CheckCircle2 className="mr-1 h-3 w-3" />
+              )}
+              {actionLabel(issue.kind, t)}
+              {issue.kind === "duplicate" ? ` → ${canonicalTitle}` : ""}
+            </Button>
+            {onDeleteAll && (
+              <Button
+                size="sm"
+                variant="destructive"
+                disabled={busy}
+                onClick={onDeleteAll}
+              >
+                <Trash2 className="mr-1 h-3 w-3" />
+                {t("settings.sections.maintenance.foreshadowing.actionDeleteAll", {
+                  defaultValue: "全部删除",
+                })}
+              </Button>
+            )}
+            <Button size="sm" variant="outline" disabled={busy} onClick={onKeep}>
+              {t("settings.sections.maintenance.foreshadowing.keepButton", {
+                defaultValue: "保留",
+              })}
+            </Button>
+          </>
+        )}
+        {task?.status === "failed" && (
+          <Button size="sm" variant="outline" onClick={onRetry}>
+            <RotateCcw className="mr-1 h-3 w-3" />
+            {t("settings.sections.maintenance.foreshadowing.retry", {
+              defaultValue: "重试",
+            })}
+          </Button>
+        )}
+        {task && task.status !== "done" && (
+          <Button size="sm" variant="ghost" onClick={onCancel}>
+            {t("settings.sections.maintenance.foreshadowing.cancel", {
+              defaultValue: "取消",
+            })}
+          </Button>
+        )}
+      </div>
+      {task?.error && (
+        <p className="text-[11px] text-destructive">{task.error}</p>
+      )}
+    </div>
+  )
+}

+ 4 - 1
src/components/settings/sections/maintenance-section.tsx

@@ -53,6 +53,7 @@ import {
 import type { DedupMergeStage } from "@/lib/dedup-runner"
 import type { WikiProject } from "@/types/wiki"
 import type { DuplicateGroup } from "@/lib/dedup"
+import { ForeshadowingCleanupTool } from "@/components/settings/sections/foreshadowing-cleanup-tool"
 
 function confidenceRank(confidence: DuplicateGroup["confidence"]): number {
   switch (confidence) {
@@ -666,11 +667,13 @@ export function MaintenanceSection() {
         <p className="mt-1 text-sm text-muted-foreground">
           {t("settings.sections.maintenance.description", {
             defaultValue:
-              "用于清理资料库的工具:检测并合并那些在多次重新摄取后被大模型以不同名称创建出来的重复实体或概念。",
+              "用于清理资料库的工具:检测并合并重复实体/概念,以及清理伏笔追踪器中的重复、噪声与失效条目。",
           })}
         </p>
       </div>
 
+      <ForeshadowingCleanupTool />
+
       <div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-4">
         <div className="flex items-center gap-2">
           <Wrench className="h-4 w-4 text-muted-foreground" />

+ 56 - 1
src/i18n/en.json

@@ -1088,9 +1088,64 @@
       },
       "maintenance": {
         "title": "Maintenance Tools",
-        "description": "Tools for cleaning up the knowledge base: detect and merge duplicate entities or concepts that the model created under different names across repeated ingests.",
+        "description": "Tools for cleaning up the knowledge base: detect and merge duplicate entities/concepts, and clean duplicate, noisy, or stale foreshadowing entries.",
         "noProject": "Open a project first.",
         "noLlm": "Configure an LLM provider first.",
+        "foreshadowing": {
+          "title": "Clean Foreshadowing",
+          "description": "Scan the foreshadowing tracker for duplicate clues, noise (status broadcasts / plot forecasts), and long-stale items. Each issue requires your confirmation before merge, delete, or abandon. Prefer \"Rebuild from snapshots\" before scanning.",
+          "helpTitle": "How are the three issue types handled?",
+          "overviewTotal": "Total {{n}}",
+          "overviewActive": "Active {{n}}",
+          "overviewResolved": "Resolved {{n}}",
+          "overviewAbandoned": "Abandoned {{n}}",
+          "overviewAvg": "Avg {{n}} / chapter",
+          "rebuildButton": "Rebuild tracker from snapshots",
+          "rebuildConfirm": "Rebuild the foreshadowing tracker from all chapter snapshots (a backup is made first). After parser fixes, this can auto-correct false \"unresolved\" states. Continue?",
+          "rebuildDone": "Rebuild complete",
+          "rebuildLogTitle": "Rebuild / cleanup log",
+          "scanInvalidButton": "Scan invalid snapshots",
+          "deleteInvalidButton": "Delete {{n}} invalid snapshots",
+          "deleteInvalidConfirm": "Delete {{count}} invalid snapshot files with chapterNumber≤0. Continue?",
+          "deleteInvalidDone": "Deleted {{count}} invalid snapshots",
+          "noInvalidSnapshots": "No invalid snapshots found",
+          "detectModelLabel": "Detection model",
+          "applyModelLabel": "Apply model (optional; apply step does not call the LLM)",
+          "selectDetectModel": "Select a detection model first.",
+          "scanButton": "Scan foreshadowing issues",
+          "scanning": "Scanning...",
+          "scanningHint": "Scanning…",
+          "scanStageLoading": "Loading foreshadowing tracker…",
+          "scanStageDetecting": "Asking the model to analyze…",
+          "scanBatchProgress": "Analyzing batch {{current}}/{{total}}…",
+          "scanBatchDetail": "{{batchSize}} items in this batch · {{activeCount}} active total (batched model calls; slowness is expected)",
+          "processLogTitle": "Scan process log",
+          "applyLogTitle": "Cleanup process log",
+          "issuesFound": "Found {{count}} issue candidates. Confirm before applying.",
+          "noneFound": "No obvious issues found.",
+          "bulkCleanBoth": "Clean all noise & stale (delete {{noise}} / abandon {{stale}})",
+          "bulkCleanNoise": "Delete all noise ({{n}})",
+          "bulkCleanStale": "Abandon all stale ({{n}})",
+          "bulkCleanConfirm": "Delete {{noise}} noise entries and mark {{stale}} stale entries as abandoned (backup first, single write). Continue?",
+          "bulkCleanDone": "Deleted {{deleted}} noise, abandoned {{abandoned}} stale",
+          "bulkLogTitle": "Bulk cleanup log",
+          "enqueued": "Added to cleanup queue",
+          "kept": "Marked as keep; skipped on next scan",
+          "queueTitle": "Cleanup task queue",
+          "kindDuplicate": "Duplicate",
+          "kindNoise": "Noise",
+          "kindStale": "Stale",
+          "actionMerge": "Merge",
+          "actionDelete": "Delete",
+          "actionDeleteAll": "Delete all",
+          "deleteAllConfirm": "Permanently delete these {{count}} foreshadowing entries (a backup is made first). Continue?",
+          "actionAbandon": "Mark abandoned",
+          "keepButton": "Keep",
+          "retry": "Retry",
+          "cancel": "Cancel",
+          "canonicalHint": "Select the entry to keep; the others will be merged into it. Use \"Delete all\" if none should remain.",
+          "itemMissing": "Not found in tracker (may already be processed)"
+        },
         "dedup": {
           "title": "Detect Duplicate Entities / Concepts",
           "description": "Ask the model to scan all entity / concept pages and group entries that likely point to the same topic under different names, such as Chinese vs English names, singular vs plural, or abbreviations vs full names. You confirm each group before merging. Merge tasks enter a queue and run one at a time to keep cross-references consistent.",

+ 56 - 1
src/i18n/zh.json

@@ -803,9 +803,64 @@
       },
       "maintenance": {
         "title": "维护工具",
-        "description": "用于清理资料库的工具:检测并合并那些在多次重新摄取后被大模型以不同名称创建出来的重复实体或概念。",
+        "description": "用于清理资料库的工具:检测并合并重复实体/概念,以及清理伏笔追踪器中的重复、噪声与失效条目。",
         "noProject": "请先打开一个项目。",
         "noLlm": "请先配置大模型提供方。",
+        "foreshadowing": {
+          "title": "清理伏笔",
+          "description": "扫描伏笔追踪器,找出重复线索、噪声条目(状态播报/剧情预告)和长期失效伏笔。每条需你确认后才会合并、删除或标记为已放弃。建议先「从快照重建」再扫描。",
+          "helpTitle": "三类问题分别怎么处理?",
+          "overviewTotal": "总计 {{n}}",
+          "overviewActive": "活跃 {{n}}",
+          "overviewResolved": "已回收 {{n}}",
+          "overviewAbandoned": "已放弃 {{n}}",
+          "overviewAvg": "平均 {{n}} 条/章",
+          "rebuildButton": "从快照重建追踪器",
+          "rebuildConfirm": "将从全部章节快照重新生成伏笔追踪器(会先备份)。修完解析问题后重建,可自动纠正错误的「未回收」状态。是否继续?",
+          "rebuildDone": "重建完成",
+          "rebuildLogTitle": "重建 / 清理日志",
+          "scanInvalidButton": "扫描异常快照",
+          "deleteInvalidButton": "删除 {{n}} 个异常快照",
+          "deleteInvalidConfirm": "将删除 {{count}} 个 chapterNumber≤0 的异常快照文件。是否继续?",
+          "deleteInvalidDone": "已删除 {{count}} 个异常快照",
+          "noInvalidSnapshots": "未发现异常快照",
+          "detectModelLabel": "检测模型",
+          "applyModelLabel": "清理模型(可选,执行阶段不用 LLM)",
+          "selectDetectModel": "请先选择检测模型。",
+          "scanButton": "开始扫描伏笔问题",
+          "scanning": "扫描中...",
+          "scanningHint": "正在扫描…",
+          "scanStageLoading": "正在读取伏笔追踪器…",
+          "scanStageDetecting": "正在调用模型分析…",
+          "scanBatchProgress": "正在分析第 {{current}}/{{total}} 批…",
+          "scanBatchDetail": "本批 {{batchSize}} 条 · 活跃共 {{activeCount}} 条(分批调用模型,较慢属正常)",
+          "processLogTitle": "扫描过程日志",
+          "applyLogTitle": "清理过程日志",
+          "issuesFound": "发现 {{count}} 个问题候选,请确认后处理。",
+          "noneFound": "未发现明显问题。",
+          "bulkCleanBoth": "一键清理噪声与失效(删 {{noise}} / 弃 {{stale}})",
+          "bulkCleanNoise": "一键删除全部噪声({{n}})",
+          "bulkCleanStale": "一键放弃全部失效({{n}})",
+          "bulkCleanConfirm": "将删除噪声 {{noise}} 条,并把失效 {{stale}} 条标记为已放弃(先备份,一次写入)。是否继续?",
+          "bulkCleanDone": "已删除 {{deleted}} 条噪声,放弃 {{abandoned}} 条失效",
+          "bulkLogTitle": "一键清理日志",
+          "enqueued": "已加入清理队列",
+          "kept": "已标记为保留,下次扫描将跳过",
+          "queueTitle": "清理任务队列",
+          "kindDuplicate": "重复",
+          "kindNoise": "噪声",
+          "kindStale": "失效",
+          "actionMerge": "合并",
+          "actionDelete": "删除",
+          "actionDeleteAll": "全部删除",
+          "deleteAllConfirm": "将永久删除这 {{count}} 条伏笔(先备份)。是否继续?",
+          "actionAbandon": "标记放弃",
+          "keepButton": "保留",
+          "retry": "重试",
+          "cancel": "取消",
+          "canonicalHint": "选中要保留的主条目,其余会合并进它;若整组都不要,点「全部删除」。",
+          "itemMissing": "追踪器中已找不到这条(可能已处理)"
+        },
         "dedup": {
           "title": "检测重复实体 / 概念",
           "description": "让大模型扫描全部实体 / 概念页面,并把那些很可能只是名称不同、实则指向同一主题的条目分组出来(例如中英文名称、单复数、简称与全称)。每组都需要你确认后才会合并。合并任务会进入队列并逐个执行,以保持交叉引用一致。",

+ 2 - 2
src/lib/agent/skills/draft-review-skill.ts

@@ -357,7 +357,7 @@ export function identifyDeviations(
   // 伏笔冲突:已 planted/advanced 的伏笔被本章提前说破
   if (evidence.foreshadowing?.items) {
     for (const fs of evidence.foreshadowing.items) {
-      if (fs.status === "resolved") continue;
+      if (fs.status === "resolved" || fs.status === "abandoned") continue;
       const hit = revealPatterns.some((p) => p(draft, fs.name));
       if (!hit) continue;
 
@@ -422,7 +422,7 @@ function buildRepairPrompt(
   const foreshadowingBrief =
     evidence.foreshadowing.items.length > 0
       ? evidence.foreshadowing.items
-          .filter((f) => f.status !== "resolved")
+          .filter((f) => f.status !== "resolved" && f.status !== "abandoned")
           .map(
             (f) =>
               `- [${f.status}] ${f.name}:${f.description}(第${f.plantedChapter}章)`,

+ 249 - 0
src/lib/foreshadowing-cleanup-cache.ts

@@ -0,0 +1,249 @@
+/**
+ * Persistence for foreshadowing cleanup: scan cache, keep whitelist, model prefs.
+ */
+import { readFile, writeFile, fileExists } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import {
+  keepKey,
+  type CleanupIssue,
+  type CleanupIssueKind,
+} from "@/lib/foreshadowing-cleanup"
+
+const SCAN_CACHE_FILE = ".qmai/foreshadowing-scan-cache.json"
+const KEEP_FILE = ".qmai/foreshadowing-keep.json"
+const MODELS_FILE = ".qmai/foreshadowing-cleanup-models.json"
+
+export interface ForeshadowingCleanupScanEntry {
+  issue: CleanupIssue
+  /** For duplicate: user-selected canonical id */
+  canonicalId: string
+  skipped: boolean
+}
+
+export interface ForeshadowingCleanupScanCache {
+  version: 1
+  projectId: string
+  scannedAt: number
+  scannedItemCount: number | null
+  currentChapter: number | null
+  modelId?: string
+  applyModelId?: string
+  issues: ForeshadowingCleanupScanEntry[]
+}
+
+export interface ForeshadowingCleanupModelPrefs {
+  detectModelId?: string
+  applyModelId?: string
+}
+
+function cachePath(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${SCAN_CACHE_FILE}`
+}
+
+function keepPath(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${KEEP_FILE}`
+}
+
+function modelsPath(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${MODELS_FILE}`
+}
+
+function isConfidence(value: unknown): value is CleanupIssue["confidence"] {
+  return value === "high" || value === "medium" || value === "low"
+}
+
+function isKind(value: unknown): value is CleanupIssueKind {
+  return value === "duplicate" || value === "noise" || value === "stale"
+}
+
+function parseIssue(raw: unknown): CleanupIssue | null {
+  if (!raw || typeof raw !== "object") return null
+  const obj = raw as Record<string, unknown>
+  if (!isKind(obj.kind)) return null
+  const ids = Array.isArray(obj.ids)
+    ? obj.ids.filter((s): s is string => typeof s === "string")
+    : []
+  if (obj.kind === "duplicate" && ids.length < 2) return null
+  if ((obj.kind === "noise" || obj.kind === "stale") && ids.length !== 1) return null
+  const reason = typeof obj.reason === "string" ? obj.reason : ""
+  const confidence = isConfidence(obj.confidence) ? obj.confidence : "low"
+  const canonicalId =
+    typeof obj.canonicalId === "string" ? obj.canonicalId : undefined
+  return {
+    kind: obj.kind,
+    ids,
+    canonicalId:
+      obj.kind === "duplicate"
+        ? canonicalId && ids.includes(canonicalId)
+          ? canonicalId
+          : ids[0]
+        : undefined,
+    reason,
+    confidence,
+  }
+}
+
+function parseEntry(raw: unknown): ForeshadowingCleanupScanEntry | null {
+  if (!raw || typeof raw !== "object") return null
+  const obj = raw as Record<string, unknown>
+  const issue = parseIssue(obj.issue)
+  if (!issue) return null
+  const canonicalId =
+    typeof obj.canonicalId === "string"
+      ? obj.canonicalId
+      : issue.canonicalId || issue.ids[0]
+  return {
+    issue,
+    canonicalId,
+    skipped: obj.skipped === true,
+  }
+}
+
+export async function loadForeshadowingCleanupScanCache(
+  projectPath: string,
+): Promise<ForeshadowingCleanupScanCache | null> {
+  const filePath = cachePath(projectPath)
+  try {
+    if (!(await fileExists(filePath))) return null
+  } catch {
+    return null
+  }
+  try {
+    const raw = JSON.parse(await readFile(filePath)) as Record<string, unknown>
+    if (raw.version !== 1) return null
+    if (typeof raw.projectId !== "string" || !raw.projectId.trim()) return null
+    if (typeof raw.scannedAt !== "number") return null
+    const issuesRaw = Array.isArray(raw.issues) ? raw.issues : []
+    const issues = issuesRaw
+      .map(parseEntry)
+      .filter((e): e is ForeshadowingCleanupScanEntry => e !== null)
+    return {
+      version: 1,
+      projectId: raw.projectId,
+      scannedAt: raw.scannedAt,
+      scannedItemCount:
+        typeof raw.scannedItemCount === "number" ? raw.scannedItemCount : null,
+      currentChapter:
+        typeof raw.currentChapter === "number" ? raw.currentChapter : null,
+      modelId: typeof raw.modelId === "string" ? raw.modelId : undefined,
+      applyModelId:
+        typeof raw.applyModelId === "string" ? raw.applyModelId : undefined,
+      issues,
+    }
+  } catch {
+    return null
+  }
+}
+
+export async function saveForeshadowingCleanupScanCache(
+  projectPath: string,
+  cache: ForeshadowingCleanupScanCache,
+): Promise<void> {
+  await writeFile(cachePath(projectPath), JSON.stringify(cache, null, 2))
+}
+
+export async function removeIssueFromForeshadowingScanCache(
+  projectPath: string,
+  issue: CleanupIssue,
+): Promise<void> {
+  const cache = await loadForeshadowingCleanupScanCache(projectPath)
+  if (!cache) return
+  const key = keepKey(issue.ids) + ":" + issue.kind
+  cache.issues = cache.issues.filter(
+    (e) => keepKey(e.issue.ids) + ":" + e.issue.kind !== key,
+  )
+  await saveForeshadowingCleanupScanCache(projectPath, cache)
+}
+
+/** Drop multiple candidate cards (e.g. after bulk noise/stale cleanup). */
+export async function removeIssuesFromForeshadowingScanCache(
+  projectPath: string,
+  issues: readonly CleanupIssue[],
+): Promise<void> {
+  if (issues.length === 0) return
+  const cache = await loadForeshadowingCleanupScanCache(projectPath)
+  if (!cache) return
+  const drop = new Set(issues.map((i) => keepKey(i.ids) + ":" + i.kind))
+  cache.issues = cache.issues.filter(
+    (e) => !drop.has(keepKey(e.issue.ids) + ":" + e.issue.kind),
+  )
+  await saveForeshadowingCleanupScanCache(projectPath, cache)
+}
+
+export async function loadForeshadowingKeep(
+  projectPath: string,
+): Promise<string[][]> {
+  const filePath = keepPath(projectPath)
+  try {
+    if (!(await fileExists(filePath))) return []
+  } catch {
+    return []
+  }
+  try {
+    const parsed = JSON.parse(await readFile(filePath))
+    if (!Array.isArray(parsed)) return []
+    return parsed.filter(
+      (g): g is string[] =>
+        Array.isArray(g) && g.every((s) => typeof s === "string"),
+    )
+  } catch {
+    return []
+  }
+}
+
+export async function addForeshadowingKeep(
+  projectPath: string,
+  ids: string[],
+): Promise<void> {
+  if (ids.length === 0) return
+  const list = await loadForeshadowingKeep(projectPath)
+  const normNew = keepKey(ids)
+  for (const existing of list) {
+    if (keepKey(existing) === normNew) return
+  }
+  list.push([...ids].sort())
+  await writeFile(keepPath(projectPath), JSON.stringify(list, null, 2))
+}
+
+export async function loadForeshadowingCleanupModelPrefs(
+  projectPath: string,
+): Promise<ForeshadowingCleanupModelPrefs | null> {
+  const filePath = modelsPath(projectPath)
+  try {
+    if (!(await fileExists(filePath))) return null
+  } catch {
+    return null
+  }
+  try {
+    const obj = JSON.parse(await readFile(filePath)) as Record<string, unknown>
+    return {
+      detectModelId:
+        typeof obj.detectModelId === "string"
+          ? obj.detectModelId.trim() || undefined
+          : undefined,
+      applyModelId:
+        typeof obj.applyModelId === "string"
+          ? obj.applyModelId.trim() || undefined
+          : undefined,
+    }
+  } catch {
+    return null
+  }
+}
+
+export async function saveForeshadowingCleanupModelPrefs(
+  projectPath: string,
+  prefs: ForeshadowingCleanupModelPrefs,
+): Promise<void> {
+  await writeFile(
+    modelsPath(projectPath),
+    JSON.stringify(
+      {
+        detectModelId: prefs.detectModelId?.trim() || undefined,
+        applyModelId: prefs.applyModelId?.trim() || undefined,
+      },
+      null,
+      2,
+    ),
+  )
+}

+ 404 - 0
src/lib/foreshadowing-cleanup-queue.ts

@@ -0,0 +1,404 @@
+/**
+ * Persistent serial queue for foreshadowing cleanup operations.
+ * Mirrors dedup-queue.ts.
+ */
+import { readFile, writeFile } from "@/commands/fs"
+import { useWikiStore } from "@/stores/wiki-store"
+import { normalizePath } from "@/lib/path-utils"
+import { getProjectPathById } from "@/lib/project-identity"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
+import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
+import {
+  cleanupIssueKey,
+  cleanupTaskKey,
+  defaultCleanupAction,
+  type CleanupApplyAction,
+  type CleanupIssue,
+} from "@/lib/foreshadowing-cleanup"
+import {
+  executeCleanupTask,
+  resolveCurrentChapter,
+  type ForeshadowingCleanupApplyStage,
+} from "@/lib/foreshadowing-cleanup-runner"
+import { removeIssueFromForeshadowingScanCache } from "@/lib/foreshadowing-cleanup-cache"
+
+export interface ForeshadowingCleanupTask {
+  id: string
+  projectId: string
+  issue: CleanupIssue
+  /** merge | delete | abandon — defaults from issue.kind */
+  action?: CleanupApplyAction
+  canonicalId?: string
+  modelId?: string
+  status: "pending" | "processing" | "done" | "failed"
+  addedAt: number
+  error: string | null
+  retryCount: number
+}
+
+let queue: ForeshadowingCleanupTask[] = []
+let processing = false
+let currentProjectId = ""
+let currentProjectPath = ""
+let currentAbortController: AbortController | null = null
+let currentApplyProgress: {
+  taskId: string
+  stage: ForeshadowingCleanupApplyStage
+} | null = null
+let currentApplyLogs: string[] = []
+
+type CompleteListener = (task: ForeshadowingCleanupTask) => void
+const completeListeners = new Set<CompleteListener>()
+
+export function onForeshadowingCleanupComplete(
+  listener: CompleteListener,
+): () => void {
+  completeListeners.add(listener)
+  return () => completeListeners.delete(listener)
+}
+
+function notifyComplete(task: ForeshadowingCleanupTask): void {
+  for (const listener of completeListeners) {
+    try {
+      listener(task)
+    } catch (err) {
+      console.error("[ForeshadowingCleanup Queue] listener failed:", err)
+    }
+  }
+}
+
+function queueFilePath(projectPath: string): string {
+  return `${normalizePath(projectPath)}/.qmai/foreshadowing-cleanup-queue.json`
+}
+
+async function saveQueue(projectPath: string): Promise<void> {
+  try {
+    const toSave = queue.filter((t) => t.status !== "done")
+    await writeFile(queueFilePath(projectPath), JSON.stringify(toSave, null, 2))
+  } catch {
+    // non-critical
+  }
+}
+
+async function loadQueue(
+  projectPath: string,
+  projectId: string,
+): Promise<ForeshadowingCleanupTask[]> {
+  try {
+    const raw = await readFile(queueFilePath(projectPath))
+    const tasks = JSON.parse(raw) as ForeshadowingCleanupTask[]
+    return tasks.map((t) => ({
+      ...t,
+      projectId: t.projectId ?? projectId,
+    }))
+  } catch {
+    return []
+  }
+}
+
+function generateId(): string {
+  return `fsclean-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+}
+
+export function foreshadowingCleanupIssueKey(issue: CleanupIssue): string {
+  return cleanupIssueKey(issue)
+}
+
+export async function enqueueForeshadowingCleanup(
+  projectId: string,
+  issue: CleanupIssue,
+  options: {
+    canonicalId?: string
+    modelId?: string
+    action?: CleanupApplyAction
+  } = {},
+): Promise<string> {
+  const active = useWikiStore.getState().project
+  if (!active || active.id !== projectId) {
+    throw new Error(
+      `enqueueForeshadowingCleanup: project ${projectId} is not the active project`,
+    )
+  }
+
+  await ensureForeshadowingCleanupQueueActive(active.id, active.path)
+
+  if (!currentProjectId || currentProjectId !== projectId) {
+    throw new Error(
+      `enqueueForeshadowingCleanup: failed to activate queue for project ${projectId}`,
+    )
+  }
+
+  const action = options.action ?? defaultCleanupAction(issue.kind)
+  const key = cleanupTaskKey(issue, action)
+  const existing = queue.find(
+    (t) =>
+      t.projectId === projectId &&
+      t.status !== "done" &&
+      cleanupTaskKey(t.issue, t.action ?? defaultCleanupAction(t.issue.kind)) ===
+        key,
+  )
+  if (existing) return existing.id
+
+  const task: ForeshadowingCleanupTask = {
+    id: generateId(),
+    projectId,
+    issue,
+    action,
+    canonicalId:
+      action === "merge"
+        ? options.canonicalId?.trim() || issue.canonicalId
+        : undefined,
+    modelId: options.modelId?.trim() || undefined,
+    status: "pending",
+    addedAt: Date.now(),
+    error: null,
+    retryCount: 0,
+  }
+
+  queue.push(task)
+  await saveQueue(currentProjectPath)
+  processNext(currentProjectId)
+  return task.id
+}
+
+export async function retryForeshadowingCleanupTask(taskId: string): Promise<void> {
+  let task = queue.find((t) => t.id === taskId)
+  if (!task) return
+  const projectId = task.projectId
+
+  const active = useWikiStore.getState().project
+  if (!active || active.id !== projectId) return
+
+  await ensureForeshadowingCleanupQueueActive(active.id, active.path)
+
+  task = queue.find((t) => t.id === taskId)
+  if (!task || task.projectId !== currentProjectId) return
+
+  task.status = "pending"
+  task.error = null
+  task.retryCount = 0
+  await saveQueue(currentProjectPath)
+  processNext(currentProjectId)
+}
+
+export async function cancelForeshadowingCleanupTask(taskId: string): Promise<void> {
+  let task = queue.find((t) => t.id === taskId)
+  if (!task) return
+  const projectId = task.projectId
+
+  const active = useWikiStore.getState().project
+  if (!active || active.id !== projectId) return
+
+  await ensureForeshadowingCleanupQueueActive(active.id, active.path)
+
+  task = queue.find((t) => t.id === taskId)
+  if (!task || task.projectId !== currentProjectId) return
+
+  if (task.status === "processing") {
+    if (currentAbortController) {
+      currentAbortController.abort()
+      currentAbortController = null
+    }
+    processing = false
+    currentApplyProgress = null
+  }
+
+  queue = queue.filter((t) => t.id !== taskId)
+  await saveQueue(currentProjectPath)
+  processNext(currentProjectId)
+}
+
+export function getForeshadowingCleanupQueue(): readonly ForeshadowingCleanupTask[] {
+  return queue
+}
+
+export function getForeshadowingCleanupProgress(): {
+  taskId: string
+  stage: ForeshadowingCleanupApplyStage
+} | null {
+  return currentApplyProgress
+}
+
+export function getForeshadowingCleanupLogs(): readonly string[] {
+  return currentApplyLogs
+}
+
+export async function ensureForeshadowingCleanupQueueActive(
+  projectId: string,
+  projectPath: string,
+): Promise<void> {
+  const pp = normalizePath(projectPath)
+  if (currentProjectId === projectId && currentProjectPath === pp) return
+  await restoreForeshadowingCleanupQueue(projectId, projectPath)
+}
+
+export async function pauseForeshadowingCleanupQueue(): Promise<void> {
+  if (!currentProjectId || !currentProjectPath) return
+
+  const pausedProjectPath = currentProjectPath
+
+  if (currentAbortController) {
+    currentAbortController.abort()
+    currentAbortController = null
+  }
+  processing = false
+  currentApplyProgress = null
+  currentApplyLogs = []
+
+  for (const task of queue) {
+    if (task.status === "processing") {
+      task.status = "pending"
+    }
+  }
+
+  await saveQueue(pausedProjectPath)
+
+  queue = []
+  currentProjectId = ""
+  currentProjectPath = ""
+}
+
+export async function restoreForeshadowingCleanupQueue(
+  projectId: string,
+  projectPath: string,
+): Promise<void> {
+  const pp = normalizePath(projectPath)
+  queue = []
+  processing = false
+  currentAbortController = null
+  currentProjectId = projectId
+  currentProjectPath = pp
+
+  const saved = await loadQueue(pp, projectId)
+  if (saved.length === 0) return
+
+  const mine = saved.filter((t) => t.projectId === projectId)
+  let restored = 0
+  for (const task of mine) {
+    if (task.status === "processing") {
+      task.status = "pending"
+      restored++
+    }
+  }
+
+  queue = mine
+  await saveQueue(pp)
+
+  const pending = queue.filter((t) => t.status === "pending").length
+  if (pending > 0 || restored > 0) {
+    console.log(
+      `[ForeshadowingCleanup Queue] Restored: ${pending} pending, ${restored} resumed`,
+    )
+    processNext(projectId)
+  }
+}
+
+const MAX_RETRIES = 3
+
+async function processNext(projectId: string): Promise<void> {
+  if (processing) return
+  if (currentProjectId !== projectId) return
+
+  const next = queue.find(
+    (t) => t.projectId === projectId && t.status === "pending",
+  )
+  if (!next) return
+
+  const registryPath = await getProjectPathById(projectId)
+  const pp = registryPath ? normalizePath(registryPath) : ""
+  if (currentProjectId !== projectId) return
+
+  if (!pp) {
+    next.status = "failed"
+    next.error = "项目未在注册表中找到(可能已被删除?)"
+    await saveQueue(currentProjectPath)
+    processNext(projectId)
+    return
+  }
+
+  processing = true
+  next.status = "processing"
+  await saveQueue(pp)
+  if (currentProjectId !== projectId) return
+
+  const state = useWikiStore.getState()
+  const llmConfig = next.modelId?.trim()
+    ? resolveModelConfig(next.modelId, state.llmConfig, state.providerConfigs)
+    : resolveDefaultModel(state.llmConfig)
+
+  // Cleanup apply itself doesn't need LLM, but we still check config for consistency
+  // with the rest of the app's model resolution paths.
+  void hasUsableLlm
+  void llmConfig
+
+  currentAbortController = new AbortController()
+  currentApplyProgress = { taskId: next.id, stage: "loading" }
+  currentApplyLogs = []
+
+  const appendLog = (message: string) => {
+    const stamp = new Date().toLocaleTimeString()
+    currentApplyLogs = [...currentApplyLogs, `${stamp}  ${message}`]
+    console.log(`[ForeshadowingCleanup Queue] ${message}`)
+  }
+
+  try {
+    const currentChapter = await resolveCurrentChapter(pp)
+    await executeCleanupTask(pp, next.issue, {
+      canonicalId: next.canonicalId,
+      action: next.action ?? defaultCleanupAction(next.issue.kind),
+      signal: currentAbortController.signal,
+      onProgress: (stage) => {
+        currentApplyProgress = { taskId: next.id, stage }
+      },
+      onLog: appendLog,
+      currentChapter,
+    })
+
+    await removeIssueFromForeshadowingScanCache(pp, next.issue).catch((err) => {
+      console.error(
+        "[ForeshadowingCleanup Queue] failed to update scan cache:",
+        err,
+      )
+    })
+
+    if (currentProjectId !== projectId) return
+
+    currentAbortController = null
+    currentApplyProgress = null
+    const completedTask = { ...next }
+    queue = queue.filter((t) => t.id !== next.id)
+    await saveQueue(pp)
+    useWikiStore.getState().bumpDataVersion()
+    notifyComplete(completedTask)
+  } catch (err) {
+    if (currentProjectId !== projectId) return
+    currentAbortController = null
+    currentApplyProgress = null
+    const message = err instanceof Error ? err.message : String(err)
+    appendLog(`失败:${message}`)
+
+    const missing =
+      /已不存在|not found|ENOENT|No such file|文件不存在/i.test(message)
+    if (missing) {
+      await removeIssueFromForeshadowingScanCache(pp, next.issue).catch(() => {})
+      queue = queue.filter((t) => t.id !== next.id)
+      await saveQueue(pp)
+      appendLog("候选伏笔已不存在,已从列表移除")
+      processing = false
+      processNext(projectId)
+      return
+    }
+
+    next.retryCount++
+    next.error = message
+    if (next.retryCount >= MAX_RETRIES) {
+      next.status = "failed"
+    } else {
+      next.status = "pending"
+    }
+    await saveQueue(pp)
+  }
+
+  processing = false
+  processNext(projectId)
+}

+ 489 - 0
src/lib/foreshadowing-cleanup-runner.ts

@@ -0,0 +1,489 @@
+/**
+ * I/O wrapper for foreshadowing cleanup: scan + execute + backup + sync docs.
+ */
+import {
+  readFile,
+  writeFile,
+  deleteFile,
+  listDirectory,
+  fileExists,
+  createDirectory,
+} from "@/commands/fs"
+import { streamChat } from "@/lib/llm-client"
+import { normalizePath } from "@/lib/path-utils"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { useWikiStore } from "@/stores/wiki-store"
+import {
+  applyBulkDeleteAndAbandon,
+  applyCleanupIssue,
+  buildOverview,
+  defaultCleanupAction,
+  detectCleanupIssues,
+  toForeshadowingSummary,
+  type CleanupApplyAction,
+  type CleanupBatchProgress,
+  type CleanupIssue,
+  type CleanupLlmCall,
+} from "@/lib/foreshadowing-cleanup"
+import {
+  loadForeshadowingTracker,
+  saveForeshadowingTracker,
+  type ForeshadowingStore,
+} from "@/lib/novel/foreshadowing-tracker"
+import { writeForeshadowingMd } from "@/lib/novel/tracking-files"
+import {
+  exportStructuredMemoryToWiki,
+  finalizeProjectMemoryRebuild,
+  listSnapshots,
+  loadSnapshot,
+} from "@/lib/novel/chapter-ingest"
+import { loadForeshadowingKeep } from "@/lib/foreshadowing-cleanup-cache"
+import type { FileNode } from "@/types/wiki"
+
+export type ForeshadowingCleanupScanStage = "loading" | "detecting"
+export type ForeshadowingCleanupApplyStage = "loading" | "applying" | "writing"
+
+export type CleanupLogFn = (message: string) => void
+
+export interface ForeshadowingCleanupScanProgress {
+  stage: ForeshadowingCleanupScanStage
+  /** 0–100; loading uses a small fixed value, detecting follows batches */
+  percent: number
+  batch?: CleanupBatchProgress
+}
+
+function describeLlm(llmConfig: LlmConfig): string {
+  const provider = llmConfig.provider?.trim() || "unknown"
+  const model = llmConfig.model?.trim() || "unknown"
+  return `${provider}/${model}`
+}
+
+export function buildCleanupLlmCall(llmConfig: LlmConfig): CleanupLlmCall {
+  return async (systemPrompt, userMessage, signal) => {
+    let result = ""
+    let streamError: Error | null = null
+    await new Promise<void>((resolve) => {
+      streamChat(
+        llmConfig,
+        [
+          { role: "system", content: systemPrompt },
+          { role: "user", content: userMessage },
+        ],
+        {
+          onToken: (t) => {
+            result += t
+          },
+          onDone: () => resolve(),
+          onError: (err) => {
+            streamError = err
+            resolve()
+          },
+        },
+        signal,
+        { temperature: 0.1 },
+      ).catch((err) => {
+        streamError = err instanceof Error ? err : new Error(String(err))
+        resolve()
+      })
+    })
+    if (streamError) throw streamError
+    return result
+  }
+}
+
+export async function resolveCurrentChapter(projectPath: string): Promise<number> {
+  const numbers = await listSnapshots(projectPath)
+  const positive = numbers.filter((n) => n > 0)
+  if (positive.length === 0) return 1
+  return Math.max(...positive)
+}
+
+export interface ForeshadowingCleanupScanResult {
+  issues: CleanupIssue[]
+  scannedItemCount: number
+  currentChapter: number
+  overview: ReturnType<typeof buildOverview>
+  store: ForeshadowingStore
+}
+
+export async function runForeshadowingCleanupScan(
+  projectPath: string,
+  llmConfig: LlmConfig,
+  options: {
+    signal?: AbortSignal
+    onProgress?: (progress: ForeshadowingCleanupScanProgress) => void
+    onLog?: CleanupLogFn
+  } = {},
+): Promise<ForeshadowingCleanupScanResult> {
+  const log = options.onLog
+  const report = (progress: ForeshadowingCleanupScanProgress) => {
+    options.onProgress?.(progress)
+  }
+  const pp = normalizePath(projectPath)
+  log?.(`开始扫描伏笔,模型:${describeLlm(llmConfig)}`)
+  report({ stage: "loading", percent: 2 })
+  log?.("正在读取伏笔追踪器…")
+
+  const store = await loadForeshadowingTracker(pp)
+  const currentChapter = await resolveCurrentChapter(pp)
+  const overview = buildOverview(store)
+  log?.(
+    `已读取 ${store.items.length} 条伏笔(活跃 ${overview.active} / 已回收 ${overview.resolved} / 已放弃 ${overview.abandoned}),当前约第 ${currentChapter} 章`,
+  )
+  report({ stage: "loading", percent: 8 })
+
+  if (store.items.length === 0) {
+    log?.("无伏笔数据,跳过检测")
+    report({ stage: "detecting", percent: 100 })
+    return {
+      issues: [],
+      scannedItemCount: 0,
+      currentChapter,
+      overview,
+      store,
+    }
+  }
+
+  report({ stage: "detecting", percent: 10 })
+  const keep = await loadForeshadowingKeep(pp)
+  if (keep.length > 0) {
+    log?.(`已加载 ${keep.length} 组「保留」白名单`)
+  }
+  const activeCount = overview.active
+  const estimatedBatches = Math.max(1, Math.ceil(activeCount / 80))
+  log?.(
+    `正在调用模型分析伏笔问题(活跃 ${activeCount} 条,约 ${estimatedBatches} 批)…`,
+  )
+  const llm = buildCleanupLlmCall(llmConfig)
+  const summaries = store.items.map(toForeshadowingSummary)
+  const issues = await detectCleanupIssues(summaries, currentChapter, llm, {
+    signal: options.signal,
+    keepKeys: keep,
+    onBatchProgress: (batch) => {
+      // loading 10% + detecting 90%
+      const base = 10
+      const span = 90
+      const completed =
+        batch.phase === "batch_done" ? batch.current : batch.current - 1
+      const percent = Math.min(
+        99,
+        Math.round(base + (completed / Math.max(1, batch.total)) * span),
+      )
+      if (batch.phase === "batch_start") {
+        log?.(
+          `分析第 ${batch.current}/${batch.total} 批(本批 ${batch.batchSize} 条,活跃共 ${batch.activeCount} 条)…`,
+        )
+      } else {
+        log?.(`第 ${batch.current}/${batch.total} 批完成`)
+      }
+      report({ stage: "detecting", percent, batch })
+    },
+  })
+  report({ stage: "detecting", percent: 100 })
+  log?.(
+    `分析完成:${issues.filter((i) => i.kind === "duplicate").length} 组重复,${issues.filter((i) => i.kind === "noise").length} 条噪声,${issues.filter((i) => i.kind === "stale").length} 条失效`,
+  )
+
+  return {
+    issues,
+    scannedItemCount: store.items.length,
+    currentChapter,
+    overview,
+    store,
+  }
+}
+
+async function backupFiles(
+  projectPath: string,
+  stamp: string,
+): Promise<string> {
+  const pp = normalizePath(projectPath)
+  const backupDir = `${pp}/.qmai/page-history/foreshadowing-${stamp}`
+  await createDirectory(backupDir)
+
+  const trackerPath = `${pp}/.novel/foreshadowing-tracker.json`
+  if (await fileExists(trackerPath)) {
+    const content = await readFile(trackerPath)
+    await writeFile(`${backupDir}/foreshadowing-tracker.json`, content)
+  }
+
+  for (const rel of [
+    "wiki/tracking/伏笔.md",
+    "QM/tracking/伏笔.md",
+    "wiki/memory/foreshadowing-tracker.md",
+    "QM/memory/foreshadowing-tracker.md",
+  ]) {
+    const abs = `${pp}/${rel}`
+    try {
+      if (await fileExists(abs)) {
+        const content = await readFile(abs)
+        const sanitized = rel.replace(/[/\\]/g, "_")
+        await writeFile(`${backupDir}/${sanitized}`, content)
+      }
+    } catch {
+      // optional paths
+    }
+  }
+  return backupDir
+}
+
+async function syncDerivedDocs(projectPath: string, store: ForeshadowingStore): Promise<void> {
+  const pp = normalizePath(projectPath)
+  const resolvedRecords = store.items
+    .filter((f) => f.status === "resolved" && f.resolvedChapter != null)
+    .map((f) => ({
+      id: f.id,
+      resolvedInChapter: f.resolvedChapter!,
+      resolution: `伏笔「${f.name}」在第${f.resolvedChapter}章回收`,
+    }))
+
+  try {
+    await writeForeshadowingMd(pp, store.items, resolvedRecords)
+  } catch (err) {
+    console.warn("[ForeshadowingCleanup] writeForeshadowingMd failed:", err)
+  }
+
+  try {
+    const numbers = await listSnapshots(pp)
+    const latestPositive = numbers.filter((n) => n > 0).sort((a, b) => b - a)[0]
+    if (latestPositive != null) {
+      const snap = await loadSnapshot(pp, latestPositive)
+      if (snap) {
+        await exportStructuredMemoryToWiki(pp, snap)
+      }
+    }
+  } catch (err) {
+    console.warn("[ForeshadowingCleanup] memory doc rewrite failed:", err)
+  }
+}
+
+export async function executeCleanupTask(
+  projectPath: string,
+  issue: CleanupIssue,
+  options: {
+    canonicalId?: string
+    action?: CleanupApplyAction
+    signal?: AbortSignal
+    onProgress?: (stage: ForeshadowingCleanupApplyStage) => void
+    onLog?: CleanupLogFn
+    currentChapter?: number
+  } = {},
+): Promise<void> {
+  const pp = normalizePath(projectPath)
+  const log = options.onLog
+  const action = options.action ?? defaultCleanupAction(issue.kind)
+  options.signal?.throwIfAborted()
+
+  const actionLabel =
+    action === "delete" ? "删除" : action === "abandon" ? "放弃" : "合并"
+  log?.(
+    `开始${actionLabel} ${issue.kind}:${issue.ids.join(", ")}${
+      action === "merge" ? ` → ${options.canonicalId || issue.canonicalId}` : ""
+    }`,
+  )
+
+  options.onProgress?.("loading")
+  const store = await loadForeshadowingTracker(pp)
+  const present = issue.ids.filter((id) => store.items.some((f) => f.id === id))
+  const missing = issue.ids.filter((id) => !present.includes(id))
+  if (present.length === 0) {
+    throw new Error(
+      `伏笔已不存在:${issue.ids.join(", ")} — 可能已被先前任务处理或重建覆盖`,
+    )
+  }
+  if (missing.length > 0) {
+    if (action === "delete") {
+      log?.(`部分条目已不存在,将删除剩余 ${present.length} 条:${present.join(", ")}`)
+    } else {
+      throw new Error(
+        `伏笔已不存在:${missing.join(", ")} — 可能已被先前任务处理或重建覆盖`,
+      )
+    }
+  }
+
+  options.onProgress?.("applying")
+  const stamp = new Date().toISOString().replace(/[:.]/g, "-")
+  const backupDir = await backupFiles(pp, stamp)
+  log?.(`已备份 → ${backupDir}`)
+
+  const effectiveIssue =
+    action === "delete" && missing.length > 0
+      ? { ...issue, ids: present }
+      : issue
+
+  applyCleanupIssue(store, effectiveIssue, {
+    canonicalId: options.canonicalId,
+    reason: issue.reason,
+    chapter: options.currentChapter,
+    action,
+  })
+
+  options.onProgress?.("writing")
+  await saveForeshadowingTracker(pp, store)
+  log?.("已写入 foreshadowing-tracker.json")
+  await syncDerivedDocs(pp, store)
+  log?.("已同步 tracking / memory 文档")
+
+  useWikiStore.getState().bumpDataVersion()
+  log?.("处理完成")
+}
+
+export async function executeBulkNoiseAndStaleCleanup(
+  projectPath: string,
+  options: {
+    deleteIds: readonly string[]
+    abandonIds: readonly string[]
+    currentChapter?: number
+    onLog?: CleanupLogFn
+    onProgress?: (stage: ForeshadowingCleanupApplyStage) => void
+    signal?: AbortSignal
+  },
+): Promise<{ deleted: number; abandoned: number }> {
+  const pp = normalizePath(projectPath)
+  const log = options.onLog
+  const deleteIds = [...new Set(options.deleteIds.filter(Boolean))]
+  const abandonIds = [...new Set(options.abandonIds.filter(Boolean))].filter(
+    (id) => !deleteIds.includes(id),
+  )
+
+  if (deleteIds.length === 0 && abandonIds.length === 0) {
+    log?.("没有可清理的噪声/失效条目")
+    return { deleted: 0, abandoned: 0 }
+  }
+
+  options.signal?.throwIfAborted()
+  log?.(
+    `开始一键清理:删除噪声 ${deleteIds.length} 条,放弃失效 ${abandonIds.length} 条`,
+  )
+
+  options.onProgress?.("loading")
+  const store = await loadForeshadowingTracker(pp)
+
+  options.onProgress?.("applying")
+  const stamp = new Date().toISOString().replace(/[:.]/g, "-")
+  const backupDir = await backupFiles(pp, `bulk-${stamp}`)
+  log?.(`已备份 → ${backupDir}`)
+
+  const result = applyBulkDeleteAndAbandon(store, {
+    deleteIds,
+    abandonIds,
+    reason: "一键清理噪声/失效",
+    chapter: options.currentChapter,
+  })
+  log?.(`已处理:删除 ${result.deleted} 条,放弃 ${result.abandoned} 条`)
+
+  options.onProgress?.("writing")
+  await saveForeshadowingTracker(pp, store)
+  log?.("已写入 foreshadowing-tracker.json")
+  await syncDerivedDocs(pp, store)
+  log?.("已同步 tracking / memory 文档")
+
+  useWikiStore.getState().bumpDataVersion()
+  log?.("一键清理完成")
+  return result
+}
+
+export async function rebuildForeshadowingFromSnapshots(
+  projectPath: string,
+  options: { onLog?: CleanupLogFn } = {},
+): Promise<void> {
+  const pp = normalizePath(projectPath)
+  const log = options.onLog
+  log?.("正在备份当前伏笔数据…")
+  const stamp = new Date().toISOString().replace(/[:.]/g, "-")
+  const backupDir = await backupFiles(pp, `rebuild-${stamp}`)
+  log?.(`已备份 → ${backupDir}`)
+  log?.("正在从快照全量重建伏笔追踪器…")
+  await finalizeProjectMemoryRebuild(pp)
+  const store = await loadForeshadowingTracker(pp)
+  const overview = buildOverview(store)
+  log?.(
+    `重建完成:共 ${overview.total} 条(活跃 ${overview.active} / 已回收 ${overview.resolved} / 已放弃 ${overview.abandoned})`,
+  )
+}
+
+export interface InvalidSnapshotInfo {
+  fileName: string
+  path: string
+  chapterNumber: number
+  foreshadowingChangeCount: number
+}
+
+function* walkFiles(nodes: FileNode[], prefix: string): Generator<FileNode> {
+  for (const node of nodes) {
+    if (node.is_dir) {
+      if (node.children) yield* walkFiles(node.children, prefix)
+      continue
+    }
+    if (node.path.includes(prefix)) yield node
+  }
+}
+
+export async function listInvalidSnapshots(
+  projectPath: string,
+): Promise<InvalidSnapshotInfo[]> {
+  const pp = normalizePath(projectPath)
+  let tree: FileNode[]
+  try {
+    tree = await listDirectory(pp)
+  } catch {
+    return []
+  }
+
+  const results: InvalidSnapshotInfo[] = []
+  for (const node of walkFiles(tree, ".novel/snapshots")) {
+    if (!node.name.endsWith(".snapshot.json")) continue
+    try {
+      const raw = await readFile(node.path)
+      const data = JSON.parse(raw) as {
+        chapterNumber?: number
+        foreshadowingChanges?: string[]
+      }
+      const chapterNumber = data.chapterNumber
+      if (typeof chapterNumber !== "number" || chapterNumber > 0) continue
+      results.push({
+        fileName: node.name,
+        path: node.path,
+        chapterNumber,
+        foreshadowingChangeCount: Array.isArray(data.foreshadowingChanges)
+          ? data.foreshadowingChanges.length
+          : 0,
+      })
+    } catch {
+      // skip unreadable
+    }
+  }
+  return results.sort((a, b) => a.chapterNumber - b.chapterNumber)
+}
+
+export async function deleteInvalidSnapshots(
+  projectPath: string,
+  paths: string[],
+  options: { onLog?: CleanupLogFn } = {},
+): Promise<number> {
+  const log = options.onLog
+  let deleted = 0
+  for (const path of paths) {
+    try {
+      await deleteFile(path)
+      // also try companion .md
+      if (path.endsWith(".json")) {
+        const md = path.replace(/\.json$/, ".md")
+        try {
+          if (await fileExists(md)) await deleteFile(md)
+        } catch {
+          // ignore
+        }
+      }
+      deleted++
+      log?.(`已删除 ${path.split("/").pop()}`)
+    } catch (err) {
+      log?.(
+        `删除失败 ${path}: ${err instanceof Error ? err.message : String(err)}`,
+      )
+    }
+  }
+  return deleted
+}
+
+export { buildOverview }

+ 227 - 0
src/lib/foreshadowing-cleanup.spec.ts

@@ -0,0 +1,227 @@
+import { describe, expect, it } from "vitest"
+import {
+  applyBulkDeleteAndAbandon,
+  applyCleanupIssue,
+  looksLikeNoise,
+  looksLikeStale,
+  ruleBasedCleanupIssues,
+  detectCleanupIssues,
+  type ForeshadowingSummary,
+} from "./foreshadowing-cleanup"
+import { createEmptyForeshadowingStore, type Foreshadowing } from "./novel/foreshadowing-tracker"
+
+function item(partial: Partial<Foreshadowing> & { id: string; name: string }): Foreshadowing {
+  return {
+    description: "",
+    status: "planted",
+    plantedChapter: 1,
+    advancedChapters: [],
+    relatedCharacters: [],
+    relatedEvents: [],
+    notes: "",
+    ...partial,
+  }
+}
+
+function summary(partial: Partial<ForeshadowingSummary> & { id: string; name: string }): ForeshadowingSummary {
+  return {
+    description: "",
+    status: "planted",
+    plantedChapter: 1,
+    advancedChapters: [],
+    ...partial,
+  }
+}
+
+describe("looksLikeNoise / looksLikeStale", () => {
+  it("flags forecast-style names as noise", () => {
+    expect(
+      looksLikeNoise(
+        summary({
+          id: "F1",
+          name: "美军地面部队规模需求升至三十万,预示大规模正规战争即将展开",
+        }),
+      ),
+    ).toBe(true)
+  })
+
+  it("flags long-planted items without advances as stale", () => {
+    expect(
+      looksLikeStale(
+        summary({ id: "F2", name: "灰门源头", plantedChapter: 4, status: "planted" }),
+        30,
+      ),
+    ).toBe(true)
+    expect(
+      looksLikeStale(
+        summary({
+          id: "F3",
+          name: "灰门源头",
+          plantedChapter: 4,
+          status: "planted",
+          advancedChapters: [10],
+        }),
+        30,
+      ),
+    ).toBe(false)
+  })
+})
+
+describe("ruleBasedCleanupIssues", () => {
+  it("emits noise and stale and respects keep whitelist", () => {
+    const issues = ruleBasedCleanupIssues(
+      [
+        summary({
+          id: "F1",
+          name: "敌意值上升预示非常规渗透事件即将触发",
+          plantedChapter: 3,
+        }),
+        summary({ id: "F2", name: "莱拉真实身份", plantedChapter: 2 }),
+      ],
+      40,
+      { keepKeys: [["F2"]] },
+    )
+    expect(issues.some((i) => i.kind === "noise" && i.ids[0] === "F1")).toBe(true)
+    expect(issues.some((i) => i.ids[0] === "F2")).toBe(false)
+  })
+})
+
+describe("applyCleanupIssue", () => {
+  it("merges duplicates with earliest planted and union advanced", () => {
+    const store = createEmptyForeshadowingStore()
+    store.items = [
+      item({
+        id: "F001",
+        name: "世界敌意值",
+        description: "短",
+        plantedChapter: 5,
+        advancedChapters: [6],
+      }),
+      item({
+        id: "F002",
+        name: "世界敌意值上升",
+        description: "更长的说明文本关于敌意值",
+        plantedChapter: 2,
+        status: "advanced",
+        advancedChapters: [8],
+      }),
+    ]
+    applyCleanupIssue(
+      store,
+      {
+        kind: "duplicate",
+        ids: ["F001", "F002"],
+        canonicalId: "F001",
+        reason: "同一线索",
+        confidence: "high",
+      },
+      { canonicalId: "F001" },
+    )
+    expect(store.items).toHaveLength(1)
+    expect(store.items[0].id).toBe("F001")
+    expect(store.items[0].plantedChapter).toBe(2)
+    expect(store.items[0].advancedChapters).toEqual([6, 8])
+    expect(store.items[0].description).toContain("更长")
+  })
+
+  it("deletes noise", () => {
+    const store = createEmptyForeshadowingStore()
+    store.items = [
+      item({ id: "F001", name: "噪声" }),
+      item({ id: "F002", name: "保留" }),
+    ]
+    applyCleanupIssue(store, {
+      kind: "noise",
+      ids: ["F001"],
+      reason: "噪声",
+      confidence: "high",
+    })
+    expect(store.items.map((f) => f.id)).toEqual(["F002"])
+  })
+
+  it("deletes all ids in a duplicate group when action is delete", () => {
+    const store = createEmptyForeshadowingStore()
+    store.items = [
+      item({ id: "F001", name: "a" }),
+      item({ id: "F002", name: "b" }),
+      item({ id: "F003", name: "keep" }),
+    ]
+    applyCleanupIssue(
+      store,
+      {
+        kind: "duplicate",
+        ids: ["F001", "F002"],
+        canonicalId: "F001",
+        reason: "都不要",
+        confidence: "high",
+      },
+      { action: "delete" },
+    )
+    expect(store.items.map((f) => f.id)).toEqual(["F003"])
+  })
+
+  it("bulk deletes noise and abandons stale in one pass", () => {
+    const store = createEmptyForeshadowingStore()
+    store.items = [
+      item({ id: "N1", name: "噪声1" }),
+      item({ id: "N2", name: "噪声2" }),
+      item({ id: "S1", name: "失效1", plantedChapter: 1 }),
+      item({ id: "K1", name: "保留" }),
+    ]
+    const result = applyBulkDeleteAndAbandon(store, {
+      deleteIds: ["N1", "N2"],
+      abandonIds: ["S1"],
+      chapter: 100,
+    })
+    expect(result).toEqual({ deleted: 2, abandoned: 1 })
+    expect(store.items.map((f) => f.id).sort()).toEqual(["K1", "S1"])
+    expect(store.items.find((f) => f.id === "S1")?.status).toBe("abandoned")
+  })
+
+  it("marks stale as abandoned with notes", () => {
+    const store = createEmptyForeshadowingStore()
+    store.items = [item({ id: "F001", name: "旧伏笔", plantedChapter: 1 })]
+    applyCleanupIssue(
+      store,
+      {
+        kind: "stale",
+        ids: ["F001"],
+        reason: "故事方向已变",
+        confidence: "medium",
+      },
+      { chapter: 100 },
+    )
+    expect(store.items[0].status).toBe("abandoned")
+    expect(store.items[0].notes).toContain("故事方向已变")
+    expect(store.items[0].notes).toContain("第100章")
+  })
+})
+
+describe("detectCleanupIssues", () => {
+  it("parses LLM JSON and supplements with rules", async () => {
+    const summaries = [
+      summary({ id: "F001", name: "灰门", plantedChapter: 4 }),
+      summary({ id: "F002", name: "灰门联络链", plantedChapter: 5 }),
+      summary({
+        id: "F003",
+        name: "美军规模上升预示大规模正规战争即将展开",
+        plantedChapter: 20,
+      }),
+    ]
+    const llm = async () =>
+      JSON.stringify({
+        issues: [
+          {
+            kind: "duplicate",
+            ids: ["F001", "F002"],
+            canonicalId: "F001",
+            reason: "同一灰门线索",
+            confidence: "high",
+          },
+        ],
+      })
+    const issues = await detectCleanupIssues(summaries, 50, llm)
+    expect(issues.some((i) => i.kind === "duplicate")).toBe(true)
+    expect(issues.some((i) => i.kind === "noise" && i.ids[0] === "F003")).toBe(true)
+  })
+})

+ 565 - 0
src/lib/foreshadowing-cleanup.ts

@@ -0,0 +1,565 @@
+/**
+ * Pure foreshadowing cleanup algorithm (no I/O).
+ *
+ * Detects three issue kinds via rule pre-filter + LLM:
+ *   - duplicate: same clue planted repeatedly → merge
+ *   - noise: status broadcast / plot forecast, not a real foreshadow → delete
+ *   - stale: real foreshadow abandoned by story direction → mark abandoned
+ */
+
+import type { Foreshadowing, ForeshadowingStore } from "@/lib/novel/foreshadowing-tracker"
+import { isActiveForeshadowingStatus } from "@/lib/novel/foreshadowing-normalize"
+
+export type CleanupIssueKind = "duplicate" | "noise" | "stale"
+
+export interface CleanupIssue {
+  kind: CleanupIssueKind
+  /** duplicate: multiple ids; noise/stale: single id */
+  ids: string[]
+  /** Only for duplicate — which id to keep (user may override) */
+  canonicalId?: string
+  reason: string
+  confidence: "high" | "medium" | "low"
+}
+
+export interface ForeshadowingSummary {
+  id: string
+  name: string
+  description: string
+  status: string
+  plantedChapter: number
+  advancedChapters: number[]
+  resolvedChapter?: number
+}
+
+export type CleanupLlmCall = (
+  systemPrompt: string,
+  userMessage: string,
+  signal?: AbortSignal,
+) => Promise<string>
+
+export const NOISE_PATTERN =
+  /(预示|暗示|将面临|为后续|埋下伏笔|即将触发|即将展开|持续上升|倒计时|距离\d+|高危区间)/u
+
+export const STALE_PLANTED_CHAPTERS = 20
+export const CLEANUP_BATCH_SIZE = 80
+
+export function toForeshadowingSummary(item: Foreshadowing): ForeshadowingSummary {
+  return {
+    id: item.id,
+    name: item.name,
+    description: item.description || "",
+    status: item.status,
+    plantedChapter: item.plantedChapter,
+    advancedChapters: [...(item.advancedChapters || [])],
+    resolvedChapter: item.resolvedChapter,
+  }
+}
+
+/** Rule pre-filter: likely noise (status broadcast / plot forecast). */
+export function looksLikeNoise(item: ForeshadowingSummary): boolean {
+  if (item.status === "resolved" || item.status === "abandoned") return false
+  const text = `${item.name} ${item.description}`.trim()
+  if (!text) return true
+  if (!item.description.trim() && NOISE_PATTERN.test(item.name)) return true
+  if (NOISE_PATTERN.test(text) && text.length > 40) return true
+  return false
+}
+
+/** Rule pre-filter: planted long ago with no advances. */
+export function looksLikeStale(
+  item: ForeshadowingSummary,
+  currentChapter: number,
+  threshold = STALE_PLANTED_CHAPTERS,
+): boolean {
+  if (item.status !== "planted") return false
+  if ((item.advancedChapters?.length ?? 0) > 0) return false
+  return currentChapter - item.plantedChapter >= threshold
+}
+
+export function buildOverview(store: ForeshadowingStore): {
+  total: number
+  active: number
+  resolved: number
+  abandoned: number
+  planted: number
+  advanced: number
+  avgPerChapter: number
+} {
+  const items = store.items
+  const active = items.filter((f) => isActiveForeshadowingStatus(f.status)).length
+  const resolved = items.filter((f) => f.status === "resolved").length
+  const abandoned = items.filter((f) => f.status === "abandoned").length
+  const planted = items.filter((f) => f.status === "planted").length
+  const advanced = items.filter((f) => f.status === "advanced").length
+  const chapters = new Set(items.map((f) => f.plantedChapter).filter((n) => n > 0))
+  const avgPerChapter = chapters.size > 0 ? items.length / chapters.size : 0
+  return {
+    total: items.length,
+    active,
+    resolved,
+    abandoned,
+    planted,
+    advanced,
+    avgPerChapter: Math.round(avgPerChapter * 10) / 10,
+  }
+}
+
+const DETECTOR_SYSTEM_PROMPT = `你是小说伏笔维护助手。你将收到一份伏笔列表(含 id、名称、说明、状态、埋设章节)。请找出三类问题:
+
+1. duplicate — 同一条线索被反复「新增」,只是措辞不同(例如「世界敌意值」「灰门」「SS-20销毁链」的多种表述)。每组至少 2 个 id。
+2. noise — 不是真正的伏笔:状态播报、剧情预告、数值倒计时、「为后续…埋下伏笔」类空话。应删除。
+3. stale — 是真正伏笔,但故事方向已变、长期未推进且不再有回收价值。应标记为已放弃(不是删除)。
+
+只输出有效 JSON,不要 markdown 代码块或解释:
+
+{
+  "issues": [
+    {
+      "kind": "duplicate",
+      "ids": ["F001", "F002"],
+      "canonicalId": "F001",
+      "reason": "中文原因",
+      "confidence": "high"
+    },
+    {
+      "kind": "noise",
+      "ids": ["F010"],
+      "reason": "中文原因",
+      "confidence": "medium"
+    },
+    {
+      "kind": "stale",
+      "ids": ["F020"],
+      "reason": "中文原因",
+      "confidence": "low"
+    }
+  ]
+}
+
+规则:
+- 只使用输入列表中存在的 id。
+- duplicate 的 ids 长度 ≥ 2,canonicalId 必须在 ids 中(选最早埋设或描述最完整的)。
+- noise / stale 的 ids 长度为 1。
+- 同一 id 只能出现在一个 issue 中;优先归入 duplicate,其次 noise,再次 stale。
+- 如果没有问题,输出 {"issues": []}。
+- reason 必须使用中文。
+- confidence: high / medium / low。`
+
+function buildBatchUserMessage(
+  summaries: ForeshadowingSummary[],
+  currentChapter: number,
+  batchIndex: number,
+  batchCount: number,
+): string {
+  const lines = summaries.map((s) => {
+    const adv =
+      s.advancedChapters.length > 0 ? ` advanced=[${s.advancedChapters.join(",")}]` : ""
+    const desc = s.description ? ` desc=${JSON.stringify(s.description.slice(0, 120))}` : ""
+    return `- id=${s.id} name=${JSON.stringify(s.name)} status=${s.status} planted=${s.plantedChapter}${adv}${desc}`
+  })
+  return [
+    `## 伏笔批次 ${batchIndex + 1}/${batchCount}(当前约第 ${currentChapter} 章,共 ${summaries.length} 条)`,
+    "",
+    "规则预筛提示(供参考,最终仍由你判断):",
+    `- 疑似 noise:名称/说明含「预示/暗示/将面临/为后续/埋下伏笔」等模板句式`,
+    `- 疑似 stale:status=planted、无 advanced、埋设已超过 ${STALE_PLANTED_CHAPTERS} 章`,
+    "",
+    ...lines,
+    "",
+    "只输出 JSON。",
+  ].join("\n")
+}
+
+function normalizeIssueKey(kind: CleanupIssueKind, ids: string[]): string {
+  return `${kind}:${[...ids].map((s) => s.toLowerCase()).sort().join(",")}`
+}
+
+export function keepKey(ids: string[]): string {
+  return [...ids].map((s) => s.toLowerCase()).sort().join(",")
+}
+
+function extractJsonObject(text: string): unknown | null {
+  const trimmed = text.trim()
+  const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)
+  const candidate = fence?.[1]?.trim() || trimmed
+  const start = candidate.indexOf("{")
+  if (start < 0) return null
+  let depth = 0
+  for (let i = start; i < candidate.length; i++) {
+    const ch = candidate[i]
+    if (ch === "{") depth++
+    else if (ch === "}") {
+      depth--
+      if (depth === 0) {
+        try {
+          return JSON.parse(candidate.slice(start, i + 1))
+        } catch {
+          return null
+        }
+      }
+    }
+  }
+  return null
+}
+
+function parseDetectorResponse(response: string): CleanupIssue[] {
+  const parsed = extractJsonObject(response)
+  if (!parsed || typeof parsed !== "object") return []
+  const issuesRaw = (parsed as { issues?: unknown }).issues
+  if (!Array.isArray(issuesRaw)) return []
+
+  const out: CleanupIssue[] = []
+  for (const raw of issuesRaw) {
+    if (!raw || typeof raw !== "object") continue
+    const obj = raw as Record<string, unknown>
+    const kind = obj.kind
+    if (kind !== "duplicate" && kind !== "noise" && kind !== "stale") continue
+    const ids = Array.isArray(obj.ids)
+      ? obj.ids.filter((s): s is string => typeof s === "string" && s.trim() !== "")
+      : []
+    if (kind === "duplicate" && ids.length < 2) continue
+    if ((kind === "noise" || kind === "stale") && ids.length !== 1) continue
+    const reason = typeof obj.reason === "string" ? obj.reason : ""
+    const confidence =
+      obj.confidence === "high" || obj.confidence === "medium" || obj.confidence === "low"
+        ? obj.confidence
+        : "low"
+    let canonicalId =
+      typeof obj.canonicalId === "string" ? obj.canonicalId : undefined
+    if (kind === "duplicate") {
+      if (!canonicalId || !ids.includes(canonicalId)) canonicalId = ids[0]
+    } else {
+      canonicalId = undefined
+    }
+    out.push({ kind, ids, canonicalId, reason, confidence })
+  }
+  return out
+}
+
+function validateAndFilterIssues(
+  issues: CleanupIssue[],
+  validIds: Set<string>,
+  keepKeys: Set<string>,
+): CleanupIssue[] {
+  const seenIds = new Set<string>()
+  const result: CleanupIssue[] = []
+
+  // Prefer duplicate > noise > stale when id conflicts
+  const order: CleanupIssueKind[] = ["duplicate", "noise", "stale"]
+  const sorted = [...issues].sort(
+    (a, b) => order.indexOf(a.kind) - order.indexOf(b.kind),
+  )
+
+  for (const issue of sorted) {
+    const ids = issue.ids.filter((id) => validIds.has(id) && !seenIds.has(id))
+    if (issue.kind === "duplicate" && ids.length < 2) continue
+    if ((issue.kind === "noise" || issue.kind === "stale") && ids.length !== 1) continue
+    if (keepKeys.has(keepKey(ids))) continue
+
+    for (const id of ids) seenIds.add(id)
+
+    result.push({
+      ...issue,
+      ids,
+      canonicalId:
+        issue.kind === "duplicate"
+          ? issue.canonicalId && ids.includes(issue.canonicalId)
+            ? issue.canonicalId
+            : ids[0]
+          : undefined,
+    })
+  }
+  return result
+}
+
+/**
+ * Rule-only candidates (used when LLM returns nothing, or for unit tests).
+ * Does not invent duplicate groups — only noise/stale from heuristics.
+ */
+export function ruleBasedCleanupIssues(
+  summaries: ForeshadowingSummary[],
+  currentChapter: number,
+  options: { keepKeys?: string[][] } = {},
+): CleanupIssue[] {
+  const keep = new Set((options.keepKeys ?? []).map((g) => keepKey(g)))
+  const issues: CleanupIssue[] = []
+  for (const item of summaries) {
+    if (!isActiveForeshadowingStatus(item.status)) continue
+    if (looksLikeNoise(item)) {
+      const ids = [item.id]
+      if (!keep.has(keepKey(ids))) {
+        issues.push({
+          kind: "noise",
+          ids,
+          reason: "规则:名称/说明像状态播报或剧情预告,不像可回收伏笔",
+          confidence: "medium",
+        })
+      }
+      continue
+    }
+    if (looksLikeStale(item, currentChapter)) {
+      const ids = [item.id]
+      if (!keep.has(keepKey(ids))) {
+        issues.push({
+          kind: "stale",
+          ids,
+          reason: `规则:已埋设超过 ${STALE_PLANTED_CHAPTERS} 章且从未推进`,
+          confidence: "low",
+        })
+      }
+    }
+  }
+  return issues
+}
+
+export interface CleanupBatchProgress {
+  /** 1-based current batch */
+  current: number
+  total: number
+  /** items in this batch */
+  batchSize: number
+  /** active foreshadowing count being scanned */
+  activeCount: number
+  phase: "batch_start" | "batch_done"
+}
+
+export async function detectCleanupIssues(
+  summaries: ForeshadowingSummary[],
+  currentChapter: number,
+  llmCall: CleanupLlmCall,
+  options: {
+    signal?: AbortSignal
+    keepKeys?: string[][]
+    batchSize?: number
+    onBatchProgress?: (progress: CleanupBatchProgress) => void
+  } = {},
+): Promise<CleanupIssue[]> {
+  const active = summaries.filter((s) => isActiveForeshadowingStatus(s.status))
+  if (active.length === 0) return []
+
+  const batchSize = options.batchSize ?? CLEANUP_BATCH_SIZE
+  const batches: ForeshadowingSummary[][] = []
+  for (let i = 0; i < active.length; i += batchSize) {
+    batches.push(active.slice(i, i + batchSize))
+  }
+
+  const allRaw: CleanupIssue[] = []
+  for (let i = 0; i < batches.length; i++) {
+    options.signal?.throwIfAborted()
+    options.onBatchProgress?.({
+      current: i + 1,
+      total: batches.length,
+      batchSize: batches[i].length,
+      activeCount: active.length,
+      phase: "batch_start",
+    })
+    const userMessage = buildBatchUserMessage(
+      batches[i],
+      currentChapter,
+      i,
+      batches.length,
+    )
+    const response = await llmCall(DETECTOR_SYSTEM_PROMPT, userMessage, options.signal)
+    allRaw.push(...parseDetectorResponse(response))
+    options.onBatchProgress?.({
+      current: i + 1,
+      total: batches.length,
+      batchSize: batches[i].length,
+      activeCount: active.length,
+      phase: "batch_done",
+    })
+  }
+
+  const validIds = new Set(active.map((s) => s.id))
+  const keep = new Set((options.keepKeys ?? []).map((g) => keepKey(g)))
+  const fromLlm = validateAndFilterIssues(allRaw, validIds, keep)
+
+  // Supplement with rule-based noise/stale not already covered
+  const covered = new Set(fromLlm.flatMap((i) => i.ids))
+  for (const ruleIssue of ruleBasedCleanupIssues(active, currentChapter, {
+    keepKeys: options.keepKeys,
+  })) {
+    if (ruleIssue.ids.every((id) => !covered.has(id))) {
+      fromLlm.push(ruleIssue)
+      for (const id of ruleIssue.ids) covered.add(id)
+    }
+  }
+
+  return fromLlm
+}
+
+/** Merge duplicate foreshadowings into the canonical item. Mutates store. */
+export function applyMergeIssue(
+  store: ForeshadowingStore,
+  issue: CleanupIssue,
+  canonicalId: string,
+): ForeshadowingStore {
+  if (issue.kind !== "duplicate") {
+    throw new Error(`applyMergeIssue expects duplicate, got ${issue.kind}`)
+  }
+  const canonical = store.items.find((f) => f.id === canonicalId)
+  if (!canonical) throw new Error(`Canonical foreshadowing ${canonicalId} not found`)
+
+  const others = issue.ids.filter((id) => id !== canonicalId)
+  for (const id of others) {
+    const other = store.items.find((f) => f.id === id)
+    if (!other) continue
+    canonical.plantedChapter = Math.min(canonical.plantedChapter, other.plantedChapter)
+    const adv = new Set([
+      ...(canonical.advancedChapters || []),
+      ...(other.advancedChapters || []),
+    ])
+    canonical.advancedChapters = [...adv].sort((a, b) => a - b)
+    if ((other.description || "").length > (canonical.description || "").length) {
+      canonical.description = other.description
+    }
+    if ((other.name || "").length < (canonical.name || "").length && other.name) {
+      // keep shorter name if more like a title — only when substantially shorter
+      if (other.name.length <= 18 && other.name.length + 6 < canonical.name.length) {
+        canonical.name = other.name
+      }
+    }
+    const chars = new Set([
+      ...(canonical.relatedCharacters || []),
+      ...(other.relatedCharacters || []),
+    ])
+    canonical.relatedCharacters = [...chars]
+    if (other.status === "advanced" && canonical.status === "planted") {
+      canonical.status = "advanced"
+    }
+    if (other.status === "resolved") {
+      canonical.status = "resolved"
+      canonical.resolvedChapter = other.resolvedChapter ?? canonical.resolvedChapter
+    }
+  }
+
+  const drop = new Set(others)
+  store.items = store.items.filter((f) => !drop.has(f.id))
+  store.lastUpdated = new Date().toISOString()
+  return store
+}
+
+export type CleanupApplyAction = "merge" | "delete" | "abandon"
+
+/** Default action for each issue kind. */
+export function defaultCleanupAction(kind: CleanupIssueKind): CleanupApplyAction {
+  if (kind === "duplicate") return "merge"
+  if (kind === "noise") return "delete"
+  return "abandon"
+}
+
+/** Delete the listed foreshadowing ids. Works for noise or "delete all" on duplicates. */
+export function applyDeleteIssue(
+  store: ForeshadowingStore,
+  issue: CleanupIssue,
+): ForeshadowingStore {
+  const drop = new Set(issue.ids)
+  store.items = store.items.filter((f) => !drop.has(f.id))
+  store.lastUpdated = new Date().toISOString()
+  return store
+}
+
+/** Mark listed items as abandoned. Mutates store. */
+export function applyAbandonIssue(
+  store: ForeshadowingStore,
+  issue: CleanupIssue,
+  options: { reason?: string; chapter?: number } = {},
+): ForeshadowingStore {
+  const noteParts = [
+    options.reason || issue.reason || "维护工具标记为已放弃",
+    options.chapter != null ? `(操作时约第${options.chapter}章)` : "",
+  ]
+  const note = noteParts.filter(Boolean).join(" ")
+  for (const id of issue.ids) {
+    const item = store.items.find((f) => f.id === id)
+    if (!item) continue
+    item.status = "abandoned"
+    item.notes = item.notes ? `${item.notes};${note}` : note
+  }
+  store.lastUpdated = new Date().toISOString()
+  return store
+}
+
+/**
+ * One-shot bulk cleanup: delete noise ids, abandon stale ids.
+ * Prefer this over N queue tasks — one backup / one write.
+ */
+export function applyBulkDeleteAndAbandon(
+  store: ForeshadowingStore,
+  options: {
+    deleteIds: readonly string[]
+    abandonIds: readonly string[]
+    reason?: string
+    chapter?: number
+  },
+): { deleted: number; abandoned: number } {
+  const deleteSet = new Set(options.deleteIds)
+  const abandonSet = new Set(options.abandonIds.filter((id) => !deleteSet.has(id)))
+  let deleted = 0
+  let abandoned = 0
+
+  if (deleteSet.size > 0) {
+    const before = store.items.length
+    store.items = store.items.filter((f) => !deleteSet.has(f.id))
+    deleted = before - store.items.length
+  }
+
+  if (abandonSet.size > 0) {
+    const noteParts = [
+      options.reason || "一键清理:标记为已放弃",
+      options.chapter != null ? `(操作时约第${options.chapter}章)` : "",
+    ]
+    const note = noteParts.filter(Boolean).join(" ")
+    for (const item of store.items) {
+      if (!abandonSet.has(item.id)) continue
+      if (item.status === "abandoned") continue
+      item.status = "abandoned"
+      item.notes = item.notes ? `${item.notes};${note}` : note
+      abandoned++
+    }
+  }
+
+  if (deleted > 0 || abandoned > 0) {
+    store.lastUpdated = new Date().toISOString()
+  }
+  return { deleted, abandoned }
+}
+
+export function applyCleanupIssue(
+  store: ForeshadowingStore,
+  issue: CleanupIssue,
+  options: {
+    canonicalId?: string
+    reason?: string
+    chapter?: number
+    action?: CleanupApplyAction
+  } = {},
+): ForeshadowingStore {
+  const action = options.action ?? defaultCleanupAction(issue.kind)
+  if (action === "delete") {
+    return applyDeleteIssue(store, issue)
+  }
+  if (action === "abandon") {
+    return applyAbandonIssue(store, issue, options)
+  }
+  // merge
+  if (issue.kind !== "duplicate") {
+    throw new Error(`merge action requires duplicate issue, got ${issue.kind}`)
+  }
+  const canonicalId = options.canonicalId || issue.canonicalId || issue.ids[0]
+  return applyMergeIssue(store, issue, canonicalId)
+}
+
+/** Stable key for matching queue tasks to UI cards (ids + kind). */
+export function cleanupIssueKey(issue: CleanupIssue): string {
+  return normalizeIssueKey(issue.kind, issue.ids)
+}
+
+/** Queue identity: same ids can be merge or delete-all. */
+export function cleanupTaskKey(
+  issue: CleanupIssue,
+  action?: CleanupApplyAction,
+): string {
+  return `${action ?? defaultCleanupAction(issue.kind)}:${cleanupIssueKey(issue)}`
+}

+ 72 - 67
src/lib/novel/chapter-ingest.ts

@@ -12,7 +12,18 @@ import { resolveNovelModel } from "./model-resolver"
 import { emptyCognitionState, mergeCognitionFromSnapshot, loadCognitionState, saveCognitionState } from "./character-cognition"
 import { createEmptyCharacterStateStore, loadCharacterStates, saveCharacterStates, type CharacterStateStore } from "./character-state"
 import { updateTrackingAfterChapter } from "./tracking-updater"
-import { createEmptyForeshadowingStore, loadForeshadowingTracker, saveForeshadowingTracker, type Foreshadowing, type ForeshadowingStore } from "./foreshadowing-tracker"
+import {
+  createEmptyForeshadowingStore,
+  generateForeshadowingId,
+  loadForeshadowingTracker,
+  saveForeshadowingTracker,
+  type Foreshadowing,
+  type ForeshadowingStore,
+} from "./foreshadowing-tracker"
+import {
+  findForeshadowingByNormalizedName,
+  parseForeshadowingChange,
+} from "./foreshadowing-normalize"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { shouldRebuildCommunitySummaries, generateCommunitySummaries } from "./community-summary"
 import { buildChapterIngestOutput, type ChapterIngestOutput } from "./chapter-ingest-output"
@@ -451,48 +462,7 @@ export async function ingestChapter(
   if (snapshot && snapshot.foreshadowingChanges.length > 0) {
     try {
       const existingForeshadows = await loadForeshadowingTracker(pp)
-      for (const change of snapshot.foreshadowingChanges) {
-        const trimmed = change.trim()
-        if (trimmed.startsWith("新增伏笔") || trimmed.startsWith("新增:")) {
-          const content = trimmed.replace(/^(新增伏笔|新增)[::]?\s*/, "")
-          const dashIdx = content.indexOf("-")
-          const name = dashIdx > 0 ? content.slice(0, dashIdx).trim() : content.trim()
-          const desc = dashIdx > 0 ? content.slice(dashIdx + 1).trim() : ""
-          const newForeshadow: Foreshadowing = {
-            id: `fs-${snapshot.chapterNumber}-${existingForeshadows.items.length + 1}`,
-            name,
-            description: desc,
-            status: "planted",
-            plantedChapter: snapshot.chapterNumber,
-            advancedChapters: [],
-            relatedCharacters: [],
-            relatedEvents: [],
-            notes: "",
-          }
-          existingForeshadows.items.push(newForeshadow)
-        } else if (trimmed.startsWith("推进伏笔") || trimmed.startsWith("推进:")) {
-          const content = trimmed.replace(/^(推进伏笔|推进)[::]?\s*/, "").trim()
-          const matched = existingForeshadows.items.find(
-            f => f.name === content || content.includes(f.name) || f.name.includes(content)
-          )
-          if (matched) {
-            matched.status = "advanced"
-            if (!matched.advancedChapters.includes(snapshot.chapterNumber)) {
-              matched.advancedChapters.push(snapshot.chapterNumber)
-            }
-          }
-        } else if (trimmed.startsWith("回收伏笔") || trimmed.startsWith("回收:")) {
-          const content = trimmed.replace(/^(回收伏笔|回收)[::]?\s*/, "").trim()
-          const matched = existingForeshadows.items.find(
-            f => f.name === content || content.includes(f.name) || f.name.includes(content)
-          )
-          if (matched) {
-            matched.status = "resolved"
-            matched.resolvedChapter = snapshot.chapterNumber
-          }
-        }
-      }
-      existingForeshadows.lastUpdated = new Date().toISOString()
+      applyForeshadowingChangesToStore(existingForeshadows, snapshot)
       await saveForeshadowingTracker(pp, existingForeshadows)
     } catch (err) {
       console.warn("[Chapter Ingest] Foreshadowing update failed:", err instanceof Error ? err.message : err)
@@ -1338,18 +1308,34 @@ async function syncCharacterStateChanges(projectPath: string, snapshot: ChapterS
   await saveCharacterStates(projectPath, existingChars)
 }
 
-function applyForeshadowingChangesToStore(existingForeshadows: ForeshadowingStore, snapshot: ChapterSnapshot): ForeshadowingStore {
+export function applyForeshadowingChangesToStore(
+  existingForeshadows: ForeshadowingStore,
+  snapshot: ChapterSnapshot,
+): ForeshadowingStore {
   for (const change of snapshot.foreshadowingChanges) {
-    const trimmed = change.trim()
-    if (trimmed.startsWith("新增伏笔") || trimmed.startsWith("新增:")) {
-      const content = trimmed.replace(/^(新增伏笔|新增)[::]?\s*/, "")
-      const dashIdx = content.indexOf("-")
-      const name = dashIdx > 0 ? content.slice(0, dashIdx).trim() : content.trim()
-      const desc = dashIdx > 0 ? content.slice(dashIdx + 1).trim() : ""
+    const parsed = parseForeshadowingChange(change)
+    if (!parsed) continue
+
+    const matched = findForeshadowingByNormalizedName(existingForeshadows.items, parsed.name)
+
+    if (parsed.kind === "plant") {
+      if (matched) {
+        // Same normalized name already exists → treat as advance (ingest-side dedup)
+        if (matched.status !== "resolved" && matched.status !== "abandoned") {
+          matched.status = "advanced"
+        }
+        if (!matched.advancedChapters.includes(snapshot.chapterNumber)) {
+          matched.advancedChapters.push(snapshot.chapterNumber)
+        }
+        if (parsed.description && parsed.description.length > (matched.description?.length ?? 0)) {
+          matched.description = parsed.description
+        }
+        continue
+      }
       const newForeshadow: Foreshadowing = {
-        id: `fs-${snapshot.chapterNumber}-${existingForeshadows.items.length + 1}`,
-        name,
-        description: desc,
+        id: generateForeshadowingId(existingForeshadows),
+        name: parsed.name,
+        description: parsed.description,
         status: "planted",
         plantedChapter: snapshot.chapterNumber,
         advancedChapters: [],
@@ -1358,26 +1344,45 @@ function applyForeshadowingChangesToStore(existingForeshadows: ForeshadowingStor
         notes: "",
       }
       existingForeshadows.items.push(newForeshadow)
-    } else if (trimmed.startsWith("推进伏笔") || trimmed.startsWith("推进:")) {
-      const content = trimmed.replace(/^(推进伏笔|推进)[::]?\s*/, "").trim()
-      const matched = existingForeshadows.items.find(
-        f => f.name === content || content.includes(f.name) || f.name.includes(content)
-      )
+      continue
+    }
+
+    if (parsed.kind === "advance") {
       if (matched) {
-        matched.status = "advanced"
+        if (matched.status !== "resolved" && matched.status !== "abandoned") {
+          matched.status = "advanced"
+        }
         if (!matched.advancedChapters.includes(snapshot.chapterNumber)) {
           matched.advancedChapters.push(snapshot.chapterNumber)
         }
+        if (parsed.description && parsed.description.length > (matched.description?.length ?? 0)) {
+          matched.description = parsed.description
+        }
       }
-    } else if (trimmed.startsWith("回收伏笔") || trimmed.startsWith("回收:")) {
-      const content = trimmed.replace(/^(回收伏笔|回收)[::]?\s*/, "").trim()
-      const matched = existingForeshadows.items.find(
-        f => f.name === content || content.includes(f.name) || f.name.includes(content)
-      )
-      if (matched) {
-        matched.status = "resolved"
-        matched.resolvedChapter = snapshot.chapterNumber
+      continue
+    }
+
+    // resolve — if no match, still record as resolved (same as memory-rebuild:
+    // resolve lines often use different wording than the original plant)
+    if (matched) {
+      matched.status = "resolved"
+      matched.resolvedChapter = snapshot.chapterNumber
+      if (parsed.description && parsed.description.length > (matched.description?.length ?? 0)) {
+        matched.description = parsed.description
       }
+    } else {
+      existingForeshadows.items.push({
+        id: generateForeshadowingId(existingForeshadows),
+        name: parsed.name,
+        description: parsed.description,
+        status: "resolved",
+        plantedChapter: snapshot.chapterNumber,
+        advancedChapters: [],
+        resolvedChapter: snapshot.chapterNumber,
+        relatedCharacters: [],
+        relatedEvents: [],
+        notes: "",
+      })
     }
   }
   existingForeshadows.lastUpdated = new Date().toISOString()

+ 4 - 2
src/lib/novel/foreshadowing-debt.ts

@@ -4,7 +4,7 @@ export interface ForeshadowingDebtItem {
   id: string
   name: string
   description: string
-  status: "planted" | "advanced" | "resolved"
+  status: "planted" | "advanced" | "resolved" | "abandoned"
   plantedChapter: number
   lastAdvancedChapter?: number
   chaptersSincePlanted: number
@@ -40,7 +40,9 @@ export function analyzeForeshadowingDebt(
   const advancedStale = options?.advancedStale ?? DEFAULT_ADVANCED_STALE
   const densityLimit = options?.densityLimit ?? DEFAULT_DENSITY_LIMIT
 
-  const unresolved = store.items.filter((item) => item.status !== "resolved")
+  const unresolved = store.items.filter(
+    (item) => item.status !== "resolved" && item.status !== "abandoned",
+  )
 
   const items: ForeshadowingDebtItem[] = unresolved.map((item) => {
     const chaptersSincePlanted = currentChapter - item.plantedChapter

+ 105 - 0
src/lib/novel/foreshadowing-ingest.spec.ts

@@ -0,0 +1,105 @@
+import { describe, expect, it } from "vitest"
+import { applyForeshadowingChangesToStore } from "./chapter-ingest"
+import { createEmptyForeshadowingStore } from "./foreshadowing-tracker"
+import type { ChapterSnapshot } from "./chapter-ingest"
+
+function snap(
+  chapterNumber: number,
+  foreshadowingChanges: string[],
+): ChapterSnapshot {
+  return {
+    chapterId: `ch-${chapterNumber}`,
+    chapterNumber,
+    summary: "",
+    characters: [],
+    locations: [],
+    organizations: [],
+    items: [],
+    events: [],
+    characterStateChanges: [],
+    relationshipChanges: [],
+    knowledgeChanges: [],
+    foreshadowingChanges,
+    newCanonFacts: [],
+    timelineEvents: [],
+    conflicts: [],
+    endingHook: "",
+    graphNodes: [],
+    graphEdges: [],
+  }
+}
+
+describe("applyForeshadowingChangesToStore", () => {
+  it("plants with full-width colon and normalized name", () => {
+    const store = createEmptyForeshadowingStore()
+    applyForeshadowingChangesToStore(
+      store,
+      snap(2, ["新增:苏式来源疑云成为美苏双方追查的核心伏笔。"]),
+    )
+    expect(store.items).toHaveLength(1)
+    expect(store.items[0].status).toBe("planted")
+    expect(store.items[0].name.length).toBeLessThanOrEqual(18)
+    expect(store.items[0].id).toMatch(/^F\d+$/)
+    expect(store.items[0].description).toBeTruthy()
+  })
+
+  it("advances and resolves by normalized name match", () => {
+    const store = createEmptyForeshadowingStore()
+    applyForeshadowingChangesToStore(
+      store,
+      snap(1, ["新增伏笔:灰门与旧网联络链浮出水面但未追至源头"]),
+    )
+    const name = store.items[0].name
+    applyForeshadowingChangesToStore(
+      store,
+      snap(5, [`推进伏笔:${name}仍未断`]),
+    )
+    expect(store.items).toHaveLength(1)
+    expect(store.items[0].status).toBe("advanced")
+    expect(store.items[0].advancedChapters).toContain(5)
+
+    applyForeshadowingChangesToStore(store, snap(10, [`回收:${name}`]))
+    expect(store.items[0].status).toBe("resolved")
+    expect(store.items[0].resolvedChapter).toBe(10)
+  })
+
+  it("turns duplicate plant into advance (ingest-side dedup)", () => {
+    const store = createEmptyForeshadowingStore()
+    applyForeshadowingChangesToStore(
+      store,
+      snap(1, ["新增伏笔:世界敌意值上升预示危机"]),
+    )
+    applyForeshadowingChangesToStore(
+      store,
+      snap(3, ["新增伏笔:世界敌意值上升预示危机加剧"]),
+    )
+    // Same normalized name prefix → should not create second item
+    expect(store.items.length).toBe(1)
+    expect(store.items[0].status).toBe("advanced")
+    expect(store.items[0].advancedChapters).toContain(3)
+  })
+
+  it("does not drop no-prefix advance lines", () => {
+    const store = createEmptyForeshadowingStore()
+    applyForeshadowingChangesToStore(
+      store,
+      snap(1, ["新增伏笔:底格里斯神经一期建设"]),
+    )
+    applyForeshadowingChangesToStore(
+      store,
+      snap(12, ["底格里斯神经一期建设启动,为后续战场指挥融合埋下伏笔。"]),
+    )
+    expect(store.items[0].advancedChapters).toContain(12)
+  })
+
+  it("records unmatched resolve as a resolved entry", () => {
+    const store = createEmptyForeshadowingStore()
+    applyForeshadowingChangesToStore(
+      store,
+      snap(14, ["回收:灰门内奸伏笔,阿德南·哈利勒被清除"]),
+    )
+    expect(store.items).toHaveLength(1)
+    expect(store.items[0].status).toBe("resolved")
+    expect(store.items[0].resolvedChapter).toBe(14)
+  })
+})

+ 99 - 0
src/lib/novel/foreshadowing-normalize.spec.ts

@@ -0,0 +1,99 @@
+import { describe, expect, it } from "vitest"
+import {
+  findForeshadowingByNormalizedName,
+  isActiveForeshadowingStatus,
+  normalizeForeshadowingName,
+  parseForeshadowingChange,
+} from "./foreshadowing-normalize"
+
+describe("parseForeshadowingChange", () => {
+  it("parses full-width and half-width colons", () => {
+    const full = parseForeshadowingChange("新增:苏式来源疑云成为美苏双方追查的核心伏笔。")
+    expect(full?.kind).toBe("plant")
+    expect(full?.name).toBeTruthy()
+
+    const half = parseForeshadowingChange("新增:外部人员可能通过车辙追踪苏式阵地")
+    expect(half?.kind).toBe("plant")
+  })
+
+  it("parses with and without 伏笔 suffix", () => {
+    const withWord = parseForeshadowingChange("推进伏笔:灰门仍未断")
+    expect(withWord?.kind).toBe("advance")
+    expect(withWord?.name).toContain("灰门")
+
+    const without = parseForeshadowingChange("推进:世界敌意值上升")
+    expect(without?.kind).toBe("advance")
+  })
+
+  it("parses resolve prefixes", () => {
+    const a = parseForeshadowingChange("回收伏笔:莱拉的真实本质")
+    expect(a?.kind).toBe("resolve")
+    const b = parseForeshadowingChange("回收:苏式来源疑云")
+    expect(b?.kind).toBe("resolve")
+  })
+
+  it("treats no-prefix lines as advance instead of dropping", () => {
+    const parsed = parseForeshadowingChange(
+      "底格里斯神经一期建设启动,为后续战场指挥融合埋下伏笔。",
+    )
+    expect(parsed?.kind).toBe("advance")
+    expect(parsed?.name).toBeTruthy()
+  })
+
+  it("returns null for empty input", () => {
+    expect(parseForeshadowingChange("   ")).toBeNull()
+  })
+})
+
+describe("normalizeForeshadowingName", () => {
+  it("prefers quoted names", () => {
+    const { name } = normalizeForeshadowingName('推进伏笔:代号“灰门”与旧网联络链浮出水面')
+    expect(name).toBe("灰门")
+  })
+
+  it("truncates long names to 18 chars", () => {
+    const long =
+      "科威特措辞与经贸附件适用范围待元首会谈前外交渠道正式答复并且还要继续写很长很长"
+    const { name } = normalizeForeshadowingName(`新增伏笔:${long}`)
+    expect(name.length).toBeLessThanOrEqual(18)
+  })
+
+  it("splits on keyword or punctuation for name/description", () => {
+    const { name, description } = normalizeForeshadowingName(
+      "新增伏笔:世界敌意值上升,预示非常规渗透事件即将触发",
+    )
+    // keyword split on「预示」wins before punctuation cleanup
+    expect(name.startsWith("世界敌意值上升")).toBe(true)
+    expect(name.length).toBeLessThanOrEqual(18)
+    expect(description.length).toBeGreaterThan(name.length)
+  })
+})
+
+describe("findForeshadowingByNormalizedName", () => {
+  const items = [
+    { id: "1", name: "世界敌意值上升" },
+    { id: "2", name: "灰门" },
+    { id: "3", name: "短" },
+  ]
+
+  it("matches exact name", () => {
+    expect(findForeshadowingByNormalizedName(items, "灰门")?.id).toBe("2")
+  })
+
+  it("matches prefix when both names are long enough", () => {
+    expect(findForeshadowingByNormalizedName(items, "世界敌意值上升预示")?.id).toBe("1")
+  })
+
+  it("does not use short bidirectional includes", () => {
+    expect(findForeshadowingByNormalizedName(items, "短名扩展很多字")).toBeUndefined()
+  })
+})
+
+describe("isActiveForeshadowingStatus", () => {
+  it("excludes resolved and abandoned", () => {
+    expect(isActiveForeshadowingStatus("planted")).toBe(true)
+    expect(isActiveForeshadowingStatus("advanced")).toBe(true)
+    expect(isActiveForeshadowingStatus("resolved")).toBe(false)
+    expect(isActiveForeshadowingStatus("abandoned")).toBe(false)
+  })
+})

+ 120 - 0
src/lib/novel/foreshadowing-normalize.ts

@@ -0,0 +1,120 @@
+/**
+ * Shared foreshadowing change parsing and name normalization.
+ * Used by chapter ingest, memory rebuild, and cleanup tools.
+ */
+
+export type ForeshadowingChangeKind = "plant" | "advance" | "resolve"
+
+export interface ParsedForeshadowingChange {
+  kind: ForeshadowingChangeKind
+  name: string
+  description: string
+}
+
+/** 兼容 新增/推进/回收 + 可选「伏笔」二字 + 全角/半角冒号 + 无冒号 */
+const PREFIX_RE = /^(新增|推进|回收)(伏笔)?[::\s-]*/u
+
+const KIND_BY_PREFIX: Record<string, ForeshadowingChangeKind> = {
+  新增: "plant",
+  推进: "advance",
+  回收: "resolve",
+}
+
+/**
+ * Strip action prefixes and derive a short name + full description.
+ * Order: quoted name → keyword split → punctuation split → slice(0, 18).
+ */
+export function normalizeForeshadowingName(text: string): { name: string; description: string } {
+  const cleaned = text
+    .trim()
+    .replace(/^(新增伏笔|推进伏笔|回收伏笔|新增|推进|回收)[::\s-]*/u, "")
+    .trim()
+
+  if (!cleaned) {
+    return { name: "", description: "" }
+  }
+
+  const quoted = cleaned.match(/[“"']([^“”"']{1,24})[”"']/u)
+  if (quoted?.[1]) {
+    const name = quoted[1].trim().slice(0, 18)
+    const description =
+      cleaned.replace(quoted[0], "").replace(/^[,。;::、\-\s]+/u, "").trim() || text.trim()
+    return { name, description }
+  }
+
+  const keywordSplit = cleaned
+    .split(/为何|并非|不仅是|存在|成为|将成|将|会|正在|开始|继续|揭示|预示|说明|意味着|指向|却能|不承认/u)
+    .map((item) => item.trim())
+    .filter(Boolean)
+  if (keywordSplit.length >= 2) {
+    return { name: keywordSplit[0].slice(0, 18), description: cleaned }
+  }
+
+  const punctuationSplit = cleaned.split(/[,。;::?!]/u).map((item) => item.trim()).filter(Boolean)
+  if (punctuationSplit.length >= 2) {
+    return { name: punctuationSplit[0].slice(0, 18), description: cleaned }
+  }
+
+  return { name: cleaned.slice(0, 18), description: cleaned }
+}
+
+/**
+ * Parse a foreshadowing change line from a chapter snapshot.
+ * Lines without a recognized prefix are treated as `advance`
+ * (empirically they are progressive descriptions, not new plants).
+ */
+export function parseForeshadowingChange(raw: string): ParsedForeshadowingChange | null {
+  const trimmed = raw.trim()
+  if (!trimmed) return null
+
+  const match = trimmed.match(PREFIX_RE)
+  let kind: ForeshadowingChangeKind
+  let rest: string
+
+  if (match) {
+    const verb = match[1]
+    kind = KIND_BY_PREFIX[verb] ?? "advance"
+    rest = trimmed.slice(match[0].length).trim()
+  } else {
+    // No prefix → treat as advance (do not silently drop)
+    kind = "advance"
+    rest = trimmed
+  }
+
+  if (!rest) return null
+
+  const { name, description } = normalizeForeshadowingName(
+    match ? `${match[0]}${rest}` : rest,
+  )
+  if (!name) return null
+
+  return { kind, name, description: description || rest }
+}
+
+/**
+ * Match an existing foreshadowing item by normalized name.
+ * Exact match first; then prefix match when both names are ≥6 chars.
+ */
+export function findForeshadowingByNormalizedName<T extends { name: string }>(
+  items: readonly T[],
+  queryName: string,
+): T | undefined {
+  const needle = queryName.trim()
+  if (!needle) return undefined
+
+  const exact = items.find((f) => f.name === needle)
+  if (exact) return exact
+
+  if (needle.length < 6) return undefined
+
+  return items.find((f) => {
+    const existing = f.name.trim()
+    if (existing.length < 6) return false
+    return existing.startsWith(needle) || needle.startsWith(existing)
+  })
+}
+
+/** Active = not resolved and not abandoned. */
+export function isActiveForeshadowingStatus(status: string): boolean {
+  return status !== "resolved" && status !== "abandoned"
+}

+ 4 - 2
src/lib/novel/foreshadowing-tracker.ts

@@ -57,7 +57,9 @@ export async function loadForeshadowingTracker(
 }
 
 export function foreshadowingToContextText(store: ForeshadowingStore): string {
-  const unresolved = store.items.filter((f) => f.status !== "resolved")
+  const unresolved = store.items.filter(
+    (f) => f.status !== "resolved" && f.status !== "abandoned",
+  )
   if (unresolved.length === 0) return ""
   return unresolved
     .map(
@@ -97,7 +99,7 @@ export function markForeshadowingAdvanced(
   foreshadowing: Foreshadowing,
   chapter: number,
 ): void {
-  if (foreshadowing.status === "resolved") return
+  if (foreshadowing.status === "resolved" || foreshadowing.status === "abandoned") return
   foreshadowing.status = "advanced"
   if (!foreshadowing.advancedChapters.includes(chapter)) {
     foreshadowing.advancedChapters.push(chapter)

+ 1 - 28
src/lib/novel/memory-rebuild.ts

@@ -1,4 +1,5 @@
 import type { ChapterSnapshot } from "./chapter-ingest"
+import { normalizeForeshadowingName } from "./foreshadowing-normalize"
 
 const UNCERTAIN_RE = /(可能|也许|似乎|疑似|或许|大概|推测|猜测|尚不确定|未证实)/u
 const PUNCTUATION_RE = /[,。;:?!“”‘’()《》【】<>]/u
@@ -71,34 +72,6 @@ function appendCandidateSection(lines: string[], candidates: string[]): void {
   lines.push("")
 }
 
-function normalizeForeshadowingName(text: string): { name: string; description: string } {
-  const cleaned = text
-    .trim()
-    .replace(/^(新增伏笔|推进伏笔|回收伏笔|新增|推进|回收)[::\s-]*/u, "")
-    .trim()
-
-  const quoted = cleaned.match(/[“"']([^“”"']{1,24})[”"']/u)
-  if (quoted?.[1]) {
-    const name = quoted[1].trim().slice(0, 18)
-    const description = cleaned.replace(quoted[0], "").replace(/^[,。;::、\-\s]+/u, "").trim() || text.trim()
-    return { name, description }
-  }
-
-  const keywordSplit = cleaned.split(/为何|并非|不仅是|存在|成为|将成|将|会|正在|开始|继续|揭示|预示|说明|意味着|指向|却能|不承认/u)
-    .map((item) => item.trim())
-    .filter(Boolean)
-  if (keywordSplit.length >= 2) {
-    return { name: keywordSplit[0].slice(0, 18), description: cleaned }
-  }
-
-  const punctuationSplit = cleaned.split(/[,。;::?!]/u).map((item) => item.trim()).filter(Boolean)
-  if (punctuationSplit.length >= 2) {
-    return { name: punctuationSplit[0].slice(0, 18), description: cleaned }
-  }
-
-  return { name: cleaned.slice(0, 18), description: cleaned }
-}
-
 function parseSubjectChange(text: string): { subject: string; detail: string } | null {
   const normalized = text.replace(/[::]/u, ":")
   const index = normalized.indexOf(":")

+ 2 - 0
src/lib/novel/section-briefing.ts

@@ -123,6 +123,7 @@ export async function buildSectionBriefing(
   const foreshadowingHints = extractForeshadowingHints(trimmedOutline)
 
   const relevantForeshadowing = fStore.items.filter((f) => {
+    if (f.status === "abandoned") return false
     // 细纲中明确提到了该伏笔的描述
     if (foreshadowingHints.some((hint) => f.description.includes(hint) || hint.includes(f.description.slice(0, 10)))) {
       return true
@@ -156,6 +157,7 @@ export async function buildSectionBriefing(
       const statusLabel =
         f.status === "resolved" ? "已回收"
         : f.status === "advanced" ? "推进中"
+        : f.status === "abandoned" ? "已放弃"
         : "已埋设"
 
       sections.push(

+ 57 - 5
src/lib/novel/tracking-files.ts

@@ -137,7 +137,8 @@ function serializeForeshadowingMd(
   items: Foreshadowing[],
   resolved: ResolvedForeshadowingRecord[],
 ): string {
-  const active = items.filter((f) => f.status !== "resolved")
+  const active = items.filter((f) => f.status !== "resolved" && f.status !== "abandoned")
+  const abandoned = items.filter((f) => f.status === "abandoned")
   const resolvedItems = resolved.length > 0
     ? resolved
     : items.filter((f) => f.status === "resolved").map((f) => ({
@@ -146,6 +147,13 @@ function serializeForeshadowingMd(
         resolution: `伏笔「${f.name}」在第${f.resolvedChapter}章回收`,
       }))
 
+  const statusLabelOf = (status: string): string => {
+    if (status === "planted") return "已埋设"
+    if (status === "advanced") return "推进中"
+    if (status === "abandoned") return "已放弃"
+    return "推进中"
+  }
+
   const lines: string[] = [
     "# 伏笔追踪",
     "",
@@ -156,11 +164,12 @@ function serializeForeshadowingMd(
 
   for (const f of active) {
     const importance = f.importance ?? "medium"
-    const statusLabel = f.status === "planted" ? "已埋设" : "推进中"
+    const statusLabel = statusLabelOf(f.status)
     const expectedChapter = f.expectedResolveChapter ? `第${f.expectedResolveChapter}章` : "待定"
     const relatedChars = (f.relatedCharacters || []).join("、")
+    const content = f.description || f.name
     lines.push(
-      `| ${f.id} | ${f.description} | 第${f.plantedChapter}章 | ${expectedChapter} | ${statusLabel} | ${importance} | ${relatedChars} | ${f.notes} |`,
+      `| ${f.id} | ${content} | 第${f.plantedChapter}章 | ${expectedChapter} | ${statusLabel} | ${importance} | ${relatedChars} | ${f.notes} |`,
     )
   }
 
@@ -169,7 +178,14 @@ function serializeForeshadowingMd(
   for (const r of resolvedItems) {
     const f = items.find((fi) => fi.id === r.id)
     const plantedChapter = f?.plantedChapter ?? "?"
-    lines.push(`| ${r.id} | ${f?.description ?? ""} | 第${plantedChapter}章 | 第${r.resolvedInChapter}章 | ${r.resolution} |`)
+    const content = f?.description || f?.name || ""
+    lines.push(`| ${r.id} | ${content} | 第${plantedChapter}章 | 第${r.resolvedInChapter}章 | ${r.resolution} |`)
+  }
+
+  lines.push("", "## 已放弃伏笔", "| ID | 伏笔内容 | 埋设章节 | 状态 | 备注 |", "|---|---|---|---|---|")
+  for (const f of abandoned) {
+    const content = f.description || f.name
+    lines.push(`| ${f.id} | ${content} | 第${f.plantedChapter}章 | 已放弃 | ${f.notes} |`)
   }
 
   return lines.join("\n")
@@ -182,20 +198,30 @@ function parseForeshadowingMd(content: string, existingStore: ForeshadowingStore
   const lines = content.split("\n")
   let inActive = false
   let inResolved = false
-  const statusMap: Record<string, "planted" | "advanced" | "resolved"> = {
+  const statusMap: Record<string, "planted" | "advanced" | "resolved" | "abandoned"> = {
     "已埋设": "planted",
     "推进中": "advanced",
+    "已放弃": "abandoned",
   }
+  let inAbandoned = false
 
   for (const line of lines) {
     if (line.startsWith("## 活跃伏笔")) {
       inActive = true
       inResolved = false
+      inAbandoned = false
       continue
     }
     if (line.startsWith("## 已回收伏笔")) {
       inActive = false
       inResolved = true
+      inAbandoned = false
+      continue
+    }
+    if (line.startsWith("## 已放弃伏笔")) {
+      inActive = false
+      inResolved = false
+      inAbandoned = true
       continue
     }
 
@@ -246,6 +272,32 @@ function parseForeshadowingMd(content: string, existingStore: ForeshadowingStore
         }
       }
     }
+
+    if (inAbandoned && line.startsWith("|") && !line.startsWith("|---")) {
+      const parts = line.split("|").map((p) => p.trim()).filter(Boolean)
+      if (parts.length >= 4 && parts[0] !== "ID") {
+        const id = parts[0]
+        const existing = existingStore.items.find((f) => f.id === id)
+        const item: Foreshadowing = existing
+          ? { ...existing, status: "abandoned" }
+          : {
+              id,
+              name: (parts[1] || "").slice(0, 20),
+              description: parts[1] || "",
+              status: "abandoned",
+              plantedChapter: 1,
+              advancedChapters: [],
+              relatedCharacters: [],
+              relatedEvents: [],
+              notes: parts[4] || "",
+            }
+        const chapterMatch = parts[2]?.match(/(\d+)/)
+        if (chapterMatch) item.plantedChapter = parseInt(chapterMatch[1], 10)
+        if (parts[4]) item.notes = parts[4]
+        item.status = "abandoned"
+        if (!items.some((f) => f.id === id)) items.push(item)
+      }
+    }
   }
 
   // 将已回收但不在活跃表中的伏笔标记为resolved

+ 1 - 1
src/lib/novel/tracking-types.ts

@@ -32,7 +32,7 @@ export interface EnhancedCharacterState {
 export type ForeshadowingImportance = "high" | "medium" | "low"
 
 /** 伏笔状态 */
-export type ForeshadowingStatus = "planted" | "advanced" | "resolved"
+export type ForeshadowingStatus = "planted" | "advanced" | "resolved" | "abandoned"
 
 /** 升级后的伏笔 */
 export interface EnhancedForeshadowing {

+ 3 - 1
src/lib/novel/tracking-updater.ts

@@ -78,7 +78,9 @@ export async function updateTrackingAfterChapter(
 
   // 5. 构建写作进度并写入 上下文.md
   try {
-    const activeForeshadowing = fStore.items.filter((f) => f.status !== "resolved")
+    const activeForeshadowing = fStore.items.filter(
+      (f) => f.status !== "resolved" && f.status !== "abandoned",
+    )
     const progress: WritingProgress = {
       lastCompletedChapter: chapterNumber,
       lastCompletedChapterTitle: chapterTitle,

+ 21 - 6
src/lib/reset-project-state.ts

@@ -45,12 +45,14 @@ export function resetProjectStores(): void {
 export async function resetProjectState(): Promise<void> {
   resetProjectStores()
 
-  const [dedupQueueMod, graphMod, fileSyncMod, scheduledImportMod] = await Promise.allSettled([
-    import("@/lib/dedup-queue"),
-    import("@/lib/graph-relevance"),
-    import("@/lib/project-file-sync"),
-    import("@/lib/scheduled-import"),
-  ])
+  const [dedupQueueMod, foreshadowingCleanupQueueMod, graphMod, fileSyncMod, scheduledImportMod] =
+    await Promise.allSettled([
+      import("@/lib/dedup-queue"),
+      import("@/lib/foreshadowing-cleanup-queue"),
+      import("@/lib/graph-relevance"),
+      import("@/lib/project-file-sync"),
+      import("@/lib/scheduled-import"),
+    ])
 
   if (scheduledImportMod.status === "fulfilled") {
     try {
@@ -79,6 +81,19 @@ export async function resetProjectState(): Promise<void> {
     console.warn("[Reset Project State] Failed to load dedup-queue:", dedupQueueMod.reason)
   }
 
+  if (foreshadowingCleanupQueueMod.status === "fulfilled") {
+    try {
+      await foreshadowingCleanupQueueMod.value.pauseForeshadowingCleanupQueue()
+    } catch (err) {
+      console.warn("[Reset Project State] foreshadowing cleanup pauseQueue failed:", err)
+    }
+  } else {
+    console.warn(
+      "[Reset Project State] Failed to load foreshadowing-cleanup-queue:",
+      foreshadowingCleanupQueueMod.reason,
+    )
+  }
+
   if (graphMod.status === "fulfilled") {
     try {
       graphMod.value.clearGraphCache()