33 Commits

Author SHA1 Message Date
17a888bd67 feat: update curator schemas to support 6-week plans and enhance user context
- Increased weekIndex max from 5 to 6 in curator task, plan day, and week schemas.
- Adjusted days array in curator week schema to allow a minimum of 1 day.
- Modified weeks array in curator plan schema to accept between 5 and 6 weeks.
- Enhanced CuratorUserContext type to include detailed user information and QScore.
- Introduced Curator ICP Playbooks for various user profiles with structured actions.
- Implemented onboarding loop for user onboarding completion and notification.
- Added prompt builder for generating structured 30-day plans based on user context and playbooks.
2026-06-22 22:24:27 +05:30
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
9b6f887c3f Fix curator preview handoffs 2026-06-17 08:09:34 +05:30
Sai-karthik
89e1be4b12 Stabilize curator handoff generation 2026-06-15 12:50:25 +00:00
Sai-karthik
2ccc0ea48d Return structured curator handoffs 2026-06-15 11:08:29 +00:00
Sai-karthik
3fecfdc403 Fix curator prompt leakage 2026-06-15 10:38:33 +00:00
Sai-karthik
37fa8f13f4 Remove curator fallback questions 2026-06-15 09:30:29 +00:00
Sai-karthik
9bb2c0de3f Require service events for curator service tasks 2026-06-15 08:57:29 +00:00
Sai-karthik
368410e9d8 Fix curator subtask chat state 2026-06-15 08:36:21 +00:00
Sai-karthik
4b23dd0905 Make curator chats live generated 2026-06-14 20:10:41 +00:00
Sai-karthik
60b1df6892 Fix curator task chat scoping 2026-06-14 19:33:24 +00:00
Sai-karthik
ed7233d6e2 Route curator chat through conversation actor 2026-06-14 19:00:28 +00:00
Sai-karthik
4a20816ba0 Refine V1 curator task context 2026-06-14 15:59:59 +00:00
Sai-karthik
036aff1d1d Implement V1 curator flow 2026-06-14 15:10:39 +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
Sai-karthik
41b0c69326 Implement mission chat actors and analytics 2026-06-14 10:06:34 +00:00
92ab414048 feat: enhance mission detail handling and update hrefs across services 2026-06-10 02:49:18 +05:30
58 changed files with 6414 additions and 172 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

View File

@@ -0,0 +1 @@
ALTER TABLE "grow_conversations" ADD COLUMN IF NOT EXISTS "metadata" jsonb;

View File

@@ -78,6 +78,13 @@
"when": 1780481500000,
"tag": "0010_mission_actions",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1780481600000,
"tag": "0011_conversation_metadata",
"breakpoints": true
}
]
}

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

@@ -0,0 +1,28 @@
# Curator Streak Chat Prompt
You are the GrowQR V1 Curator in a daily or weekly streak chat modal.
Your job is to move the user from a focused streak suggestion to a service preview CTA quickly. The dashboard renders the actual CTA card, so your chat text must never include internal URLs, route names, API paths, setup paths, JSON, or tool names.
## Conversation Rules
- Focus only on the clicked subtask.
- Ask at most one clarifying question before a handoff is ready.
- If the target role is known from onboarding, do not ask for it again.
- If no target role is known and the service is interview or roleplay, ask exactly: `What role are you targeting?`
- If the user gives a vague answer after one question, use your best guess and proceed.
- When enough context exists, summarize the captured intent in one short sentence and stop.
- Do not ask the next subtask question.
- Do not mention setup screens, preview URLs, backend services, actors, tools, or route paths.
- Use ASCII punctuation only.
## Service Defaults
- Interview defaults: `type=behavioral`, `difficulty=medium`, `duration=5`.
- Roleplay defaults: custom scenario, `difficulty=medium`, `duration=5`.
- Prefer onboarding-derived role when available.
- If no role is available after the single follow-up, use `Product Manager` as the MVP fallback.
## Tone
Be concise, calm, and action-oriented. The user should feel like the preview is prepared, not like they are filling out a form.

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

@@ -0,0 +1,151 @@
import { actor } from "rivetkit";
import { count, desc, eq, sql } from "drizzle-orm";
import { db } from "../../db/client.js";
import {
growActiveMissions,
growEvents,
growQscoreLatest,
growQscoreProjectionState,
growQscoreSignals,
missionActions,
} from "../../db/schema.js";
import { listActiveMissionsPg } from "../../grow/persistence.js";
import { listMissionActions } from "../../missions/actions.js";
async function scalarCount(table: any, where?: any) {
const query = db.select({ value: count() }).from(table);
const rows = where ? await query.where(where) : await query;
return rows[0]?.value ?? 0;
}
async function platformAnalytics() {
const [
totalEvents,
serviceEvents,
missionEvents,
activeMissions,
completedMissions,
totalActions,
doneActions,
qscoreSignalCount,
] = await Promise.all([
scalarCount(growEvents),
scalarCount(growEvents, eq(growEvents.category, "service")),
scalarCount(growEvents, eq(growEvents.category, "mission")),
scalarCount(growActiveMissions),
scalarCount(growActiveMissions, eq(growActiveMissions.status, "completed")),
scalarCount(missionActions),
scalarCount(missionActions, eq(missionActions.status, "done")),
scalarCount(growQscoreSignals),
]);
const serviceUsage = await db
.select({
source: growEvents.source,
type: growEvents.type,
count: sql<number>`count(*)::int`,
})
.from(growEvents)
.where(eq(growEvents.category, "service"))
.groupBy(growEvents.source, growEvents.type)
.orderBy(sql`count(*) desc`)
.limit(20);
return {
kind: "platform",
generatedAt: new Date().toISOString(),
totals: {
events: totalEvents,
serviceEvents,
missionEvents,
activeMissions,
completedMissions,
missionActions: totalActions,
completedActions: doneActions,
qscoreSignals: qscoreSignalCount,
},
serviceUsage,
};
}
async function userQscoreAnalytics(userId: string) {
const [projection] = await db
.select()
.from(growQscoreProjectionState)
.where(eq(growQscoreProjectionState.userId, userId))
.limit(1);
const latestSignals = await db
.select()
.from(growQscoreLatest)
.where(eq(growQscoreLatest.userId, userId))
.orderBy(desc(growQscoreLatest.updatedAt))
.limit(50);
const signalTimeline = await db
.select()
.from(growQscoreSignals)
.where(eq(growQscoreSignals.userId, userId))
.orderBy(desc(growQscoreSignals.occurredAt))
.limit(100);
return {
kind: "user-qscore",
userId,
generatedAt: new Date().toISOString(),
current: projection
? {
score: projection.score,
signalCount: projection.signalCount,
dimensions: projection.dimensions,
summary: projection.summary,
updatedAt: projection.updatedAt.toISOString(),
}
: null,
latestSignals,
signalTimeline,
globalComparison: {
status: "placeholder",
percentile: null,
cohort: null,
sampleSize: null,
note: "Global comparison is reserved for the cohort-backed analytics release.",
},
};
}
async function userActivityAnalytics(userId: string) {
const [events, activeMissions, actions] = await Promise.all([
db.select().from(growEvents).where(eq(growEvents.userId, userId)).orderBy(desc(growEvents.occurredAt)).limit(100),
listActiveMissionsPg(userId),
listMissionActions(userId, { openOnly: false }),
]);
return {
kind: "user-activity",
userId,
generatedAt: new Date().toISOString(),
events,
activeMissions: activeMissions.map((item) => item.mission),
actions,
};
}
export const analyticsActor = actor({
options: { name: "Analytics Actor", icon: "chart-no-axes-column", noSleep: true },
state: { updatedAt: Date.now() },
actions: {
getPlatform: async (c) => {
c.state.updatedAt = Date.now();
return platformAnalytics();
},
getUserQscore: async (c, input: { userId: string }) => {
c.state.updatedAt = Date.now();
return userQscoreAnalytics(input.userId);
},
getUserActivity: async (c, input: { userId: string }) => {
c.state.updatedAt = Date.now();
return userActivityAnalytics(input.userId);
},
},
});

View File

@@ -0,0 +1 @@
export { analyticsActor } from "./analytics-actor.js";

View File

