Compare commits

...

23 Commits

Author SHA1 Message Date
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
73 changed files with 3291 additions and 123 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

@@ -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,110 @@
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';
export const PLUGIN_REGISTRY = Symbol('PLUGIN_REGISTRY');
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.module.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

@@ -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(
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),
);
return this.brain.tasks.findAll();
}
@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

@@ -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 4: Memory & Intelligence (v0.0.5)
**Progress:** 4 / 8 milestones
**Current Milestone:** Phase 6: CLI & Tools (v0.0.7)
**Progress:** 6 / 8 milestones
**Status:** active
**Last Updated:** 2026-03-13 UTC
**Last Updated:** 2026-03-14 UTC
## Success Criteria
@@ -35,8 +35,8 @@
| 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) | done | — | — | 2026-03-12 | 2026-03-13 |
| 4 | ms-161 | Phase 4: Memory & Intelligence (v0.0.5) | not-started | — | — | — | — |
| 5 | ms-162 | Phase 5: Remote Control (v0.0.6) | not-started | — | — | — | — |
| 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) | not-started | — | — | — | — |
| 7 | ms-164 | Phase 7: Polish & Beta (v0.1.0) | not-started | — | — | — | — |
@@ -68,7 +68,8 @@
| 7 | claude-opus-4-6 | 2026-03-12 | — | context limit | P2-007 |
| 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 | — | active | P3-008 |
| 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

@@ -37,18 +37,18 @@
| 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 | 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 |
| 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 | 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 |
| 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 | 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 |

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,19 @@ 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
## Open Questions
(none at this time)
@@ -114,3 +127,9 @@ User confirmed: start the planning gate.
| 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

