@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.
- package/CHANGELOG.md +31 -0
- package/bin/cli.js +105 -0
- package/bin/entity.js +9 -0
- package/bin/pack.js +10 -0
- package/bin/recall-case.js +90 -0
- package/bin/remind.js +82 -0
- package/bin/tool.js +8 -3
- package/channels/kazee/adapter.js +37 -3
- package/channels/kazee/call/manager.js +128 -2
- package/channels/kazee/call/session.js +63 -25
- package/channels/kazee/socket.js +3 -0
- package/channels/spaces/socket.js +3 -0
- package/channels/telegram/adapter.js +9 -5
- package/core/apprenticeship.js +104 -0
- package/core/approvals.js +39 -0
- package/core/cost-report.js +140 -0
- package/core/dedup-adjudicator.js +250 -0
- package/core/dream.js +41 -8
- package/core/entities.js +27 -5
- package/core/loopback.js +51 -1
- package/core/media.js +58 -5
- package/core/pack-review.js +115 -12
- package/core/packs.js +102 -4
- package/core/prospective.js +135 -0
- package/core/recall-regressions.js +171 -0
- package/core/socket-reconnect.js +71 -0
- package/core/system-prompt.js +84 -6
- package/core/tools.js +77 -0
- package/package.json +3 -3
- package/test-fixtures/fake-agent-cli.js +5 -1
- package/test-kazee-call-manager.js +44 -1
- package/test-kazee-call-session.js +45 -2
- package/test-provider-language.js +11 -1
|
@@ -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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
()
|
|
189
|
-
|
|
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 (_) {}
|
package/channels/kazee/socket.js
CHANGED
|
@@ -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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
+
};
|