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.
@@ -0,0 +1,56 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdirSync, openSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dbPath } from "../config.js";
6
+ /**
7
+ * Keeps the loop from stalling on a forgotten `athena learn`: every sensor
8
+ * entry point calls maybeAutoLearn after ingesting, and once enough new
9
+ * evidence has accumulated a detached `athena learn` runs in the background.
10
+ *
11
+ * Guards, in order: a kill switch (ATHENA_AUTO_LEARN=off), an evidence
12
+ * threshold (don't spend inference on every capture), and a cooldown (don't
13
+ * stack runs while one is still thinking). Never throws — a broken trigger
14
+ * must never break capture.
15
+ */
16
+ /** Stamped by cmdLearn on every completed run (manual or automatic). */
17
+ export const LAST_LEARN_AT = "learn.lastRunAt";
18
+ const LAST_ATTEMPT_AT = "autoLearn.lastAttemptAt";
19
+ export const AUTO_LEARN_THRESHOLD = 5;
20
+ const COOLDOWN_MS = 30 * 60 * 1000;
21
+ export function autoLearnEnabled(env = process.env) {
22
+ const value = (env.ATHENA_AUTO_LEARN ?? "").toLowerCase();
23
+ return !["0", "false", "off", "no"].includes(value);
24
+ }
25
+ export function maybeAutoLearn(store, options = {}) {
26
+ const now = options.now ?? (() => new Date());
27
+ try {
28
+ const pending = store.countInstancesSince(store.getMeta(LAST_LEARN_AT));
29
+ if (!autoLearnEnabled(options.env)) {
30
+ return { launched: false, reason: "disabled via ATHENA_AUTO_LEARN", pending };
31
+ }
32
+ if (pending < AUTO_LEARN_THRESHOLD) {
33
+ return { launched: false, reason: `below threshold (${pending}/${AUTO_LEARN_THRESHOLD})`, pending };
34
+ }
35
+ const lastAttempt = store.getMeta(LAST_ATTEMPT_AT);
36
+ if (lastAttempt && now().getTime() - new Date(lastAttempt).getTime() < COOLDOWN_MS) {
37
+ return { launched: false, reason: "cooling down", pending };
38
+ }
39
+ store.setMeta(LAST_ATTEMPT_AT, now().toISOString());
40
+ (options.launch ?? launchDetachedLearn)();
41
+ return { launched: true, reason: "threshold reached", pending };
42
+ }
43
+ catch {
44
+ return { launched: false, reason: "trigger error (ignored)", pending: 0 };
45
+ }
46
+ }
47
+ /** Spawn `athena learn` detached so the calling sensor returns immediately. */
48
+ function launchDetachedLearn() {
49
+ const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
50
+ const bin = join(packageRoot, "bin", "athena");
51
+ const logDir = dirname(dbPath());
52
+ mkdirSync(logDir, { recursive: true });
53
+ const log = openSync(join(logDir, "auto-learn.log"), "a");
54
+ const child = spawn(process.execPath, [bin, "learn"], { detached: true, stdio: ["ignore", log, log] });
55
+ child.unref();
56
+ }
@@ -0,0 +1,63 @@
1
+ import { changedRatio, ingestSensorEvent } from "../capture/ingest.js";
2
+ import { recordOutcome } from "./outcome.js";
3
+ /**
4
+ * Closes the loop without anyone reporting: an agent registers the draft it
5
+ * produced from a brief (athena_record type=output); when a sensor later
6
+ * captures what the human actually did with it, the instance is matched back
7
+ * to the draft and the outcome records itself — approval → uncorrected,
8
+ * correction → corrected with the instance as the counterexample.
9
+ *
10
+ * Matching is conservative: same domain, recent, and the captured "before"
11
+ * must be essentially the registered draft. A miss costs nothing (outcomes
12
+ * can still be recorded explicitly); a false match would corrupt trust.
13
+ */
14
+ const MATCH_WINDOW_HOURS = 72;
15
+ /** Captured before-text may differ from the draft by at most this much. */
16
+ const MATCH_RATIO = 0.15;
17
+ /**
18
+ * What every sensor should call: ingest the event, then see whether the new
19
+ * evidence resolves a registered draft into an outcome.
20
+ */
21
+ export function ingestAndMatch(store, event, options = {}, now = () => new Date()) {
22
+ expireStaleDrafts(store, now);
23
+ const instance = ingestSensorEvent(store, event, options);
24
+ const autoOutcome = matchDraftToInstance(store, instance, now);
25
+ return autoOutcome ? { instance, autoOutcome } : { instance };
26
+ }
27
+ /**
28
+ * Drafts that outlive the match window resolve to result "unknown": the loop
29
+ * closes honestly instead of leaving them "awaiting" forever, and rule trust
30
+ * is untouched (only uncorrected/corrected adjust it). Swept opportunistically
31
+ * on every ingest.
32
+ */
33
+ export function expireStaleDrafts(store, now = () => new Date()) {
34
+ const cutoff = new Date(now().getTime() - MATCH_WINDOW_HOURS * 3600 * 1000).toISOString();
35
+ return store
36
+ .listUnmatchedDrafts({})
37
+ .filter((draft) => draft.recordedAt < cutoff)
38
+ .map((draft) => {
39
+ const outcome = recordOutcome(store, { briefId: draft.briefId, result: "unknown" }, now);
40
+ draft.matchedOutcomeId = outcome.id;
41
+ store.saveDraft(draft);
42
+ return outcome;
43
+ });
44
+ }
45
+ export function matchDraftToInstance(store, instance, now = () => new Date()) {
46
+ if (instance.kind !== "correction" && instance.kind !== "override" && instance.kind !== "approval") {
47
+ return undefined;
48
+ }
49
+ const captured = instance.before?.content;
50
+ if (!captured)
51
+ return undefined;
52
+ const since = new Date(now().getTime() - MATCH_WINDOW_HOURS * 3600 * 1000).toISOString();
53
+ const candidates = store.listUnmatchedDrafts({ domain: instance.situation.domain, since });
54
+ const draft = candidates.find((candidate) => changedRatio(candidate.content, captured) <= MATCH_RATIO);
55
+ if (!draft)
56
+ return undefined;
57
+ const outcome = recordOutcome(store, instance.kind === "approval"
58
+ ? { briefId: draft.briefId, result: "uncorrected" }
59
+ : { briefId: draft.briefId, result: "corrected", correctionInstanceId: instance.id }, now);
60
+ draft.matchedOutcomeId = outcome.id;
61
+ store.saveDraft(draft);
62
+ return outcome;
63
+ }
@@ -12,8 +12,9 @@ import { parseRef, refTo } from "../core/refs.js";
12
12
  * ask_human athena knows nothing useful for this task
