Compare commits
24 Commits
main
...
chore/rele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e478db9334 | ||
|
|
9e96912942 | ||
|
|
1d3cfbcff7 | ||
|
|
f03de1ea58 | ||
|
|
1f7b2ae958 | ||
|
|
821788558e | ||
|
|
b7d61944b4 | ||
|
|
5c480ce90f | ||
|
|
a1654d23b4 | ||
|
|
289f6f7844 | ||
|
|
f3fe3c4748 | ||
|
|
c4217eb18c | ||
|
|
a937bcf09e | ||
|
|
068b57c553 | ||
|
|
86ec1fa603 | ||
|
|
5839d91d97 | ||
|
|
a84f323cd5 | ||
|
|
3663fb91b0 | ||
|
|
d0b0efca74 | ||
|
|
f9f69653e3 | ||
|
|
4a4a03ebb9 | ||
|
|
370c45c002 | ||
|
|
ef87cf80e5 | ||
|
|
be486e12e3 |
14
.env.example
14
.env.example
@@ -25,19 +25,23 @@ SERVICE_TOKEN=dev-service-token-REPLACE_ME
|
||||
A2A_ALLOWED_KEY=***********
|
||||
|
||||
# ── Central Gitea (shared org-wide, changes.md §2A) ──
|
||||
GITEA_URL=http://127.0.0.1:3001
|
||||
# Public URL is used for Git remotes and must be reachable by OpenCode containers.
|
||||
# Internal URL is used only by the backend when it shares a private network with Gitea.
|
||||
GITEA_PUBLIC_URL=http://host.docker.internal:3001
|
||||
GITEA_INTERNAL_URL=http://127.0.0.1:3001
|
||||
GITEA_ROOT_URL=http://localhost:3001
|
||||
GITEA_ADMIN_USER=growqr-admin
|
||||
GITEA_ADMIN_PASSWORD=growqr-admin-dev
|
||||
GITEA_ADMIN_TOKEN=
|
||||
GITEA_ORG_NAME=growqr
|
||||
|
||||
# ── Version tracking (changes.md §9) ──
|
||||
OPENCODE_IMAGE_VERSION=1.0.0
|
||||
OPENCODE_IMAGE_VERSION=dev
|
||||
MIGRATION_VERSION=1
|
||||
PROMPT_VERSION=1
|
||||
PROMPT_VERSION=4
|
||||
|
||||
# Rivet Kit engine (self-hosted in docker-compose)
|
||||
RIVET_ENDPOINT=http://localhost:6420
|
||||
RIVET_ENDPOINT=http://default:dev-admin-token@localhost:6420
|
||||
RIVET_CLIENT_ENDPOINT=http://127.0.0.1:4000/api/rivet
|
||||
|
||||
# Product microservice sub-agent URLs
|
||||
@@ -46,7 +50,7 @@ ROLEPLAY_SERVICE_URL=http://localhost:8008
|
||||
QSCORE_SERVICE_URL=http://localhost:8000
|
||||
|
||||
# Per-user OpenCode container image (shared, changes.md §3)
|
||||
OPENCODE_IMAGE=ghcr.io/anomalyco/opencode:latest
|
||||
OPENCODE_IMAGE=growqr/opencode:dev
|
||||
|
||||
# Host where spawned containers expose their ports.
|
||||
# - localhost in dev
|
||||
|
||||
@@ -95,7 +95,7 @@ npm run lint
|
||||
|
||||
- **"missing bearer token"** from `/users/bootstrap` — Clerk session not attached. Sign out and back in.
|
||||
- **`Gitea did not become ready`** during provisioning — Gitea takes 10–20s on first pull. Wait, then `POST /actors/provision` (the frontend retries via polling).
|
||||
- **OpenCode container exits immediately** — check `OPENCODE_IMAGE`. The compose env passes `Cmd: ["serve", ...]`; if you swap to a different image, ensure it exposes the `opencode serve` HTTP surface on `:4096`.
|
||||
- **OpenCode container exits immediately** — check `OPENCODE_IMAGE`. The backend starts containers with `Cmd: ["opencode", "serve", ...]`; if you swap images, ensure they expose the OpenCode HTTP surface on `:4096`.
|
||||
- **`No free ports in USER_PORT_RANGE`** — bump `USER_PORT_RANGE_END` in `.env` or stop unused user stacks via `POST /actors/stop`.
|
||||
|
||||
## PRD status
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
id: emily
|
||||
name: Emily
|
||||
role: Roleplay Agent
|
||||
service: roleplay-service
|
||||
tools:
|
||||
- start_roleplay_session
|
||||
---
|
||||
|
||||
## Domain
|
||||
Emily is the **Roleplay Agent**. She runs realistic workplace scenarios to help users practice conversations, negotiations, and difficult situations. She plays different personas convincingly and provides feedback.
|
||||
|
||||
## When to use this agent (trigger phrases)
|
||||
Use `start_roleplay_session` when the user:
|
||||
- Wants to negotiate: "salary negotiation", "negotiate offer", "counter offer", "compensation", "equity discussion", "signing bonus", "benefits negotiation"
|
||||
- Has a difficult conversation: "asking for a raise", "promotion conversation", "talk to my manager", "difficult conversation with boss"
|
||||
- Is leaving a job: "resignation", "quit my job", "put in notice", "two weeks notice", "leaving my company"
|
||||
- Wants to practice soft skills: "roleplay", "practice conversation", "rehearse", "act out"
|
||||
- Has networking needs: "coffee chat", "informational interview", "networking event", "cold outreach"
|
||||
- Has stakeholder scenarios: "client meeting", "stakeholder presentation", "pitch to executives", "cross-functional"
|
||||
- Has conflict situations: "conflict with coworker", "team disagreement", "difficult colleague", "managing up"
|
||||
- Has performance situations: "performance review", "self-review", "annual review", "how to present my work"
|
||||
- Needs general conversation practice: "how to say", "what should I tell", "how do I bring up", "need to tell my"
|
||||
|
||||
## What Emily NEVER does
|
||||
- Interview practice or technical questions → Sara
|
||||
- Resume writing → Resume Agent
|
||||
- Job searching → Job Search Agent
|
||||
- Q-Score computation → Quinn
|
||||
- Career coaching beyond roleplay → general chat
|
||||
|
||||
## How it works
|
||||
Calls `POST /api/v1/roleplays/configure` on the roleplay-service with user_id, persona_id, roleplay_type, brief, difficulty, and qscore_context. Creates a real Gemini Live-powered roleplay session. Supports types: sales, customer_success, support, custom. Returns session_id for the user to start practicing.
|
||||
185
agents/interview.md
Normal file
185
agents/interview.md
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
id: interview
|
||||
name: Interview Agent
|
||||
role: Interview Service Client Agent
|
||||
service: interview-service
|
||||
tools: ["start_interview_session"]
|
||||
---
|
||||
|
||||
## Domain
|
||||
The Interview Agent is the GrowQR client agent for the **interview-service**. It helps users configure, preview, approve, launch, and review realistic mock job interviews. It should think and speak like an API client and workflow orchestrator: collect the right user intent and context, call the interview-service through the available tools/service operations, then explain the returned session plan, candidate brief, review, or assignment status clearly.
|
||||
|
||||
The service creates real interview sessions backed by live voice/video interview infrastructure, generates role-aware question plans, supports draft approval workflows, tracks assignments, stores session artifacts, and produces post-session scoring and coaching feedback. Do not imitate the service locally. Do not invent session IDs, questions, scores, artifact URLs, or review results when a service call fails or has not completed.
|
||||
|
||||
## Primary intents this agent handles
|
||||
Use the interview-service when the user wants any of the following:
|
||||
|
||||
1. **Start or configure mock interview practice**
|
||||
- “mock interview”, “practice interview”, “interview prep”, “run an interview”, “start a technical screen”, “behavioral practice”, “HR round”, “warm-up round”, “coding interview”, “role-specific interview”, “interview for [role/company]”.
|
||||
|
||||
2. **Preview or customize an interview plan before practice**
|
||||
- The user wants to see questions first, edit questions, change difficulty, change duration, change persona/interviewer style, add a job description, add company context, or regenerate the plan with feedback.
|
||||
|
||||
3. **Approve and launch a prepared draft**
|
||||
- The user accepts a draft plan, asks to proceed, or is ready to start the live session.
|
||||
|
||||
4. **Review performance after a session**
|
||||
- The user asks “how did I do?”, “show my interview score”, “get feedback”, “review session [id]”, “strengths/weaknesses”, “improvement plan”, or wants audio/video/presence analysis.
|
||||
|
||||
5. **Interview assignments and admin workflows**
|
||||
- Admin/new-dashboard flows that assign interviews to candidates, list a candidate’s assigned interviews, unassign candidates, or bulk-fetch results for assignment IDs.
|
||||
|
||||
6. **Leaderboard and artifacts**
|
||||
- The user or workflow needs top completed interview reviews, a downloadable artifact URL, or video upload registration.
|
||||
|
||||
## Route away from this agent
|
||||
Do not use interview-service for:
|
||||
- Resume writing, tailoring, ATS optimization, resume scoring, resume bullet generation → Resume Agent.
|
||||
- Salary negotiation, offer negotiation, workplace conversations, stakeholder conversations, manager conversations, networking roleplay, conflict practice → Roleplay Agent.
|
||||
- Overall market-readiness/Q-score computation → Q Score Agent.
|
||||
- General career advice without a concrete interview-practice, interview-review, or assignment action → general assistant unless the user chooses practice.
|
||||
|
||||
## Service capabilities from a client perspective
|
||||
|
||||
### Configure / preview interview sessions
|
||||
The core client action is to call either:
|
||||
- `POST /api/v1/configure/preview` for a draft plan the user can review/edit/approve.
|
||||
- `POST /api/v1/configure` for an immediately configured real practice session.
|
||||
|
||||
Send:
|
||||
- `user_id` — current user identifier.
|
||||
- `org_id` — current organization/workspace identifier.
|
||||
- `persona_id` — one of `payal`, `emma`, `john`, `kapil`.
|
||||
- `interview_type` — one of `warm_up`, `behavioral`, `role_related`, `coding`.
|
||||
- `duration_minutes` — exactly one of `5`, `10`, or `15`.
|
||||
- `context` — structured interview context.
|
||||
|
||||
Use `/preview` by default when the user is still deciding, asks to “show me the plan/questions first,” provides incomplete details, wants customization, or is in a broader workflow where approval is desirable. Use `/configure` when the user explicitly wants to start/create the real session now or the workflow is intentionally provisioning a ready session.
|
||||
|
||||
Preview supports:
|
||||
- `draft_session_id` to regenerate/update an existing draft.
|
||||
- `planner_feedback` to incorporate user edits such as “make it harder,” “focus more on system design,” or “avoid leadership questions.”
|
||||
|
||||
The service returns real values such as `session_id`, `status`, `needs_approval`, `config`, `opening_prompt`, `question_outline`, `history_summary`, `follow_up_policy`, `planning_brief`, and `candidate_brief`. Surface the useful parts to the user and preserve `session_id` for follow-up actions.
|
||||
|
||||
### Edit and approve draft questions
|
||||
For draft sessions only:
|
||||
- `POST /api/v1/configure/questions` with `session_id` and `questions` edits the draft question plan. Questions may be strings, but prefer objects with `question`, `topic`, and `expected_framework`. This edit is only allowed once before approval.
|
||||
- `POST /api/v1/configure/approve` with `session_id` marks a draft/configured session approved and ready as `configured`.
|
||||
|
||||
If the user asks to edit questions, keep the same session and send a clean ordered list. Do not over-edit: preserve user intent, interview type, duration, and role relevance.
|
||||
|
||||
### Live practice session
|
||||
Live practice is started by the frontend/client using the returned `session_id` over:
|
||||
- WebSocket `/api/v1/session/{session_id}`.
|
||||
|
||||
As the planning/orchestration agent, your job is to get the session configured/approved and explain next steps. Do not simulate the live WebSocket interview in text unless the user explicitly asks for informal practice outside the service.
|
||||
|
||||
### Review, leaderboard, artifacts, and video
|
||||
- `GET /api/v1/review/{session_id}` returns one of: `completed`, `processing`, `failed`, or `not_eligible`.
|
||||
- Completed reviews include overall score, rubric scores, summary, strengths, weaknesses, recommendations, historical comparison, carry-forward planning signals, deep analysis, Perplexity insights, trend data, improvement roadmap, ratio cards, presence metrics, video analysis status/analysis, session metadata, and audio artifacts.
|
||||
- If `processing`, tell the user review generation is still underway and poll later.
|
||||
- If video analysis is still pending while the review is complete, explain that the score/feedback is available and video analysis may continue asynchronously.
|
||||
- `GET /api/v1/leaderboard?org_id=&limit=` returns top completed interview reviews.
|
||||
- `GET /api/v1/artifacts/{session_id}/{artifact_type}` returns a presigned download URL for a stored artifact. Common video artifact type is `session_video`.
|
||||
- `POST /api/v1/sessions/{session_id}/video/upload-url` issues a browser PUT URL for a `video/webm` recording.
|
||||
- `POST /api/v1/sessions/{session_id}/video/uploaded` confirms the upload and starts async video analysis.
|
||||
|
||||
### Assignment workflows
|
||||
Use assignment endpoints for admin/dashboard workflows, not ordinary individual practice unless the workflow explicitly references assignments.
|
||||
|
||||
- `POST /api/v1/interviews/assignments`
|
||||
- Body: `organization_id`, `role`, `round`, `assignee_emails`.
|
||||
- `round` is one of `warm_up`, `behavioral`, `technical`.
|
||||
- Creates one assignment per deduped lowercase email and returns `batch_id` plus `assignment_id`s.
|
||||
|
||||
- `GET /api/v1/interviews/assignments?email=&status=&limit=`
|
||||
- Lists assignments for a seeker email.
|
||||
- Client statuses: `pending`, `in_progress`, `completed`, `unassigned`, `all`.
|
||||
|
||||
- `POST /api/v1/interviews/assignments/unassign`
|
||||
- Body: `organization_id`, `emails`.
|
||||
- Soft-unassigns active assigned/started rows. Completed rows cannot be unassigned.
|
||||
|
||||
- `POST /api/v1/interviews/results:bulk`
|
||||
- Body: `organization_id`, `assignment_ids`.
|
||||
- Returns `not_started`, `in_progress`, `completed`, or `unassigned` per known assignment. Completed results include score, rubric scores, summary, strengths, weaknesses, recommendations, historical comparison, presence metrics, video analysis, improvement roadmap, ratio cards, and timestamps.
|
||||
|
||||
When starting practice from an assignment, include `context.assignment_id` in the configure request so results can be linked back to the assignment.
|
||||
|
||||
## Input normalization rules
|
||||
|
||||
### Persona selection
|
||||
Valid `persona_id` values:
|
||||
- `emma` — professional, structured, crisp. Default for most users.
|
||||
- `payal` — warm, friendly, encouraging. Use for nervous users, early-career candidates, confidence-building.
|
||||
- `john` — calm, probing, senior. Use for senior roles, leadership, high-pressure, executive-style practice.
|
||||
- `kapil` — energetic, conversational, slightly challenging. Use when the user wants momentum, challenge, or a casual-but-demanding style.
|
||||
|
||||
Default to `emma` if no preference is stated.
|
||||
|
||||
### Interview type selection
|
||||
Valid `interview_type` values:
|
||||
- `warm_up` — non-technical opener: background, motivation, communication comfort, “tell me about yourself.”
|
||||
- `behavioral` — HR/behavioral/STAR questions: ownership, conflict, prioritization, leadership, decisions under pressure.
|
||||
- `role_related` — role-specific technical or practical execution questions grounded in the target role/JD; use for product, sales, operations, design, data, engineering role-fit, and practical tradeoffs.
|
||||
- `coding` — verbal technical/coding/system-design-style practice for engineering interviews. It is still spoken/conceptual; do not promise an in-browser code editor unless another tool provides it.
|
||||
|
||||
Mapping tips:
|
||||
- User says “technical round” but not necessarily programming → usually `role_related` unless they mention coding, algorithms, system design, LeetCode, architecture, or engineering implementation.
|
||||
- Assignment `round: technical` maps to configure `interview_type: role_related` by default; use `coding` only for coding/software engineering contexts.
|
||||
- For general job-readiness or broad interview prep, choose `behavioral`.
|
||||
|
||||
### Duration selection
|
||||
Valid durations are exactly `5`, `10`, or `15` minutes.
|
||||
- `5` for quick practice, warm-up, limited time, or first sample.
|
||||
- `10` for balanced practice when the user asks for a normal short mock.
|
||||
- `15` for deeper preparation, senior roles, multiple question areas, or when the user requests a full session.
|
||||
|
||||
If the user asks for an unsupported duration, choose the nearest valid duration and mention the adjustment briefly.
|
||||
|
||||
### Difficulty selection
|
||||
Put difficulty in `context.difficulty`. Valid values: `student`, `easy`, `medium`, `hard`, `advanced` (the service treats advanced as hard). Default to `medium`.
|
||||
- `student` for students, interns, first interview, early-career confidence-building.
|
||||
- `easy` for low-pressure practice.
|
||||
- `medium` for standard preparation.
|
||||
- `hard`/`advanced` for senior roles, final rounds, FAANG-style pressure, or explicit “challenge me.”
|
||||
|
||||
### Recommended context fields
|
||||
Build a rich but concise `context` object when known:
|
||||
- `target_role`
|
||||
- `company_name`
|
||||
- `job_description`
|
||||
- `difficulty`
|
||||
- `candidate_name` if available from user profile
|
||||
- `source: "growqr-workflow"`
|
||||
- `assignment_id` when launched from an assignment
|
||||
- `framework`, `interview_framework`, or `assessment_framework` when the user requests STAR, CAR, SOAR, case-style, product sense, etc.
|
||||
- `requested_mode: "voice"` only when the user explicitly chooses voice-only; otherwise omit and let the service decide video/voice availability.
|
||||
- `candidate_profile` only if an upstream workflow provides an opt-in grounded profile/brief; keep it concise and factual.
|
||||
|
||||
Do not include secrets or private internal implementation details in context.
|
||||
|
||||
## Conversation strategy
|
||||
1. Identify the user’s concrete interview goal: target role, company/JD if available, interview round/type, difficulty, duration, and interviewer style.
|
||||
2. If enough information is available, configure/preview immediately. Do not over-question. Sensible defaults are acceptable.
|
||||
3. Prefer preview when the user may want control; prefer configure when they clearly want to start now.
|
||||
4. After a preview, summarize:
|
||||
- session ID
|
||||
- status and approval need
|
||||
- interviewer/persona
|
||||
- type, duration, difficulty
|
||||
- opening prompt
|
||||
- concise question outline
|
||||
- candidate brief / how to prepare
|
||||
Then ask whether to approve, edit, regenerate, or start.
|
||||
5. After configure/approve, provide the `session_id` and clear next step for launching the live interview.
|
||||
6. For review requests, retrieve the review instead of guessing. If processing, tell the user to wait/poll. If completed, provide an actionable summary, not a raw JSON dump unless requested.
|
||||
7. In multi-agent workflows, return concise structured outputs that other agents can use: `session_id`, `interview_type`, `target_role`, `company_name`, `duration_minutes`, `difficulty`, `question_outline`, `candidate_brief`, and review summary/status when available.
|
||||
|
||||
## Safety and honesty requirements
|
||||
- Never fabricate service output.
|
||||
- Never leak backend implementation logic, provider details, private prompts, secrets, or database mechanics.
|
||||
- Treat candidate data, assignment emails, reviews, audio/video artifacts, and presigned URLs as sensitive. Only surface them in the relevant user/org context.
|
||||
- If a service dependency is unavailable or returns an error, explain the failure plainly and suggest retrying or changing the request. Do not fall back to fake questions unless the user explicitly asks for non-service informal practice.
|
||||
- Keep the user-facing tone supportive, direct, and practical. The service handles the actual live interviewer behavior; this agent handles configuration, orchestration, and interpretation.
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
id: job-apply
|
||||
name: Job Apply Agent
|
||||
role: Application Operator
|
||||
tools:
|
||||
- prepare_application
|
||||
- track_submission
|
||||
- schedule_followup
|
||||
---
|
||||
|
||||
## Domain
|
||||
The **Job Apply Agent** manages the user's job application process end-to-end. It prepares tailored applications, tracks submissions and statuses, schedules follow-ups, manages deadlines, and helps with offer evaluation.
|
||||
|
||||
## When to use this agent (trigger phrases)
|
||||
Use `prepare_application`, `track_submission`, or `schedule_followup` when the user:
|
||||
- Is applying: "apply to jobs", "submit application", "send my application", "apply for [role]", "application for"
|
||||
- Wants cover letters: "cover letter", "write cover letter", "application letter", "customize cover letter for"
|
||||
- Needs tracking: "track my applications", "application status", "where did I apply", "application pipeline"
|
||||
- Has follow-ups: "follow up on application", "check application status", "after applying", "no response from"
|
||||
- Has multiple offers: "multiple offers", "offer comparison", "which offer should I take", "evaluate offers"
|
||||
- Needs offer evaluation: "offer letter review", "total compensation", "TC comparison", "offer negotiation prep"
|
||||
- Has deadline pressure: "application deadline", "apply before", "closing date", "expiring offer"
|
||||
- Wants organization: "organize my job search", "application tracker", "job hunt organization"
|
||||
- Needs references: "reference list", "who should I use as reference", "reference check prep"
|
||||
- Has portfolio needs: "portfolio for jobs", "work samples", "GitHub for applications", "project showcase"
|
||||
|
||||
## What this agent NEVER does
|
||||
- Resume content optimization → Resume Agent
|
||||
- Job discovery → Job Search Agent
|
||||
- Interview practice → Sara
|
||||
- Roleplay → Emily
|
||||
- Q-Score → Quinn
|
||||
|
||||
## How it works
|
||||
Local workflow agent managed by Rivet. Takes the shortlist from Job Search Agent and the tailored resume from Resume Agent, then prepares complete application packages including customized cover letters, tracks submission status, and manages follow-up scheduling.
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
id: job-search
|
||||
name: Job Search Agent
|
||||
role: Opportunity Scout
|
||||
service: matchmaking-service
|
||||
tools:
|
||||
- search_jobs
|
||||
- rank_opportunities
|
||||
- prepare_shortlist
|
||||
---
|
||||
|
||||
## Domain
|
||||
The **Job Search Agent** discovers and evaluates job opportunities matching the user's skills, experience, and preferences. It searches across roles, companies, and industries; ranks opportunities by fit; and prepares shortlists for the application workflow.
|
||||
|
||||
## When to use this agent (trigger phrases)
|
||||
Use `search_jobs`, `rank_opportunities`, or `prepare_shortlist` when the user:
|
||||
- Is actively looking: "find jobs", "job search", "looking for work", "job hunting", "on the market", "searching for roles"
|
||||
- Wants matching: "what jobs match my skills", "roles that fit me", "jobs for my background", "positions for"
|
||||
- Has role preferences: "[role] jobs", "backend engineer positions", "product manager roles", "data scientist openings"
|
||||
- Has company interests: "who's hiring", "companies hiring", "startups hiring", "FAANG jobs", "tech companies"
|
||||
- Has location preferences: "remote jobs", "work from home", "hybrid jobs", "jobs in [city]", "relocation"
|
||||
- Has experience level: "entry level jobs", "senior positions", "junior roles", "[N] years experience jobs"
|
||||
- Wants market context: "job market trends", "in-demand skills", "hot jobs", "salary ranges for", "industry outlook"
|
||||
- Is unemployed/transitioning: "I need a job", "help me find work", "laid off", "between jobs", "looking after graduation"
|
||||
- Wants company research: "should I apply to [company]", "company culture", "best companies for"
|
||||
- Needs networking: "recruiter outreach", "referral strategy", "networking for jobs", "headhunter"
|
||||
|
||||
## What this agent NEVER does
|
||||
- Resume optimization → Resume Agent
|
||||
- Interview practice → Sara
|
||||
- Roleplay → Emily
|
||||
- Q-Score → Quinn
|
||||
- Application tracking → Job Apply Agent
|
||||
|
||||
## How it works
|
||||
Local workflow agent managed by Rivet. Searches and ranks opportunities based on user profile, skills, target role, and preferences. Prepares a ranked shortlist with fit scores that feeds into the Job Apply Agent for application submission.
|
||||
@@ -1,31 +1,78 @@
|
||||
---
|
||||
id: qscore
|
||||
name: Quinn
|
||||
role: Q-Score Agent
|
||||
name: Q Score Agent
|
||||
role: Q Score Agent
|
||||
service: qscore-service
|
||||
tools:
|
||||
- compute_qscore
|
||||
- ingest_signals
|
||||
tools: ["compute_qscore"]
|
||||
---
|
||||
|
||||
## Domain
|
||||
Quinn is the **Q-Score Agent**. She computes and explains the user's Q-Score — a readiness score based on resume strength, interview readiness, role alignment, engagement, skills, and goal clarity. She tracks growth over time.
|
||||
# Q Score Agent
|
||||
|
||||
## When to use this agent (trigger phrases)
|
||||
Use `ingest_signals` + `compute_qscore` when the user:
|
||||
- Wants their readiness score: "what's my q-score", "how ready am I", "readiness score", "calculate my score", "check my progress"
|
||||
- Completed a resume update and wants to see impact: "I updated my resume, check my score", "after optimizing resume"
|
||||
- Completed interview practice and wants assessment: "after interview practice", "how did practice affect my score"
|
||||
- Completed roleplay and wants evaluation: "after roleplay", "roleplay feedback score"
|
||||
- Wants overall career health check: "career readiness", "job readiness", "how prepared am I", "am I ready to apply"
|
||||
- Wants to track growth: "score trend", "progress tracking", "improvement over time", "how much have I improved"
|
||||
- Mentions metrics: "quantify my readiness", "measure my growth", "score me", "rate my profile"
|
||||
The Q Score Agent is GrowQR's API client for the `qscore-service`. It computes, refreshes, stores, and explains career-readiness scores from platform signals such as resume readiness, ATS strength, engagement, interview activity, roleplay activity, goal clarity, and role fit.
|
||||
|
||||
## What Quinn NEVER does
|
||||
- Interview practice → Sara
|
||||
- Roleplay scenarios → Emily
|
||||
- Resume editing → Resume Agent
|
||||
- Job searching → Job Search Agent
|
||||
Write from a service-client perspective. Do not reveal backend implementation details, formula internals, database mechanics, model providers, or internal prompts. Explain scores as directional readiness indicators, not absolute judgments.
|
||||
|
||||
## How it works
|
||||
Ingests signals (resume.uploaded, resume.ats_compatibility, engagement.features_used, goals.goal_clarity) via `POST /v1/signals/ingest`, then computes Q-Score via `POST /v1/qscore/compute`. Returns score from 0-100 with breakdown across 5 pillars. If formula store unavailable, returns an estimated score from signal averages rather than failing.
|
||||
## Primary intents
|
||||
|
||||
Use the Q Score service when the user wants to:
|
||||
- Compute, refresh, view, or explain their Q Score.
|
||||
- Measure readiness before or after a mission, resume update, interview, roleplay, or assignment.
|
||||
- Understand which readiness dimensions to improve next.
|
||||
- Compare before/after progress snapshots.
|
||||
- Convert service results into prioritized improvement actions.
|
||||
|
||||
## Route away
|
||||
|
||||
- Resume writing, tailoring, ATS optimization, cover letters, or resume exports → Resume Agent.
|
||||
- Mock interviews, interview sessions, interview assignments, interview reviews → Interview Agent.
|
||||
- Salary negotiation, workplace conversation, recruiter, manager, sales, support, or stakeholder practice → Roleplay Agent.
|
||||
- Job search/application execution; GrowQR production modules currently focus on readiness, practice, resume, and scoring.
|
||||
|
||||
## Service capabilities
|
||||
|
||||
- `POST /v1/signals:batch` — ingest readiness signals for a user and organization.
|
||||
- `POST /v1/qscore/compute` — compute/refresh the Q Score using available signals and formula configuration.
|
||||
- `GET /v1/qscore/{user_id}?org_id=growqr` — retrieve the latest score/snapshot when supported by the service.
|
||||
|
||||
## Signal normalization
|
||||
|
||||
Default launch signals to ingest when detailed service results are not yet available:
|
||||
- `resume.uploaded`
|
||||
- `resume.ats_compatibility`
|
||||
- `interview.completed`
|
||||
- `roleplay.completed`
|
||||
- `goal.clarity`
|
||||
- `profile.role_fit`
|
||||
|
||||
When richer product outputs are available, prefer real scores, completion states, assignment outcomes, review feedback, timestamps, and target-role context over generic defaults.
|
||||
|
||||
Typical signal fields:
|
||||
- `user_id`: stable user identifier expected by the Q Score service.
|
||||
- `org_id`: default `growqr` unless a current organization is provided.
|
||||
- `signal_id`: event/dimension identifier such as `resume.ats_compatibility`.
|
||||
- `value`: numeric, boolean, or categorical readiness value.
|
||||
- `occurred_at`: ISO timestamp.
|
||||
- `raw`: source metadata such as mission ID, target role, service result IDs, or confidence notes.
|
||||
|
||||
## Computation strategy
|
||||
|
||||
1. If no recent signals exist, ingest the best available signals first.
|
||||
2. Then call `POST /v1/qscore/compute`.
|
||||
3. If the service returns `404 No signals found for this user`, explain that readiness needs source activity first and suggest the quickest signal-producing action, such as resume analysis, interview practice, or roleplay practice.
|
||||
4. If formula configuration is unavailable or compute fails, do not invent an official Q Score. You may summarize available signals as a non-official readiness estimate only if clearly labeled.
|
||||
|
||||
## Explanation strategy
|
||||
|
||||
When presenting results:
|
||||
- Lead with the score and what changed, if known.
|
||||
- Identify the 2-3 highest-leverage dimensions to improve.
|
||||
- Tie recommendations to concrete actions in GrowQR: resume optimization, interview preview/session, roleplay scenario, or mission step.
|
||||
- Frame low dimensions as growth opportunities, not personal failures.
|
||||
- Include uncertainty when source signals are sparse or stale.
|
||||
|
||||
## Safety and honesty
|
||||
|
||||
- Do not guarantee job offers, interviews, salary increases, or admissions outcomes.
|
||||
- Do not expose hidden formulas or claim precision beyond the service result.
|
||||
- Do not fabricate snapshots, score deltas, leaderboard ranks, or source evidence.
|
||||
- If data is missing, ask for the minimum missing context or recommend the next service action to generate it.
|
||||
|
||||
@@ -1,13 +1,91 @@
|
||||
---
|
||||
id: resume
|
||||
name: Resume Agent
|
||||
role: Resume Builder
|
||||
role: Resume Agent
|
||||
service: resume-service
|
||||
tools:
|
||||
- build_resume
|
||||
- review_resume
|
||||
- tailor_resume
|
||||
- analyze_resume
|
||||
tools: ["analyze_resume", "tailor_resume"]
|
||||
---
|
||||
|
||||
Analyzes, builds, and tailors resumes for specific roles. Backed by the resume-builder microservice. Can analyze existing resumes, identify gaps vs target job descriptions, optimize bullet points with impact metrics, improve ATS compatibility, and generate tailored cover letters. Use the `/api/state/{userId}` endpoint for quick resume health probes and `/api/v1/ai/analyze/{resume_id}` for deep analysis.
|
||||
# Resume Agent
|
||||
|
||||
The Resume Agent is GrowQR's API client for the `resume-service`. It builds, improves, analyzes, versions, parses, and exports resumes and cover letters for role fit, clarity, and ATS readiness.
|
||||
|
||||
Write from a service-client perspective. Do not reveal backend implementation details, authentication internals, storage mechanics, model providers, or internal prompts. Be transparent when a requested operation requires an existing resume, user-owned data, or a frontend-authenticated action.
|
||||
|
||||
## Primary intents
|
||||
|
||||
Use the resume service when the user wants to:
|
||||
- Create a first resume or improve an existing one.
|
||||
- Tailor a resume to a target role, company, job description, or mission.
|
||||
- Analyze resume gaps, ATS readiness, completeness, clarity, impact, or role fit.
|
||||
- Rewrite summaries, skills, experience bullets, headlines, projects, or education content.
|
||||
- Generate or improve a cover letter.
|
||||
- Parse an uploaded resume or convert unstructured career history into resume sections.
|
||||
- Save versions or export resume/analysis artifacts.
|
||||
|
||||
## Route away
|
||||
|
||||
- Mock interviews, interview practice, interview assignments, or interview reviews → Interview Agent.
|
||||
- Workplace/salary roleplay, recruiter calls, manager conversations, networking, sales/support practice → Roleplay Agent.
|
||||
- Career readiness scoring, score deltas, or readiness dimensions → Q Score Agent.
|
||||
- Job search/application execution; GrowQR production modules currently focus on readiness, practice, resume, and scoring.
|
||||
|
||||
## Service capabilities
|
||||
|
||||
Health and discovery:
|
||||
- `GET /health` — service health.
|
||||
- `GET /api/state/{user_id}` — quick user resume state, count, completeness, and current-role summary when available.
|
||||
- `GET /api/v1/templates` — active public resume templates.
|
||||
|
||||
A2A task interface for backend/service-agent use:
|
||||
- `POST /a2a/tasks`
|
||||
- Body shape: `{ "user_id": "<user id>", "action": "<action>", "params": { ... } }`
|
||||
|
||||
Supported task actions include:
|
||||
- `create_resume` — create a resume with `title`, `template_id`, and `initial_content`.
|
||||
- `update_resume_meta` — update resume metadata.
|
||||
- `save_version` — save a version snapshot.
|
||||
- `ai_analyze` — analyze completeness, ATS readiness, gaps, and recommendations.
|
||||
- `ai_copilot` — perform targeted resume edits or content generation.
|
||||
- `ai_optimize_summary` — improve the professional summary.
|
||||
- `ai_optimize_experience` — improve experience bullets.
|
||||
- `ai_suggest_skills` — suggest role-aligned skills.
|
||||
- `ai_generate_summary` — generate a summary from user context.
|
||||
- `generate_cover_letter` — draft a cover letter.
|
||||
- `cover_letter_copilot` — improve a cover letter.
|
||||
- `parse_resume` — parse uploaded/unstructured resume content.
|
||||
- `export_pdf` — export a resume PDF when supported.
|
||||
- `export_analysis_pdf` — export an analysis PDF when supported.
|
||||
|
||||
Frontend/user-owned REST capabilities may include resume CRUD, AI endpoints, cover-letter CRUD, versions, and export preview. If those require a user-authenticated frontend session, do not pretend a backend service-token call can complete them directly; explain the next user action or use the A2A task path when available.
|
||||
|
||||
## Default workflow
|
||||
|
||||
1. If the user's current resume state is unknown, call/read `GET /api/state/{user_id}` first when possible.
|
||||
2. If the user has an existing resume and asks for improvement, tailor, or score, use analysis/copilot/optimization actions.
|
||||
3. If no resume exists, gather minimal source content: target role, recent experience, education, projects, skills, and desired template; then use `create_resume` or `parse_resume` as appropriate.
|
||||
4. For tailoring, require or infer a target role/JD. If missing, ask one concise clarifying question.
|
||||
5. For exports, confirm the resume/version and format before initiating.
|
||||
|
||||
## Input normalization
|
||||
|
||||
Common fields:
|
||||
- `user_id`: current user identifier.
|
||||
- `resume_id`: required for operations on an existing resume when known.
|
||||
- `target_role`, `company`, `job_description`, or `goal`: role-fit context.
|
||||
- `template_id`: use a public active template when creating from scratch.
|
||||
- `content` / `initial_content`: structured resume sections or parsed source text.
|
||||
- `instructions`: concise edit goals, such as "make bullets more metrics-driven" or "tailor to product manager JD".
|
||||
|
||||
Editing rules:
|
||||
- Preserve factual truth. Do not invent employers, degrees, certifications, dates, metrics, or tools.
|
||||
- If a metric is missing, suggest placeholders or ask the user to confirm real numbers.
|
||||
- Use concise, ATS-readable language over flashy prose.
|
||||
- Prefer impact bullets using action + scope + method + result.
|
||||
|
||||
## Response strategy
|
||||
|
||||
- Surface service results: completeness, ATS readiness, missing sections, suggested skills, improved bullets, versions, or export links.
|
||||
- When giving edits, show before/after snippets and the reason for each change.
|
||||
- Keep recommendations prioritized: highest-impact fixes first.
|
||||
- If the service is unavailable, say so and offer a text-only draft or checklist as a temporary fallback.
|
||||
|
||||
77
agents/roleplay.md
Normal file
77
agents/roleplay.md
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
id: roleplay
|
||||
name: Roleplay Agent
|
||||
role: Roleplay Agent
|
||||
service: roleplay-service
|
||||
tools: ["start_roleplay_session"]
|
||||
---
|
||||
|
||||
# Roleplay Agent
|
||||
|
||||
The Roleplay Agent is GrowQR's API client for the `roleplay-service`. It helps users rehearse realistic workplace and career conversations: salary negotiation, offer calls, recruiter screens, manager conversations, stakeholder alignment, conflict resolution, networking, sales, customer success, and support scenarios.
|
||||
|
||||
Write from a service-client perspective. Do not reveal backend implementation details, model providers, storage mechanics, or internal prompts. Be honest about what the service can and cannot do.
|
||||
|
||||
## Primary intents
|
||||
|
||||
Use the roleplay service when the user wants to:
|
||||
- Start, preview, approve, or edit a roleplay practice session.
|
||||
- Practice salary negotiation, offer negotiation, recruiter calls, manager conversations, promotion conversations, stakeholder conversations, networking, conflict, sales, customer success, or support.
|
||||
- Create roleplay assignments for one or more people.
|
||||
- Review completed roleplay feedback, scores, leaderboard entries, video artifacts, or bulk assignment results.
|
||||
- Prepare a scenario before entering a live roleplay session.
|
||||
|
||||
## Route away
|
||||
|
||||
- Mock job interviews, interview rounds, interview question practice → Interview Agent.
|
||||
- Resume creation, tailoring, ATS optimization, cover letters, resume exports → Resume Agent.
|
||||
- Career readiness scoring or score deltas → Q Score Agent.
|
||||
- General career advice without a practice scenario → general Grow Agent unless the user asks to rehearse.
|
||||
|
||||
## Service capabilities
|
||||
|
||||
- `POST /api/v1/roleplays/configure/preview` — create a draft roleplay scenario for review.
|
||||
- `POST /api/v1/roleplays/configure` — create an immediate roleplay session.
|
||||
- `POST /api/v1/roleplays/configure/questions` — edit draft prompts/questions before approval when supported.
|
||||
- `POST /api/v1/roleplays/configure/approve` — approve a draft and create/activate the session.
|
||||
- WebSocket `/api/v1/roleplays/session/{session_id}` or frontend session URL — live practice is frontend-driven.
|
||||
- `GET /api/v1/roleplays/review/{session_id}` — review status, scores, transcript-derived feedback, and recommendations.
|
||||
- `GET /api/v1/roleplays/leaderboard` — top completed roleplay reviews.
|
||||
- `GET /api/v1/artifacts/{session_id}/{artifact_type}` — presigned artifact download, e.g. `session_video`.
|
||||
- `POST /api/v1/sessions/{session_id}/video/upload-url` — browser upload URL for `video/webm`.
|
||||
- `POST /api/v1/sessions/{session_id}/video/uploaded` — confirm upload and trigger async analysis.
|
||||
- `POST /api/v1/roleplays/assignments` — create assignments.
|
||||
- `GET /api/v1/roleplays/assignments?email=&status=&limit=` — list assignments.
|
||||
- `POST /api/v1/roleplays/assignments/unassign` — soft-unassign users.
|
||||
- `POST /api/v1/roleplays/results:bulk` — bulk results by assignment IDs.
|
||||
|
||||
## Default workflow
|
||||
|
||||
Default to `POST /api/v1/roleplays/configure/preview` when the user is deciding, exploring, requesting a scenario, or wants to review/edit before starting. Use `POST /api/v1/roleplays/configure` only when the user explicitly says they want to start, launch, begin, or practice now.
|
||||
|
||||
## Input normalization
|
||||
|
||||
Required/typical fields:
|
||||
- `user_id`: current user identifier.
|
||||
- `org_id`: default to `growqr` unless a current organization is provided.
|
||||
- `persona_id`: one of `payal`, `emma`, `john`, `kapil`; default `emma`.
|
||||
- `duration_minutes`: exactly `5`, `15`, or `30`; never send `10`.
|
||||
- `roleplay_type`: one of `sales`, `customer_success`, `support`, `custom`.
|
||||
- `brief`: concise scenario brief, including user's goal, counterpart, stakes, likely objections, and desired outcome.
|
||||
- `metadata`: structured context such as `target_role`, `company`, `difficulty`, `conversation_type`, `source`, assignment IDs, or workflow IDs.
|
||||
- `qscore`: include only when available; if supplied, it must contain numeric `q_score`.
|
||||
|
||||
Mapping rules:
|
||||
- Salary negotiation, offer negotiation, recruiter calls, manager conversations, promotion talks, networking, and workplace conflict → `roleplay_type: custom`.
|
||||
- Sales discovery, demos, objection handling, or closing → `sales`.
|
||||
- Renewal, onboarding, expansion, or customer health conversations → `customer_success`.
|
||||
- Troubleshooting, escalation, or service recovery → `support`.
|
||||
- If duration is missing, choose `15` for most scenarios, `5` for quick drills, and `30` only when the user requests a deeper simulation.
|
||||
|
||||
## Conversation strategy
|
||||
|
||||
- Ask at most 1-2 clarifying questions if the counterpart, situation, or desired outcome is unclear.
|
||||
- If enough context exists, create a preview rather than stalling.
|
||||
- Summarize returned `session_id`, `scenario_id`, `status`, `needs_approval`, `opening_prompt`, `prompt_outline`, `scenario`, `qscore_context`, and `candidate_brief` when available.
|
||||
- Do not fabricate live roleplay turns, recordings, scores, or reviews. If the service is unavailable, say so and offer a text-only rehearsal as a fallback.
|
||||
- Keep feedback practical: better phrasing, likely pushback, confidence cues, and one next rehearsal step.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
id: sara
|
||||
name: Sara
|
||||
role: Interview Agent
|
||||
service: interview-service
|
||||
tools:
|
||||
- start_interview_session
|
||||
---
|
||||
|
||||
## Domain
|
||||
Sara is the **Interview Agent**. She only handles job interview preparation and practice. Her focus is behavioral interviews, technical interviews, mock sessions, and interview feedback.
|
||||
|
||||
## When to use this agent (trigger phrases)
|
||||
Use `start_interview_session` when the user:
|
||||
- Wants to practice interviews: "mock interview", "interview prep", "practice interview", "rehearse interview"
|
||||
- Has behavioral questions: "STAR method", "tell me about yourself", "behavioral questions", "common interview questions"
|
||||
- Has technical interview needs: "coding interview", "system design", "technical screen", "whiteboard"
|
||||
- Has an upcoming interview: "interview tomorrow", "interview next week", "upcoming interview", "phone screen", "onsite", "final round", "panel interview"
|
||||
- Wants interview feedback: "how did I do", "improve my answers", "interview confidence", "nervous about interview"
|
||||
- Asks about specific question types: "case interview", "product sense", "estimation questions", "leadership questions"
|
||||
- Mentions any FAANG/tech company in interview context: Google, Meta, Amazon, Apple, Netflix, Microsoft, Stripe, Airbnb, Uber, etc.
|
||||
|
||||
## What Sara NEVER does
|
||||
- Resume writing or optimization → Resume Agent
|
||||
- Roleplay scenarios, negotiation, salary talk → Emily
|
||||
- Job searching or matching → Job Search Agent
|
||||
- Q-Score analysis → Quinn
|
||||
- Career switching advice → general chat
|
||||
|
||||
## How it works
|
||||
Calls `POST /api/v1/configure` on the interview-service with user_id, interview_type, duration, and target role. Creates a real Gemini Live-powered interview session with audio streaming. Returns a session_id that the user can open to start practicing.
|
||||
962
bun.lock
962
bun.lock
@@ -1,962 +0,0 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "growqr-backend",
|
||||
"dependencies": {
|
||||
"@clerk/backend": "^1.21.0",
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"dockerode": "^4.0.7",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"hono": "^4.6.14",
|
||||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"postgres": "^3.4.5",
|
||||
"rivetkit": "^2.2.1",
|
||||
"zod": "^3.24.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dockerode": "^3.3.32",
|
||||
"@types/node": "^22.10.5",
|
||||
"drizzle-kit": "^0.31.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
||||
|
||||
"@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@8.5.0", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q=="],
|
||||
|
||||
"@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
|
||||
|
||||
"@clerk/backend": ["@clerk/backend@1.34.0", "", { "dependencies": { "@clerk/shared": "^3.9.5", "@clerk/types": "^4.59.3", "cookie": "1.0.2", "snakecase-keys": "8.0.1", "tslib": "2.8.1" }, "peerDependencies": { "svix": "^1.62.0" }, "optionalPeers": ["svix"] }, "sha512-9rZ8hQJVpX5KX2bEpiuVXfpjhojQCiqCWADJDdCI0PCeKxn58Ep0JPYiIcczg4VKUc3a7jve9vXylykG2XajLQ=="],
|
||||
|
||||
"@clerk/shared": ["@clerk/shared@3.47.5", "", { "dependencies": { "csstype": "3.1.3", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0", "swr": "2.3.4" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react-dom"] }, "sha512-rDVe73/VN2NZXhtrLRHshkUpQDrevAqDRxeXUl2M0IBEBkcl+VMHlV7fep53cVWo0b3gIqLk82pmmi+WoyF/xg=="],
|
||||
|
||||
"@clerk/types": ["@clerk/types@4.101.23", "", { "dependencies": { "@clerk/shared": "^3.47.5" } }, "sha512-t5ypYYDkT5TPaNIDjLnYk9GpkJgwNTBiS7h6FuUTjoySQtf7amNDS1A1eOu7NOcVpqiSeKg+0wzGxxcre00kMA=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||
|
||||
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="],
|
||||
|
||||
"@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@hono/node-ws": ["@hono/node-ws@1.3.1", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.11", "hono": "^4.6.0" } }, "sha512-vo/MwCnpJAVHBkGzWjCJ28wF45fYHAfbPZcH2rodZODHtch2GHA94KtMfusmVycTUtsLAsaNsHhtY6P8X3RQsA=="],
|
||||
|
||||
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
|
||||
|
||||
"@hono/zod-openapi": ["@hono/zod-openapi@1.4.0", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", "@hono/zod-validator": "^0.8.0", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.10.0", "zod": "^4.0.0" } }, "sha512-AFchqR1N/NxfI4hUOSGI2/g8zLROxA1OE7Oh5JJFlTaGxhrdRyH+93gd0tIBpb0z8s9r8hUoNnaOBfHbdb4NMw=="],
|
||||
|
||||
"@hono/zod-validator": ["@hono/zod-validator@0.8.0", "", { "peerDependencies": { "hono": ">=4.10.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-5uS4S1/LKtZQYvD4BtpPUFkOv8d1wNxHHrChm26buMiEYc1FrHWvDUaKVBwkiVtvSExHSpLGDvcnpI2Copyj9w=="],
|
||||
|
||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||
|
||||
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
||||
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
|
||||
|
||||
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
|
||||
|
||||
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
|
||||
|
||||
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||
|
||||
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="],
|
||||
|
||||
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
|
||||
|
||||
"@rivet-dev/agent-os-core": ["@rivet-dev/agent-os-core@0.1.1", "", { "dependencies": { "@rivet-dev/agent-os-posix": "0.1.0", "@rivet-dev/agent-os-python": "0.1.0", "@secure-exec/core": "^0.2.1", "@secure-exec/nodejs": "^0.2.1", "@secure-exec/v8": "^0.2.1", "croner": "^10.0.1", "long-timeout": "^0.1.1", "secure-exec": "^0.2.1" } }, "sha512-Uw5jr+gUXDY7TDUFqlypjGe1BD2KL9kTHPNo/f1iNS1R+l9IWuvT+FF/MXsLOEkc3fB06OPVu2ZvUuNwp9MpLQ=="],
|
||||
|
||||
"@rivet-dev/agent-os-posix": ["@rivet-dev/agent-os-posix@0.1.0", "", { "dependencies": { "@secure-exec/core": "^0.2.1" } }, "sha512-NIrI7cCb9x6jdmzRPPx7dAeXoTF/YCqf93ydEzYFA2zshIelLW9Rp5KtgP/2hM6fP0ly4+vVnOeavxJW0wYtcA=="],
|
||||
|
||||
"@rivet-dev/agent-os-python": ["@rivet-dev/agent-os-python@0.1.0", "", { "dependencies": { "@secure-exec/core": "^0.2.1", "pyodide": "^0.28.3" }, "peerDependencies": { "pyodide": ">=0.28.0" } }, "sha512-1tH1beMf1ceSpicQKwN/a6h+NmJrmfuT4GStiRDZmvN/UWfZhkxuy7HR5VPTQpE/feUZJ01FdtBS3Em/Qoxb2Q=="],
|
||||
|
||||
"@rivetkit/bare-ts": ["@rivetkit/bare-ts@0.6.2", "", {}, "sha512-3qndQUQXLdwafMEqfhz24hUtDPcsf1Bu3q52Kb8MqeH8JUh3h6R4HYW3ZJXiQsLcyYyFM68PuIwlLRlg1xDEpg=="],
|
||||
|
||||
"@rivetkit/engine-runner": ["@rivetkit/engine-runner@2.2.1", "", { "dependencies": { "@rivetkit/engine-runner-protocol": "2.2.1", "@rivetkit/virtual-websocket": "2.0.33", "pino": "^9.9.5", "uuid": "^12.0.0", "ws": "^8.18.3" } }, "sha512-6+Q3iohT23JlwVxY3xd3fhCnvCmPTO/Ua7IeVE2vJAtfhK9yUOeKSf8C8W/4CVfaHTgkWksuy0WuhqDX9B3ERg=="],
|
||||
|
||||
"@rivetkit/engine-runner-protocol": ["@rivetkit/engine-runner-protocol@2.2.1", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2" } }, "sha512-H0TprnHI3QOM6ccT1FEqCjazlkFYknQuprisxAfIgSXtckdF6boI5UdA4hjYqlTiuBAzMxZ1zewyD84r6hMSHg=="],
|
||||
|
||||
"@rivetkit/fast-json-patch": ["@rivetkit/fast-json-patch@3.1.2", "", {}, "sha512-CtA50xgsSSzICQduF/NDShPRzvucnNvsW/lQO0WgMTT1XAj9Lfae4pm7r3llFwilgG+9iq76Hv1LUqNy72v6yw=="],
|
||||
|
||||
"@rivetkit/on-change": ["@rivetkit/on-change@6.0.2-rc.1", "", {}, "sha512-5RC9Ze/wTKqSlJvopdCgr+EfyV93+iiH8Thog0QXrl8PT1unuBNw/jadXNMtwgAxrIaCJL+JLaHQH9w7rqpMDw=="],
|
||||
|
||||
"@rivetkit/sqlite": ["@rivetkit/sqlite@0.1.1", "", {}, "sha512-NE7ZBy/hQhOrWzMZFjkHX9SoXxf+ILcDvVV+mNbUYPgiy/fsDzlXdK0+JDTGnko5f4Xl6/KVCoCozz9gkwkq8A=="],
|
||||
|
||||
"@rivetkit/sqlite-vfs": ["@rivetkit/sqlite-vfs@2.2.1", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "@rivetkit/sqlite": "^0.1.1", "vbare": "^0.0.4" } }, "sha512-gva6rk2YMhhgMJGezGNGDiX/jLM832e7jfg26RugPDWbGCWeRANKbTDRoslKJJeuzI7a1bJyp04L26mH2bDYjw=="],
|
||||
|
||||
"@rivetkit/traces": ["@rivetkit/traces@2.2.1", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "vbare": "^0.0.4" } }, "sha512-WI7VcnxRH7hvHFT3fEgtqTdteKB83uEprazphEKEHZMyOkExw1TnaNdT58vti8mdrgbV9b//ylDUl4yUBbwp/w=="],
|
||||
|
||||
"@rivetkit/virtual-websocket": ["@rivetkit/virtual-websocket@2.0.33", "", {}, "sha512-sMoHZgBy9WDW76pv+ML3LPgf7TWk5vXdu3ZpPO20j6n+rB3fLacnnmzjt5xD6tZcJ/x5qINyEywGgcxA7MTMuQ=="],
|
||||
|
||||
"@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.2.1", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-EyWRKpPFPjkYl10hgMGYkjPWUkOwr/Qg10aBRz6EAseL+zwMH7K2jCLkJU4xsVfPCYcso2Euvir5+4dvoaI3yA=="],
|
||||
|
||||
"@sandbox-agent/cli": ["@sandbox-agent/cli@0.4.2", "", { "dependencies": { "@sandbox-agent/cli-shared": "0.4.2" }, "optionalDependencies": { "@sandbox-agent/cli-darwin-arm64": "0.4.2", "@sandbox-agent/cli-darwin-x64": "0.4.2", "@sandbox-agent/cli-linux-arm64": "0.4.2", "@sandbox-agent/cli-linux-x64": "0.4.2", "@sandbox-agent/cli-win32-x64": "0.4.2" }, "bin": { "sandbox-agent": "bin/sandbox-agent" } }, "sha512-trO//ypJBSt5xkewuol9LOykvDgHwUXq8R+yQVS+0CmpN3lYUtewHkb+At9RVGRhDMmJZY2oasaXDnhfurQ33w=="],
|
||||
|
||||
"@sandbox-agent/cli-darwin-arm64": ["@sandbox-agent/cli-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+L1O8SI7k/LLhyB4dG0ghmz1cJHa0WtVjuRTrEE2gw/5EbGLWopPBsCVCmQ7snrQ4fPwtaiZDhfExcEj1VI7aw=="],
|
||||
|
||||
"@sandbox-agent/cli-darwin-x64": ["@sandbox-agent/cli-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-dDg/EwWsdgVVbJiiCX1scSNRRA48u77SsC7Tuqrfzx4fIJMLuLiIcmEtXQyCBWysSyQNV2Cr+PYXXQfCb3xg8g=="],
|
||||
|
||||
"@sandbox-agent/cli-linux-arm64": ["@sandbox-agent/cli-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-TGmTUexMoubmWQyTeaOJu0rDVl2h0Ifh1pZ0ceZy7u/6Eoqs2n46CbfQtasUxZJf10uxPgRyzEDhcdDrTYVQUA=="],
|
||||
|
||||
"@sandbox-agent/cli-linux-x64": ["@sandbox-agent/cli-linux-x64@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-H9Rbqq0DRkCHvakzefJUDrDa2y+vJjlYd5/tefzKbQ34locE13TGNygRLxdEVXpBECjK9wVdBwTVEphQNsOcjw=="],
|
||||
|
||||
"@sandbox-agent/cli-shared": ["@sandbox-agent/cli-shared@0.4.2", "", {}, "sha512-sjZXRkKeFXCSKR6hHzF2Af8CCRO3F3WFwVQJ22+sLTXJ2xskV8lkUE4egknQU9B5BC1Zumts/YiNCFQWG85awQ=="],
|
||||
|
||||
"@sandbox-agent/cli-win32-x64": ["@sandbox-agent/cli-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-lZNfHWPwQe/VH51Yvrl/ATCUvBZ3a+c8mwovojhQcmZlv4QuUQPkuvxhPqHRh9AyBx78L5J/ha46es2doa34nQ=="],
|
||||
|
||||
"@secure-exec/core": ["@secure-exec/core@0.2.1", "", { "dependencies": { "better-sqlite3": "^12.8.0" } }, "sha512-HsnUv6gClpMA1BBRmX86j30TKTZtgJC/fO1tVavr7IpM2zNKbHU8LgSlBd7mv2SNy02ImTmU/GnQ3aYB4NSbEg=="],
|
||||
|
||||
"@secure-exec/nodejs": ["@secure-exec/nodejs@0.2.1", "", { "dependencies": { "@secure-exec/core": "0.2.1", "@secure-exec/v8": "0.2.1", "cbor-x": "^1.6.4", "cjs-module-lexer": "^2.1.0", "es-module-lexer": "^1.7.0", "esbuild": "^0.27.1", "node-stdlib-browser": "^1.3.1", "web-streams-polyfill": "^4.2.0" } }, "sha512-UJMJqVFxexlHJV0Q9nWURvrz6GElj8673DDOOFln6FHR6JS+9SaSU3eISrN158DuNC3SFi4rgjb/scKnK4YOYQ=="],
|
||||
|
||||
"@secure-exec/v8": ["@secure-exec/v8@0.2.1", "", { "dependencies": { "cbor-x": "^1.6.4" }, "optionalDependencies": { "@secure-exec/v8-darwin-arm64": "0.2.1", "@secure-exec/v8-darwin-x64": "0.2.1", "@secure-exec/v8-linux-arm64-gnu": "0.2.1", "@secure-exec/v8-linux-x64-gnu": "0.2.1" } }, "sha512-ye/seCqzvyMGnvyP+AO7RkVMR/lE3x9m0D2PfmiAXA457R78ZmOFmZ6v+JlJG2vv3LM30KsSXTUhwpG+Teh0hw=="],
|
||||
|
||||
"@secure-exec/v8-darwin-arm64": ["@secure-exec/v8-darwin-arm64@0.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gEWhMHzUpLwzuBNAD0lVkZXE8wFlWMLp4IOZ+56FYwOW/C+m07cYxuW4TjHyPqZ+vPm3IkoaMqqH5yT9VhjX/Q=="],
|
||||
|
||||
"@secure-exec/v8-darwin-x64": ["@secure-exec/v8-darwin-x64@0.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-H2Z5K+Cq+fn/kxjGvhJzepnNFWG6qNdyhZybVWGr5bAAZoSz/Qkad4WnXcurWU+880tKDtnf19LHBXrg7zewNQ=="],
|
||||
|
||||
"@secure-exec/v8-linux-arm64-gnu": ["@secure-exec/v8-linux-arm64-gnu@0.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-14subGhVV/gW35mYYm7Gv1Keeex7PxIgQfoKji/JH7wYyDuarP6kgaES0nJw+JXVkxEVud52c+kbcIjIggqCEw=="],
|
||||
|
||||
"@secure-exec/v8-linux-x64-gnu": ["@secure-exec/v8-linux-x64-gnu@0.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Az4s+vUf+78vWtsC7rTn/jQc6WKJafAdt2YpEjB4Gnu+sX+FFTIst1hRV4gJonbRyJdy6SW+OQ6DZatmwczorQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||
|
||||
"@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="],
|
||||
|
||||
"@types/dockerode": ["@types/dockerode@3.3.47", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="],
|
||||
|
||||
"@types/retry": ["@types/retry@0.12.2", "", {}, "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="],
|
||||
|
||||
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
|
||||
|
||||
"acp-http-client": ["acp-http-client@0.4.2", "", { "dependencies": { "@agentclientprotocol/sdk": "^0.16.1" } }, "sha512-3wtPieF08YIU4vNXaoL5up/1D0if4i9IX3Ye5q/bwbcwg1BKsazIK/VNNfvN4ldbPjWul69IqIOpGRS3I0qo3Q=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
||||
|
||||
"asn1.js": ["asn1.js@4.10.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw=="],
|
||||
|
||||
"assert": ["assert@2.1.0", "", { "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", "object-is": "^1.1.5", "object.assign": "^4.1.4", "util": "^0.12.5" } }, "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw=="],
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
|
||||
|
||||
"better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="],
|
||||
|
||||
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
||||
|
||||
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||
|
||||
"bn.js": ["bn.js@5.2.3", "", {}, "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w=="],
|
||||
|
||||
"brorand": ["brorand@1.1.0", "", {}, "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w=="],
|
||||
|
||||
"browser-resolve": ["browser-resolve@2.0.0", "", { "dependencies": { "resolve": "^1.17.0" } }, "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ=="],
|
||||
|
||||
"browserify-aes": ["browserify-aes@1.2.0", "", { "dependencies": { "buffer-xor": "^1.0.3", "cipher-base": "^1.0.0", "create-hash": "^1.1.0", "evp_bytestokey": "^1.0.3", "inherits": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA=="],
|
||||
|
||||
"browserify-cipher": ["browserify-cipher@1.0.1", "", { "dependencies": { "browserify-aes": "^1.0.4", "browserify-des": "^1.0.0", "evp_bytestokey": "^1.0.0" } }, "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w=="],
|
||||
|
||||
"browserify-des": ["browserify-des@1.0.2", "", { "dependencies": { "cipher-base": "^1.0.1", "des.js": "^1.0.0", "inherits": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A=="],
|
||||
|
||||
"browserify-rsa": ["browserify-rsa@4.1.1", "", { "dependencies": { "bn.js": "^5.2.1", "randombytes": "^2.1.0", "safe-buffer": "^5.2.1" } }, "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ=="],
|
||||
|
||||
"browserify-sign": ["browserify-sign@4.2.5", "", { "dependencies": { "bn.js": "^5.2.2", "browserify-rsa": "^4.1.1", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "elliptic": "^6.6.1", "inherits": "^2.0.4", "parse-asn1": "^5.1.9", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1" } }, "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw=="],
|
||||
|
||||
"browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="],
|
||||
|
||||
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"buffer-xor": ["buffer-xor@1.0.3", "", {}, "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ=="],
|
||||
|
||||
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
|
||||
|
||||
"builtin-status-codes": ["builtin-status-codes@3.0.0", "", {}, "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
|
||||
|
||||
"cbor-x": ["cbor-x@1.6.4", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q=="],
|
||||
|
||||
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||
|
||||
"cipher-base": ["cipher-base@1.0.7", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.2" } }, "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA=="],
|
||||
|
||||
"cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
|
||||
|
||||
"console-browserify": ["console-browserify@1.2.0", "", {}, "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA=="],
|
||||
|
||||
"constants-browserify": ["constants-browserify@1.0.0", "", {}, "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ=="],
|
||||
|
||||
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
|
||||
|
||||
"create-ecdh": ["create-ecdh@4.0.4", "", { "dependencies": { "bn.js": "^4.1.0", "elliptic": "^6.5.3" } }, "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A=="],
|
||||
|
||||
"create-hash": ["create-hash@1.2.0", "", { "dependencies": { "cipher-base": "^1.0.1", "inherits": "^2.0.1", "md5.js": "^1.3.4", "ripemd160": "^2.0.1", "sha.js": "^2.4.0" } }, "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg=="],
|
||||
|
||||
"create-hmac": ["create-hmac@1.1.7", "", { "dependencies": { "cipher-base": "^1.0.3", "create-hash": "^1.1.0", "inherits": "^2.0.1", "ripemd160": "^2.0.0", "safe-buffer": "^5.0.1", "sha.js": "^2.4.8" } }, "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg=="],
|
||||
|
||||
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
|
||||
|
||||
"croner": ["croner@10.0.1", "", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="],
|
||||
|
||||
"crypto-browserify": ["crypto-browserify@3.12.1", "", { "dependencies": { "browserify-cipher": "^1.0.1", "browserify-sign": "^4.2.3", "create-ecdh": "^4.0.4", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "diffie-hellman": "^5.0.3", "hash-base": "~3.0.4", "inherits": "^2.0.4", "pbkdf2": "^3.1.2", "public-encrypt": "^4.0.3", "randombytes": "^2.1.0", "randomfill": "^1.0.4" } }, "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
|
||||
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
|
||||
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"des.js": ["des.js@1.1.0", "", { "dependencies": { "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"diffie-hellman": ["diffie-hellman@5.0.3", "", { "dependencies": { "bn.js": "^4.1.0", "miller-rabin": "^4.0.0", "randombytes": "^2.0.0" } }, "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg=="],
|
||||
|
||||
"docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="],
|
||||
|
||||
"dockerode": ["dockerode@4.0.12", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4", "uuid": "^10.0.0" } }, "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw=="],
|
||||
|
||||
"domain-browser": ["domain-browser@4.22.0", "", {}, "sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw=="],
|
||||
|
||||
"dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="],
|
||||
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": "bin.cjs" }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.36.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=3", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "sql.js", "sqlite3"] }, "sha512-1OZY3PXD7BR00Gl61UUOFihslDldfH4NFRH2MbP54Yxi0G/PKn4HfO65JYZ7c16DeP3SpM3Aw+VXVG9j6CRSXA=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"elliptic": ["elliptic@6.6.1", "", { "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", "hash.js": "^1.0.0", "hmac-drbg": "^1.0.1", "inherits": "^2.0.4", "minimalistic-assert": "^1.0.1", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||
|
||||
"evp_bytestokey": ["evp_bytestokey@1.0.3", "", { "dependencies": { "md5.js": "^1.3.4", "safe-buffer": "^5.1.1" } }, "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA=="],
|
||||
|
||||
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||
|
||||
"fast-copy": ["fast-copy@4.0.3", "", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="],
|
||||
|
||||
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||
|
||||
"fdb-tuple": ["fdb-tuple@1.0.0", "", {}, "sha512-8jSvKPCYCgTpi9Pt87qlfTk6griyMx4Gk3Xv31Dp72Qp8b6XgIyFsMm8KzPmFJ9iJ8K4pGvRxvOS8D0XGnrkjw=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||
|
||||
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-port": ["get-port@7.2.0", "", {}, "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||
|
||||
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||
|
||||
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hash-base": ["hash-base@3.0.5", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1" } }, "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg=="],
|
||||
|
||||
"hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="],
|
||||
|
||||
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
||||
|
||||
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
|
||||
|
||||
"hmac-drbg": ["hmac-drbg@1.0.1", "", { "dependencies": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg=="],
|
||||
|
||||
"hono": ["hono@4.12.19", "", {}, "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ=="],
|
||||
|
||||
"https-browserify": ["https-browserify@1.0.0", "", {}, "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
|
||||
|
||||
"is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
|
||||
|
||||
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
|
||||
|
||||
"is-nan": ["is-nan@1.3.2", "", { "dependencies": { "call-bind": "^1.0.0", "define-properties": "^1.1.3" } }, "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w=="],
|
||||
|
||||
"is-network-error": ["is-network-error@1.3.2", "", {}, "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA=="],
|
||||
|
||||
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||
|
||||
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isomorphic-timers-promises": ["isomorphic-timers-promises@1.0.1", "", {}, "sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ=="],
|
||||
|
||||
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
||||
|
||||
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"long-timeout": ["long-timeout@0.1.1", "", {}, "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="],
|
||||
|
||||
"map-obj": ["map-obj@4.3.0", "", {}, "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"md5.js": ["md5.js@1.3.5", "", { "dependencies": { "hash-base": "^3.0.0", "inherits": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg=="],
|
||||
|
||||
"miller-rabin": ["miller-rabin@4.0.1", "", { "dependencies": { "bn.js": "^4.0.0", "brorand": "^1.0.1" }, "bin": "bin/miller-rabin" }, "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA=="],
|
||||
|
||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
|
||||
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
||||
|
||||
"minimalistic-crypto-utils": ["minimalistic-crypto-utils@1.0.1", "", {}, "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
||||
|
||||
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="],
|
||||
|
||||
"nanoevents": ["nanoevents@9.1.0", "", {}, "sha512-Jd0fILWG44a9luj8v5kED4WI+zfkkgwKyRQKItTtlPfEsh7Lznfi1kr8/iZ+XAIss4Qq5GqRB0qtWbaz9ceO/A=="],
|
||||
|
||||
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
|
||||
|
||||
"no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="],
|
||||
|
||||
"node-abi": ["node-abi@3.92.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="],
|
||||
|
||||
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
|
||||
|
||||
"node-stdlib-browser": ["node-stdlib-browser@1.3.1", "", { "dependencies": { "assert": "^2.0.0", "browser-resolve": "^2.0.0", "browserify-zlib": "^0.2.0", "buffer": "^5.7.1", "console-browserify": "^1.1.0", "constants-browserify": "^1.0.0", "create-require": "^1.1.1", "crypto-browserify": "^3.12.1", "domain-browser": "4.22.0", "events": "^3.0.0", "https-browserify": "^1.0.0", "isomorphic-timers-promises": "^1.0.1", "os-browserify": "^0.3.0", "path-browserify": "^1.0.1", "pkg-dir": "^5.0.0", "process": "^0.11.10", "punycode": "^1.4.1", "querystring-es3": "^0.2.1", "readable-stream": "^3.6.0", "stream-browserify": "^3.0.0", "stream-http": "^3.2.0", "string_decoder": "^1.0.0", "timers-browserify": "^2.0.4", "tty-browserify": "0.0.1", "url": "^0.11.4", "util": "^0.12.4", "vm-browserify": "^1.0.1" } }, "sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"object-is": ["object-is@1.1.6", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" } }, "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q=="],
|
||||
|
||||
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||
|
||||
"object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"openapi3-ts": ["openapi3-ts@4.5.0", "", { "dependencies": { "yaml": "^2.8.0" } }, "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ=="],
|
||||
|
||||
"os-browserify": ["os-browserify@0.3.0", "", {}, "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"p-retry": ["p-retry@6.2.1", "", { "dependencies": { "@types/retry": "0.12.2", "is-network-error": "^1.0.0", "retry": "^0.13.1" } }, "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"parse-asn1": ["parse-asn1@5.1.9", "", { "dependencies": { "asn1.js": "^4.10.1", "browserify-aes": "^1.2.0", "evp_bytestokey": "^1.0.3", "pbkdf2": "^3.1.5", "safe-buffer": "^5.2.1" } }, "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg=="],
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"pbkdf2": ["pbkdf2@3.1.5", "", { "dependencies": { "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "ripemd160": "^2.0.3", "safe-buffer": "^5.2.1", "sha.js": "^2.4.12", "to-buffer": "^1.2.1" } }, "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ=="],
|
||||
|
||||
"pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": "bin.js" }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
|
||||
|
||||
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
||||
|
||||
"pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": "bin.js" }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="],
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||
|
||||
"pkg-dir": ["pkg-dir@5.0.0", "", { "dependencies": { "find-up": "^5.0.0" } }, "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
|
||||
|
||||
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": "bin.js" }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||
|
||||
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.9", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA=="],
|
||||
|
||||
"public-encrypt": ["public-encrypt@4.0.3", "", { "dependencies": { "bn.js": "^4.1.0", "browserify-rsa": "^4.0.0", "create-hash": "^1.1.0", "parse-asn1": "^5.0.0", "randombytes": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q=="],
|
||||
|
||||
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||
|
||||
"punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="],
|
||||
|
||||
"pyodide": ["pyodide@0.28.3", "", { "dependencies": { "ws": "^8.5.0" } }, "sha512-rtCsyTU55oNGpLzSVuAd55ZvruJDEX8o6keSdWKN9jPeBVSNlynaKFG7eRqkiIgU7i2M6HEgYtm0atCEQX3u4A=="],
|
||||
|
||||
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
|
||||
|
||||
"querystring-es3": ["querystring-es3@0.2.1", "", {}, "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
||||
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
|
||||
|
||||
"randomfill": ["randomfill@1.0.4", "", { "dependencies": { "randombytes": "^2.0.5", "safe-buffer": "^5.1.0" } }, "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw=="],
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": "cli.js" }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||
|
||||
"ripemd160": ["ripemd160@2.0.3", "", { "dependencies": { "hash-base": "^3.1.2", "inherits": "^2.0.4" } }, "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA=="],
|
||||
|
||||
"rivetkit": ["rivetkit@2.2.1", "", { "dependencies": { "@hono/node-server": "^1.18.2", "@hono/node-ws": "^1.1.1", "@hono/standard-validator": "^0.1.3", "@hono/zod-openapi": "^1.1.5", "@rivet-dev/agent-os-core": "^0.1.1", "@rivetkit/bare-ts": "^0.6.2", "@rivetkit/engine-runner": "2.2.1", "@rivetkit/fast-json-patch": "^3.1.2", "@rivetkit/on-change": "^6.0.2-rc.1", "@rivetkit/sqlite": "^0.1.1", "@rivetkit/sqlite-vfs": "2.2.1", "@rivetkit/traces": "2.2.1", "@rivetkit/virtual-websocket": "2.0.33", "@rivetkit/workflow-engine": "2.2.1", "cbor-x": "^1.6.0", "get-port": "^7.1.0", "hono": "^4.7.0", "invariant": "^2.2.4", "nanoevents": "^9.1.0", "p-retry": "^6.2.1", "pino": "^9.5.0", "sandbox-agent": "^0.4.2", "tar": "^7.5.0", "uuid": "^12.0.0", "vbare": "^0.0.4", "zod": "^4.1.0" }, "peerDependencies": { "@daytonaio/sdk": "^0.150.0", "@e2b/code-interpreter": "^2.3.3", "@fly/sprites": ">=0.0.1", "@vercel/sandbox": ">=0.1.0", "computesdk": ">=0.1.0", "dockerode": "^4.0.9", "drizzle-kit": "^0.31.2", "eventsource": "^4.0.0", "modal": ">=0.1.0", "ws": "^8.0.0" }, "optionalPeers": ["@daytonaio/sdk", "@e2b/code-interpreter", "@fly/sprites", "@vercel/sandbox", "computesdk", "eventsource", "modal"] }, "sha512-Na4ED0x4iaS41QlMcSgV5xM50mAZALVFV8At3EgkwbtP8FtzddjBHH5kS/rSu2hqs++iRMT8OaKVozetbHYK+Q=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"sandbox-agent": ["sandbox-agent@0.4.2", "", { "dependencies": { "@sandbox-agent/cli-shared": "0.4.2", "acp-http-client": "0.4.2" }, "optionalDependencies": { "@sandbox-agent/cli": "0.4.2" }, "peerDependencies": { "@cloudflare/sandbox": ">=0.1.0", "@daytonaio/sdk": ">=0.12.0", "@e2b/code-interpreter": ">=1.0.0", "@fly/sprites": ">=0.0.1", "@vercel/sandbox": ">=0.1.0", "computesdk": ">=0.1.0", "dockerode": ">=4.0.0", "get-port": ">=7.0.0", "modal": ">=0.1.0" }, "optionalPeers": ["@cloudflare/sandbox", "@daytonaio/sdk", "@e2b/code-interpreter", "@fly/sprites", "@vercel/sandbox", "computesdk", "modal"] }, "sha512-fH6WDQEaIrgiu93LxZcy+4Dx+t+/cslu+hzXImDyUlsaL6jV2jIv4fdxELkALlo7uzyEDVK9lmqs9qy65RHwBQ=="],
|
||||
|
||||
"secure-exec": ["secure-exec@0.2.1", "", { "dependencies": { "@secure-exec/core": "0.2.1", "@secure-exec/nodejs": "0.2.1" } }, "sha512-oaQDzTPDSCOckYC8G0PimIqzEVxY6sYEvcx0fMGsRR/Wl4wkFVHaZgQ3kc2DHWysV6WHWt5g1AXc/6seafO2XQ=="],
|
||||
|
||||
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
|
||||
|
||||
"semver": ["semver@7.8.0", "", { "bin": "bin/semver.js" }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
||||
|
||||
"sha.js": ["sha.js@2.4.12", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.0" }, "bin": "bin.js" }, "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
|
||||
|
||||
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
||||
|
||||
"snake-case": ["snake-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg=="],
|
||||
|
||||
"snakecase-keys": ["snakecase-keys@8.0.1", "", { "dependencies": { "map-obj": "^4.1.0", "snake-case": "^3.0.4", "type-fest": "^4.15.0" } }, "sha512-Sj51kE1zC7zh6TDlNNz0/Jn1n5HiHdoQErxO8jLtnyrkJW/M5PrI7x05uDgY3BO7OUQYKCvmeMurW6BPUdwEOw=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="],
|
||||
|
||||
"stream-http": ["stream-http@3.2.0", "", { "dependencies": { "builtin-status-codes": "^3.0.0", "inherits": "^2.0.4", "readable-stream": "^3.6.0", "xtend": "^4.0.2" } }, "sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"swr": ["swr@2.3.4", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg=="],
|
||||
|
||||
"tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="],
|
||||
|
||||
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
|
||||
|
||||
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||
|
||||
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
|
||||
|
||||
"timers-browserify": ["timers-browserify@2.0.12", "", { "dependencies": { "setimmediate": "^1.0.4" } }, "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ=="],
|
||||
|
||||
"to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.22.1", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg=="],
|
||||
|
||||
"tty-browserify": ["tty-browserify@0.0.1", "", {}, "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw=="],
|
||||
|
||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||
|
||||
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"url": ["url@0.11.4", "", { "dependencies": { "punycode": "^1.4.1", "qs": "^6.12.3" } }, "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@10.0.0", "", { "bin": "dist/bin/uuid" }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
|
||||
|
||||
"vbare": ["vbare@0.0.4", "", {}, "sha512-QsxSVw76NqYUWYPVcQmOnQPX8buIVjgn+yqldTHlWISulBTB9TJ9rnzZceDu+GZmycOtzsmuPbPN1YNxvK12fg=="],
|
||||
|
||||
"vm-browserify": ["vm-browserify@1.1.2", "", {}, "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@4.3.0", "", {}, "sha512-/Gnggvj9oSrEvJbDyyPtAnxBt5fGQM2iWOKQNu7ie1OxDgK40iZpyV3TKaRiEzVj1oA1UxKnEy9XPXh6PW3eVw=="],
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": "bin.mjs" }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": "bin/esbuild" }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
|
||||
|
||||
"@rivetkit/engine-runner/uuid": ["uuid@12.0.1", "", { "bin": "dist/bin/uuid" }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": "bin/esbuild" }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
|
||||
|
||||
"@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"asn1.js/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
|
||||
|
||||
"browserify-sign/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"create-ecdh/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
|
||||
|
||||
"diffie-hellman/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
|
||||
|
||||
"elliptic/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
|
||||
|
||||
"miller-rabin/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
|
||||
|
||||
"pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||
|
||||
"public-encrypt/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
|
||||
|
||||
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||
|
||||
"ripemd160/hash-base": ["hash-base@3.1.2", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.1" } }, "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg=="],
|
||||
|
||||
"rivetkit/uuid": ["uuid@12.0.1", "", { "bin": "dist/bin/uuid" }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
|
||||
|
||||
"rivetkit/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"to-buffer/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": "bin/esbuild" }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
|
||||
|
||||
"@secure-exec/nodejs/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
|
||||
|
||||
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"browserify-sign/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"browserify-sign/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"ripemd160/hash-base/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
|
||||
|
||||
"browserify-sign/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"ripemd160/hash-base/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"ripemd160/hash-base/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"ripemd160/hash-base/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"ripemd160/hash-base/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ services:
|
||||
environment:
|
||||
USER_UID: "1000"
|
||||
USER_GID: "1000"
|
||||
GITEA__server__ROOT_URL: http://localhost:3001
|
||||
GITEA__server__ROOT_URL: ${GITEA_ROOT_URL:-http://localhost:3001}
|
||||
GITEA__server__SSH_PORT: "2222"
|
||||
GITEA__security__INSTALL_LOCK: "true"
|
||||
GITEA__service__DISABLE_REGISTRATION: "true"
|
||||
@@ -80,15 +80,17 @@ services:
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-growqr}:${POSTGRES_PASSWORD:-growqr}@postgres:5432/${POSTGRES_DB:-growqr}
|
||||
# Central Gitea (shared org-wide instance)
|
||||
GITEA_URL: http://gitea:3000
|
||||
# Internal is backend -> compose service. Public is Git remote URL used by OpenCode/spawned containers.
|
||||
GITEA_INTERNAL_URL: http://gitea:3000
|
||||
GITEA_PUBLIC_URL: ${GITEA_PUBLIC_URL:-http://host.docker.internal:3001}
|
||||
GITEA_ADMIN_USER: ${GITEA_ADMIN_USER:-growqr-admin}
|
||||
GITEA_ADMIN_PASSWORD: ${GITEA_ADMIN_PASSWORD:-growqr-admin-dev}
|
||||
GITEA_ADMIN_TOKEN: ${GITEA_ADMIN_TOKEN:-}
|
||||
GITEA_ORG_NAME: ${GITEA_ORG_NAME:-growqr}
|
||||
# Version tracking for image rollouts (changes.md §9)
|
||||
OPENCODE_IMAGE_VERSION: ${OPENCODE_IMAGE_VERSION:-1.0.0}
|
||||
OPENCODE_IMAGE_VERSION: ${OPENCODE_IMAGE_VERSION:-dev}
|
||||
MIGRATION_VERSION: ${MIGRATION_VERSION:-1}
|
||||
PROMPT_VERSION: ${PROMPT_VERSION:-1}
|
||||
PROMPT_VERSION: ${PROMPT_VERSION:-4}
|
||||
# Rivet
|
||||
RIVET_ENDPOINT: http://default:${RIVET_ADMIN_TOKEN:-dev-admin-token}@rivet-engine:6420
|
||||
RIVET_CLIENT_ENDPOINT: ${RIVET_CLIENT_ENDPOINT:-http://127.0.0.1:4000/api/rivet}
|
||||
|
||||
@@ -15,6 +15,20 @@ ln -sfn "$GROWQR_HOME/prompts" /root/.config/opencode/prompts
|
||||
ln -sfn "$GROWQR_HOME/agents" /root/.local/share/opencode/agents
|
||||
ln -sfn "$GROWQR_HOME/prompts" /root/.local/share/opencode/prompts
|
||||
|
||||
# OpenCode runs with /workspace as the project root. GrowQR prompt packs live in
|
||||
# /opt/growqr, so allow read/glob/grep access there without interactive approval;
|
||||
# otherwise backend API calls can hang waiting on an approval prompt.
|
||||
cat > /root/.config/opencode/opencode.jsonc <<EOF
|
||||
{
|
||||
"\$schema": "https://opencode.ai/config.json",
|
||||
"permission": {
|
||||
"external_directory": {
|
||||
"$GROWQR_HOME/**": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Seed an empty mounted workspace with the GrowQR Git-backed memory shape. The
|
||||
# backend later clones the user's central Gitea repo over this workspace when it
|
||||
# provisions the user stack. This only runs for truly empty workspaces.
|
||||
|
||||
34
docs/opencode-lifecycle-followups.md
Normal file
34
docs/opencode-lifecycle-followups.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# OpenCode Lifecycle Follow-ups
|
||||
|
||||
These are non-blocking improvements deferred during the time-crunch pass.
|
||||
|
||||
## Architecture split
|
||||
|
||||
Longer term, keep these concerns independently scalable:
|
||||
|
||||
- **Frontend**: Next.js app, hosted separately.
|
||||
- **Core backend/API**: auth, DB, workflows, user-facing routes.
|
||||
- **Actor runtime**: Rivet user actors; actors orchestrate work and call control-plane APIs.
|
||||
- **OpenCode lifecycle/control plane**: Docker/Kubernetes/Fly/Nomad manager that creates, health-checks, upgrades, and tears down per-user OpenCode runtimes.
|
||||
- **Git service**: Gitea or hosted Git provider, reachable through a public URL.
|
||||
|
||||
Today, `src/docker/manager.ts` is not a separate service. It is an internal module inside `growqr-backend`; authenticated HTTP routes under `/opencode` and `/actors` call into it, and the user actor calls into it directly through imports.
|
||||
|
||||
## Deferred hardening
|
||||
|
||||
- Add a dedicated OpenCode lifecycle service/API instead of embedding Docker control in the backend process.
|
||||
- Add admin rollout endpoints for image/prompt upgrades:
|
||||
- recreate one user's OpenCode container
|
||||
- recreate all stale containers
|
||||
- inspect container version/labels/health
|
||||
- Make `provisionUserStack` validate existing `running` rows instead of trusting DB state.
|
||||
- Make `startOpencodeContainer` inspect existing containers and recreate them if image/version/labels/env are stale.
|
||||
- Make `reconcileOnBoot` mark stale containers as stopped/needs migration instead of only logging.
|
||||
- Add explicit migration state to `user_stacks` if rollouts need to be asynchronous.
|
||||
- Attach spawned OpenCode containers to a controlled Docker network or move to an orchestrator-native network model.
|
||||
- Split service URLs consistently for every external dependency:
|
||||
- public browser URL
|
||||
- backend internal URL
|
||||
- OpenCode/container egress URL
|
||||
- Add rate limits and stricter resource quotas per user/container.
|
||||
- Encrypt stored OpenCode per-container passwords or move them to a secrets manager.
|
||||
53
drizzle/0002_workflow_runs.sql
Normal file
53
drizzle/0002_workflow_runs.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
CREATE TABLE IF NOT EXISTS "workflow_runs" (
|
||||
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"workflow_id" text NOT NULL,
|
||||
"workflow_version" text NOT NULL,
|
||||
"status" text DEFAULT 'running' NOT NULL,
|
||||
"goal" text,
|
||||
"input" jsonb,
|
||||
"current_step_id" text,
|
||||
"progress_percent" integer DEFAULT 0 NOT NULL,
|
||||
"qscore_before" jsonb,
|
||||
"qscore_after" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp with time zone
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "workflow_runs_user_idx" ON "workflow_runs" ("user_id", "created_at");
|
||||
CREATE INDEX IF NOT EXISTS "workflow_runs_workflow_idx" ON "workflow_runs" ("workflow_id");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "workflow_run_modules" (
|
||||
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
"run_id" text NOT NULL REFERENCES "workflow_runs"("id") ON DELETE cascade,
|
||||
"module_id" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"status" text DEFAULT 'idle' NOT NULL,
|
||||
"service" text,
|
||||
"output_summary" text,
|
||||
"output" jsonb,
|
||||
"error" text,
|
||||
"started_at" timestamp with time zone,
|
||||
"completed_at" timestamp with time zone
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "workflow_artifacts" (
|
||||
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
"run_id" text NOT NULL REFERENCES "workflow_runs"("id") ON DELETE cascade,
|
||||
"module_id" text,
|
||||
"type" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"repo_path" text,
|
||||
"public_url" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "workflow_events" (
|
||||
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
"run_id" text NOT NULL REFERENCES "workflow_runs"("id") ON DELETE cascade,
|
||||
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"type" text NOT NULL,
|
||||
"payload" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
14
drizzle/0003_workflow_phase2.sql
Normal file
14
drizzle/0003_workflow_phase2.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE "workflow_run_modules" ADD COLUMN IF NOT EXISTS "idempotency_key" text;
|
||||
ALTER TABLE "workflow_run_modules" ADD COLUMN IF NOT EXISTS "retry_count" integer DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE "workflow_run_modules" ADD COLUMN IF NOT EXISTS "max_retries" integer DEFAULT 2 NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "workflow_approvals" (
|
||||
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
"run_id" text NOT NULL REFERENCES "workflow_runs"("id") ON DELETE cascade,
|
||||
"approval_id" text NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"payload" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"resolved_at" timestamp with time zone
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "workflow_approvals_run_idx" ON "workflow_approvals" ("run_id", "approval_id");
|
||||
11
drizzle/0004_qscore_snapshots.sql
Normal file
11
drizzle/0004_qscore_snapshots.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS "qscore_snapshots" (
|
||||
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"run_id" text REFERENCES "workflow_runs"("id") ON DELETE cascade,
|
||||
"snapshot_type" text NOT NULL,
|
||||
"score" integer,
|
||||
"payload" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "qscore_snapshots_user_idx" ON "qscore_snapshots" ("user_id", "created_at");
|
||||
CREATE INDEX IF NOT EXISTS "qscore_snapshots_run_idx" ON "qscore_snapshots" ("run_id");
|
||||
15
drizzle/0005_mission_registry.sql
Normal file
15
drizzle/0005_mission_registry.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS "mission_registry" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"version" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"short_title" text NOT NULL,
|
||||
"actor_type" text,
|
||||
"actor_backed" boolean DEFAULT false NOT NULL,
|
||||
"skill_path" text NOT NULL,
|
||||
"display_order" integer NOT NULL,
|
||||
"definition" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "mission_registry_display_idx" ON "mission_registry" ("display_order");
|
||||
49
drizzle/0006_conversations_active_missions.sql
Normal file
49
drizzle/0006_conversations_active_missions.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
-- Durable Talk to Me conversations + mission actor snapshots.
|
||||
-- Actors are runtime caches/orchestrators; Postgres is the source of truth.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS grow_conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT 'Talk to Me',
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS grow_conversations_user_idx
|
||||
ON grow_conversations(user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS grow_conversation_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL REFERENCES grow_conversations(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
||||
sender TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS grow_conversation_messages_conversation_idx
|
||||
ON grow_conversation_messages(conversation_id, created_at ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS grow_active_missions (
|
||||
instance_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mission_id TEXT NOT NULL,
|
||||
workflow_id TEXT NOT NULL,
|
||||
actor_type TEXT,
|
||||
title TEXT NOT NULL,
|
||||
short_title TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
progress_percent INTEGER NOT NULL DEFAULT 0,
|
||||
current_stage_id TEXT,
|
||||
goal TEXT,
|
||||
mission JSONB NOT NULL,
|
||||
snapshot JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS grow_active_missions_user_idx
|
||||
ON grow_active_missions(user_id, updated_at DESC);
|
||||
123
drizzle/0007_grow_event_backbone.sql
Normal file
123
drizzle/0007_grow_event_backbone.sql
Normal file
@@ -0,0 +1,123 @@
|
||||
-- Grow event backbone: raw event inbox, service session links, artifacts, and QScore projections.
|
||||
-- Postgres stores every raw GrowEvent first. Actors/projectors can rebuild from this stream.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS grow_events (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
org_id TEXT,
|
||||
source TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'service' CHECK (category IN ('mission', 'service', 'artifact', 'usage', 'qscore', 'entitlement', 'system')),
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
mission JSONB,
|
||||
subject JSONB,
|
||||
correlation JSONB,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
raw JSONB,
|
||||
dedupe_key TEXT,
|
||||
processing_status TEXT NOT NULL DEFAULT 'pending' CHECK (processing_status IN ('pending', 'processing', 'processed', 'failed', 'unresolved')),
|
||||
processing_error TEXT,
|
||||
processed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS grow_events_user_idx
|
||||
ON grow_events(user_id, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS grow_events_status_idx
|
||||
ON grow_events(processing_status, received_at ASC);
|
||||
CREATE INDEX IF NOT EXISTS grow_events_source_idx
|
||||
ON grow_events(source, type, occurred_at DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS grow_events_dedupe_idx
|
||||
ON grow_events(dedupe_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mission_service_sessions (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mission_instance_id TEXT REFERENCES grow_active_missions(instance_id) ON DELETE SET NULL,
|
||||
mission_id TEXT,
|
||||
stage_id TEXT,
|
||||
service_id TEXT NOT NULL,
|
||||
external_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
metadata JSONB,
|
||||
last_event_id TEXT REFERENCES grow_events(id) ON DELETE SET NULL,
|
||||
last_checked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS mission_service_sessions_user_idx
|
||||
ON mission_service_sessions(user_id, updated_at DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mission_service_sessions_external_idx
|
||||
ON mission_service_sessions(service_id, external_id);
|
||||
CREATE INDEX IF NOT EXISTS mission_service_sessions_mission_idx
|
||||
ON mission_service_sessions(mission_instance_id, stage_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mission_artifacts (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mission_instance_id TEXT REFERENCES grow_active_missions(instance_id) ON DELETE CASCADE,
|
||||
mission_id TEXT,
|
||||
stage_id TEXT,
|
||||
source_event_id TEXT REFERENCES grow_events(id) ON DELETE SET NULL,
|
||||
service_id TEXT,
|
||||
external_id TEXT,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'ready',
|
||||
summary TEXT,
|
||||
content_md TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS mission_artifacts_user_idx
|
||||
ON mission_artifacts(user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS mission_artifacts_mission_idx
|
||||
ON mission_artifacts(mission_instance_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS mission_artifacts_external_idx
|
||||
ON mission_artifacts(service_id, external_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS grow_qscore_signals (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
source_event_id TEXT REFERENCES grow_events(id) ON DELETE SET NULL,
|
||||
signal_id TEXT NOT NULL,
|
||||
score DOUBLE PRECISION NOT NULL,
|
||||
present BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
source TEXT,
|
||||
raw JSONB,
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS grow_qscore_signals_user_idx
|
||||
ON grow_qscore_signals(user_id, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS grow_qscore_signals_signal_idx
|
||||
ON grow_qscore_signals(signal_id, occurred_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS grow_qscore_latest (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
signal_id TEXT NOT NULL,
|
||||
score DOUBLE PRECISION NOT NULL,
|
||||
present BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
source TEXT,
|
||||
source_event_id TEXT REFERENCES grow_events(id) ON DELETE SET NULL,
|
||||
raw JSONB,
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, signal_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS grow_qscore_latest_user_idx
|
||||
ON grow_qscore_latest(user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS grow_qscore_projection_state (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
signal_count INTEGER NOT NULL DEFAULT 0,
|
||||
dimensions JSONB,
|
||||
summary TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
36
drizzle/0008_home_notifications.sql
Normal file
36
drizzle/0008_home_notifications.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
CREATE TABLE IF NOT EXISTS grow_home_notifications (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
module_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
urgency TEXT NOT NULL DEFAULT 'calm',
|
||||
href TEXT NOT NULL,
|
||||
source TEXT,
|
||||
source_ref JSONB,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
generated_by TEXT NOT NULL DEFAULT 'deterministic',
|
||||
reason TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT grow_home_notifications_module_check
|
||||
CHECK (module_id IN ('suggestions', 'missions', 'social', 'pathways', 'productivity', 'rewards')),
|
||||
CONSTRAINT grow_home_notifications_urgency_check
|
||||
CHECK (urgency IN ('now', 'today', 'soon', 'calm')),
|
||||
CONSTRAINT grow_home_notifications_generated_by_check
|
||||
CHECK (generated_by IN ('deterministic', 'agent', 'demo', 'manual')),
|
||||
CONSTRAINT grow_home_notifications_status_check
|
||||
CHECK (status IN ('active', 'dismissed', 'expired'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS grow_home_notifications_user_idx
|
||||
ON grow_home_notifications(user_id, status, priority DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS grow_home_notifications_module_idx
|
||||
ON grow_home_notifications(user_id, module_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS grow_home_notifications_expiry_idx
|
||||
ON grow_home_notifications(expires_at);
|
||||
47
drizzle/0009_mission_suggestions.sql
Normal file
47
drizzle/0009_mission_suggestions.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
CREATE TABLE IF NOT EXISTS "mission_suggestions" (
|
||||
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"mission_instance_id" text NOT NULL REFERENCES "grow_active_missions"("instance_id") ON DELETE cascade,
|
||||
"mission_id" text NOT NULL,
|
||||
"stage_id" text,
|
||||
"role" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"reason" text,
|
||||
"priority" integer DEFAULT 0 NOT NULL,
|
||||
"urgency" text DEFAULT 'calm' NOT NULL,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"cta_label" text NOT NULL,
|
||||
"cta_href" text NOT NULL,
|
||||
"source_refs" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"generated_by" text DEFAULT 'deterministic' NOT NULL,
|
||||
"expires_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "mission_suggestions_mission_idx" ON "mission_suggestions" ("user_id", "mission_instance_id", "status", "priority");
|
||||
CREATE INDEX IF NOT EXISTS "mission_suggestions_role_idx" ON "mission_suggestions" ("mission_instance_id", "role", "status");
|
||||
CREATE INDEX IF NOT EXISTS "mission_suggestions_expiry_idx" ON "mission_suggestions" ("expires_at");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "mission_coach_runs" (
|
||||
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"mission_instance_id" text NOT NULL REFERENCES "grow_active_missions"("instance_id") ON DELETE cascade,
|
||||
"mission_id" text NOT NULL,
|
||||
"status" text DEFAULT 'running' NOT NULL,
|
||||
"window_start" timestamp with time zone NOT NULL,
|
||||
"window_end" timestamp with time zone NOT NULL,
|
||||
"summary" text,
|
||||
"input_digest" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"output" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"model" text,
|
||||
"prompt_version" text DEFAULT 'mission-coach-v1' NOT NULL,
|
||||
"skill_version" text,
|
||||
"error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp with time zone
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "mission_coach_runs_mission_idx" ON "mission_coach_runs" ("user_id", "mission_instance_id", "created_at");
|
||||
@@ -15,6 +15,62 @@
|
||||
"when": 1780306600000,
|
||||
"tag": "0001_central_gitea_unified_actor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1780306700000,
|
||||
"tag": "0002_workflow_runs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1780306800000,
|
||||
"tag": "0003_workflow_phase2",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1780306900000,
|
||||
"tag": "0004_qscore_snapshots",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1780307000000,
|
||||
"tag": "0005_mission_registry",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1780481100000,
|
||||
"tag": "0006_conversations_active_missions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1780481200000,
|
||||
"tag": "0007_grow_event_backbone",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1780481300000,
|
||||
"tag": "0008_home_notifications",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1780481400000,
|
||||
"tag": "0009_mission_suggestions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
12
package.json
12
package.json
@@ -8,23 +8,31 @@
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
|
||||
"rivet:actors:list": "tsx scripts/rivet-actors.ts list",
|
||||
"rivet:actors:flush": "tsx scripts/rivet-actors.ts flush",
|
||||
"rivet:actors:flush:crashed": "tsx scripts/rivet-actors.ts flush --crashed-only",
|
||||
"rivet:actors:flush:grow": "tsx scripts/rivet-actors.ts flush --name growActor",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"compose:up": "docker compose up -d",
|
||||
"compose:down": "docker compose down",
|
||||
"docker:opencode:build": "docker build -f docker/opencode/Dockerfile -t growqr/opencode:dev ."
|
||||
"docker:opencode:build": "docker build --build-arg GROWQR_IMAGE_VERSION=${OPENCODE_IMAGE_VERSION:-dev} --build-arg GROWQR_PROMPT_VERSION=${PROMPT_VERSION:-4} --build-arg GROWQR_MIGRATION_VERSION=${MIGRATION_VERSION:-1} -f docker/opencode/Dockerfile -t growqr/opencode:dev ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^3.0.63",
|
||||
"@clerk/backend": "^1.21.0",
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"ai": "^6.0.177",
|
||||
"dockerode": "^4.0.7",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"hono": "^4.6.14",
|
||||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"postgres": "^3.4.5",
|
||||
"redis": "^4.7.0",
|
||||
"rivetkit": "^2.2.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
|
||||
4251
pnpm-lock.yaml
generated
Normal file
4251
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
pnpm-workspace.yaml
Normal file
9
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
allowBuilds:
|
||||
'@clerk/shared': true
|
||||
'@sandbox-agent/cli-darwin-arm64': true
|
||||
better-sqlite3: false
|
||||
cbor-extract: false
|
||||
cpu-features: false
|
||||
esbuild: false
|
||||
protobufjs: false
|
||||
ssh2: false
|
||||
@@ -73,11 +73,10 @@ Assistant calls analyze_resume → "Here's your analysis: [results]. Your streng
|
||||
## Workflow Tags (put at the VERY END, on their own line)
|
||||
|
||||
- [WORKFLOW: interview-to-offer] — full interview prep pipeline
|
||||
- [WORKFLOW: interview-practice] — interview sessions with Sara
|
||||
- [WORKFLOW: interview-practice] — interview sessions with the Interview Agent
|
||||
- [WORKFLOW: resume-boost] — resume analysis and optimization
|
||||
- [WORKFLOW: roleplay-practice] — roleplay sessions with Emily
|
||||
- [WORKFLOW: roleplay-practice] — roleplay sessions with Roleplay Agent
|
||||
- [WORKFLOW: career-switch] — career change navigation
|
||||
- [WORKFLOW: job-search] — job discovery
|
||||
- [WORKFLOW: job-preparation] — broad company preparation
|
||||
|
||||
NEVER mention these tags in your visible text. They are system-internal.
|
||||
|
||||
7
prompts/workflows/career-transition/orchestrator.md
Normal file
7
prompts/workflows/career-transition/orchestrator.md
Normal file
@@ -0,0 +1,7 @@
|
||||
PROMPT_VERSION: 1
|
||||
Role: GrowQR workflow artifact specialist.
|
||||
Inputs: workflow goal, user context, existing memory files.
|
||||
Output: write markdown artifact under /workspace/artifacts/{workflow}/{run}/{module}.md.
|
||||
Required sections: Summary, User Inputs, Recommendations, Next Actions, Memory To Save.
|
||||
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions.
|
||||
Do not claim external service completion. If required data is missing, mark waiting_for_input.
|
||||
@@ -0,0 +1,7 @@
|
||||
PROMPT_VERSION: 1
|
||||
Role: Interview preparation strategist.
|
||||
Inputs: target role/company, job description, resume/profile memory.
|
||||
Output path: /workspace/artifacts/interview-to-offer/{runId}/interview-plan.md
|
||||
Required sections: Role Fit Hypothesis, Likely Questions, STAR Story Bank, Practice Schedule, Risks, Next Actions.
|
||||
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions, readinessRisks.
|
||||
Save durable memory: target role, interview date if known, strengths, gaps.
|
||||
7
prompts/workflows/interview-to-offer/interview-plan.md
Normal file
7
prompts/workflows/interview-to-offer/interview-plan.md
Normal file
@@ -0,0 +1,7 @@
|
||||
PROMPT_VERSION: 1
|
||||
Role: Interview preparation strategist.
|
||||
Inputs: target role/company, job description, resume/profile memory.
|
||||
Output path: /workspace/artifacts/interview-to-offer/{runId}/interview-plan.md
|
||||
Required sections: Role Fit Hypothesis, Likely Questions, STAR Story Bank, Practice Schedule, Risks, Next Actions.
|
||||
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions, readinessRisks.
|
||||
Save durable memory: target role, interview date if known, strengths, gaps.
|
||||
7
prompts/workflows/interview-to-offer/orchestrator.md
Normal file
7
prompts/workflows/interview-to-offer/orchestrator.md
Normal file
@@ -0,0 +1,7 @@
|
||||
PROMPT_VERSION: 1
|
||||
Role: GrowQR workflow artifact specialist.
|
||||
Inputs: workflow goal, user context, existing memory files.
|
||||
Output: write markdown artifact under /workspace/artifacts/{workflow}/{run}/{module}.md.
|
||||
Required sections: Summary, User Inputs, Recommendations, Next Actions, Memory To Save.
|
||||
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions.
|
||||
Do not claim external service completion. If required data is missing, mark waiting_for_input.
|
||||
7
prompts/workflows/interview-to-offer/resume-analysis.md
Normal file
7
prompts/workflows/interview-to-offer/resume-analysis.md
Normal file
@@ -0,0 +1,7 @@
|
||||
PROMPT_VERSION: 1
|
||||
Role: Interview preparation strategist.
|
||||
Inputs: target role/company, job description, resume/profile memory.
|
||||
Output path: /workspace/artifacts/interview-to-offer/{runId}/interview-plan.md
|
||||
Required sections: Role Fit Hypothesis, Likely Questions, STAR Story Bank, Practice Schedule, Risks, Next Actions.
|
||||
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions, readinessRisks.
|
||||
Save durable memory: target role, interview date if known, strengths, gaps.
|
||||
7
prompts/workflows/interview-to-offer/story-bank.md
Normal file
7
prompts/workflows/interview-to-offer/story-bank.md
Normal file
@@ -0,0 +1,7 @@
|
||||
PROMPT_VERSION: 1
|
||||
Role: Interview preparation strategist.
|
||||
Inputs: target role/company, job description, resume/profile memory.
|
||||
Output path: /workspace/artifacts/interview-to-offer/{runId}/interview-plan.md
|
||||
Required sections: Role Fit Hypothesis, Likely Questions, STAR Story Bank, Practice Schedule, Risks, Next Actions.
|
||||
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions, readinessRisks.
|
||||
Save durable memory: target role, interview date if known, strengths, gaps.
|
||||
@@ -0,0 +1,7 @@
|
||||
PROMPT_VERSION: 1
|
||||
Role: GrowQR workflow artifact specialist.
|
||||
Inputs: workflow goal, user context, existing memory files.
|
||||
Output: write markdown artifact under /workspace/artifacts/{workflow}/{run}/{module}.md.
|
||||
Required sections: Summary, User Inputs, Recommendations, Next Actions, Memory To Save.
|
||||
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions.
|
||||
Do not claim external service completion. If required data is missing, mark waiting_for_input.
|
||||
7
prompts/workflows/promotion-readiness/orchestrator.md
Normal file
7
prompts/workflows/promotion-readiness/orchestrator.md
Normal file
@@ -0,0 +1,7 @@
|
||||
PROMPT_VERSION: 1
|
||||
Role: GrowQR workflow artifact specialist.
|
||||
Inputs: workflow goal, user context, existing memory files.
|
||||
Output: write markdown artifact under /workspace/artifacts/{workflow}/{run}/{module}.md.
|
||||
Required sections: Summary, User Inputs, Recommendations, Next Actions, Memory To Save.
|
||||
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions.
|
||||
Do not claim external service completion. If required data is missing, mark waiting_for_input.
|
||||
@@ -0,0 +1,7 @@
|
||||
PROMPT_VERSION: 1
|
||||
Role: GrowQR workflow artifact specialist.
|
||||
Inputs: workflow goal, user context, existing memory files.
|
||||
Output: write markdown artifact under /workspace/artifacts/{workflow}/{run}/{module}.md.
|
||||
Required sections: Summary, User Inputs, Recommendations, Next Actions, Memory To Save.
|
||||
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions.
|
||||
Do not claim external service completion. If required data is missing, mark waiting_for_input.
|
||||
179
scripts/rivet-actors.ts
Normal file
179
scripts/rivet-actors.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import "dotenv/config";
|
||||
import { config } from "../src/config.js";
|
||||
|
||||
type ActorRecord = {
|
||||
actor_id: string;
|
||||
name: string;
|
||||
key?: string | null;
|
||||
namespace_id?: string;
|
||||
runner_name_selector?: string;
|
||||
crash_policy?: string;
|
||||
create_ts?: number;
|
||||
start_ts?: number | null;
|
||||
connectable_ts?: number | null;
|
||||
sleep_ts?: number | null;
|
||||
destroy_ts?: number | null;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
type ListActorsResponse = {
|
||||
actors?: ActorRecord[];
|
||||
pagination?: { cursor?: string | null };
|
||||
};
|
||||
|
||||
type Options = {
|
||||
command: "list" | "flush";
|
||||
endpoint: string;
|
||||
namespace: string;
|
||||
name?: string;
|
||||
key?: string;
|
||||
crashedOnly: boolean;
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
function parseArgs(): Options {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0] === "flush" ? "flush" : "list";
|
||||
const options: Options = {
|
||||
command,
|
||||
endpoint: process.env.RIVET_ENDPOINT ?? config.rivetEndpoint,
|
||||
namespace: process.env.RIVET_NAMESPACE ?? "default",
|
||||
crashedOnly: false,
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
for (let i = command === "flush" || args[0] === "list" ? 1 : 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === "--") continue;
|
||||
else if (arg === "--endpoint") options.endpoint = requireValue(args, ++i, arg);
|
||||
else if (arg === "--namespace") options.namespace = requireValue(args, ++i, arg);
|
||||
else if (arg === "--name") options.name = requireValue(args, ++i, arg);
|
||||
else if (arg === "--key") options.key = requireValue(args, ++i, arg);
|
||||
else if (arg === "--crashed-only") options.crashedOnly = true;
|
||||
else if (arg === "--dry-run") options.dryRun = true;
|
||||
else if (arg === "--help" || arg === "-h") printHelpAndExit();
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function requireValue(args: string[], index: number, flag: string) {
|
||||
const value = args[index];
|
||||
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${flag}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function printHelpAndExit(): never {
|
||||
console.log(`Usage:
|
||||
pnpm rivet:actors:list [--name growActor] [--key user_123] [--crashed-only]
|
||||
pnpm rivet:actors:flush -- --name growActor [--key user_123] [--crashed-only] [--dry-run]
|
||||
|
||||
Environment:
|
||||
RIVET_ENDPOINT Defaults to config.rivetEndpoint (${config.rivetEndpoint})
|
||||
RIVET_NAMESPACE Defaults to default
|
||||
|
||||
Examples:
|
||||
pnpm rivet:actors:list
|
||||
pnpm rivet:actors:flush:crashed
|
||||
pnpm rivet:actors:flush:grow
|
||||
pnpm rivet:actors:flush -- --name growActor --key user_123
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function buildEndpoint(rawEndpoint: string) {
|
||||
const url = new URL(rawEndpoint);
|
||||
const token = url.password || process.env.RIVET_TOKEN || process.env.RIVET_ADMIN_TOKEN || "dev-admin-token";
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
return {
|
||||
baseUrl: url.toString().replace(/\/$/, ""),
|
||||
headers: token ? { Authorization: `Bearer ${token}`, "x-rivet-token": token } : {},
|
||||
};
|
||||
}
|
||||
|
||||
function actorHasError(actor: ActorRecord) {
|
||||
return Boolean(actor.error);
|
||||
}
|
||||
|
||||
async function listActors(options: Options) {
|
||||
const { baseUrl, headers } = buildEndpoint(options.endpoint);
|
||||
const actors: ActorRecord[] = [];
|
||||
let cursor: string | null | undefined;
|
||||
|
||||
do {
|
||||
const url = new URL(`${baseUrl}/actors`);
|
||||
url.searchParams.set("namespace", options.namespace);
|
||||
if (options.name) url.searchParams.set("name", options.name);
|
||||
if (options.key) url.searchParams.set("key", options.key);
|
||||
if (cursor) url.searchParams.set("cursor", cursor);
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list actors: ${response.status} ${response.statusText}\n${await response.text()}`);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as ListActorsResponse;
|
||||
actors.push(...(body.actors ?? []));
|
||||
cursor = body.pagination?.cursor;
|
||||
} while (cursor);
|
||||
|
||||
return options.crashedOnly ? actors.filter(actorHasError) : actors;
|
||||
}
|
||||
|
||||
async function deleteActor(options: Options, actor: ActorRecord) {
|
||||
const { baseUrl, headers } = buildEndpoint(options.endpoint);
|
||||
const url = new URL(`${baseUrl}/actors/${encodeURIComponent(actor.actor_id)}`);
|
||||
url.searchParams.set("namespace", options.namespace);
|
||||
|
||||
if (options.dryRun) return;
|
||||
|
||||
const response = await fetch(url, { method: "DELETE", headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete ${actor.actor_id}: ${response.status} ${response.statusText}\n${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printActors(actors: ActorRecord[]) {
|
||||
if (!actors.length) {
|
||||
console.log("No actors matched.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const actor of actors) {
|
||||
console.log(
|
||||
[
|
||||
actor.actor_id,
|
||||
actor.name,
|
||||
`key=${actor.key ?? ""}`,
|
||||
actor.error ? `error=${JSON.stringify(actor.error)}` : "ok",
|
||||
].join("\t"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs();
|
||||
const actors = await listActors(options);
|
||||
|
||||
if (options.command === "list") {
|
||||
printActors(actors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.name && !options.crashedOnly && !options.key) {
|
||||
throw new Error("Refusing to flush every actor without a filter. Pass --name, --key, or --crashed-only.");
|
||||
}
|
||||
|
||||
printActors(actors);
|
||||
for (const actor of actors) {
|
||||
await deleteActor(options, actor);
|
||||
console.log(`${options.dryRun ? "Would delete" : "Deleted"} ${actor.name} ${actor.actor_id} key=${actor.key ?? ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
39
src/actors/conversation/README.md
Normal file
39
src/actors/conversation/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Conversation Actor Prototype
|
||||
|
||||
Standalone Rivet actor prototype based on Rivet's `examples/ai-agent` shape, but with actor-local SQLite + Drizzle.
|
||||
|
||||
This folder is intentionally **not wired into `src/actors/registry.ts` yet**.
|
||||
|
||||
## Files
|
||||
|
||||
- `conversation-actor.ts` — Rivet actor with queue-driven message processing and streaming AI SDK response events.
|
||||
- `schema.ts` — Drizzle SQLite schema for messages, tool calls, and summaries.
|
||||
- `migrations.ts` — tiny inline SQLite migration for this isolated prototype.
|
||||
- `agent.ts` — AI SDK v6 `streamText` wrapper and stub memory tools.
|
||||
- `types.ts` — public event/message/status types.
|
||||
|
||||
## Actor key
|
||||
|
||||
Use a compound actor key when it is eventually wired:
|
||||
|
||||
```ts
|
||||
client.conversationActor.getOrCreate([userId, conversationId])
|
||||
```
|
||||
|
||||
## Runtime env
|
||||
|
||||
The prototype expects:
|
||||
|
||||
```txt
|
||||
OPENAI_API_KEY=...
|
||||
CONVERSATION_ACTOR_MODEL=...
|
||||
```
|
||||
|
||||
No default model is hardcoded so we do not accidentally freeze this prototype to a stale model id.
|
||||
|
||||
## Next steps when wiring later
|
||||
|
||||
1. Add `conversationActor` to `src/actors/registry.ts`.
|
||||
2. Decide whether `userActor` creates conversation ids or frontend supplies them.
|
||||
3. Replace stub memory tools in `agent.ts` with actor-to-actor calls to `memoryActor[userId]`.
|
||||
4. Move inline migrations to generated Drizzle migrations if/when this becomes production code.
|
||||
76
src/actors/conversation/agent.ts
Normal file
76
src/actors/conversation/agent.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { streamText, tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import type { ConversationMessage } from "./types.js";
|
||||
import { config } from "../../config.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.`;
|
||||
|
||||
function normalizeModel(model: string): string {
|
||||
if (config.llmProvider === "opencode" && model.startsWith("opencode/")) {
|
||||
return model.slice("opencode/".length);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
const conversationProvider = createOpenAI({
|
||||
apiKey: config.llmApiKey,
|
||||
baseURL: config.llmBaseUrl.replace(/\/$/, ""),
|
||||
name: config.llmProvider,
|
||||
});
|
||||
|
||||
export function getConversationModel() {
|
||||
const modelId = process.env.CONVERSATION_ACTOR_MODEL ?? config.agentModel;
|
||||
if (!config.llmApiKey) {
|
||||
throw new Error("Missing LLM_API_KEY/OPENCODE_API_KEY for conversation streaming.");
|
||||
}
|
||||
|
||||
return conversationProvider.chat(normalizeModel(modelId));
|
||||
}
|
||||
|
||||
export function buildModelMessages(messages: ConversationMessage[]) {
|
||||
return messages.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
}));
|
||||
}
|
||||
|
||||
export function streamConversationResponse(messages: ConversationMessage[]) {
|
||||
return streamText({
|
||||
model: getConversationModel(),
|
||||
system: SYSTEM_PROMPT,
|
||||
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",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
233
src/actors/conversation/conversation-actor.ts
Normal file
233
src/actors/conversation/conversation-actor.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { actor, event, queue } from "rivetkit";
|
||||
import { db as drizzleDb } from "rivetkit/db/drizzle";
|
||||
import { streamConversationResponse } from "./agent.js";
|
||||
import { migrateConversationDb } from "./migrations.js";
|
||||
import {
|
||||
conversationMessages,
|
||||
conversationSchema,
|
||||
} from "./schema.js";
|
||||
import type {
|
||||
ConversationMessage,
|
||||
ConversationQueueMessage,
|
||||
ConversationResponseEvent,
|
||||
ConversationStatus,
|
||||
} from "./types.js";
|
||||
|
||||
const buildId = (prefix: string) =>
|
||||
`${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
function now() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function conversationIdFromKey(key: unknown[]) {
|
||||
return String(key[1] ?? key[0] ?? "default");
|
||||
}
|
||||
|
||||
function toPublicMessage(row: typeof conversationMessages.$inferSelect): ConversationMessage {
|
||||
return {
|
||||
id: row.id,
|
||||
conversationId: row.conversationId,
|
||||
role: row.role,
|
||||
sender: row.sender,
|
||||
content: row.content,
|
||||
createdAt:
|
||||
row.createdAt instanceof Date ? row.createdAt.getTime() : Number(row.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
export const conversationActor = actor({
|
||||
// Keep only small runtime state here. Message history lives in actor-local
|
||||
// SQLite via Drizzle so this actor can grow without bloating c.state.
|
||||
state: {
|
||||
status: {
|
||||
state: "idle",
|
||||
updatedAt: Date.now(),
|
||||
} as ConversationStatus,
|
||||
},
|
||||
|
||||
db: drizzleDb({
|
||||
schema: conversationSchema,
|
||||
}),
|
||||
|
||||
queues: {
|
||||
message: queue<ConversationQueueMessage>(),
|
||||
},
|
||||
|
||||
events: {
|
||||
messageAdded: event<ConversationMessage>(),
|
||||
status: event<ConversationStatus>(),
|
||||
response: event<ConversationResponseEvent>(),
|
||||
},
|
||||
|
||||
onCreate: async (c) => {
|
||||
await migrateConversationDb(c.db);
|
||||
},
|
||||
|
||||
onWake: async (c) => {
|
||||
await migrateConversationDb(c.db);
|
||||
},
|
||||
|
||||
run: async (c) => {
|
||||
for await (const queued of c.queue.iter()) {
|
||||
const { body } = queued;
|
||||
if (!body?.text || typeof body.text !== "string") continue;
|
||||
|
||||
const conversationId = conversationIdFromKey(c.key);
|
||||
const sender = body.sender?.trim() || "User";
|
||||
|
||||
const userMessage: ConversationMessage = {
|
||||
id: buildId("user"),
|
||||
conversationId,
|
||||
role: "user",
|
||||
sender,
|
||||
content: body.text.trim(),
|
||||
createdAt: now(),
|
||||
};
|
||||
|
||||
await c.db.insert(conversationMessages).values({
|
||||
...userMessage,
|
||||
createdAt: new Date(userMessage.createdAt),
|
||||
});
|
||||
c.broadcast("messageAdded", userMessage);
|
||||
|
||||
const historyRows = await c.db
|
||||
.select()
|
||||
.from(conversationMessages)
|
||||
.where(eq(conversationMessages.conversationId, conversationId))
|
||||
.orderBy(asc(conversationMessages.createdAt));
|
||||
const history = historyRows.map(toPublicMessage);
|
||||
|
||||
const assistantMessage: ConversationMessage = {
|
||||
id: buildId("assistant"),
|
||||
conversationId,
|
||||
role: "assistant",
|
||||
sender: "GrowQR",
|
||||
content: "",
|
||||
createdAt: now(),
|
||||
};
|
||||
|
||||
await c.db.insert(conversationMessages).values({
|
||||
...assistantMessage,
|
||||
createdAt: new Date(assistantMessage.createdAt),
|
||||
});
|
||||
c.broadcast("messageAdded", assistantMessage);
|
||||
|
||||
c.state.status = { state: "thinking", updatedAt: now() };
|
||||
c.broadcast("status", c.state.status);
|
||||
|
||||
try {
|
||||
const result = streamConversationResponse(history);
|
||||
|
||||
let content = "";
|
||||
for await (const delta of result.textStream) {
|
||||
if (c.aborted) break;
|
||||
|
||||
content += delta;
|
||||
c.broadcast("response", {
|
||||
messageId: assistantMessage.id,
|
||||
delta,
|
||||
content,
|
||||
done: false,
|
||||
});
|
||||
}
|
||||
|
||||
assistantMessage.content = content || assistantMessage.content;
|
||||
await c.db
|
||||
.update(conversationMessages)
|
||||
.set({ content: assistantMessage.content })
|
||||
.where(eq(conversationMessages.id, assistantMessage.id));
|
||||
|
||||
c.broadcast("response", {
|
||||
messageId: assistantMessage.id,
|
||||
delta: "",
|
||||
content: assistantMessage.content,
|
||||
done: true,
|
||||
});
|
||||
|
||||
c.state.status = { state: "idle", updatedAt: now() };
|
||||
c.broadcast("status", c.state.status);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown conversation error";
|
||||
|
||||
assistantMessage.content =
|
||||
assistantMessage.content ||
|
||||
"I hit a snag while responding. Please try again.";
|
||||
|
||||
await c.db
|
||||
.update(conversationMessages)
|
||||
.set({ content: assistantMessage.content })
|
||||
.where(eq(conversationMessages.id, assistantMessage.id));
|
||||
|
||||
c.state.status = {
|
||||
state: "error",
|
||||
updatedAt: now(),
|
||||
error: errorMessage,
|
||||
};
|
||||
|
||||
c.broadcast("response", {
|
||||
messageId: assistantMessage.id,
|
||||
delta: "",
|
||||
content: assistantMessage.content,
|
||||
done: true,
|
||||
error: errorMessage,
|
||||
});
|
||||
c.broadcast("status", c.state.status);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
sendMessage: async (c, input: ConversationQueueMessage) => {
|
||||
await c.queue.send("message", input);
|
||||
return { queued: true };
|
||||
},
|
||||
|
||||
getHistory: async (c): Promise<ConversationMessage[]> => {
|
||||
const conversationId = conversationIdFromKey(c.key);
|
||||
const rows = await c.db
|
||||
.select()
|
||||
.from(conversationMessages)
|
||||
.where(eq(conversationMessages.conversationId, conversationId))
|
||||
.orderBy(asc(conversationMessages.createdAt));
|
||||
|
||||
return rows.map(toPublicMessage);
|
||||
},
|
||||
|
||||
addMessage: async (
|
||||
c,
|
||||
input: { role: "user" | "assistant"; content: string; sender?: string; id?: string },
|
||||
): Promise<ConversationMessage> => {
|
||||
const conversationId = conversationIdFromKey(c.key);
|
||||
const message: ConversationMessage = {
|
||||
id: input.id ?? buildId(input.role),
|
||||
conversationId,
|
||||
role: input.role,
|
||||
sender: input.sender?.trim() || (input.role === "assistant" ? "GrowQR" : "User"),
|
||||
content: input.content,
|
||||
createdAt: now(),
|
||||
};
|
||||
|
||||
await c.db.insert(conversationMessages).values({
|
||||
...message,
|
||||
createdAt: new Date(message.createdAt),
|
||||
});
|
||||
c.broadcast("messageAdded", message);
|
||||
return message;
|
||||
},
|
||||
|
||||
clearHistory: async (c) => {
|
||||
const conversationId = conversationIdFromKey(c.key);
|
||||
await c.db
|
||||
.delete(conversationMessages)
|
||||
.where(eq(conversationMessages.conversationId, conversationId));
|
||||
c.state.status = { state: "idle", updatedAt: now() };
|
||||
c.broadcast("status", c.state.status);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
getStatus: (c): ConversationStatus => c.state.status,
|
||||
},
|
||||
});
|
||||
3
src/actors/conversation/index.ts
Normal file
3
src/actors/conversation/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { conversationActor } from "./conversation-actor.js";
|
||||
export * from "./schema.js";
|
||||
export * from "./types.js";
|
||||
42
src/actors/conversation/migrations.ts
Normal file
42
src/actors/conversation/migrations.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { RawAccess } from "rivetkit/db";
|
||||
|
||||
// Tiny inline migration for the actor-local SQLite database. This keeps the
|
||||
// example self-contained and avoids wiring drizzle-kit output into the app yet.
|
||||
export async function migrateConversationDb(db: RawAccess) {
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS conversation_messages (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
||||
sender TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS conversation_messages_conversation_created_at_idx
|
||||
ON conversation_messages (conversation_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_tool_calls (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL REFERENCES conversation_messages(id) ON DELETE CASCADE,
|
||||
tool_name TEXT NOT NULL,
|
||||
args_json TEXT,
|
||||
result_json TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'done', 'error')),
|
||||
created_at INTEGER NOT NULL,
|
||||
finished_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS conversation_tool_calls_conversation_idx
|
||||
ON conversation_tool_calls (conversation_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_summaries (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
content_md TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
89
src/actors/conversation/schema.ts
Normal file
89
src/actors/conversation/schema.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
sqliteTable,
|
||||
text,
|
||||
} from "rivetkit/db/drizzle";
|
||||
|
||||
export const conversationMessages = sqliteTable(
|
||||
"conversation_messages",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
conversationId: text("conversation_id").notNull(),
|
||||
role: text("role", { enum: ["user", "assistant"] }).notNull(),
|
||||
sender: text("sender").notNull(),
|
||||
content: text("content").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
conversationCreatedAtIdx: index("conversation_messages_conversation_created_at_idx").on(
|
||||
table.conversationId,
|
||||
table.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const conversationToolCalls = sqliteTable(
|
||||
"conversation_tool_calls",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
conversationId: text("conversation_id").notNull(),
|
||||
messageId: text("message_id")
|
||||
.notNull()
|
||||
.references(() => conversationMessages.id, { onDelete: "cascade" }),
|
||||
toolName: text("tool_name").notNull(),
|
||||
argsJson: text("args_json", { mode: "json" }).$type<Record<string, unknown>>(),
|
||||
resultJson: text("result_json", { mode: "json" }).$type<Record<string, unknown>>(),
|
||||
status: text("status", { enum: ["running", "done", "error"] })
|
||||
.notNull()
|
||||
.default("running"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
finishedAt: integer("finished_at", { mode: "timestamp_ms" }),
|
||||
},
|
||||
(table) => ({
|
||||
conversationIdx: index("conversation_tool_calls_conversation_idx").on(
|
||||
table.conversationId,
|
||||
table.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const conversationSummaries = sqliteTable("conversation_summaries", {
|
||||
id: text("id").primaryKey(),
|
||||
conversationId: text("conversation_id").notNull(),
|
||||
contentMd: text("content_md").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch('subsec') * 1000)`),
|
||||
});
|
||||
|
||||
export const conversationMessagesRelations = relations(
|
||||
conversationMessages,
|
||||
({ many }) => ({
|
||||
toolCalls: many(conversationToolCalls),
|
||||
}),
|
||||
);
|
||||
|
||||
export const conversationToolCallsRelations = relations(
|
||||
conversationToolCalls,
|
||||
({ one }) => ({
|
||||
message: one(conversationMessages, {
|
||||
fields: [conversationToolCalls.messageId],
|
||||
references: [conversationMessages.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const conversationSchema = {
|
||||
conversationMessages,
|
||||
conversationToolCalls,
|
||||
conversationSummaries,
|
||||
};
|
||||
|
||||
export type ConversationMessageRow = typeof conversationMessages.$inferSelect;
|
||||
export type NewConversationMessageRow = typeof conversationMessages.$inferInsert;
|
||||
export type ConversationToolCallRow = typeof conversationToolCalls.$inferSelect;
|
||||
export type NewConversationToolCallRow = typeof conversationToolCalls.$inferInsert;
|
||||
export type ConversationSummaryRow = typeof conversationSummaries.$inferSelect;
|
||||
39
src/actors/conversation/types.ts
Normal file
39
src/actors/conversation/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type ConversationRole = "user" | "assistant";
|
||||
|
||||
export type ConversationStatus = {
|
||||
state: "idle" | "thinking" | "error";
|
||||
updatedAt: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ConversationMessage = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
role: ConversationRole;
|
||||
sender: string;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type ConversationQueueMessage = {
|
||||
text: string;
|
||||
sender?: string;
|
||||
};
|
||||
|
||||
export type ConversationResponseEvent = {
|
||||
messageId: string;
|
||||
delta: string;
|
||||
content: string;
|
||||
done: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ConversationToolEvent = {
|
||||
id: string;
|
||||
messageId: string;
|
||||
toolName: string;
|
||||
status: "running" | "done" | "error";
|
||||
args?: Record<string, unknown>;
|
||||
result?: Record<string, unknown>;
|
||||
error?: string;
|
||||
};
|
||||
1
src/actors/events/index.ts
Normal file
1
src/actors/events/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { userEventActor } from "./user-event-actor.js";
|
||||
214
src/actors/events/user-event-actor.ts
Normal file
214
src/actors/events/user-event-actor.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { actor, event, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growEvents, missionArtifacts } from "../../db/schema.js";
|
||||
import { listActiveMissionsPg, upsertActiveMissionPg } from "../../grow/persistence.js";
|
||||
import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../missions/types.js";
|
||||
import { applyQscoreProjection } from "../../events/projectors/qscore-projector.js";
|
||||
import { applyServiceSessionProjection } from "../../events/projectors/service-session-projector.js";
|
||||
import { getProjectionInsight } from "../../events/projectors/projection-agent.js";
|
||||
import { markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing } from "../../events/record-grow-event.js";
|
||||
import { reducersForMission } from "../../missions/event-reducers.js";
|
||||
import type { MissionArtifactPatch, MissionStagePatch } from "../../missions/reducer-types.js";
|
||||
|
||||
export type UserEventCommand = {
|
||||
userId: string;
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
type UserEventActorState = {
|
||||
userId: string;
|
||||
processedCount: number;
|
||||
lastEventId?: string;
|
||||
lastError?: string;
|
||||
processedEventIds: string[];
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
function summarizeMissionSnapshot(snapshot: MissionSnapshot): GrowActiveMission {
|
||||
return {
|
||||
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: actorTypeFor(snapshot.missionId),
|
||||
createdAt: new Date(snapshot.createdAt).getTime(),
|
||||
updatedAt: new Date(snapshot.updatedAt).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
function actorTypeFor(missionId: string): MissionActorType | undefined {
|
||||
if (missionId === "interview-to-offer") return "interviewToOfferMissionActor";
|
||||
if (missionId === "career-transition") return "careerTransitionMissionActor";
|
||||
if (missionId === "salary-negotiation-war-room") return "salaryNegotiationWarRoomMissionActor";
|
||||
if (missionId === "promotion-readiness") return "promotionReadinessMissionActor";
|
||||
if (missionId === "personal-brand-opportunity-engine") return "personalBrandOpportunityEngineMissionActor";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function missionActorHandle(client: any, userId: string, mission: GrowActiveMission) {
|
||||
const key = [userId, mission.instanceId];
|
||||
switch (mission.actorType) {
|
||||
case "interviewToOfferMissionActor": return client.interviewToOfferMissionActor.getOrCreate(key);
|
||||
case "careerTransitionMissionActor": return client.careerTransitionMissionActor.getOrCreate(key);
|
||||
case "salaryNegotiationWarRoomMissionActor": return client.salaryNegotiationWarRoomMissionActor.getOrCreate(key);
|
||||
case "promotionReadinessMissionActor": return client.promotionReadinessMissionActor.getOrCreate(key);
|
||||
case "personalBrandOpportunityEngineMissionActor": return client.personalBrandOpportunityEngineMissionActor.getOrCreate(key);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyStagePatches(actorHandle: any, patches: MissionStagePatch[]) {
|
||||
let snapshot: MissionSnapshot | null = null;
|
||||
const deduped = new Map<string, MissionStagePatch>();
|
||||
for (const patch of patches) deduped.set(`${patch.stageId}:${patch.status ?? ""}:${patch.progressPercent ?? ""}`, patch);
|
||||
for (const patch of deduped.values()) {
|
||||
snapshot = await actorHandle.updateStage(patch);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function applyArtifactPatches(input: {
|
||||
actorHandle: any;
|
||||
userId: string;
|
||||
mission: GrowActiveMission;
|
||||
eventId: string;
|
||||
serviceId: string;
|
||||
externalId?: string;
|
||||
patches: MissionArtifactPatch[];
|
||||
}) {
|
||||
const created = [];
|
||||
for (const patch of input.patches) {
|
||||
const artifact = await input.actorHandle.addArtifact({
|
||||
type: patch.type,
|
||||
title: patch.title,
|
||||
status: "ready",
|
||||
summary: patch.summary,
|
||||
contentMd: patch.contentMd,
|
||||
metadata: { ...(patch.metadata ?? {}), sourceEventId: input.eventId },
|
||||
});
|
||||
created.push(artifact);
|
||||
await db.insert(missionArtifacts).values({
|
||||
id: artifact.id,
|
||||
userId: input.userId,
|
||||
missionInstanceId: input.mission.instanceId,
|
||||
missionId: input.mission.missionId,
|
||||
stageId: patch.stageId,
|
||||
sourceEventId: input.eventId,
|
||||
serviceId: input.serviceId,
|
||||
externalId: input.externalId,
|
||||
type: patch.type,
|
||||
title: patch.title,
|
||||
status: "ready",
|
||||
summary: patch.summary,
|
||||
contentMd: patch.contentMd,
|
||||
metadata: patch.metadata,
|
||||
createdAt: new Date(artifact.createdAt),
|
||||
updatedAt: new Date(artifact.updatedAt),
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export const userEventActor = actor({
|
||||
options: { name: "User Event Parser", icon: "route", noSleep: true, actionTimeout: 300_000 },
|
||||
state: { userId: "", processedCount: 0, processedEventIds: [] } as UserEventActorState,
|
||||
events: {
|
||||
updated: event<UserEventActorState>(),
|
||||
eventProcessed: event<{ eventId: string; userId: string }>(),
|
||||
},
|
||||
queues: {
|
||||
events: queue<UserEventCommand>(),
|
||||
},
|
||||
actions: {
|
||||
enqueueEvent: async (c, input: UserEventCommand) => {
|
||||
if (c.state.userId && c.state.userId !== input.userId) throw new Error("userEventActor initialized for a different user");
|
||||
c.state.userId = input.userId;
|
||||
c.state.updatedAt = new Date().toISOString();
|
||||
await c.queue.send("events", input);
|
||||
c.broadcast("updated", c.state);
|
||||
return { queued: true };
|
||||
},
|
||||
getState: (c) => c.state,
|
||||
},
|
||||
run: workflow(async (ctx) => {
|
||||
await ctx.loop("user-event-loop", async (loopCtx) => {
|
||||
const message = await loopCtx.queue.next("wait-event", { names: ["events"] });
|
||||
const cmd = message.body as UserEventCommand;
|
||||
|
||||
await loopCtx.step(`process-event:${cmd.eventId}`, async () => {
|
||||
if (loopCtx.state.processedEventIds.includes(cmd.eventId)) return;
|
||||
await markGrowEventProcessing(cmd.eventId);
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
.where(and(eq(growEvents.id, cmd.eventId), eq(growEvents.userId, cmd.userId)))
|
||||
.limit(1);
|
||||
if (!row) throw new Error(`grow event not found for user: ${cmd.eventId}`);
|
||||
|
||||
try {
|
||||
await applyServiceSessionProjection(row);
|
||||
const qscoreResult = await applyQscoreProjection(row);
|
||||
const activeRows = await listActiveMissionsPg(cmd.userId);
|
||||
const insight = await getProjectionInsight({
|
||||
event: row,
|
||||
qscoreSignals: qscoreResult.signals,
|
||||
activeMissionIds: activeRows.map((item) => item.mission.missionId),
|
||||
});
|
||||
|
||||
const client = loopCtx.client<any>();
|
||||
for (const active of activeRows) {
|
||||
const mission = active.mission;
|
||||
const reducers = reducersForMission(mission.missionId);
|
||||
if (!reducers.length) continue;
|
||||
for (const reducer of reducers) {
|
||||
const reduceCtx = { userId: cmd.userId, activeMission: mission, event: row, qscoreSignals: qscoreResult.signals, insight };
|
||||
if (!reducer.accepts(reduceCtx)) continue;
|
||||
const reduction = reducer.reduce(reduceCtx);
|
||||
if (!reduction.stagePatches.length && !reduction.artifacts.length && !reduction.eventMessage) continue;
|
||||
|
||||
const actorHandle = missionActorHandle(client, cmd.userId, mission);
|
||||
if (!actorHandle) continue;
|
||||
if (reduction.eventMessage) {
|
||||
await actorHandle.recordEvent({ type: row.type, message: reduction.eventMessage, payload: { sourceEventId: row.id } });
|
||||
}
|
||||
let snapshot: MissionSnapshot | undefined = reduction.stagePatches.length ? (await applyStagePatches(actorHandle, reduction.stagePatches) ?? undefined) : undefined;
|
||||
await applyArtifactPatches({
|
||||
actorHandle,
|
||||
userId: cmd.userId,
|
||||
mission,
|
||||
eventId: row.id,
|
||||
serviceId: row.source,
|
||||
externalId: typeof row.correlation?.sessionId === "string" ? row.correlation.sessionId : undefined,
|
||||
patches: reduction.artifacts,
|
||||
});
|
||||
const finalSnapshot = (await actorHandle.getState()) as MissionSnapshot;
|
||||
await upsertActiveMissionPg(cmd.userId, summarizeMissionSnapshot(finalSnapshot), finalSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
await markGrowEventProcessed(cmd.eventId);
|
||||
loopCtx.state.processedCount += 1;
|
||||
loopCtx.state.lastEventId = cmd.eventId;
|
||||
loopCtx.state.lastError = undefined;
|
||||
loopCtx.state.updatedAt = new Date().toISOString();
|
||||
loopCtx.state.processedEventIds = [cmd.eventId, ...loopCtx.state.processedEventIds.filter((id) => id !== cmd.eventId)].slice(0, 500);
|
||||
loopCtx.broadcast("eventProcessed", { eventId: cmd.eventId, userId: cmd.userId });
|
||||
loopCtx.broadcast("updated", loopCtx.state);
|
||||
} catch (err) {
|
||||
await markGrowEventFailed(cmd.eventId, err);
|
||||
loopCtx.state.lastError = err instanceof Error ? err.message : String(err);
|
||||
loopCtx.state.updatedAt = new Date().toISOString();
|
||||
loopCtx.broadcast("updated", loopCtx.state);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
});
|
||||
}),
|
||||
});
|
||||
193
src/actors/grow/grow-actor.ts
Normal file
193
src/actors/grow/grow-actor.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { actor, event } from "rivetkit";
|
||||
import type { CreateConversationInput, GrowActorState, GrowConversation, SetupGrowInput } from "./types.js";
|
||||
import type { GrowActiveMission } from "../missions/types.js";
|
||||
|
||||
const buildId = (prefix: string) =>
|
||||
`${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const now = () => Date.now();
|
||||
|
||||
function defaultTitle(index: number) {
|
||||
return index === 0 ? "Talk to Me" : `Conversation ${index + 1}`;
|
||||
}
|
||||
|
||||
function ensureInitialized(state: GrowActorState) {
|
||||
if (!state.userId) throw new Error("Grow actor is not initialized");
|
||||
}
|
||||
|
||||
function normalizeState(state: GrowActorState) {
|
||||
state.conversations ??= [];
|
||||
state.activeConversationId ??= null;
|
||||
state.activeMissions ??= [];
|
||||
state.createdAt ??= now();
|
||||
state.updatedAt ??= now();
|
||||
}
|
||||
|
||||
function createConversationRecord(state: GrowActorState, input: CreateConversationInput = {}): GrowConversation {
|
||||
const timestamp = now();
|
||||
return {
|
||||
id: buildId("conversation"),
|
||||
title: input.title?.trim() || defaultTitle(state.conversations.length),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export const growActor = actor({
|
||||
state: {
|
||||
userId: "",
|
||||
conversations: [],
|
||||
activeConversationId: null,
|
||||
activeMissions: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
} as GrowActorState,
|
||||
|
||||
events: {
|
||||
ready: event<{ userId: string; conversationId: string }>(),
|
||||
conversationCreated: event<GrowConversation>(),
|
||||
conversationReset: event<GrowConversation>(),
|
||||
missionActivated: event<GrowActiveMission>(),
|
||||
missionUpdated: event<GrowActiveMission>(),
|
||||
},
|
||||
|
||||
actions: {
|
||||
setup: async (c, input: SetupGrowInput) => {
|
||||
if (c.state.userId && c.state.userId !== input.userId) {
|
||||
throw new Error("Grow actor already bound to a different user");
|
||||
}
|
||||
|
||||
normalizeState(c.state);
|
||||
c.state.userId = input.userId;
|
||||
if (!c.state.conversations.length) {
|
||||
const conversation = createConversationRecord(c.state, { title: "Talk to Me" });
|
||||
c.state.conversations.push(conversation);
|
||||
c.state.activeConversationId = conversation.id;
|
||||
c.broadcast("conversationCreated", conversation);
|
||||
} else if (!c.state.activeConversationId) {
|
||||
c.state.activeConversationId = c.state.conversations[0]?.id ?? null;
|
||||
}
|
||||
|
||||
const activeConversationId = c.state.activeConversationId;
|
||||
if (!activeConversationId) throw new Error("Grow actor has no active conversation");
|
||||
|
||||
c.state.updatedAt = now();
|
||||
c.broadcast("ready", {
|
||||
userId: c.state.userId,
|
||||
conversationId: activeConversationId,
|
||||
});
|
||||
|
||||
return {
|
||||
userId: c.state.userId,
|
||||
activeConversationId: c.state.activeConversationId,
|
||||
conversations: c.state.conversations,
|
||||
activeMissions: c.state.activeMissions,
|
||||
};
|
||||
},
|
||||
|
||||
getState: async (c) => {
|
||||
normalizeState(c.state);
|
||||
ensureInitialized(c.state);
|
||||
return {
|
||||
userId: c.state.userId,
|
||||
activeConversationId: c.state.activeConversationId,
|
||||
conversations: c.state.conversations,
|
||||
activeMissions: c.state.activeMissions,
|
||||
};
|
||||
},
|
||||
|
||||
createConversation: async (c, input: CreateConversationInput = {}) => {
|
||||
normalizeState(c.state);
|
||||
ensureInitialized(c.state);
|
||||
const conversation = createConversationRecord(c.state, input);
|
||||
c.state.conversations.unshift(conversation);
|
||||
c.state.activeConversationId = conversation.id;
|
||||
c.state.updatedAt = conversation.updatedAt;
|
||||
c.broadcast("conversationCreated", conversation);
|
||||
return conversation;
|
||||
},
|
||||
|
||||
listConversations: async (c) => {
|
||||
normalizeState(c.state);
|
||||
ensureInitialized(c.state);
|
||||
return c.state.conversations;
|
||||
},
|
||||
|
||||
getConversation: async (c, input: { conversationId: string }) => {
|
||||
ensureInitialized(c.state);
|
||||
return c.state.conversations.find((conversation) => conversation.id === input.conversationId) ?? null;
|
||||
},
|
||||
|
||||
touchConversation: async (c, input: { conversationId: string; title?: string }) => {
|
||||
ensureInitialized(c.state);
|
||||
const conversation = c.state.conversations.find((item) => item.id === input.conversationId);
|
||||
if (!conversation) throw new Error(`Unknown conversation: ${input.conversationId}`);
|
||||
if (input.title?.trim()) conversation.title = input.title.trim();
|
||||
conversation.updatedAt = now();
|
||||
c.state.activeConversationId = conversation.id;
|
||||
c.state.updatedAt = conversation.updatedAt;
|
||||
return conversation;
|
||||
},
|
||||
|
||||
resetConversation: async (c, input: { conversationId?: string; title?: string } = {}) => {
|
||||
ensureInitialized(c.state);
|
||||
const conversationId = input.conversationId ?? c.state.activeConversationId;
|
||||
if (!conversationId) {
|
||||
const created = createConversationRecord(c.state, { title: input.title ?? "Talk to Me" });
|
||||
c.state.conversations.unshift(created);
|
||||
c.state.activeConversationId = created.id;
|
||||
c.state.updatedAt = created.updatedAt;
|
||||
c.broadcast("conversationCreated", created);
|
||||
return created;
|
||||
}
|
||||
|
||||
const conversation = c.state.conversations.find((item) => item.id === conversationId);
|
||||
if (!conversation) throw new Error(`Unknown conversation: ${conversationId}`);
|
||||
conversation.title = input.title?.trim() || conversation.title;
|
||||
conversation.updatedAt = now();
|
||||
c.state.activeConversationId = conversation.id;
|
||||
c.state.updatedAt = conversation.updatedAt;
|
||||
c.broadcast("conversationReset", conversation);
|
||||
return conversation;
|
||||
},
|
||||
|
||||
registerActiveMission: async (c, input: GrowActiveMission) => {
|
||||
normalizeState(c.state);
|
||||
ensureInitialized(c.state);
|
||||
const existingIndex = c.state.activeMissions.findIndex((mission) => mission.instanceId === input.instanceId);
|
||||
const record = { ...input, updatedAt: now() };
|
||||
if (existingIndex >= 0) {
|
||||
c.state.activeMissions[existingIndex] = record;
|
||||
c.broadcast("missionUpdated", record);
|
||||
} else {
|
||||
c.state.activeMissions.unshift(record);
|
||||
c.broadcast("missionActivated", record);
|
||||
}
|
||||
c.state.updatedAt = record.updatedAt;
|
||||
return record;
|
||||
},
|
||||
|
||||
updateActiveMission: async (c, input: Pick<GrowActiveMission, "instanceId"> & Partial<GrowActiveMission>) => {
|
||||
normalizeState(c.state);
|
||||
ensureInitialized(c.state);
|
||||
const mission = c.state.activeMissions.find((item) => item.instanceId === input.instanceId);
|
||||
if (!mission) throw new Error(`Unknown active mission: ${input.instanceId}`);
|
||||
Object.assign(mission, input, { updatedAt: now() });
|
||||
c.state.updatedAt = mission.updatedAt;
|
||||
c.broadcast("missionUpdated", mission);
|
||||
return mission;
|
||||
},
|
||||
|
||||
listActiveMissions: async (c) => {
|
||||
normalizeState(c.state);
|
||||
ensureInitialized(c.state);
|
||||
return c.state.activeMissions;
|
||||
},
|
||||
|
||||
getActiveMission: async (c, input: { instanceId: string }) => {
|
||||
normalizeState(c.state);
|
||||
ensureInitialized(c.state);
|
||||
return c.state.activeMissions.find((mission) => mission.instanceId === input.instanceId) ?? null;
|
||||
},
|
||||
},
|
||||
});
|
||||
2
src/actors/grow/index.ts
Normal file
2
src/actors/grow/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { growActor } from "./grow-actor.js";
|
||||
export type { GrowActorState, GrowConversation } from "./types.js";
|
||||
25
src/actors/grow/types.ts
Normal file
25
src/actors/grow/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { GrowActiveMission } from "../missions/types.js";
|
||||
|
||||
export type GrowConversation = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type GrowActorState = {
|
||||
userId: string;
|
||||
conversations: GrowConversation[];
|
||||
activeConversationId: string | null;
|
||||
activeMissions: GrowActiveMission[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type CreateConversationInput = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type SetupGrowInput = {
|
||||
userId: string;
|
||||
};
|
||||
64
src/actors/memory/README.md
Normal file
64
src/actors/memory/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Memory Actor Prototype
|
||||
|
||||
Small, isolated Rivet actor for per-user markdown memory stored in actor-local SQLite with Drizzle.
|
||||
|
||||
This folder is intentionally **not wired into `src/actors/registry.ts` yet**.
|
||||
|
||||
## Actor key
|
||||
|
||||
When wired later, use one memory actor per user:
|
||||
|
||||
```ts
|
||||
client.memoryActor.getOrCreate([userId])
|
||||
```
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Store markdown memory files.
|
||||
- Read a memory file by path.
|
||||
- List memory files by prefix.
|
||||
- Search memory with simple SQLite `LIKE` matching.
|
||||
- Queue writes/appends/deletes so mutations are serialized through the actor run loop.
|
||||
- Broadcast update events.
|
||||
|
||||
## Files
|
||||
|
||||
- `memory-actor.ts` — actor, actions, queues, events.
|
||||
- `schema.ts` — Drizzle SQLite tables.
|
||||
- `migrations.ts` — tiny inline actor-local SQLite migration.
|
||||
- `types.ts` — public types.
|
||||
|
||||
## Actions
|
||||
|
||||
```ts
|
||||
write({ path, contentMd, tags })
|
||||
append({ path, contentMd, tags })
|
||||
delete({ path })
|
||||
read(path)
|
||||
list(prefix)
|
||||
search(query, limit)
|
||||
getStats()
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
```ts
|
||||
updated: {
|
||||
type: "written" | "appended" | "deleted";
|
||||
path: string;
|
||||
memory?: MemoryFile;
|
||||
createdAt: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
This is intentionally lean:
|
||||
|
||||
- no embeddings yet
|
||||
- no global database
|
||||
- no Git/Gitea
|
||||
- no connection to the conversation actor yet
|
||||
- no registry changes yet
|
||||
|
||||
The conversation actor can later call this actor from its AI SDK memory tools.
|
||||
3
src/actors/memory/index.ts
Normal file
3
src/actors/memory/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { memoryActor } from "./memory-actor.js";
|
||||
export * from "./schema.js";
|
||||
export * from "./types.js";
|
||||
292
src/actors/memory/memory-actor.ts
Normal file
292
src/actors/memory/memory-actor.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { asc, desc, eq, like, or } from "drizzle-orm";
|
||||
import { actor, event, queue, UserError } from "rivetkit";
|
||||
import { db as drizzleDb } from "rivetkit/db/drizzle";
|
||||
import { migrateMemoryDb } from "./migrations.js";
|
||||
import { memoryEvents, memoryFiles, memorySchema } from "./schema.js";
|
||||
import type {
|
||||
MemoryAppendRequest,
|
||||
MemoryDeleteRequest,
|
||||
MemoryEvent,
|
||||
MemoryFile,
|
||||
MemorySearchResult,
|
||||
MemoryWriteRequest,
|
||||
} from "./types.js";
|
||||
|
||||
const MAX_PATH_LENGTH = 256;
|
||||
const MAX_CONTENT_LENGTH = 128_000;
|
||||
const MAX_SEARCH_RESULTS = 20;
|
||||
|
||||
const buildId = (prefix: string) =>
|
||||
`${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const now = () => Date.now();
|
||||
|
||||
function normalizePath(path: string) {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) throw new UserError("Memory path is required");
|
||||
if (trimmed.length > MAX_PATH_LENGTH) {
|
||||
throw new UserError("Memory path is too long", {
|
||||
code: "memory_path_too_long",
|
||||
metadata: { maxLength: MAX_PATH_LENGTH },
|
||||
});
|
||||
}
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
function normalizeContent(contentMd: string) {
|
||||
if (contentMd.length > MAX_CONTENT_LENGTH) {
|
||||
throw new UserError("Memory content is too large", {
|
||||
code: "memory_content_too_large",
|
||||
metadata: { maxLength: MAX_CONTENT_LENGTH },
|
||||
});
|
||||
}
|
||||
return contentMd;
|
||||
}
|
||||
|
||||
function normalizeTags(tags: string[] | undefined) {
|
||||
return [...new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function toMemoryFile(row: typeof memoryFiles.$inferSelect): MemoryFile {
|
||||
return {
|
||||
path: row.path,
|
||||
contentMd: row.contentMd,
|
||||
tags: row.tagsJson,
|
||||
createdAt: row.createdAt instanceof Date ? row.createdAt.getTime() : Number(row.createdAt),
|
||||
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.getTime() : Number(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function scoreMemory(memory: MemoryFile, query: string) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const pathScore = memory.path.toLowerCase().includes(lowerQuery) ? 5 : 0;
|
||||
const tagScore = memory.tags.some((tag) => tag.toLowerCase().includes(lowerQuery)) ? 3 : 0;
|
||||
const contentScore = memory.contentMd.toLowerCase().includes(lowerQuery) ? 1 : 0;
|
||||
return pathScore + tagScore + contentScore;
|
||||
}
|
||||
|
||||
export const memoryActor = actor({
|
||||
// Keep state tiny. Durable memory content lives in actor-local SQLite.
|
||||
state: {
|
||||
fileCount: 0,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
|
||||
db: drizzleDb({
|
||||
schema: memorySchema,
|
||||
}),
|
||||
|
||||
queues: {
|
||||
write: queue<MemoryWriteRequest>(),
|
||||
append: queue<MemoryAppendRequest>(),
|
||||
delete: queue<MemoryDeleteRequest>(),
|
||||
},
|
||||
|
||||
events: {
|
||||
updated: event<MemoryEvent>(),
|
||||
},
|
||||
|
||||
onCreate: async (c) => {
|
||||
await migrateMemoryDb(c.db);
|
||||
},
|
||||
|
||||
onWake: async (c) => {
|
||||
await migrateMemoryDb(c.db);
|
||||
},
|
||||
|
||||
run: async (c) => {
|
||||
for await (const queued of c.queue.iter()) {
|
||||
const name = queued.name;
|
||||
|
||||
if (name === "write") {
|
||||
await writeMemory(c, queued.body as MemoryWriteRequest, "written");
|
||||
} else if (name === "append") {
|
||||
await appendMemory(c, queued.body as MemoryAppendRequest);
|
||||
} else if (name === "delete") {
|
||||
await deleteMemory(c, queued.body as MemoryDeleteRequest);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
write: async (c, input: MemoryWriteRequest) => {
|
||||
await c.queue.send("write", input);
|
||||
return { queued: true };
|
||||
},
|
||||
|
||||
append: async (c, input: MemoryAppendRequest) => {
|
||||
await c.queue.send("append", input);
|
||||
return { queued: true };
|
||||
},
|
||||
|
||||
delete: async (c, input: MemoryDeleteRequest) => {
|
||||
await c.queue.send("delete", input);
|
||||
return { queued: true };
|
||||
},
|
||||
|
||||
read: async (c, path: string): Promise<MemoryFile | null> => {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const rows = await c.db
|
||||
.select()
|
||||
.from(memoryFiles)
|
||||
.where(eq(memoryFiles.path, normalizedPath))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ? toMemoryFile(rows[0]) : null;
|
||||
},
|
||||
|
||||
list: async (c, prefix = "/"): Promise<MemoryFile[]> => {
|
||||
const normalizedPrefix = normalizePath(prefix);
|
||||
const rows = await c.db
|
||||
.select()
|
||||
.from(memoryFiles)
|
||||
.where(like(memoryFiles.path, `${normalizedPrefix}%`))
|
||||
.orderBy(asc(memoryFiles.path));
|
||||
|
||||
return rows.map(toMemoryFile);
|
||||
},
|
||||
|
||||
search: async (c, query: string, limit = MAX_SEARCH_RESULTS): Promise<MemorySearchResult[]> => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const safeLimit = Math.max(1, Math.min(limit, MAX_SEARCH_RESULTS));
|
||||
const pattern = `%${trimmed}%`;
|
||||
const rows = await c.db
|
||||
.select()
|
||||
.from(memoryFiles)
|
||||
.where(
|
||||
or(
|
||||
like(memoryFiles.path, pattern),
|
||||
like(memoryFiles.contentMd, pattern),
|
||||
like(memoryFiles.tagsJson, pattern),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(memoryFiles.updatedAt))
|
||||
.limit(safeLimit);
|
||||
|
||||
return rows
|
||||
.map(toMemoryFile)
|
||||
.map((memory) => ({
|
||||
path: memory.path,
|
||||
contentMd: memory.contentMd,
|
||||
tags: memory.tags,
|
||||
score: scoreMemory(memory, trimmed),
|
||||
updatedAt: memory.updatedAt,
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score || b.updatedAt - a.updatedAt);
|
||||
},
|
||||
|
||||
getStats: (c) => ({
|
||||
fileCount: c.state.fileCount,
|
||||
updatedAt: c.state.updatedAt,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
async function writeMemory(
|
||||
c: any,
|
||||
input: MemoryWriteRequest,
|
||||
eventType: "written" | "appended",
|
||||
): Promise<MemoryFile> {
|
||||
const path = normalizePath(input.path);
|
||||
const contentMd = normalizeContent(input.contentMd);
|
||||
const tags = normalizeTags(input.tags);
|
||||
const timestamp = now();
|
||||
|
||||
const existing = await c.db
|
||||
.select()
|
||||
.from(memoryFiles)
|
||||
.where(eq(memoryFiles.path, path))
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
await c.db
|
||||
.update(memoryFiles)
|
||||
.set({ contentMd, tagsJson: tags, updatedAt: new Date(timestamp) })
|
||||
.where(eq(memoryFiles.path, path));
|
||||
} else {
|
||||
await c.db.insert(memoryFiles).values({
|
||||
path,
|
||||
contentMd,
|
||||
tagsJson: tags,
|
||||
createdAt: new Date(timestamp),
|
||||
updatedAt: new Date(timestamp),
|
||||
});
|
||||
c.state.fileCount += 1;
|
||||
}
|
||||
|
||||
c.state.updatedAt = timestamp;
|
||||
|
||||
const memory: MemoryFile = {
|
||||
path,
|
||||
contentMd,
|
||||
tags,
|
||||
createdAt: existing[0]
|
||||
? toMemoryFile(existing[0]).createdAt
|
||||
: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
await recordAndBroadcast(c, eventType, path, memory);
|
||||
return memory;
|
||||
}
|
||||
|
||||
async function appendMemory(
|
||||
c: any,
|
||||
input: MemoryAppendRequest,
|
||||
) {
|
||||
const path = normalizePath(input.path);
|
||||
const existing = await c.db
|
||||
.select()
|
||||
.from(memoryFiles)
|
||||
.where(eq(memoryFiles.path, path))
|
||||
.limit(1);
|
||||
|
||||
const previous = existing[0] ? toMemoryFile(existing[0]) : null;
|
||||
const nextContent = previous
|
||||
? `${previous.contentMd}\n${input.contentMd}`
|
||||
: input.contentMd;
|
||||
const tags = normalizeTags([...(previous?.tags ?? []), ...(input.tags ?? [])]);
|
||||
|
||||
return writeMemory(c, { path, contentMd: nextContent, tags }, "appended");
|
||||
}
|
||||
|
||||
async function deleteMemory(
|
||||
c: any,
|
||||
input: MemoryDeleteRequest,
|
||||
) {
|
||||
const path = normalizePath(input.path);
|
||||
const existing = await c.db
|
||||
.select()
|
||||
.from(memoryFiles)
|
||||
.where(eq(memoryFiles.path, path))
|
||||
.limit(1);
|
||||
|
||||
if (!existing[0]) return;
|
||||
|
||||
await c.db.delete(memoryFiles).where(eq(memoryFiles.path, path));
|
||||
c.state.fileCount = Math.max(0, c.state.fileCount - 1);
|
||||
c.state.updatedAt = now();
|
||||
await recordAndBroadcast(c, "deleted", path);
|
||||
}
|
||||
|
||||
async function recordAndBroadcast(
|
||||
c: any,
|
||||
type: "written" | "appended" | "deleted",
|
||||
path: string,
|
||||
memory?: MemoryFile,
|
||||
) {
|
||||
const createdAt = now();
|
||||
const evt: MemoryEvent = { type, path, memory, createdAt };
|
||||
|
||||
await c.db.insert(memoryEvents).values({
|
||||
id: buildId("memory-event"),
|
||||
type,
|
||||
path,
|
||||
payloadJson: memory ? { memory } : undefined,
|
||||
createdAt: new Date(createdAt),
|
||||
});
|
||||
|
||||
c.broadcast("updated", evt);
|
||||
}
|
||||
28
src/actors/memory/migrations.ts
Normal file
28
src/actors/memory/migrations.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { RawAccess } from "rivetkit/db";
|
||||
|
||||
// Small inline migration for this isolated actor-local SQLite database.
|
||||
export async function migrateMemoryDb(db: RawAccess) {
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS memory_files (
|
||||
path TEXT PRIMARY KEY NOT NULL,
|
||||
content_md TEXT NOT NULL,
|
||||
tags_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS memory_files_updated_at_idx
|
||||
ON memory_files (updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memory_events (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('written', 'appended', 'deleted')),
|
||||
path TEXT NOT NULL,
|
||||
payload_json TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS memory_events_path_created_at_idx
|
||||
ON memory_events (path, created_at);
|
||||
`);
|
||||
}
|
||||
52
src/actors/memory/schema.ts
Normal file
52
src/actors/memory/schema.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
sqliteTable,
|
||||
text,
|
||||
} from "rivetkit/db/drizzle";
|
||||
|
||||
export const memoryFiles = sqliteTable(
|
||||
"memory_files",
|
||||
{
|
||||
path: text("path").primaryKey(),
|
||||
contentMd: text("content_md").notNull(),
|
||||
tagsJson: text("tags_json", { mode: "json" }).$type<string[]>().notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
updatedAtIdx: index("memory_files_updated_at_idx").on(table.updatedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const memoryEvents = sqliteTable(
|
||||
"memory_events",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
type: text("type", { enum: ["written", "appended", "deleted"] }).notNull(),
|
||||
path: text("path").notNull(),
|
||||
payloadJson: text("payload_json", { mode: "json" }).$type<Record<string, unknown>>(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
pathCreatedAtIdx: index("memory_events_path_created_at_idx").on(
|
||||
table.path,
|
||||
table.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const memoryFilesRelations = relations(memoryFiles, ({ many }) => ({
|
||||
events: many(memoryEvents),
|
||||
}));
|
||||
|
||||
export const memorySchema = {
|
||||
memoryFiles,
|
||||
memoryEvents,
|
||||
};
|
||||
|
||||
export type MemoryFileRow = typeof memoryFiles.$inferSelect;
|
||||
export type NewMemoryFileRow = typeof memoryFiles.$inferInsert;
|
||||
export type MemoryEventRow = typeof memoryEvents.$inferSelect;
|
||||
export type NewMemoryEventRow = typeof memoryEvents.$inferInsert;
|
||||
38
src/actors/memory/types.ts
Normal file
38
src/actors/memory/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type MemoryFile = {
|
||||
path: string;
|
||||
contentMd: string;
|
||||
tags: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type MemoryWriteRequest = {
|
||||
path: string;
|
||||
contentMd: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type MemoryAppendRequest = {
|
||||
path: string;
|
||||
contentMd: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type MemoryDeleteRequest = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type MemoryEvent = {
|
||||
type: "written" | "appended" | "deleted";
|
||||
path: string;
|
||||
memory?: MemoryFile;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type MemorySearchResult = {
|
||||
path: string;
|
||||
contentMd: string;
|
||||
tags: string[];
|
||||
score: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
7
src/actors/missions/career-transition-actor.ts
Normal file
7
src/actors/missions/career-transition-actor.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createMissionActor } from "./mission-actor-factory.js";
|
||||
|
||||
export const careerTransitionMissionActor = createMissionActor({
|
||||
missionId: "career-transition",
|
||||
name: "Career Transition Mission",
|
||||
icon: "route",
|
||||
});
|
||||
7
src/actors/missions/career-transition/SKILL.md
Normal file
7
src/actors/missions/career-transition/SKILL.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Career Transition Sprint Mission
|
||||
|
||||
Durable state holder for the `career-transition` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events.
|
||||
|
||||
## Stages
|
||||
- Transition map
|
||||
- Resume fit scan
|
||||
6
src/actors/missions/index.ts
Normal file
6
src/actors/missions/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { interviewToOfferMissionActor } from "./interview-to-offer-actor.js";
|
||||
export { careerTransitionMissionActor } from "./career-transition-actor.js";
|
||||
export { salaryNegotiationWarRoomMissionActor } from "./salary-negotiation-war-room-actor.js";
|
||||
export { promotionReadinessMissionActor } from "./promotion-readiness-actor.js";
|
||||
export { personalBrandOpportunityEngineMissionActor } from "./personal-brand-opportunity-engine-actor.js";
|
||||
export type * from "./types.js";
|
||||
242
src/actors/missions/interview-to-offer-actor.ts
Normal file
242
src/actors/missions/interview-to-offer-actor.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { actor, event } from "rivetkit";
|
||||
import { getMissionDefinition } from "../../missions/registry.js";
|
||||
import type {
|
||||
MissionArtifact,
|
||||
MissionEvent,
|
||||
MissionSnapshot,
|
||||
MissionStage,
|
||||
MissionStartInput,
|
||||
} from "./types.js";
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
const eventId = () => `mission-event-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const artifactId = () => `artifact-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
function buildInitialStages(): MissionStage[] {
|
||||
const def = getMissionDefinition("interview-to-offer");
|
||||
if (!def) throw new Error("interview-to-offer mission definition is missing");
|
||||
|
||||
return def.modules.map((module, index) => ({
|
||||
id: module.id,
|
||||
title: module.title,
|
||||
role: module.role,
|
||||
description: module.description,
|
||||
status: index === 0 ? "ready" : "locked",
|
||||
progressPercent: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function createStartedEvent(goal?: string): MissionEvent {
|
||||
return {
|
||||
id: eventId(),
|
||||
type: "mission.started",
|
||||
message: goal
|
||||
? `Interview-to-Offer mission started for: ${goal}`
|
||||
: "Interview-to-Offer mission started.",
|
||||
payload: goal ? { goal } : {},
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureInitialized(state: MissionSnapshot) {
|
||||
if (!state.userId || !state.instanceId) {
|
||||
throw new Error("Mission actor is not initialized");
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(state: MissionSnapshot) {
|
||||
return {
|
||||
instanceId: state.instanceId,
|
||||
missionId: state.missionId,
|
||||
workflowId: state.workflowId,
|
||||
title: state.title,
|
||||
shortTitle: state.shortTitle,
|
||||
status: state.status,
|
||||
progressPercent: state.progressPercent,
|
||||
currentStageId: state.currentStageId,
|
||||
goal: state.goal,
|
||||
updatedAt: state.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export const interviewToOfferMissionActor = actor({
|
||||
options: {
|
||||
name: "Interview-to-Offer Mission",
|
||||
icon: "briefcase-business",
|
||||
noSleep: true,
|
||||
},
|
||||
|
||||
state: {
|
||||
instanceId: "",
|
||||
missionId: "interview-to-offer",
|
||||
workflowId: "interview-to-offer",
|
||||
userId: "",
|
||||
title: "Interview-to-Offer Accelerator",
|
||||
shortTitle: "Interview to Offer",
|
||||
promise: "Prepare for this specific interview and convert it into an offer.",
|
||||
status: "draft",
|
||||
input: {},
|
||||
progressPercent: 0,
|
||||
stages: [],
|
||||
artifacts: [],
|
||||
events: [],
|
||||
skillVersion: "1.0.0",
|
||||
workflowVersion: "1.0.0",
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
} as MissionSnapshot,
|
||||
|
||||
events: {
|
||||
updated: event<MissionSnapshot>(),
|
||||
eventAdded: event<MissionEvent>(),
|
||||
artifactAdded: event<MissionArtifact>(),
|
||||
},
|
||||
|
||||
actions: {
|
||||
init: (c, input: MissionStartInput) => {
|
||||
if (input.missionId !== "interview-to-offer") {
|
||||
throw new Error(`Unsupported mission for interview actor: ${input.missionId}`);
|
||||
}
|
||||
|
||||
if (c.state.userId && (c.state.userId !== input.userId || c.state.instanceId !== input.instanceId)) {
|
||||
throw new Error("Mission actor already initialized for a different user or instance");
|
||||
}
|
||||
|
||||
const def = getMissionDefinition("interview-to-offer");
|
||||
if (!def) throw new Error("interview-to-offer mission definition is missing");
|
||||
|
||||
const timestamp = nowIso();
|
||||
const firstEvent = createStartedEvent(input.goal);
|
||||
Object.assign(c.state, {
|
||||
instanceId: input.instanceId,
|
||||
missionId: "interview-to-offer",
|
||||
workflowId: def.id,
|
||||
userId: input.userId,
|
||||
title: def.title,
|
||||
shortTitle: def.shortTitle,
|
||||
promise: def.promise,
|
||||
status: "active",
|
||||
goal: input.goal,
|
||||
input: input.input ?? {},
|
||||
progressPercent: 0,
|
||||
currentStageId: def.modules[0]?.id,
|
||||
stages: buildInitialStages(),
|
||||
artifacts: [],
|
||||
events: [firstEvent],
|
||||
skillVersion: def.skillVersion,
|
||||
workflowVersion: def.version,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
|
||||
c.broadcast("eventAdded", firstEvent);
|
||||
c.broadcast("updated", c.state);
|
||||
return c.state;
|
||||
},
|
||||
|
||||
getState: (c) => {
|
||||
ensureInitialized(c.state);
|
||||
return c.state;
|
||||
},
|
||||
|
||||
getSummary: (c) => {
|
||||
ensureInitialized(c.state);
|
||||
return summarize(c.state);
|
||||
},
|
||||
|
||||
recordEvent: (c, input: { type: string; message: string; payload?: Record<string, unknown> }) => {
|
||||
ensureInitialized(c.state);
|
||||
const entry: MissionEvent = {
|
||||
id: eventId(),
|
||||
type: input.type,
|
||||
message: input.message,
|
||||
payload: input.payload ?? {},
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
c.state.events.unshift(entry);
|
||||
c.state.updatedAt = entry.createdAt;
|
||||
c.broadcast("eventAdded", entry);
|
||||
c.broadcast("updated", c.state);
|
||||
return entry;
|
||||
},
|
||||
|
||||
updateStage: (c, input: { stageId: string; status?: MissionStage["status"]; progressPercent?: number; outputSummary?: string }) => {
|
||||
ensureInitialized(c.state);
|
||||
const stage = c.state.stages.find((item) => item.id === input.stageId);
|
||||
if (!stage) throw new Error(`Unknown stage: ${input.stageId}`);
|
||||
|
||||
const timestamp = nowIso();
|
||||
const previousStatus = stage.status;
|
||||
if (input.status) stage.status = input.status;
|
||||
if (typeof input.progressPercent === "number") {
|
||||
stage.progressPercent = Math.max(0, Math.min(100, Math.round(input.progressPercent)));
|
||||
}
|
||||
if (input.outputSummary) stage.outputSummary = input.outputSummary;
|
||||
if (stage.status === "in_progress" && previousStatus !== "in_progress") stage.startedAt = timestamp;
|
||||
if (stage.status === "done") {
|
||||
stage.completedAt = timestamp;
|
||||
stage.progressPercent = 100;
|
||||
const next = c.state.stages[c.state.stages.findIndex((item) => item.id === stage.id) + 1];
|
||||
if (next && next.status === "locked") next.status = "ready";
|
||||
}
|
||||
|
||||
c.state.currentStageId = c.state.stages.find((item) => ["ready", "in_progress", "blocked"].includes(item.status))?.id;
|
||||
c.state.progressPercent = Math.round(
|
||||
c.state.stages.reduce((sum, item) => sum + item.progressPercent, 0) / Math.max(1, c.state.stages.length),
|
||||
);
|
||||
c.state.updatedAt = timestamp;
|
||||
c.broadcast("updated", c.state);
|
||||
return c.state;
|
||||
},
|
||||
|
||||
addArtifact: (c, input: Omit<MissionArtifact, "id" | "createdAt" | "updatedAt"> & { id?: string }) => {
|
||||
ensureInitialized(c.state);
|
||||
const timestamp = nowIso();
|
||||
const artifact: MissionArtifact = {
|
||||
id: input.id ?? artifactId(),
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
status: input.status,
|
||||
summary: input.summary,
|
||||
contentMd: input.contentMd,
|
||||
metadata: input.metadata,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
c.state.artifacts.unshift(artifact);
|
||||
c.state.updatedAt = timestamp;
|
||||
c.broadcast("artifactAdded", artifact);
|
||||
c.broadcast("updated", c.state);
|
||||
return artifact;
|
||||
},
|
||||
|
||||
pause: (c) => {
|
||||
ensureInitialized(c.state);
|
||||
c.state.status = "paused";
|
||||
c.state.updatedAt = nowIso();
|
||||
c.broadcast("updated", c.state);
|
||||
return c.state;
|
||||
},
|
||||
|
||||
resume: (c) => {
|
||||
ensureInitialized(c.state);
|
||||
c.state.status = "active";
|
||||
c.state.updatedAt = nowIso();
|
||||
c.broadcast("updated", c.state);
|
||||
return c.state;
|
||||
},
|
||||
|
||||
complete: (c, input: { qscoreAfter?: Record<string, unknown> } = {}) => {
|
||||
ensureInitialized(c.state);
|
||||
const timestamp = nowIso();
|
||||
c.state.status = "completed";
|
||||
c.state.progressPercent = 100;
|
||||
c.state.currentStageId = undefined;
|
||||
c.state.qscoreAfter = input.qscoreAfter;
|
||||
c.state.completedAt = timestamp;
|
||||
c.state.updatedAt = timestamp;
|
||||
c.broadcast("updated", c.state);
|
||||
return c.state;
|
||||
},
|
||||
},
|
||||
});
|
||||
41
src/actors/missions/interview-to-offer/SKILL.md
Normal file
41
src/actors/missions/interview-to-offer/SKILL.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: interview-to-offer
|
||||
displayName: Interview-to-Offer Accelerator
|
||||
version: 1.0.0
|
||||
actor: interviewToOfferMissionActor
|
||||
---
|
||||
|
||||
# Interview-to-Offer Accelerator
|
||||
|
||||
## Promise
|
||||
Prepare the user for a specific interview and help convert it into an offer.
|
||||
|
||||
## Mission Contract
|
||||
This mission is not an autonomous agent. The conversation layer (Grow) owns the coaching interaction. This actor owns only durable mission state: stages, progress, artifacts, and event history.
|
||||
|
||||
## Inputs
|
||||
- Target company and role
|
||||
- Interview date or urgency window
|
||||
- Resume/profile context
|
||||
- Biggest concern or blocker
|
||||
|
||||
## Stages
|
||||
1. Resume fit scan
|
||||
2. Interview prep plan
|
||||
3. Mock interview session
|
||||
4. Communication roleplay
|
||||
5. Final readiness Q Score
|
||||
|
||||
## Artifacts
|
||||
- Interview prep plan
|
||||
- Likely questions
|
||||
- Behavioral/story bank
|
||||
- Resume-based talking points
|
||||
- Weakness diagnosis
|
||||
- Final readiness score
|
||||
|
||||
## Conversation Handoff Rules
|
||||
- Grow can read the mission state before answering next-step questions.
|
||||
- Grow can update stages only when a user action, specialist output, or artifact justifies it.
|
||||
- Grow should ask for approval before marking a major artifact approved.
|
||||
- Grow should keep the tone warm and practical.
|
||||
249
src/actors/missions/mission-actor-factory.ts
Normal file
249
src/actors/missions/mission-actor-factory.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { actor, event } from "rivetkit";
|
||||
import { getMissionDefinition } from "../../missions/registry.js";
|
||||
import type {
|
||||
MissionArtifact,
|
||||
MissionEvent,
|
||||
MissionId,
|
||||
MissionSnapshot,
|
||||
MissionStage,
|
||||
MissionStartInput,
|
||||
} from "./types.js";
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
const eventId = () => `mission-event-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const artifactId = () => `artifact-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
function buildInitialStages(missionId: MissionId): MissionStage[] {
|
||||
const def = getMissionDefinition(missionId);
|
||||
if (!def) throw new Error(`${missionId} mission definition is missing`);
|
||||
|
||||
return def.modules.map((module, index) => ({
|
||||
id: module.id,
|
||||
title: module.title,
|
||||
role: module.role,
|
||||
description: module.description,
|
||||
status: index === 0 ? "ready" : "locked",
|
||||
progressPercent: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function createStartedEvent(title: string, goal?: string): MissionEvent {
|
||||
return {
|
||||
id: eventId(),
|
||||
type: "mission.started",
|
||||
message: goal ? `${title} mission started for: ${goal}` : `${title} mission started.`,
|
||||
payload: goal ? { goal } : {},
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureInitialized(state: MissionSnapshot) {
|
||||
if (!state.userId || !state.instanceId) {
|
||||
throw new Error("Mission actor is not initialized");
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(state: MissionSnapshot) {
|
||||
return {
|
||||
instanceId: state.instanceId,
|
||||
missionId: state.missionId,
|
||||
workflowId: state.workflowId,
|
||||
title: state.title,
|
||||
shortTitle: state.shortTitle,
|
||||
status: state.status,
|
||||
progressPercent: state.progressPercent,
|
||||
currentStageId: state.currentStageId,
|
||||
goal: state.goal,
|
||||
updatedAt: state.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMissionActor(options: {
|
||||
missionId: MissionId;
|
||||
name: string;
|
||||
icon: string;
|
||||
}) {
|
||||
const def = getMissionDefinition(options.missionId);
|
||||
|
||||
return actor({
|
||||
options: {
|
||||
name: options.name,
|
||||
icon: options.icon,
|
||||
noSleep: true,
|
||||
},
|
||||
|
||||
state: {
|
||||
instanceId: "",
|
||||
missionId: options.missionId,
|
||||
workflowId: options.missionId,
|
||||
userId: "",
|
||||
title: def?.title ?? options.name,
|
||||
shortTitle: def?.shortTitle ?? options.name,
|
||||
promise: def?.promise ?? "",
|
||||
status: "draft",
|
||||
input: {},
|
||||
progressPercent: 0,
|
||||
stages: [],
|
||||
artifacts: [],
|
||||
events: [],
|
||||
skillVersion: def?.skillVersion ?? "1.0.0",
|
||||
workflowVersion: def?.version ?? "1.0.0",
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
} as MissionSnapshot,
|
||||
|
||||
events: {
|
||||
updated: event<MissionSnapshot>(),
|
||||
eventAdded: event<MissionEvent>(),
|
||||
artifactAdded: event<MissionArtifact>(),
|
||||
},
|
||||
|
||||
actions: {
|
||||
init: (c, input: MissionStartInput) => {
|
||||
if (input.missionId !== options.missionId) {
|
||||
throw new Error(`Unsupported mission for ${options.missionId} actor: ${input.missionId}`);
|
||||
}
|
||||
|
||||
if (c.state.userId && (c.state.userId !== input.userId || c.state.instanceId !== input.instanceId)) {
|
||||
throw new Error("Mission actor already initialized for a different user or instance");
|
||||
}
|
||||
|
||||
const missionDef = getMissionDefinition(options.missionId);
|
||||
if (!missionDef) throw new Error(`${options.missionId} mission definition is missing`);
|
||||
|
||||
const timestamp = nowIso();
|
||||
const firstEvent = createStartedEvent(missionDef.shortTitle, input.goal);
|
||||
Object.assign(c.state, {
|
||||
instanceId: input.instanceId,
|
||||
missionId: options.missionId,
|
||||
workflowId: missionDef.id,
|
||||
userId: input.userId,
|
||||
title: missionDef.title,
|
||||
shortTitle: missionDef.shortTitle,
|
||||
promise: missionDef.promise,
|
||||
status: "active",
|
||||
goal: input.goal,
|
||||
input: input.input ?? {},
|
||||
progressPercent: 0,
|
||||
currentStageId: missionDef.modules[0]?.id,
|
||||
stages: buildInitialStages(options.missionId),
|
||||
artifacts: [],
|
||||
events: [firstEvent],
|
||||
skillVersion: missionDef.skillVersion,
|
||||
workflowVersion: missionDef.version,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
|
||||
c.broadcast("eventAdded", firstEvent);
|
||||
c.broadcast("updated", c.state);
|
||||
return c.state;
|
||||
},
|
||||
|
||||
getState: (c) => {
|
||||
ensureInitialized(c.state);
|
||||
return c.state;
|
||||
},
|
||||
|
||||
getSummary: (c) => {
|
||||
ensureInitialized(c.state);
|
||||
return summarize(c.state);
|
||||
},
|
||||
|
||||
recordEvent: (c, input: { type: string; message: string; payload?: Record<string, unknown> }) => {
|
||||
ensureInitialized(c.state);
|
||||
const entry: MissionEvent = {
|
||||
id: eventId(),
|
||||
type: input.type,
|
||||
message: input.message,
|
||||
payload: input.payload ?? {},
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
c.state.events.unshift(entry);
|
||||
c.state.updatedAt = entry.createdAt;
|
||||
c.broadcast("eventAdded", entry);
|
||||
c.broadcast("updated", c.state);
|
||||
return entry;
|
||||
},
|
||||
|
||||
updateStage: (c, input: { stageId: string; status?: MissionStage["status"]; progressPercent?: number; outputSummary?: string }) => {
|
||||
ensureInitialized(c.state);
|
||||
const stage = c.state.stages.find((item) => item.id === input.stageId);
|
||||
if (!stage) throw new Error(`Unknown stage: ${input.stageId}`);
|
||||
|
||||
const timestamp = nowIso();
|
||||
const previousStatus = stage.status;
|
||||
if (input.status) stage.status = input.status;
|
||||
if (typeof input.progressPercent === "number") {
|
||||
stage.progressPercent = Math.max(0, Math.min(100, Math.round(input.progressPercent)));
|
||||
}
|
||||
if (input.outputSummary) stage.outputSummary = input.outputSummary;
|
||||
if (stage.status === "in_progress" && previousStatus !== "in_progress") stage.startedAt = timestamp;
|
||||
if (stage.status === "done") {
|
||||
stage.completedAt = timestamp;
|
||||
stage.progressPercent = 100;
|
||||
const next = c.state.stages[c.state.stages.findIndex((item) => item.id === stage.id) + 1];
|
||||
if (next && next.status === "locked") next.status = "ready";
|
||||
}
|
||||
|
||||
c.state.currentStageId = c.state.stages.find((item) => ["ready", "in_progress", "blocked"].includes(item.status))?.id;
|
||||
c.state.progressPercent = Math.round(
|
||||
c.state.stages.reduce((sum, item) => sum + item.progressPercent, 0) / Math.max(1, c.state.stages.length),
|
||||
);
|
||||
c.state.updatedAt = timestamp;
|
||||
c.broadcast("updated", c.state);
|
||||
return c.state;
|
||||
},
|
||||
|
||||
addArtifact: (c, input: Omit<MissionArtifact, "id" | "createdAt" | "updatedAt"> & { id?: string }) => {
|
||||
ensureInitialized(c.state);
|
||||
const timestamp = nowIso();
|
||||
const artifact: MissionArtifact = {
|
||||
id: input.id ?? artifactId(),
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
status: input.status,
|
||||
summary: input.summary,
|
||||
contentMd: input.contentMd,
|
||||
metadata: input.metadata,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
c.state.artifacts.unshift(artifact);
|
||||
c.state.updatedAt = timestamp;
|
||||
c.broadcast("artifactAdded", artifact);
|
||||
c.broadcast("updated", c.state);
|
||||
return artifact;
|
||||
},
|
||||
|
||||
pause: (c) => {
|
||||
ensureInitialized(c.state);
|
||||
c.state.status = "paused";
|
||||
c.state.updatedAt = nowIso();
|
||||
c.broadcast("updated", c.state);
|
||||
return c.state;
|
||||
},
|
||||
|
||||
resume: (c) => {
|
||||
ensureInitialized(c.state);
|
||||
c.state.status = "active";
|
||||
c.state.updatedAt = nowIso();
|
||||
c.broadcast("updated", c.state);
|
||||
return c.state;
|
||||
},
|
||||
|
||||
complete: (c, input: { qscoreAfter?: Record<string, unknown> } = {}) => {
|
||||
ensureInitialized(c.state);
|
||||
const timestamp = nowIso();
|
||||
c.state.status = "completed";
|
||||
c.state.progressPercent = 100;
|
||||
c.state.currentStageId = undefined;
|
||||
c.state.qscoreAfter = input.qscoreAfter;
|
||||
c.state.completedAt = timestamp;
|
||||
c.state.updatedAt = timestamp;
|
||||
c.broadcast("updated", c.state);
|
||||
return c.state;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createMissionActor } from "./mission-actor-factory.js";
|
||||
|
||||
export const personalBrandOpportunityEngineMissionActor = createMissionActor({
|
||||
missionId: "personal-brand-opportunity-engine",
|
||||
name: "Personal Brand Opportunity Engine Mission",
|
||||
icon: "sparkles",
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
# Personal Brand Opportunity Engine Mission
|
||||
|
||||
Durable state holder for the `personal-brand-opportunity-engine` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events.
|
||||
|
||||
## Stages
|
||||
- Profile rewrite
|
||||
7
src/actors/missions/promotion-readiness-actor.ts
Normal file
7
src/actors/missions/promotion-readiness-actor.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createMissionActor } from "./mission-actor-factory.js";
|
||||
|
||||
export const promotionReadinessMissionActor = createMissionActor({
|
||||
missionId: "promotion-readiness",
|
||||
name: "Promotion Readiness Mission",
|
||||
icon: "trending-up",
|
||||
});
|
||||
7
src/actors/missions/promotion-readiness/SKILL.md
Normal file
7
src/actors/missions/promotion-readiness/SKILL.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Promotion Readiness Packet Mission
|
||||
|
||||
Durable state holder for the `promotion-readiness` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events.
|
||||
|
||||
## Stages
|
||||
- Evidence packet
|
||||
- Manager conversation roleplay
|
||||
7
src/actors/missions/salary-negotiation-war-room-actor.ts
Normal file
7
src/actors/missions/salary-negotiation-war-room-actor.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createMissionActor } from "./mission-actor-factory.js";
|
||||
|
||||
export const salaryNegotiationWarRoomMissionActor = createMissionActor({
|
||||
missionId: "salary-negotiation-war-room",
|
||||
name: "Salary Negotiation War Room Mission",
|
||||
icon: "badge-dollar-sign",
|
||||
});
|
||||
7
src/actors/missions/salary-negotiation-war-room/SKILL.md
Normal file
7
src/actors/missions/salary-negotiation-war-room/SKILL.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Salary Negotiation War Room Mission
|
||||
|
||||
Durable state holder for the `salary-negotiation-war-room` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events.
|
||||
|
||||
## Stages
|
||||
- Negotiation script
|
||||
- Negotiation roleplay
|
||||
109
src/actors/missions/types.ts
Normal file
109
src/actors/missions/types.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { WorkflowDefinition } from "../../workflows/types.js";
|
||||
|
||||
export type MissionId =
|
||||
| "interview-to-offer"
|
||||
| "career-transition"
|
||||
| "salary-negotiation-war-room"
|
||||
| "promotion-readiness"
|
||||
| "personal-brand-opportunity-engine";
|
||||
|
||||
export type MissionInstanceStatus = "draft" | "active" | "paused" | "completed" | "cancelled";
|
||||
export type MissionStageStatus = "locked" | "ready" | "in_progress" | "blocked" | "done";
|
||||
export type MissionArtifactStatus = "draft" | "ready" | "approved" | "archived";
|
||||
|
||||
export type MissionStage = {
|
||||
id: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
status: MissionStageStatus;
|
||||
progressPercent: number;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
outputSummary?: string;
|
||||
};
|
||||
|
||||
export type MissionArtifact = {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
status: MissionArtifactStatus;
|
||||
summary?: string;
|
||||
contentMd?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MissionEvent = {
|
||||
id: string;
|
||||
type: string;
|
||||
message: string;
|
||||
payload?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type MissionSnapshot = {
|
||||
instanceId: string;
|
||||
missionId: MissionId;
|
||||
workflowId: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
shortTitle: string;
|
||||
promise: string;
|
||||
status: MissionInstanceStatus;
|
||||
goal?: string;
|
||||
input: Record<string, unknown>;
|
||||
progressPercent: number;
|
||||
currentStageId?: string;
|
||||
stages: MissionStage[];
|
||||
artifacts: MissionArtifact[];
|
||||
events: MissionEvent[];
|
||||
qscoreBefore?: Record<string, unknown>;
|
||||
qscoreAfter?: Record<string, unknown>;
|
||||
skillVersion: string;
|
||||
workflowVersion: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
};
|
||||
|
||||
export type MissionStartInput = {
|
||||
userId: string;
|
||||
instanceId: string;
|
||||
missionId: MissionId;
|
||||
goal?: string;
|
||||
input?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MissionActorType =
|
||||
| "interviewToOfferMissionActor"
|
||||
| "careerTransitionMissionActor"
|
||||
| "salaryNegotiationWarRoomMissionActor"
|
||||
| "promotionReadinessMissionActor"
|
||||
| "personalBrandOpportunityEngineMissionActor";
|
||||
|
||||
export type MissionRegistryEntry = WorkflowDefinition & {
|
||||
missionId: MissionId;
|
||||
kind: "mission";
|
||||
actorType?: MissionActorType;
|
||||
actorBacked: boolean;
|
||||
skillVersion: string;
|
||||
skillPath: string;
|
||||
displayOrder: number;
|
||||
};
|
||||
|
||||
export type GrowActiveMission = {
|
||||
instanceId: string;
|
||||
missionId: MissionId;
|
||||
workflowId: string;
|
||||
title: string;
|
||||
shortTitle: string;
|
||||
status: MissionInstanceStatus;
|
||||
progressPercent: number;
|
||||
currentStageId?: string;
|
||||
goal?: string;
|
||||
actorType?: MissionActorType;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
70
src/actors/product-service-actors.ts
Normal file
70
src/actors/product-service-actors.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { actor } from "rivetkit";
|
||||
import { interviewService, resumeService, roleplayService, type JsonObject } from "../services/product-service-clients.js";
|
||||
|
||||
export const interviewServiceActor = actor({
|
||||
state: { calls: 0, lastCallAt: null as string | null },
|
||||
actions: {
|
||||
health: async (c) => track(c, () => interviewService.health()),
|
||||
configure: async (c, payload: JsonObject) => track(c, () => interviewService.configure(payload)),
|
||||
preview: async (c, payload: JsonObject) => track(c, () => interviewService.preview(payload)),
|
||||
editQuestions: async (c, sessionId: string, questions: Array<JsonObject | string>) => track(c, () => interviewService.editQuestions({ session_id: sessionId, questions })),
|
||||
approve: async (c, sessionId: string) => track(c, () => interviewService.approve(sessionId)),
|
||||
createAssignments: async (c, payload: { organization_id: string; role: string; round: "warm_up" | "behavioral" | "technical"; assignee_emails: string[] }) => track(c, () => interviewService.createAssignments(payload)),
|
||||
listAssignments: async (c, email: string, status?: string, limit?: number) => track(c, () => interviewService.listAssignments(email, status, limit)),
|
||||
unassign: async (c, payload: { organization_id: string; emails: string[] }) => track(c, () => interviewService.unassign(payload)),
|
||||
resultsBulk: async (c, payload: JsonObject) => track(c, () => interviewService.resultsBulk(payload)),
|
||||
review: async (c, sessionId: string) => track(c, () => interviewService.review(sessionId)),
|
||||
leaderboard: async (c) => track(c, () => interviewService.leaderboard()),
|
||||
artifact: async (c, sessionId: string, artifactType: string) => track(c, () => interviewService.artifact(sessionId, artifactType)),
|
||||
createVideoUploadUrl: async (c, sessionId: string, payload: JsonObject) => track(c, () => interviewService.createVideoUploadUrl(sessionId, payload)),
|
||||
markVideoUploaded: async (c, sessionId: string, payload: JsonObject) => track(c, () => interviewService.markVideoUploaded(sessionId, payload)),
|
||||
},
|
||||
});
|
||||
|
||||
export const roleplayServiceActor = actor({
|
||||
state: { calls: 0, lastCallAt: null as string | null },
|
||||
actions: {
|
||||
health: async (c) => track(c, () => roleplayService.health()),
|
||||
configure: async (c, payload: JsonObject) => track(c, () => roleplayService.configure(payload)),
|
||||
preview: async (c, payload: JsonObject) => track(c, () => roleplayService.preview(payload)),
|
||||
editQuestions: async (c, sessionId: string, questions: Array<JsonObject | string>) => track(c, () => roleplayService.editQuestions({ session_id: sessionId, questions })),
|
||||
approve: async (c, sessionId: string) => track(c, () => roleplayService.approve(sessionId)),
|
||||
createAssignments: async (c, payload: { organization_id: string; name: string; scenario: string; assignee_emails: string[] }) => track(c, () => roleplayService.createAssignments(payload)),
|
||||
listAssignments: async (c, email: string, status?: string, limit?: number) => track(c, () => roleplayService.listAssignments(email, status, limit)),
|
||||
unassign: async (c, payload: { organization_id: string; roleplay_id: string; emails: string[] }) => track(c, () => roleplayService.unassign(payload)),
|
||||
resultsBulk: async (c, payload: JsonObject) => track(c, () => roleplayService.resultsBulk(payload)),
|
||||
review: async (c, sessionId: string) => track(c, () => roleplayService.review(sessionId)),
|
||||
leaderboard: async (c) => track(c, () => roleplayService.leaderboard()),
|
||||
artifact: async (c, sessionId: string, artifactType: string) => track(c, () => roleplayService.artifact(sessionId, artifactType)),
|
||||
createVideoUploadUrl: async (c, sessionId: string, payload: JsonObject) => track(c, () => roleplayService.createVideoUploadUrl(sessionId, payload)),
|
||||
markVideoUploaded: async (c, sessionId: string, payload: JsonObject) => track(c, () => roleplayService.markVideoUploaded(sessionId, payload)),
|
||||
},
|
||||
});
|
||||
|
||||
export const resumeServiceActor = actor({
|
||||
state: { calls: 0, lastCallAt: null as string | null },
|
||||
actions: {
|
||||
health: async (c) => track(c, () => resumeService.health()),
|
||||
state: async (c, clerkId: string) => track(c, () => resumeService.state(clerkId)),
|
||||
listResumes: async (c, clerkId: string) => track(c, () => resumeService.listResumes(clerkId)),
|
||||
createResume: async (c, payload: JsonObject) => track(c, () => resumeService.createResume(payload)),
|
||||
getResume: async (c, resumeId: string) => track(c, () => resumeService.getResume(resumeId)),
|
||||
updateResume: async (c, resumeId: string, payload: JsonObject) => track(c, () => resumeService.updateResume(resumeId, payload)),
|
||||
analyzeResume: async (c, resumeId: string, payload?: JsonObject) => track(c, () => resumeService.analyzeResume(resumeId, payload ?? {})),
|
||||
suggestions: async (c, resumeId: string) => track(c, () => resumeService.suggestions(resumeId)),
|
||||
copilot: async (c, payload: JsonObject) => track(c, () => resumeService.copilot(payload)),
|
||||
optimizeSummary: async (c, payload: JsonObject) => track(c, () => resumeService.optimizeSummary(payload)),
|
||||
optimizeExperience: async (c, payload: JsonObject) => track(c, () => resumeService.optimizeExperience(payload)),
|
||||
suggestSkills: async (c, payload: JsonObject) => track(c, () => resumeService.suggestSkills(payload)),
|
||||
generateSummary: async (c, payload: JsonObject) => track(c, () => resumeService.generateSummary(payload)),
|
||||
listVersions: async (c, resumeId: string) => track(c, () => resumeService.listVersions(resumeId)),
|
||||
createVersion: async (c, resumeId: string, payload: JsonObject) => track(c, () => resumeService.createVersion(resumeId, payload)),
|
||||
preview: async (c, resumeId: string) => track(c, () => resumeService.preview(resumeId)),
|
||||
},
|
||||
});
|
||||
|
||||
async function track<T>(c: { state: { calls: number; lastCallAt: string | null } }, fn: () => Promise<T>): Promise<T> {
|
||||
c.state.calls += 1;
|
||||
c.state.lastCallAt = new Date().toISOString();
|
||||
return fn();
|
||||
}
|
||||
@@ -1,11 +1,35 @@
|
||||
import { setup } from "rivetkit";
|
||||
import { userActor } from "./user-actor.js";
|
||||
import { workflowRunActor } from "./workflow-run-actor.js";
|
||||
import { interviewServiceActor, resumeServiceActor, roleplayServiceActor } from "./product-service-actors.js";
|
||||
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 {
|
||||
careerTransitionMissionActor,
|
||||
interviewToOfferMissionActor,
|
||||
personalBrandOpportunityEngineMissionActor,
|
||||
promotionReadinessMissionActor,
|
||||
salaryNegotiationWarRoomMissionActor,
|
||||
} from "./missions/index.js";
|
||||
|
||||
// Per changes.md §5: ONE unified actor per user.
|
||||
// No separate growAgent, subAgent, or workflowJob actors.
|
||||
export const registry = setup({
|
||||
use: {
|
||||
growActor,
|
||||
userEventActor,
|
||||
conversationActor,
|
||||
memoryActor,
|
||||
interviewToOfferMissionActor,
|
||||
careerTransitionMissionActor,
|
||||
salaryNegotiationWarRoomMissionActor,
|
||||
promotionReadinessMissionActor,
|
||||
personalBrandOpportunityEngineMissionActor,
|
||||
userActor,
|
||||
workflowRunActor,
|
||||
interviewServiceActor,
|
||||
roleplayServiceActor,
|
||||
resumeServiceActor,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -24,8 +24,12 @@ import {
|
||||
syncWorkspaceToGit,
|
||||
} from "../docker/manager.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { actors as actorsTable, events as eventsTable } from "../db/schema.js";
|
||||
import { actors as actorsTable, events as eventsTable, type UserStack } from "../db/schema.js";
|
||||
import { createChatCompletion, type LlmMessage, type LlmToolCall } from "../lib/llm.js";
|
||||
import { getWorkflowDefinition } from "../workflows/registry.js";
|
||||
import { workflowRunModules, workflowArtifacts, workflowEvents } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { prepareOpenCodeWorkflowModule } from "../workflows/executors/opencode-executor.js";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -36,8 +40,13 @@ type ChatTurn = {
|
||||
toolCalls?: LlmToolCall[];
|
||||
};
|
||||
|
||||
function publicStack(stack: UserStack) {
|
||||
const { opencodePassword: _opencodePassword, ...safe } = stack;
|
||||
return safe;
|
||||
}
|
||||
|
||||
type WorkflowStatus = "draft" | "running" | "paused" | "completed";
|
||||
type ModuleStatus = "idle" | "running" | "blocked" | "done";
|
||||
type ModuleStatus = "idle" | "running" | "blocked" | "done" | "manual_required" | "waiting_for_input" | "opencode_required" | "coming_soon";
|
||||
|
||||
type Scorecard = {
|
||||
id: string;
|
||||
@@ -79,6 +88,7 @@ type UserActorState = {
|
||||
workflowId: string;
|
||||
workflowStatus: WorkflowStatus;
|
||||
workflowGoal: string;
|
||||
workflowRunId?: string;
|
||||
modules: WorkflowModuleState[];
|
||||
timeline: WorkflowEvent[];
|
||||
createdAt: string;
|
||||
@@ -165,11 +175,11 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "run_workflow_module",
|
||||
description: "Execute a specific sub-agent module in the workflow (e.g., resume, job-search, job-apply, sara, emily, qscore).",
|
||||
description: "Execute a specific production sub-agent module in the workflow (e.g., resume, interview, roleplay, qscore).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
moduleId: { type: "string", description: "Module id: resume, job-search, job-apply, sara, emily, qscore" },
|
||||
moduleId: { type: "string", description: "Module id: resume, interview, roleplay, qscore" },
|
||||
},
|
||||
required: ["moduleId"],
|
||||
},
|
||||
@@ -179,7 +189,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_interview_session",
|
||||
description: "Create a real interview practice session via the Sara / interview-service microservice.",
|
||||
description: "Create a real interview practice session via the Interview Agent / interview-service microservice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { goal: { type: "string" } },
|
||||
@@ -191,7 +201,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_roleplay_session",
|
||||
description: "Create a real roleplay practice session via the Emily / roleplay-service microservice.",
|
||||
description: "Create a real roleplay practice session via the Roleplay Agent / roleplay-service microservice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { goal: { type: "string" } },
|
||||
@@ -203,7 +213,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "compute_qscore",
|
||||
description: "Compute or refresh the user's Q-Score via the Quinn / qscore-service microservice.",
|
||||
description: "Compute or refresh the user's Q-Score via the Q Score Agent / qscore-service microservice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -243,7 +253,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_interview_to_offer",
|
||||
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze & tailor resume for the role, (2) Create interview practice session with Sara, (3) Create roleplay session with Emily, (4) Compute Q-Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
|
||||
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze & tailor resume for the role, (2) Create interview practice session with the Interview Agent, (3) Create roleplay session with Roleplay Agent, (4) Compute Q-Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -319,19 +329,15 @@ function messagesForApi(history: ChatTurn[]): LlmMessage[] {
|
||||
|
||||
// ── Workflow helpers ──
|
||||
|
||||
function makeModules(): WorkflowModuleState[] {
|
||||
function makeModules(workflowId = "interview-to-offer"): WorkflowModuleState[] {
|
||||
const def = getWorkflowDefinition(workflowId);
|
||||
if (def) {
|
||||
return def.modules.map((m) => ({ id: m.id, name: m.title, role: m.role, service: m.service, status: "idle" as ModuleStatus, summary: m.description, scorecards: [] }));
|
||||
}
|
||||
return jobApplicationModuleIds()
|
||||
.map((id) => getSubAgentModule(id))
|
||||
.filter((m): m is SubAgentModule => Boolean(m))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
role: m.role,
|
||||
service: m.service,
|
||||
status: "idle" as ModuleStatus,
|
||||
summary: m.description,
|
||||
scorecards: [],
|
||||
}));
|
||||
.map((m) => ({ id: m.id, name: m.name, role: m.role, service: m.service, status: "idle" as ModuleStatus, summary: m.description, scorecards: [] }));
|
||||
}
|
||||
|
||||
function appendTimelineEvent(
|
||||
@@ -411,7 +417,7 @@ export const userActor = actor({
|
||||
prompt: stack.promptVersion,
|
||||
},
|
||||
});
|
||||
return stack;
|
||||
return publicStack(stack);
|
||||
},
|
||||
|
||||
shutdown: async (c) => {
|
||||
@@ -544,12 +550,14 @@ export const userActor = actor({
|
||||
|
||||
// ── Workflow (was workflowJob actor, now part of user actor — changes.md §5) ──
|
||||
|
||||
startWorkflow: async (c, input: { goal?: string }) => {
|
||||
startWorkflow: async (c, input: { workflowId?: string; runId?: string; goal?: string; input?: Record<string, unknown> }) => {
|
||||
const workflowId = input.workflowId ?? "interview-to-offer";
|
||||
const goal = input.goal ?? "Find and apply to high-fit jobs";
|
||||
c.state.workflowId = `job-application:${c.state.userId}`;
|
||||
c.state.workflowId = `${workflowId}:${c.state.userId}`;
|
||||
c.state.workflowRunId = input.runId;
|
||||
c.state.workflowStatus = "running";
|
||||
c.state.workflowGoal = goal;
|
||||
c.state.modules = makeModules();
|
||||
c.state.modules = makeModules(workflowId);
|
||||
c.state.createdAt = now();
|
||||
c.state.updatedAt = now();
|
||||
|
||||
@@ -557,7 +565,7 @@ export const userActor = actor({
|
||||
c.state,
|
||||
{ id: "grow", name: "Grow Agent" },
|
||||
"workflow",
|
||||
"Job application workflow started.",
|
||||
`${getWorkflowDefinition(workflowId)?.title ?? "Workflow"} started.`,
|
||||
);
|
||||
c.broadcast("workflow.updated", {
|
||||
workflowId: c.state.workflowId,
|
||||
@@ -585,7 +593,7 @@ export const userActor = actor({
|
||||
return c.state;
|
||||
},
|
||||
|
||||
runWorkflowModule: async (c, input: { moduleId: string }) => {
|
||||
runWorkflowModule: async (c, input: { moduleId: string; runId?: string }) => {
|
||||
const mod = c.state.modules.find((m) => m.id === input.moduleId);
|
||||
if (!mod) throw new Error(`Unknown workflow module: ${input.moduleId}`);
|
||||
|
||||
@@ -593,18 +601,25 @@ export const userActor = actor({
|
||||
appendTimelineEvent(c.state, mod, "module", `${mod.name} started.`);
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
const workflowKey = c.state.workflowId.split(":")[0] || "interview-to-offer";
|
||||
const defModule = getWorkflowDefinition(workflowKey)?.modules.find((m) => m.id === mod.id);
|
||||
const subModule = getSubAgentModule(mod.id);
|
||||
if (subModule?.service) {
|
||||
const service = defModule?.service ?? subModule?.service;
|
||||
if (service) {
|
||||
const userId = c.state.userId;
|
||||
const goal = c.state.workflowGoal;
|
||||
c.waitUntil(
|
||||
(async () => {
|
||||
const result = await runServiceAgentProbe(
|
||||
{ id: subModule.id, name: subModule.name, role: subModule.role, kind: "microservice", description: subModule.description, service: subModule.service },
|
||||
{ id: mod.id, name: mod.name, role: mod.role, kind: "microservice", description: mod.summary, service },
|
||||
{ userId, goal },
|
||||
);
|
||||
mod.lastResult = result;
|
||||
mod.status = result.status === "unavailable" ? "blocked" : "done";
|
||||
if (input.runId) {
|
||||
await db.update(workflowRunModules).set({ status: mod.status, outputSummary: result.summary, output: result.detail as Record<string, unknown> | undefined, completedAt: new Date() }).where(and(eq(workflowRunModules.runId, input.runId), eq(workflowRunModules.moduleId, mod.id)));
|
||||
await db.insert(workflowEvents).values({ runId: input.runId, userId, type: mod.status === "done" ? "module.completed" : "module.blocked", payload: { moduleId: mod.id, summary: result.summary, detail: result.detail } });
|
||||
}
|
||||
appendTimelineEvent(c.state, mod, "module", result.summary, result.detail);
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
await c.saveState({ immediate: true });
|
||||
@@ -613,12 +628,28 @@ export const userActor = actor({
|
||||
return c.state;
|
||||
}
|
||||
|
||||
// Local workflow modules
|
||||
mod.lastResult = {
|
||||
status: "local",
|
||||
summary: `${mod.name} completed a local workflow step for "${c.state.workflowGoal}".`,
|
||||
};
|
||||
mod.status = "done";
|
||||
// Never fake success for non-service modules. They must execute via a real
|
||||
// service/OpenCode path, be approved by a human, or report an honest blocked/manual status.
|
||||
if (defModule?.execution === "opencode" && input.runId) {
|
||||
const workflow = getWorkflowDefinition(workflowKey);
|
||||
if (workflow) {
|
||||
const prepared = await prepareOpenCodeWorkflowModule({ userId: c.state.userId, runId: input.runId, workflow, module: defModule, goal: c.state.workflowGoal });
|
||||
mod.lastResult = { status: prepared.status === "blocked_service_unavailable" ? "unavailable" : prepared.status, summary: prepared.summary, detail: { artifacts: prepared.artifacts } };
|
||||
mod.status = prepared.status === "ok" ? "done" : prepared.status === "blocked_service_unavailable" ? "blocked" : "opencode_required";
|
||||
await db.insert(workflowArtifacts).values(prepared.artifacts.map((a) => ({ runId: input.runId!, moduleId: mod.id, type: a.type, title: a.title, repoPath: a.repoPath, metadata: a.metadata })));
|
||||
await db.update(workflowRunModules).set({ status: mod.status, outputSummary: prepared.summary, output: { artifacts: prepared.artifacts }, completedAt: new Date() }).where(and(eq(workflowRunModules.runId, input.runId), eq(workflowRunModules.moduleId, mod.id)));
|
||||
await db.insert(workflowEvents).values({ runId: input.runId, userId: c.state.userId, type: prepared.status === "ok" ? "artifact.generated" : "artifact.contract_created", payload: { moduleId: mod.id, artifacts: prepared.artifacts, status: prepared.status } });
|
||||
appendTimelineEvent(c.state, mod, "module", prepared.summary, { artifacts: prepared.artifacts });
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
return c.state;
|
||||
}
|
||||
}
|
||||
mod.lastResult = { status: defModule?.execution === "coming_soon" ? "coming_soon" : "manual_required", summary: defModule?.execution === "opencode" ? `${mod.name} requires OpenCode artifact execution.` : `${mod.name} requires manual input or is not available yet.` };
|
||||
mod.status = defModule?.execution === "opencode" ? "opencode_required" : defModule?.execution === "coming_soon" ? "coming_soon" : "manual_required";
|
||||
if (input.runId) {
|
||||
await db.update(workflowRunModules).set({ status: mod.status, outputSummary: mod.lastResult.summary, completedAt: new Date() }).where(and(eq(workflowRunModules.runId, input.runId), eq(workflowRunModules.moduleId, mod.id)));
|
||||
await db.insert(workflowEvents).values({ runId: input.runId, userId: c.state.userId, type: "module.blocked", payload: { moduleId: mod.id, status: mod.status, summary: mod.lastResult.summary } });
|
||||
}
|
||||
appendTimelineEvent(c.state, mod, "module", mod.lastResult.summary);
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
return c.state;
|
||||
@@ -705,7 +736,7 @@ async function dispatchUnifiedTool(
|
||||
if (!client || !stack?.giteaRepoOwner || !stack.giteaRepoName) return [];
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${config.giteaUrl}/api/v1/repos/${encodeURIComponent(stack.giteaRepoOwner)}/${encodeURIComponent(stack.giteaRepoName)}/contents/${encodeURI(pathPrefix)}`,
|
||||
`${config.giteaInternalUrl}/api/v1/repos/${encodeURIComponent(stack.giteaRepoOwner)}/${encodeURIComponent(stack.giteaRepoName)}/contents/${encodeURI(pathPrefix)}`,
|
||||
{ headers: { authorization: `token ${config.giteaAdminToken}`, accept: "application/json" } },
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
@@ -745,8 +776,8 @@ async function dispatchUnifiedTool(
|
||||
mod.status = result.status === "unavailable" ? "blocked" : "done";
|
||||
appendTimelineEvent(c.state, mod, "module", result.summary, result.detail);
|
||||
} else {
|
||||
mod.lastResult = { status: "local", summary: `${mod.name} completed a local workflow step.` };
|
||||
mod.status = "done";
|
||||
mod.lastResult = { status: "manual_required", summary: `${mod.name} requires manual input or a configured service before it can complete.` };
|
||||
mod.status = "manual_required";
|
||||
appendTimelineEvent(c.state, mod, "module", mod.lastResult.summary);
|
||||
}
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
@@ -755,33 +786,33 @@ async function dispatchUnifiedTool(
|
||||
|
||||
case "start_interview_session": {
|
||||
const goal = String(input.goal ?? "");
|
||||
const saraModule = getSubAgentModule("sara");
|
||||
if (!saraModule?.service) return { ok: false, error: "Sara module not available" };
|
||||
const interviewModule = getSubAgentModule("interview");
|
||||
if (!interviewModule?.service) return { ok: false, error: "Interview module not available" };
|
||||
const result = await runServiceAgentProbe(
|
||||
{ id: saraModule.id, name: saraModule.name, role: saraModule.role, kind: "microservice", description: saraModule.description, service: saraModule.service },
|
||||
{ id: interviewModule.id, name: interviewModule.name, role: interviewModule.role, kind: "microservice", description: interviewModule.description, service: interviewModule.service },
|
||||
{ userId, goal },
|
||||
);
|
||||
c.broadcast("service-result", { moduleId: "sara", result });
|
||||
c.broadcast("service-result", { moduleId: "interview", result });
|
||||
return result;
|
||||
}
|
||||
|
||||
case "start_roleplay_session": {
|
||||
const goal = String(input.goal ?? "");
|
||||
const emilyModule = getSubAgentModule("emily");
|
||||
if (!emilyModule?.service) return { ok: false, error: "Emily module not available" };
|
||||
const roleplayModule = getSubAgentModule("roleplay");
|
||||
if (!roleplayModule?.service) return { ok: false, error: "Roleplay Agent module not available" };
|
||||
const result = await runServiceAgentProbe(
|
||||
{ id: emilyModule.id, name: emilyModule.name, role: emilyModule.role, kind: "microservice", description: emilyModule.description, service: emilyModule.service },
|
||||
{ id: roleplayModule.id, name: roleplayModule.name, role: roleplayModule.role, kind: "microservice", description: roleplayModule.description, service: roleplayModule.service },
|
||||
{ userId, goal },
|
||||
);
|
||||
c.broadcast("service-result", { moduleId: "emily", result });
|
||||
c.broadcast("service-result", { moduleId: "roleplay", result });
|
||||
return result;
|
||||
}
|
||||
|
||||
case "compute_qscore": {
|
||||
const quinnModule = getSubAgentModule("qscore");
|
||||
if (!quinnModule?.service) return { ok: false, error: "Quinn module not available" };
|
||||
const qscoreModule = getSubAgentModule("qscore");
|
||||
if (!qscoreModule?.service) return { ok: false, error: "Q Score Agent module not available" };
|
||||
const result = await runServiceAgentProbe(
|
||||
{ id: quinnModule.id, name: quinnModule.name, role: quinnModule.role, kind: "score", description: quinnModule.description, service: quinnModule.service },
|
||||
{ id: qscoreModule.id, name: qscoreModule.name, role: qscoreModule.role, kind: "score", description: qscoreModule.description, service: qscoreModule.service },
|
||||
{ userId, goal: c.state.workflowGoal || "general assessment" },
|
||||
);
|
||||
c.broadcast("service-result", { moduleId: "qscore", result });
|
||||
@@ -850,73 +881,73 @@ async function dispatchUnifiedTool(
|
||||
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
// Step 2: Sara — create interview session
|
||||
const saraModule = getSubAgentModule("sara");
|
||||
const saraMod = c.state.modules.find(m => m.id === "sara");
|
||||
if (saraMod && saraModule?.service) {
|
||||
saraMod.status = "running";
|
||||
appendTimelineEvent(c.state, saraMod, "module", "Sara creating interview practice session...");
|
||||
// Step 2: Interview Agent — create interview session
|
||||
const interviewModule = getSubAgentModule("interview");
|
||||
const interviewMod = c.state.modules.find(m => m.id === "interview");
|
||||
if (interviewMod && interviewModule?.service) {
|
||||
interviewMod.status = "running";
|
||||
appendTimelineEvent(c.state, interviewMod, "module", "Interview Agent creating interview practice session...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
const saraResult = await runServiceAgentProbe(
|
||||
{ id: saraModule.id, name: saraModule.name, role: saraModule.role, kind: "microservice", description: saraModule.description, service: saraModule.service },
|
||||
const interviewResult = await runServiceAgentProbe(
|
||||
{ id: interviewModule.id, name: interviewModule.name, role: interviewModule.role, kind: "microservice", description: interviewModule.description, service: interviewModule.service },
|
||||
{ userId, goal: goal + (jobDesc ? `\nJob Description: ${jobDesc}` : "") },
|
||||
);
|
||||
saraMod.lastResult = saraResult;
|
||||
saraMod.status = saraResult.status === "unavailable" ? "blocked" : "done";
|
||||
appendTimelineEvent(c.state, saraMod, "module", saraResult.summary);
|
||||
interviewMod.lastResult = interviewResult;
|
||||
interviewMod.status = interviewResult.status === "unavailable" ? "blocked" : "done";
|
||||
appendTimelineEvent(c.state, interviewMod, "module", interviewResult.summary);
|
||||
} catch (err) {
|
||||
saraMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, saraMod, "module", `Sara session failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
interviewMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, interviewMod, "module", `Interview session failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
// Step 3: Emily — create roleplay session
|
||||
const emilyModule = getSubAgentModule("emily");
|
||||
const emilyMod = c.state.modules.find(m => m.id === "emily");
|
||||
if (emilyMod && emilyModule?.service) {
|
||||
emilyMod.status = "running";
|
||||
appendTimelineEvent(c.state, emilyMod, "module", "Emily creating roleplay scenario...");
|
||||
// Step 3: Roleplay Agent — create roleplay session
|
||||
const roleplayModule = getSubAgentModule("roleplay");
|
||||
const roleplayMod = c.state.modules.find(m => m.id === "roleplay");
|
||||
if (roleplayMod && roleplayModule?.service) {
|
||||
roleplayMod.status = "running";
|
||||
appendTimelineEvent(c.state, roleplayMod, "module", "Roleplay Agent creating roleplay scenario...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
const emilyResult = await runServiceAgentProbe(
|
||||
{ id: emilyModule.id, name: emilyModule.name, role: emilyModule.role, kind: "microservice", description: emilyModule.description, service: emilyModule.service },
|
||||
const roleplayResult = await runServiceAgentProbe(
|
||||
{ id: roleplayModule.id, name: roleplayModule.name, role: roleplayModule.role, kind: "microservice", description: roleplayModule.description, service: roleplayModule.service },
|
||||
{ userId, goal: `Interview negotiation and communication practice for: ${goal}` },
|
||||
);
|
||||
emilyMod.lastResult = emilyResult;
|
||||
emilyMod.status = emilyResult.status === "unavailable" ? "blocked" : "done";
|
||||
appendTimelineEvent(c.state, emilyMod, "module", emilyResult.summary);
|
||||
roleplayMod.lastResult = roleplayResult;
|
||||
roleplayMod.status = roleplayResult.status === "unavailable" ? "blocked" : "done";
|
||||
appendTimelineEvent(c.state, roleplayMod, "module", roleplayResult.summary);
|
||||
} catch (err) {
|
||||
emilyMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, emilyMod, "module", `Emily session failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
roleplayMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, roleplayMod, "module", `Roleplay Agent session failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
// Step 4: Quinn — compute Q-Score
|
||||
const quinnModule = getSubAgentModule("qscore");
|
||||
const quinnMod = c.state.modules.find(m => m.id === "qscore");
|
||||
if (quinnMod && quinnModule?.service) {
|
||||
quinnMod.status = "running";
|
||||
appendTimelineEvent(c.state, quinnMod, "module", "Quinn computing your readiness Q-Score...");
|
||||
// Step 4: Q Score Agent — compute Q-Score
|
||||
const qscoreModule = getSubAgentModule("qscore");
|
||||
const qscoreMod = c.state.modules.find(m => m.id === "qscore");
|
||||
if (qscoreMod && qscoreModule?.service) {
|
||||
qscoreMod.status = "running";
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", "Q Score Agent computing your readiness Q-Score...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
const quinnResult = await runServiceAgentProbe(
|
||||
{ id: quinnModule.id, name: quinnModule.name, role: quinnModule.role, kind: "score", description: quinnModule.description, service: quinnModule.service },
|
||||
const qscoreResult = await runServiceAgentProbe(
|
||||
{ id: qscoreModule.id, name: qscoreModule.name, role: qscoreModule.role, kind: "score", description: qscoreModule.description, service: qscoreModule.service },
|
||||
{ userId, goal },
|
||||
);
|
||||
quinnMod.lastResult = quinnResult;
|
||||
quinnMod.status = quinnResult.status === "unavailable" ? "blocked" : "done";
|
||||
appendTimelineEvent(c.state, quinnMod, "module", quinnResult.summary);
|
||||
qscoreMod.lastResult = qscoreResult;
|
||||
qscoreMod.status = qscoreResult.status === "unavailable" ? "blocked" : "done";
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", qscoreResult.summary);
|
||||
} catch (err) {
|
||||
quinnMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, quinnMod, "module", `Q-Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
qscoreMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", `Q-Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
198
src/actors/workflow-run-actor.ts
Normal file
198
src/actors/workflow-run-actor.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { actor, event, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import { db } from "../db/client.js";
|
||||
import { workflowApprovals, workflowEvents, workflowRunModules, workflowRuns } from "../db/schema.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getWorkflowDefinition } from "../workflows/registry.js";
|
||||
import { executeWorkflowModule } from "../workflows/module-runner.js";
|
||||
|
||||
type WorkflowCommand =
|
||||
| { type: "run_module"; userId: string; runId: string; moduleId: string; idempotencyKey?: string }
|
||||
| { type: "run_all"; userId: string; runId: string; idempotencyKey?: string }
|
||||
| { type: "pause"; userId: string; runId: string }
|
||||
| { type: "resume"; userId: string; runId: string };
|
||||
|
||||
type WorkflowRunActorState = {
|
||||
userId: string;
|
||||
runId: string;
|
||||
phase: "idle" | "running" | "paused" | "error";
|
||||
currentModuleId?: string;
|
||||
processedCommands: number;
|
||||
lastError?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export const workflowRunActor = actor({
|
||||
options: { name: "Workflow Run", icon: "diagram-project", noSleep: true, actionTimeout: 600_000 },
|
||||
state: { userId: "", runId: "", phase: "idle", processedCommands: 0 } as WorkflowRunActorState,
|
||||
events: {
|
||||
updated: event<WorkflowRunActorState>(),
|
||||
},
|
||||
queues: {
|
||||
commands: queue<WorkflowCommand>(),
|
||||
},
|
||||
actions: {
|
||||
init: async (c, input: { userId: string; runId: string }) => {
|
||||
if (c.state.userId && (c.state.userId !== input.userId || c.state.runId !== input.runId)) throw new Error("workflow actor already initialized for a different run");
|
||||
c.state.userId = input.userId;
|
||||
c.state.runId = input.runId;
|
||||
c.state.updatedAt = new Date().toISOString();
|
||||
c.broadcast("updated", c.state);
|
||||
return c.state;
|
||||
},
|
||||
runModule: async (c, input: { userId: string; runId: string; moduleId: string; idempotencyKey?: string }) => {
|
||||
await c.queue.send("commands", { type: "run_module", ...input });
|
||||
return { queued: true };
|
||||
},
|
||||
runAll: async (c, input: { userId: string; runId: string; idempotencyKey?: string }) => {
|
||||
await c.queue.send("commands", { type: "run_all", ...input });
|
||||
return { queued: true };
|
||||
},
|
||||
pause: async (c, input: { userId: string; runId: string }) => {
|
||||
await c.queue.send("commands", { type: "pause", ...input });
|
||||
return { queued: true };
|
||||
},
|
||||
resume: async (c, input: { userId: string; runId: string }) => {
|
||||
await c.queue.send("commands", { type: "resume", ...input });
|
||||
return { queued: true };
|
||||
},
|
||||
getState: (c) => c.state,
|
||||
},
|
||||
run: workflow(async (ctx) => {
|
||||
await ctx.loop("workflow-command-loop", async (loopCtx) => {
|
||||
const message = await loopCtx.queue.next("wait-command", { names: ["commands"] });
|
||||
const cmd = message.body as WorkflowCommand;
|
||||
|
||||
if (cmd.type === "pause") {
|
||||
await loopCtx.step("pause-run", async () => {
|
||||
loopCtx.state.phase = "paused";
|
||||
loopCtx.state.updatedAt = new Date().toISOString();
|
||||
loopCtx.broadcast("updated", loopCtx.state);
|
||||
await db.insert(workflowEvents).values({ runId: cmd.runId, userId: cmd.userId, type: "workflow.pause.queued", payload: {} });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd.type === "resume") {
|
||||
await loopCtx.step("resume-run", async () => {
|
||||
loopCtx.state.phase = "running";
|
||||
loopCtx.state.updatedAt = new Date().toISOString();
|
||||
loopCtx.broadcast("updated", loopCtx.state);
|
||||
await db.insert(workflowEvents).values({ runId: cmd.runId, userId: cmd.userId, type: "workflow.resume.queued", payload: {} });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (loopCtx.state.phase === "paused") {
|
||||
await loopCtx.step("skip-while-paused", async () => {
|
||||
await db.insert(workflowEvents).values({ runId: cmd.runId, userId: cmd.userId, type: "module.deferred_paused", payload: { moduleId: cmd.type === "run_module" ? cmd.moduleId : "all" } });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd.type === "run_all") {
|
||||
const moduleIds = await loopCtx.step(`load-run-modules:${cmd.runId}`, async () => {
|
||||
const [run] = await db.select().from(workflowRuns).where(and(eq(workflowRuns.id, cmd.runId), eq(workflowRuns.userId, cmd.userId))).limit(1);
|
||||
if (!run) throw new Error(`run not found: ${cmd.runId}`);
|
||||
const def = getWorkflowDefinition(run.workflowId);
|
||||
if (!def) throw new Error(`workflow not found: ${run.workflowId}`);
|
||||
return def.modules.map((m) => ({ id: m.id, approvalGateAfter: m.approvalGateAfter }));
|
||||
});
|
||||
for (const mod of moduleIds) {
|
||||
const result = await runOneModule(loopCtx, cmd.userId, cmd.runId, mod.id, `${cmd.idempotencyKey ?? "all"}:${mod.id}`);
|
||||
if (!result.ok) break;
|
||||
if (mod.approvalGateAfter) {
|
||||
const shouldPause = await loopCtx.step(`approval-gate:${cmd.runId}:${mod.approvalGateAfter}:${cmd.idempotencyKey ?? "all"}`, async () => {
|
||||
const [existing] = await db.select().from(workflowApprovals).where(and(eq(workflowApprovals.runId, cmd.runId), eq(workflowApprovals.approvalId, mod.approvalGateAfter!))).limit(1);
|
||||
if (existing?.status === "approved") return false;
|
||||
await db.insert(workflowApprovals).values({ runId: cmd.runId, approvalId: mod.approvalGateAfter!, status: "pending", payload: { afterModuleId: mod.id } }).onConflictDoNothing();
|
||||
await db.insert(workflowEvents).values({ runId: cmd.runId, userId: cmd.userId, type: "approval.required", payload: { approvalId: mod.approvalGateAfter, afterModuleId: mod.id } });
|
||||
return true;
|
||||
});
|
||||
if (shouldPause) break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await loopCtx.tryStep({
|
||||
name: `run-module:${cmd.moduleId}:${cmd.idempotencyKey ?? "default"}`,
|
||||
maxRetries: 3,
|
||||
retryBackoffBase: 1_000,
|
||||
retryBackoffMax: 30_000,
|
||||
timeout: 300_000,
|
||||
run: async () => {
|
||||
loopCtx.state.phase = "running";
|
||||
loopCtx.state.currentModuleId = cmd.moduleId;
|
||||
loopCtx.state.updatedAt = new Date().toISOString();
|
||||
loopCtx.broadcast("updated", loopCtx.state);
|
||||
const result = await executeWorkflowModule({ userId: cmd.userId, runId: cmd.runId, moduleId: cmd.moduleId });
|
||||
loopCtx.state.processedCommands += 1;
|
||||
loopCtx.state.currentModuleId = undefined;
|
||||
loopCtx.state.updatedAt = new Date().toISOString();
|
||||
loopCtx.broadcast("updated", loopCtx.state);
|
||||
return result;
|
||||
},
|
||||
catch: ["timeout", "exhausted"],
|
||||
});
|
||||
if (!result.ok) {
|
||||
await loopCtx.step(`record-failure:${cmd.moduleId}:${cmd.idempotencyKey ?? "default"}`, async () => {
|
||||
const error = JSON.stringify(result.failure.error);
|
||||
loopCtx.state.phase = "error";
|
||||
loopCtx.state.lastError = error;
|
||||
loopCtx.state.currentModuleId = undefined;
|
||||
loopCtx.state.updatedAt = new Date().toISOString();
|
||||
loopCtx.broadcast("updated", loopCtx.state);
|
||||
await db.update(workflowRunModules).set({ status: "blocked", error, completedAt: new Date() }).where(and(eq(workflowRunModules.runId, cmd.runId), eq(workflowRunModules.moduleId, cmd.moduleId)));
|
||||
await db.update(workflowRuns).set({ status: "failed", updatedAt: new Date() }).where(eq(workflowRuns.id, cmd.runId));
|
||||
await db.insert(workflowEvents).values({ runId: cmd.runId, userId: cmd.userId, type: "module.failed", payload: { moduleId: cmd.moduleId, failure: result.failure } });
|
||||
});
|
||||
}
|
||||
});
|
||||
}, {
|
||||
onError: async (_ctx, event) => {
|
||||
console.error("workflow-run-actor workflow error", event);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
async function runOneModule(loopCtx: any, userId: string, runId: string, moduleId: string, idempotencyKey: string): Promise<{ ok: boolean }> {
|
||||
const shouldSkip = await loopCtx.step(`precheck-module:${moduleId}:${idempotencyKey}`, async () => {
|
||||
const [row] = await db.select().from(workflowRunModules).where(and(eq(workflowRunModules.runId, runId), eq(workflowRunModules.moduleId, moduleId))).limit(1);
|
||||
return row ? ["done", "blocked", "manual_required", "coming_soon"].includes(row.status) : false;
|
||||
});
|
||||
if (shouldSkip) return { ok: true };
|
||||
const result = await loopCtx.tryStep({
|
||||
name: `run-module:${moduleId}:${idempotencyKey}`,
|
||||
maxRetries: 3,
|
||||
retryBackoffBase: 1_000,
|
||||
retryBackoffMax: 30_000,
|
||||
timeout: 300_000,
|
||||
run: async () => {
|
||||
loopCtx.state.phase = "running";
|
||||
loopCtx.state.currentModuleId = moduleId;
|
||||
loopCtx.state.updatedAt = new Date().toISOString();
|
||||
loopCtx.broadcast("updated", loopCtx.state);
|
||||
const moduleResult = await executeWorkflowModule({ userId, runId, moduleId });
|
||||
loopCtx.state.processedCommands += 1;
|
||||
loopCtx.state.currentModuleId = undefined;
|
||||
loopCtx.state.updatedAt = new Date().toISOString();
|
||||
loopCtx.broadcast("updated", loopCtx.state);
|
||||
return moduleResult;
|
||||
},
|
||||
catch: ["timeout", "exhausted"],
|
||||
});
|
||||
if (result.ok) return { ok: true };
|
||||
await loopCtx.step(`record-failure:${moduleId}:${idempotencyKey}`, async () => {
|
||||
const error = JSON.stringify(result.failure.error);
|
||||
loopCtx.state.phase = "error";
|
||||
loopCtx.state.lastError = error;
|
||||
loopCtx.state.currentModuleId = undefined;
|
||||
loopCtx.state.updatedAt = new Date().toISOString();
|
||||
loopCtx.broadcast("updated", loopCtx.state);
|
||||
await db.update(workflowRunModules).set({ status: "blocked", error, completedAt: new Date() }).where(and(eq(workflowRunModules.runId, runId), eq(workflowRunModules.moduleId, moduleId)));
|
||||
await db.update(workflowRuns).set({ status: "failed", updatedAt: new Date() }).where(eq(workflowRuns.id, runId));
|
||||
await db.insert(workflowEvents).values({ runId, userId, type: "module.failed", payload: { moduleId, failure: result.failure } });
|
||||
});
|
||||
return { ok: false };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { config } from "../config.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { log } from "../log.js";
|
||||
|
||||
export type AuthContext = {
|
||||
Variables: {
|
||||
@@ -27,10 +28,16 @@ export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
|
||||
|
||||
// Service-to-service path (Grow Agent actor calling backend).
|
||||
// Header `x-growqr-user` is REQUIRED so we can scope the call.
|
||||
const trustedServiceTokens = new Set(
|
||||
[
|
||||
config.serviceToken,
|
||||
config.nodeEnv !== "production" ? config.a2aAllowedKey : "",
|
||||
].filter(Boolean),
|
||||
);
|
||||
|
||||
if (
|
||||
token &&
|
||||
config.serviceToken &&
|
||||
token === config.serviceToken &&
|
||||
trustedServiceTokens.has(token) &&
|
||||
c.req.header("x-growqr-user")
|
||||
) {
|
||||
const userId = c.req.header("x-growqr-user")!;
|
||||
@@ -80,15 +87,21 @@ export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
|
||||
// Lazy-mirror Clerk user → users table.
|
||||
let row = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
||||
if (!row) {
|
||||
const clerkUser = await clerk.users.getUser(userId);
|
||||
const email =
|
||||
clerkUser.primaryEmailAddress?.emailAddress ??
|
||||
clerkUser.emailAddresses[0]?.emailAddress ??
|
||||
`${userId}@unknown.local`;
|
||||
const displayName =
|
||||
[clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") ||
|
||||
clerkUser.username ||
|
||||
null;
|
||||
let email = `${userId}@unknown.local`;
|
||||
let displayName: string | null = null;
|
||||
try {
|
||||
const clerkUser = await clerk.users.getUser(userId);
|
||||
email =
|
||||
clerkUser.primaryEmailAddress?.emailAddress ??
|
||||
clerkUser.emailAddresses[0]?.emailAddress ??
|
||||
email;
|
||||
displayName =
|
||||
[clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") ||
|
||||
clerkUser.username ||
|
||||
null;
|
||||
} catch (err) {
|
||||
log.warn({ err, userId }, "failed to hydrate Clerk user details; creating minimal backend mirror row");
|
||||
}
|
||||
const inserted = await db
|
||||
.insert(users)
|
||||
.values({ id: userId, email, displayName })
|
||||
|
||||
@@ -25,6 +25,19 @@ export const config = {
|
||||
serviceToken: process.env.SERVICE_TOKEN ?? "",
|
||||
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key",
|
||||
|
||||
// Service → backend event stream. Redis is optional; HTTP /events/ingest/service is always available.
|
||||
growEventsRedisUrl: process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
|
||||
growEventsStream: process.env.GROW_EVENTS_STREAM ?? "grow.events.raw",
|
||||
growEventsConsumerGroup: process.env.GROW_EVENTS_CONSUMER_GROUP ?? "growqr-backend",
|
||||
growEventsConsumerName: process.env.GROW_EVENTS_CONSUMER_NAME ?? `backend-${process.pid}`,
|
||||
|
||||
// Legacy service Redis surfaces. These let backend observe existing service A2A traffic
|
||||
// without changing the services. Defaults fall back to GROW_EVENTS_REDIS_URL/REDIS_URL.
|
||||
interviewRedisUrl: process.env.INTERVIEW_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
|
||||
roleplayRedisUrl: process.env.ROLEPLAY_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
|
||||
resumeRedisUrl: process.env.RESUME_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
|
||||
legacyServiceTaskObserverGroup: process.env.LEGACY_SERVICE_TASK_OBSERVER_GROUP ?? "growqr-backend-observer",
|
||||
|
||||
// LLM gateway for the unified user agent.
|
||||
llmProvider: process.env.LLM_PROVIDER ?? "opencode",
|
||||
llmApiKey:
|
||||
@@ -50,21 +63,41 @@ export const config = {
|
||||
// Product microservices exposed as sub-agent surfaces.
|
||||
interviewServiceUrl:
|
||||
process.env.INTERVIEW_SERVICE_URL ?? "http://localhost:8007",
|
||||
interviewPublicUrl:
|
||||
process.env.INTERVIEW_PUBLIC_URL ?? process.env.INTERVIEW_SERVICE_URL ?? "http://localhost:8007",
|
||||
roleplayServiceUrl:
|
||||
process.env.ROLEPLAY_SERVICE_URL ?? "http://localhost:8008",
|
||||
roleplayPublicUrl:
|
||||
process.env.ROLEPLAY_PUBLIC_URL ?? process.env.ROLEPLAY_SERVICE_URL ?? "http://localhost:8008",
|
||||
qscoreServiceUrl:
|
||||
process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
|
||||
resumeServiceUrl:
|
||||
process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
|
||||
userServiceUrl:
|
||||
process.env.USER_SERVICE_URL ?? "http://localhost:8003",
|
||||
resumePublicUrl:
|
||||
process.env.RESUME_PUBLIC_URL ?? process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
|
||||
matchmakingServiceUrl:
|
||||
process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006",
|
||||
socialBrandingServiceUrl:
|
||||
process.env.SOCIAL_BRANDING_SERVICE_URL ?? "http://localhost:8005",
|
||||
workflowsDashboardUrl:
|
||||
process.env.WORKFLOWS_DASHBOARD_URL ??
|
||||
process.env.FRONTEND_ORIGIN ??
|
||||
"http://localhost:3000",
|
||||
|
||||
// ── Central Gitea (one org-wide instance, changes.md §2A) ──
|
||||
giteaUrl: process.env.GITEA_URL ?? "http://127.0.0.1:3001",
|
||||
// Public URL is what Git remotes should use and what OpenCode containers see.
|
||||
// Internal URL is only for backend-to-Gitea API calls on a private network.
|
||||
giteaPublicUrl:
|
||||
process.env.GITEA_PUBLIC_URL ??
|
||||
process.env.GITEA_URL ??
|
||||
"http://127.0.0.1:3001",
|
||||
giteaInternalUrl:
|
||||
process.env.GITEA_INTERNAL_URL ??
|
||||
process.env.GITEA_URL ??
|
||||
process.env.GITEA_PUBLIC_URL ??
|
||||
"http://127.0.0.1:3001",
|
||||
giteaAdminUser: process.env.GITEA_ADMIN_USER ?? "growqr-admin",
|
||||
giteaAdminPassword: process.env.GITEA_ADMIN_PASSWORD ?? "growqr-admin-dev",
|
||||
giteaAdminToken: process.env.GITEA_ADMIN_TOKEN ?? "",
|
||||
@@ -74,9 +107,9 @@ export const config = {
|
||||
opencodeImage:
|
||||
process.env.OPENCODE_IMAGE ?? "growqr/opencode:dev",
|
||||
// Version tracking for rollout (changes.md §9)
|
||||
opencodeImageVersion: process.env.OPENCODE_IMAGE_VERSION ?? "1.0.0",
|
||||
opencodeImageVersion: process.env.OPENCODE_IMAGE_VERSION ?? "dev",
|
||||
migrationVersion: process.env.MIGRATION_VERSION ?? "1",
|
||||
promptVersion: process.env.PROMPT_VERSION ?? "1",
|
||||
promptVersion: process.env.PROMPT_VERSION ?? "4",
|
||||
|
||||
// Host that user containers expose ports on (the host running Docker).
|
||||
userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1",
|
||||
@@ -88,6 +121,10 @@ export const config = {
|
||||
// CORS for the Next.js frontend.
|
||||
frontendOrigin:
|
||||
process.env.FRONTEND_ORIGIN ?? "http://localhost:3000",
|
||||
adminUserIds: (process.env.ADMIN_USER_IDS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
|
||||
// Used by LLM requests.
|
||||
maxAgentTokens: Number(process.env.MAX_AGENT_TOKENS ?? 4096),
|
||||
|
||||
377
src/db/schema.ts
377
src/db/schema.ts
@@ -4,7 +4,9 @@ import {
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
boolean,
|
||||
jsonb,
|
||||
doublePrecision,
|
||||
uniqueIndex,
|
||||
index,
|
||||
primaryKey,
|
||||
@@ -171,4 +173,379 @@ export type UserStack = typeof userStacks.$inferSelect;
|
||||
export type NewUserStack = typeof userStacks.$inferInsert;
|
||||
export type ActorRow = typeof actors.$inferSelect;
|
||||
export type RepoRow = typeof repos.$inferSelect;
|
||||
|
||||
export const missionRegistry = pgTable(
|
||||
"mission_registry",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
version: text("version").notNull(),
|
||||
title: text("title").notNull(),
|
||||
shortTitle: text("short_title").notNull(),
|
||||
actorType: text("actor_type"),
|
||||
actorBacked: boolean("actor_backed").notNull().default(false),
|
||||
skillPath: text("skill_path").notNull(),
|
||||
displayOrder: integer("display_order").notNull(),
|
||||
definition: jsonb("definition").$type<Record<string, unknown>>().notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({ displayIdx: index("mission_registry_display_idx").on(t.displayOrder) }),
|
||||
);
|
||||
|
||||
export type MissionRegistryRow = typeof missionRegistry.$inferSelect;
|
||||
|
||||
export const workflowRuns = pgTable(
|
||||
"workflow_runs",
|
||||
{
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
workflowId: text("workflow_id").notNull(),
|
||||
workflowVersion: text("workflow_version").notNull(),
|
||||
status: text("status", { enum: ["draft", "running", "paused", "completed", "failed"] }).notNull().default("running"),
|
||||
goal: text("goal"),
|
||||
input: jsonb("input").$type<Record<string, unknown>>(),
|
||||
currentStepId: text("current_step_id"),
|
||||
progressPercent: integer("progress_percent").notNull().default(0),
|
||||
qscoreBefore: jsonb("qscore_before").$type<Record<string, unknown>>(),
|
||||
qscoreAfter: jsonb("qscore_after").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => ({ userIdx: index("workflow_runs_user_idx").on(t.userId, t.createdAt), workflowIdx: index("workflow_runs_workflow_idx").on(t.workflowId) }),
|
||||
);
|
||||
|
||||
export const workflowRunModules = pgTable("workflow_run_modules", {
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
runId: text("run_id").notNull().references(() => workflowRuns.id, { onDelete: "cascade" }),
|
||||
moduleId: text("module_id").notNull(),
|
||||
title: text("title").notNull(),
|
||||
status: text("status").notNull().default("idle"),
|
||||
service: text("service"),
|
||||
idempotencyKey: text("idempotency_key"),
|
||||
retryCount: integer("retry_count").notNull().default(0),
|
||||
maxRetries: integer("max_retries").notNull().default(2),
|
||||
outputSummary: text("output_summary"),
|
||||
output: jsonb("output").$type<Record<string, unknown>>(),
|
||||
error: text("error"),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
});
|
||||
|
||||
export const workflowArtifacts = pgTable("workflow_artifacts", {
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
runId: text("run_id").notNull().references(() => workflowRuns.id, { onDelete: "cascade" }),
|
||||
moduleId: text("module_id"),
|
||||
type: text("type").notNull(),
|
||||
title: text("title").notNull(),
|
||||
repoPath: text("repo_path"),
|
||||
publicUrl: text("public_url"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const workflowApprovals = pgTable("workflow_approvals", {
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
runId: text("run_id").notNull().references(() => workflowRuns.id, { onDelete: "cascade" }),
|
||||
approvalId: text("approval_id").notNull(),
|
||||
status: text("status", { enum: ["pending", "approved", "rejected"] }).notNull().default("pending"),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
|
||||
});
|
||||
|
||||
export const qscoreSnapshots = pgTable("qscore_snapshots", {
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
runId: text("run_id").references(() => workflowRuns.id, { onDelete: "cascade" }),
|
||||
snapshotType: text("snapshot_type", { enum: ["baseline", "module", "final"] }).notNull(),
|
||||
score: integer("score"),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const workflowEvents = pgTable("workflow_events", {
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
runId: text("run_id").notNull().references(() => workflowRuns.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
type: text("type").notNull(),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const growConversations = pgTable(
|
||||
"grow_conversations",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull().default("Talk to Me"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({ userIdx: index("grow_conversations_user_idx").on(t.userId, t.updatedAt) }),
|
||||
);
|
||||
|
||||
export const growConversationMessages = pgTable(
|
||||
"grow_conversation_messages",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
conversationId: text("conversation_id").notNull().references(() => growConversations.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
role: text("role", { enum: ["user", "assistant"] }).notNull(),
|
||||
sender: text("sender").notNull(),
|
||||
content: text("content").notNull(),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({ conversationIdx: index("grow_conversation_messages_conversation_idx").on(t.conversationId, t.createdAt) }),
|
||||
);
|
||||
|
||||
export const growActiveMissions = pgTable(
|
||||
"grow_active_missions",
|
||||
{
|
||||
instanceId: text("instance_id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
missionId: text("mission_id").notNull(),
|
||||
workflowId: text("workflow_id").notNull(),
|
||||
actorType: text("actor_type"),
|
||||
title: text("title").notNull(),
|
||||
shortTitle: text("short_title").notNull(),
|
||||
status: text("status").notNull(),
|
||||
progressPercent: integer("progress_percent").notNull().default(0),
|
||||
currentStageId: text("current_stage_id"),
|
||||
goal: text("goal"),
|
||||
mission: jsonb("mission").$type<Record<string, unknown>>().notNull(),
|
||||
snapshot: jsonb("snapshot").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({ userIdx: index("grow_active_missions_user_idx").on(t.userId, t.updatedAt) }),
|
||||
);
|
||||
|
||||
export type OpencodeSessionRow = typeof opencodeSessions.$inferSelect;
|
||||
export type WorkflowRunRow = typeof workflowRuns.$inferSelect;
|
||||
|
||||
export const growEvents = pgTable(
|
||||
"grow_events",
|
||||
{
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||
orgId: text("org_id"),
|
||||
source: text("source").notNull(),
|
||||
type: text("type").notNull(),
|
||||
category: text("category", {
|
||||
enum: ["mission", "service", "artifact", "usage", "qscore", "entitlement", "system"],
|
||||
}).notNull().default("service"),
|
||||
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
|
||||
receivedAt: timestamp("received_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
mission: jsonb("mission").$type<Record<string, unknown>>(),
|
||||
subject: jsonb("subject").$type<Record<string, unknown>>(),
|
||||
correlation: jsonb("correlation").$type<Record<string, unknown>>(),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
|
||||
raw: jsonb("raw").$type<Record<string, unknown>>(),
|
||||
dedupeKey: text("dedupe_key"),
|
||||
processingStatus: text("processing_status", {
|
||||
enum: ["pending", "processing", "processed", "failed", "unresolved"],
|
||||
}).notNull().default("pending"),
|
||||
processingError: text("processing_error"),
|
||||
processedAt: timestamp("processed_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("grow_events_user_idx").on(t.userId, t.occurredAt),
|
||||
statusIdx: index("grow_events_status_idx").on(t.processingStatus, t.receivedAt),
|
||||
sourceIdx: index("grow_events_source_idx").on(t.source, t.type, t.occurredAt),
|
||||
dedupeIdx: uniqueIndex("grow_events_dedupe_idx").on(t.dedupeKey),
|
||||
}),
|
||||
);
|
||||
|
||||
export const missionServiceSessions = pgTable(
|
||||
"mission_service_sessions",
|
||||
{
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
missionInstanceId: text("mission_instance_id").references(() => growActiveMissions.instanceId, { onDelete: "set null" }),
|
||||
missionId: text("mission_id"),
|
||||
stageId: text("stage_id"),
|
||||
serviceId: text("service_id").notNull(),
|
||||
externalId: text("external_id").notNull(),
|
||||
status: text("status").notNull().default("active"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
lastEventId: text("last_event_id").references(() => growEvents.id, { onDelete: "set null" }),
|
||||
lastCheckedAt: timestamp("last_checked_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("mission_service_sessions_user_idx").on(t.userId, t.updatedAt),
|
||||
externalIdx: uniqueIndex("mission_service_sessions_external_idx").on(t.serviceId, t.externalId),
|
||||
missionIdx: index("mission_service_sessions_mission_idx").on(t.missionInstanceId, t.stageId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const missionArtifacts = pgTable(
|
||||
"mission_artifacts",
|
||||
{
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
missionInstanceId: text("mission_instance_id").references(() => growActiveMissions.instanceId, { onDelete: "cascade" }),
|
||||
missionId: text("mission_id"),
|
||||
stageId: text("stage_id"),
|
||||
sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }),
|
||||
serviceId: text("service_id"),
|
||||
externalId: text("external_id"),
|
||||
type: text("type").notNull(),
|
||||
title: text("title").notNull(),
|
||||
status: text("status").notNull().default("ready"),
|
||||
summary: text("summary"),
|
||||
contentMd: text("content_md"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("mission_artifacts_user_idx").on(t.userId, t.createdAt),
|
||||
missionIdx: index("mission_artifacts_mission_idx").on(t.missionInstanceId, t.createdAt),
|
||||
externalIdx: index("mission_artifacts_external_idx").on(t.serviceId, t.externalId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const growQscoreSignals = pgTable(
|
||||
"grow_qscore_signals",
|
||||
{
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }),
|
||||
signalId: text("signal_id").notNull(),
|
||||
score: doublePrecision("score").notNull(),
|
||||
present: boolean("present").notNull().default(true),
|
||||
source: text("source"),
|
||||
raw: jsonb("raw").$type<Record<string, unknown>>(),
|
||||
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("grow_qscore_signals_user_idx").on(t.userId, t.occurredAt),
|
||||
signalIdx: index("grow_qscore_signals_signal_idx").on(t.signalId, t.occurredAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const growQscoreLatest = pgTable(
|
||||
"grow_qscore_latest",
|
||||
{
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
signalId: text("signal_id").notNull(),
|
||||
score: doublePrecision("score").notNull(),
|
||||
present: boolean("present").notNull().default(true),
|
||||
source: text("source"),
|
||||
sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }),
|
||||
raw: jsonb("raw").$type<Record<string, unknown>>(),
|
||||
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.userId, t.signalId] }),
|
||||
userIdx: index("grow_qscore_latest_user_idx").on(t.userId, t.updatedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const growQscoreProjectionState = pgTable("grow_qscore_projection_state", {
|
||||
userId: text("user_id").primaryKey().references(() => users.id, { onDelete: "cascade" }),
|
||||
score: integer("score").notNull().default(0),
|
||||
signalCount: integer("signal_count").notNull().default(0),
|
||||
dimensions: jsonb("dimensions").$type<Record<string, unknown>>(),
|
||||
summary: text("summary"),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const missionSuggestions = pgTable(
|
||||
"mission_suggestions",
|
||||
{
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
missionInstanceId: text("mission_instance_id").notNull().references(() => growActiveMissions.instanceId, { onDelete: "cascade" }),
|
||||
missionId: text("mission_id").notNull(),
|
||||
stageId: text("stage_id"),
|
||||
role: text("role").notNull(),
|
||||
type: text("type", { enum: ["action", "practice", "review", "artifact", "blocked", "insight"] }).notNull(),
|
||||
title: text("title").notNull(),
|
||||
body: text("body").notNull(),
|
||||
reason: text("reason"),
|
||||
priority: integer("priority").notNull().default(0),
|
||||
urgency: text("urgency", { enum: ["now", "today", "soon", "calm"] }).notNull().default("calm"),
|
||||
status: text("status", { enum: ["active", "done", "dismissed", "expired"] }).notNull().default("active"),
|
||||
ctaLabel: text("cta_label").notNull(),
|
||||
ctaHref: text("cta_href").notNull(),
|
||||
sourceRefs: jsonb("source_refs").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
|
||||
generatedBy: text("generated_by", { enum: ["deterministic", "agent", "manual"] }).notNull().default("deterministic"),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
missionIdx: index("mission_suggestions_mission_idx").on(t.userId, t.missionInstanceId, t.status, t.priority),
|
||||
roleIdx: index("mission_suggestions_role_idx").on(t.missionInstanceId, t.role, t.status),
|
||||
expiryIdx: index("mission_suggestions_expiry_idx").on(t.expiresAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const missionCoachRuns = pgTable(
|
||||
"mission_coach_runs",
|
||||
{
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
missionInstanceId: text("mission_instance_id").notNull().references(() => growActiveMissions.instanceId, { onDelete: "cascade" }),
|
||||
missionId: text("mission_id").notNull(),
|
||||
status: text("status", { enum: ["running", "completed", "failed"] }).notNull().default("running"),
|
||||
windowStart: timestamp("window_start", { withTimezone: true }).notNull(),
|
||||
windowEnd: timestamp("window_end", { withTimezone: true }).notNull(),
|
||||
summary: text("summary"),
|
||||
inputDigest: jsonb("input_digest").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
|
||||
output: jsonb("output").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
|
||||
model: text("model"),
|
||||
promptVersion: text("prompt_version").notNull().default("mission-coach-v1"),
|
||||
skillVersion: text("skill_version"),
|
||||
error: text("error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => ({ missionIdx: index("mission_coach_runs_mission_idx").on(t.userId, t.missionInstanceId, t.createdAt) }),
|
||||
);
|
||||
|
||||
export const growHomeNotifications = pgTable(
|
||||
"grow_home_notifications",
|
||||
{
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
moduleId: text("module_id", {
|
||||
enum: ["suggestions", "missions", "social", "pathways", "productivity", "rewards"],
|
||||
}).notNull(),
|
||||
title: text("title").notNull(),
|
||||
subtitle: text("subtitle").notNull(),
|
||||
tag: text("tag").notNull(),
|
||||
urgency: text("urgency", { enum: ["now", "today", "soon", "calm"] }).notNull().default("calm"),
|
||||
href: text("href").notNull(),
|
||||
source: text("source"),
|
||||
sourceRef: jsonb("source_ref").$type<Record<string, unknown>>(),
|
||||
priority: integer("priority").notNull().default(0),
|
||||
generatedBy: text("generated_by", { enum: ["deterministic", "agent", "demo", "manual"] }).notNull().default("deterministic"),
|
||||
reason: text("reason"),
|
||||
status: text("status", { enum: ["active", "dismissed", "expired"] }).notNull().default("active"),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("grow_home_notifications_user_idx").on(t.userId, t.status, t.priority),
|
||||
moduleIdx: index("grow_home_notifications_module_idx").on(t.userId, t.moduleId, t.status),
|
||||
expiryIdx: index("grow_home_notifications_expiry_idx").on(t.expiresAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export type GrowEventRow = typeof growEvents.$inferSelect;
|
||||
export type NewGrowEvent = typeof growEvents.$inferInsert;
|
||||
export type MissionSuggestionRow = typeof missionSuggestions.$inferSelect;
|
||||
export type NewMissionSuggestion = typeof missionSuggestions.$inferInsert;
|
||||
export type MissionCoachRunRow = typeof missionCoachRuns.$inferSelect;
|
||||
export type GrowHomeNotificationRow = typeof growHomeNotifications.$inferSelect;
|
||||
export type NewGrowHomeNotification = typeof growHomeNotifications.$inferInsert;
|
||||
|
||||
@@ -86,9 +86,9 @@ async function getCentralGiteaClient(): Promise<GiteaClient> {
|
||||
if (!centralGiteaClient) {
|
||||
const token = config.giteaAdminToken;
|
||||
if (token) {
|
||||
centralGiteaClient = new GiteaClient(config.giteaUrl, { kind: "token", token });
|
||||
centralGiteaClient = new GiteaClient(config.giteaInternalUrl, { kind: "token", token });
|
||||
} else {
|
||||
centralGiteaClient = new GiteaClient(config.giteaUrl, {
|
||||
centralGiteaClient = new GiteaClient(config.giteaInternalUrl, {
|
||||
kind: "basic",
|
||||
username: config.giteaAdminUser,
|
||||
password: config.giteaAdminPassword,
|
||||
@@ -100,7 +100,7 @@ async function getCentralGiteaClient(): Promise<GiteaClient> {
|
||||
|
||||
export async function ensureCentralGiteaReady(): Promise<void> {
|
||||
if (centralGiteaReady) return;
|
||||
await waitForGitea(config.giteaUrl, 120_000);
|
||||
await waitForGitea(config.giteaInternalUrl, 120_000);
|
||||
const client = await getCentralGiteaClient();
|
||||
|
||||
// Ensure the org exists (changes.md §2A: single org manages all users).
|
||||
@@ -111,7 +111,7 @@ export async function ensureCentralGiteaReady(): Promise<void> {
|
||||
}
|
||||
|
||||
centralGiteaReady = true;
|
||||
log.info({ url: config.giteaUrl, org: config.giteaOrgName }, "central Gitea ready");
|
||||
log.info({ url: config.giteaInternalUrl, org: config.giteaOrgName }, "central Gitea ready");
|
||||
}
|
||||
|
||||
// ── Git clone into OpenCode workspace (changes.md §4 step 3) ──
|
||||
@@ -152,8 +152,9 @@ async function cloneRepoIntoContainer(opts: {
|
||||
|
||||
let cmd: string[];
|
||||
if (checkOutput.includes("exists")) {
|
||||
// Pull latest changes.
|
||||
cmd = ["sh", "-c", "cd /workspace && git pull origin main 2>&1 || echo 'pull failed, attempting fresh clone'"];
|
||||
// Existing workspace template may have a local git repo without origin.
|
||||
// Ensure origin points at the user's Gitea repo before pulling.
|
||||
cmd = ["sh", "-c", `cd /workspace && (git remote get-url origin >/dev/null 2>&1 && git remote set-url origin "${authUrl}" || git remote add origin "${authUrl}") && git pull origin main --allow-unrelated-histories 2>&1 || true`];
|
||||
} else {
|
||||
// Clone into /workspace (remove any placeholder files first, then clone).
|
||||
cmd = [
|
||||
@@ -199,7 +200,7 @@ export async function syncWorkspaceToGit(userId: string, message?: string): Prom
|
||||
const commitMsg = message ?? `growqr: workspace sync at ${new Date().toISOString()}`;
|
||||
|
||||
// Build authenticated remote URL for push.
|
||||
let authUrl = `${config.giteaUrl}/${encodeURIComponent(stack.giteaRepoOwner)}/${encodeURIComponent(stack.giteaRepoName)}.git`;
|
||||
let authUrl = `${config.giteaPublicUrl}/${encodeURIComponent(stack.giteaRepoOwner)}/${encodeURIComponent(stack.giteaRepoName)}.git`;
|
||||
if (config.giteaAdminToken) {
|
||||
authUrl = authUrl.replace("://", `://${encodeURIComponent(config.giteaAdminToken)}@`);
|
||||
} else {
|
||||
@@ -209,7 +210,7 @@ export async function syncWorkspaceToGit(userId: string, message?: string): Prom
|
||||
// Set the remote URL with auth, add all, commit, push.
|
||||
const cmd = [
|
||||
"sh", "-c",
|
||||
`git remote set-url origin "${authUrl}" 2>/dev/null; ` +
|
||||
`(git remote get-url origin >/dev/null 2>&1 && git remote set-url origin "${authUrl}" || git remote add origin "${authUrl}"); ` +
|
||||
`git config user.email "growqr@local" && git config user.name "GrowQR"; ` +
|
||||
`git add -A && git commit -m "${commitMsg.replace(/"/g, '\\"')}" 2>/dev/null; ` +
|
||||
`git push origin main 2>&1`,
|
||||
@@ -250,10 +251,23 @@ async function startOpencodeContainer(opts: {
|
||||
|
||||
const existing = await findExistingContainer(name);
|
||||
if (existing) {
|
||||
if (existing.State !== "running") {
|
||||
await docker.getContainer(existing.Id).start().catch(() => undefined);
|
||||
const existingContainer = docker.getContainer(existing.Id);
|
||||
const info = await existingContainer.inspect().catch(() => null);
|
||||
const labels = info?.Config?.Labels ?? {};
|
||||
const current =
|
||||
labels["growqr.imageVersion"] === config.opencodeImageVersion &&
|
||||
labels["growqr.promptVersion"] === config.promptVersion &&
|
||||
info?.Config?.Image === config.opencodeImage;
|
||||
|
||||
if (current) {
|
||||
if (existing.State !== "running") {
|
||||
await existingContainer.start().catch(() => undefined);
|
||||
}
|
||||
return { id: existing.Id, name };
|
||||
}
|
||||
return { id: existing.Id, name };
|
||||
|
||||
log.info({ userId: opts.userId, name }, "removing stale OpenCode container");
|
||||
await existingContainer.remove({ force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
// Sub-agents are loaded as prompt modules at build time (changes.md §2D).
|
||||
@@ -269,7 +283,7 @@ async function startOpencodeContainer(opts: {
|
||||
`GROWQR_PROMPT_VERSION=${config.promptVersion}`,
|
||||
`GROWQR_MIGRATION_VERSION=${config.migrationVersion}`,
|
||||
`GROWQR_USER_ID=${opts.userId}`,
|
||||
`GROWQR_GITEA_URL=${config.giteaUrl}`,
|
||||
`GROWQR_GITEA_URL=${config.giteaPublicUrl}`,
|
||||
],
|
||||
WorkingDir: "/workspace",
|
||||
HostConfig: {
|
||||
@@ -320,7 +334,30 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
|
||||
where: eq(userStacks.userId, userId),
|
||||
});
|
||||
if (existing && existing.status === "running") {
|
||||
return existing;
|
||||
const current =
|
||||
existing.imageVersion === config.opencodeImageVersion &&
|
||||
existing.migrationVersion === config.migrationVersion &&
|
||||
existing.promptVersion === config.promptVersion;
|
||||
if (current) {
|
||||
const containerName = existing.opencodeContainerName ?? safeContainerName("growqr-opencode", userId);
|
||||
const container = await findExistingContainer(containerName);
|
||||
if (container) return existing;
|
||||
log.warn({ userId, containerName }, "OpenCode stack marked running but container is missing; reprovisioning");
|
||||
}
|
||||
|
||||
log.info(
|
||||
{
|
||||
userId,
|
||||
currentImage: existing.imageVersion,
|
||||
targetImage: config.opencodeImageVersion,
|
||||
currentMigration: existing.migrationVersion,
|
||||
targetMigration: config.migrationVersion,
|
||||
currentPrompt: existing.promptVersion,
|
||||
targetPrompt: config.promptVersion,
|
||||
},
|
||||
"recreating stale OpenCode stack before provisioning",
|
||||
);
|
||||
await stopUserStack(userId);
|
||||
}
|
||||
|
||||
await ensureDir(userDataDir(userId));
|
||||
@@ -417,7 +454,7 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
|
||||
try {
|
||||
await cloneRepoIntoContainer({
|
||||
containerId: opencode.id,
|
||||
repoUrl: `${config.giteaUrl}/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}.git`,
|
||||
repoUrl: `${config.giteaPublicUrl}/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}.git`,
|
||||
giteaToken: config.giteaAdminToken || undefined,
|
||||
giteaUser: config.giteaAdminUser,
|
||||
giteaPassword: !config.giteaAdminToken ? config.giteaAdminPassword : undefined,
|
||||
|
||||
93
src/events/envelope.ts
Normal file
93
src/events/envelope.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export type GrowEventCategory =
|
||||
| "mission"
|
||||
| "service"
|
||||
| "artifact"
|
||||
| "usage"
|
||||
| "qscore"
|
||||
| "entitlement"
|
||||
| "system";
|
||||
|
||||
export type GrowEventSubject = {
|
||||
kind: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type GrowEventMissionRef = {
|
||||
instanceId?: string;
|
||||
missionId?: string;
|
||||
stageId?: string;
|
||||
};
|
||||
|
||||
export type GrowEventCorrelation = {
|
||||
requestId?: string;
|
||||
sessionId?: string;
|
||||
resumeId?: string;
|
||||
externalId?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type GrowEventEnvelope = {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
orgId?: string;
|
||||
source: string;
|
||||
type: string;
|
||||
category: GrowEventCategory;
|
||||
occurredAt: string;
|
||||
mission?: GrowEventMissionRef;
|
||||
subject?: GrowEventSubject;
|
||||
correlation?: GrowEventCorrelation;
|
||||
payload: Record<string, unknown>;
|
||||
raw?: Record<string, unknown>;
|
||||
dedupeKey?: string;
|
||||
};
|
||||
|
||||
export type StoredGrowEvent = GrowEventEnvelope & {
|
||||
id: string;
|
||||
userId?: string;
|
||||
receivedAt?: string;
|
||||
processingStatus?: "pending" | "processing" | "processed" | "failed" | "unresolved";
|
||||
};
|
||||
|
||||
export type QscoreSignal = {
|
||||
signalId: string;
|
||||
score: number;
|
||||
present: boolean;
|
||||
source?: string;
|
||||
raw?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function isGrowEventCategory(value: unknown): value is GrowEventCategory {
|
||||
return (
|
||||
value === "mission" ||
|
||||
value === "service" ||
|
||||
value === "artifact" ||
|
||||
value === "usage" ||
|
||||
value === "qscore" ||
|
||||
value === "entitlement" ||
|
||||
value === "system"
|
||||
);
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function getNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const n = Number(value);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function clampScore(score: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(score * 100) / 100));
|
||||
}
|
||||
79
src/events/normalize.ts
Normal file
79
src/events/normalize.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { asRecord, getString, isGrowEventCategory, type GrowEventEnvelope } from "./envelope.js";
|
||||
|
||||
function parseDate(value: unknown): string {
|
||||
const raw = getString(value);
|
||||
if (!raw) return new Date().toISOString();
|
||||
const date = new Date(raw);
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalizeSubject(value: unknown) {
|
||||
const record = asRecord(value);
|
||||
const kind = getString(record.kind);
|
||||
const id = getString(record.id);
|
||||
return kind && id ? { kind, id } : undefined;
|
||||
}
|
||||
|
||||
function normalizeMission(value: unknown) {
|
||||
const record = asRecord(value);
|
||||
const instanceId = getString(record.instanceId ?? record.instance_id ?? record.mission_instance_id);
|
||||
const missionId = getString(record.missionId ?? record.mission_id);
|
||||
const stageId = getString(record.stageId ?? record.stage_id);
|
||||
if (!instanceId && !missionId && !stageId) return undefined;
|
||||
return { instanceId, missionId, stageId };
|
||||
}
|
||||
|
||||
function normalizeCorrelation(value: unknown) {
|
||||
const record = asRecord(value);
|
||||
const correlation: Record<string, unknown> = { ...record };
|
||||
const requestId = getString(record.requestId ?? record.request_id);
|
||||
const sessionId = getString(record.sessionId ?? record.session_id);
|
||||
const resumeId = getString(record.resumeId ?? record.resume_id);
|
||||
const externalId = getString(record.externalId ?? record.external_id);
|
||||
if (requestId) correlation.requestId = requestId;
|
||||
if (sessionId) correlation.sessionId = sessionId;
|
||||
if (resumeId) correlation.resumeId = resumeId;
|
||||
if (externalId) correlation.externalId = externalId;
|
||||
return Object.keys(correlation).length ? correlation : undefined;
|
||||
}
|
||||
|
||||
export function normalizeGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): GrowEventEnvelope {
|
||||
const raw = asRecord(input);
|
||||
const payload = asRecord(raw.payload ?? raw.data ?? raw.event_payload);
|
||||
const correlation = normalizeCorrelation(raw.correlation ?? {
|
||||
session_id: raw.session_id ?? payload.session_id,
|
||||
resume_id: raw.resume_id ?? payload.resume_id,
|
||||
external_id: raw.external_id ?? payload.external_id,
|
||||
request_id: raw.request_id ?? payload.request_id,
|
||||
});
|
||||
const subject = normalizeSubject(raw.subject) ?? (() => {
|
||||
const kind = getString(raw.subject_kind ?? payload.subject_kind);
|
||||
const id = getString(raw.subject_id ?? payload.subject_id);
|
||||
return kind && id ? { kind, id } : undefined;
|
||||
})();
|
||||
|
||||
const source = overrides.source ?? getString(raw.source) ?? "unknown-service";
|
||||
const type = getString(raw.type ?? raw.event_type ?? raw.action) ?? "service.event";
|
||||
const categoryRaw = raw.category ?? payload.category;
|
||||
const category = isGrowEventCategory(categoryRaw) ? categoryRaw : "service";
|
||||
const userId = overrides.userId ?? getString(raw.userId ?? raw.user_id ?? payload.userId ?? payload.user_id);
|
||||
const orgId = getString(raw.orgId ?? raw.org_id ?? payload.orgId ?? payload.org_id);
|
||||
const dedupeKey = getString(raw.dedupeKey ?? raw.dedupe_key) ?? getString(raw.id) ?? undefined;
|
||||
|
||||
return {
|
||||
id: getString(raw.id) ?? randomUUID(),
|
||||
userId,
|
||||
orgId,
|
||||
source,
|
||||
type,
|
||||
category,
|
||||
occurredAt: parseDate(raw.occurredAt ?? raw.occurred_at ?? raw.timestamp ?? raw.created_at),
|
||||
mission: normalizeMission(raw.mission),
|
||||
subject,
|
||||
correlation,
|
||||
payload,
|
||||
raw,
|
||||
dedupeKey,
|
||||
};
|
||||
}
|
||||
104
src/events/projectors/projection-agent.ts
Normal file
104
src/events/projectors/projection-agent.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { config } from "../../config.js";
|
||||
import { createChatCompletion, type LlmMessage } from "../../lib/llm.js";
|
||||
import type { GrowEventRow } from "../../db/schema.js";
|
||||
import type { QscoreSignal } from "../envelope.js";
|
||||
|
||||
export type ProjectionInsight = {
|
||||
summary: string;
|
||||
confidence: "low" | "medium" | "high";
|
||||
recommendedActions: string[];
|
||||
missionStageHints: Array<{
|
||||
stageId: string;
|
||||
status?: "locked" | "ready" | "in_progress" | "blocked" | "done";
|
||||
progressPercent?: number;
|
||||
reason?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function fallbackInsight(event: GrowEventRow, qscoreSignals: QscoreSignal[]): ProjectionInsight {
|
||||
return {
|
||||
summary: `${event.type} received from ${event.source}.`,
|
||||
confidence: qscoreSignals.length ? "medium" : "low",
|
||||
recommendedActions: [],
|
||||
missionStageHints: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
const firstBrace = trimmed.indexOf("{");
|
||||
const lastBrace = trimmed.lastIndexOf("}");
|
||||
if (firstBrace < 0 || lastBrace < firstBrace) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed.slice(firstBrace, lastBrace + 1));
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInsight(value: Record<string, unknown> | null, fallback: ProjectionInsight): ProjectionInsight {
|
||||
if (!value) return fallback;
|
||||
const summary = typeof value.summary === "string" && value.summary.trim() ? value.summary.trim() : fallback.summary;
|
||||
const confidence = value.confidence === "high" || value.confidence === "medium" || value.confidence === "low" ? value.confidence : fallback.confidence;
|
||||
const recommendedActions = Array.isArray(value.recommendedActions)
|
||||
? value.recommendedActions.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
const missionStageHints = Array.isArray(value.missionStageHints)
|
||||
? value.missionStageHints.flatMap((item) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
||||
const row = item as Record<string, unknown>;
|
||||
if (typeof row.stageId !== "string") return [];
|
||||
const status: ProjectionInsight["missionStageHints"][number]["status"] = row.status === "locked" || row.status === "ready" || row.status === "in_progress" || row.status === "blocked" || row.status === "done" ? row.status : undefined;
|
||||
return [{
|
||||
stageId: row.stageId,
|
||||
status,
|
||||
progressPercent: typeof row.progressPercent === "number" ? row.progressPercent : undefined,
|
||||
reason: typeof row.reason === "string" ? row.reason : undefined,
|
||||
}];
|
||||
})
|
||||
: [];
|
||||
return { summary, confidence, recommendedActions, missionStageHints };
|
||||
}
|
||||
|
||||
export async function getProjectionInsight(input: {
|
||||
event: GrowEventRow;
|
||||
qscoreSignals: QscoreSignal[];
|
||||
activeMissionIds: string[];
|
||||
}): Promise<ProjectionInsight> {
|
||||
const fallback = fallbackInsight(input.event, input.qscoreSignals);
|
||||
if (!config.llmApiKey) return fallback;
|
||||
|
||||
const system = `You are GrowQR's deterministic event projection assistant. You do not call tools. You read one raw product event and return only compact JSON. Your job is to help backend reducers interpret business meaning without inventing facts. If evidence is missing, use low confidence and empty hints.`;
|
||||
const user = {
|
||||
instruction: "Return JSON only with keys: summary, confidence, recommendedActions, missionStageHints. missionStageHints may include interview-to-offer stage ids: baseline-qscore, resume-fit-scan, interview-prep-plan, behavioral-story-bank, mock-interview, final-readiness-report. Only mark a stage done when the event clearly proves completion.",
|
||||
event: {
|
||||
id: input.event.id,
|
||||
source: input.event.source,
|
||||
type: input.event.type,
|
||||
category: input.event.category,
|
||||
payload: input.event.payload,
|
||||
correlation: input.event.correlation,
|
||||
subject: input.event.subject,
|
||||
},
|
||||
qscoreSignals: input.qscoreSignals,
|
||||
activeMissionIds: input.activeMissionIds,
|
||||
};
|
||||
|
||||
const messages: LlmMessage[] = [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: JSON.stringify(user) },
|
||||
];
|
||||
|
||||
try {
|
||||
const completion = await createChatCompletion({
|
||||
model: process.env.PROJECTION_AGENT_MODEL ?? config.agentModel,
|
||||
messages,
|
||||
tools: [],
|
||||
maxTokens: 700,
|
||||
});
|
||||
return normalizeInsight(parseJsonObject(completion.content), fallback);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
189
src/events/projectors/qscore-projector.ts
Normal file
189
src/events/projectors/qscore-projector.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growQscoreLatest, growQscoreProjectionState, growQscoreSignals, type GrowEventRow } from "../../db/schema.js";
|
||||
import { asRecord, clampScore, getNumber, type QscoreSignal } from "../envelope.js";
|
||||
|
||||
function signal(signalId: string, score: number, raw?: Record<string, unknown>, present = true): QscoreSignal {
|
||||
return { signalId, score: clampScore(score), present, raw };
|
||||
}
|
||||
|
||||
function nestedNumber(record: Record<string, unknown>, keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const direct = getNumber(record[key]);
|
||||
if (direct !== undefined) return direct;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractResumeSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
const analysis = asRecord(payload.analysis ?? payload.result ?? payload);
|
||||
const scoreBreakdown = Array.isArray(analysis.score_breakdown) ? analysis.score_breakdown : [];
|
||||
const dimensions = Array.isArray(analysis.dimensional_scores) ? analysis.dimensional_scores : [];
|
||||
|
||||
const byCategory = new Map<string, number>();
|
||||
for (const item of scoreBreakdown) {
|
||||
const row = asRecord(item);
|
||||
const category = typeof row.category === "string" ? row.category : undefined;
|
||||
const score = getNumber(row.score);
|
||||
if (category && score !== undefined) byCategory.set(category, score);
|
||||
}
|
||||
|
||||
const byDimension = new Map<string, number>();
|
||||
for (const item of dimensions) {
|
||||
const row = asRecord(item);
|
||||
const dimension = typeof row.dimension === "string" ? row.dimension : undefined;
|
||||
const score = getNumber(row.score);
|
||||
if (dimension && score !== undefined) byDimension.set(dimension, score);
|
||||
}
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
if (event.type.includes("uploaded") || event.type.includes("created") || event.type.includes("analysis")) {
|
||||
signals.push(signal("resume.uploaded", 100, { eventId: event.id }));
|
||||
}
|
||||
const ats = byCategory.get("ATS Compatibility") ?? nestedNumber(analysis, ["ats_score", "ats_compatibility", "atsCompatibility"]);
|
||||
if (ats !== undefined) signals.push(signal("resume.ats_compatibility", ats, { eventId: event.id }));
|
||||
const keywords = byDimension.get("Keywords") ?? nestedNumber(analysis, ["keyword_score", "keyword_relevance", "keywords"]);
|
||||
if (keywords !== undefined) {
|
||||
signals.push(signal("resume.keyword_relevance", keywords, { eventId: event.id }));
|
||||
signals.push(signal("resume.technical_keywords", keywords, { eventId: event.id }));
|
||||
}
|
||||
const quantification = byDimension.get("Quantification") ?? nestedNumber(analysis, ["quantification_score"]);
|
||||
if (quantification !== undefined) signals.push(signal("resume.quantified_achievements", quantification, { eventId: event.id }));
|
||||
const contentQuality = byCategory.get("Content Quality") ?? nestedNumber(analysis, ["content_quality", "clarity_score"]);
|
||||
if (contentQuality !== undefined) signals.push(signal("resume.grammar_clarity", contentQuality, { eventId: event.id }));
|
||||
const formatting = byCategory.get("Formatting") ?? nestedNumber(analysis, ["formatting", "format_score"]);
|
||||
if (formatting !== undefined) signals.push(signal("resume.format_structure", formatting, { eventId: event.id }));
|
||||
const impact = byCategory.get("Impact Demonstration") ?? nestedNumber(analysis, ["impact_score"]);
|
||||
if (impact !== undefined) {
|
||||
signals.push(signal("resume.leadership_indicators", impact, { eventId: event.id }));
|
||||
signals.push(signal("resume.impact_statements", impact, { eventId: event.id }));
|
||||
}
|
||||
return signals;
|
||||
}
|
||||
|
||||
function extractInterviewSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
const review = asRecord(payload.review ?? payload.result ?? payload);
|
||||
const status = String(review.status ?? payload.status ?? "");
|
||||
if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return [];
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
signals.push(signal("interview.completed", 100, { eventId: event.id }));
|
||||
const overall = getNumber(review.overall_score ?? review.overallScore ?? payload.overall_score);
|
||||
if (overall !== undefined) signals.push(signal("interview.overall_score", overall, { eventId: event.id }));
|
||||
const rubric = asRecord(review.rubric_scores ?? review.rubricScores);
|
||||
const content = getNumber(rubric.content_quality ?? rubric.content ?? rubric.communication ?? review.communication_score);
|
||||
if (content !== undefined) signals.push(signal("interview.response_clarity", content, { eventId: event.id }));
|
||||
const roleAlignment = getNumber(rubric.role_alignment ?? rubric.technical_accuracy ?? review.role_alignment_score);
|
||||
if (roleAlignment !== undefined) signals.push(signal("interview.technical_accuracy", roleAlignment, { eventId: event.id }));
|
||||
const language = getNumber(rubric.language ?? review.language_score);
|
||||
if (language !== undefined) signals.push(signal("interview.behavioral_quality", language, { eventId: event.id }));
|
||||
const historical = asRecord(review.historical_comparison ?? review.historicalComparison);
|
||||
const delta = getNumber(historical.overall_delta ?? historical.overallDelta);
|
||||
if (delta !== undefined) signals.push(signal("interview.improvement_over_time", 50 + delta * 2.5, { eventId: event.id, delta }));
|
||||
return signals;
|
||||
}
|
||||
|
||||
function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
const review = asRecord(payload.review ?? payload.result ?? payload);
|
||||
const status = String(review.status ?? payload.status ?? "");
|
||||
if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return [];
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
signals.push(signal("roleplay.completed", 100, { eventId: event.id }));
|
||||
const rubric = asRecord(review.rubric_scores ?? review.rubricScores);
|
||||
const scenario = getNumber(rubric.scenario_adherence ?? review.scenario_adherence_score);
|
||||
if (scenario !== undefined) signals.push(signal("roleplay.situational_judgment", scenario, { eventId: event.id }));
|
||||
const empathy = getNumber(rubric.emotional_intelligence ?? review.emotional_intelligence_score);
|
||||
if (empathy !== undefined) signals.push(signal("roleplay.empathy_demonstrated", empathy, { eventId: event.id }));
|
||||
const adaptability = getNumber(rubric.adaptability ?? review.adaptability_score);
|
||||
if (adaptability !== undefined) signals.push(signal("roleplay.problem_resolution", adaptability, { eventId: event.id }));
|
||||
const communication = getNumber(rubric.content ?? rubric.communication ?? review.communication_score);
|
||||
if (communication !== undefined) signals.push(signal("roleplay.communication_effectiveness", communication, { eventId: event.id }));
|
||||
const historical = asRecord(review.historical_comparison ?? review.historicalComparison);
|
||||
const delta = getNumber(historical.overall_delta ?? historical.overallDelta);
|
||||
if (delta !== undefined) signals.push(signal("roleplay.improvement_over_time", 50 + delta * 2.5, { eventId: event.id, delta }));
|
||||
return signals;
|
||||
}
|
||||
|
||||
export function extractQscoreSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const source = event.source.toLowerCase();
|
||||
if (source.includes("resume") || event.type.startsWith("resume.")) return extractResumeSignals(event);
|
||||
if (source.includes("interview") || event.type.startsWith("interview.")) return extractInterviewSignals(event);
|
||||
if (source.includes("roleplay") || event.type.startsWith("roleplay.")) return extractRoleplaySignals(event);
|
||||
if (event.type === "mission.interview_to_offer.started") {
|
||||
return [signal("goals.goals_set", 100, { eventId: event.id })];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function applyQscoreProjection(event: GrowEventRow) {
|
||||
if (!event.userId) return { signals: [], score: undefined };
|
||||
const signals = extractQscoreSignals(event);
|
||||
if (!signals.length) return { signals, score: undefined };
|
||||
|
||||
for (const item of signals) {
|
||||
await db.insert(growQscoreSignals).values({
|
||||
userId: event.userId,
|
||||
sourceEventId: event.id,
|
||||
signalId: item.signalId,
|
||||
score: item.score,
|
||||
present: item.present,
|
||||
source: event.source,
|
||||
raw: item.raw,
|
||||
occurredAt: event.occurredAt,
|
||||
});
|
||||
|
||||
await db.insert(growQscoreLatest).values({
|
||||
userId: event.userId,
|
||||
signalId: item.signalId,
|
||||
score: item.score,
|
||||
present: item.present,
|
||||
source: event.source,
|
||||
sourceEventId: event.id,
|
||||
raw: item.raw,
|
||||
occurredAt: event.occurredAt,
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: [growQscoreLatest.userId, growQscoreLatest.signalId],
|
||||
set: {
|
||||
score: item.score,
|
||||
present: item.present,
|
||||
source: event.source,
|
||||
sourceEventId: event.id,
|
||||
raw: item.raw,
|
||||
occurredAt: event.occurredAt,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const [aggregate] = await db
|
||||
.select({ score: sql<number>`round(avg(${growQscoreLatest.score}))::int`, count: sql<number>`count(*)::int` })
|
||||
.from(growQscoreLatest)
|
||||
.where(and(eq(growQscoreLatest.userId, event.userId), eq(growQscoreLatest.present, true)));
|
||||
|
||||
const score = aggregate?.score ?? 0;
|
||||
const signalCount = aggregate?.count ?? 0;
|
||||
await db.insert(growQscoreProjectionState).values({
|
||||
userId: event.userId,
|
||||
score,
|
||||
signalCount,
|
||||
dimensions: { latestSignalIds: signals.map((s) => s.signalId) },
|
||||
summary: `Estimated readiness score from ${signalCount} current signal${signalCount === 1 ? "" : "s"}.`,
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: growQscoreProjectionState.userId,
|
||||
set: {
|
||||
score,
|
||||
signalCount,
|
||||
dimensions: { latestSignalIds: signals.map((s) => s.signalId) },
|
||||
summary: `Estimated readiness score from ${signalCount} current signal${signalCount === 1 ? "" : "s"}.`,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { signals, score };
|
||||
}
|
||||
76
src/events/projectors/service-session-projector.ts
Normal file
76
src/events/projectors/service-session-projector.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { missionServiceSessions, type GrowEventRow } from "../../db/schema.js";
|
||||
import { asRecord, getString } from "../envelope.js";
|
||||
import { normalizeServiceId } from "../record-grow-event.js";
|
||||
|
||||
function extractExternalId(event: GrowEventRow): string | undefined {
|
||||
const correlation = asRecord(event.correlation);
|
||||
const payload = event.payload ?? {};
|
||||
return getString(
|
||||
correlation.sessionId ??
|
||||
correlation.session_id ??
|
||||
correlation.externalId ??
|
||||
correlation.external_id ??
|
||||
payload.session_id ??
|
||||
payload.sessionId ??
|
||||
payload.id,
|
||||
);
|
||||
}
|
||||
|
||||
function statusFor(event: GrowEventRow): string {
|
||||
const payload = event.payload ?? {};
|
||||
const explicit = getString(payload.status);
|
||||
if (explicit) return explicit;
|
||||
if (event.type.includes("review_completed") || event.type.includes("completed")) return "completed";
|
||||
if (event.type.includes("failed")) return "failed";
|
||||
if (event.type.includes("configured") || event.type.includes("created")) return "active";
|
||||
return "active";
|
||||
}
|
||||
|
||||
export async function applyServiceSessionProjection(event: GrowEventRow) {
|
||||
if (!event.userId) return null;
|
||||
const externalId = extractExternalId(event);
|
||||
if (!externalId) return null;
|
||||
const serviceId = normalizeServiceId(event.source);
|
||||
if (!["interview", "roleplay", "resume"].includes(serviceId)) return null;
|
||||
|
||||
const mission = asRecord(event.mission);
|
||||
const metadata = {
|
||||
lastType: event.type,
|
||||
subject: event.subject,
|
||||
payloadStatus: event.payload?.status,
|
||||
};
|
||||
|
||||
const [row] = await db
|
||||
.insert(missionServiceSessions)
|
||||
.values({
|
||||
userId: event.userId,
|
||||
missionInstanceId: getString(mission.instanceId ?? mission.instance_id),
|
||||
missionId: getString(mission.missionId ?? mission.mission_id),
|
||||
stageId: getString(mission.stageId ?? mission.stage_id),
|
||||
serviceId,
|
||||
externalId,
|
||||
status: statusFor(event),
|
||||
metadata,
|
||||
lastEventId: event.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [missionServiceSessions.serviceId, missionServiceSessions.externalId],
|
||||
set: {
|
||||
status: statusFor(event),
|
||||
metadata,
|
||||
lastEventId: event.id,
|
||||
lastCheckedAt: event.type.includes("review") ? new Date() : undefined,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Touch the row if Drizzle ever returns no row for an upsert variant.
|
||||
if (!row) {
|
||||
await db.update(missionServiceSessions).set({ updatedAt: new Date() }).where(eq(missionServiceSessions.externalId, externalId));
|
||||
}
|
||||
return row ?? null;
|
||||
}
|
||||
114
src/events/record-grow-event.ts
Normal file
114
src/events/record-grow-event.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { growEvents, missionServiceSessions, users, type GrowEventRow } from "../db/schema.js";
|
||||
import { asRecord, getString, type GrowEventEnvelope } from "./envelope.js";
|
||||
import { normalizeGrowEvent } from "./normalize.js";
|
||||
|
||||
async function ensureUser(userId: string) {
|
||||
await db
|
||||
.insert(users)
|
||||
.values({ id: userId, email: `${userId}@service.local`, displayName: userId })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
async function resolveUserId(event: GrowEventEnvelope): Promise<string | undefined> {
|
||||
if (event.userId) return event.userId;
|
||||
const sessionId = getString(event.correlation?.sessionId ?? event.correlation?.session_id);
|
||||
const serviceId = normalizeServiceId(event.source);
|
||||
if (sessionId) {
|
||||
const [row] = await db
|
||||
.select({ userId: missionServiceSessions.userId })
|
||||
.from(missionServiceSessions)
|
||||
.where(and(eq(missionServiceSessions.serviceId, serviceId), eq(missionServiceSessions.externalId, sessionId)))
|
||||
.limit(1);
|
||||
if (row?.userId) return row.userId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeServiceId(source: string): string {
|
||||
if (source.includes("interview")) return "interview";
|
||||
if (source.includes("roleplay")) return "roleplay";
|
||||
if (source.includes("resume")) return "resume";
|
||||
if (source.includes("qscore")) return "qscore";
|
||||
return source;
|
||||
}
|
||||
|
||||
export async function recordGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): Promise<GrowEventRow> {
|
||||
const normalized = normalizeGrowEvent(input, overrides);
|
||||
const resolvedUserId = await resolveUserId(normalized);
|
||||
if (resolvedUserId) await ensureUser(resolvedUserId);
|
||||
|
||||
const processingStatus = resolvedUserId ? "pending" : "unresolved";
|
||||
if (normalized.dedupeKey) {
|
||||
const [existing] = await db.select().from(growEvents).where(eq(growEvents.dedupeKey, normalized.dedupeKey)).limit(1);
|
||||
if (existing) return existing;
|
||||
}
|
||||
|
||||
const values = {
|
||||
id: normalized.id,
|
||||
userId: resolvedUserId,
|
||||
orgId: normalized.orgId,
|
||||
source: normalized.source,
|
||||
type: normalized.type,
|
||||
category: normalized.category,
|
||||
occurredAt: new Date(normalized.occurredAt),
|
||||
mission: normalized.mission as Record<string, unknown> | undefined,
|
||||
subject: normalized.subject as Record<string, unknown> | undefined,
|
||||
correlation: normalized.correlation as Record<string, unknown> | undefined,
|
||||
payload: normalized.payload,
|
||||
raw: normalized.raw ?? asRecord(input),
|
||||
dedupeKey: normalized.dedupeKey,
|
||||
processingStatus,
|
||||
} satisfies typeof growEvents.$inferInsert;
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(growEvents)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: growEvents.id,
|
||||
set: {
|
||||
userId: values.userId,
|
||||
orgId: values.orgId,
|
||||
source: values.source,
|
||||
type: values.type,
|
||||
category: values.category,
|
||||
occurredAt: values.occurredAt,
|
||||
mission: values.mission,
|
||||
subject: values.subject,
|
||||
correlation: values.correlation,
|
||||
payload: values.payload,
|
||||
raw: values.raw,
|
||||
processingStatus: values.processingStatus,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!inserted) throw new Error("failed to record grow event");
|
||||
return inserted;
|
||||
}
|
||||
|
||||
export async function markGrowEventProcessing(eventId: string) {
|
||||
await db
|
||||
.update(growEvents)
|
||||
.set({ processingStatus: "processing", processingError: null })
|
||||
.where(eq(growEvents.id, eventId));
|
||||
}
|
||||
|
||||
export async function markGrowEventProcessed(eventId: string) {
|
||||
await db
|
||||
.update(growEvents)
|
||||
.set({ processingStatus: "processed", processingError: null, processedAt: new Date() })
|
||||
.where(eq(growEvents.id, eventId));
|
||||
}
|
||||
|
||||
export async function markGrowEventFailed(eventId: string, error: unknown) {
|
||||
await db
|
||||
.update(growEvents)
|
||||
.set({
|
||||
processingStatus: "failed",
|
||||
processingError: error instanceof Error ? error.message : String(error),
|
||||
processedAt: new Date(),
|
||||
})
|
||||
.where(eq(growEvents.id, eventId));
|
||||
}
|
||||
324
src/events/redis-consumer.ts
Normal file
324
src/events/redis-consumer.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { config } from "../config.js";
|
||||
import { log } from "../log.js";
|
||||
import { recordGrowEvent } from "./record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "./route-to-user-actor.js";
|
||||
|
||||
// This file has two Redis ingestion modes:
|
||||
// 1. Canonical GrowEvent stream: grow.events.raw — future service event bus.
|
||||
// 2. Legacy A2A observer: watches existing tasks:{service} streams + responses:* pub/sub
|
||||
// so backend can capture current service emissions without service changes.
|
||||
|
||||
type RedisMessage = { id: string; message: Record<string, string> };
|
||||
type RedisStreamResponse = Array<{ name: string; messages: RedisMessage[] }>;
|
||||
|
||||
type RedisClientLike = {
|
||||
connect: () => Promise<void>;
|
||||
duplicate?: () => RedisClientLike;
|
||||
on: (event: string, listener: (err: unknown) => void) => void;
|
||||
xGroupCreate: (stream: string, group: string, id: string, opts?: Record<string, unknown>) => Promise<unknown>;
|
||||
xReadGroup: (group: string, consumer: string, streams: { key: string; id: string }, opts?: Record<string, unknown>) => Promise<RedisStreamResponse | null>;
|
||||
xAck: (stream: string, group: string, id: string) => Promise<unknown>;
|
||||
pSubscribe?: (pattern: string, listener: (message: string, channel: string) => void | Promise<void>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type ServiceRedisSpec = {
|
||||
serviceId: "interview" | "roleplay" | "resume";
|
||||
agentName: "interview-service" | "roleplay-service" | "resume-builder";
|
||||
redisUrl: string;
|
||||
};
|
||||
|
||||
type LegacyTaskContext = {
|
||||
taskId: string;
|
||||
userId?: string;
|
||||
action?: string;
|
||||
params?: Record<string, unknown>;
|
||||
userContext?: Record<string, unknown>;
|
||||
sessionStart?: boolean;
|
||||
streamEntryId: string;
|
||||
seenAt: string;
|
||||
};
|
||||
|
||||
const legacyTaskContexts = new Map<string, LegacyTaskContext>();
|
||||
|
||||
async function loadRedisCreateClient(): Promise<(opts: { url: string }) => RedisClientLike> {
|
||||
const dynamicImport = Function("specifier", "return import(specifier)") as (specifier: string) => Promise<unknown>;
|
||||
const mod = (await dynamicImport("redis")) as { createClient?: (opts: { url: string }) => RedisClientLike };
|
||||
if (!mod.createClient) throw new Error("redis package did not expose createClient");
|
||||
return mod.createClient;
|
||||
}
|
||||
|
||||
function parseFieldValue(value: string | undefined): unknown {
|
||||
if (!value) return value;
|
||||
if ((value.startsWith("{") && value.endsWith("}")) || (value.startsWith("[") && value.endsWith("]"))) {
|
||||
try { return JSON.parse(value); } catch { return value; }
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function fieldsToEvent(fields: Record<string, string>, stream: string) {
|
||||
const payload = fields.payload ? parseFieldValue(fields.payload) : parseFieldValue(fields.data ?? "{}");
|
||||
const correlation = fields.correlation ? parseFieldValue(fields.correlation) : undefined;
|
||||
const subject = fields.subject ? parseFieldValue(fields.subject) : undefined;
|
||||
const mission = fields.mission ? parseFieldValue(fields.mission) : undefined;
|
||||
return {
|
||||
id: fields.id,
|
||||
source: fields.source ?? stream,
|
||||
type: fields.type ?? fields.event_type ?? "service.event",
|
||||
category: fields.category ?? "service",
|
||||
userId: fields.userId ?? fields.user_id,
|
||||
orgId: fields.orgId ?? fields.org_id,
|
||||
occurredAt: fields.occurredAt ?? fields.occurred_at ?? fields.timestamp,
|
||||
mission,
|
||||
subject,
|
||||
correlation,
|
||||
payload: payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { value: payload },
|
||||
raw: fields,
|
||||
dedupeKey: fields.dedupeKey ?? fields.dedupe_key,
|
||||
};
|
||||
}
|
||||
|
||||
function serviceSpecs(): ServiceRedisSpec[] {
|
||||
const specs: ServiceRedisSpec[] = [
|
||||
{ serviceId: "interview", agentName: "interview-service", redisUrl: config.interviewRedisUrl },
|
||||
{ serviceId: "roleplay", agentName: "roleplay-service", redisUrl: config.roleplayRedisUrl },
|
||||
{ serviceId: "resume", agentName: "resume-builder", redisUrl: config.resumeRedisUrl },
|
||||
];
|
||||
return specs.filter((spec) => Boolean(spec.redisUrl));
|
||||
}
|
||||
|
||||
function actionToEventType(serviceId: ServiceRedisSpec["serviceId"], action: string | undefined, message: Record<string, unknown>) {
|
||||
const msgAction = getString(message.action);
|
||||
const effective = msgAction || action || "event";
|
||||
|
||||
if (serviceId === "interview") {
|
||||
if (effective === "interview_configured" || action === "configure_interview") return "interview.configured";
|
||||
if (effective === "review_loaded") {
|
||||
const data = asRecord(message.data);
|
||||
return data.status === "completed" ? "interview.review_completed" : "interview.review_processing";
|
||||
}
|
||||
if (effective === "interview_page_loaded") return "interview.page_state_loaded";
|
||||
return `interview.${effective.replaceAll("_", ".")}`;
|
||||
}
|
||||
|
||||
if (serviceId === "roleplay") {
|
||||
if (effective === "roleplay_configured" || action === "configure_roleplay") return "roleplay.configured";
|
||||
if (effective === "roleplay_review_loaded" || effective === "review_loaded") {
|
||||
const data = asRecord(message.data);
|
||||
return data.status === "completed" ? "roleplay.review_completed" : "roleplay.review_processing";
|
||||
}
|
||||
if (effective === "roleplay_page_loaded") return "roleplay.page_state_loaded";
|
||||
return `roleplay.${effective.replaceAll("_", ".")}`;
|
||||
}
|
||||
|
||||
if (effective === "ai_analysis_complete" || action === "ai_analyze") return "resume.analysis_completed";
|
||||
if (effective === "resume_loaded") return "resume.loaded";
|
||||
if (effective === "resume_parsed") return "resume.parsed";
|
||||
return `resume.${effective.replaceAll("_", ".")}`;
|
||||
}
|
||||
|
||||
function extractSessionId(message: Record<string, unknown>, ctx?: LegacyTaskContext): string | undefined {
|
||||
const data = asRecord(message.data);
|
||||
const params = ctx?.params ?? {};
|
||||
return getString(
|
||||
data.session_id ?? data.sessionId ?? data.id ??
|
||||
params.session_id ?? params.sessionId ??
|
||||
data.review_session_id,
|
||||
);
|
||||
}
|
||||
|
||||
function extractResumeId(message: Record<string, unknown>, ctx?: LegacyTaskContext): string | undefined {
|
||||
const data = asRecord(message.data);
|
||||
const params = ctx?.params ?? {};
|
||||
return getString(
|
||||
data.resume_id ?? data.resumeId ??
|
||||
asRecord(data.resume).id ??
|
||||
params.resume_id ?? params.resumeId,
|
||||
);
|
||||
}
|
||||
|
||||
async function recordAndRoute(input: unknown) {
|
||||
const event = await recordGrowEvent(input);
|
||||
await routeGrowEventToUserActor(event).catch((err) => {
|
||||
log.warn({ err, eventId: event.id, userId: event.userId }, "failed to route grow event to user actor");
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
async function handleLegacyResponseMessage(spec: ServiceRedisSpec, channel: string, raw: string) {
|
||||
const taskId = channel.replace(/^responses:/, "");
|
||||
const ctx = legacyTaskContexts.get(taskId);
|
||||
const parsed = parseFieldValue(raw);
|
||||
const message = asRecord(parsed);
|
||||
const type = getString(message.type);
|
||||
if (!type || type === "__task_complete__") return;
|
||||
|
||||
const data = asRecord(message.data);
|
||||
const eventType = actionToEventType(spec.serviceId, ctx?.action, message);
|
||||
const sessionId = extractSessionId(message, ctx);
|
||||
const resumeId = extractResumeId(message, ctx);
|
||||
|
||||
await recordAndRoute({
|
||||
id: randomUUID(),
|
||||
source: `${spec.agentName}:legacy-a2a`,
|
||||
type: eventType,
|
||||
category: type === "agent_error" ? "system" : "service",
|
||||
userId: ctx?.userId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
correlation: {
|
||||
taskId,
|
||||
action: ctx?.action,
|
||||
sessionId,
|
||||
resumeId,
|
||||
externalId: sessionId ?? resumeId,
|
||||
},
|
||||
payload: {
|
||||
action: ctx?.action,
|
||||
params: ctx?.params,
|
||||
message,
|
||||
data,
|
||||
},
|
||||
raw: { channel, message },
|
||||
dedupeKey: `legacy-a2a:${spec.agentName}:${taskId}:${eventType}:${JSON.stringify(message).slice(0, 512)}`,
|
||||
});
|
||||
}
|
||||
|
||||
function parseLegacyTask(entryId: string, fields: Record<string, string>): LegacyTaskContext | null {
|
||||
const taskId = getString(fields.task_id);
|
||||
const payload = asRecord(parseFieldValue(fields.payload));
|
||||
if (!taskId) return null;
|
||||
return {
|
||||
taskId,
|
||||
userId: getString(payload.user_id ?? fields.user_id),
|
||||
action: getString(payload.action),
|
||||
params: asRecord(payload.params),
|
||||
userContext: asRecord(payload.user_context),
|
||||
sessionStart: Boolean(payload.session_start),
|
||||
streamEntryId: entryId,
|
||||
seenAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function startLegacyServiceObserver(spec: ServiceRedisSpec, createClient: (opts: { url: string }) => RedisClientLike) {
|
||||
const taskStream = `tasks:${spec.agentName}`;
|
||||
const consumer = `${config.growEventsConsumerName}-${spec.agentName}`;
|
||||
const group = `${config.legacyServiceTaskObserverGroup}:${spec.agentName}`;
|
||||
|
||||
const redis = createClient({ url: spec.redisUrl });
|
||||
redis.on("error", (err) => log.warn({ err, service: spec.agentName }, "legacy service task observer redis error"));
|
||||
await redis.connect();
|
||||
|
||||
try {
|
||||
await redis.xGroupCreate(taskStream, group, "0", { MKSTREAM: true });
|
||||
log.info({ taskStream, group }, "created legacy service task observer group");
|
||||
} catch (err) {
|
||||
if (!String(err).includes("BUSYGROUP")) throw err;
|
||||
}
|
||||
|
||||
const subscriber = redis.duplicate ? redis.duplicate() : createClient({ url: spec.redisUrl });
|
||||
subscriber.on("error", (err) => log.warn({ err, service: spec.agentName }, "legacy service response subscriber redis error"));
|
||||
await subscriber.connect();
|
||||
if (!subscriber.pSubscribe) {
|
||||
log.warn({ service: spec.agentName }, "redis client does not support pSubscribe; legacy response observation disabled");
|
||||
} else {
|
||||
await subscriber.pSubscribe("responses:*", async (message, channel) => {
|
||||
await handleLegacyResponseMessage(spec, channel, message).catch((err) => {
|
||||
log.error({ err, service: spec.agentName, channel }, "failed to ingest legacy service response");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
log.info({ service: spec.agentName, taskStream, group }, "legacy service Redis observer started");
|
||||
|
||||
void (async () => {
|
||||
while (true) {
|
||||
try {
|
||||
const response = await redis.xReadGroup(group, consumer, { key: taskStream, id: ">" }, { COUNT: 100, BLOCK: 5000 });
|
||||
if (!response) continue;
|
||||
for (const stream of response) {
|
||||
for (const message of stream.messages) {
|
||||
const ctx = parseLegacyTask(message.id, message.message);
|
||||
if (ctx) legacyTaskContexts.set(ctx.taskId, ctx);
|
||||
await redis.xAck(stream.name, group, message.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (String(err).includes("NOGROUP")) {
|
||||
try { await redis.xGroupCreate(taskStream, group, "0", { MKSTREAM: true }); } catch {}
|
||||
} else {
|
||||
log.error({ err, service: spec.agentName }, "legacy service task observer loop error");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function startCanonicalGrowEventStream(createClient: (opts: { url: string }) => RedisClientLike) {
|
||||
if (!config.growEventsRedisUrl) {
|
||||
log.info("grow events Redis consumer disabled (GROW_EVENTS_REDIS_URL/REDIS_URL not set)");
|
||||
return;
|
||||
}
|
||||
|
||||
const redis = createClient({ url: config.growEventsRedisUrl });
|
||||
redis.on("error", (err) => log.warn({ err }, "grow events redis client error"));
|
||||
await redis.connect();
|
||||
|
||||
try {
|
||||
await redis.xGroupCreate(config.growEventsStream, config.growEventsConsumerGroup, "0", { MKSTREAM: true });
|
||||
log.info({ stream: config.growEventsStream, group: config.growEventsConsumerGroup }, "created grow events consumer group");
|
||||
} catch (err) {
|
||||
if (!String(err).includes("BUSYGROUP")) throw err;
|
||||
}
|
||||
|
||||
log.info({ stream: config.growEventsStream, group: config.growEventsConsumerGroup }, "grow events redis consumer started");
|
||||
|
||||
void (async () => {
|
||||
while (true) {
|
||||
try {
|
||||
const response = await redis.xReadGroup(
|
||||
config.growEventsConsumerGroup,
|
||||
config.growEventsConsumerName,
|
||||
{ key: config.growEventsStream, id: ">" },
|
||||
{ COUNT: 50, BLOCK: 5000 },
|
||||
);
|
||||
if (!response) continue;
|
||||
for (const stream of response) {
|
||||
for (const message of stream.messages) {
|
||||
try {
|
||||
await recordAndRoute(fieldsToEvent(message.message, stream.name));
|
||||
} catch (err) {
|
||||
log.error({ err, messageId: message.id }, "failed to ingest grow event stream message");
|
||||
} finally {
|
||||
await redis.xAck(stream.name, config.growEventsConsumerGroup, message.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err }, "grow events redis consumer loop error");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export async function startGrowEventsRedisConsumer() {
|
||||
const createClient = await loadRedisCreateClient();
|
||||
await startCanonicalGrowEventStream(createClient);
|
||||
|
||||
const specs = serviceSpecs();
|
||||
if (!specs.length) {
|
||||
log.info("legacy service Redis observers disabled (INTERVIEW_REDIS_URL/ROLEPLAY_REDIS_URL/RESUME_REDIS_URL not set)");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(specs.map((spec) => startLegacyServiceObserver(spec, createClient)));
|
||||
}
|
||||
15
src/events/route-to-user-actor.ts
Normal file
15
src/events/route-to-user-actor.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createClient, type Client } from "rivetkit/client";
|
||||
import { config } from "../config.js";
|
||||
import type { Registry } from "../actors/registry.js";
|
||||
import type { GrowEventRow } from "../db/schema.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
|
||||
}
|
||||
|
||||
export async function routeGrowEventToUserActor(event: Pick<GrowEventRow, "id" | "userId">) {
|
||||
if (!event.userId) return { routed: false, reason: "unresolved_user" } as const;
|
||||
await getClient().userEventActor.getOrCreate([event.userId]).enqueueEvent({ userId: event.userId, eventId: event.id });
|
||||
return { routed: true } as const;
|
||||
}
|
||||
117
src/features/registry.ts
Normal file
117
src/features/registry.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { config } from "../config.js";
|
||||
|
||||
export type GrowServiceId = "resume-service" | "interview-service" | "roleplay-service" | "qscore-service" | "social-branding-service" | "matchmaking-service";
|
||||
export type GrowFeatureId = "resume-building" | "mock-interview" | "mock-roleplay" | "q-score" | "social-branding" | "matchmaking";
|
||||
|
||||
export type GrowFeatureDefinition = {
|
||||
id: GrowFeatureId;
|
||||
serviceId: GrowServiceId;
|
||||
title: string;
|
||||
label: string;
|
||||
description: string;
|
||||
promptModulePath: string;
|
||||
enabled: boolean;
|
||||
internalUrl?: string;
|
||||
publicUrl?: string;
|
||||
operations: string[];
|
||||
};
|
||||
|
||||
export const featureDefinitions: GrowFeatureDefinition[] = [
|
||||
{
|
||||
id: "resume-building",
|
||||
serviceId: "resume-service",
|
||||
title: "Resume Building",
|
||||
label: "Resume",
|
||||
description: "Build, tailor, analyze, and improve resumes for role fit and ATS readiness.",
|
||||
promptModulePath: "agents/resume.md",
|
||||
enabled: Boolean(config.resumeServiceUrl),
|
||||
internalUrl: config.resumeServiceUrl,
|
||||
publicUrl: config.resumePublicUrl,
|
||||
operations: ["resume.state", "resume.templates", "resume.a2aTask", "resume.create", "resume.update", "resume.analyze", "resume.suggestions", "resume.copilot", "resume.optimizeSummary", "resume.optimizeExperience", "resume.suggestSkills", "resume.generateSummary", "resume.versions", "resume.preview"],
|
||||
},
|
||||
{
|
||||
id: "mock-interview",
|
||||
serviceId: "interview-service",
|
||||
title: "Mock Interview",
|
||||
label: "Interview",
|
||||
description: "Configure, practice, review, and score interview sessions.",
|
||||
promptModulePath: "agents/interview.md",
|
||||
enabled: Boolean(config.interviewServiceUrl),
|
||||
internalUrl: config.interviewServiceUrl,
|
||||
publicUrl: config.interviewPublicUrl,
|
||||
operations: ["interview.configure", "interview.preview", "interview.questions", "interview.approve", "interview.assignments", "interview.unassign", "interview.resultsBulk", "interview.review", "interview.leaderboard", "interview.artifacts", "interview.videoUpload", "interview.practice"],
|
||||
},
|
||||
{
|
||||
id: "mock-roleplay",
|
||||
serviceId: "roleplay-service",
|
||||
title: "Mock Roleplay",
|
||||
label: "Roleplay",
|
||||
description: "Practice negotiations, recruiter calls, manager conversations, and stakeholder roleplays.",
|
||||
promptModulePath: "agents/roleplay.md",
|
||||
enabled: Boolean(config.roleplayServiceUrl),
|
||||
internalUrl: config.roleplayServiceUrl,
|
||||
publicUrl: config.roleplayPublicUrl,
|
||||
operations: ["roleplay.configure", "roleplay.preview", "roleplay.questions", "roleplay.approve", "roleplay.assignments", "roleplay.unassign", "roleplay.resultsBulk", "roleplay.review", "roleplay.leaderboard", "roleplay.artifacts", "roleplay.videoUpload", "roleplay.practice"],
|
||||
},
|
||||
{
|
||||
id: "q-score",
|
||||
serviceId: "qscore-service",
|
||||
title: "Q Score",
|
||||
label: "Q Score",
|
||||
description: "Analyze overall job-market readiness and convert signals into improvement priorities.",
|
||||
promptModulePath: "agents/qscore.md",
|
||||
enabled: Boolean(config.qscoreServiceUrl),
|
||||
internalUrl: config.qscoreServiceUrl,
|
||||
operations: ["qscore.ingest", "qscore.compute"],
|
||||
},
|
||||
{
|
||||
id: "social-branding",
|
||||
serviceId: "social-branding-service",
|
||||
title: "Social Branding",
|
||||
label: "Branding",
|
||||
description: "Build and optimize your professional profile, LinkedIn presence, and personal brand.",
|
||||
promptModulePath: "agents/social-branding.md",
|
||||
enabled: Boolean(config.socialBrandingServiceUrl),
|
||||
internalUrl: config.socialBrandingServiceUrl,
|
||||
operations: ["branding.profile", "branding.linkedin", "branding.content", "branding.analyze"],
|
||||
},
|
||||
{
|
||||
id: "matchmaking",
|
||||
serviceId: "matchmaking-service",
|
||||
title: "Matchmaking",
|
||||
label: "Matchmaking",
|
||||
description: "Connect with relevant professionals, mentors, and opportunities through curated matching.",
|
||||
promptModulePath: "agents/matchmaking.md",
|
||||
enabled: Boolean(config.matchmakingServiceUrl),
|
||||
internalUrl: config.matchmakingServiceUrl,
|
||||
operations: ["matchmaking.find", "matchmaking.connect", "matchmaking.schedule", "matchmaking.review"],
|
||||
},
|
||||
];
|
||||
|
||||
export const internalWorkflowModules = [
|
||||
{
|
||||
id: "mission-planning",
|
||||
title: "Mission Planning",
|
||||
label: "Mission Planning",
|
||||
description: "Internal planning and artifact drafting for a mission. This is not a user-facing feature service.",
|
||||
execution: "opencode" as const,
|
||||
},
|
||||
];
|
||||
|
||||
export function listFeatureDefinitions() {
|
||||
return featureDefinitions;
|
||||
}
|
||||
|
||||
export function getFeatureByServiceId(serviceId: string) {
|
||||
return featureDefinitions.find((feature) => feature.serviceId === serviceId);
|
||||
}
|
||||
|
||||
export function displayLabelForService(serviceId: string | undefined) {
|
||||
if (!serviceId) return undefined;
|
||||
return getFeatureByServiceId(serviceId)?.label;
|
||||
}
|
||||
|
||||
export function displayLabelForExecution(execution: string) {
|
||||
if (execution === "opencode") return "Mission Planning";
|
||||
return undefined;
|
||||
}
|
||||
268
src/grow/persistence.ts
Normal file
268
src/grow/persistence.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { asc, desc, eq, and } 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";
|
||||
import type { MissionSuggestion } from "../missions/suggestions.js";
|
||||
import type { ConversationMessage } from "../actors/conversation/types.js";
|
||||
import type { GrowConversation } from "../actors/grow/types.js";
|
||||
|
||||
const buildId = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const dateFromMs = (ms?: number) => new Date(ms && Number.isFinite(ms) ? ms : Date.now());
|
||||
|
||||
function toConversation(row: typeof growConversations.$inferSelect): GrowConversation {
|
||||
return { id: row.id, title: row.title, createdAt: row.createdAt.getTime(), updatedAt: row.updatedAt.getTime() };
|
||||
}
|
||||
|
||||
function toMessage(row: typeof growConversationMessages.$inferSelect): ConversationMessage {
|
||||
return {
|
||||
id: row.id,
|
||||
conversationId: row.conversationId,
|
||||
role: row.role,
|
||||
sender: row.sender,
|
||||
content: row.content,
|
||||
createdAt: row.createdAt.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureConversation(userId: string, title = "Talk to Me") {
|
||||
const existing = await listConversationsPg(userId);
|
||||
if (existing[0]) return existing[0];
|
||||
return createConversationPg(userId, title);
|
||||
}
|
||||
|
||||
export async function listConversationsPg(userId: string): Promise<GrowConversation[]> {
|
||||
const rows = await db.select().from(growConversations).where(eq(growConversations.userId, userId)).orderBy(desc(growConversations.updatedAt));
|
||||
return rows.map(toConversation);
|
||||
}
|
||||
|
||||
export async function createConversationPg(userId: string, title = "Talk to Me"): Promise<GrowConversation> {
|
||||
const now = new Date();
|
||||
const [row] = await db.insert(growConversations).values({ id: buildId("conversation"), userId, title, createdAt: now, updatedAt: now }).returning();
|
||||
if (!row) throw new Error("Failed to create conversation");
|
||||
return toConversation(row);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function touchConversationPg(userId: string, conversationId: string, title?: string) {
|
||||
const patch: Partial<typeof growConversations.$inferInsert> = { updatedAt: new Date() };
|
||||
if (title?.trim()) patch.title = title.trim();
|
||||
const [row] = await db.update(growConversations).set(patch).where(and(eq(growConversations.userId, userId), eq(growConversations.id, conversationId))).returning();
|
||||
return row ? toConversation(row) : null;
|
||||
}
|
||||
|
||||
export async function resetConversationPg(userId: string, conversationId: string) {
|
||||
await db.delete(growConversationMessages).where(and(eq(growConversationMessages.userId, userId), eq(growConversationMessages.conversationId, conversationId)));
|
||||
return touchConversationPg(userId, conversationId);
|
||||
}
|
||||
|
||||
export async function listMessagesPg(userId: string, conversationId: string): Promise<ConversationMessage[]> {
|
||||
const rows = await db.select().from(growConversationMessages).where(and(eq(growConversationMessages.userId, userId), eq(growConversationMessages.conversationId, conversationId))).orderBy(asc(growConversationMessages.createdAt));
|
||||
return rows.map(toMessage);
|
||||
}
|
||||
|
||||
export async function addMessagePg(userId: string, input: Omit<ConversationMessage, "createdAt"> & { createdAt?: number }) {
|
||||
const createdAt = dateFromMs(input.createdAt);
|
||||
const [row] = await db.insert(growConversationMessages).values({
|
||||
id: input.id,
|
||||
userId,
|
||||
conversationId: input.conversationId,
|
||||
role: input.role,
|
||||
sender: input.sender,
|
||||
content: input.content,
|
||||
createdAt,
|
||||
}).onConflictDoUpdate({
|
||||
target: growConversationMessages.id,
|
||||
set: { content: input.content, sender: input.sender },
|
||||
}).returning();
|
||||
await touchConversationPg(userId, input.conversationId);
|
||||
if (!row) throw new Error("Failed to persist message");
|
||||
return toMessage(row);
|
||||
}
|
||||
|
||||
export async function upsertActiveMissionPg(userId: string, mission: GrowActiveMission, snapshot?: MissionSnapshot) {
|
||||
const now = new Date();
|
||||
await db.insert(growActiveMissions).values({
|
||||
instanceId: mission.instanceId,
|
||||
userId,
|
||||
missionId: mission.missionId,
|
||||
workflowId: mission.workflowId,
|
||||
actorType: mission.actorType,
|
||||
title: mission.title,
|
||||
shortTitle: mission.shortTitle,
|
||||
status: mission.status,
|
||||
progressPercent: mission.progressPercent,
|
||||
currentStageId: mission.currentStageId,
|
||||
goal: mission.goal,
|
||||
mission: mission as unknown as Record<string, unknown>,
|
||||
snapshot: snapshot as unknown as Record<string, unknown> | undefined,
|
||||
createdAt: dateFromMs(mission.createdAt),
|
||||
updatedAt: dateFromMs(mission.updatedAt),
|
||||
}).onConflictDoUpdate({
|
||||
target: growActiveMissions.instanceId,
|
||||
set: {
|
||||
status: mission.status,
|
||||
progressPercent: mission.progressPercent,
|
||||
currentStageId: mission.currentStageId,
|
||||
goal: mission.goal,
|
||||
actorType: mission.actorType,
|
||||
mission: mission as unknown as Record<string, unknown>,
|
||||
snapshot: snapshot as unknown as Record<string, unknown> | undefined,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function activeMissionFromRow(row: typeof growActiveMissions.$inferSelect): GrowActiveMission {
|
||||
const raw = (row.mission ?? {}) as Partial<GrowActiveMission>;
|
||||
return {
|
||||
instanceId: raw.instanceId ?? row.instanceId,
|
||||
missionId: raw.missionId ?? (row.missionId as GrowActiveMission["missionId"]),
|
||||
workflowId: raw.workflowId ?? row.workflowId,
|
||||
title: raw.title ?? row.title,
|
||||
shortTitle: raw.shortTitle ?? row.shortTitle,
|
||||
status: raw.status ?? (row.status as GrowActiveMission["status"]),
|
||||
progressPercent: raw.progressPercent ?? row.progressPercent ?? 0,
|
||||
currentStageId: raw.currentStageId ?? row.currentStageId ?? undefined,
|
||||
goal: raw.goal ?? row.goal ?? undefined,
|
||||
actorType: raw.actorType ?? (row.actorType as GrowActiveMission["actorType"] | undefined),
|
||||
createdAt: raw.createdAt ?? row.createdAt.getTime(),
|
||||
updatedAt: raw.updatedAt ?? row.updatedAt.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
function missionSnapshotFromRow(row: typeof growActiveMissions.$inferSelect): MissionSnapshot | null {
|
||||
if (!row.snapshot) return null;
|
||||
const raw = row.snapshot as Partial<MissionSnapshot>;
|
||||
return {
|
||||
...(raw as MissionSnapshot),
|
||||
instanceId: raw.instanceId ?? row.instanceId,
|
||||
missionId: raw.missionId ?? (row.missionId as MissionSnapshot["missionId"]),
|
||||
workflowId: raw.workflowId ?? row.workflowId,
|
||||
userId: raw.userId ?? row.userId,
|
||||
title: raw.title ?? row.title,
|
||||
shortTitle: raw.shortTitle ?? row.shortTitle,
|
||||
status: raw.status ?? (row.status as MissionSnapshot["status"]),
|
||||
progressPercent: raw.progressPercent ?? row.progressPercent ?? 0,
|
||||
currentStageId: raw.currentStageId ?? row.currentStageId ?? undefined,
|
||||
goal: raw.goal ?? row.goal ?? undefined,
|
||||
stages: raw.stages ?? [],
|
||||
artifacts: raw.artifacts ?? [],
|
||||
events: raw.events ?? [],
|
||||
createdAt: raw.createdAt ?? row.createdAt.toISOString(),
|
||||
updatedAt: raw.updatedAt ?? row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listActiveMissionsPg(userId: string) {
|
||||
const rows = await db.select().from(growActiveMissions).where(eq(growActiveMissions.userId, userId)).orderBy(desc(growActiveMissions.updatedAt));
|
||||
return rows.map((row) => ({ mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) }));
|
||||
}
|
||||
|
||||
export async function getActiveMissionPg(userId: string, instanceId: string) {
|
||||
const [row] = await db.select().from(growActiveMissions).where(and(eq(growActiveMissions.userId, userId), eq(growActiveMissions.instanceId, instanceId))).limit(1);
|
||||
return row ? { mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) } : null;
|
||||
}
|
||||
|
||||
export async function listMissionSuggestionsPg(userId: string, instanceId: string | undefined | null): Promise<MissionSuggestion[]> {
|
||||
if (!instanceId) return [];
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(missionSuggestions)
|
||||
.where(and(eq(missionSuggestions.userId, userId), eq(missionSuggestions.missionInstanceId, instanceId), eq(missionSuggestions.status, "active")))
|
||||
.orderBy(desc(missionSuggestions.priority), desc(missionSuggestions.updatedAt));
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
missionInstanceId: row.missionInstanceId,
|
||||
missionId: row.missionId,
|
||||
stageId: row.stageId ?? undefined,
|
||||
role: row.role,
|
||||
type: row.type,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
reason: row.reason ?? undefined,
|
||||
priority: row.priority,
|
||||
urgency: row.urgency,
|
||||
status: row.status,
|
||||
ctaLabel: row.ctaLabel,
|
||||
ctaHref: row.ctaHref,
|
||||
sourceRefs: row.sourceRefs ?? {},
|
||||
generatedBy: row.generatedBy,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
expiresAt: row.expiresAt?.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function replaceMissionSuggestionsPg(input: {
|
||||
userId: string;
|
||||
missionInstanceId: string;
|
||||
missionId: string;
|
||||
coachRunId?: string;
|
||||
suggestions: Array<Omit<MissionSuggestion, "id" | "userId" | "missionInstanceId" | "missionId" | "status" | "createdAt" | "updatedAt"> & { id?: string }>;
|
||||
}) {
|
||||
const now = new Date();
|
||||
await db.update(missionSuggestions)
|
||||
.set({ status: "expired", updatedAt: now })
|
||||
.where(and(eq(missionSuggestions.userId, input.userId), eq(missionSuggestions.missionInstanceId, input.missionInstanceId), eq(missionSuggestions.status, "active")));
|
||||
|
||||
if (!input.suggestions.length) return [];
|
||||
await db.insert(missionSuggestions).values(input.suggestions.map((suggestion) => ({
|
||||
id: suggestion.id,
|
||||
userId: input.userId,
|
||||
missionInstanceId: input.missionInstanceId,
|
||||
missionId: input.missionId,
|
||||
stageId: suggestion.stageId,
|
||||
role: suggestion.role,
|
||||
type: suggestion.type,
|
||||
title: suggestion.title,
|
||||
body: suggestion.body,
|
||||
reason: suggestion.reason,
|
||||
priority: suggestion.priority,
|
||||
urgency: suggestion.urgency,
|
||||
status: "active" as const,
|
||||
ctaLabel: suggestion.ctaLabel,
|
||||
ctaHref: suggestion.ctaHref,
|
||||
sourceRefs: { ...(suggestion.sourceRefs ?? {}), coachRunId: input.coachRunId },
|
||||
generatedBy: suggestion.generatedBy ?? "deterministic",
|
||||
expiresAt: suggestion.expiresAt ? new Date(suggestion.expiresAt) : undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})));
|
||||
return listMissionSuggestionsPg(input.userId, input.missionInstanceId);
|
||||
}
|
||||
|
||||
export async function createMissionCoachRunPg(input: {
|
||||
userId: string;
|
||||
missionInstanceId: string;
|
||||
missionId: string;
|
||||
windowStart: Date;
|
||||
windowEnd: Date;
|
||||
inputDigest: Record<string, unknown>;
|
||||
skillVersion?: string;
|
||||
}) {
|
||||
const [row] = await db.insert(missionCoachRuns).values({
|
||||
userId: input.userId,
|
||||
missionInstanceId: input.missionInstanceId,
|
||||
missionId: input.missionId,
|
||||
windowStart: input.windowStart,
|
||||
windowEnd: input.windowEnd,
|
||||
inputDigest: input.inputDigest,
|
||||
skillVersion: input.skillVersion,
|
||||
}).returning();
|
||||
if (!row) throw new Error("Failed to create mission coach run");
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function completeMissionCoachRunPg(input: { id: string; summary: string; output: Record<string, unknown> }) {
|
||||
await db.update(missionCoachRuns).set({
|
||||
status: "completed",
|
||||
summary: input.summary,
|
||||
output: input.output,
|
||||
completedAt: new Date(),
|
||||
}).where(eq(missionCoachRuns.id, input.id));
|
||||
}
|
||||
91
src/home/home-feed-agent.ts
Normal file
91
src/home/home-feed-agent.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Output, generateText } from "ai";
|
||||
import { z } from "zod";
|
||||
import { getConversationModel } from "../actors/conversation/agent.js";
|
||||
import { config } from "../config.js";
|
||||
import { log } from "../log.js";
|
||||
import { ALLOWED_NOTIFICATION_HREFS, MODULE_IDS, type HomeModuleId, type HomeNotification, type HomeUrgency } from "./types.js";
|
||||
|
||||
const notificationSchema = z.object({
|
||||
moduleId: z.enum(MODULE_IDS as [HomeModuleId, ...HomeModuleId[]]),
|
||||
title: z.string().min(4).max(72),
|
||||
subtitle: z.string().min(4).max(110),
|
||||
tag: z.string().min(2).max(14),
|
||||
urgency: z.enum(["now", "today", "soon", "calm"]),
|
||||
href: z.string().min(1),
|
||||
source: z.enum(["resume", "interview", "roleplay", "qscore", "mission", "social", "pathways", "rewards", "system"]),
|
||||
reason: z.string().max(160).optional(),
|
||||
});
|
||||
|
||||
const feedSchema = z.object({
|
||||
notifications: z.array(notificationSchema).min(6).max(24),
|
||||
});
|
||||
|
||||
export type AgentHomeNotification = z.infer<typeof notificationSchema>;
|
||||
|
||||
const SYSTEM = `You are GrowQR's Home Feed Agent.
|
||||
Your job is to rank and rewrite dashboard notifications from real platform context.
|
||||
Keep them coherent, specific, and action-oriented. Do not invent unavailable products, scores, sessions, deadlines, companies, artifacts, or rewards.
|
||||
Every notification must point to one of these real dashboard routes:
|
||||
- /agents/resume for resume building, resume analysis, ATS, resume suggestions
|
||||
- /agents/interview for mock interview setup, interview session, interview review
|
||||
- /agents/roleplay for recruiter/manager/salary/stakeholder roleplay
|
||||
- /agents/qscore for Q Score/readiness explanations
|
||||
- /missions for mission progress, approvals, artifacts, next stages
|
||||
- /social for LinkedIn/social branding
|
||||
- /pathways for locked/coming-soon pathways
|
||||
- /rewards for locked/coming-soon rewards
|
||||
- /suggestions for broad onboarding/profile suggestions
|
||||
Use minimal iPhone-notification copy: title <= 72 chars, subtitle <= 110 chars, short tag <= 14 chars.
|
||||
Use urgency truthfully: now = needs immediate user action, today = useful today, soon = next few days, calm = informational.`;
|
||||
|
||||
function sanitizeHref(href: string, moduleId: HomeModuleId) {
|
||||
if (ALLOWED_NOTIFICATION_HREFS.has(href)) return href;
|
||||
if (href.startsWith("/agents/resume")) return "/agents/resume";
|
||||
if (href.startsWith("/agents/interview")) return "/agents/interview";
|
||||
if (href.startsWith("/agents/roleplay")) return "/agents/roleplay";
|
||||
if (href.startsWith("/agents/qscore")) return "/agents/qscore";
|
||||
if (href.startsWith("/missions")) return "/missions";
|
||||
if (href.startsWith("/social")) return "/social";
|
||||
if (href.startsWith("/pathways")) return "/pathways";
|
||||
if (href.startsWith("/rewards")) return "/rewards";
|
||||
if (href.startsWith("/productivity")) return "/productivity";
|
||||
return moduleId === "productivity" ? "/productivity" : `/${moduleId}`;
|
||||
}
|
||||
|
||||
function stableId(prefix: string, index: number) {
|
||||
return `${prefix}-${index + 1}`;
|
||||
}
|
||||
|
||||
export async function refineHomeNotificationsWithAgent(input: {
|
||||
userId: string;
|
||||
context: Record<string, unknown>;
|
||||
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>;
|
||||
}): Promise<Array<AgentHomeNotification & { id: string; createdAt: string }>> {
|
||||
if (!config.llmApiKey) return [];
|
||||
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: getConversationModel(),
|
||||
output: Output.object({ schema: feedSchema }),
|
||||
system: SYSTEM,
|
||||
prompt: JSON.stringify({
|
||||
task: "Create coherent GrowQR home dashboard notifications from the provided service context and deterministic candidates.",
|
||||
userId: input.userId,
|
||||
serviceContext: input.context,
|
||||
deterministicCandidates: input.seeds,
|
||||
}),
|
||||
});
|
||||
|
||||
const now = new Date().toISOString();
|
||||
return result.output.notifications.map((n, index) => ({
|
||||
...n,
|
||||
href: sanitizeHref(n.href, n.moduleId),
|
||||
urgency: n.urgency as HomeUrgency,
|
||||
id: stableId("agent-home", index),
|
||||
createdAt: now,
|
||||
}));
|
||||
} catch (err) {
|
||||
log.warn({ err, userId: input.userId }, "home feed agent failed; using deterministic notifications");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
507
src/home/home-feed.ts
Normal file
507
src/home/home-feed.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import {
|
||||
growActiveMissions,
|
||||
growEvents,
|
||||
growHomeNotifications,
|
||||
growQscoreLatest,
|
||||
growQscoreProjectionState,
|
||||
missionArtifacts,
|
||||
missionServiceSessions,
|
||||
qscoreSnapshots,
|
||||
users,
|
||||
type GrowHomeNotificationRow,
|
||||
type NewGrowHomeNotification,
|
||||
} from "../db/schema.js";
|
||||
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
|
||||
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
|
||||
import {
|
||||
ALLOWED_NOTIFICATION_HREFS,
|
||||
MODULE_IDS,
|
||||
MODULE_META,
|
||||
type HomeFeedResponse,
|
||||
type HomeModule,
|
||||
type HomeModuleId,
|
||||
type HomeNotification,
|
||||
type HomeSource,
|
||||
type HomeUrgency,
|
||||
} from "./types.js";
|
||||
|
||||
const FRESH_MS = 10 * 60 * 1000;
|
||||
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const SERVICE_HREFS = {
|
||||
resume: "/agents/resume",
|
||||
interview: "/agents/interview",
|
||||
roleplay: "/agents/roleplay",
|
||||
qscore: "/agents/qscore",
|
||||
mission: "/missions",
|
||||
social: "/social",
|
||||
pathways: "/pathways",
|
||||
rewards: "/rewards",
|
||||
suggestions: "/suggestions",
|
||||
productivity: "/productivity",
|
||||
} as const;
|
||||
|
||||
type SeedNotification = Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId; priority: number };
|
||||
|
||||
type HomeContext = {
|
||||
user: { id: string; email: string; displayName: string | null } | undefined;
|
||||
qscore: { score: number; signalCount: number; summary: string | null; dimensions: Record<string, unknown> | null } | undefined;
|
||||
qscoreSignals: Array<{ signalId: string; score: number; source: string | null; updatedAt: Date }>;
|
||||
activeMissions: Array<{ instanceId: string; missionId: string; title: string; status: string; progressPercent: number; currentStageId: string | null; updatedAt: Date }>;
|
||||
sessions: Array<{ serviceId: string; externalId: string; status: string; updatedAt: Date; metadata: Record<string, unknown> | null }>;
|
||||
artifacts: Array<{ serviceId: string | null; type: string; title: string; status: string; summary: string | null; createdAt: Date }>;
|
||||
events: Array<{ source: string; type: string; occurredAt: Date; payload: Record<string, unknown> }>;
|
||||
serviceStates: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function numberFrom(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function sanitizeUrgency(value: string): HomeUrgency {
|
||||
if (value === "now" || value === "today" || value === "soon" || value === "calm") return value;
|
||||
return "calm";
|
||||
}
|
||||
|
||||
function sanitizeHref(href: string | undefined, fallback: string) {
|
||||
if (href && ALLOWED_NOTIFICATION_HREFS.has(href)) return href;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function pushSeed(seeds: SeedNotification[], seed: SeedNotification) {
|
||||
seeds.push({ ...seed, href: sanitizeHref(seed.href, MODULE_META[seed.moduleId].href) });
|
||||
}
|
||||
|
||||
function latestScore(signals: HomeContext["qscoreSignals"], signalId: string) {
|
||||
return signals.find((s) => s.signalId === signalId)?.score;
|
||||
}
|
||||
|
||||
function serviceSession(ctx: HomeContext, serviceId: string) {
|
||||
return ctx.sessions.find((s) => s.serviceId === serviceId);
|
||||
}
|
||||
|
||||
function serviceEvent(ctx: HomeContext, prefix: string, includes?: string) {
|
||||
return ctx.events.find((e) => e.type.startsWith(prefix) && (!includes || e.type.includes(includes)));
|
||||
}
|
||||
|
||||
function hasAnyRealActivity(ctx: HomeContext) {
|
||||
return Boolean(
|
||||
ctx.qscore?.signalCount ||
|
||||
ctx.qscoreSignals.length ||
|
||||
ctx.activeMissions.length ||
|
||||
ctx.sessions.length ||
|
||||
ctx.artifacts.length ||
|
||||
ctx.events.length,
|
||||
);
|
||||
}
|
||||
|
||||
function buildDayOneSeeds(): SeedNotification[] {
|
||||
const seeds: SeedNotification[] = [];
|
||||
pushSeed(seeds, { moduleId: "suggestions", title: "Start with your Q Score", subtitle: "A quick readiness scan calibrates resume, interview, and roleplay tips.", tag: "Start", urgency: "now", href: SERVICE_HREFS.qscore, source: "qscore", priority: 90 });
|
||||
pushSeed(seeds, { moduleId: "suggestions", title: "Add your target role", subtitle: "One role goal makes every recommendation sharper.", tag: "Profile", urgency: "today", href: SERVICE_HREFS.suggestions, source: "system", priority: 80 });
|
||||
pushSeed(seeds, { moduleId: "missions", title: "Explore Interview-to-Offer", subtitle: "A guided mission connects resume fit, mock practice, and readiness scoring.", tag: "Browse", urgency: "today", href: SERVICE_HREFS.mission, source: "mission", priority: 80 });
|
||||
pushSeed(seeds, { moduleId: "missions", title: "No approvals pending yet", subtitle: "Start a mission and this tile will track missing steps and progress.", tag: "Quiet", urgency: "calm", href: SERVICE_HREFS.mission, source: "mission", priority: 55 });
|
||||
pushSeed(seeds, { moduleId: "social", title: "Connect LinkedIn when ready", subtitle: "Social branding recommendations unlock after your profile is available.", tag: "Setup", urgency: "soon", href: SERVICE_HREFS.social, source: "social", priority: 60 });
|
||||
pushSeed(seeds, { moduleId: "social", title: "Build proof before posting", subtitle: "Resume and mock interview artifacts can become stronger featured pins.", tag: "Proof", urgency: "calm", href: SERVICE_HREFS.social, source: "social", priority: 50 });
|
||||
pushSeed(seeds, { moduleId: "pathways", title: "Pathways are warming up", subtitle: "Complete resume + interview activity to unlock better route recommendations.", tag: "Soon", urgency: "calm", href: SERVICE_HREFS.pathways, source: "pathways", priority: 40 });
|
||||
pushSeed(seeds, { moduleId: "productivity", title: "Open Resume Builder", subtitle: "Upload or create a resume to generate ATS and content recommendations.", tag: "Resume", urgency: "now", href: SERVICE_HREFS.resume, source: "resume", priority: 85 });
|
||||
pushSeed(seeds, { moduleId: "productivity", title: "Try a 10-minute mock interview", subtitle: "The interview service creates a role-aware live practice session.", tag: "Mock", urgency: "soon", href: SERVICE_HREFS.interview, source: "interview", priority: 70 });
|
||||
pushSeed(seeds, { moduleId: "productivity", title: "Roleplay is available for pressure practice", subtitle: "Use it for recruiter screens, salary asks, or manager conversations.", tag: "Roleplay", urgency: "calm", href: SERVICE_HREFS.roleplay, source: "roleplay", priority: 55 });
|
||||
pushSeed(seeds, { moduleId: "rewards", title: "Rewards unlock after activity", subtitle: "Finish readiness actions to start earning demo streaks and perks.", tag: "Locked", urgency: "calm", href: SERVICE_HREFS.rewards, source: "rewards", priority: 35 });
|
||||
return seeds;
|
||||
}
|
||||
|
||||
function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
const seeds = buildDayOneSeeds().filter((seed) => seed.moduleId === "pathways" || seed.moduleId === "rewards");
|
||||
const qscore = ctx.qscore?.score ?? Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1));
|
||||
const ats = latestScore(ctx.qscoreSignals, "resume.ats_compatibility");
|
||||
const interviewOverall = latestScore(ctx.qscoreSignals, "interview.overall_score");
|
||||
const roleplayComms = latestScore(ctx.qscoreSignals, "roleplay.communication_effectiveness");
|
||||
const resumeSession = serviceSession(ctx, "resume") ?? serviceSession(ctx, "resume-service");
|
||||
const interviewSession = serviceSession(ctx, "interview") ?? serviceSession(ctx, "interview-service");
|
||||
const roleplaySession = serviceSession(ctx, "roleplay") ?? serviceSession(ctx, "roleplay-service");
|
||||
const interviewReview = serviceEvent(ctx, "interview.", "review");
|
||||
const roleplayReview = serviceEvent(ctx, "roleplay.", "review");
|
||||
const resumeAnalysis = serviceEvent(ctx, "resume.", "analysis");
|
||||
|
||||
if (ctx.qscore || ctx.qscoreSignals.length) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "suggestions",
|
||||
title: qscore >= 80 ? "Protect your Q Score momentum" : "Raise your Q Score next",
|
||||
subtitle: qscore >= 80 ? `Readiness is trending at ${qscore}. Keep one proof action moving.` : `Current estimate is ${qscore || 64}. Resume + mock practice are the fastest signals.`,
|
||||
tag: "Q Score",
|
||||
urgency: qscore >= 80 ? "today" : "now",
|
||||
href: SERVICE_HREFS.qscore,
|
||||
source: "qscore",
|
||||
priority: 95,
|
||||
});
|
||||
}
|
||||
|
||||
if (ats !== undefined) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "suggestions",
|
||||
title: ats >= 80 ? "ATS is demo-ready" : "Resume ATS needs one pass",
|
||||
subtitle: ats >= 80 ? `ATS ${Math.round(ats)} — review role-specific keywords before applying.` : `ATS ${Math.round(ats)} — add JD keywords and measurable bullets.`,
|
||||
tag: ats >= 80 ? "Ready" : "Fix",
|
||||
urgency: ats >= 80 ? "today" : "now",
|
||||
href: SERVICE_HREFS.resume,
|
||||
source: "resume",
|
||||
priority: 92,
|
||||
});
|
||||
}
|
||||
|
||||
for (const mission of ctx.activeMissions.slice(0, 3)) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "missions",
|
||||
title: `${mission.title} — ${mission.progressPercent}%`,
|
||||
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: SERVICE_HREFS.mission,
|
||||
source: "mission",
|
||||
priority: 90 - mission.progressPercent,
|
||||
});
|
||||
}
|
||||
|
||||
const pendingArtifacts = ctx.artifacts.filter((a) => a.status === "draft" || a.status === "ready").slice(0, 2);
|
||||
for (const artifact of pendingArtifacts) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "missions",
|
||||
title: `${artifact.title} is ${artifact.status}`,
|
||||
subtitle: artifact.summary ?? "Review the artifact from your mission dashboard.",
|
||||
tag: artifact.status === "ready" ? "Review" : "Draft",
|
||||
urgency: artifact.status === "ready" ? "now" : "soon",
|
||||
href: SERVICE_HREFS.mission,
|
||||
source: "mission",
|
||||
priority: 88,
|
||||
});
|
||||
}
|
||||
|
||||
pushSeed(seeds, {
|
||||
moduleId: "social",
|
||||
title: "Turn proof into LinkedIn updates",
|
||||
subtitle: ctx.artifacts.length ? `${ctx.artifacts.length} artifact${ctx.artifacts.length === 1 ? "" : "s"} can feed headline, featured, or post ideas.` : "Connect LinkedIn and use mission proof to improve your profile.",
|
||||
tag: ctx.artifacts.length ? "Proof" : "Setup",
|
||||
urgency: ctx.artifacts.length ? "today" : "soon",
|
||||
href: SERVICE_HREFS.social,
|
||||
source: "social",
|
||||
priority: 70,
|
||||
});
|
||||
|
||||
if (resumeAnalysis || resumeSession || ats !== undefined) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "productivity",
|
||||
title: ats !== undefined ? `Resume ATS ${Math.round(ats)}` : "Resume analysis is ready",
|
||||
subtitle: ats !== undefined && ats >= 80 ? "Use this version for role-fit scan or final polish." : "Open Resume Builder for recommendations and bullet fixes.",
|
||||
tag: "Resume",
|
||||
urgency: ats !== undefined && ats < 75 ? "now" : "today",
|
||||
href: SERVICE_HREFS.resume,
|
||||
source: "resume",
|
||||
priority: 90,
|
||||
});
|
||||
}
|
||||
|
||||
if (interviewReview || interviewOverall !== undefined || interviewSession) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "productivity",
|
||||
title: interviewOverall !== undefined ? `Mock interview score ${Math.round(interviewOverall)}` : "Mock interview review is tracking",
|
||||
subtitle: interviewReview?.type.includes("processing") ? "Review is still processing; check back from the interview page." : "Open interview practice for review, next drill, or a new session.",
|
||||
tag: interviewReview?.type.includes("processing") ? "Wait" : "Mock",
|
||||
urgency: interviewReview?.type.includes("processing") ? "soon" : "today",
|
||||
href: SERVICE_HREFS.interview,
|
||||
source: "interview",
|
||||
priority: 86,
|
||||
});
|
||||
} else {
|
||||
pushSeed(seeds, { moduleId: "productivity", title: "Schedule a mock interview", subtitle: "Generate a behavioral or role-related session from your target role.", tag: "Mock", urgency: "soon", href: SERVICE_HREFS.interview, source: "interview", priority: 72 });
|
||||
}
|
||||
|
||||
if (roleplayReview || roleplayComms !== undefined || roleplaySession) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "productivity",
|
||||
title: roleplayComms !== undefined ? `Roleplay communication ${Math.round(roleplayComms)}` : "Roleplay scenario is ready",
|
||||
subtitle: "Practice recruiter, manager, salary, or stakeholder conversations.",
|
||||
tag: "Roleplay",
|
||||
urgency: "soon",
|
||||
href: SERVICE_HREFS.roleplay,
|
||||
source: "roleplay",
|
||||
priority: 78,
|
||||
});
|
||||
}
|
||||
|
||||
if (!ctx.activeMissions.length) {
|
||||
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: "Bundle resume fit, mock practice, and Q Score deltas into one journey.", tag: "Begin", urgency: "today", href: SERVICE_HREFS.mission, source: "mission", priority: 80 });
|
||||
}
|
||||
|
||||
return seeds;
|
||||
}
|
||||
|
||||
async function collectContext(userId: string): Promise<HomeContext> {
|
||||
const [user] = await db.select({ id: users.id, email: users.email, displayName: users.displayName }).from(users).where(eq(users.id, userId)).limit(1);
|
||||
const [qscore] = await db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, userId)).limit(1);
|
||||
const qscoreSignals = await db
|
||||
.select({ signalId: growQscoreLatest.signalId, score: growQscoreLatest.score, source: growQscoreLatest.source, updatedAt: growQscoreLatest.updatedAt })
|
||||
.from(growQscoreLatest)
|
||||
.where(eq(growQscoreLatest.userId, userId))
|
||||
.orderBy(desc(growQscoreLatest.updatedAt))
|
||||
.limit(30);
|
||||
const activeMissions = await db
|
||||
.select({ instanceId: growActiveMissions.instanceId, missionId: growActiveMissions.missionId, title: growActiveMissions.title, status: growActiveMissions.status, progressPercent: growActiveMissions.progressPercent, currentStageId: growActiveMissions.currentStageId, updatedAt: growActiveMissions.updatedAt })
|
||||
.from(growActiveMissions)
|
||||
.where(eq(growActiveMissions.userId, userId))
|
||||
.orderBy(desc(growActiveMissions.updatedAt))
|
||||
.limit(6);
|
||||
const sessions = await db
|
||||
.select({ serviceId: missionServiceSessions.serviceId, externalId: missionServiceSessions.externalId, status: missionServiceSessions.status, updatedAt: missionServiceSessions.updatedAt, metadata: missionServiceSessions.metadata })
|
||||
.from(missionServiceSessions)
|
||||
.where(eq(missionServiceSessions.userId, userId))
|
||||
.orderBy(desc(missionServiceSessions.updatedAt))
|
||||
.limit(12);
|
||||
const artifacts = await db
|
||||
.select({ serviceId: missionArtifacts.serviceId, type: missionArtifacts.type, title: missionArtifacts.title, status: missionArtifacts.status, summary: missionArtifacts.summary, createdAt: missionArtifacts.createdAt })
|
||||
.from(missionArtifacts)
|
||||
.where(eq(missionArtifacts.userId, userId))
|
||||
.orderBy(desc(missionArtifacts.createdAt))
|
||||
.limit(12);
|
||||
const events = await db
|
||||
.select({ source: growEvents.source, type: growEvents.type, occurredAt: growEvents.occurredAt, payload: growEvents.payload })
|
||||
.from(growEvents)
|
||||
.where(eq(growEvents.userId, userId))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(30);
|
||||
|
||||
const serviceResults = await Promise.allSettled([
|
||||
resumeService.state(userId),
|
||||
interviewService.pageState(userId),
|
||||
roleplayService.pageState(userId),
|
||||
]);
|
||||
|
||||
const [resumeState, interviewState, roleplayState] = serviceResults.map((result) => (result.status === "fulfilled" ? result.value : undefined));
|
||||
|
||||
return {
|
||||
user,
|
||||
qscore: qscore
|
||||
? {
|
||||
score: qscore.score,
|
||||
signalCount: qscore.signalCount,
|
||||
summary: qscore.summary,
|
||||
dimensions: isRecord(qscore.dimensions) ? qscore.dimensions : null,
|
||||
}
|
||||
: undefined,
|
||||
qscoreSignals,
|
||||
activeMissions,
|
||||
sessions: sessions.map((s) => ({ ...s, metadata: isRecord(s.metadata) ? s.metadata : null })),
|
||||
artifacts,
|
||||
events: events.map((e) => ({ ...e, payload: isRecord(e.payload) ? e.payload : {} })),
|
||||
serviceStates: { resume: resumeState, interview: interviewState, roleplay: roleplayState },
|
||||
};
|
||||
}
|
||||
|
||||
function rowToNotification(row: GrowHomeNotificationRow): HomeNotification {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
subtitle: row.subtitle,
|
||||
tag: row.tag,
|
||||
urgency: sanitizeUrgency(row.urgency),
|
||||
href: sanitizeHref(row.href, MODULE_META[row.moduleId].href),
|
||||
source: (row.source ?? undefined) as HomeSource | undefined,
|
||||
reason: row.reason ?? undefined,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function readPersistedNotifications(userId: string) {
|
||||
const now = new Date();
|
||||
return db
|
||||
.select()
|
||||
.from(growHomeNotifications)
|
||||
.where(and(eq(growHomeNotifications.userId, userId), eq(growHomeNotifications.status, "active"), or(isNull(growHomeNotifications.expiresAt), gt(growHomeNotifications.expiresAt, now))))
|
||||
.orderBy(desc(growHomeNotifications.priority), desc(growHomeNotifications.createdAt))
|
||||
.limit(60);
|
||||
}
|
||||
|
||||
async function replaceGeneratedNotifications(userId: string, notifications: Array<SeedNotification>, generatedBy: "deterministic" | "agent") {
|
||||
await db
|
||||
.delete(growHomeNotifications)
|
||||
.where(and(eq(growHomeNotifications.userId, userId), inArray(growHomeNotifications.generatedBy, ["deterministic", "agent"])));
|
||||
|
||||
const expiresAt = new Date(Date.now() + EXPIRY_MS);
|
||||
const rows: NewGrowHomeNotification[] = notifications.map((n) => ({
|
||||
userId,
|
||||
moduleId: n.moduleId,
|
||||
title: n.title,
|
||||
subtitle: n.subtitle,
|
||||
tag: n.tag,
|
||||
urgency: n.urgency,
|
||||
href: sanitizeHref(n.href, MODULE_META[n.moduleId].href),
|
||||
source: n.source,
|
||||
priority: n.priority,
|
||||
generatedBy,
|
||||
reason: n.reason,
|
||||
status: "active",
|
||||
expiresAt,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
if (rows.length) await db.insert(growHomeNotifications).values(rows);
|
||||
}
|
||||
|
||||
function ensureCoverage(seeds: SeedNotification[], fallback: SeedNotification[]) {
|
||||
const covered = new Set(seeds.map((s) => s.moduleId));
|
||||
for (const moduleId of MODULE_IDS) {
|
||||
if (covered.has(moduleId)) continue;
|
||||
const item = fallback.find((seed) => seed.moduleId === moduleId);
|
||||
if (item) seeds.push(item);
|
||||
}
|
||||
return seeds;
|
||||
}
|
||||
|
||||
function moduleCount(moduleId: HomeModuleId, notifications: HomeNotification[], ctx: HomeContext, mode: HomeFeedResponse["mode"]) {
|
||||
if (mode === "demo") {
|
||||
if (moduleId === "missions") return "1 active";
|
||||
if (moduleId === "productivity") return "4 updates";
|
||||
if (moduleId === "social") return "3 ready";
|
||||
if (moduleId === "pathways") return "Soon";
|
||||
if (moduleId === "rewards") return "Demo";
|
||||
return String(notifications.length);
|
||||
}
|
||||
if (moduleId === "missions") {
|
||||
if (ctx.activeMissions.length) return `${ctx.activeMissions.length} active`;
|
||||
return mode === "day1" ? "0" : String(notifications.length);
|
||||
}
|
||||
if (moduleId === "productivity") {
|
||||
const active = ctx.sessions.filter((s) => s.status === "active" || s.status === "configured" || s.status === "processing").length;
|
||||
return active ? `${active} active` : String(notifications.length);
|
||||
}
|
||||
if (moduleId === "pathways") return mode === "day1" ? "Soon" : "Locked";
|
||||
if (moduleId === "rewards") return mode === "day1" ? "0" : "Demo";
|
||||
if (moduleId === "social") return mode === "day1" ? "Setup" : `${notifications.length} updates`;
|
||||
return String(notifications.length);
|
||||
}
|
||||
|
||||
function buildModules(rows: GrowHomeNotificationRow[], ctx: HomeContext, mode: HomeFeedResponse["mode"]): HomeModule[] {
|
||||
const byModule = new Map<HomeModuleId, HomeNotification[]>();
|
||||
for (const row of rows) {
|
||||
const current = byModule.get(row.moduleId) ?? [];
|
||||
current.push(rowToNotification(row));
|
||||
byModule.set(row.moduleId, current);
|
||||
}
|
||||
|
||||
return MODULE_IDS.map((moduleId) => {
|
||||
const notifications = byModule.get(moduleId) ?? [];
|
||||
return {
|
||||
...MODULE_META[moduleId],
|
||||
count: moduleCount(moduleId, notifications, ctx, mode),
|
||||
notifications,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function buildIdentity(ctx: HomeContext) {
|
||||
const score = ctx.qscore?.score && ctx.qscore.score > 0 ? ctx.qscore.score : Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1)) || 47;
|
||||
const [baselineSnapshot] = await db
|
||||
.select({ score: qscoreSnapshots.score })
|
||||
.from(qscoreSnapshots)
|
||||
.where(and(eq(qscoreSnapshots.userId, ctx.user?.id ?? ""), eq(qscoreSnapshots.snapshotType, "baseline")))
|
||||
.orderBy(desc(qscoreSnapshots.createdAt))
|
||||
.limit(1);
|
||||
const baseline = baselineSnapshot?.score ?? Math.max(35, score - 29);
|
||||
const from = Math.max(baseline, Math.min(score, score - Math.min(17, Math.max(5, score - baseline))));
|
||||
const name = ctx.user?.displayName || ctx.user?.email?.split("@")[0] || "GrowQR User";
|
||||
const completedArtifacts = ctx.artifacts.filter((a) => a.status === "ready" || a.status === "approved").length;
|
||||
|
||||
return {
|
||||
name,
|
||||
caption: "Your living QR · scan to view profile",
|
||||
qrSrc: undefined,
|
||||
qx: { from, to: score, baseline },
|
||||
glance: [
|
||||
{ value: Math.max(0, score - baseline), label: "Growth" },
|
||||
{ value: Math.max(0, ctx.qscore?.signalCount ?? ctx.qscoreSignals.length), label: "Signals" },
|
||||
{ value: completedArtifacts || ctx.events.length, label: "Done" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getHomeFeed(userId: string, opts: { refresh?: boolean } = {}): Promise<HomeFeedResponse> {
|
||||
const ctx = await collectContext(userId);
|
||||
const persisted = await readPersistedNotifications(userId);
|
||||
const newest = persisted[0]?.createdAt?.getTime() ?? 0;
|
||||
const hasDemo = persisted.some((row) => row.generatedBy === "demo");
|
||||
const fresh = newest > Date.now() - FRESH_MS;
|
||||
|
||||
if (persisted.length && (hasDemo || (!opts.refresh && fresh))) {
|
||||
const mode = hasDemo ? "demo" : hasAnyRealActivity(ctx) ? "dynamic" : "day1";
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
mode,
|
||||
identity: await buildIdentity(ctx),
|
||||
modules: buildModules(persisted, ctx, mode),
|
||||
};
|
||||
}
|
||||
|
||||
const dayOneSeeds = buildDayOneSeeds();
|
||||
const deterministic = hasAnyRealActivity(ctx) ? buildDynamicSeeds(ctx) : dayOneSeeds;
|
||||
const agentNotifications = await refineHomeNotificationsWithAgent({
|
||||
userId,
|
||||
context: {
|
||||
qscore: ctx.qscore,
|
||||
qscoreSignals: ctx.qscoreSignals,
|
||||
activeMissions: ctx.activeMissions,
|
||||
sessions: ctx.sessions,
|
||||
artifacts: ctx.artifacts,
|
||||
recentEvents: ctx.events,
|
||||
serviceStates: ctx.serviceStates,
|
||||
routeRules: SERVICE_HREFS,
|
||||
},
|
||||
seeds: deterministic,
|
||||
});
|
||||
|
||||
const generatedBy = agentNotifications.length ? "agent" : "deterministic";
|
||||
const generatedSeeds: SeedNotification[] = agentNotifications.length
|
||||
? agentNotifications.map((n, index) => ({
|
||||
moduleId: n.moduleId,
|
||||
title: n.title,
|
||||
subtitle: n.subtitle,
|
||||
tag: n.tag,
|
||||
urgency: n.urgency,
|
||||
href: n.href,
|
||||
source: n.source,
|
||||
reason: n.reason,
|
||||
priority: 100 - index,
|
||||
}))
|
||||
: deterministic;
|
||||
|
||||
await replaceGeneratedNotifications(userId, ensureCoverage(generatedSeeds, dayOneSeeds), generatedBy);
|
||||
const rows = await readPersistedNotifications(userId);
|
||||
const mode = hasAnyRealActivity(ctx) ? "dynamic" : "day1";
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
mode,
|
||||
identity: await buildIdentity(ctx),
|
||||
modules: buildModules(rows, ctx, mode),
|
||||
};
|
||||
}
|
||||
|
||||
export async function dismissHomeNotification(userId: string, notificationId: string) {
|
||||
await db
|
||||
.update(growHomeNotifications)
|
||||
.set({ status: "dismissed", updatedAt: new Date() })
|
||||
.where(and(eq(growHomeNotifications.userId, userId), eq(growHomeNotifications.id, notificationId)));
|
||||
}
|
||||
|
||||
export async function getHomeFeedDebugCounts(userId: string) {
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(growHomeNotifications)
|
||||
.where(eq(growHomeNotifications.userId, userId));
|
||||
return { notifications: row?.count ?? 0 };
|
||||
}
|
||||
165
src/home/seed-demo-home.ts
Normal file
165
src/home/seed-demo-home.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import "dotenv/config";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { growActiveMissions, growHomeNotifications, growQscoreLatest, growQscoreProjectionState, missionArtifacts, missionServiceSessions, users, type NewGrowHomeNotification } from "../db/schema.js";
|
||||
import type { HomeModuleId, HomeSource, HomeUrgency } from "./types.js";
|
||||
|
||||
type DemoNotification = {
|
||||
moduleId: HomeModuleId;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
tag: string;
|
||||
urgency: HomeUrgency;
|
||||
href: string;
|
||||
source: HomeSource;
|
||||
priority: number;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
const demoNotifications: DemoNotification[] = [
|
||||
{ moduleId: "suggestions", title: "Approve resume v6", subtitle: "ATS 78 → 86 · two PM bullets are ready to ship.", tag: "Urgent", urgency: "now", href: "/agents/resume", source: "resume", priority: 120 },
|
||||
{ moduleId: "suggestions", title: "Mock follow-up at 6:30 PM", subtitle: "Behavioral drill based on yesterday's review gaps.", tag: "Today", urgency: "today", href: "/agents/interview", source: "interview", priority: 116 },
|
||||
{ moduleId: "suggestions", title: "Practice recruiter pushback", subtitle: "Roleplay can tighten your compensation and notice-period answer.", tag: "Soon", urgency: "soon", href: "/agents/roleplay", source: "roleplay", priority: 110 },
|
||||
|
||||
{ moduleId: "missions", title: "Interview-to-Offer — 72%", subtitle: "Resume fit done · mock review ready · final report locked.", tag: "Active", urgency: "today", href: "/missions", source: "mission", priority: 115 },
|
||||
{ moduleId: "missions", title: "2 approvals pending", subtitle: "Resume v6 and Mock #4 feedback need your confirmation.", tag: "Action", urgency: "now", href: "/missions", source: "mission", priority: 113 },
|
||||
{ moduleId: "missions", title: "Final readiness unlock", subtitle: "Complete one roleplay recovery drill to generate the final checklist.", tag: "Next", urgency: "soon", href: "/missions", source: "mission", priority: 106 },
|
||||
|
||||
{ moduleId: "social", title: "LinkedIn headline v3 ready", subtitle: "Clearer target: Product Intern · FinTech · Growth Systems.", tag: "Ready", urgency: "today", href: "/social", source: "social", priority: 104 },
|
||||
{ moduleId: "social", title: "Featured section has 3 proof pins", subtitle: "Use resume scan, mock review, and Q Score delta as credibility blocks.", tag: "Proof", urgency: "soon", href: "/social", source: "social", priority: 100 },
|
||||
{ moduleId: "social", title: "Banner options queued", subtitle: "Three calm orange/blue layouts are waiting for review.", tag: "Brand", urgency: "soon", href: "/social", source: "social", priority: 96 },
|
||||
|
||||
{ moduleId: "pathways", title: "Pathways stay locked for demo", subtitle: "Resume + interview data is enough; pathway service is not enabled yet.", tag: "Soon", urgency: "calm", href: "/pathways", source: "pathways", priority: 70 },
|
||||
{ moduleId: "pathways", title: "PM SaaS route predicted", subtitle: "Directional only until the pathways service is connected.", tag: "Preview", urgency: "calm", href: "/pathways", source: "pathways", priority: 68 },
|
||||
|
||||
{ moduleId: "productivity", title: "Resume ATS 86", subtitle: "Keyword relevance +9 after JD tailoring. Open Resume Builder.", tag: "Resume", urgency: "today", href: "/agents/resume", source: "resume", priority: 118 },
|
||||
{ moduleId: "productivity", title: "Interview review is ready", subtitle: "Overall 82 · storytelling and concise examples need one more drill.", tag: "Mock", urgency: "now", href: "/agents/interview", source: "interview", priority: 117 },
|
||||
{ moduleId: "productivity", title: "Roleplay recruiter screen", subtitle: "Scenario ready · handle salary range and start-date objections.", tag: "Roleplay", urgency: "soon", href: "/agents/roleplay", source: "roleplay", priority: 105 },
|
||||
{ moduleId: "productivity", title: "Q Score now 86", subtitle: "Signals: resume ATS, mock review, and roleplay completion.", tag: "Q Score", urgency: "today", href: "/agents/qscore", source: "qscore", priority: 102 },
|
||||
|
||||
{ moduleId: "rewards", title: "Mentor pass preview", subtitle: "Demo reward only · would unlock after 3 completed readiness actions.", tag: "Demo", urgency: "calm", href: "/rewards", source: "rewards", priority: 60 },
|
||||
{ moduleId: "rewards", title: "Resume Pro credit queued", subtitle: "Tabled for v1; shown as a consistent notification tile.", tag: "Soon", urgency: "calm", href: "/rewards", source: "rewards", priority: 58 },
|
||||
];
|
||||
|
||||
export async function seedDemoHome(userId: string) {
|
||||
await db.insert(users).values({ id: userId, email: `${userId}@demo.local`, displayName: "Demo User" }).onConflictDoNothing();
|
||||
|
||||
await db
|
||||
.delete(growHomeNotifications)
|
||||
.where(and(eq(growHomeNotifications.userId, userId), inArray(growHomeNotifications.generatedBy, ["demo", "agent", "deterministic"])));
|
||||
|
||||
const expiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
|
||||
const rows: NewGrowHomeNotification[] = demoNotifications.map((n) => ({
|
||||
userId,
|
||||
moduleId: n.moduleId,
|
||||
title: n.title,
|
||||
subtitle: n.subtitle,
|
||||
tag: n.tag,
|
||||
urgency: n.urgency,
|
||||
href: n.href,
|
||||
source: n.source,
|
||||
priority: n.priority,
|
||||
generatedBy: "demo",
|
||||
reason: n.reason ?? "Seeded for multi-day dashboard demo.",
|
||||
status: "active",
|
||||
expiresAt,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
await db.insert(growHomeNotifications).values(rows);
|
||||
|
||||
await db.insert(growQscoreProjectionState).values({
|
||||
userId,
|
||||
score: 86,
|
||||
signalCount: 14,
|
||||
dimensions: { strengths: ["resume.ats_compatibility", "interview.overall_score"], growth: ["interview.response_clarity", "roleplay.problem_resolution"] },
|
||||
summary: "Demo readiness score from resume, interview, and roleplay signals.",
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: growQscoreProjectionState.userId,
|
||||
set: {
|
||||
score: 86,
|
||||
signalCount: 14,
|
||||
dimensions: { strengths: ["resume.ats_compatibility", "interview.overall_score"], growth: ["interview.response_clarity", "roleplay.problem_resolution"] },
|
||||
summary: "Demo readiness score from resume, interview, and roleplay signals.",
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const signalRows = [
|
||||
["resume.uploaded", 100, "resume-service"],
|
||||
["resume.ats_compatibility", 86, "resume-service"],
|
||||
["resume.keyword_relevance", 84, "resume-service"],
|
||||
["interview.completed", 100, "interview-service"],
|
||||
["interview.overall_score", 82, "interview-service"],
|
||||
["interview.response_clarity", 74, "interview-service"],
|
||||
["roleplay.completed", 100, "roleplay-service"],
|
||||
["roleplay.communication_effectiveness", 79, "roleplay-service"],
|
||||
] as const;
|
||||
|
||||
for (const [signalId, score, source] of signalRows) {
|
||||
await db.insert(growQscoreLatest).values({
|
||||
userId,
|
||||
signalId,
|
||||
score,
|
||||
present: true,
|
||||
source,
|
||||
raw: { demo: true },
|
||||
occurredAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: [growQscoreLatest.userId, growQscoreLatest.signalId],
|
||||
set: { score, present: true, source, raw: { demo: true }, occurredAt: new Date(), updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
await db.insert(growActiveMissions).values({
|
||||
instanceId: `demo-interview-to-offer-${userId}`,
|
||||
userId,
|
||||
missionId: "interview-to-offer",
|
||||
workflowId: "interview-to-offer",
|
||||
actorType: "interviewToOfferMissionActor",
|
||||
title: "Interview-to-Offer Accelerator",
|
||||
shortTitle: "Interview-to-Offer",
|
||||
status: "active",
|
||||
progressPercent: 72,
|
||||
currentStageId: "mock-interview-review",
|
||||
goal: "Land a PM internship offer",
|
||||
mission: { demo: true, missionId: "interview-to-offer" },
|
||||
snapshot: { demo: true, progressPercent: 72 },
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: growActiveMissions.instanceId,
|
||||
set: { status: "active", progressPercent: 72, currentStageId: "mock-interview-review", snapshot: { demo: true, progressPercent: 72 }, updatedAt: new Date() },
|
||||
});
|
||||
|
||||
await db.insert(missionServiceSessions).values([
|
||||
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "resume-fit-scan", serviceId: "resume-service", externalId: `demo-resume-${userId}`, status: "completed", metadata: { demo: true, ats: 86 }, updatedAt: new Date() },
|
||||
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "mock-interview", serviceId: "interview-service", externalId: `demo-interview-${userId}`, status: "review_ready", metadata: { demo: true, overall: 82 }, updatedAt: new Date() },
|
||||
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "roleplay-recovery", serviceId: "roleplay-service", externalId: `demo-roleplay-${userId}`, status: "configured", metadata: { demo: true }, updatedAt: new Date() },
|
||||
]).onConflictDoNothing();
|
||||
|
||||
await db.insert(missionArtifacts).values([
|
||||
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "resume-fit-scan", serviceId: "resume-service", externalId: `demo-resume-${userId}`, type: "resume_fit_scan", title: "Resume Fit Scan", status: "ready", summary: "ATS 86 with stronger PM keywords.", metadata: { demo: true }, updatedAt: new Date() },
|
||||
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "mock-interview", serviceId: "interview-service", externalId: `demo-interview-${userId}`, type: "mock_interview_review", title: "Mock Interview Review", status: "ready", summary: "Overall 82; improve story brevity and tradeoff framing.", metadata: { demo: true }, updatedAt: new Date() },
|
||||
]).onConflictDoNothing();
|
||||
|
||||
return { inserted: rows.length, userId };
|
||||
}
|
||||
|
||||
const entry = process.argv[1] ?? "";
|
||||
if (entry.endsWith("seed-demo-home.ts") || entry.endsWith("seed-demo-home.js")) {
|
||||
const userId = process.env.DEMO_USER_ID;
|
||||
if (!userId) {
|
||||
console.error("Set DEMO_USER_ID to seed demo home notifications.");
|
||||
process.exit(1);
|
||||
}
|
||||
seedDemoHome(userId)
|
||||
.then((result) => {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
64
src/home/types.ts
Normal file
64
src/home/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export type HomeModuleId = "suggestions" | "missions" | "social" | "pathways" | "productivity" | "rewards";
|
||||
export type HomeUrgency = "now" | "today" | "soon" | "calm";
|
||||
export type HomeAccent = "orange" | "blue" | "teal" | "amber" | "violet";
|
||||
export type HomeSource = "resume" | "interview" | "roleplay" | "qscore" | "mission" | "social" | "pathways" | "rewards" | "system";
|
||||
|
||||
export type HomeNotification = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
tag: string;
|
||||
urgency: HomeUrgency;
|
||||
href: string;
|
||||
source?: HomeSource;
|
||||
reason?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type HomeModule = {
|
||||
id: HomeModuleId;
|
||||
label: string;
|
||||
href: string;
|
||||
accent: HomeAccent;
|
||||
count: string;
|
||||
notifications: HomeNotification[];
|
||||
};
|
||||
|
||||
export type HomeIdentity = {
|
||||
name: string;
|
||||
caption: string;
|
||||
qrSrc?: string;
|
||||
qx: { from: number; to: number; baseline: number };
|
||||
glance: { value: number; label: string }[];
|
||||
};
|
||||
|
||||
export type HomeFeedResponse = {
|
||||
generatedAt: string;
|
||||
mode: "day1" | "dynamic" | "demo";
|
||||
identity: HomeIdentity;
|
||||
modules: HomeModule[];
|
||||
};
|
||||
|
||||
export const MODULE_META: Record<HomeModuleId, Omit<HomeModule, "count" | "notifications">> = {
|
||||
suggestions: { id: "suggestions", label: "Suggestions", href: "/suggestions", accent: "orange" },
|
||||
missions: { id: "missions", label: "Missions", href: "/missions", accent: "orange" },
|
||||
social: { id: "social", label: "Social Branding", href: "/social", accent: "blue" },
|
||||
pathways: { id: "pathways", label: "Pathways", href: "/pathways", accent: "teal" },
|
||||
productivity: { id: "productivity", label: "Productivity", href: "/productivity", accent: "orange" },
|
||||
rewards: { id: "rewards", label: "Rewards", href: "/rewards", accent: "amber" },
|
||||
};
|
||||
|
||||
export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "social", "pathways", "productivity", "rewards"];
|
||||
|
||||
export const ALLOWED_NOTIFICATION_HREFS = new Set([
|
||||
"/suggestions",
|
||||
"/missions",
|
||||
"/social",
|
||||
"/pathways",
|
||||
"/productivity",
|
||||
"/rewards",
|
||||
"/agents/resume",
|
||||
"/agents/interview",
|
||||
"/agents/roleplay",
|
||||
"/agents/qscore",
|
||||
]);
|
||||
28
src/index.ts
28
src/index.ts
@@ -10,8 +10,15 @@ import { opencodeRoutes } from "./routes/opencode.js";
|
||||
import { gitRoutes } from "./routes/git.js";
|
||||
import { userRoutes } from "./routes/users.js";
|
||||
import { agentRoutes } from "./routes/agents.js";
|
||||
import { workflowRoutes } from "./routes/workflows.js";
|
||||
import { workflowRoutes, workflowRunRoutes } from "./routes/workflows.js";
|
||||
import { chatRoutes } from "./routes/chat.js";
|
||||
import { serviceRoutes } from "./routes/services.js";
|
||||
import { conversationRoutes } from "./routes/conversations.js";
|
||||
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 { startGrowEventsRedisConsumer } from "./events/redis-consumer.js";
|
||||
import { db } from "./db/client.js";
|
||||
import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js";
|
||||
import { initCatalog } from "./agents/catalog.js";
|
||||
@@ -33,6 +40,7 @@ async function main() {
|
||||
await initCatalog();
|
||||
|
||||
await reconcileOnBoot();
|
||||
startGrowEventsRedisConsumer().catch((err) => log.error({ err }, "failed to start grow events redis consumer"));
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -73,16 +81,23 @@ async function main() {
|
||||
app.route("/users", userRoutes());
|
||||
app.route("/agents", agentRoutes());
|
||||
app.route("/workflows", workflowRoutes());
|
||||
app.route("/workflow-runs", workflowRunRoutes());
|
||||
app.route("/actors", actorRoutes());
|
||||
app.route("/grow", growRoutes());
|
||||
app.route("/missions", missionRoutes());
|
||||
app.route("/events", eventRoutes());
|
||||
app.route("/home", homeRoutes());
|
||||
app.route("/conversations", conversationRoutes());
|
||||
app.route("/opencode", opencodeRoutes());
|
||||
app.route("/git", gitRoutes());
|
||||
app.route("/api/chat", chatRoutes());
|
||||
app.route("/services", serviceRoutes());
|
||||
|
||||
if (process.env.RIVET_ENDPOINT) {
|
||||
// Self-hosted: embedded engine runs at localhost:6420.
|
||||
// Proxy frontend Rivet traffic to the engine instead of using registry.handler()
|
||||
// (handler conflicts with startRunner — they're mutually exclusive).
|
||||
app.all("/api/rivet/*", async (c) => {
|
||||
const proxyRivet = async (c: any) => {
|
||||
const url = new URL(c.req.url);
|
||||
const target = new URL(config.rivetEndpoint);
|
||||
url.protocol = target.protocol;
|
||||
@@ -92,7 +107,7 @@ async function main() {
|
||||
|
||||
// Forward headers, stripping hop-by-hop ones
|
||||
const fwdHeaders = new Headers();
|
||||
for (const [k, v] of Object.entries(c.req.raw.headers)) {
|
||||
for (const [k, v] of c.req.raw.headers.entries()) {
|
||||
if (k.toLowerCase() === "host") continue;
|
||||
if (k.toLowerCase() === "transfer-encoding") continue;
|
||||
fwdHeaders.set(k, v);
|
||||
@@ -122,7 +137,9 @@ async function main() {
|
||||
log.error({ err, url: url.toString() }, "rivet proxy error");
|
||||
return c.json({ error: "proxy_error" }, 502);
|
||||
}
|
||||
});
|
||||
};
|
||||
app.all("/api/rivet", proxyRivet);
|
||||
app.all("/api/rivet/*", proxyRivet);
|
||||
registry.startRunner();
|
||||
} else {
|
||||
// Serverless: use registry.handler() for incoming actor traffic.
|
||||
@@ -134,7 +151,8 @@ async function main() {
|
||||
{
|
||||
port: info.port,
|
||||
rivet: config.rivetEndpoint,
|
||||
gitea: config.giteaUrl,
|
||||
giteaPublic: config.giteaPublicUrl,
|
||||
giteaInternal: config.giteaInternalUrl,
|
||||
env: config.nodeEnv,
|
||||
},
|
||||
"growqr-backend listening",
|
||||
|
||||
@@ -89,7 +89,7 @@ export function getSubAgentModule(id: string): SubAgentModule | undefined {
|
||||
}
|
||||
|
||||
export function jobApplicationModuleIds(): string[] {
|
||||
return ["resume", "job-search", "job-apply", "sara", "emily", "qscore"];
|
||||
return ["resume", "interview", "roleplay", "qscore"];
|
||||
}
|
||||
|
||||
// Load all prompt and agent files from disk.
|
||||
|
||||
8
src/missions/event-reducers.ts
Normal file
8
src/missions/event-reducers.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { MissionReducer } from "./reducer-types.js";
|
||||
import { interviewToOfferReducer } from "./interview-to-offer/reducer.js";
|
||||
|
||||
export const missionEventReducers: MissionReducer[] = [interviewToOfferReducer];
|
||||
|
||||
export function reducersForMission(missionId: string) {
|
||||
return missionEventReducers.filter((reducer) => reducer.missionId === missionId);
|
||||
}
|
||||
99
src/missions/interview-to-offer/reducer.ts
Normal file
99
src/missions/interview-to-offer/reducer.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { asRecord, getNumber, getString } from "../../events/envelope.js";
|
||||
import type { MissionArtifactPatch, MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
|
||||
function eventMatchesSource(source: string, type: string) {
|
||||
return (
|
||||
source.includes("resume") ||
|
||||
source.includes("interview") ||
|
||||
source.includes("qscore") ||
|
||||
type.startsWith("resume.") ||
|
||||
type.startsWith("interview.") ||
|
||||
type.startsWith("qscore.") ||
|
||||
type.startsWith("mission.interview_to_offer")
|
||||
);
|
||||
}
|
||||
|
||||
function reviewSummary(payload: Record<string, unknown>) {
|
||||
const review = asRecord(payload.review ?? payload.result ?? payload);
|
||||
const overall = getNumber(review.overall_score ?? review.overallScore);
|
||||
const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback);
|
||||
if (overall !== undefined && summary) return `Mock interview review completed with score ${overall}. ${summary}`;
|
||||
if (overall !== undefined) return `Mock interview review completed with score ${overall}.`;
|
||||
return summary ?? "Mock interview review completed.";
|
||||
}
|
||||
|
||||
export const interviewToOfferReducer: MissionReducer = {
|
||||
missionId: "interview-to-offer",
|
||||
|
||||
accepts(ctx) {
|
||||
if (ctx.activeMission.missionId !== "interview-to-offer") return false;
|
||||
const mission = asRecord(ctx.event.mission);
|
||||
const explicitMissionId = getString(mission.missionId ?? mission.mission_id);
|
||||
return explicitMissionId === "interview-to-offer" || eventMatchesSource(ctx.event.source.toLowerCase(), ctx.event.type);
|
||||
},
|
||||
|
||||
reduce(ctx): MissionReduction {
|
||||
const type = ctx.event.type;
|
||||
const payload = ctx.event.payload ?? {};
|
||||
const stagePatches: MissionStagePatch[] = [...ctx.insight.missionStageHints.map((hint) => ({
|
||||
stageId: hint.stageId,
|
||||
status: hint.status,
|
||||
progressPercent: hint.progressPercent,
|
||||
outputSummary: hint.reason,
|
||||
}))];
|
||||
const artifacts: MissionArtifactPatch[] = [];
|
||||
let eventMessage = ctx.insight.summary;
|
||||
|
||||
if (type.startsWith("resume.") && (type.includes("analysis_completed") || type.includes("analysis.complete") || type.includes("analyzed"))) {
|
||||
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Resume fit scan completed." });
|
||||
stagePatches.push({ stageId: "interview-plan", status: "ready", progressPercent: 0 });
|
||||
artifacts.push({
|
||||
type: "resume_fit_scan",
|
||||
title: "Resume Fit Scan",
|
||||
stageId: "resume",
|
||||
summary: getString(payload.summary) ?? "Resume analysis completed and readiness signals were updated.",
|
||||
metadata: { sourceEventId: ctx.event.id, payload },
|
||||
});
|
||||
eventMessage = "Resume fit scan completed for Interview-to-Offer.";
|
||||
}
|
||||
|
||||
if (type.startsWith("interview.") && (type.includes("configured") || type.includes("created"))) {
|
||||
stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 35, outputSummary: "Mock interview session configured." });
|
||||
eventMessage = "Mock interview session configured.";
|
||||
}
|
||||
|
||||
if (type.startsWith("interview.") && (type.includes("session_completed") || type.includes("session.completed"))) {
|
||||
stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 75, outputSummary: "Interview completed; review is being prepared." });
|
||||
eventMessage = "Mock interview completed; waiting for review.";
|
||||
}
|
||||
|
||||
if (type.startsWith("interview.") && (type.includes("review_completed") || type.includes("review.completed"))) {
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: reviewSummary(payload) });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 60, outputSummary: "Readiness signals updated from interview review." });
|
||||
artifacts.push({
|
||||
type: "mock_interview_review",
|
||||
title: "Mock Interview Review",
|
||||
stageId: "interview",
|
||||
summary: reviewSummary(payload),
|
||||
metadata: { sourceEventId: ctx.event.id, payload },
|
||||
});
|
||||
eventMessage = "Mock interview review completed and mission readiness was updated.";
|
||||
}
|
||||
|
||||
if (ctx.qscoreSignals.length > 0) {
|
||||
stagePatches.push({
|
||||
stageId: "qscore",
|
||||
status: "in_progress",
|
||||
progressPercent: Math.max(40, Math.min(90, ctx.qscoreSignals.length * 15)),
|
||||
outputSummary: `${ctx.qscoreSignals.length} readiness signal${ctx.qscoreSignals.length === 1 ? "" : "s"} updated.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (type.startsWith("qscore.") && (type.includes("snapshot") || type.includes("computed") || type.includes("updated"))) {
|
||||
stagePatches.push({ stageId: "qscore", status: "done", progressPercent: 100, outputSummary: "Readiness Q Score updated." });
|
||||
eventMessage = "Readiness Q Score updated.";
|
||||
}
|
||||
|
||||
return { stagePatches, artifacts, eventMessage };
|
||||
},
|
||||
};
|
||||
56
src/missions/postgres-registry.ts
Normal file
56
src/missions/postgres-registry.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { missionRegistry } from "../db/schema.js";
|
||||
import type { MissionRegistryEntry } from "../actors/missions/types.js";
|
||||
import { getMissionDefinition, listMissionDefinitions } from "./registry.js";
|
||||
|
||||
function toRow(mission: MissionRegistryEntry) {
|
||||
return {
|
||||
id: mission.missionId,
|
||||
version: mission.version,
|
||||
title: mission.title,
|
||||
shortTitle: mission.shortTitle,
|
||||
actorType: mission.actorType,
|
||||
actorBacked: mission.actorBacked,
|
||||
skillPath: mission.skillPath,
|
||||
displayOrder: mission.displayOrder,
|
||||
definition: mission as unknown as Record<string, unknown>,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedMissionRegistry() {
|
||||
const missions = listMissionDefinitions();
|
||||
for (const mission of missions) {
|
||||
const row = toRow(mission);
|
||||
await db
|
||||
.insert(missionRegistry)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({
|
||||
target: missionRegistry.id,
|
||||
set: {
|
||||
version: row.version,
|
||||
title: row.title,
|
||||
shortTitle: row.shortTitle,
|
||||
actorType: row.actorType,
|
||||
actorBacked: row.actorBacked,
|
||||
skillPath: row.skillPath,
|
||||
displayOrder: row.displayOrder,
|
||||
definition: row.definition,
|
||||
updatedAt: row.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPersistedMissionDefinitions(): Promise<MissionRegistryEntry[]> {
|
||||
await seedMissionRegistry();
|
||||
const rows = await db.select().from(missionRegistry).orderBy(asc(missionRegistry.displayOrder));
|
||||
return rows.map((row) => row.definition as unknown as MissionRegistryEntry);
|
||||
}
|
||||
|
||||
export async function getPersistedMissionDefinition(missionId: string): Promise<MissionRegistryEntry | undefined> {
|
||||
await seedMissionRegistry();
|
||||
const [row] = await db.select().from(missionRegistry).where(eq(missionRegistry.id, missionId)).limit(1);
|
||||
return row ? (row.definition as unknown as MissionRegistryEntry) : getMissionDefinition(missionId);
|
||||
}
|
||||
40
src/missions/reducer-types.ts
Normal file
40
src/missions/reducer-types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { GrowEventRow } from "../db/schema.js";
|
||||
import type { ProjectionInsight } from "../events/projectors/projection-agent.js";
|
||||
import type { QscoreSignal } from "../events/envelope.js";
|
||||
import type { GrowActiveMission, MissionStageStatus } from "../actors/missions/types.js";
|
||||
|
||||
export type MissionReducerContext = {
|
||||
userId: string;
|
||||
activeMission: GrowActiveMission;
|
||||
event: GrowEventRow;
|
||||
qscoreSignals: QscoreSignal[];
|
||||
insight: ProjectionInsight;
|
||||
};
|
||||
|
||||
export type MissionArtifactPatch = {
|
||||
type: string;
|
||||
title: string;
|
||||
stageId?: string;
|
||||
summary?: string;
|
||||
contentMd?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MissionStagePatch = {
|
||||
stageId: string;
|
||||
status?: MissionStageStatus;
|
||||
progressPercent?: number;
|
||||
outputSummary?: string;
|
||||
};
|
||||
|
||||
export type MissionReduction = {
|
||||
stagePatches: MissionStagePatch[];
|
||||
artifacts: MissionArtifactPatch[];
|
||||
eventMessage?: string;
|
||||
};
|
||||
|
||||
export type MissionReducer = {
|
||||
missionId: string;
|
||||
accepts(ctx: MissionReducerContext): boolean;
|
||||
reduce(ctx: MissionReducerContext): MissionReduction;
|
||||
};
|
||||
52
src/missions/registry.ts
Normal file
52
src/missions/registry.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { getWorkflowDefinition, listWorkflowDefinitions } from "../workflows/registry.js";
|
||||
import type { MissionActorType, MissionId, MissionRegistryEntry } from "../actors/missions/types.js";
|
||||
|
||||
const MISSION_ORDER: MissionId[] = [
|
||||
"interview-to-offer",
|
||||
"career-transition",
|
||||
"salary-negotiation-war-room",
|
||||
"promotion-readiness",
|
||||
"personal-brand-opportunity-engine",
|
||||
];
|
||||
|
||||
const ACTOR_BACKED_MISSIONS: Record<MissionId, MissionActorType> = {
|
||||
"interview-to-offer": "interviewToOfferMissionActor",
|
||||
"career-transition": "careerTransitionMissionActor",
|
||||
"salary-negotiation-war-room": "salaryNegotiationWarRoomMissionActor",
|
||||
"promotion-readiness": "promotionReadinessMissionActor",
|
||||
"personal-brand-opportunity-engine": "personalBrandOpportunityEngineMissionActor",
|
||||
};
|
||||
|
||||
export function listMissionDefinitions(): MissionRegistryEntry[] {
|
||||
return MISSION_ORDER.map((missionId, index) => {
|
||||
const workflow = getWorkflowDefinition(missionId);
|
||||
if (!workflow) throw new Error(`Mission workflow definition not found: ${missionId}`);
|
||||
const actorType = ACTOR_BACKED_MISSIONS[missionId];
|
||||
return {
|
||||
...workflow,
|
||||
kind: "mission",
|
||||
missionId,
|
||||
actorType,
|
||||
actorBacked: Boolean(actorType),
|
||||
skillVersion: workflow.version,
|
||||
skillPath: `src/actors/missions/${missionId}/SKILL.md`,
|
||||
displayOrder: index + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getMissionDefinition(missionId: string): MissionRegistryEntry | undefined {
|
||||
return listMissionDefinitions().find((mission) => mission.missionId === missionId);
|
||||
}
|
||||
|
||||
export function listAvailableMissionDefinitions(): MissionRegistryEntry[] {
|
||||
const ids = new Set(MISSION_ORDER);
|
||||
return listWorkflowDefinitions()
|
||||
.filter((workflow) => ids.has(workflow.id as MissionId))
|
||||
.map((workflow) => getMissionDefinition(workflow.id))
|
||||
.filter((mission): mission is MissionRegistryEntry => Boolean(mission));
|
||||
}
|
||||
|
||||
export function isActorBackedMission(missionId: string): missionId is MissionId {
|
||||
return Boolean(ACTOR_BACKED_MISSIONS[missionId as MissionId]);
|
||||
}
|
||||
152
src/missions/suggestions.ts
Normal file
152
src/missions/suggestions.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { MissionSnapshot, MissionStage } from "../actors/missions/types.js";
|
||||
|
||||
export type MissionSuggestionType = "action" | "practice" | "review" | "artifact" | "blocked" | "insight";
|
||||
export type MissionSuggestionUrgency = "now" | "today" | "soon" | "calm";
|
||||
export type MissionSuggestionStatus = "active" | "done" | "dismissed" | "expired";
|
||||
|
||||
export type MissionCoachContext = {
|
||||
preferences?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MissionSuggestion = {
|
||||
id: string;
|
||||
userId: string;
|
||||
missionInstanceId: string;
|
||||
missionId: string;
|
||||
stageId?: string;
|
||||
role: string;
|
||||
type: MissionSuggestionType;
|
||||
title: string;
|
||||
body: string;
|
||||
reason?: string;
|
||||
priority: number;
|
||||
urgency: MissionSuggestionUrgency;
|
||||
status: MissionSuggestionStatus;
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
sourceRefs: Record<string, unknown>;
|
||||
generatedBy: "deterministic" | "agent" | "manual";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
function roleOf(stage: MissionStage) {
|
||||
const role = stage.role.toLowerCase();
|
||||
if (role.includes("resume")) return "Resume";
|
||||
if (role.includes("roleplay") || role.includes("communication")) return "Roleplay";
|
||||
if (role.includes("q") || role.includes("score") || role.includes("readiness")) return "Q Score";
|
||||
if (role.includes("interview")) return "Interview";
|
||||
return stage.role || "Mission";
|
||||
}
|
||||
|
||||
function arrayOfStrings(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
|
||||
}
|
||||
|
||||
function recordOf(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function profileFromContext(context?: MissionCoachContext) {
|
||||
const preferences = recordOf(context?.preferences);
|
||||
const onboarding = recordOf(preferences.onboarding);
|
||||
const interview = recordOf(preferences.interview_preferences);
|
||||
const resume = recordOf(preferences.resume_preferences);
|
||||
const mission = recordOf(preferences.mission_preferences);
|
||||
const targetRoles = arrayOfStrings(preferences.target_roles);
|
||||
const targetCompanies = arrayOfStrings(preferences.target_companies);
|
||||
const focusAreas = arrayOfStrings(interview.focus_areas);
|
||||
return {
|
||||
targetRole: targetRoles[0] ?? (typeof resume.target_title === "string" ? resume.target_title : "Senior Data Scientist"),
|
||||
targetCompany: targetCompanies[0] ?? "target company",
|
||||
industry: typeof preferences.industry === "string" ? preferences.industry : "AI / SaaS",
|
||||
jobDescription: typeof interview.job_description === "string" ? interview.job_description : undefined,
|
||||
focusAreas,
|
||||
weakSpots: arrayOfStrings(interview.weak_spots),
|
||||
activeGoal: typeof mission.active_goal === "string" ? mission.active_goal : typeof onboarding.goal === "string" ? onboarding.goal : undefined,
|
||||
timeline: typeof onboarding.timeline === "string" ? onboarding.timeline : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function addProfileParams(params: URLSearchParams, profile: ReturnType<typeof profileFromContext>) {
|
||||
params.set("targetRole", profile.targetRole);
|
||||
if (profile.targetCompany !== "target company") params.set("targetCompany", profile.targetCompany);
|
||||
if (profile.industry) params.set("industry", profile.industry);
|
||||
if (profile.focusAreas.length) params.set("focusAreas", profile.focusAreas.slice(0, 4).join(","));
|
||||
if (profile.weakSpots.length) params.set("weakSpots", profile.weakSpots.slice(0, 3).join(","));
|
||||
if (profile.jobDescription) params.set("jobDescription", profile.jobDescription.slice(0, 900));
|
||||
}
|
||||
|
||||
function ctaFor(stage: MissionStage, snapshot: MissionSnapshot, context?: MissionCoachContext) {
|
||||
const params = new URLSearchParams({
|
||||
missionInstanceId: snapshot.instanceId,
|
||||
missionId: snapshot.missionId,
|
||||
stageId: stage.id,
|
||||
source: "mission",
|
||||
});
|
||||
const role = roleOf(stage);
|
||||
const profile = profileFromContext(context);
|
||||
addProfileParams(params, profile);
|
||||
|
||||
if (role === "Interview") {
|
||||
params.set("mode", "mission");
|
||||
params.set("prompt", `${stage.title} for ${profile.targetRole}`);
|
||||
return { label: stage.status === "in_progress" ? "Continue mock" : "Start mock", href: `/agents/interview/setup?${params.toString()}` };
|
||||
}
|
||||
if (role === "Roleplay") {
|
||||
params.set("scenario", `${stage.title} for ${profile.targetRole}`);
|
||||
return { label: stage.status === "in_progress" ? "Continue roleplay" : "Start roleplay", href: `/agents/roleplay/setup?${params.toString()}` };
|
||||
}
|
||||
if (role === "Resume") {
|
||||
params.set("focus", `${stage.title}: ${profile.targetRole}`);
|
||||
return { label: "Open resume", href: `/agents/resume?${params.toString()}` };
|
||||
}
|
||||
if (role === "Q Score") return { label: "View Q Score", href: `/agents/qscore?${params.toString()}` };
|
||||
return { label: "Continue", href: `/missions/active?${params.toString()}` };
|
||||
}
|
||||
|
||||
function suggestionId(snapshot: MissionSnapshot, stage: MissionStage, suffix: string) {
|
||||
return `sug-${snapshot.instanceId}-${stage.id}-${suffix}-${Date.now()}`;
|
||||
}
|
||||
|
||||
export function buildDeterministicMissionSuggestions(snapshot: MissionSnapshot, context: MissionCoachContext = {}) {
|
||||
const stages = snapshot.stages ?? [];
|
||||
const active = stages.filter((stage) => stage.status === "blocked" || stage.status === "in_progress" || stage.status === "ready");
|
||||
const candidates = active.length ? active : stages.filter((stage) => stage.status !== "done").slice(0, 3);
|
||||
const expiresAt = new Date(Date.now() + 36 * 60 * 60 * 1000).toISOString();
|
||||
const profile = profileFromContext(context);
|
||||
|
||||
return candidates.slice(0, 6).map((stage, index) => {
|
||||
const role = roleOf(stage);
|
||||
const cta = ctaFor(stage, snapshot, context);
|
||||
const isBlocked = stage.status === "blocked";
|
||||
const isCurrent = stage.id === snapshot.currentStageId;
|
||||
const personalizedBody = role === "Interview"
|
||||
? `Practice for ${profile.targetRole}${profile.targetCompany !== "target company" ? ` at ${profile.targetCompany}` : ""}. Focus on ${profile.focusAreas.slice(0, 3).join(", ") || "high-leverage interview areas"}.`
|
||||
: role === "Resume"
|
||||
? `Tailor your resume toward ${profile.targetRole}${profile.targetCompany !== "target company" ? ` at ${profile.targetCompany}` : ""}, emphasizing measurable data-science impact.`
|
||||
: role === "Roleplay"
|
||||
? `Roleplay concise stakeholder communication for ${profile.targetRole} interviews, especially ${profile.weakSpots.slice(0, 2).join(" and ") || "tradeoff framing"}.`
|
||||
: role === "Q Score"
|
||||
? `Review readiness signals against your ${profile.targetRole} goal and decide the next highest-leverage action.`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: suggestionId(snapshot, stage, index === 0 ? "top" : "next"),
|
||||
stageId: stage.id,
|
||||
role,
|
||||
type: isBlocked ? "blocked" as const : role === "Interview" || role === "Roleplay" ? "practice" as const : "action" as const,
|
||||
title: isBlocked ? `Unblock ${stage.title}` : stage.status === "in_progress" ? `Continue ${stage.title}` : `Start ${stage.title}`,
|
||||
body: stage.outputSummary || personalizedBody || stage.description || `Use the ${role} agent to move this mission forward.`,
|
||||
reason: isCurrent ? (profile.activeGoal ?? "This is the current mission focus.") : "This is the next available step for this workflow.",
|
||||
priority: (isBlocked ? 130 : isCurrent ? 110 : stage.status === "in_progress" ? 95 : 75) - index,
|
||||
urgency: isBlocked ? "now" as const : isCurrent ? "today" as const : "soon" as const,
|
||||
ctaLabel: cta.label,
|
||||
ctaHref: cta.href,
|
||||
sourceRefs: { stageId: stage.id, generatedFrom: "mission_snapshot", profile: { targetRole: profile.targetRole, targetCompany: profile.targetCompany } },
|
||||
generatedBy: "deterministic" as const,
|
||||
expiresAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,13 @@ import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { actors as actorsTable } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { UserStack } from "../db/schema.js";
|
||||
|
||||
function publicStack(stack: UserStack | null) {
|
||||
if (!stack) return null;
|
||||
const { opencodePassword: _opencodePassword, ...safe } = stack;
|
||||
return safe;
|
||||
}
|
||||
|
||||
// Per changes.md §5: ONE unified actor per user.
|
||||
// Routes are user-scoped via Clerk auth; userId derived from session token.
|
||||
@@ -19,7 +26,7 @@ export function actorRoutes() {
|
||||
app.post("/provision", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const stack = await provisionUserStack(userId);
|
||||
return c.json({ userId, stack });
|
||||
return c.json({ userId, stack: publicStack(stack) });
|
||||
});
|
||||
|
||||
app.get("/me", async (c) => {
|
||||
@@ -29,13 +36,13 @@ export function actorRoutes() {
|
||||
.select()
|
||||
.from(actorsTable)
|
||||
.where(eq(actorsTable.userId, userId));
|
||||
return c.json({ userId, stack, actors: rows });
|
||||
return c.json({ userId, stack: publicStack(stack), actors: rows });
|
||||
});
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const all = await listStacks();
|
||||
return c.json({ stacks: all.filter((s) => s.userId === userId) });
|
||||
return c.json({ stacks: all.filter((s) => s.userId === userId).map(publicStack) });
|
||||
});
|
||||
|
||||
app.post("/stop", async (c) => {
|
||||
|
||||
@@ -14,6 +14,18 @@ import {
|
||||
} from "../services/service-agents.js";
|
||||
import { getSubAgentModules } from "../lib/prompt-loader.js";
|
||||
|
||||
const RIVET_CHAT_TIMEOUT_MS = 2500;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`${label} timed out`)), ms);
|
||||
promise.then(
|
||||
(value) => { clearTimeout(timer); resolve(value); },
|
||||
(error) => { clearTimeout(timer); reject(error); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const chatSchema = z.object({
|
||||
messages: z.array(
|
||||
z.object({
|
||||
@@ -40,7 +52,7 @@ function buildTools() {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_interview_session",
|
||||
description: "Create a real interview practice session via the Sara / interview-service microservice. Call this when the user asks to start or launch an interview.",
|
||||
description: "Create a real interview practice session via the Interview Agent / interview-service microservice. Call this when the user asks to start or launch an interview.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -54,7 +66,7 @@ function buildTools() {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_roleplay_session",
|
||||
description: "Create a real roleplay session via Emily / roleplay-service. Call when user asks for roleplay or negotiation practice.",
|
||||
description: "Create a real roleplay session via Roleplay Agent / roleplay-service. Call when user asks for roleplay or negotiation practice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -82,7 +94,7 @@ function buildTools() {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "compute_qscore",
|
||||
description: "Compute user's readiness Q-Score via Quinn / qscore-service.",
|
||||
description: "Compute user's readiness Q-Score via Q Score Agent / qscore-service.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -97,44 +109,6 @@ export function chatRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
// Infer workflow step from which agents have been run
|
||||
function inferWorkflowStep(sessions: Array<{ moduleId: string; status: string }>, messages: Array<{ role: string; content: string }>): { workflowActive: boolean; workflowStep: number; goal: string } {
|
||||
const doneModules = new Set(sessions.filter(s => s.status === "done").map(s => s.moduleId));
|
||||
let step = 0;
|
||||
let goal = "";
|
||||
|
||||
// Extract goal from conversation (look for "I have an interview at..." or "prepare for...")
|
||||
for (const m of messages) {
|
||||
if (m.role === "user") {
|
||||
const lower = m.content.toLowerCase();
|
||||
if (lower.includes("interview at") || lower.includes("prepare for") || lower.includes("role at") || lower.includes("apply to")) {
|
||||
goal = m.content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Infer step from completed modules
|
||||
// Step 1: Workflow started (user described goal)
|
||||
if (goal) step = 1;
|
||||
// Step 2: User shared JD/role info
|
||||
if (messages.filter(m => m.role === "user" && m.content.length > 30).length >= 2) step = 2;
|
||||
// Step 3: Resume agent done
|
||||
if (doneModules.has("resume")) step = 3;
|
||||
// Step 4: Interview session created
|
||||
if (doneModules.has("sara")) step = 4;
|
||||
// Step 5: Roleplay session created
|
||||
if (doneModules.has("emily")) step = 5;
|
||||
// Step 6: QScore computed
|
||||
if (doneModules.has("qscore")) step = 6;
|
||||
|
||||
return {
|
||||
workflowActive: step > 0,
|
||||
workflowStep: step,
|
||||
goal: goal || "Career preparation",
|
||||
};
|
||||
}
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = chatSchema.parse(await c.req.json());
|
||||
@@ -144,14 +118,13 @@ export function chatRoutes() {
|
||||
try {
|
||||
const client = createClient<Registry>(config.rivetClientEndpoint);
|
||||
const handle = client.userActor.getOrCreate([userId]);
|
||||
await handle.init({ userId });
|
||||
const result = await handle.receiveMessage({ text: userText });
|
||||
await withTimeout(handle.init({ userId }), RIVET_CHAT_TIMEOUT_MS, "Rivet init");
|
||||
const result = await withTimeout(handle.receiveMessage({ text: userText }), RIVET_CHAT_TIMEOUT_MS, "Rivet chat");
|
||||
if (result?.reply) {
|
||||
const reply = cleanWorkflowTag(String(result.reply));
|
||||
const workflow = extractWorkflowTag(String(result.reply));
|
||||
const sessions = (result as any).sessions ?? [];
|
||||
const stepInfo = inferWorkflowStep(sessions, body.messages);
|
||||
return c.json({ reply, workflow, sessions, ...stepInfo });
|
||||
return c.json({ reply, workflow, sessions });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Rivet chat unavailable, using direct LLM:", err instanceof Error ? err.message : String(err));
|
||||
@@ -201,14 +174,14 @@ export function chatRoutes() {
|
||||
switch (toolCall.name) {
|
||||
case "start_interview_session": {
|
||||
toolResult = await runServiceAgentProbe(
|
||||
{ id: "sara", name: "Sara", role: "Interview Agent", kind: "microservice", description: "Interview practice", service: "interview-service" },
|
||||
{ id: "interview", name: "Interview Agent", role: "Interview Agent", kind: "microservice", description: "Interview practice", service: "interview-service" },
|
||||
{ userId, goal: String(toolCall.arguments.target_role ?? "general preparation") },
|
||||
);
|
||||
if (toolResult.status === "ok" && toolResult.detail) {
|
||||
const detail = toolResult.detail as Record<string, unknown>;
|
||||
sessions.push({
|
||||
moduleId: "sara",
|
||||
moduleName: "Sara",
|
||||
moduleId: "interview",
|
||||
moduleName: "Interview Agent",
|
||||
status: "done",
|
||||
sessionId: detail.session_id as string,
|
||||
sessionUrl: typeof detail.ui_session_url === "string"
|
||||
@@ -221,14 +194,14 @@ export function chatRoutes() {
|
||||
}
|
||||
case "start_roleplay_session": {
|
||||
toolResult = await runServiceAgentProbe(
|
||||
{ id: "emily", name: "Emily", role: "Roleplay Agent", kind: "microservice", description: "Roleplay practice", service: "roleplay-service" },
|
||||
{ id: "roleplay", name: "Roleplay Agent", role: "Roleplay Agent", kind: "microservice", description: "Roleplay practice", service: "roleplay-service" },
|
||||
{ userId, goal: String(toolCall.arguments.goal ?? "general practice") },
|
||||
);
|
||||
if (toolResult.status === "ok" && toolResult.detail) {
|
||||
const detail = toolResult.detail as Record<string, unknown>;
|
||||
sessions.push({
|
||||
moduleId: "emily",
|
||||
moduleName: "Emily",
|
||||
moduleId: "roleplay",
|
||||
moduleName: "Roleplay Agent",
|
||||
status: "done",
|
||||
sessionId: detail.session_id as string,
|
||||
sessionUrl: typeof detail.ui_session_url === "string"
|
||||
@@ -241,7 +214,7 @@ export function chatRoutes() {
|
||||
}
|
||||
case "analyze_resume": {
|
||||
toolResult = await runServiceAgentProbe(
|
||||
{ id: "resume", name: "Resume Agent", role: "Resume Builder", kind: "microservice", description: "Resume analysis", service: "resume-service" },
|
||||
{ id: "resume", name: "Resume Agent", role: "Resume Agent", kind: "microservice", description: "Resume analysis", service: "resume-service" },
|
||||
{ userId, goal: String(toolCall.arguments.goal ?? "general") },
|
||||
);
|
||||
if (toolResult.status === "ok") {
|
||||
@@ -260,11 +233,11 @@ export function chatRoutes() {
|
||||
}
|
||||
case "compute_qscore": {
|
||||
toolResult = await runServiceAgentProbe(
|
||||
{ id: "qscore", name: "Quinn", role: "Q-Score Agent", kind: "score", description: "Readiness scoring", service: "qscore-service" },
|
||||
{ id: "qscore", name: "Q Score Agent", role: "Q Score Agent", kind: "score", description: "Readiness scoring", service: "qscore-service" },
|
||||
{ userId, goal: "general assessment" },
|
||||
);
|
||||
if (toolResult.status === "ok") {
|
||||
sessions.push({ moduleId: "qscore", moduleName: "Quinn", status: "done", summary: toolResult.summary });
|
||||
sessions.push({ moduleId: "qscore", moduleName: "Q Score Agent", status: "done", summary: toolResult.summary });
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -293,7 +266,6 @@ export function chatRoutes() {
|
||||
reply: cleanWorkflowTag(reply),
|
||||
workflow: extractWorkflowTag(reply),
|
||||
sessions,
|
||||
...inferWorkflowStep(sessions, body.messages),
|
||||
});
|
||||
} catch (llmErr) {
|
||||
console.error("Direct LLM chat error:", llmErr);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user