@inetafrica/open-claudia 3.1.28 → 3.2.1

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.
@@ -55,12 +55,16 @@ const THINK_TICK_PCM48 = tonePcm48([{ freq: 520, ms: 90 }], 0.045);
55
55
 
56
56
  class KazeeCallSession {
57
57
  constructor({ roomId, chatId, callerId, botUserId, janusUrl, iceServers = [], onTranscript, onEnded,
58
- maxCallMs, idleMs, synthesize = synthesizePcm,
58
+ maxCallMs, idleMs, synthesize = synthesizePcm, asCaller = false, greeting = null,
59
59
  JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
60
60
  this.roomId = Number(roomId);
61
61
  this.chatId = String(chatId);
62
62
  this.callerId = String(callerId);
63
63
  this.botUserId = String(botUserId);
64
+ this.asCaller = !!asCaller;
65
+ this.greeting = greeting ? String(greeting) : null;
66
+ this._greeted = false;
67
+ this._createdRoom = false;
64
68
  this.janus = new JanusClientImpl({ url: janusUrl });
65
69
  this.PeerConnectionImpl = PeerConnectionImpl;
66
70
  this.SpeechStreamImpl = SpeechStreamImpl;
@@ -97,6 +101,21 @@ class KazeeCallSession {
97
101
  await this.janus.connect();
98
102
  this.publisherHandle = await this.janus.attach();
99
103
  this.janus.onHandle(this.publisherHandle, (frame) => this._onPublisherEvent(frame));
104
+ if (this.asCaller) {
105
+ // Outbound: the caller side owns room lifecycle, mirroring the web
106
+ // client's create params (audio-only, so no videocodec).
107
+ const created = await this.janus.message(this.publisherHandle, {
108
+ request: "create", room: this.roomId, publishers: 6, audiocodec: "opus",
109
+ description: `Call room ${this.roomId}`, permanent: false,
110
+ });
111
+ const cdata = created?.plugindata?.data || {};
112
+ const exists = Number(cdata.error_code) === 427 || /already exists/i.test(String(cdata.error || ""));
113
+ if (cdata.videoroom !== "created" && !exists) {
114
+ throw new Error(cdata.error || "Janus room create failed");
115
+ }
116
+ this._createdRoom = true;
117
+ clog(`Kazee call room created: room=${this.roomId}`);
118
+ }
100
119
  // The caller creates the Janus room during its own call setup, which can
101
120
  // land a beat after the invite reaches us — and a bot answers faster than
102
121
  // any human. Poll join through transient 426 "No such room" like the web
@@ -163,31 +182,47 @@ class KazeeCallSession {
163
182
  }
164
183
 
165
184
  async _subscribe(feedId, display) {
185
+ if (this.subscribers.has(feedId)) return;
186
+ // Reserve the slot before the first await: the join response and the async
187
+ // publishers event both list the same feed, and while attach() is in
188
+ // flight the second frame passes the caller's has() check and subscribes
189
+ // this feed twice, orphaning a PeerConnection (diagnosed 2026-07-30).
190
+ this.subscribers.set(feedId, null);
166
191
  clog(`Kazee call subscribing: feed=${feedId} display=${String(display || "")}`);
167
- const handle = await this.janus.attach();
168
- const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
169
- pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call subscriber ICE: ${state} feed=${feedId}`));
170
- const sub = {
171
- handle,
172
- pc,
173
- display: String(display || ""),
174
- // Opus decoders are stateful, so each feed needs its own.
175
- codec: new OpusAudioCodec(),
176
- vad: new TurnDetector({ thresholdDb: BASE_THRESHOLD_DB }),
177
- stt: new this.SpeechStreamImpl({ onTranscript: (text) => this._onFeedTranscript(feedId, text) }),
178
- prebuffer: [],
179
- };
180
- this.subscribers.set(feedId, sub);
181
- pc.onTrack.subscribe((track) => {
182
- if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
183
- });
184
- this.janus.onHandle(handle, (frame) => this._onSubscriberEvent(feedId, frame));
185
- const attached = await this.janus.message(handle, { request: "join", ptype: "subscriber", room: this.roomId, feed: feedId });
186
- await this._consumeSubscriberFrame(feedId, attached);
187
- await sub.stt.connect().then(
188
- () => clog(`Kazee call STT connected: feed=${feedId}`),
189
- (error) => console.error("Kazee call STT connect:", error.message),
190
- );
192
+ try {
193
+ const handle = await this.janus.attach();
194
+ const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
195
+ pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call subscriber ICE: ${state} feed=${feedId}`));
196
+ const sub = {
197
+ handle,
198
+ pc,
199
+ display: String(display || ""),
200
+ // Opus decoders are stateful, so each feed needs its own.
201
+ codec: new OpusAudioCodec(),
202
+ vad: new TurnDetector({ thresholdDb: BASE_THRESHOLD_DB }),
203
+ stt: new this.SpeechStreamImpl({ onTranscript: (text) => this._onFeedTranscript(feedId, text) }),
204
+ prebuffer: [],
205
+ };
206
+ this.subscribers.set(feedId, sub);
207
+ pc.onTrack.subscribe((track) => {
208
+ if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
209
+ });
210
+ this.janus.onHandle(handle, (frame) => this._onSubscriberEvent(feedId, frame));
211
+ const attached = await this.janus.message(handle, { request: "join", ptype: "subscriber", room: this.roomId, feed: feedId });
212
+ await this._consumeSubscriberFrame(feedId, attached);
213
+ await sub.stt.connect().then(
214
+ () => clog(`Kazee call STT connected: feed=${feedId}`),
215
+ (error) => console.error("Kazee call STT connect:", error.message),
216
+ );
217
+ // First remote feed up = both media legs established; greet exactly once.
218
+ if (this.greeting && !this._greeted) {
219
+ this._greeted = true;
220
+ this.speak(this.greeting).catch((error) => console.error("Kazee call greeting:", error.message));
221
+ }
222
+ } catch (error) {
223
+ if (this.subscribers.get(feedId) === null) this.subscribers.delete(feedId);
224
+ throw error;
225
+ }
191
226
  }
192
227
 
193
228
  async _onSubscriberEvent(feedId, frame) {
@@ -403,6 +438,9 @@ class KazeeCallSession {
403
438
  this._stopThinking();
404
439
  this.cancelSpeech();
405
440
  for (const feed of [...this.subscribers.keys()]) await this._removeSubscriber(feed);
441
+ if (this._createdRoom) {
442
+ try { await this.janus.message(this.publisherHandle, { request: "destroy", room: this.roomId }); } catch (_) {}
443
+ }
406
444
  try { await this.publisher?.pc?.close(); } catch (_) {}
407
445
  try { await this.janus.detach(this.publisherHandle); } catch (_) {}
408
446
  try { await this.janus.close(); } catch (_) {}
@@ -5,6 +5,8 @@ let io;
5
5
  try { io = require("socket.io-client").io; }
6
6
  catch (e) { io = null; }
7
7
 
8
+ const { wireTerminalReconnect } = require("../../core/socket-reconnect");
9
+
8
10
  function createKazeeSocket({ url, token, userId, onConnect, onDisconnect, onError }) {
9
11
  if (!io) {
10
12
  throw new Error("socket.io-client is not installed. Run `npm install` to install dependencies.");
@@ -22,6 +24,7 @@ function createKazeeSocket({ url, token, userId, onConnect, onDisconnect, onErro
22
24
  socket.on("connect", () => { if (onConnect) onConnect(socket); });
23
25
  socket.on("disconnect", (reason) => { if (onDisconnect) onDisconnect(reason); });
24
26
  socket.on("connect_error", (err) => { if (onError) onError(err); });
27
+ wireTerminalReconnect(socket, { label: `kazee socket (${url})` });
25
28
  return socket;
26
29
  }
27
30
 
@@ -14,6 +14,8 @@ let io;
14
14
  try { io = require("socket.io-client").io; }
15
15
  catch (e) { io = null; }
16
16
 
17
+ const { wireTerminalReconnect } = require("../../core/socket-reconnect");
18
+
17
19
  function createSpacesSocket({ url, token, onNotification, onConnect, onDisconnect, onError }) {
18
20
  if (!io) {
19
21
  throw new Error("socket.io-client is not installed. Run `npm install` to install dependencies.");
@@ -34,6 +36,7 @@ function createSpacesSocket({ url, token, onNotification, onConnect, onDisconnec
34
36
  catch (e) { console.error("spaces notification handler:", e.message); }
35
37
  }
36
38
  });
39
+ wireTerminalReconnect(socket, { label: `spaces socket (${url})` });
37
40
  return socket;
38
41
  }
39
42
 
@@ -672,11 +672,15 @@ class TelegramAdapter {
672
672
  if (!media || !media.fileId) return null;
673
673
  const file = await this.bot.getFile(media.fileId);
674
674
  const fileUrl = `https://api.telegram.org/file/bot${this.token}/${file.file_path}`;
675
- const ext = media.fileName ? path.extname(media.fileName) || ".bin"
676
- : media.type === "voice" ? ".ogg"
677
- : media.type === "audio" ? path.extname(media.fileName || "") || ".ogg"
678
- : media.type === "photo" ? ".jpg"
679
- : ".bin";
675
+ // Telegram's file_path carries the real extension even when the message
676
+ // has no fileName (e.g. videos), so prefer it over the .bin fallback.
677
+ const ext = path.extname(media.fileName || "")
678
+ || path.extname(file.file_path || "")
679
+ || (media.type === "voice" ? ".ogg"
680
+ : media.type === "audio" ? ".ogg"
681
+ : media.type === "photo" ? ".jpg"
682
+ : media.type === "video" ? ".mp4"
683
+ : ".bin");
680
684
  const baseDir = media.type === "document" ? FILES_DIR : TEMP_DIR;
681
685
  const localPath = media.type === "document" && media.fileName
682
686
  ? path.join(baseDir, media.fileName)
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+
3
+ // C4: apprenticeship radar. A deterministic 7-day scan of the project
4
+ // transcripts for repeated manual work — the same ad-hoc command re-derived by
5
+ // the agent, or the owner asking for the same thing again and again. The scan
6
+ // only gathers EVIDENCE; the dream's introspection pass turns it into at most
7
+ // two /learn CANDIDATES in its report. Report-only end to end: /learn remains
8
+ // the owner-triggered capture, and nothing here creates a pack or tool.
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const { TRANSCRIPTS_DIR } = require("./config");
13
+
14
+ const WINDOW_DAYS = 7;
15
+ const MIN_REPEATS = 3;
16
+ const MIN_DAYS = 2;
17
+ const MAX_CANDIDATES = 2;
18
+ const MIN_COMMAND_CHARS = 12;
19
+ const MIN_REQUEST_CHARS = 16;
20
+ const MAX_REQUEST_CHARS = 200;
21
+
22
+ function normalize(s) {
23
+ return String(s || "").replace(/\s+/g, " ").trim();
24
+ }
25
+
26
+ // Commands the assistant quoted in a turn: inline code spans plus "$ "-prefixed
27
+ // lines. The bot's own bookkeeping CLI is excluded — /learn-ing `open-claudia
28
+ // task list` would be circular.
29
+ function commandsIn(text) {
30
+ const out = [];
31
+ const t = String(text || "");
32
+ for (const m of t.matchAll(/`([^`\n]{4,160})`/g)) out.push(m[1]);
33
+ for (const m of t.matchAll(/^\s*\$\s+(.{4,160})$/gm)) out.push(m[1]);
34
+ return out
35
+ .map(normalize)
36
+ .filter((c) => c.length >= MIN_COMMAND_CHARS)
37
+ .filter((c) => !c.startsWith("open-claudia "))
38
+ .filter((c) => /^[a-zA-Z_./~]/.test(c) && /\s/.test(c)); // looks like an invocation, not a bare word or path
39
+ }
40
+
41
+ function scanForApprenticeship({
42
+ now = Date.now(), days = WINDOW_DAYS, dir = TRANSCRIPTS_DIR,
43
+ minRepeats = MIN_REPEATS, minDays = MIN_DAYS, max = MAX_CANDIDATES,
44
+ } = {}) {
45
+ let files;
46
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")); }
47
+ catch (e) { return []; }
48
+ const from = now - days * 86400000;
49
+ const tally = new Map(); // key → {kind, sample, count, days:Set, files:Set, lastAt}
50
+ const bump = (kind, sample, day, file, at) => {
51
+ const key = `${kind}:${sample.toLowerCase()}`;
52
+ const e = tally.get(key) || { kind, sample, count: 0, days: new Set(), files: new Set(), lastAt: "" };
53
+ e.count += 1;
54
+ e.days.add(day);
55
+ e.files.add(file);
56
+ if (at > e.lastAt) { e.lastAt = at; e.sample = sample; }
57
+ tally.set(key, e);
58
+ };
59
+ for (const f of files) {
60
+ let raw;
61
+ try { raw = fs.readFileSync(path.join(dir, f), "utf-8"); } catch (e) { continue; }
62
+ for (const line of raw.split("\n")) {
63
+ if (!line.trim()) continue;
64
+ let rec;
65
+ try { rec = JSON.parse(line); } catch (e) { continue; }
66
+ const t = Date.parse(rec.timestamp || "");
67
+ if (!Number.isFinite(t) || t < from || t > now) continue;
68
+ const day = new Date(t).toISOString().slice(0, 10);
69
+ if (rec.role === "assistant") {
70
+ for (const cmd of new Set(commandsIn(rec.text))) bump("command", cmd, day, f, rec.timestamp);
71
+ } else if (rec.role === "user") {
72
+ const req = normalize(rec.text);
73
+ if (req.length >= MIN_REQUEST_CHARS && req.length <= MAX_REQUEST_CHARS && !req.startsWith("/")) {
74
+ bump("request", req, day, f, rec.timestamp);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ return [...tally.values()]
80
+ .filter((e) => e.count >= minRepeats && e.days.size >= minDays)
81
+ .sort((a, b) => b.count - a.count || b.days.size - a.days.size)
82
+ .slice(0, max)
83
+ .map((e) => ({ kind: e.kind, sample: e.sample, count: e.count, days: e.days.size, sessions: e.files.size }));
84
+ }
85
+
86
+ // Prompt block for the dream's introspection pass. Empty string when nothing
87
+ // repeated — the section only exists when there is evidence (no standing cost).
88
+ function radarSection(opts = {}) {
89
+ const found = scanForApprenticeship(opts);
90
+ if (!found.length) return "";
91
+ const lines = found.map((c) =>
92
+ `- [${c.kind}] ${c.kind === "command" ? `\`${c.sample}\`` : `"${c.sample}"`} — ${c.count}× across ${c.days} day${c.days === 1 ? "" : "s"} (${c.sessions} session${c.sessions === 1 ? "" : "s"})`);
93
+ return [
94
+ "═══ APPRENTICESHIP RADAR (deterministic scan of the last 7 days of transcripts) ═══",
95
+ "Repeated manual work — the same thing re-derived by hand instead of taught once:",
96
+ ...lines,
97
+ `In your report, offer at most ${MAX_CANDIDATES} of these as /learn candidates ("want to teach me this properly? say /learn after we next do it"), citing the counts above as evidence. Report-only: /learn stays owner-triggered — NEVER capture a pack, tool, or lesson from this radar yourself.`,
98
+ ].join("\n");
99
+ }
100
+
101
+ module.exports = {
102
+ WINDOW_DAYS, MIN_REPEATS, MIN_DAYS, MAX_CANDIDATES,
103
+ commandsIn, scanForApprenticeship, radarSection,
104
+ };
package/core/approvals.js CHANGED
@@ -231,6 +231,43 @@ function consume(id, commandLine) {
231
231
  } finally { releaseRecordLock(held); }
232
232
  }
233
233
 
234
+ // Read-side for the reviewer's denied-approval mining (C5): recent decided
235
+ // records, newest first. The ledger itself is already persisted per-record —
236
+ // this only lists it.
237
+ function listDecided({ withinMs = MAX_AGE_MS, statuses = ["denied", "approved"] } = {}) {
238
+ let entries;
239
+ try { entries = fs.readdirSync(APPROVALS_DIR); } catch (e) { return []; }
240
+ const wanted = new Set(statuses);
241
+ const cutoff = Date.now() - withinMs;
242
+ const out = [];
243
+ for (const f of entries) {
244
+ if (!f.endsWith(".json")) continue;
245
+ let rec;
246
+ try { rec = JSON.parse(fs.readFileSync(path.join(APPROVALS_DIR, f), "utf-8")); } catch (e) { continue; }
247
+ if (!rec || !wanted.has(rec.status)) continue;
248
+ const decidedAt = Date.parse(rec.decidedAt || "");
249
+ if (!Number.isFinite(decidedAt) || decidedAt < cutoff) continue;
250
+ out.push(rec);
251
+ }
252
+ return out.sort((a, b) => Date.parse(b.decidedAt) - Date.parse(a.decidedAt));
253
+ }
254
+
255
+ // Stamp records the reviewer has already been shown, so each deny is fed to
256
+ // the correction-detector exactly once.
257
+ function markMined(ids) {
258
+ for (const id of ids || []) {
259
+ const held = acquireRecordLock(id);
260
+ if (!held) continue;
261
+ try {
262
+ const rec = read(id);
263
+ if (rec && !rec.minedAt) {
264
+ rec.minedAt = new Date().toISOString();
265
+ writeRecord(rec);
266
+ }
267
+ } finally { releaseRecordLock(held); }
268
+ }
269
+ }
270
+
234
271
  module.exports = {
235
272
  APPROVALS_DIR,
236
273
  MAX_COMMAND_BYTES,
@@ -240,6 +277,8 @@ module.exports = {
240
277
  decide,
241
278
  detach,
242
279
  formatApprovedCommand,
280
+ listDecided,
281
+ markMined,
243
282
  read,
244
283
  waitForDecision,
245
284
  };
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+
3
+ // C6: cost self-report. Aggregates the existing per-turn usage-history.jsonl
4
+ // (ts, project, provider/model, token parts, costUsd) into a rolling 7-day
5
+ // section for the dream summary: total spend + tokens, top burns by project,
6
+ // week-over-week delta, and ONE concrete suggested cut picked by
7
+ // deterministic heuristics. Pure arithmetic — no model call, no new logging
8
+ // surface, nothing recorded at turn time beyond what already exists.
9
+
10
+ const usageLog = require("./usage-log");
11
+
12
+ const WINDOW_DAYS = 7;
13
+ // Heuristic thresholds (exported for tests): concentration of spend in one
14
+ // project, poor cache-hit ratio on a meaningful token volume, oversized
15
+ // average context.
16
+ const CONCENTRATION_SHARE = 0.6;
17
+ const CONCENTRATION_MIN_USD = 1;
18
+ const CACHE_RATIO_FLOOR = 0.5;
19
+ const CACHE_MIN_INPUT_TOKENS = 1_000_000;
20
+ const CONTEXT_AVG_CEILING = 120_000;
21
+
22
+ function fmtTokens(n) {
23
+ if (!Number.isFinite(n) || n <= 0) return "0";
24
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
25
+ if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
26
+ return String(n);
27
+ }
28
+
29
+ function inWindow(ts, from, to) {
30
+ const t = Date.parse(ts);
31
+ return Number.isFinite(t) && t >= from && t < to;
32
+ }
33
+
34
+ function aggregate(records) {
35
+ const agg = {
36
+ turns: 0, costUsd: 0, inputTokens: 0, outputTokens: 0,
37
+ cacheReadTokens: 0, contextTokenSum: 0, contextTurns: 0,
38
+ byProject: new Map(),
39
+ };
40
+ for (const r of records) {
41
+ agg.turns += 1;
42
+ const cost = Number(r.costUsd) || 0;
43
+ agg.costUsd += cost;
44
+ // session_total records carry cumulative token snapshots — counting them
45
+ // as a turn's tokens would double-bill. Their per-turn cost still counts.
46
+ if (r.usageScope !== "session_total") {
47
+ agg.inputTokens += Number(r.inputTokens) || 0;
48
+ agg.outputTokens += Number(r.outputTokens) || 0;
49
+ agg.cacheReadTokens += Number(r.cacheReadTokens) || 0;
50
+ const ctx = Number(r.billedContextTokens ?? r.contextTokens) || 0;
51
+ if (ctx > 0) { agg.contextTokenSum += ctx; agg.contextTurns += 1; }
52
+ }
53
+ const key = String(r.project || "(no project)");
54
+ const p = agg.byProject.get(key) || { costUsd: 0, turns: 0 };
55
+ p.costUsd += cost;
56
+ p.turns += 1;
57
+ agg.byProject.set(key, p);
58
+ }
59
+ return agg;
60
+ }
61
+
62
+ function topBurns(agg, n = 3) {
63
+ return [...agg.byProject.entries()]
64
+ .map(([project, v]) => ({ project, costUsd: v.costUsd, turns: v.turns }))
65
+ .sort((a, b) => b.costUsd - a.costUsd)
66
+ .slice(0, n);
67
+ }
68
+
69
+ // ONE concrete cut, first matching heuristic. Deterministic on purpose — a
70
+ // suggestion the owner can check against the numbers shown next to it.
71
+ function suggestCut(agg, burns) {
72
+ const top = burns[0];
73
+ if (top && agg.costUsd > 0 && top.costUsd / agg.costUsd > CONCENTRATION_SHARE && top.costUsd >= CONCENTRATION_MIN_USD) {
74
+ return `${Math.round((top.costUsd / agg.costUsd) * 100)}% of spend is "${top.project}" — review that channel's model tier and recall engine first.`;
75
+ }
76
+ const cacheDenom = agg.cacheReadTokens + agg.inputTokens;
77
+ if (cacheDenom >= CACHE_MIN_INPUT_TOKENS && agg.cacheReadTokens / cacheDenom < CACHE_RATIO_FLOOR) {
78
+ return `cache hit ratio is only ${Math.round((agg.cacheReadTokens / cacheDenom) * 100)}% — an unstable prompt head or long gaps between turns are re-billing the prefix; keeping the always-on head stable would cut input cost.`;
79
+ }
80
+ const avgCtx = agg.contextTurns ? Math.round(agg.contextTokenSum / agg.contextTurns) : 0;
81
+ if (avgCtx > CONTEXT_AVG_CEILING) {
82
+ return `average billed context is ${fmtTokens(avgCtx)} tokens/turn — earlier compaction or fewer injected packs would trim every turn.`;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function weeklyReport({ now = Date.now(), days = WINDOW_DAYS, history } = {}) {
88
+ const records = history || usageLog.loadUsageHistory();
89
+ const from = now - days * 86400000;
90
+ const prevFrom = from - days * 86400000;
91
+ const current = aggregate(records.filter((r) => inWindow(r.ts, from, now)));
92
+ const previous = aggregate(records.filter((r) => inWindow(r.ts, prevFrom, from)));
93
+ const burns = topBurns(current);
94
+ return {
95
+ days,
96
+ turns: current.turns,
97
+ costUsd: Math.round(current.costUsd * 100) / 100,
98
+ inputTokens: current.inputTokens,
99
+ outputTokens: current.outputTokens,
100
+ cacheReadTokens: current.cacheReadTokens,
101
+ cacheHitRatio: (current.cacheReadTokens + current.inputTokens) > 0
102
+ ? current.cacheReadTokens / (current.cacheReadTokens + current.inputTokens)
103
+ : null,
104
+ prevCostUsd: Math.round(previous.costUsd * 100) / 100,
105
+ deltaPct: previous.costUsd > 0 ? Math.round(((current.costUsd - previous.costUsd) / previous.costUsd) * 100) : null,
106
+ topBurns: burns,
107
+ suggestedCut: suggestCut(current, burns),
108
+ };
109
+ }
110
+
111
+ // Dream entry point: returns announce lines (empty when there is no usage in
112
+ // the window — silence over noise).
113
+ function reportLines(opts = {}) {
114
+ const r = weeklyReport(opts);
115
+ if (r.turns === 0) return { report: r, lines: [] };
116
+ const deltaTxt = r.deltaPct === null ? "" : ` · ${r.deltaPct >= 0 ? "+" : ""}${r.deltaPct}% vs prior week`;
117
+ const cacheTxt = r.cacheHitRatio === null ? "" : ` (cache ${Math.round(r.cacheHitRatio * 100)}%)`;
118
+ const burnsTxt = r.topBurns.length
119
+ ? ` Top: ${r.topBurns.map((b) => `${b.project} $${b.costUsd.toFixed(2)}`).join(" · ")}.`
120
+ : "";
121
+ const lines = [
122
+ `💸 Cost, last ${r.days}d: $${r.costUsd.toFixed(2)} · ${r.turns} turn${r.turns === 1 ? "" : "s"} · ${fmtTokens(r.inputTokens)} in / ${fmtTokens(r.outputTokens)} out${cacheTxt}${deltaTxt}.${burnsTxt}`,
123
+ ];
124
+ if (r.suggestedCut) lines.push(`💡 Cut: ${r.suggestedCut}`);
125
+ return { report: r, lines };
126
+ }
127
+
128
+ module.exports = {
129
+ WINDOW_DAYS,
130
+ CONCENTRATION_SHARE,
131
+ CONCENTRATION_MIN_USD,
132
+ CACHE_RATIO_FLOOR,
133
+ CACHE_MIN_INPUT_TOKENS,
134
+ CONTEXT_AVG_CEILING,
135
+ aggregate,
136
+ topBurns,
137
+ suggestCut,
138
+ weeklyReport,
139
+ reportLines,
140
+ };