12 Commits

Author SHA1 Message Date
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
Sai-karthik
41b0c69326 Implement mission chat actors and analytics 2026-06-14 10:06:34 +00:00
39 changed files with 3489 additions and 149 deletions

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
}
]
}

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,34 @@
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";
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 +52,215 @@ 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;
}) {
const params = new URLSearchParams({
source: "mission",
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
});
if (input.stageId) params.set("stageId", input.stageId);
if (input.goal) params.set("goal", input.goal);
if (input.serviceId === "interview-service") return `/agents/interview/setup?${params.toString()}`;
if (input.serviceId === "roleplay-service") return `/agents/roleplay/setup?${params.toString()}`;
return `/agents/resume?${params.toString()}`;
}
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,

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

@@ -0,0 +1,509 @@
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");
}
function fallbackDailyMissionResponse(input: { task: DailyMissionTask; messages: DailyMissionMessage[] }) {
const latestUser = [...input.messages].reverse().find((message) => message.role === "user")?.content.trim() ?? "";
const userMessagesAfterStart = input.messages.filter((message) => message.role === "user");
const interviewMission = isInterviewMission(input.task);
if (latestUser.toLowerCase() === "start") {
const serviceReply = serviceStartReply(input.task);
if (serviceReply) {
return {
reply: serviceReply,
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
if (isConfidenceCheck(input.task)) {
return {
reply: "Quick confidence check: on a scale of 1 to 10, how confident do you feel about getting interview-ready for your target role this week?",
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
if (interviewMission) {
return {
reply: `Let us get your interview room ready. For "${input.task.subtask}", tell me the role you want to practice for and the kind of question you want first.`,
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
return {
reply: `Alright, I have this one. For "${input.task.subtask}", tell me your answer in one or two lines. I will use it to update this task here.`,
completed: false,
updateSummary: undefined,
actionLabel: undefined,
actionRoute: undefined,
};
}
const updateSummary = compactAnswer(latestUser);
const completed = userMessagesAfterStart.some((message) => message.content.trim().toLowerCase() === "start") && latestUser.length > 0;
if (interviewMission) {
return {
reply: `Perfect. I will carry this into the interview room setup: ${updateSummary}. Your interview room link is ready.`,
completed,
updateSummary,
actionLabel: completed ? "Generate room" : undefined,
actionRoute: completed ? getInterviewActionRoute(input.task) : undefined,
};
}
return {
reply: `Nice, saved. I updated this task with: ${updateSummary}`,
completed,
updateSummary,
actionLabel: undefined,
actionRoute: undefined,
};
}
export async function runDailyMissionAgent(input: DailyMissionAgentInput) {
const started = input.messages.some(
(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; using fallback", { message });
return fallbackDailyMissionResponse({ task: input.task, messages: input.messages });
}
}
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

@@ -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

@@ -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,7 +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 { missionDetailHref } from "../missions/reducer-helpers.js";
import { listAvailableMissionDefinitions } from "../missions/registry.js";
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
import {
isAllowedNotificationHref,
MODULE_IDS,
@@ -166,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: "/missions/available", 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: "/missions/available", 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;
}
@@ -197,10 +232,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
for (const suggestion of ctx.missionSuggestions.slice(0, 5)) {
const mission = ctx.activeMissions.find((item) => item.instanceId === suggestion.missionInstanceId);
const source = sourceFromSuggestionRole(suggestion.role);
const href = sanitizeHref(
suggestion.ctaHref,
mission ? missionDetailHref(mission.instanceId) : SERVICE_HREFS.mission,
);
const href = sanitizeHref(suggestion.ctaHref, mission ? `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}` : SERVICE_HREFS.mission);
pushSeed(seeds, {
moduleId: "suggestions",
title: suggestion.title,
@@ -271,7 +303,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
subtitle: mission.currentStageId ? `Current stage: ${mission.currentStageId.replaceAll("-", " ")}` : "Next action is ready on the mission dashboard.",
tag: mission.status === "paused" ? "Paused" : "Active",
urgency: mission.status === "paused" ? "soon" : "today",
href: missionDetailHref(mission.instanceId),
href: `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}`,
source: "mission",
priority: 90 - mission.progressPercent,
});
@@ -460,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)
@@ -569,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,14 +48,7 @@ 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",
"social",
"pathways",
"productivity",
"rewards",
];
export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "productivity"];
export const ALLOWED_NOTIFICATION_HREFS = new Set([
"/suggestions",
@@ -75,7 +68,6 @@ export const ALLOWED_NOTIFICATION_HREFS = new Set([
]);
export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
"/missions/",
"/missions/active",
"/missions/available",
"/agents/resume",
@@ -88,9 +80,5 @@ 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) =>
prefix.endsWith("/")
? href.startsWith(prefix)
: href === prefix || href.startsWith(`${prefix}?`),
);
return ALLOWED_NOTIFICATION_HREF_PREFIXES.some((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

@@ -4,7 +4,6 @@ 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"];
@@ -49,11 +48,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 = missionDetailHref(action.missionInstanceId);
const missionHref = `/missions/active?missionInstanceId=${encodeURIComponent(action.missionInstanceId)}`;
const href = hrefFromPayload ??
(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);
(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);
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, missionDetailHref, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, 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: missionDetailHref(activeMission.instanceId) },
payload: { weakAreas, href: `/missions/active?missionInstanceId=${encodeURIComponent(activeMission.instanceId)}` },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:content-pillars:${event.id}`,
priority: 82,

View File

@@ -141,7 +141,3 @@ 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,5 +1,4 @@
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";
@@ -104,7 +103,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: `${missionDetailHref(snapshot.instanceId)}?${params.toString()}` };
return { label: "Continue", href: `/missions/active?${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

@@ -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

@@ -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,7 +12,6 @@ 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> {
@@ -256,7 +255,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 : missionDetailHref(existing.missionInstanceId);
const href = typeof existing.payload?.href === "string" ? existing.payload.href : `/missions/active?missionInstanceId=${encodeURIComponent(existing.missionInstanceId)}`;
const action = await updateMissionActionStatus(userId, existing.id, {
status: "done",
result: {

View File

@@ -43,31 +43,6 @@ 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 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;
@@ -132,13 +107,8 @@ 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}${forwardedQuery.toString() ? `?${forwardedQuery.toString()}` : ""}`,
`/api/v1/${normalizedRest}${incoming.search}`,
config.resumeServiceUrl.replace(/\/$/, ""),
);
@@ -151,16 +121,10 @@ 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: forwardBody,
body,
});
if (method === "GET" || method === "HEAD") {
@@ -182,7 +146,7 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
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),
},
mission,
mission: missionFromBody(requestJson),
}).catch((err) => log.warn({ err, path: normalizedRest }, "failed to record resume gateway event"));
return new Response(responseBuffer, {
@@ -344,12 +308,11 @@ 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(rest.context) ? rest.context : {};
const incomingContext = isRecord(body.context) ? body.context : {};
const context: Record<string, unknown> = {
...incomingContext,
candidate_name: getString(incomingContext.candidate_name) ?? getString(userContext.first_name) ?? "",
@@ -365,20 +328,19 @@ async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject,
}
return {
...rest,
user_id: String(rest.user_id ?? userId),
org_id: String(rest.org_id ?? "growqr"),
...body,
user_id: String(body.user_id ?? userId),
org_id: String(body.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(rest.metadata) ? rest.metadata : {};
const incomingMetadata = isRecord(body.metadata) ? body.metadata : {};
const metadata: Record<string, unknown> = {
...incomingMetadata,
candidate_name: getString(incomingMetadata.candidate_name) ?? getString(userContext.first_name) ?? "",
@@ -397,11 +359,11 @@ async function buildPersonalizedRoleplayConfigurePayload(req: Request, body: Jso
}
return {
...rest,
user_id: String(rest.user_id ?? userId),
org_id: String(rest.org_id ?? "growqr"),
...body,
user_id: String(body.user_id ?? userId),
org_id: String(body.org_id ?? "growqr"),
metadata,
qscore: (rest.qscore as JsonObject | undefined) ?? (isRecord(userContext.qscore) ? userContext.qscore : DEFAULT_QSCORE),
qscore: (body.qscore as JsonObject | undefined) ?? (isRecord(userContext.qscore) ? userContext.qscore : DEFAULT_QSCORE),
user_context: userContext,
};
}
@@ -564,7 +526,6 @@ 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>;
@@ -574,7 +535,7 @@ export function serviceRoutes() {
type: "interview.configured",
payload: { request: payload, result: resultObj },
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id) },
mission,
mission: missionFromBody(body),
}).catch((err) => log.warn({ err }, "failed to record interview configured event"));
return c.json(result);
});
@@ -625,7 +586,6 @@ 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>;
@@ -635,7 +595,7 @@ export function serviceRoutes() {
type: "roleplay.configured",
payload: { request: payload, result: resultObj },
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id) },
mission,
mission: missionFromBody(body),
}).catch((err) => log.warn({ err }, "failed to record roleplay configured event"));
return c.json(result);
});

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,131 @@
import { generateText, tool } from "ai";
import { z } from "zod";
import { desc, eq } from "drizzle-orm";
import { createClient, type Client } from "rivetkit/client";
import { config } from "../../config.js";
import type { Registry } from "../../actors/registry.js";
import { getConversationModel } from "../../actors/conversation/agent.js";
import { db } from "../../db/client.js";
import { growConversationMessages, growEvents, users } from "../../db/schema.js";
import { 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.select({ id: users.id }).from(users).limit(500);
let improvementSignalsCreated = 0;
for (const user of userRows) {
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,29 @@
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() }).parse(await c.req.json().catch(() => ({})));
return c.json(await v1AnalyticsActor.runNightly({ date: body.date ?? new Date().toISOString().slice(0, 10), userId: body.userId ?? userId }));
});
return app;
}

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

@@ -0,0 +1,10 @@
# 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.

View File

@@ -0,0 +1,93 @@
import { buildCuratorPlan, buildCuratorStreak, buildCuratorTasks, todayIsoDate } from "./curator-store.js";
import { curatorPlanSchema, type CuratorImprovementSignal } from "./curator-types.js";
import { emitCuratorEvent } from "./curator-events.js";
import { runCuratorChat } from "./curator-agent.js";
import { prepareHandoffForTask } from "./curator-tools.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, goals: input.goals ?? [] } });
return { plan };
},
async getPlan(input: { userId: string; startDate?: string; endDate?: string }) {
return this.generatePlanRange(input);
},
async getToday(input: { userId: string; date?: string }) {
const date = input.date ?? todayIsoDate();
const plan = curatorPlanSchema.parse(await buildCuratorPlan(input.userId, { startDate: date, endDate: date }));
const tasks = plan.days[0]?.tasks ?? await buildCuratorTasks(input.userId, date);
await emitCuratorEvent({ userId: input.userId, type: "curator.day.opened", payload: { date } });
return {
date,
plan,
tasks,
streak: plan.streak,
completedCount: tasks.filter((task) => task.status === "completed").length,
totalCount: tasks.length,
source: "curator-v1" as const,
};
},
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");
if (task.serviceId) {
throw new Error("curator_service_task_requires_service_event");
}
const event = await emitCuratorEvent({
userId: input.userId,
type: "curator.task.completed",
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
payload: { taskId: task.id, date, reason: input.reason ?? "subtasks_completed" },
});
return { task: { ...task, status: "completed" as const }, eventId: event.id };
},
async recordServiceImpact(input: { userId: string; eventId: string }) {
const streak = await buildCuratorStreak(input.userId);
return { matched: true, completedTasks: await buildCuratorTasks(input.userId, todayIsoDate()), streak };
},
async applyImprovementSignals(input: { userId: string; date: string; signals: CuratorImprovementSignal[] }) {
for (const signal of input.signals) {
await emitCuratorEvent({ userId: input.userId, type: "curator.improvement_signal.applied", payload: { signal } });
}
const plan = await buildCuratorPlan(input.userId, { startDate: input.date, endDate: input.date });
return { applied: input.signals.length, plan };
},
async getState(input: { userId: string }) {
return { tasks: await buildCuratorTasks(input.userId, todayIsoDate()), streak: await buildCuratorStreak(input.userId) };
},
};

View File

@@ -0,0 +1,458 @@
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";
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"),
};
}
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 }>;
}) {
const lines = [
"You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.",
"The V1 Curator owns 30 day direction, streak continuity, and service handoff decisions.",
"Carry state from the conversation history. If the user gives a short answer like a role name, accept it and ask for the next missing slot.",
"Do not ask the same question twice. Do not output checklist items as separate baked chat messages.",
"For target-role tasks, collect target role, current background, constraints, then offer a resume or interview handoff.",
"For service work, use Conversation Actor tools to prepare handoffs only after the focused subtask has enough context.",
"Never say: What should I capture next. Ask a concrete conversational question tied to the task.",
"If a curator subtask is provided, focus on that subtask only. Do not answer as if another subtask was clicked.",
"Do not ask about another subtask, another mission, another service, or a later checklist item from this modal.",
"When the user has answered the focused subtask enough, summarize what was captured and stop. Do not ask the next subtask question.",
"If more detail is needed, ask exactly one follow-up question for the focused subtask only.",
"Use captured task memory from previous subtasks as context. Do not ask the user to repeat details already captured there.",
];
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];
}) {
return [
`The user opened this focused subtask: ${input.subtask ?? input.task?.title ?? "curator task"}.`,
"Generate the first live conversational question for this exact subtask.",
"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";
}
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);
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));
let reply = "";
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 }) });
}
const result = await generateConversationResponse(modelMessages, {
userId: input.userId,
conversationId: conversation.id,
missionInstanceId: task?.missionInstanceId,
missionId: task?.missionId,
stageId: task?.stageId,
source: "curator-v1",
systemAddendum: curatorSystemAddendum({ date, taskId: input.taskId, subtaskIndex: input.subtaskIndex, subtask: input.subtask, task, taskMemory }),
});
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),
});
throw error;
}
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: `${task.serviceName} setup is ready. Use the action below to open it.`,
confidence: Math.max(statusUpdate.confidence, 0.9),
};
}
const handoff = shouldPrepareServiceHandoff(statusUpdate, task)
? await prepareHandoffForTask(input.userId, task!, task!.serviceId)
: 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,19 @@
import { recordGrowEvent } from "../../events/record-grow-event.js";
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: `${input.userId}:${input.type}:${input.payload?.taskId ?? input.payload?.date ?? Date.now()}`,
}, { userId: input.userId, source: "curator-v1" });
}

View File

@@ -0,0 +1,78 @@
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),
});
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.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,58 @@
import type { CuratorServiceId, CuratorTask } from "./curator-types.js";
export function serviceRoute(input: {
serviceId?: CuratorServiceId;
missionInstanceId?: string;
missionId?: string;
stageId?: string;
taskId?: string;
}) {
const params = new URLSearchParams({ source: "curator-v1" });
if (input.missionInstanceId) params.set("missionInstanceId", input.missionInstanceId);
if (input.missionId) params.set("missionId", input.missionId);
if (input.stageId) params.set("stageId", input.stageId);
if (input.taskId) params.set("curatorTaskId", input.taskId);
const suffix = params.toString();
if (input.serviceId === "interview-service") return `/agents/interview/setup?${suffix}`;
if (input.serviceId === "roleplay-service") return `/agents/roleplay/setup?${suffix}`;
if (input.serviceId === "resume-service") return `/agents/resume?${suffix}`;
if (input.serviceId === "qscore-service") return `/analytics?${suffix}`;
if (input.serviceId === "social-branding-service") return `/social?${suffix}`;
if (input.serviceId === "matchmaking-service") return `/pathways?${suffix}`;
return `/missions/active${input.missionInstanceId ? `?missionInstanceId=${encodeURIComponent(input.missionInstanceId)}` : ""}`;
}
export function serviceName(serviceId?: CuratorServiceId, fallback = "Mission planner") {
if (serviceId === "interview-service") return "Interview service";
if (serviceId === "roleplay-service") return "Roleplay service";
if (serviceId === "resume-service") return "Resume service";
if (serviceId === "qscore-service") return "Q Score service";
if (serviceId === "social-branding-service") return "Social branding service";
if (serviceId === "matchmaking-service") return "Pathways service";
return fallback;
}
export function serviceToolName(serviceId?: CuratorServiceId) {
if (serviceId === "interview-service") return "prepare_interview_setup";
if (serviceId === "roleplay-service") return "prepare_roleplay_setup";
if (serviceId === "resume-service") return "prepare_resume_upload";
if (serviceId === "qscore-service") return "prepare_qscore_review";
return "prepare_mission_step";
}
export function completionEventsForService(serviceId?: CuratorServiceId) {
if (serviceId === "interview-service") return ["interview.configured", "interview.review_completed", "interview.completed"];
if (serviceId === "roleplay-service") return ["roleplay.configured", "roleplay.review_completed", "roleplay.completed"];
if (serviceId === "resume-service") return ["resume.analysis_completed", "resume.parsed", "resume.updated"];
if (serviceId === "qscore-service") return ["qscore.updated", "qscore.signal_projected"];
return ["curator.task.completed"];
}
export function actionLabel(task: CuratorTask) {
if (task.serviceId === "interview-service") return "Set up interview";
if (task.serviceId === "roleplay-service") return "Set up roleplay";
if (task.serviceId === "resume-service") return "Open resume";
if (task.serviceId === "qscore-service") return "Review Q Score";
return task.cta || "Open";
}

View File

@@ -0,0 +1,419 @@
import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { generateText } from "ai";
import { z } from "zod";
import { db } from "../../db/client.js";
import { growEvents } from "../../db/schema.js";
import { listActiveMissionsPg } from "../../grow/persistence.js";
import { listMissionDefinitions } from "../../missions/registry.js";
import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
import { getConversationModel } from "../../actors/conversation/agent.js";
import type { CuratorPlan, CuratorServiceId, CuratorStreak, CuratorTask } from "./curator-types.js";
import { completionEventsForService, serviceName, serviceRoute, serviceToolName } from "./curator-service-links.js";
const VALID_COMPLETION_TYPES = [
"resume.analysis_completed",
"resume.parsed",
"resume.updated",
"interview.configured",
"interview.review_completed",
"interview.completed",
"roleplay.configured",
"roleplay.review_completed",
"roleplay.completed",
"qscore.updated",
"qscore.signal_projected",
"curator.task.completed",
];
function todayIso(date = new Date()) {
return date.toISOString().slice(0, 10);
}
function addDaysIso(startDate: string, days: number) {
const date = new Date(`${startDate}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + days);
return todayIso(date);
}
function coerceServiceId(value?: string | null): CuratorServiceId | undefined {
if (!value) return undefined;
if (value === "interview-service" || value === "resume-service" || value === "roleplay-service" || value === "qscore-service" || value === "social-branding-service" || value === "matchmaking-service") {
return value;
}
return undefined;
}
function serviceFromRole(role?: string, service?: string): CuratorServiceId | undefined {
const raw = `${role ?? ""} ${service ?? ""}`.toLowerCase();
if (raw.includes("interview")) return "interview-service";
if (raw.includes("resume")) return "resume-service";
if (raw.includes("roleplay")) return "roleplay-service";
if (raw.includes("qscore") || raw.includes("q score")) return "qscore-service";
if (raw.includes("social") || raw.includes("brand")) return "social-branding-service";
if (raw.includes("pathway") || raw.includes("match")) return "matchmaking-service";
return coerceServiceId(service);
}
function cleanTitle(text: string) {
return text
.replace(/^final\s+/i, "")
.replace(/\bscore\b/gi, "readiness")
.replace(/\s+/g, " ")
.trim();
}
function stageIntent(input: {
missionTitle: string;
stageTitle: string;
stageDescription: string;
}) {
const raw = `${input.missionTitle} ${input.stageTitle} ${input.stageDescription}`.toLowerCase();
if (raw.includes("target role") || raw.includes("role recommendation") || raw.includes("career transition") || raw.includes("transition thesis")) {
return "target-role";
}
if (raw.includes("goal") || raw.includes("achieve") || raw.includes("onboarding")) {
return "goal-setup";
}
if (raw.includes("resume")) return "resume";
if (raw.includes("interview")) return "interview";
if (raw.includes("roleplay")) return "roleplay";
return "general";
}
const generatedTaskCopySchema = z.object({
title: z.string().min(8).max(70),
subtitle: z.string().min(20).max(160),
subtasks: z.array(z.string().min(8).max(120)).length(3),
contextNarrative: z.string().min(80).max(700),
});
type GeneratedTaskCopy = z.infer<typeof generatedTaskCopySchema>;
const taskCopyCache = new Map<string, GeneratedTaskCopy>();
function parseJsonObject(text: string) {
const trimmed = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim();
try {
return JSON.parse(trimmed);
} catch {
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) throw new Error("model_did_not_return_json");
return JSON.parse(trimmed.slice(start, end + 1));
}
}
async function taskCopy(input: {
missionTitle: string;
stageTitle: string;
stageDescription: string;
role?: string;
serviceId?: CuratorServiceId;
}) {
const cacheKey = JSON.stringify({
missionTitle: input.missionTitle,
stageTitle: input.stageTitle,
stageDescription: input.stageDescription,
role: input.role,
serviceId: input.serviceId,
});
const cached = taskCopyCache.get(cacheKey);
if (cached) return cached;
try {
const system = [
"You generate GrowQR V1 curator task copy.",
"Return valid JSON only. Do not wrap it in markdown.",
"The JSON shape must be: {\"title\": string, \"subtitle\": string, \"subtasks\": [string, string, string], \"contextNarrative\": string}.",
"Do not invent new missions or services. Use the provided mission, stage, role, and service only.",
"Generate exactly three sequential subtasks that make sense for a real user.",
"Keep each subtask label short, direct, and under 80 characters when possible.",
"Each subtask must ask for a different missing piece of context. Never repeat the same question.",
"Do not include rewards, coins, scores, or generic readiness-score language.",
"Use ASCII punctuation only.",
"For early users, prefer practical context capture: goal, current background, resume/input, constraints, or service setup.",
].join("\n");
const basePrompt = (() => {
const lines = [
`Mission: ${input.missionTitle}`,
`Stage: ${input.stageTitle}`,
`Service: ${input.serviceId ? serviceName(input.serviceId) : "Mission Planner"}`,
`Detected intent: ${stageIntent(input)}`,
];
if (input.stageDescription.trim()) lines.push(`Stage description: ${input.stageDescription}`);
if (input.role?.trim()) lines.push(`Stage role: ${input.role}`);
return lines.join("\n");
})();
let lastError = "";
for (let attempt = 0; attempt < 3; attempt += 1) {
const result = await generateText({
model: getConversationModel(),
system,
prompt: attempt === 0
? basePrompt
: `${basePrompt}\n\nPrevious JSON was invalid: ${lastError}\nRegenerate the same task copy as valid JSON only. Do not add comments or markdown.`,
});
try {
const copy = generatedTaskCopySchema.parse(parseJsonObject(result.text));
taskCopyCache.set(cacheKey, copy);
return copy;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
throw new Error(`curator_task_copy_invalid_json: ${lastError}`);
} catch (error) {
console.warn("curator task copy generation failed", {
missionTitle: input.missionTitle,
stageTitle: input.stageTitle,
serviceId: input.serviceId,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
async function taskFromStage(input: {
userId: string;
date: string;
index: number;
missionId: string;
missionTitle: string;
missionInstanceId?: string;
stageId?: string;
stageTitle: string;
stageDescription: string;
role?: string;
serviceId?: CuratorServiceId;
completed?: boolean;
}): Promise<CuratorTask> {
const serviceId = input.serviceId ?? serviceFromRole(input.role);
const id = `curator:${input.date}:${input.missionInstanceId ?? input.missionId}:${input.stageId ?? input.index}`;
const route = serviceRoute({ serviceId, missionId: input.missionId, missionInstanceId: input.missionInstanceId, stageId: input.stageId, taskId: id });
const copy = await taskCopy({
missionTitle: input.missionTitle,
stageTitle: input.stageTitle,
stageDescription: input.stageDescription,
role: input.role,
serviceId,
});
return {
id,
date: input.date,
title: copy.title,
subtitle: copy.subtitle,
missionId: input.missionId,
missionInstanceId: input.missionInstanceId,
stageId: input.stageId,
serviceId,
serviceName: serviceName(serviceId, input.role),
actorName: `${input.missionTitle} curator actor`,
toolName: serviceToolName(serviceId),
status: input.completed ? "completed" : "ready",
rewardCoins: 15 + input.index * 5,
qxImpact: serviceId === "interview-service" ? "+10 projected" : serviceId === "resume-service" ? "+7 projected" : serviceId === "roleplay-service" ? "+9 projected" : "+5 projected",
effort: serviceId ? "5-15 min" : "2 min",
route,
cta: serviceId ? `Open ${serviceName(serviceId).replace(" service", "")}` : "Open mission",
context: [
{ label: "Mission", value: input.missionTitle },
{ label: "Current focus", value: copy.title },
{ label: "Service handoff", value: serviceName(serviceId, input.role) },
{ label: "Source", value: input.missionInstanceId ? "Active mission" : "Mission registry" },
],
contextNarrative: copy.contextNarrative,
subtasks: copy.subtasks,
signals: [input.missionTitle, input.stageTitle, serviceName(serviceId, input.role)],
completionEvents: completionEventsForService(serviceId),
source: input.missionInstanceId ? "curator-v1" : "mission-registry",
};
}
async function completionRows(userId: string, sinceDate: string) {
return db
.select()
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
inArray(growEvents.type, VALID_COMPLETION_TYPES),
gte(growEvents.occurredAt, new Date(`${sinceDate}T00:00:00.000Z`)),
))
.orderBy(desc(growEvents.occurredAt))
.limit(200);
}
async function taskCompletedByEvents(userId: string, task: CuratorTask) {
const rows = await completionRows(userId, task.date);
return rows.some((row) => {
const payload = row.payload ?? {};
const mission = row.mission ?? {};
const correlation = row.correlation ?? {};
const taskId = payload.taskId ?? correlation.taskId;
if (!task.completionEvents.includes(row.type)) return false;
if (taskId === task.id) return true;
if (task.missionInstanceId && mission.missionInstanceId === task.missionInstanceId) return true;
if (task.stageId && mission.stageId === task.stageId) return true;
return !task.missionInstanceId && !task.stageId;
});
}
export async function buildCuratorTasks(userId: string, date = todayIso()): Promise<CuratorTask[]> {
const active = await listActiveMissionsPg(userId);
const tasks: CuratorTask[] = [];
for (const item of active) {
const snapshot = item.snapshot;
const stages = (snapshot?.stages ?? [])
.filter((stage) => stage.status !== "locked" && stage.status !== "done")
.filter((stage) => serviceFromRole(stage.role) !== "qscore-service")
.sort((a, b) => {
if (a.id === snapshot?.currentStageId) return -1;
if (b.id === snapshot?.currentStageId) return 1;
return a.id.localeCompare(b.id);
});
for (const stage of stages) {
tasks.push(await taskFromStage({
userId,
date,
index: tasks.length,
missionId: item.mission.missionId,
missionTitle: item.mission.shortTitle || item.mission.title,
missionInstanceId: item.mission.instanceId,
stageId: stage.id,
stageTitle: stage.title,
stageDescription: stage.description,
role: stage.role,
completed: stage.status === "done",
}));
if (tasks.length >= 3) break;
}
if (tasks.length >= 3) break;
}
if (tasks.length < 3) {
const registryModules = listMissionDefinitions().flatMap((mission) =>
mission.modules.map((module) => ({ mission, module, serviceId: serviceFromRole(module.role, module.service) })),
).filter((item) => item.serviceId !== "qscore-service");
const seenServices = new Set(tasks.map((task) => task.serviceId).filter(Boolean));
for (const item of registryModules) {
if (tasks.length >= 3) break;
if (!item.serviceId) continue;
if (item.serviceId && seenServices.has(item.serviceId)) continue;
if (tasks.some((task) => task.missionId === item.mission.missionId && task.stageId === item.module.id)) continue;
tasks.push(await taskFromStage({
userId,
date,
index: tasks.length,
missionId: item.mission.missionId,
missionTitle: item.mission.shortTitle || item.mission.title,
stageId: item.module.id,
stageTitle: item.module.title,
stageDescription: item.module.description ?? item.mission.promise,
role: item.module.role,
serviceId: item.serviceId,
}));
if (item.serviceId) seenServices.add(item.serviceId);
}
for (const item of registryModules) {
if (tasks.length >= 3) break;
if (tasks.some((task) => task.missionId === item.mission.missionId && task.stageId === item.module.id)) continue;
tasks.push(await taskFromStage({
userId,
date,
index: tasks.length,
missionId: item.mission.missionId,
missionTitle: item.mission.shortTitle || item.mission.title,
stageId: item.module.id,
stageTitle: item.module.title,
stageDescription: item.module.description ?? item.mission.promise,
role: item.module.role,
serviceId: item.serviceId,
}));
}
}
const enriched: CuratorTask[] = [];
for (const task of tasks.slice(0, 3)) {
const completed = task.status === "completed" || await taskCompletedByEvents(userId, task);
enriched.push({ ...task, status: completed ? "completed" : task.status });
}
return enriched;
}
export async function buildCuratorStreak(userId: string): Promise<CuratorStreak> {
const rows = await db
.select({
day: sql<string>`to_char(${growEvents.occurredAt}, 'YYYY-MM-DD')`,
})
.from(growEvents)
.where(and(eq(growEvents.userId, userId), inArray(growEvents.type, VALID_COMPLETION_TYPES)))
.groupBy(sql`to_char(${growEvents.occurredAt}, 'YYYY-MM-DD')`)
.orderBy(sql`to_char(${growEvents.occurredAt}, 'YYYY-MM-DD') desc`)
.limit(90);
const days = rows.map((row) => row.day);
let current = 0;
let cursor = todayIso();
while (days.includes(cursor)) {
current += 1;
cursor = addDaysIso(cursor, -1);
}
let longest = 0;
let run = 0;
let previous: string | null = null;
for (const day of [...days].reverse()) {
if (previous && addDaysIso(previous, 1) !== day) run = 0;
run += 1;
longest = Math.max(longest, run);
previous = day;
}
return { current, longest: Math.max(longest, current), lastCompletedDate: days[0] ?? null };
}
export async function buildCuratorPlan(userId: string, input: { startDate: string; endDate: string; goals?: string[] }): Promise<CuratorPlan> {
const days: CuratorPlan["days"] = [];
const start = new Date(`${input.startDate}T00:00:00.000Z`);
const end = new Date(`${input.endDate}T00:00:00.000Z`);
const totalDays = Math.max(1, Math.min(30, Math.round((end.getTime() - start.getTime()) / 86400000) + 1));
for (let index = 0; index < totalDays; index += 1) {
const date = addDaysIso(input.startDate, index);
days.push({
date,
dayIndex: index + 1,
theme: index === 0 ? "Start with the highest-leverage service action" : "Keep streak and service momentum",
tasks: await buildCuratorTasks(userId, date),
});
}
return {
id: `curator-plan:${userId}:${input.startDate}:${input.endDate}`,
userId,
startDate: input.startDate,
endDate: input.endDate,
goals: input.goals ?? [],
generatedAt: new Date().toISOString(),
days,
streak: await buildCuratorStreak(userId),
source: "curator-v1",
};
}
export async function listCuratorRegistryCapabilities() {
return {
missions: listMissionDefinitions().map((mission) => ({
id: mission.missionId,
title: mission.title,
modules: mission.modules.map((module) => ({ id: module.id, title: module.title, service: module.service, role: module.role })),
})),
services: listServiceCapabilities(),
};
}
export function todayIsoDate() {
return todayIso();
}

View File

@@ -0,0 +1,396 @@
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";
async function findTask(userId: string, taskId: string, date: string) {
const tasks = await buildCuratorTasks(userId, date);
return tasks.find((task) => task.id === taskId) ?? null;
}
export async function prepareHandoffForTask(userId: string, task: CuratorTask, serviceId = task.serviceId): Promise<CuratorServiceHandoff> {
if (!serviceId) throw new Error("Task has no service handoff.");
const route = serviceRoute({
serviceId,
missionId: task.missionId,
missionInstanceId: task.missionInstanceId,
stageId: task.stageId,
taskId: task.id,
});
let actionId: string | undefined;
if (task.missionInstanceId) {
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),
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_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,123 @@
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 curatorTaskStatusSchema = z.enum([
"ready",
"started",
"handoff_prepared",
"completed",
"blocked",
]);
export const curatorTaskSchema = z.object({
id: z.string(),
date: z.string(),
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()).min(1),
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 curatorPlanSchema = z.object({
id: z.string(),
userId: z.string(),
startDate: z.string(),
endDate: z.string(),
goals: z.array(z.string()),
generatedAt: z.string(),
days: z.array(z.object({
date: z.string(),
dayIndex: z.number().int().min(1),
theme: z.string(),
tasks: z.array(curatorTaskSchema),
})),
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 type CuratorTask = z.infer<typeof curatorTaskSchema>;
export type CuratorPlan = z.infer<typeof curatorPlanSchema>;
export type CuratorStreak = z.infer<typeof curatorStreakSchema>;
export type CuratorImprovementSignal = z.infer<typeof curatorImprovementSignalSchema>;
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";
};

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