owlmem 0.9.0 → 0.9.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.
Files changed (2) hide show
  1. package/owlmem.mjs +75 -5
  2. package/package.json +1 -1
package/owlmem.mjs CHANGED
@@ -41,7 +41,7 @@ import { join, dirname, basename, relative, isAbsolute } from "node:path";
41
41
  import { fileURLToPath } from "node:url";
42
42
  import { spawn, spawnSync } from "node:child_process";
43
43
 
44
- export const VERSION = "0.9.0";
44
+ export const VERSION = "0.9.2";
45
45
  const BASE = (process.env.OWLMEM_URL || "https://owlmem.com").replace(/\/$/, "");
46
46
  const DIR = process.env.OWLMEM_HOME || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "owlmem");
47
47
  const enc = new TextEncoder(), dec = new TextDecoder();
@@ -1237,6 +1237,7 @@ export async function wakeLoop(o) {
1237
1237
  await sleep(backoff); continue;
1238
1238
  }
1239
1239
  backoff = 0;
1240
+ if (Array.isArray(r.data.agents)) cfg.live = r.data.agents; // which AIs the owner lets us read Claude quota for (read_quota)
1240
1241
  for (const t of r.data.tasks || []) {
1241
1242
  const plan = isReply(t) ? { kind: "scratch" } : o.plan ? o.plan(t) : planWorkdir(t); // a reply is read in an empty folder
1242
1243
  log((isReply(t) ? "Reply #" + t.id + " (task #" + t.task_id + ")" : jobLabel(t)) + " for " + t.agent + ": starting " + t.client + (plan.dir ? " in " + plan.dir : "") + ".");
@@ -1392,18 +1393,39 @@ const usageAdd = (days, d, u) => { const x = days[d] || (days[d] = { input: 0, o
1392
1393
  // One line of a transcript → [day, usage] or null. f keeps what a file needs between lines (Claude: message ids seen; Codex: last total).
1393
1394
  export function usageLine(client, line, f) {
1394
1395
  if (client === "claude-code") {
1395
- if (!line.includes('"usage"')) return null;
1396
+ const err = line.includes('"isApiErrorMessage":true');
1397
+ if (!err && !line.includes('"usage"')) return null;
1396
1398
  let j; try { j = JSON.parse(line); } catch { return null; }
1397
- const m = j?.message, u = m?.usage, d = usageDay(j?.timestamp);
1399
+ const m = j?.message, u = m?.usage, d = usageDay(j?.timestamp), at = Math.floor(Date.parse(j?.timestamp) / 1000) || 0;
1400
+ if (err) { // the app's own error message in the transcript: out of quota, or signed out
1401
+ const text = (Array.isArray(m?.content) ? m.content : []).map((b) => b?.text || "").join(" ");
1402
+ const kind = j.error === "rate_limit" || /usage limit|hit your limit|limit reached|rate.?limit/i.test(text) ? "limit" : j.error === "authentication_failed" || /authenticat|log ?in|oauth/i.test(text) ? "auth" : null;
1403
+ if (kind && at >= (f.st?.block_at || 0)) { const r = /\|(\d{10})\b/.exec(text); f.st = { ...f.st, block: kind, block_at: at, resets_at: r ? Number(r[1]) : undefined }; }
1404
+ return null;
1405
+ }
1398
1406
  if (j?.type !== "assistant" || !u || !d) return null;
1407
+ if (at > (f.st?.ok_at || 0)) f.st = { ...f.st, ok_at: at };
1399
1408
  const id = (m.id || "") + ":" + (j.requestId || ""); // Claude Code writes one line per content block, each with the message's usage
1400
1409
  f.ids = f.ids || [];
1401
1410
  if (m.id && f.ids.includes(id)) return null;
1402
1411
  if (m.id) { f.ids.push(id); if (f.ids.length > 200) f.ids.shift(); }
1403
1412
  return [d, { input: u.input_tokens, output: u.output_tokens, cache_read: u.cache_read_input_tokens, cache_write: u.cache_creation_input_tokens }];
1404
1413
  }
1405
- if (!line.includes('"token_count"')) return null;
1414
+ if (!line.includes('"token_count"') && !line.includes('"error"')) return null;
1406
1415
  let j; try { j = JSON.parse(line); } catch { return null; }
1416
+ const at = Math.floor(Date.parse(j?.timestamp) / 1000) || 0;
1417
+ if (j?.payload?.type === "error") {
1418
+ if (/usage limit|rate.?limit|quota/i.test(String(j.payload.message || "")) && at >= (f.st?.block_at || 0)) f.st = { ...f.st, block: "limit", block_at: at };
1419
+ return null;
1420
+ }
1421
+ const rl = j?.payload?.type === "token_count" ? j.payload.rate_limits : null;
1422
+ if (rl && at >= (f.st?.limit_at || 0)) { // Codex says how much of its plan's window is used and when it resets
1423
+ const w = [rl.primary, rl.secondary].filter((x) => x && typeof x.used_percent === "number").sort((a, b) => b.used_percent - a.used_percent)[0];
1424
+ const windows = [rl.primary, rl.secondary].filter((x) => x && typeof x.used_percent === "number")
1425
+ .map((x) => ({ minutes: x.window_minutes, used_percent: x.used_percent, resets_at: x.resets_at }));
1426
+ f.st = { ...f.st, limit_at: at, ok_at: at, used_percent: w?.used_percent, resets_at: w?.resets_at, window_minutes: w?.window_minutes, windows,
1427
+ ...(rl.rate_limit_reached_type ? { block: "limit", block_at: at } : {}) };
1428
+ }
1407
1429
  const info = j?.payload?.type === "token_count" ? j.payload.info : null, last = info?.last_token_usage, d = usageDay(j?.timestamp);
1408
1430
  if (!last || !d) return null;
1409
1431
  const total = info.total_token_usage?.total_tokens;
@@ -1451,14 +1473,62 @@ export function usageReport(state, clients, now = Date.now()) {
1451
1473
  }
1452
1474
  return out;
1453
1475
  }
1476
+ // The newest quota state per app: what its latest transcript says (Claude Code: an out-of-quota or signed-out error after
1477
+ // the last answer; Codex: percent of its plan's window used, when it resets).
1478
+ export function usageStatus(state, clients) {
1479
+ const out = {};
1480
+ for (const f of Object.values(state.files || {})) {
1481
+ if (!clients.includes(f.client) || !f.st) continue;
1482
+ const o = out[f.client] || (out[f.client] = {});
1483
+ if ((f.st.ok_at || 0) > (o.ok_at || 0)) o.ok_at = f.st.ok_at;
1484
+ if ((f.st.block_at || 0) > (o.block_at || 0)) Object.assign(o, { block: f.st.block, block_at: f.st.block_at, resets_at: f.st.resets_at ?? o.resets_at });
1485
+ if ((f.st.limit_at || 0) > (o.limit_at || 0)) Object.assign(o, { limit_at: f.st.limit_at, used_percent: f.st.used_percent, window_minutes: f.st.window_minutes, resets_at: f.st.resets_at, windows: f.st.windows });
1486
+ }
1487
+ return out;
1488
+ }
1489
+ // Claude Code's own quota (0.9.2, only when the AI's owner turned "Read Claude quota" on in the console): ask Anthropic's
1490
+ // usage endpoint with the sign-in Claude Code keeps on this computer, the same one its /usage screen uses. The sign-in is
1491
+ // only sent to api.anthropic.com and never leaves this computer otherwise; OwlMem gets the percentages and reset times.
1492
+ // An expired sign-in isn't refreshed here (Claude Code does that itself): no quota this round.
1493
+ export function claudeToken(h = home(), env = process.env, plat = process.platform, now = Date.now()) {
1494
+ let raw = null;
1495
+ try { raw = readFileSync(join(env.CLAUDE_CONFIG_DIR || join(h, ".claude"), ".credentials.json"), "utf8"); } catch {}
1496
+ if (!raw && plat === "darwin") {
1497
+ const r = spawnSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf8", timeout: 10000 });
1498
+ if (r.status === 0) raw = r.stdout;
1499
+ }
1500
+ let o; try { o = JSON.parse(raw || "null")?.claudeAiOauth; } catch { return null; }
1501
+ if (!o?.accessToken || (o.expiresAt && o.expiresAt < now + 60000)) return null;
1502
+ return o.accessToken;
1503
+ }
1504
+ export function claudeWindows(j) {
1505
+ const sec = (v) => { const t = Date.parse(v); return isNaN(t) ? undefined : Math.floor(t / 1000); };
1506
+ return [["five_hour", 300], ["seven_day", 10080]].filter(([k]) => typeof j?.[k]?.utilization === "number")
1507
+ .map(([k, minutes]) => ({ minutes, used_percent: j[k].utilization, resets_at: sec(j[k].resets_at) }));
1508
+ }
1509
+ async function claudeQuota(fetchImpl = fetch) {
1510
+ const tok = claudeToken();
1511
+ if (!tok) return null;
1512
+ const r = await fetchImpl("https://api.anthropic.com/api/oauth/usage", { headers: { Authorization: "Bearer " + tok, "anthropic-beta": "oauth-2025-04-20", "User-Agent": "owlmem-cli/" + VERSION },
1513
+ signal: AbortSignal.timeout(15000) });
1514
+ if (!r.ok) return null;
1515
+ const w = claudeWindows(await r.json());
1516
+ return w.length ? w : null;
1517
+ }
1454
1518
  async function usageTick(cfg, api, log, dir = DIR) {
1455
1519
  const file = join(wakeDir(dir), "usage.json");
1456
1520
  let state = {}; try { state = JSON.parse(readFileSync(file, "utf8")); } catch {}
1521
+ if (state.v !== 2) state = { v: 2 }; // 0.9.1 also reads quota state: read every file once more
1457
1522
  const clients = Object.keys(cfg.clients || {}).filter((c) => c in usageRoots());
1458
1523
  if (!clients.length) return;
1459
1524
  scanUsage(state, Object.fromEntries(Object.entries(usageRoots()).filter(([c]) => clients.includes(c))));
1460
1525
  try { writeFileSync(file + ".tmp", JSON.stringify(state)); renameSync(file + ".tmp", file); } catch (e) { log("usage: " + e.message); }
1461
- const r = await api("usage", { days: usageReport(state, clients) });
1526
+ const status = usageStatus(state, clients);
1527
+ if (clients.includes("claude-code") && (cfg.live || []).some((a) => a.client === "claude-code" && a.read_quota)) {
1528
+ const w = await claudeQuota().catch(() => null);
1529
+ if (w) status["claude-code"] = { ...status["claude-code"], windows: w, limit_at: Math.floor(Date.now() / 1000) };
1530
+ }
1531
+ const r = await api("usage", { days: usageReport(state, clients), status });
1462
1532
  if (r.status !== 200 && r.status !== 404) log("usage: OwlMem answered " + (r.data?.detail || "HTTP " + r.status));
1463
1533
  }
1464
1534
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "owlmem",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "OwlMem command line: connect your AI apps to OwlMem in one command (npx -y owlmem install), an end-to-end encrypted vault for your AI agents, and a local MCP server.",
5
5
  "type": "module",
6
6
  "bin": { "owlmem": "owlmem.mjs" },