owlmem 0.4.1 → 0.7.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 (3) hide show
  1. package/README.md +67 -0
  2. package/owlmem.mjs +805 -17
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -2,6 +2,63 @@
2
2
 
3
3
  Command line for [OwlMem](https://owlmem.com) — shared memory and an end-to-end encrypted vault for your AI agents.
4
4
 
5
+ ## Connect your AI apps
6
+
7
+ ```sh
8
+ npx -y owlmem install
9
+ ```
10
+
11
+ Finds Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI and Claude Desktop on this computer and adds
12
+ OwlMem (`https://mcp.owlmem.com/mcp`) to each one. Config files are merged, not overwritten, and every file it
13
+ changes is backed up first (`<file>.bak-owlmem-<time>`). It also adds a short "Team memory" rules block to
14
+ `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` and `~/.gemini/GEMINI.md` (between `<!-- owlmem:start -->` markers).
15
+
16
+ | Option | |
17
+ |---|---|
18
+ | `--dry-run` | show the plan, change nothing |
19
+ | `--yes` | don't ask (when stdin isn't a terminal — an AI running it — it never asks) |
20
+ | `--only claude-code,cursor` | only these apps (`claude-code`, `codex`, `cursor`, `vscode`, `vscode-insiders`, `windsurf`, `gemini`, `claude-desktop`) |
21
+ | `--no-rules` | leave the instruction files alone |
22
+ | `--wake` / `--no-wake` | turn on auto-wake (below) without asking / don't ask about it |
23
+ | `--uninstall` | remove the OwlMem entries and rules block again, and auto-wake |
24
+
25
+ Signing in is done by each app (standard MCP OAuth): the first time it uses OwlMem it opens the owlmem.com
26
+ page, where you sign in and click **Allow**. AI agents can read the setup guide at https://mcp.owlmem.com.
27
+
28
+ ## Auto-wake
29
+
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
+ 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` — with the task as the prompt. The AI marks the task done or declined as usual.
33
+
34
+ ```sh
35
+ npx -y owlmem install --wake # or answer "y" when install asks; --yes alone leaves it off
36
+ npx -y owlmem wake enable # on its own (options: --boot, --max N, --timeout MIN, --again)
37
+ npx -y owlmem wake status
38
+ npx -y owlmem wake disable # also done by npx -y owlmem uninstall
39
+ ```
40
+
41
+ - **Pairing.** `wake enable` prints a link: open it, check the code matches, and pick which AI each app on this
42
+ computer is. Until then nothing starts. The computer is identified by its device key (next to `config.json`).
43
+ - **Runs as you.** A systemd user service (Linux), a LaunchAgent (macOS) or a logon task (Windows) — this user
44
+ only, no admin rights. `--boot` (Linux) turns on lingering so it runs from boot, not just from login.
45
+ - **One program per computer** for every paired AI. One task at a time per AI, at most 2 at once (`--max`),
46
+ 30 minutes per task (`--timeout`); a task that runs out of time is stopped and marked declined for a person.
47
+ A task sent to every agent wakes just one.
48
+ - **Working folder.** A line like `cwd: ~/Projects/app` in the task picks the folder. In a Git repository the AI
49
+ gets its own worktree and branch (`owlmem/task-<id>`), commits there and doesn't merge — the result names the
50
+ branch. Other folders take one task at a time; with no folder it works in an empty one.
51
+ - **Safety.** The AI uses its own OwlMem connection and the permissions it always has: no `--dangerously-*` flags.
52
+ Claude Code is only additionally allowed the two OwlMem tools it needs to report back (`kb_task_update`,
53
+ `kb_inbox`). Anything that needs a person — an approval, a sign-in, a code — it declines, saying what to do.
54
+ It is told never to put secrets in its result.
55
+ - **Turning it off.** Each AI has an auto-wake switch in the console (AI agents → Agents), where you can also remove
56
+ the computer. When the computer is removed, the AI is disconnected or the account is gone, the program gets a 401,
57
+ removes its own autostart entry and exits. The command for each app is in `~/.config/owlmem/wake/wake.json`;
58
+ logs and worktrees stay in that folder after uninstalling.
59
+
60
+ ## Vault
61
+
5
62
  ```sh
6
63
  npx owlmem login # paste the agent's token from the OwlMem console
7
64
  npx owlmem vault unlock # paste your recovery code, once per machine
@@ -13,6 +70,13 @@ npx owlmem vault import bitwarden_export.json # also 1Password / KeePass CSV,
13
70
  Vault items are encrypted on your machine with a key only you hold; OwlMem stores ciphertext it cannot read.
14
71
  Every read or write needs the space owner's approval in the console (passkey, authenticator app or GitHub).
15
72
 
73
+ For unattended jobs (scheduled tasks, overnight deploys) the owner can set a **standing grant**: one agent, on one machine,
74
+ may read chosen items or a folder without asking each time, until a set date (90 days at most), optionally only during set hours.
75
+ Run `npx owlmem device` on that machine to register it and print its fingerprint. The program signs every vault request with a
76
+ device key kept next to its config, so the same token on another machine gets nothing. By default a standing grant covers
77
+ `owlmem run` / `vault_run` only (the secret goes into a command); viewing in plain text must be allowed separately.
78
+ Saving, deleting and exporting always need approval.
79
+
16
80
  Let your AI use the vault directly through a local MCP server:
17
81
 
