useathena 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,11 +44,15 @@ The MVP is not a broad knowledge base. It is a working closed loop for tacit jud
44
44
  - Every learned rule cites the evidence that produced it.
45
45
  - Agents can propose evidence and outcomes, but cannot directly mutate durable rules.
46
46
 
47
- 3. Infer tacit rules from evidence.
47
+ 3. Infer tacit rules — and explicit facts — from evidence.
48
48
  - The hypothesis engine clusters examples by domain.
49
49
  - It infers cues, expectancies, goals, rules, and boundary conditions.
50
50
  - It replay-validates against held-out examples before a rule can be served.
51
- - Inference is model-backed, not a fake deterministic string matcher.
51
+ - It also extracts entity facts ("Acme's renewal is in September") — deduped,
52
+ cited, and staleness-tracked; restatements confirm instead of duplicating.
53
+ - Inference is model-backed, not a fake deterministic string matcher — and it
54
+ triggers itself: once enough new evidence accumulates, a background
55
+ `athena learn` runs automatically (disable with `ATHENA_AUTO_LEARN=off`).
52
56
 
53
57
  4. Validate before serving; review is optional.
54
58
  - Replay-validated rules are served to agents immediately, flagged with caveats.
@@ -59,10 +63,18 @@ The MVP is not a broad knowledge base. It is a working closed loop for tacit jud
59
63
 
60
64
  5. Brief agents before they act.
61
65
  - MCP exposes exactly four tools: `athena_brief`, `athena_open`, `athena_record`, and `athena_search`.
62
- - A brief returns applicable rules, confidence, boundaries, citations, do-not-assume items, open questions, and a readiness verdict.
63
-
64
- 6. Learn from outcomes.
65
- - Agents record whether their briefed output was accepted, corrected, abandoned, or unknown.
66
+ - A brief returns applicable rules, confidence, boundaries, citations, entity facts, do-not-assume items, open questions, and a readiness verdict.
67
+ - It also carries a knowledge map — what else athena knows and the query that
68
+ retrieves each slice — so agents pull what they need instead of being flooded.
69
+
70
+ 6. Learn from outcomes — mostly without anyone reporting.
71
+ - Agents register the artifact they produced (`athena_record type=output`); when
72
+ a sensor later captures what the human actually did with it, the outcome
73
+ records itself: approval → uncorrected, edit → corrected with the diff as a
74
+ counterexample. Matching is conservative; a false match would corrupt trust.
75
+ - Short approval sign-offs in Claude Code ("lgtm", "ship it") count as observed
76
+ approvals; unresolved drafts expire to `unknown` after the match window.
77
+ - Explicit reporting still works everywhere sensors can't see.
66
78
  - Rules gain trust when upheld and lose trust when overridden.
67
79
  - Repeated counterexamples make rules stale.
68
80
 
@@ -75,18 +87,26 @@ Implemented:
75
87
  - Core schema and typed domain model.
76
88
  - SQLite store with invariants and lexical search.
77
89
  - Capture ingestion with structured diffs.
78
- - LLM-backed hypothesis engine.
90
+ - LLM-backed hypothesis engine, plus entity-fact extraction from the same
91
+ evidence (deduped, cited, staleness-tracked; `athena facts`).
92
+ - Auto-learn: sensors trigger a background `athena learn` once enough new
93
+ evidence accumulates — the loop no longer stalls on a forgotten command.
79
94
  - Model-agnostic providers: `cli:claude`, `cli:codex`, Anthropic API,
80
95
  OpenAI-compatible APIs, and local models via Ollama / LM Studio / vLLM.
81
96
  - Golden-scenario eval harness.
82
- - Brief compilation, outcome recording, and autonomous rule promotion
83
- (validated rules graduate to active through upheld outcomes).
97
+ - Brief compilation with a knowledge map, outcome recording, and autonomous rule
98
+ promotion (validated rules graduate to active through upheld outcomes).
99
+ - Automatic outcome detection: agents register drafts (`athena_record
100
+ type=output` / `athena record output`), sensors match what the human actually
101
+ sent, and the outcome records itself.
84
102
  - MCP server with the four-tool agent surface (`athena mcp`).
