/** * memory-selector — opt-in post-lookup selection of one local memory entry. * * Off by default, and only reachable after the host has already retrieved bounded local rows * (`saiAdvisory.routes.memory.select === true`). The classifier then sees `none` plus opaque * `m1…mN` keys and, for each one, the fixed target/category vocabulary plus at most 400 characters * of that entry's text. The store id, date, and project *fields* are omitted from the request; the * text snippet is transmitted as-is and is **not** redacted, so identifiers or secrets that happen * to appear inside an entry's text can travel with it. That trade is the opt-in this flag grants. * A row attributed to another project is never offered. Selection changes only which locally * retrieved entry the injected block shows: nothing is written, rewritten, deleted, or pruned, and * an older bridge without structured `entries` simply keeps today's output. */ import type { MemorySearchResponse, MemorySearchTarget, } from "../../pi-hermes-memory/src/memory-search-bridge.ts"; import { askSai, buildSaiPayload, type SaiConnection, type SaiDecision, type SaiQuestion, type SaiRuntime, } from "./decisions.ts"; /** The host's lookup cap is 5; the selector never offers more. */ export const MAX_SELECTOR_CANDIDATES = 5; /** Longest slice of one entry's text the classifier may see. */ export const MAX_SELECTOR_CANDIDATE_CHARS = 400; /** * Caps on the local provenance the selected block renders, so a malformed bridge cannot make that * block unbounded. Both are generous enough for real rows: a filesystem basename is at most 255 * bytes, and an ISO-8601 timestamp with an offset is far under 64 characters. */ export const MAX_SELECTOR_PROJECT_CHARS = 255; export const MAX_SELECTOR_DATE_CHARS = 64; /** The stored category vocabulary; a value outside it is not advertised. */ export const MEMORY_CATEGORY_NAMES = [ "failure", "correction", "insight", "preference", "convention", "tool-quirk", ] as const; export type MemoryCategoryName = (typeof MEMORY_CATEGORY_NAMES)[number]; /** One locally validated candidate: an opaque key plus the real local row behind it. */ export interface MemorySelectCandidate { /** Host-assigned, opaque: `m1`…`m5`. The only identity the classifier ever sees. */ key: string; /** Real store row id. Local only. */ id: number; target: MemorySearchTarget; category?: MemoryCategoryName; content: string; /** Local provenance, rendered only into the local block. */ project: string | null; created: string; lastReferenced: string; } /** Why no candidate could be offered; every one of these keeps today's exact bounded output. */ export type MemorySelectRejection = "no-entries" | "malformed" | "out-of-scope"; export interface MemorySelectCandidates { candidates: MemorySelectCandidate[]; /** Set only when no candidate is offered. */ rejected?: MemorySelectRejection; } function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** The bounded text handed to the classifier for one candidate. */ export function clipCandidateText(content: string): string { const trimmed = content.trim(); return trimmed.length > MAX_SELECTOR_CANDIDATE_CHARS ? trimmed.slice(0, MAX_SELECTOR_CANDIDATE_CHARS) : trimmed; } /** * Validate one bridge response as a trust boundary and keep only rows this host may offer. * * A structurally broken response offers nothing, because the key→row mapping would be ambiguous: * a missing/invalid id, a repeated id, a non-object entry, a missing text/date/type field, or * provenance longer than a real row can be. Rows are silently dropped only for *scope* reasons — a * target other than the lookup target, a project attribution that is not this project (an older * search can return project-attributed rows for global/user/failure scopes), or a category outside * the fixed vocabulary. Absent `entries` (an older bridge) is `no-entries`, never a partial guess. */ export function memorySelectCandidates(input: { response: MemorySearchResponse | undefined; plan: { target: MemorySearchTarget }; /** The active project identity; `undefined` outside a project. */ activeProject: string | undefined; }): MemorySelectCandidates { const entries: unknown = input.response?.entries; if (entries === undefined) return { candidates: [], rejected: "no-entries" }; if (!Array.isArray(entries)) return { candidates: [], rejected: "malformed" }; if (entries.length === 0) return { candidates: [], rejected: "no-entries" }; const candidates: MemorySelectCandidate[] = []; const ids = new Set(); let outOfScope = false; for (const entry of entries) { if (!record(entry)) return { candidates: [], rejected: "malformed" }; const { id, target, category, project, content, created, lastReferenced } = entry; if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) return { candidates: [], rejected: "malformed" }; if (ids.has(id)) return { candidates: [], rejected: "malformed" }; ids.add(id); if (target !== "memory" && target !== "user" && target !== "failure" && target !== "project") { return { candidates: [], rejected: "malformed" }; } if (category !== undefined && typeof category !== "string") return { candidates: [], rejected: "malformed" }; if (typeof content !== "string" || content.trim() === "") return { candidates: [], rejected: "malformed" }; if (project !== null && (typeof project !== "string" || project.length > MAX_SELECTOR_PROJECT_CHARS)) { return { candidates: [], rejected: "malformed" }; } if (typeof created !== "string" || created.length > MAX_SELECTOR_DATE_CHARS) { return { candidates: [], rejected: "malformed" }; } if (typeof lastReferenced !== "string" || lastReferenced.length > MAX_SELECTOR_DATE_CHARS) { return { candidates: [], rejected: "malformed" }; } // Scope, checked before the row can be offered: another project's row is never transmitted, // and neither is a row whose project cannot be verified against this host's identity. if (target !== input.plan.target) { outOfScope = true; continue; } if (project !== null && project !== input.activeProject) { outOfScope = true; continue; } if (target === "project" && project === null) { outOfScope = true; continue; } if (category !== undefined && !MEMORY_CATEGORY_NAMES.includes(category as MemoryCategoryName)) { outOfScope = true; continue; } candidates.push({ key: `m${candidates.length + 1}`, id, target, ...(category === undefined ? {} : { category: category as MemoryCategoryName }), content, project, created, lastReferenced, }); if (candidates.length === MAX_SELECTOR_CANDIDATES) break; } if (candidates.length === 0) return { candidates: [], rejected: outOfScope ? "out-of-scope" : "no-entries" }; return { candidates }; } /** * The selector question: one choice, `none` plus every opaque key. The criteria carry only what the * privacy decision allows — target vocabulary, optional category vocabulary, bounded text — never * the id/date/project fields, and the text is not filtered. The shared untrusted-material focus * already frames criteria as material, never instructions. */ export function memorySelectQuestion(candidates: readonly MemorySelectCandidate[]): SaiQuestion { return { question: "Which one locally retrieved memory entry, if any, would help answer the latest request?", criteria: { none: "No supplied entry is likely to help with the latest request, or none is relevant enough to show.", ...Object.fromEntries( candidates.map((candidate) => [ candidate.key, `${candidate.target}${candidate.category ? `/${candidate.category}` : ""}: ${clipCandidateText(candidate.content)}`, ]), ), }, }; } export interface MemorySelection { entry: MemorySelectCandidate; confidence?: number; } /** * One selector dispatch, no retry: the caller's deadline and cancellation decide the outcome. * Every failure — no credential, timeout, cancellation, malformed or partial answer, an unknown * key, low confidence, or `none` — is an abstention the caller renders as today's exact bounded * local output. */ export async function selectMemoryEntry( ctx: SaiRuntime, connection: SaiConnection, request: { query: string; candidates: readonly MemorySelectCandidate[]; maxBytes: number; signal?: AbortSignal; }, ): Promise<{ selection?: MemorySelection; decision: SaiDecision }> { const decision = await askSai(ctx, connection, { payload: buildSaiPayload([{ role: "user", text: request.query }], { memory_select: memorySelectQuestion(request.candidates), }), maxBytes: request.maxBytes, allowed: { memory_select: ["none", ...request.candidates.map((candidate) => candidate.key)] }, ...(request.signal ? { signal: request.signal } : {}), }); const choice = decision.choices.memory_select; const entry = choice === undefined || choice.choice === "none" ? undefined : request.candidates.find((candidate) => candidate.key === choice.choice); if (!entry || !choice) return { decision }; return { selection: { entry, ...(choice.confidence === undefined ? {} : { confidence: choice.confidence }) }, decision, }; }