Compare commits

..
Author SHA1 Message Date
jason.woltje 7d04874f3c chore(orchestrator): complete Phase 6 milestone v0.0.7 (#105)
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-15 01:07:14 +00:00
jason.woltje 9f036242fa feat(cli): add prdy, quality-rails, and wizard subcommands (#104)
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-15 01:05:31 +00:00
jason.woltje c4e52085e3 feat(mosaic): migrate install wizard from v0 to v1 (#103)
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-15 00:59:42 +00:00
jason.woltje 84e1868028 fix(gateway): resolve two startup bugs blocking E2E testing (#102)
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-15 00:45:28 +00:00
jason.woltje f94f9f672b feat(prdy): migrate @mosaic/prdy from v0 to v1 (#101)
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-15 00:44:02 +00:00
jason.woltje cd29fc8708 feat(quality-rails): migrate @mosaic/quality-rails from v0 to v1 (#100)
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-15 00:23:56 +00:00
jason.woltjeandClaude Opus 4.6 6e22c0fdeb chore(orchestrator): complete Phase 5 milestone — v0.0.6
ci/woodpecker/push/ci Pipeline was successful
- P5-005 done: Telegram plugin wired, .env.example updated
- PR #99 merged, issue #45 closed
- Phase 5 complete, advancing to Phase 6

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-14 19:06:23 -05:00
jason.woltje 1f4d54e474 fix(gateway): wire Telegram plugin into gateway plugin host (#99)
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-15 00:05:27 +00:00
jason.woltje b7a39b45d7 chore(tasks): mark P5-004 done
ci/woodpecker/push/ci Pipeline was successful
2026-03-13 15:16:13 -05:00
jason.woltje 1bfdc91f90 Merge pull request 'feat(auth): P5-004 Authentik OIDC adapter via Better Auth genericOAuth' (#97) from feat/p5-sso-authentik into main
ci/woodpecker/push/ci Pipeline failed
2026-03-13 20:15:50 +00:00
jason.woltje 58a90ac9d7 Merge pull request 'fix(gateway): ownership checks for TasksController findAll/create + MissionsController create' (#98) from fix/task-mission-ownership into main
ci/woodpecker/push/ci Pipeline failed
2026-03-13 20:15:46 +00:00
jason.woltje 684dbdc6a4 fix(gateway): enforce task and mission ownership
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-03-13 14:43:33 -05:00
jason.woltje e92de12cf9 feat(auth): add Authentik OIDC adapter
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Refs #96
2026-03-13 14:42:05 -05:00
jason.woltje 1f784a6a04 chore(tasks): mark P5-001, P5-003 done; P5-004 in-progress
ci/woodpecker/push/ci Pipeline was successful
2026-03-13 14:33:16 -05:00
jason.woltje ab37c2e69f Merge pull request 'fix(ci): sequential steps + single install to prevent OOM on runner' (#95) from fix/ci-sequential into main
ci/woodpecker/push/ci Pipeline was successful
2026-03-13 18:13:21 +00:00
jason.woltje c8f3e0db44 fix(ci): sequential steps + single install to prevent OOM on runner
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Each step was re-running pnpm install independently, and all quality
steps (typecheck, lint, format, test) ran in parallel. On merge commits
with more accumulated code this pushed the CI runner over its memory
limit (exit code 254 = OOM kill).

Fix:
- install once, share node_modules via Woodpecker workspace volume
- sequential execution: install → typecheck → lint → format → test → build
- corepack enable in each step (fresh container) but no redundant install
2026-03-13 13:10:30 -05:00
jason.woltje 02772a3910 Merge pull request 'fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting' (#85) from fix/gateway-security into main
ci/woodpecker/push/ci Pipeline failed
2026-03-13 18:07:01 +00:00
jason.woltje 85a25fd995 fix: add plugin paths to tsconfig.typecheck.json for merged PluginModule
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-13 13:03:59 -05:00
jason.woltje 20f302367c chore(gateway): align typecheck paths after rebase 2026-03-13 13:03:09 -05:00
jason.woltje 54c6bfded0 fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting 2026-03-13 13:03:09 -05:00
jason.woltje ca5472bc31 chore: format docs files 2026-03-13 13:03:09 -05:00
jason.woltje 55b5a31c3c fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting 2026-03-13 13:03:09 -05:00
jason.woltje 01e9891243 Merge pull request 'feat(plugins): P5-003 Telegram channel plugin' (#93) from feat/p5-telegram-plugin into main
ci/woodpecker/push/ci Pipeline failed
2026-03-13 17:48:01 +00:00
jason.woltje 446a424c1f Merge pull request 'feat(gateway): P5-001 plugin host module' (#92) from feat/p5-plugin-host into main
ci/woodpecker/push/ci Pipeline failed
2026-03-13 17:47:59 +00:00
jason.woltje 02a0d515d9 fix(turbo): typecheck must depend on ^build so package types are available
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-13 12:38:55 -05:00
jason.woltje 2bf3816efc fix(turbo): typecheck must depend on ^build so package types are available
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-13 12:38:54 -05:00
jason.woltje 96902bab44 feat(plugins): add Telegram channel plugin
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
2026-03-13 12:05:42 -05:00
jason.woltje 280c5351e2 feat(gateway): add plugin host module
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline was successful
2026-03-13 12:04:42 -05:00
jason.woltje 9eb48e1d9b feat(Phase 4): Memory & Intelligence — memory, log, summarization, skills (#91)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:56:50 +00:00
jason.woltje d83ebe65e9 verify(P3-008): Phase 3 web dashboard verification (#90)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:37:43 +00:00
jason.woltje 4fe7d09e5c feat(web): admin panel with session management (#89)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:33:45 +00:00
jason.woltje e44cb7e56a feat(web): settings page with profile, providers, and models (#88)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:31:51 +00:00
jason.woltje fd4b7c2ba2 feat(web): project list and mission dashboard views (#87)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:30:11 +00:00
jason.woltje a1a1976b38 feat(web): task management with list view and kanban board (#86)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:28:17 +00:00
jason.woltje f0d1d4bafa feat(web): chat UI with conversations and WebSocket streaming (#84)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:25:28 +00:00
jason.woltje 600da70960 feat(web): wire auth pages with BetterAuth and route guards (#83)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:21:33 +00:00
jason.woltje 780f85e0d6 feat(web): scaffold Next.js 16 dashboard with design system and auth client (#82)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 13:18:09 +00:00
jason.woltjeandClaude Opus 4.6 5d936d58a0 fix: add missing @Inject() decorators and fix coord workspace root detection
- Add @Inject() to all gateway constructor params (required without emitDecoratorMetadata)
  - AgentService: ProviderService, CoordService
  - RoutingService: ProviderService
  - ProvidersController: ProviderService, RoutingService
  - SessionsController: AgentService
- Fix coord controller ALLOWED_ROOTS to walk up to monorepo root (pnpm-workspace.yaml)
- Gateway now boots and serves all routes correctly

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-13 08:00:57 -05:00
jason.woltje 8da2759fec fix: coord review remediations (path traversal, JSON parse, race condition) (#81)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:43:49 +00:00
jason.woltjeandClaude Opus 4.6 b03c603759 docs: mark Phase 2 complete — update manifest and scratchpad
Phase 0-2 all done. P2-007 closed via PR #79. Mission advances to Phase 3.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 22:40:59 -05:00
jason.woltje 77da12a5ee test: verify Phase 2 — routing + coord tests (P2-007) (#79)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:40:07 +00:00
jason.woltje 7f6dc43a2d feat: agent session management — metrics, channels, dispose (P2-006) (#78)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:35:59 +00:00
jason.woltje f3a7eadcea feat: @mosaic/coord — migrate from v0, gateway integration (P2-005) (#77)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:32:20 +00:00
jason.woltje 7f6815feaf feat: tool registration — brain tools for agent sessions (P2-004) (#76)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:18:45 +00:00
jason.woltje 7485f32e69 feat: agent routing engine — cost/capability matrix (P2-003) (#75)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:13:50 +00:00
jason.woltje 95f95f54cf feat: multi-provider support — Anthropic + Ollama (P2-002) (#74)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:10:51 +00:00
jason.woltje aa9ee75a2a fix: auth handler + circular imports — Phase 1 verification (P1-009) (#73)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 03:02:02 +00:00
jason.woltje c54b69f7ce feat: gateway CRUD routes — conversations, projects, missions, tasks (P1-005/006) (#72)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:41:03 +00:00
jason.woltje 38897fe423 feat: auth middleware, brain data layer, Valkey queue (P1-002/003/004) (#71)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:37:56 +00:00
jason.woltje cbac5902db fix: Phase 0 verification — CI gates green (P0-009) (#70)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:28:38 +00:00
jason.woltje 4cd5cbf893 feat: Woodpecker CI pipeline + project docs (P0-007, P0-008) (#69)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:25:31 +00:00
jason.woltje 6e3cccc812 feat(auth): @mosaic/auth — BetterAuth email/password setup (#68)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:21:15 +00:00
jason.woltje 2b1723e898 feat(db): @mosaic/db — Drizzle schema, PG connection, migrations (#67)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:17:18 +00:00
jason.woltje 573484c83e fix: Jaeger image tag + remap PG/Valkey ports (#66)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 02:06:00 +00:00
jason.woltjeandClaude Opus 4.6 bf2cb3a271 docs: mark P0-002, P0-005, P0-006 done (PR #65)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:56:16 -05:00
jason.woltje 35e4e2e527 feat: foundation — Docker Compose, OTEL, shared types (#65)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 01:55:33 +00:00
jason.woltjeandClaude Opus 4.6 f6f05cf23a docs: add FIX-03 agent session sandboxing task (#64)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:49:05 -05:00
jason.woltjeandClaude Opus 4.6 15352448d5 fix: switch gateway to ESM + explicit @Inject for tsx compatibility
Pi SDK is ESM-only. tsx (esbuild) doesn't emit decorator metadata,
so NestJS constructor injection fails without explicit @Inject().

- Set "type": "module" in gateway package.json
- Switch tsconfig to NodeNext module resolution
- Add @Inject(AgentService) to ChatController and ChatGateway

Tested end-to-end: REST /api/chat → Pi SDK → Anthropic → response OK.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:43:08 -05:00
jason.woltjeandClaude Opus 4.6 c58003e5e3 docs: add gatekeeper review follow-up tasks (FIX-01, FIX-02)
- #62: piSession.dispose() missing in destroySession
- #63: React anti-pattern in TUI agent:end handler

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:36:17 -05:00
jason.woltjeandClaude Opus 4.6 04528459ec docs: update TASKS.md and scratchpad for communication spine completion
Mark P1-001, P1-007, P1-008, P2-001, P5-002, P6-005 as done (PR #61).
Add session 2 log entry for vertical slice delivery.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:34:26 -05:00
jason.woltje 4f84a01072 feat: communication spine — gateway, TUI, Discord (#61)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 01:33:32 +00:00
jason.woltjeandClaude Opus 4.6 888bc32be1 docs: mark P0-001 scaffold monorepo as done
PR #60 merged, issue #1 closed.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:12:44 -05:00
jason.woltje 6d0d288e31 feat(P0-001): scaffold monorepo structure (#60)
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-03-13 01:11:46 +00:00
jason.woltjeandClaude Opus 4.6 339641352e docs: record vertical slice reorder in scratchpad
Jason directed: build Pi TUI → Gateway → Discord communication
spine before backfilling horizontal layers.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 20:06:17 -05:00
jason.woltjeandClaude Opus 4.6 e7f338e3a9 chore: planning gate — milestones, issues, and task breakdown
Break PRD into 8 milestones (Phase 0–7) with 59 issues on Gitea.
Populate TASKS.md, update mission manifest, initialize scratchpad.
Repo created at git.mosaicstack.dev/mosaic/mosaic-stack.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 19:51:51 -05:00
323 changed files with 28006 additions and 3042 deletions
+30 -20
View File
@@ -1,24 +1,34 @@
# 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
# Database (port 5433 avoids conflict with host PostgreSQL)
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
# Model provider (built-in pi provider name)
PI_PROVIDER=zai
# Valkey (Redis-compatible, port 6380 avoids conflict with host Redis/Valkey)
VALKEY_URL=redis://localhost:6380
# Model ID within the provider
PI_MODEL=glm-5.3-flash
# Docker Compose host port overrides (optional)
# PG_HOST_PORT=5433
# VALKEY_HOST_PORT=6380
# 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
# OpenTelemetry
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_SERVICE_NAME=mosaic-gateway
# Optional: documented env-var auth alternative (secret! set in your
# shell or a gitignored .env, never commit)
#ZAI_API_KEY=
#ANTHROPIC_API_KEY=
# Auth (BetterAuth)
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
BETTER_AUTH_URL=http://localhost:4000
# Gateway
GATEWAY_PORT=4000
# 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
# AUTHENTIK_CLIENT_ID=
# AUTHENTIK_CLIENT_SECRET=
+10 -7
View File
@@ -1,8 +1,11 @@
# build/deps
node_modules/
# runtime credentials — never commit, never copy into the image
logs/
node_modules
dist
.turbo
.next
coverage
.env
secrets/
# generated runtime state lives in /home/jwoltje/.mosaic-dev (outside this project)
.env.local
*.tsbuildinfo
.pnpm-store
docs/reports/
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
pnpm typecheck && pnpm lint && pnpm format:check
+9
View File
@@ -0,0 +1,9 @@
{
"**/*.{ts,tsx,js,jsx}": [
"prettier --write",
"eslint --fix"
],
"**/*.{json,md,yaml,yml}": [
"prettier --write"
]
}
+78
View File
@@ -0,0 +1,78 @@
# 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
```
+18
View File
@@ -0,0 +1,18 @@
{
"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
View File
@@ -0,0 +1 @@
+4
View File
@@ -0,0 +1,4 @@
{
"last_published_line": 0,
"since": null
}
+14
View File
@@ -0,0 +1,14 @@
{
"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": []
}
+19
View File
@@ -0,0 +1,19 @@
{
"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
View File
@@ -0,0 +1 @@
+8
View File
@@ -0,0 +1,8 @@
{
"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": ""
}
+4
View File
@@ -0,0 +1,4 @@
{
"running_task_id": null,
"updated_at": null
}
+3
View File
@@ -0,0 +1,3 @@
{
"tasks": []
}
+10
View File
@@ -0,0 +1,10 @@
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>
+17
View File
@@ -0,0 +1,17 @@
#!/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
View File
@@ -0,0 +1 @@
@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
+6
View File
@@ -0,0 +1,6 @@
pnpm-lock.yaml
**/next-env.d.ts
**/dist
**/node_modules
**/drizzle
**/.next
+6
View File
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"printWidth": 100
}
+57
View File
@@ -0,0 +1,57 @@
variables:
- &node_image 'node:22-alpine'
- &enable_pnpm 'corepack enable'
when:
- event: [push, pull_request, manual]
# Steps run sequentially to avoid OOM on the CI runner.
# node_modules is installed once by the install step and shared across
# all subsequent steps via Woodpecker's shared workspace volume.
steps:
install:
image: *node_image
commands:
- corepack enable
- pnpm install --frozen-lockfile
typecheck:
image: *node_image
commands:
- *enable_pnpm
- pnpm typecheck
depends_on:
- install
lint:
image: *node_image
commands:
- *enable_pnpm
- pnpm lint
depends_on:
- typecheck
format:
image: *node_image
commands:
- *enable_pnpm
- pnpm format:check
depends_on:
- lint
test:
image: *node_image
commands:
- *enable_pnpm
- pnpm test
depends_on:
- format
build:
image: *node_image
commands:
- *enable_pnpm
- pnpm build
depends_on:
- test
+55
View File
@@ -0,0 +1,55 @@
# 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
-371
View File
@@ -1,371 +0,0 @@
# 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.
-109
View File
@@ -1,109 +0,0 @@
# 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 27 of the required proof path plus acceptance criteria 511.
- 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.
+45
View File
@@ -0,0 +1,45 @@
# 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
-40
View File
@@ -1,40 +0,0 @@
# 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) and runtime scripts.
COPY contracts /opt/mosaic/contracts
COPY src /opt/mosaic/src
RUN chmod 0555 /opt/mosaic/contracts /opt/mosaic/contracts/* \
&& chmod 0555 /opt/mosaic/src /opt/mosaic/src/*.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"]
-50
View File
@@ -1,50 +0,0 @@
# 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.
-85
View File
@@ -1,85 +0,0 @@
# 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 this experiment
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)
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 (provider, model)
contracts/ CONSTITUTION.md, STANDARDS.md, SOUL.md, USER.md (immutable fixtures)
scripts/ build.sh, hello.sh, verify.sh, reset.sh (+ shared common.sh)
src/ load-contracts.sh, run-agent.sh (run inside the container)
```
Inside the container:
```text
/opt/mosaic/contracts immutable contract files
/var/lib/mosaic generated runtime state (mounted from /home/jwoltje/.mosaic-dev)
/workspace agent workspace
```
## How it works
1. `scripts/build.sh` builds `mosaic-poc-agent:0.84.4` 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/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/reset.sh # delete /home/jwoltje/.mosaic-dev (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.
+59
View File
@@ -0,0 +1,59 @@
{
"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",
"@mosaic/auth": "workspace:^",
"@mosaic/brain": "workspace:^",
"@mosaic/coord": "workspace:^",
"@mosaic/db": "workspace:^",
"@mosaic/discord-plugin": "workspace:^",
"@mosaic/telegram-plugin": "workspace:^",
"@mosaic/log": "workspace:^",
"@mosaic/memory": "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",
"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"
},
"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"
}
}
@@ -0,0 +1,137 @@
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' },
]);
});
});
@@ -0,0 +1,138 @@
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');
});
});
+16
View File
@@ -0,0 +1,16 @@
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 { ProvidersController } from './providers.controller.js';
import { SessionsController } from './sessions.controller.js';
import { CoordModule } from '../coord/coord.module.js';
@Global()
@Module({
imports: [CoordModule],
providers: [ProviderService, RoutingService, AgentService],
controllers: [ProvidersController, SessionsController],
exports: [AgentService, ProviderService, RoutingService],
})
export class AgentModule {}
+259
View File
@@ -0,0 +1,259 @@
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
import {
createAgentSession,
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 { createBrainTools } from './tools/brain-tools.js';
import { createCoordTools } from './tools/coord-tools.js';
import { createMemoryTools } from './tools/memory-tools.js';
import type { SessionInfoDto } from './session.dto.js';
export interface AgentSessionOptions {
provider?: string;
modelId?: string;
}
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>;
}
@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>>();
private readonly customTools: ToolDefinition[];
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,
) {
this.customTools = [
...createBrainTools(brain),
...createCoordTools(coordService),
...createMemoryTools(memory, embeddingService.available ? embeddingService : null),
];
this.logger.log(`Registered ${this.customTools.length} custom tools`);
}
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';
this.logger.log(
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId})`,
);
let piSession: PiAgentSession;
try {
const result = await createAgentSession({
sessionManager: SessionManager.inMemory(),
modelRegistry: this.providerService.getRegistry(),
model: model ?? undefined,
tools: [],
customTools: this.customTools,
});
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(),
};
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));
}
}
}
}
+139
View File
@@ -0,0 +1,139 @@
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';
@Injectable()
export class ProviderService implements OnModuleInit {
private readonly logger = new Logger(ProviderService.name);
private registry!: ModelRegistry;
async onModuleInit(): Promise<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));
}
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.registerCustomProvider({
id: 'ollama',
name: 'Ollama',
baseUrl: `${ollamaUrl}/v1`,
models: modelIds.map((id) => ({
id,
name: id,
reasoning: false,
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,
};
}
}
@@ -0,0 +1,34 @@
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';
@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('route')
route(@Body() criteria: RoutingCriteria) {
return this.routingService.route(criteria);
}
@Post('rank')
rank(@Body() criteria: RoutingCriteria) {
return this.routingService.rank(criteria);
}
}
+165
View File
@@ -0,0 +1,165 @@
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}`,
};
}
}
+14
View File
@@ -0,0 +1,14 @@
export interface SessionInfoDto {
id: string;
provider: string;
modelId: string;
createdAt: string;
promptCount: number;
channels: string[];
durationMs: number;
}
export interface SessionListDto {
sessions: SessionInfoDto[];
total: number;
}
@@ -0,0 +1,40 @@
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);
}
}
+184
View File
@@ -0,0 +1,184 @@
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,
];
}
@@ -0,0 +1,81 @@
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];
}
+2
View File
@@ -0,0 +1,2 @@
export { createBrainTools } from './brain-tools.js';
export { createCoordTools } from './coord-tools.js';
@@ -0,0 +1,158 @@
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];
}
+46
View File
@@ -0,0 +1,46 @@
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 { 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,
],
controllers: [HealthController],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}
+46
View File
@@ -0,0 +1,46 @@
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 fastify = app.getHttpAdapter().getInstance();
// Use Fastify's addHook to intercept auth requests at the raw HTTP level,
// before Fastify's body parser runs. This avoids conflicts with NestJS's
// custom content-type parser.
fastify.addHook(
'onRequest',
(
req: { raw: IncomingMessage; url: string },
reply: { raw: ServerResponse; hijack: () => void },
done: () => void,
) => {
if (!req.url.startsWith('/api/auth/')) {
done();
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);
});
},
);
}
+32
View File
@@ -0,0 +1,32 @@
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;
}
}
+23
View File
@@ -0,0 +1,23 @@
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
View File
@@ -0,0 +1 @@
export const AUTH = 'AUTH';
@@ -0,0 +1,7 @@
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;
});
@@ -0,0 +1,11 @@
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`);
}
}
+18
View File
@@ -0,0 +1,18 @@
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
View File
@@ -0,0 +1 @@
export const BRAIN = 'BRAIN';
@@ -0,0 +1,80 @@
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);
});
});
+90
View File
@@ -0,0 +1,90 @@
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 };
}
}
+31
View File
@@ -0,0 +1,31 @@
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;
}
@@ -0,0 +1,30 @@
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 },
};
}
+184
View File
@@ -0,0 +1,184 @@
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;
}
}
}
+9
View File
@@ -0,0 +1,9 @@
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 {}
@@ -0,0 +1,97 @@
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;
}
}
@@ -0,0 +1,36 @@
import { 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;
}
export class SendMessageDto {
@IsIn(['user', 'assistant', 'system'])
role!: 'user' | 'assistant' | 'system';
@IsString()
@MaxLength(10_000)
content!: string;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ConversationsController } from './conversations.controller.js';
@Module({
controllers: [ConversationsController],
})
export class ConversationsModule {}
@@ -0,0 +1,73 @@
import {
BadRequestException,
Controller,
Get,
Inject,
NotFoundException,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import fs from 'node:fs';
import path from 'node:path';
import { AuthGuard } from '../auth/auth.guard.js';
import { CoordService } from './coord.service.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) {}
@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;
}
}
+49
View File
@@ -0,0 +1,49 @@
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;
};
}
+10
View File
@@ -0,0 +1,10 @@
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 {}
+73
View File
@@ -0,0 +1,73 @@
import { Injectable, Logger } from '@nestjs/common';
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);
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 [];
}
}
}
@@ -0,0 +1,28 @@
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();
}
}
@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check(): { status: string } {
return { status: 'ok' };
}
}
+44
View File
@@ -0,0 +1,44 @@
import { 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(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');
}
}
+62
View File
@@ -0,0 +1,62 @@
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);
}
}
+18
View File
@@ -0,0 +1,18 @@
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;
}
+24
View File
@@ -0,0 +1,24 @@
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
View File
@@ -0,0 +1 @@
export const LOG_SERVICE = 'LOG_SERVICE';
@@ -0,0 +1,178 @@
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,
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;
}
}
+50
View File
@@ -0,0 +1,50 @@
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';
async function bootstrap(): Promise<void> {
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 logger = new Logger('Bootstrap');
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ bodyLimit: 1_048_576 }),
);
await app.register(helmet as never, { contentSecurityPolicy: false });
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
mountAuthHandler(app);
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);
});
@@ -0,0 +1,69 @@
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);
}
}
@@ -0,0 +1,126 @@
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 { 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,
private readonly embeddings: EmbeddingService,
) {}
// ─── Preferences ────────────────────────────────────────────────────
@Get('preferences')
async listPreferences(@Query('userId') userId: string, @Query('category') category?: string) {
if (category) {
return this.memory.preferences.findByUserAndCategory(
userId,
category as Parameters<typeof this.memory.preferences.findByUserAndCategory>[1],
);
}
return this.memory.preferences.findByUser(userId);
}
@Get('preferences/:key')
async getPreference(@Query('userId') userId: string, @Param('key') key: string) {
const pref = await this.memory.preferences.findByUserAndKey(userId, key);
if (!pref) throw new NotFoundException('Preference not found');
return pref;
}
@Post('preferences')
async upsertPreference(@Query('userId') userId: string, @Body() dto: UpsertPreferenceDto) {
return this.memory.preferences.upsert({
userId,
key: dto.key,
value: dto.value,
category: dto.category,
source: dto.source,
});
}
@Delete('preferences/:key')
@HttpCode(HttpStatus.NO_CONTENT)
async removePreference(@Query('userId') userId: string, @Param('key') key: string) {
const deleted = await this.memory.preferences.remove(userId, key);
if (!deleted) throw new NotFoundException('Preference not found');
}
// ─── Insights ───────────────────────────────────────────────────────
@Get('insights')
async listInsights(@Query('userId') userId: string, @Query('limit') limit?: string) {
return this.memory.insights.findByUser(userId, 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(@Query('userId') userId: string, @Body() dto: CreateInsightDto) {
const embedding = this.embeddings.available
? await this.embeddings.embed(dto.content)
: undefined;
return this.memory.insights.create({
userId,
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(@Query('userId') userId: 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(
userId,
queryEmbedding,
dto.limit ?? 10,
dto.maxDistance ?? 0.8,
);
return { query: dto.query, results };
}
}
+19
View File
@@ -0,0 +1,19 @@
export interface UpsertPreferenceDto {
key: string;
value: unknown;
category?: 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
source?: string;
}
export interface CreateInsightDto {
content: string;
source?: 'agent' | 'user' | 'summarization' | 'system';
category?: 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
metadata?: Record<string, unknown>;
}
export interface SearchMemoryDto {
query: string;
limit?: number;
maxDistance?: number;
}
+22
View File
@@ -0,0 +1,22 @@
import { Global, Module } from '@nestjs/common';
import { createMemory, type Memory } from '@mosaic/memory';
import type { Db } from '@mosaic/db';
import { DB } from '../database/database.module.js';
import { MEMORY } from './memory.tokens.js';
import { MemoryController } from './memory.controller.js';
import { EmbeddingService } from './embedding.service.js';
@Global()
@Module({
providers: [
{
provide: MEMORY,
useFactory: (db: Db): Memory => createMemory(db),
inject: [DB],
},
EmbeddingService,
],
controllers: [MemoryController],
exports: [MEMORY, EmbeddingService],
})
export class MemoryModule {}
+1
View File
@@ -0,0 +1 @@
export const MEMORY = 'MEMORY';
@@ -0,0 +1,98 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
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 { CreateMissionDto, UpdateMissionDto } from './missions.dto.js';
@Controller('api/missions')
@UseGuards(AuthGuard)
export class MissionsController {
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
@Get()
async list() {
return this.brain.missions.findAll();
}
@Get(':id')
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.getOwnedMission(id, user.id);
}
@Post()
async create(@Body() dto: CreateMissionDto, @CurrentUser() user: { id: string }) {
if (dto.projectId) {
await this.getOwnedProject(dto.projectId, user.id, 'Mission');
}
return this.brain.missions.create({
name: dto.name,
description: dto.description,
projectId: dto.projectId,
status: dto.status,
});
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateMissionDto,
@CurrentUser() user: { id: string },
) {
await this.getOwnedMission(id, user.id);
if (dto.projectId) {
await this.getOwnedProject(dto.projectId, user.id, 'Mission');
}
const mission = await this.brain.missions.update(id, dto);
if (!mission) throw new NotFoundException('Mission not found');
return mission;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
await this.getOwnedMission(id, user.id);
const deleted = await this.brain.missions.remove(id);
if (!deleted) throw new NotFoundException('Mission not found');
}
private async getOwnedMission(id: string, userId: string) {
const mission = await this.brain.missions.findById(id);
if (!mission) throw new NotFoundException('Mission not found');
await this.getOwnedProject(mission.projectId, userId, 'Mission');
return mission;
}
private async getOwnedProject(
projectId: string | null | undefined,
userId: string,
resourceName: string,
) {
if (!projectId) {
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
}
const project = await this.brain.projects.findById(projectId);
if (!project) {
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
}
assertOwner(project.ownerId, userId, resourceName);
return project;
}
}
+46
View File
@@ -0,0 +1,46 @@
import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
const missionStatuses = ['planning', 'active', 'paused', 'completed', 'failed'] as const;
export class CreateMissionDto {
@IsString()
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string;
@IsOptional()
@IsUUID()
projectId?: string;
@IsOptional()
@IsIn(missionStatuses)
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
}
export class UpdateMissionDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string | null;
@IsOptional()
@IsUUID()
projectId?: string | null;
@IsOptional()
@IsIn(missionStatuses)
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
@IsOptional()
@IsObject()
metadata?: Record<string, unknown> | null;
}
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { MissionsController } from './missions.controller.js';
@Module({
controllers: [MissionsController],
})
export class MissionsModule {}
@@ -0,0 +1,5 @@
export interface IChannelPlugin {
readonly name: string;
start(): Promise<void>;
stop(): Promise<void>;
}
+109
View File
@@ -0,0 +1,109 @@
import {
Global,
Inject,
Logger,
Module,
type OnModuleDestroy,
type OnModuleInit,
} from '@nestjs/common';
import { DiscordPlugin } from '@mosaic/discord-plugin';
import { TelegramPlugin } from '@mosaic/telegram-plugin';
import { PluginService } from './plugin.service.js';
import type { IChannelPlugin } from './plugin.interface.js';
import { PLUGIN_REGISTRY } from './plugin.tokens.js';
class DiscordChannelPluginAdapter implements IChannelPlugin {
readonly name = 'discord';
constructor(private readonly plugin: DiscordPlugin) {}
async start(): Promise<void> {
await this.plugin.start();
}
async stop(): Promise<void> {
await this.plugin.stop();
}
}
class TelegramChannelPluginAdapter implements IChannelPlugin {
readonly name = 'telegram';
constructor(private readonly plugin: TelegramPlugin) {}
async start(): Promise<void> {
await this.plugin.start();
}
async stop(): Promise<void> {
await this.plugin.stop();
}
}
const DEFAULT_GATEWAY_URL = 'http://localhost:4000';
function createPluginRegistry(): IChannelPlugin[] {
const plugins: IChannelPlugin[] = [];
const discordToken = process.env['DISCORD_BOT_TOKEN'];
const discordGuildId = process.env['DISCORD_GUILD_ID'];
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
if (discordToken) {
plugins.push(
new DiscordChannelPluginAdapter(
new DiscordPlugin({
token: discordToken,
guildId: discordGuildId,
gatewayUrl: discordGatewayUrl,
}),
),
);
}
const telegramToken = process.env['TELEGRAM_BOT_TOKEN'];
const telegramGatewayUrl = process.env['TELEGRAM_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
if (telegramToken) {
plugins.push(
new TelegramChannelPluginAdapter(
new TelegramPlugin({
token: telegramToken,
gatewayUrl: telegramGatewayUrl,
}),
),
);
}
return plugins;
}
@Global()
@Module({
providers: [
{
provide: PLUGIN_REGISTRY,
useFactory: (): IChannelPlugin[] => createPluginRegistry(),
},
PluginService,
],
exports: [PluginService, PLUGIN_REGISTRY],
})
export class PluginModule implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PluginModule.name);
constructor(@Inject(PLUGIN_REGISTRY) private readonly plugins: IChannelPlugin[]) {}
async onModuleInit(): Promise<void> {
for (const plugin of this.plugins) {
this.logger.log(`Starting plugin: ${plugin.name}`);
await plugin.start();
}
}
async onModuleDestroy(): Promise<void> {
for (const plugin of [...this.plugins].reverse()) {
this.logger.log(`Stopping plugin: ${plugin.name}`);
await plugin.stop();
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Inject, Injectable } from '@nestjs/common';
import { PLUGIN_REGISTRY } from './plugin.tokens.js';
import type { IChannelPlugin } from './plugin.interface.js';
@Injectable()
export class PluginService {
constructor(@Inject(PLUGIN_REGISTRY) private readonly plugins: IChannelPlugin[]) {}
getPlugins(): IChannelPlugin[] {
return this.plugins;
}
getPlugin(name: string): IChannelPlugin | undefined {
return this.plugins.find((plugin: IChannelPlugin) => plugin.name === name);
}
}
+1
View File
@@ -0,0 +1 @@
export const PLUGIN_REGISTRY = Symbol('PLUGIN_REGISTRY');
@@ -0,0 +1,73 @@
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 { CreateProjectDto, UpdateProjectDto } from './projects.dto.js';
@Controller('api/projects')
@UseGuards(AuthGuard)
export class ProjectsController {
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
@Get()
async list() {
return this.brain.projects.findAll();
}
@Get(':id')
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.getOwnedProject(id, user.id);
}
@Post()
async create(@CurrentUser() user: { id: string }, @Body() dto: CreateProjectDto) {
return this.brain.projects.create({
name: dto.name,
description: dto.description,
status: dto.status,
ownerId: user.id,
});
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateProjectDto,
@CurrentUser() user: { id: string },
) {
await this.getOwnedProject(id, user.id);
const project = await this.brain.projects.update(id, dto);
if (!project) throw new NotFoundException('Project not found');
return project;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
await this.getOwnedProject(id, user.id);
const deleted = await this.brain.projects.remove(id);
if (!deleted) throw new NotFoundException('Project not found');
}
private async getOwnedProject(id: string, userId: string) {
const project = await this.brain.projects.findById(id);
if (!project) throw new NotFoundException('Project not found');
assertOwner(project.ownerId, userId, 'Project');
return project;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { IsIn, IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
const projectStatuses = ['active', 'paused', 'completed', 'archived'] as const;
export class CreateProjectDto {
@IsString()
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string;
@IsOptional()
@IsIn(projectStatuses)
status?: 'active' | 'paused' | 'completed' | 'archived';
}
export class UpdateProjectDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string | null;
@IsOptional()
@IsIn(projectStatuses)
status?: 'active' | 'paused' | 'completed' | 'archived';
@IsOptional()
@IsObject()
metadata?: Record<string, unknown> | null;
}
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ProjectsController } from './projects.controller.js';
@Module({
controllers: [ProjectsController],
})
export class ProjectsModule {}
@@ -0,0 +1,67 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { SkillsService } from './skills.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
import type { CreateSkillDto, UpdateSkillDto } from './skills.dto.js';
@Controller('api/skills')
@UseGuards(AuthGuard)
export class SkillsController {
constructor(private readonly skills: SkillsService) {}
@Get()
async list() {
return this.skills.findAll();
}
@Get(':id')
async findOne(@Param('id') id: string) {
const skill = await this.skills.findById(id);
if (!skill) throw new NotFoundException('Skill not found');
return skill;
}
@Post()
async create(@Body() dto: CreateSkillDto) {
return this.skills.create({
name: dto.name,
description: dto.description,
version: dto.version,
source: dto.source,
config: dto.config,
enabled: dto.enabled,
});
}
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateSkillDto) {
const skill = await this.skills.update(id, dto);
if (!skill) throw new NotFoundException('Skill not found');
return skill;
}
@Patch(':id/toggle')
async toggle(@Param('id') id: string, @Body() body: { enabled: boolean }) {
const skill = await this.skills.toggle(id, body.enabled);
if (!skill) throw new NotFoundException('Skill not found');
return skill;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) {
const deleted = await this.skills.remove(id);
if (!deleted) throw new NotFoundException('Skill not found');
}
}
+15
View File
@@ -0,0 +1,15 @@
export interface CreateSkillDto {
name: string;
description?: string;
version?: string;
source?: 'builtin' | 'community' | 'custom';
config?: Record<string, unknown>;
enabled?: boolean;
}
export interface UpdateSkillDto {
description?: string;
version?: string;
config?: Record<string, unknown>;
enabled?: boolean;
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SkillsService } from './skills.service.js';
import { SkillsController } from './skills.controller.js';
@Module({
providers: [SkillsService],
controllers: [SkillsController],
exports: [SkillsService],
})
export class SkillsModule {}
+52
View File
@@ -0,0 +1,52 @@
import { Inject, Injectable } from '@nestjs/common';
import { eq, type Db, skills } from '@mosaic/db';
import { DB } from '../database/database.module.js';
type Skill = typeof skills.$inferSelect;
type NewSkill = typeof skills.$inferInsert;
@Injectable()
export class SkillsService {
constructor(@Inject(DB) private readonly db: Db) {}
async findAll(): Promise<Skill[]> {
return this.db.select().from(skills);
}
async findEnabled(): Promise<Skill[]> {
return this.db.select().from(skills).where(eq(skills.enabled, true));
}
async findById(id: string): Promise<Skill | undefined> {
const rows = await this.db.select().from(skills).where(eq(skills.id, id));
return rows[0];
}
async findByName(name: string): Promise<Skill | undefined> {
const rows = await this.db.select().from(skills).where(eq(skills.name, name));
return rows[0];
}
async create(data: NewSkill): Promise<Skill> {
const rows = await this.db.insert(skills).values(data).returning();
return rows[0]!;
}
async update(id: string, data: Partial<NewSkill>): Promise<Skill | undefined> {
const rows = await this.db
.update(skills)
.set({ ...data, updatedAt: new Date() })
.where(eq(skills.id, id))
.returning();
return rows[0];
}
async remove(id: string): Promise<boolean> {
const rows = await this.db.delete(skills).where(eq(skills.id, id)).returning();
return rows.length > 0;
}
async toggle(id: string, enabled: boolean): Promise<Skill | undefined> {
return this.update(id, { enabled });
}
}
+166
View File
@@ -0,0 +1,166 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Param,
Patch,
Post,
Query,
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 { CreateTaskDto, UpdateTaskDto } from './tasks.dto.js';
@Controller('api/tasks')
@UseGuards(AuthGuard)
export class TasksController {
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
@Get()
async list(
@CurrentUser() user: { id: string },
@Query('projectId') projectId?: string,
@Query('missionId') missionId?: string,
@Query('status') status?: string,
) {
if (projectId) {
await this.getOwnedProject(projectId, user.id, 'Task');
return this.brain.tasks.findByProject(projectId);
}
if (missionId) {
await this.getOwnedMission(missionId, user.id, 'Task');
return this.brain.tasks.findByMission(missionId);
}
const [projects, missions, tasks] = await Promise.all([
this.brain.projects.findAll(),
this.brain.missions.findAll(),
status
? this.brain.tasks.findByStatus(
status as Parameters<typeof this.brain.tasks.findByStatus>[0],
)
: this.brain.tasks.findAll(),
]);
const ownedProjectIds = new Set(
projects.filter((project) => project.ownerId === user.id).map((project) => project.id),
);
const ownedMissionIds = new Set(
missions
.filter(
(ownedMission) =>
typeof ownedMission.projectId === 'string' &&
ownedProjectIds.has(ownedMission.projectId),
)
.map((ownedMission) => ownedMission.id),
);
return tasks.filter(
(task) =>
(task.projectId ? ownedProjectIds.has(task.projectId) : false) ||
(task.missionId ? ownedMissionIds.has(task.missionId) : false),
);
}
@Get(':id')
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.getOwnedTask(id, user.id);
}
@Post()
async create(@Body() dto: CreateTaskDto, @CurrentUser() user: { id: string }) {
if (dto.projectId) {
await this.getOwnedProject(dto.projectId, user.id, 'Task');
}
if (dto.missionId) {
await this.getOwnedMission(dto.missionId, user.id, 'Task');
}
return this.brain.tasks.create({
title: dto.title,
description: dto.description,
status: dto.status,
priority: dto.priority,
projectId: dto.projectId,
missionId: dto.missionId,
assignee: dto.assignee,
tags: dto.tags,
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
});
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateTaskDto,
@CurrentUser() user: { id: string },
) {
await this.getOwnedTask(id, user.id);
if (dto.projectId) {
await this.getOwnedProject(dto.projectId, user.id, 'Task');
}
if (dto.missionId) {
await this.getOwnedMission(dto.missionId, user.id, 'Task');
}
const task = await this.brain.tasks.update(id, {
...dto,
dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined,
});
if (!task) throw new NotFoundException('Task not found');
return task;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
await this.getOwnedTask(id, user.id);
const deleted = await this.brain.tasks.remove(id);
if (!deleted) throw new NotFoundException('Task not found');
}
private async getOwnedTask(id: string, userId: string) {
const task = await this.brain.tasks.findById(id);
if (!task) throw new NotFoundException('Task not found');
if (task.projectId) {
await this.getOwnedProject(task.projectId, userId, 'Task');
return task;
}
if (task.missionId) {
await this.getOwnedMission(task.missionId, userId, 'Task');
return task;
}
throw new ForbiddenException('Task does not belong to the current user');
}
private async getOwnedMission(missionId: string, userId: string, resourceName: string) {
const mission = await this.brain.missions.findById(missionId);
if (!mission?.projectId) {
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
}
await this.getOwnedProject(mission.projectId, userId, resourceName);
return mission;
}
private async getOwnedProject(projectId: string, userId: string, resourceName: string) {
const project = await this.brain.projects.findById(projectId);
if (!project) {
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
}
assertOwner(project.ownerId, userId, resourceName);
return project;
}
}
+103
View File
@@ -0,0 +1,103 @@
import {
ArrayMaxSize,
IsArray,
IsIn,
IsISO8601,
IsObject,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
const taskStatuses = ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'] as const;
const taskPriorities = ['critical', 'high', 'medium', 'low'] as const;
export class CreateTaskDto {
@IsString()
@MaxLength(255)
title!: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string;
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
@IsOptional()
@IsIn(taskPriorities)
priority?: 'critical' | 'high' | 'medium' | 'low';
@IsOptional()
@IsUUID()
projectId?: string;
@IsOptional()
@IsUUID()
missionId?: string;
@IsOptional()
@IsString()
@MaxLength(255)
assignee?: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsISO8601()
dueDate?: string;
}
export class UpdateTaskDto {
@IsOptional()
@IsString()
@MaxLength(255)
title?: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string | null;
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
@IsOptional()
@IsIn(taskPriorities)
priority?: 'critical' | 'high' | 'medium' | 'low';
@IsOptional()
@IsUUID()
projectId?: string | null;
@IsOptional()
@IsUUID()
missionId?: string | null;
@IsOptional()
@IsString()
@MaxLength(255)
assignee?: string | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
tags?: string[] | null;
@IsOptional()
@IsISO8601()
dueDate?: string | null;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown> | null;
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller.js';
@Module({
controllers: [TasksController],
})
export class TasksModule {}
+42
View File
@@ -0,0 +1,42 @@
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
const otlpEndpoint = process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? 'http://localhost:4318';
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env['OTEL_SERVICE_NAME'] ?? 'mosaic-gateway',
[ATTR_SERVICE_VERSION]: '0.0.0',
}),
traceExporter: new OTLPTraceExporter({
url: `${otlpEndpoint}/v1/traces`,
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: `${otlpEndpoint}/v1/metrics`,
}),
exportIntervalMillis: 15_000,
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false },
'@opentelemetry/instrumentation-dns': { enabled: false },
}),
],
});
sdk.start();
process.on('SIGTERM', () => {
sdk
.shutdown()
.then(() => process.exit(0))
.catch(() => process.exit(1));
});
export { sdk };
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "NodeNext",
"moduleResolution": "NodeNext"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "../..",
"baseUrl": ".",
"paths": {
"@mosaic/auth": ["../../packages/auth/src/index.ts"],
"@mosaic/brain": ["../../packages/brain/src/index.ts"],
"@mosaic/coord": ["../../packages/coord/src/index.ts"],
"@mosaic/db": ["../../packages/db/src/index.ts"],
"@mosaic/log": ["../../packages/log/src/index.ts"],
"@mosaic/memory": ["../../packages/memory/src/index.ts"],
"@mosaic/types": ["../../packages/types/src/index.ts"],
"@mosaic/discord-plugin": ["../../plugins/discord/src/index.ts"],
"@mosaic/telegram-plugin": ["../../plugins/telegram/src/index.ts"]
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

Some files were not shown because too many files have changed in this diff Show More