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 +49 -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 +106 -0
- package/dist/cli/setup.js +30 -2
- package/dist/cli/update.js +81 -0
- package/dist/cli.js +72 -25
- 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,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
|
-
|
|
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>`.
|
|
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.
|
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,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
|
-
|
|
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
|
+
}
|