@arthovlabs/liber 0.0.0-stage → 0.5.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -2
  3. package/bin/liber +706 -0
  4. package/bin/liber-tui +244 -0
  5. package/bin/liber-tui.py +825 -0
  6. package/bin/liberd-entry +17 -0
  7. package/liber.yaml +84 -0
  8. package/package.json +43 -4
  9. package/resources/icons/appIcon.png +0 -0
  10. package/resources/icons/appIcon.svg +20 -0
  11. package/resources/icons/appIcon@512.png +0 -0
  12. package/resources/icons/atom-transparent-256.png +0 -0
  13. package/resources/icons/atom-transparent.png +0 -0
  14. package/resources/icons/atom-transparent.svg +19 -0
  15. package/resources/icons/make-atom.js +168 -0
  16. package/src/cli/ui.js +520 -0
  17. package/src/daemon/core/auth.js +217 -0
  18. package/src/daemon/core/automations.js +130 -0
  19. package/src/daemon/core/budget.js +145 -0
  20. package/src/daemon/core/circuit-breaker.js +31 -0
  21. package/src/daemon/core/config-apply.js +223 -0
  22. package/src/daemon/core/connectors.js +131 -0
  23. package/src/daemon/core/engine.js +1029 -0
  24. package/src/daemon/core/evidence.js +306 -0
  25. package/src/daemon/core/executor.js +10 -0
  26. package/src/daemon/core/grounding.js +420 -0
  27. package/src/daemon/core/injection.js +24 -0
  28. package/src/daemon/core/journal.js +81 -0
  29. package/src/daemon/core/liber-soul.js +157 -0
  30. package/src/daemon/core/lock.js +32 -0
  31. package/src/daemon/core/memory.js +106 -0
  32. package/src/daemon/core/model-switch.js +58 -0
  33. package/src/daemon/core/policy.js +102 -0
  34. package/src/daemon/core/providers.js +503 -0
  35. package/src/daemon/core/registry.js +356 -0
  36. package/src/daemon/core/secrets.js +35 -0
  37. package/src/daemon/core/skills.js +264 -0
  38. package/src/daemon/core/tools/browser-tool.js +280 -0
  39. package/src/daemon/core/tools/file-tools.js +277 -0
  40. package/src/daemon/core/tools/memory-tool.js +127 -0
  41. package/src/daemon/core/tools/skill-tool.js +44 -0
  42. package/src/daemon/core/tools/task-tool.js +41 -0
  43. package/src/daemon/core/tools/terminal-tool.js +449 -0
  44. package/src/daemon/core/tools/todo-tool.js +100 -0
  45. package/src/daemon/core/tools/web-tool.js +181 -0
  46. package/src/daemon/core/trace.js +40 -0
  47. package/src/daemon/core/validate.js +105 -0
  48. package/src/daemon/core/vault.js +158 -0
  49. package/src/daemon/core/verifier.js +98 -0
  50. package/src/daemon/core/voice.js +83 -0
  51. package/src/daemon/core/workspace.js +199 -0
  52. package/src/daemon/liberd.js +1343 -0
  53. package/src/daemon/llm/adapters/anthropic-adapter.js +190 -0
  54. package/src/daemon/llm/adapters/gemini-adapter.js +148 -0
  55. package/src/daemon/llm/adapters/ollama-adapter.js +74 -0
  56. package/src/daemon/llm/adapters/openai-adapter.js +148 -0
  57. package/src/daemon/llm/base.js +31 -0
  58. package/src/daemon/llm/manager.js +134 -0
  59. package/src/utils/config-loader.js +109 -0
  60. package/src/utils/logger.js +56 -0