85
103
  - CLI with a first-run `athena setup` wizard, plus init, status, capture, learn,
86
- review, brief, rules, open, record, serve, and hook install.
87
- - Claude Code sensor hook (installed by `athena setup` or `athena hook install`).
88
- - Localhost API for browser sensors.
104
+ review, brief, rules, facts, open, record, export, serve, and hook install.
105
+ - Claude Code sensor hook (corrections, approvals, and `remember:` notes).
106
+ - Localhost API for browser sensors — installable as a login service
107
+ (`athena serve install`, done by setup) so capture survives reboots.
89
108
  - Chrome extension v0.1 for LinkedIn and Gmail draft-edit capture.
109
+ - `athena export`: one JSON bundle of everything learned (`--redact` for sharing).
90
110
  - One-command onboarding: `npx useathena onboard --yes` (npm package `useathena`,
91
111
  binary `athena`; GitHub installs work too).
92
112
 
@@ -141,9 +161,12 @@ Onboarding does everything: creates the local store, detects which model provide
141
161
  you have (claude CLI, codex CLI, Anthropic or OpenAI API keys, a running Ollama),
142
162
  verifies the one you pick with a real inference call, installs itself globally so
143
163
  hooks survive the npx cache, wires up Claude Code (sensor hook + MCP registration),
144
- and prints the browser-extension setup. `--yes` takes the first detected provider
145
- and says yes to all of it; opt out per-piece with `--no-hook`, `--skip-test`, or
146
- `--model <spec>`.
164
+ installs the browser-sensor API as a login service, and prints the extension setup
165
+ with its token. `--yes` takes the first detected provider and says yes to all of it;
166
+ opt out per-piece with `--no-hook`, `--no-service`, `--skip-test`, or `--model <spec>`.
167
+
168
+ Already installed? `athena update` pulls the latest published version and
169
+ restarts the background serve service on the new code.
147
170
 
148
171
  Model providers are spec strings, set once by `setup` (or per-run via `ATHENA_MODEL`):
149
172
 
@@ -194,10 +217,12 @@ npm run athena -- capture correction \
194
217
  --after "Saw your post on GTM hiring. Would be glad to connect."
195
218
  ```
196
219
 
197
- Learn candidate rules from captured evidence:
220
+ Learn candidate rules (and entity facts) from captured evidence — this also runs
221
+ automatically in the background once enough new evidence accumulates:
198
222
 
199
223
  ```bash
200
224
  npm run athena -- learn --domain linkedin.outreach
225
+ npm run athena -- facts
201
226
  ```
202
227
 
203
228
  Review inferred rules:
@@ -212,12 +237,19 @@ Ask for the kind of brief an agent should get before acting:
212
237
  npm run athena -- brief "draft a LinkedIn connection note to a GTM leader" --domain linkedin.outreach
213
238
  ```
214
239
 
215
- Start the localhost API for the Chrome extension:
240
+ Start the localhost API for the Chrome extension (or install it as a login
241
+ service with `serve install`):
216
242
 
217
243
  ```bash
218
244
  npm run athena -- serve
219
245
  ```
220
246
 
247
+ Export everything athena has learned as one JSON bundle (`--redact` strips raw text):
248
+
249
+ ```bash
250
+ npm run athena -- export
251
+ ```
252
+
221
253
  ## Design principles
222
254
 
223
255
  - Instances are immutable evidence; hypotheses are revisable views over evidence.
@@ -1,6 +1,10 @@
1
1
  import { createServer } from "node:http";
