{"version":3,"file":"detector-DWXkeTlk.mjs","names":[],"sources":["../src/topics/detector.ts"],"sourcesContent":["/**\n * Topic shift detection engine.\n *\n * Accepts a context summary (recent conversation text) and determines whether\n * the conversation has drifted away from the currently-routed project.\n *\n * Algorithm:\n *   1. Run keyword memory_search against the context text (no project filter)\n *   2. Score results by project — sum of BM25 scores per project\n *   3. Compare the top-scoring project against the current project\n *   4. If a different project dominates by more than the confidence threshold,\n *      report a topic shift.\n *\n * Design decisions:\n *   - Keyword search only (no semantic) — fast, no embedding requirement\n *   - Works with or without an active daemon (direct DB access path)\n *   - Stateless: callers supply currentProject; detector has no session memory\n *   - Minimal: returns a plain result object, not MCP content arrays\n */\n\nimport type { Database } from \"better-sqlite3\";\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport { searchMemory, populateSlugs } from \"../memory/search.js\";\nimport type { SearchResult } from \"../memory/search.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TopicCheckParams {\n  /** Recent conversation context (a few sentences or tool call summaries) */\n  context: string;\n  /** The project slug the session is currently routed to. May be null/empty. */\n  currentProject?: string;\n  /**\n   * Minimum confidence [0,1] to declare a shift. Default: 0.6.\n   * Higher = less sensitive, fewer false positives.\n   */\n  threshold?: number;\n  /**\n   * Maximum results to draw from memory search (candidates). Default: 20.\n   * More candidates = more accurate scoring, slightly slower.\n   */\n  candidates?: number;\n}\n\nexport interface TopicCheckResult {\n  /** Whether a significant topic shift was detected. */\n  shifted: boolean;\n  /** The project slug the session is currently routed to (echoed from input). */\n  currentProject: string | null;\n  /** The project slug that best matches the context, or null if no clear match. */\n  suggestedProject: string | null;\n  /**\n   * Confidence score for the suggested project [0,1].\n   * Represents the fraction of total score mass held by the top project.\n   * 1.0 = all matching chunks belong to one project.\n   * 0.5 = two projects are equally matched.\n   */\n  confidence: number;\n  /** Number of memory chunks that contributed to scoring. */\n  chunkCount: number;\n  /** Top-3 scoring projects with their normalised scores (for debugging). */\n  topProjects: Array<{ slug: string; score: number }>;\n}\n\n// ---------------------------------------------------------------------------\n// Core algorithm\n// ---------------------------------------------------------------------------\n\n/**\n * Detect whether the provided context text best matches a different project\n * than the session's current routing.\n *\n * Works with either a raw SQLite Database or a StorageBackend.\n * For the StorageBackend path, keyword search is used.\n * For the raw Database path (legacy/direct), searchMemory() is called.\n */\nexport async function detectTopicShift(\n  registryDb: Database,\n  federation: Database | StorageBackend,\n  params: TopicCheckParams\n): Promise<TopicCheckResult> {\n  const threshold = params.threshold ?? 0.6;\n  const candidates = params.candidates ?? 20;\n  const currentProject = params.currentProject?.trim() || null;\n\n  if (!params.context || params.context.trim().length === 0) {\n    return {\n      shifted: false,\n      currentProject,\n      suggestedProject: null,\n      confidence: 0,\n      chunkCount: 0,\n      topProjects: [],\n    };\n  }\n\n  // -------------------------------------------------------------------------\n  // Run memory search across ALL projects (no project filter)\n  // -------------------------------------------------------------------------\n\n  let results: SearchResult[];\n\n  const isBackend = (x: Database | StorageBackend): x is StorageBackend =>\n    \"backendType\" in x;\n\n  if (isBackend(federation)) {\n    results = await federation.searchKeyword(params.context, {\n      maxResults: candidates,\n    });\n  } else {\n    results = searchMemory(federation, params.context, {\n      maxResults: candidates,\n    });\n  }\n\n  if (results.length === 0) {\n    return {\n      shifted: false,\n      currentProject,\n      suggestedProject: null,\n      confidence: 0,\n      chunkCount: 0,\n      topProjects: [],\n    };\n  }\n\n  // Populate project slugs from the registry\n  const withSlugs = populateSlugs(results, registryDb);\n\n  // -------------------------------------------------------------------------\n  // Score projects by summing BM25 scores of matching chunks\n  // -------------------------------------------------------------------------\n\n  const projectScores = new Map<string, number>();\n\n  for (const r of withSlugs) {\n    const slug = r.projectSlug;\n    if (!slug) continue;\n    projectScores.set(slug, (projectScores.get(slug) ?? 0) + r.score);\n  }\n\n  if (projectScores.size === 0) {\n    return {\n      shifted: false,\n      currentProject,\n      suggestedProject: null,\n      confidence: 0,\n      chunkCount: withSlugs.length,\n      topProjects: [],\n    };\n  }\n\n  // Sort by total score descending\n  const ranked = Array.from(projectScores.entries())\n    .sort((a, b) => b[1] - a[1]);\n\n  const totalScore = ranked.reduce((sum, [, s]) => sum + s, 0);\n\n  // Top-3 for reporting (normalised to [0,1] fraction of total mass)\n  const topProjects = ranked.slice(0, 3).map(([slug, score]) => ({\n    slug,\n    score: totalScore > 0 ? score / totalScore : 0,\n  }));\n\n  const topSlug = ranked[0][0];\n  const topRawScore = ranked[0][1];\n  const confidence = totalScore > 0 ? topRawScore / totalScore : 0;\n\n  // -------------------------------------------------------------------------\n  // Determine if a shift occurred\n  // -------------------------------------------------------------------------\n\n  // A shift is detected when:\n  //   1. confidence >= threshold (the top project dominates)\n  //   2. The top project is different from currentProject\n  //   3. There is a currentProject to compare against\n  //      (if no current project, we still return the best match but no \"shift\")\n\n  const isDifferent =\n    currentProject !== null &&\n    topSlug !== currentProject;\n\n  const shifted = isDifferent && confidence >= threshold;\n\n  return {\n    shifted,\n    currentProject,\n    suggestedProject: topSlug,\n    confidence,\n    chunkCount: withSlugs.length,\n    topProjects,\n  };\n}\n"],"mappings":";;;;;;;;;;;AA8EA,eAAsB,iBACpB,YACA,YACA,QAC2B;CAC3B,MAAM,YAAY,OAAO,aAAa;CACtC,MAAM,aAAa,OAAO,cAAc;CACxC,MAAM,iBAAiB,OAAO,gBAAgB,MAAM,IAAI;AAExD,KAAI,CAAC,OAAO,WAAW,OAAO,QAAQ,MAAM,CAAC,WAAW,EACtD,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY;EACZ,aAAa,EAAE;EAChB;CAOH,IAAI;CAEJ,MAAM,aAAa,MACjB,iBAAiB;AAEnB,KAAI,UAAU,WAAW,CACvB,WAAU,MAAM,WAAW,cAAc,OAAO,SAAS,EACvD,YAAY,YACb,CAAC;KAEF,WAAU,aAAa,YAAY,OAAO,SAAS,EACjD,YAAY,YACb,CAAC;AAGJ,KAAI,QAAQ,WAAW,EACrB,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY;EACZ,aAAa,EAAE;EAChB;CAIH,MAAM,YAAY,cAAc,SAAS,WAAW;CAMpD,MAAM,gCAAgB,IAAI,KAAqB;AAE/C,MAAK,MAAM,KAAK,WAAW;EACzB,MAAM,OAAO,EAAE;AACf,MAAI,CAAC,KAAM;AACX,gBAAc,IAAI,OAAO,cAAc,IAAI,KAAK,IAAI,KAAK,EAAE,MAAM;;AAGnE,KAAI,cAAc,SAAS,EACzB,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY,UAAU;EACtB,aAAa,EAAE;EAChB;CAIH,MAAM,SAAS,MAAM,KAAK,cAAc,SAAS,CAAC,CAC/C,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,GAAG;CAE9B,MAAM,aAAa,OAAO,QAAQ,KAAK,GAAG,OAAO,MAAM,GAAG,EAAE;CAG5D,MAAM,cAAc,OAAO,MAAM,GAAG,EAAE,CAAC,KAAK,CAAC,MAAM,YAAY;EAC7D;EACA,OAAO,aAAa,IAAI,QAAQ,aAAa;EAC9C,EAAE;CAEH,MAAM,UAAU,OAAO,GAAG;CAC1B,MAAM,cAAc,OAAO,GAAG;CAC9B,MAAM,aAAa,aAAa,IAAI,cAAc,aAAa;AAkB/D,QAAO;EACL,SANA,mBAAmB,QACnB,YAAY,kBAEiB,cAAc;EAI3C;EACA,kBAAkB;EAClB;EACA,YAAY,UAAU;EACtB;EACD"}