Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ Thumbs.db

reports

packages/web
packages/web
claude.md
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>` 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.

---

<p align="center">
Expand Down
Binary file added assets/engram-teams-logo-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/engram-teams-logo-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
140 changes: 133 additions & 7 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Command } from 'commander';

Check failure on line 1 in packages/cli/src/cli.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

`commander` import should occur after import of `@engram/core`
import chalk from 'chalk';

Check failure on line 2 in packages/cli/src/cli.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

`chalk` import should occur after import of `@engram/core`
import ora from 'ora';

Check failure on line 3 in packages/cli/src/cli.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

`ora` import should occur after import of `@engram/core`

Check failure on line 3 in packages/cli/src/cli.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

There should be at least one empty line between import groups
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';

Check failure on line 5 in packages/cli/src/cli.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

`node:path` import should occur after import of `node:os`
import { homedir } from 'node:os';

import {
Expand All @@ -15,7 +15,10 @@
SecretStore,
CryptoService,
IndexingService,
listTeams,
detectTeam,
consolidateTeam,
} from '@engram/core';

Check failure on line 21 in packages/cli/src/cli.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

Unable to resolve path to module '@engram/core'

export function createCLI() {
const program = new Command();
Expand All @@ -33,8 +36,8 @@
const spinner = ora('Initializing Engram...').start();

try {
const keyManager = new KeyManager();

Check failure on line 39 in packages/cli/src/cli.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

Unsafe construction of an any type value

Check failure on line 39 in packages/cli/src/cli.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

Unsafe assignment of an `any` value
const hasKey = await keyManager.hasMasterKey();

Check failure on line 40 in packages/cli/src/cli.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

Unsafe call of an `any` typed value

Check failure on line 40 in packages/cli/src/cli.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

Unsafe assignment of an `any` value

if (hasKey && !options.force) {
spinner.fail(
Expand Down Expand Up @@ -634,6 +637,122 @@
}
});

program
.command('teams')
.description('List past agent teams and their knowledge')
.option('-s, --search <query>', '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 <name>', '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 <name> 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')
Expand Down Expand Up @@ -764,14 +883,21 @@
}

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() {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
165 changes: 165 additions & 0 deletions packages/core/src/team/consolidate.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
}

/** 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<Category, string> = {
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<Category, string[]>,
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<ConsolidationResult> {
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<Category, string[]> = {
finding: [],
decision: [],
hypothesis: [],
blocker: [],
'action-item': [],
other: [],
};

const categoryCounts: Record<string, number> = {};

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