@@ -1,13 +1,35 @@
import { createOpenAI } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
import { generateText, stepCountIs, streamText, tool } from "ai";
import { createClient } from "rivetkit/client";
import { z } from "zod";
import type { ConversationMessage } from "./types.js";
import { config } from "../../config.js";
import { listMissionDefinitions } from "../../missions/registry.js";
import { createMissionAction, listMissionActions } from "../../missions/actions.js";
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 goals.
When you learn durable information, call the memory tools. For now these tools
are intentionally stubbed so this actor can stay isolated and unwired.`;
Keep answers concise, practical, and focused on the user's active mission.
Use tools when you need mission state, registry capabilities, memory, or a service handoff.
Service tools prepare handoffs and mission actions; the interview, roleplay, and resume services own their detailed flows.
Style rules:
- Use ASCII punctuation only. Do not use em dash or en dash.
- Do not start with filler words like Perfect, Great, Absolutely, or Sure.
- For Daily Mission turns, ask one short direct question. Keep it under 24 words.`;
export type ConversationRuntimeContext = {
userId?: string;
conversationId?: string;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
source?: string;
systemAddendum?: string;
};
function normalizeModel(model: string): string {
if (config.llmProvider === "opencode" && model.startsWith("opencode/")) {
@@ -31,46 +53,206 @@ export function getConversationModel() {
return conversationProvider.chat(normalizeModel(modelId));
}
export function buildModelMessages(messages: ConversationMessage[]) {
type ModelConversationMessage = Pick<ConversationMessage, "role" | "content">;
export function buildModelMessages(messages: ModelConversationMessage[]) {
return messages.map((message) => ({
role: message.role,
content: message.content,
}));
}
export function streamConversationResponse(messages: ConversationMessage[]) {
let _client: any | null = null;
function getRivetClient() {
return (_client ??= createClient<any>(config.rivetClientEndpoint));
}
function safeAgentRegistry() {
try {
return getSubAgentModules();
} catch {
return [
{ id: "interview", name: "Interview Agent", role: "Interview Coach", service: "interview-service", description: "Interview prep specialist.", toolNames: ["prepare_interview_handoff"] },
{ id: "roleplay", name: "Roleplay Agent", role: "Roleplay Coach", service: "roleplay-service", description: "Workplace conversation practice specialist.", toolNames: ["prepare_roleplay_handoff"] },
{ id: "resume", name: "Resume Agent", role: "Resume Agent", service: "resume-service", description: "Resume positioning and optimization specialist.", toolNames: ["prepare_resume_handoff"] },
{ id: "qscore", name: "Q Score Agent", role: "Q Score Analyst", service: "qscore-service", description: "Readiness score analyst.", toolNames: ["explain_qscore"] },
];
}
}
async function resolveMission(userId: string, missionInstanceId?: string) {
if (missionInstanceId) return getActiveMissionPg(userId, missionInstanceId);
const active = await listActiveMissionsPg(userId);
return active[0] ?? null;
}
function serviceHref(input: {
serviceId: "interview-service" | "roleplay-service" | "resume-service";
missionInstanceId: string;
missionId: string;
stageId?: string;
goal?: string;
}) {
return buildMissionServiceRoute(input);
}
function buildConversationTools(ctx: ConversationRuntimeContext = {}) {
const userId = ctx.userId;
return {
listMissionState: tool({
description: "Read mission snapshot, open actions, and suggestions for the current or requested mission.",
inputSchema: z.object({ missionInstanceId: z.string().optional() }),
execute: async ({ missionInstanceId }) => {
if (!userId) return { error: "missing_user_context" };
const active = await resolveMission(userId, missionInstanceId ?? ctx.missionInstanceId);
if (!active) return { mission: null, actions: [], suggestions: [] };
return {
mission: active.mission,
snapshot: active.snapshot,
actions: await listMissionActions(userId, { missionInstanceId: active.mission.instanceId }),
suggestions: await listMissionSuggestionsPg(userId, active.mission.instanceId),
};
},
}),
listRegistryCapabilities: tool({
description: "List deterministic registry missions, service capabilities, and specialist agents. This does not rank or generate missions.",
inputSchema: z.object({}),
execute: async () => ({
missions: listMissionDefinitions().map((mission) => ({
id: mission.id,
missionId: mission.missionId,
title: mission.title,
shortTitle: mission.shortTitle,
actorBacked: mission.actorBacked,
modules: mission.modules.map((module) => ({
id: module.id,
title: module.title,
role: module.role,
service: module.service,
})),
})),
services: listServiceCapabilities(),
agents: safeAgentRegistry(),
}),
}),
prepareServiceHandoff: tool({
description: "Prepare an interview, roleplay, or resume handoff as a mission action and return the UI route. Do not directly complete the service.",
inputSchema: z.object({
serviceId: z.enum(["interview-service", "roleplay-service", "resume-service"]),
missionInstanceId: z.string().optional(),
stageId: z.string().optional(),
title: z.string().optional(),
body: z.string().optional(),
goal: z.string().optional(),
}),
execute: async ({ serviceId, missionInstanceId, stageId, title, body, goal }) => {
if (!userId) return { error: "missing_user_context" };
const active = await resolveMission(userId, missionInstanceId ?? ctx.missionInstanceId);
if (!active) return { error: "mission_not_found" };
const selectedStageId = stageId ?? ctx.stageId ?? active.mission.currentStageId;
const href = serviceHref({
serviceId,
missionInstanceId: active.mission.instanceId,
missionId: active.mission.missionId,
stageId: selectedStageId,
goal: goal ?? active.mission.goal,
});
const agent = safeAgentRegistry().find((item) => item.service === serviceId);
const action = await createMissionAction({
userId,
missionInstanceId: active.mission.instanceId,
missionId: active.mission.missionId,
stageId: selectedStageId,
agentId: agent?.id ?? serviceId,
agentName: agent?.name ?? serviceId,
baseAgent: agent?.role,
serviceId,
toolName: `prepare_${serviceId.replace("-service", "")}_handoff`,
mode: "suggestion",
status: "queued",
title: title ?? `Open ${agent?.name ?? serviceId}`,
body: body ?? `Continue this mission in ${agent?.name ?? serviceId}.`,
prompt: goal ?? active.mission.goal,
payload: { href, goal: goal ?? active.mission.goal, source: "conversation-actor" },
idempotencyKey: `conversation-handoff:${active.mission.instanceId}:${selectedStageId ?? "mission"}:${serviceId}`,
priority: 25,
urgency: "today",
});
return { action, href, serviceId, missionInstanceId: active.mission.instanceId };
},
}),
askSubAgent: tool({
description: "Ask a specialist sub-agent for a focused answer without starting a service session.",
inputSchema: z.object({
agentId: z.enum(["interview", "roleplay", "resume", "qscore"]),
question: z.string(),
context: z.string().optional(),
}),
execute: async ({ agentId, question, context }) => {
const agent = safeAgentRegistry().find((item) => item.id === agentId);
const answer = await generateText({
model: getConversationModel(),
system: `You are ${agent?.name ?? agentId}, a GrowQR specialist. Be concise and practical. Do not start external tools.`,
prompt: `Question:\n${question}\n\nContext:\n${context ?? "No extra context."}`,
});
return { agent, answerMd: answer.text };
},
}),
readMemory: tool({
description: "Read a markdown memory file for this user.",
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => {
if (!userId) return { error: "missing_user_context" };
return { memory: await getRivetClient().memoryActor.getOrCreate([userId]).read(path) };
},
}),
writeMemory: tool({
description: "Write a durable markdown memory file for this user.",
inputSchema: z.object({
path: z.string(),
contentMd: z.string(),
tags: z.array(z.string()).optional(),
}),
execute: async ({ path, contentMd, tags }) => {
if (!userId) return { error: "missing_user_context" };
const result = await getRivetClient().memoryActor.getOrCreate([userId]).write({ path, contentMd, tags });
return { path, queued: result.queued };
},
}),
};
}
export function streamConversationResponse(messages: ModelConversationMessage[], context: ConversationRuntimeContext = {}) {
const system = [SYSTEM_PROMPT, context.systemAddendum].filter(Boolean).join("\n\n");
if (context.source === "daily-mission-start") {
return streamText({
model: getConversationModel(),
system,
messages: buildModelMessages(messages),
});
}
return streamText({
model: getConversationModel(),
system: SYSTEM_PROMPT,
system,
messages: buildModelMessages(messages),
tools: {
readMemory: tool({
description: "Read a markdown memory file. Stubbed until memoryActor is wired.",
inputSchema: z.object({
path: z.string().describe("Memory path, e.g. /profile.md"),
}),
execute: async ({ path }) => ({
path,
found: false,
content: "",
note: "memoryActor is not wired yet",
}),
}),
writeMemory: tool({
description: "Write a markdown memory file. Stubbed until memoryActor is wired.",
inputSchema: z.object({
path: z.string(),
contentMd: z.string(),
reason: z.string().optional(),
}),
execute: async ({ path, contentMd, reason }) => ({
path,
bytes: contentMd.length,
reason,
saved: false,
note: "memoryActor is not wired yet",
}),
}),
},
tools: buildConversationTools(context),
stopWhen: stepCountIs(5),
});
}
export async function generateConversationResponse(messages: ModelConversationMessage[], context: ConversationRuntimeContext = {}) {
const system = [SYSTEM_PROMPT, context.systemAddendum].filter(Boolean).join("\n\n");
return generateText({
model: getConversationModel(),
system,
messages: buildModelMessages(messages),
tools: buildConversationTools(context),
stopWhen: stepCountIs(5),
});
}

View File

@@ -25,6 +25,10 @@ function conversationIdFromKey(key: unknown[]) {
return String(key[1] ?? key[0] ?? "default");
}
function userIdFromKey(key: unknown[]) {
return String(key[0] ?? "");
}
function toPublicMessage(row: typeof conversationMessages.$inferSelect): ConversationMessage {
return {
id: row.id,
@@ -118,7 +122,14 @@ export const conversationActor = actor({
c.broadcast("status", c.state.status);
try {
const result = streamConversationResponse(history);
const result = streamConversationResponse(history, {
userId: body.context?.userId ?? userIdFromKey(c.key),
conversationId,
missionInstanceId: body.context?.missionInstanceId,
missionId: body.context?.missionId,
stageId: body.context?.stageId,
source: body.context?.source,
});
let content = "";
for await (const delta of result.textStream) {

View File

@@ -18,6 +18,14 @@ export type ConversationMessage = {
export type ConversationQueueMessage = {
text: string;
sender?: string;
context?: {
userId?: string;
conversationId?: string;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
source?: string;
};
};
export type ConversationResponseEvent = {

View File

@@ -6,6 +6,7 @@ import { conversationActor } from "./conversation/index.js";
import { memoryActor } from "./memory/index.js";
import { growActor } from "./grow/index.js";
import { userEventActor } from "./events/index.js";
import { analyticsActor } from "./analytics/index.js";
import {
careerTransitionMissionActor,
interviewToOfferMissionActor,
@@ -18,6 +19,7 @@ export const registry = setup({
use: {
growActor,
userEventActor,
analyticsActor,
conversationActor,
memoryActor,
interviewToOfferMissionActor,

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();
}

449
src/agents/daily-mission.ts Normal file
View File

@@ -0,0 +1,449 @@
import { generateText } from "ai";
import { z } from "zod";
import { getConversationModel, streamConversationResponse } from "../actors/conversation/agent.js";
export const dailyMissionTaskSchema = z.object({
day: z.number().optional(),
questId: z.string().optional(),
questTitle: z.string(),
subtaskIndex: z.number().optional(),
subtask: z.string(),
service: z.string().optional(),
route: z.string().optional(),
intro: z.string().optional(),
context: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
signals: z.array(z.string()).optional(),
});
export const dailyMissionMessageSchema = z.object({
role: z.enum(["user", "assistant"]),
content: z.string().min(1).max(4000),
});
export type DailyMissionTask = z.infer<typeof dailyMissionTaskSchema>;
export type DailyMissionMessage = z.infer<typeof dailyMissionMessageSchema>;
const dailyMissionResponseSchema = z.object({
reply: z.string(),
completed: z.boolean().default(false),
updateSummary: z.string().optional(),
actionLabel: z.string().optional(),
actionRoute: z.string().optional(),
});
export type DailyMissionResult = z.infer<typeof dailyMissionResponseSchema>;
type DailyMissionAgentInput = {
userId: string;
task: DailyMissionTask;
messages: DailyMissionMessage[];
missionInstanceId?: string;
missionId?: string;
stageId?: string;
conversationId?: string;
};
function stripJsonFence(text: string) {
return text
.trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
}
function parseDailyMissionResponse(text: string) {
const normalize = (value: z.infer<typeof dailyMissionResponseSchema>) => {
const nested = maybeParseJsonReply(value.reply);
return nested ? { ...value, ...nested } : value;
};
try {
return normalize(dailyMissionResponseSchema.parse(JSON.parse(stripJsonFence(text))));
} catch {
return {
reply: cleanAssistantReply(text) || "I could not prepare the next step. Try again.",
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
}
function maybeParseJsonReply(text: string) {
try {
return dailyMissionResponseSchema.partial().parse(JSON.parse(stripJsonFence(text)));
} catch {
return undefined;
}
}
function cleanAssistantReply(text: string) {
const stripped = stripJsonFence(text);
const nested = maybeParseJsonReply(stripped);
if (nested?.reply) return nested.reply;
const match = stripped.match(/^\s*\{[\s\S]*"reply"\s*:\s*"([\s\S]*?)"[\s\S]*\}\s*$/);
const captured = match?.[1];
if (!captured) return stripped.trim();
try {
return JSON.parse(`"${captured}"`);
} catch {
return captured.replace(/\\"/g, '"').replace(/\\u2011/g, "-").trim();
}
}
function isInterviewMission(task: DailyMissionTask) {
const service = (task.service ?? "").toLowerCase();
const routePath = task.route ? new URL(task.route, "https://growqr.local").pathname.toLowerCase() : "";
const text = [task.questTitle, task.subtask].filter(Boolean).join(" ").toLowerCase();
if (service.includes("resume") || routePath.includes("/agents/resume")) return false;
if (service.includes("roleplay") || routePath.includes("/agents/roleplay")) return false;
if (service.includes("q score") || service.includes("qscore") || routePath.includes("/agents/qscore")) return false;
return service.includes("interview") || routePath.includes("/agents/interview") || text.includes("mock question");
}
function getInterviewActionRoute(task: DailyMissionTask) {
const source = new URL(task.route ?? "/agents/interview", "https://growqr.local");
const roleFromContext = task.context?.find((item) => item.label.toLowerCase().includes("role"))?.value;
const params = new URLSearchParams();
params.set("role", source.searchParams.get("role") ?? roleFromContext ?? "Product Manager");
params.set("type", source.searchParams.get("type") ?? "behavioral");
params.set("persona", "payal");
params.set("duration", "5");
params.set("difficulty", source.searchParams.get("difficulty") ?? "medium");
params.set("media", "video");
params.set("source", "daily-mission");
return `/agents/interview/preview?${params.toString()}`;
}
function compactAnswer(answer: string) {
return answer.length > 180 ? `${answer.slice(0, 177).trimEnd()}...` : answer;
}
function isConfidenceCheck(task: DailyMissionTask) {
const haystack = [task.questTitle, task.subtask, task.service, task.intro].filter(Boolean).join(" ").toLowerCase();
return haystack.includes("confidence check") || (haystack.includes("qx") && haystack.includes("confidence"));
}
function buildDailyMissionSystemPrompt(task: DailyMissionTask) {
return `You are Daily Mission, a focused GrowQR dashboard agent.
The user clicked on the main task called ${task.questTitle}${task.intro ? ` (${task.intro})` : ""} and the subtask within it called ${task.subtask}. You are going to act as an interface for the user to complete this subtask. Garner the right questions and get the input from the user.
The frontend will send "Start". From there onwards, start with your first message to the user.
Rules:
- Ask one short question or give one short action at a time.
- Do not start a larger mission, do not pitch other workflows, and do not send the user away.
- Keep the tone warm, practical, and easy to answer.
- When the user answer is enough to satisfy the subtask, mark the task complete by returning completed=true.
- Return a single JSON object only. Do not wrap the object in a string. Shape: {"reply":"message to show","completed":false,"updateSummary":"optional short saved update"}.`;
}
function buildDailyMissionStreamingSystemPrompt(task: DailyMissionTask) {
return `You are the GrowQR conversation actor attached to a mission actor.
The user clicked a mission task card:
${formatTask(task)}
Your job is to make the mission feel alive:
- The mission actor owns progress and completion.
- You own the chat turn and can prepare service handoffs.
- The service capability is ${task.service ?? "unknown service"} at ${task.route ?? "unknown route"}.
Rules:
- Plain text only. Do not return JSON.
- On the first "start" message, do not use a template line like "This is a service handoff".
- Ask the next natural question required to advance this exact mission stage.
- If the service is Resume, ask for the resume text/file and target role or section in a natural way.
- If the service is Interview, ask for role, round type, and the one thing they want to improve.
- If the service is Roleplay, ask for scenario, counterpart, and desired outcome.
- Keep it short, warm, and specific.`;
}
function withDailyMissionActionDefaults(task: DailyMissionTask, result: z.infer<typeof dailyMissionResponseSchema>) {
if (!result.completed || !isInterviewMission(task)) return result;
return {
...result,
actionLabel: result.actionLabel ?? "Generate room",
actionRoute: result.actionRoute ?? getInterviewActionRoute(task),
};
}
function serviceStartReply(task: DailyMissionTask) {
const service = (task.service ?? "").toLowerCase();
const routePath = task.route ? new URL(task.route, "https://growqr.local").pathname.toLowerCase() : "";
if (service.includes("resume") || routePath.includes("/agents/resume")) {
return "This is a Resume service handoff. Tell me the target role or resume section you want to improve, and I will save it on this mission stage.";
}
if (service.includes("roleplay") || routePath.includes("/agents/roleplay")) {
return "This is a Roleplay service handoff. Tell me the scenario you want to practice and the outcome you want from the conversation.";
}
if (service.includes("q score") || service.includes("qscore") || routePath.includes("/agents/qscore")) {
return "This is a Q Score check. Tell me the signal you want to improve or the readiness question you want scored.";
}
return undefined;
}
function latestUserMessage(messages: DailyMissionMessage[]) {
return [...messages].reverse().find((message) => message.role === "user")?.content.trim() ?? "";
}
function firstQuestionForTask(task: DailyMissionTask) {
const subtask = task.subtask.toLowerCase();
const title = task.questTitle.toLowerCase();
const intro = (task.intro ?? "").toLowerCase();
const service = (task.service ?? "").toLowerCase();
const routePath = task.route ? new URL(task.route, "https://growqr.local").pathname.toLowerCase() : "";
const isResume = service.includes("resume") || routePath.includes("/agents/resume");
const isInterview = service.includes("interview") || routePath.includes("/agents/interview");
const isRoleplay = service.includes("roleplay") || routePath.includes("/agents/roleplay");
const isPlanner = service.includes("mission planner") || title.includes("target role") || intro.includes("target role") || title.includes("career transition");
if (subtask.includes("save") || subtask.includes("next action")) {
if (isPlanner) return "What next career move should I save: target role, skill gap, or outreach action?";
if (isResume) return "What next action should I save: revise bullets, fill gaps, or generate talking points?";
if (isInterview) return "What interview prep action should I save for the student to do next?";
if (isRoleplay) return "What roleplay action should I save for the next practice round?";
return `What next action should I save for "${task.subtask}"?`;
}
if (subtask.includes("handoff") || subtask.includes("prepare")) {
if (isPlanner) return "Should I prepare a role shortlist, transition plan, or skill-gap plan?";
if (isResume) return "Which resume handoff should I prepare: role-fit proof, gap scan, or talking points?";
if (isInterview) return "What interview setup should I prepare: role, round type, and difficulty?";
if (isRoleplay) return "What roleplay setup should I prepare: scenario, counterpart, and outcome?";
return `What handoff should I prepare for "${task.subtask}"?`;
}
if (isResume) {
return "Please share your resume text or file and the target role.";
}
if (isInterview) {
return "What role and interview round should this prep focus on?";
}
if (isRoleplay) {
return "What conversation scenario do you want to practice?";
}
if (isPlanner) {
if (subtask.includes("target role")) {
return "What is your current role, target role, and biggest transition constraint?";
}
if (subtask.includes("requirements")) {
return "What requirement should we check first: skills, experience, location, or timeline?";
}
if (subtask.includes("review") || subtask.includes("recommendation")) {
return "Which role option should we review first, and what matters most to you?";
}
return "What target role are you considering, and what constraint should I account for?";
}
if (subtask.includes("target role")) {
return "What is your current role, target role, and biggest constraint?";
}
if (subtask.includes("requirements")) {
return "Which requirement should we check first: skills, experience, location, or timeline?";
}
return `What is the key detail for "${task.subtask}"?`;
}
function buildConversationActorMessages(input: DailyMissionAgentInput) {
const latest = latestUserMessage(input.messages);
const isStart = latest.toLowerCase() === "start";
const transcript = input.messages
.filter((message) => message.content.trim().toLowerCase() !== "start")
.slice(-10)
.map((message) => `${message.role === "user" ? "User" : "Assistant"}: ${message.content}`)
.join("\n");
return [{
id: `daily-mission-${Date.now()}-${Math.random().toString(16).slice(2)}`,
conversationId: input.conversationId ?? "daily-mission",
role: "user" as const,
sender: "Daily Mission UI",
createdAt: Date.now(),
content: `The user opened a mission-linked Daily Mission chat.
Mission task:
${formatTask(input.task)}
Mission ids:
- missionInstanceId: ${input.missionInstanceId ?? "not linked yet"}
- missionId: ${input.missionId ?? "unknown"}
- stageId: ${input.stageId ?? "unknown"}
${transcript ? `Conversation so far:\n${transcript}\n\n` : ""}${isStart
? "This is the first assistant turn. Ask one short direct question that advances this stage. No greeting. No filler. No em dash. No long paragraph. For resume, ask for resume text/file and target role."
: `The user just replied: ${latest}\nRespond as the GrowQR conversation agent. If the answer is enough for this subtask, acknowledge what will be saved. Keep it concise. Use ASCII punctuation only.`}`,
}];
}
function runInterviewRoomSetup(task: DailyMissionTask, messages: DailyMissionMessage[]) {
const latestUser = latestUserMessage(messages);
const subtask = task.subtask.toLowerCase();
const actionRoute = getInterviewActionRoute(task);
if (latestUser.toLowerCase() === "start") {
if (subtask.includes("generate") || subtask.includes("jump") || subtask.includes("start")) {
return {
reply: "The interview room setup is ready. Review the details and tap Generate room to open the interview UI.",
completed: true,
updateSummary: "Interview room setup ready.",
actionLabel: "Generate room",
actionRoute,
};
}
if (subtask.includes("pressure") || subtask.includes("difficulty")) {
return {
reply: "Pick the pressure level for the student: easy, medium, or hard.",
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
return {
reply: "What should this interview room be for? Share the role, round type, and one thing the student wants to improve.",
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
const updateSummary = compactAnswer(latestUser);
return {
reply: `Got it. I saved this for the student: ${updateSummary}. Tap Generate room when you are ready to open the interview UI.`,
completed: true,
updateSummary,
actionLabel: "Generate room",
actionRoute,
};
}
function formatTask(task: DailyMissionTask) {
const lines = [
task.day ? `Sprint day: ${task.day}` : undefined,
`Quest: ${task.questTitle}`,
`Subtask: ${task.subtask}`,
task.service ? `Service: ${task.service}` : undefined,
task.route ? `Service route: ${task.route}` : undefined,
task.intro ? `Quest intent: ${task.intro}` : undefined,
task.context?.length
? `Visible context: ${task.context.map((item) => `${item.label}: ${item.value}`).join("; ")}`
: undefined,
task.signals?.length ? `Signals to improve: ${task.signals.join(", ")}` : undefined,
].filter(Boolean);
return lines.map((line) => `- ${line}`).join("\n");
}
export async function runDailyMissionAgent(input: DailyMissionAgentInput) {
const started = input.messages.some(
(message) => message.role === "user" && message.content.trim().toLowerCase() === "start",
);
if (!started) {
return {
reply: "I am ready to begin this daily mission.",
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
if (isInterviewMission(input.task)) {
return runInterviewRoomSetup(input.task, input.messages);
}
const transcript = input.messages
.slice(-12)
.map((message) => `${message.role === "user" ? "Student" : "Daily Mission"}: ${message.content}`)
.join("\n");
try {
const result = await generateText({
model: getConversationModel(),
system: buildDailyMissionSystemPrompt(input.task),
prompt: `User id: ${input.userId}
Daily task context:
${formatTask(input.task)}
Conversation so far:
${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; 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,
};
}
}
export async function streamDailyMissionAgent(input: DailyMissionAgentInput) {
const started = input.messages.some(
(message) => message.role === "user" && message.content.trim().toLowerCase() === "start",
);
if (!started) {
return { kind: "static" as const, result: await runDailyMissionAgent(input) };
}
const latest = latestUserMessage(input.messages);
const userMessagesAfterStart = input.messages.filter((message) => message.role === "user");
const isStart = latest.toLowerCase() === "start";
if (isStart) {
return {
kind: "static" as const,
result: {
reply: firstQuestionForTask(input.task),
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
},
};
}
const result = streamConversationResponse(buildConversationActorMessages(input), {
userId: input.userId,
conversationId: input.conversationId,
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
stageId: input.stageId,
source: isStart ? "daily-mission-start" : "daily-mission",
});
return {
kind: "stream" as const,
textStream: result.textStream,
finalize: (reply: string): DailyMissionResult => {
const completed = !isStart && userMessagesAfterStart.length > 1 && reply.trim().length > 0;
return {
reply: cleanAssistantReply(reply),
completed,
updateSummary: completed ? compactAnswer(latest) : 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

@@ -280,6 +280,7 @@ export const growConversations = pgTable(
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull().default("Talk to Me"),
active: boolean("active").notNull().default(true),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},

View File

@@ -3,6 +3,7 @@ import { config } from "../config.js";
import { log } from "../log.js";
import { recordGrowEvent } from "./record-grow-event.js";
import { routeGrowEventToUserActor } from "./route-to-user-actor.js";
import { runCuratorOnboardingLoopForEventSafely } from "../v1/curator/curator-onboarding-loop.js";
// This file has two Redis ingestion modes:
// 1. Canonical GrowEvent stream: grow.events.raw — future service event bus.
@@ -150,6 +151,7 @@ async function recordAndRoute(input: unknown) {
await routeGrowEventToUserActor(event).catch((err) => {
log.warn({ err, eventId: event.id, userId: event.userId }, "failed to route grow event to user actor");
});
await runCuratorOnboardingLoopForEventSafely(event);
return event;
}

View File

@@ -1,4 +1,4 @@
import { asc, desc, eq, and } from "drizzle-orm";
import { asc, desc, eq, and, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import { growActiveMissions, growConversationMessages, growConversations, missionCoachRuns, missionSuggestions } from "../db/schema.js";
import type { GrowActiveMission, MissionSnapshot } from "../actors/missions/types.js";
@@ -42,6 +42,118 @@ export async function createConversationPg(userId: string, title = "Talk to Me")
return toConversation(row);
}
export async function ensureCuratorTaskConversationPg(input: {
userId: string;
curatorTaskId: string;
subtaskIndex?: number;
subtask?: string;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
title?: string;
}): Promise<GrowConversation> {
const curatorTaskKey = `${input.curatorTaskId}:${input.subtaskIndex ?? "task"}`;
const [existing] = await db
.select()
.from(growConversations)
.where(and(
eq(growConversations.userId, input.userId),
sql`${growConversations.metadata}->>'curatorTaskKey' = ${curatorTaskKey}`,
))
.orderBy(desc(growConversations.updatedAt))
.limit(1);
const metadata = {
source: "curator-v1",
curatorTaskId: input.curatorTaskId,
curatorTaskKey,
...(Number.isInteger(input.subtaskIndex) ? { subtaskIndex: input.subtaskIndex } : {}),
...(input.subtask ? { subtask: input.subtask } : {}),
...(input.missionInstanceId ? { missionInstanceId: input.missionInstanceId } : {}),
...(input.missionId ? { missionId: input.missionId } : {}),
...(input.stageId ? { stageId: input.stageId } : {}),
};
if (existing) {
const [row] = await db.update(growConversations).set({
title: input.title?.trim() || existing.title,
metadata,
updatedAt: new Date(),
}).where(and(eq(growConversations.userId, input.userId), eq(growConversations.id, existing.id))).returning();
if (!row) throw new Error("Failed to update curator task conversation");
return toConversation(row);
}
const now = new Date();
const [row] = await db.insert(growConversations).values({
id: buildId("conversation"),
userId: input.userId,
title: input.title?.trim() || input.subtask?.trim() || "V1 Curator chat",
metadata,
createdAt: now,
updatedAt: now,
}).returning();
if (!row) throw new Error("Failed to create curator task conversation");
return toConversation(row);
}
export async function ensureMissionConversationPg(input: {
userId: string;
missionInstanceId: string;
missionId: string;
stageId?: string;
title?: string;
source?: string;
}): Promise<GrowConversation> {
const [existing] = await db
.select()
.from(growConversations)
.where(and(
eq(growConversations.userId, input.userId),
sql`${growConversations.metadata}->>'missionInstanceId' = ${input.missionInstanceId}`,
))
.orderBy(desc(growConversations.updatedAt))
.limit(1);
const metadata = {
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
...(input.stageId ? { stageId: input.stageId } : {}),
source: input.source ?? "mission",
};
if (existing) {
const [row] = await db.update(growConversations).set({
title: input.title?.trim() || existing.title,
metadata,
updatedAt: new Date(),
}).where(and(eq(growConversations.userId, input.userId), eq(growConversations.id, existing.id))).returning();
if (!row) throw new Error("Failed to update mission conversation");
return toConversation(row);
}
const now = new Date();
const [row] = await db.insert(growConversations).values({
id: buildId("conversation"),
userId: input.userId,
title: input.title?.trim() || "Mission chat",
metadata,
createdAt: now,
updatedAt: now,
}).returning();
if (!row) throw new Error("Failed to create mission conversation");
return toConversation(row);
}
export async function getConversationMetadataPg(userId: string, conversationId: string) {
const [row] = await db
.select({ metadata: growConversations.metadata })
.from(growConversations)
.where(and(eq(growConversations.userId, userId), eq(growConversations.id, conversationId)))
.limit(1);
return row?.metadata ?? {};
}
export async function getConversationPg(userId: string, conversationId: string): Promise<GrowConversation | null> {
const [row] = await db.select().from(growConversations).where(and(eq(growConversations.userId, userId), eq(growConversations.id, conversationId))).limit(1);
return row ? toConversation(row) : null;

View File

@@ -1,4 +1,4 @@
import { Output, generateText } from "ai";
import { generateText } from "ai";
import { z } from "zod";
import { getConversationModel } from "../actors/conversation/agent.js";
import { config } from "../config.js";
@@ -20,7 +20,7 @@ const feedSchema = z.object({
notifications: z.array(notificationSchema).min(6).max(24),
});
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 8000);
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 20000);
export type AgentHomeNotification = z.infer<typeof notificationSchema>;
@@ -54,6 +54,18 @@ function stableId(prefix: string, index: number) {
return `${prefix}-${index + 1}`;
}
function parseJsonObject(text: string) {
const cleaned = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim();
try {
return JSON.parse(cleaned);
} catch {
const start = cleaned.indexOf("{");
const end = cleaned.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) throw new Error("home_feed_agent_invalid_json");
return JSON.parse(cleaned.slice(start, end + 1));
}
}
export async function refineHomeNotificationsWithAgent(input: {
userId: string;
context: Record<string, unknown>;
@@ -64,8 +76,11 @@ export async function refineHomeNotificationsWithAgent(input: {
try {
const result = await generateText({
model: getConversationModel(),
output: Output.object({ schema: feedSchema }),
system: SYSTEM,
system: [
SYSTEM,
"Return JSON only. Shape: {\"notifications\": [...]}. Do not use markdown.",
"Use ASCII punctuation only.",
].join("\n"),
timeout: HOME_FEED_AGENT_TIMEOUT_MS,
prompt: JSON.stringify({
task: "Create coherent GrowQR home dashboard notifications from the provided service context and deterministic candidates.",
@@ -75,8 +90,9 @@ export async function refineHomeNotificationsWithAgent(input: {
}),
});
const parsed = feedSchema.parse(parseJsonObject(result.text));
const now = new Date().toISOString();
return result.output.notifications.map((n, index) => ({
return parsed.notifications.map((n, index) => ({
...n,
href: sanitizeHref(n.href, n.moduleId),
urgency: n.urgency as HomeUrgency,

View File

@@ -17,6 +17,8 @@ import {
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import { listAvailableMissionDefinitions } from "../missions/registry.js";
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
import {
isAllowedNotificationHref,
MODULE_IDS,
@@ -165,17 +167,51 @@ function hasAnyRealActivity(ctx: HomeContext) {
function buildDayOneSeeds(): SeedNotification[] {
const seeds: SeedNotification[] = [];
pushSeed(seeds, { moduleId: "suggestions", title: "Start with your Q Score", subtitle: "A quick readiness scan calibrates resume, interview, and roleplay tips.", tag: "Start", urgency: "now", href: SERVICE_HREFS.qscore, source: "qscore", priority: 90 });
pushSeed(seeds, { moduleId: "suggestions", title: "Add your target role", subtitle: "One role goal makes every recommendation sharper.", tag: "Profile", urgency: "today", href: SERVICE_HREFS.suggestions, source: "system", priority: 80 });
pushSeed(seeds, { moduleId: "missions", title: "Explore Interview-to-Offer", subtitle: "A guided mission connects resume fit, mock practice, and readiness scoring.", tag: "Browse", urgency: "today", href: SERVICE_HREFS.mission, source: "mission", priority: 80 });
pushSeed(seeds, { moduleId: "missions", title: "No approvals pending yet", subtitle: "Start a mission and this tile will track missing steps and progress.", tag: "Quiet", urgency: "calm", href: SERVICE_HREFS.mission, source: "mission", priority: 55 });
pushSeed(seeds, { moduleId: "social", title: "Connect LinkedIn when ready", subtitle: "Social branding recommendations unlock after your profile is available.", tag: "Setup", urgency: "soon", href: SERVICE_HREFS.social, source: "social", priority: 60 });
pushSeed(seeds, { moduleId: "social", title: "Build proof before posting", subtitle: "Resume and mock interview artifacts can become stronger featured pins.", tag: "Proof", urgency: "calm", href: SERVICE_HREFS.social, source: "social", priority: 50 });
pushSeed(seeds, { moduleId: "pathways", title: "Pathways are warming up", subtitle: "Complete resume + interview activity to unlock better route recommendations.", tag: "Soon", urgency: "calm", href: SERVICE_HREFS.pathways, source: "pathways", priority: 40 });
pushSeed(seeds, { moduleId: "productivity", title: "Open Resume Builder", subtitle: "Upload or create a resume to generate ATS and content recommendations.", tag: "Resume", urgency: "now", href: SERVICE_HREFS.resume, source: "resume", priority: 85 });
pushSeed(seeds, { moduleId: "productivity", title: "Try a 10-minute mock interview", subtitle: "The interview service creates a role-aware live practice session.", tag: "Mock", urgency: "soon", href: SERVICE_HREFS.interview, source: "interview", priority: 70 });
pushSeed(seeds, { moduleId: "productivity", title: "Roleplay is available for pressure practice", subtitle: "Use it for recruiter screens, salary asks, or manager conversations.", tag: "Roleplay", urgency: "calm", href: SERVICE_HREFS.roleplay, source: "roleplay", priority: 55 });
pushSeed(seeds, { moduleId: "rewards", title: "Rewards unlock after activity", subtitle: "Finish readiness actions to start earning demo streaks and perks.", tag: "Locked", urgency: "calm", href: SERVICE_HREFS.rewards, source: "rewards", priority: 35 });
const missions = listAvailableMissionDefinitions();
for (const [index, mission] of missions.slice(0, 3).entries()) {
const firstServiceModule = mission.modules.find((module) => module.execution === "service" && module.service);
pushSeed(seeds, {
moduleId: "missions",
title: mission.shortTitle || mission.title,
subtitle: mission.promise,
tag: mission.urgency === "high" ? "High" : mission.estimatedDuration,
urgency: mission.urgency === "high" ? "today" : "soon",
href: `/missions/available?missionId=${encodeURIComponent(mission.missionId)}`,
source: "mission",
reason: firstServiceModule ? `Registry workflow using ${firstServiceModule.role}.` : "Registry workflow.",
priority: 92 - index * 4,
});
}
const services = listServiceCapabilities().filter((service) => service.enabled);
const serviceCards = [
{ id: "resume-service", moduleId: "productivity" as const, href: SERVICE_HREFS.resume, source: "resume" as const, urgency: "today" as const },
{ id: "interview-service", moduleId: "productivity" as const, href: SERVICE_HREFS.interview, source: "interview" as const, urgency: "today" as const },
{ id: "roleplay-service", moduleId: "productivity" as const, href: SERVICE_HREFS.roleplay, source: "roleplay" as const, urgency: "soon" as const },
{ id: "qscore-service", moduleId: "suggestions" as const, href: SERVICE_HREFS.qscore, source: "qscore" as const, urgency: "now" as const },
{ id: "social-branding-service", moduleId: "social" as const, href: SERVICE_HREFS.social, source: "social" as const, urgency: "soon" as const },
{ id: "matchmaking-service", moduleId: "pathways" as const, href: SERVICE_HREFS.pathways, source: "pathways" as const, urgency: "calm" as const },
];
for (const [index, card] of serviceCards.entries()) {
const service = services.find((item) => item.id === card.id);
if (!service) continue;
pushSeed(seeds, {
moduleId: card.moduleId,
title: service.name,
subtitle: service.operations.slice(0, 3).join(", ") || "Registered GrowQR service capability.",
tag: service.featureId?.replaceAll("-", " ").slice(0, 14) || "Service",
urgency: card.urgency,
href: card.href,
source: card.source,
reason: `From service registry: ${service.promptModulePath ?? service.id}.`,
priority: 84 - index * 3,
});
}
pushSeed(seeds, { moduleId: "suggestions", title: "Set your target outcome", subtitle: "One role or career goal sharpens every registry mission and service handoff.", tag: "Profile", urgency: "today", href: SERVICE_HREFS.suggestions, source: "system", priority: 80 });
pushSeed(seeds, { moduleId: "rewards", title: "Rewards unlock from mission progress", subtitle: "Complete registry-backed mission stages to earn streak and coin progress.", tag: "Locked", urgency: "calm", href: SERVICE_HREFS.rewards, source: "rewards", priority: 35 });
return seeds;
}
@@ -456,6 +492,21 @@ async function readPersistedNotifications(userId: string) {
.limit(60);
}
function hasLegacyMockSeed(rows: GrowHomeNotificationRow[]) {
const legacyTitles = new Set([
"Complete your QX self-check",
"Create your interview room",
"Browse 1 career pathway",
"Start with your Q Score",
"Explore Interview-to-Offer",
"Pathways are warming up",
"Open Resume Builder",
"Try a 10-minute mock interview",
"Rewards unlock after activity",
]);
return rows.some((row) => legacyTitles.has(row.title));
}
async function replaceGeneratedNotifications(userId: string, notifications: Array<SeedNotification>, generatedBy: "deterministic" | "agent") {
await db
.delete(growHomeNotifications)
@@ -565,9 +616,10 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean; use
const persisted = await readPersistedNotifications(userId);
const newest = persisted[0]?.createdAt?.getTime() ?? 0;
const hasDemo = persisted.some((row) => row.generatedBy === "demo");
const hasLegacyMock = hasLegacyMockSeed(persisted);
const fresh = newest > Date.now() - FRESH_MS;
if (persisted.length && (hasDemo || (!opts.refresh && fresh))) {
if (persisted.length && !hasLegacyMock && (hasDemo || (!opts.refresh && fresh))) {
const mode = hasDemo ? "demo" : hasAnyRealActivity(ctx) ? "dynamic" : "day1";
return {
generatedAt: new Date().toISOString(),

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

@@ -18,6 +18,10 @@ import { growRoutes } from "./routes/grow.js";
import { missionRoutes } from "./routes/missions.js";
import { eventRoutes } from "./routes/events.js";
import { homeRoutes } from "./routes/home.js";
import { dailyMissionRoutes } from "./routes/daily-mission.js";
import { analyticsRoutes } from "./routes/analytics.js";
import { logRoutes } from "./routes/logs.js";
import { v1Routes } from "./v1/index.js";
import { startGrowEventsRedisConsumer } from "./events/redis-consumer.js";
import { db } from "./db/client.js";
import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js";
@@ -86,7 +90,11 @@ async function main() {
app.route("/grow", growRoutes());
app.route("/missions", missionRoutes());
app.route("/events", eventRoutes());
app.route("/analytics", analyticsRoutes());
app.route("/v1", v1Routes());
app.route("/logs", logRoutes());
app.route("/home", homeRoutes());
app.route("/daily-mission", dailyMissionRoutes());
app.route("/conversations", conversationRoutes());
app.route("/opencode", opencodeRoutes());
app.route("/git", gitRoutes());

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) {

31
src/routes/analytics.ts Normal file
View File

@@ -0,0 +1,31 @@
import { Hono } from "hono";
import { createClient, type Client } from "rivetkit/client";
import { config } from "../config.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import type { Registry } from "../actors/registry.js";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
}
export function analyticsRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/platform", async (c) => {
return c.json(await getClient().analyticsActor.getOrCreate(["platform"]).getPlatform());
});
app.get("/user/qscore", async (c) => {
const userId = c.get("userId");
return c.json(await getClient().analyticsActor.getOrCreate(["user", userId]).getUserQscore({ userId }));
});
app.get("/user/activity", async (c) => {
const userId = c.get("userId");
return c.json(await getClient().analyticsActor.getOrCreate(["user", userId]).getUserActivity({ userId }));
});
return app;
}

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

@@ -9,7 +9,7 @@ import { getConversationModel } from "../actors/conversation/agent.js";
import { getMissionDefinition, isActorBackedMission, listMissionDefinitions } from "../missions/registry.js";
import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../actors/missions/types.js";
import { getSubAgentModules } from "../lib/prompt-loader.js";
import { addMessagePg, createConversationPg, ensureConversation, getConversationPg, listActiveMissionsPg, listConversationsPg, listMessagesPg, resetConversationPg, touchConversationPg, upsertActiveMissionPg } from "../grow/persistence.js";
import { addMessagePg, createConversationPg, ensureConversation, ensureMissionConversationPg, getActiveMissionPg, getConversationMetadataPg, getConversationPg, listActiveMissionsPg, listConversationsPg, listMessagesPg, resetConversationPg, touchConversationPg, upsertActiveMissionPg } from "../grow/persistence.js";
import { getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js";
let _client: Client<Registry> | null = null;
@@ -102,6 +102,12 @@ function missionActorFor(userId: string, instanceId: string, actorType: MissionA
}
const createConversationSchema = z.object({ title: z.string().optional() });
const createMissionConversationSchema = z.object({
missionInstanceId: z.string().min(1),
stageId: z.string().optional(),
title: z.string().optional(),
source: z.string().optional(),
});
const streamSchema = z.object({
messages: z.array(z.custom<UIMessage>()),
conversationId: z.string().optional(),
@@ -164,7 +170,27 @@ function forcedToolForPrompt(text: string) {
return undefined;
}
function buildSystemPrompt() {
async function buildMissionContextPrompt(userId: string, conversationId: string) {
const metadata = await getConversationMetadataPg(userId, conversationId);
const missionInstanceId = typeof metadata.missionInstanceId === "string" ? metadata.missionInstanceId : undefined;
if (!missionInstanceId) return "";
const active = await getActiveMissionPg(userId, missionInstanceId);
if (!active) return "";
const actions = await listMissionActions(userId, { missionInstanceId });
return `\n\nCurrent mission context:
- missionInstanceId: ${active.mission.instanceId}
- missionId: ${active.mission.missionId}
- title: ${active.mission.title}
- status: ${active.mission.status}
- progress: ${active.mission.progressPercent}%
- currentStageId: ${active.mission.currentStageId ?? "none"}
- goal: ${active.mission.goal ?? "none"}
- openActions: ${actions.length}
Use this mission context when answering. If a service is needed, prepare a handoff/action instead of completing the service directly.`;
}
function buildSystemPrompt(missionContext = "") {
return `You are Grow — a friendly, normal career buddy inside GrowQR. Talk like a real person, not a coach or a robot.
Personality & Tone:
@@ -183,7 +209,7 @@ How to help:
- When the user asks about interview prep, roleplay, resume, or Q Score — just answer normally. Only route to a specialist tool if they explicitly say something like "connect me to the interview specialist" or "let me talk to the roleplay agent".
- When the user asks to see missions or plans, call discoverWorkflows or showMissions. When they ask about memory, use the memory tools. Otherwise, just chat.
- Only start a mission if the user clearly says yes to starting one. Don't push.
- When you write memory, a quick "Saved." is enough. No need to over-confirm.`
- When you write memory, a quick "Saved." is enough. No need to over-confirm.${missionContext}`
}
function safeAgentRegistry() {
@@ -570,6 +596,28 @@ export function conversationRoutes() {
return c.json({ conversation }, 201);
});
app.post("/mission", async (c) => {
const userId = c.get("userId");
const body = createMissionConversationSchema.parse(await c.req.json().catch(() => ({})));
const active = await getActiveMissionPg(userId, body.missionInstanceId);
if (!active) return c.json({ error: "mission_not_found" }, 404);
const conversation = await ensureMissionConversationPg({
userId,
missionInstanceId: active.mission.instanceId,
missionId: active.mission.missionId,
stageId: body.stageId,
title: body.title ?? active.mission.shortTitle,
source: body.source ?? "mission",
});
setupGrow(userId).then((grow) => grow.touchConversation({ conversationId: conversation.id, title: conversation.title })).catch((err) => console.warn("growActor mission conversation mirror failed", err));
return c.json({
conversation,
mission: active.mission,
snapshot: active.snapshot,
messages: await listMessagesPg(userId, conversation.id),
}, 201);
});
app.get("/:conversationId", async (c) => {
const userId = c.get("userId");
const conversationId = c.req.param("conversationId");
@@ -610,9 +658,10 @@ export function conversationRoutes() {
}
const visualTool = forcedToolForPrompt(latestUserText);
const missionContext = await buildMissionContextPrompt(userId, conversationId);
const result = streamText({
model: getConversationModel(),
system: buildSystemPrompt(),
system: buildSystemPrompt(missionContext),
messages: await convertToModelMessages(body.messages),
tools: buildConversationTools(userId),
toolChoice: visualTool ? { type: "tool", toolName: visualTool } : "auto",

299
src/routes/daily-mission.ts Normal file
View File

@@ -0,0 +1,299 @@
import { Hono } from "hono";
import { z } from "zod";
import { createClient, type Client } from "rivetkit/client";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { config } from "../config.js";
import { log } from "../log.js";
import {
dailyMissionMessageSchema,
dailyMissionTaskSchema,
type DailyMissionResult,
runDailyMissionAgent,
streamDailyMissionAgent,
} from "../agents/daily-mission.js";
import type { Registry } from "../actors/registry.js";
import type { MissionActorType, MissionSnapshot } from "../actors/missions/types.js";
import { addMessagePg, ensureMissionConversationPg, getActiveMissionPg, listMessagesPg, upsertActiveMissionPg } from "../grow/persistence.js";
const chatSchema = z.object({
task: dailyMissionTaskSchema,
messages: z.array(dailyMissionMessageSchema).min(1).max(24),
missionInstanceId: z.string().optional(),
missionId: z.string().optional(),
stageId: z.string().optional(),
conversationId: z.string().optional(),
});
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
}
function missionActorFor(userId: string, instanceId: string, actorType: MissionActorType) {
const client = getClient();
switch (actorType) {
case "interviewToOfferMissionActor": return client.interviewToOfferMissionActor.getOrCreate([userId, instanceId]);
case "careerTransitionMissionActor": return client.careerTransitionMissionActor.getOrCreate([userId, instanceId]);
case "salaryNegotiationWarRoomMissionActor": return client.salaryNegotiationWarRoomMissionActor.getOrCreate([userId, instanceId]);
case "promotionReadinessMissionActor": return client.promotionReadinessMissionActor.getOrCreate([userId, instanceId]);
case "personalBrandOpportunityEngineMissionActor": return client.personalBrandOpportunityEngineMissionActor.getOrCreate([userId, instanceId]);
}
}
function buildId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function latestUserText(messages: z.infer<typeof dailyMissionMessageSchema>[]) {
return [...messages].reverse().find((message) => message.role === "user")?.content.trim();
}
const encoder = new TextEncoder();
function sse(event: string, payload: Record<string, unknown>) {
return encoder.encode(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
}
function sanitizeAssistantText(text: string) {
return text
.replace(/[\u2013\u2014]/g, ". ")
.replace(/[\u2018\u2019]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
.replace(/\u2026/g, "...")
.replace(/^\s*(Perfect|Great|Absolutely|Sure)[.!,:;-]*\s*/i, "")
.replace(/\s+\./g, ".")
.replace(/\.{2,}/g, ".")
.replace(/\s{2,}/g, " ");
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function visibleTextChunks(text: string) {
const words = text.match(/\S+\s*/g) ?? [text];
const chunks: string[] = [];
let current = "";
for (const word of words) {
current += word;
if (current.length >= 12 || /[.!?]\s*$/.test(current)) {
chunks.push(current);
current = "";
}
}
if (current) chunks.push(current);
return chunks;
}
async function enqueueVisibleText(controller: ReadableStreamDefaultController<Uint8Array>, text: string) {
for (const chunk of visibleTextChunks(sanitizeAssistantText(text))) {
controller.enqueue(sse("delta", { text: chunk }));
await sleep(28);
}
}
async function applyDailyMissionResult(input: {
userId: string;
body: z.infer<typeof chatSchema>;
result: DailyMissionResult;
}) {
const { userId, body, result } = input;
let conversationId = body.conversationId;
let missionInstanceId = body.missionInstanceId;
let responseStageId = body.stageId ?? body.task.questId;
let snapshot: MissionSnapshot | null | undefined;
if (missionInstanceId) {
const active = await getActiveMissionPg(userId, missionInstanceId);
if (active) {
const requestedStageId = body.stageId ?? body.task.questId;
const stageId = active.snapshot?.stages.some((stage) => stage.id === requestedStageId)
? requestedStageId
: active.mission.currentStageId;
responseStageId = stageId ?? responseStageId;
const conversation = await ensureMissionConversationPg({
userId,
missionInstanceId: active.mission.instanceId,
missionId: active.mission.missionId,
stageId,
title: body.task.questTitle,
source: "daily-mission",
});
log.info({
userId,
agent: "conversation-actor",
conversationId: conversation.id,
missionInstanceId: active.mission.instanceId,
missionId: active.mission.missionId,
stageId,
service: body.task.service,
}, "conversation actor linked to daily mission");
conversationId = conversation.id;
missionInstanceId = active.mission.instanceId;
const conversationActor = getClient().conversationActor.getOrCreate([userId, conversation.id]);
const latestUser = latestUserText(body.messages);
if (latestUser) {
const userMessage = {
id: buildId("user"),
conversationId: conversation.id,
role: "user" as const,
sender: "User",
content: latestUser,
};
await addMessagePg(userId, userMessage);
conversationActor.addMessage(userMessage).catch(() => undefined);
}
const assistantMessage = {
id: buildId("assistant"),
conversationId: conversation.id,
role: "assistant" as const,
sender: "Daily Mission",
content: result.reply,
};
await addMessagePg(userId, assistantMessage);
conversationActor.addMessage(assistantMessage).catch(() => undefined);
if (result.completed && active.mission.actorType && stageId) {
snapshot = await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).updateStage({
stageId,
status: "done",
progressPercent: 100,
outputSummary: result.updateSummary,
}).catch(() => active.snapshot ?? null);
log.info({
userId,
agent: active.mission.actorType,
missionInstanceId: active.mission.instanceId,
missionId: active.mission.missionId,
stageId,
completed: result.completed,
progressPercent: snapshot?.progressPercent,
currentStageId: snapshot?.currentStageId,
}, "mission actor stage update requested from daily mission");
if (snapshot) {
await upsertActiveMissionPg(userId, {
instanceId: snapshot.instanceId,
missionId: snapshot.missionId,
workflowId: snapshot.workflowId,
title: snapshot.title,
shortTitle: snapshot.shortTitle,
status: snapshot.status,
progressPercent: snapshot.progressPercent,
currentStageId: snapshot.currentStageId,
goal: snapshot.goal,
actorType: active.mission.actorType,
createdAt: new Date(snapshot.createdAt).getTime(),
updatedAt: new Date(snapshot.updatedAt).getTime(),
}, snapshot);
}
} else {
snapshot = active.snapshot;
}
}
}
return {
agent: "daily-mission",
message: result.reply,
completed: result.completed,
updateSummary: result.updateSummary,
actionLabel: result.actionLabel,
actionRoute: result.actionRoute,
conversationId,
missionInstanceId,
stageId: responseStageId,
snapshot,
messages: conversationId ? await listMessagesPg(userId, conversationId) : undefined,
};
}
export function dailyMissionRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.post("/chat", async (c) => {
const userId = c.get("userId");
const body = chatSchema.parse(await c.req.json());
log.info({
userId,
agent: "daily-mission",
missionInstanceId: body.missionInstanceId,
missionId: body.missionId,
stageId: body.stageId,
service: body.task.service,
route: body.task.route,
questTitle: body.task.questTitle,
subtask: body.task.subtask,
}, "daily mission actor request");
const result = await runDailyMissionAgent({ userId, ...body });
return c.json(await applyDailyMissionResult({ userId, body, result }));
});
app.post("/chat/stream", async (c) => {
const userId = c.get("userId");
const body = chatSchema.parse(await c.req.json());
log.info({
userId,
agent: "daily-mission",
missionInstanceId: body.missionInstanceId,
missionId: body.missionId,
stageId: body.stageId,
service: body.task.service,
route: body.task.route,
questTitle: body.task.questTitle,
subtask: body.task.subtask,
streaming: true,
}, "daily mission actor stream request");
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
const streamed = await streamDailyMissionAgent({ userId, ...body });
let reply = "";
if (streamed.kind === "static") {
reply = streamed.result.reply;
await enqueueVisibleText(controller, reply);
const finalPayload = await applyDailyMissionResult({ userId, body, result: streamed.result });
controller.enqueue(sse("final", finalPayload));
controller.close();
return;
}
for await (const delta of streamed.textStream) {
const cleanDelta = sanitizeAssistantText(delta);
reply += cleanDelta;
controller.enqueue(sse("delta", { text: cleanDelta }));
}
let result = streamed.finalize(reply);
result = {
...result,
reply: sanitizeAssistantText(result.reply),
updateSummary: result.updateSummary ? sanitizeAssistantText(result.updateSummary) : result.updateSummary,
};
if (!result.reply.trim()) {
throw new Error("daily_mission_empty_model_reply");
}
const finalPayload = await applyDailyMissionResult({ userId, body, result });
controller.enqueue(sse("final", finalPayload));
controller.close();
} catch (error) {
controller.enqueue(sse("error", { error: error instanceof Error ? error.message : String(error) }));
controller.close();
}
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
},
});
});
return app;
}

View File

@@ -6,6 +6,7 @@ import { growEvents } from "../db/schema.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { recordGrowEvent } from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { runCuratorOnboardingLoopForEventSafely } from "../v1/curator/curator-onboarding-loop.js";
function serviceAuthorized(auth: string | undefined) {
const token = (auth ?? "").replace(/^Bearer\s+/i, "").trim();
@@ -20,7 +21,8 @@ async function ingest(body: unknown, userId?: string, source?: string) {
routed: false as const,
reason: err instanceof Error ? err.message : String(err),
}));
return { event, route };
const curatorOnboarding = await runCuratorOnboardingLoopForEventSafely(event);
return { event, route, curatorOnboarding };
}
export function eventRoutes() {
@@ -30,8 +32,8 @@ export function eventRoutes() {
app.post("/ingest", requireUser, async (c) => {
const userId = c.get("userId");
const body = await c.req.json().catch(() => ({}));
const { event, route } = await ingest(body, userId);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route }, 202);
const { event, route, curatorOnboarding } = await ingest(body, userId);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route, curatorOnboarding }, 202);
});
// Service-to-service ingress. Services may include userId directly, or we resolve it from session correlation.
@@ -41,8 +43,8 @@ export function eventRoutes() {
}
const body = await c.req.json().catch(() => ({}));
const source = c.req.header("x-growqr-source") ?? undefined;
const { event, route } = await ingest(body, undefined, source);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route }, 202);
const { event, route, curatorOnboarding } = await ingest(body, undefined, source);
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route, curatorOnboarding }, 202);
});
app.get("/", requireUser, async (c) => {

View File

@@ -3,6 +3,7 @@ import { config } from "../config.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { dismissHomeNotification, getHomeFeed, getHomeFeedDebugCounts } from "../home/home-feed.js";
import { seedDemoHome } from "../home/seed-demo-home.js";
import { log } from "../log.js";
function canSeedDemo(userId: string) {
return config.nodeEnv !== "production" || config.adminUserIds.includes(userId);
@@ -29,7 +30,10 @@ export function homeRoutes() {
app.get("/feed", async (c) => {
const refresh = c.req.query("refresh") === "1" || c.req.query("refresh") === "true";
const profile = await getUserServiceProfile(c.req.raw);
const profile = await getUserServiceProfile(c.req.raw).catch((err) => {
log.warn({ err, userId: c.get("userId") }, "home feed continuing without user-service profile");
return {};
});
return c.json(await getHomeFeed(c.get("userId"), { refresh, ...profile }));
});

117
src/routes/logs.ts Normal file
View File

@@ -0,0 +1,117 @@
import Docker from "dockerode";
import { PassThrough } from "node:stream";
import { Hono } from "hono";
import { requireUser, type AuthContext } from "../auth/clerk.js";
const LOG_CONTAINERS = {
backend: "growqr-backend",
dashboard: "growqr-dashboard",
actors: "growqr-rivet",
interview: "interview-service-api-1",
roleplay: "roleplay-service-api-1",
social: "growqr_social_api",
pathways: "pathways-service-api-1",
courses: "courses_service-api-1",
assessment: "assessment-service-api-1",
matchmaking: "matchmaking-service-api-1",
} as const;
type LogService = keyof typeof LOG_CONTAINERS;
const encoder = new TextEncoder();
function parseServices(value: string | undefined): LogService[] {
const requested = (value ?? "backend,dashboard,actors,interview,roleplay")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
const services = requested.filter((item): item is LogService => item in LOG_CONTAINERS);
return services.length ? services : ["backend", "dashboard", "actors", "interview", "roleplay"];
}
function sse(event: string, payload: Record<string, unknown>) {
return encoder.encode(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
}
function splitLines(chunk: Buffer | string) {
return chunk
.toString("utf8")
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter(Boolean);
}
export function logRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/stream", (c) => {
const services = parseServices(c.req.query("services"));
const tail = Math.max(10, Math.min(500, Number(c.req.query("tail") ?? 120)));
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
const openStreams: Array<{ destroy: () => void }> = [];
let closed = false;
let heartbeat: NodeJS.Timeout | undefined;
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (event: string, payload: Record<string, unknown>) => {
if (!closed) controller.enqueue(sse(event, payload));
};
send("ready", { services, tail, at: new Date().toISOString() });
for (const service of services) {
const containerName = LOG_CONTAINERS[service];
try {
const stream = await docker.getContainer(containerName).logs({
follow: true,
stdout: true,
stderr: true,
timestamps: true,
tail,
});
const stdout = new PassThrough();
const stderr = new PassThrough();
docker.modem.demuxStream(stream, stdout, stderr);
openStreams.push(stream as unknown as { destroy: () => void }, stdout, stderr);
stdout.on("data", (chunk) => {
for (const line of splitLines(chunk)) send("log", { service, stream: "stdout", line });
});
stderr.on("data", (chunk) => {
for (const line of splitLines(chunk)) send("log", { service, stream: "stderr", line });
});
stream.on("end", () => send("status", { service, status: "ended" }));
stream.on("error", (error) => send("error", { service, error: error instanceof Error ? error.message : String(error) }));
} catch (error) {
send("error", { service, container: containerName, error: error instanceof Error ? error.message : String(error) });
}
}
heartbeat = setInterval(() => send("ping", { at: new Date().toISOString() }), 20_000);
c.req.raw.signal.addEventListener("abort", () => {
closed = true;
if (heartbeat) clearInterval(heartbeat);
for (const stream of openStreams) stream.destroy();
controller.close();
});
},
cancel() {
closed = true;
if (heartbeat) clearInterval(heartbeat);
for (const stream of openStreams) stream.destroy();
},
});
return new Response(body, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
},
});
});
return app;
}

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

@@ -7,6 +7,10 @@ import { provisionUserStack } from "../docker/manager.js";
import { log } from "../log.js";
import { config } from "../config.js";
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
import {
onboardingCompletedAtFromPreferences,
runCuratorOnboardingLoopSafely,
} from "../v1/curator/curator-onboarding-loop.js";
function publicStack(stack: UserStack | null | undefined) {
if (!stack) return stack;
@@ -102,14 +106,27 @@ export function userRoutes() {
try {
const userProfile = JSON.parse(text) as Record<string, unknown>;
const preferences = userProfile.preferences;
const normalizedPreferences = preferences && typeof preferences === "object" && !Array.isArray(preferences)
? (preferences as Record<string, unknown>)
: undefined;
await ensureOnboardingBaselineQscore(
c.get("userId"),
preferences && typeof preferences === "object" && !Array.isArray(preferences)
? (preferences as Record<string, unknown>)
: undefined,
normalizedPreferences,
);
const completedAt = onboardingCompletedAtFromPreferences(normalizedPreferences);
if (completedAt) {
await runCuratorOnboardingLoopSafely({
userId: c.get("userId"),
completedAt,
source: "user-service-profile",
context: {
preferences: normalizedPreferences,
profile: userProfile,
},
});
}
} catch (err) {
log.warn({ err, userId: c.get("userId") }, "failed to seed onboarding Q Score baseline after user update");
log.warn({ err, userId: c.get("userId") }, "failed to run onboarding side effects after user update");
}
}

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

@@ -0,0 +1,10 @@
# V1 Analytics
V1 Analytics reuses the existing Analytics Actor for platform, Q-score, and activity reads.
The added responsibility here is the nightly improvement loop:
1. Read Grow events, service events, Q-score signals, and conversation summaries.
2. Generate validated improvement signal objects with the Vercel AI SDK.
3. Apply those signals to the V1 Curator as events.
4. The Curator uses them on the next day when shaping tasks and nudges.

View File

@@ -0,0 +1,136 @@
import { generateText, tool } from "ai";
import { z } from "zod";
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 } from "../../db/schema.js";
import { curatorActor } from "../curator/curator-actor.js";
import { curatorImprovementSignalSchema } from "../curator/curator-types.js";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
}
const signalsSchema = z.object({
signals: z.array(curatorImprovementSignalSchema.omit({ userId: true, date: true }).extend({
id: z.string(),
})).max(5),
});
function parseJsonObject(text: string) {
const cleaned = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim();
try {
return JSON.parse(cleaned);
} catch {
const start = cleaned.indexOf("{");
const end = cleaned.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) throw new Error("analytics_actor_invalid_json");
return JSON.parse(cleaned.slice(start, end + 1));
}
}
export const analyticsTools = {
read_platform_events: tool({
description: "Read latest platform events.",
inputSchema: z.object({ limit: z.number().int().min(1).max(100).default(50) }),
execute: async ({ limit }) => db.select().from(growEvents).orderBy(desc(growEvents.occurredAt)).limit(limit),
}),
read_user_service_events: tool({
description: "Read latest service events for a user.",
inputSchema: z.object({ userId: z.string(), limit: z.number().int().min(1).max(100).default(50) }),
execute: async ({ userId, limit }) => db.select().from(growEvents).where(eq(growEvents.userId, userId)).orderBy(desc(growEvents.occurredAt)).limit(limit),
}),
read_conversation_summaries: tool({
description: "Read latest conversation messages for a user.",
inputSchema: z.object({ userId: z.string(), limit: z.number().int().min(1).max(100).default(30) }),
execute: async ({ userId, limit }) => db.select().from(growConversationMessages).where(eq(growConversationMessages.userId, userId)).orderBy(desc(growConversationMessages.createdAt)).limit(limit),
}),
generate_improvement_signals: tool({
description: "Generate curator improvement signals for a user.",
inputSchema: z.object({ userId: z.string(), date: z.string() }),
execute: async ({ userId, date }) => v1AnalyticsActor.generateImprovementSignals({ userId, date }),
}),
apply_improvement_to_curator: tool({
description: "Apply generated improvement signals to the curator.",
inputSchema: z.object({ userId: z.string(), date: z.string(), signals: z.array(curatorImprovementSignalSchema) }),
execute: async ({ userId, date, signals }) => curatorActor.applyImprovementSignals({ userId, date, signals }),
}),
};
export const v1AnalyticsActor = {
async getPlatform() {
return getClient().analyticsActor.getOrCreate(["platform"]).getPlatform();
},
async getUserQscore(input: { userId: string }) {
return getClient().analyticsActor.getOrCreate(["user", input.userId]).getUserQscore(input);
},
async getUserActivity(input: { userId: string }) {
return getClient().analyticsActor.getOrCreate(["user", input.userId]).getUserActivity(input);
},
async generateImprovementSignals(input: { userId: string; date: string }) {
const events = await db.select().from(growEvents).where(eq(growEvents.userId, input.userId)).orderBy(desc(growEvents.occurredAt)).limit(80);
const messages = await db.select().from(growConversationMessages).where(eq(growConversationMessages.userId, input.userId)).orderBy(desc(growConversationMessages.createdAt)).limit(40);
try {
const result = await generateText({
model: getConversationModel(),
system: [
"You are the GrowQR V1 Analytics Actor. Generate small overnight improvement signals for the Curator.",
"Return JSON only. Shape: {\"signals\": [...]}. Do not use markdown.",
"Use ASCII punctuation.",
].join("\n"),
prompt: JSON.stringify({ date: input.date, events, messages }).slice(0, 20000),
});
const parsed = signalsSchema.parse(parseJsonObject(result.text));
return parsed.signals.map((signal) => curatorImprovementSignalSchema.parse({ ...signal, userId: input.userId, date: input.date }));
} catch {
return [curatorImprovementSignalSchema.parse({
id: `improvement:${input.userId}:${input.date}:streak`,
userId: input.userId,
date: input.date,
priority: 50,
reason: "Keep service usage meaningful and preserve streak momentum.",
nudgeText: "Pick one task that opens a real service today.",
status: "created",
})];
}
},
async applyImprovementSignals(input: { userId: string; date: string; signals: z.infer<typeof curatorImprovementSignalSchema>[] }) {
return curatorActor.applyImprovementSignals(input);
},
async runNightly(input: { date: string; userId?: string }) {
const userRows = input.userId
? [{ id: input.userId }]
: 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 });
}
return { date: input.date, usersProcessed: userRows.length, improvementSignalsCreated };
},
async explain(input: { userId: string; question: string }) {
const answer = await generateText({
model: getConversationModel(),
system: "You are the GrowQR V1 Analytics Actor. Explain analytics and Q-score movement concisely.",
prompt: input.question,
tools: analyticsTools,
});
return { answer: answer.text };
},
};

View File

@@ -0,0 +1,36 @@
import { Hono } from "hono";
import { z } from "zod";
import { requireUser, type AuthContext } from "../../auth/clerk.js";
import { v1AnalyticsActor } from "./analytics-actor.js";
export function v1AnalyticsRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/platform", async (c) => c.json(await v1AnalyticsActor.getPlatform()));
app.get("/user/qscore", async (c) => {
const userId = c.get("userId");
return c.json(await v1AnalyticsActor.getUserQscore({ userId }));
});
app.get("/user/activity", async (c) => {
const userId = c.get("userId");
return c.json(await v1AnalyticsActor.getUserActivity({ userId }));
});
app.post("/nightly/run", async (c) => {
const userId = c.get("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;
}

30
src/v1/curator/README.md Normal file
View File

@@ -0,0 +1,30 @@
# V1 Curator
V1 replaces the old Daily Mission path with a single Curator layer.
- Curator owns the 30 day plan JSON, today's tasks, streak state, service direction, and task status.
- Conversation Actor still owns chat persistence and long lived conversations.
- Analytics Actor owns the nightly loop and writes improvement signals back into Curator events.
- Services still own their workflows. Curator tools prepare handoffs and routes.
Completion is event gated. A checkbox or chat message cannot complete a task unless a matching service or platform event exists.
## Service Curation Layer
- `curator-icp-playbooks.ts` defines ICP playbooks and maps each persona goal to registry-backed service actions.
- `curator-user-context.ts` assembles deterministic user context from Grow events and QScore projection state.
- `curator-prompt-builder.ts` builds the LLM-ready curation prompt and stable prompt hash.
- `curator-store.ts` keeps generation idempotent by storing sprint starts in `grow_events` with the plan version, ICP, user context, prompt hash, playbook, plan hash, and 30-day plan days.
- `curator-service-links.ts` is the link builder over the Service Registry. Generated tasks use it to produce actionable frontend deep links.
- `POST /v1/curator/curation/preview` accepts optional `icpId`, `goals`, and `userContext` overrides and returns the assembled prompt, ICP playbook, idempotency hashes, Sunday-start `calendarWeeks`, `days` (all 30 days), `closeoutDays` (day 29-30), and deep-linked tasks.
## Curator Onboarding Loop
- `curator-onboarding-loop.ts` runs once after onboarding completion and creates the user's persisted 30-day streak plan through the curation layer.
- Trigger paths:
- Grow event ingestion: `onboarding.completed`, `user.onboarding.completed`, `profile.onboarding.completed`, or payloads/preferences with `onboarding.completed_at`.
- User profile updates: `PATCH /api/users/me` runs the loop when user-service returns onboarding preferences with `completed_at`.
- QA retry: `POST /v1/curator/onboarding/run` accepts optional `completedAt` and returns `ready` or `already_ready`.
- Before generation, the loop snapshots onboarding context into `grow_events` so curation sees the user-service profile/preferences. Event-only triggers also attempt an internal user-service fetch via the service-token path.
- Idempotency is based on the one-time `curator.onboarding_plan.ready` event. Retries do not duplicate the plan-ready analytics event or in-app notification.
- The loop stores the sprint as `curator.sprint.started`, emits `curator.onboarding_plan.ready` with weekly themes and Day 1 task links, and creates a persistent home notification pointing users to their active plan.

View File

@@ -0,0 +1,131 @@
import { buildCuratorPlan, buildCuratorSprint, buildCuratorStreak, buildCuratorTasks, buildServiceCurationPreview, 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";
import type { CuratorIcpId } from "./curator-icp-playbooks.js";
import { runCuratorOnboardingLoop } from "./curator-onboarding-loop.js";
export const curatorActor = {
async generatePlanRange(input: { userId: string; startDate?: string; endDate?: string; goals?: string[]; forceRegenerate?: boolean }) {
const startDate = input.startDate ?? todayIsoDate();
const endDate = input.endDate ?? startDate;
const plan = curatorPlanSchema.parse(await buildCuratorPlan(input.userId, { startDate, endDate, goals: input.goals }));
await emitCuratorEvent({
userId: input.userId,
type: "curator.plan.generated",
payload: {
startDate,
endDate,
planId: plan.id,
durationDays: plan.durationDays,
goals: input.goals ?? plan.goals,
weekCount: plan.weeks.length,
dayCount: plan.days.length,
plan,
},
});
return { plan };
},
async getPlan(input: { userId: string; startDate?: string; endDate?: string }) {
return this.generatePlanRange(input);
},
async previewCuration(input: { userId: string; startDate?: string; icpId?: CuratorIcpId; goals?: string[]; userContext?: Record<string, unknown> }) {
return buildServiceCurationPreview(input);
},
async runOnboardingLoop(input: { userId: string; completedAt?: string }) {
return runCuratorOnboardingLoop({
userId: input.userId,
completedAt: input.completedAt,
source: "curator-api",
});
},
async getToday(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 } });
return {
date,
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);
},
async startTask(input: { userId: string; taskId: string; date?: string }) {
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");
const event = await emitCuratorEvent({
userId: input.userId,
type: "curator.task.started",
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
payload: { taskId: task.id, date },
});
return { task: { ...task, status: "started" as const }, eventId: event.id };
},
async prepareTaskHandoff(input: { userId: string; taskId: string; date?: string }) {
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) return prepareHandoffForTask(input.userId, task, task.serviceId);
throw new Error("curator_task_has_no_handoff");
},
async completeTask(input: { userId: string; taskId: string; date?: string; reason?: string }) {
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");
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 },
});
return { task: { ...task, status: "completed" as const }, eventId: event.id };
},
async recordServiceImpact(input: { userId: string; eventId: string }) {
const streak = await buildCuratorStreak(input.userId);
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 sprint = await buildCuratorSprint(input.userId, input.date);
return { applied: input.signals.length, plan: sprint.plan, sprint };
},
async getState(input: { userId: string }) {
const sprint = await buildCuratorSprint(input.userId, todayIsoDate());
return { tasks: sprint.todayTasks, streak: sprint.streak, sprint };
},
};

View File

@@ -0,0 +1,684 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { generateText } from "ai";
import { z } from "zod";
import { and, desc, eq } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents } from "../../db/schema.js";
import { addMessagePg, createConversationPg, ensureCuratorTaskConversationPg, getConversationMetadataPg, listMessagesPg } from "../../grow/persistence.js";
import { generateConversationResponse, getConversationModel } from "../../actors/conversation/agent.js";
import { buildCuratorTasks, todayIsoDate } from "./curator-store.js";
import { emitCuratorEvent } from "./curator-events.js";
import type { CuratorChatResponse, CuratorSubtaskStatusUpdate } from "./curator-types.js";
import { prepareHandoffForTask } from "./curator-tools.js";
import { fallbackCuratorRole, resolveCuratorTargetRole } from "./curator-user-context.js";
const CURATOR_STREAK_CHAT_PROMPT = path.resolve(process.cwd(), "prompts/curator/streak-chat.md");
const FALLBACK_CURATOR_STREAK_CHAT_PROMPT = [
"You are the GrowQR V1 Curator in a daily or weekly streak chat modal.",
"Ask at most one clarifying question before a service preview handoff is ready.",
"If no target role is known for interview or roleplay, ask exactly: What role are you targeting?",
"If the target role is known, do not ask again. Proceed to a short summary and let the dashboard show the CTA.",
"Never include internal URLs, setup routes, API paths, JSON, or tool names in chat text.",
"Interview defaults: type=behavioral, difficulty=medium, duration=5.",
"Use ASCII punctuation only.",
].join("\n");
async function loadCuratorStreakPrompt() {
return readFile(CURATOR_STREAK_CHAT_PROMPT, "utf8").catch(() => FALLBACK_CURATOR_STREAK_CHAT_PROMPT);
}
const chatExtractSchema = z.object({
summary: z.string(),
userGoal: z.string().optional(),
serviceIntent: z.string().optional(),
shouldPrepareHandoff: z.boolean().default(false),
});
const subtaskStatusUpdateSchema = z.object({
status: z.enum(["needs_more_context", "ready_to_capture", "handoff_ready"]),
summary: z.string().min(1).max(280),
confidence: z.number().min(0).max(1).default(0.5),
nextMissingInfo: z.string().max(180).optional(),
});
function parseJsonObject(text: string) {
const trimmed = text.trim();
try {
return JSON.parse(trimmed);
} catch {
const match = trimmed.match(/\{[\s\S]*\}/);
if (!match) throw new Error("model_did_not_return_json");
return JSON.parse(match[0]);
}
}
function buildId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function sanitize(text: string) {
const withoutControlLines = text
.split(/\r?\n/)
.filter((line) => {
const trimmed = line.trim();
if (!trimmed) return true;
if (/^(date|curator task id|focused subtask|curator task title|curator task context|curator task subtasks|curator service|expected completion events|captured task memory|task title|task service|task context|all task subtasks|visible history):/i.test(trimmed)) return false;
if (/setup route|mission instance id|curator task id|access the setup at/i.test(trimmed)) return false;
if (/\/agents\/(interview|roleplay|resume|qscore)|\/analytics\?|\/social\?|\/pathways\?/i.test(trimmed)) return false;
if (/^```/.test(trimmed)) return false;
return true;
})
.join("\n")
.trim();
const withoutJsonEnvelope = withoutControlLines.replace(/^\s*\{[\s\S]*"reply"\s*:\s*"([^"]+)"[\s\S]*\}\s*$/i, "$1");
const withoutRoutes = withoutJsonEnvelope
.replace(/\b(?:Interview|Roleplay|Resume|Q Score)?\s*setup route:\s*\/\S+/gi, "")
.replace(/\/agents\/(?:interview|roleplay|resume|qscore)\/?\S*/gi, "")
.replace(/\/analytics\?\S*/gi, "")
.replace(/\/social\?\S*/gi, "")
.replace(/\/pathways\?\S*/gi, "");
return withoutRoutes
.replace(/[\u2013\u2014]/g, "-")
.replace(/[\u2018\u2019]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
.replace(/\u2026/g, "...")
.replace(/^\s*(Perfect|Great|Absolutely|Sure)[.!,:;-]*\s*/i, "")
.trim();
}
function pushField(lines: string[], label: string, value?: string | number | null) {
if (value === undefined || value === null) return;
const stringValue = String(value).trim();
if (!stringValue) return;
lines.push(`${label}: ${stringValue}`);
}
function pushList(lines: string[], label: string, values?: string[]) {
const cleanValues = values?.map((value) => value.trim()).filter(Boolean) ?? [];
if (cleanValues.length === 0) return;
lines.push(`${label}: ${cleanValues.join(" | ")}`);
}
type CuratorMessage = Awaited<ReturnType<typeof listMessagesPg>>[number];
async function capturedSubtaskMemory(userId: string, taskId?: string) {
if (!taskId) return [];
const rows = await db
.select()
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
eq(growEvents.type, "curator.subtask.captured"),
))
.orderBy(desc(growEvents.occurredAt))
.limit(80);
return rows
.map((row) => row.payload ?? {})
.filter((payload) => payload.taskId === taskId)
.map((payload) => ({
subtaskIndex: typeof payload.subtaskIndex === "number" ? payload.subtaskIndex : undefined,
subtask: typeof payload.subtask === "string" ? payload.subtask : undefined,
summary: typeof (payload.statusUpdate as any)?.summary === "string" ? (payload.statusUpdate as any).summary : undefined,
}))
.filter((item) => item.summary)
.reverse();
}
function visibleCuratorMessages(messages: CuratorMessage[]) {
const filtered = messages.filter((message) => {
const content = message.content.trim();
if (message.role === "user") {
if (/^start$/i.test(content)) return false;
if (/^i opened /i.test(content)) return false;
return true;
}
return !/what should i capture/i.test(content);
});
return filtered.filter((message, index) => {
const previous = filtered[index - 1];
return !previous || previous.role !== message.role || previous.content.trim() !== message.content.trim();
});
}
function usefulUserMessages(messages: CuratorMessage[]) {
return messages
.filter((message) => message.role === "user")
.map((message) => message.content.trim())
.filter((content) => content && !/^start$/i.test(content) && !content.toLowerCase().includes("i opened "));
}
function targetRoleState(messages: CuratorMessage[], latest: string) {
const userMessages = usefulUserMessages(messages);
const all = [...userMessages, latest.trim()].filter(Boolean);
const lowerAll = all.join("\n").toLowerCase();
const shortAnswers = all.filter((content) => content.length <= 80);
const targetRole = shortAnswers.find((content) => {
const lower = content.toLowerCase();
return /manager|engineer|designer|analyst|developer|product|marketing|sales|founder|consultant|operator|lead|head|director/.test(lower);
});
const currentBackground = all.find((content) => {
const lower = content.toLowerCase();
return lower.includes("currently") || lower.includes("right now") || lower.includes("i am ") || lower.includes("i'm ") || lower.includes("my background") || lower.includes("experience");
});
const constraints = all.find((content) => {
const lower = content.toLowerCase();
return lower.includes("month") || lower.includes("week") || lower.includes("salary") || lower.includes("remote") || lower.includes("location") || lower.includes("visa") || lower.includes("timeline");
});
return {
targetRole,
currentBackground,
constraints,
hasAskedCurrent: lowerAll.includes("current background") || lowerAll.includes("current role") || lowerAll.includes("where you are starting"),
hasAskedConstraints: lowerAll.includes("constraint") || lowerAll.includes("timeline"),
};
}
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;
subtask?: string;
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
taskMemory?: Array<{ subtaskIndex?: number; subtask?: string; summary?: string }>;
promptText: string;
targetRole?: string;
}) {
const template = await loadCuratorPromptTemplate();
const lines = [
input.promptText,
"",
...template
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter(Boolean),
];
pushField(lines, "Known target role", input.targetRole);
pushField(lines, "Date", input.date);
pushField(lines, "Curator task id", input.taskId);
pushField(lines, "Focused subtask index", Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : undefined);
pushField(lines, "Focused subtask title", input.subtask);
pushField(lines, "Curator task title", input.task?.title);
pushField(lines, "Curator task context", input.task?.contextNarrative);
pushList(lines, "Curator task subtasks", input.task?.subtasks);
pushField(lines, "Curator service", input.task?.serviceName);
pushList(lines, "Expected completion events", input.task?.completionEvents);
const memory = input.taskMemory
?.map((item) => {
if (!item.summary) return "";
const subtask = item.subtask?.trim() || "Subtask";
const index = Number.isInteger(item.subtaskIndex) ? `[${item.subtaskIndex}] ` : "";
return `${index}${subtask}: ${item.summary}`;
})
.filter(Boolean);
pushList(lines, "Captured task memory", memory);
return lines.join("\n");
}
function curatorTaskKey(taskId?: string, subtaskIndex?: number) {
if (!taskId) return undefined;
return `${taskId}:${subtaskIndex ?? "task"}`;
}
function firstTurnPrompt(input: {
subtask?: string;
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
targetRole?: string;
}) {
return [
`The user opened this focused subtask: ${input.subtask ?? input.task?.title ?? "curator task"}.`,
"Generate the first live conversational question for this exact subtask.",
input.targetRole ? `Known target role: ${input.targetRole}. Do not ask for the role again.` : "If this is an interview or roleplay task and no target role is known, ask exactly: What role are you targeting?",
"Ask only one question. Do not use canned wording. Do not prepare any service handoff yet.",
].join("\n");
}
function isExplicitHandoffRequest(text: string) {
const trimmed = text.trim();
if (/^start$/i.test(trimmed)) return false;
return /\b(start|open|launch|begin|set up|setup|create|generate|room|ready|go|give)\b/i.test(trimmed);
}
function shouldPrepareServiceHandoff(status: CuratorSubtaskStatusUpdate, task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number]) {
if (!task?.serviceId) return false;
return status.status === "ready_to_capture" || status.status === "handoff_ready";
}
function isPreviewHandoffService(task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number]) {
return task?.serviceId === "interview-service" || task?.serviceId === "roleplay-service";
}
function servicePreviewSummary(task: Awaited<ReturnType<typeof buildCuratorTasks>>[number], targetRole?: string) {
const role = fallbackCuratorRole(targetRole);
if (task.serviceId === "interview-service") {
return `Prepared a 5-minute behavioral interview preview for ${role}.`;
}
if (task.serviceId === "roleplay-service") {
return `Prepared a 5-minute roleplay preview for ${role}.`;
}
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;
subtaskIndex?: number;
latest: string;
reply: string;
history: CuratorMessage[];
}): Promise<CuratorSubtaskStatusUpdate> {
if (!input.subtask || /^start$/i.test(input.latest.trim())) {
return { status: "needs_more_context", summary: "Subtask opened.", confidence: 0.2 };
}
try {
const result = await generateText({
model: getConversationModel(),
system: [
"You are the GrowQR V1 Curator Actor state evaluator.",
"Return JSON only. Do not wrap it in markdown.",
"Shape: {\"status\": \"needs_more_context\" | \"ready_to_capture\" | \"handoff_ready\", \"summary\": string, \"confidence\": number, \"nextMissingInfo\"?: string}.",
"Evaluate only the focused subtask. Ignore other missions, other subtasks, and later checklist items.",
"Use ready_to_capture only when the latest user answer directly satisfies the focused subtask.",
"Use needs_more_context if the assistant reply asks another question or if the answer is too vague for this exact subtask.",
"Use handoff_ready only when the focused subtask explicitly asks to open or preview a service and the service setup details are present.",
"Use handoff_ready when the user explicitly says to start, open, launch, set up, or begin the service and the necessary setup context is already present.",
"Never mark ready just because one message exists.",
"Use ASCII punctuation only.",
].join("\n"),
prompt: (() => {
const lines: string[] = [];
pushField(lines, "Task title", input.task?.title);
pushField(lines, "Task service", input.task?.serviceName);
pushField(lines, "Focused subtask index", Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : undefined);
pushField(lines, "Focused subtask", input.subtask);
pushField(lines, "Task context", input.task?.contextNarrative);
pushList(lines, "All task subtasks", input.task?.subtasks);
pushField(lines, "Latest user answer", input.latest);
pushField(lines, "Assistant reply", input.reply);
pushField(lines, "Visible history", input.history.map((message) => `${message.role}: ${message.content}`).join("\n"));
return lines.join("\n");
})(),
});
return subtaskStatusUpdateSchema.parse(parseJsonObject(result.text));
} catch (error) {
console.warn("curator status evaluation failed; keeping subtask open", {
taskId: input.task?.id,
subtaskIndex: input.subtaskIndex,
error: error instanceof Error ? error.message : String(error),
});
return { status: "needs_more_context", summary: "The curator needs one more answer before updating this subtask.", confidence: 0.1 };
}
}
async function ensureCuratorConversation(input: { userId: string; taskId?: string; date: string; subtaskIndex?: number; subtask?: string }) {
if (!input.taskId) return createConversationPg(input.userId, "V1 Curator chat");
const task = (await buildCuratorTasks(input.userId, input.date)).find((item) => item.id === input.taskId);
if (task) {
return ensureCuratorTaskConversationPg({
userId: input.userId,
curatorTaskId: task.id,
subtaskIndex: input.subtaskIndex,
subtask: input.subtask,
missionInstanceId: task.missionInstanceId,
missionId: task.missionId,
stageId: task.stageId,
title: input.subtask ?? task.title,
});
}
return createConversationPg(input.userId, "V1 Curator chat");
}
export async function runCuratorChat(input: {
userId: string;
conversationId?: string;
taskId?: string;
subtaskIndex?: number;
subtask?: string;
date?: string;
messages: Array<{ role: "user" | "assistant"; content: string }>;
}): Promise<CuratorChatResponse> {
const date = input.date ?? todayIsoDate();
const expectedTaskKey = curatorTaskKey(input.taskId, input.subtaskIndex);
let conversation = input.conversationId ? { id: input.conversationId } : undefined;
if (conversation?.id && expectedTaskKey) {
const metadata = await getConversationMetadataPg(input.userId, conversation.id);
if (metadata?.curatorTaskKey !== expectedTaskKey) {
conversation = undefined;
}
}
conversation ??= await ensureCuratorConversation({
userId: input.userId,
taskId: input.taskId,
date,
subtaskIndex: input.subtaskIndex,
subtask: input.subtask,
});
const latest = [...input.messages].reverse().find((message) => message.role === "user")?.content?.trim() ?? "start";
const tasks = await buildCuratorTasks(input.userId, date);
const task = input.taskId ? tasks.find((item) => item.id === input.taskId) : undefined;
const taskMemory = await capturedSubtaskMemory(input.userId, input.taskId);
const promptText = await loadCuratorStreakPrompt();
await addMessagePg(input.userId, {
id: buildId("user"),
conversationId: conversation.id,
role: "user",
sender: "User",
content: latest,
});
const conversationHistory = visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id));
const targetRole = await resolveCuratorTargetRole({
userId: input.userId,
task,
latest,
history: conversationHistory,
});
const isInitialOpen = /^start$/i.test(latest);
if (isInitialOpen && isPreviewHandoffService(task) && targetRole) {
const statusUpdate: CuratorSubtaskStatusUpdate = {
status: "handoff_ready",
summary: servicePreviewSummary(task!, targetRole),
confidence: 0.95,
};
const handoff = await prepareHandoffForTask(input.userId, task!, task!.serviceId, targetRole);
const reply = sanitize(statusUpdate.summary);
await emitCuratorEvent({
userId: input.userId,
type: "curator.subtask.captured",
mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined,
payload: {
taskId: input.taskId,
subtaskIndex: input.subtaskIndex,
subtask: input.subtask,
statusUpdate,
},
});
await addMessagePg(input.userId, {
id: buildId("assistant"),
conversationId: conversation.id,
role: "assistant",
sender: "V1 Curator",
content: reply,
});
return {
conversationId: conversation.id,
taskId: input.taskId,
reply,
messages: visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id)),
statusUpdate,
handoff,
};
}
if (isInitialOpen && isPreviewHandoffService(task) && !targetRole) {
const statusUpdate: CuratorSubtaskStatusUpdate = {
status: "needs_more_context",
summary: "Target role needed before preparing the preview.",
confidence: 0.4,
nextMissingInfo: "target role",
};
const reply = "What role are you targeting?";
await addMessagePg(input.userId, {
id: buildId("assistant"),
conversationId: conversation.id,
role: "assistant",
sender: "V1 Curator",
content: reply,
});
return {
conversationId: conversation.id,
taskId: input.taskId,
reply,
messages: visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id)),
statusUpdate,
};
}
let reply = "";
let usedFallbackReply = false;
try {
try {
const extract = await generateText({
model: getConversationModel(),
system: [
"Extract compact curator memory from the user's latest message.",
"Return JSON only: {\"summary\": string, \"userGoal\"?: string, \"serviceIntent\"?: string, \"shouldPrepareHandoff\": boolean}.",
"Use ASCII punctuation only.",
].join("\n"),
prompt: (() => {
const lines: string[] = [];
pushField(lines, "Task", task?.title);
pushField(lines, "Subtask", input.subtask);
pushField(lines, "Service", task?.serviceName);
pushField(lines, "Message", latest);
return lines.join("\n");
})(),
});
const parsedExtract = chatExtractSchema.parse(parseJsonObject(extract.text));
await emitCuratorEvent({
userId: input.userId,
type: "curator.chat.context_extracted",
mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined,
payload: { taskId: input.taskId, extract: parsedExtract },
});
} catch (error) {
console.warn("curator memory extraction failed; continuing chat", {
taskId: input.taskId,
subtaskIndex: input.subtaskIndex,
error: error instanceof Error ? error.message : String(error),
});
}
const modelMessages = conversationHistory.map((message) => ({
role: message.role,
content: message.content,
}));
if (/^start$/i.test(latest) && modelMessages.length === 0) {
modelMessages.push({ role: "user", content: firstTurnPrompt({ subtask: input.subtask, task, targetRole }) });
}
const result = await generateConversationResponse(modelMessages, {
userId: input.userId,
conversationId: conversation.id,
missionInstanceId: task?.missionInstanceId,
missionId: task?.missionId,
stageId: task?.stageId,
source: "curator-v1",
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) {
throw new Error("curator_generation_failed");
}
} catch (error) {
console.warn("curator chat generation failed", {
taskId: input.taskId,
subtaskIndex: input.subtaskIndex,
subtask: input.subtask,
error: error instanceof Error ? error.message : String(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({
task,
subtask: input.subtask,
subtaskIndex: input.subtaskIndex,
latest,
reply,
history: conversationHistory,
});
if (task?.serviceId && (isExplicitHandoffRequest(latest) || statusUpdate.status === "ready_to_capture")) {
statusUpdate = {
status: "handoff_ready",
summary: servicePreviewSummary(task, targetRole),
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",
summary: servicePreviewSummary(task!, targetRole),
confidence: Math.max(statusUpdate.confidence, 0.9),
};
}
const handoff = shouldPrepareServiceHandoff(statusUpdate, task)
? await prepareHandoffForTask(input.userId, task!, task!.serviceId, targetRole)
: undefined;
if (statusUpdate.status !== "needs_more_context") {
if (reply.includes("?") || handoff) {
reply = sanitize(statusUpdate.summary);
}
await emitCuratorEvent({
userId: input.userId,
type: "curator.subtask.captured",
mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined,
payload: {
taskId: input.taskId,
subtaskIndex: input.subtaskIndex,
subtask: input.subtask,
statusUpdate,
},
});
}
await addMessagePg(input.userId, {
id: buildId("assistant"),
conversationId: conversation.id,
role: "assistant",
sender: "V1 Curator",
content: reply,
});
return {
conversationId: conversation.id,
taskId: input.taskId,
reply,
messages: visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id)),
statusUpdate,
handoff,
};
}

View File

@@ -0,0 +1,36 @@
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;
payload?: Record<string, unknown>;
mission?: Record<string, unknown>;
}) {
return recordGrowEvent({
source: "curator-v1",
type: input.type,
category: "mission",
userId: input.userId,
occurredAt: new Date().toISOString(),
mission: input.mission,
payload: input.payload ?? {},
dedupeKey: curatorDedupeKey(input),
}, { userId: input.userId, source: "curator-v1" });
}

View File

@@ -0,0 +1,103 @@
import type { CuratorServiceId, CuratorTaskType } from "./curator-types.js";
export type CuratorIcpId =
| "student_recent_grad"
| "intern"
| "fresher_early_professional"
| "experienced_professional";
export type CuratorPlaybookAction = {
taskType: CuratorTaskType;
serviceId: CuratorServiceId;
goal: string;
action: string;
deepLinkIntent: string;
expectedSignals: string[];
};
export type CuratorIcpPlaybook = {
id: CuratorIcpId;
label: string;
sprintTheme: string;
goal: string;
stageLabels: [string, string, string, string, string];
serviceActions: CuratorPlaybookAction[];
};
export const CURATOR_ICP_PLAYBOOKS: Record<CuratorIcpId, CuratorIcpPlaybook> = {
student_recent_grad: {
id: "student_recent_grad",
label: "Student / Recent Grad",
sprintTheme: "First Role Readiness Sprint",
goal: "Have a credible resume, practiced interviews, visible proof, and a clear target role by the end of the sprint.",
stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"],
serviceActions: [
play("measurement", "qscore-service", "baseline", "Establish readiness baseline and weakest drivers.", "analytics", ["qscore baseline", "weakest driver"]),
play("proof", "resume-service", "first proof", "Import resume and convert projects into proof bullets.", "resume workspace", ["resume import", "project proof"]),
play("proof", "social-branding-service", "visible credibility", "Turn proof into public-safe profile and post artifacts.", "social profile flow", ["headline", "public proof"]),
play("practice", "interview-service", "interview confidence", "Run behavioral and project interview reps.", "interview preview", ["mock interview", "feedback"]),
play("practice", "matchmaking-service", "role direction", "Shortlist realistic first-role opportunities.", "pathways", ["target roles", "opportunity shortlist"]),
],
},
intern: {
id: "intern",
label: "Intern",
sprintTheme: "Intern-to-Offer Sprint",
goal: "Convert internship work into stronger impact proof, return-offer readiness, and external backup options.",
stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"],
serviceActions: [
play("measurement", "qscore-service", "return-offer baseline", "Measure return-offer proof gaps and readiness.", "analytics", ["return offer", "readiness"]),
play("proof", "resume-service", "internship proof", "Document project decisions, metrics, and impact bullets.", "resume workspace", ["internship proof", "impact log"]),
play("proof", "social-branding-service", "manager visibility", "Prepare manager updates, feedback asks, and visibility notes.", "social profile flow", ["manager update", "feedback ask"]),
play("practice", "roleplay-service", "conversion conversations", "Practice mentor, manager, and return-offer asks.", "roleplay builder", ["conversion ask", "stakeholder conversation"]),
play("practice", "matchmaking-service", "backup options", "Maintain credible external backup opportunities.", "pathways", ["backup roles", "pipeline"]),
],
},
fresher_early_professional: {
id: "fresher_early_professional",
label: "Fresher / Early Professional",
sprintTheme: "Callback-to-Offer Sprint",
goal: "Improve callback conversion, sharpen proof, and build stronger interview confidence across the sprint.",
stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"],
serviceActions: [
play("measurement", "qscore-service", "readiness baseline", "Anchor the sprint in current QScore and missing signals.", "analytics", ["qscore", "readiness"]),
play("proof", "resume-service", "role-fit proof", "Tailor resume proof to target roles and outcomes.", "resume workspace", ["resume proof", "role fit"]),
play("proof", "social-branding-service", "credibility signal", "Create visible credibility updates from real work.", "social profile flow", ["credibility", "visibility"]),
play("practice", "interview-service", "callback conversion", "Run focused interview reps for weak question types.", "interview preview", ["interview practice", "callback"]),
play("practice", "roleplay-service", "confidence conversations", "Practice recruiter intros, objections, and pitch clarity.", "roleplay builder", ["recruiter intro", "confidence"]),
],
},
experienced_professional: {
id: "experienced_professional",
label: "Experienced Professional",
sprintTheme: "Leadership Readiness Sprint",
goal: "Strengthen leadership proof, senior interview readiness, and authority positioning for the next move.",
stageLabels: ["Leadership Baseline + Strategic Proof", "Strategic Positioning + Authority", "Negotiation + Market Action", "Conversion + Closeout", "Momentum + Carry Forward"],
serviceActions: [
play("measurement", "qscore-service", "senior readiness baseline", "Identify leadership readiness and authority gaps.", "analytics", ["leadership baseline", "authority"]),
play("proof", "resume-service", "leadership proof", "Translate execution into scope, team, and business impact.", "resume workspace", ["leadership proof", "business impact"]),
play("proof", "social-branding-service", "authority positioning", "Turn strategic lessons into public-safe authority signals.", "social profile flow", ["authority post", "positioning"]),
play("practice", "interview-service", "senior interviews", "Practice stakeholder, strategy, and leadership interview reps.", "interview preview", ["senior interview", "strategy"]),
play("practice", "roleplay-service", "negotiation and pushback", "Practice compensation, scope, promotion, and objection conversations.", "roleplay builder", ["negotiation", "pushback"]),
],
},
};
export function isCuratorIcpId(value: string): value is CuratorIcpId {
return value in CURATOR_ICP_PLAYBOOKS;
}
export function curatorPlaybookFor(id: CuratorIcpId) {
return CURATOR_ICP_PLAYBOOKS[id] ?? CURATOR_ICP_PLAYBOOKS.fresher_early_professional;
}
function play(
taskType: CuratorTaskType,
serviceId: CuratorServiceId,
goal: string,
action: string,
deepLinkIntent: string,
expectedSignals: string[],
): CuratorPlaybookAction {
return { taskType, serviceId, goal, action, deepLinkIntent, expectedSignals };
}

View File

@@ -0,0 +1,314 @@
import { and, desc, eq } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents, growHomeNotifications, type GrowEventRow } from "../../db/schema.js";
import { asRecord, getString } from "../../events/envelope.js";
import { recordGrowEvent } from "../../events/record-grow-event.js";
import { log } from "../../log.js";
import { config } from "../../config.js";
import { buildCuratorSprint, todayIsoDate } from "./curator-store.js";
import { emitCuratorEvent } from "./curator-events.js";
import type { CuratorSprintResponse } from "./curator-types.js";
const CURATOR_SOURCE = "curator-v1";
const ONBOARDING_READY_EVENT = "curator.onboarding_plan.ready";
const ONBOARDING_SKIPPED_EVENT = "curator.onboarding_plan.skipped";
type OnboardingLoopInput = {
userId: string;
completedAt?: string | Date | null;
sourceEventId?: string;
source?: string;
context?: Record<string, unknown>;
};
type OnboardingLoopResult =
| { status: "ready"; sprint: CuratorSprintResponse; eventId: string }
| { status: "already_ready"; readyEventId: string; sprint?: CuratorSprintResponse }
| { status: "skipped"; reason: string };
function isoDateFrom(value: string | Date | null | undefined) {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? todayIsoDate() : value.toISOString().slice(0, 10);
}
if (typeof value === "string" && value.trim()) {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? todayIsoDate() : parsed.toISOString().slice(0, 10);
}
return todayIsoDate();
}
function parseCompletedAt(value: unknown): string | undefined {
const raw = getString(value);
if (!raw) return undefined;
const parsed = new Date(raw);
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
}
export function onboardingCompletedAtFromPreferences(preferences: Record<string, unknown> | undefined) {
const onboarding = asRecord(preferences?.onboarding);
return parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt);
}
export function onboardingCompletedAtFromEvent(event: Pick<GrowEventRow, "type" | "payload" | "occurredAt">) {
const payload = asRecord(event.payload);
const preferences = asRecord(payload.preferences);
const onboarding = asRecord(payload.onboarding);
return (
parseCompletedAt(payload.completedAt ?? payload.completed_at) ??
onboardingCompletedAtFromPreferences(preferences) ??
parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt) ??
(isOnboardingCompletionEvent(event) ? event.occurredAt.toISOString() : undefined)
);
}
export function isOnboardingCompletionEvent(event: Pick<GrowEventRow, "type" | "payload" | "occurredAt">) {
const normalizedType = event.type.toLowerCase().replaceAll("_", ".");
if (
normalizedType === "onboarding.completed" ||
normalizedType === "user.onboarding.completed" ||
normalizedType === "profile.onboarding.completed"
) {
return true;
}
const payload = asRecord(event.payload);
const preferences = asRecord(payload.preferences);
const onboarding = asRecord(payload.onboarding);
return Boolean(
onboardingCompletedAtFromPreferences(preferences) ??
parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt) ??
parseCompletedAt(payload.onboarding_completed_at ?? payload.onboardingCompletedAt),
);
}
async function findExistingReadyEvent(userId: string) {
const [existing] = await db
.select({ id: growEvents.id, payload: growEvents.payload })
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
eq(growEvents.source, CURATOR_SOURCE),
eq(growEvents.type, ONBOARDING_READY_EVENT),
))
.orderBy(desc(growEvents.occurredAt))
.limit(1);
return existing;
}
async function recordOnboardingContextSnapshot(input: {
userId: string;
startDate: string;
completedAt?: string | Date | null;
source?: string;
sourceEventId?: string;
context?: Record<string, unknown>;
}) {
if (!input.context || !Object.keys(input.context).length) return;
await recordGrowEvent({
source: input.source ?? "onboarding",
type: "onboarding.completed",
category: "usage",
userId: input.userId,
occurredAt: input.completedAt instanceof Date
? input.completedAt.toISOString()
: typeof input.completedAt === "string" && input.completedAt.trim()
? input.completedAt
: new Date().toISOString(),
correlation: { sourceEventId: input.sourceEventId },
payload: {
completedAt: input.completedAt instanceof Date ? input.completedAt.toISOString() : input.completedAt,
...input.context,
},
dedupeKey: `curator:onboarding-context:${input.userId}:${input.startDate}`,
}, { userId: input.userId, source: input.source ?? "onboarding" });
}
async function fetchUserServiceContext(userId: string): Promise<Record<string, unknown> | undefined> {
const token = config.serviceToken || (config.nodeEnv !== "production" ? config.a2aAllowedKey : "");
if (!token) return undefined;
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
const res = await fetch(target, {
method: "GET",
headers: {
authorization: `Bearer ${token}`,
"x-growqr-user": userId,
},
}).catch((err) => {
log.warn({ err, userId }, "curator onboarding could not fetch user-service profile");
return null;
});
if (!res?.ok) return undefined;
const profile = await res.json().catch(() => null) as Record<string, unknown> | null;
if (!profile) return undefined;
const preferences = asRecord(profile.preferences);
return { profile, preferences };
}
function dayOneSubtitle(sprint: CuratorSprintResponse) {
const task = sprint.plan.days[0]?.tasks[0] ?? sprint.todayTasks[0];
if (!task) return "Your personalized Day 1 tasks are ready on the home dashboard.";
return `Day 1 starts with ${task.title.toLowerCase()}.`;
}
async function upsertPlanReadyNotification(userId: string, sprint: CuratorSprintResponse) {
const notificationId = `curator:onboarding-plan-ready:${userId}`;
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 14);
await db
.insert(growHomeNotifications)
.values({
id: notificationId,
userId,
moduleId: "missions",
title: "Your 30-day streak plan is ready",
subtitle: dayOneSubtitle(sprint),
tag: "Day 1 ready",
urgency: "today",
href: "/missions/active",
source: "system",
sourceRef: {
sprintId: sprint.sprintId,
planId: sprint.plan.id,
activeDayIndex: sprint.activeDayIndex,
source: CURATOR_SOURCE,
},
priority: 95,
generatedBy: "manual",
reason: "Created by the curator onboarding loop after onboarding completion.",
status: "active",
expiresAt,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: growHomeNotifications.id,
set: {
subtitle: dayOneSubtitle(sprint),
sourceRef: {
sprintId: sprint.sprintId,
planId: sprint.plan.id,
activeDayIndex: sprint.activeDayIndex,
source: CURATOR_SOURCE,
},
status: "active",
expiresAt,
updatedAt: new Date(),
},
});
}
function weeklyThemes(sprint: CuratorSprintResponse) {
return sprint.plan.weeks.map((week) => ({
weekIndex: week.weekIndex,
theme: week.theme,
summary: week.summary,
startDayIndex: week.startDayIndex,
endDayIndex: week.endDayIndex,
}));
}
function dayOneTasks(sprint: CuratorSprintResponse) {
return (sprint.plan.days[0]?.tasks ?? sprint.todayTasks).map((task) => ({
id: task.id,
title: task.title,
serviceId: task.serviceId,
route: task.route,
cta: task.cta,
rewardCoins: task.rewardCoins,
}));
}
export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Promise<OnboardingLoopResult> {
const userId = input.userId.trim();
if (!userId) return { status: "skipped", reason: "missing_user_id" };
const existing = await findExistingReadyEvent(userId);
if (existing) {
return { status: "already_ready", readyEventId: existing.id };
}
const startDate = isoDateFrom(input.completedAt);
const context = input.context ?? await fetchUserServiceContext(userId);
await recordOnboardingContextSnapshot({
userId,
startDate,
completedAt: input.completedAt,
source: input.source,
sourceEventId: input.sourceEventId,
context,
});
const sprint = await buildCuratorSprint(userId, startDate);
await upsertPlanReadyNotification(userId, sprint);
const event = await emitCuratorEvent({
userId,
type: ONBOARDING_READY_EVENT,
payload: {
source: input.source ?? "onboarding",
sourceEventId: input.sourceEventId,
completedAt: input.completedAt instanceof Date ? input.completedAt.toISOString() : input.completedAt,
startDate: sprint.plan.startDate,
endDate: sprint.plan.endDate,
sprintId: sprint.sprintId,
planId: sprint.plan.id,
durationDays: sprint.plan.durationDays,
weekCount: sprint.plan.weeks.length,
dayCount: sprint.plan.days.length,
activeDayIndex: sprint.activeDayIndex,
weeklyThemes: weeklyThemes(sprint),
dayOneTasks: dayOneTasks(sprint),
notificationId: `curator:onboarding-plan-ready:${userId}`,
},
});
return { status: "ready", sprint, eventId: event.id };
}
export async function runCuratorOnboardingLoopForEvent(event: GrowEventRow): Promise<OnboardingLoopResult> {
if (!event.userId) return { status: "skipped", reason: "missing_user_id" };
if (!isOnboardingCompletionEvent(event)) return { status: "skipped", reason: "not_onboarding_completion" };
return runCuratorOnboardingLoop({
userId: event.userId,
completedAt: onboardingCompletedAtFromEvent(event),
sourceEventId: event.id,
source: event.source,
});
}
export async function runCuratorOnboardingLoopSafely(input: OnboardingLoopInput): Promise<OnboardingLoopResult> {
try {
return await runCuratorOnboardingLoop(input);
} catch (err) {
log.error({ err, userId: input.userId }, "curator onboarding loop failed");
await emitCuratorEvent({
userId: input.userId,
type: ONBOARDING_SKIPPED_EVENT,
payload: {
reason: "loop_failed",
message: err instanceof Error ? err.message : String(err),
sourceEventId: input.sourceEventId,
},
}).catch((emitErr) => log.warn({ emitErr, userId: input.userId }, "failed to emit curator onboarding failure event"));
return { status: "skipped", reason: "loop_failed" };
}
}
export async function runCuratorOnboardingLoopForEventSafely(event: GrowEventRow): Promise<OnboardingLoopResult> {
try {
return await runCuratorOnboardingLoopForEvent(event);
} catch (err) {
log.error({ err, eventId: event.id, userId: event.userId }, "curator onboarding event loop failed");
if (event.userId) {
await emitCuratorEvent({
userId: event.userId,
type: ONBOARDING_SKIPPED_EVENT,
payload: {
reason: "event_loop_failed",
message: err instanceof Error ? err.message : String(err),
sourceEventId: event.id,
},
}).catch((emitErr) => log.warn({ emitErr, userId: event.userId }, "failed to emit curator onboarding event failure"));
}
return { status: "skipped", reason: "loop_failed" };
}
}

View File

@@ -0,0 +1,101 @@
import { createHash } from "node:crypto";
import type { CuratorIcpPlaybook } from "./curator-icp-playbooks.js";
import type { CuratorUserContext } from "./curator-user-context.js";
export const CURATOR_PROMPT_VERSION = "service-curation-v1";
export type CuratorPromptAssembly = {
version: typeof CURATOR_PROMPT_VERSION;
hash: string;
prompt: string;
inputs: {
startDate: string;
durationDays: number;
userContext: CuratorUserContext;
playbook: CuratorIcpPlaybook;
goals: string[];
};
};
export function buildCuratorPlanPrompt(input: {
startDate: string;
durationDays: number;
userContext: CuratorUserContext;
playbook: CuratorIcpPlaybook;
goals?: string[];
}): CuratorPromptAssembly {
const goals = input.goals?.filter(Boolean) ?? [input.playbook.sprintTheme, input.playbook.goal];
const inputs = {
startDate: input.startDate,
durationDays: input.durationDays,
userContext: input.userContext,
playbook: input.playbook,
goals,
};
const prompt = [
"# GrowQR Service Curation Layer",
"",
"You generate deterministic 30-day streak plans from user context and an ICP playbook.",
"Do not invent services. Use only service ids present in the playbook and Service Registry.",
"Do not handcraft frontend URLs. Emit linkBuilder inputs; the backend Service Registry builds final deep links.",
"No randomness, no vague tasks, no duplicate same-day service tasks.",
"",
"## Output Contract",
"Return structured JSON only with:",
"- durationDays: 30",
"- calendarWeeks: Sunday-start calendar weeks covering all 30 days",
"- days: exactly 30 days, where Day 1 is the subscription/start date",
"- closeoutDays: day 29 and day 30",
"- each day has exactly 3 tasks: measurement, proof, practice",
"- every task includes taskType, serviceId, title, subtitle, qxImpact, effort, cta, expectedSignals, and linkBuilder input",
"- weekly themes must follow the ICP stage labels",
"",
"## Staging Rules",
"Start weekly grouping on Sunday. If the user subscribes on Monday, Day 1 is Monday inside a Sunday-start Week 1.",
"The sprint is always exactly 30 days. Do not extend or shorten it to fit a calendar week.",
"Use the first calendar week for Baseline + First Proof, then progress through the ICP stage labels.",
"Use Day 29 and Day 30 for next-sprint planning and strongest-proof packaging.",
"",
"## Personalization Rules",
"- Use targetRole for interview and roleplay links.",
"- Use resume/profile context when available; if missing, day 1 proof should collect it.",
"- Use QScore to prioritize measurement tasks.",
"- Use past activity to avoid repeating completed or recently-used actions.",
"- Map every goal to one of the ICP playbook service actions.",
"",
`Start date: ${input.startDate}`,
`Duration days: ${input.durationDays}`,
`Goals: ${goals.join(" | ")}`,
"",
"User context:",
stableStringify(input.userContext),
"",
"ICP playbook:",
stableStringify(input.playbook),
].join("\n");
return {
version: CURATOR_PROMPT_VERSION,
hash: stableHash({ version: CURATOR_PROMPT_VERSION, inputs }),
prompt,
inputs,
};
}
export function stableHash(value: unknown) {
return createHash("sha256").update(stableStringify(value)).digest("hex");
}
function stableStringify(value: unknown): string {
return JSON.stringify(sortKeys(value), null, 2);
}
function sortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeys);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => [key, sortKeys(item)]),
);
}

View File

@@ -0,0 +1,104 @@
import { Hono } from "hono";
import { z } from "zod";
import { requireUser, type AuthContext } from "../../auth/clerk.js";
import { curatorActor } from "./curator-actor.js";
const chatSchema = z.object({
conversationId: z.string().optional(),
taskId: z.string().optional(),
subtaskIndex: z.number().int().min(0).optional(),
subtask: z.string().optional(),
date: z.string().optional(),
messages: z.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string() })).min(1).max(50),
});
const curationPreviewSchema = z.object({
startDate: z.string().optional(),
icpId: z.enum(["student_recent_grad", "intern", "fresher_early_professional", "experienced_professional"]).optional(),
goals: z.array(z.string()).optional(),
userContext: z.record(z.unknown()).optional(),
});
export function v1CuratorRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.post("/plan/generate", async (c) => {
const userId = c.get("userId");
const body = z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
goals: z.array(z.string()).optional(),
forceRegenerate: z.boolean().optional(),
}).parse(await c.req.json().catch(() => ({})));
return c.json(await curatorActor.generatePlanRange({ userId, ...body }));
});
app.get("/plan", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.getPlan({
userId,
startDate: c.req.query("startDate"),
endDate: c.req.query("endDate"),
}));
});
app.get("/today", async (c) => {
const userId = c.get("userId");
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("/curation/preview", async (c) => {
const userId = c.get("userId");
const body = curationPreviewSchema.parse(await c.req.json().catch(() => ({})));
return c.json(await curatorActor.previewCuration({ userId, ...body }));
});
app.post("/onboarding/run", async (c) => {
const userId = c.get("userId");
const body = z.object({
completedAt: z.string().optional(),
}).parse(await c.req.json().catch(() => ({})));
return c.json(await curatorActor.runOnboardingLoop({ userId, ...body }));
});
app.post("/chat", async (c) => {
const userId = c.get("userId");
const body = chatSchema.parse(await c.req.json());
return c.json(await curatorActor.chat({ userId, ...body }));
});
app.post("/tasks/:taskId/start", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.startTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
});
app.post("/tasks/:taskId/handoff", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.prepareTaskHandoff({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
});
app.post("/tasks/:taskId/complete", async (c) => {
const userId = c.get("userId");
const body = z.object({ reason: z.string().optional() }).parse(await c.req.json().catch(() => ({})));
return c.json(await curatorActor.completeTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date"), reason: body.reason }));
});
app.post("/events/service-impact", async (c) => {
const userId = c.get("userId");
const body = z.object({ eventId: z.string() }).parse(await c.req.json());
return c.json(await curatorActor.recordServiceImpact({ userId, eventId: body.eventId }));
});
app.get("/state", async (c) => {
const userId = c.get("userId");
return c.json(await curatorActor.getState({ userId }));
});
return app;
}

View File

@@ -0,0 +1,53 @@
import {
buildCuratorServiceRoute,
getServiceActionLabel,
getServiceCompletionEvents,
getServiceDisplayName,
getServiceToolName,
} from "../../services/service-registry.js";
import type { CuratorServiceId, CuratorTask } from "./curator-types.js";
type ServiceRouteInput = {
serviceId?: CuratorServiceId;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
taskId?: string;
targetRole?: string;
durationMinutes?: number;
difficulty?: string;
personaId?: string;
requestedMode?: string;
roleplayBrief?: string;
};
export function serviceRoute(input: ServiceRouteInput) {
return buildCuratorServiceRoute(input);
}
export function buildCuratorTaskDeepLink(task: Pick<CuratorTask, "serviceId" | "missionId" | "missionInstanceId" | "stageId" | "id">, targetRole?: string) {
return buildCuratorServiceRoute({
serviceId: task.serviceId,
missionId: task.missionId,
missionInstanceId: task.missionInstanceId,
stageId: task.stageId,
taskId: task.id,
targetRole,
});
}
export function serviceName(serviceId?: CuratorServiceId, fallback = "Mission planner") {
return getServiceDisplayName(serviceId, fallback);
}
export function serviceToolName(serviceId?: CuratorServiceId) {
return getServiceToolName(serviceId);
}
export function completionEventsForService(serviceId?: CuratorServiceId) {
return getServiceCompletionEvents(serviceId);
}
export function actionLabel(task: CuratorTask) {
return getServiceActionLabel(task);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,548 @@
import { tool } from "ai";
import { z } from "zod";
import { eq, desc, and, inArray } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents, growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js";
import { interviewService, resumeService, roleplayService } from "../../services/product-service-clients.js";
import { createMissionAction, listMissionActions } from "../../missions/actions.js";
import { listActiveMissionsPg, listMessagesPg } from "../../grow/persistence.js";
import { buildCuratorStreak, buildCuratorTasks, listCuratorRegistryCapabilities } from "./curator-store.js";
import { actionLabel, serviceRoute } from "./curator-service-links.js";
import { curatorServiceIdSchema, type CuratorServiceHandoff, type CuratorTask } from "./curator-types.js";
import { emitCuratorEvent } from "./curator-events.js";
import { fallbackCuratorRole, resolveCuratorTargetRole } from "./curator-user-context.js";
async function findTask(userId: string, taskId: string, date: string) {
const tasks = await buildCuratorTasks(userId, date);
return tasks.find((task) => task.id === taskId) ?? null;
}
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 resolvedTargetRole =
targetRoleOverride ??
(await missionGoalHint(userId, task)) ??
(await resolveCuratorTargetRole({ userId, task }));
const targetRole = fallbackCuratorRole(resolvedTargetRole);
const route = serviceRoute({
serviceId,
missionId: task.missionId,
missionInstanceId: task.missionInstanceId,
stageId: task.stageId,
taskId: task.id,
targetRole,
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 && task.missionId !== "curator-sprint") {
const action = await createMissionAction({
userId,
missionInstanceId: task.missionInstanceId,
missionId: task.missionId,
stageId: task.stageId,
agentId: "curator-v1",
agentName: "V1 Curator Actor",
baseAgent: "Curator Agent",
serviceId,
toolName: task.toolName,
mode: "suggestion",
status: "queued",
title: task.title,
body: task.subtitle,
prompt: `Prepare ${task.serviceName} handoff for ${task.title}.`,
payload: { href: route, route, taskId: task.id, source: "curator-v1" },
idempotencyKey: `curator-v1:${task.id}:${serviceId}`,
priority: 50,
urgency: "today",
});
actionId = action?.id;
}
await emitCuratorEvent({
userId,
type: "curator.service_handoff.opened",
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
payload: { taskId: task.id, serviceId, route, actionId },
});
return {
taskId: task.id,
serviceId,
route,
actionId,
actionRoute: route,
actionLabel: actionLabel({ ...task, serviceId }),
status: "prepared",
};
}
export function buildCuratorTools(ctx: { userId: string; date: string; conversationId?: string; taskId?: string }) {
return {
get_onboarding_context: tool({
description: "Read available onboarding and profile context from recent platform events.",
inputSchema: z.object({}),
execute: async () => {
const events = await db.select().from(growEvents)
.where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.category, "usage")))
.orderBy(desc(growEvents.occurredAt))
.limit(20);
return { events };
},
}),
get_user_goals: tool({
description: "Infer currently known goals from active missions and mission goals.",
inputSchema: z.object({}),
execute: async () => {
const active = await listActiveMissionsPg(ctx.userId);
return { goals: active.map((item) => ({ missionId: item.mission.missionId, title: item.mission.title, goal: item.mission.goal })) };
},
}),
get_curator_plan: tool({
description: "Read today's curator tasks and streak state.",
inputSchema: z.object({}),
execute: async () => ({ date: ctx.date, tasks: await buildCuratorTasks(ctx.userId, ctx.date), streak: await buildCuratorStreak(ctx.userId) }),
}),
get_today_tasks: tool({
description: "List today's V1 curator tasks.",
inputSchema: z.object({}),
execute: async () => ({ tasks: await buildCuratorTasks(ctx.userId, ctx.date) }),
}),
get_curator_streak: tool({
description: "Read the user's curator streak from allowed completion events.",
inputSchema: z.object({}),
execute: async () => ({ streak: await buildCuratorStreak(ctx.userId) }),
}),
read_curator_memory: tool({
description: "Read recent curator memory from existing grow events.",
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(10) }),
execute: async ({ limit }) => {
const events = await db.select().from(growEvents)
.where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.source, "curator-v1")))
.orderBy(desc(growEvents.occurredAt))
.limit(limit);
return { events };
},
}),
write_curator_memory: tool({
description: "Write a durable curator memory event. Use this for chat extracts and status updates.",
inputSchema: z.object({ summary: z.string(), tags: z.array(z.string()).default([]) }),
execute: async ({ summary, tags }) => emitCuratorEvent({ userId: ctx.userId, type: "curator.memory.updated", payload: { summary, tags } }),
}),
read_conversation_context: tool({
description: "Read the current conversation history from existing conversation storage.",
inputSchema: z.object({ conversationId: z.string().optional() }),
execute: async ({ conversationId }) => ({ messages: conversationId || ctx.conversationId ? await listMessagesPg(ctx.userId, conversationId ?? ctx.conversationId!) : [] }),
}),
list_service_capabilities: tool({
description: "List deterministic service capabilities from existing service registries.",
inputSchema: z.object({}),
execute: listCuratorRegistryCapabilities,
}),
list_available_service_routes: tool({
description: "Return known handoff routes for registered services.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = taskId ? await findTask(ctx.userId, taskId, ctx.date) : null;
return {
routes: ["interview-service", "resume-service", "roleplay-service", "qscore-service"].map((serviceId) => ({
serviceId,
route: serviceRoute({ serviceId: serviceId as any, missionId: task?.missionId, missionInstanceId: task?.missionInstanceId, stageId: task?.stageId, taskId: task?.id }),
})),
};
},
}),
validate_service_handoff: tool({
description: "Validate whether a requested service handoff exists in the registry.",
inputSchema: z.object({ serviceId: curatorServiceIdSchema }),
execute: async ({ serviceId }) => {
const capabilities = await listCuratorRegistryCapabilities();
return { valid: capabilities.services.some((service) => service.id === serviceId), serviceId };
},
}),
map_task_to_service: tool({
description: "Map a curator task to its service capability and handoff route.",
inputSchema: z.object({ taskId: z.string() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId, ctx.date);
return { task, route: task ? serviceRoute({ serviceId: task.serviceId, missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId, taskId }) : null };
},
}),
prepare_interview_setup: tool({
description: "Prepare an interview setup handoff. This creates a mission action and route, not the full interview workflow.",
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" };
return prepareHandoffForTask(ctx.userId, task, "interview-service");
},
}),
prepare_interview_preview: tool({
description: "Prepare the interview preview route after setup context exists.",
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" };
return prepareHandoffForTask(ctx.userId, task, "interview-service");
},
}),
read_interview_report: tool({
description: "Read available interview page/report state from the existing interview service.",
inputSchema: z.object({ sessionId: z.string().optional() }),
execute: async ({ sessionId }) => sessionId ? interviewService.review(sessionId) : interviewService.pageState(ctx.userId),
}),
list_interview_sessions: tool({
description: "List interview service page state for the user.",
inputSchema: z.object({}),
execute: async () => interviewService.pageState(ctx.userId),
}),
get_interview_latest_status: tool({
description: "Read the latest interview-related events.",
inputSchema: z.object({ limit: z.number().int().min(1).max(20).default(5) }),
execute: async ({ limit }) => db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.source, "interview"))).orderBy(desc(growEvents.occurredAt)).limit(limit),
}),
prepare_resume_upload: tool({
description: "Prepare resume upload or resume builder handoff.",
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" };
return prepareHandoffForTask(ctx.userId, task, "resume-service");
},
}),
extract_resume_context: tool({
description: "Extract basic context from pasted resume text for curator reasoning.",
inputSchema: z.object({ text: z.string().min(1) }),
execute: async ({ text }) => ({
length: text.length,
hasExperience: /experience|work|employment/i.test(text),
hasEducation: /education|degree|university|college/i.test(text),
hasSkills: /skills|tools|technologies/i.test(text),
preview: text.slice(0, 500),
}),
}),
read_resume_report: tool({
description: "Read existing resume service state.",
inputSchema: z.object({ resumeId: z.string().optional() }),
execute: async ({ resumeId }) => resumeId ? resumeService.getResume(resumeId) : resumeService.state(ctx.userId),
}),
prepare_resume_rewrite: tool({
description: "Prepare a resume rewrite handoff.",
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" };
return prepareHandoffForTask(ctx.userId, task, "resume-service");
},
}),
prepare_resume_talking_points: tool({
description: "Prepare resume talking point handoff.",
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" };
return prepareHandoffForTask(ctx.userId, task, "resume-service");
},
}),
prepare_resume_gap_scan: tool({
description: "Prepare resume gap scan handoff.",
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" };
return prepareHandoffForTask(ctx.userId, task, "resume-service");
},
}),
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() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
if (!task) return { error: "task_not_found" };
return prepareHandoffForTask(ctx.userId, task, "roleplay-service");
},
}),
prepare_roleplay_preview: tool({
description: "Prepare roleplay preview handoff.",
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" };
return prepareHandoffForTask(ctx.userId, task, "roleplay-service");
},
}),
suggest_roleplay_scenario: tool({
description: "Suggest a roleplay scenario from current task context.",
inputSchema: z.object({ taskId: z.string().optional() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
return { scenario: task?.title ?? "Practice a high-stakes workplace conversation", outcome: "Clear next step and confident response" };
},
}),
read_roleplay_report: tool({
description: "Read roleplay service report or page state.",
inputSchema: z.object({ sessionId: z.string().optional() }),
execute: async ({ sessionId }) => sessionId ? roleplayService.review(sessionId) : roleplayService.pageState(ctx.userId),
}),
list_roleplay_sessions: tool({
description: "List roleplay service page state for the user.",
inputSchema: z.object({}),
execute: async () => roleplayService.pageState(ctx.userId),
}),
read_qscore_state: tool({
description: "Read current Q-score projection state.",
inputSchema: z.object({}),
execute: async () => db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, ctx.userId)).limit(1),
}),
read_qscore_signals: tool({
description: "Read latest Q-score signals.",
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(20) }),
execute: async ({ limit }) => db.select().from(growQscoreLatest).where(eq(growQscoreLatest.userId, ctx.userId)).orderBy(desc(growQscoreLatest.updatedAt)).limit(limit),
}),
explain_qscore_movement: tool({
description: "Explain recent Q-score movement from available signals.",
inputSchema: z.object({}),
execute: async () => ({ state: await db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, ctx.userId)).limit(1), signals: await db.select().from(growQscoreLatest).where(eq(growQscoreLatest.userId, ctx.userId)).orderBy(desc(growQscoreLatest.updatedAt)).limit(10) }),
}),
map_task_to_qscore_signals: tool({
description: "Map a curator task to the Q-score signals it can affect.",
inputSchema: z.object({ taskId: z.string() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId, ctx.date);
return { taskId, signals: task?.signals ?? [] };
},
}),
prepare_qscore_review: tool({
description: "Prepare a Q-score review handoff.",
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" };
return prepareHandoffForTask(ctx.userId, task, "qscore-service");
},
}),
emit_curator_event: tool({
description: "Emit a curator event through existing Grow event ingestion.",
inputSchema: z.object({ type: z.string(), payload: z.record(z.string(), z.unknown()).default({}) }),
execute: async ({ type, payload }) => emitCuratorEvent({ userId: ctx.userId, type, payload }),
}),
read_recent_grow_events: tool({
description: "Read recent Grow events for this user.",
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(20) }),
execute: async ({ limit }) => db.select().from(growEvents).where(eq(growEvents.userId, ctx.userId)).orderBy(desc(growEvents.occurredAt)).limit(limit),
}),
find_matching_service_event: tool({
description: "Find service events that can complete a task.",
inputSchema: z.object({ taskId: z.string() }),
execute: async ({ taskId }) => {
const task = await findTask(ctx.userId, taskId, ctx.date);
if (!task) return { task: null, events: [] };
const events = await db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), inArray(growEvents.type as any, task.completionEvents))).orderBy(desc(growEvents.occurredAt)).limit(20);
return { task, events };
},
}),
complete_task_from_event: tool({
description: "Complete a task only when a valid service or platform event exists.",
inputSchema: z.object({ taskId: z.string(), eventId: z.string() }),
execute: async ({ taskId, eventId }) => {
const task = await findTask(ctx.userId, taskId, ctx.date);
if (!task) return { error: "task_not_found" };
const [event] = await db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.id, eventId))).limit(1);
if (!event || !task.completionEvents.includes(event.type)) return { completed: false, reason: "event_not_allowed" };
return emitCuratorEvent({ userId: ctx.userId, type: "curator.task.completed", mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId }, payload: { taskId, sourceEventId: eventId } });
},
}),
update_streak_from_completion: tool({
description: "Read streak after completion events.",
inputSchema: z.object({}),
execute: async () => ({ streak: await buildCuratorStreak(ctx.userId) }),
}),
list_mission_actions: tool({
description: "List existing mission actions so the curator does not duplicate handoffs.",
inputSchema: z.object({}),
execute: async () => listMissionActions(ctx.userId, { openOnly: false }),
}),
};
}

View File

@@ -0,0 +1,181 @@
import { z } from "zod";
export const curatorServiceIdSchema = z.enum([
"interview-service",
"resume-service",
"roleplay-service",
"qscore-service",
"social-branding-service",
"matchmaking-service",
]);
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",
"handoff_prepared",
"completed",
"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(6),
taskType: curatorTaskTypeSchema,
title: z.string(),
subtitle: z.string(),
missionId: z.string(),
missionInstanceId: z.string().optional(),
stageId: z.string().optional(),
serviceId: curatorServiceIdSchema.optional(),
serviceName: z.string(),
actorName: z.string(),
toolName: z.string(),
status: curatorTaskStatusSchema,
rewardCoins: z.number().int().min(0),
qxImpact: z.string(),
effort: z.string(),
route: z.string(),
cta: z.string(),
context: z.array(z.object({ label: z.string(), value: z.string() })),
contextNarrative: z.string(),
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"]),
});
export const curatorStreakSchema = z.object({
current: z.number().int().min(0),
longest: z.number().int().min(0),
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(6),
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(6),
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(1).max(7),
});
export const curatorPlanSchema = z.object({
id: z.string(),
userId: z.string(),
startDate: z.string(),
endDate: z.string(),
goals: z.array(z.string()),
generatedAt: z.string(),
durationDays: z.literal(30),
weeks: z.array(curatorWeekSchema).min(5).max(6),
days: z.array(curatorPlanDaySchema).length(30),
streak: curatorStreakSchema,
source: z.literal("curator-v1"),
});
export const curatorImprovementSignalSchema = z.object({
id: z.string(),
userId: z.string(),
date: z.string(),
priority: z.number().int().min(0).max(100),
reason: z.string(),
recommendedServiceId: curatorServiceIdSchema.optional(),
recommendedMissionId: z.string().optional(),
memoryPatch: z.string().optional(),
nudgeText: z.string().optional(),
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(6),
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;
plan: CuratorPlan;
tasks: CuratorTask[];
streak: CuratorStreak;
completedCount: number;
totalCount: number;
source: "curator-v1";
};
export type CuratorSubtaskStatusUpdate = {
status: "needs_more_context" | "ready_to_capture" | "handoff_ready";
summary: string;
confidence: number;
nextMissingInfo?: string;
};
export type CuratorChatResponse = {
conversationId: string;
taskId?: string;
reply: string;
messages: Array<{ id: string; role: "user" | "assistant"; sender: string; content: string; createdAt: number }>;
statusUpdate?: CuratorSubtaskStatusUpdate;
handoff?: CuratorServiceHandoff;
};
export type CuratorServiceHandoff = {
taskId: string;
serviceId: CuratorServiceId;
route: string;
actionId?: string;
actionRoute: string;
actionLabel: string;
status: "prepared";
};

View File

@@ -0,0 +1,255 @@
import { and, desc, eq } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents, growQscoreProjectionState } from "../../db/schema.js";
import { asRecord, getString } from "../../events/envelope.js";
import type { CuratorTask } from "./curator-types.js";
const ROLE_PATTERN = /\b(pm|swe|manager|engineer|designer|analyst|developer|product|marketing|sales|founder|consultant|operator|lead|head|director|recruiter|student|intern|data scientist|software)\b/i;
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
: [];
}
export type CuratorUserContext = {
userId: string;
targetRole: string;
experienceLevel: "student" | "intern" | "early" | "experienced" | "unknown";
resume: {
available: boolean;
latestSummary?: string;
skills: string[];
};
goals: string[];
pastActivity: {
recentEventCount: number;
serviceSources: string[];
latestEvents: Array<{ type: string; source: string; occurredAt: string; summary?: string }>;
};
qscore: {
score: number | null;
signalCount: number;
summary: string | null;
dimensions: Record<string, unknown> | null;
};
};
function firstRoleFromValue(value: unknown): string | undefined {
const direct = getString(value);
if (direct) return direct;
const record = asRecord(value);
return getString(
record.target_role ??
record.targetRole ??
record.current_role ??
record.currentRole ??
record.role ??
record.title ??
record.position,
);
}
function roleFromPreferences(preferences: Record<string, unknown>) {
const targetRoles = stringArray(preferences.target_roles ?? preferences.targetRoles);
if (targetRoles[0]) return targetRoles[0];
const onboarding = asRecord(preferences.onboarding);
return firstRoleFromValue(
onboarding.target_role ??
onboarding.targetRole ??
onboarding.role ??
onboarding.goal ??
onboarding.current_role,
);
}
function roleFromPayload(payload: Record<string, unknown>) {
const preferences = asRecord(payload.preferences);
const fromPreferences = roleFromPreferences(preferences);
if (fromPreferences) return fromPreferences;
const explicitRole = firstRoleFromValue(
payload.target_role ??
payload.targetRole ??
payload.current_role ??
payload.currentRole ??
payload.role,
);
if (explicitRole) return explicitRole;
return inferRoleFromText(getString(payload.goal ?? payload.userGoal ?? payload.serviceIntent));
}
function roleFromTask(task?: CuratorTask) {
for (const item of task?.context ?? []) {
if (/role|target|focus/i.test(item.label)) {
const role = firstRoleFromValue(item.value);
if (role && ROLE_PATTERN.test(role)) return role;
}
}
const raw = `${task?.title ?? ""} ${task?.subtitle ?? ""}`;
const productManager = raw.match(/\bproduct manager\b/i)?.[0];
if (productManager) return "Product Manager";
return undefined;
}
export function inferRoleFromText(text?: string) {
const value = text?.trim();
if (!value) return undefined;
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: {
userId: string;
task?: CuratorTask;
latest?: string;
history?: Array<{ role: "user" | "assistant"; content: string }>;
}) {
const latestRole = inferRoleFromText(input.latest);
if (latestRole) return latestRole;
const historyRole = [...(input.history ?? [])]
.reverse()
.filter((message) => message.role === "user")
.map((message) => inferRoleFromText(message.content))
.find(Boolean);
if (historyRole) return historyRole;
const taskRole = roleFromTask(input.task);
if (taskRole) return taskRole;
const rows = await db
.select({ payload: growEvents.payload })
.from(growEvents)
.where(and(eq(growEvents.userId, input.userId), eq(growEvents.category, "usage")))
.orderBy(desc(growEvents.occurredAt))
.limit(30);
for (const row of rows) {
const role = roleFromPayload(row.payload ?? {});
if (role) return role;
}
return undefined;
}
export function fallbackCuratorRole(role?: string) {
return role?.trim() || "Product Manager";
}
export async function buildCuratorUserContext(userId: string): Promise<CuratorUserContext> {
const rows = await db
.select({ type: growEvents.type, source: growEvents.source, payload: growEvents.payload, occurredAt: growEvents.occurredAt })
.from(growEvents)
.where(eq(growEvents.userId, userId))
.orderBy(desc(growEvents.occurredAt))
.limit(80);
const [qscore] = await db
.select({
score: growQscoreProjectionState.score,
signalCount: growQscoreProjectionState.signalCount,
summary: growQscoreProjectionState.summary,
dimensions: growQscoreProjectionState.dimensions,
})
.from(growQscoreProjectionState)
.where(eq(growQscoreProjectionState.userId, userId))
.limit(1);
const targetRole = fallbackCuratorRole(await resolveCuratorTargetRole({ userId }));
const corpus = rows.map((row) => `${row.type} ${row.source} ${payloadText(row.payload)}`).join(" ").toLowerCase();
const goals = uniqueStrings(rows.flatMap((row) => goalsFromPayload(row.payload)));
const skills = uniqueStrings(rows.flatMap((row) => stringArray(asRecord(row.payload).skills))).slice(0, 12);
const latestResume = rows
.map((row) => resumeSummaryFromPayload(row.payload))
.find(Boolean);
return {
userId,
targetRole,
experienceLevel: inferExperienceLevel(corpus),
resume: {
available: Boolean(latestResume || /\bresume|cv|linkedin\b/i.test(corpus)),
latestSummary: latestResume,
skills,
},
goals: goals.length ? goals.slice(0, 8) : [targetRole],
pastActivity: {
recentEventCount: rows.length,
serviceSources: uniqueStrings(rows.map((row) => row.source)).slice(0, 12),
latestEvents: rows.slice(0, 12).map((row) => ({
type: row.type,
source: row.source,
occurredAt: row.occurredAt.toISOString(),
summary: eventSummary(row.payload),
})),
},
qscore: {
score: typeof qscore?.score === "number" ? qscore.score : null,
signalCount: typeof qscore?.signalCount === "number" ? qscore.signalCount : 0,
summary: qscore?.summary ?? null,
dimensions: qscore?.dimensions ?? null,
},
};
}
function goalsFromPayload(payload: Record<string, unknown>) {
const preferences = asRecord(payload.preferences);
return [
...stringArray(preferences.target_roles ?? preferences.targetRoles),
...stringArray(payload.goals),
getString(payload.goal),
getString(payload.userGoal),
getString(payload.target_role ?? payload.targetRole),
].filter((item): item is string => Boolean(item));
}
function resumeSummaryFromPayload(payload: Record<string, unknown>) {
return getString(
payload.resumeSummary ??
payload.summary ??
payload.resume_text ??
payload.resumeText ??
asRecord(payload.resume).summary,
)?.slice(0, 900);
}
function eventSummary(payload: Record<string, unknown>) {
return getString(payload.summary ?? payload.title ?? payload.goal ?? payload.serviceIntent)?.slice(0, 220);
}
function payloadText(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(payloadText).join(" ");
if (value && typeof value === "object") return Object.values(value as Record<string, unknown>).map(payloadText).join(" ");
return "";
}
function inferExperienceLevel(corpus: string): CuratorUserContext["experienceLevel"] {
if (/\b(intern|internship|return offer|return-offer)\b/.test(corpus)) return "intern";
if (/\b(student|recent grad|recent graduate|campus|college|university)\b/.test(corpus)) return "student";
if (/\b(staff|principal|director|vp|head of|leadership|executive|10\+ years|5\+ years)\b/.test(corpus)) return "experienced";
if (/\b(fresher|junior|entry level|entry-level|early career|0-2 years|1 year|2 years)\b/.test(corpus)) return "early";
return "unknown";
}
function uniqueStrings(values: Array<string | undefined>) {
return [...new Set(values.map((value) => value?.trim()).filter((value): value is string => Boolean(value)))];
}

10
src/v1/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Hono } from "hono";
import { v1CuratorRoutes } from "./curator/curator-routes.js";
import { v1AnalyticsRoutes } from "./analytics/analytics-routes.js";
export function v1Routes() {
const app = new Hono();
app.route("/curator", v1CuratorRoutes());
app.route("/analytics", v1AnalyticsRoutes());
return app;
}