import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import type { GuardianDelivery } from "@vellumai/gateway-client"; import { findContactByAddress } from "../contacts/contact-store.js"; import { anyGuardian, guardianForChannel, peekCachedGuardianDelivery, } from "../contacts/guardian-delivery-reader.js"; import type { ChannelCapabilities } from "../daemon/conversation-runtime-assembly.js"; import type { TrustContext } from "../daemon/trust-context-types.js"; import { getLogger } from "../util/logger.js"; import { getWorkspaceDir, getWorkspacePromptPath } from "../util/platform.js"; import { stripCommentLines } from "../util/strip-comment-lines.js"; import type { NormalizedOnboarding } from "./normalize-onboarding.js"; const log = getLogger("persona-resolver"); // ── Guardian persona template ───────────────────────────────────── // // Scaffold written to `users/.md` when a guardian is resolved // but no per-user persona file yet exists. Kept in sync with the // legacy workspace USER.md template so that upgrading users preserve // the same editable shape. Exported so consumers can detect the // unmodified scaffold (e.g. heartbeat's `isShallowProfile`). export const GUARDIAN_PERSONA_TEMPLATE = `_ Lines starting with _ are comments - they won't appear in the system prompt # User Profile Store details about your user here. Edit freely - build this over time as you learn about them. Don't be pushy about seeking details, but when you learn something, write it down. More context makes you more useful. - Preferred name/reference: - Pronouns: - Locale: - Work role: - Goals: - Hobbies/fun: - Daily tools: `; // ── Types ────────────────────────────────────────────────────────── export interface PersonaContext { userPersona: string | null; userSlug: string | null; channelPersona: string | null; } // ── Helpers ──────────────────────────────────────────────────────── /** * Read a persona file from disk, apply comment stripping, and return * the content. Returns null if the file does not exist or is empty * after stripping. */ function readPersonaFile(filePath: string): string | null { if (!existsSync(filePath)) { return null; } try { const content = stripCommentLines(readFileSync(filePath, "utf-8")).trim(); if (content.length === 0) { return null; } log.debug({ path: filePath }, "Loaded persona file"); return content; } catch (err) { log.warn({ err, path: filePath }, "Failed to read persona file"); return null; } } // ── User filename resolution ────────────────────────────────────── /** * Resolve the guardian's persona `userFile` (INFO) for a guardian-trust turn. * * The guardian identity comes from the verdict-derived trust context * (`guardianExternalUserId`): the info read is keyed on that address so it * matches the gateway's guardian binding. When the context carries no guardian * address (desktop / native turns), fall back to the channel's guardian * contact, then any guardian: the guardian may be bound on a channel other * than the turn's source, and the construction-time warm populates both the * channel-filtered and unfiltered cache keys. Returns `"guardian.md"` when the * resolved guardian has no userFile. */ function resolveGuardianUserFile(trustContext: TrustContext): string | null { if (trustContext.guardianExternalUserId) { const guardianContact = findContactByAddress( trustContext.sourceChannel, trustContext.guardianExternalUserId, ); if (guardianContact) { return guardianContact.userFile ?? "guardian.md"; } } const guardian = peekGuardianForChannel(trustContext.sourceChannel) ?? peekAnyGuardian(); return guardian ? (guardianDeliveryUserFile(guardian) ?? "guardian.md") : null; } /** * Resolve the local INFO `userFile` for a gateway guardian delivery. The * gateway carries identity (channel + address) but not local INFO, so we join * the local contact by the guardian's channel address. Returns `undefined` when * no local contact matches. */ function guardianDeliveryUserFile( guardian: GuardianDelivery, ): string | undefined { const contact = findContactByAddress(guardian.channelType, guardian.address); return contact?.userFile ?? undefined; } /** Active guardian for a channel from the IO-free delivery cache. */ function peekGuardianForChannel( channelType: string, ): GuardianDelivery | undefined { const cached = peekCachedGuardianDelivery({ channelTypes: [channelType] }); return cached ? guardianForChannel(cached, channelType) : undefined; } /** First guardian across all channels from the IO-free delivery cache. */ function peekAnyGuardian(): GuardianDelivery | undefined { const cached = peekCachedGuardianDelivery(); return cached ? anyGuardian(cached) : undefined; } /** * Resolve the raw userFile filename for the current actor's contact. * Returns the validated filename (e.g. "alice.md") or null. */ function resolveUserFilename( trustContext: TrustContext | undefined, ): string | null { let filename: string | null = null; try { if (trustContext === undefined) { // Desktop / native — resolve via the gateway guardian delivery cache, // preferring the vellum-channel guardian, then any guardian. const guardian = peekGuardianForChannel("vellum") ?? peekAnyGuardian(); if (guardian) { filename = guardianDeliveryUserFile(guardian) ?? "guardian.md"; } } else if (trustContext.requesterExternalUserId) { // Channel-routed request — look up contact by channel identity const contactWithChannels = findContactByAddress( trustContext.sourceChannel, trustContext.requesterExternalUserId, ); if (contactWithChannels?.userFile) { filename = contactWithChannels.userFile; } else if (trustContext.trustClass === "guardian") { // Managed desktop: the JWT principal ID used as requesterExternalUserId // may differ from the contact channel's address (they are separate // identity concepts) — OR the guardian's contact row exists but carries // no explicit userFile yet (the normal post-hatch state, since the // guardian contact is created with a null userFile and onboarding // writes the file on disk, not the column). Either way, read the // guardian's user file keyed by the verdict-bound guardian identity so // the onboarding profile (written to guardian.md) loads instead of // falling through to users/default.md. filename = resolveGuardianUserFile(trustContext); } } else if (trustContext.trustClass === "guardian") { // Guardian-trust turn carrying no requester identity — background and // scheduled turns (heartbeat, scheduled pulses) run under the guardian // trust class but have no per-actor address to look up. Resolve the // guardian's user file so they load the same persona as a foreground // guardian turn instead of falling back to users/default.md. filename = resolveGuardianUserFile(trustContext); } } catch (err) { // Contacts table may be absent — happens during early bootstrap // before migrations run, in CLI smoke commands that don't touch // the DB, and in tests that build the system prompt without first // initializing a schema. Treat the same as "no guardian found" // so callers fall back to `users/default.md`. Mirrors the same // try/catch pattern used by `renderConnectedServices()` around // `listConnections()`. log.debug( { err: err instanceof Error ? err.message : String(err) }, "Contacts lookup failed during persona resolution; treating as no guardian", ); return null; } // Validate basename to prevent path traversal if (filename) { if ( basename(filename) !== filename || filename === ".." || filename === "." ) { log.warn( { userFile: filename }, "Contact userFile contains path traversal; ignoring", ); return null; } return filename; } return null; } /** * Resolve the absolute on-disk path to the guardian's per-user persona * file (e.g. `/users/alice.md`). Returns `null` when no * guardian is resolvable (no guardian contact, or its `userFile` is * unusable / fails basename validation). * * This does not check whether the file exists — it only resolves the * path. Callers use it alongside `ensureGuardianPersonaFile` to open * or scaffold the file. */ export function resolveGuardianPersonaPath(): string | null { const filename = resolveUserFilename(undefined); if (!filename) { return null; } return join(getWorkspaceDir(), "users", filename); } /** * Resolve a short slug identifying the current user, derived from * their contact's userFile. Used to scope per-user workspace directories * (e.g. journal/{slug}/). Returns null when no user is identified. */ export function resolveUserSlug( trustContext: TrustContext | undefined, ): string | null { const filename = resolveUserFilename(trustContext); if (!filename) { return null; } return filename.endsWith(".md") ? filename.slice(0, -3) : filename; } // ── User persona ─────────────────────────────────────────────────── /** * Resolve the per-user persona file for the current actor. * * - If `trustContext` is undefined (desktop/native), looks up the guardian * contact and reads their user file. * - If `trustContext` is defined and carries a `requesterExternalUserId`, * looks up the contact by channel + external user ID. * - Falls back to `users/default.md` when no contact is found or the * contact has no `userFile` set. * - Logs a debug warning when a contact's `userFile` is set but the * corresponding file is missing on disk. */ export function resolveUserPersona( trustContext: TrustContext | undefined, ): string | null { const usersDir = join(getWorkspaceDir(), "users"); const defaultPath = join(usersDir, "default.md"); const filename = resolveUserFilename(trustContext); if (filename) { const filePath = join(usersDir, filename); if (existsSync(filePath)) { return readPersonaFile(filePath); } log.debug( { userFile: filename }, "Contact has userFile set but file is missing on disk; falling back to default.md", ); } // Fall back to default.md return readPersonaFile(defaultPath); } // ── Channel persona ──────────────────────────────────────────────── /** * Resolve the per-channel persona file based on channel capabilities. * * Reads from `channels/.md` in the workspace directory. * Defaults to `"vellum"` when no channel capabilities are provided. * Returns null if the channel file does not exist. */ export function resolveChannelPersona( channelCapabilities: ChannelCapabilities | undefined, ): string | null { const channel = channelCapabilities?.channel ?? "vellum"; const filePath = join(getWorkspaceDir(), "channels", channel + ".md"); return readPersonaFile(filePath); } // ── Combined resolver ────────────────────────────────────────────── /** * Resolve both user and channel persona context in a single call. */ export function resolvePersonaContext( trustContext: TrustContext | undefined, channelCapabilities: ChannelCapabilities | undefined, ): PersonaContext { return { userPersona: resolveUserPersona(trustContext), userSlug: resolveUserSlug(trustContext), channelPersona: resolveChannelPersona(channelCapabilities), }; } // ── Guardian convenience ─────────────────────────────────────────── /** * Resolve the guardian's user persona. * * This is a convenience wrapper for background subsystems that need * the guardian's persona without a full trust context. Passing * `undefined` triggers the guardian lookup path in `resolveUserPersona`. */ export function resolveGuardianPersona(): string | null { return resolveUserPersona(undefined); } /** * Resolve the guardian's user persona strictly from their own * `users/.md` file, with NO fallback to `users/default.md`. * * Returns `null` when no guardian contact is resolvable, the * guardian's userFile is unset, or the file is missing / empty. * * Used by callers that derive guardian-specific attributes (name, * pronouns) where `default.md` content would incorrectly override an * intentional caller-supplied fallback such as `Contact.displayName`. * System-prompt callers that want the default.md fallback should * continue to use `resolveGuardianPersona`. */ export function resolveGuardianPersonaStrict(): string | null { const filename = resolveUserFilename(undefined); if (!filename) { return null; } const filePath = join(getWorkspaceDir(), "users", filename); if (!existsSync(filePath)) { return null; } return readPersonaFile(filePath); } /** * Write the guardian persona template scaffold to `users/` * when the file does not yet exist. No-op when the file already * exists (safe against clobbering user edits). * * @param userFile - A filename (not a bare slug), matching the shape * of `Contact.userFile` — a basename with a `.md` suffix * (e.g. `"alice.md"`). The path traversal guard rejects values that * are not a clean basename. * * Creates the parent `users/` directory if missing. */ export function ensureGuardianPersonaFile(userFile: string): void { if ( basename(userFile) !== userFile || userFile === ".." || userFile === "." ) { log.warn( { userFile }, "Guardian persona userFile contains path traversal; refusing to write", ); return; } const filePath = join(getWorkspaceDir(), "users", userFile); if (existsSync(filePath)) { return; } mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, GUARDIAN_PERSONA_TEMPLATE, "utf-8"); log.debug({ path: filePath }, "Wrote guardian persona scaffold"); } /** * Return `true` when the persona file at `filePath` has been edited by * the user (its stripped content differs from the bare scaffold * template). Returns `false` when the file is missing, unreadable, * empty after stripping, or byte-identical to the template after * stripping comment lines. * * Used by the vbundle importer to decide whether a legacy * `prompts/USER.md` entry may safely overwrite `users/.md`. */ export function isGuardianPersonaCustomized(filePath: string): boolean { if (!existsSync(filePath)) { return false; } let content: string; try { content = readFileSync(filePath, "utf-8"); } catch (err) { log.warn( { err, path: filePath }, "Failed to read persona file while checking customization", ); return false; } const stripped = stripCommentLines(content); if (stripped.length === 0) { return false; } const templateStripped = stripCommentLines(GUARDIAN_PERSONA_TEMPLATE); return stripped !== templateStripped; } // ── Onboarding section writer ──────────────────────────────────── const ONBOARDING_HEADING = "## Onboarding Context"; /** * Build the markdown section content for the onboarding context. * Omits bullet lines where the value is empty/absent. */ function buildOnboardingSection(normalized: NormalizedOnboarding): string { const lines: string[] = [ONBOARDING_HEADING, ""]; if (normalized.preferredName) { lines.push(`- **Preferred name:** ${normalized.preferredName}`); } if (normalized.occupation) { lines.push(`- **Role:** ${normalized.occupation}`); } if (normalized.commonWork.length > 0) { lines.push(`- **Common work:** ${normalized.commonWork.join("; ")}`); } if (normalized.dailyTools.length > 0) { lines.push(`- **Daily tools:** ${normalized.dailyTools.join(", ")}`); } if (normalized.researchFindings?.length) { lines.push( "- **Research findings** (surfaced during onboarding, confirmed by the user):", ); for (const finding of normalized.researchFindings) { lines.push(` - ${finding}`); } } lines.push(""); return lines.join("\n"); } /** * Resolve the write target for the onboarding section using the * fallback chain: guardian persona → `users/default.md` → `USER.md`. */ function resolveOnboardingWriteTarget(): string { const guardianPath = resolveGuardianPersonaPath(); if (guardianPath) { return guardianPath; } const defaultUserPath = join(getWorkspaceDir(), "users", "default.md"); if (existsSync(defaultUserPath)) { return defaultUserPath; } return getWorkspacePromptPath("USER.md"); } /** * Write a managed `## Onboarding Context` section to the guardian persona * file (or fallback target). Idempotent: replaces the section in-place if * it already exists, appends if not, and creates the file when missing. * * Never throws — logs a warning on failure (fire-and-forget pattern). */ export function writeOnboardingSection(normalized: NormalizedOnboarding): void { try { const targetPath = resolveOnboardingWriteTarget(); const section = buildOnboardingSection(normalized); let content: string; if (existsSync(targetPath)) { content = readFileSync(targetPath, "utf-8"); } else { // Create parent directories and start with a header mkdirSync(dirname(targetPath), { recursive: true }); content = "# User Profile\n\n"; } // Replace existing section or append const headingIndex = content.indexOf(ONBOARDING_HEADING); if (headingIndex !== -1) { // Find the end of the section: next `## ` heading or EOF const afterHeading = content.indexOf("\n", headingIndex); const rest = afterHeading !== -1 ? content.slice(afterHeading + 1) : ""; const nextHeadingMatch = rest.match(/^## /m); const before = content.slice(0, headingIndex); const after = nextHeadingMatch ? rest.slice(nextHeadingMatch.index!) : ""; content = before + section + after; } else { // Append after a blank line (ensure trailing newline first) if (!content.endsWith("\n")) { content += "\n"; } if (!content.endsWith("\n\n")) { content += "\n"; } content += section; } writeFileSync(targetPath, content, "utf-8"); log.debug({ path: targetPath }, "Wrote onboarding section to persona file"); } catch (err) { log.warn({ err }, "Failed to write onboarding section to persona file"); } }