13
13
  */
14
14
  const MAX_RULES = 5;
15
- const MAX_FACTS = 3;
15
+ const MAX_FACTS = 5;
16
16
  const MAX_OPEN_QUESTIONS = 3;
17
+ const MAX_MAP_ENTRIES = 8;
17
18
  const HIGH_CONFIDENCE = 0.7;
18
19
  export function compileBrief(store, request, now = () => new Date()) {
19
20
  const inScope = collectHypotheses(store, request);
@@ -32,16 +33,7 @@ export function compileBrief(store, request, now = () => new Date()) {
32
33
  boundaries: h.doesNotApplyWhen,
33
34
  ref: refTo(h.id),
34
35
  }));
35
- const facts = store
36
- .search(request.task, "source", MAX_FACTS)
37
- .flatMap((hit) => {
38
- const source = store.getSource(parseRef(hit.ref).id);
39
- return source ? [source] : [];
40
- })
41
- .map((source) => ({
42
- statement: `${source.title}: ${source.content.slice(0, 160)}`,
43
- ref: refTo(source.id),
44
- }));
36
+ const facts = collectFacts(store, request, now);
45
37
  const doNotAssume = stale.map((h) => `A previously learned rule is stale — do not assume it still holds: "${h.rule}"`);
46
38
  const openQuestions = unvalidated
