{"version":3,"file":"thread-bindings.mjs","names":[],"sources":["../../../src/services/thread-bindings.ts"],"sourcesContent":["/**\n * Thread Bindings Service — bind agent configurations to Telegram topics.\n *\n * Each Telegram topic can have its own:\n * - Persona override (e.g. \"degen\" in Trading, \"technical\" in Research)\n * - Tool restrictions (e.g. only read-only tools in Research)\n * - Safety mode override (e.g. readonly in Research, safe in Trading)\n * - Custom system prompt additions\n *\n * This enables multi-persona or multi-task separation within a single\n * Telegram group. Pairs with ForumTopicsService for topic management.\n *\n * Bindings are keyed by chatId + threadId, stored in memory (reset on restart).\n * Future: persist to MEMORY.md or a dedicated config file.\n *\n * Usage:\n *   const bindings = getThreadBindings();\n *   bindings.bind(chatId, threadId, { persona: 'degen', safetyMode: 'danger' });\n *   const config = bindings.getBinding(chatId, threadId);\n */\n\nimport { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';\nimport { join } from 'node:path';\nimport type { TopicPurpose } from './forum-topics.js';\n\n// ── Types ────────────────────────────────────────────────────────────────\n\nexport interface ThreadBinding {\n  /** Persona override for this topic */\n  persona?: string;\n  /** Safety mode override: 'safe', 'danger', 'readonly' */\n  safetyMode?: 'safe' | 'danger' | 'readonly';\n  /** If set, only these tools are available in this topic */\n  allowedTools?: string[];\n  /** If set, these tools are blocked in this topic */\n  blockedTools?: string[];\n  /** Additional system prompt text injected for this topic */\n  systemPromptExtra?: string;\n  /** Topic purpose (synced from ForumTopicsService) */\n  purpose?: TopicPurpose;\n}\n\n/** Default bindings for well-known topic purposes */\nconst DEFAULT_BINDINGS: Partial<Record<TopicPurpose, Partial<ThreadBinding>>> = {\n  trading: {\n    persona: 'professional',\n    safetyMode: 'safe',\n  },\n  portfolio: {\n    safetyMode: 'readonly',\n    systemPromptExtra: 'This topic is for portfolio overview. Show balances, positions, PnL. Do not execute trades here.',\n  },\n  research: {\n    persona: 'technical',\n    safetyMode: 'readonly',\n    systemPromptExtra: 'This topic is for research and analysis. Provide data-heavy responses with charts, metrics, and comparisons.',\n  },\n  alerts: {\n    safetyMode: 'readonly',\n    systemPromptExtra: 'This topic receives automated alerts and notifications. Keep responses brief.',\n  },\n  governance: {\n    safetyMode: 'safe',\n    systemPromptExtra: 'This topic is for DAO governance. Focus on proposal analysis, voting power, and delegation.',\n  },\n  social: {\n    safetyMode: 'safe',\n    systemPromptExtra: 'This topic is for social media management (Farcaster, X/Twitter). Draft posts, check engagement.',\n  },\n  admin: {\n    safetyMode: 'safe',\n    systemPromptExtra: 'This topic is for bot administration. Handle /setup, /flykeys, mode changes, and configuration.',\n  },\n};\n\n// ── Service ──────────────────────────────────────────────────────────────\n\nexport class ThreadBindingsService {\n  /** chatId:threadId → binding */\n  private bindings = new Map<string, ThreadBinding>();\n\n  /**\n   * Create or update a binding for a specific topic.\n   */\n  bind(chatId: string, threadId: number, config: Partial<ThreadBinding>): ThreadBinding {\n    const key = this.key(chatId, threadId);\n    const existing = this.bindings.get(key) ?? {};\n    const merged: ThreadBinding = { ...existing, ...config };\n    this.bindings.set(key, merged);\n    return merged;\n  }\n\n  /**\n   * Get the binding for a specific topic.\n   * Falls back to default bindings based on topic purpose.\n   */\n  getBinding(chatId: string, threadId: number): ThreadBinding | undefined {\n    const key = this.key(chatId, threadId);\n    return this.bindings.get(key);\n  }\n\n  /**\n   * Get the effective binding — user override merged over defaults for the purpose.\n   */\n  getEffectiveBinding(chatId: string, threadId: number, purpose?: TopicPurpose): ThreadBinding {\n    const userBinding = this.getBinding(chatId, threadId) ?? {};\n    const defaultBinding = purpose ? (DEFAULT_BINDINGS[purpose] ?? {}) : {};\n\n    return {\n      ...defaultBinding,\n      ...userBinding,\n      purpose: userBinding.purpose ?? purpose,\n    };\n  }\n\n  /**\n   * Remove a binding.\n   */\n  unbind(chatId: string, threadId: number): boolean {\n    return this.bindings.delete(this.key(chatId, threadId));\n  }\n\n  /**\n   * Check if a tool is allowed in a given topic.\n   */\n  isToolAllowed(chatId: string, threadId: number, toolName: string, purpose?: TopicPurpose): boolean {\n    const binding = this.getEffectiveBinding(chatId, threadId, purpose);\n\n    if (binding.blockedTools?.includes(toolName)) return false;\n    if (binding.allowedTools && !binding.allowedTools.includes(toolName)) return false;\n\n    return true;\n  }\n\n  /**\n   * List all bindings for a chat.\n   */\n  listBindings(chatId: string): Array<{ threadId: number; binding: ThreadBinding }> {\n    const results: Array<{ threadId: number; binding: ThreadBinding }> = [];\n    const prefix = `${chatId}:`;\n\n    for (const [key, binding] of this.bindings) {\n      if (key.startsWith(prefix)) {\n        const threadId = parseInt(key.slice(prefix.length), 10);\n        if (!isNaN(threadId)) {\n          results.push({ threadId, binding });\n        }\n      }\n    }\n\n    return results;\n  }\n\n  /**\n   * Apply default bindings for a purpose to a topic (if no user override exists).\n   */\n  applyDefaults(chatId: string, threadId: number, purpose: TopicPurpose): ThreadBinding {\n    const key = this.key(chatId, threadId);\n    if (this.bindings.has(key)) {\n      return this.bindings.get(key)!;\n    }\n\n    const defaults = DEFAULT_BINDINGS[purpose];\n    if (defaults) {\n      const binding: ThreadBinding = { ...defaults, purpose };\n      this.bindings.set(key, binding);\n      return binding;\n    }\n\n    return { purpose };\n  }\n\n  /**\n   * Reset all bindings (for testing).\n   */\n  reset(): void {\n    this.bindings.clear();\n  }\n\n  /**\n   * Export all bindings for persistence.\n   */\n  exportAll(): Array<{ chatId: string; threadId: number; binding: ThreadBinding }> {\n    const results: Array<{ chatId: string; threadId: number; binding: ThreadBinding }> = [];\n    for (const [key, binding] of this.bindings) {\n      const [chatId, threadIdStr] = key.split(':');\n      const threadId = parseInt(threadIdStr!, 10);\n      if (!isNaN(threadId)) {\n        results.push({ chatId: chatId!, threadId, binding });\n      }\n    }\n    return results;\n  }\n\n  // ── Internal ──────────────────────────────────────────────────────\n\n  private key(chatId: string, threadId: number): string {\n    return `${chatId}:${threadId}`;\n  }\n}\n\n// ── Singleton ────────────────────────────────────────────────────────────\n\nlet _instance: ThreadBindingsService | null = null;\n\nexport function getThreadBindings(): ThreadBindingsService {\n  if (!_instance) _instance = new ThreadBindingsService();\n  return _instance;\n}\n\nexport function resetThreadBindings(): void {\n  _instance = null;\n}\n\n// ── Persistence ──────────────────────────────────────────────────────────\n\nfunction getBindingsStateDir(): string {\n  return process.env.OPENCLAWNCH_TX_DIR\n    ? join(process.env.OPENCLAWNCH_TX_DIR, '..', 'thread-bindings')\n    : join(process.env.HOME ?? '/tmp', '.openclawnch', 'thread-bindings');\n}\n\nfunction getBindingsStatePath(): string {\n  return join(getBindingsStateDir(), 'bindings.json');\n}\n\ninterface PersistedBinding {\n  chatId: string;\n  threadId: number;\n  binding: ThreadBinding;\n}\n\n/** Persist all thread bindings to disk. Called on graceful shutdown. */\nexport function persistThreadBindings(): void {\n  if (!_instance) return;\n\n  // Collect all bindings by iterating known chats\n  // Since we can't enumerate all chatIds from outside, we expose internal state\n  const entries = _instance.exportAll();\n  if (entries.length === 0) return;\n\n  const dir = getBindingsStateDir();\n  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n  writeFileSync(getBindingsStatePath(), JSON.stringify(entries, null, 2), 'utf8');\n}\n\n/** Restore thread bindings from disk. Called on startup. */\nexport function restoreThreadBindings(): void {\n  const path = getBindingsStatePath();\n  try {\n    if (!existsSync(path)) return;\n    const data = JSON.parse(readFileSync(path, 'utf8')) as PersistedBinding[];\n    const svc = getThreadBindings();\n    for (const entry of data) {\n      svc.bind(entry.chatId, entry.threadId, entry.binding);\n    }\n  } catch { /* corrupt file — start fresh */ }\n}\n"],"mappings":"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"}