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,60 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>athena sensor — options</title>
6
+ <style>
7
+ body {
8
+ font-family: -apple-system, "Segoe UI", sans-serif;
9
+ background: #0d1117;
10
+ color: #e6edf3;
11
+ max-width: 460px;
12
+ margin: 40px auto;
13
+ padding: 0 20px;
14
+ }
15
+ h1 { font-size: 18px; font-weight: 600; }
16
+ label { display: block; margin: 14px 0 4px; font-size: 13px; color: #8b949e; }
17
+ input {
18
+ width: 100%;
19
+ box-sizing: border-box;
20
+ background: #161b22;
21
+ border: 1px solid #30363d;
22
+ border-radius: 6px;
23
+ color: #e6edf3;
24
+ padding: 8px 10px;
25
+ font-size: 13px;
26
+ font-family: ui-monospace, monospace;
27
+ }
28
+ button {
29
+ margin-top: 16px;
30
+ margin-right: 8px;
31
+ background: #238636;
32
+ color: #fff;
33
+ border: 0;
34
+ border-radius: 6px;
35
+ padding: 8px 14px;
36
+ font-size: 13px;
37
+ cursor: pointer;
38
+ }
39
+ button.secondary { background: #21262d; border: 1px solid #30363d; }
40
+ #result { margin-top: 12px; font-size: 13px; }
41
+ .hint { color: #8b949e; font-size: 12px; line-height: 1.5; margin-top: 18px; }
42
+ code { background: #161b22; padding: 1px 5px; border-radius: 4px; }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <h1>athena sensor</h1>
47
+ <label for="port">athena API port</label>
48
+ <input id="port" type="number" value="4517" />
49
+ <label for="token">token</label>
50
+ <input id="token" type="password" placeholder="printed by: athena serve" />
51
+ <button id="save">Save</button>
52
+ <button id="test" class="secondary">Test connection</button>
53
+ <div id="result"></div>
54
+ <div class="hint">
55
+ Start the local API with <code>athena serve</code> — it prints the token.
56
+ The extension only ever talks to <code>127.0.0.1</code>.
57
+ </div>
58
+ <script src="options.js"></script>
59
+ </body>
60
+ </html>
@@ -0,0 +1,36 @@
1
+ const port = document.getElementById("port");
2
+ const token = document.getElementById("token");
3
+ const result = document.getElementById("result");
4
+
5
+ chrome.storage.local.get({ athenaPort: 4517, athenaToken: "" }, (settings) => {
6
+ port.value = settings.athenaPort;
7
+ token.value = settings.athenaToken;
8
+ });
9
+
10
+ document.getElementById("save").addEventListener("click", () => {
11
+ chrome.storage.local.set(
12
+ { athenaPort: Number(port.value) || 4517, athenaToken: token.value.trim() },
13
+ () => {
14
+ result.textContent = "saved";
15
+ result.style.color = "#2da44e";
16
+ },
17
+ );
18
+ });
19
+
20
+ document.getElementById("test").addEventListener("click", async () => {
21
+ result.textContent = "testing…";
22
+ result.style.color = "#8b949e";
23
+ try {
24
+ const response = await fetch(`http://127.0.0.1:${Number(port.value) || 4517}/health`);
25
+ if (response.ok) {
26
+ result.textContent = "connected ✓";
27
+ result.style.color = "#2da44e";
28
+ } else {
29
+ result.textContent = `athena answered ${response.status}`;
30
+ result.style.color = "#cf222e";
31
+ }
32
+ } catch {
33
+ result.textContent = "not reachable — run: athena serve";
34
+ result.style.color = "#cf222e";
35
+ }
36
+ });
@@ -0,0 +1,37 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <style>
6
+ body {
7
+ font-family: -apple-system, "Segoe UI", sans-serif;
8
+ background: #0d1117;
9
+ color: #e6edf3;
10
+ width: 260px;
11
+ margin: 0;
12
+ padding: 14px;
13
+ }
14
+ h1 { font-size: 14px; margin: 0 0 10px; font-weight: 600; }
15
+ .row { display: flex; align-items: center; gap: 8px; margin: 8px 0; font-size: 13px; }
16
+ .dot { width: 9px; height: 9px; border-radius: 50%; background: #6e7681; }
17
+ .dot.ok { background: #2da44e; }
18
+ .dot.bad { background: #cf222e; }
19
+ a { color: #58a6ff; font-size: 12px; }
20
+ .hint { color: #8b949e; font-size: 11px; margin-top: 10px; line-height: 1.4; }
21
+ </style>
22
+ </head>
23
+ <body>
24
+ <h1>athena sensor</h1>
25
+ <div class="row"><span id="dot" class="dot"></span><span id="status">checking…</span></div>
26
+ <div class="row">
27
+ <input type="checkbox" id="paused" />
28
+ <label for="paused">pause capture</label>
29
+ </div>
30
+ <div class="row"><a href="#" id="options">options</a></div>
31
+ <div class="hint">
32
+ Captures only when you edit a pasted draft before sending, or when you right-click →
33
+ "athena: remember this". Everything goes to your local athena, nowhere else.
34
+ </div>
35
+ <script src="popup.js"></script>
36
+ </body>
37
+ </html>
@@ -0,0 +1,22 @@
1
+ const dot = document.getElementById("dot");
2
+ const status = document.getElementById("status");
3
+ const paused = document.getElementById("paused");
4
+
5
+ chrome.runtime.sendMessage({ type: "athena-health" }, (result) => {
6
+ const ok = result && result.ok;
7
+ dot.className = `dot ${ok ? "ok" : "bad"}`;
8
+ status.textContent = ok ? "connected to athena" : "athena not running — run: athena serve";
9
+ });
10
+
11
+ chrome.storage.local.get({ paused: false }, ({ paused: value }) => {
12
+ paused.checked = value;
13
+ });
14
+
15
+ paused.addEventListener("change", () => {
16
+ chrome.storage.local.set({ paused: paused.checked });
17
+ });
18
+
19
+ document.getElementById("options").addEventListener("click", (event) => {
20
+ event.preventDefault();
21
+ chrome.runtime.openOptionsPage();
22
+ });
package/bin/athena ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ // Runs the built CLI when dist/ exists (global installs build it via the
3
+ // prepare script); falls back to the TypeScript source through tsx for dev
4
+ // checkouts. The version check runs first because node:sqlite needs 22.5+.
5
+ import { existsSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
11
+ if (major < 22 || (major === 22 && minor < 5)) {
12
+ console.error(`athena needs Node 22.5 or newer (found ${process.versions.node}) — it uses the built-in node:sqlite module.`);
13
+ console.error("Upgrade via https://nodejs.org or your version manager (e.g. nvm install 22).");
14
+ process.exit(1);
15
+ }
16
+
17
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
18
+ const built = join(root, "dist", "cli.js");
19
+ if (existsSync(built)) {
20
+ await import(built);
21
+ } else {
22
+ const result = spawnSync(
23
+ process.execPath,
24
+ ["--import", "tsx", join(root, "src", "cli.ts"), ...process.argv.slice(2)],
25
+ { stdio: "inherit" },
26
+ );
27
+ process.exit(result.status ?? 1);
28
+ }
@@ -0,0 +1,145 @@
1
+ import { createServer } from "node:http";
2
+ import { timingSafeEqual } from "node:crypto";
3
+ import { ingestSensorEvent } from "../capture/ingest.js";
4
+ /**
5
+ * The localhost door for browser sensors. Deliberately tiny:
6
+ * GET /health → liveness, no auth
7
+ * POST /events → SensorEvent in, instance id out; Bearer-token auth
8
+ *
9
+ * Binds 127.0.0.1 only. The token guards against other local processes,
10
+ * not the network. sensorId is set server-side — clients are not trusted
11
+ * to attribute themselves.
12
+ */
13
+ const MAX_BODY_BYTES = 256_000;
14
+ const INSTANCE_KINDS = new Set([
15
+ "correction",
16
+ "override",
17
+ "decision",
18
+ "escalation",
19
+ "failed_attempt",
20
+ "approval",
21
+ "manual_note",
22
+ ]);
23
+ export function startApiServer(store, options) {
24
+ const server = createServer((request, response) => {
25
+ void handle(store, options, request, response);
26
+ });
27
+ return new Promise((resolve, reject) => {
28
+ server.once("error", reject);
29
+ server.listen(options.port ?? 4517, "127.0.0.1", () => resolve(server));
30
+ });
31
+ }
32
+ async function handle(store, options, request, response) {
33
+ cors(response);
34
+ if (request.method === "OPTIONS") {
35
+ response.writeHead(204).end();
36
+ return;
37
+ }
38
+ if (request.method === "GET" && request.url === "/health") {
39
+ json(response, 200, { ok: true, name: "athena", version: "0.1.0" });
40
+ return;
41
+ }
42
+ if (request.method === "POST" && request.url === "/events") {
43
+ if (!authorized(request, options.token)) {
44
+ json(response, 401, { error: "missing or invalid token" });
45
+ return;
46
+ }
47
+ try {
48
+ const body = await readBody(request);
49
+ const event = parseEvent(body, options.sensorId ?? "sen_extension");
50
+ const instance = ingestSensorEvent(store, event);
51
+ json(response, 201, { captured: instance.id, kind: instance.kind, domain: instance.situation.domain });
52
+ }
53
+ catch (error) {
54
+ json(response, 400, { error: error instanceof Error ? error.message : "invalid request" });
55
+ }
56
+ return;
57
+ }
58
+ json(response, 404, { error: "not found" });
59
+ }
60
+ function authorized(request, token) {
61
+ const header = request.headers.authorization;
62
+ if (typeof header !== "string" || !header.startsWith("Bearer "))
63
+ return false;
64
+ const presented = Buffer.from(header.slice("Bearer ".length));
65
+ const expected = Buffer.from(token);
66
+ return presented.length === expected.length && timingSafeEqual(presented, expected);
67
+ }
68
+ export function parseEvent(raw, sensorId) {
69
+ if (typeof raw !== "object" || raw === null)
70
+ throw new Error("event must be a JSON object");
71
+ const record = raw;
72
+ if (typeof record.kind !== "string" || !INSTANCE_KINDS.has(record.kind)) {
73
+ throw new Error(`invalid kind: ${String(record.kind)}`);
74
+ }
75
+ const situation = record.situation;
76
+ if (typeof situation !== "object" ||
77
+ situation === null ||
78
+ typeof situation.summary !== "string" ||
79
+ (situation.summary).trim().length === 0) {
80
+ throw new Error("event requires situation.summary");
81
+ }
82
+ const sit = situation;
83
+ const before = artifact(record.before);
84
+ const after = artifact(record.after);
85
+ return {
86
+ sensorId,
87
+ emittedAt: typeof record.emittedAt === "string" ? record.emittedAt : new Date().toISOString(),
88
+ kind: record.kind,
89
+ situation: {
90
+ summary: sit.summary,
91
+ ...(typeof sit.domain === "string" ? { domain: sit.domain } : {}),
92
+ ...(typeof sit.task === "string" ? { task: sit.task } : {}),
93
+ ...(typeof sit.app === "string" ? { app: sit.app } : {}),
94
+ },
95
+ ...(before !== undefined ? { before } : {}),
96
+ ...(after !== undefined ? { after } : {}),
97
+ ...(Array.isArray(record.probeAnswers) ? { probeAnswers: record.probeAnswers } : {}),
98
+ ...(record.raw !== undefined ? { raw: record.raw } : {}),
99
+ };
100
+ }
101
+ function artifact(value) {
102
+ if (typeof value === "string" && value.length > 0)
103
+ return { mediaType: "text/plain", content: value };
104
+ if (typeof value === "object" && value !== null && typeof value.content === "string") {
105
+ const record = value;
106
+ return {
107
+ mediaType: typeof record.mediaType === "string" ? record.mediaType : "text/plain",
108
+ content: record.content,
109
+ };
110
+ }
111
+ return undefined;
112
+ }
113
+ function readBody(request) {
114
+ return new Promise((resolve, reject) => {
115
+ let size = 0;
116
+ const chunks = [];
117
+ request.on("data", (chunk) => {
118
+ size += chunk.length;
119
+ if (size > MAX_BODY_BYTES) {
120
+ reject(new Error("body too large"));
121
+ request.destroy();
122
+ return;
123
+ }
124
+ chunks.push(chunk);
125
+ });
126
+ request.on("end", () => {
127
+ try {
128
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
129
+ }
130
+ catch {
131
+ reject(new Error("body is not valid JSON"));
132
+ }
133
+ });
134
+ request.on("error", reject);
135
+ });
136
+ }
137
+ function cors(response) {
138
+ response.setHeader("access-control-allow-origin", "*");
139
+ response.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
140
+ response.setHeader("access-control-allow-headers", "content-type, authorization");
141
+ }
142
+ function json(response, status, body) {
143
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
144
+ response.end(JSON.stringify(body));
145
+ }
@@ -0,0 +1,85 @@
1
+ import { createHash } from "node:crypto";
2
+ import { newId } from "../core/ids.js";
3
+ /**
4
+ * SensorEvent in, immutable JudgmentInstance out. Sensors observe; this is
5
+ * where interpretation starts (and stays minimal: normalization + a cheap
6
+ * structural diff — semantic interpretation belongs to the engine).
7
+ */
8
+ const CONTENT_CAP_BYTES = 64_000;
9
+ const DEFAULT_ACTOR = "act_local";
10
+ export function ingestSensorEvent(store, event, options = {}) {
11
+ const situation = {
12
+ summary: event.situation.summary,
13
+ domain: event.situation.domain ?? "general",
14
+ cues: event.situation.cues ?? [],
15
+ objectIds: event.situation.objectIds ?? [],
16
+ ...(event.situation.task !== undefined ? { task: event.situation.task } : {}),
17
+ ...(event.situation.app !== undefined ? { app: event.situation.app } : {}),
18
+ ...(event.situation.agent !== undefined ? { agent: event.situation.agent } : {}),
19
+ };
20
+ const before = event.before ? toArtifact(event.before) : undefined;
21
+ const after = event.after ? toArtifact(event.after) : undefined;
22
+ const diff = before && after && event.kind !== "approval" ? structuralDiff(before, after, situation.domain) : undefined;
23
+ const instance = {
24
+ id: newId("ins"),
25
+ kind: event.kind,
26
+ observedAt: event.emittedAt,
27
+ situation,
28
+ ...(before ? { before } : {}),
29
+ ...(after ? { after } : {}),
30
+ ...(diff ? { diff } : {}),
31
+ probeAnswers: event.probeAnswers ?? [],
32
+ sensorId: event.sensorId,
33
+ actorId: options.actorId ?? DEFAULT_ACTOR,
34
+ sourceRefs: [],
35
+ objectIds: situation.objectIds,
36
+ visibility: "user_private_raw",
37
+ canPromote: false,
38
+ canUseForAgents: false,
39
+ };
40
+ store.saveInstance(instance);
41
+ return instance;
42
+ }
43
+ function toArtifact(input) {
44
+ const content = input.content.length > CONTENT_CAP_BYTES ? input.content.slice(0, CONTENT_CAP_BYTES) : input.content;
45
+ return {
46
+ mediaType: input.mediaType,
47
+ content,
48
+ contentHash: createHash("sha256").update(input.content).digest("hex").slice(0, 16),
49
+ };
50
+ }
51
+ /** Cheap structural diff: how much changed, not what it means. */
52
+ function structuralDiff(before, after, domain) {
53
+ const ratio = changedRatio(before.content, after.content);
54
+ return {
55
+ summary: `edited ${domain} draft (~${Math.round(ratio * 100)}% changed)`,
56
+ hunks: [{ before: before.content, after: after.content }],
57
+ magnitude: magnitudeFor(ratio),
58
+ };
59
+ }
60
+ export function changedRatio(before, after) {
61
+ if (before === after)
62
+ return 0;
63
+ const max = Math.max(before.length, after.length);
64
+ if (max === 0)
65
+ return 0;
66
+ let prefix = 0;
67
+ while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix])
68
+ prefix += 1;
69
+ let suffix = 0;
70
+ while (suffix < before.length - prefix &&
71
+ suffix < after.length - prefix &&
72
+ before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) {
73
+ suffix += 1;
74
+ }
75
+ return 1 - (prefix + suffix) / max;
76
+ }
77
+ function magnitudeFor(ratio) {
78
+ if (ratio < 0.05)
79
+ return "trivial";
80
+ if (ratio < 0.25)
81
+ return "minor";
82
+ if (ratio < 0.6)
83
+ return "substantive";
84
+ return "rewrite";
85
+ }
@@ -0,0 +1,201 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { refTo } from "../core/refs.js";
3
+ import { openRef } from "../store/open.js";
4
+ import { ingestSensorEvent } from "../capture/ingest.js";
5
+ import { compileBrief } from "../serve/brief.js";
6
+ import { LlmHypothesisEngine } from "../engine/engine.js";
7
+ import { bold, confidenceBar, cyan, dim, green, hitRate, readinessBadge, red, statusBadge, wrap, yellow } from "./format.js";
8
+ /**
9
+ * Command logic, pure-ish: store in, rendered string out. The interactive
10
+ * shell (arg parsing, readline) lives in src/cli.ts and stays untested-thin.
11
+ */
12
+ export function cmdStatus(store) {
13
+ const counts = store.counts();
14
+ const instanceTotal = Object.values(counts.instances).reduce((a, b) => a + b, 0);
15
+ const hypothesisTotal = Object.values(counts.hypotheses).reduce((a, b) => a + b, 0);
16
+ const outcomes = store.listOutcomes();
17
+ const judged = outcomes.filter((o) => o.result === "uncorrected" || o.result === "corrected");
18
+ const corrected = judged.filter((o) => o.result === "corrected").length;
19
+ const lines = [
20
+ bold("athena status"),
21
+ "",
22
+ ` ${bold(String(instanceTotal))} judgment instance${instanceTotal === 1 ? "" : "s"} captured` +
23
+ (instanceTotal > 0
24
+ ? dim(` (${Object.entries(counts.instances)
25
+ .map(([kind, n]) => `${kind}: ${n}`)
26
+ .join(", ")})`)
27
+ : ""),
28
+ ` ${bold(String(hypothesisTotal))} tacit rule${hypothesisTotal === 1 ? "" : "s"}` +
29
+ (hypothesisTotal > 0
30
+ ? dim(` (${Object.entries(counts.hypotheses)
31
+ .map(([status, n]) => `${status}: ${n}`)
32
+ .join(", ")})`)
33
+ : ""),
34
+ ` ${bold(String(counts.briefs))} briefs served, ${bold(String(counts.outcomes))} outcomes recorded`,
35
+ ];
36
+ if (judged.length > 0) {
37
+ const rate = Math.round((corrected / judged.length) * 100);
38
+ const paint = rate <= 30 ? green : rate <= 60 ? yellow : red;
39
+ lines.push(` correction rate: ${paint(`${rate}%`)} ${dim(`(${corrected} of ${judged.length} judged briefs corrected — lower is better)`)}`);
40
+ }
41
+ const review = reviewQueue(store).length;
42
+ if (review > 0) {
43
+ lines.push("", yellow(` ${review} rule${review === 1 ? "" : "s"} in the review queue (optional — rules also graduate on outcomes) — run: athena review`));
44
+ }
45
+ return lines.join("\n");
46
+ }
47
+ export function cmdRules(store, options = {}) {
48
+ const hypotheses = store
49
+ .listHypotheses(options.domain !== undefined ? { domain: options.domain } : {})
50
+ .filter((h) => options.all === true || (h.status !== "retired" && h.status !== "stale"))
51
+ .sort((a, b) => b.confidence - a.confidence);
52
+ if (hypotheses.length === 0) {
53
+ return dim("no rules yet — capture corrections, then run: athena learn");
54
+ }
55
+ return hypotheses.map((h) => renderRule(h)).join("\n\n");
56
+ }
57
+ function renderRule(h) {
58
+ const lines = [
59
+ `${statusBadge(h.status)} ${confidenceBar(h.confidence)} ${dim(h.domain)} ${dim(h.id)}`,
60
+ wrap(h.rule, " "),
61
+ ];
62
+ if (h.doesNotApplyWhen.length > 0)
63
+ lines.push(dim(wrap(`except: ${h.doesNotApplyWhen.join("; ")}`, " ")));
64
+ const evidence = `${h.supportingInstanceIds.length} supporting, ${h.counterexampleInstanceIds.length} counter`;
65
+ lines.push(` ${dim(evidence)} ${hitRate(h.validity.upheld, h.validity.overridden)}`);
66
+ return lines.join("\n");
67
+ }
68
+ export function cmdBrief(store, task, domain) {
69
+ const brief = compileBrief(store, domain !== undefined ? { task, domain } : { task });
70
+ const lines = [
71
+ `${bold("brief")} ${dim(brief.id)}`,
72
+ `${bold("readiness:")} ${readinessBadge(brief.readiness)}`,
73
+ ];
74
+ if (brief.rules.length > 0) {
75
+ lines.push("", bold("rules:"));
76
+ for (const rule of brief.rules) {
77
+ lines.push(wrap(`• ${rule.rule}`, " "), dim(` conf ${rule.confidence.toFixed(2)} — ${rule.appliesBecause}`));
78
+ if (rule.boundaries.length > 0)
79
+ lines.push(dim(wrap(`except: ${rule.boundaries.join("; ")}`, " ")));
80
+ }
81
+ }
82
+ if (brief.facts.length > 0) {
83
+ lines.push("", bold("facts:"));
84
+ for (const fact of brief.facts)
85
+ lines.push(wrap(`• ${fact.statement}`, " "));
86
+ }
87
+ if (brief.doNotAssume.length > 0) {
88
+ lines.push("", red(bold("do not assume:")));
89
+ for (const caveat of brief.doNotAssume)
90
+ lines.push(wrap(`• ${caveat}`, " "));
91
+ }
92
+ if (brief.openQuestions.length > 0) {
93
+ lines.push("", yellow(bold("open questions:")));
94
+ for (const question of brief.openQuestions)
95
+ lines.push(wrap(`• ${question}`, " "));
96
+ }
97
+ lines.push("", dim(`record the outcome: athena record outcome ${brief.id} uncorrected|corrected`));
98
+ return lines.join("\n");
99
+ }
100
+ export function cmdOpen(store, ref) {
101
+ const target = ref.startsWith("athena://") ? ref : refTo(ref);
102
+ const entity = openRef(store, target);
103
+ if (!entity)
104
+ return red(`${target} does not exist`);
105
+ return JSON.stringify(entity, null, 2);
106
+ }
107
+ export function cmdCapture(store, flags) {
108
+ const event = {
109
+ sensorId: "sen_cli",
110
+ emittedAt: new Date().toISOString(),
111
+ kind: flags.kind,
112
+ situation: {
113
+ summary: flags.summary,
114
+ ...(flags.domain !== undefined ? { domain: flags.domain } : {}),
115
+ ...(flags.task !== undefined ? { task: flags.task } : {}),
116
+ ...(flags.app !== undefined ? { app: flags.app } : {}),
117
+ },
118
+ ...(flags.before !== undefined ? { before: { mediaType: "text/plain", content: maybeFile(flags.before) } } : {}),
119
+ ...(flags.after !== undefined ? { after: { mediaType: "text/plain", content: maybeFile(flags.after) } } : {}),
120
+ };
121
+ const instance = ingestSensorEvent(store, event);
122
+ return `${green("captured")} ${instance.id} ${dim(`(${instance.kind}, ${instance.situation.domain})`)}`;
123
+ }
124
+ function maybeFile(value) {
125
+ return value.startsWith("@") ? readFileSync(value.slice(1), "utf8") : value;
126
+ }
127
+ export async function cmdLearn(store, model, options = {}) {
128
+ const instances = store.listInstances({
129
+ ...(options.domain !== undefined ? { domain: options.domain } : {}),
130
+ limit: 500,
131
+ });
132
+ if (instances.length < 2) {
133
+ return dim("not enough captured instances to learn from yet (need at least 2 in a domain)");
134
+ }
135
+ const engine = new LlmHypothesisEngine(model);
136
+ const inferred = await engine.infer(instances);
137
+ const existingRules = new Set(store.listHypotheses({ limit: 1000 }).map((h) => h.rule));
138
+ let saved = 0;
139
+ const lines = [];
140
+ for (const hypothesis of inferred) {
141
+ if (existingRules.has(hypothesis.rule))
142
+ continue;
143
+ store.saveHypothesis(hypothesis);
144
+ saved += 1;
145
+ lines.push("", renderRule(hypothesis));
146
+ }
147
+ const header = `${bold(String(saved))} new rule${saved === 1 ? "" : "s"} from ${instances.length} instances ${dim(`(engine: ${model.id})`)}`;
148
+ const footer = saved > 0 ? `\n\n${yellow("review them: athena review")}` : "";
149
+ return header + lines.join("\n") + footer;
150
+ }
151
+ // --- review queue ---
152
+ export function reviewQueue(store) {
153
+ return store
154
+ .listHypotheses({ limit: 1000 })
155
+ .filter((h) => h.review.state === "unreviewed" && (h.status === "candidate" || h.status === "validated"))
156
+ .sort((a, b) => b.confidence - a.confidence);
157
+ }
158
+ export function renderReviewItem(store, hypothesis, position, total) {
159
+ const lines = [
160
+ `${dim(`[${position}/${total}]`)} ${statusBadge(hypothesis.status)} ${confidenceBar(hypothesis.confidence)} ${dim(hypothesis.domain)}`,
161
+ "",
162
+ wrap(bold(hypothesis.rule), " "),
163
+ ];
164
+ if (hypothesis.appliesWhen.length > 0)
165
+ lines.push("", dim(wrap(`applies: ${hypothesis.appliesWhen.join("; ")}`, " ")));
166
+ if (hypothesis.doesNotApplyWhen.length > 0)
167
+ lines.push(dim(wrap(`except: ${hypothesis.doesNotApplyWhen.join("; ")}`, " ")));
168
+ if (hypothesis.inferredRationale)
169
+ lines.push("", dim(wrap(`engine's reasoning: ${hypothesis.inferredRationale}`, " ")));
170
+ const examples = hypothesis.supportingInstanceIds
171
+ .slice(0, 2)
172
+ .map((id) => store.getInstance(id))
173
+ .filter((i) => i !== undefined);
174
+ for (const instance of examples) {
175
+ lines.push("", ` ${cyan("evidence:")} ${instance.situation.summary}`);
176
+ if (instance.before && instance.after) {
177
+ lines.push(red(wrap(`- ${truncate(instance.before.content)}`, " ")));
178
+ lines.push(green(wrap(`+ ${truncate(instance.after.content)}`, " ")));
179
+ }
180
+ }
181
+ if (hypothesis.replay.tested > 0) {
182
+ lines.push("", dim(` replay: predicted ${hypothesis.replay.reproduced}/${hypothesis.replay.tested} held-out corrections`));
183
+ }
184
+ return lines.join("\n");
185
+ }
186
+ export function applyReview(store, hypothesisId, decision, reviewer = "act_local") {
187
+ const hypothesis = store.getHypothesis(hypothesisId);
188
+ if (!hypothesis)
189
+ throw new Error(`unknown hypothesis ${hypothesisId}`);
190
+ hypothesis.review = {
191
+ state: decision === "approve" ? "approved" : "rejected",
192
+ byActorId: reviewer,
193
+ at: new Date().toISOString(),
194
+ };
195
+ hypothesis.status = decision === "approve" ? "active" : "retired";
196
+ store.saveHypothesis(hypothesis);
197
+ return hypothesis;
198
+ }
199
+ function truncate(text, max = 160) {
200
+ return text.length > max ? `${text.slice(0, max)}…` : text;
201
+ }