@@ -0,0 +1,217 @@
1
+ // src/daemon/core/auth.js — who may talk to the daemon, and how often.
2
+ //
3
+ // One shared token is fine for a single desktop app and wrong for anything else:
4
+ // it cannot be revoked without breaking every client, it cannot be limited, and
5
+ // an audit line saying "someone did this" is useless. This module keeps a client
6
+ // per token, each with scopes and a rate limit, and never stores the token
7
+ // itself — only its SHA-256.
8
+ const crypto = require('crypto');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const DIR = () => path.resolve(process.cwd(), '.liber');
13
+ const tokenPath = () => path.join(DIR(), 'daemon.token');
14
+ const FILE = () => path.join(DIR(), 'clients.json');
15
+ const AUDIT = () => path.join(DIR(), 'auth-audit.log');
16
+
17
+ const SCOPES = ['read', 'chat', 'tools', 'admin'];
18
+ const DEFAULT_LIMIT = 240; // requests per minute per client
19
+
20
+ /** Scope a request needs. Unlisted endpoints fall back to `tools`. */
21
+ function scopeFor(method, endpoint) {
22
+ if (endpoint === '/shutdown' || endpoint.startsWith('/clients')) return 'admin';
23
+ if (endpoint.startsWith('/config') || endpoint === '/vault/migrate') return method === 'GET' ? 'read' : 'admin';
24
+ if (endpoint === '/chat' || endpoint === '/chat/stream' || endpoint === '/model/switch' || endpoint.endsWith('/run')) return 'chat';
25
+ if (endpoint.startsWith('/hitl') || endpoint.startsWith('/memory') || endpoint.startsWith('/budget') || endpoint.startsWith('/sessions')) return 'chat';
26
+ if (method === 'GET' || method === 'HEAD') return 'read';
27
+ return 'tools';
28
+ }
29
+
30
+ const hash = (token) => crypto.createHash('sha256').update(String(token || '')).digest('hex');
31
+
32
+ function read() {
33
+ try {
34
+ const raw = JSON.parse(fs.readFileSync(FILE(), 'utf8'));
35
+ return { clients: Array.isArray(raw.clients) ? raw.clients : [] };
36
+ } catch { return { clients: [] }; }
37
+ }
38
+
39
+ function write(state) {
40
+ fs.mkdirSync(DIR(), { recursive: true });
41
+ const tmp = FILE() + '.tmp';
42
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { encoding: 'utf8', mode: 0o600 });
43
+ fs.renameSync(tmp, FILE());
44
+ }
45
+
46
+ /**
47
+ * First boot: adopt the existing daemon.token as an admin client so the desktop
48
+ * app keeps working without re-authenticating, and create the token if missing.
49
+ */
50
+ function bootstrap() {
51
+ const state = read();
52
+ if (state.clients.length) return state;
53
+
54
+ let token = null;
55
+ try { token = fs.readFileSync(tokenPath(), 'utf8').trim(); } catch { /* create below */ }
56
+ if (!token) {
57
+ token = crypto.randomBytes(32).toString('hex');
58
+ fs.mkdirSync(DIR(), { recursive: true });
59
+ fs.writeFileSync(tokenPath(), token, { encoding: 'utf8', mode: 0o600 });
60
+ }
61
+ state.clients.push({
62
+ id: 'local',
63
+ label: 'This machine (desktop app)',
64
+ hash: hash(token),
65
+ scopes: ['read', 'chat', 'tools', 'admin'],
66
+ perMin: 600,
67
+ createdAt: new Date().toISOString(),
68
+ lastUsedAt: null,
69
+ calls: 0
70
+ });
71
+ write(state);
72
+ return state;
73
+ }
74
+
75
+ function sameHash(a, b) {
76
+ const ab = Buffer.from(String(a), 'hex');
77
+ const bb = Buffer.from(String(b), 'hex');
78
+ if (ab.length !== bb.length || ab.length === 0) return false;
79
+ return crypto.timingSafeEqual(ab, bb);
80
+ }
81
+
82
+ /** Resolve a bearer token to a client record, or null. */
83
+ function verify(token) {
84
+ if (!token) return null;
85
+ const wanted = hash(token);
86
+ const state = bootstrap();
87
+ for (const c of state.clients) if (sameHash(c.hash, wanted)) return c;
88
+ return null;
89
+ }
90
+
91
+ /** Record use; best effort, never rejects a request. */
92
+ function touch(client) {
93
+ try {
94
+ const state = read();
95
+ const c = state.clients.find(x => x.id === client.id);
96
+ if (!c) return;
97
+ c.lastUsedAt = new Date().toISOString();
98
+ c.calls = (c.calls || 0) + 1;
99
+ write(state);
100
+ } catch { /* usage tracking is not worth failing a call over */ }
101
+ }
102
+
103
+ const buckets = new Map(); // clientId -> { tokens, updatedAt }
104
+
105
+ /** Token bucket, refilled continuously at `perMin` per minute. */
106
+ function rateLimit(client, now = Date.now()) {
107
+ const perMin = Math.max(1, Number(client.perMin) || DEFAULT_LIMIT);
108
+ const b = buckets.get(client.id) || { tokens: perMin, updatedAt: now };
109
+ const elapsed = Math.max(0, now - b.updatedAt) / 60000;
110
+ b.tokens = Math.min(perMin, b.tokens + elapsed * perMin);
111
+ b.updatedAt = now;
112
+ if (b.tokens < 1) {
113
+ buckets.set(client.id, b);
114
+ return { allowed: false, remaining: 0, retryAfterMs: Math.ceil(((1 - b.tokens) / perMin) * 60000), limit: perMin };
115
+ }
116
+ b.tokens -= 1;
117
+ buckets.set(client.id, b);
118
+ return { allowed: true, remaining: Math.floor(b.tokens), limit: perMin };
119
+ }
120
+
121
+ function can(client, scope) {
122
+ const list = client?.scopes || [];
123
+ return list.includes('*') || list.includes(scope);
124
+ }
125
+
126
+ function audit(entry) {
127
+ try {
128
+ fs.mkdirSync(DIR(), { recursive: true });
129
+ fs.appendFileSync(AUDIT(), JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', 'utf8');
130
+ } catch { /* never block on audit */ }
131
+ }
132
+
133
+ /** Create a client. The plaintext token is returned exactly once. */
134
+ function create(label, { scopes = ['read', 'chat', 'tools'], perMin = DEFAULT_LIMIT } = {}) {
135
+ const state = bootstrap();
136
+ const clean = (Array.isArray(scopes) ? scopes : ['read']).filter(s => SCOPES.includes(s) || s === '*');
137
+ if (!clean.length) return { ok: false, error: `scopes must be one of ${SCOPES.join(', ')}` };
138
+ const token = 'lib_' + crypto.randomBytes(24).toString('hex');
139
+ const client = {
140
+ id: 'c_' + crypto.randomBytes(4).toString('hex'),
141
+ label: String(label || 'client').slice(0, 60),
142
+ hash: hash(token),
143
+ scopes: clean,
144
+ perMin: Math.max(1, Math.min(10000, Number(perMin) || DEFAULT_LIMIT)),
145
+ createdAt: new Date().toISOString(),
146
+ lastUsedAt: null,
147
+ calls: 0
148
+ };
149
+ state.clients.push(client);
150
+ write(state);
151
+ return { ok: true, id: client.id, token, scopes: client.scopes, perMin: client.perMin };
152
+ }
153
+
154
+ function list() {
155
+ return bootstrap().clients.map(c => ({
156
+ id: c.id, label: c.label, scopes: c.scopes, perMin: c.perMin,
157
+ createdAt: c.createdAt, lastUsedAt: c.lastUsedAt, calls: c.calls || 0
158
+ }));
159
+ }
160
+
161
+ function revoke(id) {
162
+ const state = bootstrap();
163
+ if (id === 'local') return { ok: false, error: 'The local desktop client cannot be revoked — rotate it instead.' };
164
+ const before = state.clients.length;
165
+ state.clients = state.clients.filter(c => c.id !== id);
166
+ if (state.clients.length === before) return { ok: false, error: `No client '${id}'.` };
167
+ write(state);
168
+ buckets.delete(id);
169
+ return { ok: true, revoked: id };
170
+ }
171
+
172
+ /** New token for an existing client; the old one stops working immediately. */
173
+ function rotate(id) {
174
+ const state = bootstrap();
175
+ const c = state.clients.find(x => x.id === id);
176
+ if (!c) return { ok: false, error: `No client '${id}'.` };
177
+ const token = id === 'local' ? crypto.randomBytes(32).toString('hex') : 'lib_' + crypto.randomBytes(24).toString('hex');
178
+ c.hash = hash(token);
179
+ c.createdAt = new Date().toISOString();
180
+ write(state);
181
+ if (id === 'local') fs.writeFileSync(tokenPath(), token, { encoding: 'utf8', mode: 0o600 });
182
+ return { ok: true, id, token };
183
+ }
184
+
185
+ /* ── legacy helpers, kept so existing callers keep working ────────────────── */
186
+ function readToken() {
187
+ if (process.env.LIBER_TOKEN) return process.env.LIBER_TOKEN.trim();
188
+ try { return fs.readFileSync(tokenPath(), 'utf8').trim() || null; } catch { return null; }
189
+ }
190
+ function ensureToken() {
191
+ const existing = readToken();
192
+ if (existing) return existing;
193
+ const token = crypto.randomBytes(32).toString('hex');
194
+ fs.mkdirSync(DIR(), { recursive: true });
195
+ fs.writeFileSync(tokenPath(), token, { encoding: 'utf8', mode: 0o600 });
196
+ return token;
197
+ }
198
+ function extract(req) {
199
+ const auth = req.headers?.authorization || req.headers?.['x-liber-token'] || '';
200
+ return String(auth).replace(/^Bearer\s+/i, '').trim();
201
+ }
202
+ function safeEqual(provided, expected) {
203
+ if (!provided || !expected) return false;
204
+ const a = Buffer.from(String(provided));
205
+ const b = Buffer.from(String(expected));
206
+ if (a.length !== b.length) return false;
207
+ return crypto.timingSafeEqual(a, b);
208
+ }
209
+ function isAuthorized(req, expected) {
210
+ return safeEqual(extract(req), expected);
211
+ }
212
+
213
+ module.exports = {
214
+ bootstrap, verify, touch, rateLimit, can, scopeFor, audit, create, list, revoke, rotate, hash,
215
+ SCOPES, DEFAULT_LIMIT, FILE, AUDIT,
216
+ tokenPath, readToken, ensureToken, extract, safeEqual, isAuthorized
217
+ };
@@ -0,0 +1,130 @@
1
+ // src/daemon/core/automations.js — scheduled prompt runs.
2
+ // A job is a prompt plus a schedule; the daemon ticks and runs whatever is due
3
+ // through the same engine a manual turn uses, so grounding and policy apply.
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const FILE = () => process.env.LIBER_AUTOMATIONS_FILE || path.resolve(process.cwd(), '.liber', 'automations.json');
8
+ const MAX_JOBS = 20;
9
+
10
+ function nowMs() { return Date.now(); }
11
+
12
+ function load() {
13
+ try {
14
+ const raw = JSON.parse(fs.readFileSync(FILE(), 'utf8'));
15
+ const jobs = Array.isArray(raw?.jobs) ? raw.jobs : [];
16
+ return { jobs: jobs.map(normalise).filter(Boolean) };
17
+ } catch {
18
+ return { jobs: [] };
19
+ }
20
+ }
21
+
22
+ function save(state) {
23
+ try {
24
+ fs.mkdirSync(path.dirname(FILE()), { recursive: true });
25
+ fs.writeFileSync(FILE(), JSON.stringify({ jobs: state.jobs }, null, 2), 'utf8');
26
+ } catch { /* a scheduler must never break the daemon */ }
27
+ }
28
+
29
+ /** Coerce whatever came from the API into a job we can schedule. */
30
+ function normalise(job) {
31
+ if (!job || typeof job !== 'object') return null;
32
+ const prompt = String(job.prompt || '').trim();
33
+ if (!prompt) return null;
34
+ const sch = job.schedule || {};
35
+ const type = sch.type === 'daily' ? 'daily' : 'interval';
36
+ const schedule = type === 'daily'
37
+ ? { type, at: /^\d{1,2}:\d{2}$/.test(String(sch.at || '')) ? String(sch.at) : '09:00' }
38
+ : { type, minutes: Math.min(1440, Math.max(1, Number(sch.minutes) || 30)) };
39
+ return {
40
+ id: String(job.id || `a${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`),
41
+ name: String(job.name || 'Automation').slice(0, 60),
42
+ prompt,
43
+ schedule,
44
+ enabled: job.enabled !== false,
45
+ createdAt: job.createdAt || new Date().toISOString(),
46
+ lastRunAt: job.lastRunAt || null,
47
+ lastResult: job.lastResult || null,
48
+ runCount: Number(job.runCount) || 0
49
+ };
50
+ }
51
+
52
+ /** Milliseconds until this job should fire again (null = never run yet). */
53
+ function dueIn(job, from = nowMs()) {
54
+ if (!job.enabled) return null;
55
+ const last = job.lastRunAt ? Date.parse(job.lastRunAt) : null;
56
+
57
+ if (job.schedule.type === 'interval') {
58
+ if (last === null) return 0;
59
+ const next = last + job.schedule.minutes * 60_000;
60
+ return Math.max(0, next - from);
61
+ }
62
+
63
+ // daily at HH:MM local time: fire once per day, at the slot, and never twice
64
+ const [hh, mm] = job.schedule.at.split(':').map(Number);
65
+ const slot = new Date(from);
66
+ slot.setHours(hh, mm, 0, 0);
67
+ const DAY = 24 * 60 * 60 * 1000;
68
+ const ranSinceSlot = last !== null && last >= slot.getTime() - 1000 && last < slot.getTime() + DAY;
69
+ if (ranSinceSlot) return slot.getTime() + DAY - from;
70
+ return slot.getTime() <= from ? 0 : slot.getTime() - from;
71
+ }
72
+
73
+ const isDue = (job, from = nowMs()) => dueIn(job, from) === 0;
74
+
75
+ function list() {
76
+ const state = load();
77
+ return state.jobs.map(j => ({ ...j, due_in_ms: dueIn(j), due: isDue(j) }));
78
+ }
79
+
80
+ function create(input) {
81
+ const state = load();
82
+ if (state.jobs.length >= MAX_JOBS) {
83
+ return { ok: false, error: `Too many automations (max ${MAX_JOBS}).` };
84
+ }
85
+ const job = normalise({ ...input, id: null, lastRunAt: null, lastResult: null, runCount: 0 });
86
+ if (!job) return { ok: false, error: 'A prompt is required.' };
87
+ state.jobs.push(job);
88
+ save(state);
89
+ return { ok: true, job };
90
+ }
91
+
92
+ function update(id, patch = {}) {
93
+ const state = load();
94
+ const i = state.jobs.findIndex(j => j.id === id);
95
+ if (i < 0) return { ok: false, error: `No automation '${id}'.` };
96
+ const merged = normalise({ ...state.jobs[i], ...patch, id: state.jobs[i].id });
97
+ if (!merged) return { ok: false, error: 'A prompt is required.' };
98
+ state.jobs[i] = merged;
99
+ save(state);
100
+ return { ok: true, job: merged };
101
+ }
102
+
103
+ function remove(id) {
104
+ const state = load();
105
+ const before = state.jobs.length;
106
+ state.jobs = state.jobs.filter(j => j.id !== id);
107
+ if (state.jobs.length === before) return { ok: false, error: `No automation '${id}'.` };
108
+ save(state);
109
+ return { ok: true, removed: id };
110
+ }
111
+
112
+ /** Record a finished run: this is what makes the tick idempotent. */
113
+ function markRun(id, result = {}) {
114
+ const state = load();
115
+ const job = state.jobs.find(j => j.id === id);
116
+ if (!job) return null;
117
+ job.lastRunAt = new Date().toISOString();
118
+ job.runCount = Number(job.runCount || 0) + 1;
119
+ job.lastResult = {
120
+ ok: result.ok !== false,
121
+ chars: Number(result.chars) || 0,
122
+ excerpt: String(result.excerpt || '').replace(/\s+/g, ' ').slice(0, 200),
123
+ error: result.error ? String(result.error).slice(0, 200) : null,
124
+ at: job.lastRunAt
125
+ };
126
+ save(state);
127
+ return job;
128
+ }
129
+
130
+ module.exports = { list, create, update, remove, markRun, dueIn, isDue, normalise, FILE, MAX_JOBS };
@@ -0,0 +1,145 @@
1
+ // src/daemon/core/budget.js — daily spend/token guard.
2
+ // Money is enforced only when per-1k prices are configured; tokens are always enforced.
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // LIBER_BUDGET_FILE lets tests run against a scratch file instead of the live counter.
7
+ const FILE = () => process.env.LIBER_BUDGET_FILE || path.resolve(process.cwd(), '.liber', 'budget.json');
8
+
9
+ let state = null;
10
+
11
+ function today() {
12
+ return new Date().toISOString().slice(0, 10);
13
+ }
14
+
15
+ function emptyState() {
16
+ return { date: today(), prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, cost_usd: 0, calls: 0 };
17
+ }
18
+
19
+ function load() {
20
+ if (state) return state;
21
+ try {
22
+ const parsed = JSON.parse(fs.readFileSync(FILE(), 'utf8'));
23
+ state = parsed && parsed.date === today() ? { ...emptyState(), ...parsed } : emptyState();
24
+ } catch {
25
+ state = emptyState();
26
+ }
27
+ return state;
28
+ }
29
+
30
+ function save() {
31
+ try {
32
+ fs.mkdirSync(path.dirname(FILE()), { recursive: true });
33
+ fs.writeFileSync(FILE(), JSON.stringify(load(), null, 2), 'utf8');
34
+ } catch {
35
+ // budget persistence must never break a turn
36
+ }
37
+ }
38
+
39
+ function reset() {
40
+ state = emptyState();
41
+ save();
42
+ }
43
+
44
+ /** Drop the in-memory copy so the next read comes from disk. */
45
+ function reload() {
46
+ state = null;
47
+ return load();
48
+ }
49
+
50
+ /** A limit of 0 means "no limit", which is what the settings panel promises. */
51
+ function limitOf(value) {
52
+ const n = Number(value);
53
+ if (!Number.isFinite(n) || n <= 0) return null;
54
+ return n;
55
+ }
56
+
57
+ function priceOf(engineConfig = {}) {
58
+ const inPrice = Number(engineConfig.price_in_per_1k);
59
+ const outPrice = Number(engineConfig.price_out_per_1k);
60
+ return Number.isFinite(inPrice) && Number.isFinite(outPrice)
61
+ ? { inPrice, outPrice }
62
+ : null;
63
+ }
64
+
65
+ function costOf(usage, engineConfig) {
66
+ const price = priceOf(engineConfig);
67
+ if (!price || !usage) return 0;
68
+ return (
69
+ (Number(usage.prompt_tokens || 0) / 1000) * price.inPrice +
70
+ (Number(usage.completion_tokens || 0) / 1000) * price.outPrice
71
+ );
72
+ }
73
+
74
+ /**
75
+ * @returns {{allowed:boolean, reason?:string, spent_usd:number, spent_tokens:number, limit_usd:number|null, limit_tokens:number|null}}
76
+ */
77
+ function check(limits = {}, engineConfig = {}) {
78
+ const s = load();
79
+ const limitUsd = limitOf(limits.max_cost_per_day);
80
+ const limitTokens = limitOf(limits.max_tokens_per_day);
81
+ const priced = !!priceOf(engineConfig);
82
+
83
+ let allowed = true;
84
+ let reason;
85
+
86
+ if (limitTokens !== null && s.total_tokens >= limitTokens) {
87
+ allowed = false;
88
+ reason = `Daily token budget exhausted (${s.total_tokens}/${limitTokens} tokens). Raise limits.max_tokens_per_day, set it to 0 for no limit, or reset today's counter in Settings → Advanced → Budget.`;
89
+ }
90
+ if (priced && limitUsd !== null && s.cost_usd >= limitUsd) {
91
+ allowed = false;
92
+ reason = `Daily cost budget exhausted ($${s.cost_usd.toFixed(4)}/$${limitUsd.toFixed(2)}). Raise limits.max_cost_per_day, set it to 0 for no limit, or reset today's counter.`;
93
+ }
94
+
95
+ return {
96
+ allowed,
97
+ reason,
98
+ spent_usd: s.cost_usd,
99
+ spent_tokens: s.total_tokens,
100
+ limit_usd: priced ? limitUsd : null,
101
+ limit_tokens: limitTokens
102
+ };
103
+ }
104
+
105
+ /** Everything the UI needs to show headroom before a turn is refused. */
106
+ function summary(limits = {}, engineConfig = {}) {
107
+ const s = load();
108
+ const r = check(limits, engineConfig);
109
+ const remaining = r.limit_tokens === null ? null : Math.max(0, r.limit_tokens - s.total_tokens);
110
+ const percent = r.limit_tokens === null || r.limit_tokens === 0
111
+ ? null
112
+ : Math.min(100, Math.round((s.total_tokens / r.limit_tokens) * 100));
113
+ return {
114
+ date: s.date,
115
+ calls: s.calls,
116
+ spent_tokens: s.total_tokens,
117
+ spent_usd: Number(s.cost_usd.toFixed(6)),
118
+ limit_tokens: r.limit_tokens,
119
+ limit_usd: r.limit_usd,
120
+ remaining_tokens: remaining,
121
+ percent_used: percent,
122
+ allowed: r.allowed,
123
+ reason: r.reason || null
124
+ };
125
+ }
126
+
127
+ function record(usage, engineConfig = {}) {
128
+ if (!usage) return load();
129
+ const s = load();
130
+ const prompt = Number(usage.prompt_tokens || 0);
131
+ const completion = Number(usage.completion_tokens || 0);
132
+ s.prompt_tokens += prompt;
133
+ s.completion_tokens += completion;
134
+ s.total_tokens += Number(usage.total_tokens || prompt + completion);
135
+ s.cost_usd += costOf(usage, engineConfig);
136
+ s.calls += 1;
137
+ save();
138
+ return s;
139
+ }
140
+
141
+ function snapshot() {
142
+ return { ...load() };
143
+ }
144
+
145
+ module.exports = { check, summary, record, reset, reload, snapshot, costOf, priceOf, today };
@@ -0,0 +1,31 @@
1
+ // src/daemon/core/circuit-breaker.js
2
+ class CircuitBreaker {
3
+ constructor(maxIterations = 5) {
4
+ this.defaultLimit = maxIterations;
5
+ this.limits = new Map(); // agentId -> max iterations
6
+ this.counters = new Map(); // agentId -> count
7
+ }
8
+
9
+ configureLimit(agentId, maxIterations) {
10
+ if (Number.isFinite(maxIterations) && maxIterations > 0) {
11
+ this.limits.set(agentId, maxIterations);
12
+ }
13
+ }
14
+
15
+ increment(agentId) {
16
+ const count = (this.counters.get(agentId) || 0) + 1;
17
+ this.counters.set(agentId, count);
18
+ return count;
19
+ }
20
+
21
+ reset(agentId) {
22
+ this.counters.delete(agentId);
23
+ }
24
+
25
+ isBroken(agentId) {
26
+ const limit = this.limits.get(agentId) || this.defaultLimit;
27
+ return (this.counters.get(agentId) || 0) >= limit;
28
+ }
29
+ }
30
+
31
+ module.exports = new CircuitBreaker();