@ancleto/spec 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -502,6 +502,6 @@ Los 10 agentes instalados y el modelo que usa cada uno según tu tier. Al instal
502
502
 
503
503
  El tier `minimo` usa un único modelo para los 10 agentes: `deepseek-v4.1-flash` es el más económico del catálogo para coding (0.15 / 0.60 por millón de tokens) y ofrece 1M de contexto.
504
504
 
505
- **Tier `gratis`.** Los 10 agentes usan **Muse Spark 1.3 Free** (`opencode/muse-spark-1.3-contributor-free`) si está disponible en tu cuenta —no está abierto a todos—. En una instalación interactiva el wizard te lo pregunta y la respuesta queda guardada en `.ancletorc` (`gratisModel`) para los siguientes `install`/`update`. Sin TTY se detecta con `opencode models` y, si no se puede confirmar, se usa `opencode/big-pickle`. El instalador siempre informa cuál aplicó.
505
+ **Tier `gratis`.** Los 10 agentes usan **Muse Spark 1.3 Free** (`opencode/muse-spark-1.3-contributor-free`) si está disponible en tu cuenta —no está abierto a todos—. En `install` interactivo el wizard te lo pregunta la primera vez y la respuesta queda guardada en `.ancletorc` (`gratisModel`) para los siguientes `init`/`install`/`update`. `init` interactivo con tier `gratis` pregunta siempre, salvo que `ANCLETO_MUSE_SPARK` fuerce el valor (pre-selecciona el valor guardado o la detección automática como default); fuera del menú reutiliza el valor guardado o detecta con `opencode models` y, si no se puede confirmar, usa `opencode/big-pickle`. Ambos comandos informan cuál aplicaron y, cuando no preguntan, de dónde salió (variable de entorno, valor guardado o detección automática).
506
506
 
507
507
  > `ANCLETO_MUSE_SPARK=1` fuerza Muse Spark, `ANCLETO_MUSE_SPARK=0` fuerza `big-pickle`. Tiene prioridad sobre la elección guardada.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ancleto/spec",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Orquestador SDD liviano con subagentes optimizados para costo/tokens",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,10 @@
30
30
  "node": ">=24.0.0"
31
31
  },
32
32
  "license": "MIT",
33
+ "scripts": {
34
+ "lint": "eslint src/ test/",
35
+ "test": "node --test \"test/*.test.js\""
36
+ },
33
37
  "keywords": [
34
38
  "opencode",
35
39
  "aspec",
@@ -38,5 +42,9 @@
38
42
  "agents",
39
43
  "skills",
40
44
  "spec-driven"
41
- ]
45
+ ],
46
+ "devDependencies": {
47
+ "eslint": "^10.11.0",
48
+ "globals": "^17.13.0"
49
+ }
42
50
  }
package/src/cli/index.js CHANGED
@@ -9,6 +9,7 @@ import { join, dirname, resolve, basename, relative, isAbsolute } from 'node:pat
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { homedir, tmpdir } from 'node:os'
11
11
  import { createMemoryEngine, createReadonlyMemoryEngine, defaultMemoryDbPath } from '../core/memory/engine.js'
12
+ import { adaptFrontmatter, parseFrontmatter } from '../core/adapters/frontmatter.js'
12
13
  import { writeWorkingContext } from '../core/memory/working-context.js'
13
14
  import { memoryDoctor } from '../core/memory/doctor.js'
14
15
  import { serveMemoryMcp } from '../core/memory/mcp-server.js'
@@ -74,8 +75,11 @@ Uso:
74
75
  Imprime/escribe el bloque <ProjectMemoryRules> (reglas activas)
75
76
  ancleto memory list [--type X] [--scope X] [--all] [--json]
76
77
  Lista los nodos de memoria (read-only, no toca la DB)
77
- ancleto memory doctor [--rebuild] Diagnostica .ancleto/memory.db (integridad, FTS5, unicidad)
78
- y reconstruye el indice FTS5 con --rebuild
78
+ ancleto memory doctor [--rebuild] Diagnostica .ancleto/memory.db (integridad, FTS5, unicidad)
79
+ y reconstruye el indice FTS5 con --rebuild
80
+ ancleto memory export [--out <archivo>] Exporta nodos activos a JSON (stdout o archivo)
81
+ ancleto memory import <archivo> Importa nodos desde JSON (upsert por memory_key, idempotente)
82
+ ancleto memory gc [--dry-run] [--days N] Purga superseded con antiguedad > N dias (default 30)
79
83
  ancleto mcp Servidor MCP de memoria propia (stdio) para tu IDE
80
84
  expone searchMemory, recordRule y recordDecision
81
85
  ancleto check Verifica integridad de archivos instalados vs manifiesto
