@opennous/cli 0.12.3 → 0.14.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 +10 -0
- package/package.json +1 -1
- package/src/backfill.js +157 -0
- package/src/index.js +93 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @opennous/cli changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
### Added: `backfill`, in one command
|
|
6
|
+
|
|
7
|
+
`npx @opennous/cli@latest backfill` reads your history on your own Claude or ChatGPT plan. With no
|
|
8
|
+
saved key it opens the browser sign-in and carries on. If neither Claude Code nor Codex is signed in,
|
|
9
|
+
it says so before it asks you to sign in to anything (GET-1085, GET-1087).
|
|
10
|
+
|
|
11
|
+
After a sync it only reads: nothing is pulled again and no new contacts appear.
|
|
12
|
+
|
|
3
13
|
## 0.12.3
|
|
4
14
|
|
|
5
15
|
### Fixed: a plain message when the server's certificate isn't trusted
|
package/package.json
CHANGED
package/src/backfill.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// `nous backfill` (GET-1085): read your history on your own Claude or ChatGPT plan. Our server runs
|
|
2
|
+
// the production read and hands this loop one model call at a time; each is answered by the claude
|
|
3
|
+
// or codex CLI you are already signed in to.
|
|
4
|
+
//
|
|
5
|
+
// EMAIL TEXT IS UNTRUSTED. A call's prompt holds other people's words, so the model answering it
|
|
6
|
+
// gets no tools at all: an email must not be able to read your disk. For Codex the read-only
|
|
7
|
+
// sandbox is not enough (it can still read files), so its shell tool is turned off.
|
|
8
|
+
|
|
9
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
10
|
+
import { mkdtempSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** What to type to carry on. `npx` because most people never installed `nous` (GET-1087). */
|
|
15
|
+
export const RERUN = "npx @opennous/cli@latest backfill";
|
|
16
|
+
export const NO_AGENT = "Sign in to Claude Code (`claude`) or Codex (`codex login`) first: this runs on your own plan, so it needs one of them. Then run this again.";
|
|
17
|
+
|
|
18
|
+
export const POLL_MS = 2000;
|
|
19
|
+
export const WORKERS = 3;
|
|
20
|
+
export const LIMIT_WAIT_MS = 30 * 60_000;
|
|
21
|
+
const CALL_TIMEOUT_MS = 5 * 60_000;
|
|
22
|
+
const RETRIES = 2;
|
|
23
|
+
const PROGRESS_MS = 30_000;
|
|
24
|
+
|
|
25
|
+
/** The command for one call. Pure. Model and effort come from the server. */
|
|
26
|
+
export function commandFor(agent, { model, effort }, { system, messages }) {
|
|
27
|
+
const user = messages.map((m) => (m.role === "assistant" ? `ASSISTANT: ${m.content}` : m.content)).join("\n\n");
|
|
28
|
+
if (agent === "claude") {
|
|
29
|
+
return {
|
|
30
|
+
cmd: "claude",
|
|
31
|
+
args: ["-p", "--model", model, "--effort", effort, "--output-format", "json", "--tools", "",
|
|
32
|
+
"--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--setting-sources", "",
|
|
33
|
+
"--disable-slash-commands", "--no-session-persistence",
|
|
34
|
+
"--system-prompt", system || "Follow the instructions in the user message exactly."],
|
|
35
|
+
input: user,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
cmd: "codex",
|
|
40
|
+
args: ["exec", "--json", "-m", model, "--skip-git-repo-check", "--sandbox", "read-only", "--ephemeral",
|
|
41
|
+
"--ignore-user-config", "-c", "mcp_servers={}", "-c", "features.shell_tool=false",
|
|
42
|
+
"-c", `model_reasoning_effort="${effort}"`],
|
|
43
|
+
input: `${system ? `<instructions>\n${system}\n</instructions>\n\n` : ""}${user}\n\nReply only with the answer.`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The answer and token use from a CLI's output. Pure. Throws when there is no answer. */
|
|
48
|
+
export function parseAnswer(agent, out) {
|
|
49
|
+
if (agent === "claude") {
|
|
50
|
+
const j = JSON.parse(out);
|
|
51
|
+
if (j.is_error) throw new Error(String(j.result));
|
|
52
|
+
return { text: j.result, usage: { input_tokens: j.usage?.input_tokens ?? 0, output_tokens: j.usage?.output_tokens ?? 0 } };
|
|
53
|
+
}
|
|
54
|
+
let text = "", usage = null;
|
|
55
|
+
for (const line of out.split("\n")) {
|
|
56
|
+
let e;
|
|
57
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
58
|
+
if (e.item?.type === "agent_message") text = e.item.text;
|
|
59
|
+
if (e.usage) usage = e.usage;
|
|
60
|
+
}
|
|
61
|
+
if (!text) throw new Error("codex gave no answer");
|
|
62
|
+
return { text, usage: { input_tokens: usage?.input_tokens ?? 0, output_tokens: usage?.output_tokens ?? 0 } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether an error means the plan's limit, which passes with time, rather than a bad call. Pure. */
|
|
66
|
+
export function isPlanLimit(message) {
|
|
67
|
+
return /usage limit|rate.?limit|hit your .*limit|limit reached|too many requests|\b429\b|quota/i.test(String(message));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function detectAgents(run = spawnSync) {
|
|
71
|
+
const found = [];
|
|
72
|
+
if (run("claude", ["auth", "status"], { encoding: "utf8" }).status === 0) found.push("claude");
|
|
73
|
+
if (run("codex", ["login", "status"], { encoding: "utf8" }).status === 0) found.push("codex");
|
|
74
|
+
return found;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Without the API key variables, so the call runs on the plan, not a key billed by the token. */
|
|
78
|
+
function planEnv(env = process.env) {
|
|
79
|
+
const clean = { ...env, DISABLE_AUTOUPDATER: "1" };
|
|
80
|
+
for (const k of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY"]) delete clean[k];
|
|
81
|
+
return clean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function runCommand({ cmd, args, input }, cwd) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const p = spawn(cmd, args, { cwd, env: planEnv(), stdio: ["pipe", "pipe", "pipe"] });
|
|
87
|
+
let out = "", err = "";
|
|
88
|
+
p.stdout.on("data", (d) => { out += d; });
|
|
89
|
+
p.stderr.on("data", (d) => { err += d; });
|
|
90
|
+
const timer = setTimeout(() => { p.kill(); reject(new Error("timed out")); }, CALL_TIMEOUT_MS);
|
|
91
|
+
p.on("error", (e) => { clearTimeout(timer); reject(e); });
|
|
92
|
+
p.on("close", (code) => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
if (code === 0) resolve(out); else reject(new Error((err || out).slice(-400) || `exit ${code}`));
|
|
95
|
+
});
|
|
96
|
+
p.stdin.end(input);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Nothing to read in this window. Pure. */
|
|
101
|
+
export const emptyEstimate = (counts) => !Object.values(counts ?? {}).some((n) => n > 0);
|
|
102
|
+
|
|
103
|
+
/** One line on how far the job is, or null when there is nothing to say yet. Pure. */
|
|
104
|
+
export function progressLine(reading, answered) {
|
|
105
|
+
if (reading?.finished) return "Wrapping up: last summaries for your accounts.";
|
|
106
|
+
const stop = `Stop any time with Ctrl-C; run \`${RERUN}\` again to carry on.`;
|
|
107
|
+
if (reading?.total) return `Read ${reading.done} of ${reading.total} ${reading.kind === "meetings" ? "meetings" : "email threads"}. ${stop}`;
|
|
108
|
+
if (answered) return `${answered} reads done on your plan. ${stop}`;
|
|
109
|
+
return `Getting ready to read. This can take a few minutes. ${stop}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The loop: lease calls, answer them, until the job ends. Injected: `api` ({ calls, answer, resume }),
|
|
114
|
+
* `run` (one command → stdout), `sleep`, `log`. Stopping loses nothing: every read that finished
|
|
115
|
+
* is saved, and unanswered calls come back to the next run.
|
|
116
|
+
*/
|
|
117
|
+
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 }) {
|
|
118
|
+
let waitUntil = 0, answered = 0, lastLine = null, lastAt = -Infinity;
|
|
119
|
+
const say = (line) => {
|
|
120
|
+
if (!line || (line === lastLine && now() - lastAt < progressMs) || now() - lastAt < Math.min(progressMs, 1000)) return;
|
|
121
|
+
lastLine = line; lastAt = now(); log(line);
|
|
122
|
+
};
|
|
123
|
+
const answerOne = async (call, models) => {
|
|
124
|
+
for (let attempt = 0; ; attempt++) {
|
|
125
|
+
if (now() < waitUntil) await sleep(waitUntil - now());
|
|
126
|
+
try {
|
|
127
|
+
const { text, usage } = parseAnswer(agent, await run(commandFor(agent, models[agent], call)));
|
|
128
|
+
await api.answer(call.id, { answer: text, model: models[agent].model, usage });
|
|
129
|
+
answered++;
|
|
130
|
+
return;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (isPlanLimit(err.message)) {
|
|
133
|
+
if (now() >= waitUntil) log(`Your ${agent} plan hit its limit. Waiting ${Math.round(limitWaitMs / 60000)} minutes, then carrying on. You can stop now and run this again later instead.`);
|
|
134
|
+
waitUntil = Math.max(waitUntil, now() + limitWaitMs);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (attempt >= RETRIES) { await api.answer(call.id, { error: err.message }); return; }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
for (;;) {
|
|
142
|
+
const page = await api.calls(jobId, workers);
|
|
143
|
+
if (page.status === "done") return { status: "done", answered, read: page.reading?.read ?? 0, failed: page.reading?.failed ?? 0 };
|
|
144
|
+
if (page.status === "failed") throw new Error(page.error || "The backfill failed on our side.");
|
|
145
|
+
// The server gave up waiting for us (a long sleep, say). We're back, so pick it up.
|
|
146
|
+
if (page.status === "paused_own_model") { await api.resume?.(); await sleep(pollMs); continue; }
|
|
147
|
+
say(progressLine(page.reading, answered));
|
|
148
|
+
if (!page.calls.length) { await sleep(pollMs); continue; }
|
|
149
|
+
await Promise.all(page.calls.map((c) => answerOne(c, page.models)));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The real pieces for runBackfill. */
|
|
154
|
+
export function liveRun() {
|
|
155
|
+
const cwd = mkdtempSync(join(tmpdir(), "nous-backfill-"));
|
|
156
|
+
return (command) => runCommand(command, cwd);
|
|
157
|
+
}
|
package/src/index.js
CHANGED
|
@@ -107,9 +107,12 @@ function writeConfig(cfg) {
|
|
|
107
107
|
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
/** A key is there to use: from the environment, or saved by a sign-in. */
|
|
111
|
+
const hasKey = () => !!(process.env.NOUS_API_KEY || readConfig().apiKey);
|
|
112
|
+
|
|
113
|
+
function apiClient(key) {
|
|
111
114
|
const cfg = readConfig();
|
|
112
|
-
const apiKey = process.env.NOUS_API_KEY || cfg.apiKey;
|
|
115
|
+
const apiKey = key || process.env.NOUS_API_KEY || cfg.apiKey;
|
|
113
116
|
const apiUrl = process.env.NOUS_API_URL || cfg.apiUrl || "https://api.opennous.cloud";
|
|
114
117
|
|
|
115
118
|
if (!apiKey) {
|
|
@@ -137,7 +140,7 @@ function apiClient() {
|
|
|
137
140
|
}
|
|
138
141
|
if (!res.ok) {
|
|
139
142
|
const err = await res.json().catch(() => ({ message: res.statusText }));
|
|
140
|
-
console.error(`Error: ${err.
|
|
143
|
+
console.error(`Error: ${err.message || err.error}`);
|
|
141
144
|
process.exit(1);
|
|
142
145
|
}
|
|
143
146
|
if (res.status === 204) return {};
|
|
@@ -285,14 +288,18 @@ async function browserLogin(url, opts = {}) {
|
|
|
285
288
|
} catch { continue; }
|
|
286
289
|
|
|
287
290
|
if (j.status === "approved" && j.api_key) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
291
|
+
// `save: false` is one command's key: the sign-in on this machine stays what it was.
|
|
292
|
+
if (opts.save !== false) {
|
|
293
|
+
const cfg = readConfig();
|
|
294
|
+
cfg.apiKey = j.api_key;
|
|
295
|
+
cfg.apiUrl = apiUrl;
|
|
296
|
+
writeConfig(cfg);
|
|
297
|
+
}
|
|
292
298
|
const who = await fetchWhoami(apiUrl, j.api_key);
|
|
293
299
|
console.log(`\n\n ${green("✓")} ${bold("Signed in")}${who ? ` as ${identityLine(who)}` : ""}`);
|
|
294
|
-
console.log(` ${dim("Key saved to ~/.nous/config.json")}
|
|
295
|
-
|
|
300
|
+
if (opts.save !== false) console.log(` ${dim("Key saved to ~/.nous/config.json")}`);
|
|
301
|
+
console.log("");
|
|
302
|
+
return j.api_key;
|
|
296
303
|
}
|
|
297
304
|
if (j.status === "denied") { console.log("\nAuthorization was denied."); process.exit(1); }
|
|
298
305
|
if (j.status === "expired") { console.log("\nThis sign-in expired. Run it again."); process.exit(1); }
|
|
@@ -538,6 +545,81 @@ program
|
|
|
538
545
|
});
|
|
539
546
|
|
|
540
547
|
// nous logout — top-level alias of `auth logout`. Removes the saved API key.
|
|
548
|
+
program
|
|
549
|
+
.command("backfill")
|
|
550
|
+
.description("Read your email and meeting history on your own Claude or ChatGPT plan")
|
|
551
|
+
.option("--agent <agent>", "claude or codex (default: whichever you're signed in to)")
|
|
552
|
+
.option("--days <days>", "how many days back to read, 1 to 730", "180")
|
|
553
|
+
.option("--workspace <id>", "the workspace to read; the app's command fills this in")
|
|
554
|
+
.option("--yes", "start without asking")
|
|
555
|
+
.option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
|
|
556
|
+
.action(async (opts) => {
|
|
557
|
+
const { detectAgents, runBackfill, liveRun, emptyEstimate, RERUN, NO_AGENT } = await import("./backfill.js");
|
|
558
|
+
const days = Number(opts.days);
|
|
559
|
+
if (!Number.isInteger(days) || days < 1 || days > 730) {
|
|
560
|
+
console.error("--days must be a whole number from 1 to 730.");
|
|
561
|
+
process.exit(1);
|
|
562
|
+
}
|
|
563
|
+
const agents = detectAgents();
|
|
564
|
+
const agent = opts.agent ?? agents[0];
|
|
565
|
+
if (!agent || !agents.includes(agent)) {
|
|
566
|
+
console.error(opts.agent ? `You're not signed in to ${opts.agent}.` : NO_AGENT);
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
// Asked after the check above, so nobody signs in to be told they can't run this.
|
|
570
|
+
if (!hasKey()) await browserLogin(opts.url);
|
|
571
|
+
// A key saved here may be for another workspace, so say which one this is, and never read the
|
|
572
|
+
// wrong one: the app's command names its workspace, and a key for any other is not used.
|
|
573
|
+
const cfg = readConfig();
|
|
574
|
+
const whoamiUrl = opts.url || process.env.NOUS_API_URL || cfg.apiUrl || "https://api.opennous.cloud";
|
|
575
|
+
let key = process.env.NOUS_API_KEY || cfg.apiKey;
|
|
576
|
+
let who = await fetchWhoami(whoamiUrl, key);
|
|
577
|
+
if (opts.workspace && who?.workspace?.id !== opts.workspace) {
|
|
578
|
+
console.log(`This machine is signed in to ${who?.workspace?.name ?? "another workspace"}. Sign in to the workspace this backfill is for.`);
|
|
579
|
+
key = await browserLogin(opts.url, { save: false });
|
|
580
|
+
who = await fetchWhoami(whoamiUrl, key);
|
|
581
|
+
if (who?.workspace?.id !== opts.workspace) {
|
|
582
|
+
console.error(`That sign-in is for ${who?.workspace?.name ?? "another workspace"}. Open the right workspace in the app, then run this again.`);
|
|
583
|
+
process.exit(1);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const workspace = who?.workspace?.name ?? "your workspace";
|
|
587
|
+
const api = apiClient(key);
|
|
588
|
+
const est = await api.post("/v2/backfill/own/estimate", { days });
|
|
589
|
+
if (emptyEstimate(est.counts)) {
|
|
590
|
+
console.log(`\nNothing to read in ${workspace} for the last ${days} days. If your sync is still running, try again when it's done.`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
console.log(`\nWorkspace: ${workspace}`);
|
|
594
|
+
console.log(`Last ${days} days: about ${est.counts.email_thread} email threads, ${est.counts.meeting} meetings, ${est.counts.call} calls.`);
|
|
595
|
+
console.log(`They're filtered first, then read on your ${agent} plan. None of it uses your OpenNous credits.`);
|
|
596
|
+
console.log("You can stop at any time and run this again: it carries on where it stopped.\n");
|
|
597
|
+
if (!opts.yes) {
|
|
598
|
+
const { createInterface } = await import("node:readline/promises");
|
|
599
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
600
|
+
const ok = /^y/i.test(await rl.question("Start? (y/N) "));
|
|
601
|
+
rl.close();
|
|
602
|
+
if (!ok) return;
|
|
603
|
+
}
|
|
604
|
+
const job = await api.post("/v2/backfill/own", { days });
|
|
605
|
+
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.`);
|
|
606
|
+
process.once("SIGINT", () => {
|
|
607
|
+
console.log(`\nStopped. Everything read so far is saved. Run \`${RERUN}\` to carry on.`);
|
|
608
|
+
process.exit(130);
|
|
609
|
+
});
|
|
610
|
+
const result = await runBackfill({
|
|
611
|
+
agent, jobId: job.job_id, run: liveRun(), log: console.log,
|
|
612
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
613
|
+
api: {
|
|
614
|
+
calls: (id, limit) => api.get(`/v2/backfill/own/${id}/calls`, { limit }),
|
|
615
|
+
answer: (id, body) => api.post(`/v2/backfill/own/calls/${id}`, body),
|
|
616
|
+
resume: () => api.post("/v2/backfill/own", { days }),
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
console.log(`Done. This run: ${result.read} conversations read, ${result.answered} model calls on your ${agent} plan.`);
|
|
620
|
+
if (result.failed) console.log(`${result.failed} conversations couldn't be read. Run \`${RERUN}\` again to retry them.`);
|
|
621
|
+
});
|
|
622
|
+
|
|
541
623
|
program
|
|
542
624
|
.command("logout")
|
|
543
625
|
.description("Sign out — remove the saved API key")
|
|
@@ -550,4 +632,5 @@ program
|
|
|
550
632
|
});
|
|
551
633
|
|
|
552
634
|
// ---------------------------------------------------------------------------
|
|
553
|
-
|
|
635
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
636
|
+
program.name("nous").version(version).parse();
|