Add member work history and learning docs
This commit is contained in:
@@ -5,6 +5,7 @@ import type { Collision, DataMessage, HermesMessage, Intervention } from '@podma
|
||||
import { DATA_TOPIC } from '@podman/shared';
|
||||
import { env } from '../env.js';
|
||||
import { speak } from '../voice/live.js';
|
||||
import { notifyCriticalLiveConversations } from '../live-conversation/sessions.js';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
@@ -54,7 +55,10 @@ export async function publishHermesIntervention(
|
||||
topic: DATA_TOPIC,
|
||||
});
|
||||
await publishHermesMessage(room, collision, intervention);
|
||||
if (voiceLine) await speak(room, voiceLine);
|
||||
void notifyCriticalLiveConversations(collision, intervention, voiceLine).catch((err) =>
|
||||
console.warn(`[live-conversation] critical notify failed: ${(err as Error).message}`),
|
||||
);
|
||||
if (voiceLine) await speak(room, voiceLine, { priority: 'critical' });
|
||||
}
|
||||
|
||||
async function hermesToken(roomName: string): Promise<string> {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { EngineerContext, MemberWorkHistory, MemberWorkHistoryFile } from '@podman/shared';
|
||||
import { getDb } from '../memory/db.js';
|
||||
import { parseGitStatusPath } from '../graph/live.js';
|
||||
|
||||
interface EngineerStateDoc {
|
||||
_id: string;
|
||||
podId: string;
|
||||
name: string;
|
||||
changedFiles?: string[];
|
||||
branch?: string | null;
|
||||
recentCommit?: string | null;
|
||||
gitUpdatedAt?: Date | string;
|
||||
updatedAt?: Date | string;
|
||||
}
|
||||
|
||||
interface FileAccumulator {
|
||||
file: string;
|
||||
observations: number;
|
||||
gitChanges: number;
|
||||
firstSeenAt: number;
|
||||
lastSeenAt: number;
|
||||
confidenceSum: number;
|
||||
confidenceCount: number;
|
||||
activities: Set<string>;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
function toIso(ms: number): string {
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
function dateMs(value: string | Date | undefined): number {
|
||||
if (value instanceof Date) return value.getTime();
|
||||
if (value) {
|
||||
const parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function clean(value: string | undefined): string {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
function sameMember(a: string | undefined, b: string): boolean {
|
||||
return clean(a).toLowerCase() === b.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function addFile(files: Map<string, FileAccumulator>, file: string, at: number): FileAccumulator {
|
||||
const existing = files.get(file);
|
||||
if (existing) {
|
||||
if (at > 0) {
|
||||
existing.firstSeenAt = Math.min(existing.firstSeenAt || at, at);
|
||||
existing.lastSeenAt = Math.max(existing.lastSeenAt, at);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
const acc: FileAccumulator = {
|
||||
file,
|
||||
observations: 0,
|
||||
gitChanges: 0,
|
||||
firstSeenAt: at,
|
||||
lastSeenAt: at,
|
||||
confidenceSum: 0,
|
||||
confidenceCount: 0,
|
||||
activities: new Set<string>(),
|
||||
current: false,
|
||||
};
|
||||
files.set(file, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
export async function getMemberWorkHistory(
|
||||
podId: string,
|
||||
member: string,
|
||||
options: { hours?: number; limit?: number } = {},
|
||||
): Promise<MemberWorkHistory> {
|
||||
const db = await getDb();
|
||||
const windowHours = Math.min(Math.max(options.hours ?? 24, 1), 168);
|
||||
const limit = Math.min(Math.max(options.limit ?? 80, 10), 200);
|
||||
const since = new Date(Date.now() - windowHours * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const [observations, gitState] = await Promise.all([
|
||||
db
|
||||
.collection<EngineerContext>('observations')
|
||||
.find({ podId, observedAt: { $gte: since } }, { projection: { _id: 0 } })
|
||||
.sort({ observedAt: -1 })
|
||||
.limit(500)
|
||||
.toArray(),
|
||||
db.collection<EngineerStateDoc>('engineer_states').findOne({
|
||||
podId,
|
||||
name: { $regex: `^${member.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const files = new Map<string, FileAccumulator>();
|
||||
const timeline: MemberWorkHistory['timeline'] = [];
|
||||
const memberObservations = observations.filter((doc) => sameMember(doc.engineerId, member));
|
||||
|
||||
for (const doc of memberObservations) {
|
||||
const file = clean(doc.currentFile);
|
||||
if (!file) continue;
|
||||
const at = dateMs(doc.observedAt);
|
||||
const acc = addFile(files, file, at);
|
||||
acc.observations += 1;
|
||||
acc.current ||= timeline.length === 0;
|
||||
if (typeof doc.confidence === 'number') {
|
||||
acc.confidenceSum += doc.confidence;
|
||||
acc.confidenceCount += 1;
|
||||
}
|
||||
const activity = clean(doc.activity);
|
||||
if (activity) acc.activities.add(activity);
|
||||
timeline.push({
|
||||
id: `vision:${doc.engineerId}:${doc.observedAt}:${file}`,
|
||||
at: doc.observedAt,
|
||||
source: 'vision',
|
||||
file,
|
||||
title: activity || `Worked in ${file}`,
|
||||
detail: clean(doc.currentSymbol) ? `symbol ${doc.currentSymbol}` : undefined,
|
||||
confidence: doc.confidence,
|
||||
});
|
||||
}
|
||||
|
||||
const gitAt = dateMs(gitState?.gitUpdatedAt ?? gitState?.updatedAt);
|
||||
for (const raw of gitState?.changedFiles ?? []) {
|
||||
const file = parseGitStatusPath(raw);
|
||||
if (!file) continue;
|
||||
const acc = addFile(files, file, gitAt || Date.now());
|
||||
acc.gitChanges += 1;
|
||||
acc.current = true;
|
||||
timeline.push({
|
||||
id: `git:${gitState?._id}:${gitAt}:${file}`,
|
||||
at: toIso(gitAt || Date.now()),
|
||||
source: 'git',
|
||||
file,
|
||||
title: `Local change in ${file}`,
|
||||
detail: [gitState?.branch ? `branch ${gitState.branch}` : undefined, gitState?.recentCommit]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
});
|
||||
}
|
||||
|
||||
const fileRows: MemberWorkHistoryFile[] = [...files.values()]
|
||||
.sort((a, b) => b.lastSeenAt - a.lastSeenAt || b.observations - a.observations)
|
||||
.slice(0, 12)
|
||||
.map((file) => ({
|
||||
file: file.file,
|
||||
observations: file.observations,
|
||||
gitChanges: file.gitChanges,
|
||||
firstSeenAt: toIso(file.firstSeenAt || file.lastSeenAt || Date.now()),
|
||||
lastSeenAt: toIso(file.lastSeenAt || file.firstSeenAt || Date.now()),
|
||||
confidenceAvg: file.confidenceCount
|
||||
? Math.round((file.confidenceSum / file.confidenceCount) * 100) / 100
|
||||
: null,
|
||||
activities: [...file.activities].slice(0, 3),
|
||||
current: file.current,
|
||||
}));
|
||||
|
||||
timeline.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
|
||||
|
||||
return {
|
||||
podId,
|
||||
member,
|
||||
generatedAt: new Date().toISOString(),
|
||||
windowHours,
|
||||
totals: {
|
||||
files: fileRows.length,
|
||||
observations: memberObservations.length,
|
||||
gitChanges: gitState?.changedFiles?.length ?? 0,
|
||||
},
|
||||
files: fileRows,
|
||||
timeline: timeline.slice(0, limit),
|
||||
};
|
||||
}
|
||||
@@ -22,10 +22,12 @@ export const env = {
|
||||
LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'),
|
||||
LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'),
|
||||
LIVEKIT_AGENT_NAME: opt('LIVEKIT_AGENT_NAME'),
|
||||
LIVEKIT_CONVERSATION_AGENT_NAME: opt('LIVEKIT_CONVERSATION_AGENT_NAME', 'podman-live-conversation'),
|
||||
// Gemini
|
||||
GEMINI_API_KEY: reqAny('GEMINI_API_KEY', ['GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY']),
|
||||
GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),
|
||||
GEMINI_LIVE_MODEL: opt('GEMINI_LIVE_MODEL', 'gemini-3.1-flash-tts-preview'),
|
||||
GEMINI_CONVERSATION_MODEL: opt('GEMINI_CONVERSATION_MODEL', 'gemini-3.1-flash-live-preview'),
|
||||
GEMINI_TTS_VOICE: opt('GEMINI_TTS_VOICE', 'Charon'),
|
||||
GEMINI_EMBEDDING_MODEL: opt('GEMINI_EMBEDDING_MODEL', 'gemini-embedding-001'),
|
||||
// GitHub
|
||||
@@ -38,6 +40,7 @@ export const env = {
|
||||
// Server
|
||||
PORT: Number(opt('PORT', '8787')),
|
||||
NUDGE_COOLDOWN_MS: Number(opt('NUDGE_COOLDOWN_MS', '180000')),
|
||||
INTERNAL_AGENT_TOKEN: opt('INTERNAL_AGENT_TOKEN'),
|
||||
} as const;
|
||||
|
||||
export function repoParts(): { owner: string; repo: string } {
|
||||
|
||||
@@ -28,6 +28,73 @@ export function createDemoPodGraph(podId: string): PodGraph {
|
||||
detail: 'Interventions accepted this session (+14%).',
|
||||
},
|
||||
],
|
||||
loop: {
|
||||
activeStep: 'adapt',
|
||||
steps: [
|
||||
{
|
||||
key: 'observe',
|
||||
label: 'Observe',
|
||||
value: '2',
|
||||
detail: 'Screen context and local git state show two active editors.',
|
||||
status: 'complete',
|
||||
},
|
||||
{
|
||||
key: 'store',
|
||||
label: 'Store',
|
||||
value: '7',
|
||||
detail: 'Observations, collisions, interventions, and outcomes are in MongoDB.',
|
||||
status: 'complete',
|
||||
},
|
||||
{
|
||||
key: 'predict',
|
||||
label: 'Predict',
|
||||
value: '2',
|
||||
detail: 'Same-file risk paths are detected before push.',
|
||||
status: 'complete',
|
||||
},
|
||||
{
|
||||
key: 'outcome',
|
||||
label: 'Outcome',
|
||||
value: '6',
|
||||
detail: 'Accepted and dismissed outcomes supervise future routing.',
|
||||
status: 'complete',
|
||||
},
|
||||
{
|
||||
key: 'adapt',
|
||||
label: 'Adapt',
|
||||
value: '1',
|
||||
detail: 'Accepted real collision created a learned_from edge.',
|
||||
status: 'complete',
|
||||
},
|
||||
],
|
||||
},
|
||||
activity: [
|
||||
{
|
||||
id: 'demo-learned-auth',
|
||||
at: new Date().toISOString(),
|
||||
kind: 'learned',
|
||||
title: 'Learned Karti owns auth.ts',
|
||||
detail: 'Accepted sync PR outcome created a durable learned_from path.',
|
||||
nodeId: 'engineer:karti',
|
||||
edgeId: 'e7',
|
||||
},
|
||||
{
|
||||
id: 'demo-intervention-sync-pr',
|
||||
at: new Date().toISOString(),
|
||||
kind: 'intervention',
|
||||
title: 'Intervention: sync PR',
|
||||
detail: 'PodMan offered a small coordination card before voice.',
|
||||
nodeId: 'intervention:sync-pr',
|
||||
},
|
||||
{
|
||||
id: 'demo-collision-auth',
|
||||
at: new Date().toISOString(),
|
||||
kind: 'collision',
|
||||
title: 'Collision risk on auth.ts',
|
||||
detail: 'Karti and Yahya converged on unpushed work.',
|
||||
nodeId: 'collision:auth',
|
||||
},
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
id: 'engineer:shakthi',
|
||||
@@ -186,7 +253,7 @@ export function createDemoPodGraph(podId: string): PodGraph {
|
||||
source: 'collision:auth',
|
||||
target: 'intervention:sync-pr',
|
||||
kind: 'warns',
|
||||
label: 'nudges',
|
||||
label: 'routes',
|
||||
strength: 0.9,
|
||||
},
|
||||
{
|
||||
|
||||
+150
-1
@@ -3,6 +3,8 @@ import type {
|
||||
PodGraphNode,
|
||||
PodGraphEdge,
|
||||
PodGraphMetric,
|
||||
PodLearningLoop,
|
||||
PodGraphActivity,
|
||||
PodGraphNodeKind,
|
||||
PodGraphEdgeKind,
|
||||
PodGraphNodeStatus,
|
||||
@@ -148,6 +150,77 @@ function layout(nodes: PodGraphNode[]): void {
|
||||
|
||||
const SEVERITY_WEIGHT: Record<string, number> = { info: 0.4, warn: 0.7, critical: 1 };
|
||||
|
||||
function buildLoop(input: {
|
||||
observations: number;
|
||||
gitStates: number;
|
||||
collisions: number;
|
||||
interventions: number;
|
||||
outcomes: number;
|
||||
acceptedReal: number;
|
||||
learnedEdges: number;
|
||||
}): PodLearningLoop {
|
||||
const stored = input.observations + input.gitStates + input.interventions + input.outcomes;
|
||||
return {
|
||||
activeStep:
|
||||
input.acceptedReal > 0
|
||||
? 'adapt'
|
||||
: input.outcomes > 0
|
||||
? 'outcome'
|
||||
: input.collisions > 0
|
||||
? 'predict'
|
||||
: input.observations + input.gitStates > 0
|
||||
? 'store'
|
||||
: 'observe',
|
||||
steps: [
|
||||
{
|
||||
key: 'observe',
|
||||
label: 'Observe',
|
||||
value: String(input.observations + input.gitStates),
|
||||
detail: 'Recent vision observations plus local git-state reports.',
|
||||
status: input.observations + input.gitStates > 0 ? 'complete' : 'quiet',
|
||||
},
|
||||
{
|
||||
key: 'store',
|
||||
label: 'Store',
|
||||
value: String(stored),
|
||||
detail: 'MongoDB records available to recall for this pod.',
|
||||
status: stored > 0 ? 'complete' : 'quiet',
|
||||
},
|
||||
{
|
||||
key: 'predict',
|
||||
label: 'Predict',
|
||||
value: String(input.collisions),
|
||||
detail: 'Distinct collision signatures detected from live work.',
|
||||
status: input.collisions > 0 ? 'complete' : 'quiet',
|
||||
},
|
||||
{
|
||||
key: 'outcome',
|
||||
label: 'Outcome',
|
||||
value: String(input.outcomes),
|
||||
detail: 'Accepted and dismissed intervention outcomes.',
|
||||
status: input.outcomes > 0 ? 'complete' : 'quiet',
|
||||
},
|
||||
{
|
||||
key: 'adapt',
|
||||
label: 'Adapt',
|
||||
value: String(input.learnedEdges),
|
||||
detail: 'Learned graph edges created from accepted real outcomes.',
|
||||
status: input.acceptedReal > 0 ? 'complete' : 'planned',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function pushActivity(
|
||||
activity: PodGraphActivity[],
|
||||
item: PodGraphActivity,
|
||||
seen: Set<string>,
|
||||
): void {
|
||||
if (seen.has(item.id)) return;
|
||||
seen.add(item.id);
|
||||
activity.push(item);
|
||||
}
|
||||
|
||||
export async function materializePodGraph(podId: string): Promise<PodGraph | null> {
|
||||
const c = await collections();
|
||||
const db = await getDb();
|
||||
@@ -174,6 +247,8 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
||||
}
|
||||
|
||||
const b: Builder = { nodes: new Map(), edges: new Map() };
|
||||
const activity: PodGraphActivity[] = [];
|
||||
const activityIds = new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
// 1. Baseline engineer nodes from the roster.
|
||||
@@ -193,6 +268,18 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
||||
if (isFilePath(file)) {
|
||||
const f = upsertNode(b, 'file', file, { label: shortLabel(file), summary: file });
|
||||
upsertEdge(b, eng, f, 'editing', o.activity ?? 'edits', Math.max(0.4, o.confidence ?? 0.5));
|
||||
pushActivity(
|
||||
activity,
|
||||
{
|
||||
id: `editing:${o.engineerId}:${file}:${String(o.observedAt ?? '')}`,
|
||||
at: String(o.observedAt ?? new Date().toISOString()),
|
||||
kind: 'editing',
|
||||
title: `${o.engineerId} editing ${shortLabel(file)}`,
|
||||
detail: o.activity ?? 'Vision observed active work.',
|
||||
nodeId: f,
|
||||
},
|
||||
activityIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +338,18 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
||||
const eng = upsertNode(b, 'engineer', name, { label: name });
|
||||
upsertEdge(b, eng, cNode, 'collides', 'in', SEVERITY_WEIGHT[col.severity] ?? 0.7);
|
||||
}
|
||||
pushActivity(
|
||||
activity,
|
||||
{
|
||||
id: `collision:${col.id}`,
|
||||
at: col.detectedAt,
|
||||
kind: 'collision',
|
||||
title: `Collision risk on ${shortLabel(file)}`,
|
||||
detail: `${col.engineers.join(' + ')} converged on ${file}.`,
|
||||
nodeId: cNode,
|
||||
},
|
||||
activityIds,
|
||||
);
|
||||
sigToNode.set(sig, cNode);
|
||||
colNodeFor.set(col.id, cNode);
|
||||
if (!isPriority) distinctCollisions++;
|
||||
@@ -293,7 +392,19 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
||||
: 'watch',
|
||||
summary: iv.message,
|
||||
});
|
||||
upsertEdge(b, colNode, ivNode, 'warns', 'nudges', 0.85);
|
||||
upsertEdge(b, colNode, ivNode, 'warns', 'routes', 0.85);
|
||||
pushActivity(
|
||||
activity,
|
||||
{
|
||||
id: `intervention:${iv.id}`,
|
||||
at: iv.createdAt,
|
||||
kind: 'intervention',
|
||||
title: `Intervention: ${b.nodes.get(ivNode)?.label ?? iv.kind}`,
|
||||
detail: iv.message,
|
||||
nodeId: ivNode,
|
||||
},
|
||||
activityIds,
|
||||
);
|
||||
ivNodeForCol.set(colNode, ivNode);
|
||||
}
|
||||
|
||||
@@ -316,8 +427,34 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
||||
if (ivNode) {
|
||||
const ivObj = b.nodes.get(ivNode);
|
||||
if (ivObj) ivObj.status = 'learned';
|
||||
const before = b.edges.size;
|
||||
upsertEdge(b, ivNode, engNode, 'learned_from', `learned: owns ${file}`, 0.6);
|
||||
const edgeId = `${'learned_from'}:${ivNode}->${engNode}`;
|
||||
pushActivity(
|
||||
activity,
|
||||
{
|
||||
id: `learned:${out.interventionId}:${owner}:${file}`,
|
||||
at: out.recordedAt,
|
||||
kind: 'learned',
|
||||
title: `Learned ${owner} owns ${shortLabel(file)}`,
|
||||
detail: 'Accepted real outcome created a durable learned_from path.',
|
||||
nodeId: engNode,
|
||||
edgeId: before === b.edges.size ? undefined : edgeId,
|
||||
},
|
||||
activityIds,
|
||||
);
|
||||
}
|
||||
pushActivity(
|
||||
activity,
|
||||
{
|
||||
id: `outcome:${out.interventionId}:${out.recordedAt}`,
|
||||
at: out.recordedAt,
|
||||
kind: 'outcome',
|
||||
title: out.accepted ? 'Outcome accepted' : 'Outcome dismissed',
|
||||
detail: out.wasRealCollision ? 'Marked as a real collision.' : 'Marked as noise.',
|
||||
},
|
||||
activityIds,
|
||||
);
|
||||
}
|
||||
|
||||
// Prune test-artifact engineers, then anything left orphaned by that.
|
||||
@@ -389,6 +526,8 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
||||
detail: 'Interventions accepted this session.',
|
||||
},
|
||||
];
|
||||
const learnedEdges = [...b.edges.values()].filter((e) => e.kind === 'learned_from').length;
|
||||
activity.sort((a, z) => String(z.at).localeCompare(String(a.at)));
|
||||
|
||||
return {
|
||||
podId,
|
||||
@@ -396,5 +535,15 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
||||
nodes,
|
||||
edges: [...b.edges.values()],
|
||||
metrics,
|
||||
loop: buildLoop({
|
||||
observations: observations.length,
|
||||
gitStates: gitStates.size,
|
||||
collisions: riskPaths,
|
||||
interventions: interventionDocs.length,
|
||||
outcomes: totalOutcomes,
|
||||
acceptedReal,
|
||||
learnedEdges,
|
||||
}),
|
||||
activity: activity.slice(0, 12),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { getDb } from '../memory/db.js';
|
||||
import { getMemberWorkHistory } from '../activity/member-history.js';
|
||||
|
||||
const DEFAULT_LIMIT = 8;
|
||||
|
||||
function sinceIso(hours: number): string {
|
||||
return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
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([
|
||||
db.collection('pods').findOne({ id: podId }, { projection: { _id: 0 } }),
|
||||
getMemberWorkHistory(podId, identity, { hours: 24, limit: 30 }).catch(() => null),
|
||||
db.collection('engineer_states').findOne(
|
||||
{ podId, name: identity },
|
||||
{
|
||||
projection: {
|
||||
_id: 0,
|
||||
name: 1,
|
||||
branch: 1,
|
||||
changedFiles: 1,
|
||||
recentCommit: 1,
|
||||
gitUpdatedAt: 1,
|
||||
},
|
||||
},
|
||||
),
|
||||
db
|
||||
.collection('collisions')
|
||||
.find({ podId, detectedAt: { $gte: since } }, { projection: { _id: 0, embedding: 0 } })
|
||||
.sort({ detectedAt: -1 })
|
||||
.limit(DEFAULT_LIMIT)
|
||||
.toArray(),
|
||||
db
|
||||
.collection('interventions')
|
||||
.find({ podId, createdAt: { $gte: since } }, { projection: { _id: 0 } })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(DEFAULT_LIMIT)
|
||||
.toArray(),
|
||||
db
|
||||
.collection('outcomes')
|
||||
.find({ podId, recordedAt: { $gte: since } }, { projection: { _id: 0 } })
|
||||
.sort({ recordedAt: -1 })
|
||||
.limit(DEFAULT_LIMIT)
|
||||
.toArray(),
|
||||
]);
|
||||
|
||||
return {
|
||||
pod,
|
||||
identity,
|
||||
generatedAt: new Date().toISOString(),
|
||||
currentGitState: gitState,
|
||||
memberHistory: history,
|
||||
recentCollisions: collisions,
|
||||
recentInterventions: interventions,
|
||||
recentOutcomes: outcomes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordLiveConversationNote(input: {
|
||||
podId: string;
|
||||
sessionId: string;
|
||||
identity?: string;
|
||||
note: string;
|
||||
kind?: string;
|
||||
}) {
|
||||
const note = input.note.trim();
|
||||
if (!note) throw new Error('note is required');
|
||||
const doc = {
|
||||
podId: input.podId,
|
||||
sessionId: input.sessionId,
|
||||
identity: input.identity,
|
||||
kind: input.kind || 'summary',
|
||||
note: note.slice(0, 4000),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await (await getDb()).collection('conversation_notes').insertOne(doc);
|
||||
return { ...doc, _id: undefined };
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { AccessToken, RoomAgentDispatch, RoomConfiguration } from 'livekit-server-sdk';
|
||||
import { Room as LiveKitRoom } from '@livekit/rtc-node';
|
||||
import type { Collision, DataMessage, Intervention, LiveConversationEvent } from '@podman/shared';
|
||||
import { DATA_TOPIC } from '@podman/shared';
|
||||
import { env } from '../env.js';
|
||||
import { closeRoom } from '../livekit/rooms.js';
|
||||
import { speakInRoom } from '../voice/live.js';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const DEFAULT_AGENT = 'podman-live-conversation';
|
||||
|
||||
export interface LiveConversationSession {
|
||||
sessionId: string;
|
||||
podId: string;
|
||||
identity: string;
|
||||
displayName: string;
|
||||
room: string;
|
||||
url: string;
|
||||
startedAt: string;
|
||||
lastEventAt?: string;
|
||||
endedAt?: string;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, LiveConversationSession>();
|
||||
|
||||
function sessionKey(podId: string, identity: string): string {
|
||||
return `${podId}:${identity.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function cleanPart(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
function agentName(): string {
|
||||
return env.LIVEKIT_CONVERSATION_AGENT_NAME || DEFAULT_AGENT;
|
||||
}
|
||||
|
||||
function tokenFor(room: string, identity: string, name: string, metadata: object): Promise<string> {
|
||||
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||
identity,
|
||||
name,
|
||||
ttl: '4h',
|
||||
metadata: JSON.stringify(metadata),
|
||||
});
|
||||
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
|
||||
return at.toJwt();
|
||||
}
|
||||
|
||||
export async function startLiveConversation(input: {
|
||||
podId: string;
|
||||
identity: string;
|
||||
displayName?: string;
|
||||
}): Promise<LiveConversationSession & { token: string }> {
|
||||
const identity = input.identity.trim();
|
||||
if (!identity) throw new Error('identity is required');
|
||||
|
||||
const existing = activeLiveConversation(input.podId, identity);
|
||||
if (existing) {
|
||||
return {
|
||||
...existing,
|
||||
token: await tokenFor(existing.room, identity, existing.displayName, {
|
||||
podId: input.podId,
|
||||
identity,
|
||||
sessionId: existing.sessionId,
|
||||
mode: 'podman-live-conversation',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const room = `podman-live:${cleanPart(input.podId)}:${cleanPart(identity)}:${sessionId.slice(0, 8)}`;
|
||||
const displayName = input.displayName?.trim() || identity;
|
||||
const metadata = { podId: input.podId, identity, sessionId, mode: 'podman-live-conversation' };
|
||||
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||
identity,
|
||||
name: displayName,
|
||||
ttl: '4h',
|
||||
metadata: JSON.stringify(metadata),
|
||||
});
|
||||
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
|
||||
at.roomConfig = new RoomConfiguration({
|
||||
name: room,
|
||||
emptyTimeout: 60,
|
||||
departureTimeout: 15,
|
||||
agents: [
|
||||
new RoomAgentDispatch({
|
||||
agentName: agentName(),
|
||||
metadata: JSON.stringify(metadata),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const session: LiveConversationSession = {
|
||||
sessionId,
|
||||
podId: input.podId,
|
||||
identity,
|
||||
displayName,
|
||||
room,
|
||||
url: env.LIVEKIT_URL,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
sessions.set(sessionKey(input.podId, identity), session);
|
||||
return { ...session, token: await at.toJwt() };
|
||||
}
|
||||
|
||||
export function activeLiveConversation(
|
||||
podId: string,
|
||||
identity: string,
|
||||
): LiveConversationSession | null {
|
||||
const session = sessions.get(sessionKey(podId, identity));
|
||||
return session && !session.endedAt ? session : null;
|
||||
}
|
||||
|
||||
export function listActiveLiveConversations(podId: string): LiveConversationSession[] {
|
||||
return [...sessions.values()].filter((session) => session.podId === podId && !session.endedAt);
|
||||
}
|
||||
|
||||
export async function stopLiveConversation(
|
||||
podId: string,
|
||||
sessionId: string,
|
||||
): Promise<LiveConversationSession | null> {
|
||||
const session = [...sessions.values()].find(
|
||||
(candidate) => candidate.podId === podId && candidate.sessionId === sessionId,
|
||||
);
|
||||
if (!session) return null;
|
||||
session.endedAt = new Date().toISOString();
|
||||
await closeRoom(session.room);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function publishPrivateConversationEvent(
|
||||
roomName: string,
|
||||
event: LiveConversationEvent,
|
||||
): Promise<void> {
|
||||
const room = new LiveKitRoom();
|
||||
try {
|
||||
const token = await tokenFor(roomName, `podman-live-router-${Date.now()}`, 'PodMan live router', {
|
||||
mode: 'podman-live-router',
|
||||
});
|
||||
await room.connect(env.LIVEKIT_URL, token, { autoSubscribe: false, dynacast: false });
|
||||
const data: DataMessage = { type: 'LIVE_CONVERSATION_EVENT', event };
|
||||
await room.localParticipant?.publishData(encoder.encode(JSON.stringify(data)), {
|
||||
reliable: true,
|
||||
topic: DATA_TOPIC,
|
||||
});
|
||||
} finally {
|
||||
await room.disconnect().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyCriticalLiveConversations(
|
||||
collision: Collision,
|
||||
intervention: Intervention,
|
||||
voiceLine?: string,
|
||||
): Promise<void> {
|
||||
if (collision.severity !== 'critical') return;
|
||||
const recipients = new Set(collision.engineers.map((name) => name.toLowerCase()));
|
||||
const active = listActiveLiveConversations(collision.podId).filter((session) =>
|
||||
recipients.has(session.identity.toLowerCase()),
|
||||
);
|
||||
if (active.length === 0) return;
|
||||
|
||||
await Promise.allSettled(
|
||||
active.map(async (session) => {
|
||||
const createdAt = new Date().toISOString();
|
||||
session.lastEventAt = createdAt;
|
||||
const summary =
|
||||
voiceLine ||
|
||||
`Critical collision in ${collision.file}. ${collision.engineers.join(
|
||||
' and ',
|
||||
)} should sync before pushing.`;
|
||||
await publishPrivateConversationEvent(session.room, {
|
||||
id: `live_evt_${Date.now()}_${session.sessionId.slice(0, 8)}`,
|
||||
podId: collision.podId,
|
||||
sessionId: session.sessionId,
|
||||
kind: 'critical_collision',
|
||||
severity: 'critical',
|
||||
summary,
|
||||
interrupt: true,
|
||||
createdAt,
|
||||
collisionId: collision.id,
|
||||
interventionId: intervention.id,
|
||||
});
|
||||
await speakInRoom(session.room, summary, { priority: 'critical' });
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -27,8 +27,18 @@ import {
|
||||
import { getPresence, closeRoom } from './livekit/rooms.js';
|
||||
import { loadPodGraph, reachFrom } from './graph/store.js';
|
||||
import { listPodActivity } from './activity/store.js';
|
||||
import { getMemberWorkHistory } from './activity/member-history.js';
|
||||
import { speakInRoom } from './voice/live.js';
|
||||
import { notifyHermesInterventionInRoom } from './action/hermes.js';
|
||||
import {
|
||||
activeLiveConversation,
|
||||
startLiveConversation,
|
||||
stopLiveConversation,
|
||||
} from './live-conversation/sessions.js';
|
||||
import {
|
||||
getLiveConversationContext,
|
||||
recordLiveConversationNote,
|
||||
} from './live-conversation/context.js';
|
||||
import type { Collision, Intervention, InterventionOutcome, SuggestedActionKind } from '@podman/shared';
|
||||
|
||||
const app = express();
|
||||
@@ -185,6 +195,82 @@ app.post('/api/pods/:id/voice-test', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/pods/:id/live-conversation/start', async (req, res) => {
|
||||
try {
|
||||
const identity = typeof req.body?.identity === 'string' ? req.body.identity.trim() : '';
|
||||
const displayName =
|
||||
typeof req.body?.displayName === 'string' ? req.body.displayName.trim() : identity;
|
||||
if (!identity) return res.status(400).json({ error: 'identity is required' });
|
||||
const pod = await getPod(req.params.id);
|
||||
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
||||
res.json(await startLiveConversation({ podId: req.params.id, identity, displayName }));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/pods/:id/live-conversation/:sessionId/stop', async (req, res) => {
|
||||
try {
|
||||
const session = await stopLiveConversation(req.params.id, req.params.sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'session not found' });
|
||||
res.json({ ok: true, session });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/pods/:id/live-conversation/status', (req, res) => {
|
||||
const identity = typeof req.query.identity === 'string' ? req.query.identity.trim() : '';
|
||||
if (!identity) return res.status(400).json({ error: 'identity is required' });
|
||||
res.json({ active: activeLiveConversation(req.params.id, identity) });
|
||||
});
|
||||
|
||||
function requireInternalAgent(req: express.Request, res: express.Response): boolean {
|
||||
const expected = env.INTERNAL_AGENT_TOKEN;
|
||||
if (!expected) {
|
||||
res.status(503).json({ error: 'INTERNAL_AGENT_TOKEN is not configured' });
|
||||
return false;
|
||||
}
|
||||
const header = req.header('authorization') ?? '';
|
||||
const actual = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
|
||||
if (actual !== expected) {
|
||||
res.status(401).json({ error: 'unauthorized' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
app.get('/api/internal/pods/:id/live-context', async (req, res) => {
|
||||
if (!requireInternalAgent(req, res)) return;
|
||||
try {
|
||||
const identity = typeof req.query.identity === 'string' ? req.query.identity.trim() : '';
|
||||
if (!identity) return res.status(400).json({ error: 'identity is required' });
|
||||
res.json(await getLiveConversationContext(req.params.id, identity));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/internal/pods/:id/live-conversation/:sessionId/note', async (req, res) => {
|
||||
if (!requireInternalAgent(req, res)) return;
|
||||
try {
|
||||
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({
|
||||
podId: req.params.id,
|
||||
sessionId: req.params.sessionId,
|
||||
identity,
|
||||
kind,
|
||||
note,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/pods/:id/hermes/notify', async (req, res) => {
|
||||
const podId = req.params.id;
|
||||
const pod = await getPod(podId);
|
||||
@@ -258,6 +344,16 @@ app.delete('/api/pods/:id/members/:name', async (req, res) => {
|
||||
res.json(pod);
|
||||
});
|
||||
|
||||
app.get('/api/pods/:id/members/:name/history', async (req, res) => {
|
||||
try {
|
||||
const hours = Number(req.query.hours ?? 24) || 24;
|
||||
const limit = Number(req.query.limit ?? 80) || 80;
|
||||
res.json(await getMemberWorkHistory(req.params.id, req.params.name, { hours, limit }));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Continual-learning graph (team_model view) ---
|
||||
app.get('/api/pods/:id/graph', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -26,6 +26,10 @@ const encoder = new TextEncoder();
|
||||
const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
|
||||
let voiceQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
export interface SpeakOptions {
|
||||
priority?: 'normal' | 'critical';
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -239,13 +243,21 @@ async function speakAudio(room: Room, message: string): Promise<void> {
|
||||
* VOICE_CUE is sent first so clients still get the cue if audio generation or
|
||||
* publishing fails.
|
||||
*/
|
||||
export async function speak(room: Room, message: string): Promise<void> {
|
||||
export async function speak(room: Room, message: string, options: SpeakOptions = {}): Promise<void> {
|
||||
await publishVoiceCue(room, message);
|
||||
if (options.priority === 'critical') {
|
||||
await speakAudio(room, message);
|
||||
return;
|
||||
}
|
||||
voiceQueue = voiceQueue.catch(() => {}).then(() => speakAudio(room, message));
|
||||
await voiceQueue;
|
||||
}
|
||||
|
||||
export async function speakInRoom(roomName: string, message: string): Promise<void> {
|
||||
export async function speakInRoom(
|
||||
roomName: string,
|
||||
message: string,
|
||||
options: SpeakOptions = {},
|
||||
): Promise<void> {
|
||||
const room = new Room();
|
||||
try {
|
||||
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||
@@ -261,7 +273,7 @@ export async function speakInRoom(roomName: string, message: string): Promise<vo
|
||||
canPublishData: true,
|
||||
});
|
||||
await room.connect(env.LIVEKIT_URL, await at.toJwt());
|
||||
await speak(room, message);
|
||||
await speak(room, message, options);
|
||||
} finally {
|
||||
await room.disconnect().catch(() => {});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user