useathena 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -17
- package/dist/api/server.js +30 -4
- package/dist/cli/commands.js +81 -5
- package/dist/cli/export.js +35 -0
- package/dist/cli/service.js +106 -0
- package/dist/cli/setup.js +30 -2
- package/dist/cli/update.js +81 -0
- package/dist/cli.js +72 -25
- package/dist/core/fixtures.js +26 -0
- package/dist/core/refs.js +2 -0
- package/dist/engine/engine.js +15 -6
- package/dist/engine/facts.js +80 -0
- package/dist/engine/parse.js +31 -6
- package/dist/engine/prompts.js +10 -1
- package/dist/eval/run-eval.js +3 -3
- package/dist/mcp/server.js +38 -10
- package/dist/sensors/claude-code-hook.js +31 -2
- package/dist/serve/auto-learn.js +56 -0
- package/dist/serve/auto-outcome.js +63 -0
- package/dist/serve/brief.js +64 -11
- package/dist/store/open.js +4 -0
- package/dist/store/store.js +109 -0
- package/docs/schema.md +65 -6
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/serve/brief.js
CHANGED
|
@@ -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 =
|
|
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) {
|
package/dist/store/open.js
CHANGED
package/dist/store/store.js
CHANGED
|
@@ -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
|
|
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)` |
|
|
342
|
-
| `athena_search(query, lane?)` | direct
|
|
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,
|
|
353
|
-
|
|
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
|
|