@bobfrankston/rmfmail 1.2.251 → 1.2.252

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.
@@ -103,6 +103,7 @@ import { Store } from "@bobfrankston/mailx-store";
103
103
  import { SyncQueue } from "./sync-queue.js";
104
104
  import { Reconciler } from "./reconciler.js";
105
105
  import { spawnDbWorker, type SpawnedDbWorker } from "./db-worker-client.js";
106
+ import { priceCall, recordAiUsage, summarizeAiUsage, type AiUsageSummary } from "./ai-usage.js";
106
107
  export { spawnSyncWorker, type SpawnedSyncWorker } from "./sync-worker-client.js";
107
108
  import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, loadKeys, saveKeys, ensureKeysSectionExists, getStorePath, getStorageInfo, getConfigDir, loadUserDict, saveUserDict, loadReminderState, saveReminderState, cloudReadBinary, providerForDomain } from "@bobfrankston/mailx-settings";
108
109
  import type { AccountConfig, Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings, AiTransformRequest, AiTransformResponse, ExtractedEvent, MailxApi } from "@bobfrankston/mailx-types";
@@ -1095,6 +1096,14 @@ export class MailxService implements MailxApi {
1095
1096
  return { ok, status: displayStatus, statusText };
1096
1097
  }
1097
1098
 
