owlmem 0.9.1 → 0.9.3

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 (3) hide show
  1. package/README.md +1 -1
  2. package/owlmem.mjs +86 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -29,7 +29,7 @@ page, where you sign in and click **Allow**. AI agents can read the setup guide
29
29
 
30
30
  An AI on your computer normally sees a task only when someone opens it. Auto-wake starts it as soon as it gets one:
31
31
  a small background program long-polls OwlMem and runs that AI's command line unattended — Claude Code with
32
- `claude -p`, Codex with `codex exec`, Antigravity with `agy -p`, Hermes with `hermes -z` (for Codex and Antigravity, which refuse tool calls when nobody is watching, auto-wake approves just the two OwlMem tools used to report back: in `~/.codex/config.toml` and `~/.gemini/antigravity-cli/settings.json`; for Hermes, only if OwlMem is marked `trust: untrusted`, under `command_allowlist` in `~/.hermes/config.yaml`) — with the task as the prompt. The AI marks the task done or declined as usual. When a task it gave out is done or declined, it is woken too, to read the result. The program updates itself (from npm, when nothing is running).
32
+ `claude -p`, Codex with `codex exec`, Antigravity with `agy -p`, Hermes with `hermes -z`, DeepSeek Harness with `dsh --profile headless` (for Codex and Antigravity, which refuse tool calls when nobody is watching, auto-wake approves just the two OwlMem tools used to report back: in `~/.codex/config.toml` and `~/.gemini/antigravity-cli/settings.json`; for Hermes, only if OwlMem is marked `trust: untrusted`, under `command_allowlist` in `~/.hermes/config.yaml`; for DeepSeek Harness, its headless profile gets the model and the OwlMem server copied from the web profile, and its credential store turned back on, in `~/.dsh/profiles/headless/cordis.patch.yml`) — with the task as the prompt. The AI marks the task done or declined as usual. When a task it gave out is done or declined, it is woken too, to read the result. The program updates itself (from npm, when nothing is running).
33
33
 
34
34
  When someone turns auto-wake on for an AI in the console and no computer runs it yet, that AI is told so the next time it connects:
35
35
  with your OK it runs `npx -y owlmem wake enable` itself and sends you the pairing link to approve.
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.3";
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();
@@ -817,8 +817,11 @@ export const WAKE_CLIENTS = {
817
817
  // Its installer puts hermes under ~/.hermes/hermes-agent or ~/.local/bin; a system-wide service install under /var/lib/hermes
818
818
  hermes: { name: "Hermes", bin: "hermes", command: ["hermes", "-z"], prompt: "arg",
819
819
  home: [[".hermes", "hermes-agent", ".hermes", "bin", "hermes"], [".local", "bin", "hermes"], "/var/lib/hermes/hermes-agent/.hermes/bin/hermes"] },
820
+ // DeepSeek Harness: the headless profile answers one task (from stdin) and exits. Its own profile needs OwlMem and a model,
821
+ // copied from the web profile (see allowDsh). On NixOS dsh must run on the official Node build: use the dsh wrapper found here.
822
+ dsh: { name: "DeepSeek Harness", bin: "dsh", command: ["dsh", "--profile", "headless"], prompt: "stdin", home: [[".local", "bin", "dsh"]] },
820
823
  };
821
- export const WAKE_APP_IDS = ["claude-code", "codex", "antigravity", "hermes", "gemini", "custom"];
824
+ export const WAKE_APP_IDS = ["claude-code", "codex", "antigravity", "hermes", "dsh", "gemini", "custom"];
822
825
  // Where an app's command is: on PATH, or the first of its default install places that exists (path parts under your home
823
826
  // folder, or an absolute path), used by full path
