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/dist/cli.js CHANGED
@@ -1,18 +1,21 @@
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";
18
+ import { readPackageInfo, runUpdate, updateNudge } from "./cli/update.js";
16
19
  import { bold, dim, green, red, yellow } from "./cli/format.js";
17
20
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
18
21
  const athenaBin = join(packageRoot, "bin", "athena");
@@ -26,17 +29,22 @@ usage: athena <command> [args]
26
29
  ${bold("init")} create the local store and print MCP setup
27
30
  ${bold("status")} what athena has captured, learned, and served
28
31
  ${bold("rules")} [--domain d] [--all] the learned tacit rules (--all includes stale/retired)
32
+ ${bold("facts")} [--entity e] [--domain d] the extracted entity facts (athena's explicit knowledge)
29
33
  ${bold("review")} review queue: approve or reject inferred rules (optional —
30
34
  rules also graduate on their own via upheld outcomes)
31
- ${bold("learn")} [--domain d] run the hypothesis engine over captured instances
35
+ ${bold("learn")} [--domain d] infer tacit rules AND extract entity facts from evidence
32
36
  ${bold("capture")} <kind> --summary "..." [--domain d] [--before t|@f] [--after t|@f]
33
37
  capture a judgment moment (kinds: correction, override,
34
38
  decision, escalation, failed_attempt, approval, manual_note)
35
39
  ${bold("brief")} "task" [--domain d] compile the brief an agent would get for a task
36
40
  ${bold("record")} outcome <briefId> <uncorrected|corrected|abandoned>
41
+ ${bold("record")} output <briefId> <text|@file> register an agent draft for automatic outcomes
42
+ ${bold("export")} [--out f] [--redact] one JSON bundle of everything learned (for analysis)
37
43
  ${bold("open")} <athena://ref | id> inspect any entity
38
44
  ${bold("hook")} install [--print] install the Claude Code sensor hook into ~/.claude/settings.json
39
45
  ${bold("serve")} [--port n] local API for browser sensors (default 127.0.0.1:4517)
46
+ ${bold("serve")} install|uninstall run the API at login (launchd/systemd user service)
47
+ ${bold("update")} update to the latest published version (restarts serve)
40
48
  ${bold("mcp")} run the MCP stdio server (claude mcp add athena -- athena mcp)
41
49
 
42
50
  store: ${dbPath()} ${dim("(override with ATHENA_DB)")}
@@ -51,6 +59,10 @@ async function main() {
51
59
  console.log(HELP);
52
60
  return;
53
61
  }
62
+ if (command === "version" || command === "--version") {
63
+ console.log(readPackageInfo(packageRoot).version);
64
+ return;
65
+ }
54
66
  if (command === "hook") {
55
67
  await runHook(args);
56
68
  return;
@@ -70,9 +82,13 @@ async function main() {
70
82
  console.log(dim(`\nfor the guided version (model provider, sensors): athena setup`));
71
83
  break;
72
84
  }
73
- case "status":
85
+ case "status": {
74
86
  console.log(cmdStatus(store));
87
+ const nudge = await updateNudge(packageRoot);
88
+ if (nudge)
89
+ console.log(nudge);
75
90
  break;
91
+ }
76
92
  case "rules": {
77
93
  const domain = flag(args, "domain");
78
94
  console.log(cmdRules(store, {
@@ -108,6 +124,7 @@ async function main() {
108
124
  ...(app !== undefined ? { app } : {}),
109
125
  };
110
126
  console.log(cmdCapture(store, flags));
127
+ maybeAutoLearn(store);
111
128
  break;
112
129
  }
113
130
  case "learn": {
@@ -123,14 +140,42 @@ async function main() {
123
140
  break;
124
141
  }
125
142
  case "record": {
126
- const [sub, briefId, result] = args;
127
- if (sub !== "outcome" || !briefId || !result) {
143
+ const [sub, briefId, value] = args;
144
+ if (sub === "output") {
145
+ if (!briefId || !value)
146
+ throw new Error("usage: athena record output <briefId> <text|@file>");
147
+ const content = value.startsWith("@") ? readFileSync(value.slice(1), "utf8") : value;
148
+ console.log(cmdRecordDraft(store, briefId, content));
149
+ break;
150
+ }
151
+ if (sub !== "outcome" || !briefId || !value) {
128
152
  throw new Error("usage: athena record outcome <briefId> <uncorrected|corrected|abandoned>");
129
153
  }
130
- const outcome = recordOutcome(store, { briefId: briefId, result: result });
154
+ const outcome = recordOutcome(store, { briefId: briefId, result: value });
131
155
  console.log(`${green("✓")} recorded ${outcome.id} (${outcome.result})`);
132
156
  break;
133
157
  }
158
+ case "facts": {
159
+ const entity = flag(args, "entity");
160
+ const domain = flag(args, "domain");
161
+ console.log(cmdFacts(store, {
162
+ ...(entity !== undefined ? { entity } : {}),
163
+ ...(domain !== undefined ? { domain } : {}),
164
+ }));
165
+ break;
166
+ }
167
+ case "export": {
168
+ const out = flag(args, "out") ?? `athena-export-${new Date().toISOString().slice(0, 10)}.json`;
169
+ const bundle = JSON.stringify(buildExport(store, { redact: args.includes("--redact") }), null, 2);
170
+ if (out === "-") {
171
+ console.log(bundle);
172
+ }
173
+ else {
174
+ writeFileSync(out, `${bundle}\n`);
175
+ console.log(`${green("✓")} exported to ${bold(out)}${args.includes("--redact") ? dim(" (raw text redacted)") : ""}`);
176
+ }
177
+ break;
178
+ }
134
179
  case "open": {
135
180
  const ref = args[0];
136
181
  if (!ref)
@@ -139,6 +184,18 @@ async function main() {
139
184
  break;
140
185
  }
141
186
  case "serve": {
187
+ if (args[0] === "install") {
188
+ const result = installService(athenaBin);
189
+ console.log(result.installed ? `${green("✓")} ${result.message}` : yellow(result.message));
190
+ if (result.installed) {
191
+ console.log(` token: ${bold(loadOrCreateServeToken())} ${dim("(paste into the extension options page)")}`);
192
+ }
193
+ break;
194
+ }
195
+ if (args[0] === "uninstall") {
196
+ console.log(uninstallService().message);
197
+ break;
198
+ }
142
199
  const port = Number(flag(args, "port") ?? 4517);
143
200
  const token = loadOrCreateServeToken();
144
201
  const server = await startApiServer(store, { token, port });
@@ -159,6 +216,10 @@ async function main() {
159
216
  });
160
217
  break;
161
218
  }
219
+ case "update": {
220
+ await runUpdate(packageRoot, athenaBin);
221
+ break;
222
+ }
162
223
  case "mcp": {
163
224
  // stdio transport: stdout belongs to the protocol, so print nothing.
164
225
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
@@ -181,22 +242,6 @@ async function main() {
181
242
  store.close();
182
243
  }
183
244
  }
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
245
  /**
201
246
  * Hook mode runs on every prompt the user types into Claude Code:
202
247
  * stdout stays silent (it would pollute agent context), errors go to
@@ -238,7 +283,9 @@ async function runHook(args) {
238
283
  return;
239
284
  const store = new AthenaStore(dbPath());
240
285
  try {
241
- handleUserPrompt(store, input);
286
+ const result = handleUserPrompt(store, input);
287
+ if (result.captured)
288
+ maybeAutoLearn(store);
242
289
  }
243
290
  finally {
244
291
  store.close();
@@ -41,6 +41,32 @@ export function makeInstance(overrides = {}) {
41
41
  };
42
42
  return { ...base, ...overrides };
43
43
  }
44
+ export function makeObject(overrides = {}) {
45
+ const base = {
46
+ id: newId("obj"),
47
+ kind: "org",
48
+ name: "Acme Corp",
49
+ aliases: ["acme", "acme.com"],
50
+ properties: {},
51
+ validFrom: new Date(0).toISOString(),
52
+ };
53
+ return { ...base, ...overrides };
54
+ }
55
+ export function makeFact(overrides = {}) {
56
+ const base = {
57
+ id: newId("fct"),
58
+ objectId: newId("obj"),
59
+ statement: "Acme's contract renewal is in September 2026.",
60
+ domain: "email.outreach",
61
+ supportingInstanceIds: [newId("ins")],
62
+ confidence: 0.7,
63
+ firstSeenAt: new Date(0).toISOString(),
64
+ lastConfirmedAt: new Date(0).toISOString(),
65
+ staleAfter: new Date(180 * 24 * 3600 * 1000).toISOString(),
66
+ visibility: "user_private",
67
+ };
68
+ return { ...base, ...overrides };
69
+ }
44
70
  export function makeHypothesis(overrides = {}) {
45
71
  const base = {
46
72
  id: newId("hyp"),
package/dist/core/refs.js CHANGED
@@ -5,6 +5,8 @@ const KIND_BY_PREFIX = {
5
5
  src: "source",
6
6
  out: "outcome",
7
7
  brf: "brief",
8
+ fct: "fact",
9
+ drf: "draft",
8
10
  };
9
11
  export function refTo(id, fragment) {
10
12
  const prefix = id.split("_")[0] ?? "";
@@ -28,13 +28,15 @@ export class LlmHypothesisEngine {
28
28
  this.replaySamples = Math.max(1, options.replaySamples ?? DEFAULT_REPLAY_SAMPLES);
29
29
  }
30
30
  async infer(instances) {
31
- const hypotheses = [];
31
+ const output = { hypotheses: [], facts: [] };
32
32
  for (const [domain, cluster] of clusterByDomain(instances)) {
33
33
  if (cluster.length < MIN_CLUSTER_SIZE)
34
34
  continue;
35
- hypotheses.push(...(await this.inferCluster(domain, cluster)));
35
+ const clusterOutput = await this.inferCluster(domain, cluster);
36
+ output.hypotheses.push(...clusterOutput.hypotheses);
37
+ output.facts.push(...clusterOutput.facts);
36
38
  }
37
- return hypotheses;
39
+ return output;
38
40
  }
39
41
  async inferCluster(domain, cluster) {
40
42
  const { train, holdout } = splitHoldout(cluster);
@@ -42,9 +44,9 @@ export class LlmHypothesisEngine {
42
44
  system: INFERENCE_SYSTEM,
43
45
  prompt: inferencePrompt(domain, train),
44
46
  });
45
- const candidates = parseInferenceResponse(raw, train.length);
47
+ const parsed = parseInferenceResponse(raw, train.length);
46
48
  const results = [];
47
- for (const candidate of candidates) {
49
+ for (const candidate of parsed.hypotheses) {
48
50
  const supportingInstanceIds = candidate.supporting.map((index) => train[index - 1].id);
49
51
  if (supportingInstanceIds.length === 0)
50
52
  continue; // everything cites — uncited rules are dropped
@@ -66,7 +68,14 @@ export class LlmHypothesisEngine {
66
68
  }
67
69
  results.push(hypothesis);
68
70
  }
69
- return results;
71
+ const facts = parsed.facts.map((fact) => ({
72
+ entity: fact.entity,
73
+ statement: fact.statement,
74
+ domain,
75
+ supportingInstanceIds: fact.supporting.map((index) => train[index - 1].id),
76
+ confidence: fact.confidence,
77
+ }));
78
+ return { hypotheses: results, facts };
70
79
  }
71
80
  /** Majority vote across samples; stops early once a side is unreachable. */
72
81
  async replayVerdict(hypothesis, heldOut) {
@@ -0,0 +1,80 @@
1
+ import { newId } from "../core/ids.js";
2
+ import { changedRatio } from "../capture/ingest.js";
3
+ /**
4
+ * Turn engine fact drafts into stored records: resolve or create the entity,
5
+ * then dedupe against that entity's existing facts — a restated fact confirms
6
+ * (bumps lastConfirmedAt, merges evidence) instead of duplicating. Facts are
7
+ * never entered manually and agents cannot write them directly; this is the
8
+ * only path in.
9
+ */
10
+ const FACT_STALE_DAYS = 180;
11
+ /** Statements this close (normalized edit distance) are the same fact. */
12
+ const SAME_FACT_RATIO = 0.25;
13
+ export function materializeFacts(store, drafts, now = () => new Date()) {
14
+ const result = { created: [], confirmed: [] };
15
+ for (const draft of drafts) {
16
+ const object = resolveOrCreateObject(store, draft, now);
17
+ const existing = findSameFact(store.listFacts({ objectId: object.id }), draft.statement);
18
+ if (existing) {
19
+ existing.lastConfirmedAt = now().toISOString();
20
+ existing.staleAfter = staleAfter(now);
21
+ existing.confidence = Math.max(existing.confidence, draft.confidence);
22
+ existing.supportingInstanceIds = [
23
+ ...new Set([...existing.supportingInstanceIds, ...draft.supportingInstanceIds]),
24
+ ];
25
+ store.saveFact(existing);
26
+ result.confirmed.push(existing);
27
+ continue;
28
+ }
29
+ const fact = {
30
+ id: newId("fct", now().getTime()),
31
+ objectId: object.id,
32
+ statement: draft.statement,
33
+ domain: draft.domain,
34
+ supportingInstanceIds: draft.supportingInstanceIds,
35
+ confidence: draft.confidence,
36
+ firstSeenAt: now().toISOString(),
37
+ lastConfirmedAt: now().toISOString(),
38
+ staleAfter: staleAfter(now),
39
+ visibility: "user_private",
40
+ };
41
+ store.saveFact(fact);
42
+ result.created.push(fact);
43
+ }
44
+ return result;
45
+ }
46
+ function resolveOrCreateObject(store, draft, now) {
47
+ for (const alias of [draft.entity.name, ...draft.entity.aliases]) {
48
+ const matches = store.resolveObject(alias);
49
+ if (matches[0]) {
50
+ const object = matches[0];
51
+ const known = new Set([object.name.toLowerCase(), ...object.aliases.map((a) => a.toLowerCase())]);
52
+ const fresh = draft.entity.aliases.filter((a) => !known.has(a.toLowerCase()));
53
+ if (fresh.length > 0) {
54
+ object.aliases = [...object.aliases, ...fresh];
55
+ store.saveObject(object);
56
+ }
57
+ return object;
58
+ }
59
+ }
60
+ const object = {
61
+ id: newId("obj", now().getTime()),
62
+ kind: draft.entity.kind,
63
+ name: draft.entity.name,
64
+ aliases: draft.entity.aliases,
65
+ properties: {},
66
+ validFrom: now().toISOString(),
67
+ };
68
+ store.saveObject(object);
69
+ return object;
70
+ }
71
+ function findSameFact(existing, statement) {
72
+ const normalized = normalize(statement);
73
+ return existing.find((fact) => !fact.supersededById && changedRatio(normalize(fact.statement), normalized) <= SAME_FACT_RATIO);
74
+ }
75
+ function normalize(text) {
76
+ return text.toLowerCase().replace(/[^\p{L}\p{N} ]/gu, "").replace(/\s+/g, " ").trim();
77
+ }
78
+ function staleAfter(now) {
79
+ return new Date(now().getTime() + FACT_STALE_DAYS * 24 * 3600 * 1000).toISOString();
80
+ }
@@ -1,13 +1,38 @@
1
- /**
2
- * Strict parsing of the inference response. The model is untrusted input;
3
- * everything is validated, indices are checked against the instance count,
4
- * and a malformed response throws with a message naming what was wrong.
5
- */
1
+ const OBJECT_KINDS = ["person", "org", "project", "repo", "process", "custom"];
6
2
  export function parseInferenceResponse(raw, instanceCount) {
7
3
  if (typeof raw !== "object" || raw === null || !Array.isArray(raw.hypotheses)) {
8
4
  throw new Error(`inference response missing "hypotheses" array: ${preview(raw)}`);
9
5
  }
10
- return (raw.hypotheses).map((entry, i) => parseHypothesis(entry, i, instanceCount));
6
+ const record = raw;
7
+ const hypotheses = record.hypotheses.map((entry, i) => parseHypothesis(entry, i, instanceCount));
8
+ // "facts" is optional in the response — older prompts and terse models omit it.
9
+ const facts = Array.isArray(record.facts)
10
+ ? record.facts.flatMap((entry, i) => parseFact(entry, i, instanceCount))
11
+ : [];
12
+ return { hypotheses, facts };
13
+ }
14
+ /** Malformed facts are dropped, not fatal — rules are the primary output. */
15
+ function parseFact(entry, position, instanceCount) {
16
+ if (typeof entry !== "object" || entry === null)
17
+ return [];
18
+ const record = entry;
19
+ const entity = record.entity;
20
+ const name = typeof entity?.name === "string" ? entity.name.trim() : "";
21
+ const statement = typeof record.statement === "string" ? record.statement.trim() : "";
22
+ if (!name || !statement)
23
+ return [];
24
+ let supporting;
25
+ try {
26
+ supporting = requireIndexArray(record, "supporting", position, instanceCount);
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ if (supporting.length === 0)
32
+ return [];
33
+ const kind = OBJECT_KINDS.includes(entity?.kind) ? entity?.kind : "custom";
34
+ const confidence = typeof record.confidence === "number" ? clamp(record.confidence, 0.05, 0.95) : 0.5;
35
+ return [{ entity: { name, kind, aliases: stringArray(entity?.aliases) }, statement, supporting, confidence }];
11
36
  }
12
37
  function parseHypothesis(entry, position, instanceCount) {
13
38
  if (typeof entry !== "object" || entry === null) {
@@ -18,8 +18,17 @@ Extract the transferable judgment rules behind these edits. Requirements:
18
18
  - Cite supporting instances by their number. Cite counterexamples (instances that cut against the rule) by number too.
19
19
  - confidence is 0..1: your honest estimate that this rule reflects a stable preference rather than coincidence.
20
20
 
21
+ Separately, extract FACTS: durable, declarative, entity-grounded statements an agent would need before acting in this domain ("Acme's renewal is in September", "Dana is the CFO and prefers bullet summaries", "the team tracks work in Linear"). Requirements for facts:
22
+
23
+ - Each fact is about ONE named entity (a person, org, project, repo, process, or tool) and must be evident in the instances — never world knowledge, never speculation.
24
+ - Facts are stable context, not judgments and not events ("sent an email Tuesday" is not a fact; "Priya is the GTM lead at Vanta" is).
25
+ - No platitudes. If it would be true of any company, it is not a fact worth storing.
26
+ - Cite supporting instances by number. Give each entity its kind and any aliases visible in the evidence (email addresses, handles, short names).
27
+ - It is normal to extract zero facts.
28
+
21
29
  Respond with STRICT JSON only, no prose, matching:
22
- {"hypotheses": [{"rule": string, "cues": string[], "expectancies": string[], "goal": string?, "appliesWhen": string[], "doesNotApplyWhen": string[], "supporting": number[], "counterexamples": number[], "rationale": string, "confidence": number}]}`;
30
+ {"hypotheses": [{"rule": string, "cues": string[], "expectancies": string[], "goal": string?, "appliesWhen": string[], "doesNotApplyWhen": string[], "supporting": number[], "counterexamples": number[], "rationale": string, "confidence": number}],
31
+ "facts": [{"entity": {"name": string, "kind": "person"|"org"|"project"|"repo"|"process"|"custom", "aliases": string[]}, "statement": string, "supporting": number[], "confidence": number}]}`;
23
32
  export function inferencePrompt(domain, instances) {
24
33
  const blocks = instances.map((instance, i) => renderInstance(instance, i + 1));
25
34
  return `Domain: ${domain}\n\n${blocks.join("\n\n")}`;
@@ -21,9 +21,9 @@ const judge = useLlmJudge ? new LlmJudge(model) : new RubricJudge();
21
21
  const inferred = new Map();
22
22
  const engine = {
23
23
  infer: async (instances) => {
24
- const hypotheses = await inner.infer(instances);
25
- inferred.set(instances[0]?.situation.domain ?? "unknown", hypotheses);
26
- return hypotheses;
24
+ const output = await inner.infer(instances);
25
+ inferred.set(instances[0]?.situation.domain ?? "unknown", output.hypotheses);
26
+ return output.hypotheses;
27
27
  },
28
28
  };
29
29
  console.log(`engine model: ${model.id} judge: ${useLlmJudge ? `llm (${model.id})` : "rubric"}`);
@@ -1,6 +1,9 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { z } from "zod";
3
- import { ingestSensorEvent } from "../capture/ingest.js";
4
+ import { newId } from "../core/ids.js";
5
+ import { ingestAndMatch } from "../serve/auto-outcome.js";
6
+ import { maybeAutoLearn } from "../serve/auto-learn.js";
4
7
  import { compileBrief } from "../serve/brief.js";
5
8
  import { recordOutcome } from "../serve/outcome.js";
6
9
  import { openRef } from "../store/open.js";
@@ -22,7 +25,7 @@ const OUTCOME_RESULTS = ["uncorrected", "corrected", "abandoned", "unknown"];
22
25
  export function buildMcpServer(store) {
23
26
  const server = new McpServer({ name: "athena", version: "0.1.0" });
24
27
  server.registerTool("athena_brief", {
25
- description: "Call this BEFORE acting on a task. Returns the tacit judgment rules that apply (with confidence and boundary conditions), relevant facts with citations, things you must not assume, open questions, and a readiness verdict (act / act_with_caveats / inspect_first / ask_human). Honor the boundaries and do-not-assume list. After finishing the task, report what happened with athena_record type=outcome, citing the briefId.",
28
+ description: "Call this BEFORE acting on a task. Returns the tacit judgment rules that apply (with confidence and boundary conditions), relevant facts with citations, things you must not assume, open questions, a readiness verdict (act / act_with_caveats / inspect_first / ask_human), and a map of everything else athena knows (use athena_search/athena_open to pull what you need — you are not flooded by default). Honor the boundaries and do-not-assume list. After drafting, register your artifact with athena_record type=output so the outcome can be detected automatically; or report it explicitly with type=outcome.",
26
29
  inputSchema: {
27
30
  task: z.string().describe("The task you are about to perform, in one sentence"),
28
31
  domain: z
@@ -44,18 +47,19 @@ export function buildMcpServer(store) {
44
47
  return asJson(entity);
45
48
  });
46
49
  server.registerTool("athena_record", {
47
- description: "Report back to athena. type=outcome: after a task where you used a brief, report whether your output was accepted unchanged (uncorrected) or edited by the human (corrected) — this is how rules earn or lose trust. type=event: capture a judgment moment you observed (a correction of your output, a human decision, a failed approach, an explicit 'remember this'). Events become evidence for new rules after review; nothing you record changes durable rules directly.",
50
+ description: "Report back to athena. type=output: register the artifact you produced from a brief (an email draft, a message) — when a sensor later observes what the human actually sent, athena matches it and records the outcome automatically. type=outcome: explicitly report whether your briefed output was accepted unchanged (uncorrected) or edited (corrected) — this is how rules earn or lose trust. type=event: capture a judgment moment you observed (a correction of your output, a human decision, a failed approach, an explicit 'remember this'). Events become evidence for new rules and facts; nothing you record changes durable rules directly.",
48
51
  inputSchema: {
49
- type: z.enum(["outcome", "event"]),
50
- briefId: z.string().optional().describe("outcome: the brief this outcome judges"),
52
+ type: z.enum(["outcome", "event", "output"]),
53
+ briefId: z.string().optional().describe("outcome/output: the brief this belongs to"),
51
54
  result: z.enum(OUTCOME_RESULTS).optional().describe("outcome: what happened"),
52
55
  correctionInstanceId: z
53
56
  .string()
54
57
  .optional()
55
58
  .describe("outcome: if corrected, the instance id of the captured correction (record the event first)"),
59
+ content: z.string().optional().describe("output: the artifact you produced, verbatim"),
56
60
  kind: z.enum(INSTANCE_KINDS).optional().describe("event: what kind of judgment moment"),
57
61
  summary: z.string().optional().describe("event: one-line situation summary"),
58
- domain: z.string().optional().describe('event: dot-path domain, e.g. "email.outreach"'),
62
+ domain: z.string().optional().describe('event/output: dot-path domain, e.g. "email.outreach"'),
59
63
  task: z.string().optional().describe("event: what was being attempted"),
60
64
  before: z.string().optional().describe("event: the draft/output before the human acted"),
61
65
  after: z.string().optional().describe("event: the human's version (omit for approvals)"),
@@ -74,6 +78,24 @@ export function buildMcpServer(store) {
74
78
  });
75
79
  return asJson(outcome);
76
80
  }
81
+ if (args.type === "output") {
82
+ if (!args.briefId || !args.content)
83
+ throw new Error("output requires briefId and content");
84
+ const brief = store.getBrief(args.briefId);
85
+ if (!brief)
86
+ throw new Error(`unknown brief ${args.briefId}`);
87
+ const draft = {
88
+ id: newId("drf"),
89
+ briefId: brief.id,
90
+ content: args.content,
91
+ contentHash: createHash("sha256").update(args.content).digest("hex").slice(0, 16),
92
+ mediaType: "text/plain",
93
+ domain: args.domain ?? brief.domain ?? "general",
94
+ recordedAt: new Date().toISOString(),
95
+ };
96
+ store.saveDraft(draft);
97
+ return asJson({ registered: draft.id, watchingDomain: draft.domain });
98
+ }
77
99
  if (!args.kind || !args.summary)
78
100
  throw new Error("event requires kind and summary");
79
101
  const event = {
@@ -89,14 +111,20 @@ export function buildMcpServer(store) {
89
111
  ...(args.before !== undefined ? { before: { mediaType: "text/plain", content: args.before } } : {}),
90
112
  ...(args.after !== undefined ? { after: { mediaType: "text/plain", content: args.after } } : {}),
91
113
  };
92
- const instance = ingestSensorEvent(store, event);
93
- return asJson({ recorded: instance.id, kind: instance.kind, domain: instance.situation.domain });
114
+ const { instance, autoOutcome } = ingestAndMatch(store, event);
115
+ maybeAutoLearn(store);
116
+ return asJson({
117
+ recorded: instance.id,
118
+ kind: instance.kind,
119
+ domain: instance.situation.domain,
120
+ ...(autoOutcome ? { autoOutcome: { id: autoOutcome.id, result: autoOutcome.result } } : {}),
121
+ });
94
122
  });
95
123
  server.registerTool("athena_search", {
96
- description: "Lexical search across captured judgment instances, learned rules, and sources. Returns athena:// refs ranked by relevance — open them with athena_open.",
124
+ description: "Lexical search across captured judgment instances, learned rules, extracted facts, and sources. Returns athena:// refs ranked by relevance — open them with athena_open. The brief's map tells you what is worth searching for.",
97
125
  inputSchema: {
98
126
  query: z.string(),
99
- lane: z.enum(["instance", "hypothesis", "source"]).optional().describe("Restrict to one lane"),
127
+ lane: z.enum(["instance", "hypothesis", "source", "fact"]).optional().describe("Restrict to one lane"),
100
128
  limit: z.number().int().min(1).max(50).optional(),
101
129
  },
102
130
  }, ({ query, lane, limit }) => asJson(store.search(query, lane, limit ?? 20)));
@@ -1,6 +1,6 @@
1
1
  import { closeSync, openSync, readSync, statSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
- import { ingestSensorEvent } from "../capture/ingest.js";
3
+ import { ingestAndMatch } from "../serve/auto-outcome.js";
4
4
  const EXPLICIT_MARKER = /^(remember|athena)\s*[:,]\s*(.+)$/is;
5
5
  /** Redirects at the start of a prompt: the user is stopping or reversing the agent. */
6
6
  const REDIRECT_START = /^(no|nope|wrong|wait|stop|hold on|don't|do not|undo|revert|not like that)\b/i;
@@ -8,6 +8,13 @@ const REDIRECT_START = /^(no|nope|wrong|wait|stop|hold on|don't|do not|undo|reve
8
8
  const REDIRECT_START_EXCEPTIONS = /^no (worries|problem|rush|need)\b/i;
9
9
  /** Correction phrases anywhere in the prompt. */
10
10
  const REDIRECT_INLINE = /\b(that's (wrong|not right|not what)|not what i (asked|meant|wanted)|why did you|you should(n't| not) have|i didn't ask (for|you)|never do that|don't do that again|you keep|again with)\b/i;
11
+ /** Approvals at the start of a short prompt: the user is accepting the agent's output. */
12
+ const APPROVAL_START = /^(lgtm|looks good|looks great|looks right|love it|ship it|approved|that works|works for me)\b/i;
13
+ /** Bare one-word sign-offs count too — but only on their own. */
14
+ const APPROVAL_EXACT = /^(perfect|great|nice|done|exactly)[.!\s]*$/i;
15
+ /** "looks good, but…" is a correction wearing an approval's hat. */
16
+ const APPROVAL_REVERSAL = /\b(but|except|however|though|almost|one (thing|issue|nit)|small (thing|nit)|can you|change|fix)\b/i;
17
+ const APPROVAL_MAX_LENGTH = 100;
11
18
  export function detectSignal(prompt) {
12
19
  const trimmed = prompt.trim();
13
20
  if (trimmed.length === 0)
@@ -22,6 +29,11 @@ export function detectSignal(prompt) {
22
29
  if (REDIRECT_INLINE.test(trimmed)) {
23
30
  return { kind: "override", reason: "correction_phrase" };
24
31
  }
32
+ if (trimmed.length <= APPROVAL_MAX_LENGTH &&
33
+ (APPROVAL_START.test(trimmed) || APPROVAL_EXACT.test(trimmed)) &&
34
+ !APPROVAL_REVERSAL.test(trimmed)) {
35
+ return { kind: "approval", reason: "approval_phrase" };
36
+ }
25
37
  return undefined;
26
38
  }
27
39
  export function handleUserPrompt(store, input) {
@@ -45,6 +57,23 @@ export function handleUserPrompt(store, input) {
45
57
  after: { mediaType: "text/plain", content: signal.note },
46
58
  };
47
59
  }
60
+ else if (signal.kind === "approval") {
61
+ // What was approved (the transcript tail) is the evidence — it can also
62
+ // resolve a registered agent draft into an uncorrected outcome.
63
+ const lastTurn = input.transcript_path ? lastAssistantTurn(input.transcript_path) : undefined;
64
+ event = {
65
+ sensorId: "sen_claude_code",
66
+ emittedAt,
67
+ kind: "approval",
68
+ situation: {
69
+ summary: `user approved the agent's output: ${truncate(input.prompt, 100)}`,
70
+ domain,
71
+ app: "claude-code",
72
+ },
73
+ ...(lastTurn !== undefined ? { before: { mediaType: "text/markdown", content: lastTurn } } : {}),
74
+ raw: { reason: signal.reason, sessionId: input.session_id },
75
+ };
76
+ }
48
77
  else {
49
78
  const lastTurn = input.transcript_path ? lastAssistantTurn(input.transcript_path) : undefined;
50
79
  event = {
@@ -61,7 +90,7 @@ export function handleUserPrompt(store, input) {
61
90
  raw: { reason: signal.reason, sessionId: input.session_id },
62
91
  };
63
92
  }
64
- const instance = ingestSensorEvent(store, event);
93
+ const { instance } = ingestAndMatch(store, event);
65
94
  return { captured: instance.id, kind: instance.kind };
66
95
  }
67
96
  const TAIL_BYTES = 64_000;