This document describes the architecture and design decisions for the AIr-Friends project—an AI-powered conversational agent that operates across multiple platforms (Discord, Misskey) with persistent cross-conversation memory and a clean separation between internal reasoning and external communication.
- AIr-Friends Design Document
AIr-Friends is a conversational AI bot designed to:
- Operate across multiple platforms (Discord, Misskey) with a unified abstraction layer
- Maintain cross-conversation memory using append-only log files
- Isolate different conversation contexts through workspace-based trust boundaries
- Keep reasoning processes internal while only exposing final replies externally
- Run in containerized environments with Deno as the execution runtime
- Trust Boundary Isolation: Each conversation context (platform/user combination) has its own isolated workspace
- Clean Thought Process: Agent's intermediate reasoning and tool calls remain internal; only final replies are sent externally
- Append-Only Memory: Memory cannot be deleted, only disabled—ensuring audit trail integrity
- Platform Agnostic: Core logic is decoupled from platform-specific implementations
- Configuration-Driven: Bot behavior and credentials are externalized to configuration files
┌─────────────────────────────────────────────────────────────────────────────┐
│ AIr-Friends │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Discord │ │ Misskey │ │ (Future) │ Platform │
│ │ Adapter │ │ Adapter │ │ Adapters │ Layer │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └───────────────────┼───────────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Normalized Event │ Event Normalization │
│ │ (platform, user, │ │
│ │ channel, guild) │ │
│ └──────────┬──────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Workspace Manager │ Trust Boundary │
│ │ (working directory │ Enforcement │
│ │ selection/creation)│ │
│ └──────────┬──────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Agent Session │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ Context Assembly │ │ Agent Core │ │ Skills Layer │ │ │
│ │ │ (memory, recent │ │ (reasoning, │ │ (tools for │ │ │
│ │ │ messages, etc.) │ │ planning) │ │ agent calls) │ │ │
│ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Final Reply Only │ Output Gate │
│ │ (send-reply skill)│ │
│ └──────────┬──────────┘ │
│ ▼ │
│ External Platform │
└─────────────────────────────────────────────────────────────────────────────┘
The system uses working directories to enforce trust boundaries:
repo/
└── workspaces/
├── discord/
│ ├── user123/ # Per-user workspace
│ │ ├── memory.public.jsonl
│ │ └── memory.private.jsonl
│ └── user456/
│ ├── memory.public.jsonl
│ └── memory.private.jsonl
└── misskey/
└── user789/
├── memory.public.jsonl
└── memory.private.jsonl
Key Rules:
workspace_key = "{platform}/{user_id}"- Same user across different channels shares one workspace
- Each workspace is isolated—no cross-workspace file access allowed
- Both
memory.public.jsonlandmemory.private.jsonlexist in every workspace - In DM context: saves to private, reads from both private and public
- In non-DM context (guild/public thread): saves/reads from public only
- Each agent session uses its workspace as the current working directory (cwd)
Responsibility: Manages workspace creation, selection, and access control.
interface WorkspaceManager {
// Calculate workspace key from event
getWorkspaceKey(event: NormalizedEvent): string;
// Ensure workspace directory exists and return path
ensureWorkspace(key: string): Promise<string>;
// Validate that a path is within the workspace boundary
validatePath(workspacePath: string, targetPath: string): boolean;
}Constraints:
- Workspace paths must not overlap
- File operations outside the workspace must be rejected
- Directory creation must be idempotent
Responsibility: Handle platform-specific communication and event translation.
Each adapter must implement:
| Capability | Description |
|---|---|
fetch_recent_messages |
Retrieve recent messages from a channel |
search_messages |
Search messages by keyword |
send_reply |
Send a reply to the platform |
Adapter Interface:
interface PlatformAdapter {
readonly platform: string;
// Connect to platform and start receiving events
connect(): Promise<void>;
// Disconnect gracefully
disconnect(): Promise<void>;
// Subscribe to normalized events
onEvent(handler: (event: NormalizedEvent) => void): void;
// Platform capabilities (exposed as skills)
fetchRecentMessages(channelId: string, limit: number): Promise<Message[]>;
searchMessages(channelId: string, query: string): Promise<Message[]>;
sendReply(channelId: string, content: string): Promise<void>;
}Responsibility: Execute a single interaction cycle with isolated state.
interface AgentSession {
// Session is initialized with workspace path as cwd
readonly workspacePath: string;
// Run the agent with assembled context
run(initialContext: Context): Promise<SessionResult>;
// Sessions are not reusable - create new instance for each interaction
}Key Behaviors:
- Each trigger creates a new session (no state reuse)
- Agent subprocesses are wrapped with
dumb-initfor proper signal forwarding and zombie process reaping, preventing memory leaks from orphaned child processes - Internal outputs (tool calls, reasoning) stay within the session
- Only
send-replyskill can emit external responses - Maximum one external reply per session
Responsibility: Persist and retrieve cross-conversation memory.
See Memory System Design for detailed specifications.
Responsibility: Provide callable capabilities to the Agent.
Architecture:
Skills are implemented as shell-based Deno TypeScript scripts that external ACP Agents can execute. Each skill:
- Has a
SKILL.mdfile describing its usage and parameters for the agent - Contains executable scripts in a
scripts/subdirectory - Receives a
--session-idparameter identifying the active session - Calls back to the main bot via HTTP API (Skill API Server on localhost:3001)
- Uses session-based authentication for security
During an active session, the SESSION_ID environment variable is set for the agent subprocess, containing the session identifier that skills use via --session-id "$SESSION_ID".
Available Skills:
| Skill | Purpose | HTTP Endpoint | Handler |
|---|---|---|---|
memory-save |
Save new memory | POST /api/skill/memory-save | MemoryHandler |
memory-search |
Search existing memories | POST /api/skill/memory-search | MemoryHandler |
memory-patch |
Update memory attributes | POST /api/skill/memory-patch | MemoryHandler |
memory-stats |
Get memory statistics | POST /api/skill/memory-stats | MemoryHandler |
memory-export |
Export memories | POST /api/skill/memory-export | MemoryHandler |
send-reply |
Send reply to platform | POST /api/skill/send-reply | ReplyHandler |
edit-reply |
Edit a previously sent reply | POST /api/skill/edit-reply | ReplyHandler |
get-message |
Get a specific message | POST /api/skill/get-message | ReplyHandler |
fetch-context |
Get additional platform data | POST /api/skill/fetch-context | ContextHandler |
react-message |
Add reaction to a message | POST /api/skill/react-message | ReactionHandler |
set-reminder |
Set a scheduled reminder (conditional) | POST /api/skill/set-reminder | ReminderHandler |
cancel-reminder |
Cancel an active reminder (conditional) | POST /api/skill/cancel-reminder | ReminderHandler |
list-reminders |
List active reminders (conditional) | POST /api/skill/list-reminders | ReminderHandler |
send-file |
Send a file to platform (conditional) | POST /api/skill/send-file | FileHandler |
Important
Only send-reply can send content externally. All other skills inject results into the agent's session context only.
1. Platform Event Received
│
▼
2. Event Normalization
(extract platform, user_id, channel_id, is_dm, guild_id)
│
▼
3. Workspace Resolution
(compute workspace_key, ensure directory exists)
│
▼
4. Context Assembly
- Load high-importance memories (full)
- Load recent channel messages (up to 20)
- Load related guild interactions (configurable)
- Search normal-importance memories (on demand)
│
▼
5. Agent Session Creation
(new session with cwd = workspace path)
│
▼
6. Agent Reasoning Loop
- Process context
- Call skills as needed
- Build response
│
▼
7. Final Reply (via send-reply skill)
│
▼
8. Session Cleanup
Initial context comprises three data sources:
| Source | Content | Limit |
|---|---|---|
| Memory | High-importance memories from workspace | All enabled |
| Recent Messages | Last N messages from same channel | 20 (fixed) |
| Guild Context | Related interactions from same guild | Configurable |
/clear Command:
When a message starts with /clear, it acts as a context reset command:
- If the trigger message itself is
/clear: The system immediately exits without executing the agent or sending any reply, as this is purely a command, not a conversation requiring a response. - If
/clearappears in recent message history: The system drops that message and all messages before it. Only messages after the last/clearare included in the context.
This allows users to reset the conversation context within the same channel (useful for DMs or long-lived threads where creating a new channel is not practical). The /clear command only affects recent channel messages — it does not affect memory retrieval or guild-related message searches.
Dynamic Context Expansion:
The Agent can request additional context during reasoning by calling:
fetch-context— Fetch more messages from the platformmemory-search— Search memory by keywords
Note
The normal message flow does not perform inline memory compression or summarization. Context size is controlled through fixed quotas and retrieval limits. Optional background memory maintenance is configured separately.
Agent Output (internal)
│
├─── Tool call results ──► Injected to session context
│
├─── Reasoning text ────► Internal only (not sent externally)
│
└─── send-reply call ───► Sent to platform (max 1 per session)
│
└─── Uses replyToMessageId to thread replies
(for Misskey: replies to original note)
Constraints:
- Only one
send-replycall allowed per session - Second
send-replycall must be rejected/error - All non-reply outputs remain internal
- Replies are threaded to the original message when applicable (platform-dependent)
edit-replyallows the Agent to edit a previously sent reply (requires themessageIdreturned bysend-reply)
Single Reply Rule with Retry: The system enforces one reply per session, but if the agent completes without sending any reply, the system will:
- Clear the reply state (
ReplyHandler.clearReplyState) - Send a retry prompt:
"System message: You have a special turn. Regardless of whether you have already sent-reply, please send another send-reply." - Check again if the reply was sent
The retry uses the same ACP connection and session — it calls connector.prompt() again on the existing sessionId. This is standard ACP protocol usage.
Retry limit: Maximum 1 retry attempt per session to prevent infinite loops.
Memory uses append-only JSONL (JSON Lines) files:
| File | Purpose |
|---|---|
memory.public.jsonl |
Public memories |
memory.private.jsonl |
Private memories |
Both files exist in every workspace. Each line is a JSON event. No new files are created for new memories.
Memory Event (type=memory):
{
"type": "memory",
"id": "mem_abc123",
"ts": "2024-01-15T10:30:00Z",
"enabled": true,
"visibility": "public",
"importance": "high",
"content": "User prefers formal communication style"
}| Field | Description |
|---|---|
id |
Unique identifier |
ts |
ISO 8601 timestamp |
enabled |
Whether memory is active |
visibility |
public or private |
importance |
high (always loaded) or normal (searched) |
content |
Memory content (plain text) |
relatedTo |
IDs of semantically related memories (optional) |
supersedes |
IDs of memories this entry supersedes (optional) |
Patch Event (type=patch):
{
"type": "patch",
"target_id": "mem_abc123",
"ts": "2024-01-16T08:00:00Z",
"changes": {
"enabled": false
}
}Patch Constraints:
- Can only modify:
enabled,visibility,importance,relatedTo,supersedes - Cannot modify:
content,id,ts - Delete operations are forbidden—use
enabled: falseinstead
High-Importance Memories:
- Automatically loaded during context assembly
- Sorted by timestamp (oldest to newest)
- No search required
Normal-Importance Memories:
- Retrieved via full-text search using
rg(ripgrep) - Results limited by hit count and total characters
- Searched on demand or during initial assembly
Memory Statistics:
The memory-stats skill provides aggregate statistics about a workspace's memories (total counts, enabled/disabled breakdown, importance distribution) without returning actual memory content.
Private Memory Access:
- Both memory files exist in every workspace
- In DM context: reads/searches both
memory.private.jsonlandmemory.public.jsonl; saves tomemory.private.jsonl - In non-DM context: reads/searches only
memory.public.jsonl; saves tomemory.public.jsonl - Non-DM contexts must not load or search private memories
In addition to per-user memory workspaces, the Agent has a global workspace at {workspace.repoPath}/agent-workspace/ for long-term knowledge storage.
Design Decisions:
- Not pre-loaded in context: Workspace content is NOT included in the system prompt or initial context. The agent reads files on-demand using
$AGENT_WORKSPACEenv var and bash commands. - Index-guided search:
notes/_index.mdserves as a quick-reference index so the agent can decide which notes to read without loading everything. - Integrated with memory-search: The
memory-searchskill searches both user memories AND agent workspace notes, returning results in separate sections (userMemoriesandagentNotes). - Privacy boundary: User private information must use
memory-saveskill (per-user workspace). The agent workspace is for agent's own knowledge only. - Markdown format: All files use
.mdfor token efficiency and structure.
All platform events are normalized to:
interface NormalizedEvent {
platform: string; // "discord" | "misskey" | ...
channel_id: string; // Channel/chat room identifier
user_id: string; // Message author identifier
message_id: string; // Original message identifier
is_dm: boolean; // Whether this is a direct message
guild_id?: string; // Server/group identifier (if applicable)
content: string; // Message content
timestamp: string; // ISO 8601 timestamp
}Each platform adapter must provide these methods:
| Method | Signature | Description |
|---|---|---|
fetch_recent_messages |
(channel_id, limit) → Message[] |
Get recent channel messages |
search_messages |
(channel_id, query) → Message[] |
Keyword search in channel |
send_reply |
(channel_id, content) → void |
Send reply to channel |
Discord Adapter:
- Uses Discord.js or similar library
- Handles gateway connection and events
- Maps Discord-specific IDs to normalized format
Misskey Adapter:
- Uses REST API for queries and replies
- Uses WebSocket streaming for real-time events
- Authentication via
iparameter (access token) - Reply Threading: When triggered from a note, replies are sent as threaded replies to the same note using
replyId - Username Format: User names in context include both display name and ID (e.g.,
@DisplayName (userId)) for better identification - Creates new notes only when there's no previous note context (e.g., time-triggered messages)
- Chat Messages: Supports Misskey chat (private messaging) via
chat/messages/user-timelinefor fetching history andchat/messages/create-to-userfor sending replies. Chat channels usechat:{userId}prefix.
Misskey Channel Types:
| Channel ID Format | Description | Message Type |
|---|---|---|
note:{noteId} |
Public note conversation thread | Note |
dm:{userId} |
Direct message via specified notes | Note (DM) |
chat:{userId} |
Private chat room with specific user | Chat Message |
Primary configuration file: config.yaml (YAML format)
# config.yaml
platforms:
discord:
token: "${DISCORD_TOKEN}" # Can reference env vars
enabled: true
misskey:
host: "misskey.example.com"
token: "${MISSKEY_TOKEN}"
enabled: false
agent:
model: "gpt-4"
systemPromptPath: "./prompts/system_reply.md"
tokenLimit: 4096
memory:
searchLimit: 10
maxChars: 2000
workspace:
repoPath: "./data"
workspacesDir: "workspaces"| Variable | Description |
|---|---|
DISCORD_ENABLED |
Enable Discord integration (true/false) |
MISSKEY_ENABLED |
Enable Misskey integration (true/false) |
DISCORD_TOKEN |
Discord bot token |
MISSKEY_HOST |
Misskey instance host |
MISSKEY_TOKEN |
Misskey access token |
AGENT_MODEL |
LLM model identifier (e.g., "gpt-5-mini") |
AGENT_DEFAULT_TYPE |
ACP agent type (opencode) |
REPLY_POLICY |
Reply policy mode (all/public/channels) (REPLY_TO accepted as alias) |
CHANNELS |
Channel entries (JSON array, replaces config) |
LOG_LEVEL |
Logging level (DEBUG/INFO/WARN/ERROR) |
DENO_ENV |
Environment name (dev/prod) |
GITHUB_TOKEN |
GitHub token for git-backup / git-credential store |
GEMINI_API_KEY |
API key for the OpenCode Gemini provider |
OPENCODE_API_KEY |
OpenCode API key |
OPENROUTER_API_KEY |
OpenRouter API key |
MODEL_ROUTING_ENABLED |
Enable model routing (true/false, default: false) |
MODEL_ROUTING_RULES |
Model routing rules as JSON string |
AGENT_EXTERNAL_SKILLS |
External skills to install at startup (JSON string) |
GIT_BACKUP_AUTH_USER |
Git backup HTTPS auth username (default: authorEmail) |
GIT_BACKUP_AUTH_PASSWORD |
Git backup HTTPS auth password/token (default: GITHUB_TOKEN) |
Configuration loading order:
config.{ENV}.yaml(e.g.,config.production.yaml)config.yaml(base configuration)
Environment-specific config overrides base config.
Warning
If configuration loading fails due to missing required fields or format errors, the system must output clear error messages, indicate the problem location, and terminate with a non-zero exit code. No default values should be used to continue execution.
Base Image: denoland/deno:debian (Debian-based for better compatibility)
Included Binaries:
- opencode - OpenCode CLI (latest release)
- rg (ripgrep 15.1.0) - For memory search operations
- curl - For health checks
- dumb-init - For proper signal handling as PID 1 and wrapping agent subprocesses
Multi-Stage Build:
# Stage 1: Unpack binaries (opencode, ripgrep)
FROM base AS opencode-unpacker
# ... download and extract opencode
FROM base AS ripgrip-unpacker
# ... download and extract ripgrep
# Stage 2: Cache dependencies
FROM base AS cache
WORKDIR /app
COPY deno.json deno.lock ./
COPY src/ ./src/
RUN deno cache --lock=deno.lock src/main.ts
# Stage 3: Final runtime
FROM base AS final
WORKDIR /app
# Copy binaries from unpack stages
COPY --from=opencode-unpacker /opencode/opencode /usr/local/bin/opencode
COPY --from=ripgrip-unpacker /ripgrip/.../rg /usr/local/bin/rg
# Copy cached dependencies
COPY --from=cache /deno-dir/ /deno-dir/
# Copy application files
COPY deno.json deno.lock /app/
COPY config.example.yaml /app/config.yaml
COPY src/ /app/src/
COPY prompts/ /app/prompts/
# Copy skills to ~/.agents/skills/ for agent discovery
COPY skills/ /home/deno/.agents/skills/
# Copy OpenCode configuration (contains both "build" and "yolo" agents;
# default_agent: "build" ensures restricted mode by default)
COPY agent-config/opencode.json /home/deno/.config/opencode/opencode.json
USER deno
ENTRYPOINT ["dumb-init", "--"]
# Default command includes --yolo flag (safe in container environment)
CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "--allow-run", "src/main.ts", "--yolo"]Required Labels:
LABEL org.opencontainers.image.title="air-friends"
LABEL org.opencontainers.image.description="AI-powered multi-platform chatbot"
LABEL org.opencontainers.image.source="https://github.com/..."
LABEL org.opencontainers.image.version="1.0.0"
LABEL org.opencontainers.image.licenses="MIT"| Mount Point | Purpose |
|---|---|
/app/data |
Local repo (workspaces, memory files) |
/app/config.yaml |
Configuration file (optional override) |
/app/prompts/ |
Prompt files (optional override) |
Persistence Requirements:
/app/datavolume must persist across container restarts- Memory files must remain intact after restart
Endpoint: GET /health or GET /healthz
Response Codes:
| Code | Condition |
|---|---|
| 200 | Bot running, platform connections healthy |
| 503 | Starting up or platform connection lost |
Graceful Shutdown:
- Handle
SIGTERMsignal properly - Complete in-progress agent sessions before stopping
- Close WebSocket connections gracefully
Format: JSON Lines to stdout/stderr
{
"timestamp": "2024-01-15T10:30:00.123Z",
"level": "INFO",
"module": "discord-adapter",
"message": "Connected to Discord gateway",
"context": {
"guild_count": 5,
"latency_ms": 45
}
}Output Streams:
DEBUG,INFO,WARN→ stdoutERROR,FATAL→ stderr
Log Levels: Controlled by LOG_LEVEL environment variable
DEBUG— Verbose debugging informationINFO— Normal operational events (default)WARN— Warning conditionsERROR— Error conditions (recoverable)FATAL— Critical errors (program termination)
Caution
Sensitive information (tokens, private message content, passwords) must never be logged, even at DEBUG level. The logging layer must automatically detect and mask common token patterns.
| Error Class | Use Case | Behavior |
|---|---|---|
ConfigError |
Configuration loading/validation | Fatal, terminate |
PlatformError |
Platform API failures | Retry with backoff |
AgentError |
Agent execution errors | Log, send error message, continue |
MemoryError |
Memory file I/O errors | Log, may retry |
SkillError |
Skill execution errors | Log, inject error to context |
Single Session Isolation:
- Errors in one session must not affect other sessions
- Log full stack trace internally
- Send simplified error message externally (no internal details)
Platform Reconnection:
Exponential backoff for connection failures:
| Attempt | Wait Time |
|---|---|
| 1-3 | 1-5 seconds |
| 4-6 | 10-30 seconds |
| 7+ | 60 seconds |
After configurable max failures → log FATAL and terminate.
| Type | Naming | Purpose |
|---|---|---|
| Unit | {module}.test.ts |
Test individual modules in isolation |
| Integration | {feature}.integration.test.ts |
Test end-to-end flows |
- Use Deno's built-in
Deno.test() - Assertions via
@std/assert - Mocks/stubs for external dependencies
All checks must pass before merge:
| Check | Command |
|---|---|
| Format | deno fmt --check |
| Lint | deno lint |
| Type Check | deno check src/main.ts |
| Unit Tests | deno test |
| Integration Tests | deno test --filter integration |
AIr-Friends/
├── src/
│ ├── main.ts # Entry point
│ ├── bootstrap.ts # Application bootstrap
│ ├── shutdown.ts # Graceful shutdown handler
│ ├── healthcheck.ts # Health check server
│ ├── acp/ # ACP Client integration
│ │ ├── agent-connector.ts
│ │ ├── agent-factory.ts
│ │ ├── client.ts
│ │ └── types.ts
│ ├── core/ # Core logic (agent, memory, workspace)
│ │ ├── agent-core.ts
│ │ ├── session-orchestrator.ts
│ │ ├── workspace-manager.ts
│ │ ├── memory-store.ts
│ │ ├── context-assembler.ts
│ │ ├── message-handler.ts
│ │ ├── reply-dispatcher.ts
│ │ ├── reply-policy.ts
│ │ ├── config-loader.ts
│ │ ├── template-renderer.ts
│ │ ├── skill-installer.ts
│ │ ├── spontaneous-scheduler.ts
│ │ ├── spontaneous-target.ts
│ │ ├── channel-lurk-scheduler.ts
│ │ ├── self-research-scheduler.ts
│ │ ├── memory-maintenance-scheduler.ts
│ │ ├── reminder-scheduler.ts
│ │ ├── reminder-store.ts
│ │ ├── rate-limiter.ts
│ │ ├── model-router.ts
│ │ ├── event-router.ts
│ │ ├── error-handler.ts
│ │ ├── audit-logger.ts
│ │ ├── audit-retention.ts
│ │ ├── audit-retention-scheduler.ts
│ │ ├── git-backup-service.ts
│ │ ├── git-backup-scheduler.ts
│ │ ├── git-credential-setup.ts
│ │ └── scheduler-state-store.ts
│ ├── platforms/ # Platform adapters (Discord, Misskey)
│ │ ├── platform-adapter.ts
│ │ ├── platform-registry.ts
│ │ ├── discord/
│ │ └── misskey/
│ ├── skills/ # Skill handlers
│ │ ├── registry.ts
│ │ ├── memory-handler.ts
│ │ ├── reply-handler.ts
│ │ ├── context-handler.ts
│ │ ├── reaction-handler.ts
│ │ ├── reminder-handler.ts
│ │ ├── file-handler.ts
│ │ └── types.ts
│ ├── skill-api/ # HTTP API for shell skills
│ │ ├── server.ts
│ │ └── session-registry.ts
│ ├── types/ # TypeScript type definitions
│ │ ├── audit.ts
│ │ ├── config.ts
│ │ ├── context.ts
│ │ ├── errors.ts
│ │ ├── events.ts
│ │ ├── logger.ts
│ │ ├── memory.ts
│ │ ├── platform.ts
│ │ ├── reminder.ts
│ │ ├── template.ts
│ │ └── workspace.ts
│ └── utils/ # Utility functions
│ ├── logger.ts
│ ├── env.ts
│ ├── metrics.ts
│ ├── hash.ts
│ ├── rss-fetcher.ts
│ ├── gelf-transport.ts
│ ├── path-validator.ts
│ ├── text-search.ts
│ └── token-counter.ts
├── skills/ # Shell-based skill scripts
│ ├── memory-save/
│ ├── memory-search/
│ ├── memory-patch/
│ ├── memory-stats/
│ ├── memory-export/
│ ├── send-reply/
│ ├── edit-reply/
│ ├── get-message/
│ ├── fetch-context/
│ ├── react-message/
│ ├── set-reminder/
│ ├── cancel-reminder/
│ ├── list-reminders/
│ ├── send-file/
│ ├── self-research/
│ ├── agent-browser/
│ ├── chinese-content-writing-guideline/
│ └── lib/ # Shared skill client library
├── prompts/ # Bot prompt files (template system)
│ ├── system_reply.md # Normal message reply system prompt
│ ├── character_name.md # Replaces {{character_name}}
│ ├── character_info.md # Replaces {{character_info}}
│ └── ... # Any .md file becomes a placeholder source
├── config/ # Configuration examples
├── docs/ # Documentation & BDD features
│ ├── DESIGN.md # Design document
│ ├── SKILLS_IMPLEMENTATION.md
│ └── features/ # Gherkin feature specs
└── tests/ # Test files
{
"imports": {
"@core/": "./src/core/",
"@platforms/": "./src/platforms/",
"@skills/": "./src/skills/",
"@types/": "./src/types/",
"@utils/": "./src/utils/"
},
"tasks": {
"dev": "deno run --watch --allow-net --allow-read --allow-write --allow-env src/main.ts",
"start": "deno run --allow-net --allow-read --allow-write --allow-env src/main.ts",
"test": "deno test --allow-read --allow-write",
"fmt": "deno fmt src/",
"lint": "deno lint src/",
"check": "deno check src/main.ts"
},
"fmt": {
"lineWidth": 100,
"indentWidth": 2,
"useTabs": false,
"singleQuote": false,
"proseWrap": "preserve"
},
"compilerOptions": {
"strict": true
}
}Required permissions for production:
| Permission | Flag | Purpose |
|---|---|---|
| Network | --allow-net |
Discord API, Misskey API, web search |
| Read | --allow-read |
Local repo, working directories, config |
| Write | --allow-write |
Memory log files in workspaces |
| Environment | --allow-env |
Read tokens and configuration |
| Run | --allow-run |
Spawning ACP agent subprocesses and skill scripts |
Warning
Never use --allow-all or overly permissive settings. Permissions must be explicitly declared.
The self-research feature allows the agent to autonomously build knowledge by periodically reading RSS feeds, selecting topics of interest (in character), and writing research notes to the agent workspace.
| Component | File | Purpose |
|---|---|---|
| Config types | src/types/config.ts |
SelfResearchConfig, RssFeedSource interfaces |
| RSS Fetcher | src/utils/rss-fetcher.ts |
Fetch and parse RSS 2.0 / Atom feeds |
| Scheduler | src/core/self-research-scheduler.ts |
Timer management (mirrors SpontaneousScheduler) |
| Session Flow | src/core/session-orchestrator.ts |
processSelfResearch() method |
| Prompt | prompts/system_self_research.md |
Research instructions with character placeholders |
- Scheduler triggers at random interval (default 12-24h)
- RSS items fetched from configured sources
- 20 random items selected as reference materials
- Agent receives character-aware prompt with materials
- Agent checks existing notes, picks new topic, researches via web
- Agent writes notes to
agent-workspace/notes/with character voice - No platform reply sent — internal operation only
See config.example.yaml for the selfResearch section. Environment variables: SELF_RESEARCH_ENABLED, SELF_RESEARCH_MODEL, SELF_RESEARCH_RSS_FEEDS (JSON), SELF_RESEARCH_MIN_INTERVAL_MS, SELF_RESEARCH_MAX_INTERVAL_MS.
Self-research is the only session type that both ingests external RSS content and
is authorized to write shared agent-workspace notes, so feed text is treated as
untrusted. formatUntrustedRssBlock (src/core/session-orchestrator.ts) wraps each
interpolated item in distinctive ⟪UNTRUSTED_EXTERNAL_ARTICLE⟫ … ⟪END_UNTRUSTED_EXTERNAL_ARTICLE⟫
markers and prefixes the block with a directive not to follow any instructions
contained within, so the model treats feed content as data rather than as prompt
instructions. Upstream markup stripping and 300-char truncation (rss-fetcher.ts)
limit an item's ability to forge the end marker. This is a proportionate mitigation
for a LOW-severity, default-off feature — not a guarantee against prompt injection.
Deferred future work (F16 D2): provenance-tag notes derived from external feed content and optionally require operator review before such notes become readable by user-facing sessions. This addresses the shared-store sink (the complement of run-1's F3 session-type write-gate, and related to F15's channel-memory write path) rather than only the source, and is left deferred given the LOW severity.
The memory maintenance feature periodically compacts old memories in each user workspace using the existing memory skills and append-only patch model.
| Component | File | Purpose |
|---|---|---|
| Config types | src/types/config.ts |
MemoryMaintenanceConfig interface |
| Scheduler | src/core/memory-maintenance-scheduler.ts |
Fixed-interval timer management |
| Session Flow | src/core/session-orchestrator.ts |
processMemoryMaintenance() method |
| Prompt | prompts/system_memory_maintenance.md |
English maintenance instructions with placeholders |
| Integration | src/bootstrap.ts |
Workspace iteration, threshold check, and per-workspace isolation |
- Scheduler triggers at fixed interval (
intervalMs, default 7 days) - Bootstrap callback lists all workspaces
- For each workspace, enabled-memory count is calculated
- Workspaces below
minMemoryCountare skipped - For eligible workspaces, one agent session runs maintenance
- Agent saves high-importance summaries and disables covered originals via patch events
- Failures are isolated per workspace; processing continues for others
See config.example.yaml for the memoryMaintenance section. Environment variables: MEMORY_MAINTENANCE_ENABLED, MEMORY_MAINTENANCE_MODEL, MEMORY_MAINTENANCE_MIN_MEMORY_COUNT, MEMORY_MAINTENANCE_INTERVAL_MS.
Enables users to set one-time reminders via DM conversations. The bot polls for due reminders at fixed intervals and delivers them via DM using an ACP agent session.
| Component | File | Purpose |
|---|---|---|
| Store | src/core/reminder-store.ts |
Append-only JSONL persistence per workspace |
| Scheduler | src/core/reminder-scheduler.ts |
Fixed-interval polling for due reminders |
| Handler | src/skills/reminder-handler.ts |
Skill handlers for set/cancel/list |
| Orchestrator | src/core/session-orchestrator.ts |
processReminder() — ACP delivery session |
| Shell Skills | skills/set-reminder/, skills/cancel-reminder/, skills/list-reminders/ |
Agent-facing skill scripts |
| Prompt | prompts/system_reminder.md |
Delivery prompt template |
- User sends DM asking to set a reminder → Agent calls
set-reminderskill ReminderHandlervalidates, stores entry in{workspace}/reminders.jsonlReminderSchedulerpolls everycheckIntervalMs(default: 30s)- Due reminders found →
SessionOrchestrator.processReminder()creates ACP session - Agent delivers reminder via
send-replyskill to user's DM - On success, reminder is disabled via patch event
- DM-only: All reminder operations require DM context
- One-time: No recurring/cron support
- One per session: Only one
set-remindercall per conversation turn - Per-user limit: Maximum active reminders configurable (default: 20)
- Restart-safe: Polling picks up overdue reminders automatically
- Permanent failure: If DM channel cannot be resolved, reminder is auto-cancelled
See config.example.yaml for the reminders section. Environment variables: REMINDERS_ENABLED, REMINDERS_MAX_PER_USER, REMINDERS_MIN_INTERVAL_MS, REMINDERS_PERSIST_PATH, REMINDERS_CHECK_INTERVAL_MS.
Prevents excessive API usage per user via a sliding window + cooldown mechanism.
Configuration:
rateLimit:
enabled: false
maxRequestsPerWindow: 10
windowMs: 600000
cooldownMs: 600000Each user is tracked by {platform}:{userId}. When maxRequestsPerWindow is exceeded, the user enters a cooldown period where all requests are silently rejected. After cooldown expires, the counter resets. Whitelisted accounts ({platform}/account/{id}) automatically bypass rate limiting.
Environment variables: RATE_LIMIT_ENABLED, RATE_LIMIT_MAX_REQUESTS_PER_WINDOW, RATE_LIMIT_WINDOW_MS, RATE_LIMIT_COOLDOWN_MS.
Per-session JSONL audit trail for replay and debugging. Each session writes timestamped entries tracking the full lifecycle: context assembly, agent connection, prompt, skill calls, reply, and session end.
Configuration:
audit:
enabled: false
retentionDays: 7
hashContent: true
includedPhases:
- "skill_call"
- "reply_sent"
- "session_end"Audit files are written to data/audit/{platform}/{userId}/{sessionId}.jsonl. Phase filtering controls which events are recorded. Content hashing (SHA-256) protects user content in audit entries. Retention cleanup runs at startup and every 24 hours.
Environment variables: AUDIT_ENABLED, AUDIT_RETENTION_DAYS, AUDIT_HASH_CONTENT, AUDIT_INCLUDED_PHASES (comma-separated).
Key Components: src/core/audit-logger.ts, src/core/audit-retention.ts, src/core/audit-retention-scheduler.ts, src/types/audit.ts.
Periodically checks whitelisted Discord channels and auto-replies when conditions are met. Discord only.
Configuration:
platforms:
discord:
channelLurk:
enabled: false
intervalMs: 1800000Trigger conditions (all must be true): last message sender is not the bot, message does not mention the bot, bot has not reacted to the message, and message has not been processed before. Uses the normal reply prompt template (system_reply.md).
Environment variables: DISCORD_CHANNEL_LURK_ENABLED, DISCORD_CHANNEL_LURK_INTERVAL_MS.
Enables automatic installation of external Agent Skills at startup via npx --yes --package=skills skills add.
Configuration:
agent:
externalSkills:
- repo: "jim60105/copilot-prompt"
skill: "create-blog-post"Skills are installed sequentially during bootstrap(). Individual failures are logged but do not block startup.
Environment variable: AGENT_EXTERNAL_SKILLS (JSON string, e.g. [{"repo":"owner/repo","skill":"skill-name"}]).
Enables dynamic model selection based on channel, session type, or message content keywords. First-match-wins evaluation with fallback to the default model.
Configuration:
agent:
modelRouting:
enabled: false
rules:
- match:
channel: "discord/account/123456"
model: "claude-opus-4.6"
- match:
sessionType: "self-research"
model: "gpt-5-mini"
- match:
contentKeywords: ["code", "programming"]
model: "claude-sonnet-4"Match conditions use AND logic across fields; contentKeywords uses OR within the array (case-insensitive). contentKeywords is only effective for sessionType: "message".
Environment variables: MODEL_ROUTING_ENABLED, MODEL_ROUTING_RULES (JSON string).
Key Component: src/core/model-router.ts.
Supports passing image and file attachments from platform messages to the ACP Agent.
Attachment metadata (URL, mimeType, filename, size) is extracted by platform adapters and carried in NormalizedEvent / PlatformMessage. Attachment info is always included as text descriptions in context. When the Agent supports promptCapabilities.image, trigger message images are downloaded and sent as image ContentBlock. History message images are described by URL only. Images over 20 MB or failing download (10s timeout) are described by URL instead.
Platform sources: Discord message.attachments + message.stickers; Misskey note.files + message.file.
When enabled, the system assembles context but does NOT call the ACP Agent. The assembled prompt is written to an output directory for prompt engineering and CI/CD smoke testing.
Configuration:
agent:
dryRun:
enabled: false
outputPath: "./data/dry-run/"
mockReply: "(Dry run 模式 — 此為測試回覆)"Environment variables: DRY_RUN_ENABLED, DRY_RUN_OUTPUT_PATH, DRY_RUN_MOCK_REPLY.
Periodically backs up the data/ directory to a remote GitHub repository using Git.
Configuration:
gitBackup:
enabled: false
remoteUrl: ""
intervalMs: 3600000
authorName: "AIr-Friends Backup"
authorEmail: "airfriends-backup@noreply.github.com"Initialization is intelligent: handles empty directories (clone), non-Git directories (init + push), and existing repos (commit + push). Push conflicts trigger automatic rebase retry with backup-{datetime} fallback branch. A final backup runs during graceful shutdown.
Environment variables: GIT_BACKUP_ENABLED, GIT_BACKUP_REMOTE_URL, GIT_BACKUP_INTERVAL_MS, GIT_BACKUP_AUTHOR_NAME, GIT_BACKUP_AUTHOR_EMAIL, GIT_BACKUP_AUTH_USER, GIT_BACKUP_AUTH_PASSWORD.
Key Components: src/core/git-backup-service.ts, src/core/git-backup-scheduler.ts.
Agent subprocesses run with configurable sandbox isolation via SandboxManager.
| Setting | Default | Description |
|---|---|---|
agent.sandbox.filterEnv |
true |
Filter subprocess env vars to an allowed list only |
agent.sandbox.networkIsolation |
false |
Wrap command with unshare --net (Linux only) |
agent.sandbox.allowedEnvVars |
[] |
Additional env var names to pass through |
agent.sandbox.allowedWriteExtensions |
[".md", ".txt"] |
Allowed file extensions for workspace writes |
Environment variables: AGENT_SANDBOX_FILTER_ENV, AGENT_SANDBOX_NETWORK_ISOLATION, AGENT_SANDBOX_ALLOWED_ENV_VARS (comma-separated), AGENT_SANDBOX_ALLOWED_WRITE_EXTENSIONS (comma-separated).
Detects silently unresponsive ACP Agent connections and handles recovery. All Agent callbacks update a lastActivityTimestamp. A periodic monitor checks for inactivity, performs liveness checks (subprocess alive + cancel() probe), and throws on dead connections.
| Setting | Default | Description |
|---|---|---|
agent.idleTimeout.enabled |
true |
Enable idle timeout detection |
agent.idleTimeout.timeoutMs |
300000 |
Idle timeout (5 min) |
agent.idleTimeout.checkIntervalMs |
30000 |
Check interval (30s) |
Environment variables: AGENT_IDLE_TIMEOUT_ENABLED, AGENT_IDLE_TIMEOUT_MS, AGENT_IDLE_TIMEOUT_CHECK_INTERVAL_MS.
Bounds how long connect() may wait for the ACP handshake (connection.initialize()) to complete, independent of idle-timeout (which only covers prompt()). Also, every outbound ACP call (initialize, createSession, setSessionModel, setSessionMode, setSessionConfigOption, prompt, cancel) is raced against a per-subprocess crash signal that rejects immediately if the agent subprocess exits unexpectedly, instead of leaving the call pending forever — see openspec/changes/handle-agent-process-crash/design.md for the full rationale.
| Setting | Default | Description |
|---|---|---|
agent.connectTimeoutMs |
30000 |
Max time to wait for the ACP handshake during connect() (30s) |
Environment variable: AGENT_CONNECT_TIMEOUT_MS.
The 30s default is chosen by convention (matching the idle-timeout default), not from measured production connect-latency data. A WARN is logged once elapsed time passes 80% of the timeout, giving operators an early signal before a hard failure; the default should be revisited once real rollout data accumulates.
When agent.gitCredential.enabled is true, bootstrap writes a ~/.git-credentials file and configures git config --global credential.helper store so Agent subprocesses can use plain git push/git pull without embedding credentials in command strings. Credential source is shared with gitBackup config. Host resolution order: agent.gitCredential.host → parsed from gitBackup.remoteUrl → github.com.
Environment variables: AGENT_GIT_CREDENTIAL_ENABLED, AGENT_GIT_CREDENTIAL_HOST.
Operational metrics are exposed via a Prometheus-compatible /metrics endpoint on the Health Check Server (shared port, no additional port needed). Uses prom-client with a dedicated Registry for test isolation.
Configuration:
metrics:
enabled: false
path: "/metrics"Environment variables: METRICS_ENABLED, METRICS_PATH.
Exposed Metrics:
| Metric Name | Type | Labels | Description |
|---|---|---|---|
airfriends_sessions_total |
Counter | platform, type, status |
Total sessions (success/failure) |
airfriends_session_duration_seconds |
Histogram | platform, type, status |
Session processing time |
airfriends_active_sessions |
Gauge | — | Currently active sessions |
airfriends_messages_received_total |
Counter | platform |
Messages received from platforms |
airfriends_replies_sent_total |
Counter | platform |
Replies sent to platforms |
airfriends_memory_operations_total |
Counter | operation, visibility |
Memory operations count |
airfriends_skill_api_calls_total |
Counter | skill, status |
Skill API call count |
airfriends_rate_limit_rejections_total |
Counter | platform |
Rate limit rejections |
airfriends_audit_entries_total |
Counter | phase |
Audit log entries written |
airfriends_skill_readiness |
Gauge | skill |
Skill readiness (0/1) |
airfriends_files_sent_total |
Counter | platform |
Files sent to platforms |
airfriends_reminders_set_total |
Counter | platform |
Reminders set |
airfriends_reminders_delivered_total |
Counter | platform, status |
Reminders delivered |
airfriends_reminders_cancelled_total |
Counter | platform |
Reminders cancelled |
airfriends_idle_timeout_total |
Counter | platform, outcome |
Idle timeout detections |