niahere 0.5.1 → 0.5.2

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.1",
3
+ "version": "0.5.2",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -44,9 +44,9 @@
44
44
  "license": "MIT",
45
45
  "private": false,
46
46
  "dependencies": {
47
- "@anthropic-ai/claude-agent-sdk": "^0.3.190",
48
- "@anthropic-ai/sdk": "^0.105.0",
49
- "@modelcontextprotocol/sdk": "^1.29.0",
47
+ "@anthropic-ai/claude-agent-sdk": "0.3.220",
48
+ "@anthropic-ai/sdk": "0.115.0",
49
+ "@modelcontextprotocol/sdk": "1.30.0",
50
50
  "@slack/bolt": "^4.6.0",
51
51
  "cron-parser": "^5.5.0",
52
52
  "grammy": "^1.41.1",
@@ -1,6 +1,21 @@
1
1
  import type { AgentEvent, Normalizer } from "../types";
2
2
  import { truncate } from "../../utils/format-activity";
3
3
  import { isRetryable, scopeOf, parseFailure } from "../failure";
4
+ import type { FailoverScope } from "../types";
5
+
6
+ /**
7
+ * Terminal reasons that mean the turn died. The SDK used to report several of
8
+ * these as `completed`, so a dead turn landed in the audit as a successful run.
9
+ * The value is how far the chain should skip; undefined means stop.
10
+ */
11
+ const DEAD_TURNS: Record<string, FailoverScope | undefined> = {
12
+ api_error: "provider",
13
+ budget_exhausted: undefined,
14
+ malformed_tool_use_exhausted: undefined,
15
+ structured_output_retry_exhausted: undefined,
16
+ tool_deferred_unavailable: undefined,
17
+ turn_setup_failed: undefined,
18
+ };
4
19
 
5
20
  /**
6
21
  * Pure reducer: Claude Agent SDK messages → normalized `AgentEvent`s.
@@ -41,6 +56,17 @@ export class SdkNormalizer implements Normalizer {
41
56
  return [];
42
57
  }
43
58
 
59
+ if (msg.type === "system" && msg.subtype === "compact_boundary") {
60
+ // Long sessions compact silently; surface it so the transcript records
61
+ // that history was summarized and how much was dropped.
62
+ const meta = msg.compact_metadata ?? {};
63
+ const trigger = meta.trigger === "manual" ? "manual" : "auto";
64
+ const pre = meta.pre_tokens ?? 0;
65
+ const post = meta.post_tokens;
66
+ const shrink = post !== undefined ? `${pre} → ${post} tokens` : `${pre} tokens`;
67
+ return [{ type: "thinking", delta: `context compacted (${trigger}, ${shrink})` }];
68
+ }
69
+
44
70
  if (msg.type === "system") {
45
71
  // Subagent/task lifecycle (subtype init handled above).
46
72
  if (msg.subtype === "task_started" && msg.description) {
@@ -98,6 +124,16 @@ export class SdkNormalizer implements Normalizer {
98
124
  }
99
125
 
100
126
  private consumeResult(msg: any): AgentEvent {
127
+ const deadTurn = msg.terminal_reason as string | undefined;
128
+ if (!msg.is_error && deadTurn && deadTurn in DEAD_TURNS) {
129
+ return {
130
+ type: "error",
131
+ message: (msg.errors?.join(", ") as string) || `turn ended: ${deadTurn}`,
132
+ retryable: false,
133
+ failover: DEAD_TURNS[deadTurn],
134
+ terminalReason: deadTurn,
135
+ };
136
+ }
101
137
  if (!msg.is_error) {
102
138
  return {
103
139
  type: "result",
@@ -126,7 +162,11 @@ export class SdkNormalizer implements Normalizer {
126
162
  type: "error",
127
163
  message: raw,
128
164
  retryable: isRetryable(raw),
129
- failover: scopeOf(parseFailure(raw), "provider"),
165
+ // api_error_status is the reliable signal; prose is the fallback.
166
+ failover: scopeOf(
167
+ { ...parseFailure(raw), status: typeof msg.api_error_status === "number" ? msg.api_error_status : undefined },
168
+ "provider",
169
+ ),
130
170
  terminalReason: msg.terminal_reason,
131
171
  };
132
172
  }
@@ -14,6 +14,10 @@ import { scopeOf, parseFailure } from "../failure";
14
14
  * skill budget) and are dropped.
15
15
  */
16
16
  export class CodexNormalizer implements Normalizer {
17
+ /** The model this run was launched on, for usage attribution — codex's own
18
+ * events don't name it. */
19
+ constructor(private readonly model?: string) {}
20
+
17
21
  private threadId = "";
18
22
  private agentText = "";
19
23
  private failed = false;
@@ -35,20 +39,30 @@ export class CodexNormalizer implements Normalizer {
35
39
  return this.fail(typeof e.message === "string" ? e.message : "");
36
40
  case "turn.failed":
37
41
  return this.fail(typeof e.error?.message === "string" ? e.error.message : "");
38
- case "turn.completed":
42
+ case "turn.completed": {
43
+ const input = e.usage?.input_tokens ?? 0;
44
+ const output = e.usage?.output_tokens ?? 0;
39
45
  return [
40
46
  {
41
47
  type: "result",
42
48
  text: this.agentText,
43
- usage: {
44
- tokens: {
45
- input: e.usage?.input_tokens ?? 0,
46
- output: e.usage?.output_tokens ?? 0,
49
+ usage: { tokens: { input, output } },
50
+ backendSessionId: this.threadId,
51
+ // Same shape the Claude path emits, so one accumulator serves both
52
+ // and a failed-over turn is attributable to the provider that ran it.
53
+ metadata: {
54
+ model_usage: {
55
+ [this.model || "default"]: {
56
+ provider: "codex",
57
+ inputTokens: input,
58
+ outputTokens: output,
59
+ cacheReadInputTokens: e.usage?.cached_input_tokens ?? 0,
60
+ },
47
61
  },
48
62
  },
49
- backendSessionId: this.threadId,
50
63
  },
51
64
  ];
65
+ }
52
66
  default:
53
67
  return [];
54
68
  }
@@ -201,7 +201,7 @@ class CodexSession implements AgentSession {
201
201
  // Started now, not after exit: an undrained pipe blocks the child.
202
202
  const stderr = drainStderr(proc.stderr);
203
203
 
204
- const normalizer = new CodexNormalizer();
204
+ const normalizer = new CodexNormalizer(this.ctx.model);
205
205
  const stdout = proc.stdout.getReader();
206
206
  const lines = readLines(stdout)[Symbol.asyncIterator]();
207
207
  let sawTerminal = false;
@@ -82,8 +82,10 @@ function scopeOfStatus(status: number, message: string): FailoverScope | undefin
82
82
  */
83
83
  export function scopeOf(failure: Failure, blank?: FailoverScope): FailoverScope | undefined {
84
84
  const t = failure.message.trim();
85
- if (!t || t.toLowerCase() === "unknown error") return blank;
85
+ // A status is authoritative even when the message is empty — the prose that
86
+ // accompanies a 429 or 529 is routinely unhelpful.
86
87
  if (failure.status !== undefined) return scopeOfStatus(failure.status, t);
88
+ if (!t || t.toLowerCase() === "unknown error") return blank;
87
89
  if (MODEL_SCOPED.some((p) => p.test(t))) return "model";
88
90
  if (PROVIDER_SCOPED.some((p) => p.test(t))) return "provider";
89
91
  return undefined;
@@ -122,24 +122,62 @@ export async function getRecentSummaries(
122
122
  }));
123
123
  }
124
124
 
125
+ export interface UsageTotals {
126
+ inputTokens: number;
127
+ outputTokens: number;
128
+ cacheReadTokens: number;
129
+ cacheCreationTokens: number;
130
+ models: string[];
131
+ providers: string[];
132
+ }
133
+
134
+ const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
135
+ const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
136
+
137
+ /**
138
+ * Fold a result's per-model usage into totals. `provider` matters because a
139
+ * chain that fails over makes the model name alone ambiguous about who served
140
+ * the turn.
141
+ */
142
+ export function summarizeModelUsage(modelUsage: unknown): UsageTotals {
143
+ const totals: UsageTotals = {
144
+ inputTokens: 0,
145
+ outputTokens: 0,
146
+ cacheReadTokens: 0,
147
+ cacheCreationTokens: 0,
148
+ models: [],
149
+ providers: [],
150
+ };
151
+ if (!modelUsage || typeof modelUsage !== "object") return totals;
152
+
153
+ const models = new Set<string>();
154
+ const providers = new Set<string>();
155
+ for (const [key, raw] of Object.entries(modelUsage as Record<string, unknown>)) {
156
+ const usage = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
157
+ models.add(str(usage.canonicalModel) ?? key);
158
+ const provider = str(usage.provider);
159
+ if (provider) providers.add(provider);
160
+ totals.inputTokens += num(usage.inputTokens);
161
+ totals.outputTokens += num(usage.outputTokens);
162
+ totals.cacheReadTokens += num(usage.cacheReadInputTokens);
163
+ totals.cacheCreationTokens += num(usage.cacheCreationInputTokens);
164
+ }
165
+ totals.models = [...models];
166
+ totals.providers = [...providers];
167
+ return totals;
168
+ }
169
+
125
170
  export async function accumulateMetadata(id: string, resultMeta: Record<string, unknown>): Promise<void> {
126
171
  const sql = getSql();
127
172
 
128
- const modelUsage = resultMeta.model_usage as Record<string, Record<string, number>> | undefined;
129
- let inputTokens = 0;
130
- let outputTokens = 0;
131
- let cacheReadTokens = 0;
132
- let cacheCreationTokens = 0;
133
- const newModels: string[] = [];
134
- if (modelUsage) {
135
- for (const [model, usage] of Object.entries(modelUsage)) {
136
- newModels.push(model);
137
- inputTokens += usage.inputTokens || 0;
138
- outputTokens += usage.outputTokens || 0;
139
- cacheReadTokens += usage.cacheReadInputTokens || 0;
140
- cacheCreationTokens += usage.cacheCreationInputTokens || 0;
141
- }
142
- }
173
+ const {
174
+ inputTokens,
175
+ outputTokens,
176
+ cacheReadTokens,
177
+ cacheCreationTokens,
178
+ models: newModels,
179
+ providers: newProviders,
180
+ } = summarizeModelUsage(resultMeta.model_usage);
143
181
 
144
182
  // Bind deltas as jsonb via sql.json — pre-stringifying would store a
145
183
  // double-encoded string scalar, making every `->>` extraction return NULL
@@ -155,9 +193,11 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
155
193
  total_cache_creation_tokens: cacheCreationTokens,
156
194
  message_count: 1,
157
195
  models_used: newModels,
196
+ providers_used: newProviders,
158
197
  channel: (resultMeta.channel as string) || null,
159
198
  });
160
199
  const modelsDelta = sql.json(newModels);
200
+ const providersDelta = sql.json(newProviders);
161
201
 
162
202
  // Atomic accumulate — no read-then-write race
163
203
  await sql`
@@ -173,6 +213,8 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
173
213
  'message_count', COALESCE((metadata->>'message_count')::int, 0) + 1,
174
214
  'models_used', (SELECT COALESCE(jsonb_agg(DISTINCT e), '[]'::jsonb)
175
215
  FROM jsonb_array_elements(COALESCE(metadata->'models_used', '[]'::jsonb) || ${modelsDelta}) AS e),
216
+ 'providers_used', (SELECT COALESCE(jsonb_agg(DISTINCT e), '[]'::jsonb)
217
+ FROM jsonb_array_elements(COALESCE(metadata->'providers_used', '[]'::jsonb) || ${providersDelta}) AS e),
176
218
  'channel', COALESCE(metadata->>'channel', ${(resultMeta.channel as string) || null})
177
219
  )
178
220
  WHERE id = ${id}