owlmem 0.8.0 → 0.9.0

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 +98 -2
  2. package/package.json +1 -1
package/owlmem.mjs CHANGED
@@ -34,14 +34,14 @@
34
34
  // device key there, so standing grants don't apply. Viewing, writing, deleting and exporting always need approval.
35
35
 
36
36
  import { webcrypto as crypto, createHash } from "node:crypto";
37
- import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync, rmSync, statSync, realpathSync, openSync, closeSync, appendFileSync, renameSync } from "node:fs";
37
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync, rmSync, statSync, realpathSync, openSync, closeSync, appendFileSync, renameSync, readdirSync, readSync } from "node:fs";
38
38
  import { gunzipSync } from "node:zlib";
39
39
  import { homedir, hostname, userInfo } from "node:os";
40
40
  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.8.0";
44
+ export const VERSION = "0.9.0";
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();
@@ -1368,6 +1368,100 @@ export function wakeTools(cfg, home_ = home(), env = process.env) {
1368
1368
  return changed;
1369
1369
  }
1370
1370
 
1371
+ // ── token usage (0.9.0) ──
1372
+ // An AI can't see how many tokens it used, but its app records them on this computer. Auto-wake adds them up per app and
1373
+ // UTC day and tells OwlMem every USAGE_EVERY, so every agent in the space can see who is busy (kb_team). Read: Claude Code's
1374
+ // transcripts (~/.claude/projects/**/*.jsonl, message.usage, each message once) and Codex's sessions (~/.codex/sessions/**/
1375
+ // *.jsonl, token_count events). Only the numbers leave the computer — no text, no paths. Files are read from where the last
1376
+ // scan stopped (usage.json in the wake folder), so a scan only reads what's new.
1377
+ export const USAGE_EVERY = 10 * 60 * 1000, USAGE_DAYS = 8, USAGE_KEEP = 35;
1378
+ const usageDay = (ts) => { const d = new Date(ts); return isNaN(d) ? null : d.toISOString().slice(0, 10); };
1379
+ export function usageRoots(h = home(), env = process.env) {
1380
+ return { "claude-code": join(env.CLAUDE_CONFIG_DIR || join(h, ".claude"), "projects"), codex: join(env.CODEX_HOME || join(h, ".codex"), "sessions") };
1381
+ }
1382
+ function jsonlFiles(dir, since, out = []) {
1383
+ let ents = []; try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
1384
+ for (const e of ents) {
1385
+ const p = join(dir, e.name);
1386
+ if (e.isDirectory()) jsonlFiles(p, since, out);
1387
+ else if (e.name.endsWith(".jsonl")) { try { const st = statSync(p); if (st.mtimeMs >= since) out.push([p, st.size]); } catch {} }
1388
+ }
1389
+ return out;
1390
+ }
1391
+ const usageAdd = (days, d, u) => { const x = days[d] || (days[d] = { input: 0, output: 0, cache_read: 0, cache_write: 0 }); for (const k in x) x[k] += Math.max(0, Number(u[k]) || 0); };
1392
+ // 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
+ export function usageLine(client, line, f) {
1394
+ if (client === "claude-code") {
1395
+ if (!line.includes('"usage"')) return null;
1396
+ let j; try { j = JSON.parse(line); } catch { return null; }
1397
+ const m = j?.message, u = m?.usage, d = usageDay(j?.timestamp);
1398
+ if (j?.type !== "assistant" || !u || !d) return null;
1399
+ const id = (m.id || "") + ":" + (j.requestId || ""); // Claude Code writes one line per content block, each with the message's usage
1400
+ f.ids = f.ids || [];
1401
+ if (m.id && f.ids.includes(id)) return null;
1402
+ if (m.id) { f.ids.push(id); if (f.ids.length > 200) f.ids.shift(); }
1403
+ 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
+ }
1405
+ if (!line.includes('"token_count"')) return null;
1406
+ let j; try { j = JSON.parse(line); } catch { return null; }
1407
+ const info = j?.payload?.type === "token_count" ? j.payload.info : null, last = info?.last_token_usage, d = usageDay(j?.timestamp);
1408
+ if (!last || !d) return null;
1409
+ const total = info.total_token_usage?.total_tokens;
1410
+ if (total != null && total === f.total) return null; // Codex repeats the event when nothing new was used
1411
+ f.total = total;
1412
+ const cached = Number(last.cached_input_tokens) || 0;
1413
+ return [d, { input: (Number(last.input_tokens) || 0) - cached, output: last.output_tokens, cache_read: cached, cache_write: last.cache_write_input_tokens }];
1414
+ }
1415
+ export function scanUsage(state, roots = usageRoots(), now = Date.now()) {
1416
+ state.files = state.files || {};
1417
+ const since = now - USAGE_KEEP * 86400000;
1418
+ for (const [client, dir] of Object.entries(roots)) {
1419
+ for (const [p, size] of jsonlFiles(dir, since)) {
1420
+ let f = state.files[p];
1421
+ if (!f || f.client !== client || size < f.off) f = state.files[p] = { client, off: 0, days: {} };
1422
+ if (size === f.off) continue;
1423
+ let fd; try { fd = openSync(p, "r"); } catch { continue; }
1424
+ try {
1425
+ const buf = Buffer.alloc(1 << 20); let rest = "", pos = f.off;
1426
+ for (;;) {
1427
+ const n = readSync(fd, buf, 0, buf.length, pos); if (n <= 0) break;
1428
+ pos += n;
1429
+ const text = rest + buf.toString("utf8", 0, n), cut = text.lastIndexOf("\n");
1430
+ if (cut < 0) { rest = text; continue; }
1431
+ for (const l of text.slice(0, cut).split("\n")) { const r = usageLine(client, l, f); if (r) usageAdd(f.days, r[0], r[1]); }
1432
+ rest = text.slice(cut + 1);
1433
+ }
1434
+ f.off = pos - Buffer.byteLength(rest); // an unfinished last line is read again next time
1435
+ } finally { closeSync(fd); }
1436
+ }
1437
+ }
1438
+ const lo = usageDay(since);
1439
+ for (const [p, f] of Object.entries(state.files)) {
1440
+ for (const d of Object.keys(f.days)) if (d < lo) delete f.days[d];
1441
+ if (!Object.keys(f.days).length && !existsSync(p)) delete state.files[p];
1442
+ }
1443
+ return state;
1444
+ }
1445
+ // The last USAGE_DAYS days, per day and app, for the apps auto-wake runs here: {"2026-10-30": {"claude-code": {input, …}}}.
1446
+ export function usageReport(state, clients, now = Date.now()) {
1447
+ const lo = usageDay(now - (USAGE_DAYS - 1) * 86400000), out = {};
1448
+ for (const f of Object.values(state.files || {})) {
1449
+ if (!clients.includes(f.client)) continue;
1450
+ for (const [d, u] of Object.entries(f.days)) if (d >= lo) usageAdd(out[d] || (out[d] = {}), f.client, u);
1451
+ }
1452
+ return out;
1453
+ }
1454
+ async function usageTick(cfg, api, log, dir = DIR) {
1455
+ const file = join(wakeDir(dir), "usage.json");
1456
+ let state = {}; try { state = JSON.parse(readFileSync(file, "utf8")); } catch {}
1457
+ const clients = Object.keys(cfg.clients || {}).filter((c) => c in usageRoots());
1458
+ if (!clients.length) return;
1459
+ scanUsage(state, Object.fromEntries(Object.entries(usageRoots()).filter(([c]) => clients.includes(c))));
1460
+ 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) });
1462
+ if (r.status !== 200 && r.status !== 404) log("usage: OwlMem answered " + (r.data?.detail || "HTTP " + r.status));
1463
+ }
1464
+
1371
1465
  async function wakeRun() {
1372
1466
  const cfg = loadWake();
1373
1467
  if (!cfg) throw new Fail("Auto-wake isn't set up on this computer. Run: npx -y owlmem wake enable");
@@ -1383,6 +1477,8 @@ async function wakeRun() {
1383
1477
  };
1384
1478
  const sched = new Scheduler({ max: cfg.max_parallel || WAKE_DEFAULTS.max_parallel, start: (j) => runTask(j.task, cfg, { plan: j.plan }), onDone: (j, res) => { report(j, res); } });
1385
1479
  log("OwlMem auto-wake " + VERSION + " running on " + (cfg.machine || hostname()) + ".");
1480
+ const usage = () => usageTick(cfg, api, log).catch((e) => log("usage: " + e.message));
1481
+ setTimeout(usage, 30000).unref(); setInterval(usage, USAGE_EVERY).unref();
1386
1482
  const out = await wakeLoop({ cfg, api, sched, log, update: (v) => wakeUpdate(v, { log }),
1387
1483
  cleanup: () => { try { wakeUninstall(autostartOpts(), wakeRunCmd, true); } catch (e) { log("cleanup: " + e.message); } } });
1388
1484
  if (out === "revoked") process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "owlmem",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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" },