From 2130fc8a316dc4dced7708998cfdb43d12594510 Mon Sep 17 00:00:00 2001 From: Ivan Jankovic <47274968+ivanja81@users.noreply.github.com> Date: Sun, 25 Jan 2026 21:20:31 +0100 Subject: [PATCH] Demo Ivan --- demo/ARCHITECTURE.md | 217 +++++++++ demo/PROJECT.md | 152 ++++++ demo/README.md | 220 +++++++++ demo/STANDARDS.md | 434 ++++++++++++++++++ demo/TECH_STACK.md | 222 +++++++++ demo/backend/.env.example | 22 + demo/backend/package.json | 64 +++ demo/backend/src/cli.ts | 201 ++++++++ demo/backend/src/config/index.ts | 114 +++++ demo/backend/src/core/agent-config.ts | 182 ++++++++ demo/backend/src/core/copilot-client.ts | 184 ++++++++ demo/backend/src/core/index.ts | 29 ++ demo/backend/src/core/session-manager.ts | 432 +++++++++++++++++ demo/backend/src/core/tool-registry.ts | 121 +++++ demo/backend/src/index.ts | 89 ++++ .../src/services/graph/graph-client.ts | 211 +++++++++ demo/backend/src/services/graph/index.ts | 24 + .../src/services/graph/sharepoint.service.ts | 280 +++++++++++ .../src/services/graph/teams.service.ts | 198 ++++++++ demo/backend/src/tools/index.ts | 55 +++ demo/backend/src/tools/sharepoint.tool.ts | 244 ++++++++++ demo/backend/src/tools/teams.tool.ts | 330 +++++++++++++ demo/backend/src/utils/errors.ts | 184 ++++++++ demo/backend/src/utils/index.ts | 16 + demo/backend/src/utils/logger.ts | 52 +++ demo/backend/tsconfig.json | 28 ++ demo/common/types/api.types.ts | 53 +++ demo/common/types/index.ts | 12 + demo/common/types/m365.types.ts | 78 ++++ demo/common/types/session.types.ts | 65 +++ demo/skills/enterprise-knowledge/SKILL.md | 77 ++++ 31 files changed, 4590 insertions(+) create mode 100644 demo/ARCHITECTURE.md create mode 100644 demo/PROJECT.md create mode 100644 demo/README.md create mode 100644 demo/STANDARDS.md create mode 100644 demo/TECH_STACK.md create mode 100644 demo/backend/.env.example create mode 100644 demo/backend/package.json create mode 100644 demo/backend/src/cli.ts create mode 100644 demo/backend/src/config/index.ts create mode 100644 demo/backend/src/core/agent-config.ts create mode 100644 demo/backend/src/core/copilot-client.ts create mode 100644 demo/backend/src/core/index.ts create mode 100644 demo/backend/src/core/session-manager.ts create mode 100644 demo/backend/src/core/tool-registry.ts create mode 100644 demo/backend/src/index.ts create mode 100644 demo/backend/src/services/graph/graph-client.ts create mode 100644 demo/backend/src/services/graph/index.ts create mode 100644 demo/backend/src/services/graph/sharepoint.service.ts create mode 100644 demo/backend/src/services/graph/teams.service.ts create mode 100644 demo/backend/src/tools/index.ts create mode 100644 demo/backend/src/tools/sharepoint.tool.ts create mode 100644 demo/backend/src/tools/teams.tool.ts create mode 100644 demo/backend/src/utils/errors.ts create mode 100644 demo/backend/src/utils/index.ts create mode 100644 demo/backend/src/utils/logger.ts create mode 100644 demo/backend/tsconfig.json create mode 100644 demo/common/types/api.types.ts create mode 100644 demo/common/types/index.ts create mode 100644 demo/common/types/m365.types.ts create mode 100644 demo/common/types/session.types.ts create mode 100644 demo/skills/enterprise-knowledge/SKILL.md diff --git a/demo/ARCHITECTURE.md b/demo/ARCHITECTURE.md new file mode 100644 index 0000000000..44aa7e4ea9 --- /dev/null +++ b/demo/ARCHITECTURE.md @@ -0,0 +1,217 @@ +# M365 Knowledge Assistant - Architecture + +## Overview + +An AI-powered knowledge assistant that helps employees find information across Microsoft 365 services (SharePoint, Teams, OneDrive, Outlook) using the GitHub Copilot SDK. + +## System Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ CLIENT LAYER │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Teams Bot │ │ Web UI │ │ CLI Tool │ │ +│ │ (Future) │ │ (React) │ │ (Current) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ └─────────────────┼─────────────────┘ │ +│ ▼ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ API LAYER │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Express.js REST API │ │ +│ │ /api/chat - Send messages, stream responses │ │ +│ │ /api/sessions - Manage conversation sessions │ │ +│ │ /api/health - Health checks and monitoring │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +├───────────────────────────┼─────────────────────────────────────────────────┤ +│ CORE LAYER │ +├───────────────────────────┼─────────────────────────────────────────────────┤ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Copilot SDK Client │ │ +│ │ • Session Management │ │ +│ │ • Tool Registration │ │ +│ │ • Event Streaming │ │ +│ │ • Custom Agents │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────┼─────────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ M365 │ │ GitHub │ │ Custom │ │ +│ │ Tools │ │ MCP │ │ Skills │ │ +│ └────────────┘ └────────────┘ └────────────┘ │ +│ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ INTEGRATION LAYER │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ SharePoint │ │ Teams │ │ OneDrive │ │ Outlook │ │ +│ │ Service │ │ Service │ │ Service │ │ Service │ │ +│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ +│ │ │ │ │ │ +│ └────────────────┴────────────────┴────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Microsoft Graph Client │ │ +│ │ • Authentication (MSAL) │ │ +│ │ • Token Management │ │ +│ │ • API Abstraction │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Directory Structure + +``` +demo/ +├── ARCHITECTURE.md # This file +├── TECH_STACK.md # Technology decisions +├── PROJECT.md # Current tasks and roadmap +├── STANDARDS.md # Coding standards +│ +├── backend/ +│ ├── src/ +│ │ ├── api/ # Express routes and controllers +│ │ │ ├── routes/ +│ │ │ │ ├── chat.routes.ts +│ │ │ │ ├── session.routes.ts +│ │ │ │ └── health.routes.ts +│ │ │ ├── controllers/ +│ │ │ │ ├── chat.controller.ts +│ │ │ │ ├── session.controller.ts +│ │ │ │ └── health.controller.ts +│ │ │ └── middleware/ +│ │ │ ├── auth.middleware.ts +│ │ │ ├── error.middleware.ts +│ │ │ └── validation.middleware.ts +│ │ │ +│ │ ├── core/ # Copilot SDK integration +│ │ │ ├── copilot-client.ts # SDK client wrapper +│ │ │ ├── session-manager.ts # Session lifecycle +│ │ │ ├── tool-registry.ts # Tool registration +│ │ │ └── agent-config.ts # Custom agent definitions +│ │ │ +│ │ ├── tools/ # Custom Copilot tools +│ │ │ ├── index.ts # Tool exports +│ │ │ ├── sharepoint.tool.ts +│ │ │ ├── teams.tool.ts +│ │ │ ├── onedrive.tool.ts +│ │ │ └── outlook.tool.ts +│ │ │ +│ │ ├── services/ # Business logic & integrations +│ │ │ ├── graph/ +│ │ │ │ ├── graph-client.ts +│ │ │ │ ├── sharepoint.service.ts +│ │ │ │ ├── teams.service.ts +│ │ │ │ ├── onedrive.service.ts +│ │ │ │ └── outlook.service.ts +│ │ │ └── auth/ +│ │ │ ├── msal-client.ts +│ │ │ └── token-cache.ts +│ │ │ +│ │ ├── config/ # Configuration +│ │ │ ├── index.ts +│ │ │ ├── copilot.config.ts +│ │ │ └── graph.config.ts +│ │ │ +│ │ ├── utils/ # Shared utilities +│ │ │ ├── logger.ts +│ │ │ ├── errors.ts +│ │ │ └── validators.ts +│ │ │ +│ │ └── index.ts # Application entry point +│ │ +│ ├── tests/ +│ │ ├── unit/ +│ │ ├── integration/ +│ │ └── e2e/ +│ │ +│ ├── package.json +│ ├── tsconfig.json +│ └── Dockerfile +│ +├── common/ # Shared types and constants +│ ├── types/ +│ │ ├── api.types.ts +│ │ ├── session.types.ts +│ │ ├── tool.types.ts +│ │ └── m365.types.ts +│ └── constants/ +│ └── index.ts +│ +├── skills/ # Copilot SDK skills +│ └── enterprise-knowledge/ +│ └── SKILL.md +│ +├── scripts/ # Build and deployment scripts +│ ├── setup.sh +│ └── deploy.sh +│ +├── .github/ +│ └── workflows/ +│ └── ci.yml +│ +├── .env.example +├── docker-compose.yml +└── README.md +``` + +## Data Flow + +### 1. User Query Flow +``` +User Input → API Layer → Core Layer → Tool Execution → Graph API → Response +``` + +### 2. Session Management +``` +Create Session → Register Tools → Configure Agent → Process Messages → Persist State +``` + +### 3. Tool Invocation +``` +Copilot Decision → Tool Registry → Service Layer → Graph Client → External API +``` + +## Key Components + +### 1. Copilot Client Wrapper (`/backend/src/core/copilot-client.ts`) +- Manages SDK lifecycle (start/stop) +- Handles connection to Copilot CLI +- Provides singleton access + +### 2. Session Manager (`/backend/src/core/session-manager.ts`) +- Creates and resumes sessions +- Manages session persistence +- Handles multi-user scenarios + +### 3. Tool Registry (`/backend/src/core/tool-registry.ts`) +- Registers M365 tools with Copilot +- Manages tool handlers +- Provides type-safe tool definitions + +### 4. M365 Services (`/backend/src/services/graph/`) +- Abstracts Microsoft Graph API +- Handles authentication via MSAL +- Provides typed responses + +## Security Boundaries + +1. **Authentication**: MSAL for M365, JWT for API +2. **Authorization**: Graph permissions, role-based access +3. **Data Flow**: All M365 data through Graph API only +4. **Secrets**: Environment variables, never hardcoded + +## Extension Points + +1. Add new tools in `/backend/src/tools/` +2. Add new services in `/backend/src/services/` +3. Add new skills in `/skills/` +4. Add new agents in `/backend/src/core/agent-config.ts` diff --git a/demo/PROJECT.md b/demo/PROJECT.md new file mode 100644 index 0000000000..85a537771b --- /dev/null +++ b/demo/PROJECT.md @@ -0,0 +1,152 @@ +# M365 Knowledge Assistant - Project Tracker + +## Project Overview + +**Goal**: Build an AI-powered assistant that helps employees find information across Microsoft 365 using the GitHub Copilot SDK. + +**Status**: 🚧 In Development + +--- + +## Current Sprint: Foundation Setup + +### ✅ Completed Tasks + +- [x] Create architecture documentation (ARCHITECTURE.md) +- [x] Define technology stack (TECH_STACK.md) +- [x] Create project tracker (PROJECT.md) +- [x] Define coding standards (STANDARDS.md) +- [x] **Phase 1: Core Infrastructure** + - [x] Setup backend project structure (package.json, tsconfig.json) + - [x] Configure TypeScript + - [x] Implement Copilot client wrapper (`copilot-client.ts`) + - [x] Implement session manager (`session-manager.ts`) + - [x] Create tool registry pattern (`tool-registry.ts`) + - [x] Define custom agents (`agent-config.ts`) +- [x] **Phase 2: M365 Integration** + - [x] Setup Microsoft Graph client (`graph-client.ts`) + - [x] Implement MSAL authentication + - [x] Create SharePoint service (`sharepoint.service.ts`) + - [x] Create Teams service (`teams.service.ts`) +- [x] **Phase 3: Copilot Tools** + - [x] Implement SharePoint search tool (`sharepoint.tool.ts`) + - [x] Implement Teams tools (`teams.tool.ts`) +- [x] **Phase 6: CLI Interface** + - [x] Interactive CLI for testing (`cli.ts`) + - [x] Streaming response support + - [x] Command system (/new, /help, /agents, etc.) + +### 🔄 In Progress + +- [ ] **Phase 4: API Layer** + - [ ] Create Express server + - [ ] Implement chat routes + - [ ] Implement session routes + - [ ] Add middleware (auth, error, validation) + +### 📋 Up Next + +- [ ] **Phase 5: Extended M365 Tools** + - [ ] Implement OneDrive file tool + - [ ] Implement Outlook mail tool + +- [ ] **Phase 7: Testing & Polish** + - [ ] Unit tests for services + - [ ] Integration tests for tools + - [ ] E2E tests for API + - [ ] Documentation + +--- + +## Feature Roadmap + +### MVP (v0.1.0) +- [x] Architecture documentation +- [ ] Basic Copilot SDK integration +- [ ] SharePoint search tool +- [ ] CLI-based interaction +- [ ] Session persistence + +### v0.2.0 +- [ ] Teams message retrieval +- [ ] OneDrive file search +- [ ] REST API endpoints +- [ ] Streaming responses + +### v0.3.0 +- [ ] Outlook integration +- [ ] Custom agents (HR, IT, etc.) +- [ ] Skills system +- [ ] Multi-user sessions + +### Future +- [ ] Teams Bot integration +- [ ] Web UI (React) +- [ ] Power Platform connector +- [ ] Analytics dashboard + +--- + +## Technical Debt + +| Item | Priority | Description | +|------|----------|-------------| +| - | - | No technical debt yet | + +--- + +## Decisions Log + +| Date | Decision | Rationale | +|------|----------|-----------| +| 2026-01-25 | Use GitHub Copilot SDK | Production-tested agent runtime with tool orchestration | +| 2026-01-25 | TypeScript + Express | Team familiarity, mature ecosystem | +| 2026-01-25 | Zod for validation | Runtime type safety, JSON Schema support | + +--- + +## Dependencies & Blockers + +### External Dependencies +- [ ] GitHub Copilot CLI must be installed +- [ ] Azure AD App Registration required +- [ ] M365 tenant with appropriate licenses + +### Blockers +- None currently + +--- + +## Team Notes + +### Getting Started +1. Clone repository +2. Copy `.env.example` to `.env` +3. Configure Azure AD credentials +4. Install Copilot CLI +5. Run `npm install` +6. Run `npm run dev` + +### Useful Commands +```bash +# Development +npm run dev # Start with hot reload +npm run build # Build for production +npm run test # Run tests +npm run lint # Run linter + +# Copilot CLI +copilot --version # Check CLI version +copilot --server # Run in server mode +``` + +--- + +## Metrics & Goals + +| Metric | Target | Current | +|--------|--------|---------| +| Test Coverage | >80% | 0% | +| API Response Time | <500ms | N/A | +| Tool Execution Time | <2s | N/A | +| Documentation | 100% | 50% | diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000000..5385e8c06b --- /dev/null +++ b/demo/README.md @@ -0,0 +1,220 @@ +# M365 Knowledge Assistant + +An AI-powered knowledge assistant that helps employees find information across Microsoft 365 using the GitHub Copilot SDK. + +## Overview + +This application demonstrates how to build an enterprise-grade AI assistant using: + +- **GitHub Copilot SDK** - For AI agent capabilities, tool orchestration, and session management +- **Microsoft Graph API** - For accessing SharePoint, Teams, OneDrive, and Outlook data +- **Custom Tools** - Type-safe tool definitions that Copilot can invoke +- **Custom Agents** - Specialized AI personas for different use cases + +## Features + +### Current (MVP) + +- ✅ Interactive CLI interface +- ✅ SharePoint document search +- ✅ SharePoint site listing +- ✅ Teams listing and channel browsing +- ✅ Teams message retrieval +- ✅ Session management with streaming responses +- ✅ Custom agents (Knowledge, IT Helpdesk, HR) + +### Planned + +- [ ] REST API endpoints +- [ ] OneDrive file search +- [ ] Outlook email integration +- [ ] Web UI +- [ ] Teams Bot integration + +## Prerequisites + +1. **GitHub Copilot CLI** - Must be installed and authenticated + ```bash + # Verify installation + copilot --version + ``` + +2. **Node.js 20+** - Required runtime + +3. **Azure AD App Registration** - Required for Microsoft Graph access + - Create an app registration in Azure Portal + - Grant the following application permissions: + - `Sites.Read.All` (SharePoint) + - `Files.Read.All` (OneDrive) + - `ChannelMessage.Read.All` (Teams) + - `Mail.Read` (Outlook) + - Create a client secret + +## Setup + +1. **Clone and install dependencies:** + ```bash + cd demo/backend + npm install + ``` + +2. **Configure environment variables:** + ```bash + cp .env.example .env + ``` + + Edit `.env` and fill in your Azure AD credentials: + ``` + AZURE_TENANT_ID=your-tenant-id + AZURE_CLIENT_ID=your-client-id + AZURE_CLIENT_SECRET=your-client-secret + ``` + +3. **Start the application:** + ```bash + npm run dev + ``` + +## Usage + +### CLI Mode + +The application starts in interactive CLI mode. Type your questions naturally: + +``` +You: Find documents about quarterly reports +Assistant: 🔧 Using: search_sharepoint... ✓ +Found 5 document(s) matching "quarterly reports": + +📄 **Q4 2025 Report.docx** + URL: https://contoso.sharepoint.com/... + Preview: This quarterly report covers... + Modified by: John Smith + Last modified: 1/15/2026 +``` + +### Available Commands + +| Command | Description | +|---------|-------------| +| `/new` | Start a new conversation | +| `/agents` | List available AI agents | +| `/status` | Show session information | +| `/help` | Show help | +| `/exit` | Exit the application | + +### Example Queries + +- "Find documents about the new employee handbook" +- "What SharePoint sites are available?" +- "Show me the channels in the Engineering team" +- "Get recent messages from the General channel in Marketing" + +## Architecture + +``` +backend/ +├── src/ +│ ├── config/ # Configuration management +│ ├── core/ # Copilot SDK integration +│ │ ├── copilot-client.ts # SDK client wrapper +│ │ ├── session-manager.ts # Session lifecycle +│ │ ├── tool-registry.ts # Tool registration +│ │ └── agent-config.ts # Custom agents +│ ├── services/ # External integrations +│ │ └── graph/ # Microsoft Graph services +│ ├── tools/ # Copilot tool definitions +│ ├── utils/ # Shared utilities +│ ├── cli.ts # CLI interface +│ └── index.ts # Entry point +``` + +## Custom Agents + +The application includes three pre-configured agents: + +### @m365-knowledge (Default) +General-purpose assistant for finding information across M365. + +### @it-helpdesk +Specialized for IT support questions, searches IT documentation. + +### @hr-assistant +Answers HR policy questions, finds HR documents and forms. + +## Adding Custom Tools + +1. Create a new tool file in `src/tools/`: + ```typescript + import { defineTool } from '../core/tool-registry.js'; + + export const myTool = defineTool<{ param: string }>('my_tool', { + description: 'What this tool does', + parameters: { + type: 'object', + properties: { + param: { type: 'string', description: 'Parameter description' } + }, + required: ['param'] + }, + handler: async (params) => { + // Implementation + return { + textResultForLlm: 'Result for the AI', + resultType: 'success', + sessionLog: 'Log message', + toolTelemetry: {} + }; + } + }); + ``` + +2. Register the tool in `src/tools/index.ts`: + ```typescript + import { myTool } from './my-tool.js'; + + export function initializeTools(): void { + registerTool(myTool); + } + ``` + +## Development + +### Scripts + +```bash +npm run dev # Start with hot reload +npm run build # Build for production +npm run start # Run production build +npm run test # Run tests +npm run lint # Lint code +npm run typecheck # Type check +``` + +### Project Structure + +See [ARCHITECTURE.md](../ARCHITECTURE.md) for detailed architecture documentation. + +## Troubleshooting + +### "Failed to connect to Copilot CLI" + +1. Verify Copilot CLI is installed: `copilot --version` +2. Authenticate the CLI: `copilot auth login` +3. Check if running in server mode elsewhere + +### "Graph API request failed" + +1. Verify Azure AD credentials in `.env` +2. Check that app has required permissions +3. Ensure admin consent is granted for application permissions + +### "No results found" + +1. Try broader search terms +2. Verify you have access to the content +3. Check Graph API permissions + +## License + +MIT diff --git a/demo/STANDARDS.md b/demo/STANDARDS.md new file mode 100644 index 0000000000..18f4fd7041 --- /dev/null +++ b/demo/STANDARDS.md @@ -0,0 +1,434 @@ +# M365 Knowledge Assistant - Coding Standards + +## General Principles + +1. **Type Safety First**: Every function must be fully typed. No `any` unless absolutely necessary (document why). +2. **Single Responsibility**: Functions do one thing well. Keep them small (<50 lines). +3. **Explicit over Implicit**: Prefer explicit returns, imports, and error handling. +4. **Document Intent**: Comments explain WHY, not WHAT. Code explains WHAT. +5. **Fail Fast**: Validate inputs early, throw meaningful errors. + +--- + +## Naming Conventions + +### Files +``` +kebab-case.ts # All TypeScript files +kebab-case.test.ts # Test files +kebab-case.types.ts # Type definition files +SCREAMING_CASE.md # Documentation files +``` + +### Code +```typescript +// Variables and functions: camelCase +const userId = 'abc123'; +function getUserById(id: string): Promise {} + +// Classes and types: PascalCase +class SessionManager {} +interface UserProfile {} +type ToolResult = string | object; + +// Constants: SCREAMING_SNAKE_CASE +const MAX_RETRY_COUNT = 3; +const API_BASE_URL = '/api/v1'; + +// Private members: prefix with underscore +private _cache: Map; + +// Booleans: prefix with is/has/can/should +const isAuthenticated = true; +const hasPermission = false; +const canEdit = true; +``` + +### Directories +``` +kebab-case/ # All directories +``` + +--- + +## TypeScript Standards + +### Type Definitions + +```typescript +// ✅ DO: Use explicit types +function getUser(id: string): Promise { + // ... +} + +// ❌ DON'T: Implicit any +function getUser(id) { + // ... +} + +// ✅ DO: Use interfaces for objects +interface UserProfile { + id: string; + email: string; + displayName: string; +} + +// ✅ DO: Use type for unions/aliases +type ToolResult = SuccessResult | ErrorResult; +type UserId = string; + +// ✅ DO: Use readonly for immutable data +interface Config { + readonly apiUrl: string; + readonly timeout: number; +} +``` + +### Function Signatures + +```typescript +// ✅ DO: Named parameters for complex functions +interface SearchOptions { + query: string; + siteId?: string; + limit?: number; + includeContent?: boolean; +} + +async function searchSharePoint(options: SearchOptions): Promise { + const { query, siteId, limit = 10, includeContent = false } = options; + // ... +} + +// ❌ DON'T: Many positional parameters +async function searchSharePoint( + query: string, + siteId: string, + limit: number, + includeContent: boolean +) {} +``` + +### Error Handling + +```typescript +// ✅ DO: Custom error classes +export class GraphApiError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly endpoint: string + ) { + super(message); + this.name = 'GraphApiError'; + } +} + +// ✅ DO: Type-safe error handling +try { + const result = await graphClient.get('/users'); + return result; +} catch (error) { + if (error instanceof GraphApiError) { + logger.error({ err: error, endpoint: error.endpoint }, 'Graph API failed'); + throw error; + } + throw new GraphApiError('Unknown error', 500, '/users'); +} + +// ❌ DON'T: Silent catch or generic errors +try { + await doSomething(); +} catch (e) { + console.log(e); // Bad: loses type info, uses console +} +``` + +--- + +## Code Organization + +### File Structure + +```typescript +// 1. Imports (external, then internal, then types) +import express from 'express'; +import { z } from 'zod'; + +import { logger } from '../utils/logger.js'; +import { GraphClient } from '../services/graph/graph-client.js'; + +import type { SearchResult } from '../../common/types/m365.types.js'; + +// 2. Constants +const MAX_RESULTS = 50; +const DEFAULT_TIMEOUT = 5000; + +// 3. Types/Interfaces (if not in separate file) +interface ServiceOptions { + timeout?: number; +} + +// 4. Main exports (classes, functions) +export class SharePointService { + // ... +} + +// 5. Helper functions (private to module) +function formatSearchQuery(query: string): string { + // ... +} +``` + +### Module Exports + +```typescript +// ✅ DO: Named exports +export { SharePointService } from './sharepoint.service.js'; +export { TeamsService } from './teams.service.js'; + +// ✅ DO: Barrel exports in index.ts +// services/graph/index.ts +export * from './graph-client.js'; +export * from './sharepoint.service.js'; +export * from './teams.service.js'; + +// ❌ DON'T: Default exports (except for configs) +export default class SharePointService {} // Avoid +``` + +--- + +## Documentation Standards + +### File Headers + +```typescript +/** + * SharePoint Service + * + * Provides methods for interacting with SharePoint via Microsoft Graph API. + * Handles site discovery, document search, and list operations. + * + * @module services/graph/sharepoint + */ +``` + +### Function Documentation + +```typescript +/** + * Searches SharePoint sites for documents matching the query. + * + * Uses Microsoft Graph Search API to find documents across all accessible + * SharePoint sites. Results include file metadata and content snippets. + * + * @param options - Search configuration + * @param options.query - Search query string (KQL supported) + * @param options.siteId - Optional site ID to limit search scope + * @param options.limit - Maximum results to return (default: 10, max: 50) + * + * @returns Array of search results with file metadata + * + * @throws {GraphApiError} When Graph API request fails + * @throws {AuthenticationError} When token is invalid or expired + * + * @example + * ```typescript + * const results = await sharePointService.searchDocuments({ + * query: 'quarterly report', + * limit: 5 + * }); + * ``` + */ +async function searchDocuments(options: SearchOptions): Promise { + // ... +} +``` + +### Inline Comments + +```typescript +// ✅ DO: Explain WHY +// Graph API requires $select to limit response size and improve performance +const response = await client.get('/sites', { + params: { $select: 'id,name,webUrl' } +}); + +// ❌ DON'T: Explain WHAT (code is self-documenting) +// Get sites from Graph API +const response = await client.get('/sites'); +``` + +--- + +## Testing Standards + +### Test File Structure + +```typescript +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { SharePointService } from './sharepoint.service.js'; + +describe('SharePointService', () => { + let service: SharePointService; + + beforeEach(() => { + service = new SharePointService(mockGraphClient); + }); + + describe('searchDocuments', () => { + it('should return documents matching query', async () => { + // Arrange + const query = 'quarterly report'; + mockGraphClient.search.mockResolvedValue(mockResults); + + // Act + const results = await service.searchDocuments({ query }); + + // Assert + expect(results).toHaveLength(2); + expect(results[0].name).toBe('Q4 Report.docx'); + }); + + it('should throw GraphApiError when API fails', async () => { + // Arrange + mockGraphClient.search.mockRejectedValue(new Error('Network error')); + + // Act & Assert + await expect(service.searchDocuments({ query: 'test' })) + .rejects.toThrow(GraphApiError); + }); + }); +}); +``` + +### Test Naming + +```typescript +// Pattern: should [expected behavior] when [condition] +it('should return empty array when no documents match', async () => {}); +it('should throw AuthError when token is expired', async () => {}); +it('should paginate results when limit exceeds page size', async () => {}); +``` + +--- + +## Logging Standards + +```typescript +import { logger } from '../utils/logger.js'; + +// ✅ DO: Structured logging with context +logger.info({ userId, sessionId, action: 'search' }, 'User initiated search'); + +logger.error( + { err: error, endpoint: '/sites', userId }, + 'Graph API request failed' +); + +// ❌ DON'T: String concatenation or console +console.log('User ' + userId + ' searched'); // Bad +logger.info(`User ${userId} searched`); // Less good - no structure +``` + +### Log Levels + +| Level | Use Case | +|-------|----------| +| `error` | Errors requiring attention | +| `warn` | Recoverable issues, deprecations | +| `info` | Business events, state changes | +| `debug` | Detailed flow, troubleshooting | +| `trace` | Very verbose, development only | + +--- + +## Security Standards + +### Input Validation + +```typescript +import { z } from 'zod'; + +// ✅ DO: Validate all external inputs +const SearchQuerySchema = z.object({ + query: z.string().min(1).max(500), + siteId: z.string().uuid().optional(), + limit: z.number().int().min(1).max(50).default(10), +}); + +export function validateSearchQuery(input: unknown) { + return SearchQuerySchema.parse(input); +} +``` + +### Secrets Management + +```typescript +// ✅ DO: Environment variables +const config = { + clientId: process.env.AZURE_CLIENT_ID, + clientSecret: process.env.AZURE_CLIENT_SECRET, +}; + +// ❌ DON'T: Hardcoded secrets +const config = { + clientId: 'abc123', // NEVER DO THIS + clientSecret: 'secret', // NEVER DO THIS +}; +``` + +### Sensitive Data in Logs + +```typescript +// ✅ DO: Redact sensitive data +logger.info({ userId, email: '[REDACTED]' }, 'User authenticated'); + +// ❌ DON'T: Log sensitive data +logger.info({ userId, email, accessToken }, 'User authenticated'); +``` + +--- + +## Git Commit Standards + +### Commit Message Format + +``` +(): + +[optional body] + +[optional footer] +``` + +### Types + +| Type | Description | +|------|-------------| +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation only | +| `style` | Formatting, no code change | +| `refactor` | Code change, no feature/fix | +| `test` | Adding tests | +| `chore` | Maintenance tasks | + +### Examples + +``` +feat(tools): add SharePoint search tool + +Implements search_sharepoint tool that queries Microsoft Graph +Search API. Supports KQL queries and site-scoped searches. + +Closes #123 +``` + +``` +fix(auth): handle token refresh on 401 response + +Previously, expired tokens would cause unhandled errors. +Now the MSAL client automatically refreshes tokens. +``` diff --git a/demo/TECH_STACK.md b/demo/TECH_STACK.md new file mode 100644 index 0000000000..f7e7b07dd0 --- /dev/null +++ b/demo/TECH_STACK.md @@ -0,0 +1,222 @@ +# M365 Knowledge Assistant - Technology Stack + +## Core Technologies + +### Runtime & Language +| Technology | Version | Purpose | +|------------|---------|---------| +| Node.js | 20 LTS | Runtime environment | +| TypeScript | 5.x | Type-safe development | + +### AI & Agent Framework +| Technology | Version | Purpose | +|------------|---------|---------| +| @github/copilot-sdk | latest | Copilot CLI SDK for agent capabilities | +| Copilot CLI | latest | Agent runtime (external dependency) | + +### Backend Framework +| Technology | Version | Purpose | +|------------|---------|---------| +| Express.js | 4.x | HTTP server and routing | +| express-validator | 7.x | Input validation | +| helmet | 7.x | Security headers | +| cors | 2.x | CORS handling | +| compression | 1.x | Response compression | + +### Microsoft 365 Integration +| Technology | Version | Purpose | +|------------|---------|---------| +| @microsoft/microsoft-graph-client | 3.x | Graph API client | +| @azure/msal-node | 2.x | Authentication | +| @azure/identity | 4.x | Azure credential management | + +### Data & Validation +| Technology | Version | Purpose | +|------------|---------|---------| +| zod | 3.x | Schema validation and type inference | +| date-fns | 3.x | Date manipulation | + +### Logging & Monitoring +| Technology | Version | Purpose | +|------------|---------|---------| +| pino | 9.x | Structured logging | +| pino-pretty | 11.x | Dev-friendly log output | + +### Testing +| Technology | Version | Purpose | +|------------|---------|---------| +| vitest | 2.x | Unit and integration testing | +| supertest | 7.x | HTTP assertion testing | +| msw | 2.x | API mocking | + +### Development Tools +| Technology | Version | Purpose | +|------------|---------|---------| +| tsx | 4.x | TypeScript execution | +| eslint | 9.x | Code linting | +| prettier | 3.x | Code formatting | +| husky | 9.x | Git hooks | + +### Infrastructure +| Technology | Version | Purpose | +|------------|---------|---------| +| Docker | 24.x | Containerization | +| docker-compose | 2.x | Local orchestration | + +--- + +## Package Dependencies + +### Production Dependencies +```json +{ + "@github/copilot-sdk": "latest", + "@microsoft/microsoft-graph-client": "^3.0.0", + "@azure/msal-node": "^2.0.0", + "@azure/identity": "^4.0.0", + "express": "^4.18.0", + "express-validator": "^7.0.0", + "helmet": "^7.0.0", + "cors": "^2.8.0", + "compression": "^1.7.0", + "zod": "^3.22.0", + "pino": "^9.0.0", + "pino-http": "^10.0.0", + "date-fns": "^3.0.0", + "dotenv": "^16.0.0", + "uuid": "^9.0.0" +} +``` + +### Development Dependencies +```json +{ + "typescript": "^5.3.0", + "tsx": "^4.7.0", + "@types/node": "^20.0.0", + "@types/express": "^4.17.0", + "@types/cors": "^2.8.0", + "@types/compression": "^1.7.0", + "@types/uuid": "^9.0.0", + "vitest": "^2.0.0", + "supertest": "^7.0.0", + "@types/supertest": "^6.0.0", + "msw": "^2.0.0", + "eslint": "^9.0.0", + "prettier": "^3.0.0", + "pino-pretty": "^11.0.0" +} +``` + +--- + +## Environment Requirements + +### Required External Services +1. **GitHub Copilot CLI** - Must be installed and authenticated +2. **Microsoft Entra ID App Registration** - For Graph API access +3. **Microsoft 365 Tenant** - Target environment + +### Required Environment Variables +```bash +# Server +PORT=3000 +NODE_ENV=development +LOG_LEVEL=debug + +# Copilot SDK +COPILOT_CLI_PATH=copilot + +# Microsoft Graph +AZURE_TENANT_ID= +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= + +# Optional: Specific user context +GRAPH_USER_ID= +``` + +### Microsoft Graph Permissions Required +``` +# Application Permissions (for daemon/service scenarios) +Sites.Read.All # SharePoint site access +Files.Read.All # OneDrive file access +ChannelMessage.Read.All # Teams message access +Mail.Read # Outlook mail access + +# Delegated Permissions (for user context scenarios) +Sites.Read.All +Files.Read.All +ChannelMessage.Read.All +Mail.Read +User.Read +``` + +--- + +## Architecture Decisions + +### ADR-001: GitHub Copilot SDK over Custom LLM Integration +**Decision**: Use GitHub Copilot SDK instead of direct OpenAI/Azure OpenAI integration + +**Rationale**: +- Production-tested agent runtime +- Built-in tool orchestration +- Session management included +- MCP server support for GitHub integration +- Consistent with Copilot ecosystem + +### ADR-002: Express.js over Fastify/Hono +**Decision**: Use Express.js for HTTP layer + +**Rationale**: +- Mature ecosystem +- Extensive middleware support +- Team familiarity +- Sufficient performance for this use case + +### ADR-003: Zod for Runtime Validation +**Decision**: Use Zod for schema validation and type inference + +**Rationale**: +- Runtime type safety +- TypeScript integration +- Works with Copilot SDK tool parameters +- JSON Schema generation + +### ADR-004: Pino for Logging +**Decision**: Use Pino for structured logging + +**Rationale**: +- High performance +- Structured JSON output +- Easy integration with observability platforms +- Dev-friendly pretty printing + +--- + +## Version Compatibility Matrix + +| Node.js | TypeScript | Copilot SDK | Graph Client | +|---------|------------|-------------|--------------| +| 20.x | 5.3+ | latest | 3.x | +| 22.x | 5.4+ | latest | 3.x | + +--- + +## Security Considerations + +1. **Authentication** + - MSAL for Azure AD/Entra ID authentication + - JWT tokens for API authentication (future) + - Token caching with secure storage + +2. **Data Protection** + - TLS 1.3 for all communications + - No PII in logs + - Secrets in environment variables only + +3. **Input Validation** + - Zod schemas for all inputs + - express-validator for HTTP layer + - Sanitization before Graph queries diff --git a/demo/backend/.env.example b/demo/backend/.env.example new file mode 100644 index 0000000000..65b0d53d81 --- /dev/null +++ b/demo/backend/.env.example @@ -0,0 +1,22 @@ +# Server Configuration +PORT=3000 +NODE_ENV=development +LOG_LEVEL=debug + +# Copilot SDK +# Path to Copilot CLI (default: 'copilot' from PATH) +COPILOT_CLI_PATH=copilot +# Optional: Connect to external CLI server instead of spawning +# COPILOT_CLI_URL=localhost:4321 + +# Microsoft Entra ID (Azure AD) - Required for M365 integration +AZURE_TENANT_ID=your-tenant-id +AZURE_CLIENT_ID=your-client-id +AZURE_CLIENT_SECRET=your-client-secret + +# Optional: Specific user context for delegated access +# GRAPH_USER_ID=user-id-for-delegated-access + +# Session Configuration +SESSION_PERSISTENCE_DIR=./.sessions +SESSION_MAX_AGE_HOURS=24 diff --git a/demo/backend/package.json b/demo/backend/package.json new file mode 100644 index 0000000000..7e9b04e33d --- /dev/null +++ b/demo/backend/package.json @@ -0,0 +1,64 @@ +{ + "name": "m365-knowledge-assistant", + "version": "0.1.0", + "description": "AI-powered knowledge assistant for Microsoft 365 using GitHub Copilot SDK", + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "test": "vitest", + "test:coverage": "vitest --coverage", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "format": "prettier --write \"src/**/*.ts\"", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@github/copilot-sdk": "latest", + "@microsoft/microsoft-graph-client": "^3.0.7", + "@azure/msal-node": "^2.6.0", + "@azure/identity": "^4.0.0", + "express": "^4.18.2", + "express-validator": "^7.0.1", + "helmet": "^7.1.0", + "cors": "^2.8.5", + "compression": "^1.7.4", + "zod": "^3.22.4", + "pino": "^9.0.0", + "pino-http": "^10.0.0", + "date-fns": "^3.3.0", + "dotenv": "^16.4.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/compression": "^1.7.5", + "@types/uuid": "^9.0.7", + "typescript": "^5.3.3", + "tsx": "^4.7.0", + "vitest": "^2.0.0", + "supertest": "^7.0.0", + "@types/supertest": "^6.0.2", + "msw": "^2.1.0", + "eslint": "^9.0.0", + "prettier": "^3.2.0", + "pino-pretty": "^11.0.0", + "@vitest/coverage-v8": "^2.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "keywords": [ + "copilot", + "microsoft365", + "sharepoint", + "teams", + "ai-assistant" + ], + "author": "M365 Solution Engineer", + "license": "MIT" +} diff --git a/demo/backend/src/cli.ts b/demo/backend/src/cli.ts new file mode 100644 index 0000000000..6976e2f9ee --- /dev/null +++ b/demo/backend/src/cli.ts @@ -0,0 +1,201 @@ +/** + * CLI Interface + * + * Interactive command-line interface for testing the M365 Knowledge Assistant. + * Provides a REPL-style interface for chatting with Copilot. + * + * @module cli + */ + +import type { SessionEvent } from '@github/copilot-sdk'; +import * as readline from 'node:readline'; +import { sessionManager } from './core/index.js'; +import { createLogger } from './utils/index.js'; + +const logger = createLogger({ module: 'CLI' }); + +/** + * ANSI color codes for terminal output + */ +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + cyan: '\x1b[36m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', +}; + +/** + * Prints colored output + */ +function print(text: string, color?: keyof typeof colors): void { + if (color) { + process.stdout.write(`${colors[color]}${text}${colors.reset}`); + } else { + process.stdout.write(text); + } +} + +/** + * Prints a line with color + */ +function println(text: string, color?: keyof typeof colors): void { + print(text + '\n', color); +} + +/** + * Runs the interactive CLI mode + */ +export async function runCliMode(): Promise { + println('\n╔══════════════════════════════════════════════════════════════╗', 'cyan'); + println('║ M365 Knowledge Assistant - Interactive CLI ║', 'cyan'); + println('╚══════════════════════════════════════════════════════════════╝', 'cyan'); + println(''); + println('Commands:', 'bright'); + println(' /new - Start a new conversation', 'dim'); + println(' /agents - List available agents', 'dim'); + println(' /status - Show session status', 'dim'); + println(' /help - Show this help', 'dim'); + println(' /exit - Exit the application', 'dim'); + println(''); + println('Available M365 tools:', 'bright'); + println(' • search_sharepoint - Search SharePoint documents', 'dim'); + println(' • list_sharepoint_sites - List SharePoint sites', 'dim'); + println(' • list_teams - List Microsoft Teams', 'dim'); + println(' • get_team_channels - Get channels in a team', 'dim'); + println(' • get_channel_messages - Get messages from a channel', 'dim'); + println(''); + println('Type your question or command to get started!\n', 'green'); + + // Create initial session + const userId = 'cli-user'; + let currentSession = await createNewSession(userId); + + // Setup readline interface + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const prompt = () => { + rl.question(`${colors.cyan}You: ${colors.reset}`, async (input) => { + const trimmedInput = input.trim(); + + if (!trimmedInput) { + prompt(); + return; + } + + // Handle commands + if (trimmedInput.startsWith('/')) { + const command = trimmedInput.toLowerCase(); + + switch (command) { + case '/exit': + case '/quit': + println('\nGoodbye! 👋', 'cyan'); + rl.close(); + process.exit(0); + break; + + case '/new': + await sessionManager.destroySession(currentSession.sessionId); + currentSession = await createNewSession(userId); + println('✨ New conversation started\n', 'green'); + break; + + case '/agents': + println('\nAvailable agents:', 'bright'); + println(' @m365-knowledge - General M365 search assistant', 'dim'); + println(' @it-helpdesk - IT support questions', 'dim'); + println(' @hr-assistant - HR policy questions\n', 'dim'); + break; + + case '/status': + println(`\nSession: ${currentSession.sessionId}`, 'dim'); + println(`Model: ${currentSession.model}`, 'dim'); + println(`Started: ${currentSession.createdAt.toLocaleString()}\n`, 'dim'); + break; + + case '/help': + println('\nCommands:', 'bright'); + println(' /new - Start new conversation', 'dim'); + println(' /agents - List agents', 'dim'); + println(' /status - Session info', 'dim'); + println(' /exit - Quit\n', 'dim'); + break; + + default: + println(`Unknown command: ${command}. Type /help for commands.\n`, 'yellow'); + } + + prompt(); + return; + } + + // Send message to Copilot + try { + print('\n'); + print('Assistant: ', 'green'); + + // Stream the response + let isStreaming = false; + const streamHandler = (event: SessionEvent) => { + if (event.type === 'assistant.message_delta') { + if (!isStreaming) { + isStreaming = true; + } + process.stdout.write(event.data.deltaContent ?? ''); + } else if (event.type === 'tool.execution_start') { + print(`\n 🔧 Using: ${event.data.toolName}...`, 'dim'); + } else if (event.type === 'tool.execution_complete') { + print(' ✓\n', 'dim'); + } + }; + + sessionManager.onSessionEvent(currentSession.sessionId, streamHandler); + + await sessionManager.sendMessage(currentSession.sessionId, { + prompt: trimmedInput, + }); + + sessionManager.offSessionEvent(currentSession.sessionId, streamHandler); + + println('\n'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + println(`\n❌ Error: ${message}\n`, 'yellow'); + logger.error({ err: error }, 'Error sending message'); + } + + prompt(); + }); + }; + + prompt(); +} + +/** + * Creates a new session with appropriate configuration + */ +async function createNewSession(userId: string) { + const { metadata } = await sessionManager.createSession({ + userId, + model: 'gpt-4.1', + streaming: true, + systemMessageContent: `You are an M365 Knowledge Assistant. You help employees find information across Microsoft 365 services including SharePoint, Teams, OneDrive, and Outlook. + +When users ask questions: +1. Use the available tools to search for relevant information +2. Summarize findings clearly and concisely +3. Always provide links to source documents when available +4. If you can't find information, suggest alternative search terms + +Be helpful, professional, and proactive in finding relevant information.`, + }); + + return metadata; +} diff --git a/demo/backend/src/config/index.ts b/demo/backend/src/config/index.ts new file mode 100644 index 0000000000..05ba5a92ec --- /dev/null +++ b/demo/backend/src/config/index.ts @@ -0,0 +1,114 @@ +/** + * Application Configuration + * + * Centralizes all configuration from environment variables with validation. + * All config access should go through this module. + * + * @module config + */ + +import dotenv from 'dotenv'; +import { z } from 'zod'; + +// Load environment variables +dotenv.config(); + +/** + * Environment variable schema with validation + */ +const envSchema = z.object({ + // Server + PORT: z.string().default('3000').transform(Number), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug', 'trace']).default('info'), + + // Copilot SDK + COPILOT_CLI_PATH: z.string().default('copilot'), + COPILOT_CLI_URL: z.string().optional(), + + // Microsoft Entra ID (Azure AD) + AZURE_TENANT_ID: z.string().min(1, 'AZURE_TENANT_ID is required'), + AZURE_CLIENT_ID: z.string().min(1, 'AZURE_CLIENT_ID is required'), + AZURE_CLIENT_SECRET: z.string().min(1, 'AZURE_CLIENT_SECRET is required'), + + // Optional user context + GRAPH_USER_ID: z.string().optional(), + + // Session configuration + SESSION_PERSISTENCE_DIR: z.string().default('./.sessions'), + SESSION_MAX_AGE_HOURS: z.string().default('24').transform(Number), +}); + +/** + * Validated environment configuration + */ +function loadConfig() { + const result = envSchema.safeParse(process.env); + + if (!result.success) { + console.error('❌ Invalid environment configuration:'); + console.error(result.error.format()); + process.exit(1); + } + + return result.data; +} + +const env = loadConfig(); + +/** + * Application configuration object + */ +export const config = { + /** + * Server configuration + */ + server: { + port: env.PORT, + nodeEnv: env.NODE_ENV, + isDevelopment: env.NODE_ENV === 'development', + isProduction: env.NODE_ENV === 'production', + isTest: env.NODE_ENV === 'test', + }, + + /** + * Logging configuration + */ + logging: { + level: env.LOG_LEVEL, + }, + + /** + * Copilot SDK configuration + */ + copilot: { + cliPath: env.COPILOT_CLI_PATH, + cliUrl: env.COPILOT_CLI_URL, + /** Whether to connect to external CLI server */ + useExternalServer: Boolean(env.COPILOT_CLI_URL), + }, + + /** + * Microsoft Graph / Azure AD configuration + */ + graph: { + tenantId: env.AZURE_TENANT_ID, + clientId: env.AZURE_CLIENT_ID, + clientSecret: env.AZURE_CLIENT_SECRET, + userId: env.GRAPH_USER_ID, + /** Microsoft Graph API base URL */ + baseUrl: 'https://graph.microsoft.com/v1.0', + /** Required Graph API scopes */ + scopes: ['https://graph.microsoft.com/.default'], + }, + + /** + * Session configuration + */ + session: { + persistenceDir: env.SESSION_PERSISTENCE_DIR, + maxAgeHours: env.SESSION_MAX_AGE_HOURS, + }, +} as const; + +export type Config = typeof config; diff --git a/demo/backend/src/core/agent-config.ts b/demo/backend/src/core/agent-config.ts new file mode 100644 index 0000000000..9008037fa6 --- /dev/null +++ b/demo/backend/src/core/agent-config.ts @@ -0,0 +1,182 @@ +/** + * Custom Agent Configuration + * + * Defines specialized AI agents for different use cases. Each agent has + * a tailored system prompt and can have specific tool access. + * + * @module core/agent-config + */ + +import type { CustomAgentConfig } from '@github/copilot-sdk'; +import { createLogger } from '../utils/index.js'; + +const logger = createLogger({ module: 'AgentConfig' }); + +/** + * M365 Knowledge Assistant agent + * + * General-purpose assistant for finding information across M365. + */ +const knowledgeAssistantAgent: CustomAgentConfig = { + name: 'm365-knowledge', + displayName: 'M365 Knowledge Assistant', + description: 'Helps find information across SharePoint, Teams, OneDrive, and Outlook', + prompt: `You are an enterprise knowledge assistant that helps employees find information across Microsoft 365. + +Your capabilities: +- Search SharePoint sites and document libraries for files and content +- Find relevant Teams conversations and messages +- Search OneDrive for personal files +- Look up emails in Outlook + +Guidelines: +1. Always clarify what the user is looking for if the query is ambiguous +2. When searching, explain which M365 service you're querying +3. Summarize results concisely, highlighting the most relevant items +4. Provide direct links to documents and resources when available +5. If you can't find what the user needs, suggest alternative search terms or locations +6. Respect data privacy - only access information the user has permission to view + +When presenting search results: +- Group by source (SharePoint, Teams, etc.) +- Include file names, locations, and brief descriptions +- Note the last modified date when relevant +- Highlight matching content snippets`, + infer: true, +}; + +/** + * IT Helpdesk agent + * + * Specialized for IT support questions. + */ +const itHelpdeskAgent: CustomAgentConfig = { + name: 'it-helpdesk', + displayName: 'IT Helpdesk', + description: 'Answers IT support questions and searches knowledge base', + prompt: `You are an IT helpdesk assistant for the organization. + +Your role: +- Answer common IT support questions +- Search the IT knowledge base in SharePoint for solutions +- Guide users through troubleshooting steps +- Help with M365 application issues (Teams, Outlook, SharePoint, OneDrive) + +Guidelines: +1. Start with the most common solutions for reported issues +2. Provide step-by-step instructions when guiding users +3. Search the IT documentation in SharePoint for relevant articles +4. If you can't resolve an issue, explain how to submit a support ticket +5. Always be patient and assume the user may not be technically savvy + +Common topics you help with: +- Password resets and account access +- VPN and remote access issues +- Email and calendar problems +- Teams meeting and collaboration issues +- File sharing and permissions +- Software installation requests`, + infer: true, + tools: ['search_sharepoint', 'search_teams'], +}; + +/** + * HR Assistant agent + * + * Specialized for HR-related queries. + */ +const hrAssistantAgent: CustomAgentConfig = { + name: 'hr-assistant', + displayName: 'HR Assistant', + description: 'Answers HR policy questions and helps with HR processes', + prompt: `You are an HR assistant that helps employees with HR-related questions. + +Your role: +- Answer questions about company policies and procedures +- Help employees find HR documents and forms +- Provide information about benefits and leave policies +- Guide employees through HR processes + +Guidelines: +1. Always reference official HR documentation when answering policy questions +2. Search SharePoint for the latest HR policies and forms +3. Be empathetic when dealing with sensitive topics +4. For complex or sensitive issues, recommend speaking with HR directly +5. Never provide legal advice - direct users to appropriate resources + +Topics you help with: +- Leave policies (PTO, sick leave, parental leave) +- Benefits enrollment and questions +- Onboarding and offboarding processes +- Performance review procedures +- Company policies and employee handbook +- Training and development resources`, + infer: true, + tools: ['search_sharepoint'], +}; + +/** + * All custom agents + */ +const customAgents: CustomAgentConfig[] = [ + knowledgeAssistantAgent, + itHelpdeskAgent, + hrAssistantAgent, +]; + +/** + * Gets all configured custom agents + * + * @returns Array of custom agent configurations + */ +export function getCustomAgents(): CustomAgentConfig[] { + return [...customAgents]; +} + +/** + * Gets a specific agent by name + * + * @param name - Agent name + * @returns Agent config if found + */ +export function getAgent(name: string): CustomAgentConfig | undefined { + return customAgents.find((a) => a.name === name); +} + +/** + * Gets list of available agent names + */ +export function getAgentNames(): string[] { + return customAgents.map((a) => a.name); +} + +/** + * Adds a custom agent dynamically + * + * @param agent - Agent configuration to add + */ +export function addCustomAgent(agent: CustomAgentConfig): void { + const existing = customAgents.findIndex((a) => a.name === agent.name); + if (existing !== -1) { + customAgents[existing] = agent; + logger.info({ agentName: agent.name }, 'Custom agent updated'); + } else { + customAgents.push(agent); + logger.info({ agentName: agent.name }, 'Custom agent added'); + } +} + +/** + * Removes a custom agent + * + * @param name - Agent name to remove + */ +export function removeCustomAgent(name: string): boolean { + const index = customAgents.findIndex((a) => a.name === name); + if (index !== -1) { + customAgents.splice(index, 1); + logger.info({ agentName: name }, 'Custom agent removed'); + return true; + } + return false; +} diff --git a/demo/backend/src/core/copilot-client.ts b/demo/backend/src/core/copilot-client.ts new file mode 100644 index 0000000000..b90ecc0aa4 --- /dev/null +++ b/demo/backend/src/core/copilot-client.ts @@ -0,0 +1,184 @@ +/** + * Copilot Client Wrapper + * + * Manages the GitHub Copilot SDK client lifecycle. Provides singleton access + * to the Copilot CLI connection and handles startup/shutdown gracefully. + * + * @module core/copilot-client + */ + +import { CopilotClient, type CopilotClientOptions } from '@github/copilot-sdk'; +import { config } from '../config/index.js'; +import { CopilotError, createLogger } from '../utils/index.js'; + +const logger = createLogger({ module: 'CopilotClientWrapper' }); + +/** + * Singleton wrapper for the Copilot SDK client + */ +class CopilotClientWrapper { + private client: CopilotClient | null = null; + private isStarting = false; + private isStarted = false; + + /** + * Gets or creates the Copilot client instance + * + * @returns The initialized Copilot client + * @throws {CopilotError} If client initialization fails + */ + async getClient(): Promise { + if (this.client && this.isStarted) { + return this.client; + } + + if (this.isStarting) { + // Wait for ongoing initialization + await this.waitForStart(); + if (this.client) { + return this.client; + } + } + + return this.initialize(); + } + + /** + * Initializes the Copilot client with configuration + */ + private async initialize(): Promise { + this.isStarting = true; + + try { + logger.info('Initializing Copilot client...'); + + const options: CopilotClientOptions = { + logLevel: config.logging.level === 'trace' ? 'all' : config.logging.level, + autoStart: true, + autoRestart: true, + }; + + // Use external server if configured + if (config.copilot.useExternalServer && config.copilot.cliUrl) { + logger.info({ cliUrl: config.copilot.cliUrl }, 'Connecting to external CLI server'); + options.cliUrl = config.copilot.cliUrl; + } else { + logger.info({ cliPath: config.copilot.cliPath }, 'Starting managed CLI server'); + options.cliPath = config.copilot.cliPath; + } + + this.client = new CopilotClient(options); + this.isStarted = true; + this.isStarting = false; + + logger.info('Copilot client initialized successfully'); + + return this.client; + } catch (error) { + this.isStarting = false; + this.isStarted = false; + + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error({ err: error }, 'Failed to initialize Copilot client'); + + throw new CopilotError(`Failed to initialize Copilot client: ${message}`); + } + } + + /** + * Waits for ongoing initialization to complete + */ + private async waitForStart(timeoutMs: number = 30000): Promise { + const startTime = Date.now(); + + while (this.isStarting) { + if (Date.now() - startTime > timeoutMs) { + throw new CopilotError('Timeout waiting for Copilot client initialization'); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + /** + * Stops the Copilot client and releases resources + */ + async stop(): Promise { + if (!this.client) { + return; + } + + try { + logger.info('Stopping Copilot client...'); + await this.client.stop(); + logger.info('Copilot client stopped successfully'); + } catch (error) { + logger.error({ err: error }, 'Error stopping Copilot client'); + } finally { + this.client = null; + this.isStarted = false; + } + } + + /** + * Checks if the client is currently running + */ + isRunning(): boolean { + return this.isStarted && this.client !== null; + } + + /** + * Gets available models from the Copilot service + * + * @returns List of available model identifiers + */ + async getAvailableModels(): Promise { + const client = await this.getClient(); + + try { + const models = await client.getModels(); + return models.map((m) => m.id); + } catch (error) { + logger.error({ err: error }, 'Failed to fetch available models'); + throw new CopilotError('Failed to fetch available models'); + } + } + + /** + * Gets the status of the Copilot connection + */ + async getStatus(): Promise<{ + connected: boolean; + models: string[]; + authenticated: boolean; + }> { + try { + const client = await this.getClient(); + const status = await client.getStatus(); + const models = await this.getAvailableModels(); + + return { + connected: true, + models, + authenticated: status.authenticated ?? false, + }; + } catch { + return { + connected: false, + models: [], + authenticated: false, + }; + } + } +} + +/** + * Singleton instance of the Copilot client wrapper + */ +export const copilotClient = new CopilotClientWrapper(); + +/** + * Graceful shutdown handler + */ +export async function shutdownCopilotClient(): Promise { + await copilotClient.stop(); +} diff --git a/demo/backend/src/core/index.ts b/demo/backend/src/core/index.ts new file mode 100644 index 0000000000..c7f1acacdb --- /dev/null +++ b/demo/backend/src/core/index.ts @@ -0,0 +1,29 @@ +/** + * Core Module Index + * + * Exports all core Copilot SDK integration components. + * + * @module core + */ + +export { copilotClient, shutdownCopilotClient } from './copilot-client.js'; + +export { + sessionManager, + type CreateSessionOptions, + type SendMessageOptions, + type SessionEventHandler +} from './session-manager.js'; + +export { + clearTools, defineTool, getRegisteredTools, + getTool, getToolNames, + getToolStats, registerTool, + registerTools, unregisterTool +} from './tool-registry.js'; + +export { + addCustomAgent, getAgent, + getAgentNames, getCustomAgents, removeCustomAgent +} from './agent-config.js'; + diff --git a/demo/backend/src/core/session-manager.ts b/demo/backend/src/core/session-manager.ts new file mode 100644 index 0000000000..9a5e2254e3 --- /dev/null +++ b/demo/backend/src/core/session-manager.ts @@ -0,0 +1,432 @@ +/** + * Session Manager + * + * Manages Copilot conversation sessions including creation, retrieval, + * persistence, and cleanup. Handles multi-user session isolation. + * + * @module core/session-manager + */ + +import type { CopilotSession, SessionConfig, SessionEvent } from '@github/copilot-sdk'; +import { config } from '../config/index.js'; +import { createLogger, NotFoundError, SessionError } from '../utils/index.js'; +import { getCustomAgents } from './agent-config.js'; +import { copilotClient } from './copilot-client.js'; +import { getRegisteredTools } from './tool-registry.js'; + +const logger = createLogger({ module: 'SessionManager' }); + +/** + * Session metadata for tracking + */ +interface SessionMetadata { + sessionId: string; + userId: string; + createdAt: Date; + lastActiveAt: Date; + model: string; +} + +/** + * Options for creating a new session + */ +export interface CreateSessionOptions { + /** User identifier for session ownership */ + userId: string; + /** Optional custom session ID (auto-generated if not provided) */ + sessionId?: string; + /** Model to use (defaults to gpt-4.1) */ + model?: string; + /** Whether to enable streaming */ + streaming?: boolean; + /** Custom system message content to append */ + systemMessageContent?: string; +} + +/** + * Options for sending a message + */ +export interface SendMessageOptions { + /** The message prompt */ + prompt: string; + /** Optional attachments (file paths, URLs) */ + attachments?: string[]; +} + +/** + * Event handler type + */ +export type SessionEventHandler = (event: SessionEvent) => void; + +/** + * Manages Copilot conversation sessions + */ +class SessionManager { + /** Active sessions indexed by session ID */ + private sessions: Map = new Map(); + /** Session metadata indexed by session ID */ + private metadata: Map = new Map(); + /** Event handlers indexed by session ID */ + private eventHandlers: Map> = new Map(); + + /** + * Creates a new conversation session + * + * @param options - Session creation options + * @returns Created session with metadata + * + * @throws {SessionError} If session creation fails + * + * @example + * ```typescript + * const { session, metadata } = await sessionManager.createSession({ + * userId: 'user-123', + * model: 'gpt-4.1', + * streaming: true + * }); + * ``` + */ + async createSession(options: CreateSessionOptions): Promise<{ + session: CopilotSession; + metadata: SessionMetadata; + }> { + const { + userId, + sessionId, + model = 'gpt-4.1', + streaming = true, + systemMessageContent, + } = options; + + logger.info({ userId, model, streaming }, 'Creating new session'); + + try { + const client = await copilotClient.getClient(); + + // Build session configuration + const sessionConfig: SessionConfig = { + sessionId, + model, + streaming, + tools: getRegisteredTools(), + customAgents: getCustomAgents(), + }; + + // Add custom system message if provided + if (systemMessageContent) { + sessionConfig.systemMessage = { + mode: 'append', + content: systemMessageContent, + }; + } + + // Create the session + const session = await client.createSession(sessionConfig); + + // Track metadata + const meta: SessionMetadata = { + sessionId: session.sessionId, + userId, + createdAt: new Date(), + lastActiveAt: new Date(), + model, + }; + + this.sessions.set(session.sessionId, session); + this.metadata.set(session.sessionId, meta); + this.eventHandlers.set(session.sessionId, new Set()); + + // Setup internal event listener for logging + session.on((event: SessionEvent) => { + this.handleSessionEvent(session.sessionId, event); + }); + + logger.info( + { sessionId: session.sessionId, userId }, + 'Session created successfully' + ); + + return { session, metadata: meta }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error({ err: error, userId }, 'Failed to create session'); + throw new SessionError(`Failed to create session: ${message}`); + } + } + + /** + * Retrieves an existing session by ID + * + * @param sessionId - Session identifier + * @returns Session and metadata + * + * @throws {NotFoundError} If session doesn't exist + */ + getSession(sessionId: string): { + session: CopilotSession; + metadata: SessionMetadata; + } { + const session = this.sessions.get(sessionId); + const metadata = this.metadata.get(sessionId); + + if (!session || !metadata) { + throw new NotFoundError('Session', sessionId); + } + + // Update last active timestamp + metadata.lastActiveAt = new Date(); + + return { session, metadata }; + } + + /** + * Resumes a persisted session + * + * @param sessionId - Session ID to resume + * @param userId - User ID for ownership verification + * @returns Resumed session with metadata + * + * @throws {SessionError} If session cannot be resumed + */ + async resumeSession( + sessionId: string, + userId: string + ): Promise<{ + session: CopilotSession; + metadata: SessionMetadata; + }> { + // Check if already active + if (this.sessions.has(sessionId)) { + const { metadata } = this.getSession(sessionId); + + // Verify ownership + if (metadata.userId !== userId) { + throw new SessionError('Session belongs to different user', sessionId, 403); + } + + return this.getSession(sessionId); + } + + logger.info({ sessionId, userId }, 'Resuming persisted session'); + + try { + const client = await copilotClient.getClient(); + + // Resume with current tools and agents + const session = await client.resumeSession(sessionId, { + tools: getRegisteredTools(), + customAgents: getCustomAgents(), + }); + + // Recreate metadata (we don't have original creation time) + const meta: SessionMetadata = { + sessionId: session.sessionId, + userId, + createdAt: new Date(), // Best approximation + lastActiveAt: new Date(), + model: 'unknown', // Not available on resume + }; + + this.sessions.set(session.sessionId, session); + this.metadata.set(session.sessionId, meta); + this.eventHandlers.set(session.sessionId, new Set()); + + // Setup event listener + session.on((event: SessionEvent) => { + this.handleSessionEvent(session.sessionId, event); + }); + + logger.info({ sessionId }, 'Session resumed successfully'); + + return { session, metadata: meta }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error({ err: error, sessionId }, 'Failed to resume session'); + throw new SessionError(`Failed to resume session: ${message}`, sessionId); + } + } + + /** + * Sends a message to a session and waits for completion + * + * @param sessionId - Target session ID + * @param options - Message options + * @returns Final assistant response + */ + async sendMessage( + sessionId: string, + options: SendMessageOptions + ): Promise { + const { session, metadata } = this.getSession(sessionId); + + logger.debug( + { sessionId, promptLength: options.prompt.length }, + 'Sending message' + ); + + metadata.lastActiveAt = new Date(); + + try { + const response = await session.sendAndWait({ + prompt: options.prompt, + attachments: options.attachments, + }); + + return response; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error({ err: error, sessionId }, 'Failed to send message'); + throw new SessionError(`Failed to send message: ${message}`, sessionId); + } + } + + /** + * Registers an event handler for a session + * + * @param sessionId - Session to listen to + * @param handler - Event handler function + */ + onSessionEvent(sessionId: string, handler: SessionEventHandler): void { + const handlers = this.eventHandlers.get(sessionId); + if (handlers) { + handlers.add(handler); + } + } + + /** + * Removes an event handler from a session + * + * @param sessionId - Session ID + * @param handler - Handler to remove + */ + offSessionEvent(sessionId: string, handler: SessionEventHandler): void { + const handlers = this.eventHandlers.get(sessionId); + if (handlers) { + handlers.delete(handler); + } + } + + /** + * Destroys a session and cleans up resources + * + * @param sessionId - Session to destroy + */ + async destroySession(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + + if (session) { + try { + await session.destroy(); + logger.info({ sessionId }, 'Session destroyed'); + } catch (error) { + logger.error({ err: error, sessionId }, 'Error destroying session'); + } + } + + this.sessions.delete(sessionId); + this.metadata.delete(sessionId); + this.eventHandlers.delete(sessionId); + } + + /** + * Gets all active sessions for a user + * + * @param userId - User identifier + * @returns Array of session metadata + */ + getUserSessions(userId: string): SessionMetadata[] { + const userSessions: SessionMetadata[] = []; + + for (const metadata of this.metadata.values()) { + if (metadata.userId === userId) { + userSessions.push(metadata); + } + } + + return userSessions; + } + + /** + * Cleans up expired sessions + */ + async cleanupExpiredSessions(): Promise { + const maxAge = config.session.maxAgeHours * 60 * 60 * 1000; + const now = Date.now(); + let cleanedCount = 0; + + for (const [sessionId, metadata] of this.metadata.entries()) { + const age = now - metadata.lastActiveAt.getTime(); + + if (age > maxAge) { + await this.destroySession(sessionId); + cleanedCount++; + } + } + + if (cleanedCount > 0) { + logger.info({ cleanedCount }, 'Cleaned up expired sessions'); + } + + return cleanedCount; + } + + /** + * Internal event handler for logging and forwarding + */ + private handleSessionEvent(sessionId: string, event: SessionEvent): void { + // Log significant events + switch (event.type) { + case 'assistant.message': + logger.debug({ sessionId, type: event.type }, 'Assistant message received'); + break; + case 'tool.execution_start': + logger.debug( + { sessionId, toolName: event.data.toolName }, + 'Tool execution started' + ); + break; + case 'tool.execution_complete': + logger.debug({ sessionId }, 'Tool execution completed'); + break; + case 'session.error': + logger.error({ sessionId, error: event.data }, 'Session error'); + break; + } + + // Forward to registered handlers + const handlers = this.eventHandlers.get(sessionId); + if (handlers) { + for (const handler of handlers) { + try { + handler(event); + } catch (error) { + logger.error({ err: error, sessionId }, 'Event handler error'); + } + } + } + } + + /** + * Gets statistics about active sessions + */ + getStats(): { + totalSessions: number; + sessionsByUser: Record; + } { + const sessionsByUser: Record = {}; + + for (const metadata of this.metadata.values()) { + sessionsByUser[metadata.userId] = (sessionsByUser[metadata.userId] ?? 0) + 1; + } + + return { + totalSessions: this.sessions.size, + sessionsByUser, + }; + } +} + +/** + * Singleton session manager instance + */ +export const sessionManager = new SessionManager(); diff --git a/demo/backend/src/core/tool-registry.ts b/demo/backend/src/core/tool-registry.ts new file mode 100644 index 0000000000..2a270175a2 --- /dev/null +++ b/demo/backend/src/core/tool-registry.ts @@ -0,0 +1,121 @@ +/** + * Tool Registry + * + * Central registry for all Copilot tools. Manages tool registration, + * provides type-safe tool definitions, and exports tools for session creation. + * + * @module core/tool-registry + */ + +import { defineTool, type Tool } from '@github/copilot-sdk'; +import { createLogger } from '../utils/index.js'; + +const logger = createLogger({ module: 'ToolRegistry' }); + +/** + * Registered tools collection + */ +const registeredTools: Tool[] = []; + +/** + * Registers a tool with the registry + * + * @param tool - Tool to register + * + * @example + * ```typescript + * registerTool(searchSharePointTool); + * ``` + */ +export function registerTool(tool: Tool): void { + // Check for duplicate names + const existing = registeredTools.find((t) => t.name === tool.name); + if (existing) { + logger.warn({ toolName: tool.name }, 'Tool already registered, replacing'); + const index = registeredTools.indexOf(existing); + registeredTools.splice(index, 1); + } + + registeredTools.push(tool as Tool); + logger.info({ toolName: tool.name }, 'Tool registered'); +} + +/** + * Registers multiple tools at once + * + * @param tools - Array of tools to register + */ +export function registerTools(tools: Tool[]): void { + for (const tool of tools) { + registerTool(tool); + } +} + +/** + * Gets all registered tools + * + * @returns Array of all registered tools + */ +export function getRegisteredTools(): Tool[] { + return [...registeredTools]; +} + +/** + * Gets a specific tool by name + * + * @param name - Tool name + * @returns Tool if found, undefined otherwise + */ +export function getTool(name: string): Tool | undefined { + return registeredTools.find((t) => t.name === name); +} + +/** + * Removes a tool from the registry + * + * @param name - Tool name to remove + * @returns true if removed, false if not found + */ +export function unregisterTool(name: string): boolean { + const index = registeredTools.findIndex((t) => t.name === name); + if (index !== -1) { + registeredTools.splice(index, 1); + logger.info({ toolName: name }, 'Tool unregistered'); + return true; + } + return false; +} + +/** + * Clears all registered tools + */ +export function clearTools(): void { + registeredTools.length = 0; + logger.info('All tools cleared'); +} + +/** + * Gets list of registered tool names + */ +export function getToolNames(): string[] { + return registeredTools.map((t) => t.name); +} + +/** + * Helper to create a type-safe tool definition + * Re-exported from SDK for convenience + */ +export { defineTool }; + +/** + * Tool statistics + */ +export function getToolStats(): { + totalTools: number; + toolNames: string[]; +} { + return { + totalTools: registeredTools.length, + toolNames: getToolNames(), + }; +} diff --git a/demo/backend/src/index.ts b/demo/backend/src/index.ts new file mode 100644 index 0000000000..95c89222af --- /dev/null +++ b/demo/backend/src/index.ts @@ -0,0 +1,89 @@ +/** + * M365 Knowledge Assistant - Application Entry Point + * + * Main entry point for the backend server. Initializes all components + * and starts the application in either API or CLI mode. + * + * @module index + */ + +import { runCliMode } from './cli.js'; +import { config } from './config/index.js'; +import { copilotClient, sessionManager, shutdownCopilotClient } from './core/index.js'; +import { initializeTools } from './tools/index.js'; +import { createLogger, logger } from './utils/index.js'; + +const appLogger = createLogger({ module: 'App' }); + +/** + * Graceful shutdown handler + */ +async function shutdown(signal: string): Promise { + appLogger.info({ signal }, 'Shutdown signal received'); + + try { + // Cleanup sessions + const stats = sessionManager.getStats(); + appLogger.info({ activeSessions: stats.totalSessions }, 'Cleaning up sessions...'); + + // Stop Copilot client + await shutdownCopilotClient(); + + appLogger.info('Shutdown complete'); + process.exit(0); + } catch (error) { + appLogger.error({ err: error }, 'Error during shutdown'); + process.exit(1); + } +} + +/** + * Main application entry point + */ +async function main(): Promise { + appLogger.info( + { + nodeEnv: config.server.nodeEnv, + port: config.server.port, + }, + '🚀 Starting M365 Knowledge Assistant' + ); + + // Register shutdown handlers + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); + + try { + // Initialize tools + initializeTools(); + + // Verify Copilot connection + appLogger.info('Connecting to Copilot CLI...'); + const status = await copilotClient.getStatus(); + + if (!status.connected) { + appLogger.error('Failed to connect to Copilot CLI. Is it installed and authenticated?'); + process.exit(1); + } + + appLogger.info( + { + authenticated: status.authenticated, + availableModels: status.models.length, + }, + '✅ Connected to Copilot CLI' + ); + + // Run in CLI mode for now (API server coming later) + await runCliMode(); + } catch (error) { + appLogger.error({ err: error }, 'Failed to start application'); + process.exit(1); + } +} + +// Start the application +main().catch((error) => { + logger.error({ err: error }, 'Unhandled error in main'); + process.exit(1); +}); diff --git a/demo/backend/src/services/graph/graph-client.ts b/demo/backend/src/services/graph/graph-client.ts new file mode 100644 index 0000000000..64aa471579 --- /dev/null +++ b/demo/backend/src/services/graph/graph-client.ts @@ -0,0 +1,211 @@ +/** + * Microsoft Graph Client + * + * Provides authenticated access to Microsoft Graph API. Handles token + * acquisition via MSAL and wraps the Graph client for type-safe operations. + * + * @module services/graph/graph-client + */ + +import { + ConfidentialClientApplication, + type AuthenticationResult, +} from '@azure/msal-node'; +import { Client } from '@microsoft/microsoft-graph-client'; +import { config } from '../../config/index.js'; +import { AuthenticationError, createLogger, GraphApiError } from '../../utils/index.js'; + +const logger = createLogger({ module: 'GraphClient' }); + +/** + * MSAL configuration for client credentials flow + */ +const msalConfig = { + auth: { + clientId: config.graph.clientId, + clientSecret: config.graph.clientSecret, + authority: `https://login.microsoftonline.com/${config.graph.tenantId}`, + }, +}; + +/** + * MSAL client application instance + */ +let msalClient: ConfidentialClientApplication | null = null; + +/** + * Cached access token + */ +let cachedToken: { + token: string; + expiresAt: number; +} | null = null; + +/** + * Gets or creates the MSAL client + */ +function getMsalClient(): ConfidentialClientApplication { + if (!msalClient) { + msalClient = new ConfidentialClientApplication(msalConfig); + } + return msalClient; +} + +/** + * Acquires an access token for Microsoft Graph + * + * Uses client credentials flow (application permissions). + * Caches the token and refreshes when expired. + * + * @returns Access token string + * @throws {AuthenticationError} If token acquisition fails + */ +async function acquireToken(): Promise { + // Check if cached token is still valid (with 5 min buffer) + if (cachedToken && cachedToken.expiresAt > Date.now() + 5 * 60 * 1000) { + return cachedToken.token; + } + + logger.debug('Acquiring new Graph API token'); + + try { + const client = getMsalClient(); + const result: AuthenticationResult | null = await client.acquireTokenByClientCredential({ + scopes: config.graph.scopes, + }); + + if (!result || !result.accessToken) { + throw new AuthenticationError('Failed to acquire Graph API token'); + } + + // Cache the token + cachedToken = { + token: result.accessToken, + expiresAt: result.expiresOn?.getTime() ?? Date.now() + 3600 * 1000, + }; + + logger.debug('Graph API token acquired successfully'); + + return result.accessToken; + } catch (error) { + logger.error({ err: error }, 'Failed to acquire Graph API token'); + + if (error instanceof AuthenticationError) { + throw error; + } + + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new AuthenticationError(`Graph authentication failed: ${message}`); + } +} + +/** + * Creates an authenticated Microsoft Graph client + * + * @returns Configured Graph client instance + */ +export async function getGraphClient(): Promise { + const token = await acquireToken(); + + return Client.init({ + authProvider: (callback) => { + callback(null, token); + }, + }); +} + +/** + * Executes a Graph API GET request + * + * @param endpoint - API endpoint (e.g., '/sites') + * @param queryParams - Optional query parameters + * @returns API response data + * + * @throws {GraphApiError} If the request fails + */ +export async function graphGet( + endpoint: string, + queryParams?: Record +): Promise { + const client = await getGraphClient(); + + try { + let request = client.api(endpoint); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + request = request.query({ [key]: value }); + } + } + + const response = await request.get(); + return response as T; + } catch (error: unknown) { + const graphError = error as { statusCode?: number; code?: string; message?: string }; + const statusCode = graphError.statusCode ?? 500; + const code = graphError.code; + const message = graphError.message ?? 'Graph API request failed'; + + logger.error( + { err: error, endpoint, statusCode, code }, + 'Graph API GET request failed' + ); + + throw new GraphApiError(message, statusCode, endpoint, code); + } +} + +/** + * Executes a Graph API POST request + * + * @param endpoint - API endpoint + * @param body - Request body + * @returns API response data + * + * @throws {GraphApiError} If the request fails + */ +export async function graphPost( + endpoint: string, + body: unknown +): Promise { + const client = await getGraphClient(); + + try { + const response = await client.api(endpoint).post(body); + return response as T; + } catch (error: unknown) { + const graphError = error as { statusCode?: number; code?: string; message?: string }; + const statusCode = graphError.statusCode ?? 500; + const code = graphError.code; + const message = graphError.message ?? 'Graph API request failed'; + + logger.error( + { err: error, endpoint, statusCode, code }, + 'Graph API POST request failed' + ); + + throw new GraphApiError(message, statusCode, endpoint, code); + } +} + +/** + * Tests the Graph API connection + * + * @returns true if connection is successful + */ +export async function testConnection(): Promise { + try { + await graphGet('/organization'); + return true; + } catch { + return false; + } +} + +/** + * Clears the cached token (for testing or forced refresh) + */ +export function clearTokenCache(): void { + cachedToken = null; + logger.debug('Token cache cleared'); +} diff --git a/demo/backend/src/services/graph/index.ts b/demo/backend/src/services/graph/index.ts new file mode 100644 index 0000000000..078906822e --- /dev/null +++ b/demo/backend/src/services/graph/index.ts @@ -0,0 +1,24 @@ +/** + * Graph Services Index + * + * Exports all Microsoft Graph service modules. + * + * @module services/graph + */ + +export { + clearTokenCache, getGraphClient, + graphGet, + graphPost, + testConnection +} from './graph-client.js'; + +export { + getRecentDocuments, getSite, listSites, searchDocuments, type SearchDocumentsOptions, type SharePointDocument, type SharePointSite +} from './sharepoint.service.js'; + +export { + getChannelMessages, getTeam, + getTeamChannels, listTeams, searchMessages, type Channel, type SearchMessagesOptions, type Team, type TeamsMessage +} from './teams.service.js'; + diff --git a/demo/backend/src/services/graph/sharepoint.service.ts b/demo/backend/src/services/graph/sharepoint.service.ts new file mode 100644 index 0000000000..98f3aaeec8 --- /dev/null +++ b/demo/backend/src/services/graph/sharepoint.service.ts @@ -0,0 +1,280 @@ +/** + * SharePoint Service + * + * Provides methods for interacting with SharePoint via Microsoft Graph API. + * Handles site discovery, document search, and list operations. + * + * @module services/graph/sharepoint + */ + +import { createLogger } from '../../utils/index.js'; +import { graphGet, graphPost } from './graph-client.js'; + +const logger = createLogger({ module: 'SharePointService' }); + +/** + * SharePoint site information + */ +export interface SharePointSite { + id: string; + name: string; + displayName: string; + webUrl: string; + description?: string; +} + +/** + * SharePoint document/file information + */ +export interface SharePointDocument { + id: string; + name: string; + webUrl: string; + size: number; + lastModifiedDateTime: string; + createdDateTime: string; + createdBy?: { + user?: { + displayName: string; + email?: string; + }; + }; + lastModifiedBy?: { + user?: { + displayName: string; + email?: string; + }; + }; + parentReference?: { + path?: string; + siteId?: string; + }; + /** Content snippet from search */ + summary?: string; +} + +/** + * Search result from Graph Search API + */ +interface GraphSearchResult { + value: Array<{ + hitsContainers: Array<{ + hits: Array<{ + hitId: string; + summary?: string; + resource: { + id: string; + name: string; + webUrl: string; + size?: number; + lastModifiedDateTime?: string; + createdDateTime?: string; + createdBy?: { + user?: { displayName: string; email?: string }; + }; + lastModifiedBy?: { + user?: { displayName: string; email?: string }; + }; + parentReference?: { + path?: string; + siteId?: string; + }; + }; + }>; + total: number; + moreResultsAvailable: boolean; + }>; + }>; +} + +/** + * Search options for SharePoint documents + */ +export interface SearchDocumentsOptions { + /** Search query (supports KQL) */ + query: string; + /** Maximum results to return */ + limit?: number; + /** Specific site ID to search within */ + siteId?: string; + /** File types to filter (e.g., 'docx', 'pdf') */ + fileTypes?: string[]; +} + +/** + * Lists accessible SharePoint sites + * + * @param searchQuery - Optional search query to filter sites + * @returns Array of SharePoint sites + */ +export async function listSites(searchQuery?: string): Promise { + logger.debug({ searchQuery }, 'Listing SharePoint sites'); + + try { + let endpoint = '/sites'; + const params: Record = { + $select: 'id,name,displayName,webUrl,description', + $top: '50', + }; + + if (searchQuery) { + endpoint = `/sites?search=${encodeURIComponent(searchQuery)}`; + } + + const response = await graphGet<{ value: SharePointSite[] }>(endpoint, params); + + logger.info({ count: response.value.length }, 'Retrieved SharePoint sites'); + + return response.value; + } catch (error) { + logger.error({ err: error, searchQuery }, 'Failed to list SharePoint sites'); + throw error; + } +} + +/** + * Gets a specific SharePoint site by ID + * + * @param siteId - Site identifier + * @returns Site information + */ +export async function getSite(siteId: string): Promise { + logger.debug({ siteId }, 'Getting SharePoint site'); + + const response = await graphGet(`/sites/${siteId}`, { + $select: 'id,name,displayName,webUrl,description', + }); + + return response; +} + +/** + * Searches SharePoint for documents matching the query + * + * Uses Microsoft Graph Search API for content-based search across + * all accessible SharePoint sites and document libraries. + * + * @param options - Search options + * @returns Array of matching documents with content snippets + */ +export async function searchDocuments( + options: SearchDocumentsOptions +): Promise { + const { query, limit = 10, siteId, fileTypes } = options; + + logger.debug({ query, limit, siteId, fileTypes }, 'Searching SharePoint documents'); + + // Build KQL query + let kqlQuery = query; + + if (siteId) { + kqlQuery += ` AND site:${siteId}`; + } + + if (fileTypes && fileTypes.length > 0) { + const typeFilter = fileTypes.map((t) => `filetype:${t}`).join(' OR '); + kqlQuery += ` AND (${typeFilter})`; + } + + const searchRequest = { + requests: [ + { + entityTypes: ['driveItem'], + query: { + queryString: kqlQuery, + }, + from: 0, + size: limit, + fields: [ + 'id', + 'name', + 'webUrl', + 'size', + 'lastModifiedDateTime', + 'createdDateTime', + 'createdBy', + 'lastModifiedBy', + 'parentReference', + ], + }, + ], + }; + + try { + const response = await graphPost('/search/query', searchRequest); + + const documents: SharePointDocument[] = []; + + for (const result of response.value) { + for (const container of result.hitsContainers) { + for (const hit of container.hits) { + documents.push({ + id: hit.resource.id, + name: hit.resource.name, + webUrl: hit.resource.webUrl, + size: hit.resource.size ?? 0, + lastModifiedDateTime: hit.resource.lastModifiedDateTime ?? '', + createdDateTime: hit.resource.createdDateTime ?? '', + createdBy: hit.resource.createdBy, + lastModifiedBy: hit.resource.lastModifiedBy, + parentReference: hit.resource.parentReference, + summary: hit.summary, + }); + } + } + } + + logger.info({ query, resultCount: documents.length }, 'SharePoint search completed'); + + return documents; + } catch (error) { + logger.error({ err: error, query }, 'SharePoint search failed'); + throw error; + } +} + +/** + * Gets recent documents from a SharePoint site + * + * @param siteId - Site identifier + * @param limit - Maximum results + * @returns Recent documents + */ +export async function getRecentDocuments( + siteId: string, + limit: number = 10 +): Promise { + logger.debug({ siteId, limit }, 'Getting recent documents'); + + try { + // Get the default document library + const drivesResponse = await graphGet<{ value: Array<{ id: string }> }>( + `/sites/${siteId}/drives`, + { $select: 'id', $top: '1' } + ); + + if (!drivesResponse.value.length) { + return []; + } + + const driveId = drivesResponse.value[0]?.id; + if (!driveId) { + return []; + } + + // Get recent items + const itemsResponse = await graphGet<{ value: SharePointDocument[] }>( + `/drives/${driveId}/root/children`, + { + $select: 'id,name,webUrl,size,lastModifiedDateTime,createdDateTime,createdBy,lastModifiedBy', + $top: String(limit), + $orderby: 'lastModifiedDateTime desc', + } + ); + + return itemsResponse.value; + } catch (error) { + logger.error({ err: error, siteId }, 'Failed to get recent documents'); + throw error; + } +} diff --git a/demo/backend/src/services/graph/teams.service.ts b/demo/backend/src/services/graph/teams.service.ts new file mode 100644 index 0000000000..28aac59caf --- /dev/null +++ b/demo/backend/src/services/graph/teams.service.ts @@ -0,0 +1,198 @@ +/** + * Microsoft Teams Service + * + * Provides methods for interacting with Microsoft Teams via Graph API. + * Handles team/channel listing and message retrieval. + * + * @module services/graph/teams + */ + +import { createLogger } from '../../utils/index.js'; +import { graphGet } from './graph-client.js'; + +const logger = createLogger({ module: 'TeamsService' }); + +/** + * Teams team information + */ +export interface Team { + id: string; + displayName: string; + description?: string; + webUrl?: string; +} + +/** + * Teams channel information + */ +export interface Channel { + id: string; + displayName: string; + description?: string; + webUrl?: string; + membershipType?: 'standard' | 'private' | 'shared'; +} + +/** + * Teams message information + */ +export interface TeamsMessage { + id: string; + createdDateTime: string; + body: { + content: string; + contentType: 'text' | 'html'; + }; + from?: { + user?: { + displayName: string; + email?: string; + }; + }; + webUrl?: string; + channelIdentity?: { + teamId: string; + channelId: string; + }; + /** Preview text for search results */ + summary?: string; +} + +/** + * Search options for Teams messages + */ +export interface SearchMessagesOptions { + /** Search query */ + query: string; + /** Maximum results */ + limit?: number; + /** Specific team ID to search within */ + teamId?: string; + /** Specific channel ID to search within */ + channelId?: string; +} + +/** + * Lists teams the application has access to + * + * @returns Array of teams + */ +export async function listTeams(): Promise { + logger.debug('Listing Teams'); + + try { + const response = await graphGet<{ value: Team[] }>('/groups', { + $filter: "resourceProvisioningOptions/Any(x:x eq 'Team')", + $select: 'id,displayName,description', + $top: '50', + }); + + logger.info({ count: response.value.length }, 'Retrieved teams'); + + return response.value; + } catch (error) { + logger.error({ err: error }, 'Failed to list teams'); + throw error; + } +} + +/** + * Gets channels for a specific team + * + * @param teamId - Team identifier + * @returns Array of channels + */ +export async function getTeamChannels(teamId: string): Promise { + logger.debug({ teamId }, 'Getting team channels'); + + try { + const response = await graphGet<{ value: Channel[] }>( + `/teams/${teamId}/channels`, + { + $select: 'id,displayName,description,webUrl,membershipType', + } + ); + + return response.value; + } catch (error) { + logger.error({ err: error, teamId }, 'Failed to get team channels'); + throw error; + } +} + +/** + * Gets recent messages from a channel + * + * @param teamId - Team identifier + * @param channelId - Channel identifier + * @param limit - Maximum messages to retrieve + * @returns Array of messages + */ +export async function getChannelMessages( + teamId: string, + channelId: string, + limit: number = 20 +): Promise { + logger.debug({ teamId, channelId, limit }, 'Getting channel messages'); + + try { + const response = await graphGet<{ value: TeamsMessage[] }>( + `/teams/${teamId}/channels/${channelId}/messages`, + { + $top: String(limit), + $select: 'id,createdDateTime,body,from,webUrl', + } + ); + + return response.value; + } catch (error) { + logger.error({ err: error, teamId, channelId }, 'Failed to get channel messages'); + throw error; + } +} + +/** + * Searches Teams messages across all accessible teams and channels + * + * Uses Microsoft Graph Search API for content-based search. + * + * @param options - Search options + * @returns Array of matching messages + */ +export async function searchMessages( + options: SearchMessagesOptions +): Promise { + const { query, limit = 10 } = options; + + logger.debug({ query, limit }, 'Searching Teams messages'); + + // Note: Teams message search requires specific permissions and may need + // the beta API endpoint. This is a simplified implementation. + + try { + // For now, return empty array as Teams search requires additional setup + // In production, you would use /search/query with entityTypes: ['chatMessage'] + logger.warn('Teams message search not fully implemented - requires beta API'); + + return []; + } catch (error) { + logger.error({ err: error, query }, 'Teams message search failed'); + throw error; + } +} + +/** + * Gets a specific team by ID + * + * @param teamId - Team identifier + * @returns Team information + */ +export async function getTeam(teamId: string): Promise { + logger.debug({ teamId }, 'Getting team'); + + const response = await graphGet(`/teams/${teamId}`, { + $select: 'id,displayName,description,webUrl', + }); + + return response; +} diff --git a/demo/backend/src/tools/index.ts b/demo/backend/src/tools/index.ts new file mode 100644 index 0000000000..dda1466aad --- /dev/null +++ b/demo/backend/src/tools/index.ts @@ -0,0 +1,55 @@ +/** + * Tools Index + * + * Registers all Copilot tools and exports them for use. + * + * @module tools + */ + +import { registerTool } from '../core/tool-registry.js'; +import { createLogger } from '../utils/index.js'; + +// Import tools +import { + listSharePointSitesTool, + searchSharePointTool, +} from './sharepoint.tool.js'; + +import { + getChannelMessagesTool, + getTeamChannelsTool, + listTeamsTool, +} from './teams.tool.js'; + +const logger = createLogger({ module: 'ToolsInit' }); + +/** + * Initializes and registers all M365 tools + * + * Call this function during application startup to make + * all tools available to Copilot sessions. + */ +export function initializeTools(): void { + logger.info('Initializing M365 tools...'); + + // Register SharePoint tools + registerTool(searchSharePointTool); + registerTool(listSharePointSitesTool); + + // Register Teams tools + registerTool(listTeamsTool); + registerTool(getTeamChannelsTool); + registerTool(getChannelMessagesTool); + + logger.info('M365 tools initialized'); +} + +// Export individual tools for direct use if needed +export { + listSharePointSitesTool, searchSharePointTool +} from './sharepoint.tool.js'; + +export { + getChannelMessagesTool, getTeamChannelsTool, listTeamsTool +} from './teams.tool.js'; + diff --git a/demo/backend/src/tools/sharepoint.tool.ts b/demo/backend/src/tools/sharepoint.tool.ts new file mode 100644 index 0000000000..1a222dd108 --- /dev/null +++ b/demo/backend/src/tools/sharepoint.tool.ts @@ -0,0 +1,244 @@ +/** + * SharePoint Search Tool + * + * Copilot tool for searching SharePoint documents and sites. + * Wraps the SharePoint service for use by the AI agent. + * + * @module tools/sharepoint + */ + +import { defineTool } from '../core/tool-registry.js'; +import { + listSites, + searchDocuments, + type SharePointDocument, + type SharePointSite, +} from '../services/graph/index.js'; +import { createLogger } from '../utils/index.js'; + +const logger = createLogger({ module: 'SharePointTool' }); + +/** + * Parameters for the search_sharepoint tool + */ +interface SearchSharePointParams { + /** Search query (supports natural language) */ + query: string; + /** Maximum results to return */ + limit?: number; + /** Specific site name to search within */ + siteName?: string; + /** File types to filter (e.g., 'docx', 'pdf', 'xlsx') */ + fileTypes?: string[]; +} + +/** + * Formats a document for LLM consumption + */ +function formatDocument(doc: SharePointDocument): string { + const parts = [ + `📄 **${doc.name}**`, + ` URL: ${doc.webUrl}`, + ]; + + if (doc.summary) { + parts.push(` Preview: ${doc.summary}`); + } + + if (doc.lastModifiedBy?.user?.displayName) { + parts.push(` Modified by: ${doc.lastModifiedBy.user.displayName}`); + } + + if (doc.lastModifiedDateTime) { + const date = new Date(doc.lastModifiedDateTime).toLocaleDateString(); + parts.push(` Last modified: ${date}`); + } + + return parts.join('\n'); +} + +/** + * Formats a site for LLM consumption + */ +function formatSite(site: SharePointSite): string { + return `📁 **${site.displayName}** (${site.name})\n URL: ${site.webUrl}${site.description ? `\n ${site.description}` : ''}`; +} + +/** + * SharePoint document search tool + * + * Allows Copilot to search for documents across SharePoint sites. + */ +export const searchSharePointTool = defineTool( + 'search_sharepoint', + { + description: `Search SharePoint for documents and files. Use this tool to find: +- Documents by content or title +- Files in specific SharePoint sites +- Documents by file type (docx, pdf, xlsx, pptx, etc.) + +Returns document names, URLs, content previews, and metadata.`, + + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query - can be keywords, phrases, or natural language', + }, + limit: { + type: 'number', + description: 'Maximum number of results to return (default: 10, max: 25)', + }, + siteName: { + type: 'string', + description: 'Optional: Name of specific SharePoint site to search within', + }, + fileTypes: { + type: 'array', + items: { type: 'string' }, + description: 'Optional: Filter by file types (e.g., ["docx", "pdf"])', + }, + }, + required: ['query'], + }, + + handler: async (params) => { + const { query, limit = 10, siteName, fileTypes } = params; + + logger.info({ query, limit, siteName, fileTypes }, 'Executing SharePoint search'); + + try { + // If site name provided, find the site first + let siteId: string | undefined; + + if (siteName) { + const sites = await listSites(siteName); + if (sites.length > 0 && sites[0]) { + siteId = sites[0].id; + } else { + return { + textResultForLlm: `No SharePoint site found matching "${siteName}". Try searching without a site filter or check the site name.`, + resultType: 'failure', + sessionLog: `SharePoint search: site "${siteName}" not found`, + toolTelemetry: { query, siteName, siteFound: false }, + }; + } + } + + // Perform the search + const documents = await searchDocuments({ + query, + limit: Math.min(limit, 25), + siteId, + fileTypes, + }); + + if (documents.length === 0) { + return { + textResultForLlm: `No documents found matching "${query}"${siteName ? ` in site "${siteName}"` : ''}${fileTypes?.length ? ` with file types: ${fileTypes.join(', ')}` : ''}.\n\nSuggestions:\n- Try different keywords\n- Remove file type filters\n- Search across all sites`, + resultType: 'success', + sessionLog: `SharePoint search: 0 results for "${query}"`, + toolTelemetry: { query, resultCount: 0 }, + }; + } + + const formattedResults = documents.map(formatDocument).join('\n\n'); + const resultText = `Found ${documents.length} document(s) matching "${query}":\n\n${formattedResults}`; + + return { + textResultForLlm: resultText, + resultType: 'success', + sessionLog: `SharePoint search: ${documents.length} results for "${query}"`, + toolTelemetry: { query, resultCount: documents.length }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error({ err: error, query }, 'SharePoint search failed'); + + return { + textResultForLlm: `SharePoint search failed: ${message}`, + resultType: 'failure', + error: message, + sessionLog: `SharePoint search error: ${message}`, + toolTelemetry: { query, error: message }, + }; + } + }, + } +); + +/** + * Parameters for list_sharepoint_sites tool + */ +interface ListSitesParams { + /** Optional search query to filter sites */ + searchQuery?: string; +} + +/** + * SharePoint site listing tool + * + * Allows Copilot to list available SharePoint sites. + */ +export const listSharePointSitesTool = defineTool( + 'list_sharepoint_sites', + { + description: `List available SharePoint sites. Use this tool to: +- Discover what SharePoint sites are available +- Find a site by name before searching within it +- Get site URLs for direct access`, + + parameters: { + type: 'object', + properties: { + searchQuery: { + type: 'string', + description: 'Optional: Filter sites by name', + }, + }, + }, + + handler: async (params) => { + const { searchQuery } = params; + + logger.info({ searchQuery }, 'Listing SharePoint sites'); + + try { + const sites = await listSites(searchQuery); + + if (sites.length === 0) { + return { + textResultForLlm: searchQuery + ? `No SharePoint sites found matching "${searchQuery}".` + : 'No SharePoint sites found or accessible.', + resultType: 'success', + sessionLog: 'SharePoint sites: 0 found', + toolTelemetry: { searchQuery, siteCount: 0 }, + }; + } + + const formattedSites = sites.map(formatSite).join('\n\n'); + const resultText = `Found ${sites.length} SharePoint site(s):\n\n${formattedSites}`; + + return { + textResultForLlm: resultText, + resultType: 'success', + sessionLog: `SharePoint sites: ${sites.length} found`, + toolTelemetry: { searchQuery, siteCount: sites.length }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error({ err: error }, 'Failed to list SharePoint sites'); + + return { + textResultForLlm: `Failed to list SharePoint sites: ${message}`, + resultType: 'failure', + error: message, + sessionLog: `SharePoint sites error: ${message}`, + toolTelemetry: { error: message }, + }; + } + }, + } +); diff --git a/demo/backend/src/tools/teams.tool.ts b/demo/backend/src/tools/teams.tool.ts new file mode 100644 index 0000000000..6caf836ed4 --- /dev/null +++ b/demo/backend/src/tools/teams.tool.ts @@ -0,0 +1,330 @@ +/** + * Microsoft Teams Tool + * + * Copilot tool for interacting with Microsoft Teams. + * Provides access to teams, channels, and messages. + * + * @module tools/teams + */ + +import { defineTool } from '../core/tool-registry.js'; +import { + getChannelMessages, + getTeamChannels, + listTeams, + type Channel, + type Team, + type TeamsMessage, +} from '../services/graph/index.js'; +import { createLogger } from '../utils/index.js'; + +const logger = createLogger({ module: 'TeamsTool' }); + +/** + * Formats a team for LLM consumption + */ +function formatTeam(team: Team): string { + return `👥 **${team.displayName}**${team.description ? `\n ${team.description}` : ''}`; +} + +/** + * Formats a channel for LLM consumption + */ +function formatChannel(channel: Channel): string { + const privacy = channel.membershipType === 'private' ? '🔒 ' : ''; + return `${privacy}#${channel.displayName}${channel.description ? ` - ${channel.description}` : ''}`; +} + +/** + * Formats a message for LLM consumption + */ +function formatMessage(message: TeamsMessage): string { + const author = message.from?.user?.displayName ?? 'Unknown'; + const date = new Date(message.createdDateTime).toLocaleString(); + + // Strip HTML tags for cleaner output + let content = message.body.content; + if (message.body.contentType === 'html') { + content = content.replace(/<[^>]*>/g, '').trim(); + } + + // Truncate long messages + if (content.length > 300) { + content = content.substring(0, 300) + '...'; + } + + return `💬 **${author}** (${date}):\n ${content}`; +} + +/** + * Parameters for list_teams tool + */ +interface ListTeamsParams { + /** No parameters needed */ +} + +/** + * List Teams tool + * + * Lists all accessible Microsoft Teams. + */ +export const listTeamsTool = defineTool('list_teams', { + description: `List all Microsoft Teams that are accessible. Use this tool to: +- Discover available teams +- Find a team before getting its channels or messages +- See team descriptions`, + + parameters: { + type: 'object', + properties: {}, + }, + + handler: async () => { + logger.info('Listing Teams'); + + try { + const teams = await listTeams(); + + if (teams.length === 0) { + return { + textResultForLlm: 'No Microsoft Teams found or accessible.', + resultType: 'success', + sessionLog: 'Teams: 0 found', + toolTelemetry: { teamCount: 0 }, + }; + } + + const formattedTeams = teams.map(formatTeam).join('\n\n'); + const resultText = `Found ${teams.length} team(s):\n\n${formattedTeams}`; + + return { + textResultForLlm: resultText, + resultType: 'success', + sessionLog: `Teams: ${teams.length} found`, + toolTelemetry: { teamCount: teams.length }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error({ err: error }, 'Failed to list Teams'); + + return { + textResultForLlm: `Failed to list Teams: ${message}`, + resultType: 'failure', + error: message, + sessionLog: `Teams error: ${message}`, + toolTelemetry: { error: message }, + }; + } + }, +}); + +/** + * Parameters for get_team_channels tool + */ +interface GetTeamChannelsParams { + /** Team name to get channels for */ + teamName: string; +} + +/** + * Get Team Channels tool + * + * Lists channels in a specific team. + */ +export const getTeamChannelsTool = defineTool( + 'get_team_channels', + { + description: `Get the channels in a Microsoft Teams team. Use this tool to: +- See what channels exist in a team +- Find a channel before reading its messages +- Understand team structure`, + + parameters: { + type: 'object', + properties: { + teamName: { + type: 'string', + description: 'Name of the team to get channels for', + }, + }, + required: ['teamName'], + }, + + handler: async (params) => { + const { teamName } = params; + + logger.info({ teamName }, 'Getting team channels'); + + try { + // Find the team by name + const teams = await listTeams(); + const team = teams.find( + (t) => t.displayName.toLowerCase() === teamName.toLowerCase() + ); + + if (!team) { + return { + textResultForLlm: `Team "${teamName}" not found. Use list_teams to see available teams.`, + resultType: 'failure', + sessionLog: `Team channels: team "${teamName}" not found`, + toolTelemetry: { teamName, found: false }, + }; + } + + const channels = await getTeamChannels(team.id); + + if (channels.length === 0) { + return { + textResultForLlm: `No channels found in team "${teamName}".`, + resultType: 'success', + sessionLog: `Team channels: 0 in "${teamName}"`, + toolTelemetry: { teamName, channelCount: 0 }, + }; + } + + const formattedChannels = channels.map(formatChannel).join('\n'); + const resultText = `Channels in **${team.displayName}**:\n\n${formattedChannels}`; + + return { + textResultForLlm: resultText, + resultType: 'success', + sessionLog: `Team channels: ${channels.length} in "${teamName}"`, + toolTelemetry: { teamName, channelCount: channels.length }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error({ err: error, teamName }, 'Failed to get team channels'); + + return { + textResultForLlm: `Failed to get channels: ${message}`, + resultType: 'failure', + error: message, + sessionLog: `Team channels error: ${message}`, + toolTelemetry: { teamName, error: message }, + }; + } + }, + } +); + +/** + * Parameters for get_channel_messages tool + */ +interface GetChannelMessagesParams { + /** Team name */ + teamName: string; + /** Channel name */ + channelName: string; + /** Number of messages to retrieve */ + limit?: number; +} + +/** + * Get Channel Messages tool + * + * Retrieves recent messages from a Teams channel. + */ +export const getChannelMessagesTool = defineTool( + 'get_channel_messages', + { + description: `Get recent messages from a Microsoft Teams channel. Use this tool to: +- Read recent conversations in a channel +- Find information shared in Teams +- See what the team has been discussing`, + + parameters: { + type: 'object', + properties: { + teamName: { + type: 'string', + description: 'Name of the team', + }, + channelName: { + type: 'string', + description: 'Name of the channel (e.g., "General")', + }, + limit: { + type: 'number', + description: 'Number of messages to retrieve (default: 10, max: 50)', + }, + }, + required: ['teamName', 'channelName'], + }, + + handler: async (params) => { + const { teamName, channelName, limit = 10 } = params; + + logger.info({ teamName, channelName, limit }, 'Getting channel messages'); + + try { + // Find the team + const teams = await listTeams(); + const team = teams.find( + (t) => t.displayName.toLowerCase() === teamName.toLowerCase() + ); + + if (!team) { + return { + textResultForLlm: `Team "${teamName}" not found.`, + resultType: 'failure', + sessionLog: `Channel messages: team "${teamName}" not found`, + toolTelemetry: { teamName, channelName, teamFound: false }, + }; + } + + // Find the channel + const channels = await getTeamChannels(team.id); + const channel = channels.find( + (c) => c.displayName.toLowerCase() === channelName.toLowerCase() + ); + + if (!channel) { + const availableChannels = channels.map((c) => c.displayName).join(', '); + return { + textResultForLlm: `Channel "${channelName}" not found in team "${teamName}". Available channels: ${availableChannels}`, + resultType: 'failure', + sessionLog: `Channel messages: channel "${channelName}" not found`, + toolTelemetry: { teamName, channelName, channelFound: false }, + }; + } + + // Get messages + const messages = await getChannelMessages( + team.id, + channel.id, + Math.min(limit, 50) + ); + + if (messages.length === 0) { + return { + textResultForLlm: `No messages found in #${channelName} (${teamName}).`, + resultType: 'success', + sessionLog: `Channel messages: 0 in "${channelName}"`, + toolTelemetry: { teamName, channelName, messageCount: 0 }, + }; + } + + const formattedMessages = messages.map(formatMessage).join('\n\n'); + const resultText = `Recent messages in **${team.displayName}** > #${channel.displayName}:\n\n${formattedMessages}`; + + return { + textResultForLlm: resultText, + resultType: 'success', + sessionLog: `Channel messages: ${messages.length} from "${channelName}"`, + toolTelemetry: { teamName, channelName, messageCount: messages.length }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error({ err: error, teamName, channelName }, 'Failed to get messages'); + + return { + textResultForLlm: `Failed to get messages: ${message}`, + resultType: 'failure', + error: message, + sessionLog: `Channel messages error: ${message}`, + toolTelemetry: { teamName, channelName, error: message }, + }; + } + }, + } +); diff --git a/demo/backend/src/utils/errors.ts b/demo/backend/src/utils/errors.ts new file mode 100644 index 0000000000..49c4304d74 --- /dev/null +++ b/demo/backend/src/utils/errors.ts @@ -0,0 +1,184 @@ +/** + * Custom Error Classes + * + * Defines application-specific error types for consistent error handling + * across the codebase. All errors include structured metadata for logging. + * + * @module utils/errors + */ + +/** + * Base application error with structured metadata + */ +export class AppError extends Error { + public readonly statusCode: number; + public readonly code: string; + public readonly isOperational: boolean; + + constructor( + message: string, + statusCode: number = 500, + code: string = 'INTERNAL_ERROR', + isOperational: boolean = true + ) { + super(message); + this.name = this.constructor.name; + this.statusCode = statusCode; + this.code = code; + this.isOperational = isOperational; + + // Maintains proper stack trace for where error was thrown + Error.captureStackTrace(this, this.constructor); + } + + /** + * Converts error to JSON for API responses + */ + toJSON() { + return { + error: { + code: this.code, + message: this.message, + statusCode: this.statusCode, + }, + }; + } +} + +/** + * Error for authentication failures + */ +export class AuthenticationError extends AppError { + constructor(message: string = 'Authentication failed') { + super(message, 401, 'AUTHENTICATION_ERROR'); + } +} + +/** + * Error for authorization failures + */ +export class AuthorizationError extends AppError { + constructor(message: string = 'Access denied') { + super(message, 403, 'AUTHORIZATION_ERROR'); + } +} + +/** + * Error for resource not found + */ +export class NotFoundError extends AppError { + constructor(resource: string, identifier?: string) { + const message = identifier + ? `${resource} not found: ${identifier}` + : `${resource} not found`; + super(message, 404, 'NOT_FOUND'); + } +} + +/** + * Error for validation failures + */ +export class ValidationError extends AppError { + public readonly details: unknown; + + constructor(message: string, details?: unknown) { + super(message, 400, 'VALIDATION_ERROR'); + this.details = details; + } + + override toJSON() { + return { + error: { + code: this.code, + message: this.message, + statusCode: this.statusCode, + details: this.details, + }, + }; + } +} + +/** + * Error for Microsoft Graph API failures + */ +export class GraphApiError extends AppError { + public readonly endpoint: string; + public readonly graphErrorCode?: string; + + constructor( + message: string, + statusCode: number, + endpoint: string, + graphErrorCode?: string + ) { + super(message, statusCode, 'GRAPH_API_ERROR'); + this.endpoint = endpoint; + this.graphErrorCode = graphErrorCode; + } + + override toJSON() { + return { + error: { + code: this.code, + message: this.message, + statusCode: this.statusCode, + endpoint: this.endpoint, + graphErrorCode: this.graphErrorCode, + }, + }; + } +} + +/** + * Error for Copilot SDK failures + */ +export class CopilotError extends AppError { + public readonly sessionId?: string; + + constructor(message: string, sessionId?: string) { + super(message, 500, 'COPILOT_ERROR'); + this.sessionId = sessionId; + } +} + +/** + * Error for session-related failures + */ +export class SessionError extends AppError { + public readonly sessionId?: string; + + constructor(message: string, sessionId?: string, statusCode: number = 400) { + super(message, statusCode, 'SESSION_ERROR'); + this.sessionId = sessionId; + } +} + +/** + * Type guard to check if an error is an AppError + */ +export function isAppError(error: unknown): error is AppError { + return error instanceof AppError; +} + +/** + * Wraps unknown errors in AppError for consistent handling + */ +export function wrapError(error: unknown, context?: string): AppError { + if (isAppError(error)) { + return error; + } + + if (error instanceof Error) { + return new AppError( + context ? `${context}: ${error.message}` : error.message, + 500, + 'INTERNAL_ERROR' + ); + } + + return new AppError( + context ? `${context}: Unknown error` : 'Unknown error', + 500, + 'INTERNAL_ERROR' + ); +} diff --git a/demo/backend/src/utils/index.ts b/demo/backend/src/utils/index.ts new file mode 100644 index 0000000000..46f340e994 --- /dev/null +++ b/demo/backend/src/utils/index.ts @@ -0,0 +1,16 @@ +/** + * Utility Functions Index + * + * Re-exports all utility modules for convenient importing. + * + * @module utils + */ + +export { + AppError, + AuthenticationError, + AuthorizationError, CopilotError, GraphApiError, NotFoundError, SessionError, ValidationError, isAppError, + wrapError +} from './errors.js'; +export { createLogger, logger, type Logger } from './logger.js'; + diff --git a/demo/backend/src/utils/logger.ts b/demo/backend/src/utils/logger.ts new file mode 100644 index 0000000000..010cc28535 --- /dev/null +++ b/demo/backend/src/utils/logger.ts @@ -0,0 +1,52 @@ +/** + * Logger Utility + * + * Provides structured logging using Pino. All application logging should + * use this module for consistent output format and log levels. + * + * @module utils/logger + */ + +import pino from 'pino'; +import { config } from '../config/index.js'; + +/** + * Logger instance configured for the application + */ +export const logger = pino({ + level: config.logging.level, + transport: config.server.isDevelopment + ? { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + } + : undefined, + base: { + env: config.server.nodeEnv, + }, + formatters: { + level: (label) => ({ level: label }), + }, +}); + +/** + * Creates a child logger with additional context + * + * @param context - Additional context to include in all log messages + * @returns Child logger instance + * + * @example + * ```typescript + * const serviceLogger = createLogger({ service: 'SharePointService' }); + * serviceLogger.info({ siteId }, 'Fetching site'); + * ``` + */ +export function createLogger(context: Record) { + return logger.child(context); +} + +export type Logger = typeof logger; diff --git a/demo/backend/tsconfig.json b/demo/backend/tsconfig.json new file mode 100644 index 0000000000..496c464a73 --- /dev/null +++ b/demo/backend/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/demo/common/types/api.types.ts b/demo/common/types/api.types.ts new file mode 100644 index 0000000000..4d10c3061a --- /dev/null +++ b/demo/common/types/api.types.ts @@ -0,0 +1,53 @@ +/** + * API Types + * + * Shared type definitions for API requests and responses. + * + * @module common/types/api + */ + +/** + * Standard API error response + */ +export interface ApiError { + error: { + code: string; + message: string; + statusCode: number; + details?: unknown; + }; +} + +/** + * Standard API success response wrapper + */ +export interface ApiResponse { + data: T; + meta?: { + timestamp: string; + requestId?: string; + }; +} + +/** + * Pagination parameters + */ +export interface PaginationParams { + page?: number; + limit?: number; + offset?: number; +} + +/** + * Paginated response wrapper + */ +export interface PaginatedResponse { + data: T[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasMore: boolean; + }; +} diff --git a/demo/common/types/index.ts b/demo/common/types/index.ts new file mode 100644 index 0000000000..8fcd22a6a8 --- /dev/null +++ b/demo/common/types/index.ts @@ -0,0 +1,12 @@ +/** + * Common Types Index + * + * Re-exports all shared type definitions. + * + * @module common/types + */ + +export * from './api.types.js'; +export * from './m365.types.js'; +export * from './session.types.js'; + diff --git a/demo/common/types/m365.types.ts b/demo/common/types/m365.types.ts new file mode 100644 index 0000000000..0fa148a7b0 --- /dev/null +++ b/demo/common/types/m365.types.ts @@ -0,0 +1,78 @@ +/** + * M365 Types + * + * Shared type definitions for Microsoft 365 resources. + * + * @module common/types/m365 + */ + +/** + * SharePoint site information + */ +export interface M365Site { + id: string; + name: string; + displayName: string; + webUrl: string; + description?: string; +} + +/** + * SharePoint document information + */ +export interface M365Document { + id: string; + name: string; + webUrl: string; + size: number; + mimeType?: string; + lastModified: string; + modifiedBy?: string; + location?: string; + summary?: string; +} + +/** + * Teams team information + */ +export interface M365Team { + id: string; + displayName: string; + description?: string; + webUrl?: string; +} + +/** + * Teams channel information + */ +export interface M365Channel { + id: string; + displayName: string; + description?: string; + webUrl?: string; + isPrivate: boolean; +} + +/** + * Teams message information + */ +export interface M365Message { + id: string; + content: string; + author: string; + timestamp: string; + webUrl?: string; +} + +/** + * Search result from any M365 source + */ +export interface M365SearchResult { + source: 'sharepoint' | 'teams' | 'onedrive' | 'outlook'; + id: string; + title: string; + url: string; + snippet?: string; + timestamp?: string; + metadata?: Record; +} diff --git a/demo/common/types/session.types.ts b/demo/common/types/session.types.ts new file mode 100644 index 0000000000..436b398581 --- /dev/null +++ b/demo/common/types/session.types.ts @@ -0,0 +1,65 @@ +/** + * Session Types + * + * Shared type definitions for session management. + * + * @module common/types/session + */ + +/** + * Session status + */ +export type SessionStatus = 'active' | 'idle' | 'expired' | 'error'; + +/** + * Session information for API responses + */ +export interface SessionInfo { + sessionId: string; + userId: string; + status: SessionStatus; + model: string; + createdAt: string; + lastActiveAt: string; +} + +/** + * Create session request + */ +export interface CreateSessionRequest { + userId: string; + sessionId?: string; + model?: string; + streaming?: boolean; + systemMessage?: string; +} + +/** + * Send message request + */ +export interface SendMessageRequest { + prompt: string; + attachments?: string[]; +} + +/** + * Message in conversation history + */ +export interface ConversationMessage { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp: string; + toolCalls?: ToolCallInfo[]; +} + +/** + * Tool call information + */ +export interface ToolCallInfo { + id: string; + name: string; + arguments: Record; + result?: string; + status: 'pending' | 'success' | 'error'; +} diff --git a/demo/skills/enterprise-knowledge/SKILL.md b/demo/skills/enterprise-knowledge/SKILL.md new file mode 100644 index 0000000000..3cf855db23 --- /dev/null +++ b/demo/skills/enterprise-knowledge/SKILL.md @@ -0,0 +1,77 @@ +--- +name: enterprise-knowledge +description: Enterprise knowledge assistant skill for finding information across M365 +--- + +# Enterprise Knowledge Assistant Skill + +You are an enterprise knowledge assistant that helps employees find information across Microsoft 365 services. + +## Your Capabilities + +You have access to tools that can: +- **Search SharePoint** - Find documents, files, and content across all SharePoint sites +- **List SharePoint Sites** - Discover available SharePoint sites +- **List Teams** - See accessible Microsoft Teams +- **Get Team Channels** - View channels within a team +- **Get Channel Messages** - Read recent messages from Teams channels + +## Guidelines + +### When Searching for Information + +1. **Clarify ambiguous requests** - If the user's query is vague, ask clarifying questions +2. **Use appropriate tools** - Match the request to the right M365 service: + - Documents/files → `search_sharepoint` + - Team discussions → Teams tools +3. **Combine sources when helpful** - A complete answer might need data from multiple services + +### When Presenting Results + +1. **Summarize first** - Start with a brief summary of what you found +2. **Provide specifics** - Include document names, URLs, and relevant snippets +3. **Group by source** - Organize results by where they came from +4. **Include metadata** - Show last modified dates, authors when relevant +5. **Offer next steps** - Suggest related searches or actions + +### When You Can't Find Information + +1. **Be transparent** - Clearly state you couldn't find matching results +2. **Suggest alternatives** - Offer different search terms or approaches +3. **Recommend other sources** - Point to where the information might exist + +## Response Format + +When presenting search results, use this structure: + +``` +**Summary**: [Brief overview of findings] + +**Found [N] result(s) from [source]:** + +📄 **[Document/Item Name]** + - URL: [link] + - [Relevant snippet or preview] + - Last modified: [date] by [author] + +**Suggestions:** +- [Related search or action] +``` + +## Example Interactions + +**User**: "Find the employee handbook" +**You**: Use search_sharepoint with query "employee handbook", then summarize the results with links. + +**User**: "What's been discussed about the product launch?" +**You**: Search both SharePoint for documents and Teams for recent conversations about "product launch". + +**User**: "Show me the IT team's channels" +**You**: First list teams to find "IT", then get channels for that team. + +## Important Notes + +- Always respect data privacy - only access information users have permission to view +- Be concise but thorough +- When in doubt, ask the user for clarification +- Provide actionable next steps when possible