negotium 0.2.15 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,14 +4,24 @@ import { join } from "node:path";
4
4
  import { appendJsonlEntry, readJsonlLines } from "#platform/jsonl";
5
5
  import { logger } from "#platform/logger";
6
6
  import { resolveStorageLogDir } from "#storage/storage-host";
7
+ import type { AgentKind, TokenUsage } from "#types";
7
8
 
8
9
  export interface QueryRecord {
10
+ schemaVersion: 2;
9
11
  timestamp: string; // ISO 8601 UTC
10
- session: string; // topic name
12
+ session: string; // topic title (legacy output name)
13
+ topicId: string;
14
+ providerSessionId?: string;
15
+ agent: AgentKind;
16
+ model: string;
17
+ /** Cache-miss input only. */
11
18
  inputTokens: number;
12
19
  outputTokens: number;
13
20
  cacheCreationInputTokens: number;
14
21
  cacheReadInputTokens: number;
22
+ contextTokens?: number;
23
+ contextWindow?: number;
24
+ estimatedCostUsd: number;
15
25
  }
16
26
 
17
27
  export interface Bucket {
@@ -20,6 +30,23 @@ export interface Bucket {
20
30
  cacheCreationInputTokens: number;
21
31
  cacheReadInputTokens: number;
22
32
  queries: number;
33
+ estimatedCostUsd: number;
34
+ }
35
+
36
+ export interface CurrentSessionUsage {
37
+ timestamp: string;
38
+ topicId: string;
39
+ topicTitle: string;
40
+ providerSessionId?: string;
41
+ agent: AgentKind;
42
+ model: string;
43
+ contextTokens: number;
44
+ contextWindow: number;
45
+ }
46
+
47
+ export interface TopicUsageSummary extends Bucket {
48
+ topicId: string;
49
+ currentSession?: CurrentSessionUsage;
23
50
  }
24
51
 
25
52
  function emptyBucket(): Bucket {
@@ -29,6 +56,7 @@ function emptyBucket(): Bucket {
29
56
  cacheCreationInputTokens: 0,
30
57
  cacheReadInputTokens: 0,
31
58
  queries: 0,
59
+ estimatedCostUsd: 0,
32
60
  };
33
61
  }
34
62
 
@@ -48,11 +76,11 @@ function queriesPath(userId: number | string): string {
48
76
  return join(logDir, `token-queries-${fileId}.jsonl`);
49
77
  }
50
78
 
51
- function loadRecords(userId: number | string): QueryRecord[] {
79
+ function loadRecords(userId: number | string): unknown[] {
52
80
  try {
53
81
  return readJsonlLines(queriesPath(userId)).flatMap((line) => {
54
82
  try {
55
- return [JSON.parse(line) as QueryRecord];
83
+ return [JSON.parse(line) as unknown];
56
84
  } catch {
57
85
  return [];
58
86
  }
@@ -65,14 +93,72 @@ function loadRecords(userId: number | string): QueryRecord[] {
65
93
  export function calcCost(
66
94
  b: Pick<
67
95
  Bucket,
96
+ | "inputTokens"
97
+ | "outputTokens"
98
+ | "cacheCreationInputTokens"
99
+ | "cacheReadInputTokens"
100
+ | "estimatedCostUsd"
101
+ >,
102
+ ): number {
103
+ return b.estimatedCostUsd;
104
+ }
105
+
106
+ type TokenPrices = {
107
+ input: number;
108
+ output: number;
109
+ cacheWrite?: number;
110
+ cacheRead: number;
111
+ };
112
+
113
+ const TOKEN_PRICES: Record<string, TokenPrices> = {
114
+ "codex:gpt-5.6-sol": { input: 5, cacheRead: 0.5, output: 30 },
115
+ "codex:gpt-5.6-terra": { input: 2.5, cacheRead: 0.25, output: 15 },
116
+ "codex:gpt-5.6-luna": { input: 1, cacheRead: 0.1, output: 6 },
117
+ "claude:fable": { input: 10, cacheWrite: 12.5, cacheRead: 1, output: 50 },
118
+ "claude:opus": { input: 5, cacheWrite: 6.25, cacheRead: 0.5, output: 25 },
119
+ "claude:sonnet": { input: 2, cacheWrite: 2.5, cacheRead: 0.2, output: 10 },
120
+ "maestro:kimi-k3": { input: 3, cacheRead: 0.3, output: 15 },
121
+ "maestro:kimi-k2.7-code": { input: 0.95, cacheRead: 0.19, output: 4 },
122
+ "maestro:deepseek-pro": { input: 0.435, cacheRead: 0.003625, output: 0.87 },
123
+ "maestro:deepseek-flash": { input: 0.14, cacheRead: 0.0028, output: 0.28 },
124
+ };
125
+
126
+ function estimateUsageCost(
127
+ agent: AgentKind,
128
+ model: string,
129
+ usage: Pick<
130
+ QueryRecord,
68
131
  "inputTokens" | "outputTokens" | "cacheCreationInputTokens" | "cacheReadInputTokens"
69
132
  >,
70
133
  ): number {
134
+ const prices = TOKEN_PRICES[`${agent}:${model}`];
135
+ if (!prices) return 0;
71
136
  return (
72
- (b.inputTokens / 1_000_000) * 3.0 +
73
- (b.outputTokens / 1_000_000) * 15.0 +
74
- (b.cacheCreationInputTokens / 1_000_000) * 3.75 +
75
- (b.cacheReadInputTokens / 1_000_000) * 0.3
137
+ (usage.inputTokens * prices.input +
138
+ usage.outputTokens * prices.output +
139
+ (agent === "claude"
140
+ ? usage.cacheCreationInputTokens * (prices.cacheWrite ?? prices.input)
141
+ : 0) +
142
+ usage.cacheReadInputTokens * prices.cacheRead) /
143
+ 1_000_000
144
+ );
145
+ }
146
+
147
+ function isQueryRecord(value: unknown): value is QueryRecord {
148
+ if (!value || typeof value !== "object") return false;
149
+ const record = value as Partial<QueryRecord>;
150
+ return (
151
+ record.schemaVersion === 2 &&
152
+ typeof record.timestamp === "string" &&
153
+ typeof record.session === "string" &&
154
+ typeof record.topicId === "string" &&
155
+ typeof record.agent === "string" &&
156
+ typeof record.model === "string" &&
157
+ typeof record.inputTokens === "number" &&
158
+ typeof record.outputTokens === "number" &&
159
+ typeof record.cacheCreationInputTokens === "number" &&
160
+ typeof record.cacheReadInputTokens === "number" &&
161
+ typeof record.estimatedCostUsd === "number"
76
162
  );
77
163
  }
78
164
 
@@ -80,20 +166,39 @@ export function calcCost(
80
166
  export function recordUsage(
81
167
  userId: number | string,
82
168
  session: string,
83
- usage: {
84
- inputTokens: number;
85
- outputTokens: number;
86
- cacheCreationInputTokens?: number;
87
- cacheReadInputTokens?: number;
169
+ usage: TokenUsage,
170
+ context: {
171
+ topicId: string;
172
+ providerSessionId?: string;
173
+ agent: AgentKind;
174
+ model: string;
88
175
  },
89
176
  ) {
177
+ const cacheReadInputTokens = usage.cacheReadInputTokens ?? 0;
178
+ // Claude reports cache buckets separately; Codex and Maestro include cache
179
+ // hits in their provider input total.
180
+ const inputTokens =
181
+ context.agent === "claude"
182
+ ? usage.inputTokens
183
+ : Math.max(0, usage.inputTokens - cacheReadInputTokens);
184
+ const normalized = {
185
+ inputTokens,
186
+ outputTokens: usage.outputTokens,
187
+ cacheCreationInputTokens: usage.cacheCreationInputTokens ?? 0,
188
+ cacheReadInputTokens,
189
+ };
90
190
  const record: QueryRecord = {
191
+ schemaVersion: 2,
91
192
  timestamp: new Date().toISOString(),
92
193
  session,
93
- inputTokens: usage.inputTokens,
94
- outputTokens: usage.outputTokens,
95
- cacheCreationInputTokens: usage.cacheCreationInputTokens ?? 0,
96
- cacheReadInputTokens: usage.cacheReadInputTokens ?? 0,
194
+ topicId: context.topicId,
195
+ ...(context.providerSessionId ? { providerSessionId: context.providerSessionId } : {}),
196
+ agent: context.agent,
197
+ model: context.model,
198
+ ...normalized,
199
+ ...(usage.contextTokens !== undefined ? { contextTokens: usage.contextTokens } : {}),
200
+ ...(usage.contextWindow !== undefined ? { contextWindow: usage.contextWindow } : {}),
201
+ estimatedCostUsd: usage.costUsd ?? estimateUsageCost(context.agent, context.model, normalized),
97
202
  };
98
203
  try {
99
204
  appendJsonlEntry(queriesPath(userId), record);
@@ -110,6 +215,8 @@ export function getStats(
110
215
  total: Bucket;
111
216
  byHour: Record<string, Bucket>;
112
217
  bySession: Record<string, Bucket>;
218
+ currentSessions: CurrentSessionUsage[];
219
+ ignoredLegacyRecords: number;
113
220
  estimatedCostUsd: number;
114
221
  } {
115
222
  const records = loadRecords(userId);
@@ -119,14 +226,28 @@ export function getStats(
119
226
 
120
227
  if ((from && Number.isNaN(fromTs)) || (to && Number.isNaN(toTs))) {
121
228
  logger.warn({ from, to }, "token-stats: Invalid date range, returning empty");
122
- return { total: emptyBucket(), byHour: {}, bySession: {}, estimatedCostUsd: 0 };
229
+ return {
230
+ total: emptyBucket(),
231
+ byHour: {},
232
+ bySession: {},
233
+ currentSessions: [],
234
+ ignoredLegacyRecords: 0,
235
+ estimatedCostUsd: 0,
236
+ };
123
237
  }
124
238
 
125
239
  const total = emptyBucket();
126
240
  const byHour: Record<string, Bucket> = {};
127
241
  const bySession: Record<string, Bucket> = {};
242
+ const currentSessions = new Map<string, CurrentSessionUsage>();
243
+ let ignoredLegacyRecords = 0;
128
244
 
129
- for (const r of records) {
245
+ for (const raw of records) {
246
+ if (!isQueryRecord(raw)) {
247
+ ignoredLegacyRecords += 1;
248
+ continue;
249
+ }
250
+ const r = raw;
130
251
  const ts = new Date(r.timestamp).getTime();
131
252
  if (ts < fromTs || ts > toTs) continue;
132
253
 
@@ -141,8 +262,68 @@ export function getStats(
141
262
  bucket.cacheCreationInputTokens += r.cacheCreationInputTokens;
142
263
  bucket.cacheReadInputTokens += r.cacheReadInputTokens;
143
264
  bucket.queries += 1;
265
+ bucket.estimatedCostUsd += r.estimatedCostUsd;
266
+ }
267
+
268
+ if (r.contextTokens !== undefined && r.contextWindow !== undefined && r.contextWindow > 0) {
269
+ currentSessions.set(r.topicId, {
270
+ timestamp: r.timestamp,
271
+ topicId: r.topicId,
272
+ topicTitle: r.session,
273
+ ...(r.providerSessionId ? { providerSessionId: r.providerSessionId } : {}),
274
+ agent: r.agent,
275
+ model: r.model,
276
+ contextTokens: r.contextTokens,
277
+ contextWindow: r.contextWindow,
278
+ });
279
+ }
280
+ }
281
+
282
+ return {
283
+ total,
284
+ byHour,
285
+ bySession,
286
+ currentSessions: [...currentSessions.values()].sort((a, b) =>
287
+ b.timestamp.localeCompare(a.timestamp),
288
+ ),
289
+ ignoredLegacyRecords,
290
+ estimatedCostUsd: calcCost(total),
291
+ };
292
+ }
293
+
294
+ /** Exact all-time usage for one topic, independent of transcript pagination. */
295
+ export function getTopicStats(userId: number | string, topicId: string): TopicUsageSummary {
296
+ const total = emptyBucket();
297
+ let currentSession: CurrentSessionUsage | undefined;
298
+
299
+ for (const raw of loadRecords(userId)) {
300
+ if (!isQueryRecord(raw) || raw.topicId !== topicId) continue;
301
+
302
+ total.inputTokens += raw.inputTokens;
303
+ total.outputTokens += raw.outputTokens;
304
+ total.cacheCreationInputTokens += raw.cacheCreationInputTokens;
305
+ total.cacheReadInputTokens += raw.cacheReadInputTokens;
306
+ total.queries += 1;
307
+ total.estimatedCostUsd += raw.estimatedCostUsd;
308
+
309
+ if (
310
+ raw.contextTokens !== undefined &&
311
+ raw.contextWindow !== undefined &&
312
+ raw.contextWindow > 0 &&
313
+ (!currentSession || raw.timestamp > currentSession.timestamp)
314
+ ) {
315
+ currentSession = {
316
+ timestamp: raw.timestamp,
317
+ topicId: raw.topicId,
318
+ topicTitle: raw.session,
319
+ ...(raw.providerSessionId ? { providerSessionId: raw.providerSessionId } : {}),
320
+ agent: raw.agent,
321
+ model: raw.model,
322
+ contextTokens: raw.contextTokens,
323
+ contextWindow: raw.contextWindow,
324
+ };
144
325
  }
145
326
  }
146
327
 
147
- return { total, byHour, bySession, estimatedCostUsd: calcCost(total) };
328
+ return { topicId, ...total, ...(currentSession ? { currentSession } : {}) };
148
329
  }
@@ -1 +1 @@
1
- export const NEGOTIUM_VERSION = "0.2.15";
1
+ export const NEGOTIUM_VERSION = "0.2.16";
package/dist/storage.js CHANGED
@@ -3361,6 +3361,7 @@ var exports_token_stats = {};
3361
3361
  __export(exports_token_stats, {
3362
3362
  tokenStatsFileId: () => tokenStatsFileId,
3363
3363
  recordUsage: () => recordUsage,
3364
+ getTopicStats: () => getTopicStats,
3364
3365
  getStats: () => getStats,
3365
3366
  calcCost: () => calcCost
3366
3367
  });
@@ -3373,7 +3374,8 @@ function emptyBucket() {
3373
3374
  outputTokens: 0,
3374
3375
  cacheCreationInputTokens: 0,
3375
3376
  cacheReadInputTokens: 0,
3376
- queries: 0
3377
+ queries: 0,
3378
+ estimatedCostUsd: 0
3377
3379
  };
3378
3380
  }
3379
3381
  function tokenStatsFileId(userId) {
@@ -3400,16 +3402,53 @@ function loadRecords(userId) {
3400
3402
  }
3401
3403
  }
3402
3404
  function calcCost(b) {
3403
- return b.inputTokens / 1e6 * 3 + b.outputTokens / 1e6 * 15 + b.cacheCreationInputTokens / 1e6 * 3.75 + b.cacheReadInputTokens / 1e6 * 0.3;
3405
+ return b.estimatedCostUsd;
3406
+ }
3407
+ var TOKEN_PRICES = {
3408
+ "codex:gpt-5.6-sol": { input: 5, cacheRead: 0.5, output: 30 },
3409
+ "codex:gpt-5.6-terra": { input: 2.5, cacheRead: 0.25, output: 15 },
3410
+ "codex:gpt-5.6-luna": { input: 1, cacheRead: 0.1, output: 6 },
3411
+ "claude:fable": { input: 10, cacheWrite: 12.5, cacheRead: 1, output: 50 },
3412
+ "claude:opus": { input: 5, cacheWrite: 6.25, cacheRead: 0.5, output: 25 },
3413
+ "claude:sonnet": { input: 2, cacheWrite: 2.5, cacheRead: 0.2, output: 10 },
3414
+ "maestro:kimi-k3": { input: 3, cacheRead: 0.3, output: 15 },
3415
+ "maestro:kimi-k2.7-code": { input: 0.95, cacheRead: 0.19, output: 4 },
3416
+ "maestro:deepseek-pro": { input: 0.435, cacheRead: 0.003625, output: 0.87 },
3417
+ "maestro:deepseek-flash": { input: 0.14, cacheRead: 0.0028, output: 0.28 }
3418
+ };
3419
+ function estimateUsageCost(agent, model, usage) {
3420
+ const prices = TOKEN_PRICES[`${agent}:${model}`];
3421
+ if (!prices)
3422
+ return 0;
3423
+ return (usage.inputTokens * prices.input + usage.outputTokens * prices.output + (agent === "claude" ? usage.cacheCreationInputTokens * (prices.cacheWrite ?? prices.input) : 0) + usage.cacheReadInputTokens * prices.cacheRead) / 1e6;
3404
3424
  }
3405
- function recordUsage(userId, session, usage) {
3425
+ function isQueryRecord(value) {
3426
+ if (!value || typeof value !== "object")
3427
+ return false;
3428
+ const record = value;
3429
+ return record.schemaVersion === 2 && typeof record.timestamp === "string" && typeof record.session === "string" && typeof record.topicId === "string" && typeof record.agent === "string" && typeof record.model === "string" && typeof record.inputTokens === "number" && typeof record.outputTokens === "number" && typeof record.cacheCreationInputTokens === "number" && typeof record.cacheReadInputTokens === "number" && typeof record.estimatedCostUsd === "number";
3430
+ }
3431
+ function recordUsage(userId, session, usage, context) {
3432
+ const cacheReadInputTokens = usage.cacheReadInputTokens ?? 0;
3433
+ const inputTokens = context.agent === "claude" ? usage.inputTokens : Math.max(0, usage.inputTokens - cacheReadInputTokens);
3434
+ const normalized = {
3435
+ inputTokens,
3436
+ outputTokens: usage.outputTokens,
3437
+ cacheCreationInputTokens: usage.cacheCreationInputTokens ?? 0,
3438
+ cacheReadInputTokens
3439
+ };
3406
3440
  const record = {
3441
+ schemaVersion: 2,
3407
3442
  timestamp: new Date().toISOString(),
3408
3443
  session,
3409
- inputTokens: usage.inputTokens,
3410
- outputTokens: usage.outputTokens,
3411
- cacheCreationInputTokens: usage.cacheCreationInputTokens ?? 0,
3412
- cacheReadInputTokens: usage.cacheReadInputTokens ?? 0
3444
+ topicId: context.topicId,
3445
+ ...context.providerSessionId ? { providerSessionId: context.providerSessionId } : {},
3446
+ agent: context.agent,
3447
+ model: context.model,
3448
+ ...normalized,
3449
+ ...usage.contextTokens !== undefined ? { contextTokens: usage.contextTokens } : {},
3450
+ ...usage.contextWindow !== undefined ? { contextWindow: usage.contextWindow } : {},
3451
+ estimatedCostUsd: usage.costUsd ?? estimateUsageCost(context.agent, context.model, normalized)
3413
3452
  };
3414
3453
  try {
3415
3454
  appendJsonlEntry(queriesPath(userId), record);
@@ -3423,12 +3462,26 @@ function getStats(userId, from, to) {
3423
3462
  const toTs = to ? new Date(to).getTime() : Infinity;
3424
3463
  if (from && Number.isNaN(fromTs) || to && Number.isNaN(toTs)) {
3425
3464
  logger.warn({ from, to }, "token-stats: Invalid date range, returning empty");
3426
- return { total: emptyBucket(), byHour: {}, bySession: {}, estimatedCostUsd: 0 };
3465
+ return {
3466
+ total: emptyBucket(),
3467
+ byHour: {},
3468
+ bySession: {},
3469
+ currentSessions: [],
3470
+ ignoredLegacyRecords: 0,
3471
+ estimatedCostUsd: 0
3472
+ };
3427
3473
  }
3428
3474
  const total = emptyBucket();
3429
3475
  const byHour = {};
3430
3476
  const bySession = {};
3431
- for (const r of records) {
3477
+ const currentSessions = new Map;
3478
+ let ignoredLegacyRecords = 0;
3479
+ for (const raw of records) {
3480
+ if (!isQueryRecord(raw)) {
3481
+ ignoredLegacyRecords += 1;
3482
+ continue;
3483
+ }
3484
+ const r = raw;
3432
3485
  const ts = new Date(r.timestamp).getTime();
3433
3486
  if (ts < fromTs || ts > toTs)
3434
3487
  continue;
@@ -3443,9 +3496,56 @@ function getStats(userId, from, to) {
3443
3496
  bucket.cacheCreationInputTokens += r.cacheCreationInputTokens;
3444
3497
  bucket.cacheReadInputTokens += r.cacheReadInputTokens;
3445
3498
  bucket.queries += 1;
3499
+ bucket.estimatedCostUsd += r.estimatedCostUsd;
3500
+ }
3501
+ if (r.contextTokens !== undefined && r.contextWindow !== undefined && r.contextWindow > 0) {
3502
+ currentSessions.set(r.topicId, {
3503
+ timestamp: r.timestamp,
3504
+ topicId: r.topicId,
3505
+ topicTitle: r.session,
3506
+ ...r.providerSessionId ? { providerSessionId: r.providerSessionId } : {},
3507
+ agent: r.agent,
3508
+ model: r.model,
3509
+ contextTokens: r.contextTokens,
3510
+ contextWindow: r.contextWindow
3511
+ });
3512
+ }
3513
+ }
3514
+ return {
3515
+ total,
3516
+ byHour,
3517
+ bySession,
3518
+ currentSessions: [...currentSessions.values()].sort((a, b) => b.timestamp.localeCompare(a.timestamp)),
3519
+ ignoredLegacyRecords,
3520
+ estimatedCostUsd: calcCost(total)
3521
+ };
3522
+ }
3523
+ function getTopicStats(userId, topicId) {
3524
+ const total = emptyBucket();
3525
+ let currentSession;
3526
+ for (const raw of loadRecords(userId)) {
3527
+ if (!isQueryRecord(raw) || raw.topicId !== topicId)
3528
+ continue;
3529
+ total.inputTokens += raw.inputTokens;
3530
+ total.outputTokens += raw.outputTokens;
3531
+ total.cacheCreationInputTokens += raw.cacheCreationInputTokens;
3532
+ total.cacheReadInputTokens += raw.cacheReadInputTokens;
3533
+ total.queries += 1;
3534
+ total.estimatedCostUsd += raw.estimatedCostUsd;
3535
+ if (raw.contextTokens !== undefined && raw.contextWindow !== undefined && raw.contextWindow > 0 && (!currentSession || raw.timestamp > currentSession.timestamp)) {
3536
+ currentSession = {
3537
+ timestamp: raw.timestamp,
3538
+ topicId: raw.topicId,
3539
+ topicTitle: raw.session,
3540
+ ...raw.providerSessionId ? { providerSessionId: raw.providerSessionId } : {},
3541
+ agent: raw.agent,
3542
+ model: raw.model,
3543
+ contextTokens: raw.contextTokens,
3544
+ contextWindow: raw.contextWindow
3545
+ };
3446
3546
  }
3447
3547
  }
3448
- return { total, byHour, bySession, estimatedCostUsd: calcCost(total) };
3548
+ return { topicId, ...total, ...currentSession ? { currentSession } : {} };
3449
3549
  }
3450
3550
  // ../../packages/core/src/storage/topic-archive.ts
3451
3551
  var exports_topic_archive = {};
@@ -3971,6 +4071,7 @@ export {
3971
4071
  grantSubagentTellTarget,
3972
4072
  getWikiDir,
3973
4073
  getUserConfig,
4074
+ getTopicStats,
3974
4075
  getTopicSessionId,
3975
4076
  getTopicNames,
3976
4077
  getTopicMemoryOrigin,
@@ -4065,4 +4166,4 @@ export {
4065
4166
  ConversationLogDivergedError
4066
4167
  };
4067
4168
 
4068
- //# debugId=CD82519368DB318D64756E2164756E21
4169
+ //# debugId=0E9898BA8C097A7864756E2164756E21