Skip to content

Commit bf3d8f3

Browse files
Aegisclaude
authored andcommitted
feat(memory): classifyMemoryTopic helper + recordMemoryWithAutoTopic wrapper
Wires the TarotScript memory-topic-classify spread into aegis-oss as an opt-in auto-topic inference path for recordMemory. Callers that want automatic topic classification switch from recordMemory() (explicit topic) to recordMemoryWithAutoTopic() (classifier-inferred topic) and pass the tarotscript service binding alongside the memory binding. Existing call sites are unchanged — this is additive, not a replacement. The migration rollout plan (see artifacts/tarotscript-classifier-migration.md in aegis-daemon) calls for observation-mode first: flip on for one caller, watch divergence between classifier output and operator-provided topics for a week, then expand. New module: web/src/kernel/classify-memory-topic.ts Exports: - CANONICAL_MEMORY_TOPICS — the 15-topic set the classifier can emit. Kept in sync with decks/memory-topics.deck in the tarotscript repo via a test assertion. Deck drift fails loudly. - runMemoryTopicClassification(fetcher, fact) — raw classification call, returns facts object or null on failure. No fallback logic. - classifyMemoryTopic(fetcher, fact) — high-level wrapper with fallback semantics. Falls through to { topic: 'general', source: 'fallback' } when: * fetcher call fails (network, auth, binding unavailable) * classifier returns low confidence * classifier returns an unknown canonical topic (deck drift guard) Fallback rationale: classification is best-effort metadata, never block the memory write. The 'general' card exists in the deck as the stable fallback bucket precisely for this. Callers distinguish classifier vs fallback via result.source. New wrapper in memory-adapter.ts: - recordMemoryWithAutoTopic(mem, tarotscriptFetcher, fact, confidence, source) composes classifyMemoryTopic + recordMemory. Returns both the record result AND the classification metadata so callers can observe divergence during rollout. Tests (13 cases in web/tests/classify-memory-topic.test.ts): - Worker response parsing (facts extraction, receipt hash) - Non-OK response returns null (never throws) - Network error returns null - Correct request shape sent to worker (spreadType, querent, seed, inscribe) - Happy path: moderate confidence → returns classifier topic - Low confidence → falls through to 'general', marked fallback - Unreachable worker → falls through to 'general' - Unknown topic (deck drift) → falls through to 'general', confidence preserved - Seed passed through - CANONICAL_MEMORY_TOPICS drift guard (enumerates all 15 topics) - recordMemoryWithAutoTopic happy path (infers operator, writes with topic) - recordMemoryWithAutoTopic on classifier failure (writes with 'general') - Critical invariant: classifier failure must not block the memory write Full web suite: 66 files, 1473 pass, 1 skipped. Typecheck clean in aegis-oss/web and aegis-daemon/web. Not yet wired into any existing call site — operators flip on auto-topic at the call site by switching to recordMemoryWithAutoTopic as observation begins. First target is memory topic routing from dreaming cycle and inbox-processor (both async, low-latency-tolerance). Related: - Stackbilt-dev/tarotscript@7f6854c — memory-topics deck + spread - Stackbilt-dev/tarotscript@ae0c517 — worker bundle regeneration - artifacts/tarotscript-classifier-migration.md in aegis-daemon Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2cade81 commit bf3d8f3

File tree

3 files changed

+468
-0
lines changed

3 files changed

