Compare commits

...

19 Commits

Author SHA1 Message Date
Sai-karthik
1be3ab1961 Refine curator sprint planning flow 2026-06-22 07:46:50 +00:00
Sai-karthik
bd582fc6c4 Make nightly analytics operational for active curator users 2026-06-20 10:15:31 +00:00
Sai-karthik
2c5cf1bcf8 Allow nightly analytics fanout runs 2026-06-20 10:11:00 +00:00
Sai-karthik
292e375a37 Use nightly analytics signals in curator day generation 2026-06-20 10:08:21 +00:00
Sai-karthik
9a6518a5d8 Add curator resume handoff from interview evidence 2026-06-20 08:50:32 +00:00
Sai-karthik
c66360cb7e Stabilize curator chat fallbacks 2026-06-19 22:44:53 +00:00
Sai-karthik
abeefc221b Propagate curator task ids through service events 2026-06-19 22:22:51 +00:00
Sai-karthik
20c18583db Build adaptive 30-day curator sprint 2026-06-19 21:51:34 +00:00
Sai-karthik
27c9f58b80 Implement ICP-driven curator sprint flow 2026-06-19 15:10:39 +00:00
Sai-karthik
c73b1a1788 Merge staging into staging-rosh preserving curator flow 2026-06-19 09:53:19 +00:00
Sai-karthik
447b5ca726 Close qscore curator tasks from review 2026-06-18 09:50:56 +00:00
Sai-karthik
e8b4634dd1 Prefer one live curator task per mission 2026-06-18 08:34:18 +00:00
Sai-karthik
a41e8be1e1 Surface qscore stages in curator daily tasks 2026-06-18 08:06:27 +00:00
Sai-karthik
38e68d8273 Ensure curator seeds three live daily missions 2026-06-18 05:55:06 +00:00
Sai-karthik
1d887bc153 Tighten curator mission generation 2026-06-17 13:05:54 +00:00
Sai-karthik
c46b9b11f6 feat: finalize curator preview handoff flow 2026-06-17 12:33:19 +00:00
Sai-karthik
fe449fdc50 refactor: replace personified workflow labels 2026-06-17 12:22:48 +00:00
dv
72b3f03dad Merge pull request 'Canonicalize mission links and preserve mission context in the service gateway' (#5) from prm-47/agent-harness-over-microservice into staging
Reviewed-on: #5
2026-06-14 13:29:25 +00:00
92ab414048 feat: enhance mission detail handling and update hrefs across services 2026-06-10 02:49:18 +05:30
31 changed files with 2290 additions and 581 deletions

View File

@@ -0,0 +1,9 @@
# VPS override: make host.docker.internal resolve to the host so the
# backend container can reach product services + spawned per-user
# containers published on host ports (Linux has no built-in mapping).
services:
backend:
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
SOCIAL_BRANDING_SERVICE_URL: http://host.docker.internal:8015

View File

@@ -121,6 +121,7 @@ services:
volumes:
# Docker-out-of-Docker: backend uses host Docker to spawn per-user OpenCode containers.
- /var/run/docker.sock:/var/run/docker.sock
- ./prompts:/app/prompts
# Shared host dir that per-user containers will also bind-mount their
# workspace from (so backend and spawned containers see the same files).
- ./.data/users:/data/users

24
prompts/curator-v1.md Normal file
View File

@@ -0,0 +1,24 @@
# Curator V1 Conversation Prompt
You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.
## Responsibilities
- Own 30 day direction, streak continuity, and service handoff decisions.
- Carry state from the conversation history and captured task memory.
- If the user gives a short answer like a role name, accept it and ask for the next missing slot.
## Guardrails
- Do not ask the same question twice.
- Do not output checklist items as separate baked chat messages.
- Never say: What should I capture next.
- Do not ask about another subtask, another mission, another service, or a later checklist item from this modal.
- When the user has answered the focused subtask enough, summarize what was captured and stop.
- If more detail is needed, ask exactly one follow-up question for the focused subtask only.
- Use captured task memory from previous subtasks as context. Do not ask the user to repeat details already captured there.
## Task Guidance
- For target-role tasks, collect target role, current background, constraints, then offer a resume or interview handoff.
- For service work, prepare preview-oriented handoffs once the focused subtask has enough context.
- Interview preview defaults: type behavioral, difficulty medium, duration 5.
- Roleplay preview should open the builder as the preview surface.
- Keep the tone concise, warm, and practical.

View File

@@ -1,6 +1,6 @@
You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.
You are Grow — a unified AI career assistant for the GrowQR platform.
You coordinate sub-agent capabilities (loaded as tools), maintain durable state, and execute workflows through microservices.
You coordinate specialist capabilities (loaded as tools), maintain durable state, and execute workflows through microservices.
## CRITICAL RULES
@@ -43,7 +43,7 @@ You coordinate sub-agent capabilities (loaded as tools), maintain durable state,
- After resume optimization: ask what type of interview to prepare.
- When they choose type → call start_interview_session.
- Then offer roleplay → call start_roleplay_session when they confirm.
- Then offer Q-Score → call compute_qscore.
- Then offer Q Score → call compute_qscore.
- Use [WORKFLOW: interview-to-offer] tag throughout.
## IMPORTANT: Tool Calling Anti-Patterns
@@ -66,16 +66,16 @@ Assistant: "I'll analyze your resume right away."
User: "analyze my resume"
Assistant calls analyze_resume → "Here's your analysis: [results]. Your strengths are..."
## Sub-Agent Capabilities
## Specialist Capabilities
{{MODULE_DESCRIPTIONS}}
## Workflow Tags (put at the VERY END, on their own line)
- [WORKFLOW: interview-to-offer] — full interview prep pipeline
- [WORKFLOW: interview-practice] — interview sessions with the Interview Agent
- [WORKFLOW: interview-practice] — mock interview sessions
- [WORKFLOW: resume-boost] — resume analysis and optimization
- [WORKFLOW: roleplay-practice] — roleplay sessions with Roleplay Agent
- [WORKFLOW: roleplay-practice] — mock roleplay sessions
- [WORKFLOW: career-switch] — career change navigation
- [WORKFLOW: job-preparation] — broad company preparation

View File

@@ -9,6 +9,7 @@ import { createMissionAction, listMissionActions } from "../../missions/actions.
import { getActiveMissionPg, listActiveMissionsPg, listMissionSuggestionsPg } from "../../grow/persistence.js";
import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
import { getSubAgentModules } from "../../lib/prompt-loader.js";
import { buildMissionServiceRoute } from "../../services/service-registry.js";
const SYSTEM_PROMPT = `You are the GrowQR conversation agent.
Keep answers concise, practical, and focused on the user's active mission.
@@ -92,16 +93,7 @@ function serviceHref(input: {
stageId?: string;
goal?: string;
}) {
const params = new URLSearchParams({
source: "mission",
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
});
if (input.stageId) params.set("stageId", input.stageId);
if (input.goal) params.set("goal", input.goal);
if (input.serviceId === "interview-service") return `/agents/interview/setup?${params.toString()}`;
if (input.serviceId === "roleplay-service") return `/agents/roleplay/setup?${params.toString()}`;
return `/agents/resume?${params.toString()}`;
return buildMissionServiceRoute(input);
}
function buildConversationTools(ctx: ConversationRuntimeContext = {}) {

View File

@@ -189,7 +189,7 @@ function buildUnifiedTools(): Array<{
type: "function" as const,
function: {
name: "start_interview_session",
description: "Create a real interview practice session via the Interview Agent / interview-service microservice.",
description: "Create a real mock interview session via the interview-service microservice.",
parameters: {
type: "object",
properties: { goal: { type: "string" } },
@@ -201,7 +201,7 @@ function buildUnifiedTools(): Array<{
type: "function" as const,
function: {
name: "start_roleplay_session",
description: "Create a real roleplay practice session via the Roleplay Agent / roleplay-service microservice.",
description: "Create a real mock roleplay session via the roleplay-service microservice.",
parameters: {
type: "object",
properties: { goal: { type: "string" } },
@@ -213,7 +213,7 @@ function buildUnifiedTools(): Array<{
type: "function" as const,
function: {
name: "compute_qscore",
description: "Compute or refresh the user's Q-Score via the Q Score Agent / qscore-service microservice.",
description: "Compute or refresh the user's Q Score via the qscore-service microservice.",
parameters: {
type: "object",
properties: {},
@@ -225,7 +225,7 @@ function buildUnifiedTools(): Array<{
type: "function" as const,
function: {
name: "analyze_resume",
description: "Analyze the user's resume using the Resume Agent microservice. Returns completeness score, skill gaps, and optimization recommendations.",
description: "Analyze the user's resume using the Resume Building microservice. Returns completeness score, skill gaps, and optimization recommendations.",
parameters: {
type: "object",
properties: {
@@ -253,7 +253,7 @@ function buildUnifiedTools(): Array<{
type: "function" as const,
function: {
name: "start_interview_to_offer",
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze & tailor resume for the role, (2) Create interview practice session with the Interview Agent, (3) Create roleplay session with Roleplay Agent, (4) Compute Q-Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze and tailor the resume for the role, (2) Create mock interview practice, (3) Create mock roleplay practice, (4) Compute Q Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
parameters: {
type: "object",
properties: {
@@ -563,7 +563,7 @@ export const userActor = actor({
appendTimelineEvent(
c.state,
{ id: "grow", name: "Grow Agent" },
{ id: "grow", name: "Grow" },
"workflow",
`${getWorkflowDefinition(workflowId)?.title ?? "Workflow"} started.`,
);
@@ -581,14 +581,14 @@ export const userActor = actor({
pauseWorkflow: async (c) => {
c.state.workflowStatus = "paused";
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow paused.");
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", "Workflow paused.");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
return c.state;
},
resumeWorkflow: async (c) => {
c.state.workflowStatus = "running";
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow resumed.");
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", "Workflow resumed.");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
return c.state;
},
@@ -753,7 +753,7 @@ async function dispatchUnifiedTool(
c.state.modules = makeModules();
c.state.createdAt = now();
c.state.updatedAt = now();
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow started via LLM tool.");
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", "Workflow started via LLM tool.");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
return { ok: true, workflowId: c.state.workflowId, goal };
}
@@ -799,7 +799,7 @@ async function dispatchUnifiedTool(
case "start_roleplay_session": {
const goal = String(input.goal ?? "");
const roleplayModule = getSubAgentModule("roleplay");
if (!roleplayModule?.service) return { ok: false, error: "Roleplay Agent module not available" };
if (!roleplayModule?.service) return { ok: false, error: "Mock Roleplay module not available" };
const result = await runServiceAgentProbe(
{ id: roleplayModule.id, name: roleplayModule.name, role: roleplayModule.role, kind: "microservice", description: roleplayModule.description, service: roleplayModule.service },
{ userId, goal },
@@ -855,14 +855,14 @@ async function dispatchUnifiedTool(
c.state.createdAt = now();
c.state.updatedAt = now();
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", `Interview-to-Offer workflow started for: ${goal}`);
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", `Interview-to-Offer workflow started for: ${goal}`);
// Step 1: Resume Agent — analyze and tailor
// Step 1: Resume Building — analyze and tailor
const resumeModule = getSubAgentModule("resume");
const resumeMod = c.state.modules.find(m => m.id === "resume");
if (resumeMod && resumeModule) {
resumeMod.status = "running";
appendTimelineEvent(c.state, resumeMod, "module", "Resume Agent analyzing your profile...");
appendTimelineEvent(c.state, resumeMod, "module", "Resume Building is analyzing your profile...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
@@ -875,18 +875,18 @@ async function dispatchUnifiedTool(
appendTimelineEvent(c.state, resumeMod, "module", resumeResult.summary);
} catch (err) {
resumeMod.status = "blocked";
appendTimelineEvent(c.state, resumeMod, "module", `Resume Agent failed: ${err instanceof Error ? err.message : String(err)}`);
appendTimelineEvent(c.state, resumeMod, "module", `Resume Building failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
c.broadcast("workflow.updated", workflowSnapshot(c.state));
// Step 2: Interview Agent — create interview session
// Step 2: Mock Interview — create interview session
const interviewModule = getSubAgentModule("interview");
const interviewMod = c.state.modules.find(m => m.id === "interview");
if (interviewMod && interviewModule?.service) {
interviewMod.status = "running";
appendTimelineEvent(c.state, interviewMod, "module", "Interview Agent creating interview practice session...");
appendTimelineEvent(c.state, interviewMod, "module", "Mock Interview is creating an interview practice session...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
@@ -905,12 +905,12 @@ async function dispatchUnifiedTool(
c.broadcast("workflow.updated", workflowSnapshot(c.state));
// Step 3: Roleplay Agent — create roleplay session
// Step 3: Mock Roleplay — create roleplay session
const roleplayModule = getSubAgentModule("roleplay");
const roleplayMod = c.state.modules.find(m => m.id === "roleplay");
if (roleplayMod && roleplayModule?.service) {
roleplayMod.status = "running";
appendTimelineEvent(c.state, roleplayMod, "module", "Roleplay Agent creating roleplay scenario...");
appendTimelineEvent(c.state, roleplayMod, "module", "Mock Roleplay is creating a practice scenario...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
@@ -923,18 +923,18 @@ async function dispatchUnifiedTool(
appendTimelineEvent(c.state, roleplayMod, "module", roleplayResult.summary);
} catch (err) {
roleplayMod.status = "blocked";
appendTimelineEvent(c.state, roleplayMod, "module", `Roleplay Agent session failed: ${err instanceof Error ? err.message : String(err)}`);
appendTimelineEvent(c.state, roleplayMod, "module", `Mock Roleplay session failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
c.broadcast("workflow.updated", workflowSnapshot(c.state));
// Step 4: Q Score Agent — compute Q-Score
// Step 4: Q Score — compute readiness
const qscoreModule = getSubAgentModule("qscore");
const qscoreMod = c.state.modules.find(m => m.id === "qscore");
if (qscoreMod && qscoreModule?.service) {
qscoreMod.status = "running";
appendTimelineEvent(c.state, qscoreMod, "module", "Q Score Agent computing your readiness Q-Score...");
appendTimelineEvent(c.state, qscoreMod, "module", "Q Score is computing your readiness score...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
@@ -947,7 +947,7 @@ async function dispatchUnifiedTool(
appendTimelineEvent(c.state, qscoreMod, "module", qscoreResult.summary);
} catch (err) {
qscoreMod.status = "blocked";
appendTimelineEvent(c.state, qscoreMod, "module", `Q-Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
appendTimelineEvent(c.state, qscoreMod, "module", `Q Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
}
}

View File

@@ -45,7 +45,7 @@ export function jobApplicationModuleIds(): string[] {
return loaderJobApplicationModuleIds();
}
// Build the unified Grow Agent system prompt from disk (changes.md §3).
// Build the unified Grow system prompt from disk (changes.md §3).
export function buildUnifiedSystemPrompt(): string {
return getUnifiedSystemPrompt();
}

View File

@@ -346,73 +346,7 @@ function formatTask(task: DailyMissionTask) {
return lines.map((line) => `- ${line}`).join("\n");
}
function fallbackDailyMissionResponse(input: { task: DailyMissionTask; messages: DailyMissionMessage[] }) {
const latestUser = [...input.messages].reverse().find((message) => message.role === "user")?.content.trim() ?? "";
const userMessagesAfterStart = input.messages.filter((message) => message.role === "user");
const interviewMission = isInterviewMission(input.task);
if (latestUser.toLowerCase() === "start") {
const serviceReply = serviceStartReply(input.task);
if (serviceReply) {
return {
reply: serviceReply,
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
if (isConfidenceCheck(input.task)) {
return {
reply: "Quick confidence check: on a scale of 1 to 10, how confident do you feel about getting interview-ready for your target role this week?",
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
if (interviewMission) {
return {
reply: `Let us get your interview room ready. For "${input.task.subtask}", tell me the role you want to practice for and the kind of question you want first.`,
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
return {
reply: `Alright, I have this one. For "${input.task.subtask}", tell me your answer in one or two lines. I will use it to update this task here.`,
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
const updateSummary = compactAnswer(latestUser);
const completed = userMessagesAfterStart.some((message) => message.content.trim().toLowerCase() === "start") && latestUser.length > 0;
if (interviewMission) {
return {
reply: `Perfect. I will carry this into the interview room setup: ${updateSummary}. Your interview room link is ready.`,
completed,
updateSummary,
actionLabel: completed ? "Generate room" : undefined,
actionRoute: completed ? getInterviewActionRoute(input.task) : undefined,
};
}
return {
reply: `Nice, saved. I updated this task with: ${updateSummary}`,
completed,
updateSummary,
actionLabel: undefined,
actionRoute: undefined,
};
}
export async function runDailyMissionAgent(input: DailyMissionAgentInput) {
const started = input.messages.some(
@@ -453,8 +387,14 @@ ${transcript}`,
return withDailyMissionActionDefaults(input.task, parseDailyMissionResponse(result.text));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn("daily mission model failed; using fallback", { message });
return fallbackDailyMissionResponse({ task: input.task, messages: input.messages });
console.warn("daily mission model failed; returning unavailable state", { message });
return {
reply: "Daily mission is temporarily unavailable right now. No progress was saved. Please retry in a moment.",
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
}

View File

@@ -26,7 +26,7 @@ export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
const auth = c.req.header("authorization") ?? "";
const token = auth.replace(/^Bearer\s+/i, "").trim();
// Service-to-service path (Grow Agent actor calling backend).
// Service-to-service path (Grow stack calling backend).
// Header `x-growqr-user` is REQUIRED so we can scope the call.
const trustedServiceTokens = new Set(
[

View File

@@ -48,7 +48,14 @@ export const MODULE_META: Record<HomeModuleId, Omit<HomeModule, "count" | "notif
rewards: { id: "rewards", label: "Rewards", href: "/rewards", accent: "amber" },
};
export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "productivity"];
export const MODULE_IDS: HomeModuleId[] = [
"suggestions",
"missions",
"social",
"pathways",
"productivity",
"rewards",
];
export const ALLOWED_NOTIFICATION_HREFS = new Set([
"/suggestions",
@@ -68,6 +75,7 @@ export const ALLOWED_NOTIFICATION_HREFS = new Set([
]);
export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
"/missions/",
"/missions/active",
"/missions/available",
"/agents/resume",
@@ -80,5 +88,9 @@ export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
export function isAllowedNotificationHref(href: string) {
if (ALLOWED_NOTIFICATION_HREFS.has(href)) return true;
return ALLOWED_NOTIFICATION_HREF_PREFIXES.some((prefix) => href === prefix || href.startsWith(`${prefix}?`));
return ALLOWED_NOTIFICATION_HREF_PREFIXES.some((prefix) =>
prefix.endsWith("/")
? href.startsWith(prefix)
: href === prefix || href.startsWith(`${prefix}?`),
);
}

View File

@@ -164,7 +164,7 @@ export async function loadPromptsFromDisk(): Promise<void> {
} catch (err) {
log.error({ err, path: SYSTEM_PROMPT_FILE }, "failed to load system prompt — using fallback");
// Fallback: assemble from modules without a template file.
const fallback = `You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.\n\n## Sub-Agent Capabilities\n\n${modules.map((m) => `- **${m.name}**: ${m.description}`).join("\n")}`;
const fallback = `You are Grow — a unified AI career assistant for the GrowQR platform.\n\n## Specialist Capabilities\n\n${modules.map((m) => `- **${m.name}**: ${m.description}`).join("\n")}`;
cachedSystemPrompt = fallback;
}
}

View File

@@ -4,6 +4,7 @@ import { missionActions, missionSuggestions } from "../db/schema.js";
import type { GrowActiveMission } from "../actors/missions/types.js";
import type { MissionActionPatch } from "./reducer-types.js";
import { defaultMissionActionStatus, type MissionActionDto, type MissionActionRow, type MissionActionStatus, type NewMissionActionInput } from "./action-types.js";
import { missionDetailHref, serviceHref } from "./reducer-helpers.js";
const OPEN_STATUSES: MissionActionStatus[] = ["queued", "running", "waiting_approval", "waiting_user_input", "failed"];
const DONE_STATUSES: MissionActionStatus[] = ["done", "dismissed", "snoozed"];
@@ -48,11 +49,11 @@ function ctaForAction(action: MissionActionRow | NewMissionActionInput) {
const payload = action.payload && typeof action.payload === "object" && !Array.isArray(action.payload) ? action.payload as Record<string, unknown> : {};
const hrefFromPayload = typeof payload.href === "string" ? payload.href : undefined;
const serviceId = action.serviceId ?? "";
const missionHref = `/missions/active?missionInstanceId=${encodeURIComponent(action.missionInstanceId)}`;
const missionHref = missionDetailHref(action.missionInstanceId);
const href = hrefFromPayload ??
(serviceId.includes("interview") ? `/agents/interview/setup?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` :
serviceId.includes("roleplay") ? `/agents/roleplay/setup?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` :
serviceId.includes("resume") ? `/agents/resume?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` : missionHref);
(serviceId.includes("interview") ? serviceHref("interview", action.missionInstanceId, action.missionId, action.stageId ?? undefined) :
serviceId.includes("roleplay") ? serviceHref("roleplay", action.missionInstanceId, action.missionId, action.stageId ?? undefined) :
serviceId.includes("resume") ? serviceHref("resume", action.missionInstanceId, action.missionId, action.stageId ?? undefined) : missionHref);
if (action.mode === "approval_required") return { ctaLabel: "Review", ctaHref: missionHref };
if (action.mode === "user_input_required") return { ctaLabel: "Answer", ctaHref: missionHref };

View File

@@ -1,5 +1,5 @@
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionDetailHref, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
export const personalBrandOpportunityReducer: MissionReducer = {
missionId: "personal-brand-opportunity-engine",
@@ -61,7 +61,7 @@ export const personalBrandOpportunityReducer: MissionReducer = {
mode: "suggestion",
title: "Turn this pitch into weekly content pillars",
body: "Use the networking practice feedback to draft 3 credibility themes for weekly posts.",
payload: { weakAreas, href: `/missions/active?missionInstanceId=${encodeURIComponent(activeMission.instanceId)}` },
payload: { weakAreas, href: missionDetailHref(activeMission.instanceId) },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:content-pillars:${event.id}`,
priority: 82,

View File

@@ -141,3 +141,7 @@ export function serviceHref(service: "resume" | "interview" | "roleplay" | "qsco
if (service === "resume") return `/agents/resume?${params.toString()}`;
return `/agents/qscore?${params.toString()}`;
}
export function missionDetailHref(missionInstanceId: string) {
return `/missions/${encodeURIComponent(missionInstanceId)}`;
}

View File

@@ -1,4 +1,5 @@
import type { MissionSnapshot, MissionStage } from "../actors/missions/types.js";
import { missionDetailHref } from "./reducer-helpers.js";
export type MissionSuggestionType = "action" | "practice" | "review" | "artifact" | "blocked" | "insight";
export type MissionSuggestionUrgency = "now" | "today" | "soon" | "calm";
@@ -103,7 +104,7 @@ function ctaFor(stage: MissionStage, snapshot: MissionSnapshot, context?: Missio
return { label: "Open resume", href: `/agents/resume?${params.toString()}` };
}
if (role === "Q Score") return { label: "View Q Score", href: `/agents/qscore?${params.toString()}` };
return { label: "Continue", href: `/missions/active?${params.toString()}` };
return { label: "Continue", href: `${missionDetailHref(snapshot.instanceId)}?${params.toString()}` };
}
function suggestionId(snapshot: MissionSnapshot, stage: MissionStage, suffix: string) {

View File

@@ -52,7 +52,7 @@ function buildTools() {
type: "function" as const,
function: {
name: "start_interview_session",
description: "Create a real interview practice session via the Interview Agent / interview-service microservice. Call this when the user asks to start or launch an interview.",
description: "Create a real mock interview session via the interview-service microservice. Call this when the user asks to start or launch interview practice.",
parameters: {
type: "object",
properties: {
@@ -66,7 +66,7 @@ function buildTools() {
type: "function" as const,
function: {
name: "start_roleplay_session",
description: "Create a real roleplay session via Roleplay Agent / roleplay-service. Call when user asks for roleplay or negotiation practice.",
description: "Create a real mock roleplay session via roleplay-service. Call when the user asks for roleplay or negotiation practice.",
parameters: {
type: "object",
properties: {
@@ -80,7 +80,7 @@ function buildTools() {
type: "function" as const,
function: {
name: "analyze_resume",
description: "Analyze user's resume using the Resume Agent. Returns completeness, skills, and gaps.",
description: "Analyze the user's resume using Resume Building. Returns completeness, skills, and gaps.",
parameters: {
type: "object",
properties: {
@@ -94,7 +94,7 @@ function buildTools() {
type: "function" as const,
function: {
name: "compute_qscore",
description: "Compute user's readiness Q-Score via Q Score Agent / qscore-service.",
description: "Compute the user's readiness Q Score via qscore-service.",
parameters: {
type: "object",
properties: {},
@@ -174,14 +174,14 @@ export function chatRoutes() {
switch (toolCall.name) {
case "start_interview_session": {
toolResult = await runServiceAgentProbe(
{ id: "interview", name: "Interview Agent", role: "Interview Agent", kind: "microservice", description: "Interview practice", service: "interview-service" },
{ id: "interview", name: "Mock Interview", role: "Interview practice", kind: "microservice", description: "Interview practice", service: "interview-service" },
{ userId, goal: String(toolCall.arguments.target_role ?? "general preparation") },
);
if (toolResult.status === "ok" && toolResult.detail) {
const detail = toolResult.detail as Record<string, unknown>;
sessions.push({
moduleId: "interview",
moduleName: "Interview Agent",
moduleName: "Mock Interview",
status: "done",
sessionId: detail.session_id as string,
sessionUrl: typeof detail.ui_session_url === "string"
@@ -194,14 +194,14 @@ export function chatRoutes() {
}
case "start_roleplay_session": {
toolResult = await runServiceAgentProbe(
{ id: "roleplay", name: "Roleplay Agent", role: "Roleplay Agent", kind: "microservice", description: "Roleplay practice", service: "roleplay-service" },
{ id: "roleplay", name: "Mock Roleplay", role: "Roleplay practice", kind: "microservice", description: "Roleplay practice", service: "roleplay-service" },
{ userId, goal: String(toolCall.arguments.goal ?? "general practice") },
);
if (toolResult.status === "ok" && toolResult.detail) {
const detail = toolResult.detail as Record<string, unknown>;
sessions.push({
moduleId: "roleplay",
moduleName: "Roleplay Agent",
moduleName: "Mock Roleplay",
status: "done",
sessionId: detail.session_id as string,
sessionUrl: typeof detail.ui_session_url === "string"

View File

@@ -12,6 +12,7 @@ import { buildDeterministicMissionSuggestions } from "../missions/suggestions.js
import { createMissionAction, getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js";
import { recordGrowEvent } from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { missionDetailHref } from "../missions/reducer-helpers.js";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
@@ -255,7 +256,7 @@ export function missionRoutes() {
if (active?.mission.actorType) {
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).runAction({ actionId: existing.id }).catch(() => undefined);
}
const href = typeof existing.payload?.href === "string" ? existing.payload.href : `/missions/active?missionInstanceId=${encodeURIComponent(existing.missionInstanceId)}`;
const href = typeof existing.payload?.href === "string" ? existing.payload.href : missionDetailHref(existing.missionInstanceId);
const action = await updateMissionActionStatus(userId, existing.id, {
status: "done",
result: {

View File

@@ -43,6 +43,38 @@ function missionFromBody(body: JsonObject): Record<string, unknown> | undefined
return mission && typeof mission === "object" && !Array.isArray(mission) ? (mission as Record<string, unknown>) : undefined;
}
function missionFromRequest(req: Request, body?: JsonObject): Record<string, unknown> | undefined {
const fromBody = body ? missionFromBody(body) : undefined;
if (fromBody) return fromBody;
const url = new URL(req.url);
const instanceId = getString(url.searchParams.get("missionInstanceId"));
const missionId = getString(url.searchParams.get("missionId"));
const stageId = getString(url.searchParams.get("stageId"));
const source = getString(url.searchParams.get("source"));
if (!instanceId && !missionId && !stageId) return undefined;
return {
instanceId,
missionId,
stageId,
source: source ?? "mission",
};
}
function curatorTaskIdFromRequest(req: Request, body?: JsonObject) {
const fromBody = body ? getString((body as Record<string, unknown>).curatorTaskId) : undefined;
if (fromBody) return fromBody;
const url = new URL(req.url);
return getString(url.searchParams.get("curatorTaskId"));
}
function stripMissionFromBody(body: JsonObject): JsonObject {
if (!("mission" in body)) return body;
const { mission: _mission, ...rest } = body;
return rest;
}
async function recordGatewayEvent(input: {
userId: string;
source: string;
@@ -107,8 +139,13 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
.replace(/^resumes\/([^/]+)\/analyze$/, "ai/analyze/$1")
.replace(/^resumes\/([^/]+)\/suggestions$/, "ai/suggestions/$1")
.replace(/^resumes\/([^/]+)\/preview$/, "export/resumes/$1/preview");
const forwardedQuery = new URLSearchParams(incoming.searchParams);
forwardedQuery.delete("missionInstanceId");
forwardedQuery.delete("missionId");
forwardedQuery.delete("stageId");
forwardedQuery.delete("source");
const target = new URL(
`/api/v1/${normalizedRest}${incoming.search}`,
`/api/v1/${normalizedRest}${forwardedQuery.toString() ? `?${forwardedQuery.toString()}` : ""}`,
config.resumeServiceUrl.replace(/\/$/, ""),
);
@@ -121,10 +158,16 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
const method = req.method.toUpperCase();
const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer();
const requestJson = parseJsonBody(body, headers);
const mission = missionFromRequest(req, requestJson);
const forwardBody =
body && headers.get("content-type")?.includes("application/json")
? Buffer.from(JSON.stringify(stripMissionFromBody(requestJson)))
: body;
if (forwardBody !== body) headers.delete("content-length");
const res = await fetch(target, {
method,
headers,
body,
body: forwardBody,
});
if (method === "GET" || method === "HEAD") {
@@ -145,8 +188,9 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
correlation: {
resumeId: getString(responseObj.resume_id ?? responseObj.resumeId ?? responseObj.id) ?? getString(requestJson.resume_id ?? requestJson.resumeId),
externalId: getString(responseObj.resume_id ?? responseObj.resumeId ?? responseObj.id) ?? getString(requestJson.resume_id ?? requestJson.resumeId),
taskId: curatorTaskIdFromRequest(req, requestJson),
},
mission: missionFromBody(requestJson),
mission,
}).catch((err) => log.warn({ err, path: normalizedRest }, "failed to record resume gateway event"));
return new Response(responseBuffer, {
@@ -308,11 +352,12 @@ function composeCandidateProfile(userContext: Record<string, unknown>): string {
}
async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject, userId: string): Promise<JsonObject> {
const { mission: _mission, ...rest } = body;
const userContext = await resolveGrowUserContext(req, userId).catch((err) => {
log.warn({ err, userId }, "failed to resolve Grow user context for interview configure");
return {} as Record<string, unknown>;
});
const incomingContext = isRecord(body.context) ? body.context : {};
const incomingContext = isRecord(rest.context) ? rest.context : {};
const context: Record<string, unknown> = {
...incomingContext,
candidate_name: getString(incomingContext.candidate_name) ?? getString(userContext.first_name) ?? "",
@@ -328,19 +373,20 @@ async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject,
}
return {
...body,
user_id: String(body.user_id ?? userId),
org_id: String(body.org_id ?? "growqr"),
...rest,
user_id: String(rest.user_id ?? userId),
org_id: String(rest.org_id ?? "growqr"),
context,
};
}
async function buildPersonalizedRoleplayConfigurePayload(req: Request, body: JsonObject, userId: string): Promise<JsonObject> {
const { mission: _mission, ...rest } = body;
const userContext = await resolveGrowUserContext(req, userId).catch((err) => {
log.warn({ err, userId }, "failed to resolve Grow user context for roleplay configure");
return {} as Record<string, unknown>;
});
const incomingMetadata = isRecord(body.metadata) ? body.metadata : {};
const incomingMetadata = isRecord(rest.metadata) ? rest.metadata : {};
const metadata: Record<string, unknown> = {
...incomingMetadata,
candidate_name: getString(incomingMetadata.candidate_name) ?? getString(userContext.first_name) ?? "",
@@ -359,11 +405,11 @@ async function buildPersonalizedRoleplayConfigurePayload(req: Request, body: Jso
}
return {
...body,
user_id: String(body.user_id ?? userId),
org_id: String(body.org_id ?? "growqr"),
...rest,
user_id: String(rest.user_id ?? userId),
org_id: String(rest.org_id ?? "growqr"),
metadata,
qscore: (body.qscore as JsonObject | undefined) ?? (isRecord(userContext.qscore) ? userContext.qscore : DEFAULT_QSCORE),
qscore: (rest.qscore as JsonObject | undefined) ?? (isRecord(userContext.qscore) ? userContext.qscore : DEFAULT_QSCORE),
user_context: userContext,
};
}
@@ -526,6 +572,7 @@ export function serviceRoutes() {
app.post("/interview/configure", async (c) => {
const userId = c.get("userId");
const body = await c.req.json<JsonObject>();
const mission = missionFromRequest(c.req.raw, body);
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, userId);
const result = await interviewService.configure(payload);
const resultObj = result as Record<string, unknown>;
@@ -534,8 +581,8 @@ export function serviceRoutes() {
source: "interview-service",
type: "interview.configured",
payload: { request: payload, result: resultObj },
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id) },
mission: missionFromBody(body),
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), taskId: curatorTaskIdFromRequest(c.req.raw, body) },
mission,
}).catch((err) => log.warn({ err }, "failed to record interview configured event"));
return c.json(result);
});
@@ -559,7 +606,7 @@ export function serviceRoutes() {
source: "interview-service",
type: eventTypeForReview("interview", resultObj),
payload: resultObj,
correlation: { sessionId },
correlation: { sessionId, taskId: curatorTaskIdFromRequest(c.req.raw) },
}).catch((err) => log.warn({ err }, "failed to record interview review event"));
return c.json(result);
});
@@ -586,6 +633,7 @@ export function serviceRoutes() {
app.post("/roleplay/configure", async (c) => {
const userId = c.get("userId");
const body = await c.req.json<JsonObject>();
const mission = missionFromRequest(c.req.raw, body);
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, userId);
const result = await roleplayService.configure(payload);
const resultObj = result as Record<string, unknown>;
@@ -594,8 +642,8 @@ export function serviceRoutes() {
source: "roleplay-service",
type: "roleplay.configured",
payload: { request: payload, result: resultObj },
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id) },
mission: missionFromBody(body),
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), taskId: curatorTaskIdFromRequest(c.req.raw, body) },
mission,
}).catch((err) => log.warn({ err }, "failed to record roleplay configured event"));
return c.json(result);
});
@@ -619,7 +667,7 @@ export function serviceRoutes() {
source: "roleplay-service",
type: eventTypeForReview("roleplay", resultObj),
payload: resultObj,
correlation: { sessionId },
correlation: { sessionId, taskId: curatorTaskIdFromRequest(c.req.raw) },
}).catch((err) => log.warn({ err }, "failed to record roleplay review event"));
return c.json(result);
});

View File

@@ -1,5 +1,6 @@
import { config } from "../config.js";
import { createHash } from "node:crypto";
import { buildServiceSessionPath } from "./service-registry.js";
// Lightweight agent reference (works with both old AgentProfile and new SubAgentModule).
export type ServiceAgentRef = {
@@ -28,32 +29,17 @@ export function buildServiceSessionUrl(
detail: Record<string, unknown> | undefined,
goal?: string,
): string | undefined {
const base = config.workflowsDashboardUrl.replace(/\/$/, "");
const sessionId = detail?.session_id ?? detail?.sessionId;
const params = new URLSearchParams();
if (sessionId && typeof sessionId === "string") params.set("session_id", sessionId);
if (goal) params.set("goal", goal);
if (service === "interview-service") {
if (!sessionId || typeof sessionId !== "string") return undefined;
params.set("role", String(detail?.target_role ?? goal ?? "Interview practice"));
params.set("type", String(detail?.interview_type ?? "behavioral"));
return `${base}/v2/service-sessions/interview?${params.toString()}`;
if (
service !== "interview-service" &&
service !== "roleplay-service" &&
service !== "resume-service"
) {
return undefined;
}
if (service === "roleplay-service") {
if (!sessionId || typeof sessionId !== "string") return undefined;
params.set("role", String(detail?.target_role ?? goal ?? "Roleplay practice"));
params.set("type", String(detail?.roleplay_type ?? "custom"));
return `${base}/v2/service-sessions/roleplay?${params.toString()}`;
}
if (service === "resume-service") {
if (goal) params.set("role", goal);
return `${base}/v2/service-sessions/resume${params.size ? `?${params.toString()}` : ""}`;
}
return undefined;
const path = buildServiceSessionPath(service, detail, goal);
if (!path) return undefined;
return `${config.workflowsDashboardUrl.replace(/\/$/, "")}${path}`;
}
function stableUuid(input: string): string {
@@ -129,7 +115,7 @@ async function runInterviewService(ctx: ServiceAgentContext): Promise<ServiceAge
);
return {
status: "ok",
summary: `Interview Agent created interview session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
summary: `Mock Interview created interview session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
detail: {
...detail,
target_role: payload.context.target_role,
@@ -173,7 +159,7 @@ async function runRoleplayService(ctx: ServiceAgentContext): Promise<ServiceAgen
);
return {
status: "ok",
summary: `Roleplay Agent created roleplay session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
summary: `Mock Roleplay created roleplay session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
detail: {
...detail,
target_role: payload.metadata.target_role,
@@ -256,7 +242,7 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise<ServiceAgentR
);
return {
status: "ok",
summary: `Q Score Agent estimated Q-Score ~${avgSignalScore} (service compute unavailable: formula store may not be seeded). Based on ${signals.length} signals.`,
summary: `Q Score estimated Q Score ~${avgSignalScore} (service compute unavailable: formula store may not be seeded). Based on ${signals.length} signals.`,
detail: {
ingest,
estimated_q_score: avgSignalScore,
@@ -269,12 +255,12 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise<ServiceAgentR
return {
status: "ok",
summary: `Q Score Agent computed Q-Score ${compute.q_score ?? "(unknown)"} for ${ctx.goal}.`,
summary: `Q Score computed Q Score ${compute.q_score ?? "(unknown)"} for ${ctx.goal}.`,
detail: { ingest, compute, qscore_user_id: qscoreUserId },
};
}
// ── Resume Agent (resume-builder service from growqr-app) ──
// ── Resume Building (resume-builder service from growqr-app) ──
async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
// Probe resume state for the user
@@ -289,8 +275,8 @@ async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentR
return {
status: "ok",
summary: hasResume
? `Resume Agent found ${detail.resume_count} resume(s) at ${completeness}% completeness. Current role: ${detail.current_role ?? "unknown"}.`
: "No existing resume found. Resume Agent is ready to build one from scratch.",
? `Resume Building found ${detail.resume_count} resume(s) at ${completeness}% completeness. Current role: ${detail.current_role ?? "unknown"}.`
: "No existing resume found. Resume Building is ready to build one from scratch.",
detail: {
resume_count: detail.resume_count,
completeness,
@@ -302,7 +288,7 @@ async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentR
} catch (err) {
return {
status: "unavailable",
summary: `Resume Agent unavailable: ${err instanceof Error ? err.message : String(err)}`,
summary: `Resume Building unavailable: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
@@ -317,7 +303,7 @@ async function runResumeTailor(ctx: ServiceAgentContext): Promise<ServiceAgentRe
// Return summary with optimization guidance
return {
status: "ok",
summary: `Resume Agent analyzed your profile for the role "${ctx.goal}". Skills detected: ${(stateResult.detail as any)?.skills?.slice(0, 5).join(", ") ?? "none"}. Resume ready for optimization.`,
summary: `Resume Building analyzed your profile for the role "${ctx.goal}". Skills detected: ${(stateResult.detail as any)?.skills?.slice(0, 5).join(", ") ?? "none"}. Resume ready for optimization.`,
detail: {
...(stateResult.detail as Record<string, unknown> ?? {}),
goal: ctx.goal,
@@ -382,11 +368,11 @@ export async function runServiceAgentProbe(
case "interview-service":
return ctx
? await runInterviewService(ctx)
: healthCheck(config.interviewServiceUrl, "Interview Agent / interview-service");
: healthCheck(config.interviewServiceUrl, "Mock Interview / interview-service");
case "roleplay-service":
return ctx
? await runRoleplayService(ctx)
: healthCheck(config.roleplayServiceUrl, "Roleplay Agent / roleplay-service");
: healthCheck(config.roleplayServiceUrl, "Mock Roleplay / roleplay-service");
case "qscore-service":
return ctx
? await runQScoreService(ctx)
@@ -394,7 +380,7 @@ export async function runServiceAgentProbe(
case "resume-service":
return ctx
? await runResumeTailor(ctx)
: healthCheck(config.resumeServiceUrl, "Resume Agent / resume-service");
: healthCheck(config.resumeServiceUrl, "Resume Building / resume-service");
case "matchmaking-service":
return ctx
? await runMatchmaking(ctx)

View File

@@ -0,0 +1,195 @@
import type { CuratorServiceId, CuratorTask } from "../v1/curator/curator-types.js";
type QueryValue = string | number | undefined | null;
type MissionServiceId = Extract<CuratorServiceId, "interview-service" | "roleplay-service" | "resume-service">;
type MissionRouteInput = {
serviceId: MissionServiceId;
missionInstanceId: string;
missionId: string;
stageId?: string;
goal?: string;
};
type CuratorRouteInput = {
serviceId?: CuratorServiceId;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
taskId?: string;
targetRole?: string;
durationMinutes?: number;
difficulty?: string;
personaId?: string;
requestedMode?: string;
roleplayBrief?: string;
};
function appendQuery(
pathname: string,
params: Record<string, QueryValue>,
) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === "") continue;
search.set(key, String(value));
}
const query = search.toString();
return query ? `${pathname}?${query}` : pathname;
}
function getString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function getSessionId(detail?: Record<string, unknown>) {
return getString(detail?.session_id ?? detail?.sessionId ?? detail?.id);
}
export function buildServiceSessionPath(
serviceId: MissionServiceId,
detail?: Record<string, unknown>,
goal?: string,
) {
const sessionId = getSessionId(detail);
if (serviceId === "interview-service") {
if (!sessionId) return undefined;
return appendQuery("/v2/service-sessions/interview", {
session_id: sessionId,
goal,
role: getString(detail?.target_role) ?? goal ?? "Interview practice",
type: getString(detail?.interview_type) ?? "behavioral",
});
}
if (serviceId === "roleplay-service") {
if (!sessionId) return undefined;
return appendQuery("/v2/service-sessions/roleplay", {
session_id: sessionId,
goal,
role: getString(detail?.target_role) ?? goal ?? "Roleplay practice",
type: getString(detail?.roleplay_type) ?? "custom",
});
}
return appendQuery("/v2/service-sessions/resume", {
goal,
role: goal,
});
}
export function buildMissionServiceRoute(input: MissionRouteInput) {
const baseParams = {
source: "mission",
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
stageId: input.stageId,
goal: input.goal,
};
if (input.serviceId === "interview-service") {
return appendQuery("/agents/interview/setup", baseParams);
}
if (input.serviceId === "roleplay-service") {
return appendQuery("/agents/roleplay/setup", baseParams);
}
return appendQuery("/agents/resume", baseParams);
}
function curatorBaseParams(input: CuratorRouteInput) {
return {
source: "curator-v1",
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
stageId: input.stageId,
curatorTaskId: input.taskId,
};
}
export function buildCuratorServiceRoute(input: CuratorRouteInput) {
if (input.serviceId === "interview-service") {
return appendQuery("/agents/interview/preview", {
...curatorBaseParams(input),
role: input.targetRole?.trim() || "Product Manager",
type: "behavioral",
persona: input.personaId ?? "payal",
duration: input.durationMinutes ?? 5,
difficulty: input.difficulty ?? "medium",
media: input.requestedMode ?? "video",
});
}
if (input.serviceId === "roleplay-service") {
return appendQuery("/agents/roleplay/builder", {
...curatorBaseParams(input),
role: input.targetRole?.trim() || "Professional",
persona: input.personaId ?? "emma",
duration: input.durationMinutes ?? 5,
mode: input.requestedMode ?? "video",
brief: input.roleplayBrief,
});
}
if (input.serviceId === "resume-service") {
return appendQuery("/agents/resume", curatorBaseParams(input));
}
if (input.serviceId === "qscore-service") {
return appendQuery("/analytics", curatorBaseParams(input));
}
if (input.serviceId === "social-branding-service") {
return appendQuery("/social", curatorBaseParams(input));
}
if (input.serviceId === "matchmaking-service") {
return appendQuery("/pathways", curatorBaseParams(input));
}
return input.missionInstanceId
? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId })
: "/missions/active";
}
export function getServiceDisplayName(serviceId?: CuratorServiceId, fallback = "Mission planner") {
if (serviceId === "interview-service") return "Interview service";
if (serviceId === "roleplay-service") return "Roleplay service";
if (serviceId === "resume-service") return "Resume service";
if (serviceId === "qscore-service") return "Q Score service";
if (serviceId === "social-branding-service") return "Social branding service";
if (serviceId === "matchmaking-service") return "Pathways service";
return fallback;
}
export function getServiceToolName(serviceId?: CuratorServiceId) {
if (serviceId === "interview-service") return "prepare_interview_preview";
if (serviceId === "roleplay-service") return "prepare_roleplay_preview";
if (serviceId === "resume-service") return "prepare_resume_upload";
if (serviceId === "qscore-service") return "prepare_qscore_review";
return "prepare_mission_step";
}
export function getServiceCompletionEvents(serviceId?: CuratorServiceId) {
if (serviceId === "interview-service") {
return ["interview.configured", "interview.review_completed", "interview.completed"];
}
if (serviceId === "roleplay-service") {
return ["roleplay.configured", "roleplay.review_completed", "roleplay.completed"];
}
if (serviceId === "resume-service") {
return ["resume.analysis_completed", "resume.parsed", "resume.updated"];
}
if (serviceId === "qscore-service") {
return ["qscore.updated", "qscore.signal_projected"];
}
return ["curator.task.completed"];
}
export function getServiceActionLabel(task: CuratorTask) {
if (task.serviceId === "interview-service") return "Open interview preview";
if (task.serviceId === "roleplay-service") return "Open roleplay preview";
if (task.serviceId === "resume-service") return "Open resume workspace";
if (task.serviceId === "qscore-service") return "Review Q Score";
return task.cta || "Open";
}

View File

@@ -1,12 +1,12 @@
import { generateText, tool } from "ai";
import { z } from "zod";
import { desc, eq } from "drizzle-orm";
import { desc, eq, gte } from "drizzle-orm";
import { createClient, type Client } from "rivetkit/client";
import { config } from "../../config.js";
import type { Registry } from "../../actors/registry.js";
import { getConversationModel } from "../../actors/conversation/agent.js";
import { db } from "../../db/client.js";
import { growConversationMessages, growEvents, users } from "../../db/schema.js";
import { growConversationMessages, growEvents } from "../../db/schema.js";
import { curatorActor } from "../curator/curator-actor.js";
import { curatorImprovementSignalSchema } from "../curator/curator-types.js";
@@ -109,9 +109,14 @@ export const v1AnalyticsActor = {
async runNightly(input: { date: string; userId?: string }) {
const userRows = input.userId
? [{ id: input.userId }]
: await db.select({ id: users.id }).from(users).limit(500);
: await db
.selectDistinct({ id: growEvents.userId })
.from(growEvents)
.where(gte(growEvents.occurredAt, new Date(Date.now() - 7 * 86400000)))
.limit(200);
let improvementSignalsCreated = 0;
for (const user of userRows) {
if (!user.id) continue;
const signals = await this.generateImprovementSignals({ userId: user.id, date: input.date });
improvementSignalsCreated += signals.length;
await this.applyImprovementSignals({ userId: user.id, date: input.date, signals });

View File

@@ -21,8 +21,15 @@ export function v1AnalyticsRoutes() {
app.post("/nightly/run", async (c) => {
const userId = c.get("userId");
const body = z.object({ date: z.string().optional(), userId: z.string().optional() }).parse(await c.req.json().catch(() => ({})));
return c.json(await v1AnalyticsActor.runNightly({ date: body.date ?? new Date().toISOString().slice(0, 10), userId: body.userId ?? userId }));
const body = z.object({
date: z.string().optional(),
userId: z.string().optional(),
runForAll: z.boolean().optional(),
}).parse(await c.req.json().catch(() => ({})));
return c.json(await v1AnalyticsActor.runNightly({
date: body.date ?? new Date().toISOString().slice(0, 10),
userId: body.runForAll ? undefined : (body.userId ?? userId),
}));
});
return app;

View File

@@ -1,5 +1,5 @@
import { buildCuratorPlan, buildCuratorStreak, buildCuratorTasks, todayIsoDate } from "./curator-store.js";
import { curatorPlanSchema, type CuratorImprovementSignal } from "./curator-types.js";
import { buildCuratorPlan, buildCuratorSprint, buildCuratorStreak, buildCuratorTasks, todayIsoDate } from "./curator-store.js";
import { curatorPlanSchema, curatorSprintResponseSchema, type CuratorImprovementSignal } from "./curator-types.js";
import { emitCuratorEvent } from "./curator-events.js";
import { runCuratorChat } from "./curator-agent.js";
import { prepareHandoffForTask } from "./curator-tools.js";
@@ -19,20 +19,27 @@ export const curatorActor = {
async getToday(input: { userId: string; date?: string }) {
const date = input.date ?? todayIsoDate();
const plan = curatorPlanSchema.parse(await buildCuratorPlan(input.userId, { startDate: date, endDate: date }));
const tasks = plan.days[0]?.tasks ?? await buildCuratorTasks(input.userId, date);
const sprint = curatorSprintResponseSchema.parse(await buildCuratorSprint(input.userId, date));
await emitCuratorEvent({ userId: input.userId, type: "curator.day.opened", payload: { date } });
return {
date,
plan,
tasks,
streak: plan.streak,
completedCount: tasks.filter((task) => task.status === "completed").length,
totalCount: tasks.length,
plan: sprint.plan,
tasks: sprint.todayTasks,
streak: sprint.streak,
completedCount: sprint.completedCount,
totalCount: sprint.totalCount,
sprint,
source: "curator-v1" as const,
};
},
async getSprint(input: { userId: string; date?: string }) {
const date = input.date ?? todayIsoDate();
const sprint = curatorSprintResponseSchema.parse(await buildCuratorSprint(input.userId, date));
await emitCuratorEvent({ userId: input.userId, type: "curator.day.opened", payload: { date, sprintId: sprint.sprintId } });
return sprint;
},
async chat(input: { userId: string; conversationId?: string; date?: string; taskId?: string; subtaskIndex?: number; subtask?: string; messages: Array<{ role: "user" | "assistant"; content: string }> }) {
return runCuratorChat(input);
},
@@ -62,32 +69,36 @@ export const curatorActor = {
const date = input.date ?? todayIsoDate();
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
if (!task) throw new Error("curator_task_not_found");
if (task.serviceId) {
const reason = input.reason ?? "subtasks_completed";
const allowDirectServiceCompletion = task.serviceId === "qscore-service" && reason === "qscore_review_opened";
if (task.serviceId && !allowDirectServiceCompletion) {
throw new Error("curator_service_task_requires_service_event");
}
const event = await emitCuratorEvent({
userId: input.userId,
type: "curator.task.completed",
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
payload: { taskId: task.id, date, reason: input.reason ?? "subtasks_completed" },
payload: { taskId: task.id, date, reason },
});
return { task: { ...task, status: "completed" as const }, eventId: event.id };
},
async recordServiceImpact(input: { userId: string; eventId: string }) {
const streak = await buildCuratorStreak(input.userId);
return { matched: true, completedTasks: await buildCuratorTasks(input.userId, todayIsoDate()), streak };
const sprint = await buildCuratorSprint(input.userId, todayIsoDate());
return { matched: true, completedTasks: sprint.todayTasks, streak, sprint };
},
async applyImprovementSignals(input: { userId: string; date: string; signals: CuratorImprovementSignal[] }) {
for (const signal of input.signals) {
await emitCuratorEvent({ userId: input.userId, type: "curator.improvement_signal.applied", payload: { signal } });
}
const plan = await buildCuratorPlan(input.userId, { startDate: input.date, endDate: input.date });
return { applied: input.signals.length, plan };
const sprint = await buildCuratorSprint(input.userId, input.date);
return { applied: input.signals.length, plan: sprint.plan, sprint };
},
async getState(input: { userId: string }) {
return { tasks: await buildCuratorTasks(input.userId, todayIsoDate()), streak: await buildCuratorStreak(input.userId) };
const sprint = await buildCuratorSprint(input.userId, todayIsoDate());
return { tasks: sprint.todayTasks, streak: sprint.streak, sprint };
},
};

View File

@@ -176,7 +176,25 @@ function targetRoleState(messages: CuratorMessage[], latest: string) {
};
}
function curatorSystemAddendum(input: {
const CURATOR_PROMPT_FILE = path.resolve(process.cwd(), "prompts", "curator-v1.md");
const DEFAULT_CURATOR_PROMPT = `You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.
Own 30 day direction, streak continuity, and service handoff decisions.
Do not ask the same question twice.
Use captured task memory and keep the user on the focused subtask.
When the user has answered enough, summarize what was captured and stop.
If more detail is needed, ask exactly one follow-up question.
For service work, prepare preview-oriented handoffs once enough context exists.`;
async function loadCuratorPromptTemplate() {
try {
return await readFile(CURATOR_PROMPT_FILE, "utf8");
} catch {
return DEFAULT_CURATOR_PROMPT;
}
}
async function curatorSystemAddendum(input: {
date: string;
taskId?: string;
subtaskIndex?: number;
@@ -186,21 +204,14 @@ function curatorSystemAddendum(input: {
promptText: string;
targetRole?: string;
}) {
const template = await loadCuratorPromptTemplate();
const lines = [
input.promptText,
"",
"You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.",
"The V1 Curator owns 30 day direction, streak continuity, and service handoff decisions.",
"Carry state from the conversation history. If the user gives a short answer like a role name, accept it and ask for the next missing slot.",
"Do not ask the same question twice. Do not output checklist items as separate baked chat messages.",
"For target-role tasks, collect target role, current background, constraints, then offer a resume or interview handoff.",
"For service work, use Conversation Actor tools to prepare handoffs only after the focused subtask has enough context.",
"Never say: What should I capture next. Ask a concrete conversational question tied to the task.",
"If a curator subtask is provided, focus on that subtask only. Do not answer as if another subtask was clicked.",
"Do not ask about another subtask, another mission, another service, or a later checklist item from this modal.",
"When the user has answered the focused subtask enough, summarize what was captured and stop. Do not ask the next subtask question.",
"If more detail is needed, ask exactly one follow-up question for the focused subtask only.",
"Use captured task memory from previous subtasks as context. Do not ask the user to repeat details already captured there.",
...template
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter(Boolean),
];
pushField(lines, "Known target role", input.targetRole);
pushField(lines, "Date", input.date);
@@ -268,6 +279,77 @@ function servicePreviewSummary(task: Awaited<ReturnType<typeof buildCuratorTasks
return `${task.serviceName} handoff is ready.`;
}
function fallbackCuratorReply(input: {
latest: string;
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
subtask?: string;
targetRole?: string;
}) {
const latest = input.latest.trim();
const lowerTitle = input.task?.title.toLowerCase() ?? "";
const lowerSubtask = input.subtask?.toLowerCase() ?? "";
const role = fallbackCuratorRole(input.targetRole);
if ((input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") && !input.targetRole) {
return "What role are you targeting?";
}
if (/^start$/i.test(latest)) {
if (input.task?.serviceId === "qscore-service") {
return "Open your current Q Score and tell me which readiness signal looks weakest today.";
}
if (input.task?.serviceId === "resume-service") {
return "Upload your current resume or paste three recent wins so I can anchor this proof task.";
}
if (input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") {
return `I have your target role as ${role}. Say start when you want the preview opened.`;
}
if (lowerTitle.includes("role direction") || lowerSubtask.includes("role direction")) {
return "Which role family do you want this sprint to optimize toward?";
}
if (input.task?.taskType === "measurement") {
return "Open the current view and tell me the one gap or signal that stands out most.";
}
if (input.task?.taskType === "proof") {
return "Share the strongest proof you already have so we can build from something real.";
}
return "What is the single outcome you want from this task today?";
}
if (input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") {
if (isExplicitHandoffRequest(latest)) {
return servicePreviewSummary(input.task, input.targetRole);
}
return `Captured ${role} as the target role. Say start when you want the preview opened.`;
}
if (lowerTitle.includes("role direction") || lowerSubtask.includes("role direction")) {
return `Captured ${latest}. I will use that as the role direction for this sprint.`;
}
if (input.task?.serviceId === "resume-service") {
return "Captured. Open the resume flow when you are ready to turn this into proof.";
}
if (input.task?.serviceId === "qscore-service") {
return "Captured. Open the Q Score view and save the main readiness gap you want to work on.";
}
if (input.task?.taskType === "measurement") {
return "Captured the baseline signal for today.";
}
if (input.task?.taskType === "proof") {
return "Captured the proof point for today.";
}
if (input.task?.taskType === "practice") {
return "Captured the practice focus for today.";
}
return "Captured. We can use this to move the task forward.";
}
async function evaluateSubtaskStatus(input: {
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
subtask?: string;
@@ -451,6 +533,7 @@ export async function runCuratorChat(input: {
}
let reply = "";
let usedFallbackReply = false;
try {
try {
const extract = await generateText({
@@ -499,7 +582,7 @@ export async function runCuratorChat(input: {
missionId: task?.missionId,
stageId: task?.stageId,
source: "curator-v1",
systemAddendum: curatorSystemAddendum({ date, taskId: input.taskId, subtaskIndex: input.subtaskIndex, subtask: input.subtask, task, taskMemory, promptText, targetRole }),
systemAddendum: await curatorSystemAddendum({ date, taskId: input.taskId, subtaskIndex: input.subtaskIndex, subtask: input.subtask, task, taskMemory, promptText, targetRole }),
});
reply = sanitize(result.text);
if (/what should i capture next/i.test(reply) || !reply) {
@@ -512,7 +595,23 @@ export async function runCuratorChat(input: {
subtask: input.subtask,
error: error instanceof Error ? error.message : String(error),
});
throw error;
reply = sanitize(fallbackCuratorReply({
latest,
task,
subtask: input.subtask,
targetRole,
}));
usedFallbackReply = true;
}
if (!reply) {
reply = sanitize(fallbackCuratorReply({
latest,
task,
subtask: input.subtask,
targetRole,
}));
usedFallbackReply = true;
}
let statusUpdate = await evaluateSubtaskStatus({
@@ -530,6 +629,12 @@ export async function runCuratorChat(input: {
confidence: Math.max(statusUpdate.confidence, 0.9),
};
}
if (usedFallbackReply && statusUpdate.status === "needs_more_context" && !statusUpdate.nextMissingInfo) {
statusUpdate = {
...statusUpdate,
summary: reply,
};
}
if (isPreviewHandoffService(task) && !isInitialOpen && usefulUserMessages(conversationHistory).length >= 1) {
statusUpdate = {
status: "handoff_ready",

View File

@@ -1,5 +1,22 @@
import { recordGrowEvent } from "../../events/record-grow-event.js";
function curatorDedupeKey(input: {
userId: string;
type: string;
payload?: Record<string, unknown>;
}) {
const payload = input.payload ?? {};
const stableId =
payload.taskId ??
payload.sprintId ??
payload.startDate ??
payload.sourceEventId ??
payload.eventId ??
payload.date;
return `${input.userId}:${input.type}:${stableId ?? Date.now()}`;
}
export async function emitCuratorEvent(input: {
userId: string;
type: string;
@@ -14,6 +31,6 @@ export async function emitCuratorEvent(input: {
occurredAt: new Date().toISOString(),
mission: input.mission,
payload: input.payload ?? {},
dedupeKey: `${input.userId}:${input.type}:${input.payload?.taskId ?? input.payload?.date ?? Date.now()}`,
dedupeKey: curatorDedupeKey(input),
}, { userId: input.userId, source: "curator-v1" });
}

View File

@@ -41,6 +41,11 @@ export function v1CuratorRoutes() {
return c.json(await curatorActor.getToday({ userId, date: c.req.query("date") }));
});
app.get("/sprint", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.getSprint({ userId, date: c.req.query("date") }));
});
app.post("/chat", async (c) => {
const userId = c.get("userId");
const body = chatSchema.parse(await c.req.json());

View File

@@ -1,74 +1,42 @@
import {
buildCuratorServiceRoute,
getServiceActionLabel,
getServiceCompletionEvents,
getServiceDisplayName,
getServiceToolName,
} from "../../services/service-registry.js";
import type { CuratorServiceId, CuratorTask } from "./curator-types.js";
export function serviceRoute(input: {
type ServiceRouteInput = {
serviceId?: CuratorServiceId;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
taskId?: string;
targetRole?: string;
roleplayScenario?: string;
}) {
const params = new URLSearchParams({ source: "curator-v1" });
if (input.missionInstanceId) params.set("missionInstanceId", input.missionInstanceId);
if (input.missionId) params.set("missionId", input.missionId);
if (input.stageId) params.set("stageId", input.stageId);
if (input.taskId) params.set("curatorTaskId", input.taskId);
durationMinutes?: number;
difficulty?: string;
personaId?: string;
requestedMode?: string;
roleplayBrief?: string;
};
if (input.serviceId === "interview-service") {
params.set("role", input.targetRole?.trim() || "Product Manager");
params.set("type", "behavioral");
params.set("difficulty", "medium");
params.set("duration", "5");
return `/agents/interview/preview?${params.toString()}`;
}
if (input.serviceId === "roleplay-service") {
params.set("role", input.targetRole?.trim() || "Product Manager");
params.set("type", "custom");
params.set("difficulty", "medium");
params.set("duration", "5");
if (input.roleplayScenario?.trim()) params.set("scenario_name", input.roleplayScenario.trim());
return `/agents/roleplay/preview?${params.toString()}`;
}
const suffix = params.toString();
if (input.serviceId === "resume-service") return `/agents/resume?${suffix}`;
if (input.serviceId === "qscore-service") return `/agents/qscore?${suffix}`;
if (input.serviceId === "social-branding-service") return `/social?${suffix}`;
if (input.serviceId === "matchmaking-service") return `/pathways?${suffix}`;
return `/missions/active${input.missionInstanceId ? `?missionInstanceId=${encodeURIComponent(input.missionInstanceId)}` : ""}`;
export function serviceRoute(input: ServiceRouteInput) {
return buildCuratorServiceRoute(input);
}
export function serviceName(serviceId?: CuratorServiceId, fallback = "Mission planner") {
if (serviceId === "interview-service") return "Interview service";
if (serviceId === "roleplay-service") return "Roleplay service";
if (serviceId === "resume-service") return "Resume service";
if (serviceId === "qscore-service") return "Q Score service";
if (serviceId === "social-branding-service") return "Social branding service";
if (serviceId === "matchmaking-service") return "Pathways service";
return fallback;
return getServiceDisplayName(serviceId, fallback);
}
export function serviceToolName(serviceId?: CuratorServiceId) {
if (serviceId === "interview-service") return "prepare_interview_preview";
if (serviceId === "roleplay-service") return "prepare_roleplay_preview";
if (serviceId === "resume-service") return "prepare_resume_upload";
if (serviceId === "qscore-service") return "prepare_qscore_review";
return "prepare_mission_step";
return getServiceToolName(serviceId);
}
export function completionEventsForService(serviceId?: CuratorServiceId) {
if (serviceId === "interview-service") return ["interview.configured", "interview.review_completed", "interview.completed"];
if (serviceId === "roleplay-service") return ["roleplay.configured", "roleplay.review_completed", "roleplay.completed"];
if (serviceId === "resume-service") return ["resume.analysis_completed", "resume.parsed", "resume.updated"];
if (serviceId === "qscore-service") return ["qscore.updated", "qscore.signal_projected"];
return ["curator.task.completed"];
return getServiceCompletionEvents(serviceId);
}
export function actionLabel(task: CuratorTask) {
if (task.serviceId === "interview-service") return "Open interview preview";
if (task.serviceId === "roleplay-service") return "Open roleplay preview";
if (task.serviceId === "resume-service") return "Open resume workspace";
if (task.serviceId === "qscore-service") return "Review Q Score";
return task.cta || "Open";
return getServiceActionLabel(task);
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,9 +17,124 @@ async function findTask(userId: string, taskId: string, date: string) {
return tasks.find((task) => task.id === taskId) ?? null;
}
export async function prepareHandoffForTask(userId: string, task: CuratorTask, serviceId = task.serviceId, targetRoleOverride?: string): Promise<CuratorServiceHandoff> {
function conciseRoleHint(value: string | undefined) {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return trimmed.length > 80 ? `${trimmed.slice(0, 77).trimEnd()}...` : trimmed;
}
function buildRoleplayBrief(task: CuratorTask, targetRole: string) {
return `Practice a realistic ${task.title.toLowerCase()} conversation for ${targetRole}. Include one pushback moment, concise answers, and a clear next step.`;
}
async function missionGoalHint(userId: string, task: CuratorTask) {
if (!task.missionInstanceId) return undefined;
const active = await listActiveMissionsPg(userId);
const match = active.find((item) => item.mission.instanceId === task.missionInstanceId);
const goal = typeof match?.mission.goal === "string" ? match.mission.goal : undefined;
return conciseRoleHint(goal);
}
function asText(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return undefined;
}
function reviewField(payload: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const direct = asText(payload[key]);
if (direct) return direct;
}
const review = payload.review && typeof payload.review === "object" ? payload.review as Record<string, unknown> : undefined;
if (!review) return undefined;
for (const key of keys) {
const nested = asText(review[key]);
if (nested) return nested;
}
return undefined;
}
async function latestInterviewResumeEvidence(userId: string) {
const rows = await db.select({
id: growEvents.id,
type: growEvents.type,
source: growEvents.source,
payload: growEvents.payload,
occurredAt: growEvents.occurredAt,
}).from(growEvents)
.where(and(
eq(growEvents.userId, userId),
inArray(growEvents.type as any, [
"interview.review_completed",
"interview.completed",
"roleplay.review_completed",
"roleplay.completed",
]),
))
.orderBy(desc(growEvents.occurredAt))
.limit(5);
const latest = rows[0];
if (!latest) return null;
const payload = latest.payload ?? {};
const strongestAnswer = reviewField(payload, [
"strongest_answer",
"strongestAnswer",
"best_answer",
"bestAnswer",
"top_answer",
"topAnswer",
]);
const improvementArea = reviewField(payload, [
"improvement_area",
"improvementArea",
"biggest_gap",
"biggestGap",
"coaching_note",
"coachingNote",
]);
const summary = reviewField(payload, [
"summary",
"feedback_summary",
"feedbackSummary",
"overall_feedback",
"overallFeedback",
]);
const carryForward = [
summary ? `Review summary: ${summary}` : undefined,
strongestAnswer ? `Strongest answer to convert into proof: ${strongestAnswer}` : undefined,
improvementArea ? `Weakest area to repair in resume positioning: ${improvementArea}` : undefined,
].filter((item): item is string => Boolean(item));
return {
eventId: latest.id,
source: latest.source,
type: latest.type,
occurredAt: latest.occurredAt,
carryForward,
};
}
export async function prepareHandoffForTask(
userId: string,
task: CuratorTask,
serviceId = task.serviceId,
targetRoleOverride?: string,
): Promise<CuratorServiceHandoff> {
if (!serviceId) throw new Error("Task has no service handoff.");
const targetRole = fallbackCuratorRole(targetRoleOverride ?? await resolveCuratorTargetRole({ userId, task }));
const resolvedTargetRole =
targetRoleOverride ??
(await missionGoalHint(userId, task)) ??
(await resolveCuratorTargetRole({ userId, task }));
const targetRole = fallbackCuratorRole(resolvedTargetRole);
const route = serviceRoute({
serviceId,
missionId: task.missionId,
@@ -27,11 +142,15 @@ export async function prepareHandoffForTask(userId: string, task: CuratorTask, s
stageId: task.stageId,
taskId: task.id,
targetRole,
roleplayScenario: task.title,
durationMinutes: 5,
difficulty: "medium",
personaId: serviceId === "roleplay-service" ? "emma" : "payal",
requestedMode: "video",
roleplayBrief: serviceId === "roleplay-service" ? buildRoleplayBrief(task, targetRole) : undefined,
});
let actionId: string | undefined;
if (task.missionInstanceId) {
if (task.missionInstanceId && task.missionId !== "curator-sprint") {
const action = await createMissionAction({
userId,
missionInstanceId: task.missionInstanceId,
@@ -68,7 +187,7 @@ export async function prepareHandoffForTask(userId: string, task: CuratorTask, s
route,
actionId,
actionRoute: route,
actionLabel: actionLabel(task),
actionLabel: actionLabel({ ...task, serviceId }),
status: "prepared",
};
}
@@ -272,6 +391,35 @@ export function buildCuratorTools(ctx: { userId: string; date: string; conversat
},
}),
prepare_resume_from_interview_evidence: tool({
description: "Prepare a resume handoff that carries forward recent interview or roleplay review evidence into the proof task.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
if (!task) return { error: "task_not_found" };
const handoff = await prepareHandoffForTask(ctx.userId, task, "resume-service");
const evidence = await latestInterviewResumeEvidence(ctx.userId);
if (!evidence?.carryForward?.length) {
const interviewFallback = await prepareHandoffForTask(ctx.userId, task, "interview-service");
return {
handoff,
carryForward: [],
requiresInterviewEvidence: true,
recommendedNextAction: "No recent interview or roleplay review evidence is available yet. Run an interview rep first so the resume proof can be generated from real conversation evidence.",
fallbackHandoff: interviewFallback,
};
}
return {
handoff,
carryForward: evidence?.carryForward ?? [],
sourceEventId: evidence?.eventId,
sourceEventType: evidence?.type,
sourceService: evidence?.source,
sourceOccurredAt: evidence?.occurredAt,
};
},
}),
prepare_roleplay_setup: tool({
description: "Prepare roleplay setup handoff.",
inputSchema: z.object({ taskId: z.string().optional() }),

View File

@@ -11,6 +11,9 @@ export const curatorServiceIdSchema = z.enum([
export type CuratorServiceId = z.infer<typeof curatorServiceIdSchema>;
export const curatorTaskTypeSchema = z.enum(["measurement", "proof", "practice"]);
export type CuratorTaskType = z.infer<typeof curatorTaskTypeSchema>;
export const curatorTaskStatusSchema = z.enum([
"ready",
"started",
@@ -19,9 +22,16 @@ export const curatorTaskStatusSchema = z.enum([
"blocked",
]);
export const curatorWeekLifecycleSchema = z.enum(["done", "active", "upcoming"]);
export const curatorWeekPerformanceSchema = z.enum(["Missed", "Okayish", "Avg", "Excelling"]);
export const curatorTaskSchema = z.object({
id: z.string(),
date: z.string(),
dayIndex: z.number().int().min(1).max(30),
dayIndexInWeek: z.number().int().min(1).max(7),
weekIndex: z.number().int().min(1).max(5),
taskType: curatorTaskTypeSchema,
title: z.string(),
subtitle: z.string(),
missionId: z.string(),
@@ -39,7 +49,7 @@ export const curatorTaskSchema = z.object({
cta: z.string(),
context: z.array(z.object({ label: z.string(), value: z.string() })),
contextNarrative: z.string(),
subtasks: z.array(z.string()).min(1),
subtasks: z.array(z.string()).length(3),
signals: z.array(z.string()),
completionEvents: z.array(z.string()),
source: z.enum(["curator-v1", "mission-registry", "service-registry"]),
@@ -51,6 +61,38 @@ export const curatorStreakSchema = z.object({
lastCompletedDate: z.string().nullable(),
});
export const curatorPlanDaySchema = z.object({
date: z.string(),
dayIndex: z.number().int().min(1).max(30),
dayIndexInWeek: z.number().int().min(1).max(7),
weekIndex: z.number().int().min(1).max(5),
weekTheme: z.string(),
weekSummary: z.string(),
focus: z.string().optional(),
plannedServices: z.array(curatorServiceIdSchema).max(3).default([]),
generationStatus: z.enum(["seeded", "generated", "adapted"]).default("seeded"),
adaptationReason: z.string().optional(),
completedCount: z.number().int().min(0),
totalCount: z.number().int().min(0),
unlockState: z.enum(["completed", "active", "upcoming"]),
tasks: z.array(curatorTaskSchema),
});
export const curatorWeekSchema = z.object({
weekIndex: z.number().int().min(1).max(5),
title: z.string(),
theme: z.string(),
summary: z.string(),
startDayIndex: z.number().int().min(1).max(30),
endDayIndex: z.number().int().min(1).max(30),
lifecycle: curatorWeekLifecycleSchema,
performance: curatorWeekPerformanceSchema,
completedTaskCount: z.number().int().min(0),
totalTaskCount: z.number().int().min(0),
completionPercent: z.number().min(0).max(100),
days: z.array(curatorPlanDaySchema).min(2).max(7),
});
export const curatorPlanSchema = z.object({
id: z.string(),
userId: z.string(),
@@ -58,12 +100,9 @@ export const curatorPlanSchema = z.object({
endDate: z.string(),
goals: z.array(z.string()),
generatedAt: z.string(),
days: z.array(z.object({
date: z.string(),
dayIndex: z.number().int().min(1),
theme: z.string(),
tasks: z.array(curatorTaskSchema),
})),
durationDays: z.literal(30),
weeks: z.array(curatorWeekSchema).length(5),
days: z.array(curatorPlanDaySchema).length(30),
streak: curatorStreakSchema,
source: z.literal("curator-v1"),
});
@@ -81,10 +120,29 @@ export const curatorImprovementSignalSchema = z.object({
status: z.enum(["created", "applied", "skipped"]).default("created"),
});
export const curatorSprintResponseSchema = z.object({
date: z.string(),
sprintId: z.string(),
plan: curatorPlanSchema,
activeWeek: curatorWeekSchema,
activeWeekIndex: z.number().int().min(1).max(5),
activeDay: curatorPlanDaySchema,
activeDayIndex: z.number().int().min(1).max(30),
todayTasks: z.array(curatorTaskSchema).length(3),
streak: curatorStreakSchema,
completedCount: z.number().int().min(0),
totalCount: z.number().int().min(0),
overallProgressPercent: z.number().min(0).max(100),
source: z.literal("curator-v1"),
});
export type CuratorTask = z.infer<typeof curatorTaskSchema>;
export type CuratorPlanDay = z.infer<typeof curatorPlanDaySchema>;
export type CuratorWeek = z.infer<typeof curatorWeekSchema>;
export type CuratorPlan = z.infer<typeof curatorPlanSchema>;
export type CuratorStreak = z.infer<typeof curatorStreakSchema>;
export type CuratorImprovementSignal = z.infer<typeof curatorImprovementSignalSchema>;
export type CuratorSprintResponse = z.infer<typeof curatorSprintResponseSchema>;
export type CuratorTodayResponse = {
date: string;

View File

@@ -71,9 +71,27 @@ function roleFromTask(task?: CuratorTask) {
export function inferRoleFromText(text?: string) {
const value = text?.trim();
if (!value) return undefined;
if (value.length <= 80 && ROLE_PATTERN.test(value)) return value.replace(/[.?!]+$/, "").trim();
const explicit = value.match(/(?:targeting|for|as|role is|role:)\s+([A-Za-z][A-Za-z0-9 +/&.-]{2,60})/i)?.[1];
return explicit?.replace(/[.?!]+$/, "").trim();
const cleanRole = (raw?: string) => {
if (!raw) return undefined;
const normalized = raw
.replace(/^(?:i am |i'm |im )/i, "")
.replace(/^(?:targeting|aiming for|looking for)\s+/i, "")
.replace(/[.?!,]+$/, "")
.replace(/\broles?\b/gi, "")
.replace(/\b(this|next)\s+(week|month|quarter|year)\b/gi, "")
.replace(/\b(right now|currently)\b/gi, "")
.replace(/\s+/g, " ")
.trim();
if (!normalized) return undefined;
if (/^pm$/i.test(normalized)) return "Product Manager";
if (/^swe$/i.test(normalized)) return "Software Engineer";
return normalized;
};
const explicit = value.match(/(?:targeting|for|as|toward|towards|role is|role:)\s+([A-Za-z][A-Za-z0-9 +/&.-]{2,60}?)(?=\s+roles?\b|\s+(?:this|next)\s+(?:week|month|quarter|year)\b|[.?!,]|$)/i)?.[1];
if (explicit) return cleanRole(explicit);
if (value.length <= 80 && ROLE_PATTERN.test(value)) return cleanRole(value);
return undefined;
}
export async function resolveCuratorTargetRole(input: {