@hydra-acp/budgeter 0.1.15 → 0.1.16

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.
@@ -1,4 +1,4 @@
1
- import{writeFileSync as T,readFileSync as y,existsSync as x}from"node:fs";import{basename as S,resolve as c}from"node:path";import{homedir as O}from"node:os";import{buildOpencodeIndex as I,defaultOpencodeDbs as A,listSessionDirs as E,readGate as P,scanSession as R}from"./repair-io.js";import{planSession as j}from"./repair-core.js";import{applyPlan as N,revertSession as D,ALGO_VERSION as W}from"./repair-write.js";const C=`Usage: hydra-acp-budgeter repair [OPTIONS]
1
+ import{writeFileSync as j,readFileSync as v,existsSync as C}from"node:fs";import{basename as y,resolve as w}from"node:path";import{homedir as E}from"node:os";import{buildOpencodeIndex as D,defaultOpencodeDbs as W,listSessionDirs as J,knownUpstreams as U,readGate as P,scanSession as x}from"./repair-io.js";import{planSession as _,splitLedger as H}from"./repair-core.js";import{applyPlan as L,revertSession as z,reshapeSession as M,ALGO_VERSION as Y}from"./repair-write.js";const G=`Usage: hydra-acp-budgeter repair [OPTIONS]
2
2
 
3
3
  Reconcile recorded session cost against the agent's own ledger.
4
4
 
@@ -12,6 +12,11 @@ Dry-run by default \u2014 prints what would change and writes nothing.
12
12
  OPTIONS:
13
13
  --apply Write the repair (requires the daemon to be stopped)
14
14
  --revert Undo a previous repair, restoring the original values
15
+ --reshape Re-split an already-repaired session's cost ledger into
16
+ cumulativeCost (retired upstreams) + costAmount (current)
17
+ without touching its transcript. Fixes repairs written
18
+ before the split existed, whose collapsed shape makes the
19
+ next resurrect double-count the current upstream.
15
20
  --session <id> Limit to one session (repeatable)
16
21
  --limit <n> Process at most n sessions, largest inflation first
17
22
  --report <path> Write a JSON report of the plan
@@ -26,36 +31,53 @@ SAFETY
26
31
  * A repaired session is gated in meta.json and will not be repaired twice;
27
32
  to redo it, --revert first.
28
33
  * Sessions that cannot be proven are refused, never guessed at.
29
- `;function J(i){const r={apply:!1,revert:!1,sessions:[],limit:void 0,report:void 0,json:!1,force:!1,skipWarm:!1};for(let s=0;s<i.length;s+=1){const n=i[s];if(n==="--apply")r.apply=!0;else if(n==="--revert")r.revert=!0;else if(n==="--json")r.json=!0;else if(n==="--force")r.force=!0;else if(n==="--skip-warm")r.skipWarm=!0;else if(n==="--session")r.sessions.push(i[++s]??"");else if(n==="--limit")r.limit=Number(i[++s]);else if(n==="--report")r.report=i[++s];else{if(n==="-h"||n==="--help")return;process.stderr.write(`repair: unknown option ${n}
30
- `),process.exit(2)}}return r.apply&&r.revert&&(process.stderr.write(`repair: --apply and --revert are mutually exclusive
31
- `),process.exit(2)),r}function _(i){const r=i??c(process.env.HYDRA_ACP_HOME??c(O(),".hydra-acp"),"daemon.pid");if(!x(r))return"down";let s;try{s=JSON.parse(y(r,"utf8")).pid}catch{return"unknown"}if(typeof s!="number")return"unknown";try{return process.kill(s,0),"up"}catch(n){return n.code==="ESRCH"?"down":"unknown"}}async function F(){const i=process.env.HYDRA_ACP_HOME??c(O(),".hydra-acp");let r,s;try{r=JSON.parse(y(c(i,"daemon.pid"),"utf8")).loopbackPort}catch{return}try{s=y(c(i,"auth-token"),"utf8").trim()}catch{return}if(!(typeof r!="number"||!s))try{const n=new AbortController,f=setTimeout(()=>n.abort(),3e3),m=await fetch(`http://127.0.0.1:${r}/v1/sessions?status=warm`,{headers:{Authorization:`Bearer ${s}`},signal:n.signal});if(clearTimeout(f),!m.ok)return;const u=await m.json();return Array.isArray(u.sessions)?new Set(u.sessions.map(d=>d.sessionId).filter(d=>!!d)):void 0}catch{return}}function l(i){return i.toLocaleString("en-US",{minimumFractionDigits:2,maximumFractionDigits:2})}async function V(i){const r=J(i);if(r===void 0){process.stdout.write(C);return}let s=new Set;if(r.skipWarm){const e=await F();e===void 0&&(process.stderr.write(`repair: --skip-warm needs the daemon's warm-session list and could not get it.
34
+ * --revert refuses a session that recorded usage after its repair: those rows
35
+ have no pristine value to restore, so the undo cannot be lossless. --force
36
+ accepts the loss.
37
+ * --reshape is idempotent and shape-only: for a session whose total is
38
+ already right it moves money between the two fields and changes no total.
39
+ The pre-reshape currentUsage is stamped write-once into the gate.
40
+ `;function V(i){const t={apply:!1,revert:!1,sessions:[],limit:void 0,report:void 0,json:!1,force:!1,skipWarm:!1,reshape:!1};for(let s=0;s<i.length;s+=1){const n=i[s];if(n==="--apply")t.apply=!0;else if(n==="--revert")t.revert=!0;else if(n==="--reshape")t.reshape=!0;else if(n==="--json")t.json=!0;else if(n==="--force")t.force=!0;else if(n==="--skip-warm")t.skipWarm=!0;else if(n==="--session")t.sessions.push(i[++s]??"");else if(n==="--limit")t.limit=Number(i[++s]);else if(n==="--report")t.report=i[++s];else{if(n==="-h"||n==="--help")return;process.stderr.write(`repair: unknown option ${n}
41
+ `),process.exit(2)}}return t.apply&&t.revert&&(process.stderr.write(`repair: --apply and --revert are mutually exclusive
42
+ `),process.exit(2)),t.reshape&&t.revert&&(process.stderr.write(`repair: --reshape and --revert are mutually exclusive
43
+ `),process.exit(2)),t}function q(i){const t=i??w(process.env.HYDRA_ACP_HOME??w(E(),".hydra-acp"),"daemon.pid");if(!C(t))return"down";let s;try{s=JSON.parse(v(t,"utf8")).pid}catch{return"unknown"}if(typeof s!="number")return"unknown";try{return process.kill(s,0),"up"}catch(n){return n.code==="ESRCH"?"down":"unknown"}}async function B(){const i=process.env.HYDRA_ACP_HOME??w(E(),".hydra-acp");let t,s;try{t=JSON.parse(v(w(i,"daemon.pid"),"utf8")).loopbackPort}catch{return}try{s=v(w(i,"auth-token"),"utf8").trim()}catch{return}if(!(typeof t!="number"||!s))try{const n=new AbortController,c=setTimeout(()=>n.abort(),3e3),g=await fetch(`http://127.0.0.1:${t}/v1/sessions?status=warm`,{headers:{Authorization:`Bearer ${s}`},signal:n.signal});if(clearTimeout(c),!g.ok)return;const f=await g.json();return Array.isArray(f.sessions)?new Set(f.sessions.map(p=>p.sessionId).filter(p=>!!p)):void 0}catch{return}}function m(i){return i.toLocaleString("en-US",{minimumFractionDigits:2,maximumFractionDigits:2})}async function se(i){const t=V(i);if(t===void 0){process.stdout.write(G);return}let s=new Set;if(t.skipWarm){const e=await B();e===void 0&&(process.stderr.write(`repair: --skip-warm needs the daemon's warm-session list and could not get it.
32
44
  Refusing rather than guessing which sessions are live.
