kodelyth-ecc 2.24.11 → 2.29.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.
@@ -0,0 +1,123 @@
1
+ // scripts/super-agent/dag.js
2
+ //
3
+ // Pure dependency-graph model for a super-agent run's plan. No I/O. A step is
4
+ // { id, description, deps: [ids], exitCriteria?, status, ... }
5
+ // and this module answers the only questions the orchestrator needs: which steps
6
+ // are runnable right now, is the plan finished, and is it stuck. Keeping this
7
+ // pure is what makes the whole control plane testable without spawning an agent.
8
+ 'use strict';
9
+
10
+ const STATUS = Object.freeze({
11
+ PENDING: 'pending',
12
+ IN_PROGRESS: 'in_progress',
13
+ DONE: 'done',
14
+ FAILED: 'failed',
15
+ SKIPPED: 'skipped',
16
+ });
17
+
18
+ // Normalize a raw plan into well-formed steps (stable string ids, deps array,
19
+ // a starting status). Ids are coerced to strings so numeric ids from a blueprint
20
+ // and string deps compare correctly.
21
+ function normalizeSteps(steps) {
22
+ return (Array.isArray(steps) ? steps : []).map((s) => ({
23
+ status: STATUS.PENDING,
24
+ ...s,
25
+ id: String(s && s.id != null ? s.id : ''),
26
+ deps: Array.isArray(s && s.deps) ? s.deps.map(String) : [],
27
+ }));
28
+ }
29
+
30
+ // Validate the plan is a sane DAG: unique non-empty ids, deps that exist, no
31
+ // self-dependency, and no cycles. Returns { ok, errors: [] }.
32
+ function validate(steps) {
33
+ const errors = [];
34
+ const ids = new Set();
35
+ for (const s of steps) {
36
+ if (!s.id) errors.push('a step has no id');
37
+ else if (ids.has(s.id)) errors.push(`duplicate step id: ${s.id}`);
38
+ else ids.add(s.id);
39
+ }
40
+ for (const s of steps) {
41
+ for (const d of s.deps) {
42
+ if (d === s.id) errors.push(`step ${s.id} depends on itself`);
43
+ else if (!ids.has(d)) errors.push(`step ${s.id} depends on unknown step ${d}`);
44
+ }
45
+ }
46
+ if (errors.length === 0 && hasCycle(steps)) errors.push('the plan has a dependency cycle');
47
+ return { ok: errors.length === 0, errors };
48
+ }
49
+
50
+ // Kahn's algorithm: if we cannot topologically order every step, there is a cycle.
51
+ function hasCycle(steps) {
52
+ const indeg = new Map(steps.map((s) => [s.id, 0]));
53
+ const byId = new Map(steps.map((s) => [s.id, s]));
54
+ for (const s of steps) {
55
+ for (const d of s.deps) {
56
+ if (byId.has(d)) indeg.set(s.id, indeg.get(s.id) + 1);
57
+ }
58
+ }
59
+ const queue = [...indeg.entries()].filter(([, n]) => n === 0).map(([id]) => id);
60
+ const dependents = new Map(steps.map((s) => [s.id, []]));
61
+ for (const s of steps) {
62
+ for (const d of s.deps) {
63
+ if (dependents.has(d)) dependents.get(d).push(s.id);
64
+ }
65
+ }
66
+ let visited = 0;
67
+ while (queue.length) {
68
+ const id = queue.shift();
69
+ visited++;
70
+ for (const dep of dependents.get(id) || []) {
71
+ indeg.set(dep, indeg.get(dep) - 1);
72
+ if (indeg.get(dep) === 0) queue.push(dep);
73
+ }
74
+ }
75
+ return visited !== steps.length;
76
+ }
77
+
78
+ // Steps that can run now: still pending, with every dependency DONE (or SKIPPED,
79
+ // which counts as satisfied — a skipped step did not fail).
80
+ function readySteps(steps) {
81
+ const satisfied = new Set(
82
+ steps.filter((s) => s.status === STATUS.DONE || s.status === STATUS.SKIPPED).map((s) => s.id),
83
+ );
84
+ return steps.filter(
85
+ (s) => s.status === STATUS.PENDING && s.deps.every((d) => satisfied.has(d)),
86
+ );
87
+ }
88
+
89
+ function isComplete(steps) {
90
+ return steps.length > 0 && steps.every((s) => s.status === STATUS.DONE || s.status === STATUS.SKIPPED);
91
+ }
92
+
93
+ // Stuck: there is still work pending, but nothing is runnable and nothing is in
94
+ // progress — so a dependency failed (or, if validate() passed, it cannot be a
95
+ // cycle). The orchestrator turns this into a halt rather than spinning forever.
96
+ function isStalled(steps) {
97
+ const pending = steps.filter((s) => s.status === STATUS.PENDING);
98
+ const running = steps.some((s) => s.status === STATUS.IN_PROGRESS);
99
+ return pending.length > 0 && !running && readySteps(steps).length === 0;
100
+ }
101
+
102
+ function progress(steps) {
103
+ const count = (st) => steps.filter((s) => s.status === st).length;
104
+ return {
105
+ total: steps.length,
106
+ done: count(STATUS.DONE),
107
+ failed: count(STATUS.FAILED),
108
+ inProgress: count(STATUS.IN_PROGRESS),
109
+ pending: count(STATUS.PENDING),
110
+ skipped: count(STATUS.SKIPPED),
111
+ };
112
+ }
113
+
114
+ module.exports = {
115
+ STATUS,
116
+ normalizeSteps,
117
+ validate,
118
+ hasCycle,
119
+ readySteps,
120
+ isComplete,
121
+ isStalled,
122
+ progress,
123
+ };
@@ -0,0 +1,91 @@
1
+ // scripts/super-agent/integrate.js
2
+ //
3
+ // Branch integration for the super-agent — the piece Agent B flagged as missing:
4
+ // "the swarm fans out but never merges worker branches." Two parts:
5
+ //
6
+ // mergeOrder(steps) — PURE. The order to merge worker branches in, respecting
7
+ // step dependencies (a dependency's branch merges first).
8
+ // detectConflicts() — uses `git merge-tree --write-tree` to find, WITHOUT
9
+ // touching the working tree, which branches would conflict
10
+ // if merged together.
11
+ //
12
+ // The orchestrator's integrate phase uses this to decide whether to proceed
13
+ // (clean) or stop for a human (conflicts) — see submitIntegration.
14
+ 'use strict';
15
+
16
+ const { spawnSync } = require('child_process');
17
+
18
+ // Topologically order the done-with-branch steps so a dependency is merged
19
+ // before anything built on top of it. Pure; only reorders what is given.
20
+ function mergeOrder(steps) {
21
+ const withBranch = (steps || []).filter((s) => s.status === 'done' && s.branch);
22
+ const ids = new Set(withBranch.map((s) => s.id));
23
+ const byId = new Map(withBranch.map((s) => [s.id, s]));
24
+ const indeg = new Map(withBranch.map((s) => [s.id, 0]));
25
+ for (const s of withBranch) {
26
+ for (const d of s.deps || []) if (ids.has(String(d))) indeg.set(s.id, indeg.get(s.id) + 1);
27
+ }
28
+ // Stable: break ties by original order.
29
+ const order = [];
30
+ const queue = withBranch.filter((s) => indeg.get(s.id) === 0).map((s) => s.id);
31
+ while (queue.length) {
32
+ const id = queue.shift();
33
+ order.push(id);
34
+ for (const s of withBranch) {
35
+ if ((s.deps || []).map(String).includes(id) && indeg.has(s.id)) {
36
+ indeg.set(s.id, indeg.get(s.id) - 1);
37
+ if (indeg.get(s.id) === 0) queue.push(s.id);
38
+ }
39
+ }
40
+ }
41
+ // Any leftover (shouldn't happen for a validated plan) appended in input order.
42
+ for (const s of withBranch) if (!order.includes(s.id)) order.push(s.id);
43
+ return order.map((id) => ({ step: id, branch: byId.get(id).branch }));
44
+ }
45
+
46
+ function branchNames(branches) {
47
+ return (branches || [])
48
+ .map((b) => (typeof b === 'string' ? b : b && b.branch))
49
+ .filter(Boolean);
50
+ }
51
+
52
+ // Does merging branchB into branchA conflict? No working-tree side effects.
53
+ function pairConflicts(repoRoot, branchA, branchB) {
54
+ const r = spawnSync('git', ['merge-tree', '--write-tree', branchA, branchB], {
55
+ cwd: repoRoot, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
56
+ });
57
+ if (r.status === 0) return { conflict: false };
58
+ if (r.status === 1) return { conflict: true, files: parseConflictFiles(r.stdout) };
59
+ return { conflict: false, error: (r.stderr || '').trim() || `git merge-tree exited ${r.status}` };
60
+ }
61
+
62
+ // Best-effort: pull file paths out of merge-tree's conflict section. The first
63
+ // line is the tree oid; conflict detail follows. Version-tolerant — we just keep
64
+ // anything that looks like a path, deduped.
65
+ function parseConflictFiles(stdout) {
66
+ const files = new Set();
67
+ for (const line of String(stdout || '').split('\n').slice(1)) {
68
+ const m = line.match(/(?:^|\s)([\w.\-/]+\.[\w]+)(?:\s|$)/);
69
+ if (m) files.add(m[1]);
70
+ }
71
+ return [...files];
72
+ }
73
+
74
+ // Pairwise conflict scan across all worker branches. Catches the common case —
75
+ // two workers editing the same file — before any real merge happens. Returns
76
+ // { clean, conflicts: [{ branches:[a,b], files:[] }], errors: [] }.
77
+ function detectConflicts(repoRoot, branches) {
78
+ const names = branchNames(branches);
79
+ const conflicts = [];
80
+ const errors = [];
81
+ for (let i = 0; i < names.length; i++) {
82
+ for (let j = i + 1; j < names.length; j++) {
83
+ const c = pairConflicts(repoRoot, names[i], names[j]);
84
+ if (c.error) errors.push({ branches: [names[i], names[j]], error: c.error });
85
+ else if (c.conflict) conflicts.push({ branches: [names[i], names[j]], files: c.files || [] });
86
+ }
87
+ }
88
+ return { clean: conflicts.length === 0, conflicts, errors };
89
+ }
90
+
91
+ module.exports = { mergeOrder, detectConflicts, pairConflicts, parseConflictFiles, branchNames };
@@ -0,0 +1,175 @@
1
+ // scripts/super-agent/orchestrator.js
2
+ //
3
+ // The super-agent driver — the piece Agent B found missing. It is an Arena-style
4
+ // state machine: a PURE nextAction(run) decides what to do next, and the driving
5
+ // LLM performs that action (decompose with blueprint, dispatch via the swarm,
6
+ // evaluate with the GAN loop, integrate branches, verify) and reports back
7
+ // through a submit* function, which advances the run and persists it.
8
+ //
9
+ // plan ──decompose──▶ execute ──(all steps done)──▶ integrate ──▶ verify ──▶ done
10
+ // │
11
+ // └─ dispatch ready steps (deps satisfied), budget-gated
12
+ //
13
+ // nextAction never mutates, so the whole control plane is tested without spawning
14
+ // a single agent. Mutations + phase transitions live in the submit* recorders.
15
+ 'use strict';
16
+
17
+ const dag = require('./dag.js');
18
+ const state = require('./state.js');
19
+ const { PHASE } = state;
20
+
21
+ // Rough per-step token estimate for the budget gate. Deliberately conservative;
22
+ // real spend is accrued from what the driver reports.
23
+ const DEFAULT_STEP_TOKENS = 4000;
24
+
25
+ function estimateTokens(steps) {
26
+ return (steps.length || 1) * DEFAULT_STEP_TOKENS;
27
+ }
28
+
29
+ function stepBrief(s) {
30
+ return {
31
+ id: s.id,
32
+ description: s.description || '',
33
+ deps: s.deps || [],
34
+ contextBrief: s.contextBrief || s.context || '',
35
+ exitCriteria: s.exitCriteria || s.exit || '',
36
+ };
37
+ }
38
+
39
+ function branchesOf(run) {
40
+ return run.steps
41
+ .filter((s) => s.status === dag.STATUS.DONE && s.branch)
42
+ .map((s) => ({ step: s.id, branch: s.branch }));
43
+ }
44
+
45
+ // ── The state machine (pure) ─────────────────────────────────────────────────
46
+ function nextAction(run) {
47
+ if (!run || run.status !== 'running') {
48
+ return { action: 'report', reason: (run && (run.stopReason || run.status)) || 'no run', run: run && state.summarize(run) };
49
+ }
50
+
51
+ switch (run.phase) {
52
+ case PHASE.PLAN:
53
+ // No plan yet → ask the driver to decompose the goal into a step DAG.
54
+ return { action: 'decompose', goal: run.goal, scope: run.scope };
55
+
56
+ case PHASE.EXECUTE: {
57
+ if (dag.isStalled(run.steps)) {
58
+ return { action: 'halt', reason: 'plan stalled — a dependency failed, so no remaining step can run' };
59
+ }
60
+ const ready = dag.readySteps(run.steps);
61
+ if (ready.length === 0) {
62
+ // Not stalled and nothing ready → steps are in progress; wait for results.
63
+ return { action: 'await', inProgress: run.steps.filter((s) => s.status === dag.STATUS.IN_PROGRESS).map((s) => s.id) };
64
+ }
65
+ const afford = state.canAfford(run, estimateTokens(ready));
66
+ if (!afford.ok) return { action: 'halt', reason: `budget: ${afford.reason}` };
67
+ return { action: 'dispatch', parallel: ready.length > 1, steps: ready.map(stepBrief) };
68
+ }
69
+
70
+ case PHASE.INTEGRATE:
71
+ return { action: 'integrate', branches: branchesOf(run), scope: run.scope };
72
+
73
+ case PHASE.VERIFY:
74
+ return { action: 'verify', scope: run.scope };
75
+
76
+ case PHASE.DONE:
77
+ return { action: 'report', reason: 'done', run: state.summarize(run) };
78
+
79
+ default:
80
+ return { action: 'report', reason: `unknown phase: ${run.phase}`, run: state.summarize(run) };
81
+ }
82
+ }
83
+
84
+ // ── Recorders (mutate + advance + persist) ───────────────────────────────────
85
+
86
+ // The driver decomposed the goal (via blueprint). Install the plan.
87
+ function submitPlan(run, rawSteps, { persist = true } = {}) {
88
+ requirePhase(run, PHASE.PLAN);
89
+ const steps = dag.normalizeSteps(rawSteps);
90
+ const v = dag.validate(steps);
91
+ if (!v.ok) throw new Error(`super-agent: invalid plan — ${v.errors.join('; ')}`);
92
+ if (steps.length === 0) throw new Error('super-agent: plan has no steps');
93
+ run.steps = steps;
94
+ run.phase = PHASE.EXECUTE;
95
+ state.record(run, { event: 'planned', steps: steps.length });
96
+ return persist ? state.save(run) : run;
97
+ }
98
+
99
+ // Mark a step as about to run (so nextAction reports it as in-progress, not ready).
100
+ function markDispatched(run, stepIds, { persist = true } = {}) {
101
+ const want = new Set((Array.isArray(stepIds) ? stepIds : [stepIds]).map(String));
102
+ for (const s of run.steps) {
103
+ if (want.has(s.id) && s.status === dag.STATUS.PENDING) s.status = dag.STATUS.IN_PROGRESS;
104
+ }
105
+ state.record(run, { event: 'dispatched', steps: [...want] });
106
+ return persist ? state.save(run) : run;
107
+ }
108
+
109
+ // The driver finished a step. Record result + budget, then advance the phase if
110
+ // the plan is now complete or stalled.
111
+ function submitStepResult(run, stepId, { status = dag.STATUS.DONE, branch = null, result = null, tokensSpent = 0 } = {}, { persist = true } = {}) {
112
+ const s = run.steps.find((x) => x.id === String(stepId));
113
+ if (!s) throw new Error(`super-agent: unknown step ${stepId}`);
114
+ s.status = status;
115
+ if (branch) s.branch = branch;
116
+ if (result != null) s.result = result;
117
+ state.accrue(run, { tokensSpent, steps: 1 });
118
+ state.record(run, { event: 'step', step: s.id, status, tokensSpent });
119
+
120
+ if (dag.isComplete(run.steps)) {
121
+ run.phase = PHASE.INTEGRATE;
122
+ } else if (dag.isStalled(run.steps)) {
123
+ state.stop(run, 'plan stalled after a step failed');
124
+ }
125
+ return persist ? state.save(run) : run;
126
+ }
127
+
128
+ // The driver merged the worker branches.
129
+ function submitIntegration(run, { merged = true, conflicts = [], tokensSpent = 0 } = {}, { persist = true } = {}) {
130
+ requirePhase(run, PHASE.INTEGRATE);
131
+ state.accrue(run, { tokensSpent });
132
+ if (Array.isArray(conflicts) && conflicts.length > 0) {
133
+ state.record(run, { event: 'integration-conflicts', conflicts });
134
+ state.stop(run, `integration conflicts in: ${conflicts.join(', ')}`);
135
+ } else if (!merged) {
136
+ state.stop(run, 'integration reported not merged');
137
+ } else {
138
+ run.phase = PHASE.VERIFY;
139
+ state.record(run, { event: 'integrated' });
140
+ }
141
+ return persist ? state.save(run) : run;
142
+ }
143
+
144
+ // The driver ran the verification gate (tests/build).
145
+ function submitVerification(run, { passed = false, detail = '', tokensSpent = 0 } = {}, { persist = true } = {}) {
146
+ requirePhase(run, PHASE.VERIFY);
147
+ state.accrue(run, { tokensSpent });
148
+ if (passed) {
149
+ run.phase = PHASE.DONE;
150
+ run.status = 'done';
151
+ state.record(run, { event: 'verified', detail });
152
+ } else {
153
+ run.status = 'failed';
154
+ run.stopReason = `verification failed: ${detail || 'tests/build did not pass'}`;
155
+ state.record(run, { event: 'verification-failed', detail });
156
+ }
157
+ return persist ? state.save(run) : run;
158
+ }
159
+
160
+ function requirePhase(run, expected) {
161
+ if (run.phase !== expected) {
162
+ throw new Error(`super-agent: expected phase "${expected}" but run is in "${run.phase}"`);
163
+ }
164
+ }
165
+
166
+ module.exports = {
167
+ nextAction,
168
+ submitPlan,
169
+ markDispatched,
170
+ submitStepResult,
171
+ submitIntegration,
172
+ submitVerification,
173
+ estimateTokens,
174
+ DEFAULT_STEP_TOKENS,
175
+ };
@@ -0,0 +1,136 @@
1
+ // scripts/super-agent/state.js
2
+ //
3
+ // Run lifecycle, persistence (so a run resumes after a crash or a new session),
4
+ // and the budget governor for the super-agent orchestrator. Mirrors the pattern
5
+ // of scripts/arena/state.js: the control plane is tokenless and durable, so the
6
+ // expensive part (actually running agents) can stop and pick up again.
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+ const crypto = require('crypto');
13
+ const safeFs = require('../lib/safe-fs.js');
14
+ const dag = require('./dag.js');
15
+
16
+ const RUN_DIR = process.env.KODELYTH_SUPER_AGENT_DIR
17
+ || path.join(os.homedir(), '.kodelythecc', 'super-agent');
18
+
19
+ const PHASE = Object.freeze({
20
+ PLAN: 'plan',
21
+ EXECUTE: 'execute',
22
+ INTEGRATE: 'integrate',
23
+ VERIFY: 'verify',
24
+ DONE: 'done',
25
+ });
26
+
27
+ function runPath(id) {
28
+ return path.join(RUN_DIR, `${id}.json`);
29
+ }
30
+
31
+ function createRun({ goal, limits = {}, scope = '.' } = {}) {
32
+ if (!goal || !String(goal).trim()) throw new Error('super-agent: a run needs a goal');
33
+ return {
34
+ id: crypto.randomBytes(6).toString('hex'),
35
+ goal: String(goal).trim(),
36
+ scope,
37
+ createdAt: new Date().toISOString(),
38
+ status: 'running', // running | done | failed | stopped
39
+ phase: PHASE.PLAN,
40
+ steps: [],
41
+ budget: {
42
+ // 0 / null means "no cap". Caps are the governor Agent B flagged as missing
43
+ // — a single per-session token-budget could not bound a whole run.
44
+ tokenCap: limits.tokenCap || 0,
45
+ stepCap: limits.stepCap || 0,
46
+ tokensSpent: 0,
47
+ stepsRun: 0,
48
+ },
49
+ history: [],
50
+ stopReason: null,
51
+ };
52
+ }
53
+
54
+ function record(run, event) {
55
+ run.history.push({ at: new Date().toISOString(), ...event });
56
+ }
57
+
58
+ // Budget governor. Returns { ok, reason? }. Checks both the token cap and the
59
+ // step cap; an estimate of 0 still fails if we are already over.
60
+ function canAfford(run, estTokens = 0) {
61
+ const b = run.budget;
62
+ if (b.stepCap && b.stepsRun >= b.stepCap) {
63
+ return { ok: false, reason: `step cap reached (${b.stepsRun}/${b.stepCap})` };
64
+ }
65
+ if (b.tokenCap && b.tokensSpent + estTokens > b.tokenCap) {
66
+ return { ok: false, reason: `token cap would be exceeded (${b.tokensSpent}+${estTokens} > ${b.tokenCap})` };
67
+ }
68
+ return { ok: true };
69
+ }
70
+
71
+ function accrue(run, { tokensSpent = 0, steps = 0 } = {}) {
72
+ run.budget.tokensSpent += Math.max(0, tokensSpent) || 0;
73
+ run.budget.stepsRun += Math.max(0, steps) || 0;
74
+ }
75
+
76
+ function stop(run, reason) {
77
+ run.status = 'stopped';
78
+ run.stopReason = reason;
79
+ record(run, { event: 'stopped', reason });
80
+ }
81
+
82
+ function save(run) {
83
+ fs.mkdirSync(RUN_DIR, { recursive: true });
84
+ // Atomic: a crash mid-write must not corrupt a resumable run.
85
+ safeFs.replaceFileAtomic(runPath(run.id), JSON.stringify(run, null, 2) + '\n', 0o600);
86
+ return run;
87
+ }
88
+
89
+ function load(id) {
90
+ const raw = fs.readFileSync(runPath(id), 'utf8');
91
+ return JSON.parse(raw);
92
+ }
93
+
94
+ function exists(id) {
95
+ return fs.existsSync(runPath(id));
96
+ }
97
+
98
+ function list() {
99
+ if (!fs.existsSync(RUN_DIR)) return [];
100
+ return fs.readdirSync(RUN_DIR)
101
+ .filter((f) => f.endsWith('.json'))
102
+ .map((f) => {
103
+ try { return JSON.parse(fs.readFileSync(path.join(RUN_DIR, f), 'utf8')); }
104
+ catch { return null; }
105
+ })
106
+ .filter(Boolean)
107
+ .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
108
+ }
109
+
110
+ function summarize(run) {
111
+ return {
112
+ id: run.id,
113
+ goal: run.goal,
114
+ status: run.status,
115
+ phase: run.phase,
116
+ progress: dag.progress(run.steps),
117
+ budget: run.budget,
118
+ stopReason: run.stopReason,
119
+ };
120
+ }
121
+
122
+ module.exports = {
123
+ PHASE,
124
+ RUN_DIR,
125
+ runPath,
126
+ createRun,
127
+ record,
128
+ canAfford,
129
+ accrue,
130
+ stop,
131
+ save,
132
+ load,
133
+ exists,
134
+ list,
135
+ summarize,
136
+ };
@@ -1,9 +1,16 @@
1
1
  // scripts/terse/ledger.js
