mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
feat(tool-registry): augur module — 5 server-side tools
Mirrors apps/mana/apps/web/src/lib/modules/augur/tools.ts for the shared mana-tool-registry. Lets persona-runner / mana-mcp / mana-ai invoke augur over stdio and HTTP without going through the web app. Tools: - augur.captureSign (write) — log a new omen / fortune / hunch - augur.resolveSign (write) — fulfilled / partly / not-fulfilled - augur.listOpenSigns (read) — what's still waiting on resolution - augur.consultOracle (read) — Living Oracle reflection from history - augur.yearRecap (read) — structured year-in-review snapshot The pure-math engines (fingerprint, matchScore, makeReflection, yearRecap aggregation) are mirrored from the web-app lib/. Both sides have unit tests covering the same contract — keep them in sync. A future shared package would dedupe. Encrypted fields declared on each spec (audit:encrypted-tools went from 15 to 20). ModuleId extended in types.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4d77934bd5
commit
a1f2dccb68
3 changed files with 738 additions and 1 deletions
732
packages/mana-tool-registry/src/modules/augur.ts
Normal file
732
packages/mana-tool-registry/src/modules/augur.ts
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
/**
|
||||
* Augur — sign capture (omens, fortunes, hunches), resolution, and the
|
||||
* deterministic Living-Oracle / year-recap reflections.
|
||||
*
|
||||
* Five tools:
|
||||
* - augur.captureSign (write) — log a new sign
|
||||
* - augur.resolveSign (write) — mark an open sign as fulfilled / partly /
|
||||
* not-fulfilled
|
||||
* - augur.listOpenSigns (read) — what the user is still waiting on
|
||||
* - augur.consultOracle (read) — Living Oracle reflection from history
|
||||
* - augur.yearRecap (read) — structured year-in-review snapshot
|
||||
*
|
||||
* Encrypted fields mirror the web-app registry:
|
||||
* source / claim / feltMeaning / expectedOutcome / outcomeNote / tags /
|
||||
* livingOracleSnapshot
|
||||
*
|
||||
* The pure-math engines (calibration, living-oracle, year-recap) are
|
||||
* **mirrored from** apps/mana/apps/web/src/lib/modules/augur/lib/. Both
|
||||
* sides must drift together — the web app has 65 unit tests covering
|
||||
* the same contract; if you change one side, sync the other or extract
|
||||
* a shared package.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { decryptRecordFields, encryptRecordFields } from '@mana/shared-crypto';
|
||||
import { pullAll, push, pushInsert } from '../sync-client.ts';
|
||||
import { registerTool } from '../registry.ts';
|
||||
import type { ToolContext, ToolSpec } from '../types.ts';
|
||||
|
||||
const APP_ID = 'augur';
|
||||
const TABLE = 'augurEntries';
|
||||
const ENCRYPTED_FIELDS = [
|
||||
'source',
|
||||
'claim',
|
||||
'feltMeaning',
|
||||
'expectedOutcome',
|
||||
'outcomeNote',
|
||||
'tags',
|
||||
'livingOracleSnapshot',
|
||||
] as const;
|
||||
|
||||
const SYNC_URL = () => process.env.MANA_SYNC_URL ?? 'http://localhost:3050';
|
||||
const CLIENT_ID = () => process.env.MANA_MCP_CLIENT_ID ?? 'mana-mcp';
|
||||
|
||||
function syncCfg(ctx: ToolContext) {
|
||||
return { baseUrl: SYNC_URL(), jwt: ctx.jwt, clientId: CLIENT_ID() };
|
||||
}
|
||||
|
||||
// ─── Domain shapes (zod) ──────────────────────────────────────────
|
||||
|
||||
const KIND = z.enum(['omen', 'fortune', 'hunch']);
|
||||
type Kind = z.infer<typeof KIND>;
|
||||
|
||||
const VIBE = z.enum(['good', 'bad', 'mysterious']);
|
||||
type Vibe = z.infer<typeof VIBE>;
|
||||
|
||||
type Outcome = 'open' | 'fulfilled' | 'partly' | 'not-fulfilled';
|
||||
|
||||
const SOURCE_CATEGORY = z.enum([
|
||||
'gut',
|
||||
'tarot',
|
||||
'horoscope',
|
||||
'fortune-cookie',
|
||||
'iching',
|
||||
'dream',
|
||||
'person',
|
||||
'media',
|
||||
'natural',
|
||||
'other',
|
||||
]);
|
||||
type SourceCategory = z.infer<typeof SOURCE_CATEGORY>;
|
||||
|
||||
interface RawEntry {
|
||||
id?: string;
|
||||
kind?: string;
|
||||
source?: string;
|
||||
sourceCategory?: string;
|
||||
claim?: string;
|
||||
vibe?: string;
|
||||
feltMeaning?: string | null;
|
||||
expectedOutcome?: string | null;
|
||||
expectedBy?: string | null;
|
||||
probability?: number | null;
|
||||
outcome?: string;
|
||||
outcomeNote?: string | null;
|
||||
resolvedAt?: string | null;
|
||||
encounteredAt?: string;
|
||||
tags?: string[];
|
||||
livingOracleSnapshot?: string | null;
|
||||
isArchived?: boolean;
|
||||
deletedAt?: string | null;
|
||||
spaceId?: string | null;
|
||||
updatedAt?: string;
|
||||
visibility?: string;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
id: string;
|
||||
kind: Kind;
|
||||
source: string;
|
||||
sourceCategory: SourceCategory;
|
||||
claim: string;
|
||||
vibe: Vibe;
|
||||
feltMeaning: string | null;
|
||||
expectedOutcome: string | null;
|
||||
expectedBy: string | null;
|
||||
probability: number | null;
|
||||
outcome: Outcome;
|
||||
outcomeNote: string | null;
|
||||
resolvedAt: string | null;
|
||||
encounteredAt: string;
|
||||
tags: string[];
|
||||
livingOracleSnapshot: string | null;
|
||||
}
|
||||
|
||||
function fromRaw(row: RawEntry): Entry | null {
|
||||
if (!row.id || !row.kind || !row.source || !row.claim) return null;
|
||||
if (!row.encounteredAt) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as Kind,
|
||||
source: row.source,
|
||||
sourceCategory: (row.sourceCategory as SourceCategory) ?? 'other',
|
||||
claim: row.claim,
|
||||
vibe: (row.vibe as Vibe) ?? 'mysterious',
|
||||
feltMeaning: row.feltMeaning ?? null,
|
||||
expectedOutcome: row.expectedOutcome ?? null,
|
||||
expectedBy: row.expectedBy ?? null,
|
||||
probability: row.probability ?? null,
|
||||
outcome: (row.outcome as Outcome) ?? 'open',
|
||||
outcomeNote: row.outcomeNote ?? null,
|
||||
resolvedAt: row.resolvedAt ?? null,
|
||||
encounteredAt: row.encounteredAt,
|
||||
tags: row.tags ?? [],
|
||||
livingOracleSnapshot: row.livingOracleSnapshot ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAllEntries(ctx: ToolContext): Promise<Entry[]> {
|
||||
const key = await ctx.getMasterKey();
|
||||
const res = await pullAll<RawEntry>(syncCfg(ctx), APP_ID, TABLE);
|
||||
const alive = res.changes
|
||||
.filter((c) => c.op !== 'delete' && c.data)
|
||||
.map((c) => c.data as RawEntry)
|
||||
.filter((row) => !row.deletedAt && !row.isArchived)
|
||||
.filter((row) => row.spaceId === ctx.spaceId);
|
||||
const decrypted = (await Promise.all(
|
||||
alive.map((row) =>
|
||||
decryptRecordFields(row as unknown as Record<string, unknown>, ENCRYPTED_FIELDS, key)
|
||||
)
|
||||
)) as unknown as RawEntry[];
|
||||
return decrypted.map(fromRaw).filter((e): e is Entry => e !== null);
|
||||
}
|
||||
|
||||
// ─── Pure-math engines (MIRROR of web-app lib/) ──────────────────
|
||||
//
|
||||
// Mirror these with apps/mana/apps/web/src/lib/modules/augur/lib/. Both
|
||||
// sides have unit tests. Any edit here needs the same edit there.
|
||||
|
||||
const LIVING_ORACLE_COLD_START_MIN = 50;
|
||||
const LIVING_ORACLE_MIN_MATCHES = 3;
|
||||
const LIVING_ORACLE_MIN_SCORE = 2;
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'oder',
|
||||
'aber',
|
||||
'doch',
|
||||
'eine',
|
||||
'einer',
|
||||
'einen',
|
||||
'eines',
|
||||
'einem',
|
||||
'wenn',
|
||||
'dann',
|
||||
'noch',
|
||||
'sehr',
|
||||
'mehr',
|
||||
'auch',
|
||||
'durch',
|
||||
'ueber',
|
||||
'unter',
|
||||
'gegen',
|
||||
'sich',
|
||||
'haben',
|
||||
'hatte',
|
||||
'sein',
|
||||
'sind',
|
||||
'wird',
|
||||
'wurde',
|
||||
'kann',
|
||||
'koennen',
|
||||
'wie',
|
||||
'was',
|
||||
'warum',
|
||||
'wann',
|
||||
'wer',
|
||||
'this',
|
||||
'that',
|
||||
'have',
|
||||
'with',
|
||||
'from',
|
||||
'they',
|
||||
'will',
|
||||
'been',
|
||||
'were',
|
||||
'when',
|
||||
'what',
|
||||
'just',
|
||||
]);
|
||||
|
||||
function extractKeywords(text: string): Set<string> {
|
||||
return new Set(
|
||||
text
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[^a-z0-9\säöüß]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length >= 4 && !STOP_WORDS.has(w))
|
||||
);
|
||||
}
|
||||
|
||||
interface Fingerprint {
|
||||
kind: Kind;
|
||||
sourceCategory: SourceCategory;
|
||||
vibe: Vibe;
|
||||
tags: Set<string>;
|
||||
keywords: Set<string>;
|
||||
}
|
||||
|
||||
function fingerprint(input: {
|
||||
kind?: Kind | null;
|
||||
sourceCategory?: SourceCategory | null;
|
||||
vibe?: Vibe | null;
|
||||
tags?: string[] | null;
|
||||
source?: string | null;
|
||||
claim?: string | null;
|
||||
}): Fingerprint | null {
|
||||
if (!input.kind || !input.sourceCategory || !input.vibe) return null;
|
||||
return {
|
||||
kind: input.kind,
|
||||
sourceCategory: input.sourceCategory,
|
||||
vibe: input.vibe,
|
||||
tags: new Set((input.tags ?? []).map((t) => t.toLowerCase().trim()).filter(Boolean)),
|
||||
keywords: extractKeywords([input.source, input.claim].filter(Boolean).join(' ')),
|
||||
};
|
||||
}
|
||||
|
||||
function intersects<T>(a: Set<T>, b: Set<T>): boolean {
|
||||
if (a.size === 0 || b.size === 0) return false;
|
||||
const [small, big] = a.size <= b.size ? [a, b] : [b, a];
|
||||
for (const x of small) if (big.has(x)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchScore(a: Fingerprint, b: Fingerprint): number {
|
||||
let s = 0;
|
||||
if (a.kind === b.kind) s++;
|
||||
if (a.sourceCategory === b.sourceCategory) s++;
|
||||
if (a.vibe === b.vibe) s++;
|
||||
if (intersects(a.tags, b.tags)) s++;
|
||||
if (intersects(a.keywords, b.keywords)) s++;
|
||||
return s;
|
||||
}
|
||||
|
||||
function outcomeValue(o: Outcome): number | null {
|
||||
if (o === 'fulfilled') return 1;
|
||||
if (o === 'partly') return 0.5;
|
||||
if (o === 'not-fulfilled') return 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isScored(e: Entry): boolean {
|
||||
return outcomeValue(e.outcome) != null;
|
||||
}
|
||||
|
||||
interface OracleMatchSet {
|
||||
n: number;
|
||||
hitRate: number;
|
||||
fulfilled: number;
|
||||
partly: number;
|
||||
notFulfilled: number;
|
||||
}
|
||||
|
||||
function findMatches(input: Fingerprint, history: Entry[]): OracleMatchSet {
|
||||
const matches: Entry[] = [];
|
||||
for (const e of history) {
|
||||
if (!isScored(e)) continue;
|
||||
const fp = fingerprint(e);
|
||||
if (!fp) continue;
|
||||
if (matchScore(input, fp) >= LIVING_ORACLE_MIN_SCORE) matches.push(e);
|
||||
}
|
||||
let weighted = 0;
|
||||
let fulfilled = 0;
|
||||
let partly = 0;
|
||||
let notFulfilled = 0;
|
||||
for (const m of matches) {
|
||||
const v = outcomeValue(m.outcome) ?? 0;
|
||||
weighted += v;
|
||||
if (m.outcome === 'fulfilled') fulfilled++;
|
||||
else if (m.outcome === 'partly') partly++;
|
||||
else if (m.outcome === 'not-fulfilled') notFulfilled++;
|
||||
}
|
||||
return {
|
||||
n: matches.length,
|
||||
hitRate: matches.length > 0 ? weighted / matches.length : 0,
|
||||
fulfilled,
|
||||
partly,
|
||||
notFulfilled,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldSpeak(historyTotal: number, set: OracleMatchSet): boolean {
|
||||
if (historyTotal < LIVING_ORACLE_COLD_START_MIN) return false;
|
||||
return set.n >= LIVING_ORACLE_MIN_MATCHES;
|
||||
}
|
||||
|
||||
function makeReflection(set: OracleMatchSet): string | null {
|
||||
if (set.n < LIVING_ORACLE_MIN_MATCHES) return null;
|
||||
const pct = Math.round(set.hitRate * 100);
|
||||
const parts: string[] = [];
|
||||
parts.push(`Du hast ${set.n} aehnliche Zeichen schon einmal protokolliert.`);
|
||||
const breakdown: string[] = [];
|
||||
if (set.fulfilled) breakdown.push(`${set.fulfilled} eingetreten`);
|
||||
if (set.partly) breakdown.push(`${set.partly} teilweise`);
|
||||
if (set.notFulfilled) breakdown.push(`${set.notFulfilled} nicht eingetreten`);
|
||||
if (breakdown.length > 0) parts.push(`Davon: ${breakdown.join(', ')}.`);
|
||||
parts.push(`Trefferquote bei aehnlichen Mustern: ${pct}%.`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// ─── augur.captureSign ─────────────────────────────────────────────
|
||||
|
||||
const captureInput = z.object({
|
||||
kind: KIND,
|
||||
source: z.string().min(1).max(500),
|
||||
claim: z.string().min(1).max(2000),
|
||||
sourceCategory: SOURCE_CATEGORY.default('other'),
|
||||
vibe: VIBE.default('mysterious'),
|
||||
feltMeaning: z.string().max(2000).nullable().default(null),
|
||||
expectedOutcome: z.string().max(2000).nullable().default(null),
|
||||
expectedBy: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.nullable()
|
||||
.default(null),
|
||||
probability: z.number().min(0).max(1).nullable().default(null),
|
||||
tags: z.array(z.string()).default([]),
|
||||
encounteredAt: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const captureOutput = z.object({
|
||||
entryId: z.string(),
|
||||
kind: KIND,
|
||||
source: z.string(),
|
||||
});
|
||||
|
||||
export const augurCaptureSign: ToolSpec<typeof captureInput, typeof captureOutput> = {
|
||||
name: 'augur.captureSign',
|
||||
module: 'augur',
|
||||
scope: 'user-space',
|
||||
policyHint: 'write',
|
||||
description:
|
||||
'Log a new sign in the Augur module — an omen (external sign), fortune (read/cast aussage), or hunch (gut feeling). Defaults: vibe = "mysterious", encounteredAt = today, outcome starts "open" so the user can resolve it later. Source/claim/feltMeaning/expectedOutcome/tags travel encrypted.',
|
||||
input: captureInput,
|
||||
output: captureOutput,
|
||||
encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS },
|
||||
async handler(input, ctx) {
|
||||
const key = await ctx.getMasterKey();
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
const plaintext: Record<string, unknown> = {
|
||||
id,
|
||||
kind: input.kind,
|
||||
source: input.source,
|
||||
sourceCategory: input.sourceCategory,
|
||||
claim: input.claim,
|
||||
vibe: input.vibe,
|
||||
feltMeaning: input.feltMeaning,
|
||||
expectedOutcome: input.expectedOutcome,
|
||||
expectedBy: input.expectedBy,
|
||||
probability: input.probability,
|
||||
outcome: 'open',
|
||||
outcomeNote: null,
|
||||
resolvedAt: null,
|
||||
encounteredAt: input.encounteredAt ?? today,
|
||||
tags: input.tags,
|
||||
relatedDreamId: null,
|
||||
relatedDecisionId: null,
|
||||
livingOracleSnapshot: null,
|
||||
isPrivate: true,
|
||||
isArchived: false,
|
||||
visibility: 'private',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
};
|
||||
const encrypted = await encryptRecordFields(plaintext, ENCRYPTED_FIELDS, key);
|
||||
await pushInsert(syncCfg(ctx), APP_ID, {
|
||||
table: TABLE,
|
||||
id,
|
||||
spaceId: ctx.spaceId,
|
||||
data: encrypted,
|
||||
});
|
||||
ctx.logger.info('augur.captureSign', { entryId: id, kind: input.kind });
|
||||
return { entryId: id, kind: input.kind, source: input.source };
|
||||
},
|
||||
};
|
||||
|
||||
// ─── augur.resolveSign ─────────────────────────────────────────────
|
||||
|
||||
const resolveInput = z.object({
|
||||
entryId: z.string(),
|
||||
outcome: z.enum(['fulfilled', 'partly', 'not-fulfilled']),
|
||||
note: z.string().max(2000).nullable().default(null),
|
||||
});
|
||||
|
||||
const resolveOutput = z.object({
|
||||
entryId: z.string(),
|
||||
outcome: z.enum(['fulfilled', 'partly', 'not-fulfilled']),
|
||||
});
|
||||
|
||||
export const augurResolveSign: ToolSpec<typeof resolveInput, typeof resolveOutput> = {
|
||||
name: 'augur.resolveSign',
|
||||
module: 'augur',
|
||||
scope: 'user-space',
|
||||
policyHint: 'write',
|
||||
description:
|
||||
"Resolve an open augur entry — mark whether it came true (fulfilled / partly / not-fulfilled). Optional note carries the user's own write-up of how it actually went; encrypted at rest.",
|
||||
input: resolveInput,
|
||||
output: resolveOutput,
|
||||
encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS },
|
||||
async handler(input, ctx) {
|
||||
const key = await ctx.getMasterKey();
|
||||
const now = new Date().toISOString();
|
||||
const patch = (await encryptRecordFields(
|
||||
{ outcomeNote: input.note } as Record<string, unknown>,
|
||||
['outcomeNote'] as const,
|
||||
key
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
await push(syncCfg(ctx), APP_ID, [
|
||||
{
|
||||
table: TABLE,
|
||||
id: input.entryId,
|
||||
op: 'update',
|
||||
spaceId: ctx.spaceId,
|
||||
fields: {
|
||||
outcome: { value: input.outcome, updatedAt: now },
|
||||
outcomeNote: { value: patch.outcomeNote, updatedAt: now },
|
||||
resolvedAt: { value: now, updatedAt: now },
|
||||
updatedAt: { value: now, updatedAt: now },
|
||||
},
|
||||
},
|
||||
]);
|
||||
ctx.logger.info('augur.resolveSign', { entryId: input.entryId, outcome: input.outcome });
|
||||
return { entryId: input.entryId, outcome: input.outcome };
|
||||
},
|
||||
};
|
||||
|
||||
// ─── augur.listOpenSigns ───────────────────────────────────────────
|
||||
|
||||
const listInput = z.object({
|
||||
kind: KIND.optional(),
|
||||
limit: z.number().int().positive().max(100).default(30),
|
||||
});
|
||||
|
||||
const listOutput = z.object({
|
||||
entries: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
kind: KIND,
|
||||
source: z.string(),
|
||||
claim: z.string(),
|
||||
vibe: VIBE,
|
||||
encounteredAt: z.string(),
|
||||
expectedBy: z.string().nullable(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const augurListOpenSigns: ToolSpec<typeof listInput, typeof listOutput> = {
|
||||
name: 'augur.listOpenSigns',
|
||||
module: 'augur',
|
||||
scope: 'user-space',
|
||||
policyHint: 'read',
|
||||
description:
|
||||
'List augur entries still waiting on resolution (outcome = open). Sorted by reminder date (expectedBy if set, else encounteredAt + 30 days). Filter by `kind` to focus on omens/fortunes/hunches.',
|
||||
input: listInput,
|
||||
output: listOutput,
|
||||
encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS },
|
||||
async handler(input, ctx) {
|
||||
const all = await loadAllEntries(ctx);
|
||||
const open = all
|
||||
.filter((e) => e.outcome === 'open')
|
||||
.filter((e) => (input.kind ? e.kind === input.kind : true))
|
||||
.sort((a, b) =>
|
||||
(a.expectedBy ?? a.encounteredAt).localeCompare(b.expectedBy ?? b.encounteredAt)
|
||||
)
|
||||
.slice(0, input.limit);
|
||||
ctx.logger.info('augur.listOpenSigns', { count: open.length });
|
||||
return {
|
||||
entries: open.map((e) => ({
|
||||
id: e.id,
|
||||
kind: e.kind,
|
||||
source: e.source,
|
||||
claim: e.claim,
|
||||
vibe: e.vibe,
|
||||
encounteredAt: e.encounteredAt,
|
||||
expectedBy: e.expectedBy,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ─── augur.consultOracle ───────────────────────────────────────────
|
||||
|
||||
const consultInput = z.object({
|
||||
kind: KIND,
|
||||
sourceCategory: SOURCE_CATEGORY,
|
||||
vibe: VIBE,
|
||||
source: z.string().max(500).optional(),
|
||||
claim: z.string().max(2000).optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
const consultOutput = z.object({
|
||||
speaks: z.boolean(),
|
||||
reflection: z.string().nullable(),
|
||||
matches: z.number().int().nonnegative(),
|
||||
hitRate: z.number(),
|
||||
breakdown: z.object({
|
||||
fulfilled: z.number().int().nonnegative(),
|
||||
partly: z.number().int().nonnegative(),
|
||||
notFulfilled: z.number().int().nonnegative(),
|
||||
}),
|
||||
thresholds: z.object({
|
||||
coldStart: z.number().int().nonnegative(),
|
||||
minMatches: z.number().int().nonnegative(),
|
||||
historyTotal: z.number().int().nonnegative(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const augurConsultOracle: ToolSpec<typeof consultInput, typeof consultOutput> = {
|
||||
name: 'augur.consultOracle',
|
||||
module: 'augur',
|
||||
scope: 'user-space',
|
||||
policyHint: 'read',
|
||||
description:
|
||||
"Consult the deterministic Living Oracle: given a hypothetical sign's shape (kind/sourceCategory/vibe + optional source/claim/tags for keyword matching), return what happened to similar resolved signs in the user's own history. Stays silent below 50 resolved entries (cold-start) or below 3 matches.",
|
||||
input: consultInput,
|
||||
output: consultOutput,
|
||||
encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS },
|
||||
async handler(input, ctx) {
|
||||
const history = await loadAllEntries(ctx);
|
||||
const fp = fingerprint({
|
||||
kind: input.kind,
|
||||
sourceCategory: input.sourceCategory,
|
||||
vibe: input.vibe,
|
||||
tags: input.tags,
|
||||
source: input.source ?? null,
|
||||
claim: input.claim ?? null,
|
||||
});
|
||||
if (!fp) {
|
||||
throw new Error('Could not build fingerprint from input');
|
||||
}
|
||||
const set = findMatches(fp, history);
|
||||
const speaks = shouldSpeak(history.length, set);
|
||||
const reflection = speaks ? makeReflection(set) : null;
|
||||
ctx.logger.info('augur.consultOracle', {
|
||||
matches: set.n,
|
||||
speaks,
|
||||
historyTotal: history.length,
|
||||
});
|
||||
return {
|
||||
speaks,
|
||||
reflection,
|
||||
matches: set.n,
|
||||
hitRate: set.hitRate,
|
||||
breakdown: {
|
||||
fulfilled: set.fulfilled,
|
||||
partly: set.partly,
|
||||
notFulfilled: set.notFulfilled,
|
||||
},
|
||||
thresholds: {
|
||||
coldStart: LIVING_ORACLE_COLD_START_MIN,
|
||||
minMatches: LIVING_ORACLE_MIN_MATCHES,
|
||||
historyTotal: history.length,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ─── augur.yearRecap ───────────────────────────────────────────────
|
||||
|
||||
const recapInput = z.object({
|
||||
year: z.number().int().min(2000).max(2100).optional(),
|
||||
});
|
||||
|
||||
const recapOutput = z.object({
|
||||
year: z.number().int(),
|
||||
total: z.number().int().nonnegative(),
|
||||
resolved: z.number().int().nonnegative(),
|
||||
open: z.number().int().nonnegative(),
|
||||
hitRate: z.number().nullable(),
|
||||
byKind: z.object({
|
||||
omen: z.number().int().nonnegative(),
|
||||
fortune: z.number().int().nonnegative(),
|
||||
hunch: z.number().int().nonnegative(),
|
||||
}),
|
||||
byVibe: z.object({
|
||||
good: z.number().int().nonnegative(),
|
||||
bad: z.number().int().nonnegative(),
|
||||
mysterious: z.number().int().nonnegative(),
|
||||
}),
|
||||
byOutcome: z.object({
|
||||
open: z.number().int().nonnegative(),
|
||||
fulfilled: z.number().int().nonnegative(),
|
||||
partly: z.number().int().nonnegative(),
|
||||
'not-fulfilled': z.number().int().nonnegative(),
|
||||
}),
|
||||
topCategories: z.array(
|
||||
z.object({
|
||||
category: SOURCE_CATEGORY,
|
||||
n: z.number().int().nonnegative(),
|
||||
hitRate: z.number(),
|
||||
})
|
||||
),
|
||||
bestSource: z
|
||||
.object({ category: SOURCE_CATEGORY, n: z.number().int().nonnegative(), hitRate: z.number() })
|
||||
.nullable(),
|
||||
worstSource: z
|
||||
.object({ category: SOURCE_CATEGORY, n: z.number().int().nonnegative(), hitRate: z.number() })
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export const augurYearRecap: ToolSpec<typeof recapInput, typeof recapOutput> = {
|
||||
name: 'augur.yearRecap',
|
||||
module: 'augur',
|
||||
scope: 'user-space',
|
||||
policyHint: 'read',
|
||||
description:
|
||||
'Structured year-in-review: total, resolved/open counts, weighted hit-rate, by-kind / by-vibe / by-outcome breakdowns, top source-categories and best/worst forecaster (categories with n>=3 only). Year defaults to the current calendar year.',
|
||||
input: recapInput,
|
||||
output: recapOutput,
|
||||
encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS },
|
||||
async handler(input, ctx) {
|
||||
const year = input.year ?? new Date().getFullYear();
|
||||
const all = await loadAllEntries(ctx);
|
||||
const inYear = all.filter((e) => e.encounteredAt.startsWith(`${year}-`));
|
||||
|
||||
const byKind = { omen: 0, fortune: 0, hunch: 0 };
|
||||
const byVibe = { good: 0, bad: 0, mysterious: 0 };
|
||||
const byOutcome = { open: 0, fulfilled: 0, partly: 0, 'not-fulfilled': 0 };
|
||||
const sourceBuckets = new Map<
|
||||
SourceCategory,
|
||||
{ fulfilled: number; partly: number; notFulfilled: number; weighted: number; n: number }
|
||||
>();
|
||||
|
||||
let resolved = 0;
|
||||
let open = 0;
|
||||
let weighted = 0;
|
||||
for (const e of inYear) {
|
||||
byKind[e.kind]++;
|
||||
byVibe[e.vibe]++;
|
||||
byOutcome[e.outcome]++;
|
||||
if (e.outcome === 'open') {
|
||||
open++;
|
||||
continue;
|
||||
}
|
||||
const v = outcomeValue(e.outcome);
|
||||
if (v == null) continue;
|
||||
resolved++;
|
||||
weighted += v;
|
||||
const bucket = sourceBuckets.get(e.sourceCategory) ?? {
|
||||
fulfilled: 0,
|
||||
partly: 0,
|
||||
notFulfilled: 0,
|
||||
weighted: 0,
|
||||
n: 0,
|
||||
};
|
||||
if (e.outcome === 'fulfilled') bucket.fulfilled++;
|
||||
else if (e.outcome === 'partly') bucket.partly++;
|
||||
else if (e.outcome === 'not-fulfilled') bucket.notFulfilled++;
|
||||
bucket.weighted += v;
|
||||
bucket.n++;
|
||||
sourceBuckets.set(e.sourceCategory, bucket);
|
||||
}
|
||||
|
||||
const sourceRows = Array.from(sourceBuckets.entries())
|
||||
.map(([category, b]) => ({
|
||||
category,
|
||||
n: b.n,
|
||||
hitRate: b.n > 0 ? b.weighted / b.n : 0,
|
||||
}))
|
||||
.sort((a, b) => b.n - a.n);
|
||||
|
||||
const eligible = sourceRows.filter((r) => r.n >= 3);
|
||||
const sortedByHitDesc = [...eligible].sort((a, b) => b.hitRate - a.hitRate);
|
||||
const sortedByHitAsc = [...eligible].sort((a, b) => a.hitRate - b.hitRate);
|
||||
const bestSource = sortedByHitDesc[0] ?? null;
|
||||
const worstSource = sortedByHitAsc[0] ?? null;
|
||||
|
||||
ctx.logger.info('augur.yearRecap', { year, total: inYear.length, resolved });
|
||||
|
||||
return {
|
||||
year,
|
||||
total: inYear.length,
|
||||
resolved,
|
||||
open,
|
||||
hitRate: resolved > 0 ? weighted / resolved : null,
|
||||
byKind,
|
||||
byVibe,
|
||||
byOutcome,
|
||||
topCategories: sourceRows.slice(0, 5),
|
||||
bestSource,
|
||||
worstSource,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Registration barrel ──────────────────────────────────────────
|
||||
|
||||
export function registerAugurTools(): void {
|
||||
registerTool(augurCaptureSign);
|
||||
registerTool(augurResolveSign);
|
||||
registerTool(augurListOpenSigns);
|
||||
registerTool(augurConsultOracle);
|
||||
registerTool(augurYearRecap);
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import { registerSpacesTools } from './spaces.ts';
|
|||
import { registerTodoTools } from './todo.ts';
|
||||
import { registerWardrobeTools } from './wardrobe.ts';
|
||||
import { registerComicTools } from './comic.ts';
|
||||
import { registerAugurTools } from './augur.ts';
|
||||
|
||||
export function registerAllModules(): void {
|
||||
registerHabitsTools();
|
||||
|
|
@ -30,6 +31,7 @@ export function registerAllModules(): void {
|
|||
registerTodoTools();
|
||||
registerWardrobeTools();
|
||||
registerComicTools();
|
||||
registerAugurTools();
|
||||
}
|
||||
|
||||
export {
|
||||
|
|
@ -42,4 +44,5 @@ export {
|
|||
registerTodoTools,
|
||||
registerWardrobeTools,
|
||||
registerComicTools,
|
||||
registerAugurTools,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ export type ModuleId =
|
|||
// — Wardrobe M5 (garments + outfits + try-on) —
|
||||
| 'wardrobe'
|
||||
// — Comic M5 (stories + panel generation from cross-module text) —
|
||||
| 'comic';
|
||||
| 'comic'
|
||||
// — Augur M5 (signs / fortunes / hunches + Living Oracle + year recap) —
|
||||
| 'augur';
|
||||
|
||||
/**
|
||||
* `user-space` — operates on the caller's data within a specific Space.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue