Compare commits

...

37 Commits

Author SHA1 Message Date
7d04874f3c chore(orchestrator): complete Phase 6 milestone v0.0.7 (#105)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-15 01:07:14 +00:00
9f036242fa feat(cli): add prdy, quality-rails, and wizard subcommands (#104)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-15 01:05:31 +00:00
c4e52085e3 feat(mosaic): migrate install wizard from v0 to v1 (#103)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-15 00:59:42 +00:00
84e1868028 fix(gateway): resolve two startup bugs blocking E2E testing (#102)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-15 00:45:28 +00:00
f94f9f672b feat(prdy): migrate @mosaic/prdy from v0 to v1 (#101)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-15 00:44:02 +00:00
cd29fc8708 feat(quality-rails): migrate @mosaic/quality-rails from v0 to v1 (#100)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-15 00:23:56 +00:00
6e22c0fdeb chore(orchestrator): complete Phase 5 milestone — v0.0.6
All checks were successful
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) <noreply@anthropic.com>
2026-03-14 19:06:23 -05:00
1f4d54e474 fix(gateway): wire Telegram plugin into gateway plugin host (#99)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-15 00:05:27 +00:00
b7a39b45d7 chore(tasks): mark P5-004 done
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-13 15:16:13 -05:00
1bfdc91f90 Merge pull request 'feat(auth): P5-004 Authentik OIDC adapter via Better Auth genericOAuth' (#97) from feat/p5-sso-authentik into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2026-03-13 20:15:50 +00:00
58a90ac9d7 Merge pull request 'fix(gateway): ownership checks for TasksController findAll/create + MissionsController create' (#98) from fix/task-mission-ownership into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2026-03-13 20:15:46 +00:00
684dbdc6a4 fix(gateway): enforce task and mission ownership
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-03-13 14:43:33 -05:00
e92de12cf9 feat(auth): add Authentik OIDC adapter
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Refs #96
2026-03-13 14:42:05 -05:00
1f784a6a04 chore(tasks): mark P5-001, P5-003 done; P5-004 in-progress
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-13 14:33:16 -05:00
ab37c2e69f Merge pull request 'fix(ci): sequential steps + single install to prevent OOM on runner' (#95) from fix/ci-sequential into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-13 18:13:21 +00:00
c8f3e0db44 fix(ci): sequential steps + single install to prevent OOM on runner
All checks were successful
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
02772a3910 Merge pull request 'fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting' (#85) from fix/gateway-security into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2026-03-13 18:07:01 +00:00
85a25fd995 fix: add plugin paths to tsconfig.typecheck.json for merged PluginModule
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-13 13:03:59 -05:00
20f302367c chore(gateway): align typecheck paths after rebase 2026-03-13 13:03:09 -05:00
54c6bfded0 fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting 2026-03-13 13:03:09 -05:00
ca5472bc31 chore: format docs files 2026-03-13 13:03:09 -05:00
55b5a31c3c fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting 2026-03-13 13:03:09 -05:00
01e9891243 Merge pull request 'feat(plugins): P5-003 Telegram channel plugin' (#93) from feat/p5-telegram-plugin into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2026-03-13 17:48:01 +00:00
446a424c1f Merge pull request 'feat(gateway): P5-001 plugin host module' (#92) from feat/p5-plugin-host into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2026-03-13 17:47:59 +00:00
02a0d515d9 fix(turbo): typecheck must depend on ^build so package types are available
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-13 12:38:55 -05:00
2bf3816efc fix(turbo): typecheck must depend on ^build so package types are available
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-13 12:38:54 -05:00
96902bab44 feat(plugins): add Telegram channel plugin
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
2026-03-13 12:05:42 -05:00
280c5351e2 feat(gateway): add plugin host module
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline was successful
2026-03-13 12:04:42 -05:00
9eb48e1d9b feat(Phase 4): Memory & Intelligence — memory, log, summarization, skills (#91)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-13 13:56:50 +00:00
d83ebe65e9 verify(P3-008): Phase 3 web dashboard verification (#90)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-13 13:37:43 +00:00
4fe7d09e5c feat(web): admin panel with session management (#89)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-13 13:33:45 +00:00
e44cb7e56a feat(web): settings page with profile, providers, and models (#88)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-13 13:31:51 +00:00
fd4b7c2ba2 feat(web): project list and mission dashboard views (#87)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-13 13:30:11 +00:00
a1a1976b38 feat(web): task management with list view and kanban board (#86)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-13 13:28:17 +00:00
f0d1d4bafa feat(web): chat UI with conversations and WebSocket streaming (#84)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-13 13:25:28 +00:00
600da70960 feat(web): wire auth pages with BetterAuth and route guards (#83)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-13 13:21:33 +00:00
780f85e0d6 feat(web): scaffold Next.js 16 dashboard with design system and auth client (#82)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-13 13:18:09 +00:00
160 changed files with 9308 additions and 267 deletions

View File

@@ -18,3 +18,17 @@ 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=

View File

@@ -1,22 +1,25 @@
variables:
- &node_image 'node:22-alpine'
- &install_deps |
corepack enable
pnpm install --frozen-lockfile
- &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:
- *install_deps
- corepack enable
- pnpm install --frozen-lockfile
typecheck:
image: *node_image
commands:
- *install_deps
- *enable_pnpm
- pnpm typecheck
depends_on:
- install
@@ -24,34 +27,31 @@ steps:
lint:
image: *node_image
commands:
- *install_deps
- *enable_pnpm
- pnpm lint
depends_on:
- install
- typecheck
format:
image: *node_image
commands:
- *install_deps
- *enable_pnpm
- pnpm format:check
depends_on:
- install
- lint
test:
image: *node_image
commands:
- *install_deps
- *enable_pnpm
- pnpm test
depends_on:
- install
- format
build:
image: *node_image
commands:
- *install_deps
- *enable_pnpm
- pnpm build
depends_on:
- typecheck
- lint
- format
- test

View File

@@ -8,21 +8,27 @@
"build": "tsc",
"dev": "tsx watch src/main.ts",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"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",
@@ -33,7 +39,10 @@
"@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",
@@ -41,6 +50,7 @@
},
"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",

View File

@@ -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' },
]);
});
});

View File

@@ -7,11 +7,15 @@ import {
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 {
@@ -42,9 +46,15 @@ export class AgentService implements OnModuleDestroy {
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)];
this.customTools = [
...createBrainTools(brain),
...createCoordTools(coordService),
...createMemoryTools(memory, embeddingService.available ? embeddingService : null),
];
this.logger.log(`Registered ${this.customTools.length} custom tools`);
}

View File

@@ -9,7 +9,7 @@ export class ProviderService implements OnModuleInit {
private registry!: ModelRegistry;
async onModuleInit(): Promise<void> {
const authStorage = AuthStorage.create();
const authStorage = AuthStorage.inMemory();
this.registry = new ModelRegistry(authStorage);
this.registerOllamaProvider();
@@ -89,7 +89,7 @@ export class ProviderService implements OnModuleInit {
const modelsEnv = process.env['OLLAMA_MODELS'] ?? 'llama3.2,codellama,mistral';
const modelIds = modelsEnv
.split(',')
.map((m) => m.trim())
.map((modelId: string) => modelId.trim())
.filter(Boolean);
this.registerCustomProvider({

View File

@@ -145,8 +145,11 @@ export class RoutingService {
private classifyTier(model: ModelInfo): CostTier {
const cost = model.cost.input;
if (cost <= COST_TIER_THRESHOLDS.cheap.maxInput) return 'cheap';
if (cost <= COST_TIER_THRESHOLDS.standard.maxInput) return 'standard';
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';
}

View File

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

View File

@@ -1,4 +1,5 @@
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';
@@ -10,9 +11,15 @@ 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,
@@ -23,7 +30,17 @@ import { CoordModule } from './coord/coord.module.js';
MissionsModule,
TasksModule,
CoordModule,
MemoryModule,
LogModule,
SkillsModule,
PluginModule,
],
controllers: [HealthController],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}

View File

@@ -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`);
}
}

View File

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

View File

@@ -1,12 +1,20 @@
import { Controller, Post, Body, Logger, HttpException, HttpStatus, Inject } from '@nestjs/common';
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';
interface ChatRequest {
conversationId?: string;
content: string;
}
import { ChatRequestDto } from './chat.dto.js';
interface ChatResponse {
conversationId: string;
@@ -14,13 +22,18 @@ interface ChatResponse {
}
@Controller('api/chat')
@UseGuards(AuthGuard)
export class ChatController {
private readonly logger = new Logger(ChatController.name);
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
@Post()
async chat(@Body() body: ChatRequest): Promise<ChatResponse> {
@Throttle({ default: { limit: 10, ttl: 60_000 } })
async chat(
@Body() body: ChatRequestDto,
@CurrentUser() user: { id: string },
): Promise<ChatResponse> {
const conversationId = body.conversationId ?? uuid();
try {
@@ -36,6 +49,8 @@ export class ChatController {
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) => {

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

View File

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

View File

@@ -11,18 +11,17 @@ import {
} 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';
interface ChatMessage {
conversationId?: string;
content: string;
provider?: string;
modelId?: string;
}
import { ChatSocketMessageDto } from './chat.dto.js';
import { validateSocketSession } from './chat.gateway-auth.js';
@WebSocketGateway({
cors: { origin: '*' },
cors: {
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
},
namespace: '/chat',
})
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@@ -35,13 +34,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
{ conversationId: string; cleanup: () => void }
>();
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
constructor(
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(AUTH) private readonly auth: Auth,
) {}
afterInit(): void {
this.logger.log('Chat WebSocket gateway initialized');
}
handleConnection(client: Socket): void {
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}`);
}
@@ -58,7 +69,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
@SubscribeMessage('message')
async handleMessage(
@ConnectedSocket() client: Socket,
@MessageBody() data: ChatMessage,
@MessageBody() data: ChatSocketMessageDto,
): Promise<void> {
const conversationId = data.conversationId ?? uuid();

View File

@@ -16,7 +16,8 @@ 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 type {
import { assertOwner } from '../auth/resource-ownership.js';
import {
CreateConversationDto,
UpdateConversationDto,
SendMessageDto,
@@ -33,10 +34,8 @@ export class ConversationsController {
}
@Get(':id')
async findOne(@Param('id') id: string) {
const conversation = await this.brain.conversations.findById(id);
if (!conversation) throw new NotFoundException('Conversation not found');
return conversation;
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.getOwnedConversation(id, user.id);
}
@Post()
@@ -49,7 +48,12 @@ export class ConversationsController {
}
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateConversationDto) {
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;
@@ -57,22 +61,25 @@ export class ConversationsController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) {
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) {
const conversation = await this.brain.conversations.findById(id);
if (!conversation) throw new NotFoundException('Conversation not found');
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) {
const conversation = await this.brain.conversations.findById(id);
if (!conversation) throw new NotFoundException('Conversation not found');
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,
@@ -80,4 +87,11 @@ export class ConversationsController {
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;
}
}

View File

@@ -1,15 +1,36 @@
export interface CreateConversationDto {
import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class CreateConversationDto {
@IsOptional()
@IsString()
@MaxLength(255)
title?: string;
@IsOptional()
@IsUUID()
projectId?: string;
}
export interface UpdateConversationDto {
export class UpdateConversationDto {
@IsOptional()
@IsString()
@MaxLength(255)
title?: string;
@IsOptional()
@IsUUID()
projectId?: string | null;
}
export interface SendMessageDto {
role: 'user' | 'assistant' | 'system';
content: string;
export class SendMessageDto {
@IsIn(['user', 'assistant', 'system'])
role!: 'user' | 'assistant' | 'system';
@IsString()
@MaxLength(10_000)
content!: string;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

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

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

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

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

View File

@@ -0,0 +1 @@
export const LOG_SERVICE = 'LOG_SERVICE';

View File

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

View File

@@ -1,19 +1,45 @@
import './tracing.js';
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
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());
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 = process.env['GATEWAY_PORT'] ?? 4000;
await app.listen(port as number, '0.0.0.0');
const port = Number(process.env['GATEWAY_PORT'] ?? 4000);
await app.listen(port, '0.0.0.0');
logger.log(`Gateway listening on port ${port}`);
}

View File

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

View File

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

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

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

View File

@@ -0,0 +1 @@
export const MEMORY = 'MEMORY';

View File

@@ -2,6 +2,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
@@ -15,7 +16,9 @@ import {
import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import type { CreateMissionDto, UpdateMissionDto } from './missions.dto.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)
@@ -28,14 +31,15 @@ export class MissionsController {
}
@Get(':id')
async findOne(@Param('id') id: string) {
const mission = await this.brain.missions.findById(id);
if (!mission) throw new NotFoundException('Mission not found');
return mission;
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.getOwnedMission(id, user.id);
}
@Post()
async create(@Body() dto: CreateMissionDto) {
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,
@@ -45,7 +49,15 @@ export class MissionsController {
}
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateMissionDto) {
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;
@@ -53,8 +65,34 @@ export class MissionsController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) {
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;
}
}

View File

@@ -1,14 +1,46 @@
export interface CreateMissionDto {
name: string;
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 interface UpdateMissionDto {
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;
}

View File

@@ -0,0 +1,5 @@
export interface IChannelPlugin {
readonly name: string;
start(): Promise<void>;
stop(): Promise<void>;
}

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

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

View File

@@ -0,0 +1 @@
export const PLUGIN_REGISTRY = Symbol('PLUGIN_REGISTRY');

View File

@@ -16,7 +16,8 @@ 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 type { CreateProjectDto, UpdateProjectDto } from './projects.dto.js';
import { assertOwner } from '../auth/resource-ownership.js';
import { CreateProjectDto, UpdateProjectDto } from './projects.dto.js';
@Controller('api/projects')
@UseGuards(AuthGuard)
@@ -29,10 +30,8 @@ export class ProjectsController {
}
@Get(':id')
async findOne(@Param('id') id: string) {
const project = await this.brain.projects.findById(id);
if (!project) throw new NotFoundException('Project not found');
return project;
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.getOwnedProject(id, user.id);
}
@Post()
@@ -46,7 +45,12 @@ export class ProjectsController {
}
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateProjectDto) {
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;
@@ -54,8 +58,16 @@ export class ProjectsController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) {
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;
}
}

View File

@@ -1,12 +1,38 @@
export interface CreateProjectDto {
name: string;
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 interface UpdateProjectDto {
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;
}

View File

@@ -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');
}
}

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

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

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

View File

@@ -2,6 +2,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
@@ -16,7 +17,9 @@ import {
import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import type { CreateTaskDto, UpdateTaskDto } from './tasks.dto.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)
@@ -25,28 +28,63 @@ export class TasksController {
@Get()
async list(
@CurrentUser() user: { id: string },
@Query('projectId') projectId?: string,
@Query('missionId') missionId?: string,
@Query('status') status?: string,
) {
if (projectId) return this.brain.tasks.findByProject(projectId);
if (missionId) return this.brain.tasks.findByMission(missionId);
if (status)
return this.brain.tasks.findByStatus(
status as Parameters<typeof this.brain.tasks.findByStatus>[0],
);
return this.brain.tasks.findAll();
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) {
const task = await this.brain.tasks.findById(id);
if (!task) throw new NotFoundException('Task not found');
return task;
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.getOwnedTask(id, user.id);
}
@Post()
async create(@Body() dto: CreateTaskDto) {
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,
@@ -61,7 +99,18 @@ export class TasksController {
}
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateTaskDto) {
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,
@@ -72,8 +121,46 @@ export class TasksController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) {
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;
}
}

View File

@@ -1,24 +1,103 @@
export interface CreateTaskDto {
title: string;
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 interface UpdateTaskDto {
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;
}

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

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import './.next/types/routes.d.ts';
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.

View File

@@ -2,6 +2,7 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['@mosaic/design-tokens'],
};
export default nextConfig;

View File

@@ -11,11 +11,17 @@
"start": "next start"
},
"dependencies": {
"@mosaic/design-tokens": "workspace:^",
"better-auth": "^1.5.5",
"clsx": "^2.1.0",
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"socket.io-client": "^4.8.0",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;

View File

@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
import { GuestGuard } from '@/components/guest-guard';
export default function AuthLayout({ children }: { children: ReactNode }): React.ReactElement {
return (
<GuestGuard>
<div className="flex min-h-screen items-center justify-center bg-surface-bg">
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
{children}
</div>
</div>
</GuestGuard>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { signIn } from '@/lib/auth-client';
export default function LoginPage(): React.ReactElement {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setError(null);
setLoading(true);
const form = new FormData(e.currentTarget);
const email = form.get('email') as string;
const password = form.get('password') as string;
const result = await signIn.email({ email, password });
if (result.error) {
setError(result.error.message ?? 'Sign in failed');
setLoading(false);
return;
}
router.push('/chat');
}
return (
<div>
<h1 className="text-2xl font-semibold">Sign in</h1>
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
{error && (
<div
role="alert"
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
>
{error}
</div>
)}
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<p className="mt-4 text-center text-sm text-text-muted">
Don&apos;t have an account?{' '}
<Link href="/register" className="text-blue-400 hover:text-blue-300">
Sign up
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { signUp } from '@/lib/auth-client';
export default function RegisterPage(): React.ReactElement {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setError(null);
setLoading(true);
const form = new FormData(e.currentTarget);
const name = form.get('name') as string;
const email = form.get('email') as string;
const password = form.get('password') as string;
const result = await signUp.email({ name, email, password });
if (result.error) {
setError(result.error.message ?? 'Registration failed');
setLoading(false);
return;
}
router.push('/chat');
}
return (
<div>
<h1 className="text-2xl font-semibold">Create account</h1>
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
{error && (
<div
role="alert"
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
>
{error}
</div>
)}
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<div>
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
Name
</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="Your name"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
</form>
<p className="mt-4 text-center text-sm text-text-muted">
Already have an account?{' '}
<Link href="/login" className="text-blue-400 hover:text-blue-300">
Sign in
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
interface SessionInfo {
id: string;
provider: string;
modelId: string;
createdAt: string;
promptCount: number;
channels: string[];
durationMs: number;
}
interface SessionsResponse {
sessions: SessionInfo[];
total: number;
}
export default function AdminPage(): React.ReactElement {
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api<SessionsResponse>('/api/sessions')
.then((res) => setSessions(res.sessions))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
return (
<div className="mx-auto max-w-4xl space-y-8">
<h1 className="text-2xl font-semibold">Admin</h1>
{/* User Management placeholder */}
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">User Management</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
<p className="text-sm text-text-muted">
User management will be available when the admin API is implemented
</p>
</div>
</section>
{/* Active Agent Sessions */}
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">Active Agent Sessions</h2>
{loading ? (
<p className="text-sm text-text-muted">Loading sessions...</p>
) : sessions.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<p className="text-sm text-text-muted">No active sessions</p>
</div>
) : (
<div className="overflow-hidden rounded-lg border border-surface-border">
<table className="w-full">
<thead>
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
<th className="px-4 py-2 font-medium">Session ID</th>
<th className="px-4 py-2 font-medium">Provider</th>
<th className="px-4 py-2 font-medium">Model</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Prompts</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Duration</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr key={s.id} className="border-b border-surface-border last:border-b-0">
<td className="max-w-[200px] truncate px-4 py-2 font-mono text-xs text-text-primary">
{s.id}
</td>
<td className="px-4 py-2 text-xs text-text-muted">{s.provider}</td>
<td className="px-4 py-2 text-xs text-text-muted">{s.modelId}</td>
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
{s.promptCount}
</td>
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
{formatDuration(s.durationMs)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}

View File

@@ -0,0 +1,179 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@/lib/api';
import { getSocket } from '@/lib/socket';
import type { Conversation, Message } from '@/lib/types';
import { ConversationList } from '@/components/chat/conversation-list';
import { MessageBubble } from '@/components/chat/message-bubble';
import { ChatInput } from '@/components/chat/chat-input';
import { StreamingMessage } from '@/components/chat/streaming-message';
export default function ChatPage(): React.ReactElement {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [streamingText, setStreamingText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Load conversations on mount
useEffect(() => {
api<Conversation[]>('/api/conversations')
.then(setConversations)
.catch(() => {});
}, []);
// Load messages when active conversation changes
useEffect(() => {
if (!activeId) {
setMessages([]);
return;
}
api<Message[]>(`/api/conversations/${activeId}/messages`)
.then(setMessages)
.catch(() => {});
}, [activeId]);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingText]);
// Socket.io setup
useEffect(() => {
const socket = getSocket();
socket.connect();
socket.on('agent:text', (data: { conversationId: string; text: string }) => {
setStreamingText((prev) => prev + data.text);
});
socket.on('agent:start', () => {
setIsStreaming(true);
setStreamingText('');
});
socket.on('agent:end', (data: { conversationId: string }) => {
setIsStreaming(false);
setStreamingText('');
// Reload messages to get the final persisted version
api<Message[]>(`/api/conversations/${data.conversationId}/messages`)
.then(setMessages)
.catch(() => {});
});
socket.on('error', (data: { error: string }) => {
setIsStreaming(false);
setStreamingText('');
setMessages((prev) => [
...prev,
{
id: `error-${Date.now()}`,
conversationId: '',
role: 'system',
content: `Error: ${data.error}`,
createdAt: new Date().toISOString(),
},
]);
});
return () => {
socket.off('agent:text');
socket.off('agent:start');
socket.off('agent:end');
socket.off('error');
socket.disconnect();
};
}, []);
const handleNewConversation = useCallback(async () => {
const conv = await api<Conversation>('/api/conversations', {
method: 'POST',
body: { title: 'New conversation' },
});
setConversations((prev) => [conv, ...prev]);
setActiveId(conv.id);
setMessages([]);
}, []);
const handleSend = useCallback(
async (content: string) => {
let convId = activeId;
// Auto-create conversation if none selected
if (!convId) {
const conv = await api<Conversation>('/api/conversations', {
method: 'POST',
body: { title: content.slice(0, 50) },
});
setConversations((prev) => [conv, ...prev]);
setActiveId(conv.id);
convId = conv.id;
}
// Optimistic user message
const userMsg: Message = {
id: `temp-${Date.now()}`,
conversationId: convId,
role: 'user',
content,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg]);
// Persist user message
await api<Message>(`/api/conversations/${convId}/messages`, {
method: 'POST',
body: { role: 'user', content },
});
// Send to WebSocket for streaming response
const socket = getSocket();
socket.emit('message', { conversationId: convId, content });
},
[activeId],
);
return (
<div className="-m-6 flex h-[calc(100vh-3.5rem)]">
<ConversationList
conversations={conversations}
activeId={activeId}
onSelect={setActiveId}
onNew={handleNewConversation}
/>
<div className="flex flex-1 flex-col">
{activeId ? (
<>
<div className="flex-1 space-y-4 overflow-y-auto p-6">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{isStreaming && <StreamingMessage text={streamingText} />}
<div ref={messagesEndRef} />
</div>
<ChatInput onSend={handleSend} disabled={isStreaming} />
</>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<h2 className="text-lg font-medium text-text-secondary">Welcome to Mosaic Chat</h2>
<p className="mt-1 text-sm text-text-muted">
Select a conversation or start a new one
</p>
<button
type="button"
onClick={handleNewConversation}
className="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
Start new conversation
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import type { ReactNode } from 'react';
import { AppShell } from '@/components/layout/app-shell';
import { AuthGuard } from '@/components/auth-guard';
export default function DashboardLayout({ children }: { children: ReactNode }): React.ReactElement {
return (
<AuthGuard>
<AppShell>{children}</AppShell>
</AuthGuard>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/lib/api';
import type { Project } from '@/lib/types';
import { ProjectCard } from '@/components/projects/project-card';
export default function ProjectsPage(): React.ReactElement {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api<Project[]>('/api/projects')
.then(setProjects)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleProjectClick = useCallback((project: Project) => {
console.log('Project clicked:', project.id);
}, []);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold">Projects</h1>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
) : projects.length === 0 ? (
<div className="py-12 text-center">
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
<p className="mt-1 text-sm text-text-muted">
Projects will appear here when created via the gateway API
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} onClick={handleProjectClick} />
))}
</div>
)}
{/* Mission status section */}
<MissionStatus />
</div>
);
}
function MissionStatus(): React.ReactElement {
const [mission, setMission] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api<Record<string, unknown>>('/api/coord/status')
.then(setMission)
.catch(() => setMission(null))
.finally(() => setLoading(false));
}, []);
return (
<section className="mt-8">
<h2 className="mb-4 text-lg font-semibold">Active Mission</h2>
{loading ? (
<p className="text-sm text-text-muted">Loading mission status...</p>
) : !mission ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
<p className="text-sm text-text-muted">No active mission detected</p>
</div>
) : (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="Mission" value={String(mission['missionId'] ?? 'Unknown')} />
<StatCard label="Phase" value={String(mission['currentPhase'] ?? '—')} />
<StatCard
label="Tasks"
value={`${mission['completedTasks'] ?? 0} / ${mission['totalTasks'] ?? 0}`}
/>
<StatCard label="Status" value={String(mission['status'] ?? '—')} />
</div>
</div>
)}
</section>
);
}
function StatCard({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div className="rounded-lg bg-surface-elevated p-3">
<p className="text-xs text-text-muted">{label}</p>
<p className="mt-1 text-sm font-medium text-text-primary">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,150 @@
'use client';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { useSession } from '@/lib/auth-client';
interface ProviderInfo {
name: string;
enabled: boolean;
modelCount: number;
}
interface ModelInfo {
id: string;
name: string;
provider: string;
contextWindow: number;
reasoning: boolean;
cost: { input: number; output: number };
}
export default function SettingsPage(): React.ReactElement {
const { data: session } = useSession();
const [providers, setProviders] = useState<ProviderInfo[]>([]);
const [models, setModels] = useState<ModelInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
api<ProviderInfo[]>('/api/providers').catch(() => []),
api<ModelInfo[]>('/api/providers/models').catch(() => []),
])
.then(([p, m]) => {
setProviders(p);
setModels(m);
})
.finally(() => setLoading(false));
}, []);
return (
<div className="mx-auto max-w-3xl space-y-8">
<h1 className="text-2xl font-semibold">Settings</h1>
{/* Profile */}
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">Profile</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
{session?.user ? (
<div className="space-y-3">
<Field label="Name" value={session.user.name ?? '—'} />
<Field label="Email" value={session.user.email} />
<Field label="User ID" value={session.user.id} />
</div>
) : (
<p className="text-sm text-text-muted">Not signed in</p>
)}
</div>
</section>
{/* Providers */}
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">LLM Providers</h2>
{loading ? (
<p className="text-sm text-text-muted">Loading providers...</p>
) : providers.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<p className="text-sm text-text-muted">No providers configured</p>
</div>
) : (
<div className="space-y-3">
{providers.map((p) => (
<div
key={p.name}
className="flex items-center justify-between rounded-lg border border-surface-border bg-surface-card p-4"
>
<div>
<p className="text-sm font-medium text-text-primary">{p.name}</p>
<p className="text-xs text-text-muted">{p.modelCount} models available</p>
</div>
<span
className={`rounded-full px-2 py-0.5 text-xs ${
p.enabled ? 'bg-success/20 text-success' : 'bg-gray-600/20 text-gray-400'
}`}
>
{p.enabled ? 'Active' : 'Disabled'}
</span>
</div>
))}
</div>
)}
</section>
{/* Models */}
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">Available Models</h2>
{loading ? (
<p className="text-sm text-text-muted">Loading models...</p>
) : models.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<p className="text-sm text-text-muted">No models available</p>
</div>
) : (
<div className="overflow-hidden rounded-lg border border-surface-border">
<table className="w-full">
<thead>
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
<th className="px-4 py-2 font-medium">Model</th>
<th className="px-4 py-2 font-medium">Provider</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
</tr>
</thead>
<tbody>
{models.map((m) => (
<tr
key={`${m.provider}-${m.id}`}
className="border-b border-surface-border last:border-b-0"
>
<td className="px-4 py-2 text-sm text-text-primary">
{m.name}
{m.reasoning && (
<span className="ml-2 text-xs text-purple-400">reasoning</span>
)}
</td>
<td className="px-4 py-2 text-xs text-text-muted">{m.provider}</td>
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
{(m.contextWindow / 1000).toFixed(0)}k
</td>
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
${m.cost.input} / ${m.cost.output}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
function Field({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-text-muted">{label}</span>
<span className="text-sm text-text-primary">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
import { KanbanBoard } from '@/components/tasks/kanban-board';
import { TaskListView } from '@/components/tasks/task-list-view';
type ViewMode = 'list' | 'kanban';
export default function TasksPage(): React.ReactElement {
const [tasks, setTasks] = useState<Task[]>([]);
const [view, setView] = useState<ViewMode>('kanban');
const [loading, setLoading] = useState(true);
useEffect(() => {
api<Task[]>('/api/tasks')
.then(setTasks)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleTaskClick = useCallback((task: Task) => {
// Task detail view will be added in future iteration
console.log('Task clicked:', task.id);
}, []);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold">Tasks</h1>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-surface-border">
<button
type="button"
onClick={() => setView('list')}
className={cn(
'px-3 py-1.5 text-xs transition-colors',
view === 'list'
? 'bg-surface-elevated text-text-primary'
: 'text-text-muted hover:text-text-secondary',
)}
>
List
</button>
<button
type="button"
onClick={() => setView('kanban')}
className={cn(
'px-3 py-1.5 text-xs transition-colors',
view === 'kanban'
? 'bg-surface-elevated text-text-primary'
: 'text-text-muted hover:text-text-secondary',
)}
>
Kanban
</button>
</div>
</div>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
) : view === 'kanban' ? (
<KanbanBoard tasks={tasks} onTaskClick={handleTaskClick} />
) : (
<TaskListView tasks={tasks} onTaskClick={handleTaskClick} />
)}
</div>
);
}

View File

@@ -0,0 +1,115 @@
@import 'tailwindcss';
/*
* Mosaic Stack design tokens mapped to Tailwind v4 theme.
* Source: @mosaic/design-tokens (AD-13)
* Fonts: Outfit (sans), Fira Code (mono)
* Palette: deep blue-grays + blue/purple/teal accents
* Default: dark theme
*/
@theme {
/* ─── Fonts ─── */
--font-sans: 'Outfit', system-ui, -apple-system, sans-serif;
--font-mono: 'Fira Code', ui-monospace, Menlo, monospace;
/* ─── Neutral blue-gray scale ─── */
--color-gray-50: #f0f2f5;
--color-gray-100: #dce0e8;
--color-gray-200: #b8c0cc;
--color-gray-300: #8e99a9;
--color-gray-400: #6b7a8d;
--color-gray-500: #4e5d70;
--color-gray-600: #3b4859;
--color-gray-700: #2a3544;
--color-gray-800: #1c2433;
--color-gray-900: #111827;
--color-gray-950: #0a0f1a;
/* ─── Primary — blue ─── */
--color-blue-50: #eff4ff;
--color-blue-100: #dae5ff;
--color-blue-200: #bdd1ff;
--color-blue-300: #8fb4ff;
--color-blue-400: #5b8bff;
--color-blue-500: #3b6cf7;
--color-blue-600: #2551e0;
--color-blue-700: #1d40c0;
--color-blue-800: #1e369c;
--color-blue-900: #1e317b;
--color-blue-950: #162050;
/* ─── Accent — purple ─── */
--color-purple-50: #f3f0ff;
--color-purple-100: #e7dfff;
--color-purple-200: #d2c3ff;
--color-purple-300: #b49aff;
--color-purple-400: #9466ff;
--color-purple-500: #7c3aed;
--color-purple-600: #6d28d9;
--color-purple-700: #5b21b6;
--color-purple-800: #4c1d95;
--color-purple-900: #3b1578;
--color-purple-950: #230d4d;
/* ─── Accent — teal ─── */
--color-teal-50: #effcf9;
--color-teal-100: #d0f7ef;
--color-teal-200: #a4eddf;
--color-teal-300: #6fddcb;
--color-teal-400: #3ec5b2;
--color-teal-500: #25aa99;
--color-teal-600: #1c897e;
--color-teal-700: #1b6e66;
--color-teal-800: #1a5853;
--color-teal-900: #194945;
--color-teal-950: #082d2b;
/* ─── Semantic surface tokens ─── */
--color-surface-bg: #0a0f1a;
--color-surface-card: #111827;
--color-surface-elevated: #1c2433;
--color-surface-border: #2a3544;
/* ─── Semantic text tokens ─── */
--color-text-primary: #f0f2f5;
--color-text-secondary: #8e99a9;
--color-text-muted: #6b7a8d;
/* ─── Status colors ─── */
--color-success: #22c55e;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #3b82f6;
/* ─── Sidebar width ─── */
--spacing-sidebar: 16rem;
}
/* ─── Base styles ─── */
body {
background-color: var(--color-surface-bg);
color: var(--color-text-primary);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ─── Scrollbar styling ─── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-gray-600);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-gray-500);
}

View File

@@ -1,13 +1,29 @@
import type { ReactNode } from 'react';
import { Outfit, Fira_Code } from 'next/font/google';
import './globals.css';
const outfit = Outfit({
subsets: ['latin'],
variable: '--font-sans',
display: 'swap',
weight: ['300', '400', '500', '600', '700'],
});
const firaCode = Fira_Code({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
weight: ['400', '500', '700'],
});
export const metadata = {
title: 'Mosaic',
description: 'Mosaic Stack Dashboard',
};
export default function RootLayout({ children }: { children: ReactNode }): ReactNode {
export default function RootLayout({ children }: { children: ReactNode }): React.ReactElement {
return (
<html lang="en">
<html lang="en" className={`dark ${outfit.variable} ${firaCode.variable}`}>
<body>{children}</body>
</html>
);

View File

@@ -1,7 +1,5 @@
export default function HomePage(): React.ReactElement {
return (
<main>
<h1>Mosaic Stack</h1>
</main>
);
import { redirect } from 'next/navigation';
export default function HomePage(): never {
redirect('/chat');
}

View File

@@ -0,0 +1,34 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useSession } from '@/lib/auth-client';
interface AuthGuardProps {
children: React.ReactNode;
}
export function AuthGuard({ children }: AuthGuardProps): React.ReactElement | null {
const { data: session, isPending } = useSession();
const router = useRouter();
useEffect(() => {
if (!isPending && !session) {
router.replace('/login');
}
}, [isPending, session, router]);
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-text-muted">Loading...</div>
</div>
);
}
if (!session) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,53 @@
'use client';
import { useRef, useState } from 'react';
interface ChatInputProps {
onSend: (content: string) => void;
disabled?: boolean;
}
export function ChatInput({ onSend, disabled }: ChatInputProps): React.ReactElement {
const [value, setValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
function handleSubmit(e: React.FormEvent): void {
e.preventDefault();
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue('');
textareaRef.current?.focus();
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}
return (
<form onSubmit={handleSubmit} className="border-t border-surface-border bg-surface-card p-4">
<div className="flex items-end gap-3">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
rows={1}
placeholder="Type a message... (Enter to send, Shift+Enter for newline)"
className="max-h-32 min-h-[2.5rem] flex-1 resize-none rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
/>
<button
type="submit"
disabled={disabled || !value.trim()}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
Send
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import { cn } from '@/lib/cn';
import type { Conversation } from '@/lib/types';
interface ConversationListProps {
conversations: Conversation[];
activeId: string | null;
onSelect: (id: string) => void;
onNew: () => void;
}
export function ConversationList({
conversations,
activeId,
onSelect,
onNew,
}: ConversationListProps): React.ReactElement {
return (
<div className="flex h-full w-64 flex-col border-r border-surface-border bg-surface-card">
<div className="flex items-center justify-between p-3">
<h2 className="text-sm font-medium text-text-secondary">Conversations</h2>
<button
type="button"
onClick={onNew}
className="rounded-md px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-surface-elevated"
>
+ New
</button>
</div>
<div className="flex-1 overflow-y-auto">
{conversations.length === 0 && (
<p className="px-3 py-2 text-xs text-text-muted">No conversations yet</p>
)}
{conversations.map((conv) => (
<button
key={conv.id}
type="button"
onClick={() => onSelect(conv.id)}
className={cn(
'w-full px-3 py-2 text-left text-sm transition-colors',
activeId === conv.id
? 'bg-blue-600/20 text-blue-400'
: 'text-text-secondary hover:bg-surface-elevated',
)}
>
<span className="block truncate">{conv.title ?? 'Untitled'}</span>
<span className="block text-xs text-text-muted">
{new Date(conv.updatedAt).toLocaleDateString()}
</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { cn } from '@/lib/cn';
import type { Message } from '@/lib/types';
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
const isUser = message.role === 'user';
return (
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[75%] rounded-xl px-4 py-3 text-sm',
isUser
? 'bg-blue-600 text-white'
: 'border border-surface-border bg-surface-elevated text-text-primary',
)}
>
<div className="whitespace-pre-wrap break-words">{message.content}</div>
<div
className={cn('mt-1 text-right text-xs', isUser ? 'text-blue-200' : 'text-text-muted')}
>
{new Date(message.createdAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
/** Renders an in-progress assistant message from streaming text. */
interface StreamingMessageProps {
text: string;
}
export function StreamingMessage({ text }: StreamingMessageProps): React.ReactElement | null {
if (!text) return null;
return (
<div className="flex justify-start">
<div className="max-w-[75%] rounded-xl border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-primary">
<div className="whitespace-pre-wrap break-words">{text}</div>
<div className="mt-1 flex items-center gap-1 text-xs text-text-muted">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500" />
Thinking...
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useSession } from '@/lib/auth-client';
interface GuestGuardProps {
children: React.ReactNode;
}
/** Redirects authenticated users away from auth pages. */
export function GuestGuard({ children }: GuestGuardProps): React.ReactElement | null {
const { data: session, isPending } = useSession();
const router = useRouter();
useEffect(() => {
if (!isPending && session) {
router.replace('/chat');
}
}, [isPending, session, router]);
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-text-muted">Loading...</div>
</div>
);
}
if (session) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
import { Sidebar } from './sidebar';
import { Topbar } from './topbar';
interface AppShellProps {
children: ReactNode;
}
export function AppShell({ children }: AppShellProps): React.ReactElement {
return (
<div className="min-h-screen">
<Sidebar />
<div className="pl-sidebar">
<Topbar />
<main className="p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/cn';
interface NavItem {
label: string;
href: string;
icon: string;
}
const navItems: NavItem[] = [
{ label: 'Chat', href: '/chat', icon: '💬' },
{ label: 'Tasks', href: '/tasks', icon: '📋' },
{ label: 'Projects', href: '/projects', icon: '📁' },
{ label: 'Settings', href: '/settings', icon: '⚙️' },
{ label: 'Admin', href: '/admin', icon: '🛡️' },
];
export function Sidebar(): React.ReactElement {
const pathname = usePathname();
return (
<aside className="fixed left-0 top-0 z-30 flex h-screen w-sidebar flex-col border-r border-surface-border bg-surface-card">
<div className="flex h-14 items-center px-4">
<Link href="/" className="text-lg font-semibold text-text-primary">
Mosaic
</Link>
</div>
<nav className="flex-1 space-y-1 px-2 py-2">
{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
isActive
? 'bg-blue-600/20 text-blue-400'
: 'text-text-secondary hover:bg-surface-elevated hover:text-text-primary',
)}
>
<span className="text-base" aria-hidden="true">
{item.icon}
</span>
{item.label}
</Link>
);
})}
</nav>
<div className="border-t border-surface-border p-4">
<p className="text-xs text-text-muted">Mosaic Stack v0.0.4</p>
</div>
</aside>
);
}

View File

@@ -0,0 +1,39 @@
'use client';
import { useRouter } from 'next/navigation';
import { useSession, signOut } from '@/lib/auth-client';
export function Topbar(): React.ReactElement {
const { data: session } = useSession();
const router = useRouter();
async function handleSignOut(): Promise<void> {
await signOut();
router.replace('/login');
}
return (
<header className="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-surface-border bg-surface-card/80 px-6 backdrop-blur-sm">
<div />
<div className="flex items-center gap-4">
{session?.user ? (
<>
<span className="text-sm text-text-secondary">
{session.user.name ?? session.user.email}
</span>
<button
type="button"
onClick={handleSignOut}
className="rounded-md px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
>
Sign out
</button>
</>
) : (
<span className="text-sm text-text-muted">Not signed in</span>
)}
</div>
</header>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import { cn } from '@/lib/cn';
import type { Project } from '@/lib/types';
interface ProjectCardProps {
project: Project;
onClick: (project: Project) => void;
}
const statusColors: Record<string, string> = {
active: 'bg-success/20 text-success',
paused: 'bg-warning/20 text-warning',
completed: 'bg-blue-600/20 text-blue-400',
archived: 'bg-gray-600/20 text-gray-400',
};
export function ProjectCard({ project, onClick }: ProjectCardProps): React.ReactElement {
return (
<button
type="button"
onClick={() => onClick(project)}
className="w-full rounded-lg border border-surface-border bg-surface-card p-4 text-left transition-colors hover:border-gray-500"
>
<div className="flex items-start justify-between">
<h3 className="text-sm font-medium text-text-primary">{project.name}</h3>
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs',
statusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
)}
>
{project.status}
</span>
</div>
{project.description && (
<p className="mt-2 line-clamp-2 text-xs text-text-muted">{project.description}</p>
)}
<p className="mt-3 text-xs text-text-muted">
Created {new Date(project.createdAt).toLocaleDateString()}
</p>
</button>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import type { Task, TaskStatus } from '@/lib/types';
import { TaskCard } from './task-card';
interface KanbanBoardProps {
tasks: Task[];
onTaskClick: (task: Task) => void;
}
const columns: { id: TaskStatus; label: string }[] = [
{ id: 'not-started', label: 'Not Started' },
{ id: 'in-progress', label: 'In Progress' },
{ id: 'blocked', label: 'Blocked' },
{ id: 'done', label: 'Done' },
];
export function KanbanBoard({ tasks, onTaskClick }: KanbanBoardProps): React.ReactElement {
return (
<div className="flex gap-4 overflow-x-auto pb-4">
{columns.map((col) => {
const columnTasks = tasks.filter((t) => t.status === col.id);
return (
<div
key={col.id}
className="flex w-72 shrink-0 flex-col rounded-lg border border-surface-border bg-surface-elevated"
>
<div className="flex items-center justify-between border-b border-surface-border px-3 py-2">
<h3 className="text-sm font-medium text-text-secondary">{col.label}</h3>
<span className="rounded-full bg-surface-card px-2 py-0.5 text-xs text-text-muted">
{columnTasks.length}
</span>
</div>
<div className="flex-1 space-y-2 p-2">
{columnTasks.length === 0 && (
<p className="py-4 text-center text-xs text-text-muted">No tasks</p>
)}
{columnTasks.map((task) => (
<TaskCard key={task.id} task={task} onClick={onTaskClick} />
))}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
interface TaskCardProps {
task: Task;
onClick: (task: Task) => void;
}
const priorityColors: Record<string, string> = {
critical: 'text-error',
high: 'text-warning',
medium: 'text-blue-400',
low: 'text-text-muted',
};
const statusBadgeColors: Record<string, string> = {
'not-started': 'bg-gray-600/20 text-gray-300',
'in-progress': 'bg-blue-600/20 text-blue-400',
blocked: 'bg-error/20 text-error',
done: 'bg-success/20 text-success',
cancelled: 'bg-gray-600/20 text-gray-500',
};
export function TaskCard({ task, onClick }: TaskCardProps): React.ReactElement {
return (
<button
type="button"
onClick={() => onClick(task)}
className="w-full rounded-lg border border-surface-border bg-surface-card p-3 text-left transition-colors hover:border-gray-500"
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium text-text-primary">{task.title}</span>
<span className={cn('text-xs', priorityColors[task.priority])}>{task.priority}</span>
</div>
{task.description && (
<p className="mt-1 line-clamp-2 text-xs text-text-muted">{task.description}</p>
)}
<div className="mt-2 flex items-center gap-2">
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs',
statusBadgeColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
)}
>
{task.status}
</span>
{task.dueDate && (
<span className="text-xs text-text-muted">
{new Date(task.dueDate).toLocaleDateString()}
</span>
)}
</div>
</button>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
interface TaskListViewProps {
tasks: Task[];
onTaskClick: (task: Task) => void;
}
const priorityColors: Record<string, string> = {
critical: 'text-error',
high: 'text-warning',
medium: 'text-blue-400',
low: 'text-text-muted',
};
const statusColors: Record<string, string> = {
'not-started': 'text-gray-400',
'in-progress': 'text-blue-400',
blocked: 'text-error',
done: 'text-success',
cancelled: 'text-gray-500',
};
export function TaskListView({ tasks, onTaskClick }: TaskListViewProps): React.ReactElement {
if (tasks.length === 0) {
return <p className="py-8 text-center text-sm text-text-muted">No tasks found</p>;
}
return (
<div className="overflow-hidden rounded-lg border border-surface-border">
<table className="w-full">
<thead>
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
<th className="px-4 py-2 font-medium">Title</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Priority</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Due</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => (
<tr
key={task.id}
onClick={() => onTaskClick(task)}
className="cursor-pointer border-b border-surface-border transition-colors last:border-b-0 hover:bg-surface-elevated"
>
<td className="px-4 py-3 text-sm text-text-primary">{task.title}</td>
<td className="px-4 py-3">
<span className={cn('text-xs', statusColors[task.status])}>{task.status}</span>
</td>
<td className="px-4 py-3">
<span className={cn('text-xs', priorityColors[task.priority])}>
{task.priority}
</span>
</td>
<td className="hidden px-4 py-3 text-xs text-text-muted md:table-cell">
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

47
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,47 @@
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000';
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
body?: unknown;
}
export interface ApiError {
statusCode: number;
message: string;
}
/**
* Fetch wrapper for the Mosaic gateway API.
* Sends credentials (cookies) and JSON body automatically.
*/
export async function api<T>(path: string, init?: ApiRequestInit): Promise<T> {
const { body, headers: customHeaders, ...rest } = init ?? {};
const headers: Record<string, string> = {
Accept: 'application/json',
...(customHeaders as Record<string, string>),
};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`${GATEWAY_URL}${path}`, {
credentials: 'include',
...rest,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const errorBody = (await res.json().catch(() => ({
statusCode: res.status,
message: res.statusText,
}))) as ApiError;
throw Object.assign(new Error(errorBody.message), {
statusCode: errorBody.statusCode,
});
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}

View File

@@ -0,0 +1,7 @@
import { createAuthClient } from 'better-auth/react';
export const authClient: ReturnType<typeof createAuthClient> = createAuthClient({
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
});
export const { useSession, signIn, signUp, signOut } = authClient;

7
apps/web/src/lib/cn.ts Normal file
View File

@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** Merge and deduplicate Tailwind class names. */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,15 @@
import { io, type Socket } from 'socket.io-client';
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000';
let socket: Socket | null = null;
export function getSocket(): Socket {
if (!socket) {
socket = io(`${GATEWAY_URL}/chat`, {
withCredentials: true,
autoConnect: false,
});
}
return socket;
}

57
apps/web/src/lib/types.ts Normal file
View File

@@ -0,0 +1,57 @@
/** Conversation returned by the gateway API. */
export interface Conversation {
id: string;
userId: string;
title: string | null;
projectId: string | null;
createdAt: string;
updatedAt: string;
}
/** Message within a conversation. */
export interface Message {
id: string;
conversationId: string;
role: 'user' | 'assistant' | 'system';
content: string;
metadata?: Record<string, unknown>;
createdAt: string;
}
/** Task statuses. */
export type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
/** Task priorities. */
export type TaskPriority = 'critical' | 'high' | 'medium' | 'low';
/** Task returned by the gateway API. */
export interface Task {
id: string;
title: string;
description: string | null;
status: TaskStatus;
priority: TaskPriority;
projectId: string | null;
missionId: string | null;
assignee: string | null;
tags: string[] | null;
dueDate: string | null;
metadata: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
/** Project statuses. */
export type ProjectStatus = 'active' | 'paused' | 'completed' | 'archived';
/** Project returned by the gateway API. */
export interface Project {
id: string;
name: string;
description: string | null;
status: ProjectStatus;
userId: string;
metadata: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}

View File

@@ -1,11 +0,0 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
export default config;

View File

@@ -8,10 +8,10 @@
**ID:** mvp-20260312
**Statement:** Build Mosaic Stack v0.1.0 — a self-hosted, multi-user AI agent platform with web dashboard, TUI, remote control, shared memory, mission orchestration, and extensible skill/plugin architecture. All TypeScript. Pi as agent harness. Brain as knowledge layer. Queue as coordination backbone.
**Phase:** Execution
**Current Milestone:** Phase 3: Web Dashboard (v0.0.4)
**Progress:** 3 / 8 milestones
**Current Milestone:** Phase 7: Polish & Beta (v0.1.0)
**Progress:** 7 / 8 milestones
**Status:** active
**Last Updated:** 2026-03-12 UTC
**Last Updated:** 2026-03-14 UTC
## Success Criteria
@@ -34,10 +34,10 @@
| 0 | ms-157 | Phase 0: Foundation (v0.0.1) | done | — | — | 2026-03-13 | 2026-03-13 |
| 1 | ms-158 | Phase 1: Core API (v0.0.2) | done | — | — | 2026-03-13 | 2026-03-13 |
| 2 | ms-159 | Phase 2: Agent Layer (v0.0.3) | done | — | — | 2026-03-13 | 2026-03-12 |
| 3 | ms-160 | Phase 3: Web Dashboard (v0.0.4) | not-started | — | — | — | — |
| 4 | ms-161 | Phase 4: Memory & Intelligence (v0.0.5) | not-started | — | — | — | — |
| 5 | ms-162 | Phase 5: Remote Control (v0.0.6) | not-started | — | — | — | — |
| 6 | ms-163 | Phase 6: CLI & Tools (v0.0.7) | not-started | — | — | — | — |
| 3 | ms-160 | Phase 3: Web Dashboard (v0.0.4) | done | — | — | 2026-03-12 | 2026-03-13 |
| 4 | ms-161 | Phase 4: Memory & Intelligence (v0.0.5) | done | — | — | 2026-03-13 | 2026-03-13 |
| 5 | ms-162 | Phase 5: Remote Control (v0.0.6) | done | — | #99 | 2026-03-14 | 2026-03-14 |
| 6 | ms-163 | Phase 6: CLI & Tools (v0.0.7) | done | — | #104 | 2026-03-14 | 2026-03-14 |
| 7 | ms-164 | Phase 7: Polish & Beta (v0.1.0) | not-started | — | — | — | — |
## Deployment
@@ -66,7 +66,10 @@
| 5 | claude-opus-4-6 | 2026-03-12 | — | context limit | P1-009 |
| 6 | claude-opus-4-6 | 2026-03-12 | — | context limit | P2-006, FIX-01 |
| 7 | claude-opus-4-6 | 2026-03-12 | — | context limit | P2-007 |
| 8 | claude-opus-4-6 | 2026-03-12 | — | active | Phase 2 complete |
| 8 | claude-opus-4-6 | 2026-03-12 | — | context limit | Phase 2 complete |
| 9 | claude-opus-4-6 | 2026-03-12 | — | context limit | P3-007 |
| 10 | claude-opus-4-6 | 2026-03-13 | — | context limit | P3-008 |
| 11 | claude-opus-4-6 | 2026-03-14 | — | active | P5-005 |
## Scratchpad

View File

@@ -2,67 +2,67 @@
> Single-writer: orchestrator only. Workers read but never modify.
| id | status | milestone | description | pr | notes |
| ------ | ----------- | --------- | ------------------------------------------------------------- | --- | ----- |
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
| P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 |
| P0-004 | done | Phase 0 | @mosaic/auth — BetterAuth email/password setup | #68 | #4 |
| P0-005 | done | Phase 0 | Docker Compose — PG 17, Valkey 8, SigNoz | #65 | #5 |
| P0-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 |
| P0-007 | done | Phase 0 | CI pipeline — Woodpecker config | #69 | #7 |
| P0-008 | done | Phase 0 | Project docs — AGENTS.md, CLAUDE.md, README | #69 | #8 |
| P0-009 | done | Phase 0 | Verify Phase 0 — CI green, all packages build | #70 | #9 |
| P1-001 | done | Phase 1 | apps/gateway scaffold — NestJS + Fastify adapter | #61 | #10 |
| P1-002 | done | Phase 1 | Auth middleware — BetterAuth session validation | #71 | #11 |
| P1-003 | done | Phase 1 | @mosaic/brain — migrate from v0, PG backend | #71 | #12 |
| P1-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 |
| P1-005 | done | Phase 1 | Gateway routes — conversations CRUD + messages | #72 | #14 |
| P1-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 |
| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
| P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 |
| P1-009 | done | Phase 1 | Verify Phase 1 — gateway functional, API tested | #73 | #18 |
| P2-001 | done | Phase 2 | @mosaic/agent — Pi SDK integration + agent pool | #61 | #19 |
| P2-002 | done | Phase 2 | Multi-provider support — Anthropic + Ollama | #74 | #20 |
| P2-003 | done | Phase 2 | Agent routing engine — cost/capability matrix | #75 | #21 |
| P2-004 | done | Phase 2 | Tool registration — brain, queue, memory tools | #76 | #22 |
| P2-005 | done | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | #77 | #23 |
| P2-006 | done | Phase 2 | Agent session management — tmux + monitoring | #78 | #24 |
| P2-007 | done | Phase 2 | Verify Phase 2 — multi-provider routing works | #79 | #25 |
| P3-001 | not-started | Phase 3 | apps/web scaffold — Next.js 16 + BetterAuth + Tailwind | | #26 |
| P3-002 | not-started | Phase 3 | Auth pages — login, registration, SSO redirect | | #27 |
| P3-003 | not-started | Phase 3 | Chat UI — conversations, messages, streaming | | #28 |
| P3-004 | not-started | Phase 3 | Task management — list view + kanban board | | #29 |
| P3-005 | not-started | Phase 3 | Project & mission views — dashboard + PRD viewer | | #30 |
| P3-006 | not-started | Phase 3 | Settings — provider config, profile, integrations | | #31 |
| P3-007 | not-started | Phase 3 | Admin panel — user management, RBAC | | #32 |
| P3-008 | not-started | Phase 3 | Verify Phase 3 — web dashboard functional E2E | — | #33 |
| P4-001 | not-started | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 |
| P4-002 | not-started | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 |
| P4-003 | not-started | Phase 4 | @mosaic/log — log ingest, parsing, tiered storage | — | #36 |
| P4-004 | not-started | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 |
| P4-005 | not-started | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
| P4-006 | not-started | Phase 4 | Skill management — catalog, install, config | — | #39 |
| P4-007 | not-started | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 |
| P5-001 | not-started | Phase 5 | Plugin host — gateway plugin loading + channel interface | — | #41 |
| P5-002 | done | Phase 5 | @mosaic/discord-plugin — Discord bot + channel plugin | #61 | #42 |
| P5-003 | not-started | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
| P5-004 | not-started | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
| P5-005 | not-started | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | | #45 |
| P6-001 | not-started | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | | #46 |
| P6-002 | not-started | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | | #47 |
| P6-003 | not-started | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | | #48 |
| P6-004 | not-started | Phase 6 | @mosaic/mosaic — install wizard for v1 | | #49 |
| P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
| P6-006 | not-started | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
| P7-001 | not-started | Phase 7 | MCP endpoint hardening — streamable HTTP transport | — | #52 |
| P7-002 | not-started | Phase 7 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
| P7-003 | not-started | Phase 7 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
| P7-004 | not-started | Phase 7 | E2E test suite — Playwright critical paths | — | #55 |
| P7-005 | not-started | Phase 7 | Performance optimization | — | #56 |
| P7-006 | not-started | Phase 7 | Documentation — user guide, admin guide, dev guide | — | #57 |
| P7-007 | not-started | Phase 7 | Bare-metal deployment docs + .env.example | — | #58 |
| P7-008 | not-started | Phase 7 | Beta release gate — v0.1.0 tag | — | #59 |
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
| FIX-02 | not-started | Backlog | TUI agent:end — fix React state updater side-effect | — | #63 |
| FIX-03 | not-started | Backlog | Agent session — cwd sandbox, system prompt, tool restrictions | — | #64 |
| id | status | milestone | description | pr | notes |
| ------ | ----------- | --------- | ------------------------------------------------------------- | ---- | ----- |
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
| P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 |
| P0-004 | done | Phase 0 | @mosaic/auth — BetterAuth email/password setup | #68 | #4 |
| P0-005 | done | Phase 0 | Docker Compose — PG 17, Valkey 8, SigNoz | #65 | #5 |
| P0-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 |
| P0-007 | done | Phase 0 | CI pipeline — Woodpecker config | #69 | #7 |
| P0-008 | done | Phase 0 | Project docs — AGENTS.md, CLAUDE.md, README | #69 | #8 |
| P0-009 | done | Phase 0 | Verify Phase 0 — CI green, all packages build | #70 | #9 |
| P1-001 | done | Phase 1 | apps/gateway scaffold — NestJS + Fastify adapter | #61 | #10 |
| P1-002 | done | Phase 1 | Auth middleware — BetterAuth session validation | #71 | #11 |
| P1-003 | done | Phase 1 | @mosaic/brain — migrate from v0, PG backend | #71 | #12 |
| P1-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 |
| P1-005 | done | Phase 1 | Gateway routes — conversations CRUD + messages | #72 | #14 |
| P1-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 |
| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
| P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 |
| P1-009 | done | Phase 1 | Verify Phase 1 — gateway functional, API tested | #73 | #18 |
| P2-001 | done | Phase 2 | @mosaic/agent — Pi SDK integration + agent pool | #61 | #19 |
| P2-002 | done | Phase 2 | Multi-provider support — Anthropic + Ollama | #74 | #20 |
| P2-003 | done | Phase 2 | Agent routing engine — cost/capability matrix | #75 | #21 |
| P2-004 | done | Phase 2 | Tool registration — brain, queue, memory tools | #76 | #22 |
| P2-005 | done | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | #77 | #23 |
| P2-006 | done | Phase 2 | Agent session management — tmux + monitoring | #78 | #24 |
| P2-007 | done | Phase 2 | Verify Phase 2 — multi-provider routing works | #79 | #25 |
| P3-001 | done | Phase 3 | apps/web scaffold — Next.js 16 + BetterAuth + Tailwind | #82 | #26 |
| P3-002 | done | Phase 3 | Auth pages — login, registration, SSO redirect | #83 | #27 |
| P3-003 | done | Phase 3 | Chat UI — conversations, messages, streaming | #84 | #28 |
| P3-004 | done | Phase 3 | Task management — list view + kanban board | #86 | #29 |
| P3-005 | done | Phase 3 | Project & mission views — dashboard + PRD viewer | #87 | #30 |
| P3-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 |
| P3-007 | done | Phase 3 | Admin panel — user management, RBAC | #89 | #32 |
| P3-008 | done | Phase 3 | Verify Phase 3 — web dashboard functional E2E | — | #33 |
| P4-001 | done | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 |
| P4-002 | done | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 |
| P4-003 | done | Phase 4 | @mosaic/log — log ingest, parsing, tiered storage | — | #36 |
| P4-004 | done | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 |
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
| P4-007 | done | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 |
| P5-001 | done | Phase 5 | Plugin host — gateway plugin loading + channel interface | — | #41 |
| P5-002 | done | Phase 5 | @mosaic/discord-plugin — Discord bot + channel plugin | #61 | #42 |
| P5-003 | done | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
| P5-004 | done | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
| P5-005 | done | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | #99 | #45 |
| P6-001 | done | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | #104 | #46 |
| P6-002 | done | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | #101 | #47 |
| P6-003 | done | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | #100 | #48 |
| P6-004 | done | Phase 6 | @mosaic/mosaic — install wizard for v1 | #103 | #49 |
| P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
| P7-001 | not-started | Phase 7 | MCP endpoint hardening — streamable HTTP transport | — | #52 |
| P7-002 | not-started | Phase 7 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
| P7-003 | not-started | Phase 7 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
| P7-004 | not-started | Phase 7 | E2E test suite — Playwright critical paths | — | #55 |
| P7-005 | not-started | Phase 7 | Performance optimization | — | #56 |
| P7-006 | not-started | Phase 7 | Documentation — user guide, admin guide, dev guide | — | #57 |
| P7-007 | not-started | Phase 7 | Bare-metal deployment docs + .env.example | — | #58 |
| P7-008 | not-started | Phase 7 | Beta release gate — v0.1.0 tag | — | #59 |
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
| FIX-02 | not-started | Backlog | TUI agent:end — fix React state updater side-effect | — | #63 |
| FIX-03 | not-started | Backlog | Agent session — cwd sandbox, system prompt, tool restrictions | — | #64 |

View File

@@ -0,0 +1,98 @@
# Gateway Security Hardening Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Finish the requested gateway security hardening fixes in the existing `fix/gateway-security` worktree and produce a PR-ready branch.
**Architecture:** Tighten NestJS gateway boundaries in-place by enforcing auth guards, session validation, ownership checks, DTO validation, and Fastify security defaults. Preserve the current module structure and existing ESM import conventions.
**Tech Stack:** NestJS 11, Fastify, Socket.IO, Better Auth, class-validator, Vitest, pnpm, TypeScript ESM
---
### Task 1: Reconcile Security Tests
**Files:**
- Modify: `apps/gateway/src/chat/__tests__/chat-security.test.ts`
- Modify: `apps/gateway/src/__tests__/resource-ownership.test.ts`
**Step 1: Write the failing test**
- Encode the requested DTO constraints and socket-auth contract exactly.
**Step 2: Run test to verify it fails**
Run: `pnpm --filter @mosaic/gateway test -- src/chat/__tests__/chat-security.test.ts src/__tests__/resource-ownership.test.ts`
Expected: FAIL on current DTO/helper mismatch.
**Step 3: Write minimal implementation**
- Update DTO/helper/controller code only where tests prove a gap.
**Step 4: Run test to verify it passes**
Run the same command and require green.
### Task 2: Align Gateway Runtime Hardening
**Files:**
- Modify: `apps/gateway/src/conversations/conversations.dto.ts`
- Modify: `apps/gateway/src/chat/chat.dto.ts`
- Modify: `apps/gateway/src/chat/chat.gateway-auth.ts`
- Modify: `apps/gateway/src/chat/chat.gateway.ts`
- Modify: `apps/gateway/src/main.ts`
- Modify: `apps/gateway/src/app.module.ts`
**Step 1: Verify remaining requested deltas**
- Confirm code matches requested guard, rate limit, helmet, body limit, env validation, and CORS settings.
**Step 2: Apply minimal patch**
- Keep changes scoped to requested behavior only.
**Step 3: Run targeted tests**
Run: `pnpm --filter @mosaic/gateway test -- src/chat/__tests__/chat-security.test.ts src/__tests__/resource-ownership.test.ts`
Expected: PASS.
### Task 3: Verification, Review, and Delivery
**Files:**
- Create: `docs/reports/code-review/gateway-security-20260313.md`
- Create: `docs/reports/qa/gateway-security-20260313.md`
- Modify: `docs/scratchpads/gateway-security-20260313.md`
**Step 1: Run baseline gates**
Run:
```bash
pnpm typecheck
pnpm lint
```
**Step 2: Perform manual code review**
- Record correctness/security/testing/doc findings.
**Step 3: Commit and publish**
Run:
```bash
git add -A
git commit -m "fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting"
git push origin fix/gateway-security
```
**Step 4: Open PR and notify**
- Open PR titled `fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting`
- Run `openclaw system event --text "PR ready: mosaic-mono-v1 fix/gateway-security — 7 security fixes" --mode now`
- Remove worktree after PR is created.

View File

@@ -0,0 +1,40 @@
# Authentik SSO Setup
## Create the Authentik application
1. In Authentik, create an OAuth2/OpenID Provider.
2. Create an Application and link it to that provider.
3. Copy the generated client ID and client secret.
## Required environment variables
Set these values for the gateway/auth runtime:
```bash
AUTHENTIK_CLIENT_ID=your-client-id
AUTHENTIK_CLIENT_SECRET=your-client-secret
AUTHENTIK_ISSUER=https://authentik.example.com
```
`AUTHENTIK_ISSUER` should be the Authentik base URL, for example `https://authentik.example.com`.
## Redirect URI
Configure this redirect URI in the Authentik provider/application:
```text
{BETTER_AUTH_URL}/api/auth/callback/authentik
```
Example:
```text
https://mosaic.example.com/api/auth/callback/authentik
```
## Test the flow
1. Start the gateway with `BETTER_AUTH_URL` and the Authentik environment variables set.
2. Open the Mosaic login flow and choose the Authentik provider.
3. Complete the Authentik login.
4. Confirm the browser returns to Mosaic and a session is created successfully.

View File

@@ -0,0 +1,23 @@
# Code Review Report — Gateway Security Hardening
## Scope Reviewed
- `apps/gateway/src/chat/chat.gateway-auth.ts`
- `apps/gateway/src/chat/chat.gateway.ts`
- `apps/gateway/src/conversations/conversations.dto.ts`
- `apps/gateway/src/chat/__tests__/chat-security.test.ts`
## Findings
- No blocker findings in the final changed surface.
## Review Summary
- Correctness: socket auth helper now returns Better Auth session data unchanged, and gateway disconnects clients whose handshake does not narrow to a valid session payload
- Security: conversation role validation now rejects `system`; conversation content ceiling is 32k; chat request ceiling remains 10k
- Testing: targeted auth, ownership, and DTO regression tests pass
- Quality: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` all pass after the final edits
## Residual Risk
- `chat.gateway.ts` uses local narrowing around an `unknown` session result because the requested helper contract intentionally returns `unknown`.

View File

@@ -0,0 +1,39 @@
# QA Report — Gateway Security Hardening
## Scope
- Chat HTTP auth guard hardening
- Chat WebSocket session validation
- DTO validation rules for chat and conversation payloads
- Ownership regression coverage for by-id routes
## TDD
- Required: yes
- Applied: yes
- Red step: targeted tests failed on socket session reshaping and DTO role/length mismatches
- Green step: targeted tests passed after runtime and DTO alignment
## Baseline Verification
| Command | Result | Evidence |
| --- | --- | --- |
| `pnpm --filter @mosaic/gateway test -- src/chat/__tests__/chat-security.test.ts src/__tests__/resource-ownership.test.ts` | pass | 3 test files passed, 20 tests passed |
| `pnpm typecheck` | pass | turbo completed 18/18 package typecheck tasks |
| `pnpm lint` | pass | turbo completed 18/18 package lint tasks |
| `pnpm format:check` | pass | `All matched files use Prettier code style!` |
## Situational Verification
| Acceptance Criterion | Verification Method | Evidence |
| --- | --- | --- |
| Chat controller requires auth and current-user context | source assertion test | `chat-security.test.ts` checks `@UseGuards(AuthGuard)` and `@CurrentUser() user: { id: string }` |
| WebSocket handshake requires Better Auth session | unit tests for `validateSocketSession()` | null handshake returns `null`; valid handshake returns original session object |
| Conversation messages reject non-user/assistant roles | class-validator test | `system` role fails validation |
| Conversation messages enforce a 32k max length | class-validator test | `32_001` chars fail validation |
| Chat request payload enforces a 10k max length | class-validator test | `10_001` chars fail validation |
| By-id routes reject cross-user access | ownership regression tests | conversations, projects, missions, tasks each raise `ForbiddenException` for non-owner access |
## Residual Risk
- No live HTTP or WebSocket smoke test against a running gateway process was executed in this session.

View File

@@ -0,0 +1,30 @@
# Scratchpad — P5-001 Plugin Host
- Task: P5-001 / issue #41
- Branch: feat/p5-plugin-host
- Objective: add global NestJS plugin host module, wire Discord import, register active plugins from env, and attach to AppModule.
- TDD: skipped as optional for module wiring/integration work; relying on targeted typecheck/lint and module-level situational verification.
- Constraints: ESM .js imports, explicit @Inject(), follow existing gateway patterns, do not touch TASKS.md.
## Progress Log
- 2026-03-13: session started in worktree; loading gateway/plugin package context.
- 2026-03-13: implemented initial plugin module, service, interface, and AppModule wiring; pending verification.
- 2026-03-13: added `@mosaic/discord-plugin` as a gateway workspace dependency and regenerated `pnpm-lock.yaml`.
- 2026-03-13: built gateway dependency chain so workspace packages exported `dist/*` for clean TypeScript resolution in this fresh worktree.
- 2026-03-13: verification complete.
## Verification
- `pnpm --filter @mosaic/gateway... build`
- `pnpm --filter @mosaic/gateway typecheck`
- `pnpm --filter @mosaic/gateway lint`
- `pnpm format:check`
- `pnpm typecheck`
- `pnpm lint`
## Review
- Automated review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
- Manual review: diff inspection of gateway plugin host changes
- Result: no blocker findings

View File

@@ -0,0 +1,68 @@
# Gateway Security Hardening Scratchpad
## Metadata
- Date: 2026-03-13
- Worktree: `/home/jwoltje/src/mosaic-mono-v1-worktrees/sec-remediation`
- Branch: `fix/gateway-security`
- Scope: Finish 7 requested gateway security fixes without switching branches or worktrees
- Related tracker: worker task only; `docs/TASKS.md` is orchestrator-owned and left unchanged
- Budget assumption: no explicit token cap; keep scope limited to requested gateway/auth/validation hardening
## Objective
Complete the remaining gateway security hardening work:
1. Chat HTTP auth guard enforcement
2. Chat WebSocket session validation
3. Ownership checks on by-id CRUD routes
4. Global validation pipe and DTO enforcement
5. Rate limiting
6. Helmet security headers
7. Body limit and env validation
## Plan
1. Reconcile current worktree state against requested fixes.
2. Patch or extend tests first for DTO/auth behavior mismatches.
3. Implement minimal code changes to satisfy tests and requested behavior.
4. Run targeted gateway tests.
5. Run baseline gates: `pnpm typecheck`, `pnpm lint`.
6. Perform manual code review and record findings.
7. Commit, push branch, open PR, send OpenClaw event, remove worktree.
## Progress Log
### 2026-03-13T00:00 local
- Loaded required Mosaic/global/runtime instructions and applicable skills.
- Confirmed active worktree is `sec-remediation` and branch is already dirty with prior session changes.
- Identified remaining gaps: DTO validation mismatch and non-requested socket auth helper typing/behavior drift.
## TDD Notes
- Required: yes. This is security/auth/permission logic.
- Approach: update targeted unit tests first, verify failure, then patch code minimally.
## Verification Log
- `pnpm --filter @mosaic/gateway test -- src/chat/__tests__/chat-security.test.ts src/__tests__/resource-ownership.test.ts`
- Red: failed on socket session reshaping and DTO role/length mismatches.
- Green: passed with 3 test files and 20 tests passing.
- `pnpm typecheck`
- Pass on 2026-03-13 with 18/18 package typecheck tasks successful.
- `pnpm lint`
- Pass on 2026-03-13 with 18/18 package lint tasks successful.
- `pnpm format:check`
- Pass on 2026-03-13 with `All matched files use Prettier code style!`
## Review Log
- Manual review completed against auth, authorization, validation, and runtime hardening requirements.
- No blocker findings remained after remediation.
## Risks / Blockers
- Repository instructions conflict on PR merge behavior; user explicitly instructed PR-only, no merge. Follow user instruction.
- Existing worktree contains prior-session modifications; do not revert unrelated changes.
- `missions` and `tasks` currently depend on project ownership because the schema does not carry a direct user owner column.

View File

@@ -73,6 +73,34 @@ User confirmed: start the planning gate.
| ------- | ---------- | --------- | ---------- | ----------------------------------------------------------------------------------- |
| 7-8 | 2026-03-12 | Phase 2 | P2-007 | 19 unit tests (routing + coord). PR #79 merged, issue #25 closed. Phase 2 complete. |
### Session 11 — Phase 5 completion
| Session | Date | Milestone | Tasks Done | Outcome |
| ------- | ---------- | --------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| 11 | 2026-03-14 | Phase 5 | P5-005 | Wired Telegram plugin into gateway (was stubbed). Updated .env.example with all P5 env vars. PR #99 merged, issue #45 closed. Phase 5 complete. |
**Findings during verification:**
- Telegram plugin was built but not wired into gateway (stub warning in plugin.module.ts)
- Discord plugin was fully wired
- SSO/Authentik OIDC adapter was fully wired
- All three quality gates passing
### Session 11 (continued) — Phase 6 completion
| Session | Date | Milestone | Tasks Done | Outcome |
| ------- | ---------- | --------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| 11 | 2026-03-14 | Phase 6 | P6-002, P6-003, P6-004, P6-001, P6-006 | Full CLI & Tools migration. PRs #100-#104 merged. Also fixed 2 gateway startup bugs (PR #102). Phase 6 complete. |
**Phase 6 details:**
- P6-002: @mosaic/prdy migrated from v0 (~400 LOC). PR #101.
- P6-003: @mosaic/quality-rails migrated from v0 (~500 LOC). PR #100.
- P6-004: @mosaic/mosaic wizard migrated from v0 (2272 LOC, 28 files). PR #103.
- P6-001: CLI subcommands wired — tui, prdy, quality-rails, wizard all working. PR #104.
- BUG-1: PLUGIN_REGISTRY circular import fixed via plugin.tokens.ts. PR #102.
- BUG-2: AuthStorage.create() → .inMemory() to prevent silent exit. PR #102.
## Open Questions
(none at this time)
@@ -102,3 +130,21 @@ User confirmed: start the planning gate.
| 9 | P5-001: Plugin host (channel plugin interface) | Plugin arch works |
| 10 | P5-002: Discord plugin (bot + channel) | Discord ↔ Gateway proven |
| — | Then backfill: auth, brain, db, queue, OTEL, CI, web dashboard, etc. |
### Session 9 — Phase 3 Web Dashboard (P3-001 through P3-007)
| Session | Date | Milestone | Tasks Done | Outcome |
| ------- | ---------- | --------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| 9 | 2026-03-12 | Phase 3 | P3-001 through P3-007 | Full web dashboard: Next.js 16 scaffold, auth pages, chat UI, tasks (list+kanban), projects, settings, admin. PRs #82-#89 merged. |
### Session 10 — Phase 3 verification (P3-008)
| Session | Date | Milestone | Tasks Done | Outcome |
| ------- | ---------- | --------- | ---------- | -------------------------------------------------------------------------------------------------------------------------- |
| 10 | 2026-03-13 | Phase 3 | P3-008 | Phase 3 verification: typecheck 18/18, lint 18/18, format clean, build green (10 routes), 10 tests pass. Phase 3 complete. |
### Session 10 (continued) — Phase 4 Memory & Intelligence
| Session | Date | Milestone | Tasks Done | Outcome |
| ------- | ---------- | --------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 10 | 2026-03-13 | Phase 4 | P4-001 through P4-007 | Full memory + log system: DB schema (preferences, insights w/ pgvector, agent_logs, skills, summarization_jobs), @mosaic/memory + @mosaic/log packages, embedding service, summarization pipeline w/ cron, memory tools in agent sessions, skill management CRUD. All gates green. |

View File

@@ -0,0 +1,50 @@
# Scratchpad — P5-003 Telegram Plugin
## Objective
Implement `@mosaic/telegram-plugin` by matching the established Discord plugin pattern with Telegraf + socket.io-client, add package docs, and pass package typecheck/lint.
## Requirements Source
- docs/PRD.md: Phase 5 remote control / Telegram plugin
- docs/TASKS.md: P5-003
- User task brief dated 2026-03-13
## Plan
1. Inspect Discord plugin behavior and package conventions
2. Add Telegram runtime dependencies if missing
3. Implement Telegram plugin with matching gateway event flow
4. Add README usage documentation
5. Run package typecheck and lint
6. Run code review and remediate findings
7. Commit, push, open PR, notify, remove worktree
## TDD Rationale
ASSUMPTION: No existing telegram package test harness or fixture coverage makes package-level TDD
disproportionate for this plugin scaffold task. Validation will rely on typecheck, lint, and
manual structural parity with the Discord plugin.
## Risks
- Telegram API typings may differ from Discords event shapes and require narrower guards.
- Socket event payloads may already include `role` in shared gateway expectations.
## Progress Log
- 2026-03-13: Loaded Mosaic/global/repo guidance, mission files, Discord reference implementation, and Telegram package scaffold.
- 2026-03-13: Added `telegraf` and `socket.io-client` to `@mosaic/telegram-plugin`.
- 2026-03-13: Implemented Telegram message forwarding, gateway streaming accumulation, response chunking, and package README.
## Verification Evidence
- `pnpm --filter @mosaic/telegram-plugin typecheck` → pass
- `pnpm --filter @mosaic/telegram-plugin lint` → pass
- `pnpm typecheck` → pass
- `pnpm lint` → pass
## Review
- Automated uncommitted review wrapper was invoked for the current delta.
- Manual review completed against Discord parity, gateway event contracts, and package docs; no additional blockers found.

View File

@@ -0,0 +1,44 @@
# P5-004 Scratchpad
- Objective: Add optional Authentik OIDC SSO adapter via Better Auth genericOAuth.
- Task ref: P5-004
- Issue ref: #96
- Plan:
1. Inspect auth/gateway surfaces and Better Auth plugin shape.
2. Add failing coverage for auth config/startup validation where feasible.
3. Implement adapter, docs, and warnings.
4. Run targeted typechecks, lint, and review.
- TDD note: no low-friction auth plugin or bootstrap-env test seam exists for `packages/auth/src/auth.ts` or `apps/gateway/src/main.ts`. This change is configuration-oriented and does not alter an existing behavioral contract with a current test harness. I skipped new tests for this pass and relied on exact typecheck/lint/test commands plus manual review.
- Changes:
1. Added conditional Better Auth `genericOAuth` plugin registration for the `authentik` provider in `packages/auth/src/auth.ts`.
2. Added a soft startup warning in `apps/gateway/src/main.ts` for incomplete Authentik env configuration.
3. Added `docs/plans/authentik-sso-setup.md` with env, redirect URI, and test-flow guidance.
4. Confirmed `packages/auth/src/index.ts` already exports `AuthConfig`; no change required there.
- Verification:
1. `pnpm --filter @mosaic/db build`
2. `pnpm --filter @mosaic/auth typecheck`
3. `pnpm --filter @mosaic/gateway typecheck`
4. `pnpm lint`
5. `pnpm format:check`
6. `pnpm --filter @mosaic/auth test`
7. `pnpm --filter @mosaic/gateway test`
- Results:
1. `@mosaic/auth` typecheck passed after replacing the non-existent `enabled` field with conditional plugin registration.
2. `@mosaic/gateway` typecheck passed.
3. Repo lint passed.
4. Prettier check passed after formatting `apps/gateway/src/main.ts`.
5. `@mosaic/auth` tests reported `No test files found, exiting with code 0`.
6. `@mosaic/gateway` tests passed: `3` files, `20` tests.
- Review:
1. Manual review of the diff found no blocker issues.
2. External `codex-code-review.sh --uncommitted` was attempted but did not return a usable verdict in-session; no automated review findings were available from that run.
- Situational evidence:
1. Provider activation is env-gated by `AUTHENTIK_CLIENT_ID`.
2. Misconfigured optional SSO surfaces a warning instead of crashing gateway startup.
3. Setup doc records the expected redirect path: `{BETTER_AUTH_URL}/api/auth/callback/authentik`.

View File

@@ -0,0 +1,58 @@
# Task Ownership Gap Fix Scratchpad
## Metadata
- Date: 2026-03-13
- Worktree: `/home/jwoltje/src/mosaic-mono-v1-worktrees/fix-task-ownership`
- Branch: `fix/task-mission-ownership`
- Scope: Fix ownership checks in TasksController/MissionsController and extend gateway ownership tests
- Related tracker: worker task only; `docs/TASKS.md` is orchestrator-owned and left unchanged
- Budget assumption: no explicit token cap; keep scope limited to requested gateway permission fixes
## Objective
Close ownership gaps so task listing/creation and mission creation enforce project/mission ownership and reject cross-user access.
## Acceptance Criteria
1. TasksController `list()` enforces ownership for `projectId` and `missionId`, and does not return cross-user data when neither filter is provided.
2. TasksController `create()` rejects unowned `projectId` and `missionId` references.
3. MissionsController `create()` rejects unowned `projectId` references.
4. Gateway ownership tests cover forbidden task creation and forbidden task listing by unowned project.
## Plan
1. Inspect current controller and ownership test patterns.
2. Add failing permission tests first.
3. Patch controller methods with existing ownership helpers.
4. Run targeted gateway tests, then gateway typecheck/lint/full test.
5. Perform independent review, record evidence, then complete the requested git/PR workflow.
## TDD Notes
- Required: yes. This is auth/permission logic and a bugfix.
- Strategy: add failing tests in `resource-ownership.test.ts`, verify red, then implement minimal controller changes.
## Verification Log
- `pnpm --filter @mosaic/gateway test -- src/__tests__/resource-ownership.test.ts`
- Red: failed with 2 expected permission-path failures before controller changes.
- Green: passed after wiring ownership checks and adding owned-task filtering coverage.
- `pnpm --filter @mosaic/gateway typecheck`
- Pass on 2026-03-13 after fixing parameter ordering and mission project nullability.
- `pnpm --filter @mosaic/gateway lint`
- Pass on 2026-03-13.
- `pnpm --filter @mosaic/gateway test`
- Pass on 2026-03-13 with 3 test files and 23 tests passing.
- `pnpm format:check`
- Pass on 2026-03-13.
## Review Log
- Manual review: checked for auth regressions, cross-user list leakage, and dashboard behavior impact; kept unfiltered task list functional by filtering to owned projects/missions instead of returning an empty list.
- Automated review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` running/re-run for independent review evidence.
## Risks / Blockers
- Repository-wide Mosaic instructions require merge/issue closure, but the user explicitly instructed PR-only and no merge; follow the user instruction.
- `docs/TASKS.md` is orchestrator-owned and will not be edited from this worker task.

View File

@@ -1,5 +1,6 @@
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { genericOAuth } from 'better-auth/plugins';
import type { Db } from '@mosaic/db';
export interface AuthConfig {
@@ -10,6 +11,33 @@ export interface AuthConfig {
export function createAuth(config: AuthConfig) {
const { db, baseURL, secret } = config;
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
const plugins = authentikClientId
? [
genericOAuth({
config: [
{
providerId: 'authentik',
clientId: authentikClientId,
clientSecret: authentikClientSecret ?? '',
discoveryUrl: authentikIssuer
? `${authentikIssuer}/.well-known/openid-configuration`
: undefined,
authorizationUrl: authentikIssuer
? `${authentikIssuer}/application/o/authorize/`
: undefined,
tokenUrl: authentikIssuer ? `${authentikIssuer}/application/o/token/` : undefined,
userInfoUrl: authentikIssuer
? `${authentikIssuer}/application/o/userinfo/`
: undefined,
scopes: ['openid', 'email', 'profile'],
},
],
}),
]
: undefined;
return betterAuth({
database: drizzleAdapter(db, {
@@ -36,6 +64,7 @@ export function createAuth(config: AuthConfig) {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // refresh daily
},
plugins,
});
}

View File

@@ -21,6 +21,9 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mosaic/mosaic": "workspace:^",
"@mosaic/prdy": "workspace:^",
"@mosaic/quality-rails": "workspace:^",
"ink": "^5.0.0",
"ink-text-input": "^6.0.0",
"ink-spinner": "^5.0.0",
@@ -29,6 +32,7 @@
"commander": "^13.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"tsx": "^4.0.0",
"typescript": "^5.8.0",

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { buildPrdyCli } from '@mosaic/prdy';
import { createQualityRailsCli } from '@mosaic/quality-rails';
const program = new Command();
@@ -25,4 +27,85 @@ program
);
});
// prdy subcommand
// buildPrdyCli() returns a wrapper Command; extract the 'prdy' subcommand from it.
// Type cast is required because @mosaic/prdy uses commander@12 while @mosaic/cli uses commander@13.
const prdyWrapper = buildPrdyCli();
const prdyCmd = prdyWrapper.commands.find((c) => c.name() === 'prdy');
if (prdyCmd !== undefined) {
program.addCommand(prdyCmd as unknown as Command);
}
// quality-rails subcommand
// createQualityRailsCli() returns a wrapper Command; extract the 'quality-rails' subcommand.
const qrWrapper = createQualityRailsCli();
const qrCmd = qrWrapper.commands.find((c) => c.name() === 'quality-rails');
if (qrCmd !== undefined) {
program.addCommand(qrCmd as unknown as Command);
}
// wizard subcommand — wraps @mosaic/mosaic installation wizard
program
.command('wizard')
.description('Run the Mosaic installation wizard')
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
.option('--source-dir <path>', 'Source directory for framework files')
.option('--mosaic-home <path>', 'Target config directory')
.option('--name <name>', 'Agent name')
.option('--role <description>', 'Agent role description')
.option('--style <style>', 'Communication style: direct|friendly|formal')
.option('--accessibility <prefs>', 'Accessibility preferences')
.option('--guardrails <rules>', 'Custom guardrails')
.option('--user-name <name>', 'Your name')
.option('--pronouns <pronouns>', 'Your pronouns')
.option('--timezone <tz>', 'Your timezone')
.action(async (opts: Record<string, string | boolean | undefined>) => {
// Dynamic import to avoid loading wizard deps for other commands
const {
runWizard,
ClackPrompter,
HeadlessPrompter,
createConfigService,
WizardCancelledError,
DEFAULT_MOSAIC_HOME,
} = await import('@mosaic/mosaic');
try {
const mosaicHome = (opts['mosaicHome'] as string | undefined) ?? DEFAULT_MOSAIC_HOME;
const sourceDir = (opts['sourceDir'] as string | undefined) ?? mosaicHome;
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
const configService = createConfigService(mosaicHome, sourceDir);
await runWizard({
mosaicHome,
sourceDir,
prompter,
configService,
cliOverrides: {
soul: {
agentName: opts['name'] as string | undefined,
roleDescription: opts['role'] as string | undefined,
communicationStyle: opts['style'] as 'direct' | 'friendly' | 'formal' | undefined,
accessibility: opts['accessibility'] as string | undefined,
customGuardrails: opts['guardrails'] as string | undefined,
},
user: {
userName: opts['userName'] as string | undefined,
pronouns: opts['pronouns'] as string | undefined,
timezone: opts['timezone'] as string | undefined,
},
},
});
} catch (err) {
if (err instanceof WizardCancelledError) {
console.log('\nWizard cancelled.');
process.exit(0);
}
console.error('Wizard failed:', err);
process.exit(1);
}
});
program.parse();

View File

@@ -1,4 +1,18 @@
export { createDb, type Db, type DbHandle } from './client.js';
export { runMigrations } from './migrate.js';
export * from './schema.js';
export { eq, and, or, desc, asc, sql, inArray, isNull, isNotNull } from 'drizzle-orm';
export {
eq,
and,
or,
desc,
asc,
sql,
inArray,
isNull,
isNotNull,
gt,
lt,
gte,
lte,
} from 'drizzle-orm';

View File

@@ -3,7 +3,18 @@
* drizzle-kit reads this file directly (avoids CJS/ESM extension issues).
*/
import { pgTable, text, timestamp, boolean, uuid, jsonb, index } from 'drizzle-orm/pg-core';
import {
pgTable,
text,
timestamp,
boolean,
uuid,
jsonb,
index,
real,
integer,
customType,
} from 'drizzle-orm/pg-core';
// ─── Auth (BetterAuth-compatible) ────────────────────────────────────────────
@@ -211,3 +222,152 @@ export const messages = pgTable(
},
(t) => [index('messages_conversation_id_idx').on(t.conversationId)],
);
// ─── pgvector custom type ───────────────────────────────────────────────────
const vector = customType<{ data: number[]; driverParam: string; config: { dimensions: number } }>({
dataType(config) {
return `vector(${config?.dimensions ?? 1536})`;
},
fromDriver(value: unknown): number[] {
const str = value as string;
return str
.slice(1, -1)
.split(',')
.map((v) => Number(v));
},
toDriver(value: number[]): string {
return `[${value.join(',')}]`;
},
});
// ─── Memory ─────────────────────────────────────────────────────────────────
export const preferences = pgTable(
'preferences',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
key: text('key').notNull(),
value: jsonb('value').notNull(),
category: text('category', {
enum: ['communication', 'coding', 'workflow', 'appearance', 'general'],
})
.notNull()
.default('general'),
source: text('source'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('preferences_user_id_idx').on(t.userId),
index('preferences_user_key_idx').on(t.userId, t.key),
],
);
export const insights = pgTable(
'insights',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
content: text('content').notNull(),
embedding: vector('embedding', { dimensions: 1536 }),
source: text('source', {
enum: ['agent', 'user', 'summarization', 'system'],
})
.notNull()
.default('agent'),
category: text('category', {
enum: ['decision', 'learning', 'preference', 'fact', 'pattern', 'general'],
})
.notNull()
.default('general'),
relevanceScore: real('relevance_score').notNull().default(1.0),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
decayedAt: timestamp('decayed_at', { withTimezone: true }),
},
(t) => [
index('insights_user_id_idx').on(t.userId),
index('insights_category_idx').on(t.category),
index('insights_relevance_idx').on(t.relevanceScore),
],
);
// ─── Agent Logs ─────────────────────────────────────────────────────────────
export const agentLogs = pgTable(
'agent_logs',
{
id: uuid('id').primaryKey().defaultRandom(),
sessionId: text('session_id').notNull(),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
level: text('level', { enum: ['debug', 'info', 'warn', 'error'] })
.notNull()
.default('info'),
category: text('category', {
enum: ['decision', 'tool_use', 'learning', 'error', 'general'],
})
.notNull()
.default('general'),
content: text('content').notNull(),
metadata: jsonb('metadata'),
tier: text('tier', { enum: ['hot', 'warm', 'cold'] })
.notNull()
.default('hot'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
summarizedAt: timestamp('summarized_at', { withTimezone: true }),
archivedAt: timestamp('archived_at', { withTimezone: true }),
},
(t) => [
index('agent_logs_session_id_idx').on(t.sessionId),
index('agent_logs_user_id_idx').on(t.userId),
index('agent_logs_tier_idx').on(t.tier),
index('agent_logs_created_at_idx').on(t.createdAt),
],
);
// ─── Skills ─────────────────────────────────────────────────────────────────
export const skills = pgTable(
'skills',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
description: text('description'),
version: text('version'),
source: text('source', { enum: ['builtin', 'community', 'custom'] })
.notNull()
.default('custom'),
config: jsonb('config'),
enabled: boolean('enabled').notNull().default(true),
installedBy: text('installed_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('skills_enabled_idx').on(t.enabled)],
);
// ─── Summarization Jobs ─────────────────────────────────────────────────────
export const summarizationJobs = pgTable(
'summarization_jobs',
{
id: uuid('id').primaryKey().defaultRandom(),
status: text('status', { enum: ['pending', 'running', 'completed', 'failed'] })
.notNull()
.default('pending'),
logsProcessed: integer('logs_processed').notNull().default(0),
insightsCreated: integer('insights_created').notNull().default(0),
errorMessage: text('error_message'),
startedAt: timestamp('started_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('summarization_jobs_status_idx').on(t.status)],
);

View File

@@ -1,6 +1,7 @@
{
"name": "@mosaic/design-tokens",
"version": "0.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {

View File

@@ -0,0 +1,91 @@
/**
* Mosaic Stack color palette.
* Deep blue-grays with blue/purple/teal accents.
*/
export const colors = {
/** Neutral blue-gray scale */
gray: {
50: '#f0f2f5',
100: '#dce0e8',
200: '#b8c0cc',
300: '#8e99a9',
400: '#6b7a8d',
500: '#4e5d70',
600: '#3b4859',
700: '#2a3544',
800: '#1c2433',
900: '#111827',
950: '#0a0f1a',
},
/** Primary — blue */
blue: {
50: '#eff4ff',
100: '#dae5ff',
200: '#bdd1ff',
300: '#8fb4ff',
400: '#5b8bff',
500: '#3b6cf7',
600: '#2551e0',
700: '#1d40c0',
800: '#1e369c',
900: '#1e317b',
950: '#162050',
},
/** Accent — purple */
purple: {
50: '#f3f0ff',
100: '#e7dfff',
200: '#d2c3ff',
300: '#b49aff',
400: '#9466ff',
500: '#7c3aed',
600: '#6d28d9',
700: '#5b21b6',
800: '#4c1d95',
900: '#3b1578',
950: '#230d4d',
},
/** Accent — teal */
teal: {
50: '#effcf9',
100: '#d0f7ef',
200: '#a4eddf',
300: '#6fddcb',
400: '#3ec5b2',
500: '#25aa99',
600: '#1c897e',
700: '#1b6e66',
800: '#1a5853',
900: '#194945',
950: '#082d2b',
},
/** Semantic */
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
info: '#3b82f6',
/** Surface — dark theme defaults */
surface: {
background: '#0a0f1a',
card: '#111827',
elevated: '#1c2433',
overlay: 'rgba(0, 0, 0, 0.6)',
border: '#2a3544',
},
/** Text */
text: {
primary: '#f0f2f5',
secondary: '#8e99a9',
muted: '#6b7a8d',
inverse: '#0a0f1a',
},
} as const;
export type Colors = typeof colors;

View File

@@ -0,0 +1,33 @@
/**
* Mosaic Stack font definitions.
* Outfit (sans) — headings and body.
* Fira Code (mono) — code and terminal.
*/
export const fonts = {
sans: {
family: 'Outfit',
fallback: 'system-ui, -apple-system, sans-serif',
stack: "'Outfit', system-ui, -apple-system, sans-serif",
weights: {
light: 300,
regular: 400,
medium: 500,
semibold: 600,
bold: 700,
},
},
mono: {
family: 'Fira Code',
fallback: 'ui-monospace, Menlo, monospace',
stack: "'Fira Code', ui-monospace, Menlo, monospace",
weights: {
regular: 400,
medium: 500,
bold: 700,
},
},
} as const;
export type Fonts = typeof fonts;

View File

@@ -1 +1,5 @@
export const VERSION = '0.0.0';
export const VERSION = '0.0.1';
export { colors, type Colors } from './colors.js';
export { fonts, type Fonts } from './fonts.js';
export { spacing, radius, type Spacing, type Radius } from './spacing.js';

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