@ancleto/spec 0.9.1 → 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/LICENSE +21 -0
- package/README.md +12 -7
- package/commands/cleto-review.md +9 -0
- package/package.json +10 -2
- package/skills/ancleto-review/SKILL.md +80 -0
- package/src/cli/index.js +201 -158
- package/src/core/adapters/frontmatter.js +186 -0
- package/src/core/memory/engine.js +153 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Damian Argañarás, Lucas Bonch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
- [Características principales](#características-principales)
|
|
24
24
|
- [Requisitos](#requisitos)
|
|
25
25
|
- [Instalación](#instalación)
|
|
26
|
+
- [Instalación manual (sin npm)](docs/instalacion-manual.md)
|
|
26
27
|
- [Hosts soportados y layouts por agente](#hosts-soportados-y-layouts-por-agente)
|
|
27
28
|
- [Guía de .gitignore](#guía-de-gitignore)
|
|
28
29
|
- [Configuración y tiers de costo](#configuración-y-tiers-de-costo)
|
|
@@ -165,10 +166,11 @@ verificados de Antigravity (`read`→`view_file`, `edit`→`replace_file_content
|
|
|
165
166
|
tool de opencode sin id verificado se **omite con aviso** (`skip tool '<k>': no verified Antigravity id
|
|
166
167
|
for agent '<n>'`): un id inexistente cuelga el subagent. Los ids que sólo existen en el SDK
|
|
167
168
|
(`find_file`, `edit_file`, `search_web`, `read_url_content`, …), los de comunidad (`write_to_file`,
|
|
168
|
-
`
|
|
169
|
-
`
|
|
170
|
-
`
|
|
171
|
-
|
|
169
|
+
`multi_replace_file_content`, …) y los de delegación (`invoke_subagent`, `start_subagent`,
|
|
170
|
+
`define_subagent`) **no** se emiten. Las claves de MCP declaradas dentro de `tools` (`mcpServers`,
|
|
171
|
+
`mcp_*`, `call_mcp_tool`) caen en **omisión con aviso** y `call_mcp_tool` queda **prohibido**; el uso
|
|
172
|
+
de MCP se expresa por `mcpServers`/`.agents/mcp_config.json`, nunca como tool id. El campo `skill` no
|
|
173
|
+
es una tool: se cubre con el surfaceo automático de skills de Antigravity.
|
|
172
174
|
|
|
173
175
|
Los assets que un host no soporta (por ejemplo `agents`/`commands` de `cursor` y `roo`)
|
|
174
176
|
**no se escriben** y el instalador avisa por `stderr`
|
|
@@ -308,6 +310,8 @@ ancleto init # Crea .ancletorc, aspec/, AGENTS.md y PRODUCT.md
|
|
|
308
310
|
|
|
309
311
|
`init` **no pisa** lo que ya existe: si tu repo tiene su propio `AGENTS.md` o `PRODUCT.md`, los respeta (solo fusiona los bloques `<!-- LOCKED -->` del template). Además materializa `.ancleto/working-context.md` con tus reglas activas cuando hay memoria — es el bloque que el orquestador inyecta al arrancar.
|
|
310
312
|
|
|
313
|
+
Si el repo trae una carpeta legacy `openspec/`, `init`, `install --project` y `upgrade` la detectan y migran su contenido a `aspec/` **por copia**, sin prompts, cuando `aspec/` no existe o sólo tiene el scaffold: conservan `openspec/` como backup y escriben el marcador `aspec/.migrated-from-openspec`. Si `aspec/` ya tiene contenido real propio no fusionan las carpetas y avisan para revisión manual, y si el marcador ya existe la corrida es un no-op silencioso.
|
|
314
|
+
|
|
311
315
|
> ¿Querés que el proyecto lleve **su propia** copia de agents/skills (sin depender de la instalación global)? Sumá `ancleto install --project .`.
|
|
312
316
|
|
|
313
317
|
### 2. Descubrir contexto técnico
|
|
@@ -409,7 +413,7 @@ Funciona en tres piezas que comparten el mismo archivo:
|
|
|
409
413
|
|
|
410
414
|
Cada entrada tiene un `scope`: `project` (default), `feature` o `task`. Solo las **reglas** activas con scope `project` entran en `<ProjectMemoryRules>`; las **decisiones** con scope `project` se recuperan reactivamente con `searchMemory` y no se inyectan de forma proactiva. El orquestador inyecta los bloques `<ProjectMemoryRules>` y `<ProjectTopology>` en el system prompt de tu agente desde el día cero —incluso sin reglas previas— para que no repita errores ya resueltos.
|
|
411
415
|
|
|
412
|
-
> El mismo motor se puede consultar a mano desde la terminal: `ancleto memory context --scope project`. El archivo `.ancleto/working-context.md` que lee el orquestador se regenera
|
|
416
|
+
> El mismo motor se puede consultar a mano desde la terminal: `ancleto memory context --scope project`. El archivo `.ancleto/working-context.md` que lee el orquestador se regenera en los lifecycle points (`init`, `install --project`, `upgrade` y `memory context --out`) y también **on-write**, cuando una escritura MCP lo altera: al registrar una `rule` con scope `project` o al superseder una `memory_key` existente (sin importar `type` ni `scope`).
|
|
413
417
|
|
|
414
418
|
**Dos memorias, sin mezcla.** La memoria **del repositorio** (esta, `.ancleto/memory.db`) guarda reglas y decisiones del proyecto y se comparte con el equipo. Si el runtime expone además una memoria **del agente** (por ejemplo engram, habilitable con `--with-engram`), esa guarda notas de sesión, no del repo. **Una entrada vive en una sola**: si un futuro agente del equipo debería encontrarla, va al repo.
|
|
415
419
|
|
|
@@ -425,7 +429,8 @@ Una vez instalado, tu IDE expone el ciclo de vida completo como comandos barra:
|
|
|
425
429
|
| `/cleto-propose` | Proponer el diseño técnico. |
|
|
426
430
|
| `/cleto-ff` | Avanzar rápido con el contexto ya recuperado. |
|
|
427
431
|
| `/cleto-apply` | Aplicar el código del change. |
|
|
428
|
-
| `/cleto-verify` | Verificar reglas, tests y memoria antes de cerrar. |
|
|
432
|
+
| `/cleto-verify` | Verificar reglas, tests y memoria antes de cerrar. En modo `aspec Change`, cuando el proposal declara garantías de efecto sobre estado, verifica además que se sostengan bajo el modelo real; si no, emite `GUARANTEE NOT SUSTAINED` (`CRITICAL`) y el orquestador bloquea el archive. |
|
|
433
|
+
| `/cleto-review` | Revisar código del scope por duplicación, redundancia, mala ubicación y código sin uso. |
|
|
429
434
|
| `/cleto-security` | Realizar una auditoría de seguridad integral en el proyecto o change. |
|
|
430
435
|
| `/cleto-sync` | Sincronizar las specs con el estado del repositorio. |
|
|
431
436
|
| `/cleto-archive` | Archivar el change y registrar aprendizajes. |
|
|
@@ -497,6 +502,6 @@ Los 10 agentes instalados y el modelo que usa cada uno según tu tier. Al instal
|
|
|
497
502
|
|
|
498
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.
|
|
499
504
|
|
|
500
|
-
**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).
|
|
501
506
|
|
|
502
507
|
> `ANCLETO_MUSE_SPARK=1` fuerza Muse Spark, `ANCLETO_MUSE_SPARK=0` fuerza `big-pickle`. Tiene prioridad sobre la elección guardada.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Review scoped code for duplication, redundancy, misplacement and dead code
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Invoke the `ancleto-review` skill with the Skill tool and follow it exactly; do not improvise steps.
|
|
6
|
+
|
|
7
|
+
**Input**: The argument after `/cleto-review` is the scope to review: a ticket reference (e.g. `#123`, resolved to commits whose message contains it), a git range (e.g. `main..HEAD`), or paths/directories. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available scopes.
|
|
8
|
+
|
|
9
|
+
Work Item grounding (when the request references a Work Item) is owned by `@context-resolver`.
|
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
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ancleto-review
|
|
3
|
+
description: Review user-scoped code for duplication, redundancy, misplacement and dead code. Reports findings with file:line refs without modifying code. No external binaries required.
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: No external binaries required. Works with any agent runtime.
|
|
6
|
+
metadata:
|
|
7
|
+
author: ancleto
|
|
8
|
+
version: '1.0'
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# aspec Review
|
|
12
|
+
|
|
13
|
+
Review code quality inside a user-declared scope. Read-only: never write or edit code.
|
|
14
|
+
|
|
15
|
+
**Input**: the scope to review — free text after `/cleto-review`. Forms (first match wins): `#<n>` (commits whose message contains `#<n>`), an explicit git range (`A..B`, `HEAD~n`), existing paths/directories, or empty (infer from context). If ambiguous, list candidate scopes and ask. Never guess.
|
|
16
|
+
|
|
17
|
+
**IMPORTANT**: Do NOT guess or auto-select a scope when ambiguous. Always let the user choose. Work Item grounding (when the request references a Work Item) is owned by `@context-resolver` — treat a resolved item as `#<n>`.
|
|
18
|
+
|
|
19
|
+
## Steps
|
|
20
|
+
|
|
21
|
+
### 1. Resolve the scope
|
|
22
|
+
|
|
23
|
+
- `#<n>` → `git log --grep="#<n>" --format=%H`, then the diff of each commit (`H^ H`). Only code introduced there is in scope.
|
|
24
|
+
- Range (`A..B`, `HEAD~n`) → `git diff` of that range.
|
|
25
|
+
- Paths → worktree diff filtered to those paths.
|
|
26
|
+
- Nothing usable → list `aspec/changes/` dirs with `tasks.md` and recent `git log` subjects as candidates, and ask.
|
|
27
|
+
|
|
28
|
+
The result is a concrete file:line list. Report ONLY on that list.
|
|
29
|
+
|
|
30
|
+
### 2. Collect evidence (reading outside the scope is allowed)
|
|
31
|
+
|
|
32
|
+
Read outside the diff solely as comparison evidence: twin definitions (redundancy), call/import references (dead code), and the project map (`PRODUCT.md`, directory structure, `aspec/specs/`) for placement. Never report findings outside the scope.
|
|
33
|
+
|
|
34
|
+
### 3. Run the four detectors
|
|
35
|
+
|
|
36
|
+
**Duplicated code:** twin blocks inside the scope doing the same logic → CRITICAL: "Duplicated block: `<file:line>` and `<file:line>`" + "Extract to a shared helper".
|
|
37
|
+
|
|
38
|
+
**Redundant method:** scope logic already implemented outside the diff → WARNING: "Redundant with `<external-file:line>`" + "Reuse `<name>` instead of duplicating". If the difference is subtle or uncertain, downgrade to SUGGESTION.
|
|
39
|
+
|
|
40
|
+
**Misplaced code:** scope code contradicting the project map (e.g. business logic in a handler, stray util in a component) → WARNING or SUGGESTION: "Misplaced code: `<file:line>` belongs in `<location>`" + reason. When the architecture is unclear, prefer SUGGESTION.
|
|
41
|
+
|
|
42
|
+
**Dead code:** scope functions/imports with no references in the repo (search calls and imports) → CRITICAL when proven (no references at all): "Unused code: `<file:line>`" + "Remove it". Otherwise SUGGESTION.
|
|
43
|
+
|
|
44
|
+
### 4. Review Report
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
## Review: <scope description>
|
|
48
|
+
|
|
49
|
+
### CRITICAL
|
|
50
|
+
- ...
|
|
51
|
+
|
|
52
|
+
### WARNING
|
|
53
|
+
- ...
|
|
54
|
+
|
|
55
|
+
### SUGGESTION
|
|
56
|
+
- ...
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Issues by priority**, each with a specific recommendation:
|
|
60
|
+
|
|
61
|
+
1. **CRITICAL** (proven duplication or proven dead code inside the scope).
|
|
62
|
+
2. **WARNING** (likely redundancy or misplacement, evidence cited).
|
|
63
|
+
3. **SUGGESTION** (uncertain or stylistic, needs human judgment).
|
|
64
|
+
|
|
65
|
+
**Assessment:** CRITICAL → "X critical issue(s) found. Fix before merging."; warnings only → "No critical issues. Y warning(s) to consider."; clean → "No issues found in scope: `<scope>`."
|
|
66
|
+
|
|
67
|
+
## Heuristics
|
|
68
|
+
|
|
69
|
+
- **Scope discipline**: every finding must point at a file:line inside the scope; external files appear only as evidence.
|
|
70
|
+
- **False positives**: when uncertain, prefer SUGGESTION > WARNING > CRITICAL.
|
|
71
|
+
- **Actionability**: every issue carries a specific recommendation with file/line refs.
|
|
72
|
+
|
|
73
|
+
## Guardrails
|
|
74
|
+
|
|
75
|
+
- Read-only: never write, edit, or refactor code — only report.
|
|
76
|
+
- Reference code as `file.ts:123`.
|
|
77
|
+
- No vague suggestions ("consider reviewing") — every issue needs a specific recommendation.
|
|
78
|
+
- Never paste diffs or code blocks — cite locations.
|
|
79
|
+
- At most 5 findings per severity; if more exist, keep the 5 highest-impact and state the count of the rest.
|
|
80
|
+
- This skill does not verify artifacts (that is `ancleto-verify`), conventions against an approved request (that is `reviewer`), or vulnerabilities (that is `ancleto-security`).
|
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
|
}
|