Files
podman/backend/src/memory/user-learning.ts
T
Yahya Alhinai 454fdab13b chore(box): snapshot in-progress auth + user-learning WIP before deploy
Captures uncommitted work present on the live droplet so origin/main can be integrated and the new control toolbar deployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:51:21 +00:00

283 lines
10 KiB
TypeScript

import type { Collision, EngineerContext, HermesJob, Pod, UserLearningProfile } from '@podman/shared';
import { getDb, type UserPodContextDoc } from './db.js';
interface EngineerStateDoc {
podId: string;
name: string;
changedFiles?: string[];
branch?: string | null;
recentCommit?: string | null;
gitUpdatedAt?: Date | string;
}
interface ConversationNoteDoc {
podId: string;
identity?: string;
kind?: string;
note?: string;
createdAt?: string;
}
function clean(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function toIso(value: unknown): string {
if (value instanceof Date) return value.toISOString();
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return new Date(value).toISOString();
return new Date().toISOString();
}
function uniq(values: Array<string | undefined>): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const value of values) {
const item = value?.trim();
if (!item) continue;
const key = item.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
function top(values: string[], limit: number): string[] {
const counts = new Map<string, number>();
for (const value of values) {
const item = value.trim();
if (!item) continue;
counts.set(item, (counts.get(item) ?? 0) + 1);
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, limit)
.map(([value]) => value);
}
function lowerRegex(values: string[]): RegExp[] {
return values.map((value) => new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i'));
}
function noteGoals(notes: ConversationNoteDoc[]): string[] {
const goalNotes = notes
.filter((note) => /goal|plan|todo|next|preference|prefers|likes|wants|needs/i.test(`${note.kind} ${note.note}`))
.map((note) => clean(note.note)?.slice(0, 180))
.filter(Boolean) as string[];
return uniq(goalNotes).slice(0, 8);
}
function inferCollaborationStyle(input: {
contexts: UserPodContextDoc[];
observations: EngineerContext[];
collisions: Collision[];
outcomes: Array<{ accepted?: boolean; wasRealCollision?: boolean }>;
notes: ConversationNoteDoc[];
jobs: HermesJob[];
}): string[] {
const style: string[] = [];
const actions = input.contexts.map((ctx) => ctx.action);
const pods = new Set(input.contexts.map((ctx) => ctx.podId));
if (pods.size > 1) style.push(`Works across ${pods.size} pods and carries context between rooms.`);
if (actions.filter((action) => action === 'joined_pod').length >= 3)
style.push('Frequently joins live rooms and collaborates synchronously.');
if (input.collisions.length > 0)
style.push(`Has been involved in ${input.collisions.length} coordination risk signals.`);
const accepted = input.outcomes.filter((outcome) => outcome.accepted).length;
if (accepted > 0) style.push(`Accepts Hermes help when useful (${accepted} accepted outcomes).`);
if (input.jobs.length > 0) style.push(`Delegates complex work to Hermes (${input.jobs.length} jobs).`);
if (input.notes.some((note) => /concise|short|brief/i.test(note.note ?? '')))
style.push('Prefers concise coordination.');
return style.slice(0, 8);
}
function inferWorkingStyle(input: {
observations: EngineerContext[];
gitStates: EngineerStateDoc[];
}): string[] {
const style: string[] = [];
const modes = top(input.observations.map((obs) => obs.mode ?? '').filter(Boolean), 2);
const activities = top(input.observations.map((obs) => obs.activity ?? '').filter(Boolean), 5);
const files = top(
[
...input.observations.map((obs) => obs.currentFile ?? ''),
...input.gitStates.flatMap((state) => state.changedFiles ?? []),
].filter(Boolean),
6,
);
if (modes.length) style.push(`Usually seen in ${modes.join(' and ')} mode.`);
if (activities.length) style.push(`Common work patterns: ${activities.join(', ')}.`);
if (files.length) style.push(`Frequently touches ${files.join(', ')}.`);
return style.slice(0, 8);
}
function inferKnowledge(input: {
observations: EngineerContext[];
gitStates: EngineerStateDoc[];
notes: ConversationNoteDoc[];
}): string[] {
const topics = top(
[
...input.observations.map((obs) => obs.researchTopic ?? ''),
...input.observations.map((obs) => obs.researchSource ?? ''),
...input.observations.map((obs) => obs.currentSymbol ?? ''),
...input.gitStates.flatMap((state) => state.changedFiles ?? []),
...input.notes
.filter((note) => /learn|knows|expert|worked on|decision/i.test(note.note ?? ''))
.map((note) => note.note?.slice(0, 120) ?? ''),
].filter(Boolean),
10,
);
return topics;
}
export async function buildUserLearningProfile(clerkUserId: string): Promise<UserLearningProfile | null> {
const db = await getDb();
const contexts = await db
.collection<UserPodContextDoc>('user_pod_context')
.find({ clerkUserId }, { projection: { _id: 0 } })
.sort({ observedAt: -1 })
.limit(500)
.toArray();
if (!contexts.length) return null;
const latest = contexts[0];
const identities = uniq([
...contexts.map((ctx) => ctx.memberName),
...contexts.map((ctx) => clean(ctx.metadata?.identity)),
...contexts.map((ctx) => clean(ctx.metadata?.email)),
]);
const email = clean(latest?.metadata?.email) ?? identities.find((item) => item.includes('@'));
const displayName = latest?.memberName ?? identities.find((item) => !item.includes('@')) ?? email;
const imageUrl = clean(latest?.metadata?.imageUrl);
const identityRegex = lowerRegex(identities);
const podIds = uniq(contexts.map((ctx) => ctx.podId));
const [pods, observations, gitStates, collisions, outcomes, notes, jobs] = await Promise.all([
db
.collection<Pod>('pods')
.find({ id: { $in: podIds } }, { projection: { _id: 0 } })
.toArray(),
db
.collection<EngineerContext>('observations')
.find({ engineerId: { $in: identities } }, { projection: { _id: 0, screenshotDataUrl: 0 } })
.sort({ observedAt: -1 })
.limit(500)
.toArray(),
db
.collection<EngineerStateDoc>('engineer_states')
.find({ name: { $in: identityRegex } }, { projection: { _id: 0 } })
.limit(100)
.toArray(),
db
.collection<Collision>('collisions')
.find({ engineers: { $in: identities } }, { projection: { _id: 0, embedding: 0 } })
.sort({ detectedAt: -1 })
.limit(100)
.toArray(),
db
.collection<{ podId: string; accepted?: boolean; wasRealCollision?: boolean }>('outcomes')
.find({ podId: { $in: podIds } }, { projection: { _id: 0 } })
.sort({ recordedAt: -1 })
.limit(100)
.toArray(),
db
.collection<ConversationNoteDoc>('conversation_notes')
.find({ identity: { $in: identities } }, { projection: { _id: 0 } })
.sort({ createdAt: -1 })
.limit(100)
.toArray(),
db
.collection<HermesJob>('hermes_jobs')
.find({ identity: { $in: identities } }, { projection: { _id: 0 } })
.sort({ createdAt: -1 })
.limit(100)
.toArray(),
]);
const podById = new Map(pods.map((pod) => [pod.id, pod]));
const podsSummary = podIds.map((podId) => {
const rows = contexts.filter((ctx) => ctx.podId === podId);
const sorted = [...rows].sort((a, b) => Date.parse(a.observedAt) - Date.parse(b.observedAt));
return {
podId,
podName: podById.get(podId)?.name,
visits: rows.filter((row) => row.action === 'joined_pod').length,
actions: top(rows.map((row) => row.action), 6),
firstSeenAt: toIso(sorted[0]?.observedAt),
lastSeenAt: toIso(sorted.at(-1)?.observedAt),
};
});
const profile: UserLearningProfile = {
clerkUserId,
displayName,
email,
imageUrl,
identities,
pods: podsSummary,
recentWork: observations.slice(0, 20).map((obs) => ({
podId: obs.podId,
file: obs.currentFile,
activity: obs.activity ?? obs.researchTopic,
at: obs.observedAt,
})),
collaborationStyle: inferCollaborationStyle({ contexts, observations, collisions, outcomes, notes, jobs }),
workingStyle: inferWorkingStyle({ observations, gitStates }),
goals: noteGoals(notes),
knowledge: inferKnowledge({ observations, gitStates, notes }),
counts: {
podActions: contexts.length,
observations: observations.length,
gitStates: gitStates.length,
collisionsInvolved: collisions.length,
outcomes: outcomes.length,
conversationNotes: notes.length,
hermesJobs: jobs.length,
},
updatedAt: new Date().toISOString(),
};
await db
.collection<UserLearningProfile>('user_learning_profiles')
.updateOne({ clerkUserId }, { $set: profile }, { upsert: true });
return profile;
}
export async function refreshUserLearningProfiles(): Promise<UserLearningProfile[]> {
const db = await getDb();
const ids = await db.collection<UserPodContextDoc>('user_pod_context').distinct('clerkUserId');
const profiles = await Promise.all(ids.map((id) => buildUserLearningProfile(String(id))));
return profiles.filter(Boolean) as UserLearningProfile[];
}
export async function getUserLearningProfileByIdentity(identity: string): Promise<UserLearningProfile | null> {
const db = await getDb();
const direct = await db
.collection<UserLearningProfile>('user_learning_profiles')
.findOne({ identities: { $regex: `^${identity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' } }, { projection: { _id: 0 } });
if (direct) return direct;
const context = await db
.collection<UserPodContextDoc>('user_pod_context')
.findOne(
{
$or: [
{ memberName: { $regex: `^${identity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' } },
{ 'metadata.identity': { $regex: `^${identity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' } },
],
},
{ sort: { observedAt: -1 }, projection: { _id: 0 } },
);
return context ? buildUserLearningProfile(context.clerkUserId) : null;
}
export async function listUserLearningProfiles(): Promise<UserLearningProfile[]> {
const db = await getDb();
return db
.collection<UserLearningProfile>('user_learning_profiles')
.find({}, { projection: { _id: 0 } })
.sort({ updatedAt: -1 })
.toArray();
}