18
82
  ```sh
@@ -22,3 +86,6 @@ claude mcp add owlmem-vault -- npx -y owlmem mcp
22
86
  Tools: `vault_list`, `vault_run` (secrets are injected into the command and masked in its output), `vault_get`, `vault_put`.
23
87
 
24
88
  Requires Node 18+. No dependencies.
89
+
90
+ `owlmem vault unlock` opens your browser only when talking to owlmem.com; set `OWLMEM_NO_BROWSER=1` (or `BROWSER=none`)
91
+ to just print the link instead.
package/owlmem.mjs CHANGED
@@ -5,6 +5,10 @@
5
5
  // server only ever sees ciphertext. Your space key comes from the recovery code shown once when the vault
6
6
  // was set up in the console; `owlmem vault unlock` stores it on this machine (0600).
7
7
  //
8
+ // owlmem install [--dry-run] [--yes] [--only a,b] [--no-rules] [--wake] [--uninstall]
9
+ // connect Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI and
10
+ // Claude Desktop on this computer to OwlMem (npx -y owlmem install)
11
+ // owlmem wake enable|disable|status auto-wake: start the AI on this computer when it gets a task (runs from login)
8
12
  // owlmem login save this agent's token (from the console)
9
13
  // owlmem vault unlock paste the recovery code once per machine
10
14
  // owlmem vault ls
@@ -15,19 +19,27 @@
15
19
  // owlmem vault export [--format bitwarden-json|keepass-csv|env] > file
16
20
  // owlmem run --env VAR=<name>[:field] [...] -- <command> [args] run a command with secrets in its environment
17
21
  // owlmem vault keygen (setup without a browser) print a new recovery code and its key check
22
+ // owlmem device register this machine and show its fingerprint (for standing grants)
18
23
  // owlmem mcp local MCP server for your AI (vault_list, vault_run, vault_get, vault_put)
19
24
  // e.g. claude mcp add owlmem-vault -- node ~/owlmem.mjs mcp
20
25
  //
21
26
  // Reading, writing, deleting and exporting need the space owner's approval in the console; this program waits
22
27
  // for it (up to 5 minutes). Secrets go to stdout only; messages go to stderr.
28
+ //
29
+ // Standing grants (for unattended jobs): the owner can let one agent, on one machine, read chosen items without
30
+ // asking each time — until a set date, optionally only during set hours. This program signs every vault request
31
+ // with a device key kept on this machine (device.json next to config.json), and says whether the secret goes into a
32
+ // command (`owlmem run`, vault_run) or is shown (`vault get`, vault_get). A token copied to another machine has no
33
+ // device key there, so standing grants don't apply. Writing, deleting and exporting always need approval.
23
34
 
24
35
  import { webcrypto as crypto } from "node:crypto";
25
- import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
26
- import { homedir } from "node:os";
27
- import { join, dirname } from "node:path";
28
- import { spawn } from "node:child_process";
36
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync, rmSync, statSync, realpathSync, openSync, closeSync, appendFileSync } from "node:fs";
37
+ import { homedir, hostname, userInfo } from "node:os";
38
+ import { join, dirname, basename, relative, isAbsolute } from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+ import { spawn, spawnSync } from "node:child_process";
29
41
 
30
- export const VERSION = "0.4.1";
42
+ export const VERSION = "0.7.0";
31
43
  const BASE = (process.env.OWLMEM_URL || "https://owlmem.com").replace(/\/$/, "");
32
44
  const DIR = process.env.OWLMEM_HOME || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "owlmem");
33
45
  const enc = new TextEncoder(), dec = new TextDecoder();
@@ -169,11 +181,43 @@ function spaceKey(space) {
169
181
  return unb64(readFileSync(p, "utf8").trim());
170
182
  }
171
183
 
184
+ // ── this machine's device key (standing grants are bound to it) ──
185
+ // ECDSA P-256, made on first use, kept next to config.json (0600). The server only ever sees the public key and signatures.
186
+ let DEVICE = null;
187
+ async function device() {
188
+ if (DEVICE) return DEVICE;
189
+ const p = join(DIR, "device.json");
190
+ let d = null;
191
+ try { d = JSON.parse(readFileSync(p, "utf8")); } catch {}
192
+ if (!d || !d.pkcs8 || !d.spki) {
193
+ const kp = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
194
+ d = { v: 1, spki: b64url(new Uint8Array(await crypto.subtle.exportKey("spki", kp.publicKey))), pkcs8: b64(new Uint8Array(await crypto.subtle.exportKey("pkcs8", kp.privateKey))), created: new Date().toISOString() };
195
+ saveFile(p, JSON.stringify(d, null, 2));
196
+ }
197
+ const key = await crypto.subtle.importKey("pkcs8", unb64(d.pkcs8), { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
198
+ return (DEVICE = { spki: d.spki, key });
199
+ }
200
+ export async function deviceFingerprint(spki) {
201
+ const h = [...new Uint8Array(await crypto.subtle.digest("SHA-256", unb64(spki.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - spki.length % 4) % 4))))].map((x) => x.toString(16).padStart(2, "0")).join("").slice(0, 16).toUpperCase();
202
+ return h.match(/.{4}/g).join("-");
203
+ }
204
+ // mode: "run" = the secret goes into a command; "view" = it is shown (stdout / the AI's conversation); "other" = anything else
205
+ // url: the full request URL (only its path is signed)
206
+ async function signHeaders(method, url, mode) {
207
+ const d = await device();
208
+ const ts = String(Math.floor(Date.now() / 1000)), nonce = b64url(crypto.getRandomValues(new Uint8Array(18)));
209
+ const msg = ["owlmem-device-v1", method.toUpperCase(), new URL(url).pathname, mode, ts, nonce].join("\n");
210
+ const sig = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, d.key, enc.encode(msg)));
211
+ return { "X-OwlMem-Device": d.spki, "X-OwlMem-Device-Name": (process.env.OWLMEM_DEVICE_NAME || hostname()).replace(/[^\w .@()-]/g, "").slice(0, 60),
212
+ "X-OwlMem-Mode": mode, "X-OwlMem-Time": ts, "X-OwlMem-Nonce": nonce, "X-OwlMem-Sig": b64url(sig) };
213
+ }
214
+
172
215
  // ── http ──
173
- async function call(method, path, body) {
216
+ async function call(method, path, body, mode = "other") {
174
217
  let r;
175
218
  try {
176
- r = await fetch(BASE + "/v1/vault/" + path, { method, headers: { Authorization: "Bearer " + token(), "Content-Type": "application/json", "User-Agent": "owlmem-cli/" + VERSION },
219
+ const sh = await signHeaders(method, BASE + "/v1/vault/" + path, mode).catch(() => ({})); // can't write the device key: no standing grants, everything else still works
220
+ r = await fetch(BASE + "/v1/vault/" + path, { method, headers: { Authorization: "Bearer " + token(), "Content-Type": "application/json", "User-Agent": "owlmem-cli/" + VERSION, ...sh },
177
221
  body: body === undefined ? undefined : JSON.stringify(body) });
178
222
  } catch (e) { throw new Fail("Can't reach OwlMem (" + BASE + "): " + e.message); }
179
223
  const data = await r.json().catch(() => ({}));
@@ -181,10 +225,10 @@ async function call(method, path, body) {
181
225
  }
182
226
  // Ask, and if the owner has to approve, wait for it (polling every 3 s, up to 5 minutes), then ask again.
183
227
  let WAIT_MS = 5 * 60 * 1000; // 命令行等 5 分钟;MCP 里只等 30 秒,免得 AI 客户端的工具调用超时
184
- async function approved(method, path, body, kind = "access") {
228
+ async function approved(method, path, body, kind = "access", mode = "other") {
185
229
  let extra = "";
186
230
  for (let round = 0; round < 3; round++) {
187
- const r = await call(method, path + extra, body);
231
+ const r = await call(method, path + extra, body, mode);
188
232
  if (r.status < 400) return r.data;
189
233
  if (r.data.error !== "approval_required") throw new Fail(r.data.detail || r.data.error || "HTTP " + r.status);
190
234
  const id = r.data.request;
@@ -196,7 +240,7 @@ async function approved(method, path, body, kind = "access") {
196
240
  st = (await call("GET", "requests/" + id)).data.status;
197
241
  if (st !== "pending") break;
198
242
  }
199
- 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.`);
243
+ 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 for a standing grant for this agent and this machine (run: owlmem device).`);
200
244
  if (st !== "approved") throw new Fail(st === "denied" ? "The request was denied." : "The request expired before it was approved.");
201
245
  say("Approved.");
202
246
  if (kind !== "access") extra = (path.includes("?") ? "&" : "?") + "request=" + encodeURIComponent(id);
@@ -311,6 +355,21 @@ export function guessCSV(text) {
311
355
  }
312
356
  // <<< vault core
313
357
 
358
+ // ── opening a browser ──
359
+ // Only for the real service: OWLMEM_NO_BROWSER=1 or BROWSER=none turns it off, and so does any OWLMEM_URL that isn't owlmem.com
360
+ // (tests and local previews run on 127.0.0.1 — 10-24 a test run kept opening tabs in the owner's browser). The link is always printed.
361
+ export function browserAllowed(env = process.env) {
362
+ if (env.OWLMEM_NO_BROWSER && env.OWLMEM_NO_BROWSER !== "0") return false;
363
+ if (String(env.BROWSER || "").toLowerCase() === "none") return false;
364
+ let host = "";
365
+ try { const u = new URL(env.OWLMEM_URL || "https://owlmem.com"); if (u.protocol !== "https:") return false; host = u.hostname; } catch { return false; }
366
+ return host === "owlmem.com" || host.endsWith(".owlmem.com");
367
+ }
368
+ function openBrowser(url) {
369
+ if (!browserAllowed()) return false;
370
+ try { spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open", [url], { stdio: "ignore", detached: true }).on("error", () => {}).unref(); return true; } catch { return false; }
371
+ }
372
+
314
373
  // ── unlock via the console ──
315
374
  async function unlockInBrowser(space) {
316
375
  const kp = await unlockKeypair();
@@ -318,7 +377,7 @@ async function unlockInBrowser(space) {
318
377
  if (r.status >= 400) throw new Fail(r.data.detail || r.data.error);
319
378
  const fp = await unlockFingerprint(kp.pubkey);
320
379
  say("Open this link, sign in, and approve with your passkey:\n\n " + r.data.url + "\n\nThe console will show a code — check it matches: " + fp + "\nWaiting (up to 10 minutes)…");
321
- try { spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open", [r.data.url], { stdio: "ignore", detached: true }).on("error", () => {}).unref(); } catch {}
380
+ openBrowser(r.data.url);
322
381
  const wait = Number(process.env.OWLMEM_POLL_MS || 2000), until = Date.now() + 10 * 60 * 1000;
323
382
  while (Date.now() < until) {
324
383
  await new Promise((res) => setTimeout(res, wait));
@@ -350,9 +409,9 @@ function ask(question, hidden = true) {
350
409
 
351
410
  // ── commands ──
352
411
  function flag(args, name, dflt) { const i = args.indexOf(name); if (i < 0) return dflt; const v = args[i + 1]; args.splice(i, 2); return v; }
353
- async function fetchItem(name) {
412
+ async function fetchItem(name, mode = "view") {
354
413
  const sp = await space(); const raw = spaceKey(sp.space);
355
- const it = await approved("GET", itemPath(name));
414
+ const it = await approved("GET", itemPath(name), undefined, "access", mode);
356
415
  return open(raw, sp.space, name, it.nonce, it.ciphertext);
357
416
  }
358
417
  function pick(body, field) {
@@ -366,7 +425,7 @@ function pick(body, field) {
366
425
  const MCP_TOOLS = [
367
426
  { name: "vault_list", description: "List the credentials in the team vault (names and descriptions only; no secrets).", inputSchema: { type: "object", properties: {} } },
368
427
  { name: "vault_run",
369
- 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).",
428
+ 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 set a standing grant for you on this machine — standing grants cover vault_run only (not vault_get) unless the owner also allowed viewing.",
370
429
  inputSchema: { type: "object", properties: {
371
430
  command: { type: "string", description: "Shell command to run (sh -c)" },
372
431
  env: { type: "object", description: 'Map of ENV_VAR to "item" or "item:field", e.g. {"DB_PASSWORD": "prod/db:password"}', additionalProperties: { type: "string" } },
@@ -396,7 +455,7 @@ async function mcpCall(name, a) {
396
455
  for (const [k, ref] of Object.entries(a.env || {})) {
397
456
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) throw new Fail("Bad variable name " + k);
398
457
  const [item, field] = String(ref).split(":");
399
- const v = pick(await fetchItem(item), field); env[k] = v; if (v && v.length >= 4) secrets.push(v);
458
+ const v = pick(await fetchItem(item, "run"), field); env[k] = v; if (v && v.length >= 4) secrets.push(v);
400
459
  }
401
460
  const mask = (t) => secrets.sort((x, y) => y.length - x.length).reduce((acc, v) => acc.split(v).join("****"), t);
402
461
  const out = await new Promise((res) => {
@@ -434,10 +493,739 @@ async function mcpServe() {
434
493
  await new Promise((res) => process.stdin.on("end", res));
435
494
  }
436
495
 
496
+ // ── install: connect the AI apps on this machine to OwlMem's MCP server ──
497
+ // The snippets here must say the same as the website (design/owlmem/site.py CODE); tests/test_owlmem_install.py compares them.
498
+ export const MCP_URL = "https://mcp.owlmem.com/mcp";
499
+ export const RULES = `## Team memory (OwlMem)
500
+ - Before starting a task, use kb_search (documents) and kb_recall (facts) for the topic.
501
+ - When you learn something reusable — a fix, a gotcha, a decision — save it with kb_write
502
+ under lessons/ or projects/<name>/. Search first and update the existing page instead of
503
+ creating a duplicate.
504
+ - Store facts that change (prices, versions, status) with kb_retain and snapshot=true or an
505
+ expiry, so they retire on their own.
506
+ - Never put passwords or API keys in documents or memory. Use the OwlMem vault.`;
507
+ const MARK_START = "<!-- owlmem:start -->", MARK_END = "<!-- owlmem:end -->";
508
+ const DOCS = "https://owlmem.com/docs#";
509
+ // What each app's config file gets under its servers key (same shapes as on owlmem.com/docs)
510
+ export const ENTRIES = {
511
+ cursor: { url: MCP_URL },
512
+ vscode: { type: "http", url: MCP_URL },
513
+ windsurf: { serverUrl: MCP_URL },
514
+ gemini: { httpUrl: MCP_URL },
515
+ "claude-desktop": { command: "npx", args: ["-y", "mcp-remote", MCP_URL] },
516
+ };
517
+ export const CLAUDE_ADD = ["mcp", "add", "--transport", "http", "--scope", "user", "owlmem", MCP_URL];
518
+ export const CODEX_TOML = `[mcp_servers.owlmem]\nurl = "${MCP_URL}"`;
519
+
520
+ function onPath(name) {
521
+ const exts = process.platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
522
+ for (const d of (process.env.PATH || "").split(process.platform === "win32" ? ";" : ":")) {
523
+ if (!d) continue;
524
+ for (const e of exts) { const p = join(d, name + e); if (existsSync(p)) return p; }
525
+ }
526
+ return null;
527
+ }
528
+ function home() { return process.env.HOME || process.env.USERPROFILE || homedir(); }
529
+ function platform() { return process.env.OWLMEM_PLATFORM || process.platform; } // OWLMEM_PLATFORM: tests only
530
+ function appData() { // per-user application data folder (VS Code, Claude Desktop)
531
+ const p = platform(), h = home();
532
+ if (p === "darwin") return join(h, "Library", "Application Support");
533
+ if (p === "win32") return process.env.APPDATA || join(h, "AppData", "Roaming");
534
+ return process.env.XDG_CONFIG_HOME || join(h, ".config");
535
+ }
536
+ const readText = (f) => { try { return readFileSync(f, "utf8"); } catch { return null; } };
537
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
538
+
539
+ // Client table. detect(): is this app on this machine? Each plan returns {state, ops, note}.
540
+ // state: "ok" (already set up) | "add" | "update" | "remove" | "absent" (nothing to remove) | "fail"
541
+ // ops: [{type:"write", file, text} | {type:"run", argv}]
542
+ function jsonClient(id, name, file, key, entry) {
543
+ return { id, name, file, key, entry, docs: DOCS + ({ windsurf: "others", gemini: "others", "claude-desktop": "claude" }[id] || id) };
544
+ }
545
+ export function clients() {
546
+ const h = home(), ad = appData();
547
+ const codexHome = process.env.CODEX_HOME || join(h, ".codex");
548
+ const list = [
549
+ { id: "claude-code", name: "Claude Code", docs: DOCS + "claude-code", rules: join(process.env.CLAUDE_CONFIG_DIR || join(h, ".claude"), "CLAUDE.md"),
550
+ detect: () => !!onPath("claude") },
551
+ { id: "codex", name: "Codex", docs: DOCS + "codex", file: join(codexHome, "config.toml"), rules: join(codexHome, "AGENTS.md"),
552
+ detect: () => !!onPath("codex") || existsSync(codexHome) },
553
+ Object.assign(jsonClient("cursor", "Cursor", join(h, ".cursor", "mcp.json"), "mcpServers", ENTRIES.cursor),
554
+ { detect: () => existsSync(join(h, ".cursor")) || !!onPath("cursor") }),
555
+ Object.assign(jsonClient("vscode", "VS Code", join(ad, "Code", "User", "mcp.json"), "servers", ENTRIES.vscode),
556
+ { detect: () => existsSync(join(ad, "Code", "User")) }),
557
+ Object.assign(jsonClient("vscode-insiders", "VS Code Insiders", join(ad, "Code - Insiders", "User", "mcp.json"), "servers", ENTRIES.vscode),
558
+ { detect: () => existsSync(join(ad, "Code - Insiders", "User")), docs: DOCS + "vscode", optional: true }),
559
+ Object.assign(jsonClient("windsurf", "Windsurf", join(h, ".codeium", "windsurf", "mcp_config.json"), "mcpServers", ENTRIES.windsurf),
560
+ { detect: () => existsSync(join(h, ".codeium", "windsurf")) }),
561
+ Object.assign(jsonClient("gemini", "Gemini CLI", join(h, ".gemini", "settings.json"), "mcpServers", ENTRIES.gemini),
562
+ { detect: () => existsSync(join(h, ".gemini")) || !!onPath("gemini"), rules: join(h, ".gemini", "GEMINI.md") }),
563
+ Object.assign(jsonClient("claude-desktop", "Claude Desktop", join(ad, "Claude", "claude_desktop_config.json"), "mcpServers", ENTRIES["claude-desktop"]),
564
+ { detect: () => existsSync(join(ad, "Claude")) }),
565
+ ];
566
+ return list;
567
+ }
568
+ export const CLIENT_IDS = ["claude-code", "codex", "cursor", "vscode", "vscode-insiders", "windsurf", "gemini", "claude-desktop"];
569
+
570
+ function planJSON(c, uninstall) {
571
+ const text = readText(c.file);
572
+ let obj = {};
573
+ if (text !== null && text.trim()) {
574
+ try { obj = JSON.parse(text); } catch {
575
+ const hint = /\/\/|\/\*/.test(text) ? "it has comments or isn't plain JSON" : "it isn't valid JSON";
576
+ return { state: "fail", ops: [], note: `${c.file}: ${hint}, so it was left alone. Add OwlMem by hand: ${c.docs}` };
577
+ }
578
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return { state: "fail", ops: [], note: `${c.file}: unexpected content, left alone. Add OwlMem by hand: ${c.docs}` };
579
+ }
580
+ const servers = obj[c.key] && typeof obj[c.key] === "object" && !Array.isArray(obj[c.key]) ? obj[c.key] : null;
581
+ const cur = servers ? servers.owlmem : undefined;
582
+ if (uninstall) {
583
+ if (cur === undefined) return { state: "absent", ops: [] };
584
+ const next = { ...obj, [c.key]: { ...servers } }; delete next[c.key].owlmem;
585
+ return { state: "remove", ops: [{ type: "write", file: c.file, text: JSON.stringify(next, null, 2) + "\n" }] };
586
+ }
587
+ if (same(cur, c.entry)) return { state: "ok", ops: [] };
588
+ const next = { ...obj, [c.key]: { ...(servers || {}), owlmem: c.entry } };
589
+ return { state: cur === undefined ? "add" : "update", ops: [{ type: "write", file: c.file, text: JSON.stringify(next, null, 2) + "\n" }],
590
+ note: cur === undefined ? "" : "replaced the existing owlmem entry " + JSON.stringify(cur) };
591
+ }
592
+
593
+ // Codex: ~/.codex/config.toml. Only the [mcp_servers.owlmem] table (and its sub-tables) is touched; everything else is kept as is.
594
+ export function tomlSplit(text) {
595
+ const rest = [], section = []; let mine = false;
596
+ for (const l of text.split("\n")) {
597
+ if (/^\s*\[\[/.test(l)) mine = false;
598
+ else { const m = /^\s*\[\s*([^\]]+?)\s*\]\s*(#.*)?$/.exec(l); if (m) { const n = m[1].replace(/["'\s]/g, ""); mine = n === "mcp_servers.owlmem" || n.startsWith("mcp_servers.owlmem."); } }
599
+ (mine ? section : rest).push(l);
600
+ }
601
+ return { rest: rest.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\s+$/, ""), section: section.join("\n").trim(), found: section.length > 0 };
602
+ }
603
+ function planCodex(c, uninstall) {
604
+ const text = readText(c.file) ?? "";
605
+ if (/^\s*mcp_servers\s*[.=]|^\s*owlmem\s*=\s*\{/m.test(text))
606
+ return { state: "fail", ops: [], note: `${c.file} defines MCP servers in a form this installer doesn't edit; left alone. Add OwlMem by hand: ${c.docs}` };
607
+ const { rest, section, found } = tomlSplit(text);
608
+ if (uninstall) return found ? { state: "remove", ops: [{ type: "write", file: c.file, text: rest ? rest + "\n" : "" }] } : { state: "absent", ops: [] };
609
+ const norm = (s) => s.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).join("\n");
610
+ if (found && norm(section) === norm(CODEX_TOML)) return { state: "ok", ops: [] };
611
+ return { state: found ? "update" : "add", ops: [{ type: "write", file: c.file, text: (rest ? rest + "\n\n" : "") + CODEX_TOML + "\n" }],
612
+ note: found ? "replaced the existing [mcp_servers.owlmem] table" : "" };
613
+ }
614
+
615
+ // Claude Code: through its own command (`claude mcp add … --scope user`); ~/.claude.json tells us what is there already.
616
+ function planClaude(c, uninstall) {
617
+ let cur;
618
+ try { cur = (JSON.parse(readText(join(home(), ".claude.json")) || "{}").mcpServers || {}).owlmem; } catch { cur = undefined; }
619
+ const rm = { type: "run", argv: ["claude", "mcp", "remove", "owlmem", "--scope", "user"], ignoreFail: !uninstall };
620
+ if (uninstall) return cur === undefined ? { state: "absent", ops: [] } : { state: "remove", ops: [rm] };
621
+ if (cur && cur.url === MCP_URL && (cur.type === "http" || cur.type === "streamable-http") && !cur.headers) return { state: "ok", ops: [] };
622
+ return { state: cur === undefined ? "add" : "update", ops: [...(cur === undefined ? [] : [rm]), { type: "run", argv: ["claude", ...CLAUDE_ADD], retryWith: rm }],
623
+ note: cur === undefined ? "" : "replaced the existing owlmem server " + JSON.stringify(cur) };
624
+ }
625
+
626
+ export function withRules(text, uninstall) {
627
+ const t = text ?? "";
628
+ const re = new RegExp("\\n*" + MARK_START + "[\\s\\S]*?" + MARK_END + "\\n?");
629
+ if (uninstall) return re.test(t) ? t.replace(re, "\n").replace(/^\n+/, "").replace(/\n{3,}/g, "\n\n") : t;
630
+ const block = MARK_START + "\n" + RULES + "\n" + MARK_END + "\n";
631
+ if (re.test(t)) return t.replace(re, (m) => (m.startsWith("\n") ? "\n\n" : "") + block);
632
+ return t.trim() ? t.replace(/\s*$/, "\n\n") + block : block;
633
+ }
634
+
635
+ export function plan(opts = {}) {
636
+ const only = opts.only ? new Set(opts.only) : null;
637
+ const out = [];
638
+ for (const c of clients()) {
639
+ if (only && !only.has(c.id) && !(c.id === "vscode-insiders" && only.has("vscode"))) continue;
640
+ const r = { id: c.id, name: c.name, docs: c.docs, file: c.file, ops: [], note: "" };
641
+ if (!c.detect()) { if (!c.optional || only) Object.assign(r, { state: "missing" }); else continue; out.push(r); continue; }
642
+ Object.assign(r, c.id === "claude-code" ? planClaude(c, opts.uninstall) : c.id === "codex" ? planCodex(c, opts.uninstall) : planJSON(c, opts.uninstall));
643
+ if (c.rules && !opts.noRules && r.state !== "fail") {
644
+ const before = readText(c.rules), after = withRules(before, opts.uninstall);
645
+ if (after !== (before ?? "") && !(opts.uninstall && before === null)) { r.ops.push({ type: "write", file: c.rules, text: after, rules: true }); r.rulesChanged = true; }
646
+ r.rulesFile = c.rules;
647
+ }
648
+ out.push(r);
649
+ }
650
+ return out;
651
+ }
652
+
653
+ function stamp() { const d = new Date(), p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; }
654
+ function runCmd(argv) {
655
+ const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", shell: process.platform === "win32", timeout: 60000 });
656
+ return { ok: r.status === 0, out: ((r.stderr || "") + (r.stdout || "")).trim() || (r.error ? r.error.message : "") };
657
+ }
658
+ export function apply(items) {
659
+ const ts = stamp();
660
+ for (const r of items) {
661
+ r.backups = [];
662
+ try {
663
+ for (const op of r.ops) {
664
+ if (op.type === "write") {
665
+ mkdirSync(dirname(op.file), { recursive: true });
666
+ if (existsSync(op.file)) { const b = op.file + ".bak-owlmem-" + ts; copyFileSync(op.file, b); r.backups.push(b); }
667
+ writeFileSync(op.file, op.text);
668
+ } else {
669
+ let res = runCmd(op.argv);
670
+ if (!res.ok && op.retryWith && /already exists/i.test(res.out)) { runCmd(op.retryWith.argv); res = runCmd(op.argv); }
671
+ if (!res.ok && !op.ignoreFail) throw new Error("`" + op.argv.join(" ") + "` failed: " + (res.out.split("\n").slice(-3).join(" ") || "no output"));
672
+ }
673
+ }
674
+ } catch (e) { r.state = "fail"; r.note = (e.message || String(e)) + " — set it up by hand: " + r.docs; }
675
+ }
676
+ return items;
677
+ }
678
+
679
+ const tilde = (f) => { const h = home(); return f && h && f.startsWith(h + (platform() === "win32" ? "\\" : "/")) ? "~" + f.slice(h.length) : f; };
680
+ const SYM = { ok: "✓", add: "✓", update: "↺", remove: "✓", absent: "–", missing: "–", fail: "✗" };
681
+ function describe(r, done, uninstall) {
682
+ const verb = {
683
+ ok: "already set up", add: done ? "added" : "will add OwlMem", update: done ? "updated" : "will update the owlmem entry",
684
+ remove: done ? "removed" : "will remove OwlMem", absent: "not configured", missing: "not installed", fail: "failed",
685
+ }[r.state];
686
+ const sym = !done && r.state === "add" ? "+" : !done && r.state === "remove" ? "-" : SYM[r.state];
687
+ const lines = [`${sym} ${r.name.padEnd(17)} ${verb}${r.state !== "missing" && r.file && r.state !== "fail" ? " (" + tilde(r.file) + ")" : ""}`];
688
+ if (r.note) lines.push(" " + r.note);
689
+ for (const op of r.ops) {
690
+ if (op.type === "run" && !done) lines.push(" run: " + op.argv.join(" "));
691
+ if (op.rules) lines.push(` ${done ? "" : "will "}${uninstall ? (done ? "removed" : "remove") : (done ? "wrote" : "write")} the OwlMem rules block in ${tilde(op.file)}`);
692
+ }
693
+ if (done && r.backups?.length) lines.push(" backup: " + r.backups.join(", "));
694
+ return lines.join("\n");
695
+ }
696
+
697
+ const LOGIN = {
698
+ "claude-code": "Claude Code: run /mcp, pick owlmem, choose Authenticate.",
699
+ codex: "Codex: run `codex mcp login owlmem`.",
700
+ cursor: "Cursor: Settings → MCP, click \"Needs login\" next to owlmem.",
701
+ vscode: "VS Code: it asks you to sign in the first time Copilot uses owlmem (or run \"MCP: List Servers\" → owlmem → Start).",
702
+ "vscode-insiders": "VS Code Insiders: same as VS Code.",
703
+ windsurf: "Windsurf: open Settings → MCP servers and refresh; sign in when it asks.",
704
+ gemini: "Gemini CLI: run `/mcp auth owlmem`.",
705
+ "claude-desktop": "Claude Desktop: restart it; the sign-in page opens on the first start (via mcp-remote).",
706
+ };
707
+
708
+ async function install(args) {
709
+ const opts = { dryRun: false, yes: false, noRules: false, uninstall: false, only: null, wake: null };
710
+ for (let i = 0; i < args.length; i++) {
711
+ const a = args[i];
712
+ if (a === "--dry-run") opts.dryRun = true;
713
+ else if (a === "--yes" || a === "-y") opts.yes = true;
714
+ else if (a === "--no-rules") opts.noRules = true;
715
+ else if (a === "--uninstall") opts.uninstall = true;
716
+ else if (a === "--wake") opts.wake = true;
717
+ else if (a === "--no-wake") opts.wake = false;
718
+ else if (a === "--only" || a.startsWith("--only=")) {
719
+ const v = a.includes("=") ? a.slice(7) : args[++i];
720
+ opts.only = String(v || "").split(",").map((s) => s.trim()).filter(Boolean);
721
+ const bad = opts.only.filter((x) => !CLIENT_IDS.includes(x));
722
+ if (bad.length || !opts.only.length) throw new Fail(`--only takes a comma-separated list of: ${CLIENT_IDS.join(", ")}`);
723
+ } else if (a === "--help" || a === "-h") { console.log(INSTALL_HELP); return; }
724
+ else throw new Fail("Unknown option " + a + "\n\n" + INSTALL_HELP);
725
+ }
726
+ const items = plan(opts);
727
+ const changes = items.filter((r) => r.ops.length);
728
+ const out = (s = "") => console.log(s);
729
+ out(opts.uninstall ? "OwlMem uninstall — " + (opts.dryRun ? "dry run, nothing is changed" : "plan") : "OwlMem install — connects the AI apps on this computer to " + MCP_URL + (opts.dryRun ? " (dry run, nothing is changed)" : ""));
730
+ out();
731
+ for (const r of items) out(describe(r, false, opts.uninstall));
732
+ out();
733
+ if (!changes.length) {
734
+ out(items.some((r) => r.state === "ok") || opts.uninstall ? "Nothing to change." : "No supported AI apps found. Manual setup for every app: https://owlmem.com/docs");
735
+ if (!opts.uninstall && items.some((r) => r.state === "ok")) nextSteps(items, out);
736
+ process.exitCode = items.some((r) => r.state === "fail") ? 1 : 0;
737
+ if (!opts.dryRun) await installWake(opts, items, out);
738
+ return;
739
+ }
740
+ if (opts.dryRun) { out(`Dry run: ${changes.length} app(s) would change. Run again without --dry-run to apply.`); return; }
741
+ const tty = process.stdin.isTTY && process.stdout.isTTY;
742
+ if (tty && !opts.yes) {
743
+ const ans = (await ask("Apply these changes? [Y/n] ", false)).toLowerCase();
744
+ if (ans && !ans.startsWith("y")) { out("Nothing changed."); return; }
745
+ }
746
+ apply(changes);
747
+ out();
748
+ for (const r of changes) out(describe(r, true, opts.uninstall));
749
+ out();
750
+ if (opts.uninstall) out("OwlMem was removed from those apps. Backups of every changed file sit next to it (*.bak-owlmem-*).");
751
+ else {
752
+ nextSteps(items, out);
753
+ const codex = items.find((r) => r.id === "codex" && r.state !== "fail" && r.state !== "missing");
754
+ if (codex && tty && !opts.yes && onPath("codex") && browserAllowed()) { // codex mcp login opens a browser: same rule as openBrowser
755
+ const ans = (await ask("Sign Codex in now? This runs `codex mcp login owlmem` and opens your browser. [y/N] ", false)).toLowerCase();
756
+ if (ans.startsWith("y")) await new Promise((res) => spawn("codex", ["mcp", "login", "owlmem"], { stdio: "inherit", shell: process.platform === "win32" }).on("exit", res).on("error", res));
757
+ }
758
+ }
759
+ await installWake(opts, items, out);
760
+ if (items.some((r) => r.state === "fail")) process.exitCode = 1;
761
+ }
762
+
763
+ // `owlmem install` asks once whether to turn on auto-wake (--wake: yes without asking; --yes alone: no). `uninstall` removes it.
764
+ async function installWake(opts, items, out) {
765
+ try {
766
+ if (opts.uninstall) { await wakeDisable(true); return; }
767
+ if (opts.wake === false || loadWake()) return;
768
+ if (!items.some((r) => (r.id === "claude-code" || r.id === "codex") && ["ok", "add", "update"].includes(r.state))) { if (opts.wake) out("Auto-wake: needs Claude Code or Codex on this computer."); return; }
769
+ let on = opts.wake === true;
770
+ if (!on && !opts.yes && process.stdin.isTTY && process.stdout.isTTY)
771
+ on = (await ask("\nStart your AI automatically when it gets a task (auto-wake, runs in the background)? [y/N] ", false)).toLowerCase().startsWith("y");
772
+ if (!on) { if (!opts.yes) out("Auto-wake is off. Turn it on any time: npx -y owlmem wake enable"); return; }
773
+ out(); await wakeEnable([]);
774
+ } catch (e) { out("Auto-wake: " + (e instanceof Fail ? e.message : e?.message || e)); process.exitCode = 1; }
775
+ }
776
+
777
+ function nextSteps(items, out) {
778
+ const live = items.filter((r) => ["ok", "add", "update"].includes(r.state));
779
+ if (!live.length) return;
780
+ out("Next: sign in once in each app. The first time an app uses OwlMem it opens the owlmem.com sign-in page —");
781
+ out("sign in with your email (new accounts get a free space) and click \"Allow\". An AI can't click it for you.");
782
+ for (const r of live) out(" • " + LOGIN[r.id]);
783
+ if (live.some((r) => ["cursor", "vscode", "vscode-insiders", "windsurf"].includes(r.id)))
784
+ out("Cursor, VS Code and Windsurf have no global rules file: add the rules from https://owlmem.com/docs#instructions to your project rules.");
785
+ if (live.some((r) => r.id === "claude-desktop")) out("Claude Desktop: restart it to load OwlMem. (Or add https://mcp.owlmem.com/mcp under Settings → Connectors instead.)");
786
+ out("Already-running sessions don't see new MCP servers: start a new session, then ask your AI to run kb_search.");
787
+ }
788
+
789
+ // ── auto-wake: start the AI on this computer when it gets a task ──
790
+ // One background program per user per computer (`owlmem wake run`, started at login by systemd --user / a LaunchAgent / a
791
+ // logon task) long-polls OwlMem. When an agent paired with this computer gets a task, it runs that agent's command line
792
+ // unattended (`claude -p`, `codex exec`) with the task as the prompt. The woken AI uses its own OwlMem connection (the one
793
+ // `owlmem install` set up), so it acts as itself. It gets no extra permissions: no --dangerously-* flags; anything that
794
+ // needs a person, it declines with what the person has to do.
795
+ // This computer is identified by its device key (device.json); the pairing is approved in the console, where each AI has an
796
+ // auto-wake switch and the computer can be removed. If OwlMem answers 401 (computer removed, AI disconnected, account gone)
797
+ // the program removes its own autostart entry and exits.
798
+ export const WAKE_CLIENTS = {
799
+ "claude-code": { name: "Claude Code", bin: "claude",
800
+ // -p runs one prompt and exits; it may only use the two OwlMem tools it needs to report back, beyond what your own settings allow
801
+ command: ["claude", "-p", "--allowedTools", "mcp__owlmem__kb_task_update,mcp__owlmem__kb_inbox"], prompt: "stdin" },
802
+ codex: { name: "Codex", bin: "codex", command: ["codex", "exec", "--skip-git-repo-check", "-"], prompt: "stdin" },
803
+ };
804
+ export const WAKE_DEFAULTS = { max_parallel: 2, timeout_min: 30 };
805
+ const WAKE_UNIT = "owlmem-wake.service", WAKE_LABEL = "com.owlmem.wake", WAKE_TASK = "OwlMem Wake", WAKE_RUNKEY = "OwlMemWake";
806
+ const wakeDir = (dir = DIR) => join(dir, "wake");
807
+ const wakeFile = (dir = DIR) => join(wakeDir(dir), "wake.json");
808
+ export function loadWake(dir = DIR) { try { return JSON.parse(readFileSync(wakeFile(dir), "utf8")); } catch { return null; } }
809
+ function saveWake(dir, c) { saveFile(wakeFile(dir), JSON.stringify(c, null, 2) + "\n"); }
810
+
811
+ // Where the autostart entry goes and what it says. o: {platform, home, dir, node, script, path, uid, user, boot}
812
+ const xml = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
813
+ const unitq = (s) => '"' + String(s).replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
814
+ export function autostart(o) {
815
+ const p = o.platform, args = [o.node, o.script, "wake", "run"];
816
+ if (p === "linux") {
817
+ const file = join(o.xdg || join(o.home, ".config"), "systemd", "user", WAKE_UNIT);
818
+ const text = `[Unit]
819
+ Description=OwlMem auto-wake (starts your AI when it gets a task)
820
+ Documentation=https://owlmem.com/docs#wake
821
+ After=network-online.target
822
+
823
+ [Service]
824
+ Type=simple
825
+ ExecStart=${args.map(unitq).join(" ")}
826
+ Environment=${unitq("OWLMEM_HOME=" + o.dir)}
827
+ Environment=${unitq("PATH=" + o.path)}
828
+ Restart=on-failure
829
+ RestartSec=30
830
+
831
+ [Install]
832
+ WantedBy=default.target
833
+ `;
834
+ const sc = (...a) => ["systemctl", "--user", ...a];
835
+ return { kind: "systemd", file, files: [{ file, text }],
836
+ start: [{ argv: sc("daemon-reload") }, { argv: sc("enable", "--now", WAKE_UNIT) },
837
+ ...(o.boot ? [{ argv: ["loginctl", "enable-linger", o.user], ignoreFail: true, note: "boot" }] : [])],
838
+ stop: [{ argv: sc("disable", "--now", WAKE_UNIT), ignoreFail: true }],
839
+ selfStop: [{ argv: sc("disable", WAKE_UNIT), ignoreFail: true }], // from inside the service: don't stop ourselves mid-cleanup
840
+ after: [{ argv: sc("daemon-reload"), ignoreFail: true }] };
841
+ }
842
+ if (p === "darwin") {
843
+ const file = join(o.home, "Library", "LaunchAgents", WAKE_LABEL + ".plist");
844
+ const log = join(wakeDir(o.dir), "wake.out.log");
845
+ const text = `<?xml version="1.0" encoding="UTF-8"?>
846
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
847
+ <plist version="1.0">
848
+ <dict>
849
+ <key>Label</key><string>${WAKE_LABEL}</string>
850
+ <key>ProgramArguments</key>
851
+ <array>${args.map((a) => `<string>${xml(a)}</string>`).join("")}</array>
852
+ <key>EnvironmentVariables</key>
853
+ <dict><key>OWLMEM_HOME</key><string>${xml(o.dir)}</string><key>PATH</key><string>${xml(o.path)}</string></dict>
854
+ <key>RunAtLoad</key><true/>
855
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
856
+ <key>ThrottleInterval</key><integer>30</integer>
857
+ <key>StandardOutPath</key><string>${xml(log)}</string>
858
+ <key>StandardErrorPath</key><string>${xml(log)}</string>
859
+ </dict>
860
+ </plist>
861
+ `;
862
+ return { kind: "launchd", file, files: [{ file, text }],
863
+ start: [{ argv: ["launchctl", "bootstrap", "gui/" + o.uid, file], fallback: { argv: ["launchctl", "load", "-w", file] } }],
864
+ stop: [{ argv: ["launchctl", "bootout", "gui/" + o.uid + "/" + WAKE_LABEL], ignoreFail: true }],
865
+ selfStop: [], after: [] };
866
+ }
867
+ if (p === "win32") {
868
+ const vbs = join(wakeDir(o.dir), "owlmem-wake.vbs");
869
+ const vq = (s) => '""' + String(s).replace(/"/g, '""""') + '""';
870
+ const text = `' OwlMem auto-wake: runs "owlmem wake run" without a console window (started at logon)\r
871
+ Set sh = CreateObject("WScript.Shell")\r
872
+ sh.Environment("PROCESS")("OWLMEM_HOME") = "${String(o.dir).replace(/"/g, '""')}"\r
873
+ sh.Run "${args.map(vq).join(" ")}", 0, False\r
874
+ `;
875
+ const tr = `wscript.exe "${vbs}"`;
876
+ const runKey = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
877
+ return { kind: "schtasks", file: vbs, files: [{ file: vbs, text }],
878
+ start: [{ argv: ["schtasks", "/Create", "/TN", WAKE_TASK, "/SC", "ONLOGON", "/TR", tr, "/RL", "LIMITED", "/F"],
879
+ fallback: { argv: ["reg", "add", runKey, "/v", WAKE_RUNKEY, "/t", "REG_SZ", "/d", tr, "/f"] } },
880
+ { argv: ["wscript.exe", vbs], ignoreFail: true, detach: true }],
881
+ stop: [{ argv: ["schtasks", "/End", "/TN", WAKE_TASK], ignoreFail: true }, { argv: ["schtasks", "/Delete", "/TN", WAKE_TASK, "/F"], ignoreFail: true },
882
+ { argv: ["reg", "delete", runKey, "/v", WAKE_RUNKEY, "/f"], ignoreFail: true }],
883
+ selfStop: [{ argv: ["schtasks", "/Delete", "/TN", WAKE_TASK, "/F"], ignoreFail: true }, { argv: ["reg", "delete", runKey, "/v", WAKE_RUNKEY, "/f"], ignoreFail: true }],
884
+ after: [] };
885
+ }
886
+ throw new Fail("Auto-wake isn't supported on " + p + ".");
887
+ }
888
+ // The autostart entry outlives this run: under Nix (and similar) process.execPath is a versioned store path that
889
+ // disappears after the next system upgrade, so prefer a stable path that resolves to the same node binary.
890
+ export function stableNode(execPath = process.execPath, candidates = ["/run/current-system/sw/bin/node", join(home(), ".nix-profile", "bin", "node"), "/usr/local/bin/node", "/usr/bin/node", onPath("node")]) {
891
+ let real; try { real = realpathSync(execPath); } catch { return execPath; }
892
+ for (const c of candidates) { try { if (c && c !== execPath && realpathSync(c) === real) return c; } catch {} }
893
+ return execPath;
894
+ }
895
+ function autostartOpts(dir = DIR, extra = {}) {
896
+ let uid = "", user = "";
897
+ try { const u = userInfo(); uid = String(u.uid); user = u.username; } catch {}
898
+ return { platform: platform(), home: home(), xdg: process.env.XDG_CONFIG_HOME, dir, node: stableNode(), script: join(wakeDir(dir), "owlmem.mjs"),
899
+ path: process.env.PATH || "", uid, user, ...extra };
900
+ }
901
+ // run(argv, op) → {ok, out}; op.detach: start it and don't wait (the Windows launcher)
902
+ function wakeRunCmd(argv, op = {}) {
903
+ if (!op.detach) return runCmd(argv);
904
+ try { spawn(argv[0], argv.slice(1), { stdio: "ignore", detached: true }).on("error", () => {}).unref(); return { ok: true, out: "" }; } catch (e) { return { ok: false, out: e.message }; }
905
+ }
906
+ function runOps(ops, run) {
907
+ const out = [];
908
+ for (const op of ops) {
909
+ let r = run(op.argv, op);
910
+ if (!r.ok && op.fallback) r = run(op.fallback.argv);
911
+ out.push({ argv: op.argv, ...r });
912
+ if (!r.ok && !op.ignoreFail) throw new Fail("`" + op.argv.join(" ") + "` failed: " + (r.out || "").split("\n").slice(-2).join(" "));
913
+ }
914
+ return out;
915
+ }
916
+ export function installAutostart(o, run = wakeRunCmd) {
917
+ const a = autostart(o);
918
+ for (const f of a.files) { mkdirSync(dirname(f.file), { recursive: true }); writeFileSync(f.file, f.text); }
919
+ return { ...a, ran: runOps(a.start, run) };
920
+ }
921
+ // Remove the autostart entry. self: called by the running program itself (after a 401), so it doesn't stop its own service first.
922
+ export function removeAutostart(o, run = wakeRunCmd, self = false) {
923
+ const a = autostart(o);
924
+ const ran = runOps(self ? a.selfStop : a.stop, run);
925
+ for (const f of a.files) rmSync(f.file, { force: true });
926
+ ran.push(...runOps(a.after, run));
927
+ return { ...a, ran };
928
+ }
929
+ // Everything local except the work itself: autostart entry, wake.json, the program copy. Worktrees and logs stay (they hold the AI's work).
930
+ export function wakeUninstall(o, run = wakeRunCmd, self = false) {
931
+ const r = removeAutostart(o, run, self);
932
+ for (const f of ["wake.json", "owlmem.mjs", "owlmem-wake.vbs"]) rmSync(join(wakeDir(o.dir), f), { force: true });
933
+ return r;
934
+ }
935
+
936
+ // ── a task's working directory ──
937
+ // A task can name one with a line like "cwd: ~/Projects/app" (also dir:, workdir:, 目录:). Git repository → a separate
938
+ // worktree on its own branch (owlmem/task-<id>), so two tasks in one project don't collide; the AI commits there and doesn't merge.
939
+ // Plain directory → one task at a time in it. No directory → an empty folder of its own (OwlMem-only work).
940
+ const DIR_RE = /^[ \t>*-]*(?:cwd|dir|directory|workdir|working directory|目录|工作目录)[ \t]*[::=][ \t]*`?([^`\r\n]+?)`?[ \t]*$/im;
941
+ export function taskDir(body, h = home()) {
942
+ const m = DIR_RE.exec(String(body || ""));
943
+ if (!m) return null;
944
+ let d = m[1].trim().replace(/^["']|["']$/g, "");
945
+ if (d === "~" || d.startsWith("~/") || d.startsWith("~\\")) d = join(h, d.slice(2));
946
+ return isAbsolute(d) ? d : null;
947
+ }
948
+ function gitRun(args) { const r = spawnSync("git", args, { encoding: "utf8", timeout: 60000 }); return { ok: r.status === 0, out: ((r.stdout || "") + (r.stderr || "")).trim() }; }
949
+ export function planWorkdir(task, h = home(), git = gitRun) {
950
+ const d = taskDir(task.body, h);
951
+ if (!d) return { kind: "scratch" };
952
+ let st = null; try { st = statSync(d); } catch {}
953
+ if (!st || !st.isDirectory()) return { kind: "missing", dir: d };
954
+ const real = realpathSync(d);
955
+ const top = git(["-C", real, "rev-parse", "--show-toplevel"]);
956
+ if (top.ok && top.out) return { kind: "worktree", dir: real, top: realpathSync(top.out.split("\n")[0].trim()) };
957
+ return { kind: "dir", dir: real, lock: real };
958
+ }
959
+ export function prepareWorkdir(task, plan, dir = DIR, git = gitRun) {
960
+ if (plan.kind === "missing") throw new Fail("the task's directory doesn't exist on this computer: " + plan.dir);
961
+ if (plan.kind === "scratch") { const cwd = join(wakeDir(dir), "scratch", "task-" + task.id); mkdirSync(cwd, { recursive: true }); return { cwd }; }
962
+ if (plan.kind === "dir") return { cwd: plan.dir };
963
+ const branch = "owlmem/task-" + task.id, wt = join(wakeDir(dir), "worktrees", basename(plan.top) + "-task-" + task.id);
964
+ if (!existsSync(wt)) {
965
+ mkdirSync(dirname(wt), { recursive: true });
966
+ let r = git(["-C", plan.top, "worktree", "add", "-b", branch, wt, "HEAD"]);
967
+ if (!r.ok && /already exists/i.test(r.out)) r = git(["-C", plan.top, "worktree", "add", wt, branch]);
968
+ if (!r.ok) throw new Fail("couldn't make a git worktree in " + plan.top + ": " + r.out.split("\n").slice(-1)[0]);
969
+ }
970
+ const rel = relative(plan.top, plan.dir);
971
+ return { cwd: rel ? join(wt, rel) : wt, worktree: wt, branch, repo: plan.top };
972
+ }
973
+
974
+ export function wakePrompt(task, place, o = {}) {
975
+ const id = task.id, mins = o.timeout_min || WAKE_DEFAULTS.timeout_min;
976
+ const where = place.worktree
977
+ ? `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.`
978
+ : place.scratch ? `There is no project directory for this task: you are in an empty folder (${place.cwd}). Use the OwlMem tools.`
979
+ : `Work in ${place.cwd}. Other tasks wait until you finish here.`;
980
+ return `OwlMem auto-wake started you on ${o.machine || "this computer"} to handle one task. Nobody is watching this session and nobody can answer questions.
981
+
982
+ Task #${id}, for ${task.agent}${task.broadcast ? " (sent to every agent; it is yours once you take it)" : ""}, from ${task.from}:
983
+ Title: ${task.title}
984
+
985
+ ${task.body || "(no details)"}
986
+
987
+ Rules:
988
+ 1. First call kb_task_update with id=${id} and status="doing". If the OwlMem tools aren't available, or OwlMem says the task isn't yours or is already taken, stop there and do nothing else.
989
+ 2. ${where}
990
+ 3. When you're finished, call kb_task_update with id=${id}, status="done" and note = the result (what you did, where it is).
991
+ 4. If you can't do it — including anything that needs a person to approve, sign in, enter a code or confirm — call kb_task_update with id=${id}, status="declined" and note = what a person needs to do. Don't try to get around a permission you don't have.
992
+ 5. Never put passwords, tokens, keys or other secrets in the note or in any message.
993
+ 6. You have ${mins} minutes. After that this session is stopped and the task is handed to a person.
994
+ `;
995
+ }
996
+
997
+ // ── running a task ──
998
+ export async function runTask(task, cfg, o = {}) {
999
+ const dir = o.dir || DIR, cl = (cfg.clients || {})[task.client];
1000
+ if (!cl || !Array.isArray(cl.command) || !cl.command.length) return { outcome: "error", detail: "no command for " + task.client + " on this computer (wake.json)" };
1001
+ let place;
1002
+ try { const plan = o.plan || planWorkdir(task, o.home || home(), o.git); place = prepareWorkdir(task, plan, dir, o.git); if (plan.kind === "scratch") place.scratch = true; }
1003
+ catch (e) { return { outcome: "error", detail: e.message }; }
1004
+ const prompt = wakePrompt(task, place, { machine: cfg.machine, timeout_min: cfg.timeout_min });
1005
+ const argv = [...cl.command, ...(cl.prompt === "arg" ? [prompt] : [])];
1006
+ mkdirSync(join(wakeDir(dir), "logs"), { recursive: true });
1007
+ const logPath = join(wakeDir(dir), "logs", "task-" + task.id + ".log");
1008
+ const fd = openSync(logPath, "a");
1009
+ const win = (o.platform || platform()) === "win32";
1010
+ const ms = o.timeoutMs ?? (cfg.timeout_min || WAKE_DEFAULTS.timeout_min) * 60000;
1011
+ try {
1012
+ return await new Promise((res) => {
1013
+ let ch, timedOut = false, killer = null;
1014
+ try {
1015
+ ch = spawn(argv[0], argv.slice(1), { cwd: place.cwd, stdio: ["pipe", fd, fd], detached: !win, shell: win, windowsHide: true,
1016
+ env: { ...process.env, OWLMEM_NO_BROWSER: "1", OWLMEM_WAKE_TASK: String(task.id) } });
1017
+ } catch (e) { res({ outcome: "error", detail: "couldn't start " + argv[0] + ": " + e.message }); return; }
1018
+ 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 {} } };
1019
+ const timer = setTimeout(() => { timedOut = true; kill("SIGTERM"); killer = setTimeout(() => kill("SIGKILL"), 10000); }, ms);
1020
+ ch.on("error", (e) => { clearTimeout(timer); clearTimeout(killer); res({ outcome: "error", detail: (e.code === "ENOENT" ? "command not found: " : "couldn't start: ") + argv[0] }); });
1021
+ ch.on("close", (code, sig) => { clearTimeout(timer); clearTimeout(killer); res(timedOut ? { outcome: "timeout", code } : { outcome: "exit", code: code ?? (sig ? 128 : null) }); });
1022
+ ch.stdin.on("error", () => {});
1023
+ ch.stdin.end(cl.prompt === "arg" ? "" : prompt);
1024
+ });
1025
+ } finally { closeSync(fd); }
1026
+ }
1027
+
1028
+ // One at a time per AI, one at a time per plain directory, at most `max` at once on the computer. start(job) → Promise<result>.
1029
+ export class Scheduler {
1030
+ constructor({ max = WAKE_DEFAULTS.max_parallel, start, onDone } = {}) {
1031
+ Object.assign(this, { max, start, onDone, queue: [], running: new Set(), agents: new Set(), locks: new Set() });
1032
+ }
1033
+ add(job) { this.queue.push(job); this.pump(); }
1034
+ pump() {
1035
+ for (let i = 0; i < this.queue.length && this.running.size < this.max;) {
1036
+ const j = this.queue[i];
1037
+ if (this.agents.has(j.agent) || (j.lock && this.locks.has(j.lock))) { i++; continue; }
1038
+ this.queue.splice(i, 1); this.running.add(j); this.agents.add(j.agent); if (j.lock) this.locks.add(j.lock);
1039
+ Promise.resolve().then(() => this.start(j)).catch((e) => ({ outcome: "error", detail: e?.message || String(e) })).then((res) => {
1040
+ this.running.delete(j); this.agents.delete(j.agent); if (j.lock) this.locks.delete(j.lock);
1041
+ try { this.onDone?.(j, res); } catch {}
1042
+ this.pump();
1043
+ });
1044
+ }
1045
+ }
1046
+ busy() { return [...new Set([...this.agents, ...this.queue.map((j) => j.agent)])]; }
1047
+ slots() { return Math.max(0, this.max - this.running.size - this.queue.length); }
1048
+ get size() { return this.running.size + this.queue.length; }
1049
+ }
1050
+
1051
+ // The long-poll loop. api(sub, body) → {status, data}. Returns "revoked" after cleaning up on a 401.
1052
+ export async function wakeLoop(o) {
1053
+ const { cfg, api, sched, log = () => {}, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), once = false, cleanup } = o;
1054
+ let backoff = 0;
1055
+ for (;;) {
1056
+ const t0 = Date.now();
1057
+ let r;
1058
+ try { r = await api("poll", { busy: sched.busy(), slots: sched.slots(), timeout: (cfg.timeout_min || WAKE_DEFAULTS.timeout_min) * 60, wait: 50, version: VERSION }); }
1059
+ catch (e) { r = { status: 0, data: { detail: e?.message || String(e) } }; }
1060
+ if (r.status === 401) {
1061
+ log("OwlMem no longer accepts this computer for auto-wake (" + (r.data?.detail || r.data?.error || "401") + "). Removing auto-wake from this computer.");
1062
+ if (cleanup) cleanup();
1063
+ return "revoked";
1064
+ }
1065
+ if (r.status !== 200) { // network trouble or a server hiccup: try again, waiting longer each time (up to 5 minutes)
1066
+ backoff = Math.min(300000, backoff ? backoff * 2 : 2000);
1067
+ log("Can't reach OwlMem (" + (r.data?.detail || "HTTP " + r.status) + "); trying again in " + Math.round(backoff / 1000) + " s.");
1068
+ if (once) return "error";
1069
+ await sleep(backoff); continue;
1070
+ }
1071
+ backoff = 0;
1072
+ for (const t of r.data.tasks || []) {
1073
+ const plan = o.plan ? o.plan(t) : planWorkdir(t);
1074
+ log("Task #" + t.id + " for " + t.agent + ": starting " + t.client + (plan.dir ? " in " + plan.dir : "") + ".");
1075
+ sched.add({ id: t.id, agent: t.agent, lock: plan.lock || null, task: t, plan });
1076
+ }
1077
+ if (once) return r.data.status;
1078
+ if (Date.now() - t0 < 1000 && !(r.data.tasks || []).length) await sleep(1000); // never spin
1079
+ }
1080
+ }
1081
+
1082
+ function wakeApi() {
1083
+ return async (sub, body) => {
1084
+ const url = BASE + "/v1/wake/" + sub;
1085
+ let r;
1086
+ try {
1087
+ const sh = await signHeaders("POST", url, "other");
1088
+ r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": "owlmem-cli/" + VERSION, ...sh },
1089
+ body: JSON.stringify(body || {}), signal: AbortSignal.timeout(90000) });
1090
+ } catch (e) { return { status: 0, data: { detail: e.message } }; }
1091
+ return { status: r.status, data: await r.json().catch(() => ({})) };
1092
+ };
1093
+ }
1094
+
1095
+ function wakeLogger(dir = DIR) {
1096
+ const f = join(wakeDir(dir), "wake.log");
1097
+ return (msg) => {
1098
+ const line = new Date().toISOString() + " " + msg + "\n";
1099
+ try { mkdirSync(wakeDir(dir), { recursive: true }); if (existsSync(f) && statSync(f).size > 1 << 20) copyFileSync(f, f + ".1"), writeFileSync(f, ""); appendFileSync(f, line); } catch {}
1100
+ process.stderr.write(line);
1101
+ };
1102
+ }
1103
+
1104
+ async function wakeRun() {
1105
+ const cfg = loadWake();
1106
+ if (!cfg) throw new Fail("Auto-wake isn't set up on this computer. Run: npx -y owlmem wake enable");
1107
+ const log = wakeLogger(), api = wakeApi();
1108
+ const report = async (j, res) => {
1109
+ log("Task #" + j.id + ": " + res.outcome + (res.code != null ? " (exit code " + res.code + ")" : "") + (res.detail ? " — " + res.detail : ""));
1110
+ for (let i = 0; i < 5; i++) {
1111
+ const r = await api("report", { id: j.id, outcome: res.outcome, code: res.code ?? null, detail: res.detail || "" });
1112
+ if (r.status === 200 || r.status === 404 || r.status === 401) return;
1113
+ await new Promise((ok) => setTimeout(ok, 5000 * (i + 1)));
1114
+ }
1115
+ };
1116
+ 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); } });
1117
+ log("OwlMem auto-wake " + VERSION + " running on " + (cfg.machine || hostname()) + ".");
1118
+ const out = await wakeLoop({ cfg, api, sched, log, cleanup: () => { try { wakeUninstall(autostartOpts(), wakeRunCmd, true); } catch (e) { log("cleanup: " + e.message); } } });
1119
+ if (out === "revoked") process.exit(0);
1120
+ }
1121
+
1122
+ async function wakeEnable(args = []) {
1123
+ const boot = args.includes("--boot"), again = args.includes("--again");
1124
+ const num = (name, lo, hi) => { const i = args.indexOf(name); if (i < 0) return undefined; const n = Number(args[i + 1]); if (!Number.isInteger(n) || n < lo || n > hi) throw new Fail(`${name} takes a whole number from ${lo} to ${hi}.`); return n; };
1125
+ const max = num("--max", 1, 16), timeout = num("--timeout", 1, 240);
1126
+ const old = loadWake() || {};
1127
+ const clients = { ...(old.clients || {}) };
1128
+ for (const [id, c] of Object.entries(WAKE_CLIENTS)) if (!clients[id] && onPath(c.bin)) clients[id] = { command: c.command, prompt: c.prompt };
1129
+ if (!Object.keys(clients).length)
1130
+ throw new Fail("No AI command line found on this computer that auto-wake can start (Claude Code: claude, Codex: codex). Install one, or add its command to " + wakeFile() + " under \"clients\".");
1131
+ 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 };
1132
+ // OwlMem knows these app names; a command you add yourself to wake.json goes under "gemini" or "custom"
1133
+ const r = await wakeApi()("pair", { clients: Object.keys(clients).filter((c) => ["claude-code", "codex", "gemini", "custom"].includes(c)), force: again });
1134
+ if (r.status !== 200) throw new Fail("OwlMem didn't accept the pairing: " + (r.data.detail || r.data.error || "HTTP " + r.status));
1135
+ mkdirSync(wakeDir(), { recursive: true });
1136
+ copyFileSync(fileURLToPath(import.meta.url), join(wakeDir(), "owlmem.mjs")); // the service runs this copy (npx's cache can be cleaned out)
1137
+ if (r.data.status === "pending") cfg.pair_url = r.data.url; else delete cfg.pair_url;
1138
+ saveWake(DIR, cfg);
1139
+ const a = installAutostart(autostartOpts(DIR, { boot: cfg.boot }));
1140
+ say("Auto-wake is installed (" + a.kind + ": " + tilde(a.file) + ").");
1141
+ 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.");
1142
+ if (r.data.status === "pending") {
1143
+ 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.");
1144
+ openBrowser(r.data.url);
1145
+ } 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).");
1146
+ }
1147
+
1148
+ function wakeDisable(quiet = false) {
1149
+ const had = !!loadWake();
1150
+ const o = autostartOpts();
1151
+ const present = existsSync(autostart(o).file);
1152
+ if (!had && !present) { if (!quiet) say("Auto-wake isn't set up on this computer."); return false; }
1153
+ const api = wakeApi();
1154
+ return api("bye", {}).catch(() => null).then(() => {
1155
+ wakeUninstall(o);
1156
+ say("Auto-wake removed from this computer (autostart entry and settings). Worktrees and logs it made stay in " + tilde(wakeDir()) + ".");
1157
+ return true;
1158
+ });
1159
+ }
1160
+
1161
+ async function wakeStatus() {
1162
+ const cfg = loadWake(), a = autostart(autostartOpts());
1163
+ if (!cfg) { say("Auto-wake isn't set up on this computer. Turn it on: npx -y owlmem wake enable"); return; }
1164
+ say("Autostart: " + (existsSync(a.file) ? a.kind + " (" + tilde(a.file) + ")" : "missing"));
1165
+ say("Apps: " + Object.entries(cfg.clients || {}).map(([k, v]) => k + " → " + v.command.join(" ")).join("; "));
1166
+ say("At most " + cfg.max_parallel + " at once, " + cfg.timeout_min + " minutes per task. Log: " + tilde(join(wakeDir(), "wake.log")));
1167
+ const r = await wakeApi()("poll", { wait: 0, slots: 0 });
1168
+ if (r.status === 401) say("OwlMem: this computer isn't paired any more. Pair it again: npx -y owlmem wake enable --again");
1169
+ else if (r.status !== 200) say("OwlMem: can't reach it (" + (r.data.detail || "HTTP " + r.status) + ")");
1170
+ else if (r.data.status === "pending") say("OwlMem: waiting for approval in the console" + (cfg.pair_url ? ": " + cfg.pair_url : "") + " (code " + r.data.code + ")");
1171
+ else say("OwlMem: paired as " + r.data.code + ". " + r.data.agents.map((x) => x.agent + " (" + x.client + (x.wake ? "" : ", switched off") + ")").join(", "));
1172
+ }
1173
+
1174
+ const WAKE_HELP = `Usage: npx -y owlmem wake <enable|disable|status>
1175
+
1176
+ Auto-wake starts the AI on this computer when it gets an OwlMem task (Claude Code with \`claude -p\`, Codex with
1177
+ \`codex exec\`), without you opening it. It runs in the background from login, for this user only.
1178
+
1179
+ enable [--boot] [--max N] [--timeout MIN] [--again]
1180
+ install it and pair this computer (approve in the console). --boot (Linux): run from boot,
1181
+ not just login. --max: tasks at once (default 2). --timeout: minutes per task (default 30).
1182
+ --again: pair again (choose the AIs again).
1183
+ disable remove it from this computer (also done by npx -y owlmem uninstall)
1184
+ status show what's set up and what OwlMem says
1185
+
1186
+ Turn it off for one AI with the switch in the console (AI agents → Agents). Settings, including the command for
1187
+ each app ("claude-code", "codex", or your own under "gemini" / "custom"): ~/.config/owlmem/wake/wake.json`;
1188
+
1189
+ async function wakeCmd(sub, args) {
1190
+ if (sub === "enable") return wakeEnable(args);
1191
+ if (sub === "disable") return wakeDisable();
1192
+ if (sub === "status") return wakeStatus();
1193
+ if (sub === "run") return wakeRun();
1194
+ console.log(WAKE_HELP);
1195
+ if (sub && sub !== "help" && sub !== "--help") process.exitCode = 2;
1196
+ }
1197
+
1198
+ const INSTALL_HELP = `Usage: npx -y owlmem install [options]
1199
+
1200
+ Connects Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI and Claude Desktop on this computer to
1201
+ OwlMem (${MCP_URL}). Config files are merged, never overwritten; each changed file is backed up first
1202
+ as <file>.bak-owlmem-<time>. Also adds the OwlMem usage rules to CLAUDE.md / AGENTS.md / GEMINI.md.
1203
+
1204
+ --dry-run show what would change, change nothing
1205
+ --yes, -y don't ask (non-interactive runs, e.g. an AI running this, never ask)
1206
+ --only a,b only these apps: ${CLIENT_IDS.join(", ")}
1207
+ --no-rules don't touch CLAUDE.md / AGENTS.md / GEMINI.md
1208
+ --uninstall remove OwlMem entries and the rules block (with backups), and auto-wake
1209
+ --wake also turn on auto-wake: start your AI when it gets a task (without --wake, --yes leaves it off)
1210
+ --no-wake don't ask about auto-wake
1211
+
1212
+ Signing in happens in each app the first time it uses OwlMem (standard MCP OAuth): you click "Allow" once.`;
1213
+
437
1214
  export async function main(argv) {
438
1215
  const [cmd, sub, ...rest] = argv;
439
1216
  if (cmd === "--version" || cmd === "version") { console.log(VERSION); return; }
1217
+ if (cmd === "install") { await install(argv.slice(1)); return; }
1218
+ if (cmd === "uninstall") { await install(["--uninstall", ...argv.slice(1)]); return; }
440
1219
  if (cmd === "mcp") { await mcpServe(); return; }
1220
+ if (cmd === "wake") { await wakeCmd(sub, rest); return; }
1221
+ if (cmd === "device") { // register this machine for standing grants and show its fingerprint
1222
+ const r = await call("POST", "device", {});
1223
+ if (r.status >= 400) throw new Fail(r.data.detail || r.data.error || "HTTP " + r.status);
1224
+ say("This machine: " + (process.env.OWLMEM_DEVICE_NAME || hostname()));
1225
+ console.log(r.data.device);
1226
+ say("Registered for " + r.data.agent + ". In the OwlMem console (Vault → Standing grants) pick the machine with this fingerprint.");
1227
+ return;
1228
+ }
441
1229
  if (cmd === "login") {
442
1230
  const t = (process.env.OWLMEM_TOKEN || await ask("Paste this agent's token (om_…, shown as dots), then Enter: ")).replace(/\x1b\[[0-9;]*[~A-Za-z]/g, "").replace(/[^A-Za-z0-9_-]/g, "");
443
1231
  if (!/^om_[A-Za-z0-9_-]{20,80}$/.test(t)) throw new Fail("That doesn't look like an OwlMem agent token (om_…).");
@@ -455,13 +1243,13 @@ export async function main(argv) {
455
1243
  if (opts[i] !== "--env") throw new Fail("Unknown option " + opts[i]);
456
1244
  const m = /^([A-Za-z_][A-Za-z0-9_]*)=([^:]+)(?::(.+))?$/.exec(opts[++i] || "");
457
1245
  if (!m) throw new Fail("Use --env VAR=item or --env VAR=item:field");
458
- env[m[1]] = pick(await fetchItem(m[2]), m[3] || (undefined));
1246
+ env[m[1]] = pick(await fetchItem(m[2], "run"), m[3] || (undefined));
459
1247
  }
460
1248
  const child = spawn(command[0], command.slice(1), { stdio: "inherit", env });
461
1249
  await new Promise((res) => child.on("exit", (code, sig) => { process.exitCode = sig ? 1 : code; res(); }));
462
1250
  return;
463
1251
  }
464
- if (cmd !== "vault") { say(readFileSync(new URL(import.meta.url), "utf8").split("\n").filter((l) => l.startsWith("//")).slice(0, 26).map((l) => l.slice(3)).join("\n")); process.exitCode = cmd ? 2 : 0; return; }
1252
+ if (cmd !== "vault") { { const ls = readFileSync(new URL(import.meta.url), "utf8").split("\n").slice(1); say(ls.slice(0, ls.findIndex((l) => !l.startsWith("//"))).map((l) => l.slice(3)).join("\n")); } process.exitCode = cmd ? 2 : 0; return; }
465
1253
  const args = [...rest];
466
1254
  if (sub === "keygen") {
467
1255
  const sp = process.env.OWLMEM_SPACE || (await space().catch(() => ({}))).space;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "owlmem",
3
- "version": "0.4.1",
4
- "description": "OwlMem command line: end-to-end encrypted vault for your AI agents, and a local MCP server.",
3
+ "version": "0.7.0",
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" },
7
7
  "files": ["owlmem.mjs", "README.md", "LICENSE"],