@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ancleto/spec",
3
- "version": "0.6.27",
3
+ "version": "0.6.28",
4
4
  "description": "Orquestador SDD liviano con subagentes optimizados para costo/tokens",
5
5
  "type": "module",
6
6
  "bin": {
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