2
- import { timingSafeEqual } from "node:crypto";
3
- import { ingestSensorEvent } from "../capture/ingest.js";
2
+ import { randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { dbPath } from "../config.js";
6
+ import { ingestAndMatch } from "../serve/auto-outcome.js";
7
+ import { maybeAutoLearn } from "../serve/auto-learn.js";
4
8
  /**
5
9
  * The localhost door for browser sensors. Deliberately tiny:
6
10
  * GET /health → liveness, no auth
@@ -20,6 +24,22 @@ const INSTANCE_KINDS = new Set([
20
24
  "approval",
21
25
  "manual_note",
22
26
  ]);
27
+ /** The serve token guards the local API against other local processes. */
28
+ export function loadOrCreateServeToken() {
29
+ const tokenPath = join(dirname(dbPath()), "serve.token");
30
+ try {
31
+ const existing = readFileSync(tokenPath, "utf8").trim();
32
+ if (existing.length >= 16)
33
+ return existing;
34
+ }
35
+ catch {
36
+ // first run — create below
37
+ }
38
+ const token = randomBytes(24).toString("base64url");
39
+ mkdirSync(dirname(tokenPath), { recursive: true });
40
+ writeFileSync(tokenPath, `${token}\n`, { mode: 0o600 });
41
+ return token;
42
+ }
23
43
  export function startApiServer(store, options) {
24
44
  const server = createServer((request, response) => {
25
45
  void handle(store, options, request, response);
@@ -47,8 +67,14 @@ async function handle(store, options, request, response) {
47
67
  try {
48
68
  const body = await readBody(request);
49
69
  const event = parseEvent(body, options.sensorId ?? "sen_extension");
50
- const instance = ingestSensorEvent(store, event);
51
- json(response, 201, { captured: instance.id, kind: instance.kind, domain: instance.situation.domain });
70
+ const { instance, autoOutcome } = ingestAndMatch(store, event);
71
+ maybeAutoLearn(store);
72
+ json(response, 201, {
73
+ captured: instance.id,
74
+ kind: instance.kind,
75
+ domain: instance.situation.domain,
76
+ ...(autoOutcome ? { autoOutcome: { id: autoOutcome.id, result: autoOutcome.result } } : {}),
77
+ });
52
78
  }
53
79
  catch (error) {
54
80
  json(response, 400, { error: error instanceof Error ? error.message : "invalid request" });
@@ -1,9 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { newId } from "../core/ids.js";
2
4
  import { refTo } from "../core/refs.js";
3
5
  import { openRef } from "../store/open.js";
4
- import { ingestSensorEvent } from "../capture/ingest.js";
6
+ import { ingestAndMatch } from "../serve/auto-outcome.js";
7
+ import { AUTO_LEARN_THRESHOLD, LAST_LEARN_AT } from "../serve/auto-learn.js";
5
8
  import { compileBrief } from "../serve/brief.js";
6
9
  import { LlmHypothesisEngine } from "../engine/engine.js";
10
+ import { materializeFacts } from "../engine/facts.js";
7
11
  import { bold, confidenceBar, cyan, dim, green, hitRate, readinessBadge, red, statusBadge, wrap, yellow } from "./format.js";
8
12
  /**
9
13
  * Command logic, pure-ish: store in, rendered string out. The interactive
@@ -33,6 +37,16 @@ export function cmdStatus(store) {
33
37
  : ""),
34
38
  ` ${bold(String(counts.briefs))} briefs served, ${bold(String(counts.outcomes))} outcomes recorded`,
35
39
  ];
40
+ if (counts.facts > 0) {
41
+ lines.splice(3, 0, ` ${bold(String(counts.facts))} fact${counts.facts === 1 ? "" : "s"} about ${bold(String(store.listObjects().length))} entities`);
42
+ }
43
+ if (counts.unmatchedDrafts > 0) {
44
+ lines.push(` ${bold(String(counts.unmatchedDrafts))} agent draft${counts.unmatchedDrafts === 1 ? "" : "s"} awaiting an observed outcome`);
45
+ }
46
+ const pending = store.countInstancesSince(store.getMeta(LAST_LEARN_AT));
47
+ if (pending > 0) {
48
+ lines.push(` ${bold(String(pending))} instance${pending === 1 ? "" : "s"} awaiting inference ${dim(`(auto-learn runs at ${AUTO_LEARN_THRESHOLD}; or now: athena learn)`)}`);
49
+ }
36
50
  if (judged.length > 0) {
37
51
  const rate = Math.round((corrected / judged.length) * 100);
38
52
  const paint = rate <= 30 ? green : rate <= 60 ? yellow : red;
@@ -118,8 +132,11 @@ export function cmdCapture(store, flags) {
118
132
  ...(flags.before !== undefined ? { before: { mediaType: "text/plain", content: maybeFile(flags.before) } } : {}),
119
133
  ...(flags.after !== undefined ? { after: { mediaType: "text/plain", content: maybeFile(flags.after) } } : {}),
120
134
  };
121
- const instance = ingestSensorEvent(store, event);
122
- return `${green("captured")} ${instance.id} ${dim(`(${instance.kind}, ${instance.situation.domain})`)}`;
135
+ const { instance, autoOutcome } = ingestAndMatch(store, event);
136
+ const matched = autoOutcome
137
+ ? `\n${green("✓")} matched a registered agent draft — outcome ${autoOutcome.result} recorded (${autoOutcome.id})`
138
+ : "";
139
+ return `${green("captured")} ${instance.id} ${dim(`(${instance.kind}, ${instance.situation.domain})`)}${matched}`;
123
140
  }
124
141
  function maybeFile(value) {
125
142
  return value.startsWith("@") ? readFileSync(value.slice(1), "utf8") : value;
@@ -134,20 +151,79 @@ export async function cmdLearn(store, model, options = {}) {
134
151
  }
135
152
  const engine = new LlmHypothesisEngine(model);
136
153
  const inferred = await engine.infer(instances);
154
+ store.setMeta(LAST_LEARN_AT, new Date().toISOString());
137
155
  const existingRules = new Set(store.listHypotheses({ limit: 1000 }).map((h) => h.rule));
138
156
  let saved = 0;
139
157
  const lines = [];
140
- for (const hypothesis of inferred) {
158
+ for (const hypothesis of inferred.hypotheses) {
141
159
  if (existingRules.has(hypothesis.rule))
142
160
  continue;
143
161
  store.saveHypothesis(hypothesis);
144
162
  saved += 1;
145
163
  lines.push("", renderRule(hypothesis));
146
164
  }
147
- const header = `${bold(String(saved))} new rule${saved === 1 ? "" : "s"} from ${instances.length} instances ${dim(`(engine: ${model.id})`)}`;
165
+ const { created, confirmed } = materializeFacts(store, inferred.facts);
166
+ for (const fact of created) {
167
+ const entity = store.getObject(fact.objectId);
168
+ lines.push("", `${cyan("fact")} ${dim(`(${entity?.name ?? fact.objectId})`)} ${wrap(fact.statement, " ").trim()}`);
169
+ }
170
+ const factNote = created.length + confirmed.length > 0
171
+ ? `, ${bold(String(created.length))} new fact${created.length === 1 ? "" : "s"}${confirmed.length > 0 ? ` (${confirmed.length} confirmed)` : ""}`
172
+ : "";
173
+ const header = `${bold(String(saved))} new rule${saved === 1 ? "" : "s"}${factNote} from ${instances.length} instances ${dim(`(engine: ${model.id})`)}`;
148
174
  const footer = saved > 0 ? `\n\n${yellow("review them: athena review")}` : "";
149
175
  return header + lines.join("\n") + footer;
150
176
  }
177
+ export function cmdFacts(store, options = {}) {
178
+ let facts;
179
+ if (options.entity) {
180
+ const objects = store.resolveObject(options.entity);
181
+ if (objects.length === 0)
182
+ return dim(`no entity matches "${options.entity}"`);
183
+ facts = objects.flatMap((object) => store.listFacts({ objectId: object.id }));
184
+ }
185
+ else {
186
+ facts = store.listFacts(options.domain !== undefined ? { domain: options.domain } : {});
187
+ }
188
+ if (facts.length === 0) {
189
+ return dim("no facts yet — facts are extracted from evidence when you run: athena learn");
190
+ }
191
+ return facts
192
+ .map((fact) => {
193
+ const entity = store.getObject(fact.objectId);
194
+ return [
195
+ `${cyan(entity?.name ?? "?")} ${confidenceBar(fact.confidence)} ${dim(fact.domain)} ${dim(fact.id)}`,
196
+ wrap(fact.statement, " "),
197
+ dim(` ${fact.supportingInstanceIds.length} supporting, last confirmed ${fact.lastConfirmedAt.slice(0, 10)}`),
198
+ ].join("\n");
199
+ })
200
+ .join("\n\n");
201
+ }
202
+ export function cmdRecordDraft(store, briefId, content) {
203
+ const brief = store.getBrief(briefId);
204
+ if (!brief)
205
+ throw new Error(`unknown brief ${briefId}`);
206
+ const draft = {
207
+ id: newId("drf"),
208
+ briefId: brief.id,
209
+ content,
210
+ contentHash: createHash("sha256").update(content).digest("hex").slice(0, 16),
211
+ mediaType: "text/plain",
212
+ domain: domainOfBrief(store, brief.id),
213
+ recordedAt: new Date().toISOString(),
214
+ };
215
+ store.saveDraft(draft);
216
+ return `${green("registered")} ${draft.id} ${dim("— the outcome records itself when a sensor sees the final version")}`;
217
+ }
218
+ function domainOfBrief(store, briefId) {
219
+ const brief = store.getBrief(briefId);
220
+ if (brief?.domain)
221
+ return brief.domain;
222
+ const first = brief?.rules[0];
223
+ if (first)
224
+ return store.getHypothesis(first.hypothesisId)?.domain ?? "general";
225
+ return "general";
226
+ }
151
227
  // --- review queue ---
152
228
  export function reviewQueue(store) {
153
229
  return store
@@ -0,0 +1,35 @@
1
+ /**
2
+ * One self-describing JSON bundle of everything athena has learned and how it
3
+ * got there — the raw material for improving the engine: wrong rules become
4
+ * eval scenarios, missed captures become sensor fixes, outcome history grades
5
+ * the briefs. `redact` strips raw text (artifact contents, probe answers) and
6
+ * keeps structure + metrics, for sharing outside the inner circle.
7
+ */
8
+ export const EXPORT_FORMAT_VERSION = 1;
9
+ export function buildExport(store, options = {}) {
10
+ const instances = store.listInstances({ limit: 10_000 });
11
+ return {
12
+ athenaExport: EXPORT_FORMAT_VERSION,
13
+ exportedAt: new Date().toISOString(),
14
+ redacted: options.redact === true,
15
+ counts: store.counts(),
16
+ hypotheses: store.listHypotheses({ limit: 10_000 }),
17
+ facts: store.listFacts({ limit: 10_000 }),
18
+ objects: store.listObjects(),
19
+ instances: options.redact ? instances.map(redactInstance) : instances,
20
+ briefs: store.listBriefs(10_000),
21
+ outcomes: store.listOutcomes(),
22
+ drafts: store.listDrafts().map((draft) => (options.redact ? { ...draft, content: "(redacted)" } : draft)),
23
+ };
24
+ }
25
+ function redactInstance(instance) {
26
+ return {
27
+ ...instance,
28
+ ...(instance.before ? { before: { ...instance.before, content: "(redacted)" } } : {}),
29
+ ...(instance.after ? { after: { ...instance.after, content: "(redacted)" } } : {}),
30
+ ...(instance.diff
31
+ ? { diff: { ...instance.diff, hunks: [] } }
32
+ : {}),
33
+ probeAnswers: instance.probeAnswers.map((probe) => ({ ...probe, answer: "(redacted)" })),
34
+ };
35
+ }
@@ -0,0 +1,106 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { dbPath } from "../config.js";
6
+ /**
7
+ * Keep `athena serve` running without a dedicated terminal: a per-user login
8
+ * service (launchd on macOS, systemd --user on Linux) runs the same command
9
+ * the user would run by hand and restarts it if it dies. Logs land next to
10
+ * the store so `athena serve` stays debuggable.
11
+ */
12
+ export const SERVICE_LABEL = "com.useathena.serve";
13
+ export const SYSTEMD_UNIT = "athena-serve.service";
14
+ export function launchdPlist(nodePath, athenaBin, logPath) {
15
+ return `<?xml version="1.0" encoding="UTF-8"?>
16
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
17
+ <plist version="1.0">
18
+ <dict>
19
+ <key>Label</key><string>${SERVICE_LABEL}</string>
20
+ <key>ProgramArguments</key>
21
+ <array>
22
+ <string>${nodePath}</string>
23
+ <string>${athenaBin}</string>
24
+ <string>serve</string>
25
+ </array>
26
+ <key>RunAtLoad</key><true/>
27
+ <key>KeepAlive</key><true/>
28
+ <key>StandardOutPath</key><string>${logPath}</string>
29
+ <key>StandardErrorPath</key><string>${logPath}</string>
30
+ </dict>
31
+ </plist>
32
+ `;
33
+ }
34
+ export function systemdUnit(nodePath, athenaBin) {
35
+ return `[Unit]
36
+ Description=athena local API for browser sensors
37
+
38
+ [Service]
39
+ ExecStart=${nodePath} ${athenaBin} serve
40
+ Restart=always
41
+
42
+ [Install]
43
+ WantedBy=default.target
44
+ `;
45
+ }
46
+ export function serviceInstalled(platform = process.platform) {
47
+ if (platform === "darwin")
48
+ return existsSync(join(homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`));
49
+ if (platform === "linux")
50
+ return existsSync(join(homedir(), ".config", "systemd", "user", SYSTEMD_UNIT));
51
+ return false;
52
+ }
53
+ export function installService(athenaBin, platform = process.platform) {
54
+ const logPath = join(dirname(dbPath()), "serve.log");
55
+ if (platform === "darwin") {
56
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
57
+ mkdirSync(dirname(plistPath), { recursive: true });
58
+ writeFileSync(plistPath, launchdPlist(process.execPath, athenaBin, logPath));
59
+ spawnSync("launchctl", ["unload", plistPath], { stdio: "ignore" }); // replace any previous version
60
+ const load = spawnSync("launchctl", ["load", plistPath], { encoding: "utf8" });
61
+ if (load.status !== 0) {
62
+ return {
63
+ installed: false,
64
+ path: plistPath,
65
+ message: `wrote ${plistPath} but launchctl load failed: ${(load.stderr ?? "").trim().slice(0, 200)}`,
66
+ };
67
+ }
68
+ return { installed: true, path: plistPath, message: `serve runs at login (launchd ${SERVICE_LABEL}, logs: ${logPath})` };
69
+ }
70
+ if (platform === "linux") {
71
+ const unitPath = join(homedir(), ".config", "systemd", "user", SYSTEMD_UNIT);
72
+ mkdirSync(dirname(unitPath), { recursive: true });
73
+ writeFileSync(unitPath, systemdUnit(process.execPath, athenaBin));
74
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
75
+ const enable = spawnSync("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT], { encoding: "utf8" });
76
+ if (enable.status !== 0) {
77
+ return {
78
+ installed: false,
79
+ path: unitPath,
80
+ message: `wrote ${unitPath} but systemctl enable failed: ${(enable.stderr ?? "").trim().slice(0, 200)}`,
81
+ };
82
+ }
83
+ return { installed: true, path: unitPath, message: `serve runs at login (systemd --user ${SYSTEMD_UNIT})` };
84
+ }
85
+ return { installed: false, message: `no service template for ${platform} — run "athena serve" manually or add it to your startup apps` };
86
+ }
87
+ export function uninstallService(platform = process.platform) {
88
+ if (platform === "darwin") {
89
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
90
+ if (!existsSync(plistPath))
91
+ return { installed: false, message: "no launchd agent installed" };
92
+ spawnSync("launchctl", ["unload", plistPath], { stdio: "ignore" });
93
+ rmSync(plistPath);
94
+ return { installed: false, path: plistPath, message: `removed ${plistPath}` };
95
+ }
96
+ if (platform === "linux") {
97
+ const unitPath = join(homedir(), ".config", "systemd", "user", SYSTEMD_UNIT);
98
+ if (!existsSync(unitPath))
99
+ return { installed: false, message: "no systemd unit installed" };
100
+ spawnSync("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT], { stdio: "ignore" });
101
+ rmSync(unitPath);
102
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
103
+ return { installed: false, path: unitPath, message: `removed ${unitPath}` };
104
+ }
105
+ return { installed: false, message: `nothing to uninstall on ${platform}` };
106
+ }
package/dist/cli/setup.js CHANGED
@@ -4,14 +4,16 @@ import { homedir } from "node:os";
4
4
  import { dirname, join, sep } from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { configPath, dbPath, loadConfig, saveConfig } from "../config.js";
7
+ import { loadOrCreateServeToken } from "../api/server.js";
7
8
  import { DEFAULT_ANTHROPIC_MODEL, modelClientFromSpec, SUPPORTED_SPECS } from "../model/registry.js";
9
+ import { installService } from "./service.js";
8
10
  import { bold, cyan, dim, green, red, yellow } from "./format.js";
9
11
  /**
10
12
  * First-run wizard (aliases: setup, onboard): pick a model provider, prove it
11
13
  * answers, wire up the sensors. `npx useathena onboard --yes` is the one-command
12
14
  * path: it accepts every default, including a global self-install when running
13
15
  * from the npx cache (hooks and MCP registrations must outlive cache pruning).
14
- * Granular escape hatches: --model <spec>, --hook/--no-hook, --skip-test.
16
+ * Granular escape hatches: --model <spec>, --hook/--no-hook, --no-service, --skip-test.
15
17
  */
16
18
  const OLLAMA_TAGS_URL = "http://127.0.0.1:11434/api/tags";
17
19
  export async function runSetup(args, packageRoot) {
@@ -35,9 +37,15 @@ export async function runSetup(args, packageRoot) {
35
37
  console.log(` claude mcp add --scope user athena -- ${home.bin} mcp`);
36
38
  console.log(dim(` (other MCP clients: stdio command "${home.bin} mcp")`));
37
39
  }
40
+ const serviceRunning = await offerServeService(args, rl, home.bin);
38
41
  console.log(`\n${bold("browser sensor")} ${dim("(LinkedIn and Gmail draft edits)")}:`);
39
42
  console.log(` 1. chrome://extensions → Developer mode → Load unpacked → ${join(home.root, "apps", "chrome-extension")}`);
40
- console.log(` 2. run ${bold("athena serve")} and paste the printed token into the extension options page`);
43
+ if (serviceRunning) {
44
+ console.log(` 2. extension options page → token ${bold(loadOrCreateServeToken())}`);
45
+ }
46
+ else {
47
+ console.log(` 2. run ${bold("athena serve")} and paste the printed token into the extension options page`);
48
+ }
41
49
  console.log(`\n${bold("the loop:")} capture runs in the background — then`);
42
50
  console.log(` ${cyan("athena learn")} infer tacit rules from captured evidence`);
43
51
  console.log(` ${cyan("athena rules")} see what athena believes (and how confidently)`);
@@ -212,6 +220,26 @@ async function ensureDurableInstall(rl, packageRoot) {
212
220
  }
213
221
  return local;
214
222
  }
223
+ /**
224
+ * The browser sensor only captures while `athena serve` is up — installing it
225
+ * as a login service removes the "keep a terminal open" failure mode.
226
+ */
227
+ async function offerServeService(args, rl, athenaBin) {
228
+ if (args.includes("--no-service"))
229
+ return false;
230
+ let wanted = args.includes("--yes");
231
+ if (!wanted && rl) {
232
+ const answer = (await rl.question(`\nRun the browser-sensor API at login? ${dim("(background service for athena serve)")} [Y/n] `))
233
+ .trim()
234
+ .toLowerCase();
235
+ wanted = answer === "" || answer === "y" || answer === "yes";
236
+ }
237
+ if (!wanted)
238
+ return false;
239
+ const result = installService(athenaBin);
240
+ console.log(result.installed ? `${green("✓")} ${result.message}` : yellow(` ${result.message}`));
241
+ return result.installed;
242
+ }
215
243
  /** Wire up Claude Code end to end: sensor hook + MCP registration. */
216
244
  async function offerClaudeCode(args, rl, athenaBin) {
217
245
  if (args.includes("--no-hook"))
@@ -0,0 +1,81 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { installService, serviceInstalled } from "./service.js";
5
+ import { bold, dim, green, red, yellow } from "./format.js";
6
+ export function compareVersions(a, b) {
7
+ const left = a.split(".").map(Number);
8
+ const right = b.split(".").map(Number);
9
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
10
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
11
+ if (diff !== 0)
12
+ return Math.sign(diff);
13
+ }
14
+ return 0;
15
+ }
16
+ export function planUpdate(opts) {
17
+ if (opts.devCheckout)
18
+ return "dev-checkout";
19
+ return compareVersions(opts.current, opts.latest) < 0 ? "install" : "up-to-date";
20
+ }
21
+ export function readPackageInfo(packageRoot) {
22
+ const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
23
+ return { name: pkg.name, version: pkg.version };
24
+ }
25
+ export async function fetchLatestVersion(name, timeoutMs = 10_000) {
26
+ const response = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(timeoutMs) });
27
+ if (!response.ok)
28
+ throw new Error(`npm registry lookup failed: HTTP ${response.status}`);
29
+ const body = (await response.json());
30
+ if (!body.version)
31
+ throw new Error("npm registry reply had no version");
32
+ return body.version;
33
+ }
34
+ export function nudgeLine(current, latest) {
35
+ if (compareVersions(current, latest) >= 0)
36
+ return undefined;
37
+ return `\n${yellow("↑")} ${latest} is out (you run ${current}) — update with ${bold("athena update")}`;
38
+ }
39
+ /** One quiet line at the end of `athena status` when the registry is ahead; silent offline. */
40
+ export async function updateNudge(packageRoot) {
41
+ try {
42
+ const { name, version } = readPackageInfo(packageRoot);
43
+ return nudgeLine(version, await fetchLatestVersion(name, 1_500));
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
49
+ function globalAthenaBin() {
50
+ const prefix = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8" }).stdout?.trim();
51
+ if (!prefix)
52
+ return undefined;
53
+ const bin = join(prefix, "bin", "athena");
54
+ return existsSync(bin) ? bin : undefined;
55
+ }
56
+ export async function runUpdate(packageRoot, athenaBin) {
57
+ const { name, version } = readPackageInfo(packageRoot);
58
+ const latest = await fetchLatestVersion(name);
59
+ const plan = planUpdate({ current: version, latest, devCheckout: existsSync(join(packageRoot, ".git")) });
60
+ if (plan === "dev-checkout") {
61
+ console.log(`${yellow("dev checkout")} at ${packageRoot} — update with ${bold("git pull")} (running ${version}, registry has ${latest})`);
62
+ return;
63
+ }
64
+ if (plan === "up-to-date") {
65
+ console.log(`${green("✓")} athena ${version} is up to date`);
66
+ return;
67
+ }
68
+ console.log(dim(`updating ${name} ${version} → ${latest} (npm install -g)…`));
69
+ const install = spawnSync("npm", ["install", "-g", `${name}@latest`], { encoding: "utf8" });
70
+ if (install.status !== 0) {
71
+ console.log(red(`✗ update failed: ${(install.stderr ?? "").trim().slice(-300)}`));
72
+ console.log(yellow(` retry manually: npm install -g ${name}@latest`));
73
+ process.exitCode = 1;
74
+ return;
75
+ }
76
+ console.log(`${green("✓")} athena ${latest} installed`);
77
+ if (serviceInstalled()) {
78
+ const result = installService(globalAthenaBin() ?? athenaBin);
79
+ console.log(result.installed ? `${green("✓")} serve service restarted on ${latest}` : yellow(result.message));
80
+ }
81
+ }