+468
-0
lines changed
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Memory topic classification via TarotScript memory-topic-classify spread.
2+
//
3+
// The spread lives at Stackbilt-dev/tarotscript:spreads/memory-topic-classify.tarot
4+
// and draws from decks/memory-topics (15 canonical topics for AEGIS semantic
5+
// memory). See artifacts/tarotscript-classifier-migration.md in aegis-daemon
6+
// for the broader design and rollout plan.
7+
//
8+
// Contract:
9+
// classifyMemoryTopic(fetcher, fact) → {
10+
// topic: canonical topic string,
11+
// confidence: 'high' | 'moderate' | 'low',
12+
// source: 'classifier' | 'fallback',
13+
// element?: element of drawn card (debugging aid),
14+
// }
15+
//
16+
// When the classifier returns confidence='low', OR when the classification
17+
// lands on the 'general' fallback card, OR when the fetcher call fails for
18+
// any reason, the helper falls through to { topic: 'general', source: 'fallback' }.
19+
// Callers who want the raw classifier output can use runMemoryTopicClassification
20+
// directly instead.
21+
//
22+
// Telemetry: the helper does not log or persist anything itself. Callers are
23+
// responsible for recording divergence between operator-provided topics and
24+
// classifier-inferred topics during the observation rollout.
25+
26+
// The set of canonical topics the classifier is trained to emit. Kept in sync
27+
// with decks/memory-topics.deck in the tarotscript repo. If the deck adds a
28+
// new card, also update this list so typecheck catches stale consumers.
29+
export type CanonicalMemoryTopic =
30+
| 'operator'
31+
| 'feedback'
32+
| 'business_ops'
33+
| 'compliance'
34+
| 'execution'
35+
| 'content'
36+
| 'research'
37+
| 'reflection'
38+
| 'cross_repo_intelligence'
39+
| 'feed_intel'
40+
| 'aegis'
41+
| 'tarotscript'
42+
| 'infrastructure'
43+
| 'portfolio_project'
44+
| 'general';
45+
46+
export const CANONICAL_MEMORY_TOPICS: readonly CanonicalMemoryTopic[] = [
47+
'operator', 'feedback', 'business_ops', 'compliance', 'execution',
48+
'content', 'research', 'reflection', 'cross_repo_intelligence',
49+
'feed_intel', 'aegis', 'tarotscript', 'infrastructure',
50+
'portfolio_project', 'general',
51+
] as const;
52+
53+
export type ClassificationConfidence = 'high' | 'moderate' | 'low';
54+
55+
export interface MemoryTopicClassification {
56+
topic: CanonicalMemoryTopic;
57+
confidence: ClassificationConfidence;
58+
source: 'classifier' | 'fallback';
59+
element?: string;
60+
receipt_hash?: string;
61+
}
62+
63+
/**
64+
* Raw classification call. Invokes the memory-topic-classify spread on the
65+
* tarotscript worker and returns the parsed facts object, or null if the
66+
* call fails for any reason (network, auth, worker down, spread error).
67+
* The helper wrapper `classifyMemoryTopic` applies fallback semantics on
68+
* top of this; most callers should use the wrapper instead.
69+
*/
70+
export async function runMemoryTopicClassification(
71+
fetcher: Fetcher,
72+
fact: string,
73+
opts: { seed?: number } = {},
74+
): Promise<{
75+
classification?: string;
76+
classification_confidence?: string;
77+
classification_element?: string;
78+
receipt_hash?: string;
79+
} | null> {
80+
try {
81+
const response = await fetcher.fetch('https://internal/run', {
82+
method: 'POST',
83+
headers: { 'Content-Type': 'application/json' },
84+
body: JSON.stringify({
85+
spreadType: 'memory-topic-classify',
86+
querent: {
87+
id: 'aegis',
88+
intention: fact,
89+
},
90+
seed: opts.seed,
91+
inscribe: false, // classification is metadata, not a consultation
92+
}),
93+
});
94+
95+
if (!response.ok) {
96+
console.warn(`[classify-memory-topic] worker returned ${response.status}`);
97+
return null;
98+
}
99+
100+
const body = await response.json() as {
101+
facts?: Record<string, string>;
102+
receipt?: { hash?: string };
103+
};
104+
105+
return {
106+
classification: body.facts?.classification,
107+
classification_confidence: body.facts?.classification_confidence,
108+
classification_element: body.facts?.classification_element,
109+
receipt_hash: body.receipt?.hash,
110+
};
111+
} catch (err) {
112+
console.warn(
113+
`[classify-memory-topic] fetch failed: ${err instanceof Error ? err.message : String(err)}`,
114+
);
115+
return null;
116+
}
117+
}
118+
119+
function isCanonicalTopic(value: string | undefined): value is CanonicalMemoryTopic {
120+
return typeof value === 'string'
121+
&& (CANONICAL_MEMORY_TOPICS as readonly string[]).includes(value);
122+
}
123+
124+
/**
125+
* High-level classifier with fallback. Calls the memory-topic-classify spread,
126+
* validates the output, and returns 'general' as a stable fallback when:
127+
* - the fetcher call fails (network, auth, worker down)
128+
* - the spread returns an unknown topic (deck drift — log + fall through)
129+
* - the classification confidence is 'low' (deck scoring was ambiguous)
130+
*
131+
* The fallback is 'general' on purpose — that card exists in the deck for
132+
* exactly this reason and callers can filter on `source === 'fallback'` to
133+
* distinguish model output from fall-through behavior.
134+
*/
135+
export async function classifyMemoryTopic(
136+
fetcher: Fetcher,
137+
fact: string,
138+
opts: { seed?: number } = {},
139+
): Promise<MemoryTopicClassification> {
140+
const raw = await runMemoryTopicClassification(fetcher, fact, opts);
141+
142+
if (!raw) {
143+
return { topic: 'general', confidence: 'low', source: 'fallback' };
144+
}
145+
146+
const confidence = (raw.classification_confidence ?? 'low') as ClassificationConfidence;
147+
148+
if (confidence === 'low') {
149+
return { topic: 'general', confidence: 'low', source: 'fallback' };
150+
}
151+
152+
if (!isCanonicalTopic(raw.classification)) {
153+
console.warn(
154+
`[classify-memory-topic] unknown canonical topic from classifier: "${raw.classification}" — falling through to 'general'`,
155+
);
156+
return { topic: 'general', confidence, source: 'fallback' };
157+
}
158+
159+
return {
160+
topic: raw.classification,
161+
confidence,
162+
source: 'classifier',
163+
element: raw.classification_element,
164+
receipt_hash: raw.receipt_hash,
165+
};
166+
}

