niahere 0.5.2 → 0.5.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -17,6 +17,21 @@ const DEAD_TURNS: Record<string, FailoverScope | undefined> = {
17
17
  turn_setup_failed: undefined,
18
18
  };
19
19
 
20
+ /**
21
+ * Stamp the chain's provider over the SDK's own, which names the deployment
22
+ * target (firstParty/bedrock/vertex) — a different axis from which backend ran
23
+ * the turn, and the one `providers_used` has to answer.
24
+ */
25
+ function attribute(modelUsage: unknown): Record<string, unknown> | undefined {
26
+ if (!modelUsage || typeof modelUsage !== "object") return undefined;
27
+ return Object.fromEntries(
28
+ Object.entries(modelUsage as Record<string, unknown>).map(([model, usage]) => [
29
+ model,
30
+ { ...(usage as object), provider: "claude" },
31
+ ]),
32
+ );
33
+ }
34
+
20
35
  /**
21
36
  * Pure reducer: Claude Agent SDK messages → normalized `AgentEvent`s.
22
37
  *
@@ -151,7 +166,7 @@ export class SdkNormalizer implements Normalizer {
151
166
  session_id: msg.session_id,
152
167
  subtype: msg.subtype,
153
168
  usage: msg.usage,
154
- model_usage: msg.modelUsage,
169
+ model_usage: attribute(msg.modelUsage),
155
170
  },
156
171
  };
157
172
  }
@@ -40,7 +40,11 @@ export class CodexNormalizer implements Normalizer {
40
40
  case "turn.failed":
41
41
  return this.fail(typeof e.error?.message === "string" ? e.error.message : "");
42
42
  case "turn.completed": {
43
- const input = e.usage?.input_tokens ?? 0;
43
+ // Codex counts like OpenAI: `input_tokens` is the whole prompt with
44
+ // `cached_input_tokens` a slice of it. Claude reports the two as
45
+ // siblings and one accumulator sums both, so the slice comes out here.
46
+ const cacheRead = e.usage?.cached_input_tokens ?? 0;
47
+ const input = Math.max(0, (e.usage?.input_tokens ?? 0) - cacheRead);
44
48
  const output = e.usage?.output_tokens ?? 0;
45
49
  return [
46
50
  {
@@ -50,13 +54,16 @@ export class CodexNormalizer implements Normalizer {
50
54
  backendSessionId: this.threadId,
51
55
  // Same shape the Claude path emits, so one accumulator serves both
52
56
  // and a failed-over turn is attributable to the provider that ran it.
57
+ // No cost: codex reports none, and an invented zero would read as a
58
+ // free turn.
53
59
  metadata: {
54
60
  model_usage: {
55
61
  [this.model || "default"]: {
56
62
  provider: "codex",
57
63
  inputTokens: input,
58
64
  outputTokens: output,
59
- cacheReadInputTokens: e.usage?.cached_input_tokens ?? 0,
65
+ cacheReadInputTokens: cacheRead,
66
+ cacheCreationInputTokens: e.usage?.cache_write_input_tokens ?? 0,
60
67
  },
61
68
  },
62
69
  },
@@ -323,7 +323,11 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
323
323
  };
324
324
  try {
325
325
  messageId = await Message.save(saveParams);
326
- } catch {
326
+ } catch (e) {
327
+ // Saving the reply matters more than saving its usage, but a
328
+ // silent drop cost eight days of the ledger before anyone
329
+ // looked.
330
+ log.error({ room, error: errMsg(e) }, "dropping turn metadata; message saved without it");
327
331
  messageId = await Message.save({ ...saveParams, metadata: undefined });
328
332
  }
329
333
  await Session.touch(sessionId);
package/src/cli/index.ts CHANGED
@@ -203,6 +203,12 @@ switch (command) {
203
203
  break;
204
204
  }
205
205
 
206
+ case "usage": {
207
+ const { usageCommand } = await import("./usage");
208
+ await usageCommand(process.argv.slice(3));
209
+ break;
210
+ }
211
+
206
212
  case "seed": {
207
213
  await import("../db/seed");
208
214
  break;
@@ -337,6 +343,7 @@ Daemon:
337
343
  model [name] Show or set global Claude model
338
344
  health Check daemon, db, channels, config
339
345
  logs [-f] [--channel ch] Daemon logs (filter by channel)
346
+ usage [--days N --by dim --room R --json] Tokens and cost per day/model/provider/room
340
347
 
341
348
  Chat:
342
349
  chat [-c] [-r] [--employee|--agent|--job name] Interactive chat
@@ -0,0 +1,189 @@
1
+ import { withDb } from "../db/with-db";
2
+ import { queryUsage, type UsageRow } from "../db/models/usage";
3
+ import { getConfig } from "../utils/config";
4
+ import { BOLD, DIM, RESET, fail } from "../utils/cli";
5
+ import { errMsg } from "../utils/errors";
6
+
7
+ export type Dimension = "day" | "model" | "provider" | "room";
8
+
9
+ const DIMENSIONS: Dimension[] = ["day", "model", "provider", "room"];
10
+
11
+ export interface UsageOptions {
12
+ days: number;
13
+ by: Dimension;
14
+ room?: string;
15
+ json: boolean;
16
+ }
17
+
18
+ export interface UsageBucket {
19
+ key: string;
20
+ inputTokens: number;
21
+ outputTokens: number;
22
+ cacheReadTokens: number;
23
+ cacheWriteTokens: number;
24
+ /** null when nothing in the bucket carried a cost. */
25
+ costUsd: number | null;
26
+ turns: number;
27
+ unpricedTurns: number;
28
+ }
29
+
30
+ export function parseUsageArgs(argv: string[]): UsageOptions {
31
+ const opts: UsageOptions = { days: 7, by: "day", json: false };
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const arg = argv[i]!;
34
+ const value = argv[i + 1];
35
+ if (arg === "--json") {
36
+ opts.json = true;
37
+ } else if (arg === "--days" && value) {
38
+ const parsed = Number.parseInt(value, 10);
39
+ if (Number.isInteger(parsed) && parsed > 0) opts.days = parsed;
40
+ i += 1;
41
+ } else if (arg === "--by" && value) {
42
+ if (!DIMENSIONS.includes(value as Dimension)) throw new Error(`--by must be one of: ${DIMENSIONS.join(", ")}`);
43
+ opts.by = value as Dimension;
44
+ i += 1;
45
+ } else if (arg === "--room" && value) {
46
+ opts.room = value;
47
+ i += 1;
48
+ }
49
+ }
50
+ return opts;
51
+ }
52
+
53
+ export function rollup(rows: UsageRow[], by: Dimension): UsageBucket[] {
54
+ const byKey = new Map<string, UsageBucket>();
55
+ for (const row of rows) {
56
+ const key = row[by];
57
+ const seen = byKey.get(key);
58
+ if (!seen) {
59
+ byKey.set(key, {
60
+ key,
61
+ inputTokens: row.inputTokens,
62
+ outputTokens: row.outputTokens,
63
+ cacheReadTokens: row.cacheReadTokens,
64
+ cacheWriteTokens: row.cacheWriteTokens,
65
+ costUsd: row.costUsd,
66
+ turns: row.turns,
67
+ unpricedTurns: row.unpricedTurns,
68
+ });
69
+ continue;
70
+ }
71
+ seen.inputTokens += row.inputTokens;
72
+ seen.outputTokens += row.outputTokens;
73
+ seen.cacheReadTokens += row.cacheReadTokens;
74
+ seen.cacheWriteTokens += row.cacheWriteTokens;
75
+ seen.turns += row.turns;
76
+ seen.unpricedTurns += row.unpricedTurns;
77
+ // A known cost survives an unknown one beside it; only an all-unknown
78
+ // bucket reports nothing.
79
+ seen.costUsd = seen.costUsd === null && row.costUsd === null ? null : (seen.costUsd ?? 0) + (row.costUsd ?? 0);
80
+ }
81
+
82
+ const buckets = [...byKey.values()];
83
+ return by === "day"
84
+ ? buckets.sort((a, b) => a.key.localeCompare(b.key))
85
+ : buckets.sort((a, b) => (b.costUsd ?? 0) - (a.costUsd ?? 0) || b.turns - a.turns);
86
+ }
87
+
88
+ export function compactTokens(n: number): string {
89
+ if (n >= 1_000_000) return `${round(n / 1_000_000)}M`;
90
+ if (n >= 1_000) return `${round(n / 1_000)}K`;
91
+ return String(n);
92
+ }
93
+
94
+ function round(n: number): string {
95
+ return n.toFixed(1).replace(/\.0$/, "");
96
+ }
97
+
98
+ export function formatCost(cost: number | null): string {
99
+ if (cost === null) return "—";
100
+ // Sub-cent turns are common; rounding them to $0.00 hides real spend.
101
+ return cost > 0 && cost < 0.01 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`;
102
+ }
103
+
104
+ const COLUMNS: [string, (b: UsageBucket) => string][] = [
105
+ ["Turns", (b) => String(b.turns)],
106
+ ["Input", (b) => compactTokens(b.inputTokens)],
107
+ ["Output", (b) => compactTokens(b.outputTokens)],
108
+ ["Cache rd", (b) => compactTokens(b.cacheReadTokens)],
109
+ ["Cache wr", (b) => compactTokens(b.cacheWriteTokens)],
110
+ ["Cost", (b) => formatCost(b.costUsd)],
111
+ ];
112
+
113
+ export function renderTable(buckets: UsageBucket[], label: string): string[] {
114
+ const header = [label, ...COLUMNS.map(([name]) => name)];
115
+ const body = buckets.map((b) => [b.key, ...COLUMNS.map(([, read]) => read(b))]);
116
+ const total = buckets.reduce<UsageBucket>((sum, b) => {
117
+ sum.inputTokens += b.inputTokens;
118
+ sum.outputTokens += b.outputTokens;
119
+ sum.cacheReadTokens += b.cacheReadTokens;
120
+ sum.cacheWriteTokens += b.cacheWriteTokens;
121
+ sum.turns += b.turns;
122
+ sum.unpricedTurns += b.unpricedTurns;
123
+ sum.costUsd = sum.costUsd === null && b.costUsd === null ? null : (sum.costUsd ?? 0) + (b.costUsd ?? 0);
124
+ return sum;
125
+ }, emptyBucket("Total"));
126
+
127
+ const rows = [header, ...body, [total.key, ...COLUMNS.map(([, read]) => read(total))]];
128
+ const widths = header.map((_, col) => Math.max(...rows.map((r) => r[col]!.length)));
129
+ const line = (cells: string[]) =>
130
+ cells.map((cell, col) => (col === 0 ? cell.padEnd(widths[col]!) : cell.padStart(widths[col]!))).join(" ");
131
+
132
+ const out = [`${BOLD}${line(header)}${RESET}`, ...body.map(line), `${BOLD}${line(rows.at(-1)!)}${RESET}`];
133
+ if (total.unpricedTurns > 0) {
134
+ out.push(`${DIM}${total.unpricedTurns} of ${total.turns} turns report no cost (codex bills no per-token price)${RESET}`);
135
+ }
136
+ return out;
137
+ }
138
+
139
+ function emptyBucket(key: string): UsageBucket {
140
+ return {
141
+ key,
142
+ inputTokens: 0,
143
+ outputTokens: 0,
144
+ cacheReadTokens: 0,
145
+ cacheWriteTokens: 0,
146
+ costUsd: null,
147
+ turns: 0,
148
+ unpricedTurns: 0,
149
+ };
150
+ }
151
+
152
+ export async function usageCommand(argv: string[]): Promise<void> {
153
+ let opts: UsageOptions;
154
+ try {
155
+ opts = parseUsageArgs(argv);
156
+ } catch (e) {
157
+ fail(errMsg(e));
158
+ }
159
+ const since = new Date(Date.now() - opts.days * 24 * 60 * 60 * 1000);
160
+ const timezone = getConfig().timezone || "UTC";
161
+
162
+ const rows = await withDb(() =>
163
+ queryUsage({ since, ...(opts.room !== undefined ? { room: opts.room } : {}), timezone }),
164
+ );
165
+
166
+ if (opts.json) {
167
+ console.log(JSON.stringify({ since: since.toISOString(), timezone, rows }, null, 2));
168
+ return;
169
+ }
170
+
171
+ if (rows.length === 0) {
172
+ console.log(`No recorded usage in the last ${opts.days} days${opts.room ? ` for ${opts.room}` : ""}.`);
173
+ return;
174
+ }
175
+
176
+ const scope = opts.room ? ` · ${opts.room}` : "";
177
+ console.log(`\nUsage — last ${opts.days} days${scope} (${timezone})\n`);
178
+ for (const line of renderTable(rollup(rows, opts.by), title(opts.by))) console.log(line);
179
+
180
+ if (opts.by === "day") {
181
+ console.log("\nBy model");
182
+ for (const line of renderTable(rollup(rows, "model"), "Model")) console.log(` ${line}`);
183
+ }
184
+ console.log();
185
+ }
186
+
187
+ function title(by: Dimension): string {
188
+ return by === "day" ? "Day" : by[0]!.toUpperCase() + by.slice(1);
189
+ }
@@ -125,15 +125,30 @@ export async function runJobAcrossChain(
125
125
  ): Promise<RunnerOutput> {
126
126
  const cursor = new ChainCursor(chain);
127
127
  let output: RunnerOutput = { agentText: "", sessionId: "", error: "no model configured" };
128
+ const abandoned: string[] = [];
129
+
130
+ // The last entry's message alone hides why the chain started walking, which
131
+ // is usually the more useful half.
132
+ const withTrail = (out: RunnerOutput): RunnerOutput =>
133
+ out.error && abandoned.length > 0 ? { ...out, error: `${out.error} (after ${abandoned.join("; ")})` } : out;
128
134
 
129
135
  for (let entry = cursor.current; entry; ) {
130
136
  output = await runEntry(entry, sessionCtx, jobPrompt, onActivity, activeRoom);
131
- if (!output.failover) return output;
137
+ if (!output.failover) return withTrail(output);
132
138
 
133
139
  const from = describeEntry(entry);
140
+ if (output.error) abandoned.push(`${from}: ${output.error}`);
134
141
  entry = cursor.advance(output.failover);
135
- if (entry) log.warn({ from, to: describeEntry(entry), scope: output.failover }, "failing over to next model");
142
+ if (entry) {
143
+ log.warn(
144
+ { from, to: describeEntry(entry), scope: output.failover, err: output.error, terminal_reason: output.terminalReason },
145
+ "failing over to next model",
146
+ );
147
+ }
136
148
  }
149
+
150
+ // The chain ran out: the final entry's own failure is already the last trail
151
+ // entry, so reporting it twice adds nothing.
137
152
  return output;
138
153
  }
139
154
 
@@ -182,8 +182,14 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
182
182
  // Bind deltas as jsonb via sql.json — pre-stringifying would store a
183
183
  // double-encoded string scalar, making every `->>` extraction return NULL
184
184
  // and silently zeroing all accumulated totals.
185
+ // Codex reports tokens but no cost. Folding that in as $0 would make a
186
+ // session that failed over look cheaper than one that never left Claude, so
187
+ // the unknown is counted rather than absorbed.
188
+ const priced = typeof resultMeta.cost_usd === "number";
189
+
185
190
  const delta = sql.json({
186
191
  total_cost_usd: (resultMeta.cost_usd as number) || 0,
192
+ unpriced_turns: priced ? 0 : 1,
187
193
  total_turns: (resultMeta.turns as number) || 0,
188
194
  total_duration_ms: (resultMeta.duration_ms as number) || 0,
189
195
  total_duration_api_ms: (resultMeta.duration_api_ms as number) || 0,
@@ -203,6 +209,7 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
203
209
  await sql`
204
210
  UPDATE sessions SET metadata = jsonb_build_object(
205
211
  'total_cost_usd', COALESCE((metadata->>'total_cost_usd')::real, 0) + (${delta}->>'total_cost_usd')::real,
212
+ 'unpriced_turns', COALESCE((metadata->>'unpriced_turns')::int, 0) + (${delta}->>'unpriced_turns')::int,
206
213
  'total_turns', COALESCE((metadata->>'total_turns')::int, 0) + (${delta}->>'total_turns')::int,
207
214
  'total_duration_ms', COALESCE((metadata->>'total_duration_ms')::real, 0) + (${delta}->>'total_duration_ms')::real,
208
215
  'total_duration_api_ms', COALESCE((metadata->>'total_duration_api_ms')::real, 0) + (${delta}->>'total_duration_api_ms')::real,
Binary file