useathena 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +258 -0
  2. package/apps/chrome-extension/README.md +35 -0
  3. package/apps/chrome-extension/background.js +97 -0
  4. package/apps/chrome-extension/gmail.js +107 -0
  5. package/apps/chrome-extension/linkedin.js +123 -0
  6. package/apps/chrome-extension/manifest.json +27 -0
  7. package/apps/chrome-extension/options.html +60 -0
  8. package/apps/chrome-extension/options.js +36 -0
  9. package/apps/chrome-extension/popup.html +37 -0
  10. package/apps/chrome-extension/popup.js +22 -0
  11. package/bin/athena +28 -0
  12. package/dist/api/server.js +145 -0
  13. package/dist/capture/ingest.js +85 -0
  14. package/dist/cli/commands.js +201 -0
  15. package/dist/cli/format.js +76 -0
  16. package/dist/cli/setup.js +316 -0
  17. package/dist/cli.js +291 -0
  18. package/dist/config.js +26 -0
  19. package/dist/core/fixtures.js +65 -0
  20. package/dist/core/ids.js +34 -0
  21. package/dist/core/refs.js +25 -0
  22. package/dist/core/types.js +10 -0
  23. package/dist/engine/engine.js +136 -0
  24. package/dist/engine/parse.js +76 -0
  25. package/dist/engine/prompts.js +64 -0
  26. package/dist/eval/harness.js +123 -0
  27. package/dist/eval/judge.js +75 -0
  28. package/dist/eval/run-eval.js +46 -0
  29. package/dist/eval/scenarios.js +470 -0
  30. package/dist/mcp/server.js +107 -0
  31. package/dist/mcp-server.js +7 -0
  32. package/dist/model/api-model-client.js +99 -0
  33. package/dist/model/cli-model-client.js +111 -0
  34. package/dist/model/model-client.js +28 -0
  35. package/dist/model/registry.js +67 -0
  36. package/dist/sensors/claude-code-hook.js +131 -0
  37. package/dist/serve/brief.js +95 -0
  38. package/dist/serve/outcome.js +56 -0
  39. package/dist/store/open.js +19 -0
  40. package/dist/store/store.js +269 -0
  41. package/docs/schema.md +368 -0
  42. package/package.json +43 -0
  43. package/scripts/prepare.mjs +20 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Hand-rolled ANSI formatting — no dependency earns its keep for this.