web/src/kernel/memory-adapter.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import type { MemoryServiceBinding, MemoryFragmentResult, MemoryStatsResult, MemoryStoreRequest } from '../types.js';
99
import { estimateTokens } from './memory/episodic.js';
10+
import { classifyMemoryTopic, type MemoryTopicClassification } from './classify-memory-topic.js';
1011

1112
const TENANT = 'aegis';
1213
const MEMORY_CONTEXT_LIMIT = 50;
@@ -115,6 +116,39 @@ export async function recordMemory(
115116
}
116117
}
117118

119+
// ─── recordMemoryWithAutoTopic ──────────────────────────────
120+
// Opt-in convenience wrapper: classify the fact via TarotScript's
121+
// memory-topic-classify spread, then write with the inferred topic.
122+
//
123+
// Callers pass the tarotscript service binding alongside the memory binding.
124+
// If the classifier returns 'general' via the fallback path (classifier
125+
// down, low confidence, unknown topic), the wrapper still writes the fact
126+
// but with the 'general' topic — the caller can filter on the returned
127+
// `classification.source === 'fallback'` to decide whether to re-tag later.
128+
//
129+
// Rollout note: this is additive, not a replacement for recordMemory. Existing
130+
// call sites that pass an explicit topic continue to work unchanged. Callers
131+
// wanting auto-topic opt in explicitly by switching to this wrapper. The
132+
// feature flag lives at the call site (not in this helper) so each caller
133+
// can independently decide when to flip on auto-topic inference.
134+
135+
export interface RecordMemoryWithTopicResult extends RecordMemoryResult {
136+
classification: MemoryTopicClassification;
137+
}
138+
139+
export async function recordMemoryWithAutoTopic(
140+
mem: MB,
141+
tarotscriptFetcher: Fetcher,
142+
fact: string,
143+
confidence: number,
144+
source: string,
145+
opts: { seed?: number } = {},
146+
): Promise<RecordMemoryWithTopicResult> {
147+
const classification = await classifyMemoryTopic(tarotscriptFetcher, fact, opts);
148+
const record = await recordMemory(mem, classification.topic, fact, confidence, source);
149+
return { ...record, classification };
150+
}
151+
118152
// ─── getMemoryEntries ───────────────────────────────────────
119153
export async function getMemoryEntries(
120154
mem: MB, topic?: string, limit = 50,

0 commit comments

Comments
 (0)