824
827
  export function wakeBin(c, home_ = home(), exists = existsSync, path = onPath) {
@@ -854,6 +857,40 @@ export function allowAntigravity(home_ = home()) {
854
857
  writeFileSync(f, JSON.stringify(j, null, 2) + "\n");
855
858
  return true;
856
859
  }
860
+ // DeepSeek Harness keeps each profile under ~/.dsh/profiles/<name>/, configured by a patch list (cordis.patch.yml). The headless
861
+ // profile that auto-wake runs starts empty: copy over from the web profile what it needs to work for OwlMem — the default model
862
+ // and its provider (agent-default-model, llm-pi-ai) and the OwlMem MCP server — and turn its credential store back on (headless
863
+ // turns it off, but the web profile keeps the provider's key there). Adds entries only; the file is backed up first.
864
+ // Returns the names of what was added; [] when nothing was missing; "init" when the headless profile isn't there yet (run
865
+ // `dsh --profile headless --help` once); "noweb" when OwlMem isn't in the web profile either, so there's nothing to copy.
866
+ export function allowDsh(home_ = home(), env = process.env, now = new Date()) {
867
+ const dir = join(env.DSH_HOME || join(home_, ".dsh"), "profiles"), hf = join(dir, "headless", "cordis.patch.yml");
868
+ if (!existsSync(join(dir, "headless"))) return "init";
869
+ let h = ""; try { h = readFileSync(hf, "utf8"); } catch { /* an empty profile has no patch file yet */ }
870
+ let w = ""; try { w = readFileSync(join(dir, "web", "cordis.patch.yml"), "utf8"); } catch { /* no web profile */ }
871
+ // the top-level entries of a patch list: each starts with "- " at column 0 and runs to the next one
872
+ const items = (t) => t.split(/\n(?=- )/).map((x) => x.replace(/^[\s\S]*?(?=^- )/m, "")).filter((x) => /^- /.test(x));
873
+ const has = (t, rx) => items(t).some((x) => rx.test(x));
874
+ const want = [
875
+ ["model", /^- id: agent-default-model\b/m], ["provider", /^- id: llm-pi-ai\b/m], ["owlmem", /serverName:\s*["']?owlmem\b/],
876
+ ];
877
+ const add = [], names = [];
878
+ for (const [name, rx] of want) {
879
+ if (has(h, rx)) continue;
880
+ const from = items(w).find((x) => rx.test(x));
881
+ if (from) { add.push(from.replace(/\s+$/, "")); names.push(name); }
882
+ }
883
+ if (!has(h, /serverName:\s*["']?owlmem\b/) && !names.includes("owlmem")) return "noweb";
884
+ if (!/^- id: credentials\s*\n\s+disabled:\s*false\b/m.test(h)) {
885
+ add.push("# headless turns the credential store off; the web profile keeps the model's key there\n- id: credentials\n disabled: false");
886
+ names.push("credentials");
887
+ }
888
+ if (!add.length) return [];
889
+ if (existsSync(hf)) copyFileSync(hf, hf + ".bak-" + now.toISOString().slice(0, 10).replace(/-/g, ""));
890
+ const base = h.replace(/^\[\]\s*$/m, "").replace(/\s*$/, "");
891
+ writeFileSync(hf, (base ? base + "\n" : "") + "# Added by OwlMem auto-wake (owlmem wake enable): what the headless profile needs to answer OwlMem tasks\n" + add.join("\n") + "\n");
892
+ return names;
893
+ }
857
894
  // Hermes calls MCP tools without asking unless the server is marked `trust: untrusted` (or an unknown value) under mcp_servers in
858
895
  // ~/.hermes/config.yaml; then unattended runs refuse its write tools. Only in that case, add WAKE_TOOLS
859
896
  // to the top-level command_allowlist as mcp:<server>/<tool> (what Hermes itself writes for "Always"). Adds lines only,
@@ -1237,6 +1274,7 @@ export async function wakeLoop(o) {
1237
1274
  await sleep(backoff); continue;
1238
1275
  }
1239
1276
  backoff = 0;
1277
+ if (Array.isArray(r.data.agents)) cfg.live = r.data.agents; // which AIs the owner lets us read Claude quota for (read_quota)
1240
1278
  for (const t of r.data.tasks || []) {
1241
1279
  const plan = isReply(t) ? { kind: "scratch" } : o.plan ? o.plan(t) : planWorkdir(t); // a reply is read in an empty folder
1242
1280
  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 +1458,9 @@ export function usageLine(client, line, f) {
1420
1458
  const rl = j?.payload?.type === "token_count" ? j.payload.rate_limits : null;
1421
1459
  if (rl && at >= (f.st?.limit_at || 0)) { // Codex says how much of its plan's window is used and when it resets
1422
1460
  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,
1461
+ const windows = [rl.primary, rl.secondary].filter((x) => x && typeof x.used_percent === "number")
1462
+ .map((x) => ({ minutes: x.window_minutes, used_percent: x.used_percent, resets_at: x.resets_at }));
1463
+ 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
1464
  ...(rl.rate_limit_reached_type ? { block: "limit", block_at: at } : {}) };
1425
1465
  }
1426
1466
  const info = j?.payload?.type === "token_count" ? j.payload.info : null, last = info?.last_token_usage, d = usageDay(j?.timestamp);
@@ -1479,10 +1519,39 @@ export function usageStatus(state, clients) {
1479
1519
  const o = out[f.client] || (out[f.client] = {});
1480
1520
  if ((f.st.ok_at || 0) > (o.ok_at || 0)) o.ok_at = f.st.ok_at;
1481
1521
  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 });
1522
+ 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
1523
  }
1484
1524
  return out;
1485
1525
  }
1526
+ // 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
1527
+ // usage endpoint with the sign-in Claude Code keeps on this computer, the same one its /usage screen uses. The sign-in is
1528
+ // only sent to api.anthropic.com and never leaves this computer otherwise; OwlMem gets the percentages and reset times.
1529
+ // An expired sign-in isn't refreshed here (Claude Code does that itself): no quota this round.
1530
+ export function claudeToken(h = home(), env = process.env, plat = process.platform, now = Date.now()) {
1531
+ let raw = null;
1532
+ try { raw = readFileSync(join(env.CLAUDE_CONFIG_DIR || join(h, ".claude"), ".credentials.json"), "utf8"); } catch {}
1533
+ if (!raw && plat === "darwin") {
1534
+ const r = spawnSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf8", timeout: 10000 });
1535
+ if (r.status === 0) raw = r.stdout;
1536
+ }
1537
+ let o; try { o = JSON.parse(raw || "null")?.claudeAiOauth; } catch { return null; }
1538
+ if (!o?.accessToken || (o.expiresAt && o.expiresAt < now + 60000)) return null;
1539
+ return o.accessToken;
1540
+ }
1541
+ export function claudeWindows(j) {
1542
+ const sec = (v) => { const t = Date.parse(v); return isNaN(t) ? undefined : Math.floor(t / 1000); };
1543
+ return [["five_hour", 300], ["seven_day", 10080]].filter(([k]) => typeof j?.[k]?.utilization === "number")
1544
+ .map(([k, minutes]) => ({ minutes, used_percent: j[k].utilization, resets_at: sec(j[k].resets_at) }));
1545
+ }
1546
+ async function claudeQuota(fetchImpl = fetch) {
1547
+ const tok = claudeToken();
1548
+ if (!tok) return null;
1549
+ 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 },
1550
+ signal: AbortSignal.timeout(15000) });
1551
+ if (!r.ok) return null;
1552
+ const w = claudeWindows(await r.json());
1553
+ return w.length ? w : null;
1554
+ }
1486
1555
  async function usageTick(cfg, api, log, dir = DIR) {
1487
1556
  const file = join(wakeDir(dir), "usage.json");
1488
1557
  let state = {}; try { state = JSON.parse(readFileSync(file, "utf8")); } catch {}
@@ -1491,7 +1560,12 @@ async function usageTick(cfg, api, log, dir = DIR) {
1491
1560
  if (!clients.length) return;
1492
1561
  scanUsage(state, Object.fromEntries(Object.entries(usageRoots()).filter(([c]) => clients.includes(c))));
1493
1562
  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) });