47
39
  .slice(0, MAX_OPEN_QUESTIONS)
@@ -49,6 +41,7 @@ export function compileBrief(store, request, now = () => new Date()) {
49
41
  const brief = {
50
42
  id: newId("brf", now().getTime()),
51
43
  task: request.task,
44
+ ...(request.domain !== undefined ? { domain: request.domain } : {}),
52
45
  compiledAt: now().toISOString(),
53
46
  rules,
54
47
  facts,
@@ -56,6 +49,7 @@ export function compileBrief(store, request, now = () => new Date()) {
56
49
  openQuestions,
57
50
  readiness: readinessFor(rules, facts, doNotAssume, openQuestions),
58
51
  refs: [...rules.map((r) => r.ref), ...facts.map((f) => f.ref), ...unvalidated.slice(0, MAX_OPEN_QUESTIONS).map((h) => refTo(h.id))],
52
+ map: knowledgeMap(store),
59
53
  };
60
54
  // Serving is an event: fires feed the outcome loop.
61
55
  for (const rule of rules) {
@@ -69,6 +63,65 @@ export function compileBrief(store, request, now = () => new Date()) {
69
63
  store.saveBrief(brief);
70
64
  return brief;
71
65
  }
66
+ /**
67
+ * Facts reach a brief three ways: the task names a known entity (alias match),
68
+ * the task's domain produced facts, or lexical search hits. Stale and
69
+ * superseded facts never serve.
70
+ */
71
+ function collectFacts(store, request, now) {
72
+ const byId = new Map();
73
+ const task = ` ${request.task.toLowerCase()} `;
74
+ for (const object of store.listObjects()) {
75
+ const aliases = [object.name, ...object.aliases].filter((alias) => alias.length >= 3);
76
+ if (!aliases.some((alias) => task.includes(alias.toLowerCase())))
77
+ continue;
78
+ for (const fact of store.listFacts({ objectId: object.id }))
79
+ byId.set(fact.id, fact);
80
+ }
81
+ if (request.domain) {
82
+ for (const fact of store.listFacts({ domain: request.domain }))
83
+ byId.set(fact.id, fact);
84
+ }
85
+ for (const hit of store.search(request.task, "fact", MAX_FACTS)) {
86
+ const fact = store.getFact(parseRef(hit.ref).id);
87
+ if (fact)
88
+ byId.set(fact.id, fact);
89
+ }
90
+ const nowIso = now().toISOString();
91
+ const live = [...byId.values()]
92
+ .filter((fact) => !fact.supersededById && fact.staleAfter > nowIso)
93
+ .sort((a, b) => b.lastConfirmedAt.localeCompare(a.lastConfirmedAt))
94
+ .slice(0, MAX_FACTS)
95
+ .map((fact) => ({ statement: fact.statement, ref: refTo(fact.id) }));
96
+ if (live.length >= MAX_FACTS)
97
+ return live;
98
+ // Raw sources fill remaining slots — weaker than extracted facts, still cited.
99
+ const sources = store
100
+ .search(request.task, "source", MAX_FACTS - live.length)
101
+ .flatMap((hit) => {
102
+ const source = store.getSource(parseRef(hit.ref).id);
103
+ return source ? [source] : [];
104
+ })
105
+ .map((source) => ({
106
+ statement: `${source.title}: ${source.content.slice(0, 160)}`,
107
+ ref: refTo(source.id),
108
+ }));
109
+ return [...live, ...sources];
110
+ }
111
+ /** The coordinates: what athena knows about and how to retrieve each slice. */
112
+ function knowledgeMap(store) {
113
+ const entries = [];
114
+ for (const { domain, count } of store.hypothesisCountsByDomain()) {
115
+ entries.push({ label: `${count} rule${count === 1 ? "" : "s"} in ${domain}`, count, query: domain });
116
+ }
117
+ for (const { objectId, count } of store.factCountsByObject()) {
118
+ const object = store.getObject(objectId);
119
+ if (!object)
120
+ continue;
121
+ entries.push({ label: `${count} fact${count === 1 ? "" : "s"} about ${object.name}`, count, query: object.name });
122
+ }
123
+ return entries.sort((a, b) => b.count - a.count).slice(0, MAX_MAP_ENTRIES);
124
+ }
72
125
  function collectHypotheses(store, request) {
73
126
  const byId = new Map();
74
127
  if (request.domain) {
@@ -15,5 +15,9 @@ export function openRef(store, ref) {
15
15
  return store.getBrief(parsed.id);
16
16
  case "outcome":
17
17
  return store.getOutcome(parsed.id);
18
+ case "fact":
19
+ return store.getFact(parsed.id);
20
+ case "draft":
21
+ return store.getDraft(parsed.id);
18
22
  }
19
23
  }
@@ -55,6 +55,19 @@ export class AthenaStore {
55
55
  CREATE TABLE IF NOT EXISTS briefs (
56
56
  id TEXT PRIMARY KEY, compiled_at TEXT NOT NULL, data TEXT NOT NULL
57
57
  );
58
+ CREATE TABLE IF NOT EXISTS facts (
59
+ id TEXT PRIMARY KEY, object_id TEXT NOT NULL, domain TEXT NOT NULL,
60
+ last_confirmed_at TEXT NOT NULL, data TEXT NOT NULL
61
+ );
62
+ CREATE INDEX IF NOT EXISTS idx_facts_object ON facts(object_id);
63
+ CREATE TABLE IF NOT EXISTS drafts (
64
+ id TEXT PRIMARY KEY, brief_id TEXT NOT NULL, domain TEXT NOT NULL,
65
+ recorded_at TEXT NOT NULL, matched_outcome_id TEXT, data TEXT NOT NULL
66
+ );
67
+ CREATE INDEX IF NOT EXISTS idx_drafts_unmatched ON drafts(domain, recorded_at) WHERE matched_outcome_id IS NULL;
68
+ CREATE TABLE IF NOT EXISTS meta (
69
+ key TEXT PRIMARY KEY, value TEXT NOT NULL
70
+ );
58
71
  CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(ref, lane, text);
59
72
  `);
60
73
  }
@@ -97,6 +110,13 @@ export class AthenaStore {
97
110
  ORDER BY observed_at DESC LIMIT ${filter.limit ?? 200}`;
98
111
  return this.db.prepare(sql).all(...params).map((rowData));
99
112
  }
113
+ /** Instances observed strictly after `since` (all instances when omitted). */
114
+ countInstancesSince(since) {
115
+ const row = since
116
+ ? this.db.prepare("SELECT COUNT(*) AS n FROM instances WHERE observed_at > ?").get(since)
117
+ : this.db.prepare("SELECT COUNT(*) AS n FROM instances").get();
118
+ return row.n;
119
+ }
100
120
  // --- hypotheses (revisable views over evidence) ---
101
121
  saveHypothesis(hypothesis) {
102
122
  if (hypothesis.supportingInstanceIds.length === 0) {
@@ -170,6 +190,77 @@ export class AthenaStore {
170
190
  .all(objectId, objectId)
171
191
  .map((rowData));
172
192
  }
193
+ // --- facts (revisable views over evidence, like hypotheses) ---
194
+ saveFact(fact) {
195
+ if (fact.supportingInstanceIds.length === 0) {
196
+ throw new Error(`fact ${fact.id} has no supporting instances — everything cites`);
197
+ }
198
+ const object = this.getObject(fact.objectId);
199
+ if (!object) {
200
+ throw new Error(`fact ${fact.id} references unknown object ${fact.objectId}`);
201
+ }
202
+ this.upsert("facts", "INSERT INTO facts (id, object_id, domain, last_confirmed_at, data) VALUES (?, ?, ?, ?, ?) " +
203
+ "ON CONFLICT(id) DO UPDATE SET object_id = excluded.object_id, domain = excluded.domain, " +
204
+ "last_confirmed_at = excluded.last_confirmed_at, data = excluded.data", [fact.id, fact.objectId, fact.domain, fact.lastConfirmedAt, JSON.stringify(fact)]);
205
+ this.index(`athena://fact/${fact.id}`, "fact", [fact.statement, fact.domain, object.name, object.aliases.join(" ")].join("\n"));
206
+ }
207
+ getFact(id) {
208
+ return this.getData("facts", id);
209
+ }
210
+ listFacts(filter = {}) {
211
+ const where = [];
212
+ const params = [];
213
+ if (filter.objectId) {
214
+ where.push("object_id = ?");
215
+ params.push(filter.objectId);
216
+ }
217
+ if (filter.domain) {
218
+ where.push("domain = ?");
219
+ params.push(filter.domain);
220
+ }
221
+ const sql = `SELECT data FROM facts ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
222
+ ORDER BY last_confirmed_at DESC LIMIT ${filter.limit ?? 200}`;
223
+ return this.db.prepare(sql).all(...params).map((rowData));
224
+ }
225
+ /** Per-entity fact counts for the brief's knowledge map. */
226
+ factCountsByObject() {
227
+ return this.db
228
+ .prepare("SELECT object_id AS objectId, COUNT(*) AS count FROM facts GROUP BY object_id ORDER BY count DESC")
229
+ .all();
230
+ }
231
+ /** Per-domain servable-rule counts for the brief's knowledge map. */
232
+ hypothesisCountsByDomain() {
233
+ return this.db
234
+ .prepare("SELECT domain, COUNT(*) AS count FROM hypotheses WHERE status IN ('validated','active') GROUP BY domain ORDER BY count DESC")
235
+ .all();
236
+ }
237
+ // --- agent drafts (outputs awaiting an observed outcome) ---
238
+ saveDraft(draft) {
239
+ this.upsert("drafts", "INSERT INTO drafts (id, brief_id, domain, recorded_at, matched_outcome_id, data) VALUES (?, ?, ?, ?, ?, ?) " +
240
+ "ON CONFLICT(id) DO UPDATE SET matched_outcome_id = excluded.matched_outcome_id, data = excluded.data", [draft.id, draft.briefId, draft.domain, draft.recordedAt, draft.matchedOutcomeId ?? null, JSON.stringify(draft)]);
241
+ }
242
+ getDraft(id) {
243
+ return this.getData("drafts", id);
244
+ }
245
+ listUnmatchedDrafts(filter = {}) {
246
+ const where = ["matched_outcome_id IS NULL"];
247
+ const params = [];
248
+ if (filter.domain) {
249
+ where.push("domain = ?");
250
+ params.push(filter.domain);
251
+ }
252
+ if (filter.since) {
253
+ where.push("recorded_at >= ?");
254
+ params.push(filter.since);
255
+ }
256
+ return this.db
257
+ .prepare(`SELECT data FROM drafts WHERE ${where.join(" AND ")} ORDER BY recorded_at DESC LIMIT 100`)
258
+ .all(...params)
259
+ .map((rowData));
260
+ }
261
+ listDrafts() {
262
+ return this.db.prepare("SELECT data FROM drafts ORDER BY recorded_at DESC LIMIT 500").all().map((rowData));
263
+ }
173
264
  // --- serving layer ---
174
265
  saveBrief(brief) {
175
266
  this.upsert("briefs", "INSERT INTO briefs (id, compiled_at, data) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data", [brief.id, brief.compiledAt, JSON.stringify(brief)]);
@@ -190,6 +281,22 @@ export class AthenaStore {
190
281
  : this.db.prepare("SELECT data FROM outcomes ORDER BY recorded_at DESC").all();
191
282
  return rows.map((rowData));
192
283
  }
284
+ listBriefs(limit = 500) {
285
+ return this.db
286
+ .prepare(`SELECT data FROM briefs ORDER BY compiled_at DESC LIMIT ${limit}`)
287
+ .all()
288
+ .map((rowData));
289
+ }
290
+ // --- meta (operational state: last learn run, cooldowns) ---
291
+ getMeta(key) {
292
+ const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
293
+ return row ? row.value : undefined;
294
+ }
295
+ setMeta(key, value) {
296
+ this.db
297
+ .prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
298
+ .run(key, value);
299
+ }
193
300
  // --- stats ---
194
301
  counts() {
195
302
  const byColumn = (table, column) => {
@@ -205,6 +312,8 @@ export class AthenaStore {
205
312
  sources: total("sources"),
206
313
  outcomes: total("outcomes"),
207
314
  briefs: total("briefs"),
315
+ facts: total("facts"),
316
+ unmatchedDrafts: this.db.prepare("SELECT COUNT(*) AS n FROM drafts WHERE matched_outcome_id IS NULL").get().n,
208
317
  };
209
318
  }
210
319
  // --- search (lexical lane) ---
package/docs/schema.md CHANGED
@@ -29,7 +29,8 @@ Mission framing: athena captures **tacit knowledge** from real work so agents be
29
29
  ## IDs
30
30
 
31
31
  Prefixed ULIDs: `ins_` instance, `hyp_` hypothesis, `obj_` object, `src_` source,
32
- `out_` outcome, `brf_` brief, `act_` actor, `sen_` sensor. Sortable by creation time.
32
+ `out_` outcome, `brf_` brief, `act_` actor, `sen_` sensor, `fct_` fact, `drf_` agent
33
+ draft. Sortable by creation time.
33
34
 
34
35
  ---
35
36
 
@@ -268,6 +269,30 @@ type ObjectRelation = {
268
269
  };
269
270
  ```
270
271
 
272
+ ### Fact — the explicit counterpart of a hypothesis
273
+
274
+ A declarative, entity-grounded statement extracted from evidence ("Acme's renewal
275
+ is in September" vs the tacit "be formal with Acme execs"). Facts are never entered
276
+ manually and agents cannot write them: the engine extracts them during learn, and
277
+ `materializeFacts` is the only write path — it resolves or creates the entity and
278
+ dedupes near-identical statements into a confirmation instead of a duplicate.
279
+
280
+ ```ts
281
+ type Fact = {
282
+ id: FactId; // fct_…
283
+ objectId: ObjectId; // every fact is about exactly one entity
284
+ statement: string;
285
+ domain: string; // domain of the evidence cluster it came from
286
+ supportingInstanceIds: InstanceId[]; // facts cite, like everything else
287
+ confidence: number;
288
+ firstSeenAt: string;
289
+ lastConfirmedAt: string; // bumped when new evidence restates the fact
290
+ staleAfter: string; // unconfirmed past this → no longer served
291
+ supersededById?: FactId;
292
+ visibility: Exclude<Visibility, "user_private_raw">;
293
+ };
294
+ ```
295
+
271
296
  Markdown pages (per object, per domain) are **projections** generated from sources +
272
297
  hypotheses + relations. Stored in `.athena/pages/` for humans and git; rebuilt, never edited
273
298
  as truth.
@@ -284,6 +309,7 @@ Compact payload + dereferenceable pointers. Persisted for audit and outcome link
284
309
  type Brief = {
285
310
  id: BriefId;
286
311
  task: string; // as the agent stated it
312
+ domain?: string; // when given — drafts registered against this brief match captures in it
287
313
  compiledAt: string;
288
314
 
289
315
  rules: {
@@ -305,6 +331,12 @@ type Brief = {
305
331
 
306
332
  readiness: "act" | "act_with_caveats" | "inspect_first" | "ask_human";
307
333
  refs: Ref[]; // additional evidence worth opening, ranked
334
+
335
+ map?: { // coordinates into everything else athena knows:
336
+ label: string; // "12 rules in email.outreach", "4 facts about Acme"
337
+ count: number;
338
+ query: string; // pass to athena_search — agents pull, never flooded
339
+ }[];
308
340
  };
309
341
 
310
342
  type Ref = string; // athena://hypothesis/hyp_x | athena://instance/ins_x
@@ -332,14 +364,39 @@ type AgentOutcome = {
332
364
  This closes the loop: **brief → action → outcome → instance → hypothesis update**, and
333
365
  gives us the north-star metric directly: correction rate per served brief, over time.
334
366
 
367
+ ### AgentDraft — outcomes that record themselves
368
+
369
+ The artifact an agent produced from a brief, registered (`athena_record type=output`)
370
+ so athena can detect the outcome without anyone reporting: when a sensor later
371
+ captures what the human actually did with it, the instance matches back —
372
+ approval → `uncorrected`, correction → `corrected` with the instance as the
373
+ counterexample.
374
+
375
+ ```ts
376
+ type AgentDraft = {
377
+ id: DraftId; // drf_…
378
+ briefId: BriefId;
379
+ content: string; // the artifact, verbatim
380
+ contentHash: string;
381
+ mediaType: string;
382
+ domain: string;
383
+ recordedAt: string;
384
+ matchedOutcomeId?: OutcomeId; // set once resolved (matched or expired)
385
+ };
386
+ ```
387
+
388
+ Matching is conservative — same domain, within 72h, captured before-text within
389
+ 15% of the draft — because a false match would corrupt trust; a miss costs nothing.
390
+ Drafts that outlive the window expire to `result: "unknown"` (rule trust untouched).
391
+
335
392
  ### MCP surface (4 tools, not 19)
336
393
 
337
394
  | Tool | Does |
338
395
  |---|---|
339
396
  | `athena_brief(task, domain?)` | compile + persist a Brief |
340
397
  | `athena_open(ref)` | dereference any `athena://` ref |
341
- | `athena_record(event)` | SensorEvent in: outcome, correction, manual note, rule *proposal* |
342
- | `athena_search(query, lane?)` | direct lexical+semantic search when the agent wants to explore |
398
+ | `athena_record(event)` | outcome report, draft registration (`type=output`), or SensorEvent: correction, manual note, rule *proposal* |
399
+ | `athena_search(query, lane?)` | direct search (lanes: instance, hypothesis, source, fact) when the agent wants to explore |
343
400
 
344
401
  No durable-mutation tools. `athena_record` proposals enter the review queue like any sensor event.
345
402
 
@@ -348,9 +405,11 @@ No durable-mutation tools. `athena_record` proposals enter the review queue like
348
405
  ## 5. Storage mapping
349
406
 
350
407
  SQLite, one file per workspace (`.athena/athena.db`):
351
- - `instances`, `hypotheses`, `sources`, `objects`, `relations`, `outcomes`, `briefs`
352
- — JSON column + extracted indexed columns (id, kind, domain, status, observedAt, visibility).
353
- - FTS5 over instance summaries/diffs, hypothesis rules, source content.
408
+ - `instances`, `hypotheses`, `sources`, `objects`, `relations`, `outcomes`, `briefs`,
409
+ `facts`, `drafts` — JSON column + extracted indexed columns (id, kind, domain, status,
410
+ observedAt, visibility).
411
+ - `meta` — key/value operational state (last learn run, auto-learn cooldown).
412
+ - FTS5 over instance summaries/diffs, hypothesis rules, source content, fact statements.
354
413
  - `sqlite-vec` over situation/rule/source embeddings (optional lane — everything works without it).
355
414
  - WAL mode; single-writer per file is acceptable for local; server mode serializes per workspace.
356
415
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "useathena",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "athena captures tacit knowledge from real work so agents become truly autonomous and reliable.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {