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.
- package/owlmem.mjs +75 -5
- 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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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" },
|