1563
+ const status = usageStatus(state, clients);
1564
+ if (clients.includes("claude-code") && (cfg.live || []).some((a) => a.client === "claude-code" && a.read_quota)) {
1565
+ const w = await claudeQuota().catch(() => null);
1566
+ if (w) status["claude-code"] = { ...status["claude-code"], windows: w, limit_at: Math.floor(Date.now() / 1000) };
1567
+ }
1568
+ const r = await api("usage", { days: usageReport(state, clients), status });
1495
1569
  if (r.status !== 200 && r.status !== 404) log("usage: OwlMem answered " + (r.data?.detail || "HTTP " + r.status));
1496
1570
  }
1497
1571
 
@@ -1539,11 +1613,17 @@ async function wakeEnable(args = []) {
1539
1613
  }
1540
1614
  if (!Object.keys(clients).length) {
1541
1615
  const told = await wakeNoClients(wakeApi()).catch(() => false);
1542
- throw new Fail("No AI command line found on this computer that auto-wake can start (Claude Code: claude, Codex: codex, Antigravity: agy, Hermes: hermes). Install one, or add its command to " + wakeFile() + " under \"clients\"." +
1616
+ throw new Fail("No AI command line found on this computer that auto-wake can start (Claude Code: claude, Codex: codex, Antigravity: agy, Hermes: hermes, DeepSeek Harness: dsh). Install one, or add its command to " + wakeFile() + " under \"clients\"." +
1543
1617
  (told ? "\nOwlMem support has been told; you'll hear from us once your AI app is supported." : ""));
1544
1618
  }
1545
1619
  if (clients.codex && allowCodex()) say("Let Codex report back to OwlMem when nobody is watching (tool approvals in ~/.codex/config.toml).");
1546
1620
  if (clients.antigravity && allowAntigravity()) say("Let Antigravity's command line report back to OwlMem when nobody is watching (rules in ~/.gemini/antigravity-cli/settings.json).");
1621
+ if (clients.dsh) {
1622
+ const d = allowDsh();
1623
+ if (d === "init") say("DeepSeek Harness: run `dsh --profile headless --help` once to create its headless profile, then run this again.");
1624
+ else if (d === "noweb") say("DeepSeek Harness: OwlMem isn't set up in its web profile, so the headless profile can't be set up from it. Connect OwlMem in DeepSeek Harness first, then run this again.");
1625
+ else if (d.length) say("Set up DeepSeek Harness's headless profile for auto-wake (" + d.join(", ") + " in ~/.dsh/profiles/headless/cordis.patch.yml, copied from the web profile; the old file is backed up next to it).");
1626
+ }
1547
1627
  if (clients.hermes) {
1548
1628
  const h = allowHermes();
1549
1629
  if (h === true) say("Let Hermes report back to OwlMem when nobody is watching (entries under command_allowlist in ~/.hermes/config.yaml).");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "owlmem",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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" },