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 +46 -17
- package/dist/api/server.js +30 -4
- package/dist/cli/commands.js +81 -5
- package/dist/cli/export.js +35 -0
- package/dist/cli/service.js +99 -0
- package/dist/cli/setup.js +30 -2
- package/dist/cli.js +57 -24
- package/dist/core/fixtures.js +26 -0
- package/dist/core/refs.js +2 -0
- package/dist/engine/engine.js +15 -6
- package/dist/engine/facts.js +80 -0
- package/dist/engine/parse.js +31 -6
- package/dist/engine/prompts.js +10 -1
- package/dist/eval/run-eval.js +3 -3
- package/dist/mcp/server.js +38 -10
- package/dist/sensors/claude-code-hook.js +31 -2
- package/dist/serve/auto-learn.js +56 -0
- package/dist/serve/auto-outcome.js +63 -0
- package/dist/serve/brief.js +64 -11
- package/dist/store/open.js +4 -0
- package/dist/store/store.js +109 -0
- package/docs/schema.md +65 -6
- package/package.json +1 -1
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
|
-
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
145
|
-
and says yes to all of it;
|
|
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.
|
package/dist/api/server.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
|
-
import { timingSafeEqual } from "node:crypto";
|
|
3
|
-
import {
|
|
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 =
|
|
51
|
-
|
|
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" });
|
package/dist/cli/commands.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
122
|
-
|
|
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
|
|
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
|
-
|
|
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]
|
|
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,
|
|
127
|
-
if (sub
|
|
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:
|
|
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();
|