Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0292392e64 | ||
|
|
594b8d711c | ||
|
|
3c1ffd2c2d | ||
|
|
4ebb123ba3 | ||
|
|
bb5cecb348 | ||
|
|
88f55d9135 | ||
|
|
e7e1bd26eb | ||
|
|
5d86b8fa93 | ||
|
|
35ea464661 | ||
|
|
e87ecdb3e5 | ||
|
|
a947db7bfd | ||
|
|
a35ea62ab1 | ||
|
|
f6c93dcb5c | ||
|
|
7f0408d417 | ||
|
|
cfd2a19bd7 | ||
|
|
d2a9e26395 | ||
|
|
0734b1f3a5 | ||
|
|
ce6420f3de | ||
|
|
81f58b15c8 | ||
|
|
0c2113f710 | ||
|
|
900a506c1f | ||
|
|
c3d29e796a | ||
|
|
c2365ae519 |
+20
-125
@@ -1,129 +1,24 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Mosaic — Environment Variables Reference
|
||||
# Copy this file to .env and fill in the values for your deployment.
|
||||
# Lines beginning with # are comments; optional vars are commented out.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Non-secret runtime settings for the mosaic-poc-agent container.
|
||||
# Copy to .env if you want to override the defaults in compose.yaml.
|
||||
#
|
||||
# NEVER put credentials in this file. Authentication is supplied at
|
||||
# runtime only, via one of the two documented paths:
|
||||
# 1. read-only mounted pi auth file (default: ~/.pi/agent/auth.json,
|
||||
# override the host path with PI_AUTH_FILE)
|
||||
# 2. provider API key environment variable (ZAI_API_KEY or
|
||||
# ANTHROPIC_API_KEY), passed through by compose.yaml when set
|
||||
|
||||
# Model provider (built-in pi provider name)
|
||||
PI_PROVIDER=zai
|
||||
|
||||
# ─── Database (PostgreSQL 17 + pgvector) ─────────────────────────────────────
|
||||
# Full connection string used by the gateway, ORM, and migration runner.
|
||||
# Port 5433 avoids conflict with a host-side PostgreSQL instance.
|
||||
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
|
||||
# Model ID within the provider
|
||||
PI_MODEL=glm-5.3-flash
|
||||
|
||||
# Docker Compose host-port override for the PostgreSQL container (default: 5433)
|
||||
# PG_HOST_PORT=5433
|
||||
# Optional: alternative host path of the pi credential file mounted
|
||||
# read-only at /home/node/.pi/agent/auth.json in the container
|
||||
#PI_AUTH_FILE=/home/jwoltje/.pi/agent/auth.json
|
||||
|
||||
|
||||
# ─── Queue (Valkey 8 / Redis-compatible) ─────────────────────────────────────
|
||||
# Port 6380 avoids conflict with a host-side Redis/Valkey instance.
|
||||
VALKEY_URL=redis://localhost:6380
|
||||
|
||||
# Docker Compose host-port override for the Valkey container (default: 6380)
|
||||
# VALKEY_HOST_PORT=6380
|
||||
|
||||
|
||||
# ─── Gateway ─────────────────────────────────────────────────────────────────
|
||||
# TCP port the NestJS/Fastify gateway listens on (default: 4000)
|
||||
GATEWAY_PORT=4000
|
||||
|
||||
# Comma-separated list of allowed CORS origins.
|
||||
# Must include the web app origin in production.
|
||||
GATEWAY_CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
|
||||
# ─── Auth (BetterAuth) ───────────────────────────────────────────────────────
|
||||
# REQUIRED — random secret used to sign sessions and tokens.
|
||||
# Generate with: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
||||
|
||||
# Public base URL of the gateway (used by BetterAuth for callback URLs)
|
||||
BETTER_AUTH_URL=http://localhost:4000
|
||||
|
||||
|
||||
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
|
||||
# Public gateway URL — accessible from the browser, not just the server.
|
||||
NEXT_PUBLIC_GATEWAY_URL=http://localhost:4000
|
||||
|
||||
|
||||
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
||||
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
|
||||
# Service name shown in traces
|
||||
OTEL_SERVICE_NAME=mosaic-gateway
|
||||
|
||||
|
||||
# ─── AI Providers ────────────────────────────────────────────────────────────
|
||||
|
||||
# Ollama (local models — set OLLAMA_BASE_URL to enable)
|
||||
# OLLAMA_BASE_URL=http://localhost:11434
|
||||
# OLLAMA_HOST is a legacy alias for OLLAMA_BASE_URL
|
||||
# OLLAMA_HOST=http://localhost:11434
|
||||
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
|
||||
# OLLAMA_MODELS=llama3.2,codellama,mistral
|
||||
|
||||
# OpenAI — required for embedding and log-summarization features
|
||||
# OPENAI_API_KEY=sk-...
|
||||
|
||||
# Custom providers — JSON array of provider configs
|
||||
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
|
||||
# MOSAIC_CUSTOM_PROVIDERS=
|
||||
|
||||
|
||||
# ─── Embedding Service ───────────────────────────────────────────────────────
|
||||
# OpenAI-compatible embeddings endpoint (default: OpenAI)
|
||||
# EMBEDDING_API_URL=https://api.openai.com/v1
|
||||
# EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
|
||||
# ─── Log Summarization Service ───────────────────────────────────────────────
|
||||
# OpenAI-compatible chat completions endpoint for log summarization (default: OpenAI)
|
||||
# SUMMARIZATION_API_URL=https://api.openai.com/v1
|
||||
# SUMMARIZATION_MODEL=gpt-4o-mini
|
||||
|
||||
# Cron schedule for summarization job (default: every 6 hours)
|
||||
# SUMMARIZATION_CRON=0 */6 * * *
|
||||
|
||||
# Cron schedule for log tier management (default: daily at 03:00)
|
||||
# TIER_MANAGEMENT_CRON=0 3 * * *
|
||||
|
||||
|
||||
# ─── Agent ───────────────────────────────────────────────────────────────────
|
||||
# Filesystem sandbox root for agent file tools (default: process.cwd())
|
||||
# AGENT_FILE_SANDBOX_DIR=/var/lib/mosaic/sandbox
|
||||
|
||||
# Comma-separated list of tool names available to non-admin users.
|
||||
# Leave unset to allow all tools for all authenticated users.
|
||||
# AGENT_USER_TOOLS=read_file,list_directory,search_files
|
||||
|
||||
# System prompt injected into every agent session (optional)
|
||||
# AGENT_SYSTEM_PROMPT=You are a helpful assistant.
|
||||
|
||||
|
||||
# ─── MCP Servers ─────────────────────────────────────────────────────────────
|
||||
# JSON array of MCP server configs — set to enable MCP tool integration.
|
||||
# Each entry: {"name":"<id>","url":"<http-or-sse-url>"}
|
||||
# MCP_SERVERS=[{"name":"my-mcp","url":"http://localhost:3100/sse"}]
|
||||
|
||||
|
||||
# ─── Coordinator ─────────────────────────────────────────────────────────────
|
||||
# Root directory used to scope coordinator (worktree/repo) operations.
|
||||
# Defaults to the monorepo root auto-detected from process.cwd().
|
||||
# MOSAIC_WORKSPACE_ROOT=/home/user/projects/mosaic
|
||||
|
||||
|
||||
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
|
||||
# DISCORD_BOT_TOKEN=
|
||||
# DISCORD_GUILD_ID=
|
||||
# DISCORD_GATEWAY_URL=http://localhost:4000
|
||||
|
||||
|
||||
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
# TELEGRAM_GATEWAY_URL=http://localhost:4000
|
||||
|
||||
|
||||
# ─── Authentik SSO (optional — set AUTHENTIK_CLIENT_ID to enable) ────────────
|
||||
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
||||
# AUTHENTIK_CLIENT_ID=
|
||||
# AUTHENTIK_CLIENT_SECRET=
|
||||
# Optional: documented env-var auth alternative (secret! set in your
|
||||
# shell or a gitignored .env, never commit)
|
||||
#ZAI_API_KEY=
|
||||
#ANTHROPIC_API_KEY=
|
||||
|
||||
+7
-10
@@ -1,11 +1,8 @@
|
||||
logs/
|
||||
node_modules
|
||||
dist
|
||||
.turbo
|
||||
.next
|
||||
coverage
|
||||
# build/deps
|
||||
node_modules/
|
||||
|
||||
# runtime credentials — never commit, never copy into the image
|
||||
.env
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
docs/reports/
|
||||
secrets/
|
||||
|
||||
# generated runtime state lives in /home/jwoltje/.mosaic-dev (outside this project)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
npx lint-staged
|
||||
@@ -1 +0,0 @@
|
||||
pnpm typecheck && pnpm lint && pnpm format:check
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"**/*.{ts,tsx,js,jsx}": [
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
],
|
||||
"**/*.{json,md,yaml,yml}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
# Repo Mosaic Linkage
|
||||
|
||||
This repository is attached to the machine-wide Mosaic framework.
|
||||
|
||||
## Load Order for Agents
|
||||
|
||||
1. `~/.config/mosaic/STANDARDS.md`
|
||||
2. `AGENTS.md` (this repository)
|
||||
3. `.mosaic/repo-hooks.sh` (repo-specific automation hooks)
|
||||
|
||||
## Purpose
|
||||
|
||||
- Keep universal standards in `~/.config/mosaic`
|
||||
- Keep repo-specific behavior in this repo
|
||||
- Avoid copying large runtime configs into each project
|
||||
|
||||
## Optional Quality Rails
|
||||
|
||||
Use `.mosaic/quality-rails.yml` to track whether quality rails are enabled for this repo.
|
||||
|
||||
Apply a template:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/bin/mosaic-quality-apply --template <template> --target .
|
||||
```
|
||||
|
||||
Verify enforcement:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/bin/mosaic-quality-verify --target .
|
||||
```
|
||||
|
||||
## Optional Matrix Orchestrator Rail
|
||||
|
||||
Repo-local orchestrator state lives in `.mosaic/orchestrator/`.
|
||||
|
||||
Run one cycle:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/bin/mosaic-orchestrator-matrix-cycle
|
||||
~/.config/mosaic/bin/mosaic-orchestrator-run --once
|
||||
```
|
||||
|
||||
Run continuously:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/bin/mosaic-orchestrator-run --poll-sec 10
|
||||
```
|
||||
|
||||
Bridge events to Matrix:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/bin/mosaic-orchestrator-matrix-publish
|
||||
~/.config/mosaic/bin/mosaic-orchestrator-matrix-consume
|
||||
```
|
||||
|
||||
Run until queue is drained (syncs from `docs/tasks.md` first):
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/bin/mosaic-orchestrator-drain
|
||||
```
|
||||
|
||||
Set worker command if auto-detect does not match your CLI:
|
||||
|
||||
```bash
|
||||
export MOSAIC_WORKER_EXEC="codex -p"
|
||||
# or
|
||||
export MOSAIC_WORKER_EXEC="opencode -p"
|
||||
```
|
||||
|
||||
Use repo helper (foreground or detached):
|
||||
|
||||
```bash
|
||||
bash scripts/agent/orchestrator-daemon.sh drain
|
||||
bash scripts/agent/orchestrator-daemon.sh start
|
||||
bash scripts/agent/orchestrator-daemon.sh status
|
||||
bash scripts/agent/orchestrator-daemon.sh stop
|
||||
```
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"transport": "matrix",
|
||||
"matrix": {
|
||||
"control_room_id": "",
|
||||
"workspace_id": "",
|
||||
"homeserver_url": "",
|
||||
"access_token": "",
|
||||
"bot_user_id": ""
|
||||
},
|
||||
"worker": {
|
||||
"runtime": "codex",
|
||||
"command_template": "bash scripts/agent/orchestrator-worker.sh {task_file}",
|
||||
"timeout_seconds": 7200,
|
||||
"max_attempts": 1
|
||||
},
|
||||
"quality_gates": ["pnpm lint", "pnpm typecheck", "pnpm test"]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"last_published_line": 0,
|
||||
"since": null
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"mission_id": "mvp-20260312",
|
||||
"name": "MVP",
|
||||
"description": "",
|
||||
"project_path": "/home/jwoltje/src/mosaic-mono-v1",
|
||||
"created_at": "2026-03-13T00:44:02Z",
|
||||
"status": "active",
|
||||
"task_prefix": "",
|
||||
"quality_gates": "",
|
||||
"milestone_version": "0.0.1",
|
||||
"milestones": [],
|
||||
"sessions": []
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"generated_at": "2026-03-13T00:44:51Z",
|
||||
"runtime": "claude",
|
||||
"mission_id": "mvp-20260312",
|
||||
"mission_name": "MVP",
|
||||
"project_path": "/home/jwoltje/src/mosaic-mono-v1",
|
||||
"quality_gates": "",
|
||||
"current_milestone": {
|
||||
"id": "",
|
||||
"name": ""
|
||||
},
|
||||
"next_task": "",
|
||||
"progress": {
|
||||
"tasks_done": 0,
|
||||
"tasks_total": 0,
|
||||
"pct": 0
|
||||
},
|
||||
"current_branch": ""
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"session_id": "claude-20260312-194506-357136",
|
||||
"runtime": "claude",
|
||||
"pid": 357136,
|
||||
"started_at": "2026-03-13T00:45:06Z",
|
||||
"project_path": "/home/jwoltje/src/mosaic-mono-v1",
|
||||
"milestone_id": ""
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"running_task_id": null,
|
||||
"updated_at": null
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"tasks": []
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
enabled: false
|
||||
template: ""
|
||||
|
||||
# Set enabled: true and choose one template:
|
||||
# - typescript-node
|
||||
# - typescript-nextjs
|
||||
# - monorepo
|
||||
#
|
||||
# Apply manually:
|
||||
# ~/.mosaic/bin/mosaic-quality-apply --template <template> --target <repo>
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Optional repo-specific hooks used by scripts/agent/*.sh
|
||||
|
||||
# Called by session-start.sh
|
||||
# mosaic_hook_session_start() {
|
||||
# echo "Run repo-specific startup checks"
|
||||
# }
|
||||
|
||||
# Called by critical.sh
|
||||
# mosaic_hook_critical() {
|
||||
# echo "Run repo-specific critical queries"
|
||||
# }
|
||||
|
||||
# Called by session-end.sh
|
||||
# mosaic_hook_session_end() {
|
||||
# echo "Run repo-specific end-of-session checks"
|
||||
# }
|
||||
@@ -1 +0,0 @@
|
||||
@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
|
||||
@@ -1,7 +0,0 @@
|
||||
pnpm-lock.yaml
|
||||
**/next-env.d.ts
|
||||
**/dist
|
||||
**/node_modules
|
||||
**/drizzle
|
||||
**/.next
|
||||
.claude/
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"semi": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
variables:
|
||||
- &node_image 'node:22-alpine'
|
||||
- &enable_pnpm 'corepack enable'
|
||||
|
||||
when:
|
||||
- event: [push, pull_request, manual]
|
||||
|
||||
# Turbo remote cache is at turbo.mosaicstack.dev (ducktors/turborepo-remote-cache).
|
||||
# TURBO_TOKEN is a Woodpecker secret injected via from_secret into the environment.
|
||||
# Turbo picks up TURBO_API, TURBO_TOKEN, and TURBO_TEAM automatically.
|
||||
|
||||
steps:
|
||||
install:
|
||||
image: *node_image
|
||||
commands:
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
environment:
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm typecheck
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
# lint, format, and test are independent — run in parallel after typecheck
|
||||
lint:
|
||||
image: *node_image
|
||||
environment:
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm lint
|
||||
depends_on:
|
||||
- typecheck
|
||||
|
||||
format:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm format:check
|
||||
depends_on:
|
||||
- typecheck
|
||||
|
||||
test:
|
||||
image: *node_image
|
||||
environment:
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm test
|
||||
depends_on:
|
||||
- typecheck
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
environment:
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm build
|
||||
depends_on:
|
||||
- lint
|
||||
- format
|
||||
- test
|
||||
@@ -1,55 +0,0 @@
|
||||
# Agent Guidelines — Mosaic Stack
|
||||
|
||||
## Required Load Order
|
||||
|
||||
1. `~/.config/mosaic/SOUL.md`
|
||||
2. `~/.config/mosaic/STANDARDS.md`
|
||||
3. `~/.config/mosaic/AGENTS.md`
|
||||
4. `~/.config/mosaic/guides/E2E-DELIVERY.md`
|
||||
5. `AGENTS.md` (this file)
|
||||
6. Runtime-specific guide: `~/.config/mosaic/runtime/<runtime>/RUNTIME.md`
|
||||
|
||||
## Project Context
|
||||
|
||||
Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram.
|
||||
|
||||
## Package Map
|
||||
|
||||
| Package | Purpose | Key Dependencies |
|
||||
| ------------------ | ------------------------------- | -------------------------------- |
|
||||
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
||||
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
||||
| `packages/types` | Shared TypeScript contracts | class-validator |
|
||||
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
|
||||
| `packages/auth` | BetterAuth configuration | better-auth, @mosaic/db |
|
||||
| `packages/brain` | Data layer (PG-backed) | @mosaic/db |
|
||||
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
||||
| `packages/coord` | Mission coordination | @mosaic/queue |
|
||||
| `packages/cli` | Unified CLI + Pi TUI | Ink, Pi SDK |
|
||||
| `plugins/discord` | Discord channel plugin | discord.js |
|
||||
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
||||
|
||||
## Architecture Rules
|
||||
|
||||
1. Gateway is the single API surface — all clients connect through it
|
||||
2. Pi SDK is ESM-only — gateway and CLI must use ESM
|
||||
3. Socket.IO typed events defined in `@mosaic/types` enforce compile-time contracts
|
||||
4. OTEL auto-instrumentation loads before NestJS bootstrap
|
||||
5. BetterAuth manages auth tables; schema defined in `@mosaic/db`
|
||||
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
|
||||
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
```bash
|
||||
docker compose up -d # Infrastructure
|
||||
pnpm install # Dependencies
|
||||
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
||||
```
|
||||
|
||||
## Repo-Specific Notes
|
||||
|
||||
- DTOs in `*.dto.ts` files at module boundaries
|
||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
||||
- NodeNext module resolution in all tsconfigs
|
||||
- Scratchpads are mandatory for non-trivial tasks
|
||||
@@ -0,0 +1,371 @@
|
||||
# Minimal Mosaic Stack container proof of concept
|
||||
|
||||
## Purpose
|
||||
|
||||
Build the smallest isolated container that can:
|
||||
- launch Pi
|
||||
- load a small set of Mosaic-style contract files
|
||||
- send one real request to a model
|
||||
- return a known response.
|
||||
|
||||
This is a standalone experiment. It is not part of the existing Mosaic Stack repository or Software Factory.
|
||||
|
||||
## Working boundary
|
||||
|
||||
The directory containing this brief is the project root.
|
||||
|
||||
### Do not read, copy, mount, import, or modify anything from:
|
||||
- `/home/jwoltje/.mosaic`
|
||||
- `/home/jwoltje/.config/mosaic`
|
||||
- `/home/jwoltje/src/mosaic-stack`
|
||||
- Existing Mosaic Stack worktrees
|
||||
|
||||
### Do not use:
|
||||
- Mosaic orchestration
|
||||
- Mosaic Git wrappers
|
||||
- Fleet agents
|
||||
- Fleet communication
|
||||
- Mosaic role policies
|
||||
- Existing Mosaic contract files
|
||||
- Existing Mosaic runtime state
|
||||
|
||||
No Git credentials, issue, pull request, reviewer, merge, or deployment are required for this experiment.
|
||||
|
||||
Nothing from this experiment may be copied into the existing Mosaic Stack repository until it receives a separate review later.
|
||||
|
||||
## Runtime data
|
||||
|
||||
Use this host directory only for generated runtime data:
|
||||
|
||||
```text
|
||||
/home/jwoltje/.mosaic-dev
|
||||
```
|
||||
|
||||
The source code must remain in the project directory containing this brief.
|
||||
|
||||
Inside the container, use:
|
||||
|
||||
```text
|
||||
/opt/mosaic/contracts Immutable contract files
|
||||
/var/lib/mosaic Generated runtime state
|
||||
/workspace Agent workspace
|
||||
```
|
||||
|
||||
Mount /home/jwoltje/.mosaic-dev at /var/lib/mosaic.
|
||||
|
||||
### Required proof
|
||||
|
||||
The finished experiment must prove one path:
|
||||
|
||||
1. Build one container image.
|
||||
2. Start one Pi agent inside the container.
|
||||
3. Load four local contract files from /opt/mosaic/contracts.
|
||||
4. Send a request that does not contain the expected response.
|
||||
5. Receive MOSAIC_HELLO_OK from the agent.
|
||||
6. Exit successfully when the response matches.
|
||||
7. Exit nonzero when the response does not match.
|
||||
|
||||
This is the entire required functional result.
|
||||
|
||||
### Required discovery
|
||||
|
||||
Before writing the runtime command:
|
||||
|
||||
1. Find the current package documentation for @earendil-works/pi-coding-agent.
|
||||
2. Determine the current package version.
|
||||
3. Determine the supported noninteractive command.
|
||||
4. Determine how Pi accepts a custom system prompt or system prompt file.
|
||||
5. Determine Pi's documented container authentication method.
|
||||
6. Record the commands and findings in BUILD-LOG.md.
|
||||
|
||||
Do not guess CLI flags, authentication paths, or SDK methods.
|
||||
|
||||
Pin the selected Pi package version in the project. Do not install an unversioned package during each container start.
|
||||
|
||||
Prefer the Pi CLI. Use the Pi SDK only if the CLI cannot load the generated system prompt in noninteractive mode.
|
||||
|
||||
### Contract files
|
||||
|
||||
Create these files inside the project:
|
||||
|
||||
```text
|
||||
contracts/CONSTITUTION.md
|
||||
contracts/STANDARDS.md
|
||||
contracts/SOUL.md
|
||||
contracts/USER.md
|
||||
```
|
||||
|
||||
Use these exact contents.
|
||||
|
||||
### contracts/CONSTITUTION.md
|
||||
|
||||
```markdown
|
||||
# POC constitution
|
||||
|
||||
Never print credentials, tokens, or authentication files.
|
||||
|
||||
Follow the loaded system instructions before the user request.
|
||||
```
|
||||
|
||||
### contracts/STANDARDS.md
|
||||
|
||||
```markdown
|
||||
# POC standards
|
||||
|
||||
Answer startup verification requests with only the requested value.
|
||||
Do not add explanation or formatting.
|
||||
```
|
||||
|
||||
### contracts/SOUL.md
|
||||
|
||||
```markdown
|
||||
# POC identity
|
||||
|
||||
Your name is mosaic-poc-agent.
|
||||
|
||||
Your startup marker is MOSAIC_HELLO_OK.
|
||||
|
||||
When asked for your startup marker, return only the marker.
|
||||
```
|
||||
|
||||
### contracts/USER.md
|
||||
|
||||
```markdown
|
||||
# POC user
|
||||
|
||||
This is an isolated local runtime test.
|
||||
```
|
||||
|
||||
Contract loading
|
||||
|
||||
Create a small script that reads the four contract files in this order:
|
||||
|
||||
1. CONSTITUTION.md
|
||||
2. STANDARDS.md
|
||||
3. SOUL.md
|
||||
4. USER.md
|
||||
|
||||
Join them with clear file separators.
|
||||
|
||||
Write the generated system prompt to:
|
||||
|
||||
```text
|
||||
/var/lib/mosaic/system-prompt.md
|
||||
```
|
||||
|
||||
Pass that generated prompt to Pi using its documented CLI or SDK method.
|
||||
|
||||
Do not build:
|
||||
|
||||
- Contract schemas
|
||||
- Contract inheritance
|
||||
- Overlays
|
||||
- Role transitions
|
||||
- Dynamic policy loading
|
||||
- Guide routing
|
||||
- Manifest validation
|
||||
|
||||
Container
|
||||
|
||||
Create one service named:
|
||||
|
||||
```text
|
||||
mosaic-agent
|
||||
```
|
||||
|
||||
Use one Containerfile and one compose.yaml.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Use a maintained Node.js base image.
|
||||
- Run as a non-root user.
|
||||
- Install a pinned Pi package version.
|
||||
- Copy the local contract fixtures into /opt/mosaic/contracts.
|
||||
- Do not copy credentials into the image.
|
||||
- Do not mount the Docker socket.
|
||||
- Do not mount either live Mosaic directory.
|
||||
- Do not add a database, web server, queue, or second container.
|
||||
- The container may run as a one-shot command. It does not need to remain running.
|
||||
|
||||
### Authentication
|
||||
|
||||
Use Pi's documented authentication mechanism.
|
||||
|
||||
Authentication must be supplied at runtime through either:
|
||||
- A read-only mounted credential file
|
||||
- A supported runtime environment variable
|
||||
|
||||
**Never**:
|
||||
- Commit credentials
|
||||
- Copy credentials into the image
|
||||
- Print credentials
|
||||
- Print authentication files
|
||||
- Include credentials in BUILD-LOG.md
|
||||
- Store credentials under the project directory
|
||||
|
||||
Provide .env.example only for non-secret settings such as model or provider names.
|
||||
|
||||
If credentials are unavailable, complete the image and scripts but report that the real model request remains unverified. Do not fake the response.
|
||||
|
||||
### Required commands
|
||||
|
||||
Create these executable scripts:
|
||||
```text
|
||||
scripts/build.sh
|
||||
scripts/hello.sh
|
||||
scripts/verify.sh
|
||||
scripts/reset.sh
|
||||
```
|
||||
|
||||
### scripts/build.sh
|
||||
|
||||
Build the container image using Docker Compose.
|
||||
|
||||
### scripts/hello.sh
|
||||
|
||||
Run the mosaic-agent service as a one-shot container.
|
||||
|
||||
Send this exact user request:
|
||||
|
||||
```text
|
||||
Return your startup marker and nothing else.
|
||||
```
|
||||
|
||||
The request must not contain MOSAIC_HELLO_OK.
|
||||
|
||||
Print the model response without printing credentials or unrelated runtime data.
|
||||
|
||||
### scripts/verify.sh
|
||||
|
||||
Run the complete test.
|
||||
|
||||
**It must**:
|
||||
|
||||
1. Build or confirm the image is built.
|
||||
2. Run the agent request.
|
||||
3. Remove surrounding whitespace from the response.
|
||||
4. Compare the response with MOSAIC_HELLO_OK.
|
||||
5. Exit 0 only when they match exactly.
|
||||
6. Exit nonzero with a clear error when they do not match.
|
||||
|
||||
### scripts/reset.sh
|
||||
|
||||
Delete generated POC state only when all checks pass:
|
||||
1. The resolved path is exactly /home/jwoltje/.mosaic-dev.
|
||||
2. The path is not a symbolic link.
|
||||
3. The directory contains a .mosaic-poc-root ownership marker created by this project.
|
||||
|
||||
Refuse to delete anything if a check fails.
|
||||
|
||||
## Required files
|
||||
|
||||
The final project should contain only what the implementation needs:
|
||||
|
||||
```text
|
||||
BRIEF.md
|
||||
BUILD-LOG.md
|
||||
README.md
|
||||
LAYERS.md
|
||||
Containerfile
|
||||
compose.yaml
|
||||
package.json
|
||||
package-lock.json
|
||||
.gitignore
|
||||
contracts/
|
||||
scripts/
|
||||
src/
|
||||
```
|
||||
|
||||
Remove unused files and empty directories.
|
||||
|
||||
Build log
|
||||
|
||||
Create BUILD-LOG.md.
|
||||
|
||||
Treat it as append-only.
|
||||
|
||||
Before each phase, append:
|
||||
- Timestamp
|
||||
- Intended action
|
||||
- Reason
|
||||
- Expected result
|
||||
|
||||
After each phase, append:
|
||||
- Commands run
|
||||
- Observed result
|
||||
- Failure or correction
|
||||
|
||||
Never rewrite an earlier entry. Add a correction as a new entry.
|
||||
|
||||
Do not record credentials.
|
||||
|
||||
Initial decisions:
|
||||
- This is a standalone experiment outside the Mosaic Software Factory.
|
||||
- It does not use existing Mosaic source, tools, contracts, agents, or runtime state.
|
||||
- The first proof uses one Pi agent and four small local contract files.
|
||||
- The only required model result is MOSAIC_HELLO_OK.
|
||||
- Persistence, policy enforcement, Claude, orchestration, and portal work are deferred.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
The experiment passes when:
|
||||
1. scripts/build.sh exits 0.
|
||||
2. The image contains the four local contract files.
|
||||
3. The image contains no credentials.
|
||||
4. The container has no mounts from ~/.mosaic or ~/.config/mosaic.
|
||||
5. scripts/hello.sh performs a real model request.
|
||||
6. The request does not contain the expected marker.
|
||||
7. The agent returns exactly MOSAIC_HELLO_OK.
|
||||
8. scripts/verify.sh exits 0.
|
||||
9. Changing the expected value makes scripts/verify.sh exit nonzero.
|
||||
10. scripts/reset.sh refuses unsafe paths.
|
||||
11. Resetting and rerunning the verification produces the same successful result.
|
||||
|
||||
## Deferred layers
|
||||
|
||||
Document these in LAYERS.md. Do not implement them.
|
||||
|
||||
- L0: Container builds and returns MOSAIC_HELLO_OK.
|
||||
- L1: Persist and resume a named Pi session.
|
||||
- L2: Add a fixed tool permission policy.
|
||||
- L3: Load full versioned contract bundles.
|
||||
- L4: Add Claude as a second runtime.
|
||||
- L5: Add multiple agents and communication.
|
||||
- L6: Add orchestration, knowledge storage, and portal features.
|
||||
|
||||
## Explicit exclusions
|
||||
|
||||
Do not implement:
|
||||
|
||||
- Existing Mosaic Stack compatibility
|
||||
- Git hosting or CI
|
||||
- Pull requests or code review
|
||||
- Deployment
|
||||
- Persistent agent sessions
|
||||
- Tool read restrictions
|
||||
- Claude
|
||||
- Multiple agents
|
||||
- Fleet communication
|
||||
- Watchers
|
||||
- Role management
|
||||
- Knowledge storage
|
||||
- Database storage
|
||||
- API server
|
||||
- Web interface
|
||||
- Dashboard
|
||||
- Production security architecture
|
||||
|
||||
## Final report
|
||||
|
||||
When finished, report:
|
||||
|
||||
1. Files created.
|
||||
2. Pi package version.
|
||||
3. Exact build command.
|
||||
4. Exact verification command.
|
||||
5. Verification output with credentials removed.
|
||||
6. Whether the real model request passed.
|
||||
7. Any remaining failure.
|
||||
8. Anything implemented beyond this brief.
|
||||
|
||||
Do not describe the experiment as production-ready.
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
# BUILD-LOG
|
||||
|
||||
Append-only build log for the Minimal Mosaic Stack container proof of concept.
|
||||
Each phase records the plan before it runs and the observed result after it runs.
|
||||
No credentials are recorded in this file.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Pi package discovery
|
||||
|
||||
### Entry 1.1 — before
|
||||
|
||||
- Timestamp: 2026-02-02 (session start, local)
|
||||
- Intended action: Locate the current package documentation for `@earendil-works/pi-coding-agent`, determine the current version, the supported noninteractive command, the custom system prompt mechanism, and the documented container authentication method.
|
||||
- Reason: The brief forbids guessing CLI flags, authentication paths, or SDK methods; all runtime commands must be derived from the package documentation.
|
||||
- Expected result: Documented answers for all five discovery questions, recorded below, with the Pi package version pinned in the project.
|
||||
|
||||
### Entry 1.2 — after
|
||||
|
||||
- Timestamp: 2026-02-02
|
||||
- Commands run:
|
||||
- Read `README.md` of the locally installed `@earendil-works/pi-coding-agent` package
|
||||
- Read `docs/containerization.md`, excerpts of `docs/models.md` and `docs/providers.md`
|
||||
- `grep '"version"' .../pi-coding-agent/package.json`
|
||||
- `npm view @earendil-works/pi-coding-agent version`
|
||||
- Inspected host auth store structure (keys only, values never printed)
|
||||
- Observed result:
|
||||
1. **Package documentation**: full docs ship inside the installed package (`README.md` plus a `docs/` directory including `docs/containerization.md`).
|
||||
2. **Current package version**: `0.84.4` — the locally installed version and the npm registry latest are identical. Selected version to pin: `0.84.4`.
|
||||
3. **Supported noninteractive command**: `pi -p` / `pi --print` — "Print response and exit". Documented in the CLI Reference. Print mode also merges piped stdin into the initial prompt (not used here).
|
||||
4. **Custom system prompt**: documented CLI flag `--system-prompt <text>` — "Replace default prompt (context files and skills still appended)". Because the generated contract prompt must fully control behavior, the run also passes the documented discovery-off switches (`--no-context-files`, `--no-skills`, `--no-extensions`, `--no-prompt-templates`, `--no-themes`) so nothing is appended from ambient files. (`APPEND_SYSTEM.md` / `.pi/SYSTEM.md` file conventions exist but the CLI flag is the documented noninteractive path.)
|
||||
5. **Documented container authentication**: `docs/containerization.md` documents the "Plain Docker" pattern: run the whole `pi` process in the container and pass provider API keys at runtime via environment variables (example: `docker run -e ANTHROPIC_API_KEY ...`). The documented alternative credential store is `auth.json` written by `/login` under the pi agent directory (`~/.pi/agent/auth.json`).
|
||||
- Decision: supply the credential at runtime as a **read-only mounted credential file** (host `auth.json`, which contains a static API-key entry for the built-in `zai` provider) mounted at the container pi agent directory, and additionally allow the documented environment-variable path (`ZAI_API_KEY` / `ANTHROPIC_API_KEY`) as an alternative. Provider and model are non-secret settings supplied via `.env` (`PI_PROVIDER=zai`, `PI_MODEL=glm-5.3-flash`).
|
||||
- Failure or correction: none. Host check confirmed no API-key environment variables are exported on the host, so the read-only auth.json mount is the working runtime credential path for this experiment.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Project scaffold
|
||||
|
||||
### Entry 2.1 — before
|
||||
|
||||
- Timestamp: 2026-02-02
|
||||
- Intended action: Create the contract fixtures (exact brief contents), the contract loader (`src/load-contracts.sh`), the one-shot agent runner (`src/run-agent.sh`), the four required scripts (`scripts/build.sh`, `hello.sh`, `verify.sh`, `reset.sh`), `Containerfile`, `compose.yaml`, pinned `package.json` + `package-lock.json`, `.gitignore`, `README.md`, `LAYERS.md`.
|
||||
- Reason: Implement exactly the file set the brief requires, with no extra machinery (no schemas, overlays, manifests, or policy loading).
|
||||
- Expected result: A complete project whose only remaining unknown is whether the pinned image builds and the real model request returns `MOSAIC_HELLO_OK`.
|
||||
|
||||
### Entry 2.2 — after
|
||||
|
||||
- Timestamp: 2026-02-02
|
||||
- Commands run: file creation; `npm install --package-lock-only --ignore-scripts` to generate the lockfile from the pinned dependency.
|
||||
- Observed result: All files created; `package-lock.json` pins `@earendil-works/[email protected]` (exact, no range).
|
||||
- Failure or correction: none.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Container image build
|
||||
|
||||
### Entry 3.1 — before
|
||||
|
||||
- Timestamp: 2026-02-02
|
||||
- Intended action: Run `scripts/build.sh` (Docker Compose build) to produce image `mosaic-poc-agent:0.84.4` from `node:24-bookworm-slim` with the pinned Pi, the four contract fixtures at `/opt/mosaic/contracts`, and a non-root user (uid/gid 1000).
|
||||
- Reason: Phase 1 of the required proof path; `node:24-bookworm-slim` is the maintained base image used in Pi's own documented containerization example.
|
||||
- Expected result: `docker compose build` exits 0 and the image contains the contracts, the runner scripts, and the pinned `pi` binary, with no credentials baked in.
|
||||
|
||||
### Entry 3.2 — after
|
||||
|
||||
- Timestamp: 2026-02-02
|
||||
- Commands run: `scripts/build.sh`; `docker run --rm mosaic-poc-agent:0.84.4 --version`; `id` via `--entrypoint`; contract listing; credential file scan.
|
||||
- Observed result:
|
||||
- Build exit 0; image tagged `mosaic-poc-agent:0.84.4`.
|
||||
- `pi --version` inside the image reports `0.84.4` (and this run also executed the contract loader successfully, writing `/var/lib/mosaic/system-prompt.md`).
|
||||
- Container user is `uid=1000(node) gid=1000(node)` — non-root.
|
||||
- All four contract files present at `/opt/mosaic/contracts` with read-only permissions (0555).
|
||||
- Credential scan: no `auth.json` or other auth files exist in the image; `/home/node/.pi/agent/` is empty in the image.
|
||||
- Failure or correction:
|
||||
1. First build failed: Docker Compose expects `Dockerfile` by default; fixed by setting `build.dockerfile: Containerfile` in `compose.yaml`.
|
||||
2. Second build failed: `useradd` exit 4 (uid 1000 already exists) because the maintained node image ships a `node` user at uid/gid 1000. Fixed by reusing the built-in `node` user (same 1000:1000 host mapping) instead of creating a duplicate `mosaic` user; container paths updated from `/home/mosaic/...` to `/home/node/...` in `Containerfile`, `compose.yaml`, `README.md`, `.env.example`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Runtime verification
|
||||
|
||||
### Entry 4.1 — before
|
||||
|
||||
- Timestamp: 2026-02-02
|
||||
- Intended action: Run `scripts/hello.sh` (one-shot request: "Return your startup marker and nothing else."), then `scripts/verify.sh` (exact-match gate against `MOSAIC_HELLO_OK`), then the negative test (`EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh` must exit nonzero), then the `scripts/reset.sh` safety tests and a final rerun after reset.
|
||||
- Reason: Phases 2–7 of the required proof path plus acceptance criteria 5–11.
|
||||
- Expected result: hello prints only the marker; verify exits 0; negative test exits nonzero; reset refuses unsafe paths and succeeds on the real path; rerun after reset reproduces the success.
|
||||
|
||||
### Entry 4.2 — after
|
||||
|
||||
- Timestamp: 2026-02-02
|
||||
- Commands run: `scripts/hello.sh`; `scripts/verify.sh`; `EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh`; `scripts/reset.sh` (refusal tests: missing marker, symlink with canary file, then real reset, then missing dir); `scripts/build.sh && scripts/verify.sh` after reset; `docker compose config` mount inspection.
|
||||
- Observed result:
|
||||
- `hello.sh`: stdout exactly `MOSAIC_HELLO_OK` — a real model request (provider `zai`, model `glm-5.3-flash`, auth via the read-only mounted auth.json credential file). The request string contains no marker.
|
||||
- `verify.sh`: `PASS: response matches expected marker`, exit 0.
|
||||
- Negative test: `FAIL: response does not match expected marker` (expected `MOSAIC_NOT_OK`, actual `MOSAIC_HELLO_OK`), exit 1.
|
||||
- `reset.sh` refusal tests: missing marker → exit 1, nothing deleted; symlink (with canary file at the target) → exit 1, canary survived; real path with marker → removed, exit 0; missing dir → "nothing to remove", exit 0.
|
||||
- Rerun after reset: build + verify → PASS, exit 0 (criterion 11).
|
||||
- Resolved compose mounts: only `/home/jwoltje/.mosaic-dev → /var/lib/mosaic` (rw) and `~/.pi/agent/auth.json → /home/node/.pi/agent/auth.json` (read-only). No `~/.mosaic` or `~/.config/mosaic` mounts, no Docker socket.
|
||||
- Failure or correction:
|
||||
1. First hello run: the contract loader's status line was printed on stdout, mixing runtime data into the model response stream and contaminating the exact-match capture. Fixed by sending the loader's status message to stderr (`src/load-contracts.sh`), rebuilt the image, reran: stdout is exactly the model response.
|
||||
- Credential check: no credential material appears in this log, in hello/verify output, or in the image (image scan found no auth files).
|
||||
|
||||
## Result
|
||||
|
||||
All 11 acceptance criteria demonstrated. The real model request passed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Configuration-driven Hello World (M1)
|
||||
|
||||
### Entry 5.1 — before
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Intended action: Make the container POC configuration-driven. Baseline committed and tagged `poc-container-hello-v0`. Milestone M1 tracked in Gitea (issues #1-#4): (T1) config module with idempotent bootstrap and strict v1 validation; (T2) wire scripts and compose to config.json with fail-closed behavior; (T3) sandboxed config selftests; (T4) E2E verification and documentation.
|
||||
- Reason: Per docs/plans/2026-09-02_atomic-mosaic-foundation.md — config.json must be the sole discovery entry point; updates and runs must never corrupt or invent configuration.
|
||||
- Expected result: All M1 acceptance criteria pass; Hello World reproducible from configuration alone.
|
||||
|
||||
### Entry 5.2 — after
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Commands run: `scripts/test-config.sh` (20 cases); fail-closed checks (compose without launcher env, verify/reset with missing config); `scripts/bootstrap.sh`; config-driven `scripts/hello.sh`, `scripts/verify.sh`, negative marker test, sandboxed reset symlink refusal (canary survived), real reset + bootstrap + build + verify; config checksum comparison across the entire flow.
|
||||
- Observed result:
|
||||
- Config selftests: 20 passed, 0 failed.
|
||||
- Fail-closed confirmed: compose exits 1 without launcher env; verify/reset exit 1 on missing config before any mutation.
|
||||
- Bootstrap created `~/.config/mosaic-dev/config.json` exclusively; second run validated without rewriting (content + mtime unchanged).
|
||||
- Config-driven hello/verify returned exactly `MOSAIC_HELLO_OK`; verify exit 0; negative marker test exit 1.
|
||||
- Reset refused symlinked dataRoot; canary file survived; real reset removed only the configured data root.
|
||||
- config.json checksum unchanged across hello/verify/reset/bootstrap/build/verify.
|
||||
- Failure or correction:
|
||||
1. Selftest harness bug: `cfg` helper invoked without a body for the symlink case (`$2: unbound variable`). Fixed in the harness; product code unaffected.
|
||||
2. E2E rerun-after-reset failure: `verify.sh` did not ensure the configured data root existed before the container mount. With the data root absent, Docker auto-created the host path as root:root, and the container's uid-1000 user could not write the generated system prompt. Fixed by calling `bootstrap_runtime_dir` in `verify.sh`; also hardened it to fail with a clear message when the data root exists but is not writable (root-owned leftover). Clean-slate E2E rerun: all steps green.
|
||||
- Credential check: no credential material in config, scripts, logs, or test output.
|
||||
|
||||
## Result (M1)
|
||||
|
||||
Configuration-driven Hello World verified. `main` merged with M1 and tagged `config-hello-v1`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Mission and task abstraction (M2)
|
||||
|
||||
### Entry 6.1 — before
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Intended action: Add the first mission/task layer, host-side only (Gitea milestone M2, issues #6-#9): strict v1 schemas for missions and tasks, a task runner executing through the proven config-driven container path, immutable write-once run records under `<dataRoot>/runs/`, `expectExact` gating, timeouts, selftests, fixtures, and docs.
|
||||
- Reason: The foundation plan's following layer — mission (objective + directives), task (bounded unit), run (one attempt), result (immutable evidence) — must exist as data and records before any policy or multi-agent work.
|
||||
- Expected result: `scripts/run-task.sh tasks/hello-marker.json` succeeds with exactly `MOSAIC_HELLO_OK`; wrong expectations fail; every run leaves an immutable record; configuration remains untouched.
|
||||
|
||||
### Entry 6.2 — after
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Commands run: `scripts/test-task.sh` (18 cases incl. live runs); `scripts/run-task.sh validate` / `run` on committed fixtures; `node scripts/mosaic-task.mjs list`; config checksum comparison across runs.
|
||||
- Observed result:
|
||||
- Selftests: 18 passed, 0 failed (schema negatives; live exact-marker success; wrong expectExact fails; distinct run dirs; result.json contents; list).
|
||||
- Fixture run: status `succeeded`, response exactly `MOSAIC_HELLO_OK`, mission snapshot recorded.
|
||||
- Run records written once under `<dataRoot>/runs/r-<utcstamp>-<rand>/`; reruns never clobber.
|
||||
- Failure or correction: none this phase.
|
||||
- Credential check: no credential material in task data, run records, or logs.
|
||||
|
||||
## Result (M2)
|
||||
|
||||
Mission/task layer verified end-to-end. `main` merged with M2 and tagged `mission-task-v1`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Release model and safe updates (M3)
|
||||
|
||||
### Entry 7.1 — before
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Intended action: Add the release substrate (Gitea milestone M3, issues #10-#13): RELEASE file single-sources the version (0.0.X line per owner direction), image tags derive from it, scripts/release.sh provides package/activate/rollback/status, activation is health-gated by the M2 task runner, pointer + append-only log under <dataRoot>/state/.
|
||||
- Reason: The owner's top invariant — updates must never corrupt a working installation — needs a mechanism, not a convention: gate-then-flip with recorded history and rollback.
|
||||
- Expected result: Update, refusal, and rollback drills all green with config checksums unchanged.
|
||||
|
||||
### Entry 7.2 — after
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Commands run: scripts/test-release.sh (14 cases); recorded drills: update (0.0.3 -> 0.0.4 package+activate+verify), fault-injected refusal, rollback to 0.0.3.
|
||||
- Observed result:
|
||||
- Selftests: 14 passed, 0 failed.
|
||||
- Update drill: packaged and activated r0.0.4 after exact-marker health gate; verify green under the new tag; config checksum unchanged.
|
||||
- Refusal drill: health-gate fault injection -> activation refused (exit 1), pointer untouched, refusal appended to the log.
|
||||
- Rollback drill: health-gated rollback to r0.0.3; pointer restored; log records package/activate/refused/rollback history append-only.
|
||||
- Failure or correction:
|
||||
1. release.sh initially failed with missing state/ directory (no mkdir before pointer/log writes); fixed.
|
||||
2. Selftest harness mutated the repo RELEASE and restored the mutated copy (mv-back bug) plus a second trap replacing the first; fixed with inline backup restore and one self-healing exit trap. Product code unaffected.
|
||||
- Credential check: no credential material in release state, logs, or drills.
|
||||
|
||||
## Result (M3)
|
||||
|
||||
Release model and safe updates verified by drills. `main` merged with M3 and tagged `release-model-v1`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Runtime adapter seam (M4)
|
||||
|
||||
### Entry 8.1 — before
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Intended action: Formalize the harness boundary (Gitea milestone M4, issues #16-#19): documented adapter contract under /opt/mosaic/adapters/<name>/adapter.sh; run-agent.sh becomes a dispatcher; pi extracted unchanged; deterministic mock adapter for provider-free seam tests; config gains optional execution.adapter (default pi, configVersion unchanged); mission directives gain their sanctioned injection point via the run snapshot; RELEASE bumps to 0.0.5 with a health-gated activation.
|
||||
- Reason: Future harnesses (Claude, Codex, OpenCode) must be additive — one directory each — and mission content needs a single sanctioned path into the runtime.
|
||||
- Expected result: All suites green including new deterministic seam cases; 0.0.5 activated by health gate; mission-bearing run recorded.
|
||||
|
||||
### Entry 8.2 — after
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Commands run: scripts/test-config.sh; scripts/test-task.sh; scripts/test-release.sh; manual seam drills (mock verbatim, unknown/traversal adapter refusal); mission injection checks; release package + activate for 0.0.5.
|
||||
- Observed result:
|
||||
- Config suite 24/24 (adapter default/validation/env export).
|
||||
- Task suite 24/24 including deterministic mock cases (gate pass, expect-mismatch with reason, unknown adapter fail-closed) and mission injection asserted by prompt content.
|
||||
- Release suite 14/14; image mosaic-poc-agent:0.84.4-r0.0.5 packaged and activated via exact-marker health gate.
|
||||
- Mission directives now flow: task -> run snapshot -> container env -> generated prompt MISSION (runtime) section.
|
||||
- Failure or correction:
|
||||
1. Selection authority settled: load_config always exports MOSAIC_ADAPTER from config; environment overrides for scripts are therefore not a supported selection path (by design).
|
||||
2. Selftest harness: three authoring defects fixed (helpers used before definition; one config file reused across cases leaking adapter state; a static mission fixture asserted against distinctive seam directives; plus an accidentally duplicated live block removed).
|
||||
- Credential check: no credential material in adapters, prompts, run records, or logs.
|
||||
|
||||
## Result (M4)
|
||||
|
||||
Adapter seam verified; harness boundary is now additive by construction. `main` merged with M4 and tagged `adapter-seam-v1`.
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# CLAUDE.md — Mosaic Stack
|
||||
|
||||
## Project
|
||||
|
||||
Self-hosted, multi-user AI agent platform. TypeScript monorepo.
|
||||
|
||||
## Stack
|
||||
|
||||
- **API**: NestJS + Fastify adapter (`apps/gateway`)
|
||||
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
||||
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
||||
- **Auth**: BetterAuth (`packages/auth`)
|
||||
- **Agent**: Pi SDK (`packages/agent`, `packages/cli`)
|
||||
- **Queue**: Valkey 8 (`packages/queue`)
|
||||
- **Build**: pnpm workspaces + Turborepo
|
||||
- **CI**: Woodpecker CI
|
||||
- **Observability**: OpenTelemetry → Jaeger
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm typecheck # TypeScript check (all packages)
|
||||
pnpm lint # ESLint (all packages)
|
||||
pnpm format:check # Prettier check
|
||||
pnpm test # Vitest (all packages)
|
||||
pnpm build # Build all packages
|
||||
|
||||
# Database
|
||||
pnpm --filter @mosaic/db db:push # Push schema to PG (dev)
|
||||
pnpm --filter @mosaic/db db:generate # Generate migrations
|
||||
pnpm --filter @mosaic/db db:migrate # Run migrations
|
||||
|
||||
# Dev
|
||||
docker compose up -d # Start PG, Valkey, OTEL, Jaeger
|
||||
pnpm --filter @mosaic/gateway exec tsx src/main.ts # Start gateway
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
||||
- NodeNext module resolution
|
||||
- Explicit `@Inject()` decorators in NestJS (tsx/esbuild doesn't support emitDecoratorMetadata)
|
||||
- DTOs in `*.dto.ts` files at module boundaries
|
||||
- OTEL tracing imported before NestJS bootstrap (`import './tracing.js'`)
|
||||
- All three gates must pass before push: typecheck, lint, format:check
|
||||
@@ -0,0 +1,43 @@
|
||||
# Minimal Mosaic Stack POC agent image.
|
||||
# Base: maintained Node.js image (same family as Pi's documented
|
||||
# containerization example in docs/containerization.md).
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
# Tools Pi's documented container image expects (bash, CA certs, git, ripgrep).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user: the maintained node image ships a 'node' user at
|
||||
# uid/gid 1000, which matches the host user that owns the runtime
|
||||
# state directory mounted at /var/lib/mosaic. It is reused as-is.
|
||||
|
||||
# Pinned Pi install: package.json pins the exact version and
|
||||
# package-lock.json is installed with npm ci. No unversioned installs.
|
||||
WORKDIR /opt/app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
# Immutable contract fixtures (required location), runtime scripts, and
|
||||
# runtime adapters.
|
||||
COPY contracts /opt/mosaic/contracts
|
||||
COPY src /opt/mosaic/src
|
||||
COPY adapters /opt/mosaic/adapters
|
||||
RUN chmod 0555 /opt/mosaic/contracts /opt/mosaic/contracts/* \
|
||||
&& chmod 0555 /opt/mosaic/src /opt/mosaic/src/*.sh \
|
||||
&& chmod 0555 /opt/mosaic/adapters /opt/mosaic/adapters/*/adapter.sh
|
||||
|
||||
# Writable state, workspace, and pi agent directory (auth.json is
|
||||
# bind-mounted read-only at runtime; nothing is copied into the image).
|
||||
RUN mkdir -p /var/lib/mosaic /workspace /home/node/.pi/agent \
|
||||
&& chown -R node:node /var/lib/mosaic /workspace /home/node /opt/app
|
||||
|
||||
USER node
|
||||
WORKDIR /workspace
|
||||
ENV HOME=/home/node \
|
||||
PATH="/opt/app/node_modules/.bin:${PATH}" \
|
||||
PI_OFFLINE=1
|
||||
|
||||
# One-shot agent: args form the user request (default is the startup
|
||||
# verification request defined in compose.yaml).
|
||||
ENTRYPOINT ["/opt/mosaic/src/run-agent.sh"]
|
||||
@@ -0,0 +1,50 @@
|
||||
# LAYERS
|
||||
|
||||
Deferred capability layers for the Mosaic experiment. Only L0 is implemented by
|
||||
this proof of concept; everything below it is documented here and deliberately
|
||||
not implemented (see BRIEF.md, "Explicit exclusions").
|
||||
|
||||
## L0 — Implemented: container returns MOSAIC_HELLO_OK
|
||||
|
||||
One image (`mosaic-poc-agent:0.84.4`, built on `node:24-bookworm-slim`, non-root,
|
||||
pinned Pi) runs one Pi agent one-shot. Four immutable local contract files are
|
||||
loaded in fixed order into the generated system prompt
|
||||
(`/var/lib/mosaic/system-prompt.md`). One real model request is sent
|
||||
noninteractively; the response must equal `MOSAIC_HELLO_OK` exactly or the
|
||||
verification exits nonzero. Authentication is supplied at runtime only
|
||||
(read-only mounted pi auth file, or a provider API key environment variable).
|
||||
|
||||
## L1 — Deferred: persist and resume a named Pi session
|
||||
|
||||
Keep a named Pi session across container runs (`--name`, session storage under
|
||||
`/var/lib/mosaic`), resume it with the documented session flags, and verify
|
||||
state survives a container restart.
|
||||
|
||||
## L2 — Deferred: fixed tool permission policy
|
||||
|
||||
Add a fixed allow/deny policy for Pi tools (e.g. restricting built-in tools via
|
||||
documented `--tools` / `--exclude-tools` or an extension-based permission gate),
|
||||
so contract files can constrain what the agent may do, not just what it says.
|
||||
|
||||
## L3 — Deferred: load full versioned contract bundles
|
||||
|
||||
Replace the four static fixtures with versioned contract bundles: bundle
|
||||
manifests, contract versions, and deterministic ordering/hashing, loaded from
|
||||
an immutable bundle artifact instead of files copied at image build time.
|
||||
|
||||
## L4 — Deferred: Claude as a second runtime
|
||||
|
||||
Add a second runtime (Claude) alongside the Pi agent in the same container
|
||||
stack, behind the same contract-loading path, to compare behavior across
|
||||
runtimes.
|
||||
|
||||
## L5 — Deferred: multiple agents and communication
|
||||
|
||||
Run several named agents with defined roles and a communication channel between
|
||||
them (message passing or shared state under `/var/lib/mosaic`).
|
||||
|
||||
## L6 — Deferred: orchestration, knowledge storage, and portal features
|
||||
|
||||
Fleet-level orchestration, knowledge storage, monitoring, and portal UI on top
|
||||
of L1-L5. This is where the existing Mosaic Stack concepts would be re-evaluated
|
||||
from first principles.
|
||||
@@ -0,0 +1,191 @@
|
||||
# Minimal Mosaic Stack container POC
|
||||
|
||||
Standalone experiment, not part of the Mosaic Stack repository or Software Factory.
|
||||
|
||||
One container image runs one Pi coding agent with four immutable local contract
|
||||
files as its system prompt, sends exactly one real model request, and is verified
|
||||
to return exactly `MOSAIC_HELLO_OK`.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
BRIEF.md requirements for the original container proof
|
||||
BUILD-LOG.md append-only build/verification log
|
||||
LAYERS.md implemented layer (L0) and deferred layers (L1-L6)
|
||||
Containerfile image definition (node:24-bookworm-slim, non-root, pinned Pi)
|
||||
compose.yaml one service: mosaic-agent (one-shot; configured via env)
|
||||
package.json pins @earendil-works/pi-coding-agent at exactly 0.84.4
|
||||
package-lock.json resolved lockfile used by npm ci in the image
|
||||
.env.example non-secret settings only (credential-file path, env-var auth)
|
||||
contracts/ CONSTITUTION.md, STANDARDS.md, SOUL.md, USER.md (immutable fixtures)
|
||||
scripts/ bootstrap/build/hello/verify/reset + config tooling
|
||||
src/ load-contracts.sh, run-agent.sh (run inside the container)
|
||||
docs/plans/ architecture and milestone plans
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The sole discovery entry point is:
|
||||
|
||||
```text
|
||||
~/.config/mosaic-dev/config.json
|
||||
```
|
||||
|
||||
Created only by the explicit, idempotent bootstrap:
|
||||
|
||||
```bash
|
||||
scripts/bootstrap.sh # create-if-absent; validates existing config, never rewrites
|
||||
```
|
||||
|
||||
Minimal shape (`configVersion` 1):
|
||||
|
||||
```json
|
||||
{
|
||||
"configVersion": 1,
|
||||
"environment": "development",
|
||||
"dataRoot": "/home/jwoltje/.mosaic-dev",
|
||||
"execution": {
|
||||
"backend": "docker",
|
||||
"provider": "zai",
|
||||
"model": "glm-5.3-flash"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules enforced by `scripts/mosaic-config.mjs`:
|
||||
|
||||
- Unknown keys, unsupported versions/backends, and malformed JSON exit nonzero; nothing is modified.
|
||||
- `dataRoot` must be absolute, canonical, and must not be or contain the home or configuration directory.
|
||||
- Validation failures never touch config, state, or images.
|
||||
- `scripts/test-config.sh` runs the sandboxed config selftests (no Docker required).
|
||||
|
||||
Run paths (`build/hello/verify/reset`) fail closed when configuration is missing or invalid; they never invent it.
|
||||
|
||||
## Missions & tasks (M2)
|
||||
|
||||
Missions and tasks are validated JSON data (strict schemas, version-pinned). The M2 layer is host-side only: mission directives are recorded for provenance but do not yet reach the runtime system prompt (capability/policy layer comes later).
|
||||
|
||||
```text
|
||||
missions/hello.json objective + directives (missionVersion 1)
|
||||
tasks/hello-marker.json prompt + optional mission ref + expectExact + timeout
|
||||
<dataRoot>/runs/r-<id>/ immutable run record: task.json, mission.json,
|
||||
stderr.txt, result.json (all write-once)
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
scripts/run-task.sh validate tasks/hello-marker.json # strict validation, writes nothing
|
||||
scripts/run-task.sh run tasks/hello-marker.json # execute; result recorded under dataRoot/runs
|
||||
scripts/mosaic-task.mjs list # list runs and statuses
|
||||
scripts/test-task.sh # selftests (schema negatives + live runs)
|
||||
```
|
||||
|
||||
A run exits 0 only when its expectation is met (`expectExact` match); mismatches, nonzero agent exits, and timeouts record `status: failed` in `result.json` and exit 1. Each run gets a unique directory — rerunning never rewrites history.
|
||||
|
||||
## Release model (M3)
|
||||
|
||||
`RELEASE` single-sources the release version (0.0.X until declared stable); the image tag derives from it plus the pinned Pi version. Activation is health-gated and every event is recorded:
|
||||
|
||||
```bash
|
||||
scripts/release.sh package # build + tag the release image
|
||||
scripts/release.sh activate # health check (exact marker) -> atomic pointer swap
|
||||
scripts/release.sh activate --fault-injection # prove the refusal path (drills only)
|
||||
scripts/release.sh rollback # health-gated return to the previous release
|
||||
scripts/release.sh status # release, tag, active pointer, recent log
|
||||
scripts/test-release.sh # release selftests
|
||||
```
|
||||
|
||||
- `<dataRoot>/state/active.json` — the activation pointer (atomic tmp+rename replace)
|
||||
- `<dataRoot>/state/activation-log.jsonl` — append-only history: package / activate / refused / rollback
|
||||
|
||||
A failed health check never activates; the previously active release remains deployed. Updating the software therefore cannot corrupt the running installation: package beside, gate, then flip. Verified by the update/refusal/rollback drills in BUILD-LOG Phase 7.
|
||||
|
||||
## Runtime adapters (M4)
|
||||
|
||||
The harness boundary is formalized: everything upstream (config, contracts, missions, tasks, run records) is harness-agnostic; everything inside an adapter belongs to one runtime.
|
||||
|
||||
```text
|
||||
adapters/<name>/adapter.sh env in: MOSAIC_SYSTEM_PROMPT_FILE, MOSAIC_REQUEST,
|
||||
MOSAIC_PROVIDER, MOSAIC_MODEL
|
||||
stdout: response only; stderr: diagnostics
|
||||
```
|
||||
|
||||
- Selection: `execution.adapter` in config.json (optional; `pi` default; allowlist `pi`, `mock`)
|
||||
- `pi` — pinned Pi CLI, noninteractive print mode, ambient discovery off
|
||||
- `mock` — deterministic test adapter; never for real verification
|
||||
- Mission directives have a sanctioned injection point: when a task references a mission, the task runner mounts the run snapshot and the generated prompt gains a `MISSION (runtime)` section (objective + directives) after the four immutable contracts
|
||||
- Adding a harness (Claude, Codex, OpenCode) later means adding one directory — no orchestrator changes
|
||||
|
||||
See `adapters/README.md` for the full contract.
|
||||
|
||||
See `docs/plans/2026-09-02_atomic-mosaic-foundation.md` for the full plan.
|
||||
|
||||
Inside the container:
|
||||
|
||||
```text
|
||||
/opt/mosaic/contracts immutable contract files
|
||||
/var/lib/mosaic generated runtime state (mounted from configured dataRoot)
|
||||
/workspace agent workspace
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. `scripts/build.sh` builds the release image (`mosaic-poc-agent:<pi>-r<release>`,
|
||||
tag derived from `RELEASE` + the pinned Pi version) with Docker Compose.
|
||||
2. On each run, `/opt/mosaic/src/load-contracts.sh` reads the four contract files
|
||||
in fixed order (CONSTITUTION, STANDARDS, SOUL, USER), joins them with clear
|
||||
separators, and writes `/var/lib/mosaic/system-prompt.md`.
|
||||
3. `/opt/mosaic/src/run-agent.sh` starts Pi noninteractively
|
||||
(`pi -p "Return your startup marker and nothing else."`) with
|
||||
`--system-prompt "$(cat /var/lib/mosaic/system-prompt.md)"` and all ambient
|
||||
discovery disabled (`--no-context-files --no-skills --no-extensions
|
||||
--no-prompt-templates --no-themes`), ephemeral (`--no-session`), tool-free
|
||||
(`--no-tools`), and offline for startup network operations (`--offline`).
|
||||
4. `scripts/verify.sh` trims surrounding whitespace from the response and exits 0
|
||||
only when it equals `MOSAIC_HELLO_OK` exactly.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
scripts/bootstrap.sh # create config.json if absent (idempotent)
|
||||
scripts/build.sh # build the image
|
||||
scripts/hello.sh # one-shot request; prints the model response
|
||||
scripts/verify.sh # full gated test; exit 0 only on exact MOSAIC_HELLO_OK
|
||||
scripts/run-task.sh # run a mission/task file (see Missions & tasks)
|
||||
scripts/release.sh # package / activate / rollback / status (see Release model)
|
||||
scripts/test-config.sh # fast config-layer selftests (no Docker)
|
||||
scripts/test-task.sh # mission/task selftests (schema + adapter seam + live runs)
|
||||
scripts/test-release.sh # release selftests
|
||||
scripts/reset.sh # delete the configured data root (safety-checked)
|
||||
```
|
||||
|
||||
Prove the failure path (acceptance criterion 9):
|
||||
|
||||
```bash
|
||||
EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh # must exit nonzero
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
Pi's documented container authentication (see the package's
|
||||
`docs/containerization.md`) is used, in this order:
|
||||
|
||||
1. **Read-only mounted credential file** (default): the host pi auth file
|
||||
`~/.pi/agent/auth.json` is bind-mounted read-only to
|
||||
`/home/node/.pi/agent/auth.json`. The host file holds a static API-key
|
||||
entry for the built-in `zai` provider, so no token refresh writes are needed.
|
||||
2. **Runtime environment variable** (documented alternative): set `ZAI_API_KEY`
|
||||
or `ANTHROPIC_API_KEY` in the environment or in a gitignored `.env`; compose
|
||||
passes them through. Pi's documented precedence applies.
|
||||
|
||||
Credentials are never committed, never copied into the image, and never printed.
|
||||
`.env.example` contains non-secret settings only.
|
||||
|
||||
## Boundaries honored
|
||||
|
||||
- No mounts of `~/.mosaic` or `~/.config/mosaic`; no Docker socket mount.
|
||||
- Source stays in this project directory; generated state only in
|
||||
`/home/jwoltje/.mosaic-dev` (host) and `/var/lib/mosaic` (container).
|
||||
- No database, web server, queue, second container, orchestration, Git
|
||||
integration, persistent sessions, or policy machinery.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Mosaic runtime adapters
|
||||
|
||||
An adapter is the entire harness-specific surface of the system. Everything
|
||||
upstream of an adapter — configuration, contracts, missions, tasks, run
|
||||
records — is harness-agnostic; everything inside an adapter may assume one
|
||||
specific agent runtime.
|
||||
|
||||
## Contract
|
||||
|
||||
An adapter lives at:
|
||||
|
||||
```text
|
||||
/opt/mosaic/adapters/<name>/adapter.sh
|
||||
```
|
||||
|
||||
and must be executable. The dispatcher (`/opt/mosaic/src/run-agent.sh`)
|
||||
selects it via `MOSAIC_ADAPTER` (default: `pi`) and execs it after the
|
||||
system prompt has been generated.
|
||||
|
||||
**Inputs (environment):**
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `MOSAIC_SYSTEM_PROMPT_FILE` | Absolute path to the generated system prompt (contracts + optional mission section). Read it; do not modify it. |
|
||||
| `MOSAIC_REQUEST` | The exact user request text (may contain newlines). |
|
||||
| `MOSAIC_PROVIDER` | Configured provider name. |
|
||||
| `MOSAIC_MODEL` | Configured model id. |
|
||||
|
||||
Optional, adapter-specific (documented per adapter):
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `MOSAIC_MOCK_RESPONSE` | mock only: the verbatim response to emit |
|
||||
|
||||
**Outputs:**
|
||||
|
||||
- `stdout`: the model response text — the only channel the orchestrator captures
|
||||
- `stderr`: diagnostics (never credentials)
|
||||
- exit `0`: success; nonzero: failure
|
||||
|
||||
## Rules
|
||||
|
||||
1. Adapters print ONLY the response on stdout. Status lines go to stderr.
|
||||
2. Adapters never read configuration files; the resolved settings arrive via environment.
|
||||
3. Adapters never write outside `/var/lib/mosaic`.
|
||||
4. Adding an adapter requires: a new directory, the contract implementation, and
|
||||
adding the name to the allowlist in `scripts/mosaic-config.mjs`.
|
||||
|
||||
## Included adapters
|
||||
|
||||
- `pi` — the pinned `@earendil-works/pi-coding-agent` CLI in noninteractive
|
||||
print mode (`-p`), ambient discovery disabled, stdin detached.
|
||||
- `mock` — deterministic echo of `MOSAIC_MOCK_RESPONSE`. Test-only: never use
|
||||
it where a real model response is required.
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Mock adapter: deterministic response for seam tests. NEVER use where a
|
||||
# real model response is required.
|
||||
#
|
||||
# Contract: see /opt/mosaic/adapters/README.md.
|
||||
set -eu
|
||||
|
||||
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "mock adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
|
||||
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "mock adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
|
||||
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "mock adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
|
||||
|
||||
echo "mock adapter: responding verbatim from MOSAIC_MOCK_RESPONSE" >&2
|
||||
printf '%s\n' "${MOSAIC_MOCK_RESPONSE:-}"
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Pi adapter: implements the Mosaic adapter contract for the pinned
|
||||
# @earendil-works/pi-coding-agent CLI.
|
||||
#
|
||||
# Contract: see /opt/mosaic/adapters/README.md. stdout = response only.
|
||||
set -eu
|
||||
|
||||
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "pi adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
|
||||
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "pi adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
|
||||
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "pi adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
|
||||
|
||||
: "${PI_PROVIDER:?pi adapter: PI_PROVIDER is required}"
|
||||
: "${PI_MODEL:?pi adapter: PI_MODEL is required}"
|
||||
|
||||
# All flags documented in the pi package README (CLI Reference):
|
||||
# -p/--print noninteractive: print the response and exit
|
||||
# --system-prompt replace the default prompt with the generated one
|
||||
# --no-* no ambient context/skills/extensions/templates/themes
|
||||
# --no-session ephemeral; --no-tools this runtime needs no tools
|
||||
# --offline no startup network operations (update checks/telemetry)
|
||||
exec pi \
|
||||
--offline \
|
||||
--no-session \
|
||||
--no-extensions \
|
||||
--no-skills \
|
||||
--no-prompt-templates \
|
||||
--no-themes \
|
||||
--no-context-files \
|
||||
--no-tools \
|
||||
--provider "$PI_PROVIDER" \
|
||||
--model "$PI_MODEL" \
|
||||
--system-prompt "$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")" \
|
||||
-p "$MOSAIC_REQUEST"
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"name": "@mosaic/gateway",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@mariozechner/pi-ai": "~0.57.1",
|
||||
"@mariozechner/pi-coding-agent": "~0.57.1",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@mosaic/auth": "workspace:^",
|
||||
"@mosaic/queue": "workspace:^",
|
||||
"@mosaic/brain": "workspace:^",
|
||||
"@mosaic/coord": "workspace:^",
|
||||
"@mosaic/db": "workspace:^",
|
||||
"@mosaic/discord-plugin": "workspace:^",
|
||||
"@mosaic/log": "workspace:^",
|
||||
"@mosaic/memory": "workspace:^",
|
||||
"@mosaic/telegram-plugin": "workspace:^",
|
||||
"@mosaic/types": "workspace:^",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-fastify": "^11.0.0",
|
||||
"@nestjs/platform-socket.io": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.0.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.71.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.213.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.213.0",
|
||||
"@opentelemetry/resources": "^2.6.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.6.0",
|
||||
"@opentelemetry/sdk-node": "^0.213.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@sinclair/typebox": "^0.34.48",
|
||||
"better-auth": "^1.5.5",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"fastify": "^5.0.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"socket.io": "^4.8.0",
|
||||
"uuid": "^11.0.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConversationsController } from '../conversations/conversations.controller.js';
|
||||
import { MissionsController } from '../missions/missions.controller.js';
|
||||
import { ProjectsController } from '../projects/projects.controller.js';
|
||||
import { TasksController } from '../tasks/tasks.controller.js';
|
||||
|
||||
function createBrain() {
|
||||
return {
|
||||
conversations: {
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
findMessages: vi.fn(),
|
||||
addMessage: vi.fn(),
|
||||
},
|
||||
projects: {
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
missions: {
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByProject: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
tasks: {
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByProject: vi.fn(),
|
||||
findByMission: vi.fn(),
|
||||
findByStatus: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Resource ownership checks', () => {
|
||||
it('forbids access to another user conversation', async () => {
|
||||
const brain = createBrain();
|
||||
brain.conversations.findById.mockResolvedValue({ id: 'conv-1', userId: 'user-2' });
|
||||
const controller = new ConversationsController(brain as never);
|
||||
|
||||
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids access to another user project', async () => {
|
||||
const brain = createBrain();
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const controller = new ProjectsController(brain as never);
|
||||
|
||||
await expect(controller.findOne('project-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids access to a mission owned by another project owner', async () => {
|
||||
const brain = createBrain();
|
||||
brain.missions.findById.mockResolvedValue({ id: 'mission-1', projectId: 'project-1' });
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const controller = new MissionsController(brain as never);
|
||||
|
||||
await expect(controller.findOne('mission-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids access to a task owned by another project owner', async () => {
|
||||
const brain = createBrain();
|
||||
brain.tasks.findById.mockResolvedValue({ id: 'task-1', projectId: 'project-1' });
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const controller = new TasksController(brain as never);
|
||||
|
||||
await expect(controller.findOne('task-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids creating a task with an unowned project', async () => {
|
||||
const brain = createBrain();
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const controller = new TasksController(brain as never);
|
||||
|
||||
await expect(
|
||||
controller.create(
|
||||
{
|
||||
title: 'Task',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
{ id: 'user-1' },
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('forbids listing tasks for an unowned project', async () => {
|
||||
const brain = createBrain();
|
||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
||||
const controller = new TasksController(brain as never);
|
||||
|
||||
await expect(
|
||||
controller.list({ id: 'user-1' }, 'project-1', undefined, undefined),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('lists only tasks for the current user owned projects when no filter is provided', async () => {
|
||||
const brain = createBrain();
|
||||
brain.projects.findAll.mockResolvedValue([
|
||||
{ id: 'project-1', ownerId: 'user-1' },
|
||||
{ id: 'project-2', ownerId: 'user-2' },
|
||||
]);
|
||||
brain.missions.findAll.mockResolvedValue([{ id: 'mission-1', projectId: 'project-1' }]);
|
||||
brain.tasks.findAll.mockResolvedValue([
|
||||
{ id: 'task-1', projectId: 'project-1' },
|
||||
{ id: 'task-2', missionId: 'mission-1' },
|
||||
{ id: 'task-3', projectId: 'project-2' },
|
||||
]);
|
||||
const controller = new TasksController(brain as never);
|
||||
|
||||
await expect(
|
||||
controller.list({ id: 'user-1' }, undefined, undefined, undefined),
|
||||
).resolves.toEqual([
|
||||
{ id: 'task-1', projectId: 'project-1' },
|
||||
{ id: 'task-2', missionId: 'mission-1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import { sql, type Db } from '@mosaic/db';
|
||||
import { createQueue } from '@mosaic/queue';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { ProviderService } from '../agent/provider.service.js';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||
|
||||
@Controller('api/admin/health')
|
||||
@UseGuards(AdminGuard)
|
||||
export class AdminHealthController {
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async check(): Promise<HealthStatusDto> {
|
||||
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
|
||||
|
||||
const sessions = this.agentService.listSessions();
|
||||
const providers = this.providerService.listProviders();
|
||||
|
||||
const allOk = database.status === 'ok' && cache.status === 'ok';
|
||||
|
||||
return {
|
||||
status: allOk ? 'ok' : 'degraded',
|
||||
database,
|
||||
cache,
|
||||
agentPool: { activeSessions: sessions.length },
|
||||
providers: providers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
available: p.available,
|
||||
modelCount: p.models.length,
|
||||
})),
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async checkDatabase(): Promise<ServiceStatusDto> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await this.db.execute(sql`SELECT 1`);
|
||||
return { status: 'ok', latencyMs: Date.now() - start };
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 'error',
|
||||
latencyMs: Date.now() - start,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkCache(): Promise<ServiceStatusDto> {
|
||||
const start = Date.now();
|
||||
const handle = createQueue();
|
||||
try {
|
||||
await handle.redis.ping();
|
||||
return { status: 'ok', latencyMs: Date.now() - start };
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 'error',
|
||||
latencyMs: Date.now() - start,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
await handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { eq, type Db, users as usersTable } from '@mosaic/db';
|
||||
import type { Auth } from '@mosaic/auth';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
import type {
|
||||
BanUserDto,
|
||||
CreateUserDto,
|
||||
UpdateUserRoleDto,
|
||||
UserDto,
|
||||
UserListDto,
|
||||
} from './admin.dto.js';
|
||||
|
||||
type UserRow = typeof usersTable.$inferSelect;
|
||||
|
||||
function toUserDto(u: UserRow): UserDto {
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
banned: u.banned ?? false,
|
||||
banReason: u.banReason ?? null,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
updatedAt: u.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function requireUpdated(
|
||||
db: Db,
|
||||
id: string,
|
||||
update: Partial<Omit<UserRow, 'id' | 'createdAt'>>,
|
||||
): Promise<UserDto> {
|
||||
const [updated] = await db
|
||||
.update(usersTable)
|
||||
.set({ ...update, updatedAt: new Date() })
|
||||
.where(eq(usersTable.id, id))
|
||||
.returning();
|
||||
if (!updated) throw new InternalServerErrorException('Update returned no rows');
|
||||
return toUserDto(updated);
|
||||
}
|
||||
|
||||
@Controller('api/admin/users')
|
||||
@UseGuards(AdminGuard)
|
||||
export class AdminController {
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(AUTH) private readonly auth: Auth,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async listUsers(): Promise<UserListDto> {
|
||||
const rows = await this.db.select().from(usersTable).orderBy(usersTable.createdAt);
|
||||
const userList: UserDto[] = rows.map(toUserDto);
|
||||
return { users: userList, total: userList.length };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getUser(@Param('id') id: string): Promise<UserDto> {
|
||||
const [user] = await this.db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1);
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
return toUserDto(user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createUser(@Body() body: CreateUserDto): Promise<UserDto> {
|
||||
// Use auth API to create user so password is properly hashed
|
||||
const authApi = this.auth.api as unknown as {
|
||||
createUser: (opts: {
|
||||
body: { name: string; email: string; password: string; role?: string };
|
||||
}) => Promise<{
|
||||
user: { id: string; name: string; email: string; createdAt: unknown; updatedAt: unknown };
|
||||
}>;
|
||||
};
|
||||
|
||||
const result = await authApi.createUser({
|
||||
body: {
|
||||
name: body.name,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
role: body.role ?? 'member',
|
||||
},
|
||||
});
|
||||
|
||||
// Re-fetch from DB to get full row with our schema
|
||||
const [user] = await this.db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, result.user.id))
|
||||
.limit(1);
|
||||
|
||||
if (!user) throw new InternalServerErrorException('User created but not found in DB');
|
||||
return toUserDto(user);
|
||||
}
|
||||
|
||||
@Patch(':id/role')
|
||||
async setRole(@Param('id') id: string, @Body() body: UpdateUserRoleDto): Promise<UserDto> {
|
||||
await this.ensureExists(id);
|
||||
return requireUpdated(this.db, id, { role: body.role });
|
||||
}
|
||||
|
||||
@Post(':id/ban')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async banUser(@Param('id') id: string, @Body() body: BanUserDto): Promise<UserDto> {
|
||||
await this.ensureExists(id);
|
||||
return requireUpdated(this.db, id, { banned: true, banReason: body.reason ?? null });
|
||||
}
|
||||
|
||||
@Post(':id/unban')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async unbanUser(@Param('id') id: string): Promise<UserDto> {
|
||||
await this.ensureExists(id);
|
||||
return requireUpdated(this.db, id, { banned: false, banReason: null, banExpires: null });
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteUser(@Param('id') id: string): Promise<void> {
|
||||
await this.ensureExists(id);
|
||||
await this.db.delete(usersTable).where(eq(usersTable.id, id));
|
||||
}
|
||||
|
||||
private async ensureExists(id: string): Promise<void> {
|
||||
const [existing] = await this.db
|
||||
.select({ id: usersTable.id })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw new NotFoundException('User not found');
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
export interface UserDto {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
banned: boolean;
|
||||
banReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UserListDto {
|
||||
users: UserDto[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CreateUserDto {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRoleDto {
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface BanUserDto {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface HealthStatusDto {
|
||||
status: 'ok' | 'degraded' | 'error';
|
||||
database: ServiceStatusDto;
|
||||
cache: ServiceStatusDto;
|
||||
agentPool: AgentPoolStatusDto;
|
||||
providers: ProviderStatusDto[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface ServiceStatusDto {
|
||||
status: 'ok' | 'error';
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AgentPoolStatusDto {
|
||||
activeSessions: number;
|
||||
}
|
||||
|
||||
export interface ProviderStatusDto {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
modelCount: number;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { fromNodeHeaders } from 'better-auth/node';
|
||||
import type { Auth } from '@mosaic/auth';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
|
||||
interface UserWithRole {
|
||||
id: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
constructor(@Inject(AUTH) private readonly auth: Auth) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
const headers = fromNodeHeaders(request.raw.headers);
|
||||
|
||||
const result = await this.auth.api.getSession({ headers });
|
||||
|
||||
if (!result) {
|
||||
throw new UnauthorizedException('Invalid or expired session');
|
||||
}
|
||||
|
||||
const user = result.user as UserWithRole;
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
throw new ForbiddenException('Admin access required');
|
||||
}
|
||||
|
||||
(request as FastifyRequest & { user: unknown; session: unknown }).user = result.user;
|
||||
(request as FastifyRequest & { user: unknown; session: unknown }).session = result.session;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller.js';
|
||||
import { AdminHealthController } from './admin-health.controller.js';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminController, AdminHealthController],
|
||||
providers: [AdminGuard],
|
||||
})
|
||||
export class AdminModule {}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { RoutingService } from '../routing.service.js';
|
||||
import type { ModelInfo } from '@mosaic/types';
|
||||
|
||||
const mockModels: ModelInfo[] = [
|
||||
{
|
||||
id: 'claude-3-haiku',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude 3 Haiku',
|
||||
reasoning: false,
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 4096,
|
||||
inputTypes: ['text', 'image'],
|
||||
cost: { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 },
|
||||
},
|
||||
{
|
||||
id: 'claude-3-sonnet',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude 3 Sonnet',
|
||||
reasoning: true,
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8192,
|
||||
inputTypes: ['text', 'image'],
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
},
|
||||
{
|
||||
id: 'llama3.2',
|
||||
provider: 'ollama',
|
||||
name: 'Llama 3.2',
|
||||
reasoning: false,
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
inputTypes: ['text'],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
function createMockProviderService() {
|
||||
return {
|
||||
listAvailableModels: vi.fn().mockReturnValue(mockModels),
|
||||
findModel: vi.fn(),
|
||||
getDefaultModel: vi.fn(),
|
||||
getRegistry: vi.fn(),
|
||||
listProviders: vi.fn(),
|
||||
registerCustomProvider: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('RoutingService', () => {
|
||||
let routingService: RoutingService;
|
||||
let mockProviderService: ReturnType<typeof createMockProviderService>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockProviderService = createMockProviderService();
|
||||
routingService = new RoutingService(mockProviderService as never);
|
||||
});
|
||||
|
||||
it('returns a model when no criteria specified', () => {
|
||||
const result = routingService.route();
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.provider).toBeDefined();
|
||||
expect(result!.modelId).toBeDefined();
|
||||
expect(result!.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns null when no models available', () => {
|
||||
mockProviderService.listAvailableModels.mockReturnValue([]);
|
||||
const result = routingService.route();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('selects preferred model when specified', () => {
|
||||
const result = routingService.route({
|
||||
preferredProvider: 'anthropic',
|
||||
preferredModel: 'claude-3-sonnet',
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.provider).toBe('anthropic');
|
||||
expect(result!.modelId).toBe('claude-3-sonnet');
|
||||
expect(result!.score).toBe(100);
|
||||
});
|
||||
|
||||
it('disqualifies models without reasoning when required', () => {
|
||||
const result = routingService.route({ requireReasoning: true });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.modelId).toBe('claude-3-sonnet');
|
||||
});
|
||||
|
||||
it('disqualifies models without image input when required', () => {
|
||||
const result = routingService.route({ requireImageInput: true });
|
||||
expect(result).not.toBeNull();
|
||||
// Llama doesn't support images, should be excluded
|
||||
expect(result!.provider).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('respects minimum context window', () => {
|
||||
const result = routingService.route({ minContextWindow: 150_000 });
|
||||
expect(result).not.toBeNull();
|
||||
// Only anthropic models have 200k context
|
||||
expect(result!.provider).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('favors cheap models when costTier is cheap', () => {
|
||||
const result = routingService.route({ costTier: 'cheap' });
|
||||
expect(result).not.toBeNull();
|
||||
// Ollama (free) and Haiku ($0.25/M) are cheap
|
||||
expect(['ollama', 'anthropic'].includes(result!.provider)).toBe(true);
|
||||
if (result!.provider === 'anthropic') {
|
||||
expect(result!.modelId).toBe('claude-3-haiku');
|
||||
}
|
||||
});
|
||||
|
||||
it('ranks all models and returns sorted results', () => {
|
||||
const ranked = routingService.rank({ taskType: 'coding' });
|
||||
expect(ranked.length).toBeGreaterThan(0);
|
||||
// Should be sorted by score descending
|
||||
for (let i = 1; i < ranked.length; i++) {
|
||||
expect(ranked[i]!.score).toBeLessThanOrEqual(ranked[i - 1]!.score);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives reasoning bonus for coding tasks', () => {
|
||||
const ranked = routingService.rank({ taskType: 'coding' });
|
||||
const sonnet = ranked.find((r) => r.modelId === 'claude-3-sonnet');
|
||||
const haiku = ranked.find((r) => r.modelId === 'claude-3-haiku');
|
||||
expect(sonnet).toBeDefined();
|
||||
expect(haiku).toBeDefined();
|
||||
// Sonnet (reasoning) should score higher for coding than haiku (no reasoning)
|
||||
expect(sonnet!.score).toBeGreaterThan(haiku!.score);
|
||||
});
|
||||
|
||||
it('prefers specified provider', () => {
|
||||
const ranked = routingService.rank({ preferredProvider: 'ollama' });
|
||||
const ollamaModel = ranked.find((r) => r.provider === 'ollama');
|
||||
expect(ollamaModel).toBeDefined();
|
||||
expect(ollamaModel!.reasoning).toContain('preferred provider');
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AgentService } from './agent.service.js';
|
||||
import { ProviderService } from './provider.service.js';
|
||||
import { RoutingService } from './routing.service.js';
|
||||
import { SkillLoaderService } from './skill-loader.service.js';
|
||||
import { ProvidersController } from './providers.controller.js';
|
||||
import { SessionsController } from './sessions.controller.js';
|
||||
import { CoordModule } from '../coord/coord.module.js';
|
||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||
import { SkillsModule } from '../skills/skills.module.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [CoordModule, McpClientModule, SkillsModule],
|
||||
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
|
||||
controllers: [ProvidersController, SessionsController],
|
||||
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
|
||||
})
|
||||
export class AgentModule {}
|
||||
@@ -1,390 +0,0 @@
|
||||
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||
import {
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
SessionManager,
|
||||
type AgentSession as PiAgentSession,
|
||||
type AgentSessionEvent,
|
||||
type ToolDefinition,
|
||||
} from '@mariozechner/pi-coding-agent';
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import type { Memory } from '@mosaic/memory';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { MEMORY } from '../memory/memory.tokens.js';
|
||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||
import { CoordService } from '../coord/coord.service.js';
|
||||
import { ProviderService } from './provider.service.js';
|
||||
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
||||
import { SkillLoaderService } from './skill-loader.service.js';
|
||||
import { createBrainTools } from './tools/brain-tools.js';
|
||||
import { createCoordTools } from './tools/coord-tools.js';
|
||||
import { createMemoryTools } from './tools/memory-tools.js';
|
||||
import { createFileTools } from './tools/file-tools.js';
|
||||
import { createGitTools } from './tools/git-tools.js';
|
||||
import { createShellTools } from './tools/shell-tools.js';
|
||||
import { createWebTools } from './tools/web-tools.js';
|
||||
import type { SessionInfoDto } from './session.dto.js';
|
||||
|
||||
export interface AgentSessionOptions {
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
/**
|
||||
* Sandbox working directory for the session.
|
||||
* File, git, and shell tools will be restricted to this directory.
|
||||
* Falls back to AGENT_FILE_SANDBOX_DIR env var or process.cwd().
|
||||
*/
|
||||
sandboxDir?: string;
|
||||
/**
|
||||
* Platform-level system prompt for this session.
|
||||
* Merged with skill prompt additions (platform prompt first, then skills).
|
||||
* Falls back to AGENT_SYSTEM_PROMPT env var when omitted.
|
||||
*/
|
||||
systemPrompt?: string;
|
||||
/**
|
||||
* Explicit allowlist of tool names available in this session.
|
||||
* When set, only listed tools are registered with the agent.
|
||||
* When omitted for non-admin users, falls back to AGENT_USER_TOOLS env var.
|
||||
* Admins (isAdmin=true) always receive the full tool set unless explicitly restricted.
|
||||
*/
|
||||
allowedTools?: string[];
|
||||
/** Whether the requesting user has admin privileges. Controls default tool access. */
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentSession {
|
||||
id: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
piSession: PiAgentSession;
|
||||
listeners: Set<(event: AgentSessionEvent) => void>;
|
||||
unsubscribe: () => void;
|
||||
createdAt: number;
|
||||
promptCount: number;
|
||||
channels: Set<string>;
|
||||
/** System prompt additions injected from enabled prompt-type skills. */
|
||||
skillPromptAdditions: string[];
|
||||
/** Resolved sandbox directory for this session. */
|
||||
sandboxDir: string;
|
||||
/** Tool names available in this session, or null when all tools are available. */
|
||||
allowedTools: string[] | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AgentService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(AgentService.name);
|
||||
private readonly sessions = new Map<string, AgentSession>();
|
||||
private readonly creating = new Map<string, Promise<AgentSession>>();
|
||||
|
||||
constructor(
|
||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
@Inject(MEMORY) private readonly memory: Memory,
|
||||
@Inject(EmbeddingService) private readonly embeddingService: EmbeddingService,
|
||||
@Inject(CoordService) private readonly coordService: CoordService,
|
||||
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
||||
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Build the full set of custom tools scoped to the given sandbox directory.
|
||||
* Brain/coord/memory/web tools are stateless with respect to cwd; file/git/shell
|
||||
* tools receive the resolved sandboxDir so they operate within the sandbox.
|
||||
*/
|
||||
private buildToolsForSandbox(sandboxDir: string): ToolDefinition[] {
|
||||
return [
|
||||
...createBrainTools(this.brain),
|
||||
...createCoordTools(this.coordService),
|
||||
...createMemoryTools(
|
||||
this.memory,
|
||||
this.embeddingService.available ? this.embeddingService : null,
|
||||
),
|
||||
...createFileTools(sandboxDir),
|
||||
...createGitTools(sandboxDir),
|
||||
...createShellTools(sandboxDir),
|
||||
...createWebTools(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the tool allowlist for a session.
|
||||
* - Admin users: all tools unless an explicit allowedTools list is passed.
|
||||
* - Regular users: use allowedTools if provided, otherwise parse AGENT_USER_TOOLS env var.
|
||||
* Returns null when all tools should be available.
|
||||
*/
|
||||
private resolveAllowedTools(isAdmin: boolean, allowedTools?: string[]): string[] | null {
|
||||
if (allowedTools !== undefined) {
|
||||
return allowedTools.length === 0 ? [] : allowedTools;
|
||||
}
|
||||
if (isAdmin) {
|
||||
return null; // admins get everything
|
||||
}
|
||||
const envTools = process.env['AGENT_USER_TOOLS'];
|
||||
if (!envTools) {
|
||||
return null; // no restriction configured
|
||||
}
|
||||
return envTools
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
|
||||
const existing = this.sessions.get(sessionId);
|
||||
if (existing) return existing;
|
||||
|
||||
const inflight = this.creating.get(sessionId);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const promise = this.doCreateSession(sessionId, options).finally(() => {
|
||||
this.creating.delete(sessionId);
|
||||
});
|
||||
this.creating.set(sessionId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async doCreateSession(
|
||||
sessionId: string,
|
||||
options?: AgentSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const model = this.resolveModel(options);
|
||||
const providerName = model?.provider ?? 'default';
|
||||
const modelId = model?.id ?? 'default';
|
||||
|
||||
// Resolve sandbox directory: option > env var > process.cwd()
|
||||
const sandboxDir =
|
||||
options?.sandboxDir ?? process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd();
|
||||
|
||||
// Resolve allowed tool set
|
||||
const allowedTools = this.resolveAllowedTools(options?.isAdmin ?? false, options?.allowedTools);
|
||||
|
||||
this.logger.log(
|
||||
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId}, sandbox=${sandboxDir}, tools=${allowedTools === null ? 'all' : allowedTools.join(',') || 'none'})`,
|
||||
);
|
||||
|
||||
// Load skill tools from the catalog
|
||||
const { metaTools: skillMetaTools, promptAdditions } =
|
||||
await this.skillLoaderService.loadForSession();
|
||||
if (skillMetaTools.length > 0) {
|
||||
this.logger.log(`Attaching ${skillMetaTools.length} skill tool(s) to session ${sessionId}`);
|
||||
}
|
||||
if (promptAdditions.length > 0) {
|
||||
this.logger.log(
|
||||
`Injecting ${promptAdditions.length} skill prompt addition(s) into session ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Build per-session tools scoped to the sandbox directory
|
||||
const sandboxTools = this.buildToolsForSandbox(sandboxDir);
|
||||
|
||||
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
||||
const mcpTools = this.mcpClientService.getToolDefinitions();
|
||||
let allCustomTools = [...sandboxTools, ...skillMetaTools, ...mcpTools];
|
||||
if (mcpTools.length > 0) {
|
||||
this.logger.log(`Attaching ${mcpTools.length} MCP client tool(s) to session ${sessionId}`);
|
||||
}
|
||||
|
||||
// Filter tools by allowlist when a restriction is in effect
|
||||
if (allowedTools !== null) {
|
||||
const allowedSet = new Set(allowedTools);
|
||||
const before = allCustomTools.length;
|
||||
allCustomTools = allCustomTools.filter((t) => allowedSet.has(t.name));
|
||||
this.logger.log(
|
||||
`Tool restriction applied: ${allCustomTools.length}/${before} tools allowed for session ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Build system prompt: platform prompt + skill additions appended
|
||||
const platformPrompt = options?.systemPrompt ?? process.env['AGENT_SYSTEM_PROMPT'] ?? undefined;
|
||||
const appendSystemPrompt =
|
||||
promptAdditions.length > 0 ? promptAdditions.join('\n\n') : undefined;
|
||||
|
||||
// Construct a resource loader that injects the configured system prompt
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: sandboxDir,
|
||||
noExtensions: true,
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
systemPrompt: platformPrompt,
|
||||
appendSystemPrompt: appendSystemPrompt,
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
let piSession: PiAgentSession;
|
||||
try {
|
||||
const result = await createAgentSession({
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
modelRegistry: this.providerService.getRegistry(),
|
||||
model: model ?? undefined,
|
||||
cwd: sandboxDir,
|
||||
tools: [],
|
||||
customTools: allCustomTools,
|
||||
resourceLoader,
|
||||
});
|
||||
piSession = result.session;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to create agent session for ${sessionId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
throw new Error(`Agent session creation failed for ${sessionId}: ${String(err)}`);
|
||||
}
|
||||
|
||||
const listeners = new Set<(event: AgentSessionEvent) => void>();
|
||||
|
||||
const unsubscribe = piSession.subscribe((event) => {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (err) {
|
||||
this.logger.error(`Event listener error in session ${sessionId}`, err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const session: AgentSession = {
|
||||
id: sessionId,
|
||||
provider: providerName,
|
||||
modelId,
|
||||
piSession,
|
||||
listeners,
|
||||
unsubscribe,
|
||||
createdAt: Date.now(),
|
||||
promptCount: 0,
|
||||
channels: new Set(),
|
||||
skillPromptAdditions: promptAdditions,
|
||||
sandboxDir,
|
||||
allowedTools,
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
this.logger.log(`Agent session ${sessionId} ready (${providerName}/${modelId})`);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private resolveModel(options?: AgentSessionOptions) {
|
||||
if (!options?.provider && !options?.modelId) {
|
||||
return this.providerService.getDefaultModel() ?? null;
|
||||
}
|
||||
|
||||
if (options.provider && options.modelId) {
|
||||
const model = this.providerService.findModel(options.provider, options.modelId);
|
||||
if (!model) {
|
||||
throw new Error(`Model not found: ${options.provider}/${options.modelId}`);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
if (options.modelId) {
|
||||
const available = this.providerService.listAvailableModels();
|
||||
const match = available.find((m) => m.id === options.modelId);
|
||||
if (match) {
|
||||
return this.providerService.findModel(match.provider, match.id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return this.providerService.getDefaultModel() ?? null;
|
||||
}
|
||||
|
||||
getSession(sessionId: string): AgentSession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
listSessions(): SessionInfoDto[] {
|
||||
const now = Date.now();
|
||||
return Array.from(this.sessions.values()).map((s) => ({
|
||||
id: s.id,
|
||||
provider: s.provider,
|
||||
modelId: s.modelId,
|
||||
createdAt: new Date(s.createdAt).toISOString(),
|
||||
promptCount: s.promptCount,
|
||||
channels: Array.from(s.channels),
|
||||
durationMs: now - s.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
|
||||
const s = this.sessions.get(sessionId);
|
||||
if (!s) return undefined;
|
||||
return {
|
||||
id: s.id,
|
||||
provider: s.provider,
|
||||
modelId: s.modelId,
|
||||
createdAt: new Date(s.createdAt).toISOString(),
|
||||
promptCount: s.promptCount,
|
||||
channels: Array.from(s.channels),
|
||||
durationMs: Date.now() - s.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
addChannel(sessionId: string, channel: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.channels.add(channel);
|
||||
}
|
||||
}
|
||||
|
||||
removeChannel(sessionId: string, channel: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.channels.delete(channel);
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, message: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`No agent session found: ${sessionId}`);
|
||||
}
|
||||
session.promptCount += 1;
|
||||
try {
|
||||
await session.piSession.prompt(message);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Prompt failed for session=${sessionId}, messageLength=${message.length}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`No agent session found: ${sessionId}`);
|
||||
}
|
||||
session.listeners.add(listener);
|
||||
return () => session.listeners.delete(listener);
|
||||
}
|
||||
|
||||
async destroySession(sessionId: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.logger.log(`Destroying agent session ${sessionId}`);
|
||||
try {
|
||||
session.unsubscribe();
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to unsubscribe session ${sessionId}`, String(err));
|
||||
}
|
||||
try {
|
||||
session.piSession.dispose();
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to dispose piSession for ${sessionId}`, String(err));
|
||||
}
|
||||
session.listeners.clear();
|
||||
session.channels.clear();
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.logger.log('Shutting down all agent sessions');
|
||||
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
|
||||
const results = await Promise.allSettled(stops);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
this.logger.error('Session shutdown failure', String(result.reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
export interface TestConnectionDto {
|
||||
/** Provider identifier to test (e.g. 'ollama', custom provider id) */
|
||||
providerId: string;
|
||||
/** Optional base URL override for ad-hoc testing */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export interface TestConnectionResultDto {
|
||||
providerId: string;
|
||||
reachable: boolean;
|
||||
/** Round-trip latency in milliseconds (present when reachable) */
|
||||
latencyMs?: number;
|
||||
/** Human-readable error when unreachable */
|
||||
error?: string;
|
||||
/** Model ids discovered at the remote endpoint (present when reachable) */
|
||||
discoveredModels?: string[];
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
|
||||
import type { Model, Api } from '@mariozechner/pi-ai';
|
||||
import type { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
|
||||
import type { TestConnectionResultDto } from './provider.dto.js';
|
||||
|
||||
@Injectable()
|
||||
export class ProviderService implements OnModuleInit {
|
||||
private readonly logger = new Logger(ProviderService.name);
|
||||
private registry!: ModelRegistry;
|
||||
|
||||
onModuleInit(): void {
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
this.registry = new ModelRegistry(authStorage);
|
||||
|
||||
this.registerOllamaProvider();
|
||||
this.registerCustomProviders();
|
||||
|
||||
const available = this.registry.getAvailable();
|
||||
this.logger.log(`Providers initialized: ${available.length} models available`);
|
||||
}
|
||||
|
||||
getRegistry(): ModelRegistry {
|
||||
return this.registry;
|
||||
}
|
||||
|
||||
findModel(provider: string, modelId: string): Model<Api> | undefined {
|
||||
return this.registry.find(provider, modelId);
|
||||
}
|
||||
|
||||
getDefaultModel(): Model<Api> | undefined {
|
||||
const available = this.registry.getAvailable();
|
||||
return available[0];
|
||||
}
|
||||
|
||||
listProviders(): ProviderInfo[] {
|
||||
const allModels = this.registry.getAll();
|
||||
const availableModels = this.registry.getAvailable();
|
||||
const availableIds = new Set(availableModels.map((m) => `${m.provider}:${m.id}`));
|
||||
|
||||
const providerMap = new Map<string, ProviderInfo>();
|
||||
|
||||
for (const model of allModels) {
|
||||
let info = providerMap.get(model.provider);
|
||||
if (!info) {
|
||||
info = {
|
||||
id: model.provider,
|
||||
name: model.provider,
|
||||
available: false,
|
||||
models: [],
|
||||
};
|
||||
providerMap.set(model.provider, info);
|
||||
}
|
||||
|
||||
const isAvailable = availableIds.has(`${model.provider}:${model.id}`);
|
||||
if (isAvailable) info.available = true;
|
||||
|
||||
info.models.push(this.toModelInfo(model));
|
||||
}
|
||||
|
||||
return Array.from(providerMap.values());
|
||||
}
|
||||
|
||||
listAvailableModels(): ModelInfo[] {
|
||||
return this.registry.getAvailable().map((m) => this.toModelInfo(m));
|
||||
}
|
||||
|
||||
async testConnection(providerId: string, baseUrl?: string): Promise<TestConnectionResultDto> {
|
||||
// Resolve baseUrl: explicit override > registered provider > ollama env
|
||||
let resolvedUrl = baseUrl;
|
||||
|
||||
if (!resolvedUrl) {
|
||||
const allModels = this.registry.getAll();
|
||||
const providerModels = allModels.filter((m) => m.provider === providerId);
|
||||
if (providerModels.length === 0) {
|
||||
return { providerId, reachable: false, error: `Provider '${providerId}' not found` };
|
||||
}
|
||||
// For Ollama, derive the base URL from environment
|
||||
if (providerId === 'ollama') {
|
||||
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
|
||||
if (!ollamaUrl) {
|
||||
return { providerId, reachable: false, error: 'OLLAMA_BASE_URL not configured' };
|
||||
}
|
||||
resolvedUrl = `${ollamaUrl}/v1/models`;
|
||||
} else {
|
||||
// For other providers, we can only do a basic check
|
||||
return { providerId, reachable: true, discoveredModels: providerModels.map((m) => m.id) };
|
||||
}
|
||||
} else {
|
||||
resolvedUrl = resolvedUrl.replace(/\/?$/, '') + '/models';
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(resolvedUrl, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
if (!res.ok) {
|
||||
return { providerId, reachable: false, latencyMs, error: `HTTP ${res.status}` };
|
||||
}
|
||||
|
||||
let discoveredModels: string[] | undefined;
|
||||
try {
|
||||
const json = (await res.json()) as { models?: Array<{ id?: string; name?: string }> };
|
||||
if (Array.isArray(json.models)) {
|
||||
discoveredModels = json.models.map((m) => m.id ?? m.name ?? '').filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors — endpoint was reachable
|
||||
}
|
||||
|
||||
return { providerId, reachable: true, latencyMs, discoveredModels };
|
||||
} catch (err) {
|
||||
const latencyMs = Date.now() - start;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { providerId, reachable: false, latencyMs, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
registerCustomProvider(config: CustomProviderConfig): void {
|
||||
this.registry.registerProvider(config.id, {
|
||||
baseUrl: config.baseUrl,
|
||||
apiKey: config.apiKey,
|
||||
models: config.models.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
reasoning: m.reasoning ?? false,
|
||||
input: ['text'] as ('text' | 'image')[],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: m.contextWindow ?? 4096,
|
||||
maxTokens: m.maxTokens ?? 4096,
|
||||
})),
|
||||
});
|
||||
|
||||
this.logger.log(`Registered custom provider: ${config.id} (${config.models.length} models)`);
|
||||
}
|
||||
|
||||
private registerOllamaProvider(): void {
|
||||
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
|
||||
if (!ollamaUrl) return;
|
||||
|
||||
const modelsEnv = process.env['OLLAMA_MODELS'] ?? 'llama3.2,codellama,mistral';
|
||||
const modelIds = modelsEnv
|
||||
.split(',')
|
||||
.map((modelId: string) => modelId.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
this.registry.registerProvider('ollama', {
|
||||
baseUrl: `${ollamaUrl}/v1`,
|
||||
apiKey: 'ollama',
|
||||
api: 'openai-completions' as never,
|
||||
models: modelIds.map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
reasoning: false,
|
||||
input: ['text'] as ('text' | 'image')[],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 4096,
|
||||
})),
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Ollama provider registered at ${ollamaUrl} with models: ${modelIds.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
private registerCustomProviders(): void {
|
||||
const customJson = process.env['MOSAIC_CUSTOM_PROVIDERS'];
|
||||
if (!customJson) return;
|
||||
|
||||
try {
|
||||
const configs = JSON.parse(customJson) as CustomProviderConfig[];
|
||||
for (const config of configs) {
|
||||
this.registerCustomProvider(config);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to parse MOSAIC_CUSTOM_PROVIDERS', String(err));
|
||||
}
|
||||
}
|
||||
|
||||
private toModelInfo(model: Model<Api>): ModelInfo {
|
||||
return {
|
||||
id: model.id,
|
||||
provider: model.provider,
|
||||
name: model.name,
|
||||
reasoning: model.reasoning,
|
||||
contextWindow: model.contextWindow,
|
||||
maxTokens: model.maxTokens,
|
||||
inputTypes: model.input,
|
||||
cost: model.cost,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Body, Controller, Get, Inject, Post, UseGuards } from '@nestjs/common';
|
||||
import type { RoutingCriteria } from '@mosaic/types';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { ProviderService } from './provider.service.js';
|
||||
import { RoutingService } from './routing.service.js';
|
||||
import type { TestConnectionDto, TestConnectionResultDto } from './provider.dto.js';
|
||||
|
||||
@Controller('api/providers')
|
||||
@UseGuards(AuthGuard)
|
||||
export class ProvidersController {
|
||||
constructor(
|
||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||
@Inject(RoutingService) private readonly routingService: RoutingService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.providerService.listProviders();
|
||||
}
|
||||
|
||||
@Get('models')
|
||||
listModels() {
|
||||
return this.providerService.listAvailableModels();
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
testConnection(@Body() body: TestConnectionDto): Promise<TestConnectionResultDto> {
|
||||
return this.providerService.testConnection(body.providerId, body.baseUrl);
|
||||
}
|
||||
|
||||
@Post('route')
|
||||
route(@Body() criteria: RoutingCriteria) {
|
||||
return this.routingService.route(criteria);
|
||||
}
|
||||
|
||||
@Post('rank')
|
||||
rank(@Body() criteria: RoutingCriteria) {
|
||||
return this.routingService.rank(criteria);
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { ModelInfo } from '@mosaic/types';
|
||||
import type { RoutingCriteria, RoutingResult, CostTier } from '@mosaic/types';
|
||||
import { ProviderService } from './provider.service.js';
|
||||
|
||||
/** Per-million-token cost thresholds for tier classification */
|
||||
const COST_TIER_THRESHOLDS: Record<CostTier, { maxInput: number }> = {
|
||||
cheap: { maxInput: 1 },
|
||||
standard: { maxInput: 10 },
|
||||
premium: { maxInput: Infinity },
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RoutingService {
|
||||
private readonly logger = new Logger(RoutingService.name);
|
||||
|
||||
constructor(@Inject(ProviderService) private readonly providerService: ProviderService) {}
|
||||
|
||||
/**
|
||||
* Select the best available model for the given criteria.
|
||||
* Returns null if no model matches the requirements.
|
||||
*/
|
||||
route(criteria: RoutingCriteria = {}): RoutingResult | null {
|
||||
const available = this.providerService.listAvailableModels();
|
||||
if (available.length === 0) {
|
||||
this.logger.warn('No available models for routing');
|
||||
return null;
|
||||
}
|
||||
|
||||
// If a specific model is preferred, try it first
|
||||
if (criteria.preferredProvider && criteria.preferredModel) {
|
||||
const match = available.find(
|
||||
(m) => m.provider === criteria.preferredProvider && m.id === criteria.preferredModel,
|
||||
);
|
||||
if (match) {
|
||||
return {
|
||||
provider: match.provider,
|
||||
modelId: match.id,
|
||||
modelName: match.name,
|
||||
score: 100,
|
||||
reasoning: 'Preferred model selected',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Score and rank candidates
|
||||
const scored = available
|
||||
.map((model) => this.scoreModel(model, criteria))
|
||||
.filter((s) => s.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
if (scored.length === 0) {
|
||||
this.logger.warn('No models matched routing criteria', criteria);
|
||||
return null;
|
||||
}
|
||||
|
||||
const best = scored[0] as RoutingResult;
|
||||
this.logger.debug(
|
||||
`Routed to ${best.provider}/${best.modelId} (score=${best.score}): ${best.reasoning}`,
|
||||
);
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available models ranked by suitability for the given criteria.
|
||||
*/
|
||||
rank(criteria: RoutingCriteria = {}): RoutingResult[] {
|
||||
const available = this.providerService.listAvailableModels();
|
||||
return available
|
||||
.map((model) => this.scoreModel(model, criteria))
|
||||
.filter((s) => s.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
private scoreModel(model: ModelInfo, criteria: RoutingCriteria): RoutingResult {
|
||||
let score = 50; // Base score
|
||||
const reasons: string[] = [];
|
||||
|
||||
// Hard requirements — disqualify if not met
|
||||
if (criteria.requireReasoning && !model.reasoning) {
|
||||
return this.disqualified(model, 'reasoning required but not supported');
|
||||
}
|
||||
|
||||
if (criteria.requireImageInput && !model.inputTypes.includes('image')) {
|
||||
return this.disqualified(model, 'image input required but not supported');
|
||||
}
|
||||
|
||||
if (criteria.minContextWindow && model.contextWindow < criteria.minContextWindow) {
|
||||
return this.disqualified(
|
||||
model,
|
||||
`context window ${model.contextWindow} < required ${criteria.minContextWindow}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Cost tier matching
|
||||
if (criteria.costTier) {
|
||||
const tier = this.classifyTier(model);
|
||||
if (tier === criteria.costTier) {
|
||||
score += 20;
|
||||
reasons.push(`cost tier match (${tier})`);
|
||||
} else if (
|
||||
(criteria.costTier === 'cheap' && tier === 'standard') ||
|
||||
(criteria.costTier === 'standard' && tier === 'premium')
|
||||
) {
|
||||
score += 5;
|
||||
reasons.push(`adjacent cost tier (wanted ${criteria.costTier}, got ${tier})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer cheaper models when no cost tier specified
|
||||
if (!criteria.costTier) {
|
||||
const costPerMillion = model.cost.input;
|
||||
if (costPerMillion <= 1) score += 10;
|
||||
else if (costPerMillion <= 5) score += 5;
|
||||
}
|
||||
|
||||
// Provider preference
|
||||
if (criteria.preferredProvider && model.provider === criteria.preferredProvider) {
|
||||
score += 15;
|
||||
reasons.push('preferred provider');
|
||||
}
|
||||
|
||||
// Reasoning bonus for complex tasks
|
||||
if (model.reasoning) {
|
||||
if (criteria.taskType === 'coding' || criteria.taskType === 'analysis') {
|
||||
score += 10;
|
||||
reasons.push('reasoning model for complex task');
|
||||
}
|
||||
}
|
||||
|
||||
// Large context bonus for analysis tasks
|
||||
if (criteria.taskType === 'analysis' && model.contextWindow >= 128_000) {
|
||||
score += 5;
|
||||
reasons.push('large context window');
|
||||
}
|
||||
|
||||
return {
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
modelName: model.name,
|
||||
score,
|
||||
reasoning: reasons.length > 0 ? reasons.join('; ') : 'base score',
|
||||
};
|
||||
}
|
||||
|
||||
private classifyTier(model: ModelInfo): CostTier {
|
||||
const cost = model.cost.input;
|
||||
const cheapThreshold = COST_TIER_THRESHOLDS['cheap'];
|
||||
const standardThreshold = COST_TIER_THRESHOLDS['standard'];
|
||||
|
||||
if (cost <= cheapThreshold.maxInput) return 'cheap';
|
||||
if (cost <= standardThreshold.maxInput) return 'standard';
|
||||
return 'premium';
|
||||
}
|
||||
|
||||
private disqualified(model: ModelInfo, reason: string): RoutingResult {
|
||||
return {
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
modelName: model.name,
|
||||
score: 0,
|
||||
reasoning: `disqualified: ${reason}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
export interface SessionInfoDto {
|
||||
id: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
createdAt: string;
|
||||
promptCount: number;
|
||||
channels: string[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface SessionListDto {
|
||||
sessions: SessionInfoDto[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options accepted when creating an agent session.
|
||||
* All fields are optional; omitting them falls back to env-var or process defaults.
|
||||
*/
|
||||
export interface CreateSessionOptionsDto {
|
||||
/** Provider name (e.g. "anthropic", "openai"). */
|
||||
provider?: string;
|
||||
/** Model ID to use for this session. */
|
||||
modelId?: string;
|
||||
/**
|
||||
* Sandbox working directory for the session.
|
||||
* File, git, and shell tools will be restricted to this directory.
|
||||
* Defaults to AGENT_FILE_SANDBOX_DIR env var or process.cwd().
|
||||
*/
|
||||
sandboxDir?: string;
|
||||
/**
|
||||
* Platform-level system prompt for this session.
|
||||
* Merged with skill prompt additions (platform prompt first, then skills).
|
||||
* Falls back to AGENT_SYSTEM_PROMPT env var when omitted.
|
||||
*/
|
||||
systemPrompt?: string;
|
||||
/**
|
||||
* Explicit allowlist of tool names available in this session.
|
||||
* When provided, only listed tools are registered with the agent.
|
||||
* Admins receive all tools; regular users fall back to AGENT_USER_TOOLS
|
||||
* env var (comma-separated) when this field is not supplied.
|
||||
*/
|
||||
allowedTools?: string[];
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { AgentService } from './agent.service.js';
|
||||
|
||||
@Controller('api/sessions')
|
||||
@UseGuards(AuthGuard)
|
||||
export class SessionsController {
|
||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
const sessions = this.agentService.listSessions();
|
||||
return { sessions, total: sessions.length };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
const info = this.agentService.getSessionInfo(id);
|
||||
if (!info) throw new NotFoundException('Session not found');
|
||||
return info;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async destroy(@Param('id') id: string) {
|
||||
const info = this.agentService.getSessionInfo(id);
|
||||
if (!info) throw new NotFoundException('Session not found');
|
||||
await this.agentService.destroySession(id);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { SkillsService } from '../skills/skills.service.js';
|
||||
import { createSkillTools } from './tools/skill-tools.js';
|
||||
|
||||
export interface LoadedSkills {
|
||||
/** Meta-tools: skill_list + skill_invoke */
|
||||
metaTools: ToolDefinition[];
|
||||
/**
|
||||
* System prompt additions from enabled prompt-type skills.
|
||||
* Callers may prepend these to the session system prompt.
|
||||
*/
|
||||
promptAdditions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* SkillLoaderService is responsible for:
|
||||
* 1. Providing the skill meta-tools (skill_list, skill_invoke) to agent sessions.
|
||||
* 2. Collecting system-prompt additions from enabled prompt-type skills.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SkillLoaderService {
|
||||
private readonly logger = new Logger(SkillLoaderService.name);
|
||||
|
||||
constructor(@Inject(SkillsService) private readonly skillsService: SkillsService) {}
|
||||
|
||||
/**
|
||||
* Load enabled skills and return tools + prompt additions for a new session.
|
||||
*/
|
||||
async loadForSession(): Promise<LoadedSkills> {
|
||||
const metaTools = createSkillTools(this.skillsService);
|
||||
|
||||
let promptAdditions: string[] = [];
|
||||
try {
|
||||
const enabledSkills = await this.skillsService.findEnabled();
|
||||
promptAdditions = enabledSkills.flatMap((skill) => {
|
||||
const config = (skill.config ?? {}) as Record<string, unknown>;
|
||||
const skillType = (config['type'] as string | undefined) ?? 'prompt';
|
||||
if (skillType === 'prompt') {
|
||||
const addition = (config['prompt'] as string | undefined) ?? skill.description;
|
||||
return addition ? [addition] : [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Loaded ${enabledSkills.length} enabled skill(s), ` +
|
||||
`${promptAdditions.length} prompt addition(s)`,
|
||||
);
|
||||
} catch (err) {
|
||||
// Non-fatal: log and continue without prompt additions
|
||||
this.logger.warn(
|
||||
`Failed to load skill prompt additions: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { metaTools, promptAdditions };
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
|
||||
export function createBrainTools(brain: Brain): ToolDefinition[] {
|
||||
const listProjects: ToolDefinition = {
|
||||
name: 'brain_list_projects',
|
||||
label: 'List Projects',
|
||||
description: 'List all projects in the brain.',
|
||||
parameters: Type.Object({}),
|
||||
async execute() {
|
||||
const projects = await brain.projects.findAll();
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const getProject: ToolDefinition = {
|
||||
name: 'brain_get_project',
|
||||
label: 'Get Project',
|
||||
description: 'Get a project by ID.',
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: 'Project ID (UUID)' }),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { id } = params as { id: string };
|
||||
const project = await brain.projects.findById(id);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const listTasks: ToolDefinition = {
|
||||
name: 'brain_list_tasks',
|
||||
label: 'List Tasks',
|
||||
description: 'List tasks, optionally filtered by project, mission, or status.',
|
||||
parameters: Type.Object({
|
||||
projectId: Type.Optional(Type.String({ description: 'Filter by project ID' })),
|
||||
missionId: Type.Optional(Type.String({ description: 'Filter by mission ID' })),
|
||||
status: Type.Optional(Type.String({ description: 'Filter by status' })),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as { projectId?: string; missionId?: string; status?: string };
|
||||
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
let tasks;
|
||||
if (p.projectId) tasks = await brain.tasks.findByProject(p.projectId);
|
||||
else if (p.missionId) tasks = await brain.tasks.findByMission(p.missionId);
|
||||
else if (p.status) tasks = await brain.tasks.findByStatus(p.status as TaskStatus);
|
||||
else tasks = await brain.tasks.findAll();
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const createTask: ToolDefinition = {
|
||||
name: 'brain_create_task',
|
||||
label: 'Create Task',
|
||||
description: 'Create a new task in the brain.',
|
||||
parameters: Type.Object({
|
||||
title: Type.String({ description: 'Task title' }),
|
||||
description: Type.Optional(Type.String({ description: 'Task description' })),
|
||||
projectId: Type.Optional(Type.String({ description: 'Project ID' })),
|
||||
missionId: Type.Optional(Type.String({ description: 'Mission ID' })),
|
||||
priority: Type.Optional(
|
||||
Type.String({ description: 'Priority: low, medium, high, critical' }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as {
|
||||
title: string;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
missionId?: string;
|
||||
priority?: string;
|
||||
};
|
||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||
const task = await brain.tasks.create({
|
||||
...p,
|
||||
priority: p.priority as Priority | undefined,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(task, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const updateTask: ToolDefinition = {
|
||||
name: 'brain_update_task',
|
||||
label: 'Update Task',
|
||||
description: 'Update an existing task.',
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: 'Task ID' }),
|
||||
title: Type.Optional(Type.String()),
|
||||
description: Type.Optional(Type.String()),
|
||||
status: Type.Optional(
|
||||
Type.String({ description: 'not-started, in-progress, blocked, done, cancelled' }),
|
||||
),
|
||||
priority: Type.Optional(Type.String()),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { id, ...updates } = params as {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
};
|
||||
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||
const task = await brain.tasks.update(id, {
|
||||
...updates,
|
||||
status: updates.status as TaskStatus | undefined,
|
||||
priority: updates.priority as Priority | undefined,
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: task ? JSON.stringify(task, null, 2) : `Task not found: ${id}`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const listMissions: ToolDefinition = {
|
||||
name: 'brain_list_missions',
|
||||
label: 'List Missions',
|
||||
description: 'List all missions, optionally filtered by project.',
|
||||
parameters: Type.Object({
|
||||
projectId: Type.Optional(Type.String({ description: 'Filter by project ID' })),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as { projectId?: string };
|
||||
const missions = p.projectId
|
||||
? await brain.missions.findByProject(p.projectId)
|
||||
: await brain.missions.findAll();
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const listConversations: ToolDefinition = {
|
||||
name: 'brain_list_conversations',
|
||||
label: 'List Conversations',
|
||||
description: 'List conversations for a user.',
|
||||
parameters: Type.Object({
|
||||
userId: Type.String({ description: 'User ID' }),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { userId } = params as { userId: string };
|
||||
const conversations = await brain.conversations.findAll(userId);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
listProjects,
|
||||
getProject,
|
||||
listTasks,
|
||||
createTask,
|
||||
updateTask,
|
||||
listMissions,
|
||||
listConversations,
|
||||
];
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import type { CoordService } from '../../coord/coord.service.js';
|
||||
|
||||
export function createCoordTools(coordService: CoordService): ToolDefinition[] {
|
||||
const getMissionStatus: ToolDefinition = {
|
||||
name: 'coord_mission_status',
|
||||
label: 'Mission Status',
|
||||
description:
|
||||
'Get the current orchestration mission status including milestones, tasks, and active session.',
|
||||
parameters: Type.Object({
|
||||
projectPath: Type.Optional(
|
||||
Type.String({ description: 'Project path. Defaults to gateway working directory.' }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { projectPath } = params as { projectPath?: string };
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const status = await coordService.getMissionStatus(resolvedPath);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: status ? JSON.stringify(status, null, 2) : 'No active coord mission found.',
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const listCoordTasks: ToolDefinition = {
|
||||
name: 'coord_list_tasks',
|
||||
label: 'List Coord Tasks',
|
||||
description: 'List all tasks from the orchestration TASKS.md file.',
|
||||
parameters: Type.Object({
|
||||
projectPath: Type.Optional(
|
||||
Type.String({ description: 'Project path. Defaults to gateway working directory.' }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { projectPath } = params as { projectPath?: string };
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const tasks = await coordService.listTasks(resolvedPath);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const getCoordTaskDetail: ToolDefinition = {
|
||||
name: 'coord_task_detail',
|
||||
label: 'Coord Task Detail',
|
||||
description: 'Get detailed status for a specific orchestration task.',
|
||||
parameters: Type.Object({
|
||||
taskId: Type.String({ description: 'Task ID (e.g. P2-005)' }),
|
||||
projectPath: Type.Optional(
|
||||
Type.String({ description: 'Project path. Defaults to gateway working directory.' }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { taskId, projectPath } = params as { taskId: string; projectPath?: string };
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const detail = await coordService.getTaskStatus(resolvedPath, taskId);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: detail
|
||||
? JSON.stringify(detail, null, 2)
|
||||
: `Task ${taskId} not found in coord mission.`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return [getMissionStatus, listCoordTasks, getCoordTaskDetail];
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
||||
import { resolve, relative, join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Safety constraint: all file operations are restricted to a base directory.
|
||||
* Paths that escape the sandbox via ../ traversal are rejected.
|
||||
*/
|
||||
function resolveSafe(baseDir: string, inputPath: string): string {
|
||||
const resolved = resolve(baseDir, inputPath);
|
||||
const rel = relative(baseDir, resolved);
|
||||
if (rel.startsWith('..') || resolve(resolved) !== resolve(join(baseDir, rel))) {
|
||||
throw new Error(`Path escape detected: "${inputPath}" resolves outside base directory`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
|
||||
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit
|
||||
|
||||
export function createFileTools(baseDir: string): ToolDefinition[] {
|
||||
const readFileTool: ToolDefinition = {
|
||||
name: 'fs_read_file',
|
||||
label: 'Read File',
|
||||
description:
|
||||
'Read the contents of a file. Path is resolved relative to the sandbox base directory.',
|
||||
parameters: Type.Object({
|
||||
path: Type.String({
|
||||
description: 'File path (relative to sandbox base or absolute within it)',
|
||||
}),
|
||||
encoding: Type.Optional(
|
||||
Type.String({ description: 'Encoding: utf8 (default), base64, hex' }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { path, encoding } = params as { path: string; encoding?: string };
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = resolveSafe(baseDir, path);
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await stat(safePath);
|
||||
if (!info.isFile()) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: path is not a file: ${path}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
if (info.size > MAX_READ_BYTES) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Error: file too large (${info.size} bytes, limit ${MAX_READ_BYTES} bytes)`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
const enc = (encoding ?? 'utf8') as BufferEncoding;
|
||||
const content = await readFile(safePath, { encoding: enc });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: String(content) }],
|
||||
details: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const writeFileTool: ToolDefinition = {
|
||||
name: 'fs_write_file',
|
||||
label: 'Write File',
|
||||
description:
|
||||
'Write content to a file. Path is resolved relative to the sandbox base directory. Overwrites existing file.',
|
||||
parameters: Type.Object({
|
||||
path: Type.String({
|
||||
description: 'File path (relative to sandbox base or absolute within it)',
|
||||
}),
|
||||
content: Type.String({ description: 'Content to write' }),
|
||||
encoding: Type.Optional(Type.String({ description: 'Encoding: utf8 (default), base64' })),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { path, content, encoding } = params as {
|
||||
path: string;
|
||||
content: string;
|
||||
encoding?: string;
|
||||
};
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = resolveSafe(baseDir, path);
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (Buffer.byteLength(content, 'utf8') > MAX_WRITE_BYTES) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Error: content too large (limit ${MAX_WRITE_BYTES} bytes)`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const enc = (encoding ?? 'utf8') as BufferEncoding;
|
||||
await writeFile(safePath, content, { encoding: enc });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `File written successfully: ${path}` }],
|
||||
details: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error writing file: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const listDirectoryTool: ToolDefinition = {
|
||||
name: 'fs_list_directory',
|
||||
label: 'List Directory',
|
||||
description: 'List files and directories at a given path within the sandbox base directory.',
|
||||
parameters: Type.Object({
|
||||
path: Type.Optional(
|
||||
Type.String({
|
||||
description: 'Directory path (relative to sandbox base). Defaults to base directory.',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { path } = params as { path?: string };
|
||||
const target = path ?? '.';
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = resolveSafe(baseDir, target);
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await stat(safePath);
|
||||
if (!info.isDirectory()) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: path is not a directory: ${target}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
const entries = await readdir(safePath, { withFileTypes: true });
|
||||
const items = entries.map((e) => ({
|
||||
name: e.name,
|
||||
type: e.isDirectory() ? 'directory' : e.isSymbolicLink() ? 'symlink' : 'file',
|
||||
}));
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(items, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error listing directory: ${String(err)}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return [readFileTool, writeFileTool, listDirectoryTool];
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { resolve, relative } from 'node:path';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const GIT_TIMEOUT_MS = 15_000;
|
||||
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
||||
|
||||
/**
|
||||
* Clamp a user-supplied cwd to within the sandbox directory.
|
||||
* If the resolved path escapes the sandbox (via ../ or absolute path outside),
|
||||
* falls back to the sandbox directory itself.
|
||||
*/
|
||||
function clampCwd(sandboxDir: string, requestedCwd?: string): string {
|
||||
if (!requestedCwd) return sandboxDir;
|
||||
const resolved = resolve(sandboxDir, requestedCwd);
|
||||
const rel = relative(sandboxDir, resolved);
|
||||
if (rel.startsWith('..') || rel.startsWith('/')) {
|
||||
// Escape attempt — fall back to sandbox root
|
||||
return sandboxDir;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function runGit(
|
||||
args: string[],
|
||||
cwd?: string,
|
||||
): Promise<{ stdout: string; stderr: string; error?: string }> {
|
||||
// Only allow specific safe read-only git subcommands
|
||||
const allowedSubcommands = ['status', 'log', 'diff', 'show', 'branch', 'tag', 'ls-files'];
|
||||
const subcommand = args[0];
|
||||
if (!subcommand || !allowedSubcommands.includes(subcommand)) {
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
error: `Blocked: git subcommand "${subcommand}" is not allowed. Permitted: ${allowedSubcommands.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
const cmd = `git ${args.map((a) => JSON.stringify(a)).join(' ')}`;
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(cmd, {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: MAX_OUTPUT_BYTES,
|
||||
});
|
||||
return { stdout, stderr };
|
||||
} catch (err: unknown) {
|
||||
const e = err as { stdout?: string; stderr?: string; message?: string };
|
||||
return {
|
||||
stdout: e.stdout ?? '',
|
||||
stderr: e.stderr ?? '',
|
||||
error: e.message ?? String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
||||
const defaultCwd = sandboxDir ?? process.cwd();
|
||||
|
||||
const gitStatus: ToolDefinition = {
|
||||
name: 'git_status',
|
||||
label: 'Git Status',
|
||||
description: 'Show the working tree status (staged, unstaged, untracked files).',
|
||||
parameters: Type.Object({
|
||||
cwd: Type.Optional(
|
||||
Type.String({
|
||||
description: 'Repository working directory (relative to sandbox or absolute within it).',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { cwd } = params as { cwd?: string };
|
||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||
const result = await runGit(['status', '--short', '--branch'], safeCwd);
|
||||
const text = result.error
|
||||
? `Error: ${result.error}\n${result.stderr}`
|
||||
: result.stdout || '(no output)';
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: text }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const gitLog: ToolDefinition = {
|
||||
name: 'git_log',
|
||||
label: 'Git Log',
|
||||
description: 'Show recent commit history.',
|
||||
parameters: Type.Object({
|
||||
limit: Type.Optional(Type.Number({ description: 'Number of commits to show (default 20)' })),
|
||||
oneline: Type.Optional(
|
||||
Type.Boolean({ description: 'Compact one-line format (default true)' }),
|
||||
),
|
||||
cwd: Type.Optional(
|
||||
Type.String({
|
||||
description: 'Repository working directory (relative to sandbox or absolute within it).',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { limit, oneline, cwd } = params as {
|
||||
limit?: number;
|
||||
oneline?: boolean;
|
||||
cwd?: string;
|
||||
};
|
||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||
const args = ['log', `--max-count=${limit ?? 20}`];
|
||||
if (oneline !== false) args.push('--oneline');
|
||||
const result = await runGit(args, safeCwd);
|
||||
const text = result.error
|
||||
? `Error: ${result.error}\n${result.stderr}`
|
||||
: result.stdout || '(no commits)';
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: text }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const gitDiff: ToolDefinition = {
|
||||
name: 'git_diff',
|
||||
label: 'Git Diff',
|
||||
description: 'Show changes between commits, working tree, or staged changes.',
|
||||
parameters: Type.Object({
|
||||
staged: Type.Optional(
|
||||
Type.Boolean({ description: 'Show staged (cached) changes instead of unstaged' }),
|
||||
),
|
||||
ref: Type.Optional(
|
||||
Type.String({ description: 'Compare against this ref (commit SHA, branch, or tag)' }),
|
||||
),
|
||||
path: Type.Optional(
|
||||
Type.String({ description: 'Limit diff to a specific file or directory' }),
|
||||
),
|
||||
cwd: Type.Optional(
|
||||
Type.String({
|
||||
description: 'Repository working directory (relative to sandbox or absolute within it).',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { staged, ref, path, cwd } = params as {
|
||||
staged?: boolean;
|
||||
ref?: string;
|
||||
path?: string;
|
||||
cwd?: string;
|
||||
};
|
||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||
const args = ['diff'];
|
||||
if (staged) args.push('--cached');
|
||||
if (ref) args.push(ref);
|
||||
args.push('--');
|
||||
if (path) args.push(path);
|
||||
const result = await runGit(args, safeCwd);
|
||||
const text = result.error
|
||||
? `Error: ${result.error}\n${result.stderr}`
|
||||
: result.stdout || '(no diff)';
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: text }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return [gitStatus, gitLog, gitDiff];
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export { createBrainTools } from './brain-tools.js';
|
||||
export { createCoordTools } from './coord-tools.js';
|
||||
export { createFileTools } from './file-tools.js';
|
||||
export { createGitTools } from './git-tools.js';
|
||||
export { createShellTools } from './shell-tools.js';
|
||||
export { createWebTools } from './web-tools.js';
|
||||
export { createSkillTools } from './skill-tools.js';
|
||||
@@ -1,158 +0,0 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import type { Memory } from '@mosaic/memory';
|
||||
import type { EmbeddingProvider } from '@mosaic/memory';
|
||||
|
||||
export function createMemoryTools(
|
||||
memory: Memory,
|
||||
embeddingProvider: EmbeddingProvider | null,
|
||||
): ToolDefinition[] {
|
||||
const searchMemory: ToolDefinition = {
|
||||
name: 'memory_search',
|
||||
label: 'Search Memory',
|
||||
description:
|
||||
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
|
||||
parameters: Type.Object({
|
||||
userId: Type.String({ description: 'User ID to search memory for' }),
|
||||
query: Type.String({ description: 'Natural language search query' }),
|
||||
limit: Type.Optional(Type.Number({ description: 'Max results (default 5)' })),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { userId, query, limit } = params as {
|
||||
userId: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
if (!embeddingProvider) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'Semantic search unavailable — no embedding provider configured',
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const embedding = await embeddingProvider.embed(query);
|
||||
const results = await memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const getPreferences: ToolDefinition = {
|
||||
name: 'memory_get_preferences',
|
||||
label: 'Get User Preferences',
|
||||
description: 'Retrieve stored preferences for a user.',
|
||||
parameters: Type.Object({
|
||||
userId: Type.String({ description: 'User ID' }),
|
||||
category: Type.Optional(
|
||||
Type.String({
|
||||
description: 'Filter by category: communication, coding, workflow, appearance, general',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { userId, category } = params as { userId: string; category?: string };
|
||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||
const prefs = category
|
||||
? await memory.preferences.findByUserAndCategory(userId, category as Cat)
|
||||
: await memory.preferences.findByUser(userId);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const savePreference: ToolDefinition = {
|
||||
name: 'memory_save_preference',
|
||||
label: 'Save User Preference',
|
||||
description:
|
||||
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
|
||||
parameters: Type.Object({
|
||||
userId: Type.String({ description: 'User ID' }),
|
||||
key: Type.String({ description: 'Preference key' }),
|
||||
value: Type.String({ description: 'Preference value (JSON string)' }),
|
||||
category: Type.Optional(
|
||||
Type.String({
|
||||
description: 'Category: communication, coding, workflow, appearance, general',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { userId, key, value, category } = params as {
|
||||
userId: string;
|
||||
key: string;
|
||||
value: string;
|
||||
category?: string;
|
||||
};
|
||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch {
|
||||
parsedValue = value;
|
||||
}
|
||||
const pref = await memory.preferences.upsert({
|
||||
userId,
|
||||
key,
|
||||
value: parsedValue,
|
||||
category: (category as Cat) ?? 'general',
|
||||
source: 'agent',
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(pref, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const saveInsight: ToolDefinition = {
|
||||
name: 'memory_save_insight',
|
||||
label: 'Save Insight',
|
||||
description:
|
||||
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
|
||||
parameters: Type.Object({
|
||||
userId: Type.String({ description: 'User ID' }),
|
||||
content: Type.String({ description: 'The insight or knowledge to store' }),
|
||||
category: Type.Optional(
|
||||
Type.String({
|
||||
description: 'Category: decision, learning, preference, fact, pattern, general',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { userId, content, category } = params as {
|
||||
userId: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
};
|
||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||
|
||||
let embedding: number[] | null = null;
|
||||
if (embeddingProvider) {
|
||||
embedding = await embeddingProvider.embed(content);
|
||||
}
|
||||
|
||||
const insight = await memory.insights.create({
|
||||
userId,
|
||||
content,
|
||||
embedding,
|
||||
source: 'agent',
|
||||
category: (category as Cat) ?? 'learning',
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return [searchMemory, getPreferences, savePreference, saveInsight];
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { resolve, relative } from 'node:path';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
||||
|
||||
/**
|
||||
* Commands that are outright blocked for safety.
|
||||
* This is a denylist; the agent should be instructed to use
|
||||
* the least-privilege command necessary.
|
||||
*/
|
||||
const BLOCKED_COMMANDS = new Set([
|
||||
'rm',
|
||||
'rmdir',
|
||||
'mkfs',
|
||||
'dd',
|
||||
'format',
|
||||
'fdisk',
|
||||
'parted',
|
||||
'shred',
|
||||
'wipefs',
|
||||
'sudo',
|
||||
'su',
|
||||
'chown',
|
||||
'chmod',
|
||||
'passwd',
|
||||
'useradd',
|
||||
'userdel',
|
||||
'groupadd',
|
||||
'shutdown',
|
||||
'reboot',
|
||||
'halt',
|
||||
'poweroff',
|
||||
'kill',
|
||||
'killall',
|
||||
'pkill',
|
||||
'curl',
|
||||
'wget',
|
||||
'nc',
|
||||
'netcat',
|
||||
'ncat',
|
||||
'ssh',
|
||||
'scp',
|
||||
'sftp',
|
||||
'rsync',
|
||||
'iptables',
|
||||
'ip6tables',
|
||||
'nft',
|
||||
'ufw',
|
||||
'firewall-cmd',
|
||||
'docker',
|
||||
'podman',
|
||||
'kubectl',
|
||||
'helm',
|
||||
'terraform',
|
||||
'ansible',
|
||||
'crontab',
|
||||
'at',
|
||||
'batch',
|
||||
]);
|
||||
|
||||
function extractBaseCommand(command: string): string {
|
||||
// Extract the first word (the binary name), stripping path
|
||||
const trimmed = command.trim();
|
||||
const firstToken = trimmed.split(/\s+/)[0] ?? '';
|
||||
return firstToken.split('/').pop() ?? firstToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a user-supplied cwd to within the sandbox directory.
|
||||
* If the resolved path escapes the sandbox (via ../ or absolute path outside),
|
||||
* falls back to the sandbox directory itself.
|
||||
*/
|
||||
function clampCwd(sandboxDir: string, requestedCwd?: string): string {
|
||||
if (!requestedCwd) return sandboxDir;
|
||||
const resolved = resolve(sandboxDir, requestedCwd);
|
||||
const rel = relative(sandboxDir, resolved);
|
||||
if (rel.startsWith('..') || rel.startsWith('/')) {
|
||||
// Escape attempt — fall back to sandbox root
|
||||
return sandboxDir;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function runCommand(
|
||||
command: string,
|
||||
options: { timeoutMs: number; cwd?: string },
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('sh', ['-c', command], {
|
||||
cwd: options.cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
let totalBytes = 0;
|
||||
let truncated = false;
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
if (truncated) return;
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > MAX_OUTPUT_BYTES) {
|
||||
stdout += chunk.subarray(0, MAX_OUTPUT_BYTES - (totalBytes - chunk.length)).toString();
|
||||
stdout += '\n[output truncated at 100 KB limit]';
|
||||
truncated = true;
|
||||
child.kill('SIGTERM');
|
||||
} else {
|
||||
stdout += chunk.toString();
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
if (stderr.length < MAX_OUTPUT_BYTES) {
|
||||
stderr += chunk.toString();
|
||||
}
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
// already exited
|
||||
}
|
||||
}, 2000);
|
||||
}, options.timeoutMs);
|
||||
|
||||
child.on('close', (exitCode) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr, exitCode, timedOut });
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr: stderr + String(err), exitCode: null, timedOut: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createShellTools(sandboxDir?: string): ToolDefinition[] {
|
||||
const defaultCwd = sandboxDir ?? process.cwd();
|
||||
|
||||
const shellExec: ToolDefinition = {
|
||||
name: 'shell_exec',
|
||||
label: 'Shell Execute',
|
||||
description:
|
||||
'Execute a shell command with timeout and output limits. Dangerous commands (rm, sudo, docker, etc.) are blocked. Working directory is restricted to the session sandbox.',
|
||||
parameters: Type.Object({
|
||||
command: Type.String({ description: 'Shell command to execute' }),
|
||||
cwd: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
'Working directory for the command (relative to sandbox or absolute within it).',
|
||||
}),
|
||||
),
|
||||
timeout: Type.Optional(
|
||||
Type.Number({ description: 'Timeout in milliseconds (default 30000, max 60000)' }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { command, cwd, timeout } = params as {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
const base = extractBaseCommand(command);
|
||||
if (BLOCKED_COMMANDS.has(base)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Error: command "${base}" is blocked for safety reasons.`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 60_000);
|
||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
||||
|
||||
const result = await runCommand(command, {
|
||||
timeoutMs,
|
||||
cwd: safeCwd,
|
||||
});
|
||||
|
||||
if (result.timedOut) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Command timed out after ${timeoutMs}ms.\nPartial stdout:\n${result.stdout}\nPartial stderr:\n${result.stderr}`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (result.stdout) parts.push(`stdout:\n${result.stdout}`);
|
||||
if (result.stderr) parts.push(`stderr:\n${result.stderr}`);
|
||||
parts.push(`exit code: ${result.exitCode ?? 'null'}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: parts.join('\n') }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return [shellExec];
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import type { SkillsService } from '../../skills/skills.service.js';
|
||||
|
||||
/**
|
||||
* Creates meta-tools that allow agents to list and invoke skills from the catalog.
|
||||
*
|
||||
* skill_list — list all enabled skills
|
||||
* skill_invoke — execute a skill by name with parameters
|
||||
*/
|
||||
export function createSkillTools(skillsService: SkillsService): ToolDefinition[] {
|
||||
const skillList: ToolDefinition = {
|
||||
name: 'skill_list',
|
||||
label: 'List Skills',
|
||||
description:
|
||||
'List all enabled skills available in the catalog. Returns name, description, type, and config for each skill.',
|
||||
parameters: Type.Object({}),
|
||||
async execute() {
|
||||
const skills = await skillsService.findEnabled();
|
||||
const summary = skills.map((s) => ({
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
version: s.version,
|
||||
source: s.source,
|
||||
config: s.config,
|
||||
}));
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text:
|
||||
summary.length > 0
|
||||
? JSON.stringify(summary, null, 2)
|
||||
: 'No enabled skills found in catalog.',
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const skillInvoke: ToolDefinition = {
|
||||
name: 'skill_invoke',
|
||||
label: 'Invoke Skill',
|
||||
description:
|
||||
'Invoke a skill from the catalog by name. For prompt skills, returns the prompt addition. ' +
|
||||
'For tool skills, executes the embedded logic. For workflow skills, returns the workflow steps.',
|
||||
parameters: Type.Object({
|
||||
name: Type.String({ description: 'Skill name to invoke' }),
|
||||
params: Type.Optional(
|
||||
Type.Record(Type.String(), Type.Unknown(), {
|
||||
description: 'Parameters to pass to the skill (if applicable)',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, rawParams) {
|
||||
const { name, params } = rawParams as {
|
||||
name: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const skill = await skillsService.findByName(name);
|
||||
if (!skill) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Skill not found: ${name}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (!skill.enabled) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Skill is disabled: ${name}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const config = (skill.config ?? {}) as Record<string, unknown>;
|
||||
const skillType = (config['type'] as string | undefined) ?? 'prompt';
|
||||
|
||||
switch (skillType) {
|
||||
case 'prompt': {
|
||||
const promptAddition =
|
||||
(config['prompt'] as string | undefined) ?? skill.description ?? '';
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: promptAddition
|
||||
? `[Skill: ${name}] ${promptAddition}`
|
||||
: `[Skill: ${name}] No prompt content defined.`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool': {
|
||||
const toolLogic = config['logic'] as string | undefined;
|
||||
if (!toolLogic) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `[Skill: ${name}] Tool skill has no logic defined.`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
// Inline tool skill execution: the logic field holds a JS expression or template
|
||||
// For safety, treat it as a template that can reference params
|
||||
const result = renderTemplate(toolLogic, { params: params ?? {}, skill });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `[Skill: ${name}]\n${result}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case 'workflow': {
|
||||
const steps = config['steps'] as unknown[] | undefined;
|
||||
if (!steps || steps.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `[Skill: ${name}] Workflow has no steps defined.`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `[Skill: ${name}] Workflow steps:\n${JSON.stringify(steps, null, 2)}`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
// Unknown type — return full config so the agent can decide what to do
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `[Skill: ${name}] (type: ${skillType})\n${JSON.stringify(config, null, 2)}`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return [skillList, skillInvoke];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal template renderer — replaces {{key}} with values from the context.
|
||||
* Used for tool skill logic templates.
|
||||
*/
|
||||
function renderTemplate(template: string, context: Record<string, unknown>): string {
|
||||
return template.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (_match, path: string) => {
|
||||
const parts = path.split('.');
|
||||
let value: unknown = context;
|
||||
for (const part of parts) {
|
||||
if (value != null && typeof value === 'object') {
|
||||
value = (value as Record<string, unknown>)[part];
|
||||
} else {
|
||||
value = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return value !== undefined && value !== null ? String(value) : '';
|
||||
});
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const MAX_RESPONSE_BYTES = 512 * 1024; // 512 KB
|
||||
|
||||
/**
|
||||
* Blocked URL patterns (private IP ranges, localhost, link-local).
|
||||
*/
|
||||
const BLOCKED_HOSTNAMES = [
|
||||
/^localhost$/i,
|
||||
/^127\./,
|
||||
/^10\./,
|
||||
/^172\.(1[6-9]|2\d|3[01])\./,
|
||||
/^192\.168\./,
|
||||
/^::1$/,
|
||||
/^fc[0-9a-f][0-9a-f]:/i,
|
||||
/^fe80:/i,
|
||||
/^0\.0\.0\.0$/,
|
||||
/^169\.254\./,
|
||||
];
|
||||
|
||||
function isBlockedUrl(urlString: string): string | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(urlString);
|
||||
} catch {
|
||||
return `Invalid URL: ${urlString}`;
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return `Unsupported protocol: ${parsed.protocol}. Only http and https are allowed.`;
|
||||
}
|
||||
const hostname = parsed.hostname;
|
||||
for (const pattern of BLOCKED_HOSTNAMES) {
|
||||
if (pattern.test(hostname)) {
|
||||
return `Blocked: requests to "${hostname}" are not allowed (private/local addresses).`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchWithLimit(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
timeoutMs: number,
|
||||
): Promise<{ text: string; status: number; contentType: string }> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...options, signal: controller.signal });
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
|
||||
// Stream response and enforce size limit
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return { text: '', status: response.status, contentType };
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
let truncated = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalBytes += value.length;
|
||||
if (totalBytes > MAX_RESPONSE_BYTES) {
|
||||
const remaining = MAX_RESPONSE_BYTES - (totalBytes - value.length);
|
||||
chunks.push(value.subarray(0, remaining));
|
||||
truncated = true;
|
||||
reader.cancel();
|
||||
break;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const combined = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
let text = new TextDecoder().decode(combined);
|
||||
if (truncated) {
|
||||
text += '\n[response truncated at 512 KB limit]';
|
||||
}
|
||||
|
||||
return { text, status: response.status, contentType };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export function createWebTools(): ToolDefinition[] {
|
||||
const webGet: ToolDefinition = {
|
||||
name: 'web_get',
|
||||
label: 'HTTP GET',
|
||||
description:
|
||||
'Perform an HTTP GET request and return the response body. Private/local addresses are blocked.',
|
||||
parameters: Type.Object({
|
||||
url: Type.String({ description: 'URL to fetch (http/https only)' }),
|
||||
headers: Type.Optional(
|
||||
Type.Record(Type.String(), Type.String(), {
|
||||
description: 'Optional request headers as key-value pairs',
|
||||
}),
|
||||
),
|
||||
timeout: Type.Optional(
|
||||
Type.Number({ description: 'Timeout in milliseconds (default 15000, max 30000)' }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { url, headers, timeout } = params as {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
const blocked = isBlockedUrl(url);
|
||||
if (blocked) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${blocked}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 30_000);
|
||||
|
||||
try {
|
||||
const result = await fetchWithLimit(
|
||||
url,
|
||||
{ method: 'GET', headers: headers ?? {} },
|
||||
timeoutMs,
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `HTTP ${result.status} (${result.contentType})\n\n${result.text}`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error fetching URL: ${msg}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const webPost: ToolDefinition = {
|
||||
name: 'web_post',
|
||||
label: 'HTTP POST',
|
||||
description:
|
||||
'Perform an HTTP POST request with a JSON or text body. Private/local addresses are blocked.',
|
||||
parameters: Type.Object({
|
||||
url: Type.String({ description: 'URL to POST to (http/https only)' }),
|
||||
body: Type.String({ description: 'Request body (JSON string or plain text)' }),
|
||||
contentType: Type.Optional(
|
||||
Type.String({ description: 'Content-Type header (default: application/json)' }),
|
||||
),
|
||||
headers: Type.Optional(
|
||||
Type.Record(Type.String(), Type.String(), {
|
||||
description: 'Optional additional request headers',
|
||||
}),
|
||||
),
|
||||
timeout: Type.Optional(
|
||||
Type.Number({ description: 'Timeout in milliseconds (default 15000, max 30000)' }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { url, body, contentType, headers, timeout } = params as {
|
||||
url: string;
|
||||
body: string;
|
||||
contentType?: string;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
const blocked = isBlockedUrl(url);
|
||||
if (blocked) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${blocked}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 30_000);
|
||||
const ct = contentType ?? 'application/json';
|
||||
|
||||
try {
|
||||
const result = await fetchWithLimit(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': ct, ...(headers ?? {}) },
|
||||
body,
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `HTTP ${result.status} (${result.contentType})\n\n${result.text}`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error posting to URL: ${msg}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return [webGet, webPost];
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { HealthController } from './health/health.controller.js';
|
||||
import { DatabaseModule } from './database/database.module.js';
|
||||
import { AuthModule } from './auth/auth.module.js';
|
||||
import { BrainModule } from './brain/brain.module.js';
|
||||
import { AgentModule } from './agent/agent.module.js';
|
||||
import { ChatModule } from './chat/chat.module.js';
|
||||
import { ConversationsModule } from './conversations/conversations.module.js';
|
||||
import { ProjectsModule } from './projects/projects.module.js';
|
||||
import { MissionsModule } from './missions/missions.module.js';
|
||||
import { TasksModule } from './tasks/tasks.module.js';
|
||||
import { CoordModule } from './coord/coord.module.js';
|
||||
import { MemoryModule } from './memory/memory.module.js';
|
||||
import { LogModule } from './log/log.module.js';
|
||||
import { SkillsModule } from './skills/skills.module.js';
|
||||
import { PluginModule } from './plugin/plugin.module.js';
|
||||
import { McpModule } from './mcp/mcp.module.js';
|
||||
import { AdminModule } from './admin/admin.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 60 }]),
|
||||
DatabaseModule,
|
||||
AuthModule,
|
||||
BrainModule,
|
||||
AgentModule,
|
||||
ChatModule,
|
||||
ConversationsModule,
|
||||
ProjectsModule,
|
||||
MissionsModule,
|
||||
TasksModule,
|
||||
CoordModule,
|
||||
MemoryModule,
|
||||
LogModule,
|
||||
SkillsModule,
|
||||
PluginModule,
|
||||
McpModule,
|
||||
AdminModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { toNodeHandler } from 'better-auth/node';
|
||||
import type { Auth } from '@mosaic/auth';
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { AUTH } from './auth.tokens.js';
|
||||
|
||||
export function mountAuthHandler(app: NestFastifyApplication): void {
|
||||
const auth = app.get<Auth>(AUTH);
|
||||
const nodeHandler = toNodeHandler(auth);
|
||||
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||
|
||||
const fastify = app.getHttpAdapter().getInstance();
|
||||
|
||||
// BetterAuth is mounted at the raw HTTP level via Fastify's onRequest hook,
|
||||
// bypassing NestJS middleware (including CORS). We must set CORS headers
|
||||
// manually on the raw response before handing off to BetterAuth.
|
||||
fastify.addHook(
|
||||
'onRequest',
|
||||
(
|
||||
req: { raw: IncomingMessage; url: string; method: string },
|
||||
reply: { raw: ServerResponse; hijack: () => void },
|
||||
done: () => void,
|
||||
) => {
|
||||
if (!req.url.startsWith('/api/auth/')) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = req.raw.headers.origin;
|
||||
const allowed = corsOrigin.split(',').map((o) => o.trim());
|
||||
|
||||
if (origin && allowed.includes(origin)) {
|
||||
reply.raw.setHeader('Access-Control-Allow-Origin', origin);
|
||||
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
reply.raw.setHeader(
|
||||
'Access-Control-Allow-Methods',
|
||||
'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
||||
);
|
||||
reply.raw.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Cookie');
|
||||
}
|
||||
|
||||
// Handle preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
reply.hijack();
|
||||
reply.raw.writeHead(204);
|
||||
reply.raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
reply.hijack();
|
||||
nodeHandler(req.raw as IncomingMessage, reply.raw as ServerResponse)
|
||||
.then(() => {
|
||||
if (!reply.raw.writableEnded) {
|
||||
reply.raw.end();
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!reply.raw.headersSent) {
|
||||
reply.raw.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
if (!reply.raw.writableEnded) {
|
||||
reply.raw.end(JSON.stringify({ error: 'Internal auth error' }));
|
||||
}
|
||||
console.error('[AUTH] Handler error:', err);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { fromNodeHeaders } from 'better-auth/node';
|
||||
import type { Auth } from '@mosaic/auth';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { AUTH } from './auth.tokens.js';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(@Inject(AUTH) private readonly auth: Auth) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
const headers = fromNodeHeaders(request.raw.headers);
|
||||
|
||||
const result = await this.auth.api.getSession({ headers });
|
||||
|
||||
if (!result) {
|
||||
throw new UnauthorizedException('Invalid or expired session');
|
||||
}
|
||||
|
||||
(request as FastifyRequest & { user: unknown; session: unknown }).user = result.user;
|
||||
(request as FastifyRequest & { user: unknown; session: unknown }).session = result.session;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { createAuth, type Auth } from '@mosaic/auth';
|
||||
import type { Db } from '@mosaic/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AUTH } from './auth.tokens.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: AUTH,
|
||||
useFactory: (db: Db): Auth =>
|
||||
createAuth({
|
||||
db,
|
||||
baseURL: process.env['BETTER_AUTH_URL'] ?? 'http://localhost:4000',
|
||||
secret: process.env['BETTER_AUTH_SECRET'],
|
||||
}),
|
||||
inject: [DB],
|
||||
},
|
||||
],
|
||||
exports: [AUTH],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -1 +0,0 @@
|
||||
export const AUTH = 'AUTH';
|
||||
@@ -1,7 +0,0 @@
|
||||
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<FastifyRequest & { user?: unknown }>();
|
||||
return request.user;
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
export function assertOwner(
|
||||
ownerId: string | null | undefined,
|
||||
userId: string,
|
||||
resourceName: string,
|
||||
): void {
|
||||
if (!ownerId || ownerId !== userId) {
|
||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { createBrain, type Brain } from '@mosaic/brain';
|
||||
import type { Db } from '@mosaic/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { BRAIN } from './brain.tokens.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: BRAIN,
|
||||
useFactory: (db: Db): Brain => createBrain(db),
|
||||
inject: [DB],
|
||||
},
|
||||
],
|
||||
exports: [BRAIN],
|
||||
})
|
||||
export class BrainModule {}
|
||||
@@ -1 +0,0 @@
|
||||
export const BRAIN = 'BRAIN';
|
||||
@@ -1,80 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SendMessageDto } from '../../conversations/conversations.dto.js';
|
||||
import { ChatRequestDto } from '../chat.dto.js';
|
||||
import { validateSocketSession } from '../chat.gateway-auth.js';
|
||||
|
||||
describe('Chat controller source hardening', () => {
|
||||
it('applies AuthGuard and reads the current user', () => {
|
||||
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
||||
|
||||
expect(source).toContain('@UseGuards(AuthGuard)');
|
||||
expect(source).toContain('@CurrentUser() user: { id: string }');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebSocket session authentication', () => {
|
||||
it('returns null when the handshake does not resolve to a session', async () => {
|
||||
const result = await validateSocketSession(
|
||||
{},
|
||||
{
|
||||
api: {
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the resolved session when Better Auth accepts the headers', async () => {
|
||||
const session = { user: { id: 'user-1' }, session: { id: 'session-1' } };
|
||||
|
||||
const result = await validateSocketSession(
|
||||
{ cookie: 'session=abc' },
|
||||
{
|
||||
api: {
|
||||
getSession: vi.fn().mockResolvedValue(session),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chat DTO validation', () => {
|
||||
it('rejects unsupported message roles', () => {
|
||||
const dto = Object.assign(new SendMessageDto(), {
|
||||
content: 'hello',
|
||||
role: 'moderator',
|
||||
});
|
||||
|
||||
const errors = validateSync(dto);
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects oversized conversation message content above 10000 characters', () => {
|
||||
const dto = Object.assign(new SendMessageDto(), {
|
||||
content: 'x'.repeat(10_001),
|
||||
role: 'user',
|
||||
});
|
||||
|
||||
const errors = validateSync(dto);
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects oversized chat content above 10000 characters', () => {
|
||||
const dto = Object.assign(new ChatRequestDto(), {
|
||||
content: 'x'.repeat(10_001),
|
||||
});
|
||||
|
||||
const errors = validateSync(dto);
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Logger,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ChatRequestDto } from './chat.dto.js';
|
||||
|
||||
interface ChatResponse {
|
||||
conversationId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Controller('api/chat')
|
||||
@UseGuards(AuthGuard)
|
||||
export class ChatController {
|
||||
private readonly logger = new Logger(ChatController.name);
|
||||
|
||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
||||
|
||||
@Post()
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
async chat(
|
||||
@Body() body: ChatRequestDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
): Promise<ChatResponse> {
|
||||
const conversationId = body.conversationId ?? uuid();
|
||||
|
||||
try {
|
||||
let agentSession = this.agentService.getSession(conversationId);
|
||||
if (!agentSession) {
|
||||
agentSession = await this.agentService.createSession(conversationId);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Session creation failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`);
|
||||
|
||||
let responseText = '';
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
this.logger.error(`Agent response timed out after 120s for conversation=${conversationId}`);
|
||||
reject(new Error('Agent response timed out'));
|
||||
}, 120_000);
|
||||
|
||||
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, body.content);
|
||||
await done;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('timed out')) {
|
||||
throw new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
|
||||
}
|
||||
this.logger.error(`Chat prompt failed for conversation=${conversationId}`, String(err));
|
||||
throw new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return { conversationId, text: responseText };
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
export class ChatRequestDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
conversationId?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
content!: string;
|
||||
}
|
||||
|
||||
export class ChatSocketMessageDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
conversationId?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
content!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
provider?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
modelId?: string;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { IncomingHttpHeaders } from 'node:http';
|
||||
import { fromNodeHeaders } from 'better-auth/node';
|
||||
|
||||
export interface SocketSessionResult {
|
||||
session: unknown;
|
||||
user: { id: string };
|
||||
}
|
||||
|
||||
export interface SessionAuth {
|
||||
api: {
|
||||
getSession(context: { headers: Headers }): Promise<SocketSessionResult | null>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateSocketSession(
|
||||
headers: IncomingHttpHeaders,
|
||||
auth: SessionAuth,
|
||||
): Promise<SocketSessionResult | null> {
|
||||
const sessionHeaders = fromNodeHeaders(headers);
|
||||
const result = await auth.api.getSession({ headers: sessionHeaders });
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
session: result.session,
|
||||
user: { id: result.user.id },
|
||||
};
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
type OnGatewayInit,
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import type { Auth } from '@mosaic/auth';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||
import { validateSocketSession } from './chat.gateway-auth.js';
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
||||
},
|
||||
namespace: '/chat',
|
||||
})
|
||||
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
private readonly logger = new Logger(ChatGateway.name);
|
||||
private readonly clientSessions = new Map<
|
||||
string,
|
||||
{ conversationId: string; cleanup: () => void }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(AUTH) private readonly auth: Auth,
|
||||
) {}
|
||||
|
||||
afterInit(): void {
|
||||
this.logger.log('Chat WebSocket gateway initialized');
|
||||
}
|
||||
|
||||
async handleConnection(client: Socket): Promise<void> {
|
||||
const session = await validateSocketSession(client.handshake.headers, this.auth);
|
||||
if (!session) {
|
||||
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
client.data.user = session.user;
|
||||
client.data.session = session.session;
|
||||
this.logger.log(`Client connected: ${client.id}`);
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket): void {
|
||||
this.logger.log(`Client disconnected: ${client.id}`);
|
||||
const session = this.clientSessions.get(client.id);
|
||||
if (session) {
|
||||
session.cleanup();
|
||||
this.agentService.removeChannel(session.conversationId, `websocket:${client.id}`);
|
||||
this.clientSessions.delete(client.id);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('message')
|
||||
async handleMessage(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: ChatSocketMessageDto,
|
||||
): Promise<void> {
|
||||
const conversationId = data.conversationId ?? uuid();
|
||||
|
||||
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
|
||||
|
||||
// Ensure agent session exists for this conversation
|
||||
try {
|
||||
let agentSession = this.agentService.getSession(conversationId);
|
||||
if (!agentSession) {
|
||||
agentSession = await this.agentService.createSession(conversationId, {
|
||||
provider: data.provider,
|
||||
modelId: data.modelId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Session creation failed for client=${client.id}, conversation=${conversationId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
client.emit('error', {
|
||||
conversationId,
|
||||
error: 'Failed to start agent session. Please try again.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Always clean up previous listener to prevent leak
|
||||
const existing = this.clientSessions.get(client.id);
|
||||
if (existing) {
|
||||
existing.cleanup();
|
||||
}
|
||||
|
||||
// Subscribe to agent events and relay to client
|
||||
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||
this.relayEvent(client, conversationId, event);
|
||||
});
|
||||
|
||||
this.clientSessions.set(client.id, { conversationId, cleanup });
|
||||
|
||||
// Track channel connection
|
||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
||||
|
||||
// Send acknowledgment
|
||||
client.emit('message:ack', { conversationId, messageId: uuid() });
|
||||
|
||||
// Dispatch to agent
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, data.content);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
client.emit('error', {
|
||||
conversationId,
|
||||
error: 'The agent failed to process your message. Please try again.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||
if (!client.connected) {
|
||||
this.logger.warn(
|
||||
`Dropping event ${event.type} for disconnected client=${client.id}, conversation=${conversationId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'agent_start':
|
||||
client.emit('agent:start', { conversationId });
|
||||
break;
|
||||
|
||||
case 'agent_end':
|
||||
client.emit('agent:end', { conversationId });
|
||||
break;
|
||||
|
||||
case 'message_update': {
|
||||
const assistantEvent = event.assistantMessageEvent;
|
||||
if (assistantEvent.type === 'text_delta') {
|
||||
client.emit('agent:text', {
|
||||
conversationId,
|
||||
text: assistantEvent.delta,
|
||||
});
|
||||
} else if (assistantEvent.type === 'thinking_delta') {
|
||||
client.emit('agent:thinking', {
|
||||
conversationId,
|
||||
text: assistantEvent.delta,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_execution_start':
|
||||
client.emit('agent:tool:start', {
|
||||
conversationId,
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'tool_execution_end':
|
||||
client.emit('agent:tool:end', {
|
||||
conversationId,
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
isError: event.isError,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatGateway } from './chat.gateway.js';
|
||||
import { ChatController } from './chat.controller.js';
|
||||
|
||||
@Module({
|
||||
controllers: [ChatController],
|
||||
providers: [ChatGateway],
|
||||
})
|
||||
export class ChatModule {}
|
||||
@@ -1,97 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { assertOwner } from '../auth/resource-ownership.js';
|
||||
import {
|
||||
CreateConversationDto,
|
||||
UpdateConversationDto,
|
||||
SendMessageDto,
|
||||
} from './conversations.dto.js';
|
||||
|
||||
@Controller('api/conversations')
|
||||
@UseGuards(AuthGuard)
|
||||
export class ConversationsController {
|
||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser() user: { id: string }) {
|
||||
return this.brain.conversations.findAll(user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
return this.getOwnedConversation(id, user.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: { id: string }, @Body() dto: CreateConversationDto) {
|
||||
return this.brain.conversations.create({
|
||||
userId: user.id,
|
||||
title: dto.title,
|
||||
projectId: dto.projectId,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateConversationDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
await this.getOwnedConversation(id, user.id);
|
||||
const conversation = await this.brain.conversations.update(id, dto);
|
||||
if (!conversation) throw new NotFoundException('Conversation not found');
|
||||
return conversation;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
await this.getOwnedConversation(id, user.id);
|
||||
const deleted = await this.brain.conversations.remove(id);
|
||||
if (!deleted) throw new NotFoundException('Conversation not found');
|
||||
}
|
||||
|
||||
@Get(':id/messages')
|
||||
async listMessages(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
await this.getOwnedConversation(id, user.id);
|
||||
return this.brain.conversations.findMessages(id);
|
||||
}
|
||||
|
||||
@Post(':id/messages')
|
||||
async addMessage(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: SendMessageDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
await this.getOwnedConversation(id, user.id);
|
||||
return this.brain.conversations.addMessage({
|
||||
conversationId: id,
|
||||
role: dto.role,
|
||||
content: dto.content,
|
||||
metadata: dto.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
private async getOwnedConversation(id: string, userId: string) {
|
||||
const conversation = await this.brain.conversations.findById(id);
|
||||
if (!conversation) throw new NotFoundException('Conversation not found');
|
||||
assertOwner(conversation.userId, userId, 'Conversation');
|
||||
return conversation;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateConversationDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export class UpdateConversationDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
export class SendMessageDto {
|
||||
@IsIn(['user', 'assistant', 'system'])
|
||||
role!: 'user' | 'assistant' | 'system';
|
||||
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
content!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConversationsController } from './conversations.controller.js';
|
||||
|
||||
@Module({
|
||||
controllers: [ConversationsController],
|
||||
})
|
||||
export class ConversationsModule {}
|
||||
@@ -1,205 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { CoordService } from './coord.service.js';
|
||||
import type {
|
||||
CreateDbMissionDto,
|
||||
UpdateDbMissionDto,
|
||||
CreateMissionTaskDto,
|
||||
UpdateMissionTaskDto,
|
||||
} from './coord.dto.js';
|
||||
|
||||
/** Walk up from cwd to find the monorepo root (has pnpm-workspace.yaml). */
|
||||
function findMonorepoRoot(start: string): string {
|
||||
let dir = start;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
fs.accessSync(path.join(dir, 'pnpm-workspace.yaml'));
|
||||
return dir;
|
||||
} catch {
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
/** Only paths under these roots are allowed for coord queries. */
|
||||
const WORKSPACE_ROOT = process.env['MOSAIC_WORKSPACE_ROOT'] ?? findMonorepoRoot(process.cwd());
|
||||
const ALLOWED_ROOTS = [process.cwd(), WORKSPACE_ROOT];
|
||||
|
||||
function resolveAndValidatePath(raw: string | undefined): string {
|
||||
const resolved = path.resolve(raw ?? process.cwd());
|
||||
const isAllowed = ALLOWED_ROOTS.some(
|
||||
(root) => resolved === root || resolved.startsWith(`${root}/`),
|
||||
);
|
||||
if (!isAllowed) {
|
||||
throw new BadRequestException('projectPath is outside the allowed workspace');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@Controller('api/coord')
|
||||
@UseGuards(AuthGuard)
|
||||
export class CoordController {
|
||||
constructor(@Inject(CoordService) private readonly coordService: CoordService) {}
|
||||
|
||||
// ── File-based coord endpoints (legacy) ──
|
||||
|
||||
@Get('status')
|
||||
async missionStatus(@Query('projectPath') projectPath?: string) {
|
||||
const resolvedPath = resolveAndValidatePath(projectPath);
|
||||
const status = await this.coordService.getMissionStatus(resolvedPath);
|
||||
if (!status) throw new NotFoundException('No active coord mission found');
|
||||
return status;
|
||||
}
|
||||
|
||||
@Get('tasks')
|
||||
async listTasks(@Query('projectPath') projectPath?: string) {
|
||||
const resolvedPath = resolveAndValidatePath(projectPath);
|
||||
return this.coordService.listTasks(resolvedPath);
|
||||
}
|
||||
|
||||
@Get('tasks/:taskId')
|
||||
async taskStatus(@Param('taskId') taskId: string, @Query('projectPath') projectPath?: string) {
|
||||
const resolvedPath = resolveAndValidatePath(projectPath);
|
||||
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
|
||||
if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`);
|
||||
return detail;
|
||||
}
|
||||
|
||||
// ── DB-backed mission endpoints ──
|
||||
|
||||
@Get('missions')
|
||||
async listDbMissions(@CurrentUser() user: { id: string }) {
|
||||
return this.coordService.getMissionsByUser(user.id);
|
||||
}
|
||||
|
||||
@Get('missions/:id')
|
||||
async getDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(id, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return mission;
|
||||
}
|
||||
|
||||
@Post('missions')
|
||||
async createDbMission(@Body() dto: CreateDbMissionDto, @CurrentUser() user: { id: string }) {
|
||||
return this.coordService.createDbMission({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
projectId: dto.projectId,
|
||||
userId: user.id,
|
||||
phase: dto.phase,
|
||||
milestones: dto.milestones,
|
||||
config: dto.config,
|
||||
status: dto.status,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('missions/:id')
|
||||
async updateDbMission(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateDbMissionDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.updateDbMission(id, user.id, dto);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return mission;
|
||||
}
|
||||
|
||||
@Delete('missions/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
const deleted = await this.coordService.deleteDbMission(id, user.id);
|
||||
if (!deleted) throw new NotFoundException('Mission not found');
|
||||
}
|
||||
|
||||
// ── DB-backed mission task endpoints ──
|
||||
|
||||
@Get('missions/:missionId/mission-tasks')
|
||||
async listMissionTasks(
|
||||
@Param('missionId') missionId: string,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return this.coordService.getMissionTasksByMissionAndUser(missionId, user.id);
|
||||
}
|
||||
|
||||
@Get('missions/:missionId/mission-tasks/:taskId')
|
||||
async getMissionTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const task = await this.coordService.getMissionTaskByIdAndUser(taskId, user.id);
|
||||
if (!task) throw new NotFoundException('Mission task not found');
|
||||
return task;
|
||||
}
|
||||
|
||||
@Post('missions/:missionId/mission-tasks')
|
||||
async createMissionTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Body() dto: CreateMissionTaskDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
return this.coordService.createMissionTask({
|
||||
missionId,
|
||||
taskId: dto.taskId,
|
||||
userId: user.id,
|
||||
status: dto.status,
|
||||
description: dto.description,
|
||||
notes: dto.notes,
|
||||
pr: dto.pr,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('missions/:missionId/mission-tasks/:taskId')
|
||||
async updateMissionTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() dto: UpdateMissionTaskDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const updated = await this.coordService.updateMissionTask(taskId, user.id, dto);
|
||||
if (!updated) throw new NotFoundException('Mission task not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Delete('missions/:missionId/mission-tasks/:taskId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteMissionTask(
|
||||
@Param('missionId') missionId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
const deleted = await this.coordService.deleteMissionTask(taskId, user.id);
|
||||
if (!deleted) throw new NotFoundException('Mission task not found');
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// ── File-based coord DTOs (legacy file-system backed) ──
|
||||
|
||||
export interface CoordMissionStatusDto {
|
||||
mission: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
projectPath: string;
|
||||
};
|
||||
milestones: {
|
||||
total: number;
|
||||
completed: number;
|
||||
current?: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
tasks: {
|
||||
total: number;
|
||||
done: number;
|
||||
inProgress: number;
|
||||
pending: number;
|
||||
blocked: number;
|
||||
cancelled: number;
|
||||
};
|
||||
nextTaskId?: string;
|
||||
activeSession?: {
|
||||
sessionId: string;
|
||||
runtime: string;
|
||||
startedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CoordTaskDetailDto {
|
||||
missionId: string;
|
||||
task: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
milestone?: string;
|
||||
pr?: string;
|
||||
notes?: string;
|
||||
};
|
||||
isNextTask: boolean;
|
||||
activeSession?: {
|
||||
sessionId: string;
|
||||
runtime: string;
|
||||
startedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ── DB-backed coord DTOs ──
|
||||
|
||||
export interface CreateDbMissionDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
phase?: string;
|
||||
milestones?: Record<string, unknown>[];
|
||||
config?: Record<string, unknown>;
|
||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
export interface UpdateDbMissionDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
phase?: string;
|
||||
milestones?: Record<string, unknown>[];
|
||||
config?: Record<string, unknown>;
|
||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
export interface CreateMissionTaskDto {
|
||||
missionId: string;
|
||||
taskId?: string;
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
description?: string;
|
||||
notes?: string;
|
||||
pr?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMissionTaskDto {
|
||||
taskId?: string;
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
description?: string;
|
||||
notes?: string;
|
||||
pr?: string;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CoordService } from './coord.service.js';
|
||||
import { CoordController } from './coord.controller.js';
|
||||
|
||||
@Module({
|
||||
providers: [CoordService],
|
||||
controllers: [CoordController],
|
||||
exports: [CoordService],
|
||||
})
|
||||
export class CoordModule {}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import {
|
||||
loadMission,
|
||||
getMissionStatus,
|
||||
getTaskStatus,
|
||||
parseTasksFile,
|
||||
type Mission,
|
||||
type MissionStatusSummary,
|
||||
type MissionTask,
|
||||
type TaskDetail,
|
||||
} from '@mosaic/coord';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
@Injectable()
|
||||
export class CoordService {
|
||||
private readonly logger = new Logger(CoordService.name);
|
||||
|
||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
||||
|
||||
async loadMission(projectPath: string): Promise<Mission | null> {
|
||||
try {
|
||||
return await loadMission(projectPath);
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
`No coord mission at ${projectPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getMissionStatus(projectPath: string): Promise<MissionStatusSummary | null> {
|
||||
const mission = await this.loadMission(projectPath);
|
||||
if (!mission) return null;
|
||||
|
||||
try {
|
||||
return await getMissionStatus(mission);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to get mission status: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getTaskStatus(projectPath: string, taskId: string): Promise<TaskDetail | null> {
|
||||
const mission = await this.loadMission(projectPath);
|
||||
if (!mission) return null;
|
||||
|
||||
try {
|
||||
return await getTaskStatus(mission, taskId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to get task status for ${taskId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async listTasks(projectPath: string): Promise<MissionTask[]> {
|
||||
const mission = await this.loadMission(projectPath);
|
||||
if (!mission) return [];
|
||||
|
||||
const tasksFile = path.isAbsolute(mission.tasksFile)
|
||||
? mission.tasksFile
|
||||
: path.join(mission.projectPath, mission.tasksFile);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(tasksFile, 'utf8');
|
||||
return parseTasksFile(content);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── DB-backed methods for multi-tenant mission management ──
|
||||
|
||||
async getMissionsByUser(userId: string) {
|
||||
return this.brain.missions.findAllByUser(userId);
|
||||
}
|
||||
|
||||
async getMissionByIdAndUser(id: string, userId: string) {
|
||||
return this.brain.missions.findByIdAndUser(id, userId);
|
||||
}
|
||||
|
||||
async getMissionsByProjectAndUser(projectId: string, userId: string) {
|
||||
return this.brain.missions.findByProjectAndUser(projectId, userId);
|
||||
}
|
||||
|
||||
async createDbMission(data: Parameters<Brain['missions']['create']>[0]) {
|
||||
return this.brain.missions.create(data);
|
||||
}
|
||||
|
||||
async updateDbMission(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: Parameters<Brain['missions']['update']>[1],
|
||||
) {
|
||||
const existing = await this.brain.missions.findByIdAndUser(id, userId);
|
||||
if (!existing) return null;
|
||||
return this.brain.missions.update(id, data);
|
||||
}
|
||||
|
||||
async deleteDbMission(id: string, userId: string) {
|
||||
const existing = await this.brain.missions.findByIdAndUser(id, userId);
|
||||
if (!existing) return false;
|
||||
return this.brain.missions.remove(id);
|
||||
}
|
||||
|
||||
// ── DB-backed methods for mission tasks (coord tracking) ──
|
||||
|
||||
async getMissionTasksByMissionAndUser(missionId: string, userId: string) {
|
||||
return this.brain.missionTasks.findByMissionAndUser(missionId, userId);
|
||||
}
|
||||
|
||||
async getMissionTaskByIdAndUser(id: string, userId: string) {
|
||||
return this.brain.missionTasks.findByIdAndUser(id, userId);
|
||||
}
|
||||
|
||||
async createMissionTask(data: Parameters<Brain['missionTasks']['create']>[0]) {
|
||||
return this.brain.missionTasks.create(data);
|
||||
}
|
||||
|
||||
async updateMissionTask(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: Parameters<Brain['missionTasks']['update']>[1],
|
||||
) {
|
||||
const existing = await this.brain.missionTasks.findByIdAndUser(id, userId);
|
||||
if (!existing) return null;
|
||||
return this.brain.missionTasks.update(id, data);
|
||||
}
|
||||
|
||||
async deleteMissionTask(id: string, userId: string) {
|
||||
const existing = await this.brain.missionTasks.findByIdAndUser(id, userId);
|
||||
if (!existing) return false;
|
||||
return this.brain.missionTasks.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Global, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { createDb, type Db, type DbHandle } from '@mosaic/db';
|
||||
|
||||
export const DB_HANDLE = 'DB_HANDLE';
|
||||
export const DB = 'DB';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DB_HANDLE,
|
||||
useFactory: (): DbHandle => createDb(),
|
||||
},
|
||||
{
|
||||
provide: DB,
|
||||
useFactory: (handle: DbHandle): Db => handle.db,
|
||||
inject: [DB_HANDLE],
|
||||
},
|
||||
],
|
||||
exports: [DB],
|
||||
})
|
||||
export class DatabaseModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(DB_HANDLE) private readonly handle: DbHandle) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle.close();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check(): { status: string } {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
type OnModuleInit,
|
||||
type OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import cron from 'node-cron';
|
||||
import { SummarizationService } from './summarization.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CronService.name);
|
||||
private readonly tasks: cron.ScheduledTask[] = [];
|
||||
|
||||
constructor(@Inject(SummarizationService) private readonly summarization: SummarizationService) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||
|
||||
this.tasks.push(
|
||||
cron.schedule(summarizationSchedule, () => {
|
||||
this.summarization.runSummarization().catch((err) => {
|
||||
this.logger.error(`Scheduled summarization failed: ${err}`);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.tasks.push(
|
||||
cron.schedule(tierManagementSchedule, () => {
|
||||
this.summarization.runTierManagement().catch((err) => {
|
||||
this.logger.error(`Scheduled tier management failed: ${err}`);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Cron scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`,
|
||||
);
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
for (const task of this.tasks) {
|
||||
task.stop();
|
||||
}
|
||||
this.tasks.length = 0;
|
||||
this.logger.log('Cron tasks stopped');
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Body, Controller, Get, Inject, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import type { LogService } from '@mosaic/log';
|
||||
import { LOG_SERVICE } from './log.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import type { IngestLogDto, QueryLogsDto } from './log.dto.js';
|
||||
|
||||
@Controller('api/logs')
|
||||
@UseGuards(AuthGuard)
|
||||
export class LogController {
|
||||
constructor(@Inject(LOG_SERVICE) private readonly logService: LogService) {}
|
||||
|
||||
@Post()
|
||||
async ingest(@Query('userId') userId: string, @Body() dto: IngestLogDto) {
|
||||
return this.logService.logs.ingest({
|
||||
sessionId: dto.sessionId,
|
||||
userId,
|
||||
level: dto.level,
|
||||
category: dto.category,
|
||||
content: dto.content,
|
||||
metadata: dto.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('batch')
|
||||
async ingestBatch(@Query('userId') userId: string, @Body() dtos: IngestLogDto[]) {
|
||||
const entries = dtos.map((dto) => ({
|
||||
sessionId: dto.sessionId,
|
||||
userId,
|
||||
level: dto.level as 'debug' | 'info' | 'warn' | 'error' | undefined,
|
||||
category: dto.category as
|
||||
| 'decision'
|
||||
| 'tool_use'
|
||||
| 'learning'
|
||||
| 'error'
|
||||
| 'general'
|
||||
| undefined,
|
||||
content: dto.content,
|
||||
metadata: dto.metadata,
|
||||
}));
|
||||
return this.logService.logs.ingestBatch(entries);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async query(@Query('userId') userId: string, @Query() params: QueryLogsDto) {
|
||||
return this.logService.logs.query({
|
||||
userId,
|
||||
sessionId: params.sessionId,
|
||||
level: params.level,
|
||||
category: params.category,
|
||||
tier: params.tier,
|
||||
since: params.since ? new Date(params.since) : undefined,
|
||||
until: params.until ? new Date(params.until) : undefined,
|
||||
limit: params.limit ? Number(params.limit) : undefined,
|
||||
offset: params.offset ? Number(params.offset) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.logService.logs.findById(id);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
export interface IngestLogDto {
|
||||
sessionId: string;
|
||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||
category?: 'decision' | 'tool_use' | 'learning' | 'error' | 'general';
|
||||
content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QueryLogsDto {
|
||||
sessionId?: string;
|
||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||
category?: 'decision' | 'tool_use' | 'learning' | 'error' | 'general';
|
||||
tier?: 'hot' | 'warm' | 'cold';
|
||||
since?: string;
|
||||
until?: string;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { createLogService, type LogService } from '@mosaic/log';
|
||||
import type { Db } from '@mosaic/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { LOG_SERVICE } from './log.tokens.js';
|
||||
import { LogController } from './log.controller.js';
|
||||
import { SummarizationService } from './summarization.service.js';
|
||||
import { CronService } from './cron.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: LOG_SERVICE,
|
||||
useFactory: (db: Db): LogService => createLogService(db),
|
||||
inject: [DB],
|
||||
},
|
||||
SummarizationService,
|
||||
CronService,
|
||||
],
|
||||
controllers: [LogController],
|
||||
exports: [LOG_SERVICE, SummarizationService],
|
||||
})
|
||||
export class LogModule {}
|
||||
@@ -1 +0,0 @@
|
||||
export const LOG_SERVICE = 'LOG_SERVICE';
|
||||
@@ -1,178 +0,0 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { LogService } from '@mosaic/log';
|
||||
import type { Memory } from '@mosaic/memory';
|
||||
import { LOG_SERVICE } from './log.tokens.js';
|
||||
import { MEMORY } from '../memory/memory.tokens.js';
|
||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||
import type { Db } from '@mosaic/db';
|
||||
import { sql, summarizationJobs } from '@mosaic/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
const SUMMARIZATION_PROMPT = `You are a knowledge extraction assistant. Given the following agent interaction logs, extract the key decisions, learnings, and patterns. Output a concise summary (2-4 sentences) that captures the most important information for future reference. Focus on actionable insights, not raw events.
|
||||
|
||||
Logs:
|
||||
{logs}
|
||||
|
||||
Summary:`;
|
||||
|
||||
interface ChatCompletion {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SummarizationService {
|
||||
private readonly logger = new Logger(SummarizationService.name);
|
||||
private readonly apiKey: string | undefined;
|
||||
private readonly baseUrl: string;
|
||||
private readonly model: string;
|
||||
|
||||
constructor(
|
||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||
@Inject(MEMORY) private readonly memory: Memory,
|
||||
@Inject(EmbeddingService) private readonly embeddings: EmbeddingService,
|
||||
@Inject(DB) private readonly db: Db,
|
||||
) {
|
||||
this.apiKey = process.env['OPENAI_API_KEY'];
|
||||
this.baseUrl = process.env['SUMMARIZATION_API_URL'] ?? 'https://api.openai.com/v1';
|
||||
this.model = process.env['SUMMARIZATION_MODEL'] ?? 'gpt-4o-mini';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one summarization cycle:
|
||||
* 1. Find hot logs older than 24h with decision/learning/tool_use categories
|
||||
* 2. Group by session
|
||||
* 3. Summarize each group via cheap LLM
|
||||
* 4. Store as insights with embeddings
|
||||
* 5. Transition processed logs to warm tier
|
||||
*/
|
||||
async runSummarization(): Promise<{ logsProcessed: number; insightsCreated: number }> {
|
||||
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24h ago
|
||||
|
||||
// Create job record
|
||||
const [job] = await this.db
|
||||
.insert(summarizationJobs)
|
||||
.values({ status: 'running', startedAt: new Date() })
|
||||
.returning();
|
||||
|
||||
try {
|
||||
const logs = await this.logService.logs.getLogsForSummarization(cutoff, 200);
|
||||
if (logs.length === 0) {
|
||||
await this.db
|
||||
.update(summarizationJobs)
|
||||
.set({ status: 'completed', completedAt: new Date() })
|
||||
.where(sql`id = ${job!.id}`);
|
||||
return { logsProcessed: 0, insightsCreated: 0 };
|
||||
}
|
||||
|
||||
// Group logs by session
|
||||
const bySession = new Map<string, typeof logs>();
|
||||
for (const log of logs) {
|
||||
const group = bySession.get(log.sessionId) ?? [];
|
||||
group.push(log);
|
||||
bySession.set(log.sessionId, group);
|
||||
}
|
||||
|
||||
let insightsCreated = 0;
|
||||
|
||||
for (const [sessionId, sessionLogs] of bySession) {
|
||||
const userId = sessionLogs[0]?.userId;
|
||||
if (!userId) continue;
|
||||
|
||||
const logsText = sessionLogs.map((l) => `[${l.category}] ${l.content}`).join('\n');
|
||||
|
||||
const summary = await this.summarize(logsText);
|
||||
if (!summary) continue;
|
||||
|
||||
const embedding = this.embeddings.available
|
||||
? await this.embeddings.embed(summary)
|
||||
: undefined;
|
||||
|
||||
await this.memory.insights.create({
|
||||
userId,
|
||||
content: summary,
|
||||
embedding: embedding ?? null,
|
||||
source: 'summarization',
|
||||
category: 'learning',
|
||||
metadata: { sessionId, logCount: sessionLogs.length },
|
||||
});
|
||||
insightsCreated++;
|
||||
}
|
||||
|
||||
// Transition processed logs to warm
|
||||
await this.logService.logs.promoteToWarm(cutoff);
|
||||
|
||||
await this.db
|
||||
.update(summarizationJobs)
|
||||
.set({
|
||||
status: 'completed',
|
||||
logsProcessed: logs.length,
|
||||
insightsCreated,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(sql`id = ${job!.id}`);
|
||||
|
||||
this.logger.log(`Summarization complete: ${logs.length} logs → ${insightsCreated} insights`);
|
||||
return { logsProcessed: logs.length, insightsCreated };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await this.db
|
||||
.update(summarizationJobs)
|
||||
.set({ status: 'failed', errorMessage: message, completedAt: new Date() })
|
||||
.where(sql`id = ${job!.id}`);
|
||||
this.logger.error(`Summarization failed: ${message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run tier management:
|
||||
* - Warm logs older than 30 days → cold
|
||||
* - Cold logs older than 90 days → purged
|
||||
* - Decay old insight relevance scores
|
||||
*/
|
||||
async runTierManagement(): Promise<void> {
|
||||
const warmCutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const coldCutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
const decayCutoff = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const promoted = await this.logService.logs.promoteToCold(warmCutoff);
|
||||
const purged = await this.logService.logs.purge(coldCutoff);
|
||||
const decayed = await this.memory.insights.decayOldInsights(decayCutoff);
|
||||
|
||||
this.logger.log(
|
||||
`Tier management: ${promoted} logs→cold, ${purged} purged, ${decayed} insights decayed`,
|
||||
);
|
||||
}
|
||||
|
||||
private async summarize(logsText: string): Promise<string | null> {
|
||||
if (!this.apiKey) {
|
||||
this.logger.warn('No API key configured — skipping summarization');
|
||||
return null;
|
||||
}
|
||||
|
||||
const prompt = SUMMARIZATION_PROMPT.replace('{logs}', logsText);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: 300,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
this.logger.error(`Summarization API error: ${response.status} ${body}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = (await response.json()) as ChatCompletion;
|
||||
return json.choices[0]?.message.content ?? null;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
|
||||
config({ path: resolve(process.cwd(), '../../.env') });
|
||||
config(); // Also load apps/gateway/.env if present (overrides)
|
||||
|
||||
import './tracing.js';
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import helmet from '@fastify/helmet';
|
||||
import { AppModule } from './app.module.js';
|
||||
import { mountAuthHandler } from './auth/auth.controller.js';
|
||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||
import { McpService } from './mcp/mcp.service.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||
throw new Error('BETTER_AUTH_SECRET is required');
|
||||
}
|
||||
|
||||
if (
|
||||
process.env['AUTHENTIK_CLIENT_ID'] &&
|
||||
(!process.env['AUTHENTIK_CLIENT_SECRET'] || !process.env['AUTHENTIK_ISSUER'])
|
||||
) {
|
||||
console.warn(
|
||||
'[warn] AUTHENTIK_CLIENT_ID is set but AUTHENTIK_CLIENT_SECRET or AUTHENTIK_ISSUER is missing — Authentik SSO will not work',
|
||||
);
|
||||
}
|
||||
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter({ bodyLimit: 1_048_576 }),
|
||||
);
|
||||
|
||||
app.enableCors({
|
||||
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
await app.register(helmet as never, { contentSecurityPolicy: false });
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
mountAuthHandler(app);
|
||||
mountMcpHandler(app, app.get(McpService));
|
||||
|
||||
const port = Number(process.env['GATEWAY_PORT'] ?? 4000);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
logger.log(`Gateway listening on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap().catch((err: unknown) => {
|
||||
const logger = new Logger('Bootstrap');
|
||||
logger.error('Fatal startup error', err instanceof Error ? err.stack : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* DTOs for MCP client configuration and tool discovery.
|
||||
*/
|
||||
|
||||
export interface McpServerConfigDto {
|
||||
/** Unique name identifying this MCP server */
|
||||
name: string;
|
||||
/** URL of the MCP server (streamable HTTP or SSE endpoint) */
|
||||
url: string;
|
||||
/** Optional HTTP headers to send with requests (e.g., Authorization) */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface McpToolDto {
|
||||
/** Namespaced tool name: "<serverName>__<toolName>" */
|
||||
name: string;
|
||||
/** Human-readable description of the tool */
|
||||
description: string;
|
||||
/** JSON Schema for tool input parameters */
|
||||
inputSchema: Record<string, unknown>;
|
||||
/** MCP server this tool belongs to */
|
||||
serverName: string;
|
||||
/** Original tool name on the remote server */
|
||||
remoteName: string;
|
||||
}
|
||||
|
||||
export interface McpServerStatusDto {
|
||||
name: string;
|
||||
url: string;
|
||||
connected: boolean;
|
||||
toolCount: number;
|
||||
error?: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { McpClientService } from './mcp-client.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [McpClientService],
|
||||
exports: [McpClientService],
|
||||
})
|
||||
export class McpClientModule {}
|
||||
@@ -1,331 +0,0 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import type { McpServerConfigDto, McpToolDto, McpServerStatusDto } from './mcp-client.dto.js';
|
||||
|
||||
interface ConnectedServer {
|
||||
config: McpServerConfigDto;
|
||||
client: Client;
|
||||
tools: McpToolDto[];
|
||||
connected: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* McpClientService connects to external MCP servers, discovers their tools,
|
||||
* and bridges them into Pi SDK ToolDefinition format for agent sessions.
|
||||
*
|
||||
* Configuration is read from the MCP_SERVERS environment variable:
|
||||
* MCP_SERVERS='[{"name":"my-server","url":"http://localhost:3001/mcp","headers":{"Authorization":"Bearer token"}}]'
|
||||
*/
|
||||
@Injectable()
|
||||
export class McpClientService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(McpClientService.name);
|
||||
private readonly servers = new Map<string, ConnectedServer>();
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const configs = this.loadConfigs();
|
||||
if (configs.length === 0) {
|
||||
this.logger.log('No external MCP servers configured (MCP_SERVERS not set)');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Connecting to ${configs.length} external MCP server(s)`);
|
||||
await Promise.allSettled(configs.map((cfg) => this.connectServer(cfg)));
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.logger.log(`Disconnecting from ${this.servers.size} MCP server(s)`);
|
||||
const disconnects = Array.from(this.servers.values()).map((s) => this.disconnectServer(s));
|
||||
await Promise.allSettled(disconnects);
|
||||
this.servers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all bridged Pi SDK ToolDefinitions from all connected MCP servers.
|
||||
*/
|
||||
getToolDefinitions(): ToolDefinition[] {
|
||||
const tools: ToolDefinition[] = [];
|
||||
for (const server of this.servers.values()) {
|
||||
if (!server.connected) continue;
|
||||
for (const mcpTool of server.tools) {
|
||||
tools.push(this.bridgeTool(server.client, mcpTool));
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns status information for all configured MCP servers.
|
||||
*/
|
||||
getServerStatuses(): McpServerStatusDto[] {
|
||||
return Array.from(this.servers.values()).map((s) => ({
|
||||
name: s.config.name,
|
||||
url: s.config.url,
|
||||
connected: s.connected,
|
||||
toolCount: s.tools.length,
|
||||
error: s.error,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to reconnect a server that has been disconnected.
|
||||
*/
|
||||
async reconnectServer(serverName: string): Promise<void> {
|
||||
const existing = this.servers.get(serverName);
|
||||
if (!existing) {
|
||||
throw new Error(`MCP server not found: ${serverName}`);
|
||||
}
|
||||
if (existing.connected) return;
|
||||
|
||||
this.logger.log(`Reconnecting to MCP server: ${serverName}`);
|
||||
await this.connectServer(existing.config);
|
||||
}
|
||||
|
||||
// ─── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private loadConfigs(): McpServerConfigDto[] {
|
||||
const raw = process.env['MCP_SERVERS'];
|
||||
if (!raw) return [];
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
this.logger.warn('MCP_SERVERS must be a JSON array — ignoring');
|
||||
return [];
|
||||
}
|
||||
|
||||
const configs: McpServerConfigDto[] = [];
|
||||
for (const item of parsed) {
|
||||
if (
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
'name' in item &&
|
||||
typeof (item as Record<string, unknown>)['name'] === 'string' &&
|
||||
'url' in item &&
|
||||
typeof (item as Record<string, unknown>)['url'] === 'string'
|
||||
) {
|
||||
const cfg = item as McpServerConfigDto;
|
||||
configs.push({
|
||||
name: cfg.name,
|
||||
url: cfg.url,
|
||||
headers: cfg.headers,
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(`Skipping invalid MCP server config entry: ${JSON.stringify(item)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return configs;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to parse MCP_SERVERS: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async connectServer(config: McpServerConfigDto): Promise<void> {
|
||||
const serverEntry: ConnectedServer = {
|
||||
config,
|
||||
client: new Client({ name: 'mosaic-gateway', version: '1.0.0' }),
|
||||
tools: [],
|
||||
connected: false,
|
||||
};
|
||||
|
||||
// Preserve existing entry if reconnecting
|
||||
this.servers.set(config.name, serverEntry);
|
||||
|
||||
try {
|
||||
const url = new URL(config.url);
|
||||
const headers = config.headers ?? {};
|
||||
|
||||
// Attempt StreamableHTTP first, fall back to SSE
|
||||
let connected = false;
|
||||
|
||||
try {
|
||||
const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers } });
|
||||
await serverEntry.client.connect(transport);
|
||||
connected = true;
|
||||
this.logger.log(`Connected to MCP server "${config.name}" via StreamableHTTP`);
|
||||
} catch (streamErr) {
|
||||
this.logger.warn(
|
||||
`StreamableHTTP failed for "${config.name}", trying SSE: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}`,
|
||||
);
|
||||
|
||||
// Reset client for SSE attempt
|
||||
serverEntry.client = new Client({ name: 'mosaic-gateway', version: '1.0.0' });
|
||||
|
||||
try {
|
||||
const transport = new SSEClientTransport(url, { requestInit: { headers } });
|
||||
await serverEntry.client.connect(transport);
|
||||
connected = true;
|
||||
this.logger.log(`Connected to MCP server "${config.name}" via SSE`);
|
||||
} catch (sseErr) {
|
||||
throw new Error(
|
||||
`Both transports failed for "${config.name}": SSE error: ${sseErr instanceof Error ? sseErr.message : String(sseErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!connected) return;
|
||||
|
||||
// Discover tools
|
||||
const toolsResult = await serverEntry.client.listTools();
|
||||
serverEntry.tools = toolsResult.tools.map((t) => ({
|
||||
name: `${config.name}__${t.name}`,
|
||||
description: t.description ?? `Tool ${t.name} from MCP server ${config.name}`,
|
||||
inputSchema: (t.inputSchema as Record<string, unknown>) ?? {},
|
||||
serverName: config.name,
|
||||
remoteName: t.name,
|
||||
}));
|
||||
|
||||
serverEntry.connected = true;
|
||||
this.logger.log(
|
||||
`Discovered ${serverEntry.tools.length} tool(s) from MCP server "${config.name}"`,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
serverEntry.error = message;
|
||||
serverEntry.connected = false;
|
||||
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async disconnectServer(server: ConnectedServer): Promise<void> {
|
||||
try {
|
||||
await server.client.close();
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Error closing MCP client for "${server.config.name}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges a single McpToolDto into a Pi SDK ToolDefinition.
|
||||
* The MCP inputSchema is converted to a TypeBox schema representation.
|
||||
*/
|
||||
private bridgeTool(client: Client, mcpTool: McpToolDto): ToolDefinition {
|
||||
const schema = this.inputSchemaToTypeBox(mcpTool.inputSchema);
|
||||
|
||||
return {
|
||||
name: mcpTool.name,
|
||||
label: mcpTool.remoteName,
|
||||
description: mcpTool.description,
|
||||
parameters: schema,
|
||||
execute: async (_toolCallId: string, params: unknown) => {
|
||||
try {
|
||||
const result = await client.callTool({
|
||||
name: mcpTool.remoteName,
|
||||
arguments: (params as Record<string, unknown>) ?? {},
|
||||
});
|
||||
|
||||
// MCP callTool returns { content: [...], isError?: boolean }
|
||||
const content = Array.isArray(result.content) ? result.content : [];
|
||||
const textParts = content
|
||||
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
|
||||
.map((c) => c.text)
|
||||
.join('\n');
|
||||
|
||||
if (result.isError) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `MCP tool error from "${mcpTool.serverName}/${mcpTool.remoteName}": ${textParts || 'Unknown error'}`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content:
|
||||
content.length > 0
|
||||
? (content as { type: 'text'; text: string }[])
|
||||
: [{ type: 'text' as const, text: '' }],
|
||||
details: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`MCP tool call failed: ${mcpTool.serverName}/${mcpTool.remoteName}: ${message}`,
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to call MCP tool "${mcpTool.name}": ${message}`,
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a JSON Schema object to a TypeBox-compatible schema.
|
||||
* For simplicity, maps the inputSchema properties to TypeBox Type.Object.
|
||||
* Unknown/complex schemas fall back to Type.Object with Type.Unknown values.
|
||||
*/
|
||||
private inputSchemaToTypeBox(
|
||||
inputSchema: Record<string, unknown>,
|
||||
): ReturnType<typeof Type.Object> {
|
||||
const properties = inputSchema['properties'];
|
||||
|
||||
if (!properties || typeof properties !== 'object') {
|
||||
return Type.Object({});
|
||||
}
|
||||
|
||||
const required: string[] = Array.isArray(inputSchema['required'])
|
||||
? (inputSchema['required'] as string[])
|
||||
: [];
|
||||
|
||||
const tbProps: Record<string, ReturnType<typeof Type.String>> = {};
|
||||
|
||||
for (const [key, schemaDef] of Object.entries(properties as Record<string, unknown>)) {
|
||||
const def = schemaDef as Record<string, unknown>;
|
||||
const desc = typeof def['description'] === 'string' ? def['description'] : undefined;
|
||||
const isOptional = !required.includes(key);
|
||||
const base = this.jsonSchemaToTypeBox(def);
|
||||
tbProps[key] = isOptional
|
||||
? (Type.Optional(base) as unknown as ReturnType<typeof Type.String>)
|
||||
: (base as unknown as ReturnType<typeof Type.String>);
|
||||
if (desc && tbProps[key]) {
|
||||
// Attach description via metadata
|
||||
(tbProps[key] as Record<string, unknown>)['description'] = desc;
|
||||
}
|
||||
}
|
||||
|
||||
return Type.Object(tbProps as Parameters<typeof Type.Object>[0]);
|
||||
}
|
||||
|
||||
private jsonSchemaToTypeBox(
|
||||
def: Record<string, unknown>,
|
||||
):
|
||||
| ReturnType<typeof Type.String>
|
||||
| ReturnType<typeof Type.Number>
|
||||
| ReturnType<typeof Type.Boolean>
|
||||
| ReturnType<typeof Type.Unknown> {
|
||||
const type = def['type'];
|
||||
const desc = typeof def['description'] === 'string' ? { description: def['description'] } : {};
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return Type.String(desc);
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return Type.Number(desc);
|
||||
case 'boolean':
|
||||
return Type.Boolean(desc);
|
||||
default:
|
||||
return Type.Unknown(desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const MCP_CLIENT_SERVICE = 'MCP_CLIENT_SERVICE';
|
||||
@@ -1,142 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { fromNodeHeaders } from 'better-auth/node';
|
||||
import type { Auth } from '@mosaic/auth';
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import type { McpService } from './mcp.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
|
||||
/**
|
||||
* Mounts the MCP streamable HTTP transport endpoint at /mcp on the Fastify instance.
|
||||
*
|
||||
* This follows the same low-level Fastify hook pattern used by the auth controller,
|
||||
* bypassing NestJS routing to directly delegate to the MCP SDK transport handlers.
|
||||
*
|
||||
* Endpoint: POST /mcp (and GET /mcp for SSE stream reconnect)
|
||||
* Auth: Requires a valid BetterAuth session (cookie or Authorization header).
|
||||
* Session: Stateful — each initialized client gets a session ID via Mcp-Session-Id header.
|
||||
*/
|
||||
export function mountMcpHandler(app: NestFastifyApplication, mcpService: McpService): void {
|
||||
const auth = app.get<Auth>(AUTH);
|
||||
const logger = new Logger('McpController');
|
||||
const fastify = app.getHttpAdapter().getInstance();
|
||||
|
||||
fastify.addHook(
|
||||
'onRequest',
|
||||
(
|
||||
req: { raw: IncomingMessage; url: string; method: string },
|
||||
reply: { raw: ServerResponse; hijack: () => void },
|
||||
done: () => void,
|
||||
) => {
|
||||
if (!req.url.startsWith('/mcp')) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
reply.hijack();
|
||||
|
||||
handleMcpRequest(req, reply, auth, mcpService, logger).catch((err: unknown) => {
|
||||
logger.error(
|
||||
`MCP request handler error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
if (!reply.raw.headersSent) {
|
||||
reply.raw.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
if (!reply.raw.writableEnded) {
|
||||
reply.raw.end(JSON.stringify({ error: 'Internal server error' }));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleMcpRequest(
|
||||
req: { raw: IncomingMessage; url: string; method: string },
|
||||
reply: { raw: ServerResponse; hijack: () => void },
|
||||
auth: Auth,
|
||||
mcpService: McpService,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
// ─── Authentication ─────────────────────────────────────────────────────
|
||||
const headers = fromNodeHeaders(req.raw.headers);
|
||||
const result = await auth.api.getSession({ headers });
|
||||
|
||||
if (!result) {
|
||||
reply.raw.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
reply.raw.end(JSON.stringify({ error: 'Unauthorized: valid session required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = result.user.id;
|
||||
|
||||
// ─── Session routing ─────────────────────────────────────────────────────
|
||||
const sessionId = req.raw.headers['mcp-session-id'];
|
||||
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||
// Existing session request
|
||||
const transport = mcpService.getSession(sessionId);
|
||||
if (!transport) {
|
||||
logger.warn(`MCP session not found: ${sessionId}`);
|
||||
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
reply.raw.end(JSON.stringify({ error: 'Session not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
await transport.handleRequest(req.raw, reply.raw);
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Initialize new session ───────────────────────────────────────────────
|
||||
// Only POST requests can initialize a new session (must be initialize message)
|
||||
if (req.method !== 'POST') {
|
||||
reply.raw.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
reply.raw.end(
|
||||
JSON.stringify({
|
||||
error: 'New session must be established via POST with initialize message',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse body to verify this is an initialize request before creating a session
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await readRequestBody(req.raw);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Failed to parse MCP request body: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
reply.raw.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
reply.raw.end(JSON.stringify({ error: 'Invalid request body' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new session and handle this initializing request
|
||||
const { transport } = mcpService.createSession(userId);
|
||||
logger.log(`New MCP session created for user ${userId}`);
|
||||
|
||||
await transport.handleRequest(req.raw, reply.raw, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses the JSON body from a Node.js IncomingMessage.
|
||||
*/
|
||||
function readRequestBody(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
if (!raw) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* MCP (Model Context Protocol) DTOs
|
||||
*
|
||||
* Defines the data transfer objects for the MCP streamable HTTP transport.
|
||||
* See: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http
|
||||
*/
|
||||
|
||||
export interface McpToolDescriptor {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface McpServerInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
protocolVersion: string;
|
||||
tools: McpToolDescriptor[];
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { McpService } from './mcp.service.js';
|
||||
import { CoordModule } from '../coord/coord.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [CoordModule],
|
||||
providers: [McpService],
|
||||
exports: [McpService],
|
||||
})
|
||||
export class McpModule {}
|
||||
@@ -1,429 +0,0 @@
|
||||
import { Injectable, Logger, Inject, OnModuleDestroy } from '@nestjs/common';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import type { Memory } from '@mosaic/memory';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { MEMORY } from '../memory/memory.tokens.js';
|
||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||
import { CoordService } from '../coord/coord.service.js';
|
||||
|
||||
interface SessionEntry {
|
||||
server: McpServer;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class McpService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(McpService.name);
|
||||
private readonly sessions = new Map<string, SessionEntry>();
|
||||
|
||||
constructor(
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
@Inject(MEMORY) private readonly memory: Memory,
|
||||
@Inject(EmbeddingService) private readonly embeddings: EmbeddingService,
|
||||
@Inject(CoordService) private readonly coordService: CoordService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a new MCP session with its own server + transport pair.
|
||||
* Returns the transport for use by the controller.
|
||||
*/
|
||||
createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } {
|
||||
const sessionId = randomUUID();
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => sessionId,
|
||||
onsessioninitialized: (id) => {
|
||||
this.logger.log(`MCP session initialized: ${id} for user ${userId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const server = new McpServer(
|
||||
{ name: 'mosaic-gateway', version: '1.0.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
this.registerTools(server, userId);
|
||||
|
||||
transport.onclose = () => {
|
||||
this.logger.log(`MCP session closed: ${sessionId}`);
|
||||
this.sessions.delete(sessionId);
|
||||
};
|
||||
|
||||
server.connect(transport).catch((err: unknown) => {
|
||||
this.logger.error(
|
||||
`MCP server connect error for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId });
|
||||
return { sessionId, transport };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the transport for an existing session, or null if not found.
|
||||
*/
|
||||
getSession(sessionId: string): StreamableHTTPServerTransport | null {
|
||||
return this.sessions.get(sessionId)?.transport ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers all platform tools on the given McpServer instance.
|
||||
*/
|
||||
private registerTools(server: McpServer, _userId: string): void {
|
||||
// ─── Brain: Project tools ────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
'brain_list_projects',
|
||||
{
|
||||
description: 'List all projects in the brain.',
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
async () => {
|
||||
const projects = await this.brain.projects.findAll();
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'brain_get_project',
|
||||
{
|
||||
description: 'Get a project by ID.',
|
||||
inputSchema: z.object({
|
||||
id: z.string().describe('Project ID (UUID)'),
|
||||
}),
|
||||
},
|
||||
async ({ id }) => {
|
||||
const project = await this.brain.projects.findById(id);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Brain: Task tools ───────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
'brain_list_tasks',
|
||||
{
|
||||
description: 'List tasks, optionally filtered by project, mission, or status.',
|
||||
inputSchema: z.object({
|
||||
projectId: z.string().optional().describe('Filter by project ID'),
|
||||
missionId: z.string().optional().describe('Filter by mission ID'),
|
||||
status: z.string().optional().describe('Filter by status'),
|
||||
}),
|
||||
},
|
||||
async ({ projectId, missionId, status }) => {
|
||||
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
let tasks;
|
||||
if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
|
||||
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
|
||||
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
|
||||
else tasks = await this.brain.tasks.findAll();
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'brain_create_task',
|
||||
{
|
||||
description: 'Create a new task in the brain.',
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe('Task title'),
|
||||
description: z.string().optional().describe('Task description'),
|
||||
projectId: z.string().optional().describe('Project ID'),
|
||||
missionId: z.string().optional().describe('Mission ID'),
|
||||
priority: z.string().optional().describe('Priority: low, medium, high, critical'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||
const task = await this.brain.tasks.create({
|
||||
...params,
|
||||
priority: params.priority as Priority | undefined,
|
||||
});
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(task, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'brain_update_task',
|
||||
{
|
||||
description: 'Update an existing task.',
|
||||
inputSchema: z.object({
|
||||
id: z.string().describe('Task ID'),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('not-started, in-progress, blocked, done, cancelled'),
|
||||
priority: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
async ({ id, ...updates }) => {
|
||||
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||
const task = await this.brain.tasks.update(id, {
|
||||
...updates,
|
||||
status: updates.status as TaskStatus | undefined,
|
||||
priority: updates.priority as Priority | undefined,
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: task ? JSON.stringify(task, null, 2) : `Task not found: ${id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Brain: Mission tools ────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
'brain_list_missions',
|
||||
{
|
||||
description: 'List all missions, optionally filtered by project.',
|
||||
inputSchema: z.object({
|
||||
projectId: z.string().optional().describe('Filter by project ID'),
|
||||
}),
|
||||
},
|
||||
async ({ projectId }) => {
|
||||
const missions = projectId
|
||||
? await this.brain.missions.findByProject(projectId)
|
||||
: await this.brain.missions.findAll();
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'brain_list_conversations',
|
||||
{
|
||||
description: 'List conversations for a user.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
}),
|
||||
},
|
||||
async ({ userId }) => {
|
||||
const conversations = await this.brain.conversations.findAll(userId);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Memory tools ────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
'memory_search',
|
||||
{
|
||||
description:
|
||||
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID to search memory for'),
|
||||
query: z.string().describe('Natural language search query'),
|
||||
limit: z.number().optional().describe('Max results (default 5)'),
|
||||
}),
|
||||
},
|
||||
async ({ userId, query, limit }) => {
|
||||
if (!this.embeddings.available) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'Semantic search unavailable — no embedding provider configured',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const embedding = await this.embeddings.embed(query);
|
||||
const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'memory_get_preferences',
|
||||
{
|
||||
description: 'Retrieve stored preferences for a user.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
category: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Filter by category: communication, coding, workflow, appearance, general'),
|
||||
}),
|
||||
},
|
||||
async ({ userId, category }) => {
|
||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||
const prefs = category
|
||||
? await this.memory.preferences.findByUserAndCategory(userId, category as Cat)
|
||||
: await this.memory.preferences.findByUser(userId);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'memory_save_preference',
|
||||
{
|
||||
description:
|
||||
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
key: z.string().describe('Preference key'),
|
||||
value: z.string().describe('Preference value (JSON string)'),
|
||||
category: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Category: communication, coding, workflow, appearance, general'),
|
||||
}),
|
||||
},
|
||||
async ({ userId, key, value, category }) => {
|
||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch {
|
||||
parsedValue = value;
|
||||
}
|
||||
const pref = await this.memory.preferences.upsert({
|
||||
userId,
|
||||
key,
|
||||
value: parsedValue,
|
||||
category: (category as Cat) ?? 'general',
|
||||
source: 'agent',
|
||||
});
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(pref, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'memory_save_insight',
|
||||
{
|
||||
description:
|
||||
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
content: z.string().describe('The insight or knowledge to store'),
|
||||
category: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Category: decision, learning, preference, fact, pattern, general'),
|
||||
}),
|
||||
},
|
||||
async ({ userId, content, category }) => {
|
||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
|
||||
const insight = await this.memory.insights.create({
|
||||
userId,
|
||||
content,
|
||||
embedding,
|
||||
source: 'agent',
|
||||
category: (category as Cat) ?? 'learning',
|
||||
});
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Coord tools ─────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
'coord_mission_status',
|
||||
{
|
||||
description:
|
||||
'Get the current orchestration mission status including milestones, tasks, and active session.',
|
||||
inputSchema: z.object({
|
||||
projectPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Project path. Defaults to gateway working directory.'),
|
||||
}),
|
||||
},
|
||||
async ({ projectPath }) => {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const status = await this.coordService.getMissionStatus(resolvedPath);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: status ? JSON.stringify(status, null, 2) : 'No active coord mission found.',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'coord_list_tasks',
|
||||
{
|
||||
description: 'List all tasks from the orchestration TASKS.md file.',
|
||||
inputSchema: z.object({
|
||||
projectPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Project path. Defaults to gateway working directory.'),
|
||||
}),
|
||||
},
|
||||
async ({ projectPath }) => {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const tasks = await this.coordService.listTasks(resolvedPath);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'coord_task_detail',
|
||||
{
|
||||
description: 'Get detailed status for a specific orchestration task.',
|
||||
inputSchema: z.object({
|
||||
taskId: z.string().describe('Task ID (e.g. P2-005)'),
|
||||
projectPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Project path. Defaults to gateway working directory.'),
|
||||
}),
|
||||
},
|
||||
async ({ taskId, projectPath }) => {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: detail
|
||||
? JSON.stringify(detail, null, 2)
|
||||
: `Task ${taskId} not found in coord mission.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.logger.log(`Closing ${this.sessions.size} MCP sessions on shutdown`);
|
||||
const closePromises = Array.from(this.sessions.values()).map(({ transport }) =>
|
||||
transport.close().catch((err: unknown) => {
|
||||
this.logger.warn(
|
||||
`Error closing MCP transport: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
await Promise.all(closePromises);
|
||||
this.sessions.clear();
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const MCP_SERVICE = 'MCP_SERVICE';
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { EmbeddingProvider } from '@mosaic/memory';
|
||||
|
||||
const DEFAULT_MODEL = 'text-embedding-3-small';
|
||||
const DEFAULT_DIMENSIONS = 1536;
|
||||
|
||||
interface EmbeddingResponse {
|
||||
data: Array<{ embedding: number[]; index: number }>;
|
||||
model: string;
|
||||
usage: { prompt_tokens: number; total_tokens: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates embeddings via the OpenAI-compatible embeddings API.
|
||||
* Supports OpenAI, Azure OpenAI, and any provider with a compatible endpoint.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmbeddingService implements EmbeddingProvider {
|
||||
private readonly logger = new Logger(EmbeddingService.name);
|
||||
private readonly apiKey: string | undefined;
|
||||
private readonly baseUrl: string;
|
||||
private readonly model: string;
|
||||
|
||||
readonly dimensions = DEFAULT_DIMENSIONS;
|
||||
|
||||
constructor() {
|
||||
this.apiKey = process.env['OPENAI_API_KEY'];
|
||||
this.baseUrl = process.env['EMBEDDING_API_URL'] ?? 'https://api.openai.com/v1';
|
||||
this.model = process.env['EMBEDDING_MODEL'] ?? DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
get available(): boolean {
|
||||
return !!this.apiKey;
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
const results = await this.embedBatch([text]);
|
||||
return results[0]!;
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
if (!this.apiKey) {
|
||||
this.logger.warn('No OPENAI_API_KEY configured — returning zero vectors');
|
||||
return texts.map(() => new Array<number>(this.dimensions).fill(0));
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
input: texts,
|
||||
dimensions: this.dimensions,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
this.logger.error(`Embedding API error: ${response.status} ${body}`);
|
||||
throw new Error(`Embedding API returned ${response.status}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as EmbeddingResponse;
|
||||
return json.data.sort((a, b) => a.index - b.index).map((d) => d.embedding);
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Memory } from '@mosaic/memory';
|
||||
import { MEMORY } from './memory.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { EmbeddingService } from './embedding.service.js';
|
||||
import type { UpsertPreferenceDto, CreateInsightDto, SearchMemoryDto } from './memory.dto.js';
|
||||
|
||||
@Controller('api/memory')
|
||||
@UseGuards(AuthGuard)
|
||||
export class MemoryController {
|
||||
constructor(
|
||||
@Inject(MEMORY) private readonly memory: Memory,
|
||||
@Inject(EmbeddingService) private readonly embeddings: EmbeddingService,
|
||||
) {}
|
||||
|
||||
// ─── Preferences ────────────────────────────────────────────────────
|
||||
|
||||
@Get('preferences')
|
||||
async listPreferences(@CurrentUser() user: { id: string }, @Query('category') category?: string) {
|
||||
if (category) {
|
||||
return this.memory.preferences.findByUserAndCategory(
|
||||
user.id,
|
||||
category as Parameters<typeof this.memory.preferences.findByUserAndCategory>[1],
|
||||
);
|
||||
}
|
||||
return this.memory.preferences.findByUser(user.id);
|
||||
}
|
||||
|
||||
@Get('preferences/:key')
|
||||
async getPreference(@CurrentUser() user: { id: string }, @Param('key') key: string) {
|
||||
const pref = await this.memory.preferences.findByUserAndKey(user.id, key);
|
||||
if (!pref) throw new NotFoundException('Preference not found');
|
||||
return pref;
|
||||
}
|
||||
|
||||
@Post('preferences')
|
||||
async upsertPreference(@CurrentUser() user: { id: string }, @Body() dto: UpsertPreferenceDto) {
|
||||
return this.memory.preferences.upsert({
|
||||
userId: user.id,
|
||||
key: dto.key,
|
||||
value: dto.value,
|
||||
category: dto.category,
|
||||
source: dto.source,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('preferences/:key')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async removePreference(@CurrentUser() user: { id: string }, @Param('key') key: string) {
|
||||
const deleted = await this.memory.preferences.remove(user.id, key);
|
||||
if (!deleted) throw new NotFoundException('Preference not found');
|
||||
}
|
||||
|
||||
// ─── Insights ───────────────────────────────────────────────────────
|
||||
|
||||
@Get('insights')
|
||||
async listInsights(@CurrentUser() user: { id: string }, @Query('limit') limit?: string) {
|
||||
return this.memory.insights.findByUser(user.id, limit ? Number(limit) : undefined);
|
||||
}
|
||||
|
||||
@Get('insights/:id')
|
||||
async getInsight(@Param('id') id: string) {
|
||||
const insight = await this.memory.insights.findById(id);
|
||||
if (!insight) throw new NotFoundException('Insight not found');
|
||||
return insight;
|
||||
}
|
||||
|
||||
@Post('insights')
|
||||
async createInsight(@CurrentUser() user: { id: string }, @Body() dto: CreateInsightDto) {
|
||||
const embedding = this.embeddings.available
|
||||
? await this.embeddings.embed(dto.content)
|
||||
: undefined;
|
||||
|
||||
return this.memory.insights.create({
|
||||
userId: user.id,
|
||||
content: dto.content,
|
||||
source: dto.source,
|
||||
category: dto.category,
|
||||
metadata: dto.metadata,
|
||||
embedding: embedding ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('insights/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async removeInsight(@Param('id') id: string) {
|
||||
const deleted = await this.memory.insights.remove(id);
|
||||
if (!deleted) throw new NotFoundException('Insight not found');
|
||||
}
|
||||
|
||||
// ─── Search ─────────────────────────────────────────────────────────
|
||||
|
||||
@Post('search')
|
||||
async searchMemory(@CurrentUser() user: { id: string }, @Body() dto: SearchMemoryDto) {
|
||||
if (!this.embeddings.available) {
|
||||
return {
|
||||
query: dto.query,
|
||||
results: [],
|
||||
message: 'Semantic search requires OPENAI_API_KEY for embeddings',
|
||||
};
|
||||
}
|
||||
|
||||
const queryEmbedding = await this.embeddings.embed(dto.query);
|
||||
const results = await this.memory.insights.searchByEmbedding(
|
||||
user.id,
|
||||
queryEmbedding,
|
||||
dto.limit ?? 10,
|
||||
dto.maxDistance ?? 0.8,
|
||||
);
|
||||
|
||||
return { query: dto.query, results };
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user