owlmem 0.7.3 → 0.7.7
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/README.md +11 -8
- package/owlmem.mjs +219 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,7 +29,10 @@ 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` (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`) — with the task as the prompt. The AI marks the task done or declined as usual.
|
|
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).
|
|
33
|
+
|
|
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
|
+
with your OK it runs `npx -y owlmem wake enable` itself and sends you the pairing link to approve.
|
|
33
36
|
|
|
34
37
|
```sh
|
|
35
38
|
npx -y owlmem install --wake # or answer "y" when install asks; --yes alone leaves it off
|
|
@@ -71,13 +74,13 @@ npx owlmem vault import bitwarden_export.json # also 1Password / KeePass CSV,
|
|
|
71
74
|
Vault items are encrypted on your machine with a key only you hold; OwlMem stores ciphertext it cannot read.
|
|
72
75
|
Every read or write needs the space owner's approval in the console (passkey, authenticator app or GitHub).
|
|
73
76
|
|
|
74
|
-
For unattended jobs (scheduled tasks, overnight deploys) the owner can
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
If an agent without access asks for an item, the answer names up to three agents in the space with a standing grant
|
|
77
|
+
For unattended jobs (scheduled tasks, overnight deploys) the owner can turn on an agent's **standing grant**: in the console
|
|
78
|
+
(Agents, one switch per agent), or while approving one of its requests. The agent can then use any vault item without asking,
|
|
79
|
+
on computers the owner approved (a new computer asks once; approving remembers it), and only through `owlmem run` / `vault_run`,
|
|
80
|
+
so the secret goes into a command. The program signs every vault request with a device key kept next to its config, so the same
|
|
81
|
+
token on another machine gets nothing (`npx owlmem device` prints this machine's fingerprint). Viewing a secret, saving, deleting
|
|
82
|
+
and exporting always need approval.
|
|
83
|
+
If an agent without access asks for an item, the answer names up to three agents in the space with a standing grant,
|
|
81
84
|
so it can hand the job to one of them (`kb_send` with `kind` `task`): that agent runs it with `vault_run` and sends back only
|
|
82
85
|
the result. Secrets themselves never go into messages, documents or memory — OwlMem refuses text that looks like a key.
|
|
83
86
|
|
package/owlmem.mjs
CHANGED
|
@@ -19,27 +19,29 @@
|
|
|
19
19
|
// owlmem vault export [--format bitwarden-json|keepass-csv|env] > file
|
|
20
20
|
// owlmem run --env VAR=<name>[:field] [...] -- <command> [args] run a command with secrets in its environment
|
|
21
21
|
// owlmem vault keygen (setup without a browser) print a new recovery code and its key check
|
|
22
|
-
// owlmem device
|
|
22
|
+
// owlmem device show this machine's fingerprint (standing grants are bound to machines)
|
|
23
23
|
// owlmem mcp local MCP server for your AI (vault_list, vault_run, vault_get, vault_put)
|
|
24
24
|
// e.g. claude mcp add owlmem-vault -- node ~/owlmem.mjs mcp
|
|
25
25
|
//
|
|
26
26
|
// Reading, writing, deleting and exporting need the space owner's approval in the console; this program waits
|
|
27
27
|
// for it (up to 5 minutes). Secrets go to stdout only; messages go to stderr.
|
|
28
28
|
//
|
|
29
|
-
// Standing grants (for unattended jobs): the owner
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
import {
|
|
29
|
+
// Standing grants (for unattended jobs): the owner turns one on for an agent in the console (Agents), or while approving
|
|
30
|
+
// one of its requests. The agent can then use any vault item without asking, on computers the owner approved (a new
|
|
31
|
+
// computer asks once; approving remembers it), and only through `owlmem run` / vault_run. This program signs every vault
|
|
32
|
+
// request with a device key kept on this machine (device.json next to config.json), and says whether the secret goes into
|
|
33
|
+
// a command (`owlmem run`, vault_run) or is shown (`vault get`, vault_get). A token copied to another machine has no
|
|
34
|
+
// device key there, so standing grants don't apply. Viewing, writing, deleting and exporting always need approval.
|
|
35
|
+
|
|
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";
|
|
38
|
+
import { gunzipSync } from "node:zlib";
|
|
37
39
|
import { homedir, hostname, userInfo } from "node:os";
|
|
38
40
|
import { join, dirname, basename, relative, isAbsolute } from "node:path";
|
|
39
41
|
import { fileURLToPath } from "node:url";
|
|
40
42
|
import { spawn, spawnSync } from "node:child_process";
|
|
41
43
|
|
|
42
|
-
export const VERSION = "0.7.
|
|
44
|
+
export const VERSION = "0.7.7";
|
|
43
45
|
const BASE = (process.env.OWLMEM_URL || "https://owlmem.com").replace(/\/$/, "");
|
|
44
46
|
const DIR = process.env.OWLMEM_HOME || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "owlmem");
|
|
45
47
|
const enc = new TextEncoder(), dec = new TextDecoder();
|
|
@@ -232,7 +234,7 @@ async function approved(method, path, body, kind = "access", mode = "other") {
|
|
|
232
234
|
if (r.status < 400) return r.data;
|
|
233
235
|
if (r.data.error !== "approval_required") throw new Fail(r.data.detail || r.data.error || "HTTP " + r.status);
|
|
234
236
|
const id = r.data.request;
|
|
235
|
-
// the server may name other agents that could do this for us (a standing grant
|
|
237
|
+
// the server may name other agents that could do this for us (they have a standing grant) — names only
|
|
236
238
|
let hint = r.data.hint || "";
|
|
237
239
|
say(`Waiting for approval in the OwlMem console (request ${id}${kind === "access" ? "" : ", " + kind}) …`);
|
|
238
240
|
if (hint) say(hint);
|
|
@@ -245,7 +247,7 @@ async function approved(method, path, body, kind = "access", mode = "other") {
|
|
|
245
247
|
if (st !== "pending") break;
|
|
246
248
|
}
|
|
247
249
|
const tail = hint ? " " + hint : "";
|
|
248
|
-
if (st === "pending") throw new Fail(`Waiting for approval (request ${id}). Ask the space owner to approve it in the OwlMem console (Vault), then try again. For unattended jobs, ask them
|
|
250
|
+
if (st === "pending") throw new Fail(`Waiting for approval (request ${id}). Ask the space owner to approve it in the OwlMem console (Vault), then try again. For unattended jobs, ask them to turn on your standing grant (console → Agents, or when they approve).` + tail);
|
|
249
251
|
if (st !== "approved") throw new Fail((st === "denied" ? "The request was denied." : "The request expired before it was approved.") + tail);
|
|
250
252
|
say("Approved.");
|
|
251
253
|
if (kind !== "access") extra = (path.includes("?") ? "&" : "?") + "request=" + encodeURIComponent(id);
|
|
@@ -430,7 +432,7 @@ function pick(body, field) {
|
|
|
430
432
|
const MCP_TOOLS = [
|
|
431
433
|
{ name: "vault_list", description: "List the credentials in the team vault (names and descriptions only; no secrets).", inputSchema: { type: "object", properties: {} } },
|
|
432
434
|
{ name: "vault_run",
|
|
433
|
-
description: "PREFERRED way to use a secret: run a shell command with vault items injected as environment variables. The secret values are masked as **** in the output returned to you, so they never enter the conversation. Needs the space owner's approval in the OwlMem console the first time (then 15 min – 2 h), unless they
|
|
435
|
+
description: "PREFERRED way to use a secret: run a shell command with vault items injected as environment variables. The secret values are masked as **** in the output returned to you, so they never enter the conversation. Needs the space owner's approval in the OwlMem console the first time (then 15 min – 2 h), unless they turned on your standing grant and approved this computer — standing grants cover vault_run only, never vault_get.",
|
|
434
436
|
inputSchema: { type: "object", properties: {
|
|
435
437
|
command: { type: "string", description: "Shell command to run (sh -c)" },
|
|
436
438
|
env: { type: "object", description: 'Map of ENV_VAR to "item" or "item:field", e.g. {"DB_PASSWORD": "prod/db:password"}', additionalProperties: { type: "string" } },
|
|
@@ -796,7 +798,8 @@ function nextSteps(items, out) {
|
|
|
796
798
|
// logon task) long-polls OwlMem. When an agent paired with this computer gets a task, it runs that agent's command line
|
|
797
799
|
// unattended (`claude -p`, `codex exec`) with the task as the prompt. The woken AI uses its own OwlMem connection (the one
|
|
798
800
|
// `owlmem install` set up), so it acts as itself. It gets no extra permissions: no --dangerously-* flags; anything that
|
|
799
|
-
// needs a person, it declines with what the person has to do.
|
|
801
|
+
// needs a person, it declines with what the person has to do. When a task the agent gave out is done or declined, it is
|
|
802
|
+
// woken too, to read the result (OwlMem sends those only to 0.7.7 and later).
|
|
800
803
|
// This computer is identified by its device key (device.json); the pairing is approved in the console, where each AI has an
|
|
801
804
|
// auto-wake switch and the computer can be removed. If OwlMem answers 401 (computer removed, AI disconnected, account gone)
|
|
802
805
|
// the program removes its own autostart entry and exits.
|
|
@@ -806,14 +809,22 @@ export const WAKE_CLIENTS = {
|
|
|
806
809
|
command: ["claude", "-p", "--allowedTools", "mcp__owlmem__kb_task_update,mcp__owlmem__kb_inbox"], prompt: "stdin" },
|
|
807
810
|
codex: { name: "Codex", bin: "codex", command: ["codex", "exec", "--skip-git-repo-check", "-"], prompt: "stdin" },
|
|
808
811
|
// agy -p takes the prompt as an argument; headless it denies MCP tools unless settings.json allows them (see allowAntigravity)
|
|
809
|
-
antigravity: { name: "Antigravity", bin: "agy", home: [".gemini", "bin", "agy"], command: ["agy", "-p"], prompt: "arg" },
|
|
812
|
+
antigravity: { name: "Antigravity", bin: "agy", home: [[".gemini", "bin", "agy"]], command: ["agy", "-p"], prompt: "arg" },
|
|
813
|
+
// hermes -z runs one prompt (an argument) and exits; it calls MCP tools without asking unless OwlMem is marked untrusted (see allowHermes).
|
|
814
|
+
// Its installer puts hermes under ~/.hermes/hermes-agent or ~/.local/bin; a system-wide service install under /var/lib/hermes
|
|
815
|
+
hermes: { name: "Hermes", bin: "hermes", command: ["hermes", "-z"], prompt: "arg",
|
|
816
|
+
home: [[".hermes", "hermes-agent", ".hermes", "bin", "hermes"], [".local", "bin", "hermes"], "/var/lib/hermes/hermes-agent/.hermes/bin/hermes"] },
|
|
810
817
|
};
|
|
811
|
-
export const WAKE_APP_IDS = ["claude-code", "codex", "antigravity", "gemini", "custom"];
|
|
812
|
-
// Where an app's command is: on PATH, or
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
818
|
+
export const WAKE_APP_IDS = ["claude-code", "codex", "antigravity", "hermes", "gemini", "custom"];
|
|
819
|
+
// Where an app's command is: on PATH, or the first of its default install places that exists (path parts under your home
|
|
820
|
+
// folder, or an absolute path), used by full path
|
|
821
|
+
export function wakeBin(c, home_ = home(), exists = existsSync, path = onPath) {
|
|
822
|
+
if (path(c.bin)) return c.bin;
|
|
823
|
+
for (const w of c.home || []) {
|
|
824
|
+
const p = typeof w === "string" ? w : join(home_, ...w);
|
|
825
|
+
if (exists(p)) return p;
|
|
826
|
+
}
|
|
827
|
+
return null;
|
|
817
828
|
}
|
|
818
829
|
// codex exec runs with approval "never", so an MCP call that needs approval is refused: approve just the two OwlMem
|
|
819
830
|
// tools it needs to report back, as tool tables under the [mcp_servers.owlmem] that install wrote (append only)
|
|
@@ -840,6 +851,56 @@ export function allowAntigravity(home_ = home()) {
|
|
|
840
851
|
writeFileSync(f, JSON.stringify(j, null, 2) + "\n");
|
|
841
852
|
return true;
|
|
842
853
|
}
|
|
854
|
+
// Hermes calls MCP tools without asking unless the server is marked `trust: untrusted` (or an unknown value) under mcp_servers in
|
|
855
|
+
// ~/.hermes/config.yaml; then unattended runs refuse its write tools. Only in that case, add the two OwlMem tools it reports back
|
|
856
|
+
// with to the top-level command_allowlist as mcp:<server>/<tool> (what Hermes itself writes for "Always"). Adds lines only,
|
|
857
|
+
// never rewrites what's there; a command_allowlist it can't extend safely (not a plain block list) returns "manual".
|
|
858
|
+
// false: nothing to do (no config, no OwlMem server in it, a trusted one, or the entries are already there).
|
|
859
|
+
export function allowHermes(home_ = home(), env = process.env) {
|
|
860
|
+
const f = join(env.HERMES_HOME || join(home_, ".hermes"), "config.yaml");
|
|
861
|
+
let t; try { t = readFileSync(f, "utf8"); } catch { return false; }
|
|
862
|
+
const lines = t.split(/\r?\n/), ind = (l) => l.match(/^ */)[0].length, live = (l) => !!l.trim() && !l.trim().startsWith("#");
|
|
863
|
+
const top = lines.findIndex((l) => /^mcp_servers:\s*(#.*)?$/.test(l));
|
|
864
|
+
if (top < 0) return false;
|
|
865
|
+
let end = top + 1;
|
|
866
|
+
while (end < lines.length && (!live(lines[end]) || ind(lines[end]) > 0)) end++;
|
|
867
|
+
const kids = [];
|
|
868
|
+
let step = 0;
|
|
869
|
+
for (let i = top + 1; i < end; i++) if (live(lines[i])) { step ||= ind(lines[i]); if (ind(lines[i]) === step) kids.push(i); }
|
|
870
|
+
let server = null, trust = "full";
|
|
871
|
+
for (const [k, i] of kids.entries()) { // the OwlMem entry: named owlmem, or its url is ours
|
|
872
|
+
const m = lines[i].trim().match(/^["']?([A-Za-z0-9_.-]+)["']?:\s*(#.*)?$/);
|
|
873
|
+
if (!m) continue;
|
|
874
|
+
const body = lines.slice(i + 1, k + 1 < kids.length ? kids[k + 1] : end), first = body.find(live);
|
|
875
|
+
if (m[1] !== "owlmem" && !body.some((l) => l.includes("owlmem.com/mcp"))) continue;
|
|
876
|
+
server = m[1];
|
|
877
|
+
const tr = first && body.find((l) => ind(l) === ind(first) && /^\s*trust:/.test(l));
|
|
878
|
+
if (tr) trust = tr.replace(/#.*/, "").slice(tr.indexOf(":") + 1).trim().replace(/^["']|["']$/g, "");
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
if (!server || trust === "full") return false;
|
|
882
|
+
const need = ["kb_task_update", "kb_inbox"].map((n) => "mcp:" + server + "/" + n);
|
|
883
|
+
const at = lines.findIndex((l) => /^command_allowlist:/.test(l));
|
|
884
|
+
if (at < 0) {
|
|
885
|
+
writeFileSync(f, t.replace(/\n*$/, "\n") + "# OwlMem auto-wake: unattended hermes -z may call these two OwlMem tools to report back\ncommand_allowlist:\n" +
|
|
886
|
+
need.map((r) => " - " + r + "\n").join(""));
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
if (!/^command_allowlist:\s*(#.*)?$/.test(lines[at])) return "manual"; // [a, b] on one line, or a string: leave it to the person
|
|
890
|
+
let last = at, pad = null;
|
|
891
|
+
for (let i = at + 1; i < lines.length && (!live(lines[i]) || ind(lines[i]) > 0 || lines[i].startsWith("- ")); i++) {
|
|
892
|
+
if (!live(lines[i])) continue;
|
|
893
|
+
if (!/^\s*- /.test(lines[i])) return "manual";
|
|
894
|
+
pad ??= lines[i].match(/^\s*/)[0];
|
|
895
|
+
last = i;
|
|
896
|
+
}
|
|
897
|
+
const have = lines.slice(at + 1, last + 1).map((l) => l.replace(/\s#.*/, "").trim().replace(/^-\s*/, "").replace(/^["']|["']$/g, ""));
|
|
898
|
+
const add = need.filter((r) => !have.includes(r));
|
|
899
|
+
if (!add.length) return false;
|
|
900
|
+
lines.splice(last + 1, 0, ...add.map((r) => (pad ?? " ") + "- " + r));
|
|
901
|
+
writeFileSync(f, lines.join("\n"));
|
|
902
|
+
return true;
|
|
903
|
+
}
|
|
843
904
|
export const WAKE_DEFAULTS = { max_parallel: 2, timeout_min: 30 };
|
|
844
905
|
const WAKE_UNIT = "owlmem-wake.service", WAKE_LABEL = "com.owlmem.wake", WAKE_TASK = "OwlMem Wake", WAKE_RUNKEY = "OwlMemWake";
|
|
845
906
|
const wakeDir = (dir = DIR) => join(dir, "wake");
|
|
@@ -997,7 +1058,7 @@ export function planWorkdir(task, h = home(), git = gitRun) {
|
|
|
997
1058
|
}
|
|
998
1059
|
export function prepareWorkdir(task, plan, dir = DIR, git = gitRun) {
|
|
999
1060
|
if (plan.kind === "missing") throw new Fail("the task's directory doesn't exist on this computer: " + plan.dir);
|
|
1000
|
-
if (plan.kind === "scratch") { const cwd = join(wakeDir(dir), "scratch",
|
|
1061
|
+
if (plan.kind === "scratch") { const cwd = join(wakeDir(dir), "scratch", jobName(task)); mkdirSync(cwd, { recursive: true }); return { cwd }; }
|
|
1001
1062
|
if (plan.kind === "dir") return { cwd: plan.dir };
|
|
1002
1063
|
const branch = "owlmem/task-" + task.id, wt = join(wakeDir(dir), "worktrees", basename(plan.top) + "-task-" + task.id);
|
|
1003
1064
|
if (!existsSync(wt)) {
|
|
@@ -1010,7 +1071,33 @@ export function prepareWorkdir(task, plan, dir = DIR, git = gitRun) {
|
|
|
1010
1071
|
return { cwd: rel ? join(wt, rel) : wt, worktree: wt, branch, repo: plan.top };
|
|
1011
1072
|
}
|
|
1012
1073
|
|
|
1074
|
+
// A job from OwlMem is a task given to this AI (type "task"), or the result of a task this AI gave someone else (type "reply":
|
|
1075
|
+
// the automatic note sent when that task was done or declined). Older OwlMem servers send no type: a task.
|
|
1076
|
+
const isReply = (t) => t?.type === "reply";
|
|
1077
|
+
const jobName = (t) => (isReply(t) ? "reply-" : "task-") + t.id;
|
|
1078
|
+
const shortName = (who) => { who = String(who || ""); return who.startsWith("user:") ? who.slice(5) : who.includes("/") ? who.split("/").slice(1).join("/") : who; };
|
|
1079
|
+
|
|
1080
|
+
// Woken for a reply: read it (kb_inbox marks it read; that's how OwlMem knows it was handled), act on it only if needed.
|
|
1081
|
+
export function replyPrompt(r, o = {}) {
|
|
1082
|
+
const by = shortName(r.by), done = r.result === "done", note = String(r.note || "").trim(), mins = o.timeout_min || WAKE_DEFAULTS.timeout_min;
|
|
1083
|
+
if (r.zh) return `奥麦自动唤醒在 ${o.machine || "这台电脑"} 上启动了你(${r.agent})。没有人在看这个会话,也没人能回答问题。
|
|
1084
|
+
|
|
1085
|
+
你派的任务 #${r.task_id}「${r.task_title || ""}」有了结果:${by} ${done ? "完成" : "拒绝"}了,${done ? "结果" : "原因"}:${note || "(没写)"}
|
|
1086
|
+
|
|
1087
|
+
先用 kb_inbox 看这封回信(#${r.id},会标成已读);需要接着做(换人派、把结果告诉站长、自己补做)就做;没有要做的就直接结束,不要发任何消息。
|
|
1088
|
+
不要把密码、令牌、密钥写进任何消息。你有 ${mins} 分钟。
|
|
1089
|
+
`;
|
|
1090
|
+
return `OwlMem auto-wake started you (${r.agent}) on ${o.machine || "this computer"}. Nobody is watching this session and nobody can answer questions.
|
|
1091
|
+
|
|
1092
|
+
The task you gave out, #${r.task_id} "${r.task_title || ""}", has a result: ${by} ${done ? "finished it" : "declined it"}. ${done ? "Result" : "Reason"}: ${note || "(none given)"}
|
|
1093
|
+
|
|
1094
|
+
First read this reply with kb_inbox (#${r.id}; that marks it read). If something needs doing next (hand it to someone else, tell your user the result, do it yourself), do it. If nothing does, just end here and don't send any message.
|
|
1095
|
+
Never put passwords, tokens, keys or other secrets in any message. You have ${mins} minutes.
|
|
1096
|
+
`;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1013
1099
|
export function wakePrompt(task, place, o = {}) {
|
|
1100
|
+
if (isReply(task)) return replyPrompt(task, o);
|
|
1014
1101
|
const id = task.id, mins = o.timeout_min || WAKE_DEFAULTS.timeout_min;
|
|
1015
1102
|
const where = place.worktree
|
|
1016
1103
|
? `Work in ${place.cwd}. It is a separate git worktree of ${place.repo} on the branch ${place.branch}, made for this task so it doesn't collide with other work. Commit your changes on this branch only. Don't merge, rebase or push other branches, and don't touch ${place.repo} itself. Put the branch name in your result so a person can review and merge it.`
|
|
@@ -1043,7 +1130,7 @@ export async function runTask(task, cfg, o = {}) {
|
|
|
1043
1130
|
const prompt = wakePrompt(task, place, { machine: cfg.machine, timeout_min: cfg.timeout_min });
|
|
1044
1131
|
const argv = [...cl.command, ...(cl.prompt === "arg" ? [prompt] : [])];
|
|
1045
1132
|
mkdirSync(join(wakeDir(dir), "logs"), { recursive: true });
|
|
1046
|
-
const logPath = join(wakeDir(dir), "logs",
|
|
1133
|
+
const logPath = join(wakeDir(dir), "logs", jobName(task) + ".log");
|
|
1047
1134
|
const fd = openSync(logPath, "a");
|
|
1048
1135
|
const win = (o.platform || platform()) === "win32";
|
|
1049
1136
|
const ms = o.timeoutMs ?? (cfg.timeout_min || WAKE_DEFAULTS.timeout_min) * 60000;
|
|
@@ -1052,7 +1139,7 @@ export async function runTask(task, cfg, o = {}) {
|
|
|
1052
1139
|
let ch, timedOut = false, killer = null;
|
|
1053
1140
|
try {
|
|
1054
1141
|
ch = spawn(argv[0], argv.slice(1), { cwd: place.cwd, stdio: ["pipe", fd, fd], detached: !win, shell: win, windowsHide: true,
|
|
1055
|
-
env: { ...process.env, OWLMEM_NO_BROWSER: "1", OWLMEM_WAKE_TASK: String(task.id) } });
|
|
1142
|
+
env: { ...process.env, OWLMEM_NO_BROWSER: "1", [isReply(task) ? "OWLMEM_WAKE_REPLY" : "OWLMEM_WAKE_TASK"]: String(task.id) } });
|
|
1056
1143
|
} catch (e) { res({ outcome: "error", detail: "couldn't start " + argv[0] + ": " + e.message }); return; }
|
|
1057
1144
|
const kill = (sig) => { try { if (win) spawnSync("taskkill", ["/T", "/F", "/PID", String(ch.pid)]); else process.kill(-ch.pid, sig); } catch { try { ch.kill(sig); } catch {} } };
|
|
1058
1145
|
const timer = setTimeout(() => { timedOut = true; kill("SIGTERM"); killer = setTimeout(() => kill("SIGKILL"), 10000); }, ms);
|
|
@@ -1109,15 +1196,93 @@ export async function wakeLoop(o) {
|
|
|
1109
1196
|
}
|
|
1110
1197
|
backoff = 0;
|
|
1111
1198
|
for (const t of r.data.tasks || []) {
|
|
1112
|
-
const plan = o.plan ? o.plan(t) : planWorkdir(t);
|
|
1113
|
-
log("Task #" + t.id + " for " + t.agent + ": starting " + t.client + (plan.dir ? " in " + plan.dir : "") + ".");
|
|
1199
|
+
const plan = isReply(t) ? { kind: "scratch" } : o.plan ? o.plan(t) : planWorkdir(t); // a reply is read in an empty folder
|
|
1200
|
+
log((isReply(t) ? "Reply #" + t.id + " (task #" + t.task_id + ")" : "Task #" + t.id) + " for " + t.agent + ": starting " + t.client + (plan.dir ? " in " + plan.dir : "") + ".");
|
|
1114
1201
|
sched.add({ id: t.id, agent: t.agent, lock: plan.lock || null, task: t, plan });
|
|
1115
1202
|
}
|
|
1203
|
+
// OwlMem says a newer version is out: update only while nothing runs or waits (no task is cut off), then restart
|
|
1204
|
+
if (o.update && newer(r.data.latest, VERSION) && !(r.data.tasks || []).length && sched.size === 0) {
|
|
1205
|
+
if ((await o.update(r.data.latest)) === "restart") return "updated";
|
|
1206
|
+
}
|
|
1116
1207
|
if (once) return r.data.status;
|
|
1117
1208
|
if (Date.now() - t0 < 1000 && !(r.data.tasks || []).length) await sleep(1000); // never spin
|
|
1118
1209
|
}
|
|
1119
1210
|
}
|
|
1120
1211
|
|
|
1212
|
+
// ── auto-wake updates itself ──
|
|
1213
|
+
// The program runs its own copy (wake/owlmem.mjs). When OwlMem's poll answer names a newer version (latest), and nothing is
|
|
1214
|
+
// running: download that version's package from the npm registry, check it against the registry's sha512, take owlmem.mjs
|
|
1215
|
+
// out of it, check that it runs and says it is that version, then swap it in (rename in the same folder, so it's all or
|
|
1216
|
+
// nothing) and restart. If anything fails the old copy keeps running, and that version isn't tried again for 24 hours.
|
|
1217
|
+
// Restart: on Linux and macOS the program exits with WAKE_RESTART_CODE; the systemd unit (Restart=on-failure) and the
|
|
1218
|
+
// LaunchAgent (KeepAlive unless the exit was successful) start it again 30 s later. On Windows nothing watches it, so it
|
|
1219
|
+
// starts the new copy itself (detached, like the logon launcher) and then exits.
|
|
1220
|
+
export const WAKE_RESTART_CODE = 75;
|
|
1221
|
+
const UPDATE_RETRY = 24 * 3600 * 1000;
|
|
1222
|
+
const REGISTRY = () => String(process.env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
|
|
1223
|
+
export function newer(a, b) {
|
|
1224
|
+
const p = (v) => (/^\d+\.\d+\.\d+$/.test(String(v || "")) ? String(v).split(".").map(Number) : null);
|
|
1225
|
+
const x = p(a), y = p(b);
|
|
1226
|
+
if (!x || !y) return false;
|
|
1227
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
|
|
1228
|
+
return false;
|
|
1229
|
+
}
|
|
1230
|
+
// The one file we need from an npm package (.tgz): plain ustar, read in memory.
|
|
1231
|
+
export function tarFile(tgz, want = "package/owlmem.mjs") {
|
|
1232
|
+
const t = gunzipSync(tgz);
|
|
1233
|
+
for (let off = 0; off + 512 <= t.length;) {
|
|
1234
|
+
const h = t.subarray(off, off + 512);
|
|
1235
|
+
if (h.every((b) => b === 0)) break;
|
|
1236
|
+
const str = (a, b) => h.subarray(a, b).toString("utf8").replace(/\0.*$/s, "");
|
|
1237
|
+
const name = (str(345, 500) ? str(345, 500) + "/" : "") + str(0, 100), size = parseInt(str(124, 136).trim() || "0", 8), type = str(156, 157);
|
|
1238
|
+
if ((type === "0" || type === "") && name === want) return t.subarray(off + 512, off + 512 + size);
|
|
1239
|
+
off += 512 + Math.ceil(size / 512) * 512;
|
|
1240
|
+
}
|
|
1241
|
+
return null;
|
|
1242
|
+
}
|
|
1243
|
+
// → "restart" (new copy in place) | "skip" (tried that version less than 24 h ago) | "failed" (old copy stays)
|
|
1244
|
+
export async function wakeUpdate(latest, o = {}) {
|
|
1245
|
+
const dir = o.dir || DIR, log = o.log || (() => {}), now = o.now || Date.now, get = o.fetch || fetch;
|
|
1246
|
+
const wd = wakeDir(dir), state = join(wd, "update.json"), target = join(wd, "owlmem.mjs"), tmpFile = join(wd, "owlmem.update.mjs");
|
|
1247
|
+
let st = {}; try { st = JSON.parse(readFileSync(state, "utf8")); } catch {}
|
|
1248
|
+
if (st.version === latest && now() - (st.at || 0) < UPDATE_RETRY) return "skip";
|
|
1249
|
+
try { mkdirSync(wd, { recursive: true }); writeFileSync(state, JSON.stringify({ version: latest, at: now() }) + "\n"); } catch {}
|
|
1250
|
+
log("Updating auto-wake from " + VERSION + " to " + latest + ".");
|
|
1251
|
+
try {
|
|
1252
|
+
const meta = await get(REGISTRY() + "/owlmem/" + encodeURIComponent(latest), { signal: AbortSignal.timeout(60000) });
|
|
1253
|
+
if (!meta.ok) throw new Error("registry answered HTTP " + meta.status);
|
|
1254
|
+
const dist = (await meta.json())?.dist || {};
|
|
1255
|
+
const m = /^sha512-(.+)$/.exec(String(dist.integrity || ""));
|
|
1256
|
+
if (!dist.tarball || !m) throw new Error("the registry gave no package or checksum");
|
|
1257
|
+
const res = await get(dist.tarball, { signal: AbortSignal.timeout(120000) });
|
|
1258
|
+
if (!res.ok) throw new Error("download answered HTTP " + res.status);
|
|
1259
|
+
const tgz = Buffer.from(await res.arrayBuffer());
|
|
1260
|
+
if (createHash("sha512").update(tgz).digest("base64") !== m[1]) throw new Error("the download doesn't match the registry's checksum");
|
|
1261
|
+
const file = tarFile(tgz);
|
|
1262
|
+
if (!file) throw new Error("owlmem.mjs isn't in the package");
|
|
1263
|
+
writeFileSync(tmpFile, file);
|
|
1264
|
+
const check = (o.check || ((f) => { const r = spawnSync(process.execPath, [f, "--version"], { encoding: "utf8", timeout: 30000, env: { ...process.env, OWLMEM_NO_BROWSER: "1" } }); return (r.stdout || "").trim(); }))(tmpFile);
|
|
1265
|
+
if (check !== latest) throw new Error("the new copy says it is " + JSON.stringify(check) + ", not " + latest);
|
|
1266
|
+
renameSync(tmpFile, target);
|
|
1267
|
+
} catch (e) {
|
|
1268
|
+
rmSync(tmpFile, { force: true });
|
|
1269
|
+
log("Update to " + latest + " failed (" + (e?.message || e) + "); keeping " + VERSION + " and not trying " + latest + " again for 24 hours.");
|
|
1270
|
+
return "failed";
|
|
1271
|
+
}
|
|
1272
|
+
log("Updated auto-wake to " + latest + "; restarting.");
|
|
1273
|
+
return "restart";
|
|
1274
|
+
}
|
|
1275
|
+
// After an update: come back as the new copy (see above).
|
|
1276
|
+
export function wakeRestart(o = {}) {
|
|
1277
|
+
const p = o.platform || platform(), exit = o.exit || ((c) => process.exit(c));
|
|
1278
|
+
if (p === "win32") {
|
|
1279
|
+
const run = o.spawn || ((argv) => spawn(argv[0], argv.slice(1), { stdio: "ignore", detached: true, windowsHide: true, env: process.env }).on("error", () => {}).unref());
|
|
1280
|
+
run([process.execPath, join(wakeDir(o.dir || DIR), "owlmem.mjs"), "wake", "run"]);
|
|
1281
|
+
return exit(0);
|
|
1282
|
+
}
|
|
1283
|
+
return exit(WAKE_RESTART_CODE);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1121
1286
|
function wakeApi() {
|
|
1122
1287
|
return async (sub, body) => {
|
|
1123
1288
|
const url = BASE + "/v1/wake/" + sub;
|
|
@@ -1145,7 +1310,7 @@ async function wakeRun() {
|
|
|
1145
1310
|
if (!cfg) throw new Fail("Auto-wake isn't set up on this computer. Run: npx -y owlmem wake enable");
|
|
1146
1311
|
const log = wakeLogger(), api = wakeApi();
|
|
1147
1312
|
const report = async (j, res) => {
|
|
1148
|
-
log("Task #" + j.id + ": " + res.outcome + (res.code != null ? " (exit code " + res.code + ")" : "") + (res.detail ? " — " + res.detail : ""));
|
|
1313
|
+
log((isReply(j.task) ? "Reply #" : "Task #") + j.id + ": " + res.outcome + (res.code != null ? " (exit code " + res.code + ")" : "") + (res.detail ? " — " + res.detail : ""));
|
|
1149
1314
|
for (let i = 0; i < 5; i++) {
|
|
1150
1315
|
const r = await api("report", { id: j.id, outcome: res.outcome, code: res.code ?? null, detail: res.detail || "" });
|
|
1151
1316
|
if (r.status === 200 || r.status === 404 || r.status === 401) return;
|
|
@@ -1154,8 +1319,19 @@ async function wakeRun() {
|
|
|
1154
1319
|
};
|
|
1155
1320
|
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); } });
|
|
1156
1321
|
log("OwlMem auto-wake " + VERSION + " running on " + (cfg.machine || hostname()) + ".");
|
|
1157
|
-
const out = await wakeLoop({ cfg, api, sched, log,
|
|
1322
|
+
const out = await wakeLoop({ cfg, api, sched, log, update: (v) => wakeUpdate(v, { log }),
|
|
1323
|
+
cleanup: () => { try { wakeUninstall(autostartOpts(), wakeRunCmd, true); } catch (e) { log("cleanup: " + e.message); } } });
|
|
1158
1324
|
if (out === "revoked") process.exit(0);
|
|
1325
|
+
if (out === "updated") wakeRestart();
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// No command line auto-wake can start: tell OwlMem support which AI apps this computer has, so it can add them. Only the
|
|
1329
|
+
// system, CPU architecture, node version and app names (what install detects) — no paths, user name or computer name.
|
|
1330
|
+
export async function wakeNoClients(api, items = null) {
|
|
1331
|
+
let apps = [];
|
|
1332
|
+
try { apps = (items || plan()).filter((r) => r.state !== "missing").map((r) => r.name); } catch {}
|
|
1333
|
+
const r = await api("feedback", { os: platform(), arch: process.arch, node: process.versions.node, apps: [...new Set(apps)] });
|
|
1334
|
+
return r.status === 200;
|
|
1159
1335
|
}
|
|
1160
1336
|
|
|
1161
1337
|
async function wakeEnable(args = []) {
|
|
@@ -1168,10 +1344,18 @@ async function wakeEnable(args = []) {
|
|
|
1168
1344
|
const bin = !clients[id] && wakeBin(c);
|
|
1169
1345
|
if (bin) clients[id] = { command: [bin, ...c.command.slice(1)], prompt: c.prompt };
|
|
1170
1346
|
}
|
|
1171
|
-
if (!Object.keys(clients).length)
|
|
1172
|
-
|
|
1347
|
+
if (!Object.keys(clients).length) {
|
|
1348
|
+
const told = await wakeNoClients(wakeApi()).catch(() => false);
|
|
1349
|
+
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\"." +
|
|
1350
|
+
(told ? "\nOwlMem support has been told; you'll hear from us once your AI app is supported." : ""));
|
|
1351
|
+
}
|
|
1173
1352
|
if (clients.codex && allowCodex()) say("Let Codex report back to OwlMem when nobody is watching (2 tool approvals in ~/.codex/config.toml).");
|
|
1174
1353
|
if (clients.antigravity && allowAntigravity()) say("Let Antigravity's command line report back to OwlMem when nobody is watching (2 rules in ~/.gemini/antigravity-cli/settings.json).");
|
|
1354
|
+
if (clients.hermes) {
|
|
1355
|
+
const h = allowHermes();
|
|
1356
|
+
if (h === true) say("Let Hermes report back to OwlMem when nobody is watching (2 entries under command_allowlist in ~/.hermes/config.yaml).");
|
|
1357
|
+
else if (h === "manual") say("Hermes treats OwlMem as untrusted: add mcp:<server>/kb_task_update and mcp:<server>/kb_inbox to command_allowlist in ~/.hermes/config.yaml, or it can't report back when nobody is watching.");
|
|
1358
|
+
}
|
|
1175
1359
|
const cfg = { v: 1, ...WAKE_DEFAULTS, ...old, machine: process.env.OWLMEM_DEVICE_NAME || hostname(), clients, ...(max ? { max_parallel: max } : {}), ...(timeout ? { timeout_min: timeout } : {}), boot: boot || !!old.boot };
|
|
1176
1360
|
// OwlMem knows these app names; a command you add yourself to wake.json goes under "gemini" or "custom"
|
|
1177
1361
|
const r = await wakeApi()("pair", { clients: Object.keys(clients).filter((c) => WAKE_APP_IDS.includes(c)), force: again });
|
|
@@ -1184,7 +1368,8 @@ async function wakeEnable(args = []) {
|
|
|
1184
1368
|
say("Auto-wake is installed (" + a.kind + ": " + tilde(a.file) + ").");
|
|
1185
1369
|
say("Apps it can start here: " + Object.keys(clients).map((c) => WAKE_CLIENTS[c]?.name || c).join(", ") + ". At most " + cfg.max_parallel + " at once, " + cfg.timeout_min + " minutes per task.");
|
|
1186
1370
|
if (r.data.status === "pending") {
|
|
1187
|
-
say("\nApprove this computer in the OwlMem console and pick which AI each app is:\n\n " + r.data.url + "\n\nCheck that the console shows this code: " + r.data.code + "\nUntil then auto-wake waits and starts nothing."
|
|
1371
|
+
say("\nApprove this computer in the OwlMem console and pick which AI each app is:\n\n " + r.data.url + "\n\nCheck that the console shows this code: " + r.data.code + "\nUntil then auto-wake waits and starts nothing." +
|
|
1372
|
+
"\n(If you're an AI agent running this: send the link and the code to the person who turned auto-wake on, with kb_send.)");
|
|
1188
1373
|
openBrowser(r.data.url);
|
|
1189
1374
|
} else say("This computer is already paired (" + r.data.code + "). Turn auto-wake on or off for each AI in the console (AI agents → Agents).");
|
|
1190
1375
|
}
|
|
@@ -1218,7 +1403,7 @@ async function wakeStatus() {
|
|
|
1218
1403
|
const WAKE_HELP = `Usage: npx -y owlmem wake <enable|disable|status>
|
|
1219
1404
|
|
|
1220
1405
|
Auto-wake starts the AI on this computer when it gets an OwlMem task (Claude Code with \`claude -p\`, Codex with
|
|
1221
|
-
\`codex exec\`), without you opening it. It runs in the background from login, for this user only.
|
|
1406
|
+
\`codex exec\`, Antigravity with \`agy -p\`, Hermes with \`hermes -z\`), without you opening it. It runs in the background from login, for this user only.
|
|
1222
1407
|
|
|
1223
1408
|
enable [--boot] [--max N] [--timeout MIN] [--again]
|
|
1224
1409
|
install it and pair this computer (approve in the console). --boot (Linux): run from boot,
|
|
@@ -1262,12 +1447,12 @@ export async function main(argv) {
|
|
|
1262
1447
|
if (cmd === "uninstall") { await install(["--uninstall", ...argv.slice(1)]); return; }
|
|
1263
1448
|
if (cmd === "mcp") { await mcpServe(); return; }
|
|
1264
1449
|
if (cmd === "wake") { await wakeCmd(sub, rest); return; }
|
|
1265
|
-
if (cmd === "device") { //
|
|
1450
|
+
if (cmd === "device") { // show this machine's fingerprint (the console lists approved computers with it)
|
|
1266
1451
|
const r = await call("POST", "device", {});
|
|
1267
1452
|
if (r.status >= 400) throw new Fail(r.data.detail || r.data.error || "HTTP " + r.status);
|
|
1268
1453
|
say("This machine: " + (process.env.OWLMEM_DEVICE_NAME || hostname()));
|
|
1269
1454
|
console.log(r.data.device);
|
|
1270
|
-
say("
|
|
1455
|
+
say("This computer's fingerprint for " + r.data.agent + ". With a standing grant on, the first use here asks once; approving remembers this computer.");
|
|
1271
1456
|
return;
|
|
1272
1457
|
}
|
|
1273
1458
|
if (cmd === "login") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "owlmem",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
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" },
|