@opennous/cli 0.2.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 +44 -0
  2. package/package.json +25 -0
  3. package/src/index.js +651 -0
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @opennous/cli
2
+
3
+ Nous CLI — install Nous event tracking into your app, plus terminal access to the Context API.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ # in your project root
9
+ npx -y @opennous/cli@latest install
10
+ ```
11
+
12
+ `nous install` detects your stack (Next.js, Node, Python), writes a small `nous.js` (or `nous.py`) module with three helpers, and updates your `.env`:
13
+
14
+ - **`trackSignup({email, ...})`** — fires `interaction.signed_up` + initial `state.stage`
15
+ - **`handleStripeEvent(event, {customerEmail})`** — fires `interaction.subscription_started / updated / canceled` and flips `state.stage` (Customer / Churned)
16
+ - **`track(focus, property, value)`** — escape hatch for any other event
17
+
18
+ The generated module is plain code you own. Read it, edit it, or delete it.
19
+
20
+ ## Other commands
21
+
22
+ ```bash
23
+ nous auth login --key <your-key> # save your API key
24
+ nous context <email> # engineered context for a person
25
+ nous account <email> # full record with epistemics + timeline
26
+ nous record <email> --property X --value Y
27
+ nous query --property stage --value Customer
28
+ nous attention # surface what changed recently
29
+ nous verify <email> <property> # confirm a claim is current
30
+ ```
31
+
32
+ Run `nous --help` for the full list.
33
+
34
+ ## Auth
35
+
36
+ `NOUS_API_KEY` env var, or `nous auth login --key <key>`. Mint a key at [app.opennous.cloud](https://app.opennous.cloud) → Settings → API Keys.
37
+
38
+ ## Self-hosting
39
+
40
+ Set `NOUS_API_URL` to your own deployment.
41
+
42
+ ## License
43
+
44
+ AGPL-3.0
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@opennous/cli",
3
+ "version": "0.2.0",
4
+ "description": "Nous CLI — install Nous event tracking into your app, plus terminal access to the Context API",
5
+ "type": "module",
6
+ "bin": {
7
+ "nous": "src/index.js"
8
+ },
9
+ "files": ["src", "README.md"],
10
+ "scripts": {
11
+ "dev": "node src/index.js",
12
+ "start": "node src/index.js"
13
+ },
14
+ "keywords": ["nous", "crm", "cli", "agents", "memory", "observability", "stripe"],
15
+ "license": "AGPL-3.0",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/bennetglinder1/nous.git",
19
+ "directory": "apps/cli"
20
+ },
21
+ "homepage": "https://opennous.cloud",
22
+ "dependencies": {
23
+ "commander": "^12.1.0"
24
+ }
25
+ }
package/src/index.js ADDED
@@ -0,0 +1,651 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Nous CLI — a thin client of the v2 Context API.
4
+ //
5
+ // You read engineered, epistemics-tagged context and write observations.
6
+ // You never overwrite — Nous derives the facts.
7
+
8
+ import { program, Command } from "commander";
9
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from "fs";
10
+ import { homedir } from "os";
11
+ import { join, resolve } from "path";
12
+ import { createInterface } from "readline";
13
+
14
+ const CONFIG_PATH = join(homedir(), ".nous", "config.json");
15
+
16
+ function readConfig() {
17
+ if (!existsSync(CONFIG_PATH)) return {};
18
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
19
+ }
20
+
21
+ function writeConfig(cfg) {
22
+ const dir = join(homedir(), ".nous");
23
+ if (!existsSync(dir)) {
24
+ mkdirSync(dir, { recursive: true });
25
+ }
26
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
27
+ }
28
+
29
+ function apiClient() {
30
+ const cfg = readConfig();
31
+ const apiKey = process.env.NOUS_API_KEY || cfg.apiKey;
32
+ const apiUrl = process.env.NOUS_API_URL || cfg.apiUrl || "https://api.opennous.cloud";
33
+
34
+ if (!apiKey) {
35
+ console.error("No API key found. Run: nous auth login --key <your-key>");
36
+ process.exit(1);
37
+ }
38
+
39
+ async function request(method, path, { body, query } = {}) {
40
+ const url = new URL(path, apiUrl);
41
+ if (query) {
42
+ for (const [k, v] of Object.entries(query)) {
43
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
44
+ }
45
+ }
46
+ const res = await fetch(url.toString(), {
47
+ method,
48
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
49
+ body: body ? JSON.stringify(body) : undefined,
50
+ });
51
+ if (!res.ok) {
52
+ const err = await res.json().catch(() => ({ message: res.statusText }));
53
+ console.error(`Error: ${err.error || err.message}`);
54
+ process.exit(1);
55
+ }
56
+ if (res.status === 204) return {};
57
+ return res.json();
58
+ }
59
+
60
+ return {
61
+ get: (path, query) => request("GET", path, { query }),
62
+ post: (path, body) => request("POST", path, { body }),
63
+ };
64
+ }
65
+
66
+ // A name that matches several entities comes back as { status: 'ambiguous' }.
67
+ // Print the candidates and stop — the caller re-runs with a precise focus.
68
+ function handleAmbiguous(r) {
69
+ if (r && r.status === "ambiguous") {
70
+ console.log("Several entities match that focus — re-run with one of:");
71
+ for (const c of r.candidates ?? []) {
72
+ const detail = c.detail ? ` — ${c.detail}` : "";
73
+ console.log(` ${c.name || "(unnamed)"}${detail} [${c.entity_id}]`);
74
+ }
75
+ return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ // --value accepts JSON ('{"description":"…"}') or a bare string.
81
+ function parseValue(raw) {
82
+ if (raw === undefined) return undefined;
83
+ try {
84
+ return JSON.parse(raw);
85
+ } catch {
86
+ return raw;
87
+ }
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // nous install — drop a starter Nous integration into the current project
92
+ // ---------------------------------------------------------------------------
93
+ // Detects the framework, asks for the API key, writes a single module with the
94
+ // helpers wired (signup tracking + Stripe webhook handler + generic track()),
95
+ // and prints exactly where the user has to hook it up.
96
+ //
97
+ // No magic — everything is plain code the user can read and edit. The CLI's job
98
+ // is to save them the 30 minutes of writing it from scratch and to make sure
99
+ // the event names match what the Nous UI expects.
100
+
101
+ function detectStack(root) {
102
+ // Returns one of: "next", "node-esm", "node-cjs", "python", "unknown"
103
+ const pkgPath = join(root, "package.json");
104
+ if (existsSync(pkgPath)) {
105
+ try {
106
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
107
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
108
+ if (deps.next) return "next";
109
+ if (pkg.type === "module") return "node-esm";
110
+ return "node-cjs";
111
+ } catch { /* fall through */ }
112
+ }
113
+ if (existsSync(join(root, "pyproject.toml")) || existsSync(join(root, "requirements.txt"))) {
114
+ return "python";
115
+ }
116
+ return "unknown";
117
+ }
118
+
119
+ function prompt(question, fallback) {
120
+ return new Promise(resolve => {
121
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
122
+ rl.question(question + (fallback ? ` [${fallback}] ` : " "), answer => {
123
+ rl.close();
124
+ resolve(answer.trim() || fallback || "");
125
+ });
126
+ });
127
+ }
128
+
129
+ const TEMPLATES = {
130
+ // Node ESM template — works for raw Express, Fastify, Hono, anything that
131
+ // accepts async functions with the request body. The user wires the import
132
+ // into whichever route handles signups + Stripe.
133
+ "node-esm": `// Nous event tracking. Generated by \`nous install\`.
134
+ // Edit freely — this is just a starting point.
135
+
136
+ const NOUS_API_URL = process.env.NOUS_API_URL || "https://api.opennous.cloud";
137
+ const NOUS_API_KEY = process.env.NOUS_API_KEY;
138
+
139
+ async function postObservations(focus, observations) {
140
+ if (!NOUS_API_KEY) { console.warn("[NOUS] NOUS_API_KEY not set, skipping"); return; }
141
+ try {
142
+ const res = await fetch(\`\${NOUS_API_URL}/v2/observations\`, {
143
+ method: "POST",
144
+ headers: { "Content-Type": "application/json", Authorization: \`Bearer \${NOUS_API_KEY}\` },
145
+ body: JSON.stringify({ focus, observations }),
146
+ });
147
+ if (!res.ok) console.error("[NOUS] observation failed:", res.status, await res.text().catch(() => ""));
148
+ } catch (err) {
149
+ console.error("[NOUS] observation error:", err.message);
150
+ }
151
+ }
152
+
153
+ // Call this right after a new user account is created in your auth flow.
154
+ export async function trackSignup({ email, first_name = null, last_name = null, company = null, plan = "free", source }) {
155
+ if (!email) return;
156
+ await postObservations(email, [
157
+ { kind: "event", property: "interaction.signed_up",
158
+ value: { plan, source: source || "your-app", first_name, last_name, company, at: new Date().toISOString() } },
159
+ { kind: "state", property: "stage", value: plan === "free" ? "Free User" : "Trial" },
160
+ ...(company ? [{ kind: "state", property: "company", value: company }] : []),
161
+ ]);
162
+ }
163
+
164
+ // Mount this on your Stripe webhook handler. Expects a Stripe Event object.
165
+ export async function handleStripeEvent(event, { customerEmail }) {
166
+ if (!customerEmail) return;
167
+ const sub = event.data?.object;
168
+ if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
169
+ if (sub?.status !== "active") return;
170
+ const price = sub.items?.data?.[0]?.price;
171
+ await postObservations(customerEmail, [
172
+ { kind: "event",
173
+ property: event.type === "customer.subscription.created"
174
+ ? "interaction.subscription_started"
175
+ : "interaction.subscription_updated",
176
+ value: {
177
+ source: "stripe", plan: sub.metadata?.plan_id || price?.lookup_key || null, status: sub.status,
178
+ amount: price?.unit_amount ? price.unit_amount / 100 : null, currency: sub.currency || null,
179
+ stripe_subscription_id: sub.id, at: new Date().toISOString(),
180
+ } },
181
+ { kind: "state", property: "stage", value: "Customer" },
182
+ { kind: "state", property: "plan", value: sub.metadata?.plan_id || price?.lookup_key || null },
183
+ ]);
184
+ return;
185
+ }
186
+ if (event.type === "customer.subscription.deleted") {
187
+ await postObservations(customerEmail, [
188
+ { kind: "event", property: "interaction.subscription_canceled",
189
+ value: { source: "stripe", stripe_subscription_id: sub?.id, at: new Date().toISOString() } },
190
+ { kind: "state", property: "stage", value: "Churned" },
191
+ ]);
192
+ }
193
+ }
194
+
195
+ // Escape hatch for any other event you want to land on the timeline.
196
+ export async function track(focus, property, value = {}) {
197
+ await postObservations(focus, [{ kind: "event", property, value: { ...value, at: new Date().toISOString() } }]);
198
+ }
199
+ `,
200
+
201
+ python: `# Nous event tracking. Generated by \`nous install\`.
202
+ # Edit freely — this is just a starting point.
203
+ import os
204
+ import urllib.request
205
+ import json
206
+ from datetime import datetime, timezone
207
+
208
+ NOUS_API_URL = os.getenv("NOUS_API_URL", "https://api.opennous.cloud")
209
+ NOUS_API_KEY = os.getenv("NOUS_API_KEY")
210
+
211
+ def _post(focus, observations):
212
+ if not NOUS_API_KEY:
213
+ print("[NOUS] NOUS_API_KEY not set, skipping")
214
+ return
215
+ body = json.dumps({"focus": focus, "observations": observations}).encode()
216
+ req = urllib.request.Request(
217
+ f"{NOUS_API_URL}/v2/observations", data=body, method="POST",
218
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {NOUS_API_KEY}"},
219
+ )
220
+ try:
221
+ with urllib.request.urlopen(req, timeout=5) as r:
222
+ if r.status >= 400: print("[NOUS] observation failed:", r.status)
223
+ except Exception as e:
224
+ print("[NOUS] observation error:", e)
225
+
226
+ def track_signup(email, *, first_name=None, last_name=None, company=None, plan="free", source="your-app"):
227
+ if not email: return
228
+ now = datetime.now(timezone.utc).isoformat()
229
+ _post(email, [
230
+ {"kind":"event","property":"interaction.signed_up",
231
+ "value":{"plan":plan,"source":source,"first_name":first_name,"last_name":last_name,"company":company,"at":now}},
232
+ {"kind":"state","property":"stage","value":"Free User" if plan=="free" else "Trial"},
233
+ *([{"kind":"state","property":"company","value":company}] if company else []),
234
+ ])
235
+
236
+ def handle_stripe_event(event, customer_email):
237
+ if not customer_email: return
238
+ sub = event.get("data", {}).get("object", {})
239
+ t = event.get("type")
240
+ now = datetime.now(timezone.utc).isoformat()
241
+ if t in ("customer.subscription.created", "customer.subscription.updated"):
242
+ if sub.get("status") != "active": return
243
+ price = (sub.get("items", {}).get("data") or [{}])[0].get("price", {}) or {}
244
+ _post(customer_email, [
245
+ {"kind":"event",
246
+ "property":"interaction.subscription_started" if t.endswith("created") else "interaction.subscription_updated",
247
+ "value":{"source":"stripe","plan":sub.get("metadata",{}).get("plan_id"),"status":sub.get("status"),
248
+ "amount":(price.get("unit_amount") or 0)/100 or None,"currency":sub.get("currency"),
249
+ "stripe_subscription_id":sub.get("id"),"at":now}},
250
+ {"kind":"state","property":"stage","value":"Customer"},
251
+ ])
252
+ elif t == "customer.subscription.deleted":
253
+ _post(customer_email, [
254
+ {"kind":"event","property":"interaction.subscription_canceled",
255
+ "value":{"source":"stripe","stripe_subscription_id":sub.get("id"),"at":now}},
256
+ {"kind":"state","property":"stage","value":"Churned"},
257
+ ])
258
+
259
+ def track(focus, property_, value=None):
260
+ now = datetime.now(timezone.utc).isoformat()
261
+ _post(focus, [{"kind":"event","property":property_,"value":{**(value or {}),"at":now}}])
262
+ `,
263
+ };
264
+
265
+ const NEXT_STEPS = {
266
+ "node-esm": `Next steps:
267
+ 1. Set NOUS_API_KEY in your .env (see https://app.opennous.cloud/integrations).
268
+ 2. Import { trackSignup } from "./nous.js" — call it right after you persist a new user.
269
+ 3. Import { handleStripeEvent } in your Stripe webhook route — call it after verifying the signature.
270
+ 4. Use track(email, "interaction.<your_event>", {...}) for anything else.`,
271
+ "node-cjs": `Next steps:
272
+ 1. Set NOUS_API_KEY in your .env (see https://app.opennous.cloud/integrations).
273
+ 2. const { trackSignup, handleStripeEvent, track } = require("./nous.js");
274
+ 3. Call trackSignup() after persisting a new user; handleStripeEvent() inside your Stripe webhook.`,
275
+ next: `Next steps:
276
+ 1. Set NOUS_API_KEY in your .env (see https://app.opennous.cloud/integrations).
277
+ 2. Inside your signup API route, await trackSignup({ email, ... }) after the user row is written.
278
+ 3. Inside app/api/webhooks/stripe/route.ts (or pages/api/webhooks/stripe.ts), await handleStripeEvent(event, { customerEmail }) after Stripe signature verification.`,
279
+ python: `Next steps:
280
+ 1. Set NOUS_API_KEY in your .env (see https://app.opennous.cloud/integrations).
281
+ 2. from nous import track_signup, handle_stripe_event, track — call track_signup() after persisting a new user.
282
+ 3. Call handle_stripe_event(event, customer_email) inside your Stripe webhook view.`,
283
+ };
284
+
285
+ program
286
+ .command("install")
287
+ .description("Drop Nous event tracking (signups + Stripe lifecycle) into the current project")
288
+ .option("--dir <path>", "Target project directory (default: cwd)", process.cwd())
289
+ .option("--lang <stack>", "Force language/stack: next | node-esm | node-cjs | python")
290
+ .option("--key <key>", "API key (otherwise prompted)")
291
+ .option("--yes", "Skip prompts and use defaults", false)
292
+ .action(async ({ dir, lang, key, yes }) => {
293
+ const root = resolve(dir);
294
+ if (!existsSync(root)) {
295
+ console.error(`Directory not found: ${root}`);
296
+ process.exit(1);
297
+ }
298
+
299
+ const detected = lang || detectStack(root);
300
+ const stack = (detected === "unknown") ? "node-esm" : detected;
301
+ const templateKey = stack === "next" ? "node-esm" : (stack === "node-cjs" ? "node-esm" : stack);
302
+ const template = TEMPLATES[templateKey];
303
+ if (!template) {
304
+ console.error(`No template for stack: ${stack}. Use --lang next|node-esm|node-cjs|python.`);
305
+ process.exit(1);
306
+ }
307
+
308
+ const outFile = stack === "python" ? "nous.py" : "nous.js";
309
+ const outPath = join(root, outFile);
310
+
311
+ if (existsSync(outPath) && !yes) {
312
+ const answer = await prompt(`${outFile} already exists. Overwrite? (y/N)`, "N");
313
+ if (!answer.toLowerCase().startsWith("y")) {
314
+ console.log("Aborted.");
315
+ process.exit(0);
316
+ }
317
+ }
318
+
319
+ let apiKey = key || process.env.NOUS_API_KEY || readConfig().apiKey;
320
+ if (!apiKey && !yes) {
321
+ apiKey = await prompt("Paste your Nous API key (or press Enter to skip and set NOUS_API_KEY later):", "");
322
+ }
323
+
324
+ writeFileSync(outPath, template);
325
+
326
+ // Append NOUS_API_KEY to .env if not already there.
327
+ const envPath = join(root, ".env");
328
+ let envTouched = false;
329
+ if (existsSync(envPath)) {
330
+ const env = readFileSync(envPath, "utf8");
331
+ if (!/^NOUS_API_KEY\s*=/m.test(env)) {
332
+ appendFileSync(envPath, `\n# Nous event tracking (https://app.opennous.cloud)\nNOUS_API_KEY=${apiKey || ""}\n`);
333
+ envTouched = true;
334
+ }
335
+ } else if (apiKey) {
336
+ writeFileSync(envPath, `# Nous event tracking (https://app.opennous.cloud)\nNOUS_API_KEY=${apiKey}\n`);
337
+ envTouched = true;
338
+ }
339
+
340
+ console.log(`\n✓ Wrote ${outFile}`);
341
+ if (envTouched) console.log(`✓ Updated .env`);
342
+ console.log(`\nDetected stack: ${stack}`);
343
+ console.log(`\n${NEXT_STEPS[stack] || NEXT_STEPS["node-esm"]}\n`);
344
+ });
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // nous auth
348
+ // ---------------------------------------------------------------------------
349
+ program
350
+ .command("auth")
351
+ .description("Manage authentication")
352
+ .addCommand(
353
+ new Command("login")
354
+ .description("Save your API key")
355
+ .requiredOption("--key <key>", "Your Nous API key")
356
+ .option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
357
+ .action(({ key, url }) => {
358
+ const cfg = readConfig();
359
+ cfg.apiKey = key;
360
+ if (url) cfg.apiUrl = url;
361
+ writeConfig(cfg);
362
+ console.log("✓ Authenticated. Run `nous attention` to verify.");
363
+ })
364
+ )
365
+ .addCommand(
366
+ new Command("status")
367
+ .description("Show current auth status")
368
+ .action(() => {
369
+ const cfg = readConfig();
370
+ const key = process.env.NOUS_API_KEY || cfg.apiKey;
371
+ const url = process.env.NOUS_API_URL || cfg.apiUrl || "https://api.opennous.cloud";
372
+ if (key) {
373
+ console.log(`Logged in\nAPI URL: ${url}\nKey: ${key.slice(0, 8)}...`);
374
+ } else {
375
+ console.log("Not logged in. Run: nous auth login --key <your-key>");
376
+ }
377
+ })
378
+ );
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // nous context <focus> — engineered, intent-shaped context for a task
382
+ // ---------------------------------------------------------------------------
383
+ program
384
+ .command("context <focus>")
385
+ .description("Engineered context for a task about one person or company")
386
+ .option(
387
+ "--intent <intent>",
388
+ "draft_email | follow_up | meeting_prep | call_prep | account_review",
389
+ "account_review"
390
+ )
391
+ .option("--budget <tokens>", "Token budget for the assembled context")
392
+ .option("--json", "Print the raw JSON response")
393
+ .action(async (focus, { intent, budget, json }) => {
394
+ const api = apiClient();
395
+ const ctx = await api.post("/v2/context", {
396
+ focus,
397
+ intent,
398
+ budget_tokens: budget ? Number(budget) : undefined,
399
+ });
400
+ if (handleAmbiguous(ctx)) return;
401
+ if (json) {
402
+ console.log(JSON.stringify(ctx, null, 2));
403
+ return;
404
+ }
405
+ const lines = [`${ctx.entity.type} ${ctx.entity.id} · intent: ${ctx.intent}`];
406
+ if (ctx.summary) lines.push(`\n${ctx.summary}`);
407
+ if (ctx.claims?.length) {
408
+ lines.push(`\nClaims:`);
409
+ ctx.claims.forEach(c => {
410
+ const val = typeof c.value === "object" ? JSON.stringify(c.value) : c.value;
411
+ lines.push(
412
+ ` ${c.property} = ${val} (${c.epistemic_class}, ${c.freshness}, ` +
413
+ `conf ${Math.round((c.confidence ?? 0) * 100)}%)`
414
+ );
415
+ });
416
+ }
417
+ if (ctx.timeline?.length) {
418
+ lines.push(`\nTimeline:`);
419
+ ctx.timeline.slice(0, 8).forEach(t => {
420
+ const when = t.when ? new Date(t.when).toLocaleDateString() : "";
421
+ const detail = t.summary ? `: ${t.summary}` : t.count ? ` ×${t.count}` : "";
422
+ lines.push(` ${when} — ${t.type}${detail}`);
423
+ });
424
+ }
425
+ if (ctx.stakeholders?.length) {
426
+ lines.push(`\nStakeholders:`);
427
+ ctx.stakeholders.forEach(s =>
428
+ lines.push(` ${s.name || s.entity_id}${s.role ? ` — ${s.role}` : ""}`)
429
+ );
430
+ }
431
+ lines.push(`\n~${ctx.meta?.token_estimate ?? "?"} tokens`);
432
+ console.log(lines.join("\n"));
433
+ });
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // nous account <focus> — the full record: every claim + its epistemics
437
+ // ---------------------------------------------------------------------------
438
+ program
439
+ .command("account <focus>")
440
+ .description("The full account record — every claim with its epistemics + timeline")
441
+ .option("--json", "Print the raw JSON response")
442
+ .action(async (focus, { json }) => {
443
+ const api = apiClient();
444
+ const rec = await api.get(`/v2/accounts/${encodeURIComponent(focus)}`);
445
+ if (handleAmbiguous(rec)) return;
446
+ if (json) {
447
+ console.log(JSON.stringify(rec, null, 2));
448
+ return;
449
+ }
450
+ const lines = [`${rec.type} ${rec.entity_id}`];
451
+ const claims = Object.entries(rec.claims ?? {});
452
+ if (claims.length) {
453
+ lines.push(`\nClaims:`);
454
+ claims.forEach(([prop, c]) => {
455
+ const val = typeof c.value === "object" ? JSON.stringify(c.value) : c.value;
456
+ lines.push(
457
+ ` ${prop} = ${val} (${c.epistemic_class}, ${c.freshness}, ` +
458
+ `conf ${Math.round((c.confidence ?? 0) * 100)}%)`
459
+ );
460
+ });
461
+ }
462
+ const obs = rec.recent_observations ?? [];
463
+ if (obs.length) {
464
+ lines.push(`\nRecent observations:`);
465
+ obs.slice(0, 10).forEach(o => {
466
+ const when = o.observed_at ? new Date(o.observed_at).toLocaleDateString() : "";
467
+ lines.push(` ${when} — [${o.kind}] ${o.property} (${o.source})`);
468
+ });
469
+ }
470
+ console.log(lines.join("\n"));
471
+ });
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // nous record <focus> — observe what happened; Nous derives the facts
475
+ // ---------------------------------------------------------------------------
476
+ program
477
+ .command("record <focus>")
478
+ .description("Record an observation. You observe — Nous derives the claims.")
479
+ .requiredOption("--kind <kind>", "event | state")
480
+ .requiredOption("--property <property>", "e.g. interaction.email_sent or job_title")
481
+ .option("--value <value>", "The event detail or fact value (JSON or string)")
482
+ .option("--source <source>", "Where this came from", "cli")
483
+ .option("--method <method>", "How it was observed")
484
+ .option("--observed-at <iso>", "When it was observed (ISO 8601)")
485
+ .action(async (focus, opts) => {
486
+ const api = apiClient();
487
+ const observation = {
488
+ kind: opts.kind,
489
+ property: opts.property,
490
+ value: parseValue(opts.value),
491
+ source: opts.source,
492
+ method: opts.method,
493
+ observed_at: opts.observedAt,
494
+ };
495
+ const result = await api.post("/v2/observations", { focus, observations: [observation] });
496
+ if (handleAmbiguous(result)) return;
497
+ const recomputed = result.claims_recomputed ?? [];
498
+ console.log(
499
+ `✓ Recorded ${result.recorded} observation(s) for ${result.entity_id}` +
500
+ (recomputed.length ? `\n Claims recomputed: ${recomputed.join(", ")}` : "")
501
+ );
502
+ });
503
+
504
+ // ---------------------------------------------------------------------------
505
+ // nous query — retrieve and summarise a corpus of activity across many people
506
+ // ---------------------------------------------------------------------------
507
+ program
508
+ .command("query")
509
+ .description("Retrieve and summarise activity across many people")
510
+ .option("--kind <kind>", "event | state")
511
+ .option("--property <prefix>", "Property prefix — e.g. interaction.linkedin")
512
+ .option("--source <source>", "Filter by source")
513
+ .option("--entity <id>", "Restrict to one entity")
514
+ .option("--since <days>", "Look back this many days")
515
+ .option("--limit <n>", "Max results", "20")
516
+ .option("--question <text>", "A question — switches to semantic retrieval")
517
+ .option("--json", "Print the raw JSON response")
518
+ .action(async opts => {
519
+ const api = apiClient();
520
+ const scope = {
521
+ kind: opts.kind,
522
+ property: opts.property,
523
+ source: opts.source,
524
+ entity_id: opts.entity,
525
+ since_days: opts.since ? Number(opts.since) : undefined,
526
+ limit: opts.limit ? Number(opts.limit) : undefined,
527
+ };
528
+ const data = await api.post("/v2/query", { scope, question: opts.question });
529
+ if (opts.json) {
530
+ console.log(JSON.stringify(data, null, 2));
531
+ return;
532
+ }
533
+ const items = data.items ?? [];
534
+ if (!items.length) {
535
+ console.log("No matching activity.");
536
+ return;
537
+ }
538
+ items.forEach(it => {
539
+ const when = it.when ? new Date(it.when).toLocaleDateString() : "";
540
+ const who = it.entity_name || it.entity_id;
541
+ const sim = it.similarity != null ? ` ${Math.round(it.similarity * 100)}%` : "";
542
+ console.log(` ${when} — ${who} — ${it.type}${sim}${it.summary ? `: ${it.summary}` : ""}`);
543
+ });
544
+ console.log(
545
+ `\n${data.returned} of ${data.matched} (${data.mode})` +
546
+ (data.sampled ? " — sampled" : "")
547
+ );
548
+ });
549
+
550
+ // ---------------------------------------------------------------------------
551
+ // nous attention — what needs attention across the workspace
552
+ // ---------------------------------------------------------------------------
553
+ program
554
+ .command("attention")
555
+ .description("Accounts gone quiet, facts decayed — ranked, with suggested actions")
556
+ .option("--limit <n>", "Max results", "20")
557
+ .option("--json", "Print the raw JSON response")
558
+ .action(async ({ limit, json }) => {
559
+ const api = apiClient();
560
+ const data = await api.get("/v2/attention", { limit });
561
+ if (json) {
562
+ console.log(JSON.stringify(data, null, 2));
563
+ return;
564
+ }
565
+ const items = data.items ?? [];
566
+ if (!items.length) {
567
+ console.log("Nothing needs attention. ✓");
568
+ return;
569
+ }
570
+ items.forEach(it => {
571
+ const who = it.entity_name || it.entity_id;
572
+ console.log(` [${it.kind}] ${who} — ${it.what} (${it.age_days}d)`);
573
+ console.log(` → ${it.suggested_action}`);
574
+ });
575
+ });
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // nous verify <focus> <property> — re-check a claim before acting on it
579
+ // ---------------------------------------------------------------------------
580
+ program
581
+ .command("verify <focus> <property>")
582
+ .description("Re-check a claim before acting on it — the calibration check")
583
+ .action(async (focus, property) => {
584
+ const api = apiClient();
585
+ const r = await api.post("/v2/verify", { focus, property });
586
+ if (handleAmbiguous(r)) return;
587
+ const fmt = c =>
588
+ c
589
+ ? `${typeof c.value === "object" ? JSON.stringify(c.value) : c.value} ` +
590
+ `(${c.freshness}, conf ${Math.round((c.confidence ?? 0) * 100)}%)`
591
+ : "—";
592
+ console.log(`${r.property}`);
593
+ console.log(` before: ${fmt(r.before)}`);
594
+ console.log(` after: ${fmt(r.after)}`);
595
+ if (r.note) console.log(` ${r.note}`);
596
+ });
597
+
598
+ // ---------------------------------------------------------------------------
599
+ // nous classify — pre-flight cold-outbound dedup
600
+ // ---------------------------------------------------------------------------
601
+ program
602
+ .command("classify")
603
+ .description("Pre-flight dedup a list of emails and/or LinkedIn URLs against the workspace's engagement history")
604
+ .option("--emails <list>", "Comma-separated emails")
605
+ .option("--linkedin <list>", "Comma-separated LinkedIn URLs")
606
+ .option("--file <path>", "Path to any text/CSV — both emails and LinkedIn URLs are auto-extracted")
607
+ .option("--json", "Print the raw JSON response")
608
+ .action(async ({ emails, linkedin, file, json }) => {
609
+ const api = apiClient();
610
+ const EMAIL_RE = /[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}/gi;
611
+ const LINKEDIN_RE = /https?:\/\/(?:www\.)?linkedin\.com\/in\/[a-zA-Z0-9_\-%]+/gi;
612
+
613
+ let emailList = [];
614
+ let linkedinList = [];
615
+
616
+ if (file) {
617
+ const { readFileSync } = await import("fs");
618
+ const text = readFileSync(file, "utf8");
619
+ emailList = text.match(EMAIL_RE) || [];
620
+ linkedinList = text.match(LINKEDIN_RE) || [];
621
+ }
622
+ if (emails) emailList = emailList.concat(emails.split(/[\s,]+/).filter(Boolean));
623
+ if (linkedin) linkedinList = linkedinList.concat(linkedin.split(/[\s,]+/).filter(Boolean));
624
+
625
+ emailList = [...new Set(emailList.map(e => e.toLowerCase().trim()))].filter(Boolean);
626
+ linkedinList = [...new Set(linkedinList.map(u => u.trim()))].filter(Boolean);
627
+
628
+ if (!emailList.length && !linkedinList.length) {
629
+ console.error("Pass at least one of --emails, --linkedin, or --file");
630
+ process.exit(1);
631
+ }
632
+
633
+ const body = {};
634
+ if (emailList.length) body.emails = emailList;
635
+ if (linkedinList.length) body.linkedin_urls = linkedinList;
636
+
637
+ const data = await api.post("/v2/dedup", body);
638
+ if (json) { console.log(JSON.stringify(data, null, 2)); return; }
639
+ const s = data.summary || {};
640
+ const pad = (n, w = 6) => String(n).padStart(w);
641
+ console.log(`\n ${pad(s.total)} total (${emailList.length} emails, ${linkedinList.length} linkedin)`);
642
+ console.log(` ${pad(s.net_new)} net_new ← safe to send / safe to buy`);
643
+ console.log(` ${pad(s.engaged)} engaged skip (in an active convo)`);
644
+ console.log(` ${pad(s.recent)} recent defer (contacted in last 30d)`);
645
+ console.log(` ${pad(s.bounced)} bounced skip`);
646
+ console.log(` ${pad(s.unsubscribed)} unsubscribed skip`);
647
+ console.log(` ${pad(s.suppressed)} suppressed skip (workspace policy)\n`);
648
+ });
649
+
650
+ // ---------------------------------------------------------------------------
651
+ program.name("nous").version("0.3.0").parse();