File size: 10,670 Bytes
b6ecafa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
/**
 * Local Agent Sync β€” Discovers agent definitions from local directories
 * and syncs them bidirectionally with the MC database.
 *
 * Scans:
 *   ~/.agents/         β€” top-level dirs with agent config files
 *   ~/.codex/agents/   β€” Codex agent definitions
 *   ~/.claude/agents/  β€” Claude agent definitions (if present)
 *
 * A directory counts as an agent if it contains one of:
 *   AGENT.md, agent.md, soul.md, identity.md, config.json, agent.json
 */

import { createHash } from 'node:crypto'
import { readdirSync, readFileSync, statSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { homedir } from 'node:os'
import { getDatabase, logAuditEvent } from './db'
import { logger } from './logger'

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

interface DiskAgent {
  name: string
  dir: string
  role: string
  soulContent: string | null
  configContent: string | null
  contentHash: string
}

interface AgentRow {
  id: number
  name: string
  role: string
  soul_content: string | null
  status: string
  source: string | null
  content_hash: string | null
  workspace_path: string | null
  config: string | null
}

// Detection files β€” order matters: first found wins for role extraction
const IDENTITY_FILES = ['soul.md', 'AGENT.md', 'agent.md', 'identity.md', 'SKILL.md']
const CONFIG_FILES = ['config.json', 'agent.json']
const ALL_MARKERS = [...IDENTITY_FILES, ...CONFIG_FILES]

// YAML frontmatter fields for flat .md agent files (Claude Code format)
interface AgentFrontmatter {
  name?: string
  description?: string
  model?: string
  color?: string
  tools?: string[]
}

function parseYamlFrontmatter(content: string): { frontmatter: AgentFrontmatter; body: string } {
  const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
  if (!match) return { frontmatter: {}, body: content }
  const raw = match[1]
  const body = match[2]
  const fm: AgentFrontmatter = {}
  for (const line of raw.split('\n')) {
    const kv = line.match(/^(\w+)\s*:\s*(.+)$/)
    if (!kv) continue
    const [, key, val] = kv
    const cleaned = val.replace(/^["']|["']$/g, '').trim()
    if (key === 'name') fm.name = cleaned
    else if (key === 'description') fm.description = cleaned
    else if (key === 'model') fm.model = cleaned
    else if (key === 'color') fm.color = cleaned
    else if (key === 'tools') {
      try { fm.tools = JSON.parse(val) } catch { /* ignore */ }
    }
  }
  return { frontmatter: fm, body }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function sha256(content: string): string {
  return createHash('sha256').update(content, 'utf8').digest('hex')
}

function extractRole(content: string): string {
  const lines = content.split('\n').map(l => l.trim()).filter(Boolean)
  // Look for "role:" or "theme:" in first 10 lines
  for (const line of lines.slice(0, 10)) {
    const match = line.match(/^(?:role|theme)\s*:\s*(.+)$/i)
    if (match?.[1]) return match[1].trim()
  }
  return 'agent'
}

function getLocalAgentRoots(): string[] {
  const home = homedir()
  return [
    join(home, '.agents'),
    join(home, '.codex', 'agents'),
    join(home, '.claude', 'agents'),
    join(home, '.hermes', 'skills'),
  ]
}

// ---------------------------------------------------------------------------
// Disk scanner
// ---------------------------------------------------------------------------

function scanLocalAgents(): DiskAgent[] {
  const agents: DiskAgent[] = []
  const seen = new Set<string>()

  for (const root of getLocalAgentRoots()) {
    if (!existsSync(root)) continue
    let entries: string[]
    try {
      entries = readdirSync(root)
    } catch {
      continue
    }

    for (const entry of entries) {
      // Skip 'skills' subdirectory β€” that's the skill roots
      if (entry === 'skills') continue

      const fullPath = join(root, entry)
      let stat
      try {
        stat = statSync(fullPath)
      } catch {
        continue
      }

      // --- Flat .md agent files (Claude Code format) ---
      if (stat.isFile() && entry.endsWith('.md') && entry !== 'CLAUDE.md' && entry !== 'AGENTS.md') {
        try {
          const content = readFileSync(fullPath, 'utf8')
          const { frontmatter, body } = parseYamlFrontmatter(content)
          const agentName = frontmatter.name || entry.replace(/\.md$/, '')
          if (seen.has(agentName)) continue
          seen.add(agentName)

          const configObj: Record<string, unknown> = {}
          if (frontmatter.model) configObj.model = frontmatter.model
          if (frontmatter.color) configObj.color = frontmatter.color
          if (frontmatter.tools) configObj.tools = frontmatter.tools
          if (frontmatter.description) configObj.description = frontmatter.description
          const configJson = Object.keys(configObj).length > 0 ? JSON.stringify(configObj) : null

          agents.push({
            name: agentName,
            dir: fullPath,
            role: frontmatter.description ? 'agent' : 'agent',
            soulContent: body.trim() || null,
            configContent: configJson,
            contentHash: sha256(content),
          })
        } catch { /* unreadable */ }
        continue
      }

      // --- Directory-based agents (workspace format) ---
      if (!stat.isDirectory()) continue

      // Check if any marker file exists
      const hasMarker = ALL_MARKERS.some(f => existsSync(join(fullPath, f)))
      if (!hasMarker) continue

      if (seen.has(entry)) continue
      seen.add(entry)

      // Read identity content (soul/agent/identity.md)
      let soulContent: string | null = null
      let role = 'agent'
      for (const f of IDENTITY_FILES) {
        const p = join(fullPath, f)
        if (existsSync(p)) {
          try {
            soulContent = readFileSync(p, 'utf8')
            role = extractRole(soulContent)
            break
          } catch { /* unreadable */ }
        }
      }

      // Read config JSON if present
      let configContent: string | null = null
      for (const f of CONFIG_FILES) {
        const p = join(fullPath, f)
        if (existsSync(p)) {
          try {
            configContent = readFileSync(p, 'utf8')
            break
          } catch { /* unreadable */ }
        }
      }

      // Build content hash from whatever identity files exist
      const hashInput = (soulContent || '') + (configContent || '')
      if (!hashInput) continue

      agents.push({
        name: entry,
        dir: fullPath,
        role,
        soulContent,
        configContent,
        contentHash: sha256(hashInput),
      })
    }
  }

  return agents
}

// ---------------------------------------------------------------------------
// Sync engine
// ---------------------------------------------------------------------------

export async function syncLocalAgents(): Promise<{ ok: boolean; message: string }> {
  try {
    const db = getDatabase()
    const diskAgents = scanLocalAgents()
    const now = Math.floor(Date.now() / 1000)

    const diskMap = new Map<string, DiskAgent>()
    for (const a of diskAgents) {
      diskMap.set(a.name, a)
    }

    // Fetch DB agents with source='local'
    const dbRows = db.prepare(
      `SELECT id, name, role, soul_content, status, source, content_hash, workspace_path, config FROM agents WHERE source = 'local'`
    ).all() as AgentRow[]

    const dbMap = new Map<string, AgentRow>()
    for (const r of dbRows) {
      dbMap.set(r.name, r)
    }

    let created = 0
    let updated = 0
    let removed = 0

    const insertStmt = db.prepare(`
      INSERT INTO agents (name, role, soul_content, status, source, content_hash, workspace_path, config, created_at, updated_at)
      VALUES (?, ?, ?, 'offline', 'local', ?, ?, ?, ?, ?)
    `)
    const updateStmt = db.prepare(`
      UPDATE agents SET role = ?, soul_content = ?, content_hash = ?, workspace_path = ?, config = ?, updated_at = ?
      WHERE id = ?
    `)
    const markRemovedStmt = db.prepare(`
      UPDATE agents SET status = 'offline', updated_at = ? WHERE id = ?
    `)

    db.transaction(() => {
      // Disk β†’ DB: additions and changes
      for (const [name, disk] of diskMap) {
        const existing = dbMap.get(name)
        const configJson = disk.configContent ? disk.configContent : null

        if (!existing) {
          insertStmt.run(name, disk.role, disk.soulContent, disk.contentHash, disk.dir, configJson, now, now)
          created++
        } else if (existing.content_hash !== disk.contentHash) {
          updateStmt.run(disk.role, disk.soulContent, disk.contentHash, disk.dir, configJson, now, existing.id)
          updated++
        }
      }

      // Agents that vanished from disk β€” mark offline but don't delete
      for (const [name, row] of dbMap) {
        if (!diskMap.has(name) && row.status !== 'offline') {
          markRemovedStmt.run(now, row.id)
          removed++
        }
      }
    })()

    const msg = `Local agent sync: ${created} added, ${updated} updated, ${removed} marked offline (${diskAgents.length} on disk)`
    if (created > 0 || updated > 0 || removed > 0) {
      logger.info(msg)
      logAuditEvent({
        action: 'local_agent_sync',
        actor: 'scheduler',
        detail: { created, updated, removed, total: diskAgents.length },
      })
    }
    return { ok: true, message: msg }
  } catch (err: any) {
    logger.error({ err }, 'Local agent sync failed')
    return { ok: false, message: `Local agent sync failed: ${err.message}` }
  }
}

/**
 * Write agent soul content back to disk (UI β†’ Disk direction).
 * Called when a user edits a local agent's soul in the MC UI.
 */
export function writeLocalAgentSoul(agentDir: string, soulContent: string): void {
  // Prefer soul.md, fall back to AGENT.md
  const soulPath = join(agentDir, 'soul.md')
  const agentMdPath = join(agentDir, 'AGENT.md')
  const targetPath = existsSync(soulPath) ? soulPath : existsSync(agentMdPath) ? agentMdPath : soulPath

  mkdirSync(agentDir, { recursive: true })
  writeFileSync(targetPath, soulContent, 'utf8')

  // Update the DB hash so the next sync doesn't re-overwrite
  try {
    const db = getDatabase()
    const hash = sha256(soulContent)
    db.prepare(`UPDATE agents SET content_hash = ?, updated_at = ? WHERE workspace_path = ? AND source = 'local'`)
      .run(hash, Math.floor(Date.now() / 1000), agentDir)
  } catch { /* best-effort */ }
}