@@ -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/log",
"version": "0.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
@@ -15,6 +16,10 @@
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mosaic/db": "workspace:*",
"drizzle-orm": "^0.45.1"
},
"devDependencies": {
"typescript": "^5.8.0",
"vitest": "^2.0.0"

View File

@@ -0,0 +1,117 @@
import { eq, and, desc, lt, sql, type Db, agentLogs } from '@mosaic/db';
export type AgentLog = typeof agentLogs.$inferSelect;
export type NewAgentLog = typeof agentLogs.$inferInsert;
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type LogCategory = 'decision' | 'tool_use' | 'learning' | 'error' | 'general';
export type LogTier = 'hot' | 'warm' | 'cold';
export interface LogQuery {
userId?: string;
sessionId?: string;
level?: LogLevel;
category?: LogCategory;
tier?: LogTier;
since?: Date;
until?: Date;
limit?: number;
offset?: number;
}
export function createAgentLogsRepo(db: Db) {
return {
async ingest(entry: NewAgentLog): Promise<AgentLog> {
const rows = await db.insert(agentLogs).values(entry).returning();
return rows[0]!;
},
async ingestBatch(entries: NewAgentLog[]): Promise<AgentLog[]> {
if (entries.length === 0) return [];
return db.insert(agentLogs).values(entries).returning();
},
async query(params: LogQuery): Promise<AgentLog[]> {
const conditions = [];
if (params.userId) conditions.push(eq(agentLogs.userId, params.userId));
if (params.sessionId) conditions.push(eq(agentLogs.sessionId, params.sessionId));
if (params.level) conditions.push(eq(agentLogs.level, params.level));
if (params.category) conditions.push(eq(agentLogs.category, params.category));
if (params.tier) conditions.push(eq(agentLogs.tier, params.tier));
if (params.since) conditions.push(sql`${agentLogs.createdAt} >= ${params.since}`);
if (params.until) conditions.push(sql`${agentLogs.createdAt} <= ${params.until}`);
const where = conditions.length > 0 ? and(...conditions) : undefined;
return db
.select()
.from(agentLogs)
.where(where)
.orderBy(desc(agentLogs.createdAt))
.limit(params.limit ?? 100)
.offset(params.offset ?? 0);
},
async findById(id: string): Promise<AgentLog | undefined> {
const rows = await db.select().from(agentLogs).where(eq(agentLogs.id, id));
return rows[0];
},
/**
* Transition hot logs older than the cutoff to warm tier.
* Returns the number of logs transitioned.
*/
async promoteToWarm(olderThan: Date): Promise<number> {
const result = await db
.update(agentLogs)
.set({ tier: 'warm', summarizedAt: new Date() })
.where(and(eq(agentLogs.tier, 'hot'), lt(agentLogs.createdAt, olderThan)))
.returning();
return result.length;
},
/**
* Transition warm logs older than the cutoff to cold tier.
*/
async promoteToCold(olderThan: Date): Promise<number> {
const result = await db
.update(agentLogs)
.set({ tier: 'cold', archivedAt: new Date() })
.where(and(eq(agentLogs.tier, 'warm'), lt(agentLogs.createdAt, olderThan)))
.returning();
return result.length;
},
/**
* Delete cold logs older than the retention period.
*/
async purge(olderThan: Date): Promise<number> {
const result = await db
.delete(agentLogs)
.where(and(eq(agentLogs.tier, 'cold'), lt(agentLogs.createdAt, olderThan)))
.returning();
return result.length;
},
/**
* Get hot logs ready for summarization (decisions + learnings).
*/
async getLogsForSummarization(olderThan: Date, limit = 100): Promise<AgentLog[]> {
return db
.select()
.from(agentLogs)
.where(
and(
eq(agentLogs.tier, 'hot'),
lt(agentLogs.createdAt, olderThan),
sql`${agentLogs.category} IN ('decision', 'learning', 'tool_use')`,
),
)
.orderBy(agentLogs.createdAt)
.limit(limit);
},
};
}
export type AgentLogsRepo = ReturnType<typeof createAgentLogsRepo>;

View File

@@ -1 +1,11 @@
export const VERSION = '0.0.0';
export { createLogService, type LogService } from './log-service.js';
export {
createAgentLogsRepo,
type AgentLogsRepo,
type AgentLog,
type NewAgentLog,
type LogLevel,
type LogCategory,
type LogTier,
type LogQuery,
} from './agent-logs.js';

View File

@@ -0,0 +1,12 @@
import type { Db } from '@mosaic/db';
import { createAgentLogsRepo, type AgentLogsRepo } from './agent-logs.js';
export interface LogService {
logs: AgentLogsRepo;
}
export function createLogService(db: Db): LogService {
return {
logs: createAgentLogsRepo(db),
};
}

View File

@@ -1,6 +1,7 @@
{
"name": "@mosaic/memory",
"version": "0.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
@@ -16,7 +17,9 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mosaic/types": "workspace:*"
"@mosaic/db": "workspace:*",
"@mosaic/types": "workspace:*",
"drizzle-orm": "^0.45.1"
},
"devDependencies": {
"typescript": "^5.8.0",

View File

@@ -1 +1,15 @@
export const VERSION = '0.0.0';
export { createMemory, type Memory } from './memory.js';
export {
createPreferencesRepo,
type PreferencesRepo,
type Preference,
type NewPreference,
} from './preferences.js';
export {
createInsightsRepo,
type InsightsRepo,
type Insight,
type NewInsight,
type SearchResult,
} from './insights.js';
export type { VectorStore, VectorSearchResult, EmbeddingProvider } from './vector-store.js';

View File

@@ -0,0 +1,89 @@
import { eq, and, desc, sql, lt, type Db, insights } from '@mosaic/db';
export type Insight = typeof insights.$inferSelect;
export type NewInsight = typeof insights.$inferInsert;
export interface SearchResult {
insight: Insight;
distance: number;
}
export function createInsightsRepo(db: Db) {
return {
async findByUser(userId: string, limit = 50): Promise<Insight[]> {
return db
.select()
.from(insights)
.where(eq(insights.userId, userId))
.orderBy(desc(insights.createdAt))
.limit(limit);
},
async findById(id: string): Promise<Insight | undefined> {
const rows = await db.select().from(insights).where(eq(insights.id, id));
return rows[0];
},
async create(data: NewInsight): Promise<Insight> {
const rows = await db.insert(insights).values(data).returning();
return rows[0]!;
},
async update(id: string, data: Partial<NewInsight>): Promise<Insight | undefined> {
const rows = await db
.update(insights)
.set({ ...data, updatedAt: new Date() })
.where(eq(insights.id, id))
.returning();
return rows[0];
},
async remove(id: string): Promise<boolean> {
const rows = await db.delete(insights).where(eq(insights.id, id)).returning();
return rows.length > 0;
},
/**
* Semantic search using pgvector cosine distance.
* Requires the vector extension and an embedding for the query.
*/
async searchByEmbedding(
userId: string,
queryEmbedding: number[],
limit = 10,
maxDistance = 0.8,
): Promise<SearchResult[]> {
const embeddingStr = `[${queryEmbedding.join(',')}]`;
const rows = await db.execute(sql`
SELECT *,
(embedding <=> ${embeddingStr}::vector) AS distance
FROM insights
WHERE user_id = ${userId}
AND embedding IS NOT NULL
AND (embedding <=> ${embeddingStr}::vector) < ${maxDistance}
ORDER BY distance ASC
LIMIT ${limit}
`);
return rows as unknown as SearchResult[];
},
/**
* Decay relevance scores for old insights that haven't been accessed recently.
*/
async decayOldInsights(olderThan: Date, decayFactor = 0.95): Promise<number> {
const result = await db
.update(insights)
.set({
relevanceScore: sql`${insights.relevanceScore} * ${decayFactor}`,
decayedAt: new Date(),
updatedAt: new Date(),
})
.where(and(lt(insights.updatedAt, olderThan), sql`${insights.relevanceScore} > 0.1`))
.returning();
return result.length;
},
};
}
export type InsightsRepo = ReturnType<typeof createInsightsRepo>;

View File

@@ -0,0 +1,15 @@
import type { Db } from '@mosaic/db';
import { createPreferencesRepo, type PreferencesRepo } from './preferences.js';
import { createInsightsRepo, type InsightsRepo } from './insights.js';
export interface Memory {
preferences: PreferencesRepo;
insights: InsightsRepo;
}
export function createMemory(db: Db): Memory {
return {
preferences: createPreferencesRepo(db),
insights: createInsightsRepo(db),
};
}

View File

@@ -0,0 +1,59 @@
import { eq, and, type Db, preferences } from '@mosaic/db';
export type Preference = typeof preferences.$inferSelect;
export type NewPreference = typeof preferences.$inferInsert;
export function createPreferencesRepo(db: Db) {
return {
async findByUser(userId: string): Promise<Preference[]> {
return db.select().from(preferences).where(eq(preferences.userId, userId));
},
async findByUserAndKey(userId: string, key: string): Promise<Preference | undefined> {
const rows = await db
.select()
.from(preferences)
.where(and(eq(preferences.userId, userId), eq(preferences.key, key)));
return rows[0];
},
async findByUserAndCategory(
userId: string,
category: Preference['category'],
): Promise<Preference[]> {
return db
.select()
.from(preferences)
.where(and(eq(preferences.userId, userId), eq(preferences.category, category)));
},
async upsert(data: NewPreference): Promise<Preference> {
const existing = await db
.select()
.from(preferences)
.where(and(eq(preferences.userId, data.userId), eq(preferences.key, data.key)));
if (existing[0]) {
const rows = await db
.update(preferences)
.set({ value: data.value, category: data.category, updatedAt: new Date() })
.where(eq(preferences.id, existing[0].id))
.returning();
return rows[0]!;
}
const rows = await db.insert(preferences).values(data).returning();
return rows[0]!;
},
async remove(userId: string, key: string): Promise<boolean> {
const rows = await db
.delete(preferences)
.where(and(eq(preferences.userId, userId), eq(preferences.key, key)))
.returning();
return rows.length > 0;
},
};
}
export type PreferencesRepo = ReturnType<typeof createPreferencesRepo>;

View File

@@ -0,0 +1,39 @@
/**
* VectorStore interface — abstraction over pgvector that allows future
* swap to Qdrant, Pinecone, etc.
*/
export interface VectorStore {
/** Store an embedding with an associated document ID. */
store(documentId: string, embedding: number[], metadata?: Record<string, unknown>): Promise<void>;
/** Search for similar embeddings, returning document IDs and distances. */
search(
queryEmbedding: number[],
limit?: number,
filter?: Record<string, unknown>,
): Promise<VectorSearchResult[]>;
/** Delete an embedding by document ID. */
remove(documentId: string): Promise<void>;
}
export interface VectorSearchResult {
documentId: string;
distance: number;
metadata?: Record<string, unknown>;
}
/**
* EmbeddingProvider interface — generates embeddings from text.
* Implemented by the gateway using the configured LLM provider.
*/
export interface EmbeddingProvider {
/** Generate an embedding vector for the given text. */
embed(text: string): Promise<number[]>;
/** Generate embeddings for multiple texts in batch. */
embedBatch(texts: string[]): Promise<number[][]>;
/** The dimensionality of the embeddings this provider generates. */
dimensions: number;
}

View File

@@ -0,0 +1,23 @@
# @mosaic/telegram-plugin
`@mosaic/telegram-plugin` connects a Telegram bot to the Mosaic gateway chat namespace so Telegram chats can participate in the same conversation flow as the web, TUI, and Discord channels.
## Required Environment Variables
- `TELEGRAM_BOT_TOKEN`: Bot token issued by BotFather
- `TELEGRAM_GATEWAY_URL`: Base URL for the Mosaic gateway, for example `http://localhost:3000`
## What It Does
- Launches a Telegram bot with `telegraf`
- Connects to `${TELEGRAM_GATEWAY_URL}/chat` with `socket.io-client`
- Maps Telegram `chat.id` values to Mosaic `conversationId` values
- Forwards inbound Telegram text messages to the gateway as user messages
- Buffers `agent:start` / `agent:text` / `agent:end` socket events and sends the completed response back to the Telegram chat
## Getting a Bot Token
1. Open Telegram and start a chat with `@BotFather`
2. Run `/newbot`
3. Follow the prompts to name the bot and choose a username
4. Copy the generated token and assign it to `TELEGRAM_BOT_TOKEN`

View File

@@ -18,5 +18,9 @@
"devDependencies": {
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"dependencies": {
"socket.io-client": "^4.8.0",
"telegraf": "^4.16.3"
}
}

View File

@@ -1 +1,187 @@
export const VERSION = '0.0.0';
import { Telegraf } from 'telegraf';
import { io, type Socket } from 'socket.io-client';
interface TelegramPluginConfig {
token: string;
gatewayUrl: string;
}
interface TelegramUser {
is_bot?: boolean;
}
interface TelegramChat {
id: number;
}
interface TelegramTextMessage {
chat: TelegramChat;
from?: TelegramUser;
text: string;
}
class TelegramPlugin {
readonly name = 'telegram';
private bot: Telegraf;
private socket: Socket | null = null;
private config: TelegramPluginConfig;
/** Map Telegram chat ID → Mosaic conversation ID */
private chatConversations = new Map<string, string>();
/** Track in-flight responses to avoid duplicate streaming */
private pendingResponses = new Map<string, string>();
constructor(config: TelegramPluginConfig) {
this.config = config;
this.bot = new Telegraf(this.config.token);
}
async start(): Promise<void> {
// Connect to gateway WebSocket
this.socket = io(`${this.config.gatewayUrl}/chat`, {
transports: ['websocket'],
});
this.socket.on('connect', () => {
console.log('[telegram] Connected to gateway');
});
this.socket.on('disconnect', (reason: string) => {
console.error(`[telegram] Disconnected from gateway: ${reason}`);
this.pendingResponses.clear();
});
this.socket.on('connect_error', (err: Error) => {
console.error(`[telegram] Gateway connection error: ${err.message}`);
});
// Handle streaming text from gateway
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
const pending = this.pendingResponses.get(data.conversationId);
if (pending !== undefined) {
this.pendingResponses.set(data.conversationId, pending + data.text);
}
});
// When agent finishes, send the accumulated response
this.socket.on('agent:end', (data: { conversationId: string }) => {
const text = this.pendingResponses.get(data.conversationId);
if (text) {
this.pendingResponses.delete(data.conversationId);
this.sendToTelegram(data.conversationId, text).catch((err) => {
console.error(`[telegram] Error sending response for ${data.conversationId}:`, err);
});
}
});
this.socket.on('agent:start', (data: { conversationId: string }) => {
this.pendingResponses.set(data.conversationId, '');
});
// Set up Telegram message handler
this.bot.on('message', (ctx) => {
const message = this.getTextMessage(ctx.message);
if (message) {
this.handleTelegramMessage(message);
}
});
await this.bot.launch();
}
async stop(): Promise<void> {
this.bot.stop('SIGTERM');
this.socket?.disconnect();
}
private handleTelegramMessage(message: TelegramTextMessage): void {
// Ignore bot messages
if (message.from?.is_bot) return;
const content = message.text.trim();
if (!content) return;
// Get or create conversation for this Telegram chat
const chatId = String(message.chat.id);
let conversationId = this.chatConversations.get(chatId);
if (!conversationId) {
conversationId = `telegram-${chatId}`;
this.chatConversations.set(chatId, conversationId);
}
// Send to gateway
if (!this.socket?.connected) {
console.error(`[telegram] Cannot forward message: not connected to gateway. chat=${chatId}`);
return;
}
this.socket.emit('message', {
conversationId,
content,
role: 'user',
});
}
private getTextMessage(message: unknown): TelegramTextMessage | null {
if (!message || typeof message !== 'object') return null;
const candidate = message as Partial<TelegramTextMessage>;
if (typeof candidate.text !== 'string') return null;
if (!candidate.chat || typeof candidate.chat.id !== 'number') return null;
return {
chat: candidate.chat,
from: candidate.from,
text: candidate.text,
};
}
private async sendToTelegram(conversationId: string, text: string): Promise<void> {
// Find the Telegram chat for this conversation
const chatId = Array.from(this.chatConversations.entries()).find(
([, convId]) => convId === conversationId,
)?.[0];
if (!chatId) {
console.error(`[telegram] No chat found for conversation ${conversationId}`);
return;
}
// Chunk responses for Telegram's 4096-char limit
const chunks = this.chunkText(text, 4000);
for (const chunk of chunks) {
try {
await this.bot.telegram.sendMessage(chatId, chunk);
} catch (err) {
console.error(`[telegram] Failed to send message to chat ${chatId}:`, err);
}
}
}
private chunkText(text: string, maxLength: number): string[] {
if (text.length <= maxLength) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= maxLength) {
chunks.push(remaining);
break;
}
// Try to break at a newline
let breakPoint = remaining.lastIndexOf('\n', maxLength);
if (breakPoint <= 0) breakPoint = maxLength;
chunks.push(remaining.slice(0, breakPoint));
remaining = remaining.slice(breakPoint).trimStart();
}
return chunks;
}
}
export { TelegramPlugin };
export type { TelegramPluginConfig };
export const VERSION = '0.0.5';

