evOo-local/apps/web/src/lib/user-profile-harvest.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

126 lines
4.6 KiB
TypeScript

import type { Me } from "@rakazo/contracts";
import { localTimezone } from "./local-timezone";
import { resolveUiLocale } from "./ui-locale";
import {
inferHelpVibeHints,
mergeHelpVibeIntoMemory,
type OnboardingFocusId,
type UserHelpVibeHints,
} from "./user-help-vibe";
import { mergeRelationshipContractIntoMemory } from "./user-relationship-contract";
export interface UserProfileSignals {
name?: string;
email?: string;
timezone: string;
locale: string;
uiLocale?: string;
platform?: string;
languages: string[];
defaultProvider?: string | null;
defaultModel?: string | null;
computerHost?: string | null;
harvestedAt: string;
}
/** Local, respectful signals available during web onboarding (no new permissions). */
export function collectLocalProfileSignals(
me?: Pick<Me, "name" | "email" | "defaultProvider" | "defaultModel" | "computerHost">,
): UserProfileSignals {
const locale = typeof navigator !== "undefined" && navigator.language ? navigator.language : "en";
const languages =
typeof navigator !== "undefined" && navigator.languages?.length
? [...navigator.languages]
: [locale];
return {
name: me?.name?.trim() || undefined,
email: me?.email?.trim() || undefined,
timezone: localTimezone(),
locale,
uiLocale: resolveUiLocale(),
platform: typeof navigator !== "undefined" ? navigator.platform : undefined,
languages,
defaultProvider: me?.defaultProvider ?? null,
defaultModel: me?.defaultModel ?? null,
computerHost: me?.computerHost ?? null,
harvestedAt: new Date().toISOString(),
};
}
const PROFILE_SECTION = "## User profile (auto-harvested)";
export function mergeUserProfileIntoMemory(existing: string, signals: UserProfileSignals): string {
const block = formatProfileBlock(signals);
const marker = PROFILE_SECTION;
const start = existing.indexOf(marker);
if (start === -1) {
const trimmed = existing.trimEnd();
const prefix = trimmed.length ? `${trimmed}\n\n` : "";
return `${prefix}${marker}\n\n${block}\n`;
}
const rest = existing.slice(start + marker.length);
const nextSection = rest.search(/\n## /);
const tail = nextSection === -1 ? "" : rest.slice(nextSection);
return `${existing.slice(0, start).trimEnd()}\n\n${marker}\n\n${block}${tail}`;
}
function formatProfileBlock(signals: UserProfileSignals): string {
const lines = [
`- **Updated:** ${signals.harvestedAt}`,
signals.name ? `- **Name:** ${signals.name}` : null,
signals.email ? `- **Email:** ${signals.email}` : null,
`- **Timezone:** ${signals.timezone}`,
`- **Locale:** ${signals.locale}`,
signals.uiLocale ? `- **UI locale:** ${signals.uiLocale}` : null,
signals.languages.length ? `- **Languages:** ${signals.languages.join(", ")}` : null,
signals.platform ? `- **Platform:** ${signals.platform}` : null,
signals.defaultProvider || signals.defaultModel
? `- **Default model:** ${signals.defaultProvider ?? "—"} / ${signals.defaultModel ?? "—"}`
: null,
signals.computerHost ? `- **Computer host:** ${signals.computerHost}` : null,
"",
"_Seeded during onboarding from local/browser signals. Extend manually in Memory settings._",
];
return lines.filter((line) => line !== null).join("\n");
}
export type MemoryListClient = {
memory: {
list: (input: {
scope?: "user" | "bot";
}) => Promise<Array<{ id: string; path: string; content: string }>>;
update: (input: { documentId: string; content: string }) => Promise<unknown>;
};
};
/** Profile + optional help/vibe + Oo relationship product contract → user MEMORY.md. */
export function mergeFullUserProfileMemory(
existing: string,
signals: UserProfileSignals,
helpVibe?: UserHelpVibeHints,
): string {
let content = mergeUserProfileIntoMemory(existing, signals);
if (helpVibe) {
content = mergeHelpVibeIntoMemory(content, helpVibe);
}
return mergeRelationshipContractIntoMemory(content);
}
/** Persist harvest + help/vibe hints into space user `MEMORY.md` (existing memory system). */
export async function persistUserProfileHarvest(
client: MemoryListClient,
signals: UserProfileSignals,
options?: { focusChoiceId?: OnboardingFocusId },
): Promise<UserHelpVibeHints> {
const helpVibe = inferHelpVibeHints(signals, options);
const docs = await client.memory.list({ scope: "user" });
const memoryDoc = docs.find((doc) => doc.path === "MEMORY.md") ?? docs[0];
if (!memoryDoc) return helpVibe;
const content = mergeFullUserProfileMemory(memoryDoc.content, signals, helpVibe);
if (content !== memoryDoc.content) {
await client.memory.update({ documentId: memoryDoc.id, content });
}
return helpVibe;
}