@opennous/cli 0.15.0 → 0.17.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @opennous/cli changelog
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Added: `runner`, reading on your own plan all the time
6
+
7
+ `backfill` reads your history once. `runner` keeps going: it reads your new calls and email on your
8
+ own Claude or ChatGPT plan as they come in, with the same no-tools sandbox. `runner install` starts it
9
+ now and every time you log in (launchd on macOS, systemd on Linux); `runner status` and
10
+ `runner uninstall` check on it and remove it. When your plan hits its limit it waits and carries on
11
+ by itself. The app's Settings → Your plan shows it as connected (GET-1289).
12
+
13
+ ## 0.16.0
14
+
15
+ ### Added: your Intelligence report after `backfill`
16
+
17
+ When the read is done, `backfill` writes your Intelligence report: private to you, built from what it
18
+ read. Its executive summary, recommended next steps and objection responses are written by your own model, from the report's data only,
19
+ with no tools. The command prints the link. If that step fails, the report is still there (GET-1199).
20
+
3
21
  ## 0.15.0
4
22
 
5
23
  ### Added: Slack in `backfill`
package/README.md CHANGED
@@ -1,16 +1,27 @@
1
1
  # @opennous/cli
2
2
 
3
- Nous CLI — set Nous up, connect it to your coding agent, and read the Context API from your terminal.
3
+ Ownable CLI — set Ownable up, connect it to your coding agent, and read the Context API from your terminal.
4
4
 
5
5
  ## Get started
6
6
 