2
- // Output-token savings ledger. Stores one JSONL row per terse-active turn.
3
- // Zero deps. Reads only. Writes append-only.
2
+ // Output-token savings ledger. Stores one JSONL row per recorded terse turn.
3
+ // Zero deps. Writes append-only.
4
+ //
5
+ // IMPORTANT — these rows are ESTIMATES, not measured savings. There is no
6
+ // non-terse copy of the same reply to diff against, so `saved` is modeled:
7
+ // actual output tokens times a per-level baseline multiplier (RAW_MULT below).
8
+ // Every figure here is an estimate and each row is flagged `estimated: true`.
9
+ // (The Terse *file* compressor in compress.js, by contrast, reports real,
10
+ // measured before/after bytes.)
4
11
  //
5
12
  // Row shape:
6
- // { ts, level, rawEstimate, actual, saved, projectHash?, source }
13
+ // { ts, level, rawEstimate, actual, saved, estimated, baseline, projectHash?, source }
7
14
  //
8
15
  // Path: ~/.kodelythecc/terse/ledger.jsonl (overridable via KODELYTH_TERSE_DIR)
9
16
 
@@ -31,8 +38,10 @@ function estimateTokens(text) {
31
38
  return Math.round(Buffer.byteLength(text, 'utf8') / 4);
32
39
  }