1099
+ /** AI spend since `sinceMs` (default: start of this month). Reads the
1100
+ * per-call log written by aiTransform. `unpricedCalls` are excluded from
1101
+ * `costUsd` — a model with no published price contributes tokens, not a
1102
+ * made-up dollar figure. */
1103
+ getAiUsage(sinceMs?: number): AiUsageSummary {
1104
+ return summarizeAiUsage(sinceMs);
1105
+ }
1106
+
1098
1107
  /** Fetch a remote image so the WebView can put it on the clipboard.
1099
1108
  *
1100
1109
  * The client cannot do this itself: an image hosted by a sender is
@@ -4532,6 +4541,8 @@ Resolve relative dates ("tomorrow", "next Friday") against today's date. If no y
4532
4541
  // provider-call site so the three branches stay close together.
4533
4542
  let rawText = "";
4534
4543
  let reason = "";
4544
+ // Token usage for cost accounting, filled in by whichever branch runs.
4545
+ let usage: { input: number; output: number; model: string } | null = null;
4535
4546
  try {
4536
4547
  if (cfg.provider === "ollama") {
4537
4548
  const res = await fetch(`${cfg.ollamaUrl}/api/generate`, {
@@ -4547,6 +4558,9 @@ Resolve relative dates ("tomorrow", "next Friday") against today's date. If no y
4547
4558
  if (!res.ok) return { text: "", reason: `ollama ${res.status}` };
4548
4559
  const data = await res.json() as any;
4549
4560
  rawText = (data.response || "").trim();
4561
+ // Local model — free, but still counted so "$0.00" is measured
4562
+ // rather than merely absent.
4563
+ usage = { input: data.prompt_eval_count || 0, output: data.eval_count || 0, model: cfg.ollamaModel };
4550
4564
  } else {
4551
4565
  // Cloud providers: read API keys from accounts.jsonc,
4552
4566
  // fall back to the legacy preferences.cloudApiKey field.
@@ -4571,6 +4585,11 @@ Resolve relative dates ("tomorrow", "next Friday") against today's date. If no y
4571
4585
  if (!res.ok) return { text: "", reason: `claude ${res.status}` };
4572
4586
  const data = await res.json() as any;
4573
4587
  rawText = (data.content?.[0]?.text || "").trim();
4588
+ usage = {
4589
+ input: data.usage?.input_tokens || 0,
4590
+ output: data.usage?.output_tokens || 0,
4591
+ model: data.model || cfg.cloudModel,
4592
+ };
4574
4593
  } else if (cfg.provider === "openai") {
4575
4594
  const apiKey = aiKeys.openai || cfg.cloudApiKey;
4576
4595
  if (!apiKey) return { text: "", reason: "no OpenAI API key in accounts.jsonc keys" };
@@ -4592,13 +4611,58 @@ Resolve relative dates ("tomorrow", "next Friday") against today's date. If no y
4592
4611
  if (!res.ok) return { text: "", reason: `openai ${res.status}` };
4593
4612
  const data = await res.json() as any;
4594
4613
  rawText = (data.choices?.[0]?.message?.content || "").trim();
4614
+ usage = {
4615
+ input: data.usage?.prompt_tokens || 0,
4616
+ output: data.usage?.completion_tokens || 0,
4617
+ model: data.model || cfg.cloudModel,
4618
+ };
4595
4619
  } else {
4596
4620
  return { text: "", reason: "no provider matched" };
4597
4621
  }
4598
4622
  }
4599
4623
  } catch (e: any) {
4600
- console.error(` [aiTransform] ${cfg.provider} ${req.action} error: ${e.message}`);
4601
- return { text: "", reason: e.message };
4624
+ // Node's fetch throws a bare `TypeError: fetch failed` and buries
4625
+ // the actual cause (ECONNREFUSED, ENOTFOUND, cert error) in
4626
+ // `e.cause`. Reporting only the message told Bob nothing — the
4627
+ // real answer was "Ollama isn't running" (2026-08-11). Unwrap it,
4628
+ // and name the endpoint we couldn't reach.
4629
+ const cause = e?.cause?.code || e?.cause?.message || "";
4630
+ const where = cfg.provider === "ollama" ? cfg.ollamaUrl
4631
+ : cfg.provider === "claude" ? "api.anthropic.com"
4632
+ : cfg.provider === "openai" ? "api.openai.com" : cfg.provider;
4633
+ const detail = cause ? `${e.message} (${cause})` : e.message;
4634
+ const reasonText = /fetch failed/i.test(e?.message || "")
4635
+ ? `can't reach ${cfg.provider} at ${where} — ${cause || "connection failed"}`
4636
+ : detail;
4637
+ console.error(` [aiTransform] ${cfg.provider} ${req.action} error: ${detail} [${where}]`);
4638
+ return { text: "", reason: reasonText };
4639
+ }
4640
+
4641
+ // Cost accounting. Bob asked to monitor AI spend (2026-08-11); nothing
4642
+ // tracked it before. Recorded per call to ~/.rmfmail/logs/ai-usage.jsonl,
4643
+ // with the month-to-date total on the log line so spend is visible
4644
+ // without opening anything. A local model records $0, not "no data";
4645
+ // a model with no published price records tokens and a null cost
4646
+ // rather than a guessed figure.
4647
+ if (usage) {
4648
+ const costUsd = priceCall(cfg.provider, usage.model, usage.input, usage.output);
4649
+ recordAiUsage({
4650
+ ts: Date.now(),
4651
+ provider: cfg.provider,
4652
+ model: usage.model,
4653
+ action: req.action,
4654
+ inputTokens: usage.input,
4655
+ outputTokens: usage.output,
4656
+ costUsd,
4657
+ });
4658
+ const mtd = summarizeAiUsage();
4659
+ const costText = costUsd == null
4660
+ ? "cost unknown (no published price for this model)"
4661
+ : `$${costUsd.toFixed(4)}`;
4662
+ const mtdText = mtd.unpricedCalls > 0
4663
+ ? `$${mtd.costUsd.toFixed(2)} + ${mtd.unpricedCalls} unpriced call(s)`
4664
+ : `$${mtd.costUsd.toFixed(2)}`;
4665
+ console.log(` [ai-usage] ${cfg.provider}/${usage.model} ${req.action}: ${usage.input} in + ${usage.output} out = ${costText}; month-to-date ${mtdText} over ${mtd.calls} call(s)`);
4602
4666
  }
4603
4667
 
4604
4668
  if (req.action !== "extractEvent") return { text: rawText, reason };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-service",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",