33
45
  `),process.exit(2)),s=e,process.stderr.write(`skipping ${s.size} warm session(s) held by the running daemon
34
- `)}if(r.apply||r.revert){const e=_();e==="up"&&r.skipWarm&&!r.force||e!=="down"&&!r.force&&(process.stderr.write(e==="up"?`repair: the hydra daemon is running.
46
+ `)}if(t.apply||t.revert||t.reshape){const e=q();e==="up"&&t.skipWarm&&!t.force||e!=="down"&&!t.force&&(process.stderr.write(e==="up"?`repair: the hydra daemon is running.
35
47
  Stop it first (\`hydra daemon stop\`) \u2014 a live daemon overwrites meta.json
36
48
  from stale in-memory state and undoes half of the repair.
37
49
  Use --force only if you are certain no session is live.
38
50
  `:`repair: cannot determine whether the hydra daemon is running.
39
51
  Refusing rather than risking a write against a live daemon.
40
52
  Stop the daemon and retry, or pass --force.
41
- `),process.exit(2))}let n=E();if(r.sessions.length>0){const e=new Set(r.sessions);n=n.filter(t=>e.has(S(t))||[...e].some(o=>S(t).endsWith(o)))}if(r.revert){let e=0,t=0;for(const o of n){if(s.size>0&&s.has(S(o)))continue;const a=D(o);a&&(e+=1,t+=a.rowsReverted,process.stdout.write(`reverted ${S(o)} (${a.rowsReverted} rows)
42
- `))}process.stdout.write(`
43
- ${e} session(s) reverted, ${t} rows restored.
44
- `);return}const f=A();f.length===0&&(process.stderr.write(`repair: no OpenCode database found; nothing to reconcile against.
45
- `),process.exit(1)),process.stderr.write(`indexing ${f.length} OpenCode database(s)\u2026
46
- `);const m=I(f),u=[],d=new Map;let w=0;for(const e of n){const t=R(e);if(t===void 0||s.has(t.input.sessionId))continue;if(P(t.meta)){w+=1;continue}const o=j(t.input,m.callIndex,m.ledger);if(!o.ok){const a=d.get(o.refusal.reason)??{n:0,reported:0};a.n+=1,a.reported+=t.input.reportedTotal,d.set(o.refusal.reason,a);continue}u.push({sessionId:t.input.sessionId,dir:e,reported:o.plan.reportedTotal,trueTotal:o.plan.trueTotal,rows:o.plan.rows.length,upstreams:o.plan.attribution.upstreams.length,plan:o.plan})}u.sort((e,t)=>t.reported-t.trueTotal-(e.reported-e.trueTotal));const p=r.limit===void 0?u:u.slice(0,r.limit),h=p.reduce((e,t)=>e+t.reported,0),g=p.reduce((e,t)=>e+t.trueTotal,0),$={tool:"hydra-acp-budgeter",algo:W,at:new Date().toISOString(),mode:r.apply?"apply":"dry-run",sessions:p.map(e=>({sessionId:e.sessionId,reported:e.reported,trueTotal:e.trueTotal,rows:e.rows,upstreams:e.plan.attribution.upstreams,forwardedSkipped:e.plan.attribution.forwardedSkipped})),totals:{reported:h,trueTotal:g,phantom:h-g},refused:Object.fromEntries([...d].map(([e,t])=>[e,t])),alreadyRepaired:w,warmSkipped:s.size};if(r.report!==void 0&&T(r.report,`${JSON.stringify($,null,2)}
47
- `),r.json)process.stdout.write(`${JSON.stringify($,null,2)}
53
+ `),process.exit(2))}let n=J();if(t.sessions.length>0){const e=new Set(t.sessions);n=n.filter(r=>e.has(y(r))||[...e].some(o=>y(r).endsWith(o)))}if(t.revert){let e=0,r=0,o=0;for(const a of n){if(s.size>0&&s.has(y(a)))continue;const l=z(a,{force:t.force});if(l){if(l.refused!==void 0){o+=1,process.stdout.write(`refused ${y(a)} ${l.refused}
54
+ `);continue}e+=1,r+=l.rowsReverted,process.stdout.write(`reverted ${y(a)} (${l.rowsReverted} rows)
55
+ `)}}process.stdout.write(`
56
+ ${e} session(s) reverted, ${r} rows restored.
57
+ `),o>0&&process.stdout.write(`${o} session(s) refused: they kept working after their repair, so a
58
+ revert would lose spend recorded since. Use --force to accept that.
59
+ `);return}const c=W();if(c.length===0&&(process.stderr.write(`repair: no OpenCode database found; nothing to reconcile against.
60
+ `),process.exit(1)),t.reshape){process.stderr.write(`indexing ${c.length} OpenCode database(s)\u2026
61
+ `);const e=D(c);let r=0,o=0,a=0,l=0;for(const T of n){const h=x(T);if(h===void 0||s.size>0&&s.has(h.input.sessionId)||P(h.meta)===void 0)continue;const A=U(h.meta);if(A.length===0)continue;const N=H(e.ledger,A,h.meta.upstreamSessionId),d=M(T,N,F(),{dryRun:!t.apply});if(d.kind==="refuse"){a+=1,process.stdout.write(`refused ${h.input.sessionId} ${d.why}
62
+ `);continue}if(d.kind==="skip"){o+=1;continue}r+=1,l+=d.totalDelta,process.stdout.write(`${t.apply?"reshaped":"would reshape"} ${h.input.sessionId} ${d.beforeCum.toFixed(2)}+${d.beforeCur.toFixed(2)} -> ${d.cum.toFixed(2)}+${d.cur.toFixed(2)}`+(Math.abs(d.totalDelta)>.005?` total ${d.totalDelta>0?"+":""}${d.totalDelta.toFixed(2)}`:"")+`
63
+ `)}process.stdout.write(`
64
+ ${r} session(s) ${t.apply?"reshaped":"to reshape"}, ${o} already correct, ${a} refused.
65
+ `+(t.apply?"":`dry run \u2014 nothing written. Re-run with --apply.
66
+ `)+`net total change $${l.toFixed(2)} (shape-only for sessions whose total was already right)
67
+ `);return}process.stderr.write(`indexing ${c.length} OpenCode database(s)\u2026
68
+ `);const g=D(c),f=[],p=new Map;let $=0;for(const e of n){const r=x(e);if(r===void 0||s.has(r.input.sessionId))continue;if(P(r.meta)){$+=1;continue}const o=_(r.input,g.callIndex,g.ledger);if(!o.ok){const a=p.get(o.refusal.reason)??{n:0,reported:0};a.n+=1,a.reported+=r.input.reportedTotal,p.set(o.refusal.reason,a);continue}f.push({sessionId:r.input.sessionId,dir:e,reported:o.plan.reportedTotal,trueTotal:o.plan.trueTotal,rows:o.plan.rows.length,upstreams:o.plan.attribution.upstreams.length,plan:o.plan})}f.sort((e,r)=>r.reported-r.trueTotal-(e.reported-e.trueTotal));const u=t.limit===void 0?f:f.slice(0,t.limit),b=u.reduce((e,r)=>e+r.reported,0),S=u.reduce((e,r)=>e+r.trueTotal,0),O={tool:"hydra-acp-budgeter",algo:Y,at:new Date().toISOString(),mode:t.apply?"apply":"dry-run",sessions:u.map(e=>({sessionId:e.sessionId,reported:e.reported,trueTotal:e.trueTotal,rows:e.rows,upstreams:e.plan.attribution.upstreams,forwardedSkipped:e.plan.attribution.forwardedSkipped})),totals:{reported:b,trueTotal:S,phantom:b-S},refused:Object.fromEntries([...p].map(([e,r])=>[e,r])),alreadyRepaired:$,warmSkipped:s.size};if(t.report!==void 0&&j(t.report,`${JSON.stringify(O,null,2)}
69
+ `),t.json)process.stdout.write(`${JSON.stringify(O,null,2)}
48
70
  `);else{process.stdout.write(`
49
71
  ${"session".padEnd(34)}${"rows".padStart(6)}${"ups".padStart(5)}${"reported".padStart(13)}${"true".padStart(12)}${"phantom".padStart(13)}
50
- `);for(const e of p.slice(0,25))process.stdout.write(`${e.sessionId.slice(-32).padEnd(34)}${String(e.rows).padStart(6)}${String(e.upstreams).padStart(5)}${l(e.reported).padStart(13)}${l(e.trueTotal).padStart(12)}${l(e.reported-e.trueTotal).padStart(13)}
51
- `);p.length>25&&process.stdout.write(`\u2026 and ${p.length-25} more
72
+ `);for(const e of u.slice(0,25))process.stdout.write(`${e.sessionId.slice(-32).padEnd(34)}${String(e.rows).padStart(6)}${String(e.upstreams).padStart(5)}${m(e.reported).padStart(13)}${m(e.trueTotal).padStart(12)}${m(e.reported-e.trueTotal).padStart(13)}
73
+ `);u.length>25&&process.stdout.write(`\u2026 and ${u.length-25} more
52
74
  `),process.stdout.write(`
53
- ${p.length} session(s) repairable: $${l(h)} recorded -> $${l(g)} actual ($${l(h-g)} phantom)
54
- `),w>0&&process.stdout.write(`${w} session(s) already repaired (use --revert to redo)
55
- `);for(const[e,t]of[...d].sort((o,a)=>a[1].reported-o[1].reported))process.stdout.write(`refused: ${e.padEnd(22)} ${String(t.n).padStart(4)} sessions $${l(t.reported)}
56
- `)}if(!r.apply){const e=`
75
+ ${u.length} session(s) repairable: $${m(b)} recorded -> $${m(S)} actual ($${m(b-S)} phantom)
76
+ `),$>0&&process.stdout.write(`${$} session(s) already repaired (use --revert to redo)
77
+ `);for(const[e,r]of[...p].sort((o,a)=>a[1].reported-o[1].reported))process.stdout.write(`refused: ${e.padEnd(22)} ${String(r.n).padStart(4)} sessions $${m(r.reported)}
78
+ `)}if(!t.apply){const e=`
57
79
  Dry run \u2014 nothing written. Re-run with --apply to commit.
58
- `;r.json?process.stderr.write(e):process.stdout.write(e);return}let k=0,v=0;const b=[];for(const e of p){const t=R(e.dir);if(t!==void 0)try{const o=N(t,e.plan,H());k+=1,v+=o.rowsRewritten}catch(o){b.push(`${e.sessionId}: ${o.message}`)}}process.stdout.write(`
59
- applied to ${k} session(s), ${v} rows rewritten.
60
- `);for(const e of b)process.stderr.write(`FAILED ${e}
61
- `);b.length>0&&(process.exitCode=1)}function H(){try{const i=c(import.meta.dirname,"../../package.json");return JSON.parse(y(i,"utf8")).version??"unknown"}catch{return"unknown"}}export{C as REPAIR_HELP,_ as daemonState,V as runRepair};
80
+ `;t.json?process.stderr.write(e):process.stdout.write(e);return}let R=0,I=0;const k=[];for(const e of u){const r=x(e.dir);if(r!==void 0)try{const o=L(r,e.plan,F());R+=1,I+=o.rowsRewritten}catch(o){k.push(`${e.sessionId}: ${o.message}`)}}process.stdout.write(`
81
+ applied to ${R} session(s), ${I} rows rewritten.
82
+ `);for(const e of k)process.stderr.write(`FAILED ${e}
83
+ `);k.length>0&&(process.exitCode=1)}function F(){try{const i=w(import.meta.dirname,"../../package.json");return JSON.parse(v(i,"utf8")).version??"unknown"}catch{return"unknown"}}export{G as REPAIR_HELP,q as daemonState,se as runRepair};
@@ -1 +1 @@
1
- function p(e,d){const t=new Set;let n=0,o=0,r=0;for(const a of e){if(a.forwarded){r+=1;continue}const l=d.get(a.toolCallId);if(l===void 0){o+=1;continue}t.add(l),n+=1}return{upstreams:[...t].sort(),ownResolved:n,ownUnresolved:o,forwardedSkipped:r}}function y(e,d,t){let n=0;for(const o of d)for(const[r,a]of e.get(o)??[]){if(r>t)break;n+=a}return n}function c(e,d,t){const n=[];for(const l of d)for(const s of e.get(l)??[])n.push(s);n.sort((l,s)=>l[0]-s[0]);const o=[];let r=0,a=0;for(const l of t){for(;r<n.length&&n[r][0]<=l;)a+=n[r][1],r+=1;o.push(a)}return o}function g(e,d){const t=[];for(const r of e)if(r.repaired<-1e-6){t.push(`negative repaired value ${r.repaired} at ${r.file}:${r.line}`);break}for(let r=1;r<e.length;r+=1)if(e[r].repaired<e[r-1].repaired-1e-6){t.push(`repaired series decreases at ${e[r].file}:${e[r].line} (${e[r-1].repaired} -> ${e[r].repaired})`);break}for(let r=0;r<e.length;r+=1){const a=Math.max(e[r].amount,e[r+1]?.amount??e[r].amount);if(e[r].repaired>a+1e-6){t.push(`repaired ${e[r].repaired} exceeds recorded ${e[r].amount} at ${e[r].file}:${e[r].line}`);break}}const o=e[e.length-1];return o!==void 0&&o.repaired>d+1e-6&&t.push(`repaired final ${o.repaired} exceeds meta.json total ${d}`),{ok:t.length===0,violations:t}}function m(e,d,t){const n=(i,u,f)=>({ok:!1,refusal:{sessionId:e.sessionId,reason:i,detail:u,...f?{attribution:f}:{}}});if(e.reportedTotal<=0)return n("zero-cost","nothing to repair");if(e.forkedFromSessionId)return n("fork",`forked from ${e.forkedFromSessionId}`);const o=p(e.toolCalls,d);if(o.upstreams.length===0)return n("no-own-upstream",`no tool call resolves to an agent session (${o.ownUnresolved} unresolved, ${o.forwardedSkipped} forwarded)`,o);if(o.ownUnresolved>o.ownResolved)return n("majority-unresolved",`${o.ownUnresolved} of ${o.ownUnresolved+o.ownResolved} own tool ids unresolved \u2014 ledger partially purged, total would under-report`,o);const r=c(t,o.upstreams,e.usageRows.map(i=>i.recordedAt)),a=e.usageRows.map((i,u)=>({...i,repaired:r[u]??0})),l=g(a,e.reportedTotal);if(!l.ok)return n("guard-violation",l.violations.join("; "),o);const s=y(t,o.upstreams,Number.MAX_SAFE_INTEGER);return{ok:!0,plan:{sessionId:e.sessionId,attribution:o,rows:a,reportedTotal:e.reportedTotal,trueTotal:s}}}export{p as attribute,g as checkGuards,m as planSession,y as truthAt,c as truthSeries};
1
+ function y(e,l,a){const n=l.map(t=>{const d=e.get(t)??[];let u=0;for(const[,i]of d)u+=i;return{upstreamSessionId:t,cost:u,firstAt:d[0]?.[0]??Number.MAX_SAFE_INTEGER,lastAt:d[d.length-1]?.[0]??0,isCurrent:t===a}});n.sort((t,d)=>t.firstAt-d.firstAt);const o=a!==void 0&&n.some(t=>t.isCurrent);let r=0,s=0;for(const t of n)!o||t.isCurrent?s+=t.cost:r+=t.cost;return{retired:r,current:s,perUpstream:n}}function c(e,l){const a=new Set;let n=0,o=0,r=0;for(const s of e){if(s.forwarded){r+=1;continue}const t=l.get(s.toolCallId);if(t===void 0){o+=1;continue}a.add(t),n+=1}return{upstreams:[...a].sort(),ownResolved:n,ownUnresolved:o,forwardedSkipped:r}}function m(e,l,a){let n=0;for(const o of l)for(const[r,s]of e.get(o)??[]){if(r>a)break;n+=s}return n}function g(e,l,a){const n=[];for(const t of l)for(const d of e.get(t)??[])n.push(d);n.sort((t,d)=>t[0]-d[0]);const o=[];let r=0,s=0;for(const t of a){for(;r<n.length&&n[r][0]<=t;)s+=n[r][1],r+=1;o.push(s)}return o}function b(e,l){const a=[];for(const r of e)if(r.repaired<-1e-6){a.push(`negative repaired value ${r.repaired} at ${r.file}:${r.line}`);break}for(let r=1;r<e.length;r+=1)if(e[r].repaired<e[r-1].repaired-1e-6){a.push(`repaired series decreases at ${e[r].file}:${e[r].line} (${e[r-1].repaired} -> ${e[r].repaired})`);break}for(let r=0;r<e.length;r+=1){const s=Math.max(e[r].amount,e[r+1]?.amount??e[r].amount);if(e[r].repaired>s+1e-6){a.push(`repaired ${e[r].repaired} exceeds recorded ${e[r].amount} at ${e[r].file}:${e[r].line}`);break}}const o=e[e.length-1];return o!==void 0&&o.repaired>l+1e-6&&a.push(`repaired final ${o.repaired} exceeds meta.json total ${l}`),{ok:a.length===0,violations:a}}function R(e,l,a){const n=(i,p,f)=>({ok:!1,refusal:{sessionId:e.sessionId,reason:i,detail:p,...f?{attribution:f}:{}}});if(e.reportedTotal<=0)return n("zero-cost","nothing to repair");if(e.forkedFromSessionId)return n("fork",`forked from ${e.forkedFromSessionId}`);const o=c(e.toolCalls,l);if(o.upstreams.length===0)return n("no-own-upstream",`no tool call resolves to an agent session (${o.ownUnresolved} unresolved, ${o.forwardedSkipped} forwarded)`,o);if(o.ownUnresolved>o.ownResolved)return n("majority-unresolved",`${o.ownUnresolved} of ${o.ownUnresolved+o.ownResolved} own tool ids unresolved \u2014 ledger partially purged, total would under-report`,o);const r=g(a,o.upstreams,e.usageRows.map(i=>i.recordedAt)),s=e.usageRows.map((i,p)=>({...i,repaired:r[p]??0})),t=b(s,e.reportedTotal);if(!t.ok)return n("guard-violation",t.violations.join("; "),o);const d=m(a,o.upstreams,Number.MAX_SAFE_INTEGER),u=y(a,o.upstreams,e.currentUpstream);return{ok:!0,plan:{sessionId:e.sessionId,attribution:o,rows:s,reportedTotal:e.reportedTotal,trueTotal:d,split:u}}}export{c as attribute,b as checkGuards,R as planSession,y as splitLedger,m as truthAt,g as truthSeries};
@@ -1,2 +1,2 @@
1
- import{existsSync as f,readdirSync as S,readFileSync as x}from"node:fs";import{gunzipSync as w}from"node:zlib";import{homedir as b}from"node:os";import{resolve as d}from"node:path";import{DatabaseSync as R}from"node:sqlite";import{logger as A}from"../util/log.js";const u=A("cost/repair-io"),_="hydra-acp-budgeter",I="costRepair",p=/^history\.jsonl(\.(\d+)(\.gz)?)?$/;function z(){const t=process.env.OPENCODE_DATA_DIR??d(b(),".local/share/opencode");return["opencode.db","opencode-local.db"].map(e=>d(t,e)).filter(e=>f(e))}function j(t){const e=new Map,r=new Map;for(const o of t){let s;try{s=new R(o,{readOnly:!0})}catch(n){u.warn(`cannot open ${o}: ${n.message}`);continue}try{for(const n of s.prepare("select session_id, data from part where data like '%callID%'").all()){const a=n.data.indexOf('"callID":"');if(a<0)continue;const i=n.data.indexOf('"',a+10);i<0||e.set(n.data.slice(a+10,i),n.session_id)}for(const n of s.prepare("select session_id, data from message").all()){let a;try{a=JSON.parse(n.data)}catch{continue}if(a.role!=="assistant")continue;const i=a.time?.created;if(typeof i!="number")continue;const c=r.get(n.session_id)??[];c.push([i,a.cost??0]),r.set(n.session_id,c)}}catch(n){u.warn(`reading ${o} failed: ${n.message}`)}finally{s.close()}}for(const o of r.values())o.sort((s,n)=>s[0]-n[0]);return u.debug(`indexed ${e.size} tool calls, ${r.size} agent sessions`),{callIndex:e,ledger:r}}function E(t){const e=S(t).filter(o=>p.test(o));e.sort((o,s)=>{const n=o==="history.jsonl"?Number.MAX_SAFE_INTEGER:Number(p.exec(o)?.[2]??0),a=s==="history.jsonl"?Number.MAX_SAFE_INTEGER:Number(p.exec(s)?.[2]??0);return n-a});const r=[];for(const o of e)try{const s=x(d(t,o)),n=o.endsWith(".gz")?w(s).toString("utf8"):s.toString("utf8");r.push({name:o,gz:o.endsWith(".gz"),lines:n.split(`
2
- `)})}catch(s){u.warn(`reading ${o} failed: ${s.message}`)}return r}function k(t){try{return JSON.parse(x(d(t,"meta.json"),"utf8"))}catch{return}}function C(t){const e=t.currentUsage??{},r=typeof e.costAmount=="number"?e.costAmount:0,o=typeof e.cumulativeCost=="number"?e.cumulativeCost:0;return r+o}function v(t){const r=t.extensionState?.[_]?.[I];return r&&typeof r=="object"?r:void 0}function U(t){const e=k(t);if(e===void 0)return;const r=E(t),o=[],s=[];for(const n of r)for(let a=0;a<n.lines.length;a+=1){const i=n.lines[a];if(i.length===0||!i.includes('"usage_update"')&&!i.includes('"toolCallId"'))continue;let c;try{c=JSON.parse(i)}catch{continue}const l=c.params?.update;if(!l)continue;const g=l._meta?.["hydra-acp"];if(l.sessionUpdate==="usage_update"){const h=l.cost,m=g?.[I]?.original,y=typeof m=="number"?m:h?.amount;typeof y=="number"&&typeof c.recordedAt=="number"&&o.push({file:n.name,line:a,recordedAt:c.recordedAt,amount:y});continue}typeof l.toolCallId=="string"&&s.push({toolCallId:l.toolCallId,forwarded:g?.planner!==void 0})}return o.sort((n,a)=>n.recordedAt-a.recordedAt),{dir:t,meta:e,files:r,input:{sessionId:e.sessionId,agentId:e.agentId,forkedFromSessionId:e.forkedFromSessionId,reportedTotal:C(e),usageRows:o,toolCalls:s}}}function O(){const t=process.env.HYDRA_ACP_HOME??d(b(),".hydra-acp");return d(t,"sessions")}function G(){const t=O();return f(t)?S(t).map(e=>d(t,e)).filter(e=>f(d(e,"meta.json"))):[]}export{p as ARCHIVE_RE,_ as BUDGETER_NS,I as REPAIR_KEY,j as buildOpencodeIndex,z as defaultOpencodeDbs,O as hydraSessionsDir,G as listSessionDirs,v as readGate,k as readMeta,E as readTranscriptFiles,C as reportedTotalOf,U as scanSession};
1
+ import{existsSync as p,readdirSync as S,readFileSync as I}from"node:fs";import{gunzipSync as w}from"node:zlib";import{homedir as x}from"node:os";import{resolve as d}from"node:path";import{DatabaseSync as A}from"node:sqlite";import{logger as R}from"../util/log.js";const l=R("cost/repair-io"),_="hydra-acp-budgeter",b="costRepair",f=/^history\.jsonl(\.(\d+)(\.gz)?)?$/;function U(){const n=process.env.OPENCODE_DATA_DIR??d(x(),".local/share/opencode");return["opencode.db","opencode-local.db"].map(e=>d(n,e)).filter(e=>p(e))}function j(n){const e=new Map,s=new Map;for(const t of n){let r;try{r=new A(t,{readOnly:!0})}catch(o){l.warn(`cannot open ${t}: ${o.message}`);continue}try{for(const o of r.prepare("select session_id, data from part where data like '%callID%'").all()){const a=o.data.indexOf('"callID":"');if(a<0)continue;const i=o.data.indexOf('"',a+10);i<0||e.set(o.data.slice(a+10,i),o.session_id)}for(const o of r.prepare("select session_id, data from message").all()){let a;try{a=JSON.parse(o.data)}catch{continue}if(a.role!=="assistant")continue;const i=a.time?.created;if(typeof i!="number")continue;const c=s.get(o.session_id)??[];c.push([i,a.cost??0]),s.set(o.session_id,c)}}catch(o){l.warn(`reading ${t} failed: ${o.message}`)}finally{r.close()}}for(const t of s.values())t.sort((r,o)=>r[0]-o[0]);return l.debug(`indexed ${e.size} tool calls, ${s.size} agent sessions`),{callIndex:e,ledger:s}}function E(n){const e=S(n).filter(t=>f.test(t));e.sort((t,r)=>{const o=t==="history.jsonl"?Number.MAX_SAFE_INTEGER:Number(f.exec(t)?.[2]??0),a=r==="history.jsonl"?Number.MAX_SAFE_INTEGER:Number(f.exec(r)?.[2]??0);return o-a});const s=[];for(const t of e)try{const r=I(d(n,t)),o=t.endsWith(".gz")?w(r).toString("utf8"):r.toString("utf8");s.push({name:t,gz:t.endsWith(".gz"),lines:o.split(`
2
+ `)})}catch(r){l.warn(`reading ${t} failed: ${r.message}`)}return s}function k(n){try{return JSON.parse(I(d(n,"meta.json"),"utf8"))}catch{return}}function C(n){const e=n.currentUsage??{},s=typeof e.costAmount=="number"?e.costAmount:0,t=typeof e.cumulativeCost=="number"?e.cumulativeCost:0;return s+t}function O(n){const s=n.extensionState?.[_]?.[b];return s&&typeof s=="object"?s:void 0}function v(n){const e=O(n),s=[],t=r=>{typeof r=="string"&&r.length>0&&!s.includes(r)&&s.push(r)};if(e!==void 0&&Array.isArray(e.upstreams))for(const r of e.upstreams)t(r);for(const r of n.upstreamGenerations??[])t(r.upstreamSessionId);return t(n.upstreamSessionId),s}function G(n){const e=k(n);if(e===void 0)return;const s=E(n),t=[],r=[];for(const o of s)for(let a=0;a<o.lines.length;a+=1){const i=o.lines[a];if(i.length===0||!i.includes('"usage_update"')&&!i.includes('"toolCallId"'))continue;let c;try{c=JSON.parse(i)}catch{continue}const u=c.params?.update;if(!u)continue;const g=u._meta?.["hydra-acp"];if(u.sessionUpdate==="usage_update"){const h=u.cost,m=g?.[b]?.original,y=typeof m=="number"?m:h?.amount;typeof y=="number"&&typeof c.recordedAt=="number"&&t.push({file:o.name,line:a,recordedAt:c.recordedAt,amount:y});continue}typeof u.toolCallId=="string"&&r.push({toolCallId:u.toolCallId,forwarded:g?.planner!==void 0})}return t.sort((o,a)=>o.recordedAt-a.recordedAt),{dir:n,meta:e,files:s,input:{sessionId:e.sessionId,agentId:e.agentId,forkedFromSessionId:e.forkedFromSessionId,reportedTotal:C(e),usageRows:t,toolCalls:r,currentUpstream:e.upstreamSessionId}}}function M(){const n=process.env.HYDRA_ACP_HOME??d(x(),".hydra-acp");return d(n,"sessions")}function H(){const n=M();return p(n)?S(n).map(e=>d(n,e)).filter(e=>p(d(e,"meta.json"))):[]}export{f as ARCHIVE_RE,_ as BUDGETER_NS,b as REPAIR_KEY,j as buildOpencodeIndex,U as defaultOpencodeDbs,M as hydraSessionsDir,v as knownUpstreams,H as listSessionDirs,O as readGate,k as readMeta,E as readTranscriptFiles,C as reportedTotalOf,G as scanSession};
@@ -1,4 +1,5 @@
1
- import{renameSync as x,writeFileSync as v,readFileSync as y,statSync as O,utimesSync as N}from"node:fs";import{gzipSync as E}from"node:zlib";import{resolve as p}from"node:path";import{logger as j}from"../util/log.js";import{BUDGETER_NS as c,REPAIR_KEY as l,readMeta as U,readTranscriptFiles as T}from"./repair-io.js";const $=j("cost/repair-write"),b=1,k=1;function J(e){return`${c}@${e}`}function _(e,i){const u=JSON.parse(e),n=u.params.update,o=n._meta??{},t=o["hydra-acp"]??{},r=n.cost??{},s=t[l],a=s&&typeof s.original=="number"?s.original:r.amount;return t[l]={v:k,algo:b,original:a},o["hydra-acp"]=t,n._meta=o,n.cost={...r,amount:i},JSON.stringify(u)}function A(e){const i=JSON.parse(e),u=i.params.update,n=u._meta,o=n?.["hydra-acp"],t=o?.[l];if(t)return typeof t.original=="number"&&(u.cost={...u.cost??{},amount:t.original}),delete o[l],Object.keys(o).length===0&&delete n["hydra-acp"],Object.keys(n).length===0&&delete u._meta,JSON.stringify(i)}function w(e,i){const u=`${e}.repair-tmp`;let n;try{const o=O(e);n={atime:o.atime,mtime:o.mtime}}catch{n=void 0}v(u,i,{mode:384}),x(u,e),n&&N(e,n.atime,n.mtime)}function h(e){const i=e.lines.join(`
2
- `);return e.gz?E(Buffer.from(i,"utf8")):i}function B(e,i,u){const n=new Map;for(const r of i.rows){if(Math.abs(r.repaired-r.amount)<1e-9)continue;const s=n.get(r.file)??new Map;s.set(r.line,r.repaired),n.set(r.file,s)}const o=[];let t=0;for(const r of e.files){const s=n.get(r.name);if(!s||s.size===0)continue;const a=[...r.lines];for(const[d,f]of s){const g=a[d];if(g===void 0||g.length===0)throw new Error(`${r.name}:${d} missing while applying repair`);a[d]=_(g,f),t+=1}w(p(e.dir,r.name),h({...r,lines:a})),o.push(r.name)}return M(e,i,t,u),{filesRewritten:o,rowsRewritten:t}}function M(e,i,u,n){const o=p(e.dir,"meta.json"),t=JSON.parse(y(o,"utf8")),r=t.currentUsage??{},s={v:k,algo:b,tool:J(n),source:"opencode-ledger",method:"tool-call-join",upstreams:[...i.attribution.upstreams],rowsRewritten:u,originalCurrentUsage:t.currentUsage,hadExtensionState:t.extensionState!==void 0,reportedTotal:i.reportedTotal,trueTotal:i.trueTotal,at:new Date().toISOString()};t.currentUsage={...r,costAmount:i.trueTotal,cumulativeCost:void 0};const a=t.extensionState??{};a[c]={...a[c]??{},[l]:s},t.extensionState=a,w(o,`${JSON.stringify(t,null,2)}
3
- `)}function I(e){const i=U(e);if(i===void 0)return;const n=i.extensionState?.[c]?.[l];if(!n)return;const o=[];let t=0;for(const d of T(e)){const f=[...d.lines];let g=!1;for(let m=0;m<f.length;m+=1){const R=f[m];if(R.length===0||!R.includes(l))continue;let S;try{S=A(R)}catch{continue}S!==void 0&&(f[m]=S,g=!0,t+=1)}g&&(w(p(e,d.name),h({...d,lines:f})),o.push(d.name))}const r=p(e,"meta.json"),s=JSON.parse(y(r,"utf8"));n.originalCurrentUsage===void 0?delete s.currentUsage:s.currentUsage=n.originalCurrentUsage;const a=s.extensionState??{};return a[c]&&(delete a[c][l],Object.keys(a[c]).length===0&&delete a[c]),Object.keys(a).length===0&&!n.hadExtensionState?delete s.extensionState:s.extensionState=a,w(r,`${JSON.stringify(s,null,2)}
4
- `),$.debug(`reverted ${e}: ${t} rows`),{filesRewritten:o,rowsReverted:t}}export{b as ALGO_VERSION,k as MARKER_VERSION,B as applyPlan,A as revertLine,I as revertSession,_ as rewriteLine};
1
+ import{renameSync as U,writeFileSync as C,readFileSync as h,statSync as $,utimesSync as A}from"node:fs";import{gzipSync as j}from"node:zlib";import{resolve as R}from"node:path";import{logger as E}from"../util/log.js";import{BUDGETER_NS as f,REPAIR_KEY as g,readMeta as J,readTranscriptFiles as k}from"./repair-io.js";const S=E("cost/repair-write"),x=1,v=1;function O(n){return`${f}@${n}`}function M(n,r){const s=JSON.parse(n),i=s.params.update,o=i._meta??{},e=o["hydra-acp"]??{},t=i.cost??{},u=e[g],a=u&&typeof u.original=="number"?u.original:t.amount;return e[g]={v,algo:x,original:a},o["hydra-acp"]=e,i._meta=o,i.cost={...t,amount:r},JSON.stringify(s)}function T(n){const r=JSON.parse(n),s=r.params.update,i=s._meta,o=i?.["hydra-acp"],e=o?.[g];if(e)return typeof e.original=="number"&&(s.cost={...s.cost??{},amount:e.original}),delete o[g],Object.keys(o).length===0&&delete i["hydra-acp"],Object.keys(i).length===0&&delete s._meta,JSON.stringify(r)}function b(n,r){const s=`${n}.repair-tmp`;let i;try{const o=$(n);i={atime:o.atime,mtime:o.mtime}}catch{i=void 0}C(s,r,{mode:384}),U(s,n),i&&A(n,i.atime,i.mtime)}function N(n){const r=n.lines.join(`
2
+ `);return n.gz?j(Buffer.from(r,"utf8")):r}function B(n,r,s){const i=new Map;for(const t of r.rows){if(Math.abs(t.repaired-t.amount)<1e-9)continue;const u=i.get(t.file)??new Map;u.set(t.line,t.repaired),i.set(t.file,u)}const o=[];let e=0;for(const t of n.files){const u=i.get(t.name);if(!u||u.size===0)continue;const a=[...t.lines];for(const[d,c]of u){const l=a[d];if(l===void 0||l.length===0)throw new Error(`${t.name}:${d} missing while applying repair`);a[d]=M(l,c),e+=1}b(R(n.dir,t.name),N({...t,lines:a})),o.push(t.name)}return _(n,r,e,s),{filesRewritten:o,rowsRewritten:e}}function _(n,r,s,i){const o=R(n.dir,"meta.json"),e=JSON.parse(h(o,"utf8")),t=e.currentUsage??{},u={v,algo:x,tool:O(i),source:"opencode-ledger",method:"tool-call-join",upstreams:[...r.attribution.upstreams],rowsRewritten:s,originalCurrentUsage:e.currentUsage,hadExtensionState:e.extensionState!==void 0,reportedTotal:r.reportedTotal,trueTotal:r.trueTotal,at:new Date().toISOString()};e.currentUsage={...t,costAmount:r.split.current,cumulativeCost:r.split.retired>0?r.split.retired:void 0};const a=e.extensionState??{};a[f]={...a[f]??{},[g]:u},e.extensionState=a,b(o,`${JSON.stringify(e,null,2)}
3
+ `)}function G(n,r){const s=Date.parse(r);if(Number.isNaN(s))return 0;let i=0;for(const o of k(n))for(const e of o.lines){if(e.length===0||!e.includes('"usage_update"')||e.includes(g))continue;let t;try{t=JSON.parse(e)}catch{continue}typeof t.params?.update?.cost!="object"||t.params.update.cost===null||typeof t.recordedAt=="number"&&t.recordedAt>s&&(i+=1)}return i}function D(n,r={}){const s=J(n);if(s===void 0)return;const o=s.extensionState?.[f]?.[g];if(!o)return;if(r.force!==!0&&typeof o.at=="string"){const c=G(n,o.at);if(c>0)return{filesRewritten:[],rowsReverted:0,postRepairRows:c,refused:`${c} usage row(s) recorded after the repair have no pristine value to restore; reverting would strand the transcript on two baselines and roll meta.json back to its repair-time snapshot, discarding spend since then`}}const e=[];let t=0;for(const c of k(n)){const l=[...c.lines];let m=!1;for(let w=0;w<l.length;w+=1){const p=l[w];if(p.length===0||!p.includes(g))continue;let y;try{y=T(p)}catch{continue}y!==void 0&&(l[w]=y,m=!0,t+=1)}m&&(b(R(n,c.name),N({...c,lines:l})),e.push(c.name))}const u=R(n,"meta.json"),a=JSON.parse(h(u,"utf8"));o.originalCurrentUsage===void 0?delete a.currentUsage:a.currentUsage=o.originalCurrentUsage;const d=a.extensionState??{};return d[f]&&(delete d[f][g],Object.keys(d[f]).length===0&&delete d[f]),Object.keys(d).length===0&&!o.hadExtensionState?delete a.extensionState:a.extensionState=d,b(u,`${JSON.stringify(a,null,2)}
4
+ `),S.debug(`reverted ${n}: ${t} rows`),{filesRewritten:e,rowsReverted:t}}function K(n,r,s,i={}){const o=R(n,"meta.json"),e=JSON.parse(h(o,"utf8")),u=e.extensionState?.[f]?.[g];if(u===void 0)return{kind:"skip",why:"no-gate"};if(r.perUpstream.length<2)return{kind:"skip",why:"single-upstream"};if(!r.perUpstream.some(y=>y.isCurrent))return{kind:"refuse",why:"current upstream absent from the ledger"};const a=e.currentUsage??{},d=typeof a.cumulativeCost=="number"?a.cumulativeCost:0,c=typeof a.costAmount=="number"?a.costAmount:0,l=r.retired,m=r.current;if(Math.abs(d-l)<1e-6&&Math.abs(c-m)<1e-6)return{kind:"skip",why:"already-correct"};const w=e.extensionState,p={...u};return p.preReshapeCurrentUsage===void 0&&(p.preReshapeCurrentUsage=e.currentUsage),p.reshapedAt=new Date().toISOString(),p.reshapeTool=O(s),e.currentUsage={...a,costAmount:m,cumulativeCost:l>0?l:void 0},w[f]={...w[f]??{},[g]:p},e.extensionState=w,i.dryRun!==!0&&(b(o,`${JSON.stringify(e,null,2)}
5
+ `),S.info(`reshaped ${String(e.sessionId)}: ${d}+${c} -> ${l}+${m}`)),{kind:"ok",beforeCum:d,beforeCur:c,cum:l,cur:m,totalDelta:l+m-(d+c)}}export{x as ALGO_VERSION,v as MARKER_VERSION,B as applyPlan,G as postRepairUsageRows,K as reshapeSession,T as revertLine,D as revertSession,M as rewriteLine};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydra-acp/budgeter",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Cost-budget transformer extension for hydra-acp — warns on soft limit, rejects further prompts/sessions on hard limit.",
5
5
  "license": "MIT",
6
6
  "type": "module",