33
40
 
34
- // Baseline output multiplier per level.
35
- // Empirically: full ≈ 0.5x normal, ultra ≈ 0.35x, lite ≈ 0.75x.
41
+ // Per-level baseline multiplier used to MODEL what the same reply would have
42
+ // cost without terse (full ≈ 2x the terse size, ultra ≈ 2.85x, lite ≈ 1.33x).
43
+ // These are assumptions, not measurements — every `saved` figure derived from
44
+ // them is an estimate, which is why each row is flagged `estimated: true`.
36
45
  const RAW_MULT = { lite: 1.33, full: 2.0, ultra: 2.85, off: 1.0 };
37
46
 
38
47
  function appendTurn({ actualText, level = 'full', source = 'unknown', projectHash = null }) {
@@ -46,6 +55,8 @@ function appendTurn({ actualText, level = 'full', source = 'unknown', projectHas
46
55
  rawEstimate,
47
56
  actual,
48
57
  saved,
58
+ estimated: true, // modeled from RAW_MULT, not a measured A/B
59
+ baseline: RAW_MULT[level] || 1,
49
60
  source,
50
61
  ...(projectHash ? { projectHash } : {}),
51
62
  };
@@ -83,7 +83,7 @@ Example — ~70% shorter:
83
83
 
84
84
  - Slash command: `/terse [lite|full|ultra|off]` — sticks for the session
85
85
  - CLI: `kodelyth-ecc terse enable [--target claude-code|--all]` — installs the skill + command into your AI tool
86
- - Statusline (Claude Code): shows `[TERSE ⚡ 12.4k]` — lifetime output tokens saved
86
+ - Dashboard (Claude Code): the **Token Savings** tab shows lifetime *estimated* output savings from the terse ledger — modeled, not measured (see the ledger note in `docs/terse-mode.md`)
87
87
 
88
88
  ## Honest numbers
89
89