@@ -108,7 +112,7 @@ async function readAncletorc(projectDir) {
108
112
 
109
113
  async function writeManifest(projectDir, extra = {}) {
110
114
  const existing = (await readAncletorc(projectDir)) || {}
111
- const { version: _legacy, ...rest } = existing
115
+ const { ...rest } = existing
112
116
  const manifest = {
113
117
  ...rest,
114
118
  schemaVersion: 1,
@@ -195,15 +199,6 @@ async function registerProject(projectDir, { agent, tier } = {}) {
195
199
  await writeProjectsRegistry(reg)
196
200
  }
197
201
 
198
- async function unregisterProject(projectDir) {
199
- const reg = await readProjectsRegistry()
200
- const key = normKey(projectDir)
201
- if (!reg.projects[key]) return false
202
- delete reg.projects[key]
203
- await writeProjectsRegistry(reg)
204
- return true
205
- }
206
-
207
202
  // Color helpers (respetan NO_COLOR y TTY).
208
203
  const useColor = () => process.stdout.isTTY && !process.env.NO_COLOR
209
204
  function paint(code, s) {
@@ -750,6 +745,9 @@ async function resolveAgent(args, existing, allowAsk = Boolean(process.stdin.isT
750
745
  if (flag) return flag
751
746
  if (existing && SUPPORTED_AGENTS.includes(existing)) return existing
752
747
  if (allowAsk) return askAgent()
748
+ if (existing && !SUPPORTED_AGENTS.includes(existing)) {
749
+ console.error(`ancleto: advertencia: agente '${existing}' no soportado, usando '${DEFAULT_AGENT}'`)
750
+ }
753
751
  return DEFAULT_AGENT
754
752
  }
755
753
 
@@ -947,36 +945,6 @@ const AGENT_TARGETS = {
947
945
 
948
946
  const ANCLETO_SKILLS = ['ancleto-new', 'ancleto-propose', 'ancleto-apply', 'ancleto-verify', 'ancleto-archive', 'ancleto-bulk-archive', 'ancleto-continue', 'ancleto-explore', 'ancleto-ff', 'ancleto-onboard', 'ancleto-workflow']
949
947
 
950
- // Frontmatter: parser propio sin dependencias. Cada entrada conserva su clave y
951
- // sus lineas crudas (incluye block scalars y mapas anidados), preservando orden.
952
- function parseFrontmatter(content) {
953
- const m = /^(---\r?\n)([\s\S]*?)(\r?\n---)(\r?\n?)([\s\S]*)$/.exec(content)
954
- if (!m) return { hasFrontmatter: false, content }
955
- const entries = []
956
- for (const line of m[2].split(/\r?\n/)) {
957
- if (/^[A-Za-z0-9_-]+:/.test(line)) {
958
- entries.push({ key: line.slice(0, line.indexOf(':')), lines: [line] })
959
- } else if (entries.length) {
960
- entries[entries.length - 1].lines.push(line)
961
- } else {
962
- entries.push({ key: null, lines: [line] })
963
- }
964
- }
965
- return { hasFrontmatter: true, open: m[1], entries, close: m[3], nl: m[4], body: m[5] }
966
- }
967
-
968
- function serializeFrontmatter(parsed) {
969
- if (!parsed.hasFrontmatter) return parsed.content
970
- const inner = parsed.entries.flatMap((e) => e.lines).join('\n')
971
- return `${parsed.open}${inner}${parsed.close}${parsed.nl}${parsed.body}`
972
- }
973
-
974
- // D4/D5: hosts cuyo formato de agents difiere del origen requieren adaptacion.
975
- // Claude/VS Code/Copilot eliminan las claves de opencode; Antigravity usa una
976
- // transformacion propia (D2). El resto es identidad (preservacion verbatim).
977
- const AGENT_ADAPTER_HOSTS = new Set(['claude', 'vscode', 'antigravity', 'copilot'])
978
- const AGENT_ADAPTER_DROP = new Set(['mode', 'color', 'temperature', 'permission', 'model', 'tools'])
979
-
980
948
  // Solo Copilot: los tiers no cambian modelos (el usuario elige en el picker);
981
949
  // el orchestrator lo recuerda al empezar.
982
950
  const COPILOT_MODEL_NOTE = `
@@ -988,119 +956,6 @@ normal = flujo completo, minimo = pasos agrupados y económicos, gratis = modelo
988
956
  gratis disponible en tu cuenta.
989
957
  `
990
958
 
991
- // D3: tabla unica (fijada contra la tabla oficial de frontmatter de Custom
992
- // Subagents) de ids de Antigravity. Conjunto CERRADO: solo se emiten estos ids.
993
- // Los ids solo-SDK (`find_file`, `edit_file`, `search_web`, `read_url_content`,
994
- // ...), los de comunidad (`write_to_file`, `call_mcp_tool`,
995
- // `multi_replace_file_content`, ...) y los de delegacion (`invoke_subagent`,
996
- // `start_subagent`, `define_subagent`) NO se emiten: no estan confirmados en el
997
- // frontmatter y un id inexistente cuelga el subagent (Known Issue). Una clave no
998
- // listada aqui se omite con aviso a stderr, nunca en silencio (D5).
999
- const ANTIGRAVITY_TOOL_MAP = {
1000
- read: 'view_file',
1001
- edit: 'replace_file_content',
1002
- grep: 'grep_search',
1003
- bash: 'run_command',
1004
- todowrite: 'manage_task'
1005
- }
1006
-
1007
- // Hijos directos del mapa `tools:` con su valor crudo (p. ej. `read: true`).
1008
- function parseToolFlags(entry) {
1009
- const flags = []
1010
- if (!entry) return flags
1011
- let childIndent = null
1012
- for (const line of entry.lines.slice(1)) {
1013
- const m = /^(\s+)([A-Za-z0-9_-]+):\s*(.*?)\s*$/.exec(line)
1014
- if (!m) continue
1015
- if (childIndent === null) childIndent = m[1].length
1016
- if (m[1].length !== childIndent) continue
1017
- flags.push({ key: m[2], value: m[3] })
1018
- }
1019
- return flags
1020
- }
1021
-
1022
- function frontmatterValue(entry, fallback = null) {
1023
- if (!entry) return fallback
1024
- const m = /^[A-Za-z0-9_-]+:\s*(.*?)\s*$/.exec(entry.lines[0] || '')
1025
- return m ? m[1] : fallback
1026
- }
1027
-
1028
- // D2/D3/D5/D8: adapta el frontmatter de un agent de opencode al de Antigravity.
1029
- // `name` (requerido por el host) se inyecta desde el nombre del archivo. `tools` se
1030
- // emite siempre como lista (el default del host es la lista vacia). Nunca se inventa
1031
- // un id: una clave sin id verificado se omite con aviso (un id inexistente cuelga el
1032
- // subagent).
1033
- function adaptAntigravityFrontmatter(content, name) {
1034
- const parsed = parseFrontmatter(content)
1035
- if (!parsed.hasFrontmatter) return content
1036
- const byKey = (k) => parsed.entries.find((e) => e.key === k)
1037
- // Claves que el adaptador gestiona: las dropeadas (AGENT_ADAPTER_DROP), las que
1038
- // re-emite (name/tools/mainAgent/subagent/model/commandExecutionPolicy) y los campos
1039
- // que preserva del origen (description/skills/mcpServers). Se filtran TODAS del
1040
- // conjunto conservado para que ninguna pueda quedar duplicada, y se re-emiten una
1041
- // sola vez en orden determinista, sin importar lo que declare la fuente.
1042
- const managed = new Set([
1043
- ...AGENT_ADAPTER_DROP,
1044
- 'name',
1045
- 'description',
1046
- 'mainAgent',
1047
- 'subagent',
1048
- 'commandExecutionPolicy',
1049
- 'mcpServers',
1050
- 'skills'
1051
- ])
1052
- // Se conservan las claves extra (no gestionadas); los campos preservados se
1053
- // re-emiten abajo desde su primera ocurrencia en el origen.
1054
- const kept = parsed.entries.filter((e) => e.key && !managed.has(e.key))
1055
-
1056
- const toolIds = []
1057
- for (const { key, value } of parseToolFlags(byKey('tools'))) {
1058
- if (value === 'false') continue
1059
- if (key === 'skill') continue // no es tool: se cubre por `skills`/surfaceo automatico (OQ2)
1060
- const id = ANTIGRAVITY_TOOL_MAP[key]
1061
- if (id) {
1062
- toolIds.push(id)
1063
- continue
1064
- }
1065
- // D3/D5: regla dura. Un id fuera del frontmatter confirmado (exista o no en el
1066
- // SDK) no se emite: un id inexistente cuelga el subagent. Se omite con aviso,
1067
- // nunca en silencio. El uso de MCP NO se infiere desde claves desconocidas del
1068
- // mapa `tools`: se expresa por `mcpServers`/`.agents/mcp_config.json` (D6).
1069
- console.error(`skip tool '${key}': no verified Antigravity id for agent '${name}'`)
1070
- }
1071
-
1072
- const mode = frontmatterValue(byKey('mode'))
1073
- const mainAgent = mode === 'subagent' ? 'false' : 'true'
1074
- const subagent = mode === 'primary' ? 'false' : 'true'
1075
-
1076
- // Orden determinista: name, description, tools, mainAgent, subagent, model,
1077
- // commandExecutionPolicy, mcpServers, skills, y luego las claves extra conservadas.
1078
- const out = [{ key: 'name', lines: [`name: ${name}`] }]
1079
- const description = byKey('description')
1080
- if (description) out.push(description)
1081
- out.push({ key: 'tools', lines: [`tools: [${toolIds.join(', ')}]`] })
1082
- out.push({ key: 'mainAgent', lines: [`mainAgent: ${mainAgent}`] })
1083
- out.push({ key: 'subagent', lines: [`subagent: ${subagent}`] })
1084
- out.push({ key: 'model', lines: [`model: inherit`] })
1085
- out.push({ key: 'commandExecutionPolicy', lines: [`commandExecutionPolicy: sandbox`] })
1086
- const mcpServers = byKey('mcpServers')
1087
- if (mcpServers) out.push(mcpServers)
1088
- const skills = byKey('skills')
1089
- if (skills) out.push(skills)
1090
- out.push(...kept)
1091
- parsed.entries = out
1092
- return serializeFrontmatter(parsed)
1093
- }
1094
-
1095
- function adaptFrontmatter(content, host, assetKind, name) {
1096
- if (assetKind !== 'agents' || !AGENT_ADAPTER_HOSTS.has(host)) return content
1097
- if (host === 'antigravity') return adaptAntigravityFrontmatter(content, name)
1098
- const parsed = parseFrontmatter(content)
1099
- if (!parsed.hasFrontmatter) return content
1100
- parsed.entries = parsed.entries.filter((e) => !(e.key && AGENT_ADAPTER_DROP.has(e.key)))
1101
- return serializeFrontmatter(parsed)
1102
- }
1103
-
1104
959
  // D12: asset no soportado -> no se escribe y se avisa a stderr (no bloqueante).
1105
960
  function warnUnsupportedAsset(asset, agent) {
1106
961
  console.error(`skip ${asset}: not supported by host '${agent}'`)
@@ -1208,6 +1063,18 @@ function installedExtFor(cat, dirRel) {
1208
1063
  return null
1209
1064
  }
1210
1065
 
1066
+ // Host dueno de un directorio instalado por categoria). Para agents los
1067
+ // destinos son unicos por host (vscode escribe `.github/agents`, copilot
1068
+ // `.github/prompts`), pero commands comparte `.github/prompts` entre ambos:
1069
+ // se devuelve el primer match (chequeamos solo agents en el frontmatter check).
1070
+ function hostForDir(cat, dirRel) {
1071
+ for (const [host, t] of Object.entries(AGENT_TARGETS)) {
1072
+ const spec = t[cat]
1073
+ if (spec && spec.dir === dirRel) return host
1074
+ }
1075
+ return null
1076
+ }
1077
+
1211
1078
  // D9: hosts realmente instalados derivados del manifiesto (union de destinos
1212
1079
  // escritos), no de un unico `rc.agent`. Un proyecto multi-host debe sumar los
1213
1080
  // esperados de todos los hosts para no reportar huerfanos falsos.
@@ -1497,15 +1364,22 @@ async function install(args) {
1497
1364
  }
1498
1365
  if (!language) language = 'auto'
1499
1366
 
1500
- if (projectDir && !agent) agent = DEFAULT_AGENT
1367
+ if (projectDir && !agent) {
1368
+ if (existingRc?.agent && !SUPPORTED_AGENTS.includes(existingRc.agent)) {
1369
+ console.error(`ancleto: advertencia: agente '${existingRc.agent}' no soportado, usando '${DEFAULT_AGENT}'`)
1370
+ }
1371
+ agent = DEFAULT_AGENT
1372
+ }
1501
1373
  if (!tier) tier = TIERS[storedTier] ? storedTier : 'normal'
1502
1374
 
1503
1375
  let gratisModelChoice = null
1376
+ let gratisModelOrigin = null
1504
1377
  if (tier === 'gratis') {
1505
1378
  const env = envGratisModel()
1506
1379
  const persisted = isKnownGratisModel(existingRc?.gratisModel) ? existingRc.gratisModel : null
1507
1380
  if (env || persisted) {
1508
1381
  gratisModelChoice = env || persisted
1382
+ gratisModelOrigin = env ? 'variable de entorno ANCLETO_MUSE_SPARK' : 'valor guardado en .ancletorc'
1509
1383
  } else if (process.stdout.isTTY) {
1510
1384
  if (!bannerShown) await showBanner()
1511
1385
  const answer = await selectOption('Muse Spark 1.3 Free disponible en tu cuenta?', ['No (usar big-pickle)', 'Si (Muse Spark)'], 0)
@@ -1513,6 +1387,7 @@ async function install(args) {
1513
1387
  gratisModelChoice = answer.startsWith('Si') ? MUSE_SPARK_MODEL : GRATIS_FALLBACK_MODEL
1514
1388
  } else {
1515
1389
  gratisModelChoice = gratisModel()
1390
+ gratisModelOrigin = 'detección automática'
1516
1391
  }
1517
1392
  }
1518
1393
 
@@ -1570,7 +1445,7 @@ async function install(args) {
1570
1445
  : `${target} (disponible en todos tus proyectos)`
1571
1446
  console.log(`ancleto: instalado en ${loc}`)
1572
1447
  console.log(`ancleto: tier de costo de agents: ${tier}`)
1573
- if (tier === 'gratis') console.log(`ancleto: modelo gratis: ${models.orchestrator}`)
1448
+ if (tier === 'gratis') console.log(`ancleto: modelo gratis: ${models.orchestrator}${gratisModelOrigin ? ` (${gratisModelOrigin})` : ''}`)
1574
1449
  if (res.added.length) {
1575
1450
  console.log(`ancleto: MCP configurados: ${res.added.join(', ')} en ${res.file}`)
1576
1451
  }
@@ -1683,9 +1558,26 @@ async function initProject(args) {
1683
1558
  // Paridad con install: el tier elegido tambien reescribe los modelos de los
1684
1559
  // agentes locales. Antes quedaba huerfano (tier declarado, modelos de otro tier).
1685
1560
  let gratisModelChoice = null
1561
+ let gratisModelOrigin = null
1686
1562
  if (tier === 'gratis') {
1563
+ const env = envGratisModel()
1687
1564
  const persisted = isKnownGratisModel(existing?.gratisModel) ? existing.gratisModel : null
1688
- gratisModelChoice = persisted || gratisModel()
1565
+ if (env) {
1566
+ gratisModelChoice = env
1567
+ gratisModelOrigin = 'variable de entorno ANCLETO_MUSE_SPARK'
1568
+ } else if (isInteractive) {
1569
+ // En menú interactivo siempre se pregunta: el valor guardado (o la
1570
+ // detección) solo pre-selecciona el default, nunca se aplica en silencio.
1571
+ const defIdx = (persisted || gratisModel()) === MUSE_SPARK_MODEL ? 1 : 0
1572
+ const answer = await selectOption('Muse Spark 1.3 Free disponible en tu cuenta?', ['No (usar big-pickle)', 'Si (Muse Spark)'], defIdx)
1573
+ gratisModelChoice = answer.startsWith('Si') ? MUSE_SPARK_MODEL : GRATIS_FALLBACK_MODEL
1574
+ } else if (persisted) {
1575
+ gratisModelChoice = persisted
1576
+ gratisModelOrigin = 'valor guardado en .ancletorc'
1577
+ } else {
1578
+ gratisModelChoice = gratisModel()
1579
+ gratisModelOrigin = 'detección automática'
1580
+ }
1689
1581
  }
1690
1582
  const written = await installAgentAssets(projectDir, agent)
1691
1583
  if (profile === 'test') await installProfileOverlay(projectDir, agent)
@@ -1717,6 +1609,7 @@ async function initProject(args) {
1717
1609
  if (azure.enabled) console.log(AZURE_MCP_NOTICE)
1718
1610
  if (mcpRes.added.length) console.log(`ancleto: MCP configurados: ${mcpRes.added.join(', ')} en ${mcpRes.file}`)
1719
1611
  console.log(`ancleto: .ancletorc actualizado en ${projectDir} (v${manifest.version})${azure.enabled ? ' (Azure habilitado)' : ' (Azure desactivado)'} (Agente: ${agent})${tier ? ` (Tier: ${tier})` : ''} (Idioma: ${language})`)
1612
+ if (tier === 'gratis' && gratisModelChoice) console.log(`ancleto: modelo gratis: ${gratisModelChoice}${gratisModelOrigin ? ` (${gratisModelOrigin})` : ''}`)
1720
1613
  }
1721
1614
 
1722
1615
  const DEFAULT_IGNORES = ['node_modules', '.git', 'dist']
@@ -2142,6 +2035,88 @@ async function memoryListCmd(flags) {
2142
2035
  console.log(`ancleto: ${nodes.length} nodo(s)`)
2143
2036
  }
2144
2037
 
2038
+ async function memoryExport(flags) {
2039
+ const out = flagValue(flags, '--out')
2040
+ const dbPath = defaultMemoryDbPath()
2041
+ if (!(await exists(dbPath))) {
2042
+ console.error(`ancleto: no hay memoria en este repo (${dbPath})`)
2043
+ process.exit(1)
2044
+ }
2045
+ const engine = createMemoryEngine(dbPath)
2046
+ try {
2047
+ const nodes = engine.exportActive()
2048
+ const json = JSON.stringify(nodes, null, 2)
2049
+ if (out) {
2050
+ await writeFile(resolve(out), json + '\n')
2051
+ console.log(`ancleto: exportados ${nodes.length} nodo(s) a ${out}`)
2052
+ } else {
2053
+ console.log(json)
2054
+ }
2055
+ } finally {
2056
+ engine.close()
2057
+ }
2058
+ }
2059
+
2060
+ async function memoryImport(flags) {
2061
+ const positional = flags.find((a) => !a.startsWith('-'))
2062
+ if (!positional) {
2063
+ console.error('ancleto: uso: ancleto memory import <archivo>')
2064
+ process.exit(1)
2065
+ }
2066
+ const file = resolve(positional)
2067
+ if (!(await exists(file))) {
2068
+ console.error(`ancleto: no existe el archivo: ${file}`)
2069
+ process.exit(1)
2070
+ }
2071
+ let json
2072
+ try {
2073
+ json = JSON.parse((await readFile(file, 'utf8')).replace(/^\uFEFF/, ''))
2074
+ } catch (err) {
2075
+ console.error(`ancleto: no se pudo parsear ${file} como JSON: ${err.message}`)
2076
+ process.exit(1)
2077
+ }
2078
+ const dbPath = defaultMemoryDbPath()
2079
+ const engine = createMemoryEngine(dbPath)
2080
+ try {
2081
+ const summary = engine.importNodes(json)
2082
+ console.log(`ancleto: import OK -> insertados:${summary.inserted} actualizados:${summary.updated} omitidos:${summary.skipped}`)
2083
+ } finally {
2084
+ engine.close()
2085
+ }
2086
+ }
2087
+
2088
+ async function memoryGc(flags) {
2089
+ const dryRun = flags.includes('--dry-run')
2090
+ const daysRaw = flagValue(flags, '--days')
2091
+ let days
2092
+ if (daysRaw !== null) {
2093
+ days = Number(daysRaw)
2094
+ if (!Number.isFinite(days) || days <= 0 || !Number.isInteger(days)) {
2095
+ console.error(`ancleto: --days invalido: ${daysRaw} (entero positivo)`)
2096
+ process.exit(1)
2097
+ }
2098
+ }
2099
+ const dbPath = defaultMemoryDbPath()
2100
+ if (!(await exists(dbPath))) {
2101
+ console.error(`ancleto: no hay memoria en este repo (${dbPath})`)
2102
+ process.exit(0)
2103
+ }
2104
+ const engine = createMemoryEngine(dbPath)
2105
+ try {
2106
+ const result = engine.gcSuperseded({ dryRun, days })
2107
+ if (result.dryRun) {
2108
+ console.log(`ancleto: gc --dry-run (umbral ${result.days} dias) -> ${result.nodes} nodo(s), ~${result.estimated_bytes} bytes a liberar`)
2109
+ } else {
2110
+ console.log(`ancleto: gc (umbral ${result.days} dias) -> ${result.removed} nodo(s) purgados + VACUUM + REINDEX`)
2111
+ if (result.vacuumError) {
2112
+ console.error(`ancleto: advertencia: VACUUM/REINDEX falló: ${result.vacuumError}`)
2113
+ }
2114
+ }
2115
+ } finally {
2116
+ engine.close()
2117
+ }
2118
+ }
2119
+
2145
2120
  async function memoryCmd(args) {
2146
2121
  const [sub, ...flags] = args
2147
2122
  if (sub === 'context') {
@@ -2156,10 +2131,25 @@ async function memoryCmd(args) {
2156
2131
  await memoryDoctorCmd(flags)
2157
2132
  return
2158
2133
  }
2134
+ if (sub === 'export') {
2135
+ await memoryExport(flags)
2136
+ return
2137
+ }
2138
+ if (sub === 'import') {
2139
+ await memoryImport(flags)
2140
+ return
2141
+ }
2142
+ if (sub === 'gc') {
2143
+ await memoryGc(flags)
2144
+ return
2145
+ }
2159
2146
  console.error(`ancleto: subcomando de memory desconocido: ${sub || '(ninguno)'}`)
2160
2147
  console.error('ancleto: uso: ancleto memory context [--scope X] [--out file]')
2161
2148
  console.error('ancleto: uso: ancleto memory list [--type X] [--scope X] [--all] [--json]')
2162
2149
  console.error('ancleto: uso: ancleto memory doctor [--rebuild]')
2150
+ console.error('ancleto: uso: ancleto memory export [--out <archivo>]')
2151
+ console.error('ancleto: uso: ancleto memory import <archivo>')
2152
+ console.error('ancleto: uso: ancleto memory gc [--dry-run] [--days <n>]')
2163
2153
  process.exit(1)
2164
2154
  }
2165
2155
 
@@ -2219,6 +2209,13 @@ async function checkCommand() {
2219
2209
  console.log(` ⚠ ${dirRel}/${f} (huerfano)`)
2220
2210
  orphans++
2221
2211
  }
2212
+ // T3 (delta `frontmatter-adapters`): validacion no bloqueante del frontmatter
2213
+ // de agents contra el adaptador. Solo aplica a `cat === 'agents'` y a archivos
2214
+ // esperados (los huérfanos ya quedan avisados). Reporta como warning (⚠), no como
2215
+ // faltante (✖): el exit code NO se ve alterado por divergencias (solo por faltantes).
2216
+ if (cat === 'agents') {
2217
+ await checkAgentsFrontmatter(cwd, dirRel, actual, expected, ext)
2218
+ }
2222
2219
  if (missingFiles.length === 0 && orphanFiles.length === 0) {
2223
2220
  console.log(` ✔ ${dirRel} (${actual.length} archivos)`)
2224
2221
  }
@@ -2232,6 +2229,52 @@ async function checkCommand() {
2232
2229
  process.exit(missing > 0 ? 1 : 0)
2233
2230
  }
2234
2231
 
2232
+ // T3 (delta `frontmatter-adapters`): para cada `.md` de un directorio de agents
2233
+ // instalado, compara el contenido contra `adaptFrontmatter(source, host, 'agents', name)`.
2234
+ // Para opencode tambien aplica la sustitucion de `model:` del tier (paso del install que
2235
+ // se ejecuta despues del adaptador). Para copilot + orchestrator suma `COPILOT_MODEL_NOTE`.
2236
+ // Reporta divergencia como warning (no bloqueante). No compara archivos huérfanos (ya
2237
+ // quedan avisados arriba) ni archivos sin origen comparable.
2238
+ async function checkAgentsFrontmatter(cwd, dirRel, actual, expected, fileExt) {
2239
+ const host = hostForDir('agents', dirRel)
2240
+ if (!host) return
2241
+ // Mapa de modelos aplicado por install en opencode (model: <x> -> tier).
2242
+ // Para 'gratis' usa el gratisModel persistido en .ancletorc.
2243
+ let tierModelsMap = null
2244
+ if (host === 'opencode') {
2245
+ const tierFile = join(cwd, '.opencode', '.ancleto-tier')
2246
+ if (await exists(tierFile)) {
2247
+ const tier = (await readFile(tierFile, 'utf8')).trim()
2248
+ if (TIERS[tier]) {
2249
+ const rc = await readAncletorc(cwd)
2250
+ const persisted = isKnownGratisModel(rc?.gratisModel) ? rc.gratisModel : null
2251
+ tierModelsMap = tierModels(tier, TIERS, persisted)
2252
+ }
2253
+ }
2254
+ }
2255
+ const nameFromFile = (f) => fileExt ? f.slice(0, -fileExt.length) : f.replace(/\.md$/, '')
2256
+ for (const f of actual) {
2257
+ if (!expected.includes(f)) continue
2258
+ const name = nameFromFile(f)
2259
+ const srcPath = join(ROOT, 'agents', `${name}.md`)
2260
+ if (!(await exists(srcPath))) continue
2261
+ let source = await readFile(srcPath, 'utf8')
2262
+ // Para opencode + tier aplicado, simular la sustitucion que hace applyTier
2263
+ // sobre el campo `model:`. El resto lo gestiona el adaptador (identidad).
2264
+ if (tierModelsMap && tierModelsMap[name]) {
2265
+ source = source.replace(/^model: .*$/m, `model: ${tierModelsMap[name]}`)
2266
+ }
2267
+ let expectedContent = adaptFrontmatter(source, host, 'agents', name)
2268
+ if (host === 'copilot' && name === 'orchestrator') {
2269
+ expectedContent += COPILOT_MODEL_NOTE
2270
+ }
2271
+ const installed = await readFile(join(cwd, dirRel, f), 'utf8')
2272
+ if (installed !== expectedContent) {
2273
+ console.log(` ⚠ ${dirRel}/${f} (frontmatter diverge del adaptador para host '${host}')`)
2274
+ }
2275
+ }
2276
+ }
2277
+
2235
2278
  // Avisa si hay un tier del proyecto sin agentes locales donde aplicarlo:
2236
2279
  // el tier solo se aplica a los agentes instalados en el proyecto, no a los globales.
2237
2280
  async function checkTierOrphan(cwd) {
@@ -0,0 +1,186 @@
1
+ // Modulo puro de adaptacion de frontmatter (D2/D3/D4/D5/D8). Dueno unico de la
2
+ // transformacion del frontmatter: ningun otro punto del CLI SHALL transformarlo.
3
+ // Sin E/S, sin dependencias externas, sin estado global.
4
+ //
5
+ // Contrato publico: `adaptFrontmatter(content, host, assetKind, name)` retorna
6
+ // el contenido completo (frontmatter adaptado + body verbatim). `parseFrontmatter`
7
+ // y `serializeFrontmatter` se re-exportan para que `commandToSkill` en el CLI los
8
+ // pueda usar sin redefinirlos.
9
+
10
+ export const AGENT_ADAPTER_DROP = new Set(['mode', 'color', 'temperature', 'permission', 'model', 'tools'])
11
+
12
+ // Hosts conocidos por el modulo. Cualquier host fuera de este conjunto cae en la
13
+ // rama `unknown` (no aborta; emite aviso a stderr y retorna el contenido sin
14
+ // cambios).
15
+ const KNOWN_HOSTS = new Set(['opencode', 'claude', 'vscode', 'antigravity', 'cursor', 'roo', 'copilot'])
16
+
17
+ // Hosts para los que existe una transformacion documentada de agents
18
+ // (distinta de identidad). Cursor y Roo viven fuera: no hay formato de frontmatter
19
+ // propio, así que la adaptación es passthrough con aviso.
20
+ const TRANSFORM_AGENTS = new Set(['claude', 'vscode', 'antigravity', 'copilot'])
21
+
22
+ // Tabla unica (fijada contra la tabla oficial de frontmatter de Custom
23
+ // Subagents) de ids de Antigravity. Conjunto CERRADO: solo se emiten estos ids.
24
+ // Los ids solo-SDK (`find_file`, `edit_file`, `search_web`, `read_url_content`,
25
+ // ...), los de comunidad (`write_to_file`, `call_mcp_tool`,
26
+ // `multi_replace_file_content`, ...) y los de delegacion (`invoke_subagent`,
27
+ // `start_subagent`, `define_subagent`) NO se emiten: no estan confirmados en el
28
+ // frontmatter y un id inexistente cuelga el subagent (Known Issue). Una clave no
29
+ // listada aqui se omite con aviso a stderr, nunca en silencio.
30
+ export const ANTIGRAVITY_TOOL_MAP = {
31
+ read: 'view_file',
32
+ edit: 'replace_file_content',
33
+ grep: 'grep_search',
34
+ bash: 'run_command',
35
+ todowrite: 'manage_task'
36
+ }
37
+
38
+ // Parser propio sin dependencias. Cada entrada conserva su clave y sus lineas
39
+ // crudas (incluye block scalars y mapas anidados), preservando orden.
40
+ export function parseFrontmatter(content) {
41
+ const m = /^(---\r?\n)([\s\S]*?)(\r?\n---)(\r?\n?)([\s\S]*)$/.exec(content)
42
+ if (!m) return { hasFrontmatter: false, content }
43
+ const entries = []
44
+ for (const line of m[2].split(/\r?\n/)) {
45
+ if (/^[A-Za-z0-9_-]+:/.test(line)) {
46
+ entries.push({ key: line.slice(0, line.indexOf(':')), lines: [line] })
47
+ } else if (entries.length) {
48
+ entries[entries.length - 1].lines.push(line)
49
+ } else {
50
+ entries.push({ key: null, lines: [line] })
51
+ }
52
+ }
53
+ return { hasFrontmatter: true, open: m[1], entries, close: m[3], nl: m[4], body: m[5] }
54
+ }
55
+
56
+ export function serializeFrontmatter(parsed) {
57
+ if (!parsed.hasFrontmatter) return parsed.content
58
+ const inner = parsed.entries.flatMap((e) => e.lines).join('\n')
59
+ return `${parsed.open}${inner}${parsed.close}${parsed.nl}${parsed.body}`
60
+ }
61
+
62
+ // Hijos directos del mapa `tools:` con su valor crudo (p. ej. `read: true`).
63
+ function parseToolFlags(entry) {
64
+ const flags = []
65
+ if (!entry) return flags
66
+ let childIndent = null
67
+ for (const line of entry.lines.slice(1)) {
68
+ const m = /^(\s+)([A-Za-z0-9_-]+):\s*(.*?)\s*$/.exec(line)
69
+ if (!m) continue
70
+ if (childIndent === null) childIndent = m[1].length
71
+ if (m[1].length !== childIndent) continue
72
+ flags.push({ key: m[2], value: m[3] })
73
+ }
74
+ return flags
75
+ }
76
+
77
+ function frontmatterValue(entry, fallback = null) {
78
+ if (!entry) return fallback
79
+ const m = /^[A-Za-z0-9_-]+:\s*(.*?)\s*$/.exec(entry.lines[0] || '')
80
+ return m ? m[1] : fallback
81
+ }
82
+
83
+ // D2/D3/D5/D8: adapta el frontmatter de un agent de opencode al de Antigravity.
84
+ // `name` (requerido por el host) se inyecta desde el nombre del archivo. `tools` se
85
+ // emite siempre como lista (el default del host es la lista vacia). Nunca se inventa
86
+ // un id: una clave sin id verificado se omite con aviso (un id inexistente cuelga el
87
+ // subagent).
88
+ function adaptAntigravityFrontmatter(content, name) {
89
+ const parsed = parseFrontmatter(content)
90
+ if (!parsed.hasFrontmatter) return content
91
+ const byKey = (k) => parsed.entries.find((e) => e.key === k)
92
+ // Claves que el adaptador gestiona: las dropeadas (AGENT_ADAPTER_DROP), las que
93
+ // re-emite (name/tools/mainAgent/subagent/model/commandExecutionPolicy) y los campos
94
+ // que preserva del origen (description/skills/mcpServers). Se filtran TODAS del
95
+ // conjunto conservado para que ninguna pueda quedar duplicada, y se re-emiten una
96
+ // sola vez en orden determinista, sin importar lo que declare la fuente.
97
+ const managed = new Set([
98
+ ...AGENT_ADAPTER_DROP,
99
+ 'name',
100
+ 'description',
101
+ 'mainAgent',
102
+ 'subagent',
103
+ 'commandExecutionPolicy',
104
+ 'mcpServers',
105
+ 'skills'
106
+ ])
107
+ // Se conservan las claves extra (no gestionadas); los campos preservados se
108
+ // re-emiten abajo desde su primera ocurrencia en el origen.
109
+ const kept = parsed.entries.filter((e) => e.key && !managed.has(e.key))
110
+
111
+ const toolIds = []
112
+ for (const { key, value } of parseToolFlags(byKey('tools'))) {
113
+ if (value === 'false') continue
114
+ if (key === 'skill') continue // no es tool: se cubre por `skills`/surfaceo automatico
115
+ const id = ANTIGRAVITY_TOOL_MAP[key]
116
+ if (id) {
117
+ toolIds.push(id)
118
+ continue
119
+ }
120
+ // Regla D3/D5. Un id fuera del frontmatter confirmado (exista o no en el
121
+ // SDK) no se emite: un id inexistente cuelga el subagent. Se omite con aviso,
122
+ // nunca en silencio. El uso de MCP NO se infiere desde claves desconocidas del
123
+ // mapa `tools`: se expresa por `mcpServers`/`.agents/mcp_config.json`.
124
+ console.error(`skip tool '${key}': no verified Antigravity id for agent '${name}'`)
125
+ }
126
+
127
+ const mode = frontmatterValue(byKey('mode'))
128
+ const mainAgent = mode === 'subagent' ? 'false' : 'true'
129
+ const subagent = mode === 'primary' ? 'false' : 'true'
130
+
131
+ // Orden determinista: name, description, tools, mainAgent, subagent, model,
132
+ // commandExecutionPolicy, mcpServers, skills, y luego las claves extra conservadas.
133
+ const out = [{ key: 'name', lines: [`name: ${name}`] }]
134
+ const description = byKey('description')
135
+ if (description) out.push(description)
136
+ out.push({ key: 'tools', lines: [`tools: [${toolIds.join(', ')}]`] })
137
+ out.push({ key: 'mainAgent', lines: [`mainAgent: ${mainAgent}`] })
138
+ out.push({ key: 'subagent', lines: [`subagent: ${subagent}`] })
139
+ out.push({ key: 'model', lines: [`model: inherit`] })
140
+ out.push({ key: 'commandExecutionPolicy', lines: [`commandExecutionPolicy: sandbox`] })
141
+ const mcpServers = byKey('mcpServers')
142
+ if (mcpServers) out.push(mcpServers)
143
+ const skills = byKey('skills')
144
+ if (skills) out.push(skills)
145
+ out.push(...kept)
146
+ parsed.entries = out
147
+ return serializeFrontmatter(parsed)
148
+ }
149
+
150
+ // Elimina las claves gestionadas por el adaptador (mode/color/temperature/
151
+ // permission/model/tools) preservando el resto y el body. Para claude, vscode y
152
+ // copilot. Si no hay frontmatter, retorna el contenido sin cambios.
153
+ function dropManagedKeys(content) {
154
+ const parsed = parseFrontmatter(content)
155
+ if (!parsed.hasFrontmatter) return content
156
+ parsed.entries = parsed.entries.filter((e) => !(e.key && AGENT_ADAPTER_DROP.has(e.key)))
157
+ return serializeFrontmatter(parsed)
158
+ }
159
+
160
+ // Punto de entrada publico. Funcion pura (D2/D4): `(content, host, assetKind, name)`
161
+ // → string (contenido completo con frontmatter adaptado + body verbatim).
162
+ // Sin E/S: ni leer ni escribir archivos. Sin estado global mutable.
163
+ //
164
+ // Reglas de dispatch (preservadas del spec `skill-frontmatter-adapters` y
165
+ // extendidas por este change):
166
+ // opencode → identity (passthrough)
167
+ // antigravity + agents → adaptAntigravityFrontmatter(content, name)
168
+ // antigravity + (skills/cmds) → identity
169
+ // claude/vscode/copilot + ags → dropManagedKeys(content)
170
+ // claude/vscode/copilot + sk → identity
171
+ // cursor/roo → identity + aviso stderr (sin formato documentado)
172
+ // host desconocido → identity + aviso stderr (no aborta)
173
+ export function adaptFrontmatter(content, host, assetKind, name) {
174
+ if (!KNOWN_HOSTS.has(host)) {
175
+ console.error(`unknown agent '${host}', passthrough`)
176
+ return content
177
+ }
178
+ if (host === 'cursor' || host === 'roo') {
179
+ console.error(`no documented frontmatter adaptation for host '${host}'`)
180
+ return content
181
+ }
182
+ if (assetKind !== 'agents') return content
183
+ if (!TRANSFORM_AGENTS.has(host)) return content // opencode u otro conocido sin transform
184
+ if (host === 'antigravity') return adaptAntigravityFrontmatter(content, name)
185
+ return dropManagedKeys(content)
186
+ }
@@ -14,6 +14,23 @@ const SCOPE_HIERARCHY = {
14
14
  }
15
15
 
16
16
  const PUBLIC_COLUMNS = 'n.memory_key, n.type, n.scope, n.content, n.justification, n.created_at'
17
+ const GC_DEFAULT_DAYS = 30
18
+
19
+ // Sanitizacion best-effort de paths absolutos en texto libre. Cubre los casos
20
+ // pedidos por el spec: `C:\...` en Windows, `/home/...` y `/Users/...` en Unix.
21
+ // No es exhaustiva (se podria perder un path sin barra inicial o con `~`); el
22
+ // usuario debe revisar el archivo antes de compartirlo.
23
+ const ABS_PATH_PATTERNS = [
24
+ /[A-Za-z]:\\[^\s]*\S/g,
25
+ /\/home\/[^\s)]+/g,
26
+ /\/Users\/[^\s)]+/g
27
+ ]
28
+ function sanitizePaths(text) {
29
+ if (!text) return text
30
+ let out = String(text)
31
+ for (const re of ABS_PATH_PATTERNS) out = out.replace(re, '<redacted>')
32
+ return out
33
+ }
17
34
 
18
35
  const STOPWORDS = new Set([
19
36
  'como', 'cómo', 'the', 'a', 'an', 'de', 'del', 'la', 'el', 'los', 'las', 'un', 'una', 'unos', 'unas',
@@ -80,11 +97,23 @@ export function createReadonlyMemoryEngine(dbPath = defaultMemoryDbPath()) {
80
97
  function buildEngine(db) {
81
98
 
82
99
  const findActive = db.prepare(`SELECT id FROM memory_nodes WHERE memory_key = ? AND status = 'active'`)
100
+ const findActiveRow = db.prepare(`SELECT id, created_at FROM memory_nodes WHERE memory_key = ? AND status = 'active'`)
101
+ const countSuperseded = db.prepare(`SELECT COUNT(*) AS c FROM memory_nodes WHERE memory_key = ? AND status = 'superseded'`)
83
102
  const supersede = db.prepare(`UPDATE memory_nodes SET status = 'superseded', superseded_by = ? WHERE id = ? AND status = 'active'`)
84
103
  const insertNode = db.prepare(
85
104
  `INSERT INTO memory_nodes (id, memory_key, type, scope, status, content, justification, superseded_by, source, confidence, created_at)
86
105
  VALUES (?, ?, ?, ?, 'active', ?, ?, NULL, ?, ?, ?)`
87
106
  )
107
+ const listActiveAll = db.prepare(
108
+ `SELECT memory_key, type, scope, content, justification, created_at FROM memory_nodes WHERE status = 'active' ORDER BY created_at, rowid`
109
+ )
110
+ const gcSelect = db.prepare(
111
+ `SELECT id, length(content) + length(justification) AS size FROM memory_nodes
112
+ WHERE status = 'superseded' AND created_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-' || ? || ' days')`
113
+ )
114
+ const gcDelete = db.prepare(
115
+ `DELETE FROM memory_nodes WHERE status = 'superseded' AND created_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-' || ? || ' days')`
116
+ )
88
117
 
89
118
  function buildWorkingContext(scope, maxTokens = MAX_TOKENS, cwd = process.cwd()) {
90
119
  const scopes = SCOPE_HIERARCHY[scope] || [scope]
@@ -128,7 +157,7 @@ function buildEngine(db) {
128
157
  const lim = Math.min(Math.max(Number(limit) || 10, 1), 50)
129
158
  const base = `SELECT ${PUBLIC_COLUMNS} FROM memory_fts f JOIN memory_nodes n ON n.rowid = f.rowid
130
159
  WHERE memory_fts MATCH ? AND n.status = 'active'`
131
- const run = (match, loose) => {
160
+ const run = (match, _loose) => {
132
161
  const sql = type
133
162
  ? db.prepare(`${base} AND n.type = ? ORDER BY rank, n.rowid LIMIT ?`)
134
163
  : db.prepare(`${base} ORDER BY rank, n.rowid LIMIT ?`)
@@ -166,7 +195,7 @@ function buildEngine(db) {
166
195
  const id = randomUUID()
167
196
  const source = String(context.source || 'runtime')
168
197
  const confidence = Math.min(Math.max(Number(context.confidence ?? 1), 0), 1)
169
- const created_at = new Date().toISOString()
198
+ const created_at = String(input.createdAt || new Date().toISOString())
170
199
 
171
200
  db.exec('BEGIN IMMEDIATE')
172
201
  let prev = null
@@ -210,6 +239,127 @@ function buildEngine(db) {
210
239
  return rows.map((r) => ({ ...publicNode(r), status: r.status }))
211
240
  }
212
241
 
242
+ // Devuelve los nodos activos en el formato de export. Sanitiza paths
243
+ // absolutos en `content` y `justification` antes de serializar. Solo nodos
244
+ // activos: los superseded son historia interna y no se exportan.
245
+ function exportActive() {
246
+ const rows = listActiveAll.all()
247
+ return rows.map((r) => ({
248
+ memory_key: r.memory_key,
249
+ type: r.type,
250
+ content: sanitizePaths(r.content),
251
+ justification: sanitizePaths(r.justification),
252
+ scope: r.scope,
253
+ createdAt: r.created_at
254
+ }))
255
+ }
256
+
257
+ // Importa un array validado. Cada entrada hace upsert por memory_key. Por
258
+ // decision aprobada, los superseded son historia y NO se reactivan: si la
259
+ // clave solo existe en ese estado, se ignora la entrada. Si existe activa y el
260
+ // createdAt del JSON es mas reciente, supersede el existente; si es mas
261
+ // antiguo o igual, no-op (idempotencia). Valida schema antes de procesar y
262
+ // aborta todo el batch si alguna entrada es invalida.
263
+ function importNodes(json) {
264
+ if (!Array.isArray(json)) throw new Error('importNodes: el argumento debe ser un array')
265
+ const required = ['memory_key', 'type', 'content', 'scope', 'createdAt']
266
+ for (let i = 0; i < json.length; i++) {
267
+ const entry = json[i]
268
+ if (!entry || typeof entry !== 'object') throw new Error(`entry[${i}] invalida: no es objeto`)
269
+ for (const k of required) {
270
+ if (entry[k] === undefined || entry[k] === null || entry[k] === '') {
271
+ throw new Error(`entry[${i}] invalida: falta campo obligatorio "${k}"`)
272
+ }
273
+ }
274
+ if (entry.type !== 'rule' && entry.type !== 'decision') {
275
+ throw new Error(`entry[${i}].type invalido: ${entry.type}`)
276
+ }
277
+ if (typeof entry.createdAt !== 'string' || Number.isNaN(Date.parse(entry.createdAt))) {
278
+ throw new Error(`entry[${i}].createdAt invalido: ${entry.createdAt}`)
279
+ }
280
+ }
281
+
282
+ const summary = { inserted: 0, updated: 0, skipped: 0 }
283
+ db.exec('BEGIN IMMEDIATE')
284
+ try {
285
+ for (const entry of json) {
286
+ const memory_key = String(entry.memory_key).trim()
287
+ const active = findActiveRow.get(memory_key)
288
+ if (active) {
289
+ const incomingTs = Date.parse(entry.createdAt)
290
+ const existingTs = Date.parse(active.created_at)
291
+ if (incomingTs > existingTs) {
292
+ const newId = randomUUID()
293
+ supersede.run(newId, active.id)
294
+ insertNode.run(
295
+ newId, memory_key, entry.type, entry.scope,
296
+ String(entry.content), String(entry.justification || ''),
297
+ 'import', 1, entry.createdAt
298
+ )
299
+ summary.updated++
300
+ } else {
301
+ summary.skipped++
302
+ }
303
+ continue
304
+ }
305
+ const supersededCount = countSuperseded.get(memory_key).c
306
+ if (supersededCount > 0) {
307
+ // Decision aprobada: superseded es historia, no se reactiva.
308
+ summary.skipped++
309
+ continue
310
+ }
311
+ insertNode.run(
312
+ randomUUID(), memory_key, entry.type, entry.scope,
313
+ String(entry.content), String(entry.justification || ''),
314
+ 'import', 1, entry.createdAt
315
+ )
316
+ summary.inserted++
317
+ }
318
+ db.exec('COMMIT')
319
+ } catch (err) {
320
+ db.exec('ROLLBACK')
321
+ throw err
322
+ }
323
+ return summary
324
+ }
325
+
326
+ // Purga nodos superseded cuya antiguedad (proxy: created_at, ya que el
327
+ // schema no expone superseded_at) supere `opts.days`. Con `opts.dryRun` solo
328
+ // reporta conteo y tamano estimado sin modificar la DB. Sin dryRun ejecuta
329
+ // el DELETE en transaccion propia y luego VACUUM + REINDEX fuera (SQLite no
330
+ // permite VACUUM dentro de una transaccion; si falla la compactacion la BD
331
+ // sigue consistente, solo no se reempaqueta).
332
+ function gcSuperseded(opts = {}) {
333
+ const days = Number.isFinite(opts.days) && opts.days > 0 ? Math.floor(opts.days) : GC_DEFAULT_DAYS
334
+ const dryRun = !!opts.dryRun
335
+
336
+ if (dryRun) {
337
+ const rows = gcSelect.all(days)
338
+ const nodes = rows.length
339
+ const estimated_bytes = rows.reduce((acc, r) => acc + Number(r.size || 0), 0)
340
+ return { dryRun: true, days, nodes, estimated_bytes }
341
+ }
342
+
343
+ db.exec('BEGIN IMMEDIATE')
344
+ let removed = 0
345
+ try {
346
+ const result = gcDelete.run(days)
347
+ removed = Number(result.changes || 0)
348
+ db.exec('COMMIT')
349
+ } catch (err) {
350
+ db.exec('ROLLBACK')
351
+ throw err
352
+ }
353
+ try {
354
+ db.exec('VACUUM')
355
+ db.exec('REINDEX')
356
+ } catch (err) {
357
+ // VACUUM/REINDEX son best-effort: si fallan, la BD esta consistente.
358
+ return { dryRun: false, days, removed, vacuumError: err.message }
359
+ }
360
+ return { dryRun: false, days, removed }
361
+ }
362
+
213
363
  function checkpoint() {
214
364
  return checkpointDatabase(db)
215
365
  }
@@ -218,5 +368,5 @@ function buildEngine(db) {
218
368
  db.close()
219
369
  }
220
370
 
221
- return { buildWorkingContext, searchMemory, recordNode, listNodes, checkpoint, close }
371
+ return { buildWorkingContext, searchMemory, recordNode, listNodes, exportActive, importNodes, gcSuperseded, checkpoint, close }
222
372
  }