@ancleto/spec 0.6.27 → 0.6.28
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 +5 -0
- package/package.json +1 -1
- package/src/cli/index.js +241 -0
package/README.md
CHANGED
|
@@ -120,6 +120,8 @@ Cómo bajarlo:
|
|
|
120
120
|
- Evitá `--with-engram` salvo que uses esa memoria: es el que más pesa (9× la memoria propia).
|
|
121
121
|
- Podés deshabilitar cualquier servidor en tu `opencode.json`: `"mcp": { "engram": { "enabled": false } }`.
|
|
122
122
|
|
|
123
|
+
Para medir el consumo **real** por sesión (no estimado): `ancleto stats` lee la base de sesiones de opencode y reporta tokens de entrada/salida/razonamiento/cache, costo y subagentes por sesión. Con `--session <id>` desglosa por agente y con `--since YYYY-MM-DD` permite comparar antes/después de una optimización. Solo funciona con opencode.
|
|
124
|
+
|
|
123
125
|
---
|
|
124
126
|
|
|
125
127
|
## Guía de .gitignore
|
|
@@ -204,6 +206,9 @@ ancleto check # Verifica la integridad de la instalación (faltantes
|
|
|
204
206
|
ancleto specs check [--change <nombre>] [--json]
|
|
205
207
|
# Valida keywords canónicos en aspec/specs y en los deltas de un change
|
|
206
208
|
|
|
209
|
+
ancleto stats [--all] [--limit N] [--since YYYY-MM-DD] [--session <id>] [--json]
|
|
210
|
+
# Tokens por sesión de opencode (rollup de subagentes; --session desglosa por agente)
|
|
211
|
+
|
|
207
212
|
ancleto doctor # Diagnostica el entorno (Node, node:sqlite, opencode.json)
|
|
208
213
|
|
|
209
214
|
ancleto memory context [--scope <project|feature|task>] [--out <archivo>]
|
package/package.json
CHANGED
package/src/cli/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { cp, mkdir, access, writeFile, readFile, readdir, rename } from 'node:fs
|
|
|
4
4
|
import { spawnSync } from 'node:child_process'
|
|
5
5
|
import { createHash } from 'node:crypto'
|
|
6
6
|
import { createInterface } from 'node:readline'
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
7
8
|
import { join, dirname, resolve, basename, relative } from 'node:path'
|
|
8
9
|
import { fileURLToPath } from 'node:url'
|
|
9
10
|
import { homedir, tmpdir } from 'node:os'
|
|
@@ -43,6 +44,9 @@ Uso:
|
|
|
43
44
|
ancleto specs check [--change <name>] [--json]
|
|
44
45
|
Valida keywords canonicos (### Requirement:, WHEN, THEN)
|
|
45
46
|
en aspec/specs y, con --change, en los deltas del change
|
|
47
|
+
ancleto stats [--all] [--limit N] [--since YYYY-MM-DD] [--session <id>] [--json]
|
|
48
|
+
Tokens por sesion de opencode (entrada/salida/razonamiento/cache),
|
|
49
|
+
con rollup de subagentes y desglose por agente con --session
|
|
46
50
|
ancleto memory context [--scope X] [--out file]
|
|
47
51
|
Imprime/escribe el bloque <ProjectMemoryRules> (reglas activas)
|
|
48
52
|
ancleto memory list [--type X] [--scope X] [--all] [--json]
|
|
@@ -1054,6 +1058,240 @@ async function specsCheckCommand(args) {
|
|
|
1054
1058
|
process.exit(ok ? 0 : 1)
|
|
1055
1059
|
}
|
|
1056
1060
|
|
|
1061
|
+
function opencodeDbPath() {
|
|
1062
|
+
return process.env.ANCLETO_OPENCODE_DB || join(homedir(), '.local', 'share', 'opencode', 'opencode.db')
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function fmtTokens(n) {
|
|
1066
|
+
if (!n) return '0'
|
|
1067
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`
|
|
1068
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
|
|
1069
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
|
|
1070
|
+
return String(n)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function fmtCost(c) {
|
|
1074
|
+
return c && c < 0.01 ? `$${c.toFixed(4)}` : `$${(c || 0).toFixed(2)}`
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function fmtDate(ms) {
|
|
1078
|
+
const d = new Date(ms || 0)
|
|
1079
|
+
const p = (x) => String(x).padStart(2, '0')
|
|
1080
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const normPath = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
|
|
1084
|
+
|
|
1085
|
+
function sessionModel(model) {
|
|
1086
|
+
try {
|
|
1087
|
+
const m = JSON.parse(model || '{}')
|
|
1088
|
+
return m.id ? `${m.id}${m.providerID ? ` (${m.providerID})` : ''}` : ''
|
|
1089
|
+
} catch {
|
|
1090
|
+
return ''
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function buildSessionTree(sessions) {
|
|
1095
|
+
const byId = new Map(sessions.map((s) => [s.id, s]))
|
|
1096
|
+
const children = new Map()
|
|
1097
|
+
for (const s of sessions) {
|
|
1098
|
+
if (!s.parent_id) continue
|
|
1099
|
+
if (!children.has(s.parent_id)) children.set(s.parent_id, [])
|
|
1100
|
+
children.get(s.parent_id).push(s)
|
|
1101
|
+
}
|
|
1102
|
+
const cache = new Map()
|
|
1103
|
+
const totalsOf = (id) => {
|
|
1104
|
+
if (cache.has(id)) return cache.get(id)
|
|
1105
|
+
const own = byId.get(id)
|
|
1106
|
+
const t = {
|
|
1107
|
+
input: own?.tokens_input || 0,
|
|
1108
|
+
output: own?.tokens_output || 0,
|
|
1109
|
+
reasoning: own?.tokens_reasoning || 0,
|
|
1110
|
+
cacheRead: own?.tokens_cache_read || 0,
|
|
1111
|
+
cacheWrite: own?.tokens_cache_write || 0,
|
|
1112
|
+
cost: own?.cost || 0,
|
|
1113
|
+
sessions: own ? 1 : 0
|
|
1114
|
+
}
|
|
1115
|
+
for (const c of children.get(id) || []) {
|
|
1116
|
+
const ct = totalsOf(c.id)
|
|
1117
|
+
t.input += ct.input
|
|
1118
|
+
t.output += ct.output
|
|
1119
|
+
t.reasoning += ct.reasoning
|
|
1120
|
+
t.cacheRead += ct.cacheRead
|
|
1121
|
+
t.cacheWrite += ct.cacheWrite
|
|
1122
|
+
t.cost += ct.cost
|
|
1123
|
+
t.sessions += ct.sessions
|
|
1124
|
+
}
|
|
1125
|
+
cache.set(id, t)
|
|
1126
|
+
return t
|
|
1127
|
+
}
|
|
1128
|
+
const subtreeIds = (id) => [id, ...(children.get(id) || []).flatMap((c) => subtreeIds(c.id))]
|
|
1129
|
+
return { byId, totalsOf, subtreeIds }
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function sessionInfo(s, totals) {
|
|
1133
|
+
return {
|
|
1134
|
+
id: s.id,
|
|
1135
|
+
title: s.title || null,
|
|
1136
|
+
agent: s.agent || null,
|
|
1137
|
+
model: sessionModel(s.model) || null,
|
|
1138
|
+
subagentSessions: Math.max(0, totals.sessions - 1),
|
|
1139
|
+
timeCreated: s.time_created || null,
|
|
1140
|
+
timeUpdated: s.time_updated || null,
|
|
1141
|
+
cost: totals.cost,
|
|
1142
|
+
tokens: {
|
|
1143
|
+
input: totals.input,
|
|
1144
|
+
output: totals.output,
|
|
1145
|
+
reasoning: totals.reasoning,
|
|
1146
|
+
cacheRead: totals.cacheRead,
|
|
1147
|
+
cacheWrite: totals.cacheWrite
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function statsCommand(args) {
|
|
1153
|
+
const asJson = args.includes('--json')
|
|
1154
|
+
const all = args.includes('--all')
|
|
1155
|
+
const si = args.indexOf('--session')
|
|
1156
|
+
const sessionId = si >= 0 ? args[si + 1] : null
|
|
1157
|
+
const li = args.indexOf('--limit')
|
|
1158
|
+
const limit = li >= 0 ? Number.parseInt(args[li + 1], 10) : 10
|
|
1159
|
+
if (li >= 0 && (!Number.isFinite(limit) || limit < 1)) {
|
|
1160
|
+
console.error('ancleto: --limit requiere un entero >= 1')
|
|
1161
|
+
process.exit(1)
|
|
1162
|
+
}
|
|
1163
|
+
const sinceIdx = args.indexOf('--since')
|
|
1164
|
+
const sinceRaw = sinceIdx >= 0 ? args[sinceIdx + 1] : null
|
|
1165
|
+
let since = null
|
|
1166
|
+
if (sinceRaw) {
|
|
1167
|
+
const t = Date.parse(`${sinceRaw}T00:00:00`)
|
|
1168
|
+
if (Number.isNaN(t)) {
|
|
1169
|
+
console.error('ancleto: --since requiere formato YYYY-MM-DD')
|
|
1170
|
+
process.exit(1)
|
|
1171
|
+
}
|
|
1172
|
+
since = t
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const dbPath = opencodeDbPath()
|
|
1176
|
+
if (!(await exists(dbPath))) {
|
|
1177
|
+
console.error(`ancleto: no se encontro la base de sesiones de opencode en ${dbPath}`)
|
|
1178
|
+
console.error('ancleto: ancleto stats solo funciona con opencode (podes apuntar ANCLETO_OPENCODE_DB a la base)')
|
|
1179
|
+
process.exit(1)
|
|
1180
|
+
}
|
|
1181
|
+
const db = new DatabaseSync(dbPath, { readOnly: true })
|
|
1182
|
+
let allSessions
|
|
1183
|
+
try {
|
|
1184
|
+
allSessions = db.prepare('SELECT * FROM session').all()
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
console.error(`ancleto: no se pudo leer la base de sesiones: ${err.message}`)
|
|
1187
|
+
process.exit(1)
|
|
1188
|
+
}
|
|
1189
|
+
const tree = buildSessionTree(allSessions)
|
|
1190
|
+
const cwd = process.cwd()
|
|
1191
|
+
|
|
1192
|
+
if (sessionId) {
|
|
1193
|
+
const root = tree.byId.get(sessionId)
|
|
1194
|
+
if (!root) {
|
|
1195
|
+
console.error(`ancleto: no existe la sesion "${sessionId}"`)
|
|
1196
|
+
process.exit(1)
|
|
1197
|
+
}
|
|
1198
|
+
const ids = tree.subtreeIds(sessionId)
|
|
1199
|
+
let messages = []
|
|
1200
|
+
try {
|
|
1201
|
+
messages = db.prepare(`SELECT data FROM message WHERE session_id IN (${ids.map(() => '?').join(',')})`).all(...ids)
|
|
1202
|
+
} catch {
|
|
1203
|
+
messages = []
|
|
1204
|
+
}
|
|
1205
|
+
const byAgent = new Map()
|
|
1206
|
+
for (const row of messages) {
|
|
1207
|
+
let d
|
|
1208
|
+
try {
|
|
1209
|
+
d = JSON.parse(row.data)
|
|
1210
|
+
} catch {
|
|
1211
|
+
continue
|
|
1212
|
+
}
|
|
1213
|
+
if (d.role !== 'assistant' || !d.tokens) continue
|
|
1214
|
+
const key = d.agent || '(desconocido)'
|
|
1215
|
+
const agg = byAgent.get(key) || { agent: key, messages: 0, input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }
|
|
1216
|
+
agg.messages++
|
|
1217
|
+
agg.input += d.tokens.input || 0
|
|
1218
|
+
agg.output += d.tokens.output || 0
|
|
1219
|
+
agg.reasoning += d.tokens.reasoning || 0
|
|
1220
|
+
agg.cacheRead += d.tokens.cache?.read || 0
|
|
1221
|
+
agg.cacheWrite += d.tokens.cache?.write || 0
|
|
1222
|
+
byAgent.set(key, agg)
|
|
1223
|
+
}
|
|
1224
|
+
const agents = [...byAgent.values()].sort((a, b) => b.input - a.input)
|
|
1225
|
+
const totals = tree.totalsOf(sessionId)
|
|
1226
|
+
db.close()
|
|
1227
|
+
if (asJson) {
|
|
1228
|
+
console.log(JSON.stringify({ ok: true, scope: 'session', session: sessionInfo(root, totals), byAgent: agents, totals }, null, 2))
|
|
1229
|
+
return
|
|
1230
|
+
}
|
|
1231
|
+
console.log(`ancleto: sesion ${root.id}`)
|
|
1232
|
+
console.log(` titulo: ${root.title || '(sin titulo)'}`)
|
|
1233
|
+
console.log(` agente: ${root.agent || '-'} · modelo: ${sessionModel(root.model) || '-'}`)
|
|
1234
|
+
console.log(` creada: ${fmtDate(root.time_created)} · ultima actividad: ${fmtDate(root.time_updated)}`)
|
|
1235
|
+
console.log(` sesiones incluidas: ${totals.sessions} (raiz + subagentes)`)
|
|
1236
|
+
console.log('')
|
|
1237
|
+
console.log(' agente mensajes entrada salida razon cache-read')
|
|
1238
|
+
for (const a of agents) {
|
|
1239
|
+
console.log(` ${String(a.agent).padEnd(13)} ${String(a.messages).padEnd(10)} ${fmtTokens(a.input).padEnd(9)} ${fmtTokens(a.output).padEnd(9)} ${fmtTokens(a.reasoning).padEnd(9)} ${fmtTokens(a.cacheRead)}`)
|
|
1240
|
+
}
|
|
1241
|
+
console.log('')
|
|
1242
|
+
console.log(` totales: entrada ${fmtTokens(totals.input)} · salida ${fmtTokens(totals.output)} · razon ${fmtTokens(totals.reasoning)} · cache ${fmtTokens(totals.cacheRead)} · costo ${fmtCost(totals.cost)}`)
|
|
1243
|
+
return
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
let roots = allSessions.filter((s) => !s.parent_id)
|
|
1247
|
+
let scope = 'all'
|
|
1248
|
+
if (!all) {
|
|
1249
|
+
scope = 'directory'
|
|
1250
|
+
const cwdN = normPath(cwd)
|
|
1251
|
+
const inDir = (d) => {
|
|
1252
|
+
const dn = normPath(d)
|
|
1253
|
+
return dn === cwdN || dn.startsWith(`${cwdN}/`) || cwdN.startsWith(`${dn}/`)
|
|
1254
|
+
}
|
|
1255
|
+
roots = roots.filter((s) => inDir(s.directory))
|
|
1256
|
+
}
|
|
1257
|
+
if (since !== null) roots = roots.filter((s) => (s.time_created || 0) >= since)
|
|
1258
|
+
roots.sort((a, b) => (b.time_updated || 0) - (a.time_updated || 0))
|
|
1259
|
+
const shown = roots.slice(0, limit)
|
|
1260
|
+
const rows = shown.map((s) => ({ ...sessionInfo(s, tree.totalsOf(s.id)), directory: s.directory }))
|
|
1261
|
+
const totals = rows.reduce(
|
|
1262
|
+
(acc, r) => {
|
|
1263
|
+
acc.input += r.tokens.input
|
|
1264
|
+
acc.output += r.tokens.output
|
|
1265
|
+
acc.reasoning += r.tokens.reasoning
|
|
1266
|
+
acc.cacheRead += r.tokens.cacheRead
|
|
1267
|
+
acc.cacheWrite += r.tokens.cacheWrite
|
|
1268
|
+
acc.cost += r.cost
|
|
1269
|
+
return acc
|
|
1270
|
+
},
|
|
1271
|
+
{ input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
|
|
1272
|
+
)
|
|
1273
|
+
db.close()
|
|
1274
|
+
|
|
1275
|
+
if (asJson) {
|
|
1276
|
+
console.log(JSON.stringify({ ok: true, scope, directory: scope === 'directory' ? cwd.replace(/\\/g, '/') : null, since: sinceRaw || null, sessions: rows, totals }, null, 2))
|
|
1277
|
+
return
|
|
1278
|
+
}
|
|
1279
|
+
if (rows.length === 0) {
|
|
1280
|
+
console.log(`ancleto: no hay sesiones${scope === 'directory' ? ' para este directorio' : ''}${sinceRaw ? ` desde ${sinceRaw}` : ''}`)
|
|
1281
|
+
if (scope === 'directory') console.log('ancleto: proba --all para ver todas las sesiones de la maquina')
|
|
1282
|
+
return
|
|
1283
|
+
}
|
|
1284
|
+
console.log(`ancleto: stats (opencode)${scope === 'directory' ? ` — ${cwd.replace(/\\/g, '/')}` : ' — todas las sesiones'}${sinceRaw ? ` — desde ${sinceRaw}` : ''}`)
|
|
1285
|
+
console.log('')
|
|
1286
|
+
console.log(' fecha agente entrada salida razon cache costo titulo')
|
|
1287
|
+
for (const r of rows) {
|
|
1288
|
+
console.log(` ${fmtDate(r.timeUpdated).padEnd(18)} ${String(r.agent || '-').padEnd(13)} ${fmtTokens(r.tokens.input).padEnd(9)} ${fmtTokens(r.tokens.output).padEnd(9)} ${fmtTokens(r.tokens.reasoning).padEnd(9)} ${fmtTokens(r.tokens.cacheRead).padEnd(9)} ${fmtCost(r.cost).padEnd(8)} ${String(r.title || '').slice(0, 42)}`)
|
|
1289
|
+
}
|
|
1290
|
+
console.log('')
|
|
1291
|
+
const shownLabel = roots.length > rows.length ? `${rows.length} de ${roots.length} sesiones` : rows.length === 1 ? '1 sesion' : `${rows.length} sesiones`
|
|
1292
|
+
console.log(` totales (${shownLabel}): entrada ${fmtTokens(totals.input)} · salida ${fmtTokens(totals.output)} · razon ${fmtTokens(totals.reasoning)} · cache ${fmtTokens(totals.cacheRead)} · costo ${fmtCost(totals.cost)}`)
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1057
1295
|
async function doctorCommand() {
|
|
1058
1296
|
let fatal = false
|
|
1059
1297
|
|
|
@@ -1122,6 +1360,9 @@ switch (cmd) {
|
|
|
1122
1360
|
case 'specs':
|
|
1123
1361
|
await specsCheckCommand(rest)
|
|
1124
1362
|
break
|
|
1363
|
+
case 'stats':
|
|
1364
|
+
await statsCommand(rest)
|
|
1365
|
+
break
|
|
1125
1366
|
case 'doctor':
|
|
1126
1367
|
await doctorCommand()
|
|
1127
1368
|
break
|