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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ config.json
.playwright-cli
.nano-banana-config.json
generated_imgs/
AGENTS.local.md
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ All bots default to **it/its** pronouns. Bots are software, not people. When wri

## Key Conventions

### Local Instructions

The bridge loads `AGENTS.local.md` from each bot's working directory if present. This file is gitignored and injected into sessions via `custom_instructions`. Use it for per-operator conventions (e.g., push policies, workflow preferences) that don't belong in the repo.

### Channel Adapter Pattern

New platforms implement `ChannelAdapter` (in `src/types.ts`). The Mattermost adapter (`src/channels/mattermost/adapter.ts`) is the reference implementation.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ More screenshots [here](docs/screenshots.md).
## Features

- **Multi-bot support** — Run multiple bot identities on the same platform (e.g., `@copilot` for admin, `@alice` for tasks)
- **Workspaces** — Each bot gets an isolated workspace with its own `AGENTS.md`, `.env` secrets, and `MEMORY.md`
- **Workspaces** — Each bot gets an isolated workspace with its own `AGENTS.md`, `.env` secrets, `MEMORY.md`, and optional `AGENTS.local.md` for per-operator conventions
- **DM auto-discovery** — Just message a bot; no channel config needed for direct messages
- **Streaming responses** — Edit-in-place message updates with throttling
- **MCP & skills** — Auto-loads MCP servers and skill directories from Copilot config
Expand Down Expand Up @@ -68,7 +68,7 @@ See the [Setup Guide — Running as a Service](docs/setup.md#running-as-a-servic
| **Session** | | |
| `/new` | | Start a fresh session |
| `/stop` | `/cancel` | Stop the current task |
| `/reload` | | Reload session (re-reads AGENTS.md, workspace config) |
| `/reload` | | Reload session (re-reads AGENTS.md, AGENTS.local.md, workspace config) |
| `/reload config` | | Hot-reload config.json (safe changes apply without restart) |
| `/resume [id]` | | List past sessions, or resume one by ID |
Comment thread
ChrisRomp marked this conversation as resolved.
| `/model [name]` | `/models` | List models or switch model (fuzzy match) |
Expand Down
9 changes: 9 additions & 0 deletions docs/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Workspaces are auto-created when the bridge starts and detects a bot without one
```
~/.copilot-bridge/workspaces/agent-name/
├── AGENTS.md # Agent instructions (auto-generated from template, customizable)
├── AGENTS.local.md # Local operator conventions (gitignored, optional)
├── MEMORY.md # Persistent memory across sessions (managed by the agent)
├── mcp-config.json # Workspace-specific MCP servers (optional, overrides global)
└── .env # Environment variables loaded at session start
Expand All @@ -18,6 +19,14 @@ Workspaces are auto-created when the bridge starts and detects a bot without one

For group channels or project-specific DMs, override the workspace via `workingDirectory` in [config.json](configuration.md#channels). The same bot can serve multiple channels, each pointed at a different directory.

## AGENTS.local.md

An optional, gitignored file for per-operator conventions (push policies, branching rules, workflow preferences). The bridge loads it from the working directory at session creation and injects its content into `custom_instructions` alongside bridge instructions.

This file is **not** discovered by the Copilot SDK/CLI — the bridge handles it in `buildSystemMessage()`. It's intended for conventions that are personal to the operator rather than the project (which belong in `AGENTS.md`).

To use it, create `AGENTS.local.md` in the working directory and add it to `.gitignore`.

## Agent templates

Templates in the repo define the baseline instructions for agents:
Expand Down
7 changes: 7 additions & 0 deletions src/core/bridge-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,13 +348,20 @@ Each bot has a dedicated workspace directory (default: \`~/.copilot-bridge/works
\`\`\`
<workspace>/
├── AGENTS.md # Agent instructions (read by Copilot CLI)
├── AGENTS.local.md # Local operator conventions (gitignored, optional)
├── .env # Environment variables (secrets, API tokens)
├── mcp-config.json # Workspace-specific MCP servers (optional)
├── agents/ # Named agent personas (*.agent.md)
├── .github/skills/ # Project-level skills
└── .agents/skills/ # Legacy skill location
\`\`\`

## AGENTS.local.md

Optional, gitignored file for per-operator conventions. Loaded by the bridge and injected into \`custom_instructions\` alongside bridge instructions. Use for preferences like push policies, branching rules, or workflow constraints that are personal to the operator rather than the project.

Not loaded by the SDK/CLI directly — the bridge handles it in \`buildSystemMessage()\`.

## .env Files

- Loaded into shell environment at session start
Expand Down
2 changes: 1 addition & 1 deletion src/core/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -916,7 +916,7 @@ export async function handleCommand(channelId: string, text: string, sessionInfo
'**Session**',
'`/new` — Start a new session',
'`/stop` — Stop the current task (alias: `/cancel`)',
'`/reload` — Reload session (re-reads AGENTS.md, workspace config)',
'`/reload` — Reload session (re-reads AGENTS.md, AGENTS.local.md, workspace config)',
'`/reload config` — Hot-reload config.json',
'`/reload mcp` — Reload MCP servers (no session restart)',
'`/reload skills` — Reload skills (no session restart)',
Expand Down
57 changes: 57 additions & 0 deletions src/core/local-instructions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { loadLocalInstructions } from './session-manager.js';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';

describe('loadLocalInstructions', () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'local-instructions-'));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('returns undefined when workingDirectory is undefined', () => {
expect(loadLocalInstructions(undefined)).toBeUndefined();
});

it('returns undefined when workingDirectory is empty string', () => {
expect(loadLocalInstructions('')).toBeUndefined();
});

it('returns undefined when AGENTS.local.md does not exist', () => {
expect(loadLocalInstructions(tmpDir)).toBeUndefined();
});

it('returns undefined when AGENTS.local.md is empty', () => {
fs.writeFileSync(path.join(tmpDir, 'AGENTS.local.md'), '');
expect(loadLocalInstructions(tmpDir)).toBeUndefined();
});

it('returns undefined when AGENTS.local.md is whitespace-only', () => {
fs.writeFileSync(path.join(tmpDir, 'AGENTS.local.md'), ' \n\n ');
expect(loadLocalInstructions(tmpDir)).toBeUndefined();
});

it('wraps content in <local_instructions> tags', () => {
fs.writeFileSync(path.join(tmpDir, 'AGENTS.local.md'), '# My Rules\nDo the thing.');
const result = loadLocalInstructions(tmpDir);
expect(result).toBe(
'<local_instructions>\n# My Rules\nDo the thing.\n</local_instructions>',
);
});

it('trims leading/trailing whitespace from content', () => {
fs.writeFileSync(path.join(tmpDir, 'AGENTS.local.md'), '\n Hello \n\n');
const result = loadLocalInstructions(tmpDir);
expect(result).toBe('<local_instructions>\nHello\n</local_instructions>');
});

it('returns undefined for a nonexistent directory', () => {
expect(loadLocalInstructions('/tmp/nonexistent-dir-' + Date.now())).toBeUndefined();
});
});
51 changes: 43 additions & 8 deletions src/core/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,26 @@ export function extractPlanSummary(content: string): string {
return '(empty plan)';
}

/** Load AGENTS.local.md from a workspace directory, wrapped in XML tags.
* Returns the wrapped content string, or undefined if the file doesn't exist or is empty. */
export function loadLocalInstructions(workingDirectory?: string): string | undefined {
if (!workingDirectory) return undefined;
const localAgentsPath = path.join(workingDirectory, 'AGENTS.local.md');
try {
if (fs.existsSync(localAgentsPath)) {
const localContent = fs.readFileSync(localAgentsPath, 'utf-8').trim();
if (localContent) {
log.debug(`Loaded AGENTS.local.md from ${workingDirectory}`);
return `<local_instructions>\n${localContent}\n</local_instructions>`;
}
}
} catch (err: any) {
if (err?.code === 'ENOENT') return undefined;
log.warn(`Failed to read AGENTS.local.md from ${localAgentsPath}:`, err);
}
return undefined;
}

export class SessionManager {
private bridge: CopilotBridge;
private channelSessions = new Map<string, string>(); // channelId → sessionId
Expand Down Expand Up @@ -1602,9 +1622,13 @@ export class SessionManager {

/** Build the system message config for session create/resume.
* Appends bridge-specific instructions to the SDK's custom_instructions section
* so agents get channel communication context without polluting AGENTS.md. */
private buildSystemMessage(): SystemMessageCustomizeConfig {
const content = [
* so agents get channel communication context without polluting AGENTS.md.
* Also loads AGENTS.local.md from the working directory if present. */
private buildSystemMessage(workingDirectory?: string): SystemMessageCustomizeConfig {
const parts: string[] = [];

// Bridge-specific instructions
parts.push([
'<bridge_instructions>',
'You are communicating through copilot-bridge, a messaging bridge to a chat platform (e.g., Mattermost, Slack).',
'',
Expand All @@ -1624,12 +1648,18 @@ export class SessionManager {
'- When you have nothing meaningful to add to a conversation, call the `no_reply` tool instead of sending text',
'- This is preferred over typing "NO_REPLY" or similar text responses',
'</bridge_instructions>',
].join('\n');
].join('\n'));

// Load AGENTS.local.md if present (gitignored, per-operator conventions)
const localInstructions = loadLocalInstructions(workingDirectory);
if (localInstructions) {
parts.push(localInstructions);
}

return {
mode: 'customize' as const,
sections: {
custom_instructions: { action: 'append' as const, content },
custom_instructions: { action: 'append' as const, content: parts.join('\n\n') },
},
};
}
Expand Down Expand Up @@ -1696,7 +1726,7 @@ export class SessionManager {
tools: customTools.length > 0 ? customTools : undefined,
hooks,
infiniteSessions: getConfig().infiniteSessions,
systemMessage: this.buildSystemMessage(),
systemMessage: this.buildSystemMessage(workingDirectory),
})
);
};
Expand Down Expand Up @@ -1747,7 +1777,7 @@ export class SessionManager {
tools: customTools.length > 0 ? customTools : undefined,
hooks,
infiniteSessions: getConfig().infiniteSessions,
systemMessage: this.buildSystemMessage(),
systemMessage: this.buildSystemMessage(workingDirectory),
})
);
};
Expand Down Expand Up @@ -1857,7 +1887,7 @@ export class SessionManager {
tools: customTools.length > 0 ? customTools : undefined,
hooks,
infiniteSessions: getConfig().infiniteSessions,
systemMessage: this.buildSystemMessage(),
systemMessage: this.buildSystemMessage(workingDirectory),
})
);

Expand Down Expand Up @@ -1924,6 +1954,11 @@ export class SessionManager {
if (agentDef) {
systemParts.push(`\n--- Agent Definition: ${agentDef.name} ---\n${agentDef.content}`);
}
// Load AGENTS.local.md from target bot's workspace if present
const localInstructions = loadLocalInstructions(targetWorkspace);
if (localInstructions) {
systemParts.push(localInstructions);
}
// If the target has an ask_agent tool available, inject the chain context
if (nextContext.depth < (iaConfig.maxDepth ?? 3)) {
systemParts.push(
Expand Down
Loading