3
+ * Honors NO_COLOR and non-TTY output.
4
+ */
5
+ const enabled = process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
6
+ function paint(code, text) {
7
+ return enabled ? `[${code}m${text}` : text;
8
+ }
9
+ export const bold = (s) => paint(1, s);
10
+ export const dim = (s) => paint(2, s);
11
+ export const green = (s) => paint(32, s);
12
+ export const yellow = (s) => paint(33, s);
13
+ export const red = (s) => paint(31, s);
14
+ export const cyan = (s) => paint(36, s);
15
+ export const magenta = (s) => paint(35, s);
16
+ export function statusBadge(status) {
17
+ switch (status) {
18
+ case "active":
19
+ return green("● active ");
20
+ case "validated":
21
+ return cyan("◐ validated");
22
+ case "candidate":
23
+ return yellow("○ candidate");
24
+ case "stale":
25
+ return red("◌ stale ");
26
+ case "retired":
27
+ return dim("· retired ");
28
+ default:
29
+ return status;
30
+ }
31
+ }
32
+ export function readinessBadge(readiness) {
33
+ switch (readiness) {
34
+ case "act":
35
+ return green("ACT");
36
+ case "act_with_caveats":
37
+ return yellow("ACT WITH CAVEATS");
38
+ case "inspect_first":
39
+ return yellow("INSPECT FIRST");
40
+ case "ask_human":
41
+ return red("ASK HUMAN");
42
+ default:
43
+ return readiness;
44
+ }
45
+ }
46
+ export function confidenceBar(confidence) {
47
+ const filled = Math.round(confidence * 10);
48
+ const bar = "▰".repeat(filled) + "▱".repeat(10 - filled);
49
+ const color = confidence >= 0.7 ? green : confidence >= 0.4 ? yellow : red;
50
+ return `${color(bar)} ${confidence.toFixed(2)}`;
51
+ }
52
+ export function hitRate(upheld, overridden) {
53
+ const total = upheld + overridden;
54
+ if (total === 0)
55
+ return dim("no outcomes yet");
56
+ const rate = upheld / total;
57
+ const text = `${upheld}/${total} upheld`;
58
+ return rate >= 0.7 ? green(text) : rate >= 0.4 ? yellow(text) : red(text);
59
+ }
60
+ export function wrap(text, indent, width = 88) {
61
+ const words = text.split(/\s+/);
62
+ const lines = [];
63
+ let line = "";
64
+ for (const word of words) {
65
+ if (line.length + word.length + 1 > width && line.length > 0) {
66
+ lines.push(line);
67
+ line = word;
68
+ }
69
+ else {
70
+ line = line.length === 0 ? word : `${line} ${word}`;
71
+ }
72
+ }
73
+ if (line.length > 0)
74
+ lines.push(line);
75
+ return lines.map((l) => indent + l).join("\n");
76
+ }
@@ -0,0 +1,316 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, sep } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { configPath, dbPath, loadConfig, saveConfig } from "../config.js";
7
+ import { DEFAULT_ANTHROPIC_MODEL, modelClientFromSpec, SUPPORTED_SPECS } from "../model/registry.js";
8
+ import { bold, cyan, dim, green, red, yellow } from "./format.js";
9
+ /**
10
+ * First-run wizard (aliases: setup, onboard): pick a model provider, prove it
11
+ * answers, wire up the sensors. `npx useathena onboard --yes` is the one-command
12
+ * path: it accepts every default, including a global self-install when running
13
+ * from the npx cache (hooks and MCP registrations must outlive cache pruning).
14
+ * Granular escape hatches: --model <spec>, --hook/--no-hook, --skip-test.
15
+ */
16
+ const OLLAMA_TAGS_URL = "http://127.0.0.1:11434/api/tags";
17
+ export async function runSetup(args, packageRoot) {
18
+ const interactive = process.stdin.isTTY === true && !args.includes("--yes");
19
+ const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
20
+ try {
21
+ console.log(`${bold("athena setup")}\n`);
22
+ console.log(`${green("✓")} store ready at ${bold(dbPath())}`);
23
+ const config = loadConfig();
24
+ const spec = await chooseModelSpec(args, config, rl);
25
+ config.model = spec;
26
+ saveConfig(config);
27
+ console.log(`${green("✓")} model: ${bold(spec)} ${dim(`(saved to ${configPath()})`)}`);
28
+ if (!args.includes("--skip-test")) {
29
+ await testModel(spec, config);
30
+ }
31
+ const home = await ensureDurableInstall(rl, packageRoot);
32
+ const mcpRegistered = await offerClaudeCode(args, rl, home.bin);
33
+ if (!mcpRegistered) {
34
+ console.log(`\n${bold("connect your agent (MCP):")}`);
35
+ console.log(` claude mcp add --scope user athena -- ${home.bin} mcp`);
36
+ console.log(dim(` (other MCP clients: stdio command "${home.bin} mcp")`));
37
+ }
38
+ console.log(`\n${bold("browser sensor")} ${dim("(LinkedIn and Gmail draft edits)")}:`);
39
+ 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`);
41
+ console.log(`\n${bold("the loop:")} capture runs in the background — then`);
42
+ console.log(` ${cyan("athena learn")} infer tacit rules from captured evidence`);
43
+ console.log(` ${cyan("athena rules")} see what athena believes (and how confidently)`);
44
+ console.log(` ${cyan('athena brief "task"')} the judgment an agent gets before acting`);
45
+ console.log(dim(`\nrules serve themselves once replay-validated; they graduate to confident\nthrough upheld outcomes — or faster via: athena review`));
46
+ }
47
+ finally {
48
+ rl?.close();
49
+ }
50
+ }
51
+ async function chooseModelSpec(args, config, rl) {
52
+ const flagged = flag(args, "model");
53
+ if (flagged) {
54
+ modelClientFromSpec(flagged, config); // validate the spec before saving it
55
+ return flagged;
56
+ }
57
+ const options = await buildProviderOptions(config);
58
+ if (!rl) {
59
+ const auto = options.find((option) => option.auto !== undefined);
60
+ if (!auto?.auto) {
61
+ throw new Error(`no provider auto-detected — pass one explicitly: athena setup --model <spec> (${SUPPORTED_SPECS})`);
62
+ }
63
+ return auto.auto;
64
+ }
65
+ console.log(`\n${bold("model provider")} ${dim("— powers rule inference; capture works without it")}`);
66
+ options.forEach((option, index) => {
67
+ console.log(` ${bold(String(index + 1))}. ${option.name} ${dim(option.hint)}`);
68
+ });
69
+ while (true) {
70
+ const answer = (await rl.question(`pick [1-${options.length}, default 1] > `)).trim();
71
+ const index = answer === "" ? 0 : Number(answer) - 1;
72
+ const option = options[index];
73
+ if (!option) {
74
+ console.log(yellow("not an option, try again"));
75
+ continue;
76
+ }
77
+ try {
78
+ return await option.resolve(rl, config);
79
+ }
80
+ catch (error) {
81
+ console.log(red(String(error instanceof Error ? error.message : error)));
82
+ }
83
+ }
84
+ }
85
+ async function buildProviderOptions(config) {
86
+ const options = [];
87
+ if (onPath("claude")) {
88
+ options.push({
89
+ name: "cli:claude",
90
+ hint: "your Claude subscription via the claude CLI — no API key",
91
+ auto: "cli:claude",
92
+ resolve: async () => "cli:claude",
93
+ });
94
+ }
95
+ if (onPath("codex")) {
96
+ options.push({
97
+ name: "cli:codex",
98
+ hint: "your ChatGPT subscription via the codex CLI — no API key",
99
+ auto: "cli:codex",
100
+ resolve: async () => "cli:codex",
101
+ });
102
+ }
103
+ const hasAnthropicKey = Boolean(process.env.ANTHROPIC_API_KEY ?? config.keys?.anthropic);
104
+ options.push({
105
+ name: "anthropic",
106
+ hint: hasAnthropicKey
107
+ ? `Anthropic API, key found — ${DEFAULT_ANTHROPIC_MODEL}`
108
+ : "Anthropic API — asks for an API key",
109
+ ...(hasAnthropicKey ? { auto: "anthropic" } : {}),
110
+ resolve: async (rl, config) => {
111
+ if (!hasAnthropicKey)
112
+ await promptForKey(rl, config, "anthropic", "ANTHROPIC_API_KEY");
113
+ return "anthropic";
114
+ },
115
+ });
116
+ const hasOpenAiKey = Boolean(process.env.OPENAI_API_KEY ?? config.keys?.openai);
117
+ options.push({
118
+ name: "openai",
119
+ hint: hasOpenAiKey ? "OpenAI API, key found — asks for a model" : "OpenAI API — asks for a key and a model",
120
+ resolve: async (rl, config) => {
121
+ if (!hasOpenAiKey)
122
+ await promptForKey(rl, config, "openai", "OPENAI_API_KEY");
123
+ const model = (await rl.question("OpenAI model name (e.g. gpt-5.2) > ")).trim();
124
+ if (!model)
125
+ throw new Error("a model name is required for the openai provider");
126
+ return `openai:${model}`;
127
+ },
128
+ });
129
+ const ollamaModels = await detectOllamaModels();
130
+ if (ollamaModels.length > 0) {
131
+ options.push({
132
+ name: "ollama",
133
+ hint: `local Ollama is running — ${ollamaModels.slice(0, 3).join(", ")}${ollamaModels.length > 3 ? ", …" : ""}`,
134
+ resolve: async (rl) => {
135
+ const fallback = ollamaModels[0] ?? "";
136
+ const model = (await rl.question(`Ollama model [${fallback}] > `)).trim() || fallback;
137
+ return `ollama:${model}`;
138
+ },
139
+ });
140
+ }
141
+ options.push({
142
+ name: "custom",
143
+ hint: `any spec: ${SUPPORTED_SPECS}`,
144
+ resolve: async (rl, config) => {
145
+ const spec = (await rl.question("model spec > ")).trim();
146
+ modelClientFromSpec(spec, config); // validate before accepting
147
+ return spec;
148
+ },
149
+ });
150
+ return options;
151
+ }
152
+ async function promptForKey(rl, config, provider, envVar) {
153
+ const key = (await rl.question(`${provider} API key ${dim(`(stored 0600 in ${configPath()}; or export ${envVar} instead)`)} > `)).trim();
154
+ if (!key)
155
+ throw new Error(`an API key is required — or export ${envVar} and re-run setup`);
156
+ config.keys = { ...config.keys, [provider]: key };
157
+ }
158
+ async function testModel(spec, config) {
159
+ console.log(dim(`testing ${spec} with one inference call (CLI runners can take ~30s)…`));
160
+ try {
161
+ const client = modelClientFromSpec(spec, config);
162
+ const result = await client.generateJson({
163
+ system: "You are a JSON echo. Reply with JSON only, no prose.",
164
+ prompt: 'Reply with exactly this JSON object: {"ok": true}',
165
+ });
166
+ if (typeof result !== "object" || result === null)
167
+ throw new Error(`unexpected reply: ${JSON.stringify(result)}`);
168
+ console.log(`${green("✓")} model responds`);
169
+ }
170
+ catch (error) {
171
+ console.log(red(`✗ model test failed: ${String(error instanceof Error ? error.message : error).slice(0, 200)}`));
172
+ console.log(dim(" the choice is saved anyway — fix the provider and re-run: athena setup"));
173
+ }
174
+ }
175
+ /**
176
+ * One-off runs from the npx (or pnpm dlx) cache must not be what hooks and MCP
177
+ * registrations point at — the cache gets pruned. Self-install globally so the
178
+ * recorded paths survive; the running package root is the install source, which
179
+ * works the same for registry, git, and tarball invocations.
180
+ */
181
+ async function ensureDurableInstall(rl, packageRoot) {
182
+ const local = { root: packageRoot, bin: join(packageRoot, "bin", "athena") };
183
+ const parts = packageRoot.split(sep);
184
+ const ephemeral = parts.includes("_npx") || parts.some((part) => part.startsWith("dlx-"));
185
+ if (!ephemeral)
186
+ return local;
187
+ let wanted = true;
188
+ if (rl) {
189
+ const answer = (await rl.question(`\nRunning via npx — install athena globally so hooks and MCP survive the npx cache? [Y/n] `))
190
+ .trim()
191
+ .toLowerCase();
192
+ wanted = answer === "" || answer === "y" || answer === "yes";
193
+ }
194
+ if (!wanted) {
195
+ console.log(yellow(" skipping global install — hook/MCP paths will break when the npx cache is pruned"));
196
+ return local;
197
+ }
198
+ const packageName = readPackageName(packageRoot);
199
+ console.log(dim("installing globally (npm install -g)…"));
200
+ const install = spawnSync("npm", ["install", "-g", packageRoot], { encoding: "utf8" });
201
+ if (install.status !== 0) {
202
+ console.log(red(`✗ global install failed: ${(install.stderr ?? "").trim().slice(-300)}`));
203
+ console.log(yellow(` continuing with npx-cache paths — install manually with: npm install -g ${packageName}`));
204
+ return local;
205
+ }
206
+ const prefix = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8" }).stdout?.trim();
207
+ const root = prefix ? join(prefix, "lib", "node_modules", packageName) : "";
208
+ const bin = prefix ? join(prefix, "bin", "athena") : "";
209
+ if (root && existsSync(bin)) {
210
+ console.log(`${green("✓")} installed globally — ${bold("athena")} is now on your PATH`);
211
+ return { root, bin };
212
+ }
213
+ return local;
214
+ }
215
+ /** Wire up Claude Code end to end: sensor hook + MCP registration. */
216
+ async function offerClaudeCode(args, rl, athenaBin) {
217
+ if (args.includes("--no-hook"))
218
+ return false;
219
+ const hasClaudeDir = existsSync(join(homedir(), ".claude"));
220
+ let wanted = args.includes("--hook") || (args.includes("--yes") && hasClaudeDir);
221
+ if (!wanted && rl && hasClaudeDir) {
222
+ const answer = (await rl.question(`\nWire up Claude Code? ${dim("(sensor hook + MCP registration)")} [Y/n] `))
223
+ .trim()
224
+ .toLowerCase();
225
+ wanted = answer === "" || answer === "y" || answer === "yes";
226
+ }
227
+ if (!wanted)
228
+ return false;
229
+ const { settingsPath, changed } = installClaudeCodeHook(athenaBin);
230
+ console.log(changed
231
+ ? `${green("✓")} hook installed in ${settingsPath} ${dim("(restart Claude Code sessions to pick it up)")}`
232
+ : `${green("✓")} hook already installed in ${settingsPath}`);
233
+ if (!onPath("claude"))
234
+ return false;
235
+ const existing = spawnSync("claude", ["mcp", "get", "athena"], { stdio: "ignore" });
236
+ if (existing.status === 0) {
237
+ console.log(`${green("✓")} MCP server already registered with Claude Code`);
238
+ return true;
239
+ }
240
+ const added = spawnSync("claude", ["mcp", "add", "--scope", "user", "athena", "--", athenaBin, "mcp"], {
241
+ encoding: "utf8",
242
+ });
243
+ if (added.status === 0) {
244
+ console.log(`${green("✓")} MCP server registered with Claude Code (scope: user)`);
245
+ return true;
246
+ }
247
+ console.log(yellow(`could not register MCP automatically: ${(added.stderr ?? "").trim().slice(-200)}`));
248
+ return false;
249
+ }
250
+ /**
251
+ * Merge the UserPromptSubmit hook into ~/.claude/settings.json. Any previous
252
+ * athena entry is replaced (handles a bin path that moved); everything else in
253
+ * the file is preserved, and the pre-edit file is kept as a one-off backup.
254
+ */
255
+ export function installClaudeCodeHook(athenaBin) {
256
+ const settingsPath = join(homedir(), ".claude", "settings.json");
257
+ const command = `${athenaBin} hook user-prompt`;
258
+ let settings = {};
259
+ const exists = existsSync(settingsPath);
260
+ if (exists) {
261
+ try {
262
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
263
+ }
264
+ catch (error) {
265
+ throw new Error(`${settingsPath} is not valid JSON (${String(error)}) — fix it or add the hook manually: athena hook install --print`);
266
+ }
267
+ }
268
+ const hooks = (settings.hooks ??= {});
269
+ const entries = (hooks.UserPromptSubmit ??= []);
270
+ const isAthena = (entry) => (entry.hooks ?? []).some((h) => h.command?.includes("hook user-prompt") === true && h.command.includes("athena"));
271
+ const kept = entries.filter((entry) => !isAthena(entry));
272
+ const upToDate = entries.some((entry) => (entry.hooks ?? []).some((h) => h.command === command));
273
+ if (upToDate && kept.length === entries.length - 1) {
274
+ return { settingsPath, changed: false };
275
+ }
276
+ kept.push({ hooks: [{ type: "command", command }] });
277
+ hooks.UserPromptSubmit = kept;
278
+ if (exists)
279
+ copyFileSync(settingsPath, `${settingsPath}.athena-backup`);
280
+ mkdirSync(dirname(settingsPath), { recursive: true });
281
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
282
+ return { settingsPath, changed: true };
283
+ }
284
+ async function detectOllamaModels() {
285
+ const controller = new AbortController();
286
+ const timer = setTimeout(() => controller.abort(), 1200);
287
+ try {
288
+ const response = await fetch(OLLAMA_TAGS_URL, { signal: controller.signal });
289
+ if (!response.ok)
290
+ return [];
291
+ const body = (await response.json());
292
+ return (body.models ?? []).map((m) => m.name).filter((name) => typeof name === "string");
293
+ }
294
+ catch {
295
+ return [];
296
+ }
297
+ finally {
298
+ clearTimeout(timer);
299
+ }
300
+ }
301
+ function readPackageName(packageRoot) {
302
+ try {
303
+ const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
304
+ return pkg.name ?? "useathena";
305
+ }
306
+ catch {
307
+ return "useathena";
308
+ }
309
+ }
310
+ function onPath(command) {
311
+ return spawnSync("which", [command], { stdio: "ignore" }).status === 0;
312
+ }
313
+ function flag(args, name) {
314
+ const index = args.indexOf(`--${name}`);
315
+ return index >= 0 ? args[index + 1] : undefined;
316
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,291 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { randomBytes } from "node:crypto";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { AthenaStore } from "./store/store.js";
8
+ import { dbPath } from "./config.js";
9
+ import { startApiServer } from "./api/server.js";
10
+ import { handleUserPrompt } from "./sensors/claude-code-hook.js";
11
+ import { modelClientFromSpec, resolveModelSpec } from "./model/registry.js";
12
+ import { applyReview, cmdBrief, cmdCapture, cmdLearn, cmdOpen, cmdRules, cmdStatus, renderReviewItem, reviewQueue, } from "./cli/commands.js";
13
+ import { recordOutcome } from "./serve/outcome.js";
14
+ import { loadConfig } from "./config.js";
15
+ import { installClaudeCodeHook, runSetup } from "./cli/setup.js";
16
+ import { bold, dim, green, red, yellow } from "./cli/format.js";
17
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
18
+ const athenaBin = join(packageRoot, "bin", "athena");
19
+ const HELP = `${bold("athena")} — agents that learn the tacit knowledge behind how you work
20
+
21
+ usage: athena <command> [args]
22
+
23
+ ${bold("setup")} [--model spec] [--yes]
24
+ first-run wizard: store, model provider, sensors, MCP
25
+ (alias: onboard — npx useathena onboard --yes)
26
+ ${bold("init")} create the local store and print MCP setup
27
+ ${bold("status")} what athena has captured, learned, and served
28
+ ${bold("rules")} [--domain d] [--all] the learned tacit rules (--all includes stale/retired)
29
+ ${bold("review")} review queue: approve or reject inferred rules (optional —
30
+ rules also graduate on their own via upheld outcomes)
31
+ ${bold("learn")} [--domain d] run the hypothesis engine over captured instances
32
+ ${bold("capture")} <kind> --summary "..." [--domain d] [--before t|@f] [--after t|@f]
33
+ capture a judgment moment (kinds: correction, override,
34
+ decision, escalation, failed_attempt, approval, manual_note)
35
+ ${bold("brief")} "task" [--domain d] compile the brief an agent would get for a task
36
+ ${bold("record")} outcome <briefId> <uncorrected|corrected|abandoned>
37
+ ${bold("open")} <athena://ref | id> inspect any entity
38
+ ${bold("hook")} install [--print] install the Claude Code sensor hook into ~/.claude/settings.json
39
+ ${bold("serve")} [--port n] local API for browser sensors (default 127.0.0.1:4517)
40
+ ${bold("mcp")} run the MCP stdio server (claude mcp add athena -- athena mcp)
41
+
42
+ store: ${dbPath()} ${dim("(override with ATHENA_DB)")}
43
+ model: ${resolveModelSpec(loadConfig())} ${dim("(set via athena setup; override with ATHENA_MODEL)")}`;
44
+ function flag(args, name) {
45
+ const index = args.indexOf(`--${name}`);
46
+ return index >= 0 ? args[index + 1] : undefined;
47
+ }
48
+ async function main() {
49
+ const [command, ...args] = process.argv.slice(2);
50
+ if (!command || command === "help" || command === "--help") {
51
+ console.log(HELP);
52
+ return;
53
+ }
54
+ if (command === "hook") {
55
+ await runHook(args);
56
+ return;
57
+ }
58
+ const store = new AthenaStore(dbPath());
59
+ try {
60
+ switch (command) {
61
+ case "setup":
62
+ case "onboard": {
63
+ await runSetup(args, packageRoot);
64
+ break;
65
+ }
66
+ case "init": {
67
+ console.log(`${green("✓")} store ready at ${bold(dbPath())}`);
68
+ console.log(`\nConnect your agent (Claude Code):\n ${bold(`claude mcp add --scope user athena -- ${athenaBin} mcp`)}`);
69
+ console.log(`\nThen capture corrections (or let your agent record them) and run ${bold("athena learn")}.`);
70
+ console.log(dim(`\nfor the guided version (model provider, sensors): athena setup`));
71
+ break;
72
+ }
73
+ case "status":
74
+ console.log(cmdStatus(store));
75
+ break;
76
+ case "rules": {
77
+ const domain = flag(args, "domain");
78
+ console.log(cmdRules(store, {
79
+ ...(domain !== undefined ? { domain } : {}),
80
+ all: args.includes("--all"),
81
+ }));
82
+ break;
83
+ }
84
+ case "brief": {
85
+ const task = args.find((a) => !a.startsWith("--"));
86
+ if (!task)
87
+ throw new Error('usage: athena brief "task description" [--domain d]');
88
+ console.log(cmdBrief(store, task, flag(args, "domain")));
89
+ break;
90
+ }
91
+ case "capture": {
92
+ const kind = args.find((a) => !a.startsWith("--"));
93
+ const summary = flag(args, "summary");
94
+ if (!kind || !summary)
95
+ throw new Error('usage: athena capture <kind> --summary "..." [...]');
96
+ const domain = flag(args, "domain");
97
+ const task = flag(args, "task");
98
+ const before = flag(args, "before");
99
+ const after = flag(args, "after");
100
+ const app = flag(args, "app");
101
+ const flags = {
102
+ kind,
103
+ summary,
104
+ ...(domain !== undefined ? { domain } : {}),
105
+ ...(task !== undefined ? { task } : {}),
106
+ ...(before !== undefined ? { before } : {}),
107
+ ...(after !== undefined ? { after } : {}),
108
+ ...(app !== undefined ? { app } : {}),
109
+ };
110
+ console.log(cmdCapture(store, flags));
111
+ break;
112
+ }
113
+ case "learn": {
114
+ const config = loadConfig();
115
+ const model = modelClientFromSpec(resolveModelSpec(config), config);
116
+ console.log(dim(`thinking with ${model.id}…`));
117
+ const domain = flag(args, "domain");
118
+ console.log(await cmdLearn(store, model, domain !== undefined ? { domain } : {}));
119
+ break;
120
+ }
121
+ case "review": {
122
+ await reviewLoop(store);
123
+ break;
124
+ }
125
+ case "record": {
126
+ const [sub, briefId, result] = args;
127
+ if (sub !== "outcome" || !briefId || !result) {
128
+ throw new Error("usage: athena record outcome <briefId> <uncorrected|corrected|abandoned>");
129
+ }
130
+ const outcome = recordOutcome(store, { briefId: briefId, result: result });
131
+ console.log(`${green("✓")} recorded ${outcome.id} (${outcome.result})`);
132
+ break;
133
+ }
134
+ case "open": {
135
+ const ref = args[0];
136
+ if (!ref)
137
+ throw new Error("usage: athena open <athena://ref | id>");
138
+ console.log(cmdOpen(store, ref));
139
+ break;
140
+ }
141
+ case "serve": {
142
+ const port = Number(flag(args, "port") ?? 4517);
143
+ const token = loadOrCreateServeToken();
144
+ const server = await startApiServer(store, { token, port });
145
+ const address = server.address();
146
+ const actualPort = address !== null && typeof address !== "string" ? address.port : port;
147
+ console.log(`${green("●")} athena API listening on ${bold(`http://127.0.0.1:${actualPort}`)}`);
148
+ console.log(` token: ${bold(token)} ${dim("(paste into the extension options page)")}`);
149
+ console.log(dim(" ctrl-c to stop"));
150
+ await new Promise((resolve) => {
151
+ process.on("SIGINT", () => {
152
+ server.close();
153
+ resolve();
154
+ });
155
+ process.on("SIGTERM", () => {
156
+ server.close();
157
+ resolve();
158
+ });
159
+ });
160
+ break;
161
+ }
162
+ case "mcp": {
163
+ // stdio transport: stdout belongs to the protocol, so print nothing.
164
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
165
+ const { buildMcpServer } = await import("./mcp/server.js");
166
+ const server = buildMcpServer(store);
167
+ await server.connect(new StdioServerTransport());
168
+ await new Promise((resolve) => {
169
+ process.on("SIGINT", resolve);
170
+ process.on("SIGTERM", resolve);
171
+ });
172
+ break;
173
+ }
174
+ default:
175
+ console.error(red(`unknown command: ${command}`));
176
+ console.log(HELP);
177
+ process.exitCode = 1;
178
+ }
179
+ }
180
+ finally {
181
+ store.close();
182
+ }
183
+ }
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
+ /**
201
+ * Hook mode runs on every prompt the user types into Claude Code:
202
+ * stdout stays silent (it would pollute agent context), errors go to
203
+ * ~/.athena/hook.log, and the exit code is always 0 — a broken sensor
204
+ * must never block the user's prompt.
205
+ */
206
+ async function runHook(args) {
207
+ const sub = args[0];
208
+ if (sub === "install") {
209
+ if (args.includes("--print")) {
210
+ const snippet = {
211
+ hooks: {
212
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: `${athenaBin} hook user-prompt` }] }],
213
+ },
214
+ };
215
+ console.log(`Add this to .claude/settings.json (workspace or ~/.claude/settings.json):\n`);
216
+ console.log(JSON.stringify(snippet, null, 2));
217
+ }
218
+ else {
219
+ const { settingsPath, changed } = installClaudeCodeHook(athenaBin);
220
+ console.log(changed
221
+ ? `${green("✓")} hook installed in ${bold(settingsPath)} ${dim("(restart Claude Code sessions to pick it up)")}`
222
+ : `${green("✓")} hook already installed in ${bold(settingsPath)}`);
223
+ }
224
+ console.log(`\nThen connect the MCP server:\n ${bold(`claude mcp add --scope user athena -- ${athenaBin} mcp`)}`);
225
+ return;
226
+ }
227
+ if (sub !== "user-prompt") {
228
+ console.error(red("usage: athena hook <user-prompt|install>"));
229
+ process.exitCode = 1;
230
+ return;
231
+ }
232
+ try {
233
+ let raw = "";
234
+ for await (const chunk of process.stdin)
235
+ raw += chunk;
236
+ const input = JSON.parse(raw);
237
+ if (typeof input.prompt !== "string")
238
+ return;
239
+ const store = new AthenaStore(dbPath());
240
+ try {
241
+ handleUserPrompt(store, input);
242
+ }
243
+ finally {
244
+ store.close();
245
+ }
246
+ }
247
+ catch (error) {
248
+ try {
249
+ const logDir = join(homedir(), ".athena");
250
+ mkdirSync(logDir, { recursive: true });
251
+ appendFileSync(join(logDir, "hook.log"), `${new Date().toISOString()} ${String(error)}\n`);
252
+ }
253
+ catch {
254
+ // a broken sensor must never block the prompt
255
+ }
256
+ }
257
+ }
258
+ async function reviewLoop(store) {
259
+ const queue = reviewQueue(store);
260
+ if (queue.length === 0) {
261
+ console.log(dim("review queue is empty"));
262
+ return;
263
+ }
264
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
265
+ try {
266
+ for (const [index, hypothesis] of queue.entries()) {
267
+ console.log(`\n${renderReviewItem(store, hypothesis, index + 1, queue.length)}\n`);
268
+ const answer = (await readline.question(`${bold("[a]")}pprove ${bold("[r]")}eject ${bold("[s]")}kip ${bold("[q]")}uit > `))
269
+ .trim()
270
+ .toLowerCase();
271
+ if (answer === "a") {
272
+ applyReview(store, hypothesis.id, "approve");
273
+ console.log(green("approved — now served to agents"));
274
+ }
275
+ else if (answer === "r") {
276
+ applyReview(store, hypothesis.id, "reject");
277
+ console.log(red("rejected — retired"));
278
+ }
279
+ else if (answer === "q") {
280
+ break;
281
+ }
282
+ else {
283
+ console.log(yellow("skipped"));
284
+ }
285
+ }
286
+ }
287
+ finally {
288
+ readline.close();
289
+ }
290
+ }
291
+ await main();