Implement V1 curator flow

This commit is contained in:
Sai-karthik
2026-06-14 15:10:39 +00:00
parent 41b0c69326
commit 036aff1d1d
14 changed files with 1326 additions and 0 deletions

View File

@@ -21,6 +21,7 @@ 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";
@@ -90,6 +91,7 @@ async function main() {
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());

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,115 @@
import { generateObject, 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),
});
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 generateObject({
model: getConversationModel(),
schema: signalsSchema,
system: "You are the GrowQR V1 Analytics Actor. Generate small overnight improvement signals for the Curator. Use ASCII punctuation.",
prompt: JSON.stringify({ date: input.date, events, messages }).slice(0, 20000),
});
return result.object.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,77 @@
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; 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 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,131 @@
import { generateObject, generateText, stepCountIs } from "ai";
import { z } from "zod";
import { addMessagePg, createConversationPg, ensureMissionConversationPg, listMessagesPg } from "../../grow/persistence.js";
import { getConversationModel } from "../../actors/conversation/agent.js";
import { buildCuratorTools } from "./curator-tools.js";
import { buildCuratorTasks, todayIsoDate } from "./curator-store.js";
import { emitCuratorEvent } from "./curator-events.js";
import type { CuratorChatResponse } from "./curator-types.js";
const chatExtractSchema = z.object({
summary: z.string(),
userGoal: z.string().optional(),
serviceIntent: z.string().optional(),
shouldPrepareHandoff: z.boolean().default(false),
});
function buildId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function sanitize(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, "")
.trim();
}
async function ensureCuratorConversation(input: { userId: string; taskId?: string; date: 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?.missionInstanceId) {
return ensureMissionConversationPg({
userId: input.userId,
missionInstanceId: task.missionInstanceId,
missionId: task.missionId,
stageId: task.stageId,
title: task.title,
source: "curator-v1",
});
}
return createConversationPg(input.userId, task?.title ?? "V1 Curator chat");
}
export async function runCuratorChat(input: {
userId: string;
conversationId?: string;
taskId?: string;
date?: string;
messages: Array<{ role: "user" | "assistant"; content: string }>;
}): Promise<CuratorChatResponse> {
const date = input.date ?? todayIsoDate();
const conversation = input.conversationId
? { id: input.conversationId }
: await ensureCuratorConversation({ userId: input.userId, taskId: input.taskId, date });
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;
await addMessagePg(input.userId, {
id: buildId("user"),
conversationId: conversation.id,
role: "user",
sender: "User",
content: latest,
});
let reply = "";
try {
const extract = await generateObject({
model: getConversationModel(),
schema: chatExtractSchema,
system: "Extract compact curator memory from the user's latest message. Use ASCII punctuation only.",
prompt: `Task: ${task?.title ?? "General curator chat"}\nService: ${task?.serviceName ?? "none"}\nMessage: ${latest}`,
});
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: extract.object },
});
const result = await generateText({
model: getConversationModel(),
system: [
"You are the GrowQR V1 Curator Agent.",
"You own 30 day task direction, streak continuity, and service handoffs.",
"Use the supplied tools to read tasks, Q-score, service capabilities, reports, and to prepare handoffs.",
"Do not claim a task is completed unless a valid service or platform event exists.",
"Ask a task-specific question, not the same generic question for every task.",
"Keep the answer under 80 words. Use ASCII punctuation only. Do not use em dash or en dash.",
].join("\n"),
prompt: [
`Date: ${date}`,
`Task id: ${input.taskId ?? "none"}`,
`Task title: ${task?.title ?? "General curator chat"}`,
`Service: ${task?.serviceName ?? "none"}`,
`Completion events: ${task?.completionEvents.join(", ") ?? "none"}`,
`User message: ${latest}`,
].join("\n"),
tools: buildCuratorTools({ userId: input.userId, date, conversationId: conversation.id, taskId: input.taskId }),
stopWhen: stepCountIs(6),
});
reply = sanitize(result.text);
} catch (error) {
reply = task?.serviceId === "resume-service"
? "Share the resume text or upload the resume file, plus the target role. I will prepare the resume handoff and keep this task tied to service events."
: task?.serviceId === "interview-service"
? "Tell me the role, round type, and one thing you want to improve. I will prepare the interview setup handoff."
: task?.serviceId === "roleplay-service"
? "Tell me the scenario, who you are speaking with, and the outcome you want. I will prepare the roleplay handoff."
: `What should I capture for ${task?.title ?? "this curator task"}?`;
}
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: await listMessagesPg(input.userId, conversation.id),
};
}

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,70 @@
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(),
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("/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,286 @@
import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
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 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 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;
}): 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 });
return {
id,
date: input.date,
title: input.stageTitle,
subtitle: input.stageDescription || `Continue ${input.missionTitle}`,
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: "Stage", value: input.stageTitle },
{ label: "Source", value: input.missionInstanceId ? "Active mission" : "Mission registry" },
],
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 (taskId === task.id) return true;
if (!task.completionEvents.includes(row.type)) return false;
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")
.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(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 fallbackModules = listMissionDefinitions().flatMap((mission) =>
mission.modules.map((module) => ({ mission, module, serviceId: serviceFromRole(module.role, module.service) })),
);
const seenServices = new Set(tasks.map((task) => task.serviceId).filter(Boolean));
for (const item of fallbackModules) {
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(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 fallbackModules) {
if (tasks.length >= 3) break;
if (tasks.some((task) => task.missionId === item.mission.missionId && task.stageId === item.module.id)) continue;
tasks.push(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,113 @@
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() })),
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 CuratorChatResponse = {
conversationId: string;
taskId?: string;
reply: string;
messages: Array<{ id: string; role: "user" | "assistant"; sender: string; content: string; createdAt: number }>;
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;
}