owlmem 0.9.1 → 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 +41 -4
  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.1";
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 : "") + ".");
@@ -1420,7 +1421,9 @@ export function usageLine(client, line, f) {
1420
1421
  const rl = j?.payload?.type === "token_count" ? j.payload.rate_limits : null;
1421
1422
  if (rl && at >= (f.st?.limit_at || 0)) { // Codex says how much of its plan's window is used and when it resets
1422
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];
1423
- 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,
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,
1424
1427
  ...(rl.rate_limit_reached_type ? { block: "limit", block_at: at } : {}) };
1425
1428
  }
1426
1429
  const info = j?.payload?.type === "token_count" ? j.payload.info : null, last = info?.last_token_usage, d = usageDay(j?.timestamp);
@@ -1479,10 +1482,39 @@ export function usageStatus(state, clients) {
1479
1482
  const o = out[f.client] || (out[f.client] = {});
1480
1483
  if ((f.st.ok_at || 0) > (o.ok_at || 0)) o.ok_at = f.st.ok_at;
1481
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 });
1482
- 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 });
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 });
1483
1486
  }
1484
1487
  return out;
1485
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
+ }
1486
1518
  async function usageTick(cfg, api, log, dir = DIR) {
1487
1519
  const file = join(wakeDir(dir), "usage.json");
1488
1520
  let state = {}; try { state = JSON.parse(readFileSync(file, "utf8")); } catch {}
@@ -1491,7 +1523,12 @@ async function usageTick(cfg, api, log, dir = DIR) {
1491
1523
  if (!clients.length) return;
1492
1524
  scanUsage(state, Object.fromEntries(Object.entries(usageRoots()).filter(([c]) => clients.includes(c))));
1493
1525
  try { writeFileSync(file + ".tmp", JSON.stringify(state)); renameSync(file + ".tmp", file); } catch (e) { log("usage: " + e.message); }
1494
- const r = await api("usage", { days: usageReport(state, clients), status: usageStatus(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 });
1495
1532
  if (r.status !== 200 && r.status !== 404) log("usage: OwlMem answered " + (r.data?.detail || "HTTP " + r.status));
1496
1533
  }
1497
1534
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "owlmem",
3
- "version": "0.9.1",
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" },