spool-memory 0.1.0

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
import * as React from 'react'
import { invoke as tauriInvoke } from '@tauri-apps/api/core'
import { isTauriHost } from '@/lib/api/desktop'

export type ConfigState = {
  configPath: string
  cwd: string
  vaultRoot: string
  daemonEnabled: boolean
  daemonBin: string
  defaultActor: string
  currentProjectId: string | null
  ready: boolean
  setConfigPath: (value: string) => void
  setVaultRoot: (value: string) => void
  setDefaultActor: (value: string) => void
  setCurrentProjectId: (value: string | null) => void
}

const KEY = 'spool.desktop.config.v2'
const DEFAULT_ACTOR_KEY = 'spool.defaultActor'
const PROJECT_ID_KEY = 'spool.currentProjectId'

type Persisted = {
  configPath: string
  cwd: string
  vaultRoot: string
  daemonEnabled: boolean
  daemonBin: string
  defaultActor: string
  currentProjectId: string | null
}

const initial: Persisted = {
  configPath: '',
  cwd: '',
  vaultRoot: '',
  daemonEnabled: false,
  daemonBin: '',
  defaultActor: '',
  currentProjectId: null,
}

function load(): Persisted {
  if (typeof window === 'undefined') return initial
  try {
    const raw = window.localStorage.getItem(KEY)
    const fallbackActor = window.localStorage.getItem(DEFAULT_ACTOR_KEY) ?? ''
    const fallbackProjectRaw = window.localStorage.getItem(PROJECT_ID_KEY)
    const fallbackProject =
      fallbackProjectRaw === null || fallbackProjectRaw === ''
        ? null
        : fallbackProjectRaw
    if (!raw) {
      return {
        ...initial,
        defaultActor: fallbackActor,
        currentProjectId: fallbackProject,
      }
    }
    const parsed = { ...initial, ...(JSON.parse(raw) as Partial<Persisted>) }
    // Discard stale relative config paths from older versions
    if (parsed.configPath && !parsed.configPath.startsWith('/') && !parsed.configPath.startsWith('~')) {
      parsed.configPath = initial.configPath
    }
    if (!parsed.defaultActor && fallbackActor) {
      parsed.defaultActor = fallbackActor
    }
    if (parsed.currentProjectId === undefined) {
      parsed.currentProjectId = fallbackProject
    }
    return parsed
  } catch {
    return initial
  }
}

const ConfigContext = React.createContext<ConfigState | null>(null)

export function ConfigProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = React.useState<Persisted>(load)
  const [ready, setReady] = React.useState(false)

  React.useEffect(() => {
    if (isTauriHost()) {
      tauriInvoke<{ config_path: string; home: string }>('desktop_get_defaults')
        .then((defaults) => {
          setState((prev) => ({
            ...prev,
            configPath: defaults.config_path,
            cwd: prev.cwd || defaults.home,
          }))
          setReady(true)
        })
        .catch(() => setReady(true))
    } else {
      setReady(true)
    }
  }, [])

  React.useEffect(() => {
    if (!ready) return
    try {
      window.localStorage.setItem(KEY, JSON.stringify(state))
      // Mirror to standalone keys as a fallback for older surfaces
      window.localStorage.setItem(DEFAULT_ACTOR_KEY, state.defaultActor ?? '')
      if (state.currentProjectId === null || state.currentProjectId === undefined) {
        window.localStorage.removeItem(PROJECT_ID_KEY)
      } else {
        window.localStorage.setItem(PROJECT_ID_KEY, state.currentProjectId)
      }
    } catch {
      // ignore persistence failure
    }
  }, [state, ready])

  const value = React.useMemo<ConfigState>(
    () => ({
      ...state,
      ready,
      setConfigPath: (configPath) =>
        setState((prev) => ({ ...prev, configPath })),
      setVaultRoot: (vaultRoot) =>
        setState((prev) => ({ ...prev, vaultRoot })),
      setDefaultActor: (defaultActor) =>
        setState((prev) => ({ ...prev, defaultActor })),
      setCurrentProjectId: (currentProjectId) =>
        setState((prev) => ({ ...prev, currentProjectId })),
    }),
    [state, ready]
  )

  return <ConfigContext.Provider value={value}>{children}</ConfigContext.Provider>
}

export function useConfig(): ConfigState {
  const ctx = React.useContext(ConfigContext)
  if (!ctx) throw new Error('useConfig must be used inside <ConfigProvider>')
  return ctx
}

export function buildDaemonRequest(cfg: ConfigState) {
  if (!cfg.daemonEnabled) return null
  return {
    enabled: true,
    daemon_bin: cfg.daemonBin.trim() ? cfg.daemonBin.trim() : null,
  }
}