200
pnpm-lock.yaml generated
View File

@@ -41,6 +41,9 @@ importers:
apps/gateway:
dependencies:
'@fastify/helmet':
specifier: ^13.0.2
version: 13.0.2
'@mariozechner/pi-ai':
specifier: ~0.57.1
version: 0.57.1(ws@8.19.0)(zod@4.3.6)
@@ -59,6 +62,18 @@ importers:
'@mosaic/db':
specifier: workspace:^
version: link:../../packages/db
'@mosaic/discord-plugin':
specifier: workspace:^
version: link:../../plugins/discord
'@mosaic/log':
specifier: workspace:^
version: link:../../packages/log
'@mosaic/memory':
specifier: workspace:^
version: link:../../packages/memory
'@mosaic/telegram-plugin':
specifier: workspace:^
version: link:../../plugins/telegram
'@mosaic/types':
specifier: workspace:^
version: link:../../packages/types
@@ -74,6 +89,9 @@ importers:
'@nestjs/platform-socket.io':
specifier: ^11.0.0
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(rxjs@7.8.2)
'@nestjs/throttler':
specifier: ^6.5.0
version: 6.5.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)
'@nestjs/websockets':
specifier: ^11.0.0
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -104,9 +122,18 @@ importers:
better-auth:
specifier: ^1.5.5
version: 1.5.5(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(lightningcss@1.31.1))
class-transformer:
specifier: ^0.5.1
version: 0.5.1
class-validator:
specifier: ^0.15.1
version: 0.15.1
fastify:
specifier: ^5.0.0
version: 5.8.2
node-cron:
specifier: ^4.2.1
version: 4.2.1
reflect-metadata:
specifier: ^0.2.0
version: 0.2.2
@@ -123,6 +150,9 @@ importers:
'@types/node':
specifier: ^22.0.0
version: 22.19.15
'@types/node-cron':
specifier: ^3.0.11
version: 3.0.11
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
@@ -321,6 +351,13 @@ importers:
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
packages/log:
dependencies:
'@mosaic/db':
specifier: workspace:*
version: link:../db
drizzle-orm:
specifier: ^0.45.1
version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8)
devDependencies:
typescript:
specifier: ^5.8.0
@@ -331,9 +368,15 @@ importers:
packages/memory:
dependencies:
'@mosaic/db':
specifier: workspace:*
version: link:../db
'@mosaic/types':
specifier: workspace:*
version: link:../types
drizzle-orm:
specifier: ^0.45.1
version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(kysely@0.28.11)(postgres@3.4.8)
devDependencies:
typescript:
specifier: ^5.8.0
@@ -421,6 +464,13 @@ importers:
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
plugins/telegram:
dependencies:
socket.io-client:
specifier: ^4.8.0
version: 4.8.3
telegraf:
specifier: ^4.16.3
version: 4.16.3
devDependencies:
typescript:
specifier: ^5.8.0
@@ -1336,6 +1386,9 @@ packages:
'@fastify/forwarded@3.0.1':
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
'@fastify/helmet@13.0.2':
resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==}
'@fastify/merge-json-schemas@0.2.1':
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
@@ -1685,6 +1738,13 @@ packages:
'@nestjs/websockets': ^11.0.0
rxjs: ^7.1.0
'@nestjs/throttler@6.5.0':
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
peerDependencies:
'@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
reflect-metadata: ^0.1.13 || ^0.2.0
'@nestjs/websockets@11.1.16':
resolution: {integrity: sha512-kfLhCFsq6139JVFCQpbFB6LOEjZzdpE7JzXsZtRbVjqmsgTKVSIh8gKRgzpcq27rbLNqHhhZavboOltOfSxZow==}
peerDependencies:
@@ -2691,6 +2751,9 @@ packages:
'@tailwindcss/postcss@4.2.1':
resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==}
'@telegraf/types@7.1.0':
resolution: {integrity: sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==}
'@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'}
@@ -2728,6 +2791,9 @@ packages:
'@types/mysql@2.15.27':
resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==}
'@types/node-cron@3.0.11':
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
'@types/node@22.19.15':
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
@@ -2870,6 +2936,10 @@ packages:
resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==}
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@@ -3072,12 +3142,21 @@ packages:
resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==}
engines: {node: '>=20.19.0'}
buffer-alloc-unsafe@1.1.0:
resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==}
buffer-alloc@1.2.0:
resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==}
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-fill@1.0.0:
resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -3497,6 +3576,10 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
@@ -3681,6 +3764,10 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
helmet@8.1.0:
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
engines: {node: '>=18.0.0'}
highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
@@ -4108,6 +4195,10 @@ packages:
socks:
optional: true
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -4155,11 +4246,24 @@ packages:
sass:
optional: true
node-cron@4.2.1:
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
engines: {node: '>=6.0.0'}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -4223,6 +4327,10 @@ packages:
resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
engines: {node: '>=8'}
p-timeout@4.1.0:
resolution: {integrity: sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==}
engines: {node: '>=10'}
pac-proxy-agent@7.2.0:
resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==}
engines: {node: '>= 14'}
@@ -4493,6 +4601,9 @@ packages:
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-compare@1.1.4:
resolution: {integrity: sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==}
safe-regex2@5.1.0:
resolution: {integrity: sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==}
hasBin: true
@@ -4501,6 +4612,10 @@ packages:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
sandwich-stream@2.0.2:
resolution: {integrity: sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==}
engines: {node: '>= 0.10'}
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
@@ -4678,6 +4793,11 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
telegraf@4.16.3:
resolution: {integrity: sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==}
engines: {node: ^12.20.0 || >=14.13.1}
hasBin: true
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@@ -4723,6 +4843,9 @@ packages:
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
engines: {node: '>=14.16'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
tr46@5.1.1:
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
engines: {node: '>=18'}
@@ -4900,6 +5023,9 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
@@ -4908,6 +5034,9 @@ packages:
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
engines: {node: '>=18'}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -5892,6 +6021,11 @@ snapshots:
'@fastify/forwarded@3.0.1': {}
'@fastify/helmet@13.0.2':
dependencies:
fastify-plugin: 5.1.0
helmet: 8.1.0
'@fastify/merge-json-schemas@0.2.1':
dependencies:
dequal: 2.0.3
@@ -6262,6 +6396,12 @@ snapshots:
- supports-color
- utf-8-validate
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
'@nestjs/websockets@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -7490,6 +7630,8 @@ snapshots:
postcss: 8.5.8
tailwindcss: 4.2.1
'@telegraf/types@7.1.0': {}
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3
@@ -7529,6 +7671,8 @@ snapshots:
dependencies:
'@types/node': 22.19.15
'@types/node-cron@3.0.11': {}
'@types/node@22.19.15':
dependencies:
undici-types: 6.21.0
@@ -7720,6 +7864,10 @@ snapshots:
'@vladfrangu/async_event_emitter@2.4.7': {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
abstract-logging@2.0.1: {}
accepts@1.3.8:
@@ -7864,10 +8012,19 @@ snapshots:
bson@7.2.0: {}
buffer-alloc-unsafe@1.1.0: {}
buffer-alloc@1.2.0:
dependencies:
buffer-alloc-unsafe: 1.1.0
buffer-fill: 1.0.0
buffer-crc32@0.2.13: {}
buffer-equal-constant-time@1.0.1: {}
buffer-fill@1.0.0: {}
buffer-from@1.1.2: {}
cac@6.7.14: {}
@@ -8313,6 +8470,8 @@ snapshots:
esutils@2.0.3: {}
event-target-shim@5.0.1: {}
eventemitter3@5.0.4: {}
execa@8.0.1:
@@ -8556,6 +8715,8 @@ snapshots:
has-flag@4.0.0: {}
helmet@8.1.0: {}
highlight.js@10.7.3: {}
hosted-git-info@9.0.2:
@@ -8939,6 +9100,8 @@ snapshots:
optionalDependencies:
socks: 2.8.7
mri@1.2.0: {}
ms@2.1.3: {}
mz@2.7.0:
@@ -8982,8 +9145,14 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
node-cron@4.2.1: {}
node-domexception@1.0.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-fetch@3.3.2:
dependencies:
data-uri-to-buffer: 4.0.1
@@ -9043,6 +9212,8 @@ snapshots:
'@types/retry': 0.12.0
retry: 0.13.1
p-timeout@4.1.0: {}
pac-proxy-agent@7.2.0:
dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0
@@ -9327,12 +9498,18 @@ snapshots:
safe-buffer@5.2.1: {}
safe-compare@1.1.4:
dependencies:
buffer-alloc: 1.2.0
safe-regex2@5.1.0:
dependencies:
ret: 0.5.0
safe-stable-stringify@2.5.0: {}
sandwich-stream@2.0.2: {}
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0
@@ -9539,6 +9716,20 @@ snapshots:
tapable@2.3.0: {}
telegraf@4.16.3:
dependencies:
'@telegraf/types': 7.1.0
abort-controller: 3.0.0
debug: 4.4.3
mri: 1.2.0
node-fetch: 2.7.0
p-timeout: 4.1.0
safe-compare: 1.1.4
sandwich-stream: 2.0.2
transitivePeerDependencies:
- encoding
- supports-color
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
@@ -9578,6 +9769,8 @@ snapshots:
'@tokenizer/token': 0.3.0
ieee754: 1.2.1
tr46@0.0.3: {}
tr46@5.1.1:
dependencies:
punycode: 2.3.1
@@ -9732,6 +9925,8 @@ snapshots:
web-streams-polyfill@3.3.3: {}
webidl-conversions@3.0.1: {}
webidl-conversions@7.0.0: {}
whatwg-url@14.2.0:
@@ -9739,6 +9934,11 @@ snapshots:
tr46: 5.1.1
webidl-conversions: 7.0.0
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@2.0.2:
dependencies:
isexe: 2.0.0

View File

@@ -11,7 +11,7 @@
"dependsOn": ["^lint"]
},
"typecheck": {
"dependsOn": ["^typecheck"]
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"],