@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 +1 -1
- package/package.json +10 -2
- package/src/cli/index.js +201 -158
- package/src/core/adapters/frontmatter.js +186 -0
- package/src/core/memory/engine.js +153 -3
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
|
|
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.
|
|
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
|
-
|
|
78
|
-
|
|
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 {
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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
|
}
|