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>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/express": "^2.1.32",
|
||||
"@google/genai": "^2.10.0",
|
||||
"@livekit/rtc-node": "^0.13.29",
|
||||
"@podman/shared": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { clerkMiddleware, getAuth } from '@clerk/express';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { env } from './env.js';
|
||||
|
||||
export interface RequestUserContext {
|
||||
clerkUserId: string;
|
||||
}
|
||||
|
||||
export const clerkAuthMiddleware: RequestHandler = env.CLERK_SECRET_KEY
|
||||
? clerkMiddleware()
|
||||
: (_req, _res, next) => next();
|
||||
|
||||
export function requestUser(req: Request): RequestUserContext | null {
|
||||
if (!env.CLERK_SECRET_KEY) return null;
|
||||
try {
|
||||
const auth = getAuth(req);
|
||||
return auth.isAuthenticated && auth.userId ? { clerkUserId: auth.userId } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function requireRequestUser(req: Request, res: Response): RequestUserContext | null {
|
||||
const user = requestUser(req);
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'sign in required' });
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
+4
-1
@@ -1,4 +1,6 @@
|
||||
import 'dotenv/config';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config({ path: ['.env.local', '../.env.local', '.env', '../.env'] });
|
||||
|
||||
function req(name: string): string {
|
||||
const v = process.env[name];
|
||||
@@ -42,6 +44,7 @@ export const env = {
|
||||
VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'),
|
||||
// Server
|
||||
PORT: Number(opt('PORT', '8787')),
|
||||
CLERK_SECRET_KEY: opt('CLERK_SECRET_KEY'),
|
||||
NUDGE_COOLDOWN_MS: Number(opt('NUDGE_COOLDOWN_MS', '180000')),
|
||||
RESEARCH_OVERLAP_THRESHOLD: Number(opt('RESEARCH_OVERLAP_THRESHOLD', '0.6')),
|
||||
INTERNAL_AGENT_TOKEN: opt('INTERNAL_AGENT_TOKEN'),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { getDb } from '../memory/db.js';
|
||||
import { getMemberWorkHistory } from '../activity/member-history.js';
|
||||
import {
|
||||
buildUserLearningProfile,
|
||||
getUserLearningProfileByIdentity,
|
||||
} from '../memory/user-learning.js';
|
||||
|
||||
const DEFAULT_LIMIT = 8;
|
||||
|
||||
@@ -10,7 +14,8 @@ function sinceIso(hours: number): string {
|
||||
export async function getLiveConversationContext(podId: string, identity: string) {
|
||||
const db = await getDb();
|
||||
const since = sinceIso(12);
|
||||
const [pod, history, gitState, collisions, interventions, outcomes] = await Promise.all([
|
||||
const [pod, history, gitState, collisions, interventions, outcomes, userLearningProfile] =
|
||||
await Promise.all([
|
||||
db.collection('pods').findOne({ id: podId }, { projection: { _id: 0 } }),
|
||||
getMemberWorkHistory(podId, identity, { hours: 24, limit: 30 }).catch(() => null),
|
||||
db.collection('engineer_states').findOne(
|
||||
@@ -44,6 +49,7 @@ export async function getLiveConversationContext(podId: string, identity: string
|
||||
.sort({ recordedAt: -1 })
|
||||
.limit(DEFAULT_LIMIT)
|
||||
.toArray(),
|
||||
getUserLearningProfileByIdentity(identity).catch(() => null),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -51,6 +57,7 @@ export async function getLiveConversationContext(podId: string, identity: string
|
||||
identity,
|
||||
generatedAt: new Date().toISOString(),
|
||||
currentGitState: gitState,
|
||||
userLearningProfile,
|
||||
memberHistory: history,
|
||||
recentCollisions: collisions,
|
||||
recentInterventions: interventions,
|
||||
@@ -76,5 +83,13 @@ export async function recordLiveConversationNote(input: {
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await (await getDb()).collection('conversation_notes').insertOne(doc);
|
||||
if (input.identity) {
|
||||
const profile = await getUserLearningProfileByIdentity(input.identity);
|
||||
if (profile) {
|
||||
void buildUserLearningProfile(profile.clerkUserId).catch((err) =>
|
||||
console.warn(`[memory] note user learning refresh failed: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
return { ...doc, _id: undefined };
|
||||
}
|
||||
|
||||
@@ -56,6 +56,17 @@ export interface PodCollections {
|
||||
suppressions: Collection<SuppressionDoc>;
|
||||
}
|
||||
|
||||
export interface UserPodContextDoc {
|
||||
id: string;
|
||||
clerkUserId: string;
|
||||
podId: string;
|
||||
memberName?: string;
|
||||
action: string;
|
||||
source: 'clerk';
|
||||
observedAt: string;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
}
|
||||
|
||||
export async function collections(): Promise<PodCollections> {
|
||||
const db = await getDb();
|
||||
return {
|
||||
@@ -142,6 +153,26 @@ export async function initMemory(): Promise<void> {
|
||||
'hermes_job_events.job',
|
||||
() => db.collection('hermes_job_events').createIndex({ jobId: 1, createdAt: 1 }),
|
||||
],
|
||||
[
|
||||
'user_pod_context.user',
|
||||
() => db.collection('user_pod_context').createIndex({ clerkUserId: 1, observedAt: -1 }),
|
||||
],
|
||||
[
|
||||
'user_pod_context.pod',
|
||||
() => db.collection('user_pod_context').createIndex({ podId: 1, observedAt: -1 }),
|
||||
],
|
||||
[
|
||||
'user_learning_profiles.user',
|
||||
() => db.collection('user_learning_profiles').createIndex({ clerkUserId: 1 }, { unique: true }),
|
||||
],
|
||||
[
|
||||
'user_learning_profiles.updated',
|
||||
() => db.collection('user_learning_profiles').createIndex({ updatedAt: -1 }),
|
||||
],
|
||||
[
|
||||
'conversation_notes.identity',
|
||||
() => db.collection('conversation_notes').createIndex({ identity: 1, createdAt: -1 }),
|
||||
],
|
||||
];
|
||||
for (const [name, make] of indexes) {
|
||||
try {
|
||||
|
||||
@@ -5,16 +5,19 @@ import type {
|
||||
InterventionOutcome,
|
||||
InterventionStatus,
|
||||
} from '@podman/shared';
|
||||
import { collections, getGitStates } from './db.js';
|
||||
import { collections, getDb, getGitStates, type UserPodContextDoc } from './db.js';
|
||||
import { enrichCollisionMemory } from './vectors.js';
|
||||
import { buildUserLearningProfile } from './user-learning.js';
|
||||
|
||||
function comparableFile(raw?: string): string {
|
||||
return (raw ?? '')
|
||||
.trim()
|
||||
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.toLowerCase() ?? '';
|
||||
return (
|
||||
(raw ?? '')
|
||||
.trim()
|
||||
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.toLowerCase() ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +41,30 @@ export async function recordObservation(ctx: EngineerContext): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
export async function recordUserPodContext(input: {
|
||||
clerkUserId: string;
|
||||
podId: string;
|
||||
memberName?: string;
|
||||
action: string;
|
||||
metadata?: UserPodContextDoc['metadata'];
|
||||
}): Promise<void> {
|
||||
await persist('user pod context', async () =>
|
||||
(await getDb()).collection<UserPodContextDoc>('user_pod_context').insertOne({
|
||||
id: `upc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
clerkUserId: input.clerkUserId,
|
||||
podId: input.podId,
|
||||
memberName: input.memberName,
|
||||
action: input.action,
|
||||
source: 'clerk',
|
||||
observedAt: new Date().toISOString(),
|
||||
metadata: input.metadata,
|
||||
}),
|
||||
);
|
||||
void buildUserLearningProfile(input.clerkUserId).catch((err) =>
|
||||
console.warn(`[memory] user learning refresh failed: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function recordCollision(collision: Collision): Promise<void> {
|
||||
await persist('collision', async () =>
|
||||
(await collections()).collisions.insertOne(await enrichCollisionMemory(collision)),
|
||||
@@ -163,12 +190,31 @@ export async function recordOutcome(outcome: InterventionOutcome): Promise<void>
|
||||
/** Document counts per collection — used by the /api/memory/stats endpoint. */
|
||||
export async function memoryStats(): Promise<Record<string, number>> {
|
||||
const c = await collections();
|
||||
const [observations, collisions, interventions, outcomes, suppressions] = await Promise.all([
|
||||
const db = await getDb();
|
||||
const [
|
||||
observations,
|
||||
collisions,
|
||||
interventions,
|
||||
outcomes,
|
||||
suppressions,
|
||||
userPodContext,
|
||||
userLearningProfiles,
|
||||
] = await Promise.all([
|
||||
c.observations.estimatedDocumentCount(),
|
||||
c.collisions.estimatedDocumentCount(),
|
||||
c.interventions.estimatedDocumentCount(),
|
||||
c.outcomes.estimatedDocumentCount(),
|
||||
c.suppressions.estimatedDocumentCount(),
|
||||
db.collection<UserPodContextDoc>('user_pod_context').estimatedDocumentCount(),
|
||||
db.collection('user_learning_profiles').estimatedDocumentCount(),
|
||||
]);
|
||||
return { observations, collisions, interventions, outcomes, suppressions };
|
||||
return {
|
||||
observations,
|
||||
collisions,
|
||||
interventions,
|
||||
outcomes,
|
||||
suppressions,
|
||||
userPodContext,
|
||||
userLearningProfiles,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Pod, PodInput } from '@podman/shared';
|
||||
import { collections } from '../memory/db.js';
|
||||
import { collections, getDb, type UserPodContextDoc } from '../memory/db.js';
|
||||
|
||||
const NO_ID = { projection: { _id: 0 } } as const;
|
||||
|
||||
@@ -59,14 +59,67 @@ function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function regexExact(value: string): RegExp {
|
||||
return new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i');
|
||||
}
|
||||
|
||||
function profileString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function matchesMember(doc: UserPodContextDoc, member: string): boolean {
|
||||
const key = member.trim().toLowerCase();
|
||||
return [doc.memberName, doc.metadata?.identity, doc.metadata?.email]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).trim().toLowerCase() === key);
|
||||
}
|
||||
|
||||
async function hydrateMemberProfiles(pod: Pod): Promise<Pod> {
|
||||
if (!pod.members.length) return pod;
|
||||
const db = await getDb();
|
||||
const matchers = pod.members.map(regexExact);
|
||||
const docs = await db
|
||||
.collection<UserPodContextDoc>('user_pod_context')
|
||||
.find(
|
||||
{
|
||||
$or: [
|
||||
{ memberName: { $in: matchers } },
|
||||
{ 'metadata.identity': { $in: matchers } },
|
||||
{ 'metadata.email': { $in: matchers } },
|
||||
],
|
||||
},
|
||||
{ projection: { _id: 0 } },
|
||||
)
|
||||
.sort({ observedAt: -1 })
|
||||
.limit(500)
|
||||
.toArray();
|
||||
const memberProfiles: NonNullable<Pod['memberProfiles']> = {};
|
||||
for (const member of pod.members) {
|
||||
const doc = docs.find((row) => matchesMember(row, member));
|
||||
const imageUrl = profileString(doc?.metadata?.imageUrl);
|
||||
if (!doc || !imageUrl) continue;
|
||||
memberProfiles[member] = {
|
||||
displayName: doc.memberName ?? member,
|
||||
email: profileString(doc.metadata?.email),
|
||||
imageUrl,
|
||||
};
|
||||
}
|
||||
return Object.keys(memberProfiles).length ? { ...pod, memberProfiles } : pod;
|
||||
}
|
||||
|
||||
async function hydratePods(pods: Pod[]): Promise<Pod[]> {
|
||||
return Promise.all(pods.map((pod) => hydrateMemberProfiles(pod)));
|
||||
}
|
||||
|
||||
export async function listPods(): Promise<Pod[]> {
|
||||
const c = await collections();
|
||||
return c.pods.find({}, NO_ID).sort({ createdAt: 1 }).toArray();
|
||||
return hydratePods(await c.pods.find({}, NO_ID).sort({ createdAt: 1 }).toArray());
|
||||
}
|
||||
|
||||
export async function getPod(id: string): Promise<Pod | null> {
|
||||
const c = await collections();
|
||||
return c.pods.findOne({ id }, NO_ID);
|
||||
const pod = await c.pods.findOne({ id }, NO_ID);
|
||||
return pod ? hydrateMemberProfiles(pod) : null;
|
||||
}
|
||||
|
||||
export async function createPod(input: PodInput): Promise<Pod> {
|
||||
@@ -92,7 +145,7 @@ export async function createPod(input: PodInput): Promise<Pod> {
|
||||
};
|
||||
try {
|
||||
await c.pods.insertOne({ ...pod });
|
||||
return pod;
|
||||
return hydrateMemberProfiles(pod);
|
||||
} catch (err) {
|
||||
if (isDuplicateKey(err)) continue;
|
||||
throw err;
|
||||
@@ -119,7 +172,7 @@ export async function updatePod(id: string, patch: PodInput): Promise<Pod | null
|
||||
{ $set: set },
|
||||
{ returnDocument: 'after', projection: { _id: 0 } },
|
||||
);
|
||||
return updated ?? null;
|
||||
return updated ? hydrateMemberProfiles(updated) : null;
|
||||
}
|
||||
|
||||
export async function deletePod(id: string): Promise<boolean> {
|
||||
@@ -135,7 +188,7 @@ async function setMembers(id: string, members: string[]): Promise<Pod | null> {
|
||||
{ $set: { members, updatedAt: now() } },
|
||||
{ returnDocument: 'after', projection: { _id: 0 } },
|
||||
);
|
||||
return updated ?? null;
|
||||
return updated ? hydrateMemberProfiles(updated) : null;
|
||||
}
|
||||
|
||||
export async function addMember(id: string, rawName: unknown): Promise<Pod | null> {
|
||||
|
||||
+91
-7
@@ -12,7 +12,9 @@ import {
|
||||
recordOutcome,
|
||||
hasRecentInterventionForCollision,
|
||||
memoryStats,
|
||||
recordUserPodContext,
|
||||
} from './memory/store.js';
|
||||
import { clerkAuthMiddleware, requestUser } from './auth.js';
|
||||
import { closeMemory, initMemory } from './memory/db.js';
|
||||
import {
|
||||
listPods,
|
||||
@@ -40,6 +42,10 @@ import {
|
||||
getLiveConversationContext,
|
||||
recordLiveConversationNote,
|
||||
} from './live-conversation/context.js';
|
||||
import {
|
||||
listUserLearningProfiles,
|
||||
refreshUserLearningProfiles,
|
||||
} from './memory/user-learning.js';
|
||||
import {
|
||||
abortHermesJob,
|
||||
appendHermesJobEvent,
|
||||
@@ -60,6 +66,7 @@ import type {
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(clerkAuthMiddleware);
|
||||
app.get('/health', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
@@ -72,6 +79,10 @@ function suggestedAction(value: unknown): SuggestedActionKind {
|
||||
: 'ping_teammate';
|
||||
}
|
||||
|
||||
function stringMeta(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function hermesJobEventType(value: unknown): HermesJobEventType | null {
|
||||
return value === 'accepted' ||
|
||||
value === 'heartbeat' ||
|
||||
@@ -90,11 +101,16 @@ function hermesJobEventType(value: unknown): HermesJobEventType | null {
|
||||
app.post('/api/token', async (req, res) => {
|
||||
const { room, identity, name, githubLogin } = req.body ?? {};
|
||||
if (!room || !identity) return res.status(400).json({ error: 'room+identity required' });
|
||||
const user = requestUser(req);
|
||||
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||
identity,
|
||||
name,
|
||||
ttl: '4h',
|
||||
metadata: JSON.stringify({ githubLogin: githubLogin ?? name }),
|
||||
metadata: JSON.stringify({
|
||||
githubLogin: githubLogin ?? name,
|
||||
email: stringMeta(req.body?.profile?.email),
|
||||
imageUrl: stringMeta(req.body?.profile?.imageUrl),
|
||||
}),
|
||||
});
|
||||
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
|
||||
const agents = env.LIVEKIT_AGENT_NAME
|
||||
@@ -113,6 +129,19 @@ app.post('/api/token', async (req, res) => {
|
||||
departureTimeout: 20,
|
||||
agents,
|
||||
});
|
||||
if (user) {
|
||||
await recordUserPodContext({
|
||||
clerkUserId: user.clerkUserId,
|
||||
podId: String(room),
|
||||
memberName: typeof name === 'string' ? name : String(identity),
|
||||
action: 'joined_pod',
|
||||
metadata: {
|
||||
identity: String(identity),
|
||||
email: stringMeta(req.body?.profile?.email) ?? null,
|
||||
imageUrl: stringMeta(req.body?.profile?.imageUrl) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
res.json({ token: await at.toJwt(), url: env.LIVEKIT_URL });
|
||||
});
|
||||
|
||||
@@ -150,6 +179,22 @@ app.get('/api/memory/stats', async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/memory/users', async (_req, res) => {
|
||||
try {
|
||||
res.json(await listUserLearningProfiles());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/memory/users/refresh', async (_req, res) => {
|
||||
try {
|
||||
res.json({ profiles: await refreshUserLearningProfiles() });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Live presence: who is currently connected in each pod's LiveKit room.
|
||||
app.get('/api/presence', async (_req, res) => {
|
||||
try {
|
||||
@@ -170,7 +215,24 @@ app.get('/api/pods', async (_req, res) => {
|
||||
|
||||
app.post('/api/pods', async (req, res) => {
|
||||
try {
|
||||
res.status(201).json(await createPod(req.body ?? {}));
|
||||
const pod = await createPod(req.body ?? {});
|
||||
const user = requestUser(req);
|
||||
if (user) {
|
||||
await recordUserPodContext({
|
||||
clerkUserId: user.clerkUserId,
|
||||
podId: pod.id,
|
||||
memberName: stringMeta(req.body?.profile?.displayName),
|
||||
action: 'created_pod',
|
||||
metadata: {
|
||||
podName: pod.name,
|
||||
repo: pod.repo,
|
||||
identity: stringMeta(req.body?.profile?.displayName) ?? null,
|
||||
email: stringMeta(req.body?.profile?.email) ?? null,
|
||||
imageUrl: stringMeta(req.body?.profile?.imageUrl) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
res.status(201).json(await getPod(pod.id));
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: (e as Error).message });
|
||||
}
|
||||
@@ -186,6 +248,15 @@ app.patch('/api/pods/:id', async (req, res) => {
|
||||
try {
|
||||
const pod = await updatePod(req.params.id, req.body ?? {});
|
||||
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
||||
const user = requestUser(req);
|
||||
if (user) {
|
||||
await recordUserPodContext({
|
||||
clerkUserId: user.clerkUserId,
|
||||
podId: pod.id,
|
||||
action: 'updated_pod',
|
||||
metadata: { podName: pod.name, repo: pod.repo },
|
||||
});
|
||||
}
|
||||
res.json(pod);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: (e as Error).message });
|
||||
@@ -203,7 +274,21 @@ app.post('/api/pods/:id/members', async (req, res) => {
|
||||
try {
|
||||
const pod = await addMember(req.params.id, req.body?.name ?? '');
|
||||
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
||||
res.json(pod);
|
||||
const user = requestUser(req);
|
||||
if (user) {
|
||||
await recordUserPodContext({
|
||||
clerkUserId: user.clerkUserId,
|
||||
podId: pod.id,
|
||||
memberName: typeof req.body?.name === 'string' ? req.body.name.trim() : undefined,
|
||||
action: 'added_member',
|
||||
metadata: {
|
||||
identity: typeof req.body?.name === 'string' ? req.body.name.trim() : null,
|
||||
email: stringMeta(req.body?.profile?.email) ?? null,
|
||||
imageUrl: stringMeta(req.body?.profile?.imageUrl) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
res.json(await getPod(pod.id));
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: (e as Error).message });
|
||||
}
|
||||
@@ -306,15 +391,14 @@ app.post('/api/internal/pods/:id/live-conversation/:sessionId/note', async (req,
|
||||
const note = typeof req.body?.note === 'string' ? req.body.note : '';
|
||||
const identity = typeof req.body?.identity === 'string' ? req.body.identity : undefined;
|
||||
const kind = typeof req.body?.kind === 'string' ? req.body.kind : undefined;
|
||||
res.status(201).json(
|
||||
await recordLiveConversationNote({
|
||||
const saved = await recordLiveConversationNote({
|
||||
podId: req.params.id,
|
||||
sessionId: req.params.sessionId,
|
||||
identity,
|
||||
kind,
|
||||
note,
|
||||
}),
|
||||
);
|
||||
});
|
||||
res.status(201).json(saved);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: (e as Error).message });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user