diff --git a/.gitignore b/.gitignore index 125daad..b5b13bf 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ Thumbs.db reports -packages/web \ No newline at end of file +packages/web +claude.md \ No newline at end of file diff --git a/README.md b/README.md index 76e22dc..6298a77 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,33 @@ The AI industry solved intelligence. It forgot about sovereignty. - Encryption is a right, not a premium tier - If you can't export it, you don't own it +## Claude Code Agent Teams Integration + +Engram integrates with Claude Code Agent Teams through lifecycle hooks. When teams complete tasks or wind down, Engram can automatically consolidate team memories into structured summaries. + +**How it works:** +- Agents save memories tagged with `team:` during work +- Hooks trigger `engram consolidate` at key moments (task completion, teammate idle) +- Consolidation categorizes memories into findings, decisions, hypotheses, blockers, and action items +- Summaries are saved as searchable `team-summary` memories for future sessions + +**Configuration** (`.claude/settings.json`): + +```json +{ + "hooks": { + "TaskCompleted": [ + "engram consolidate" + ], + "TeammateIdle": [ + "engram consolidate" + ] + } +} +``` + +You can also run `engram consolidate` manually to generate team summaries on demand. + ---

diff --git a/assets/engram-teams-logo-dark.png b/assets/engram-teams-logo-dark.png new file mode 100644 index 0000000..017093c Binary files /dev/null and b/assets/engram-teams-logo-dark.png differ diff --git a/assets/engram-teams-logo-light.png b/assets/engram-teams-logo-light.png new file mode 100644 index 0000000..8cbe5eb Binary files /dev/null and b/assets/engram-teams-logo-light.png differ diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 3ddd093..d2ba136 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -15,6 +15,9 @@ import { SecretStore, CryptoService, IndexingService, + listTeams, + detectTeam, + consolidateTeam, } from '@engram/core'; export function createCLI() { @@ -634,6 +637,122 @@ export function createCLI() { } }); + program + .command('teams') + .description('List past agent teams and their knowledge') + .option('-s, --search ', 'Search team summaries semantically') + .action(async (options: { search?: string }) => { + const spinner = ora('Loading teams...').start(); + + try { + const db = initDatabase(); + const store = new MemoryStore(db); + + if (options.search) { + spinner.text = 'Searching team summaries...'; + const embedder = new EmbeddingService(); + const vector = await embedder.embed(options.search); + + const results = store.search(vector, 10); + const teamResults = results.filter((r) => + r.memory.tags.includes('team-summary') + ); + + spinner.stop(); + + if (teamResults.length === 0) { + console.log(chalk.yellow('No matching team summaries found.')); + } else { + console.log( + chalk.bold(`\nFound ${teamResults.length} team summaries:\n`) + ); + teamResults.forEach((r, i) => { + const date = new Date(r.memory.createdAt).toLocaleDateString(); + const similarity = (1 - r.distance).toFixed(3); + const teamTag = r.memory.tags.find((t) => t.startsWith('team:')); + const teamName = teamTag ? teamTag.replace('team:', '') : 'unknown'; + console.log(chalk.green.bold(`${i + 1}. Team: ${teamName}`)); + console.log(chalk.dim(` ${date} | similarity: ${similarity}`)); + console.log(chalk.white(` ${r.memory.content.split('\n')[0]}`)); + console.log(chalk.dim('─'.repeat(40)) + '\n'); + }); + } + } else { + const teams = listTeams(); + spinner.stop(); + + if (teams.length === 0) { + console.log(chalk.yellow('No teams found.')); + } else { + console.log(chalk.bold(`\nFound ${teams.length} teams:\n`)); + for (const team of teams) { + const teamTag = `team:${team.name}`; + const allMemories = store.list({ limit: 10000 }); + const teamMemoryCount = allMemories.filter((m) => + m.tags.includes(teamTag) + ).length; + + console.log( + chalk.cyan.bold(` ${team.name}`) + + chalk.dim(` (${team.members.length} members, ${teamMemoryCount} memories)`) + ); + for (const member of team.members) { + console.log(chalk.dim(` - ${member.name} [${member.agentType}]`)); + } + console.log(''); + } + } + } + + db.close(); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + spinner.fail(`Teams failed: ${msg}`); + process.exit(1); + } + }); + + program + .command('consolidate') + .description('Consolidate team memories into a structured summary') + .option('-t, --team ', 'Team name (auto-detected if omitted)') + .action(async (options: { team?: string }) => { + const spinner = ora('Consolidating team memories...').start(); + + try { + const teamName = options.team ?? detectTeam()?.name ?? null; + + if (!teamName) { + spinner.fail('No team detected. Use --team to specify.'); + process.exit(1); + } + + spinner.text = `Consolidating memories for team "${teamName}"...`; + + const db = initDatabase(); + const store = new MemoryStore(db); + const embedder = new EmbeddingService(); + + const result = await consolidateTeam(teamName, store, embedder); + + spinner.succeed( + `Consolidated ${result.memoriesProcessed} memories for team "${result.teamName}"` + ); + + console.log(chalk.bold('\nSummary:\n')); + console.log(result.summary); + console.log( + chalk.dim(`\nMemory ID: ${result.memoryId}`) + ); + + db.close(); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + spinner.fail(`Consolidation failed: ${msg}`); + process.exit(1); + } + }); + program .command('link') .description('Link a new device via QR code') @@ -764,14 +883,21 @@ function getClaudeCodeConfigPath(): string { } function getEngramMcpConfig() { - return { - command: 'npx', - args: ['-y', 'engram-core', 'server'], - env: { - ENGRAM_PATH: join(homedir(), '.engram', 'memory.db'), - EMBEDDING_PROVIDER: 'local:onnx', - }, + const env = { + ENGRAM_PATH: join(homedir(), '.engram', 'memory.db'), + EMBEDDING_PROVIDER: 'local:onnx', }; + + // Resolve server bin relative to this file: packages/cli/src → packages/server/dist/bin.js + const cliSrc = dirname(new URL(import.meta.url).pathname); + const serverBin = join(cliSrc, '..', '..', 'server', 'dist', 'bin.js'); + + if (existsSync(serverBin)) { + return { command: 'node', args: [serverBin], env }; + } + + // Fallback: npx (for published npm package scenario) + return { command: 'npx', args: ['-y', 'engram-core', 'server'], env }; } async function configureClients() { diff --git a/packages/core/package.json b/packages/core/package.json index 86f36c9..6369153 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,6 +45,10 @@ "./indexing": { "types": "./dist/indexing/index.d.ts", "import": "./dist/indexing/index.js" + }, + "./team": { + "types": "./dist/team/index.d.ts", + "import": "./dist/team/index.js" } }, "files": [ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3344c92..c8c116c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,4 +8,5 @@ export * from './sync/index.js'; export * from './secrets/index.js'; export * from './indexing/index.js'; export * from './llm/index.js'; +export * from './team/index.js'; export * from './types.js'; diff --git a/packages/core/src/team/consolidate.ts b/packages/core/src/team/consolidate.ts new file mode 100644 index 0000000..bd765e8 --- /dev/null +++ b/packages/core/src/team/consolidate.ts @@ -0,0 +1,165 @@ +import type { MemoryStore } from '../memory/store.js'; +import type { EmbeddingService } from '../embedding/service.js'; + +/** + * Result of consolidating a team's memories into a summary + */ +export interface ConsolidationResult { + teamName: string; + summary: string; + memoryId: string; + memoriesProcessed: number; + categoryCounts: Record; +} + +/** Category labels used for sorting team memories */ +const CATEGORIES = [ + 'finding', + 'decision', + 'hypothesis', + 'blocker', + 'action-item', +] as const; + +type Category = (typeof CATEGORIES)[number] | 'other'; + +/** + * Categorize a memory by scanning its tags for known category labels. + * Falls back to 'other' if no known category tag is found. + */ +function categorizeByTags(tags: string[]): Category { + for (const tag of tags) { + const lower = tag.toLowerCase(); + for (const cat of CATEGORIES) { + if (lower === cat) { + return cat; + } + } + } + return 'other'; +} + +/** Map category keys to human-readable markdown section headings */ +const SECTION_HEADINGS: Record = { + finding: 'Findings', + decision: 'Decisions', + hypothesis: 'Hypotheses', + blocker: 'Blockers', + 'action-item': 'Action Items', + other: 'Other Insights', +}; + +/** + * Build a markdown summary from categorized memories. + */ +function buildMarkdown( + teamName: string, + categorized: Record, + totalCount: number +): string { + const lines: string[] = []; + lines.push(`# Team Summary: ${teamName}`); + lines.push(''); + + const categoryOrder: Category[] = [ + 'finding', + 'decision', + 'hypothesis', + 'blocker', + 'action-item', + 'other', + ]; + + for (const cat of categoryOrder) { + const items = categorized[cat]; + if (items.length === 0) continue; + + lines.push(`## ${SECTION_HEADINGS[cat]}`); + for (const item of items) { + lines.push(`- ${item}`); + } + lines.push(''); + } + + lines.push('---'); + lines.push( + `Consolidated from ${totalCount} team memories on ${new Date().toISOString().split('T')[0]}` + ); + + return lines.join('\n'); +} + +/** + * Consolidate a team's memories into a single summary memory. + * + * Fetches all memories tagged with `team:{teamName}` (excluding existing + * `team-summary` entries), categorizes them, generates a markdown summary, + * embeds it, and saves it back to the store. + * + * @param teamName - The team name to consolidate + * @param store - The memory store instance + * @param embedder - The embedding service instance + * @returns The consolidation result + * @throws Error if no team memories are found + */ +export async function consolidateTeam( + teamName: string, + store: MemoryStore, + embedder: EmbeddingService +): Promise { + const teamTag = `team:${teamName}`; + + // Fetch all memories (large limit to get everything) + const allMemories = store.list({ limit: 10000 }); + + // Filter: must have team tag, must NOT have team-summary tag + const teamMemories = allMemories.filter( + (m) => m.tags.includes(teamTag) && !m.tags.includes('team-summary') + ); + + if (teamMemories.length === 0) { + throw new Error( + `No memories found for team "${teamName}". Nothing to consolidate.` + ); + } + + // Categorize memories + const categorized: Record = { + finding: [], + decision: [], + hypothesis: [], + blocker: [], + 'action-item': [], + other: [], + }; + + const categoryCounts: Record = {}; + + for (const memory of teamMemories) { + const category = categorizeByTags(memory.tags); + categorized[category].push(memory.content); + categoryCounts[category] = (categoryCounts[category] ?? 0) + 1; + } + + // Build the summary markdown + const summary = buildMarkdown(teamName, categorized, teamMemories.length); + + // Embed and save + const vector = await embedder.embed(summary); + const saved = store.create( + { + content: summary, + tags: ['team-summary', teamTag], + source: `team-consolidation:${teamName}`, + }, + vector + ); + + return { + teamName, + summary, + memoryId: saved.id, + memoriesProcessed: teamMemories.length, + categoryCounts, + }; +} diff --git a/packages/core/src/team/detector.ts b/packages/core/src/team/detector.ts new file mode 100644 index 0000000..4b03b47 --- /dev/null +++ b/packages/core/src/team/detector.ts @@ -0,0 +1,178 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +/** + * A member of a Claude Code agent team + */ +export interface TeamMember { + name: string; + agentId: string; + agentType: string; +} + +/** + * Information about a detected team + */ +export interface TeamInfo { + name: string; + members: TeamMember[]; + configPath: string; +} + +/** + * Parse and validate a team config JSON object. + * Returns a TeamInfo or null if the config is invalid. + */ +function parseTeamConfig( + raw: unknown, + teamName: string, + configPath: string +): TeamInfo | null { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return null; + } + + const obj = raw as Record; + + if (!Array.isArray(obj.members)) { + return null; + } + + const members: TeamMember[] = []; + for (const m of obj.members) { + if (typeof m !== 'object' || m === null || Array.isArray(m)) { + continue; + } + const member = m as Record; + if (typeof member.name !== 'string') { + continue; + } + members.push({ + name: member.name, + agentId: typeof member.agentId === 'string' ? member.agentId : 'unknown', + agentType: + typeof member.agentType === 'string' ? member.agentType : 'unknown', + }); + } + + return { + name: teamName, + members, + configPath, + }; +} + +/** + * Read and parse a single team config file. + * Returns null if the file cannot be read or is malformed. + */ +function readTeamConfig( + teamDir: string, + teamName: string +): TeamInfo | null { + const configPath = join(teamDir, 'config.json'); + try { + const content = readFileSync(configPath, 'utf-8'); + const parsed: unknown = JSON.parse(content); + return parseTeamConfig(parsed, teamName, configPath); + } catch { + return null; + } +} + +/** + * Detect the currently active team. + * + * First checks the CLAUDE_TEAM_NAME environment variable. + * If not set, scans the teams directory and returns the + * most recently modified team (by config.json mtime). + * + * @param teamsDir - Override the default teams directory + * @returns The detected team, or null if none found + */ +export function detectTeam(teamsDir?: string): TeamInfo | null { + const dir = teamsDir ?? join(homedir(), '.claude', 'teams'); + + // Check env var first + const envTeamName = process.env.CLAUDE_TEAM_NAME; + if (envTeamName) { + const teamDir = join(dir, envTeamName); + const info = readTeamConfig(teamDir, envTeamName); + if (info) return info; + } + + // Scan teams directory for the most recently modified team + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return null; + } + + let bestTeam: TeamInfo | null = null; + let bestMtime = 0; + + for (const entry of entries) { + const teamDir = join(dir, entry); + try { + const dirStat = statSync(teamDir); + if (!dirStat.isDirectory()) continue; + } catch { + continue; + } + + const configPath = join(teamDir, 'config.json'); + let mtime: number; + try { + const configStat = statSync(configPath); + mtime = configStat.mtimeMs; + } catch { + continue; + } + + const info = readTeamConfig(teamDir, entry); + if (info && mtime > bestMtime) { + bestTeam = info; + bestMtime = mtime; + } + } + + return bestTeam; +} + +/** + * List all valid teams found in the teams directory. + * + * @param teamsDir - Override the default teams directory + * @returns Array of valid team info objects + */ +export function listTeams(teamsDir?: string): TeamInfo[] { + const dir = teamsDir ?? join(homedir(), '.claude', 'teams'); + + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return []; + } + + const teams: TeamInfo[] = []; + + for (const entry of entries) { + const teamDir = join(dir, entry); + try { + const dirStat = statSync(teamDir); + if (!dirStat.isDirectory()) continue; + } catch { + continue; + } + + const info = readTeamConfig(teamDir, entry); + if (info) { + teams.push(info); + } + } + + return teams; +} diff --git a/packages/core/src/team/index.ts b/packages/core/src/team/index.ts new file mode 100644 index 0000000..0cf4df4 --- /dev/null +++ b/packages/core/src/team/index.ts @@ -0,0 +1,5 @@ +// Team module exports +export { detectTeam, listTeams } from './detector.js'; +export { consolidateTeam } from './consolidate.js'; +export type { TeamMember, TeamInfo } from './detector.js'; +export type { ConsolidationResult } from './consolidate.js'; diff --git a/packages/core/test/team/consolidate.test.ts b/packages/core/test/team/consolidate.test.ts new file mode 100644 index 0000000..f533f70 --- /dev/null +++ b/packages/core/test/team/consolidate.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { consolidateTeam } from '../../src/team/consolidate'; +import type { MemoryStore } from '../../src/memory/store'; +import type { EmbeddingService } from '../../src/embedding/service'; +import type { Memory } from '../../src/types'; + +function makeMemory( + overrides: Partial & { content: string; tags: string[] } +): Memory { + return { + id: overrides.id ?? `mem-${Math.random().toString(36).slice(2)}`, + content: overrides.content, + vector: overrides.vector ?? new Float32Array(384), + tags: overrides.tags, + source: overrides.source ?? null, + confidence: overrides.confidence ?? 0.5, + isVerified: overrides.isVerified ?? false, + createdAt: overrides.createdAt ?? Date.now(), + updatedAt: overrides.updatedAt ?? Date.now(), + }; +} + +describe('consolidateTeam', () => { + let mockStore: { + list: ReturnType; + create: ReturnType; + }; + let mockEmbedder: { + embed: ReturnType; + }; + + beforeEach(() => { + mockStore = { + list: vi.fn().mockReturnValue([]), + create: vi.fn().mockImplementation((input: { content: string; tags: string[] }, _vector: Float32Array) => ({ + id: 'summary-id-123', + content: input.content, + vector: new Float32Array(384), + tags: input.tags, + source: null, + confidence: 0.5, + isVerified: false, + createdAt: Date.now(), + updatedAt: Date.now(), + })), + }; + mockEmbedder = { + embed: vi.fn().mockResolvedValue(new Float32Array(384)), + }; + }); + + it('should throw when no team memories are found', async () => { + mockStore.list.mockReturnValue([]); + + await expect( + consolidateTeam( + 'test-team', + mockStore as unknown as MemoryStore, + mockEmbedder as unknown as EmbeddingService + ) + ).rejects.toThrow('No memories found for team "test-team"'); + }); + + it('should throw when only team-summary memories exist (no non-summary team memories)', async () => { + mockStore.list.mockReturnValue([ + makeMemory({ + content: 'Old summary', + tags: ['team:test-team', 'team-summary'], + }), + ]); + + await expect( + consolidateTeam( + 'test-team', + mockStore as unknown as MemoryStore, + mockEmbedder as unknown as EmbeddingService + ) + ).rejects.toThrow('No memories found for team "test-team"'); + }); + + it('should categorize memories correctly and produce markdown', async () => { + mockStore.list.mockReturnValue([ + makeMemory({ + content: 'The API returns 404 for missing users', + tags: ['team:my-team', 'finding'], + }), + makeMemory({ + content: 'We decided to use REST over GraphQL', + tags: ['team:my-team', 'decision'], + }), + makeMemory({ + content: 'Need to update the docs', + tags: ['team:my-team', 'action-item'], + }), + makeMemory({ + content: 'Might be a caching issue', + tags: ['team:my-team', 'hypothesis'], + }), + makeMemory({ + content: 'CI pipeline is broken', + tags: ['team:my-team', 'blocker'], + }), + makeMemory({ + content: 'General observation about code quality', + tags: ['team:my-team'], + }), + ]); + + const result = await consolidateTeam( + 'my-team', + mockStore as unknown as MemoryStore, + mockEmbedder as unknown as EmbeddingService + ); + + expect(result.teamName).toBe('my-team'); + expect(result.memoriesProcessed).toBe(6); + expect(result.memoryId).toBe('summary-id-123'); + + // Check category counts + expect(result.categoryCounts['finding']).toBe(1); + expect(result.categoryCounts['decision']).toBe(1); + expect(result.categoryCounts['action-item']).toBe(1); + expect(result.categoryCounts['hypothesis']).toBe(1); + expect(result.categoryCounts['blocker']).toBe(1); + expect(result.categoryCounts['other']).toBe(1); + + // Check markdown content + expect(result.summary).toContain('# Team Summary: my-team'); + expect(result.summary).toContain('## Findings'); + expect(result.summary).toContain('- The API returns 404 for missing users'); + expect(result.summary).toContain('## Decisions'); + expect(result.summary).toContain('- We decided to use REST over GraphQL'); + expect(result.summary).toContain('## Action Items'); + expect(result.summary).toContain('- Need to update the docs'); + expect(result.summary).toContain('## Hypotheses'); + expect(result.summary).toContain('- Might be a caching issue'); + expect(result.summary).toContain('## Blockers'); + expect(result.summary).toContain('- CI pipeline is broken'); + expect(result.summary).toContain('## Other Insights'); + expect(result.summary).toContain( + '- General observation about code quality' + ); + expect(result.summary).toContain('Consolidated from 6 team memories on'); + }); + + it('should exclude team-summary tagged memories from processing', async () => { + mockStore.list.mockReturnValue([ + makeMemory({ + content: 'Old summary that should be excluded', + tags: ['team:dev-team', 'team-summary'], + }), + makeMemory({ + content: 'Actual finding', + tags: ['team:dev-team', 'finding'], + }), + ]); + + const result = await consolidateTeam( + 'dev-team', + mockStore as unknown as MemoryStore, + mockEmbedder as unknown as EmbeddingService + ); + + expect(result.memoriesProcessed).toBe(1); + expect(result.summary).toContain('- Actual finding'); + expect(result.summary).not.toContain('Old summary that should be excluded'); + }); + + it('should only include non-empty category sections', async () => { + mockStore.list.mockReturnValue([ + makeMemory({ + content: 'Only a finding here', + tags: ['team:sparse-team', 'finding'], + }), + ]); + + const result = await consolidateTeam( + 'sparse-team', + mockStore as unknown as MemoryStore, + mockEmbedder as unknown as EmbeddingService + ); + + expect(result.summary).toContain('## Findings'); + expect(result.summary).not.toContain('## Decisions'); + expect(result.summary).not.toContain('## Hypotheses'); + expect(result.summary).not.toContain('## Blockers'); + expect(result.summary).not.toContain('## Action Items'); + expect(result.summary).not.toContain('## Other Insights'); + }); + + it('should filter memories from other teams', async () => { + mockStore.list.mockReturnValue([ + makeMemory({ + content: 'My team finding', + tags: ['team:target', 'finding'], + }), + makeMemory({ + content: 'Other team finding', + tags: ['team:other', 'finding'], + }), + ]); + + const result = await consolidateTeam( + 'target', + mockStore as unknown as MemoryStore, + mockEmbedder as unknown as EmbeddingService + ); + + expect(result.memoriesProcessed).toBe(1); + expect(result.summary).toContain('- My team finding'); + expect(result.summary).not.toContain('Other team finding'); + }); + + it('should save consolidated memory with correct tags', async () => { + mockStore.list.mockReturnValue([ + makeMemory({ + content: 'A finding', + tags: ['team:proj', 'finding'], + }), + ]); + + await consolidateTeam( + 'proj', + mockStore as unknown as MemoryStore, + mockEmbedder as unknown as EmbeddingService + ); + + expect(mockStore.create).toHaveBeenCalledTimes(1); + const createCall = mockStore.create.mock.calls[0]; + expect(createCall[0].tags).toEqual(['team-summary', 'team:proj']); + expect(createCall[0].source).toBe('team-consolidation:proj'); + }); + + it('should embed the summary text before saving', async () => { + mockStore.list.mockReturnValue([ + makeMemory({ + content: 'A finding', + tags: ['team:embed-test', 'finding'], + }), + ]); + + const fakeVector = new Float32Array(384).fill(0.42); + mockEmbedder.embed.mockResolvedValue(fakeVector); + + await consolidateTeam( + 'embed-test', + mockStore as unknown as MemoryStore, + mockEmbedder as unknown as EmbeddingService + ); + + expect(mockEmbedder.embed).toHaveBeenCalledTimes(1); + // The embed call should receive the summary markdown + const embedArg = mockEmbedder.embed.mock.calls[0][0] as string; + expect(embedArg).toContain('# Team Summary: embed-test'); + + // The vector passed to create should be the one from embed + const createCall = mockStore.create.mock.calls[0]; + expect(createCall[1]).toBe(fakeVector); + }); +}); diff --git a/packages/core/test/team/detector.test.ts b/packages/core/test/team/detector.test.ts new file mode 100644 index 0000000..22d5832 --- /dev/null +++ b/packages/core/test/team/detector.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync, utimesSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { detectTeam, listTeams } from '../../src/team/detector'; + +describe('detectTeam', () => { + let teamsDir: string; + + beforeEach(() => { + teamsDir = join(tmpdir(), `engram-test-teams-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(teamsDir, { recursive: true }); + }); + + afterEach(() => { + // Clean up env + delete process.env.CLAUDE_TEAM_NAME; + // Remove temp dir + try { + rmSync(teamsDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should return null when teams directory does not exist', () => { + const result = detectTeam('/nonexistent/path/to/teams'); + expect(result).toBeNull(); + }); + + it('should return null when teams directory is empty', () => { + const result = detectTeam(teamsDir); + expect(result).toBeNull(); + }); + + it('should detect team from CLAUDE_TEAM_NAME env var', () => { + const teamDir = join(teamsDir, 'my-team'); + mkdirSync(teamDir, { recursive: true }); + writeFileSync( + join(teamDir, 'config.json'), + JSON.stringify({ + members: [ + { name: 'lead', agentId: 'a1', agentType: 'coordinator' }, + { name: 'worker', agentId: 'a2', agentType: 'implementer' }, + ], + }) + ); + + process.env.CLAUDE_TEAM_NAME = 'my-team'; + const result = detectTeam(teamsDir); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('my-team'); + expect(result!.members).toHaveLength(2); + expect(result!.members[0]!.name).toBe('lead'); + expect(result!.members[0]!.agentId).toBe('a1'); + expect(result!.members[0]!.agentType).toBe('coordinator'); + expect(result!.configPath).toBe(join(teamDir, 'config.json')); + }); + + it('should fall back to directory scan when env var team is invalid', () => { + // Create a valid team that should be found by scan + const teamDir = join(teamsDir, 'fallback-team'); + mkdirSync(teamDir, { recursive: true }); + writeFileSync( + join(teamDir, 'config.json'), + JSON.stringify({ + members: [{ name: 'agent-1', agentId: 'x', agentType: 'worker' }], + }) + ); + + process.env.CLAUDE_TEAM_NAME = 'nonexistent-team'; + const result = detectTeam(teamsDir); + + // Env var team doesn't exist, so it should scan and find fallback-team + expect(result).not.toBeNull(); + expect(result!.name).toBe('fallback-team'); + }); + + it('should select the most recently modified team config', () => { + // Create older team + const oldDir = join(teamsDir, 'old-team'); + mkdirSync(oldDir, { recursive: true }); + const oldConfigPath = join(oldDir, 'config.json'); + writeFileSync( + oldConfigPath, + JSON.stringify({ + members: [{ name: 'old-agent', agentId: 'o1', agentType: 'worker' }], + }) + ); + // Set mtime to past + const pastDate = new Date(Date.now() - 60000); + utimesSync(oldConfigPath, pastDate, pastDate); + + // Create newer team + const newDir = join(teamsDir, 'new-team'); + mkdirSync(newDir, { recursive: true }); + writeFileSync( + join(newDir, 'config.json'), + JSON.stringify({ + members: [{ name: 'new-agent', agentId: 'n1', agentType: 'worker' }], + }) + ); + + const result = detectTeam(teamsDir); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('new-team'); + expect(result!.members[0]!.name).toBe('new-agent'); + }); + + it('should skip entries with malformed JSON', () => { + const badDir = join(teamsDir, 'bad-team'); + mkdirSync(badDir, { recursive: true }); + writeFileSync(join(badDir, 'config.json'), '{ invalid json!!!'); + + const goodDir = join(teamsDir, 'good-team'); + mkdirSync(goodDir, { recursive: true }); + writeFileSync( + join(goodDir, 'config.json'), + JSON.stringify({ + members: [{ name: 'agent', agentId: 'a1', agentType: 'worker' }], + }) + ); + + const result = detectTeam(teamsDir); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('good-team'); + }); + + it('should skip entries without members array', () => { + const noMembersDir = join(teamsDir, 'no-members'); + mkdirSync(noMembersDir, { recursive: true }); + writeFileSync( + join(noMembersDir, 'config.json'), + JSON.stringify({ description: 'no members field' }) + ); + + const result = detectTeam(teamsDir); + expect(result).toBeNull(); + }); + + it('should default agentId and agentType to unknown when missing', () => { + const teamDir = join(teamsDir, 'minimal-team'); + mkdirSync(teamDir, { recursive: true }); + writeFileSync( + join(teamDir, 'config.json'), + JSON.stringify({ + members: [{ name: 'agent-only-name' }], + }) + ); + + const result = detectTeam(teamsDir); + + expect(result).not.toBeNull(); + expect(result!.members[0]!.name).toBe('agent-only-name'); + expect(result!.members[0]!.agentId).toBe('unknown'); + expect(result!.members[0]!.agentType).toBe('unknown'); + }); + + it('should skip members without a name', () => { + const teamDir = join(teamsDir, 'partial-team'); + mkdirSync(teamDir, { recursive: true }); + writeFileSync( + join(teamDir, 'config.json'), + JSON.stringify({ + members: [ + { agentId: 'no-name' }, + { name: 'valid', agentId: 'v1', agentType: 'worker' }, + ], + }) + ); + + const result = detectTeam(teamsDir); + + expect(result).not.toBeNull(); + expect(result!.members).toHaveLength(1); + expect(result!.members[0]!.name).toBe('valid'); + }); + + it('should skip non-directory entries', () => { + // Create a file (not a directory) in the teams dir + writeFileSync(join(teamsDir, 'not-a-team.txt'), 'just a file'); + + const result = detectTeam(teamsDir); + expect(result).toBeNull(); + }); +}); + +describe('listTeams', () => { + let teamsDir: string; + + beforeEach(() => { + teamsDir = join(tmpdir(), `engram-test-teams-list-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(teamsDir, { recursive: true }); + }); + + afterEach(() => { + try { + rmSync(teamsDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should return empty array when directory does not exist', () => { + const result = listTeams('/nonexistent/path/to/teams'); + expect(result).toEqual([]); + }); + + it('should return empty array when no valid teams exist', () => { + const badDir = join(teamsDir, 'bad'); + mkdirSync(badDir, { recursive: true }); + writeFileSync(join(badDir, 'config.json'), 'not json'); + + const result = listTeams(teamsDir); + expect(result).toEqual([]); + }); + + it('should list all valid teams', () => { + const team1Dir = join(teamsDir, 'alpha'); + mkdirSync(team1Dir, { recursive: true }); + writeFileSync( + join(team1Dir, 'config.json'), + JSON.stringify({ + members: [{ name: 'a1', agentId: 'id1', agentType: 'lead' }], + }) + ); + + const team2Dir = join(teamsDir, 'beta'); + mkdirSync(team2Dir, { recursive: true }); + writeFileSync( + join(team2Dir, 'config.json'), + JSON.stringify({ + members: [{ name: 'b1', agentId: 'id2', agentType: 'worker' }], + }) + ); + + // Add an invalid team that should be skipped + const badDir = join(teamsDir, 'invalid'); + mkdirSync(badDir, { recursive: true }); + writeFileSync(join(badDir, 'config.json'), '<<>>'); + + const result = listTeams(teamsDir); + + expect(result).toHaveLength(2); + const names = result.map((t) => t.name).sort(); + expect(names).toEqual(['alpha', 'beta']); + }); + + it('should use team directory name as team name', () => { + const teamDir = join(teamsDir, 'my-project-team'); + mkdirSync(teamDir, { recursive: true }); + writeFileSync( + join(teamDir, 'config.json'), + JSON.stringify({ + members: [{ name: 'agent', agentId: 'a', agentType: 't' }], + }) + ); + + const result = listTeams(teamsDir); + + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('my-project-team'); + }); +}); diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index b0335a7..11a2284 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ 'src/secrets/index.ts', 'src/indexing/index.ts', 'src/llm/index.ts', + 'src/team/index.ts', ], format: ['esm'], dts: true, diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 06bd3c8..85b6d6f 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,7 +1,9 @@ import { existsSync } from 'node:fs'; import { + consolidateTeam, CryptoService, + detectTeam, EmbeddingService, generateRecoveryKit, IndexingService, @@ -158,15 +160,50 @@ export function createEngramServer( try { // Sanitize content BEFORE embedding to ensure secrets are not in the vector const { sanitized } = store.sanitize(content); + + // Auto-tag with team name if running in a team context + const teamInfo = detectTeam(); + const finalTags = [...(tags ?? [])]; + if (teamInfo) { + const teamTag = `team:${teamInfo.name}`; + if (!finalTags.includes(teamTag)) { + finalTags.push(teamTag); + } + } + const vector = await embedder.embed(sanitized); - const memory = store.create({ content: sanitized, tags }, vector); + const memory = store.create( + { content: sanitized, tags: finalTags }, + vector + ); + + // Check for contradictions among findings + let contradictionWarning = ''; + if (finalTags.includes('finding')) { + const similar = store.search(vector, 3); + const contradictions = similar.filter( + (r) => + r.distance < 0.3 && + r.memory.id !== memory.id && + r.memory.tags.includes('finding') + ); + if (contradictions.length > 0) { + const warnings = contradictions + .map( + (c) => + ` - "${c.memory.content.substring(0, 80)}..." (similarity: ${(1 - c.distance).toFixed(3)})` + ) + .join('\n'); + contradictionWarning = `\n\nNote: Found ${contradictions.length} very similar existing finding(s) that may conflict:\n${warnings}`; + } + } return { content: [ { type: 'text', - text: `Remembered: "${memory.content.substring(0, 100)}${memory.content.length > 100 ? '...' : ''}" (ID: ${memory.id})`, + text: `Remembered: "${memory.content.substring(0, 100)}${memory.content.length > 100 ? '...' : ''}" (ID: ${memory.id})${contradictionWarning}`, }, ], }; @@ -360,7 +397,11 @@ export function createEngramServer( }); const sessionResults = results - .filter((r) => r.memory.tags.includes('session-index')) + .filter( + (r) => + r.memory.tags.includes('session-index') || + r.memory.tags.includes('team-summary') + ) .slice(0, limit); if (sessionResults.length === 0) { @@ -373,6 +414,17 @@ export function createEngramServer( const formatted = sessionResults .map((r) => { + if (r.memory.tags.includes('team-summary')) { + const teamNameTag = r.memory.tags.find((t) => + t.startsWith('team:') + ); + const teamName = teamNameTag + ? teamNameTag.substring(5) + : 'unknown'; + return `Team: ${teamName} (Similarity: ${(1 - r.distance).toFixed(2)}): +Summary: ${r.memory.content} +Actionable: This is a consolidated team summary. Use it as context for your current task.`; + } return `Found relevant session (Similarity: ${(1 - r.distance).toFixed(2)}): Path: ${r.memory.source} Summary: ${r.memory.content} @@ -397,6 +449,54 @@ Actionable: Use grep/ripgrep on this file to find implementation details.`; } ); + server.tool( + 'mcp_consolidate_team', + 'Consolidate all memories from an agent team into a structured summary for future reference', + { + team_name: z + .string() + .optional() + .describe('Team name. Auto-detected if omitted.'), + }, + async ({ team_name }) => { + try { + const resolvedName = team_name ?? detectTeam()?.name; + if (!resolvedName) { + return { + content: [ + { + type: 'text' as const, + text: 'No team detected and no team_name provided. Specify team_name or run within a team context.', + }, + ], + isError: true, + }; + } + const result = await consolidateTeam(resolvedName, store, embedder); + return { + content: [ + { + type: 'text' as const, + text: `Team "${resolvedName}" consolidated: ${result.memoriesProcessed} memories processed.\n\n${result.summary}`, + }, + ], + }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown error'; + return { + content: [ + { + type: 'text' as const, + text: `Consolidation failed: ${message}`, + }, + ], + isError: true, + }; + } + } + ); + server.tool( 'mcp_set_secret', 'Create or update a secret in the encrypted vault with optional cloud sync.', diff --git a/packages/server/test/e2e/mcp-protocol.e2e.test.ts b/packages/server/test/e2e/mcp-protocol.e2e.test.ts index dde2e0c..571ddef 100644 --- a/packages/server/test/e2e/mcp-protocol.e2e.test.ts +++ b/packages/server/test/e2e/mcp-protocol.e2e.test.ts @@ -109,12 +109,13 @@ describe('MCP Server E2E — Real Protocol', () => { expect(info!.name).toBe('engram'); }); - it('should list all 12 registered tools with schemas', async () => { + it('should list all 13 registered tools with schemas', async () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name).sort(); expect(names).toEqual([ 'mcp_authorize_device', + 'mcp_consolidate_team', 'mcp_create_recovery_kit', 'mcp_delete_memory', 'mcp_find_similar_sessions', diff --git a/packages/server/test/integration/team.integration.test.ts b/packages/server/test/integration/team.integration.test.ts new file mode 100644 index 0000000..dd93d50 --- /dev/null +++ b/packages/server/test/integration/team.integration.test.ts @@ -0,0 +1,741 @@ +/** + * Integration Tests for Team Memory Features + * + * Tests the end-to-end team memory workflow: + * E.1 — Team auto-tagging via detectTeam() in mcp_save_memory + * E.2 — Consolidation round-trip (save team memories -> consolidate -> verify summary) + * E.3 — Contradiction detection for similar findings + * E.4 — CLI teams / listTeams with mock directory structure + * E.5 — Edge cases (no teams dir, empty team, malformed config, double consolidation) + * + * Uses mocked McpServer (captures tool handlers) + mocked SessionWatcher, + * but real @engram/core logic (MemoryStore, EmbeddingService, consolidateTeam, detectTeam). + */ +import { + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, + afterEach, + vi, +} from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Mock only McpServer (capture tool handlers) and SessionWatcher (filesystem) +vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => { + return { + McpServer: vi.fn().mockImplementation(() => { + const tools = new Map(); + return { + tool: vi.fn().mockImplementation( + ( + name: string, + _desc: string, + _schema: unknown, + handler: Function + ) => { + tools.set(name, handler); + } + ), + _getToolHandler: (name: string) => tools.get(name), + }; + }), + }; +}); + +vi.mock('@engram/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // Mock detectTeam so integration tests can control team context per-test + detectTeam: vi.fn().mockReturnValue(null), + // Only mock SessionWatcher to avoid filesystem side effects + SessionWatcher: Object.assign( + vi.fn().mockImplementation(() => ({ + watch: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + })), + { + getDefaultPaths: vi + .fn() + .mockReturnValue([ + '/mock/.claude/projects', + '/mock/.claude/plugins', + ]), + getProjectPath: vi.fn().mockReturnValue('/mock/project/.claude'), + } + ), + // IndexingService requires LLMService which we don't need + IndexingService: vi.fn().mockImplementation(() => ({})), + }; +}); + +import { createEngramServer } from '../../src/server'; +import type { EngramServerResult } from '../../src/server'; +import { + CryptoService, + detectTeam, + listTeams, + consolidateTeam, +} from '@engram/core'; + +// Import the real (unmocked) detector functions for E.4/E.5 tests. +// vi.mock('@engram/core') only mocks the main '@engram/core' specifier; +// subpath exports like '@engram/core/team' remain unmocked. +import { + detectTeam as realDetectTeam, + listTeams as realListTeams, +} from '@engram/core/team'; + +// ============================================================ +// E.1 — Team auto-tagging +// ============================================================ + +describe('E.1 — Team auto-tagging in mcp_save_memory', () => { + let serverResult: EngramServerResult; + let getHandler: (name: string) => Function; + let teamsDir: string; + + beforeAll(async () => { + // Create a temporary teams directory with a valid team config + teamsDir = join( + tmpdir(), + `engram-team-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + const teamDir = join(teamsDir, 'alpha-team'); + mkdirSync(teamDir, { recursive: true }); + writeFileSync( + join(teamDir, 'config.json'), + JSON.stringify({ + members: [ + { name: 'lead', agentId: 'a1', agentType: 'coordinator' }, + { name: 'worker', agentId: 'a2', agentType: 'implementer' }, + ], + }) + ); + + // Set the env var so detectTeam() picks up our test team + process.env.CLAUDE_TEAM_NAME = 'alpha-team'; + // Override the default teams directory by setting the env to find our dir + // Since detectTeam() in server.ts uses default homedir path, we need to + // mock detectTeam to return our team. The real detectTeam is tested in + // core unit tests; here we test the server integration. + + const masterKey = CryptoService.generateMasterKey(); + serverResult = createEngramServer({ + dbPath: ':memory:', + masterKey, + }); + + await serverResult.embedder.initialize(); + + getHandler = (name: string) => { + const handler = (serverResult.server as any)._getToolHandler(name); + if (!handler) throw new Error(`Tool handler '${name}' not found`); + return handler; + }; + }, 120_000); + + afterAll(async () => { + delete process.env.CLAUDE_TEAM_NAME; + await serverResult.close(); + try { + rmSync(teamsDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + }); + + it('should auto-tag memory with team name when team is detected', async () => { + // Since detectTeam() in the server uses the default homedir path, + // we need to verify the behavior through the mocked detectTeam. + // The server.ts calls detectTeam() without arguments. + // For this integration test, we mock detectTeam at the module level. + const { detectTeam: mockedDetectTeam } = await import('@engram/core'); + vi.mocked(mockedDetectTeam).mockReturnValue({ + name: 'alpha-team', + members: [ + { name: 'lead', agentId: 'a1', agentType: 'coordinator' }, + ], + configPath: join(teamsDir, 'alpha-team', 'config.json'), + }); + + const save = getHandler('mcp_save_memory'); + const list = getHandler('mcp_list_memories'); + + const result = await save({ + content: 'The API endpoint returns 200 for valid requests', + tags: ['finding'], + }); + + expect(result.content[0].text).toContain('Remembered:'); + expect(result.isError).toBeUndefined(); + + // Verify the tag is present by listing memories + const listResult = await list({ limit: 50 }); + expect(listResult.content[0].text).toContain('team:alpha-team'); + expect(listResult.content[0].text).toContain('finding'); + }); + + it('should not duplicate team tag if already present', async () => { + const { detectTeam: mockedDetectTeam } = await import('@engram/core'); + vi.mocked(mockedDetectTeam).mockReturnValue({ + name: 'alpha-team', + members: [], + configPath: '/mock/path', + }); + + const save = getHandler('mcp_save_memory'); + const list = getHandler('mcp_list_memories'); + + // Provide the team tag manually + await save({ + content: 'Already tagged with team name manually', + tags: ['team:alpha-team', 'decision'], + }); + + const listResult = await list({ limit: 50 }); + const text = listResult.content[0].text; + + // Check that "team:alpha-team" appears but is not doubled + // The memory should have [team:alpha-team, decision] not [team:alpha-team, decision, team:alpha-team] + expect(text).toContain('Already tagged with team name manually'); + }); + + it('should not add team tag when no team is detected', async () => { + const { detectTeam: mockedDetectTeam } = await import('@engram/core'); + vi.mocked(mockedDetectTeam).mockReturnValue(null); + + const save = getHandler('mcp_save_memory'); + + const result = await save({ + content: 'Memory without team context', + tags: ['general'], + }); + + expect(result.content[0].text).toContain('Remembered:'); + // No team tag should be added + }); +}); + +// ============================================================ +// E.2 — Consolidation round-trip +// ============================================================ + +describe('E.2 — Consolidation round-trip', () => { + let serverResult: EngramServerResult; + let getHandler: (name: string) => Function; + + beforeAll(async () => { + const masterKey = CryptoService.generateMasterKey(); + serverResult = createEngramServer({ + dbPath: ':memory:', + masterKey, + }); + + await serverResult.embedder.initialize(); + + getHandler = (name: string) => { + const handler = (serverResult.server as any)._getToolHandler(name); + if (!handler) throw new Error(`Tool handler '${name}' not found`); + return handler; + }; + }, 120_000); + + afterAll(async () => { + await serverResult.close(); + }); + + it('should save team memories, consolidate them, and verify the summary', async () => { + const { detectTeam: mockedDetectTeam } = await import('@engram/core'); + vi.mocked(mockedDetectTeam).mockReturnValue({ + name: 'consolidation-test', + members: [], + configPath: '/mock/path', + }); + + const save = getHandler('mcp_save_memory'); + const consolidate = getHandler('mcp_consolidate_team'); + const find = getHandler('mcp_find_similar_sessions'); + const list = getHandler('mcp_list_memories'); + + // 1. Save multiple team memories with different categories + await save({ + content: 'The auth service has a memory leak in session handling', + tags: ['finding'], + }); + + await save({ + content: 'We decided to use JWT tokens instead of session cookies', + tags: ['decision'], + }); + + await save({ + content: 'Migrate legacy endpoints to new auth system by Friday', + tags: ['action-item'], + }); + + await save({ + content: 'General observation about code structure', + tags: [], + }); + + // 2. Consolidate the team + const consolidateResult = await consolidate({ + team_name: 'consolidation-test', + }); + + expect(consolidateResult.content[0].text).toContain( + 'Team "consolidation-test" consolidated' + ); + expect(consolidateResult.content[0].text).toContain( + '4 memories processed' + ); + expect(consolidateResult.isError).toBeUndefined(); + + // 3. Verify summary content + const summaryText = consolidateResult.content[0].text; + expect(summaryText).toContain('# Team Summary: consolidation-test'); + expect(summaryText).toContain('## Findings'); + expect(summaryText).toContain('memory leak'); + expect(summaryText).toContain('## Decisions'); + expect(summaryText).toContain('JWT tokens'); + expect(summaryText).toContain('## Action Items'); + expect(summaryText).toContain('Migrate legacy endpoints'); + expect(summaryText).toContain('## Other Insights'); + expect(summaryText).toContain('General observation'); + + // 4. Verify the summary is listed with correct tags + const listResult = await list({ limit: 50 }); + expect(listResult.content[0].text).toContain('team-summary'); + expect(listResult.content[0].text).toContain('team:consolidation-test'); + + // 5. Verify the summary is searchable via mcp_find_similar_sessions + // (team-summary tagged memories are included in session search) + const findResult = await find({ + intent: 'auth service memory leak JWT tokens', + limit: 5, + }); + + const findText = findResult.content[0].text; + // The find should return the team summary since it has the team-summary tag + expect(findText).toContain('Team: consolidation-test'); + }); + + it('should fail consolidation when no team memories exist', async () => { + const consolidate = getHandler('mcp_consolidate_team'); + + const result = await consolidate({ + team_name: 'nonexistent-team', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Consolidation failed'); + expect(result.content[0].text).toContain('No memories found'); + }); + + it('should fail when no team name is provided and no team is detected', async () => { + const { detectTeam: mockedDetectTeam } = await import('@engram/core'); + vi.mocked(mockedDetectTeam).mockReturnValue(null); + + const consolidate = getHandler('mcp_consolidate_team'); + + const result = await consolidate({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('No team detected'); + }); +}); + +// ============================================================ +// E.3 — Contradiction detection +// ============================================================ + +describe('E.3 — Contradiction detection', () => { + let serverResult: EngramServerResult; + let getHandler: (name: string) => Function; + + beforeAll(async () => { + const masterKey = CryptoService.generateMasterKey(); + serverResult = createEngramServer({ + dbPath: ':memory:', + masterKey, + }); + + await serverResult.embedder.initialize(); + + getHandler = (name: string) => { + const handler = (serverResult.server as any)._getToolHandler(name); + if (!handler) throw new Error(`Tool handler '${name}' not found`); + return handler; + }; + }, 120_000); + + afterAll(async () => { + await serverResult.close(); + }); + + it('should detect contradictions between very similar findings', async () => { + const { detectTeam: mockedDetectTeam } = await import('@engram/core'); + vi.mocked(mockedDetectTeam).mockReturnValue(null); + + const save = getHandler('mcp_save_memory'); + + // Use identical sentences to guarantee distance < 0.15. + // The contradiction logic fires when: distance < 0.15, different id, both tagged 'finding'. + const sharedContent = + 'The database connection pool is configured with a maximum size of exactly 10 concurrent connections'; + + // 1. Save the first finding + const first = await save({ + content: sharedContent, + tags: ['finding'], + }); + + expect(first.content[0].text).toContain('Remembered:'); + expect(first.content[0].text).not.toContain('similar existing finding'); + + // 2. Save an identical finding — should trigger contradiction + const second = await save({ + content: sharedContent, + tags: ['finding'], + }); + + const secondText = second.content[0].text; + expect(secondText).toContain('Remembered:'); + // Exact duplicate should produce distance ~0, well below 0.15 threshold + expect(secondText).toContain('similar existing finding'); + }); + + it('should not trigger contradiction warning for non-finding tags', async () => { + const { detectTeam: mockedDetectTeam } = await import('@engram/core'); + vi.mocked(mockedDetectTeam).mockReturnValue(null); + + const save = getHandler('mcp_save_memory'); + + // Save a decision (not a finding) + await save({ + content: 'We use Redis for caching with TTL of 3600 seconds', + tags: ['decision'], + }); + + // Save a very similar decision + const second = await save({ + content: 'We use Redis for caching with TTL of 3600 seconds globally', + tags: ['decision'], + }); + + // No contradiction warning because these are decisions, not findings + expect(second.content[0].text).not.toContain('similar existing finding'); + }); + + it('should not trigger contradiction for unrelated findings', async () => { + const { detectTeam: mockedDetectTeam } = await import('@engram/core'); + vi.mocked(mockedDetectTeam).mockReturnValue(null); + + const save = getHandler('mcp_save_memory'); + + await save({ + content: 'Python uses garbage collection with reference counting', + tags: ['finding'], + }); + + const second = await save({ + content: + 'Kubernetes pods are scheduled by the kube-scheduler component', + tags: ['finding'], + }); + + // These are completely unrelated findings, no contradiction expected + expect(second.content[0].text).not.toContain('similar existing finding'); + }); +}); + +// ============================================================ +// E.4 — CLI teams / listTeams with mock directory +// ============================================================ + +describe('E.4 — listTeams and detectTeam with mock directory', () => { + let teamsDir: string; + + beforeEach(() => { + teamsDir = join( + tmpdir(), + `engram-teams-e4-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(teamsDir, { recursive: true }); + }); + + afterEach(() => { + delete process.env.CLAUDE_TEAM_NAME; + try { + rmSync(teamsDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + it('should list all valid teams from a directory', () => { + // Create team directories + const team1Dir = join(teamsDir, 'frontend'); + mkdirSync(team1Dir, { recursive: true }); + writeFileSync( + join(team1Dir, 'config.json'), + JSON.stringify({ + members: [ + { name: 'ui-lead', agentId: 'u1', agentType: 'lead' }, + { name: 'css-expert', agentId: 'u2', agentType: 'specialist' }, + ], + }) + ); + + const team2Dir = join(teamsDir, 'backend'); + mkdirSync(team2Dir, { recursive: true }); + writeFileSync( + join(team2Dir, 'config.json'), + JSON.stringify({ + members: [ + { name: 'api-lead', agentId: 'b1', agentType: 'lead' }, + ], + }) + ); + + // Use the real (unmocked) listTeams with our custom dir + const teams = realListTeams(teamsDir); + + expect(teams).toHaveLength(2); + const names = teams.map((t) => t.name).sort(); + expect(names).toEqual(['backend', 'frontend']); + + const frontend = teams.find((t) => t.name === 'frontend')!; + expect(frontend.members).toHaveLength(2); + expect(frontend.members[0]!.name).toBe('ui-lead'); + }); + + it('should detect team via CLAUDE_TEAM_NAME env var', () => { + const teamDir = join(teamsDir, 'my-active-team'); + mkdirSync(teamDir, { recursive: true }); + writeFileSync( + join(teamDir, 'config.json'), + JSON.stringify({ + members: [{ name: 'agent', agentId: 'x', agentType: 'worker' }], + }) + ); + + process.env.CLAUDE_TEAM_NAME = 'my-active-team'; + // Use the real (unmocked) detectTeam with our custom dir + const result = realDetectTeam(teamsDir); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('my-active-team'); + expect(result!.members).toHaveLength(1); + }); + + it('should return empty array when directory does not exist', () => { + const teams = realListTeams('/nonexistent/teams/dir'); + expect(teams).toEqual([]); + }); + + it('should return null when no teams directory exists', () => { + const result = realDetectTeam('/nonexistent/teams/dir'); + expect(result).toBeNull(); + }); +}); + +// ============================================================ +// E.5 — Edge cases +// ============================================================ + +describe('E.5 — Edge cases', () => { + let teamsDir: string; + + beforeEach(() => { + teamsDir = join( + tmpdir(), + `engram-teams-e5-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(teamsDir, { recursive: true }); + }); + + afterEach(() => { + delete process.env.CLAUDE_TEAM_NAME; + try { + rmSync(teamsDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + // ── No teams directory ──────────────────────────────────────── + + it('should gracefully handle non-existent teams directory', () => { + const teams = realListTeams('/no/such/dir'); + expect(teams).toEqual([]); + + const detected = realDetectTeam('/no/such/dir'); + expect(detected).toBeNull(); + }); + + // ── Malformed config.json ───────────────────────────────────── + + it('should skip teams with malformed config.json', () => { + // Create a team with broken JSON + const badDir = join(teamsDir, 'broken-team'); + mkdirSync(badDir, { recursive: true }); + writeFileSync(join(badDir, 'config.json'), '{ this is not valid JSON!!!'); + + // Create a valid team + const goodDir = join(teamsDir, 'valid-team'); + mkdirSync(goodDir, { recursive: true }); + writeFileSync( + join(goodDir, 'config.json'), + JSON.stringify({ + members: [{ name: 'agent', agentId: 'a1', agentType: 'worker' }], + }) + ); + + const teams = realListTeams(teamsDir); + expect(teams).toHaveLength(1); + expect(teams[0]!.name).toBe('valid-team'); + }); + + it('should skip config.json without members array', () => { + const noMembersDir = join(teamsDir, 'no-members'); + mkdirSync(noMembersDir, { recursive: true }); + writeFileSync( + join(noMembersDir, 'config.json'), + JSON.stringify({ description: 'missing members' }) + ); + + const teams = realListTeams(teamsDir); + expect(teams).toEqual([]); + }); + + it('should skip config.json that is a JSON array instead of object', () => { + const arrayDir = join(teamsDir, 'array-config'); + mkdirSync(arrayDir, { recursive: true }); + writeFileSync( + join(arrayDir, 'config.json'), + JSON.stringify([{ name: 'agent' }]) + ); + + const teams = realListTeams(teamsDir); + expect(teams).toEqual([]); + }); + + // ── Empty team (no memories to consolidate) ─────────────────── + + describe('Empty team consolidation', () => { + let serverResult: EngramServerResult; + let getHandler: (name: string) => Function; + + beforeAll(async () => { + const masterKey = CryptoService.generateMasterKey(); + serverResult = createEngramServer({ + dbPath: ':memory:', + masterKey, + }); + + await serverResult.embedder.initialize(); + + getHandler = (name: string) => { + const handler = (serverResult.server as any)._getToolHandler(name); + if (!handler) throw new Error(`Tool handler '${name}' not found`); + return handler; + }; + }, 120_000); + + afterAll(async () => { + await serverResult.close(); + }); + + it('should return error when consolidating empty team', async () => { + const consolidate = getHandler('mcp_consolidate_team'); + + const result = await consolidate({ team_name: 'empty-team' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Consolidation failed'); + expect(result.content[0].text).toContain('No memories found'); + }); + }); + + // ── Double consolidation ────────────────────────────────────── + + describe('Double consolidation exclusion', () => { + let serverResult: EngramServerResult; + let getHandler: (name: string) => Function; + + beforeAll(async () => { + const masterKey = CryptoService.generateMasterKey(); + serverResult = createEngramServer({ + dbPath: ':memory:', + masterKey, + }); + + await serverResult.embedder.initialize(); + + getHandler = (name: string) => { + const handler = (serverResult.server as any)._getToolHandler(name); + if (!handler) throw new Error(`Tool handler '${name}' not found`); + return handler; + }; + }, 120_000); + + afterAll(async () => { + await serverResult.close(); + }); + + it('should exclude team-summary memories from re-consolidation', async () => { + const { detectTeam: mockedDetectTeam } = await import('@engram/core'); + vi.mocked(mockedDetectTeam).mockReturnValue({ + name: 'double-test', + members: [], + configPath: '/mock/path', + }); + + const save = getHandler('mcp_save_memory'); + const consolidate = getHandler('mcp_consolidate_team'); + + // Save some team memories + await save({ + content: 'The payment API returns HTTP 402 for failed charges', + tags: ['finding'], + }); + + await save({ + content: 'We decided to retry failed payments 3 times', + tags: ['decision'], + }); + + // First consolidation + const first = await consolidate({ team_name: 'double-test' }); + expect(first.content[0].text).toContain('2 memories processed'); + expect(first.isError).toBeUndefined(); + + // Add another memory after consolidation + await save({ + content: 'Payment webhook needs to handle duplicate events', + tags: ['finding'], + }); + + // Second consolidation — should NOT include the first summary + const second = await consolidate({ team_name: 'double-test' }); + expect(second.content[0].text).toContain('3 memories processed'); + expect(second.isError).toBeUndefined(); + + // The second summary should contain the new finding but should + // have been generated from 3 original memories, not 4 (excluding the first summary) + expect(second.content[0].text).toContain( + 'Payment webhook needs to handle duplicate events' + ); + expect(second.content[0].text).toContain( + 'payment API returns HTTP 402' + ); + }); + }); +}); diff --git a/packages/server/test/server.test.ts b/packages/server/test/server.test.ts index 2a3ed86..2cc4cce 100644 --- a/packages/server/test/server.test.ts +++ b/packages/server/test/server.test.ts @@ -83,6 +83,14 @@ vi.mock('@engram/core', () => { } ), KeyManager: vi.fn().mockImplementation(() => ({})), + detectTeam: vi.fn().mockReturnValue(null), + consolidateTeam: vi.fn().mockResolvedValue({ + teamName: 'test-team', + summary: '# Team Summary', + memoryId: 'sum-1', + memoriesProcessed: 3, + categoryCounts: { finding: 2, other: 1 }, + }), }; });