7
- One command. It signs you in (creating your account if you don't have one), registers the Nous MCP server with the agent on your machine, and hands off. The same command on macOS, Linux, and Windows (needs [Node 18+](https://nodejs.org)):
7
+ One command. It signs you in (creating your account if you don't have one), registers the Ownable MCP server with the agent on your machine, and hands off. The same command on macOS, Linux, and Windows (needs [Node 18+](https://nodejs.org)):
8
8
 
9
9
  ```bash
10
10
  npx @opennous/cli@latest init
11
11
  ```
12
12
 
13
- Then tell your agent *"set up my Nous workspace"* — it reads your project, finds your ICP (or drafts one), and syncs it. On self-host, add `--url https://api.yourdomain.com`.
13
+ Then tell your agent *"set up my Ownable workspace"* — it reads your project, finds your ICP (or drafts one), and syncs it. On self-host, add `--url https://api.yourdomain.com`.
14
+
15
+ ## Read on your own plan
16
+
17
+ Reading your calls and email runs on your own Claude Code or Codex plan, not on credits:
18
+
19
+ ```bash
20
+ npx @opennous/cli@latest backfill # your history, once
21
+ npx @opennous/cli@latest runner install # what's new, from now on; starts at login
22
+ ```
23
+
24
+ `runner status` says whether it's running, `runner uninstall` stops it. Settings → Your plan in the app shows each runner and what's waiting to be read.
14
25
 
15
26
  ## Event tracking
16
27
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@opennous/cli",
3
- "version": "0.15.0",
4
- "description": "Nous CLI — set up Nous, connect it to your coding agent, and read the Context API from your terminal",
3
+ "version": "0.17.0",
4
+ "description": "Ownable CLI — set up Ownable, connect it to your coding agent, and read the Context API from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "nous": "src/index.js"
package/src/backfill.js CHANGED
@@ -105,12 +105,41 @@ export function estimateLine(days, counts) {
105
105
  return `Last ${days} days: about ${counts?.email_thread ?? 0} email threads, ${counts?.meeting ?? 0} meetings, ${counts?.call ?? 0} calls${slack}.`;
106
106
  }
107
107
 
108
+ /**
109
+ * "5 to review: https://…/review (12 not on file)", or null when there is nothing to decide. Pure.
110
+ * To review is Probably mentions plus open groups (GET-1152); a mention nothing on file could be is
111
+ * not on file and never counted. An older server sends only `mentions`.
112
+ */
113
+ export function reviewLine(review) {
114
+ const n = Number(review?.to_review ?? review?.mentions ?? 0);
115
+ if (!Number.isFinite(n) || n <= 0) return null;
116
+ const quiet = Number(review?.not_on_file ?? 0);
117
+ return `${n} to review${review?.url ? `: ${review.url}` : ""}${quiet > 0 ? ` (${quiet} not on file)` : ""}`;
118
+ }
119
+
120
+ /**
121
+ * Who each person is, before anything is read (GET-1159): the people worth a look, as a short table,
122
+ * with where to confirm them. Null when there is nothing to show. Pure.
123
+ */
124
+ export function proposalsLines(rel) {
125
+ if (!rel || !(rel.judged > 0)) return null;
126
+ const pct = (c) => `${Math.round(Number(c ?? 0) * 100)}%`;
127
+ const lines = [`Who's who: ${rel.judged} people judged, ${rel.to_confirm ?? 0} worth a look.`];
128
+ const top = (rel.top ?? []).slice(0, 15);
129
+ const w = Math.min(28, Math.max(4, ...top.map((p) => String(p.name ?? "Unknown").length)));
130
+ for (const p of top) lines.push(` ${String(p.name ?? "Unknown").slice(0, w).padEnd(w)} ${String(p.group ?? "").padEnd(14)} ${pct(p.confidence).padStart(4)} ${p.reason ?? ""}`.trimEnd());
131
+ if (rel.url) lines.push(`Confirm or fix them: ${rel.url}`);
132
+ lines.push(rel.waiting ? "Waiting for you to confirm before reading (or Ctrl-C and run with --yes)." : rel.mode === "yes" ? "Accepted as proposed." : "Reading now; fixing someone later re-reads their calls.");
133
+ return lines;
134
+ }
135
+
108
136
  /** Nothing to read in this window. Pure. */
109
137
  export const emptyEstimate = (counts) => !Object.values(counts ?? {}).some((n) => n > 0);
110
138
 
111
139
  /** One line on how far the job is, or null when there is nothing to say yet. Pure. */
112
140
  export function progressLine(reading, answered, rerun = RERUN) {
113
141
  if (reading?.finished) return "Wrapping up: last summaries for your accounts.";
142
+ if (reading?.kind === "relationships") return `Working out who each person is, before reading. ${`Stop any time with Ctrl-C; run \`${rerun}\` again to carry on.`}`;
114
143
  const stop = `Stop any time with Ctrl-C; run \`${rerun}\` again to carry on.`;
115
144
  const what = { meetings: "meetings", slack: "Slack conversations" }[reading?.kind] ?? "email threads";
116
145
  if (reading?.total) return `Read ${reading.done} of ${reading.total} ${what}. ${stop}`;
@@ -124,7 +153,7 @@ export function progressLine(reading, answered, rerun = RERUN) {
124
153
  * is saved, and unanswered calls come back to the next run.
125
154
  */
126
155
  export async function runBackfill({ api, agent, jobId, run, sleep, log, now = Date.now, workers = WORKERS, limitWaitMs = LIMIT_WAIT_MS, pollMs = POLL_MS, progressMs = PROGRESS_MS, rerun = RERUN }) {
127
- let waitUntil = 0, answered = 0, lastLine = null, lastAt = -Infinity;
156
+ let waitUntil = 0, answered = 0, lastLine = null, lastAt = -Infinity, shownProposals = false;
128
157
  const say = (line) => {
129
158
  if (!line || (line === lastLine && now() - lastAt < progressMs) || now() - lastAt < Math.min(progressMs, 1000)) return;
130
159
  lastLine = line; lastAt = now(); log(line);
@@ -149,10 +178,15 @@ export async function runBackfill({ api, agent, jobId, run, sleep, log, now = Da
149
178
  };
150
179
  for (;;) {
151
180
  const page = await api.calls(jobId, workers);
152
- if (page.status === "done") return { status: "done", answered, read: page.reading?.read ?? 0, failed: page.reading?.failed ?? 0 };
181
+ if (page.status === "done") return { status: "done", answered, read: page.reading?.read ?? 0, failed: page.reading?.failed ?? 0, ...(page.memory_review ? { memoryReview: page.memory_review } : {}) };
153
182
  if (page.status === "failed") throw new Error(page.error || "The backfill failed on our side.");
154
183
  // The server gave up waiting for us (a long sleep, say). We're back, so pick it up.
155
184
  if (page.status === "paused_own_model") { await api.resume?.(); await sleep(pollMs); continue; }
185
+ // Phase 1's proposals (GET-1159), once, as soon as they are in.
186
+ if (!shownProposals && page.relationships?.judged > 0) {
187
+ shownProposals = true;
188
+ for (const l of proposalsLines(page.relationships) ?? []) log(l);
189
+ }
156
190
  say(progressLine(page.reading, answered, rerun));
157
191
  if (!page.calls.length) { await sleep(pollMs); continue; }
158
192
  await Promise.all(page.calls.map((c) => answerOne(c, page.models)));
@@ -164,3 +198,21 @@ export function liveRun() {
164
198
  const cwd = mkdtempSync(join(tmpdir(), "nous-backfill-"));
165
199
  return (command) => runCommand(command, cwd);
166
200
  }
201
+
202
+ /**
203
+ * The Intelligence report (GET-1199), once the read is done. The server builds it from what was
204
+ * filed; its Summary and Recommended actions are then written by your own model, from the report's
205
+ * data only, with no tools. If that fails the report keeps the ones code wrote. Returns its link,
206
+ * or null. Injected like runBackfill: `api` ({ report, writing }), `run`.
207
+ */
208
+ export async function writeReport({ api, agent, jobId, run }) {
209
+ const r = await api.report(jobId);
210
+ const models = r?.models?.[agent];
211
+ if (r?.writing && models) {
212
+ try {
213
+ const { text } = parseAnswer(agent, await run(commandFor(agent, models, r.writing)));
214
+ await api.writing(jobId, { answer: text, model: models.model });
215
+ } catch { /* the report stands as code wrote it */ }
216
+ }
217
+ return r?.url ?? null;
218
+ }
package/src/certHint.js CHANGED
@@ -1,4 +1,4 @@
1
- // A self-hosted Nous on a laptop serves https://api.localhost with Caddy's own local CA, which
1
+ // A self-hosted Ownable on a laptop serves https://api.localhost with Caddy's own local CA, which
2
2
  // Node does not trust. fetch then rejects with a bare "fetch failed" whose cause carries one of
3
3
  // these codes. Name the fix in one line instead of printing a stack trace (GET-1011).
4
4
  const UNTRUSTED_CERT_CODES = new Set([
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // Nous ICP auto-sync reminder (installed by `npx @opennous/cli init`).
2
+ // Ownable ICP auto-sync reminder (installed by `npx @opennous/cli init`).
3
3
  //
4
- // A Claude Code PostToolUse hook. After the agent edits a Nous context file, it
4
+ // A Claude Code PostToolUse hook. After the agent edits an Ownable context file, it
5
5
  // reminds the agent that the file is INERT until synced: the app, the ICP tab, and
6
- // the ICP scoring model read the Nous GRAPH, not the repo file, so an edit does
6
+ // the ICP scoring model read the Ownable GRAPH, not the repo file, so an edit does
7
7
  // nothing until mcp__nous__sync_icp runs. Standalone Node (no bash/jq) so it behaves
8
8
  // identically on Windows, macOS, and Linux.
9
9
 
@@ -25,8 +25,8 @@ process.stdin.on("end", () => {
25
25
  const base = norm.split("/").pop().toLowerCase();
26
26
 
27
27
  const ctx = base === "icp.md"
28
- ? `You just edited ${rel}, your Nous ICP file. It is INERT until synced: the app, the ICP tab, and the ICP scoring model read the Nous graph, not this file. Call the mcp__nous__sync_icp tool THIS TURN with section "ICP" and source_path "${rel}", passing the file body as the section content, so the graph and the scoring model rebuild from your edit. Do not end the turn with the ICP file edited but unsynced.`
29
- : `You edited a Nous context file (${rel}). It stays inert until synced into the Nous graph. If it is an ICP, positioning, pricing, competitors, or market file, call mcp__nous__sync_icp this turn with the matching section and its source_path so the graph updates.`;
28
+ ? `You just edited ${rel}, your Ownable ICP file. It is INERT until synced: the app, the ICP tab, and the ICP scoring model read the Ownable graph, not this file. Call the mcp__nous__sync_icp tool THIS TURN with section "ICP" and source_path "${rel}", passing the file body as the section content, so the graph and the scoring model rebuild from your edit. Do not end the turn with the ICP file edited but unsynced.`
29
+ : `You edited an Ownable context file (${rel}). It stays inert until synced into the Ownable graph. If it is an ICP, positioning, pricing, competitors, or market file, call mcp__nous__sync_icp this turn with the matching section and its source_path so the graph updates.`;
30
30
 
31
31
  process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: ctx } }));
32
32
  } catch { /* never block a tool call */ }
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // OpenNous CLI — the setup + auth utility. NOT a data surface.
3
+ // Ownable CLI — the setup + auth utility. NOT a data surface.
4
4
  //
5
5
  // It signs you in (browser device flow → key in ~/.nous/config.json) and installs the
6
6
  // opennous plugin into your agent (`init`). All data work happens in the agent through
@@ -120,7 +120,8 @@ function apiClient(key) {
120
120
  process.exit(1);
121
121
  }
122
122
 
123
- async function request(method, path, { body, query } = {}) {
123
+ // soft: a failure throws instead of ending the process, for a step that may fail on its own.
124
+ async function request(method, path, { body, query, soft = false } = {}) {
124
125
  const url = new URL(path, apiUrl);
125
126
  if (query) {
126
127
  for (const [k, v] of Object.entries(query)) {
@@ -135,11 +136,13 @@ function apiClient(key) {
135
136
  body: body ? JSON.stringify(body) : undefined,
136
137
  });
137
138
  } catch (e) {
139
+ if (soft) throw e;
138
140
  console.error(certHint(e) || `Couldn't reach ${apiUrl}: ${e.message}`);
139
141
  process.exit(1);
140
142
  }
141
143
  if (!res.ok) {
142
144
  const err = await res.json().catch(() => ({ message: res.statusText }));
145
+ if (soft) throw new Error(err.message || err.error || `HTTP ${res.status}`);
143
146
  console.error(`Error: ${err.message || err.error}`);
144
147
  process.exit(1);
145
148
  }
@@ -150,6 +153,7 @@ function apiClient(key) {
150
153
  return {
151
154
  get: (path, query) => request("GET", path, { query }),
152
155
  post: (path, body) => request("POST", path, { body }),
156
+ softPost: (path, body) => request("POST", path, { body, soft: true }),
153
157
  };
154
158
  }
155
159
 
@@ -163,7 +167,7 @@ program
163
167
  .addCommand(
164
168
  new Command("login")
165
169
  .description("Save your API key")
166
- .requiredOption("--key <key>", "Your Nous API key")
170
+ .requiredOption("--key <key>", "Your Ownable API key")
167
171
  .option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
168
172
  .action(({ key, url }) => {
169
173
  const cfg = readConfig();
@@ -205,7 +209,7 @@ program
205
209
  // ---------------------------------------------------------------------------
206
210
  // You're already signed in (the saved key identifies you), so there's no device-code
207
211
  // dance — we start a Composio consent, open it in your browser, and poll until the
208
- // account goes ACTIVE. Composio holds the OAuth token; Nous never sees it.
212
+ // account goes ACTIVE. Composio holds the OAuth token; Ownable never sees it.
209
213
  const CONNECT_ALIASES = {
210
214
  gmail: "gmail_oauth",
211
215
  gmail_oauth: "gmail_oauth",
@@ -329,11 +333,11 @@ program
329
333
  });
330
334
 
331
335
  // ---------------------------------------------------------------------------
332
- // Connect OpenNous to whatever agent is on this machine — host-aware.
336
+ // Connect Ownable to whatever agent is on this machine — host-aware.
333
337
  //
334
338
  // The MCP server (7 primitives) is the universal capability: any MCP host runs `npx @opennous/mcp`.
335
339
  // But the SKILLS + routing are the product surface, and those are delivered per host:
336
- // • Claude Code → the OpenNous PLUGIN (skills + routing hooks + the trimmed MCP, one install).
340
+ // • Claude Code → the Ownable PLUGIN (skills + routing hooks + the trimmed MCP, one install).
337
341
  // • Any other host (Cursor, Codex, Claude Desktop, …) → the trimmed MCP via a `.mcp.json`;
338
342
  // each host's skills pack ships as we light it up.
339
343
  // So `init` installs the plugin on Claude Code, and writes the MCP config everywhere else — one
@@ -388,7 +392,7 @@ function writeJsonMcp(path, apiUrl) {
388
392
  const PLUGIN_MARKETPLACE = "NousC/revenue-plugin";
389
393
  const PLUGIN_ID = "opennous@opennous";
390
394
 
391
- // Claude Code → install the OpenNous PLUGIN (skills + routing hooks + the trimmed MCP, in one).
395
+ // Claude Code → install the Ownable PLUGIN (skills + routing hooks + the trimmed MCP, in one).
392
396
  // We try the plugin CLI; if this Claude build has no `plugin` subcommand it returns null and the
393
397
  // caller prints the two slash-commands instead. We deliberately do NOT fall back to a bare
394
398
  // `claude mcp add` — a standalone MCP server would duplicate the plugin's and drop the skills.
@@ -404,11 +408,11 @@ function installClaudePlugin() {
404
408
  function registerMcpServer(apiUrl) {
405
409
  const path = join(process.cwd(), ".mcp.json");
406
410
  writeJsonMcp(path, apiUrl);
407
- return `Wrote ${path} — your agent will pick up OpenNous (7 primitives) from it.`;
411
+ return `Wrote ${path} — your agent will pick up Ownable (7 primitives) from it.`;
408
412
  }
409
413
 
410
414
  // Install the ICP auto-sync hook into the project's Claude Code config. Once in place,
411
- // editing a Nous context file (context/**/icp.md, etc.) reminds the agent to call
415
+ // editing an Ownable context file (context/**/icp.md, etc.) reminds the agent to call
412
416
  // sync_icp that turn — because an edited ICP file is INERT until synced into the graph.
413
417
  // Copies our packaged hook script into .claude/hooks/ and merges the PostToolUse entry
414
418
  // into .claude/settings.json WITHOUT clobbering any existing hooks or settings.
@@ -447,7 +451,7 @@ function installIcpSyncHook() {
447
451
  // This is the command the install script and the marketing site point to.
448
452
  program
449
453
  .command("init")
450
- .description("Get started — sign in and connect Nous to your agent")
454
+ .description("Get started — sign in and connect Ownable to your agent")
451
455
  .option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
452
456
  .option("--no-register", "Skip registering the MCP server (just sign in)")
453
457
  .action(async ({ url, register }) => {
@@ -460,41 +464,41 @@ program
460
464
  let installed = false;
461
465
  try { installed = installClaudePlugin(); } catch { /* fall through to instructions */ }
462
466
  if (installed) {
463
- console.log("\n✓ Installed the OpenNous plugin into Claude Code — skills, routing, and the 8 primitives.");
464
- console.log(" Open Claude Code and OpenNous starts onboarding automatically.");
467
+ console.log("\n✓ Installed the Ownable plugin into Claude Code — skills, routing, and the 8 primitives.");
468
+ console.log(" Open Claude Code and Ownable starts onboarding automatically.");
465
469
  console.log(dim(" (Already have it open? Start a new session so the skills load.)"));
466
470
  } else {
467
- console.log("\nDetected Claude Code. Install the OpenNous plugin (skills + hooks + tools):");
471
+ console.log("\nDetected Claude Code. Install the Ownable plugin (skills + hooks + tools):");
468
472
  console.log(" /plugin marketplace add NousC/revenue-plugin");
469
473
  console.log(" /plugin install opennous@opennous");
470
- console.log(dim(" Then open Claude Code — OpenNous starts onboarding on its own."));
474
+ console.log(dim(" Then open Claude Code — Ownable starts onboarding on its own."));
471
475
  }
472
476
  } else {
473
477
  try {
474
478
  console.log(`\n${registerMcpServer(apiUrl)}`);
475
479
  } catch {
476
- console.log("\nCouldn't write .mcp.json automatically. Add OpenNous to your host's MCP config:");
480
+ console.log("\nCouldn't write .mcp.json automatically. Add Ownable to your host's MCP config:");
477
481
  console.log(` command: ${MCP_CMD} args: ${MCP_ARGS.join(" ")} env: NOUS_SURFACE=plugin`);
478
482
  }
479
483
  }
480
484
 
481
485
  // Auto-sync the ICP: a Claude Code hook so editing your ICP file reminds the
482
- // agent to sync it into Nous (the file is inert until synced). Best-effort —
486
+ // agent to sync it into Ownable (the file is inert until synced). Best-effort —
483
487
  // a convenience, never a reason to fail setup.
484
488
  try {
485
489
  const already = installIcpSyncHook();
486
- if (!already) console.log("Installed the ICP auto-sync hook — edits to your ICP file now sync to Nous automatically.");
490
+ if (!already) console.log("Installed the ICP auto-sync hook — edits to your ICP file now sync to Ownable automatically.");
487
491
  } catch { /* non-fatal */ }
488
492
  }
489
493
 
490
- console.log("\n✅ Signed in. Open Claude Code — OpenNous will walk you through onboarding on its own.");
494
+ console.log("\n✅ Signed in. Open Claude Code — Ownable will walk you through onboarding on its own.");
491
495
  console.log("\n" + dim("Docs: https://docs.opennous.cloud/mcp/introduction"));
492
496
  });
493
497
 
494
498
  // nous pull — write the Vault into ./context/*.md and hand authorship to the repo.
495
499
  //
496
500
  // The bridge for the Google road. A user who set up in the browser has a Vault whose author
497
- // is Nous (source 'nous', no file). The day they open a repo and connect an agent, that
501
+ // is Ownable (source 'nous', no file). The day they open a repo and connect an agent, that
498
502
  // arrangement is backwards: they now live in files, and the Vault should mirror the files,
499
503
  // not the other way round. `pull` flips it — it writes each context doc to disk and re-syncs
500
504
  // it WITH its path, which sets source='claude_code'. From then on their repo is the author,
@@ -506,7 +510,7 @@ const PULL_KINDS = { icp: "icp.md" };
506
510
 
507
511
  program
508
512
  .command("pull")
509
- .description("Write your Nous context docs into ./context and let your repo own them")
513
+ .description("Write your Ownable context docs into ./context and let your repo own them")
510
514
  .option("--dir <path>", "Where to write the context/ folder (default: cwd)", process.cwd())
511
515
  .option("--force", "Overwrite files that already exist", false)
512
516
  .action(async ({ dir, force }) => {
@@ -542,7 +546,7 @@ program
542
546
 
543
547
  console.log(`\n✓ ${written} written${skipped ? `, ${skipped} skipped` : ""}. Your repo now owns your context.`);
544
548
  if (synced < written) console.log(dim(" (some didn't sync back — re-run `nous pull` to finish)"));
545
- console.log(dim("Edit these files and your agent syncs them with sync_foundation. Nous mirrors them."));
549
+ console.log(dim("Edit these files and your agent syncs them with sync_foundation. Ownable mirrors them."));
546
550
  });
547
551
 
548
552
  // nous logout — top-level alias of `auth logout`. Removes the saved API key.
@@ -552,10 +556,11 @@ program
552
556
  .option("--agent <agent>", "claude or codex (default: whichever you're signed in to)")
553
557
  .option("--days <days>", "how many days back to read, 1 to 730", "180")
554
558
  .option("--workspace <id>", "the workspace to read; the app's command fills this in")
555
- .option("--yes", "start without asking")
559
+ .option("--yes", "start without asking, and accept who each person is as proposed")
560
+ .option("--confirm", "wait until you confirm who each person is in Routing before reading")
556
561
  .option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
557
562
  .action(async (opts) => {
558
- const { detectAgents, runBackfill, liveRun, emptyEstimate, estimateLine, rerunCommand, NO_AGENT } = await import("./backfill.js");
563
+ const { detectAgents, runBackfill, liveRun, emptyEstimate, estimateLine, rerunCommand, reviewLine, writeReport, NO_AGENT } = await import("./backfill.js");
559
564
  const days = Number(opts.days);
560
565
  if (!Number.isInteger(days) || days < 1 || days > 730) {
561
566
  console.error("--days must be a whole number from 1 to 730.");
@@ -602,7 +607,7 @@ program
602
607
  }
603
608
  console.log(`\nWorkspace: ${workspace}`);
604
609
  console.log(estimateLine(days, est.counts));
605
- console.log(`They're filtered first, then read on your ${agent} plan. None of it uses your OpenNous credits.`);
610
+ console.log(`They're filtered first, then read on your ${agent} plan. None of it uses your Ownable credits.`);
606
611
  console.log("You can stop at any time and run this again: it carries on where it stopped.\n");
607
612
  if (!opts.yes) {
608
613
  const { createInterface } = await import("node:readline/promises");
@@ -611,25 +616,152 @@ program
611
616
  rl.close();
612
617
  if (!ok) return;
613
618
  }
614
- const job = await api.post("/v2/backfill/own", { days });
619
+ // Who each person is (GET-1159): read with the proposals by default; --confirm waits for you, --yes accepts them.
620
+ const confirm = opts.confirm ? "wait" : opts.yes ? "yes" : "proceed";
621
+ const job = await api.post("/v2/backfill/own", { days, confirm });
615
622
  console.log(job.resumed ? "Picking up where you left off. Stop any time with Ctrl-C." : `Started. Stop any time with Ctrl-C; run \`${rerun}\` again to carry on.`);
616
623
  process.once("SIGINT", () => {
617
624
  console.log(`\nStopped. Everything read so far is saved. Run \`${rerun}\` to carry on.`);
618
625
  process.exit(130);
619
626
  });
627
+ const run = liveRun();
620
628
  const result = await runBackfill({
621
- agent, jobId: job.job_id, run: liveRun(), log: console.log, rerun,
629
+ agent, jobId: job.job_id, run, log: console.log, rerun,
622
630
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
623
631
  api: {
624
632
  calls: (id, limit) => api.get(`/v2/backfill/own/${id}/calls`, { limit }),
625
633
  answer: (id, body) => api.post(`/v2/backfill/own/calls/${id}`, body),
626
- resume: () => api.post("/v2/backfill/own", { days }),
634
+ resume: () => api.post("/v2/backfill/own", { days, confirm }),
627
635
  },
628
636
  });
629
637
  console.log(`Done. This run: ${result.read} conversations read, ${result.answered} model calls on your ${agent} plan.`);
630
638
  if (result.failed) console.log(`${result.failed} conversations couldn't be read. Run \`${rerun}\` again to retry them.`);
639
+ const review = reviewLine(result.memoryReview);
640
+ if (review) console.log(review);
641
+ // The Intelligence report (GET-1199): private to you, its summary written on your plan.
642
+ console.log("Writing your Intelligence report…");
643
+ const reportUrl = await writeReport({
644
+ agent, jobId: job.job_id, run,
645
+ api: {
646
+ report: (id) => api.softPost(`/v2/backfill/own/${id}/report`, {}),
647
+ writing: (id, body) => api.softPost(`/v2/backfill/own/${id}/report/writing`, body),
648
+ },
649
+ }).catch(() => null);
650
+ if (reportUrl) console.log(`Your Intelligence report is ready: ${reportUrl}`);
651
+ });
652
+
653
+ // `nous runner` (GET-1293): keeps reading your new calls and email on your own plan. `install`
654
+ // starts it at login; the app's "Your plan" page shows it as connected once it has polled.
655
+ const runnerCmd = program
656
+ .command("runner")
657
+ .description("Keep reading your new calls and email on your own Claude or ChatGPT plan")
658
+ .option("--agent <agent>", "claude or codex (default: whichever you're signed in to)")
659
+ .option("--workspace <id>", "the workspace to read; the app's command fills this in")
660
+ .option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
661
+ .action(async (opts) => {
662
+ const { detectAgents, liveRun, NO_AGENT } = await import("./backfill.js");
663
+ const { runRunner } = await import("./runner.js");
664
+ const agent = await pickAgent(opts.agent, detectAgents, NO_AGENT);
665
+ const key = await runnerKey(opts);
666
+ const api = apiClient(key);
667
+ const service = process.env.NOUS_RUNNER_SERVICE === "1";
668
+ const stamp = (line) => (service ? `${new Date().toISOString()} ${line}` : line);
669
+ console.log(stamp(`Reading on your ${agent} plan. ${service ? "" : "Stop with Ctrl-C; `nous runner install` keeps it going on its own."}`.trim()));
670
+ await runRunner({
671
+ agent, version, run: liveRun(), log: (l) => console.log(stamp(l)),
672
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
673
+ api: {
674
+ poll: (body) => api.softPost("/v2/runner/poll", body),
675
+ answer: (id, body) => api.softPost(`/v2/runner/calls/${id}`, body),
676
+ },
677
+ });
678
+ });
679
+
680
+ runnerCmd
681
+ .command("install")
682
+ .description("Start the runner now and every time you log in")
683
+ .option("--agent <agent>", "claude or codex (default: whichever you're signed in to)")
684
+ .option("--workspace <id>", "the workspace to read; the app's command fills this in")
685
+ .option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
686
+ .action(async (opts) => {
687
+ const { detectAgents, NO_AGENT } = await import("./backfill.js");
688
+ const { installService } = await import("./runner.js");
689
+ const agent = await pickAgent(opts.agent, detectAgents, NO_AGENT);
690
+ const key = await runnerKey(opts);
691
+ // One poll before installing, so a bad key or plan fails here and not silently in the background.
692
+ try {
693
+ await apiClient(key).softPost("/v2/runner/poll", { agent, host: hostname(), version, limit: 0 });
694
+ } catch (e) {
695
+ console.error(`Couldn't connect: ${e.message}`);
696
+ process.exit(1);
697
+ }
698
+ try {
699
+ const where = installService({ agent, workspace: opts.workspace ?? null });
700
+ console.log(`Connected. The runner reads on your ${agent} plan and starts every time you log in.`);
701
+ if (where.log) console.log(`Log: ${where.log}`);
702
+ console.log("Check on it with `npx @opennous/cli@latest runner status`. Stop it with `npx @opennous/cli@latest runner uninstall`.");
703
+ } catch (e) {
704
+ console.error(e.message);
705
+ process.exit(1);
706
+ }
631
707
  });
632
708
 
709
+ runnerCmd
710
+ .command("uninstall")
711
+ .description("Stop the runner and stop it starting at login")
712
+ .action(async () => {
713
+ const { uninstallService } = await import("./runner.js");
714
+ console.log(uninstallService() ? "Stopped. It won't start at login any more." : "No runner is installed on this machine.");
715
+ });
716
+
717
+ runnerCmd
718
+ .command("status")
719
+ .description("Whether the runner is installed and running on this machine")
720
+ .action(async () => {
721
+ const { serviceStatus } = await import("./runner.js");
722
+ const s = serviceStatus();
723
+ if (!s.installed) { console.log("Not installed. Run `npx @opennous/cli@latest runner install`."); return; }
724
+ console.log(s.running ? "Running." : "Installed, but not running right now.");
725
+ if (s.log) console.log(`Log: ${s.log}`);
726
+ });
727
+
728
+ /** The agent to read with: the one asked for, or whichever is signed in. Exits with what to do. */
729
+ async function pickAgent(asked, detectAgents, noAgent) {
730
+ const agents = detectAgents();
731
+ const agent = asked ?? agents[0];
732
+ if (!agent || !agents.includes(agent)) {
733
+ console.error(asked ? `You're not signed in to ${asked}. Run \`${asked === "codex" ? "codex login" : "claude"}\` first.` : noAgent);
734
+ process.exit(1);
735
+ }
736
+ return agent;
737
+ }
738
+
739
+ /** The key the runner reads with: for --workspace when given (signing in to it if needed), else the saved one. */
740
+ async function runnerKey(opts) {
741
+ if (opts.url) writeConfig({ ...readConfig(), apiUrl: opts.url });
742
+ if (!hasKey()) await browserLogin(opts.url);
743
+ const cfg = readConfig();
744
+ const apiUrl = opts.url || process.env.NOUS_API_URL || cfg.apiUrl || "https://api.opennous.cloud";
745
+ let key = process.env.NOUS_API_KEY || cfg.apiKey;
746
+ if (!opts.workspace) return key;
747
+ const kept = cfg.workspaceKeys?.[opts.workspace];
748
+ for (const k of [key, kept].filter(Boolean)) {
749
+ if ((await fetchWhoami(apiUrl, k))?.workspace?.id === opts.workspace) {
750
+ if (k !== kept) writeConfig({ ...cfg, workspaceKeys: { ...cfg.workspaceKeys, [opts.workspace]: k } });
751
+ return k;
752
+ }
753
+ }
754
+ console.log("Sign in to the workspace this runner is for.");
755
+ key = await browserLogin(opts.url, { save: false });
756
+ if ((await fetchWhoami(apiUrl, key))?.workspace?.id !== opts.workspace) {
757
+ console.error("That sign-in is for another workspace. Open the right workspace in the app, then run this again.");
758
+ process.exit(1);
759
+ }
760
+ const saved = readConfig();
761
+ writeConfig({ ...saved, workspaceKeys: { ...saved.workspaceKeys, [opts.workspace]: key } });
762
+ return key;
763
+ }
764
+
633
765
  program
634
766
  .command("logout")
635
767
  .description("Sign out — remove the saved API key")
package/src/runner.js ADDED
@@ -0,0 +1,205 @@
1
+ // `nous runner` (GET-1293): reads your new calls and email on your own Claude or ChatGPT plan, all
2
+ // the time. It is `nous backfill`'s loop that never ends: it polls the server, answers each model
3
+ // call with the `claude` or `codex` CLI you are signed in to (no tools, the same sandbox as backfill:
4
+ // a call's prompt holds other people's words), and posts the answer back. The server decides what to
5
+ // read; this only answers. `nous runner install` starts it at login, so it keeps going on its own.
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
9
+ import { homedir, hostname, platform } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { commandFor, parseAnswer, isPlanLimit } from "./backfill.js";
12
+
13
+ export const WORKERS = 3;
14
+ /** Busy: there is a read going, so ask again soon. */
15
+ export const BUSY_MS = 2000;
16
+ /** Idle: nothing going. A heartbeat still has to land well inside the server's two minutes. */
17
+ export const IDLE_MS = 20_000;
18
+ /** At the plan's limit: take nothing, keep the heartbeat. */
19
+ export const LIMITED_POLL_MS = 60_000;
20
+ export const LIMIT_WAIT_MS = 30 * 60_000;
21
+ const RETRIES = 2;
22
+ const ERROR_BACKOFF_MS = 30_000;
23
+
24
+ export const LABEL = "cloud.opennous.runner";
25
+ export const RUN_COMMAND = "npx -y @opennous/cli@latest runner";
26
+
27
+ /** What the status line says about the read in progress, or null. Pure. */
28
+ export function readingLine(reading) {
29
+ if (!reading) return null;
30
+ const what = reading.live ? "Reading what's new" : "Reading your history";
31
+ if (reading.status === "paused_own_model") return `${what}: picking it back up.`;
32
+ if (reading.finished) return `${what}: wrapping up.`;
33
+ if (reading.total) return `${what}: ${reading.done ?? 0} of ${reading.total}.`;
34
+ return `${what}.`;
35
+ }
36
+
37
+ /**
38
+ * The loop. Injected: `api` ({ poll, answer }), `run` (one command → stdout), `sleep`, `log`.
39
+ * `stop()` → true ends it (tests; the real one runs until the process is stopped).
40
+ */
41
+ export async function runRunner({ api, agent, host = hostname(), version = null, run, sleep, log, now = Date.now,
42
+ workers = WORKERS, limitWaitMs = LIMIT_WAIT_MS, stop = () => false }) {
43
+ let limitedUntil = 0, answered = 0, lastLine = null, lastError = null, toldBackfill = false;
44
+ const answerOne = async (call, models) => {
45
+ for (let attempt = 0; ; attempt++) {
46
+ try {
47
+ const { text, usage } = parseAnswer(agent, await run(commandFor(agent, models[agent], call)));
48
+ await api.answer(call.id, { answer: text, model: models[agent].model, usage });
49
+ answered++;
50
+ return;
51
+ } catch (err) {
52
+ // The plan's limit: drop the call (its lease runs out and it comes back) and stop taking
53
+ // more until the limit lifts. The heartbeat keeps going, so the app shows why.
54
+ if (isPlanLimit(err.message)) {
55
+ if (now() >= limitedUntil) log(`Your ${agent} plan hit its limit. Reading waits ${Math.round(limitWaitMs / 60000)} minutes, then carries on by itself.`);
56
+ limitedUntil = Math.max(limitedUntil, now() + limitWaitMs);
57
+ return;
58
+ }
59
+ if (attempt >= RETRIES) { await api.answer(call.id, { error: err.message }).catch(() => {}); return; }
60
+ }
61
+ }
62
+ };
63
+ while (!stop()) {
64
+ let page;
65
+ try {
66
+ page = await api.poll({ agent, host, version, answered, limit: workers,
67
+ limited_until: limitedUntil > now() ? new Date(limitedUntil).toISOString() : null });
68
+ if (lastError) { log("Back online."); lastError = null; }
69
+ } catch (err) {
70
+ if (err.message !== lastError) log(`Waiting on Ownable: ${err.message}. Trying again every ${ERROR_BACKOFF_MS / 1000} seconds.`);
71
+ lastError = err.message;
72
+ await sleep(ERROR_BACKOFF_MS);
73
+ continue;
74
+ }
75
+ if (page.needs_backfill && !toldBackfill) {
76
+ toldBackfill = true;
77
+ log("Your history hasn't been read yet. Run `npx @opennous/cli@latest backfill` once; after that this keeps it current.");
78
+ }
79
+ const line = readingLine(page.reading);
80
+ if (line && line !== lastLine) { log(line); }
81
+ lastLine = line;
82
+ const calls = page.calls ?? [];
83
+ if (!calls.length) { await sleep(limitedUntil > now() ? LIMITED_POLL_MS : page.reading ? BUSY_MS : IDLE_MS); continue; }
84
+ await Promise.all(calls.map((c) => answerOne(c, page.models)));
85
+ }
86
+ return { answered };
87
+ }
88
+
89
+ // ── Start at login ───────────────────────────────────────────────────────────────────────────
90
+
91
+ /** The absolute path of a command, or null. */
92
+ export function which(cmd, run = spawnSync) {
93
+ const r = run("/bin/sh", ["-c", `command -v ${cmd}`], { encoding: "utf8" });
94
+ const p = r.status === 0 ? String(r.stdout).trim().split("\n")[0] : "";
95
+ return p.startsWith("/") ? p : null;
96
+ }
97
+
98
+ /** PATH for the service: where node, npx and the agent CLIs live, then the usual places. Pure. */
99
+ export function servicePath(binaries) {
100
+ const dirs = [...new Set(binaries.filter(Boolean).map((b) => dirname(b)))];
101
+ for (const d of ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]) if (!dirs.includes(d)) dirs.push(d);
102
+ return dirs.join(":");
103
+ }
104
+
105
+ const xml = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
106
+
107
+ /** The launchd agent for macOS. Pure. */
108
+ export function launchdPlist({ npx, agent, workspace = null, path, log }) {
109
+ const args = [npx, "-y", "@opennous/cli@latest", "runner", "--agent", agent, ...(workspace ? ["--workspace", workspace] : [])];
110
+ return `<?xml version="1.0" encoding="UTF-8"?>
111
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
112
+ <plist version="1.0">
113
+ <dict>
114
+ <key>Label</key><string>${LABEL}</string>
115
+ <key>ProgramArguments</key>
116
+ <array>
117
+ ${args.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
118
+ </array>
119
+ <key>EnvironmentVariables</key>
120
+ <dict>
121
+ <key>PATH</key><string>${xml(path)}</string>
122
+ <key>NOUS_RUNNER_SERVICE</key><string>1</string>
123
+ </dict>
124
+ <key>RunAtLoad</key><true/>
125
+ <key>KeepAlive</key><true/>
126
+ <key>ThrottleInterval</key><integer>30</integer>
127
+ <key>StandardOutPath</key><string>${xml(log)}</string>
128
+ <key>StandardErrorPath</key><string>${xml(log)}</string>
129
+ </dict>
130
+ </plist>
131
+ `;
132
+ }
133
+
134
+ /** The systemd user unit for Linux. Pure. */
135
+ export function systemdUnit({ npx, agent, workspace = null, path }) {
136
+ const args = [npx, "-y", "@opennous/cli@latest", "runner", "--agent", agent, ...(workspace ? ["--workspace", workspace] : [])];
137
+ return `[Unit]
138
+ Description=Ownable runner: reads on your own ${agent === "codex" ? "ChatGPT" : "Claude"} plan
139
+ After=network-online.target
140
+
141
+ [Service]
142
+ ExecStart=${args.join(" ")}
143
+ Environment=PATH=${path}
144
+ Environment=NOUS_RUNNER_SERVICE=1
145
+ Restart=always
146
+ RestartSec=30
147
+
148
+ [Install]
149
+ WantedBy=default.target
150
+ `;
151
+ }
152
+
153
+ /** Where the service files live on this machine. Pure. */
154
+ export function servicePaths(os = platform(), home = homedir()) {
155
+ if (os === "darwin") return { kind: "launchd", file: join(home, "Library", "LaunchAgents", `${LABEL}.plist`), log: join(home, "Library", "Logs", "opennous-runner.log") };
156
+ if (os === "linux") return { kind: "systemd", file: join(home, ".config", "systemd", "user", "opennous-runner.service"), log: null };
157
+ return null;
158
+ }
159
+
160
+ /** Install and start the runner as a service. Returns { kind, file, log } or throws with what to do. */
161
+ export function installService({ agent, workspace = null, run = spawnSync, os = platform(), home = homedir() } = {}) {
162
+ const where = servicePaths(os, home);
163
+ if (!where) throw new Error(`Starting at login isn't supported on ${os} yet. Run \`${RUN_COMMAND}\` in a terminal you keep open instead.`);
164
+ const npx = which("npx", run);
165
+ if (!npx) throw new Error("Couldn't find npx. Install Node.js 20 or later, then run this again.");
166
+ const path = servicePath([npx, which("node", run), which(agent, run)]);
167
+ mkdirSync(dirname(where.file), { recursive: true });
168
+ if (where.kind === "launchd") {
169
+ mkdirSync(dirname(where.log), { recursive: true });
170
+ writeFileSync(where.file, launchdPlist({ npx, agent, workspace, path, log: where.log }));
171
+ run("launchctl", ["unload", where.file], { stdio: "ignore" });
172
+ const r = run("launchctl", ["load", "-w", where.file], { encoding: "utf8" });
173
+ if (r.status !== 0) throw new Error(`launchctl couldn't start it: ${(r.stderr || "").trim()}`);
174
+ } else {
175
+ writeFileSync(where.file, systemdUnit({ npx, agent, workspace, path }));
176
+ run("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
177
+ const r = run("systemctl", ["--user", "enable", "--now", "opennous-runner.service"], { encoding: "utf8" });
178
+ if (r.status !== 0) throw new Error(`systemd couldn't start it: ${(r.stderr || "").trim()}`);
179
+ // Keeps it running after logout, where the machine allows it. Best effort.
180
+ run("loginctl", ["enable-linger"], { stdio: "ignore" });
181
+ }
182
+ return where;
183
+ }
184
+
185
+ /** Stop and remove the service. True when there was one. */
186
+ export function uninstallService({ run = spawnSync, os = platform(), home = homedir() } = {}) {
187
+ const where = servicePaths(os, home);
188
+ if (!where || !existsSync(where.file)) return false;
189
+ if (where.kind === "launchd") run("launchctl", ["unload", "-w", where.file], { stdio: "ignore" });
190
+ else {
191
+ run("systemctl", ["--user", "disable", "--now", "opennous-runner.service"], { stdio: "ignore" });
192
+ }
193
+ rmSync(where.file, { force: true });
194
+ return true;
195
+ }
196
+
197
+ /** Whether the service is installed, and running as far as the OS says. */
198
+ export function serviceStatus({ run = spawnSync, os = platform(), home = homedir() } = {}) {
199
+ const where = servicePaths(os, home);
200
+ if (!where || !existsSync(where.file)) return { installed: false, running: false, ...(where ?? {}) };
201
+ const r = where.kind === "launchd"
202
+ ? run("launchctl", ["list", LABEL], { encoding: "utf8" })
203
+ : run("systemctl", ["--user", "is-active", "opennous-runner.service"], { encoding: "utf8" });
204
+ return { installed: true, running: r.status === 0, ...where };
205
+ }