UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

509 lines (508 loc) • 21.4 kB
import { uuidv7 } from "../internal/harness.js"; import { parseScopeKey } from "./memory-engine/scope-contract.js"; import { termSet } from "./memory-vector.js"; import { attrEscape } from "./task-notification.js"; import { sanitizeUntrustedText, ENGINE_AUTHORITY_ENVELOPE_TAGS } from "./untrusted-text.js"; import { thrownText } from "./thrown-value.js"; import { CREDENTIAL_VOCABULARY, refuseWriteView } from "./credential-vocabulary.js"; export function supportsConsolidation(store) { return (typeof store.searchScored === "function" && typeof store.update === "function" && typeof store.delete === "function"); } export function supportsPeriodicConsolidation(store) { return (typeof store.getConsolidationCursor === "function" && typeof store.setConsolidationCursor === "function"); } export const CALLER_AUTHORED_TYPES = new Set(["user", "feedback", "project", "reference"]); export const DEFAULT_CALLER_AUTHORED_TYPE = "project"; export class MemoryGateError extends Error { code; constructor(code, detail) { super(detail ? `${code}: ${detail}` : code); this.name = "MemoryGateError"; this.code = code; } } export class MemoryProjectionError extends Error { code = "memory.projection_failed"; committed = true; path; constructor(path, cause) { const detail = thrownText(cause); super(`memory.projection_failed: the note IS durably committed — only the derived projection ${path} could not be rewritten: ${detail}. ` + `Do not retry the write (it would store the note twice); the projection is rebuilt by the next mutation of this scope.`, cause !== undefined ? { cause } : undefined); this.name = "MemoryProjectionError"; this.path = path; } } function hasPromotableMarker(note) { return note.outcomeStats !== undefined; } const REFUSE_WRITE_FORMS = refuseWriteView(CREDENTIAL_VOCABULARY); export function detectSecret(text) { if (!text) return null; for (const form of REFUSE_WRITE_FORMS) { if (form.re.test(text)) return form.label; } return null; } export function enforceSecretWriteGate(body) { const label = detectSecret(body); if (label !== null) { throw new MemoryGateError("memory.secret_detected", `memory write blocked: detected ${label}`); } } export function enforceStructuredNoteSecretGate(note) { enforceSecretWriteGate(note.body); if (note.name !== undefined) enforceSecretWriteGate(note.name); if (note.description !== undefined) enforceSecretWriteGate(note.description); if (note.type !== undefined) enforceSecretWriteGate(note.type); } export function classifyPromotable(note) { if (hasPromotableMarker(note)) return "promotable"; if (note.type !== undefined && !CALLER_AUTHORED_TYPES.has(note.type)) return "promotable"; return "caller_authored"; } export function enforcePromotableWriteGate(note, utilityVerdict, utilityGate) { if (classifyPromotable(note) === "caller_authored") return; if (utilityVerdict === undefined) { throw new MemoryGateError("memory.promotable_ungated", "promotable memory write requires a utilityVerdict"); } if (utilityGate === undefined) { throw new MemoryGateError("memory.promotable_ungated", "promotable memory write but profile utilityGate is unwired"); } if (!utilityGate(note.outcomeStats)) { throw new MemoryGateError("memory.promotable_ungated", "promotable memory write failed the profile utilityGate"); } } function stripConsolidationGenerated(note) { if (note.consolidationGenerated === undefined) return note; const { consolidationGenerated: _drop, ...rest } = note; return rest; } export function guardedMemoryStore(inner, utilityGate) { const guarded = { get vectorMode() { return inner.vectorMode; }, read: (scope) => inner.read(scope), clear: (scope) => inner.clear(scope), append: (scope, note) => { enforceSecretWriteGate(note); return inner.append(scope, note); }, appendStructured: inner.appendStructured ? (scope, note) => { enforceStructuredNoteSecretGate(note); enforcePromotableWriteGate(note, undefined, utilityGate); return inner.appendStructured(scope, stripConsolidationGenerated(note)); } : undefined, appendConsolidationGenerated: inner.appendStructured ? (scope, note) => { enforceStructuredNoteSecretGate(note); enforcePromotableWriteGate(note, undefined, utilityGate); const withMarker = { ...note, consolidationGenerated: true }; if (inner.appendConsolidationGenerated) return inner.appendConsolidationGenerated(scope, withMarker); return inner.appendStructured(scope, withMarker); } : undefined, appendPromotable: (scope, note, utilityVerdict) => { enforceStructuredNoteSecretGate(note); enforcePromotableWriteGate(note, utilityVerdict, utilityGate); if (inner.appendPromotable) return inner.appendPromotable(scope, note, utilityVerdict); if (inner.appendStructured) return inner.appendStructured(scope, note); return inner.append(scope, note.body); }, }; if (inner.search) guarded.search = (scope, query, limit) => inner.search(scope, query, limit); if (inner.searchScored) guarded.searchScored = (scope, query, limit) => inner.searchScored(scope, query, limit); if (inner.update) guarded.update = (scope, id, text) => { enforceSecretWriteGate(text); return inner.update(scope, id, text); }; if (inner.delete) guarded.delete = (scope, id) => inner.delete(scope, id); if (inner.listStructuredNotes) guarded.listStructuredNotes = (scope) => inner.listStructuredNotes(scope); if (inner.getByIds) guarded.getByIds = (scope, ids) => inner.getByIds(scope, ids); if (inner.getConsolidationCursor) guarded.getConsolidationCursor = (scope) => inner.getConsolidationCursor(scope); if (inner.setConsolidationCursor) guarded.setConsolidationCursor = (scope, cursor) => inner.setConsolidationCursor(scope, cursor); return guarded; } function renderBullet(e) { return `- (${e.ts} UTC) ${e.text}`; } export class InMemoryMemoryStore { byScope = new Map(); cursorByScope = new Map(); vectorMode = "lexical"; read(scope) { const entries = this.byScope.get(scope); if (!entries || entries.length === 0) { return null; } return entries.map(renderBullet).join("\n"); } append(scope, note) { const text = note.trim().replace(/^#+\s*/, ""); if (!text) { return; } const ts = new Date().toISOString().replace("T", " ").slice(0, 16); const entry = { id: uuidv7(), text, ts }; const arr = this.byScope.get(scope) ?? []; arr.push(entry); this.byScope.set(scope, arr); return entry.id; } appendStructured(scope, note) { const body = note.body.trim().replace(/^#+\s*/, ""); if (!body) return ""; const ts = new Date().toISOString().replace("T", " ").slice(0, 16); const entry = { id: uuidv7(), text: body, ts, name: note.name, type: note.type ?? "project", description: note.description?.trim() || firstSentence(body), links: note.links, ...(note.consolidationGenerated ? { consolidationGenerated: true } : {}), }; const arr = this.byScope.get(scope) ?? []; arr.push(entry); this.byScope.set(scope, arr); return entry.id; } clear(scope) { this.byScope.delete(scope); this.cursorByScope.delete(scope); } search(scope, query, limit = 10) { const entries = this.byScope.get(scope); if (!entries || entries.length === 0) { return []; } return entries .map(renderBullet) .filter((b) => lexicalSearchMatch(query, b)) .slice(-limit); } searchScored(scope, query, limit = 20) { const entries = this.byScope.get(scope); if (!entries || entries.length === 0) { return []; } const q = termSet(query); if (q.size === 0) { return []; } const scored = []; for (const e of entries) { const t = termSet(e.text); let inter = 0; for (const term of q) { if (t.has(term)) inter++; } const union = q.size + t.size - inter; const jaccard = union === 0 ? 0 : inter / union; if (jaccard === 0) continue; scored.push({ id: e.id, text: e.text, score: 1 - jaccard, ...(e.consolidationGenerated ? { consolidationGenerated: true } : {}) }); } scored.sort((a, b) => a.score - b.score); return scored.slice(0, limit); } update(scope, id, text) { const clean = text.trim(); if (!clean) { return; } const entry = this.byScope.get(scope)?.find((e) => e.id === id); if (!entry) { return; } const newBody = clean.replace(/^#+\s*/, ""); if (entry.description !== undefined && entry.description === firstSentence(entry.text)) { entry.description = firstSentence(newBody); } entry.text = newBody; entry.ts = new Date().toISOString().replace("T", " ").slice(0, 16); } delete(scope, id) { const arr = this.byScope.get(scope); if (!arr) { return; } const i = arr.findIndex((e) => e.id === id); if (i !== -1) { arr.splice(i, 1); } } listStructuredNotes(scope) { const entries = this.byScope.get(scope) ?? []; return entries.map((e) => ({ id: e.id, description: e.description ?? firstSentence(e.text), ...parseNoteTimestamp(e.ts), ...(e.name ? { name: e.name } : {}), ...(e.type ? { type: e.type } : {}), ...(e.consolidationGenerated ? { consolidationGenerated: true } : {}), })); } getConsolidationCursor(scope) { return this.cursorByScope.get(scope); } setConsolidationCursor(scope, cursor) { this.cursorByScope.set(scope, cursor); } getByIds(scope, ids) { const entries = this.byScope.get(scope); if (!entries) return []; const want = new Set(ids); return entries .filter((e) => want.has(e.id)) .map((e) => ({ id: e.id, text: e.text, description: e.description ?? firstSentence(e.text), ...parseNoteTimestamp(e.ts), ...(e.name ? { name: e.name } : {}), ...(e.type ? { type: e.type } : {}), ...(e.consolidationGenerated ? { consolidationGenerated: true } : {}), })); } } export function expandLexicalTerms(term) { const t = term.toLowerCase(); const out = new Set([t]); const stem = lightStem(t); const stemOk = stem !== t && stem.length >= 3; if (stemOk) out.add(stem); for (const group of LEXICAL_SYNONYM_GROUPS) { if (group.has(t) || (stemOk && group.has(stem))) for (const w of group) out.add(w); } return [...out]; } export function lexicalSearchMatch(query, text) { const terms = query.toLowerCase().split(/\s+/).filter(Boolean); if (terms.length === 0) return false; const lower = text.toLowerCase(); const tokens = termSet(text); return terms.every((t) => { const variants = expandLexicalTerms(t); return variants.some((v) => v === t ? lower.includes(v) : tokens.has(v)); }); } const LEXICAL_SYNONYM_GROUPS = [ new Set(["auth", "authentication", "authorization", "credentials", "credential", "login", "signin"]), new Set(["token", "bearer", "apikey", "secret", "key"]), new Set(["password", "passwd", "passphrase", "pwd"]), new Set(["delete", "remove", "drop", "erase"]), new Set(["config", "configuration", "settings", "setting"]), new Set(["error", "failure", "exception", "fault"]), new Set(["db", "database"]), ]; function lightStem(t) { if (t.length > 4 && t.endsWith("ies")) return `${t.slice(0, -3)}y`; if (t.length > 3 && t.endsWith("es")) return t.slice(0, -2); if (t.length > 3 && t.endsWith("s") && !t.endsWith("ss")) return t.slice(0, -1); return t; } export function firstSentence(text) { const t = text.trim().replace(/\s+/g, " "); const dot = t.search(/[.!?](\s|$)/); const cut = dot >= 0 && dot < 160 ? dot + 1 : Math.min(t.length, 120); return t.slice(0, cut).trim(); } export function parseNoteTimestamp(ts) { if (typeof ts !== "string" || ts.trim() === "") return { mtimeMs: 0, timestampMissing: true }; const ms = Date.parse(`${ts.replace(" ", "T")}:00Z`); return Number.isFinite(ms) ? { mtimeMs: ms } : { mtimeMs: 0, timestampMissing: true }; } export const MEMORY_PROJECT_KEY_ABSENT_REASONS = Object.freeze(["not_git", "bare", "unreadable"]); export function normalizeMemorySpec(input) { if (!input) return undefined; if (Object.prototype.hasOwnProperty.call(input, "scope")) { const e = new Error("TaskSpec.memory: the singular `scope` field was removed — write `scopes: [\"<scope>\"]` instead " + "(an ordered read layering; `writeScope` defaults to its last entry). Refusing to start with memory silently off."); e.code = "config.memory_scope_spelling"; throw e; } if (input.capture !== undefined && input.capture !== "off") { const rawCapture = input.capture; const got = typeof rawCapture === "string" ? JSON.stringify(rawCapture.length > 64 ? `${rawCapture.slice(0, 64)}…` : rawCapture) : rawCapture === null ? "null" : typeof rawCapture; const e = new Error(`TaskSpec.memory.capture must be exactly "off" when present (got ${got}) — the capture opt-out is a ` + `single-member closed set (absence = capture as usual; there is no "on" spelling). Refusing to guess ` + `which way a privacy switch was meant.`); e.code = "config.memory_capture_spelling"; throw e; } const projectKey = readProjectKey(input); const raw = input.scopes ?? []; const scopes = []; const seen = new Set(); for (const s of raw) { const t = typeof s === "string" ? s.trim() : ""; if (!t || seen.has(t)) continue; seen.add(t); scopes.push(t); } if (scopes.length === 0) return undefined; let writeScope; if (input.writeScope === null) { writeScope = null; } else if (typeof input.writeScope === "string" && input.writeScope.trim()) { writeScope = input.writeScope.trim(); } else { writeScope = scopes[scopes.length - 1]; } const trustedTools = []; const trustedSeen = new Set(); for (const t of input.trustedTools ?? []) { const name = typeof t === "string" ? t.trim() : ""; if (!name || trustedSeen.has(name)) continue; trustedSeen.add(name); trustedTools.push(name); } const contentSafety = { ...(trustedTools.length > 0 ? { trustedTools } : {}), ...(input.execIsExternalContent === true ? { execIsExternalContent: true } : {}), }; const origins = input.scopeOrigins !== undefined ? { scopeOrigins: input.scopeOrigins } : {}; if (projectKey?.scope !== undefined && writeScope !== null && writeScope !== projectKey.scope) { const e = new Error(`TaskSpec.memory.projectKey declares project scope ${JSON.stringify(projectKey.scope)} while writeScope is ${JSON.stringify(writeScope)} — ` + `every entry this session writes would land in ${JSON.stringify(writeScope)} instead of the project's own layer. ` + `Pin writeScope to the project scope (or drop projectKey if the layering is deliberate). Refusing to start mis-addressed.`); e.code = "config.memory_write_scope_mismatch"; throw e; } const capture = input.capture === "off" ? { capture: "off" } : {}; const project = projectKey !== undefined ? { projectKey } : {}; if (input.scopeContract === "v2") { for (const key of writeScope !== null ? [...scopes, writeScope] : scopes) parseScopeKey(key); return { scopes, writeScope, enabled: input.enabled !== false, scopeContract: "v2", ...contentSafety, ...origins, ...capture, ...project }; } return { scopes, writeScope, enabled: input.enabled !== false, ...contentSafety, ...origins, ...capture, ...project }; } function readProjectKey(input) { const raw = input.projectKey; if (raw === undefined) return undefined; const refuse = (why) => { const e = new Error(`TaskSpec.memory.projectKey must be exactly { scope: "<non-empty>" } or { absent: ${MEMORY_PROJECT_KEY_ABSENT_REASONS.map((r) => JSON.stringify(r)).join(" | ")} } — ${why}. ` + `Refusing to guess whether this deployment has a project identity.`); e.code = "config.memory_project_key_spelling"; throw e; }; if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return refuse(`got ${raw === null ? "null" : Array.isArray(raw) ? "an array" : typeof raw}`); const scope = raw.scope; const absent = raw.absent; if ((scope === undefined) === (absent === undefined)) return refuse(absent === undefined ? "neither arm was spelled" : "both arms were spelled"); if (absent !== undefined) { if (typeof absent !== "string" || !MEMORY_PROJECT_KEY_ABSENT_REASONS.includes(absent)) { return refuse(`the absence reason ${typeof absent === "string" ? JSON.stringify(absent.slice(0, 64)) : typeof absent} is not one of the stated words`); } return { absent: absent }; } if (typeof scope !== "string" || scope.trim() === "") return refuse(typeof scope === "string" ? "the project scope is blank" : `the project scope is ${typeof scope}`); return { scope: scope.trim() }; } export function memoryProjectScopeAbsentNotice(input) { return { code: "memory.project_scope_absent", message: `No project identity was derived for this session's working root (${input.reason}), so its memory layering is used as declared: ` + `${input.writeScope === null ? "the session writes nothing (read-only layering)" : `entries this session writes go to the shared layer ${JSON.stringify(input.writeScope)}`}. ` + `If this root should have its own project memory, fix how the deployment resolves it — nothing here retries.`, detail: { reason: input.reason, taskRoot: input.taskRoot, sessionId: input.sessionId, ...(input.writeScope !== null ? { writeScope: input.writeScope } : {}) }, }; } export const MAX_MEMORY_BYTES = 100 * 1024; export const MEMORY_WRAPPER_TAGS = ["user_memory"]; export const MEMORY_INJECTION_TAGS = [...ENGINE_AUTHORITY_ENVELOPE_TAGS]; export function composeMemoryBlock(content, scope) { const trimmed = (content ?? "").trim(); if (!trimmed) { return undefined; } let payload = trimmed; const bytes = Buffer.byteLength(trimmed, "utf8"); if (bytes > MAX_MEMORY_BYTES) { const buf = Buffer.from(trimmed, "utf8"); let cut = MAX_MEMORY_BYTES; while (cut > 0 && (buf[cut] & 0xc0) === 0x80) { cut--; } const head = buf.subarray(0, cut).toString("utf8"); payload = `${head}\n<truncated bytes=${bytes - cut}>`; } return `<user_memory scope="${attrEscape(scope)}">\n${sanitizeUntrustedText(payload, [...MEMORY_INJECTION_TAGS])}\n</user_memory>`; } export function composeLayeredMemoryBlock(layers) { const sections = []; for (const layer of layers) { const trimmed = (layer.content ?? "").trim(); if (!trimmed) continue; let payload = trimmed; const bytes = Buffer.byteLength(trimmed, "utf8"); if (bytes > MAX_MEMORY_BYTES) { const buf = Buffer.from(trimmed, "utf8"); let cut = MAX_MEMORY_BYTES; while (cut > 0 && (buf[cut] & 0xc0) === 0x80) cut--; payload = `${buf.subarray(0, cut).toString("utf8")}\n<truncated bytes=${bytes - cut}>`; } sections.push(`<scope name="${attrEscape(layer.scope)}">\n` + `${sanitizeUntrustedText(payload, [...MEMORY_INJECTION_TAGS])}\n` + `</scope>`); } if (sections.length === 0) return undefined; return `<user_memory>\n${sections.join("\n")}\n</user_memory>`; }