// The one definition of "this account's active, de-conflicted, bounded // rule-set" (Task 1486). Consumed by the memory plugin's profile-read // (agent-callable) and by the three inbound gateways (per-turn injection). Kept // free of neo4j-driver types so both an MCP process and the UI can import the // built dist. // Write-time only: profile-update routes a same-subject write (a neighbour at // or above this raw-cosine threshold) into the existing node instead of minting // a duplicate. Injection no longer dedups — it surfaces every stored active // preference verbatim (Task 1951) — so this constant has no read-side use. export const SAME_SUBJECT_COSINE = 0.85; export const ACTIVE_CANDIDATE_FLOOR = 0.4; export interface RuleCandidate { preferenceId: string; category: string; key: string; value: string; confidence: number; observedAt: string; embedding: number[] | null; } export interface ActiveRule { preferenceId: string; category: string; key: string; value: string; confidence: number; observedAt: string; } // Render every rule verbatim. Injection has no cap (Task 1951): concision is // enforced at write time by the conversational-memory skill so each stored // value is already short, not by truncating the block here. export function formatStandingRulesBlock(rules: ActiveRule[]): string { if (rules.length === 0) return ""; const lines = rules.map((r) => `- ${r.value}`); return `## Standing rules\n${lines.join("\n")}`; } interface MinimalSession { run: ( q: string, p: Record, ) => Promise<{ records: Array<{ get(k: string): unknown }> }>; } // Task 1564 — which admin `AdminUser`/`UserProfile` owns a preference now // decides whether it is injected. `source` records how the rule-set was // selected; `InjectionSource` widens it with `fetch-error`, produced only by // the ui/server never-throw catch (a hard Neo4j failure), never by this lib. export type ActiveRuleSource = "owner" | "no-owner" | "owner-error"; export type InjectionSource = ActiveRuleSource | "fetch-error"; export interface ActiveRuleResolution { rules: ActiveRule[]; ownerUserId: string | null; source: ActiveRuleSource; } // The per-turn injection payload the UI hands each channel gateway: the // formatted `## Standing rules` block plus the provenance the gateway logs. export interface StandingRulesInjection { block: string; ownerUserId: string | null; source: InjectionSource; } // The account's owner identity (Task 1461). One `AdminUser {role:'owner'}` is // seeded per account (Task 1359); `null` when absent (a pre-seeding legacy // account). Mirrors the memory plugin's resolve-owner-userid.ts, but takes a // session so this lib stays dependency-free (no neo4j-driver) — the same // cross-process lockstep as the duplicated literals elsewhere in this tree. export async function resolveOwnerUserId( session: MinimalSession, accountId: string, ): Promise { const res = await session.run( ` MATCH (au:AdminUser {accountId: $accountId, role: 'owner'}) RETURN au.userId AS userId ORDER BY au.createdAt ASC LIMIT 1 `, { accountId }, ); const rec = res.records[0]; const userId = rec ? rec.get("userId") : null; return typeof userId === "string" && userId.length > 0 ? userId : null; } // The owner's admin-scope candidates, selected through the owner's // `UserProfile` and pinned to the resolved owner userId so the prefs selected // and the owner logged are one identity. Not trashed, not a "does not apply" // declination, above the injection floor, ordered so the highest-confidence / // most-recent wins de-confliction. The trashed predicate mirrors graph-trash's // `notTrashed()` exactly (label + deletedAt) so this lib stays dependency-free. async function queryOwnerCandidates( session: MinimalSession, accountId: string, ownerUserId: string, ): Promise { // No LIMIT — every owner admin preference above the floor is a candidate // (Task 1951). The result is bounded by how many the owner has, not by a cap. const result = await session.run( ` MATCH (up:UserProfile {accountId: $accountId, userId: $ownerUserId})-[:HAS_PREFERENCE]->(pref:Preference {scope: 'admin'}) WHERE NOT pref:Trashed AND pref.deletedAt IS NULL AND coalesce(pref.notApplicable, false) = false AND pref.confidence >= $floor RETURN pref.preferenceId AS preferenceId, pref.category AS category, pref.key AS key, pref.value AS value, pref.confidence AS confidence, pref.observedAt AS observedAt, pref.embedding AS embedding ORDER BY pref.confidence DESC, pref.observedAt DESC `, { accountId, ownerUserId, floor: ACTIVE_CANDIDATE_FLOOR }, ); return result.records.map((r) => ({ preferenceId: r.get("preferenceId") as string, category: r.get("category") as string, key: r.get("key") as string, value: r.get("value") as string, confidence: r.get("confidence") as number, observedAt: (r.get("observedAt") as string) ?? "", embedding: (r.get("embedding") as number[] | null) ?? null, })); } // Resolve the owner then select. Injection has exactly one selection rule — the // owner's prefs. No owner (or an owner-resolution error) yields no candidates // rather than falling back to a different selection key. An owner-resolution // error is caught here and degrades to empty; it never throws. async function resolveCandidates( session: MinimalSession, accountId: string, ): Promise<{ candidates: RuleCandidate[]; ownerUserId: string | null; source: ActiveRuleSource; }> { let ownerUserId: string | null; try { ownerUserId = await resolveOwnerUserId(session, accountId); } catch { return { candidates: [], ownerUserId: null, source: "owner-error" }; } if (!ownerUserId) return { candidates: [], ownerUserId: null, source: "no-owner" }; const candidates = await queryOwnerCandidates(session, accountId, ownerUserId); return { candidates, ownerUserId, source: "owner" }; } // Owner-scoped candidates for a downstream reader that only needs the rows // (the standing preference-reconciliation audit). Signature unchanged. export async function queryActiveRuleCandidates( session: MinimalSession, accountId: string, ): Promise { return (await resolveCandidates(session, accountId)).candidates; } // The account's active rule-set plus its provenance. Every owner candidate is // returned verbatim, in `confidence DESC, observedAt DESC` order — no cap, no // de-confliction (Task 1951). Only the read-facing `embedding` is dropped. export async function resolveActiveRules( session: MinimalSession, accountId: string, ): Promise { const { candidates, ownerUserId, source } = await resolveCandidates(session, accountId); const rules = candidates.map(({ embedding: _embedding, ...rest }) => rest); return { rules, ownerUserId, source }; } // The rule array alone — the unchanged call boundary for profile-read and the // ui/server injection wrapper. export async function activeRuleSet( session: MinimalSession, accountId: string, ): Promise { return (await resolveActiveRules(session, accountId)).rules; }