useathena 0.1.0 → 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.
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,9 @@ 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>`.
147
167
 
148
168
  Model providers are spec strings, set once by `setup` (or per-run via `ATHENA_MODEL`):
149
169
 
@@ -194,10 +214,12 @@ npm run athena -- capture correction \
194
214
  --after "Saw your post on GTM hiring. Would be glad to connect."
195
215
  ```
196
216
 
197
- Learn candidate rules from captured evidence:
217
+ Learn candidate rules (and entity facts) from captured evidence — this also runs
218
+ automatically in the background once enough new evidence accumulates:
198
219
 
199
220
  ```bash
200
221
  npm run athena -- learn --domain linkedin.outreach
222
+ npm run athena -- facts
201
223
  ```
202
224
 
203
225
  Review inferred rules:
@@ -212,12 +234,19 @@ Ask for the kind of brief an agent should get before acting:
212
234
  npm run athena -- brief "draft a LinkedIn connection note to a GTM leader" --domain linkedin.outreach
213
235
  ```
214
236
 
215
- Start the localhost API for the Chrome extension:
237
+ Start the localhost API for the Chrome extension (or install it as a login
238
+ service with `serve install`):
216
239
 
217
240
  ```bash
218
241
  npm run athena -- serve
219
242
  ```
220
243
 
244
+ Export everything athena has learned as one JSON bundle (`--redact` strips raw text):
245
+
246
+ ```bash
247
+ npm run athena -- export
248
+ ```
249
+
221
250
  ## Design principles
222
251
 
223
252
  - 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,99 @@
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 installService(athenaBin, platform = process.platform) {
47
+ const logPath = join(dirname(dbPath()), "serve.log");
48
+ if (platform === "darwin") {
49
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
50
+ mkdirSync(dirname(plistPath), { recursive: true });
51
+ writeFileSync(plistPath, launchdPlist(process.execPath, athenaBin, logPath));
52
+ spawnSync("launchctl", ["unload", plistPath], { stdio: "ignore" }); // replace any previous version
53
+ const load = spawnSync("launchctl", ["load", plistPath], { encoding: "utf8" });
54
+ if (load.status !== 0) {
55
+ return {
56
+ installed: false,
57
+ path: plistPath,
58
+ message: `wrote ${plistPath} but launchctl load failed: ${(load.stderr ?? "").trim().slice(0, 200)}`,
59
+ };
60
+ }
61
+ return { installed: true, path: plistPath, message: `serve runs at login (launchd ${SERVICE_LABEL}, logs: ${logPath})` };
62
+ }
63
+ if (platform === "linux") {
64
+ const unitPath = join(homedir(), ".config", "systemd", "user", SYSTEMD_UNIT);
65
+ mkdirSync(dirname(unitPath), { recursive: true });
66
+ writeFileSync(unitPath, systemdUnit(process.execPath, athenaBin));
67
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
68
+ const enable = spawnSync("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT], { encoding: "utf8" });
69
+ if (enable.status !== 0) {
70
+ return {
71
+ installed: false,
72
+ path: unitPath,
73
+ message: `wrote ${unitPath} but systemctl enable failed: ${(enable.stderr ?? "").trim().slice(0, 200)}`,
74
+ };
75
+ }
76
+ return { installed: true, path: unitPath, message: `serve runs at login (systemd --user ${SYSTEMD_UNIT})` };
77
+ }
78
+ return { installed: false, message: `no service template for ${platform} — run "athena serve" manually or add it to your startup apps` };
79
+ }
80
+ export function uninstallService(platform = process.platform) {
81
+ if (platform === "darwin") {
82
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
83
+ if (!existsSync(plistPath))
84
+ return { installed: false, message: "no launchd agent installed" };
85
+ spawnSync("launchctl", ["unload", plistPath], { stdio: "ignore" });
86
+ rmSync(plistPath);
87
+ return { installed: false, path: plistPath, message: `removed ${plistPath}` };
88
+ }
89
+ if (platform === "linux") {
90
+ const unitPath = join(homedir(), ".config", "systemd", "user", SYSTEMD_UNIT);
91
+ if (!existsSync(unitPath))
92
+ return { installed: false, message: "no systemd unit installed" };
93
+ spawnSync("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT], { stdio: "ignore" });
94
+ rmSync(unitPath);
95
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
96
+ return { installed: false, path: unitPath, message: `removed ${unitPath}` };
97
+ }
98
+ return { installed: false, message: `nothing to uninstall on ${platform}` };
99
+ }
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"))
package/dist/cli.js CHANGED
@@ -1,15 +1,17 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { randomBytes } from "node:crypto";
4
3
  import { homedir } from "node:os";
5
4
  import { dirname, join } from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
6
  import { AthenaStore } from "./store/store.js";
8
7
  import { dbPath } from "./config.js";
9
- import { startApiServer } from "./api/server.js";
8
+ import { loadOrCreateServeToken, startApiServer } from "./api/server.js";
9
+ import { installService, uninstallService } from "./cli/service.js";
10
10
  import { handleUserPrompt } from "./sensors/claude-code-hook.js";
11
11
  import { modelClientFromSpec, resolveModelSpec } from "./model/registry.js";
12
- import { applyReview, cmdBrief, cmdCapture, cmdLearn, cmdOpen, cmdRules, cmdStatus, renderReviewItem, reviewQueue, } from "./cli/commands.js";
12
+ import { applyReview, cmdBrief, cmdCapture, cmdFacts, cmdLearn, cmdOpen, cmdRecordDraft, cmdRules, cmdStatus, renderReviewItem, reviewQueue, } from "./cli/commands.js";
13
+ import { buildExport } from "./cli/export.js";
14
+ import { maybeAutoLearn } from "./serve/auto-learn.js";
13
15
  import { recordOutcome } from "./serve/outcome.js";
14
16
  import { loadConfig } from "./config.js";
15
17
  import { installClaudeCodeHook, runSetup } from "./cli/setup.js";
@@ -26,17 +28,21 @@ usage: athena <command> [args]
26
28
  ${bold("init")} create the local store and print MCP setup
27
29
  ${bold("status")} what athena has captured, learned, and served
28
30
  ${bold("rules")} [--domain d] [--all] the learned tacit rules (--all includes stale/retired)
31
+ ${bold("facts")} [--entity e] [--domain d] the extracted entity facts (athena's explicit knowledge)
29
32
  ${bold("review")} review queue: approve or reject inferred rules (optional —
30
33
  rules also graduate on their own via upheld outcomes)
31
- ${bold("learn")} [--domain d] run the hypothesis engine over captured instances
34
+ ${bold("learn")} [--domain d] infer tacit rules AND extract entity facts from evidence
32
35
  ${bold("capture")} <kind> --summary "..." [--domain d] [--before t|@f] [--after t|@f]
33
36
  capture a judgment moment (kinds: correction, override,
34
37
  decision, escalation, failed_attempt, approval, manual_note)
35
38
  ${bold("brief")} "task" [--domain d] compile the brief an agent would get for a task
36
39
  ${bold("record")} outcome <briefId> <uncorrected|corrected|abandoned>
40
+ ${bold("record")} output <briefId> <text|@file> register an agent draft for automatic outcomes
41
+ ${bold("export")} [--out f] [--redact] one JSON bundle of everything learned (for analysis)
37
42
  ${bold("open")} <athena://ref | id> inspect any entity
38
43
  ${bold("hook")} install [--print] install the Claude Code sensor hook into ~/.claude/settings.json
39
44
  ${bold("serve")} [--port n] local API for browser sensors (default 127.0.0.1:4517)
45
+ ${bold("serve")} install|uninstall run the API at login (launchd/systemd user service)
40
46
  ${bold("mcp")} run the MCP stdio server (claude mcp add athena -- athena mcp)
41
47
 
42
48
  store: ${dbPath()} ${dim("(override with ATHENA_DB)")}
@@ -108,6 +114,7 @@ async function main() {
108
114
  ...(app !== undefined ? { app } : {}),
109
115
  };
110
116
  console.log(cmdCapture(store, flags));
117
+ maybeAutoLearn(store);
111
118
  break;
112
119
  }
113
120
  case "learn": {
@@ -123,14 +130,42 @@ async function main() {
123
130
  break;
124
131
  }
125
132
  case "record": {
126
- const [sub, briefId, result] = args;
127
- if (sub !== "outcome" || !briefId || !result) {
133
+ const [sub, briefId, value] = args;
134
+ if (sub === "output") {
135
+ if (!briefId || !value)
136
+ throw new Error("usage: athena record output <briefId> <text|@file>");
137
+ const content = value.startsWith("@") ? readFileSync(value.slice(1), "utf8") : value;
138
+ console.log(cmdRecordDraft(store, briefId, content));
139
+ break;
140
+ }
141
+ if (sub !== "outcome" || !briefId || !value) {
128
142
  throw new Error("usage: athena record outcome <briefId> <uncorrected|corrected|abandoned>");
129
143
  }
130
- const outcome = recordOutcome(store, { briefId: briefId, result: result });
144
+ const outcome = recordOutcome(store, { briefId: briefId, result: value });
131
145
  console.log(`${green("✓")} recorded ${outcome.id} (${outcome.result})`);
132
146
  break;
133
147
  }
148
+ case "facts": {
149
+ const entity = flag(args, "entity");
150
+ const domain = flag(args, "domain");
151
+ console.log(cmdFacts(store, {
152
+ ...(entity !== undefined ? { entity } : {}),
153
+ ...(domain !== undefined ? { domain } : {}),
154
+ }));
155
+ break;
156
+ }
157
+ case "export": {
158
+ const out = flag(args, "out") ?? `athena-export-${new Date().toISOString().slice(0, 10)}.json`;
159
+ const bundle = JSON.stringify(buildExport(store, { redact: args.includes("--redact") }), null, 2);
160
+ if (out === "-") {
161
+ console.log(bundle);
162
+ }
163
+ else {
164
+ writeFileSync(out, `${bundle}\n`);
165
+ console.log(`${green("✓")} exported to ${bold(out)}${args.includes("--redact") ? dim(" (raw text redacted)") : ""}`);
166
+ }
167
+ break;
168
+ }
134
169
  case "open": {
135
170
  const ref = args[0];
136
171
  if (!ref)
@@ -139,6 +174,18 @@ async function main() {
139
174
  break;
140
175
  }
141
176
  case "serve": {
177
+ if (args[0] === "install") {
178
+ const result = installService(athenaBin);
179
+ console.log(result.installed ? `${green("✓")} ${result.message}` : yellow(result.message));
180
+ if (result.installed) {
181
+ console.log(` token: ${bold(loadOrCreateServeToken())} ${dim("(paste into the extension options page)")}`);
182
+ }
183
+ break;
184
+ }
185
+ if (args[0] === "uninstall") {
186
+ console.log(uninstallService().message);
187
+ break;
188
+ }
142
189
  const port = Number(flag(args, "port") ?? 4517);
143
190
  const token = loadOrCreateServeToken();
144
191
  const server = await startApiServer(store, { token, port });
@@ -181,22 +228,6 @@ async function main() {
181
228
  store.close();
182
229
  }
183
230
  }
184
- /** The serve token guards the local API against other local processes. */
185
- function loadOrCreateServeToken() {
186
- const tokenPath = join(dirname(dbPath()), "serve.token");
187
- try {
188
- const existing = readFileSync(tokenPath, "utf8").trim();
189
- if (existing.length >= 16)
190
- return existing;
191
- }
192
- catch {
193
- // first run — create below
194
- }
195
- const token = randomBytes(24).toString("base64url");
196
- mkdirSync(dirname(tokenPath), { recursive: true });
197
- writeFileSync(tokenPath, `${token}\n`, { mode: 0o600 });
198
- return token;
199
- }
200
231
  /**
201
232
  * Hook mode runs on every prompt the user types into Claude Code:
202
233
  * stdout stays silent (it would pollute agent context), errors go to
@@ -238,7 +269,9 @@ async function runHook(args) {
238
269
  return;
239
270
  const store = new AthenaStore(dbPath());
240
271
  try {
241
- handleUserPrompt(store, input);
272
+ const result = handleUserPrompt(store, input);
273
+ if (result.captured)
274
+ maybeAutoLearn(store);
242
275
  }
243
276
  finally {
244
277
  store.close();