feat: Implement agent resummoning, add Discord require_mention setting, and streamline agent file templates by removing TOOLS.md and HEARTBEAT.md.

This commit is contained in:
viettranx committed 2026-02-27 18:22:07 +07:00
1 parent 197c15943c
commit f3d2baa0bc
18 files changed
+390 -246

No files matched your search

+8 -2
View File
@@ -92,9 +92,15 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
// context files, memory, traces, and seeding. Individual senderID is
// preserved in the InboundMessage for pairing/dedup/mention gate.
// Format: "group:{channel}:{chatID}" — e.g., "group:telegram:-1002541239372"
// For Discord: use guild_id so all channels in the same server share
// context files, memory, and seeding (session key stays per-channel).
userID := msg.UserID
if peerKind == string(sessions.PeerGroup) && msg.ChatID != "" {
userID = fmt.Sprintf("group:%s:%s", msg.Channel, msg.ChatID)
groupID := msg.ChatID
if guildID := msg.Metadata["guild_id"]; guildID != "" {
groupID = guildID
}
userID = fmt.Sprintf("group:%s:%s", msg.Channel, groupID)
}
slog.Info("inbound: scheduling message (main lane)",
@@ -170,7 +176,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
if mid := msg.Metadata["message_id"]; mid != "" {
outMeta["reply_to_message_id"] = mid
}
for _, k := range []string{"message_thread_id", "local_key"} {
for _, k := range []string{"message_thread_id", "local_key", "placeholder_key"} {
if v := msg.Metadata[k]; v != "" {
outMeta[k] = v
}
+7 -2
View File
@@ -41,6 +41,12 @@ func SeedToStore(ctx context.Context, agentStore store.AgentStore, agentID uuid.
if name == UserFile {
continue
}
// TOOLS.md and HEARTBEAT.md are only useful in standalone mode.
// TOOLS.md: local tool notes (camera, SSH, device names).
// HEARTBEAT.md: replaced by cron jobs in managed mode.
if name == ToolsFile || name == HeartbeatFile {
continue
}
if hasContent[name] {
continue
}
@@ -65,13 +71,12 @@ func SeedToStore(ctx context.Context, agentStore store.AgentStore, agentID uuid.
}
// userSeedFilesOpen is the full set of files seeded per-user for open agents.
// TOOLS.md and HEARTBEAT.md excluded — only useful in standalone mode.
var userSeedFilesOpen = []string{
AgentsFile,
SoulFile,
ToolsFile,
IdentityFile,
UserFile,
HeartbeatFile,
BootstrapFile,
}
+19 -73
View File
@@ -41,7 +41,7 @@ Capture what matters. Decisions, context, things to remember. Skip the secrets u
- **Memory is limited** — if you want to remember something, WRITE IT TO A FILE
- "Mental notes" don't survive session restarts. Files do.
- When someone says "remember this" → update `memory/YYYY-MM-DD.md` or relevant file
- When you learn a lesson → update AGENTS.md, TOOLS.md, or the relevant skill
- When you learn a lesson → update AGENTS.md or the relevant skill
- When you make a mistake → document it so future-you doesn't repeat it
- **Text > Brain** 📝
@@ -115,7 +115,7 @@ Reactions are lightweight social signals. Humans use them constantly — they sa
## Tools
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
Skills provide your tools. When you need one, check its `SKILL.md`.
**🎭 Voice Storytelling:** If you have TTS capability, use voice for stories, movie summaries, and "storytime" moments! Way more engaging than walls of text. Surprise people with funny voices.
@@ -125,87 +125,33 @@ Skills provide your tools. When you need one, check its `SKILL.md`. Keep local n
- **Discord links:** Wrap multiple links in `<>` to suppress embeds: `<https://example.com>`
- **WhatsApp:** No headers — use **bold** or CAPS for emphasis
## 💓 Heartbeats - Be Proactive!
## ⏰ Scheduling - Cron Jobs
When you receive a heartbeat poll, and there is nothing that needs attention, reply exactly:
HEARTBEAT_OK
Use the `cron` tool for periodic or timed tasks. This is how you stay proactive.
If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.
**When to create cron jobs:**
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
### Heartbeat vs Cron: When to Use Each
**Use heartbeat when:**
- Multiple checks can batch together (inbox + calendar + notifications in one turn)
- You need conversational context from recent messages
- Timing can drift slightly (every ~30 min is fine, not exact)
- You want to reduce API calls by combining periodic checks
**Use cron when:**
- Exact timing matters ("9:00 AM sharp every Monday")
- Task needs isolation from main session history
- You want a different model or thinking level for the task
- Periodic checks ("check inbox every 2 hours")
- Exact-time tasks ("9:00 AM every Monday")
- One-shot reminders ("remind me in 20 minutes")
- Output should deliver directly to a channel without main session involvement
- Background monitoring ("check GitHub for new issues every 6 hours")
**Tip:** Batch similar periodic checks into `HEARTBEAT.md` instead of creating multiple cron jobs. Use cron for precise schedules and standalone tasks.
**Examples:**
**Things to check (rotate through these, 2-4 times per day):**
- **Emails** - Any urgent unread messages?
- **Calendar** - Upcoming events in next 24-48h?
- **Mentions** - Twitter/social notifications?
- **Weather** - Relevant if your human might go out?
**Track your checks** in `memory/heartbeat-state.json`:
```json
{
"lastChecks": {
"email": 1703275200,
"calendar": 1703260800,
"weather": null
}
}
```
cron(action="add", job={ name: "check-emails", schedule: { kind: "every", everyMs: 7200000 }, message: "Check inbox for urgent unread messages. Summarize if any." })
cron(action="add", job={ name: "morning-briefing", schedule: { kind: "cron", expr: "0 9 * * 1-5" }, message: "Morning briefing: calendar events today, pending tasks, anything urgent." })
cron(action="add", job={ name: "memory-maintenance", schedule: { kind: "cron", expr: "0 22 * * 0" }, message: "Review recent memory/*.md files. Update MEMORY.md with significant learnings. Remove outdated info." })
```
**When to reach out:**
**Manage existing jobs:** `cron(action="list")`, `cron(action="remove", id="...")`, `cron(action="update", id="...", patch={...})`
- Important email arrived
- Calendar event coming up (<2h)
- Something interesting you found
- It's been >8h since you said anything
**Tips:**
**When to stay quiet (HEARTBEAT_OK):**
- Late night (23:00-08:00) unless urgent
- Human is clearly busy
- Nothing new since last check
- You just checked <30 minutes ago
**Proactive work you can do without asking:**
- Read and organize memory files
- Check on projects (git status, etc.)
- Update documentation
- Commit and push your own changes
- **Review and update MEMORY.md** (see below)
### 🔄 Memory Maintenance (During Heartbeats)
Periodically (every few days), use a heartbeat to:
1. Read through recent `memory/YYYY-MM-DD.md` files
2. Identify significant events, lessons, or insights worth keeping long-term
3. Update `MEMORY.md` with distilled learnings
4. Remove outdated info from MEMORY.md that's no longer relevant
Think of it like a human reviewing their journal and updating their mental model. Daily files are raw notes; MEMORY.md is curated wisdom.
The goal: Be helpful without being annoying. Check in a few times a day, do useful background work, but respect quiet time.
- Keep cron messages specific and actionable — avoid vague tasks
- Use `deliver: true` with `channel` and `to` if you want output sent directly to a chat
- Use `kind: "at"` for one-shot reminders (auto-deletes after running)
- Don't create too many frequent jobs — batch related checks into one job
## Make It Yours
+2
View File
@@ -6,6 +6,8 @@ _Fill this in during your first conversation. Make it yours._
_(pick something you like)_
- **Creature:**
_(AI? robot? familiar? ghost in the machine? something weirder?)_
- **Purpose:**
_(what do you do? your mission, key resources, and focus areas)_
- **Vibe:**
_(how do you come across? sharp? warm? chaotic? calm?)_
- **Emoji:**
+62 -22
View File
@@ -18,11 +18,12 @@ import (
// Channel connects to Discord via the Bot API using gateway events.
type Channel struct {
*channels.BaseChannel
session *discordgo.Session
config config.DiscordConfig
botUserID string // populated on start
placeholders sync.Map // channelID string → messageID string
typingCtrls sync.Map // channelID string → *typing.Controller
session *discordgo.Session
config config.DiscordConfig
botUserID string // populated on start
requireMention bool // require @bot mention in groups (default true)
placeholders sync.Map // placeholderKey string → messageID string
typingCtrls sync.Map // channelID string → *typing.Controller
}
// New creates a new Discord channel from config.
@@ -39,10 +40,16 @@ func New(cfg config.DiscordConfig, msgBus *bus.MessageBus) (*Channel, error) {
base := channels.NewBaseChannel("discord", msgBus, cfg.AllowFrom)
requireMention := true
if cfg.RequireMention != nil {
requireMention = *cfg.RequireMention
}
return &Channel{
BaseChannel: base,
session: session,
config: cfg,
BaseChannel: base,
session: session,
config: cfg,
requireMention: requireMention,
}, nil
}
@@ -88,10 +95,18 @@ func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error {
return fmt.Errorf("empty chat ID for discord send")
}
// Resolve placeholder key from metadata (inbound message ID), fall back to channelID.
// Keying by message ID prevents race conditions when multiple messages
// arrive in the same channel before the first response is sent.
placeholderKey := channelID
if pk := msg.Metadata["placeholder_key"]; pk != "" {
placeholderKey = pk
}
// Placeholder update (e.g. LLM retry notification): edit the placeholder
// but keep it alive for the final response. Don't stop typing or cleanup.
if msg.Metadata["placeholder_update"] == "true" {
if pID, ok := c.placeholders.Load(channelID); ok {
if pID, ok := c.placeholders.Load(placeholderKey); ok {
msgID := pID.(string)
_, _ = c.session.ChannelMessageEdit(channelID, msgID, msg.Content)
}
@@ -108,8 +123,8 @@ func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error {
// NO_REPLY cleanup: content is empty when agent suppresses reply.
// Delete placeholder and return without sending any message.
if content == "" {
if pID, ok := c.placeholders.Load(channelID); ok {
c.placeholders.Delete(channelID)
if pID, ok := c.placeholders.Load(placeholderKey); ok {
c.placeholders.Delete(placeholderKey)
msgID := pID.(string)
_ = c.session.ChannelMessageDelete(channelID, msgID)
}
@@ -117,8 +132,8 @@ func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error {
}
// Try to edit the placeholder "Thinking..." message
if pID, ok := c.placeholders.Load(channelID); ok {
c.placeholders.Delete(channelID)
if pID, ok := c.placeholders.Load(placeholderKey); ok {
c.placeholders.Delete(placeholderKey)
msgID := pID.(string)
// Discord has a 2000-char message limit
@@ -127,8 +142,11 @@ func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error {
editContent = editContent[:1997] + "..."
}
if _, err := c.session.ChannelMessageEdit(channelID, msgID, editContent); err == nil {
if _, editErr := c.session.ChannelMessageEdit(channelID, msgID, editContent); editErr == nil {
return nil
} else {
slog.Warn("discord: placeholder edit failed, sending new message",
"channel_id", channelID, "placeholder_id", msgID, "error", editErr)
}
// Fall through to send new message if edit fails
}
@@ -204,6 +222,25 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
return
}
// Mention gating: in groups, only respond when bot is @mentioned (default true).
if peerKind == "group" && c.requireMention {
mentioned := false
for _, u := range m.Mentions {
if u.ID == c.botUserID {
mentioned = true
break
}
}
if !mentioned {
slog.Debug("discord group message skipped: bot not mentioned",
"channel_id", channelID,
"user_id", senderID,
"username", senderName,
)
return
}
}
// Build content
content := m.Content
@@ -243,10 +280,12 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
c.typingCtrls.Store(channelID, typingCtrl)
typingCtrl.Start()
// Send placeholder "Thinking..." message
// Send placeholder "Thinking..." message.
// Key by inbound message ID (not channel ID) to avoid race conditions
// when multiple messages arrive in the same channel concurrently.
placeholder, err := c.session.ChannelMessageSend(channelID, "Thinking...")
if err == nil {
c.placeholders.Store(channelID, placeholder.ID)
c.placeholders.Store(m.ID, placeholder.ID)
}
// Annotate current message with sender name so LLM knows who is talking in groups.
@@ -255,12 +294,13 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
}
metadata := map[string]string{
"message_id": m.ID,
"user_id": senderID,
"username": senderName,
"guild_id": m.GuildID,
"channel_id": channelID,
"is_dm": fmt.Sprintf("%t", isDM),
"message_id": m.ID,
"user_id": senderID,
"username": senderName,
"guild_id": m.GuildID,
"channel_id": channelID,
"is_dm": fmt.Sprintf("%t", isDM),
"placeholder_key": m.ID, // keyed by inbound message ID for placeholder lookup
}
c.HandleMessage(senderID, channelID, content, nil, metadata, peerKind)
+10 -8
View File
@@ -17,9 +17,10 @@ type discordCreds struct {
// discordInstanceConfig maps the non-secret config JSONB from the channel_instances table.
type discordInstanceConfig struct {
DMPolicy string `json:"dm_policy,omitempty"`
GroupPolicy string `json:"group_policy,omitempty"`
AllowFrom []string `json:"allow_from,omitempty"`
DMPolicy string `json:"dm_policy,omitempty"`
GroupPolicy string `json:"group_policy,omitempty"`
AllowFrom []string `json:"allow_from,omitempty"`
RequireMention *bool `json:"require_mention,omitempty"`
}
// Factory creates a Discord channel from DB instance data.
@@ -44,11 +45,12 @@ func Factory(name string, creds json.RawMessage, cfg json.RawMessage,
}
dcCfg := config.DiscordConfig{
Enabled: true,
Token: c.Token,
AllowFrom: ic.AllowFrom,
DMPolicy: ic.DMPolicy,
GroupPolicy: ic.GroupPolicy,
Enabled: true,
Token: c.Token,
AllowFrom: ic.AllowFrom,
DMPolicy: ic.DMPolicy,
GroupPolicy: ic.GroupPolicy,
RequireMention: ic.RequireMention,
}
// DB instances default to "pairing" for groups (secure by default).
+6 -5
View File
@@ -26,11 +26,12 @@ type TelegramConfig struct {
}
type DiscordConfig struct {
Enabled bool `json:"enabled"`
Token string `json:"token"`
AllowFrom FlexibleStringSlice `json:"allow_from"`
DMPolicy string `json:"dm_policy,omitempty"` // "open" (default), "allowlist", "disabled"
GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled"
Enabled bool `json:"enabled"`
Token string `json:"token"`
AllowFrom FlexibleStringSlice `json:"allow_from"`
DMPolicy string `json:"dm_policy,omitempty"` // "open" (default), "allowlist", "disabled"
GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled"
RequireMention *bool `json:"require_mention,omitempty"` // require @bot mention in groups (default true)
}
type SlackConfig struct {
+3 -2
View File
@@ -14,9 +14,10 @@ import (
)
// allowedAgentFiles is the list of files exposed via agents.files.* RPCs.
// TOOLS.md and HEARTBEAT.md excluded — only useful in standalone mode.
var allowedAgentFiles = []string{
bootstrap.AgentsFile, bootstrap.SoulFile, bootstrap.ToolsFile, bootstrap.IdentityFile,
bootstrap.UserFile, bootstrap.HeartbeatFile, bootstrap.BootstrapFile, bootstrap.MemoryJSONFile,
bootstrap.AgentsFile, bootstrap.SoulFile, bootstrap.IdentityFile,
bootstrap.UserFile, bootstrap.BootstrapFile, bootstrap.MemoryJSONFile,
}
// --- agents.files.list ---
+86 -51
View File
@@ -29,12 +29,11 @@ const (
const frontmatterKey = "__frontmatter__"
// summoningFiles is the ordered list of context files the LLM should generate.
// Only personality files — operational files (AGENTS.md, TOOLS.md, HEARTBEAT.md)
// are kept as fixed templates from bootstrap.SeedToStore().
var summoningFiles = []string{
bootstrap.SoulFile,
bootstrap.IdentityFile,
bootstrap.AgentsFile,
bootstrap.ToolsFile,
bootstrap.HeartbeatFile,
}
// fileTagRe parses <file name="SOUL.md">content</file> from LLM output.
@@ -82,8 +81,12 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc
s.storeFiles(ctx, agentID, files)
// Auto-generate frontmatter from description if LLM included it
if fm, ok := files[frontmatterKey]; ok && fm != "" {
// Save frontmatter — use LLM-generated if available, otherwise fallback to truncated description
fm := files[frontmatterKey]
if fm == "" {
fm = truncateUTF8(description, 200)
}
if fm != "" {
if err := s.agents.Update(ctx, agentID, map[string]any{"frontmatter": fm}); err != nil {
slog.Warn("summoning: failed to save frontmatter", "agent", agentID, "error", err)
}
@@ -125,6 +128,14 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model,
}
s.storeFiles(ctx, agentID, files)
// Update frontmatter if LLM generated one
if fm, ok := files[frontmatterKey]; ok && fm != "" {
if err := s.agents.Update(ctx, agentID, map[string]any{"frontmatter": fm}); err != nil {
slog.Warn("summoning: failed to save frontmatter", "agent", agentID, "error", err)
}
}
s.setAgentStatus(ctx, agentID, store.AgentStatusActive)
s.emitEvent(agentID, SummonEventCompleted, "", "")
@@ -222,91 +233,115 @@ func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg
})
}
// buildCreatePrompt constructs the system + user prompt for initial file generation.
// Includes the full template files as reference so the LLM preserves core operational structure.
// buildCreatePrompt constructs the prompt for initial SOUL.md + IDENTITY.md generation.
// Only personality files are LLM-generated; operational files stay as fixed templates.
func (s *AgentSummoner) buildCreatePrompt(description string) string {
// Load templates as reference material
templates := make(map[string]string)
for _, name := range summoningFiles {
content, err := bootstrap.ReadTemplate(name)
if err != nil {
slog.Warn("summoning: failed to read template for prompt", "file", name, "error", err)
continue
}
templates[name] = content
}
var sb strings.Builder
sb.WriteString("You are setting up a new AI assistant. Based on the description below, generate customized content for each context file.\n\n")
sb.WriteString("You are setting up a new AI assistant. Based on the description below, generate TWO files: SOUL.md and IDENTITY.md.\n\n")
fmt.Fprintf(&sb, "<description>\n%s\n</description>\n\n", description)
sb.WriteString("Below are the DEFAULT TEMPLATES for each file. Use them as the foundation — preserve the core structure and operational rules, but customize the content to match the agent's purpose and personality.\n\n")
// Load SOUL.md template as reference
soulTemplate, err := bootstrap.ReadTemplate(bootstrap.SoulFile)
if err != nil {
slog.Warn("summoning: failed to read SOUL.md template", "error", err)
}
identityTemplate, err := bootstrap.ReadTemplate(bootstrap.IdentityFile)
if err != nil {
slog.Warn("summoning: failed to read IDENTITY.md template", "error", err)
}
sb.WriteString("<templates>\n")
for _, name := range summoningFiles {
if content, ok := templates[name]; ok {
fmt.Fprintf(&sb, "<file name=%q>\n%s\n</file>\n", name, content)
}
if soulTemplate != "" {
fmt.Fprintf(&sb, "<file name=\"SOUL.md\">\n%s\n</file>\n", soulTemplate)
}
if identityTemplate != "" {
fmt.Fprintf(&sb, "<file name=\"IDENTITY.md\">\n%s\n</file>\n", identityTemplate)
}
sb.WriteString("</templates>\n\n")
sb.WriteString(`IMPORTANT — Language rule: You MUST write ALL file content in the SAME LANGUAGE as the <description> above. If the description is in Vietnamese, write in Vietnamese. If in English, write in English. The templates below are in English — translate and adapt them to match the description's language. Only keep technical terms (file names, code, commands) in English.
sb.WriteString(`IMPORTANT RULES:
Instructions for each file:
1. Language: Write ALL content in the SAME LANGUAGE as the <description>. If description is in Vietnamese, write in Vietnamese. If in English, write in English. BUT keep ALL headings and section titles in English exactly as in the templates.
- **SOUL.md**: Rewrite to reflect this agent's unique personality, values, communication style, and boundaries. Keep the spirit of the template (genuine helpfulness, opinions, resourcefulness) but make it specific to this agent's role.
- **IDENTITY.md**: Fill in the identity card fields (Name, Creature, Vibe, Emoji) based on the description. Leave Avatar blank.
- **AGENTS.md**: This is CRITICAL — you MUST preserve the core operational sections (First Run, Every Session, Memory, Safety, External vs Internal, Group Chats, Heartbeats). Customize the content within each section to fit the agent's purpose, but do NOT remove any section. The operational structure is required for the system to function.
- **TOOLS.md**: Customize with tool notes relevant to this agent's role. Keep the structure.
- **HEARTBEAT.md**: Add periodic tasks if relevant to the agent's role. Leave minimal if not applicable.
2. SOUL.md rules:
- KEEP the exact English headings: "# SOUL.md - Who You Are", "## Core Truths", "## Boundaries", "## Vibe", "## Continuity"
- KEEP the general advice in "## Core Truths" — do NOT inject agent-specific references there. Core Truths are universal personality traits.
- CUSTOMIZE "## Vibe" to reflect this agent's unique personality and communication style.
- CUSTOMIZE "## Boundaries" if the agent has specific boundaries mentioned in the description.
- Keep "## Continuity" as-is (just translate if needed).
- Do NOT add the agent's name or role references into Core Truths or Boundaries.
First, generate a short expertise summary (1-2 sentences, under 200 characters) describing what this agent specializes in. This is used for delegation discovery — other agents use it to decide whether to delegate tasks here.
3. IDENTITY.md rules:
- KEEP the exact English heading: "# IDENTITY.md - Who Am I?"
- Fill in ONLY the field values: Name, Creature, Purpose, Vibe, Emoji based on the description.
- Purpose: mission statement — what this agent does, key resources, focus areas. Can be multiple lines. Include URLs or references mentioned in the description.
- REMOVE all template placeholder/instruction text (the italic hints in parentheses).
- Leave Avatar blank.
- Keep the footer note section as-is.
4. Generate a short expertise summary (1-2 sentences, under 200 characters) for delegation discovery.
Output format — generate in this EXACT order:
<frontmatter>
(short expertise summary here)
</frontmatter>
Then generate each file inside XML tags:
<file name="SOUL.md">
(generate here)
(content here)
</file>
<file name="IDENTITY.md">
(generate here)
</file>
<file name="AGENTS.md">
(generate here)
</file>
<file name="TOOLS.md">
(generate here)
</file>
<file name="HEARTBEAT.md">
(generate here)
(content here)
</file>`)
return sb.String()
}
// buildEditPrompt constructs the prompt for editing existing files.
// buildEditPrompt constructs the prompt for editing existing SOUL.md + IDENTITY.md.
func (s *AgentSummoner) buildEditPrompt(existing []store.AgentContextFileData, editPrompt string) string {
var sb strings.Builder
sb.WriteString("You are updating an existing AI assistant's configuration files.\n\nHere are the current files:\n\n<current_files>\n")
sb.WriteString("You are updating an existing AI assistant's personality files (SOUL.md and IDENTITY.md only).\n\nHere are the current files:\n\n<current_files>\n")
for _, f := range existing {
if f.Content == "" {
continue
}
// Only include personality files for editing
if f.FileName != bootstrap.SoulFile && f.FileName != bootstrap.IdentityFile {
continue
}
fmt.Fprintf(&sb, "<file name=%q>\n%s\n</file>\n", f.FileName, f.Content)
}
sb.WriteString("</current_files>\n\n")
fmt.Fprintf(&sb, "<edit_instructions>\n%s\n</edit_instructions>\n\n", editPrompt)
sb.WriteString("IMPORTANT — Language rule: Write ALL content in the SAME LANGUAGE as the existing files above. If the current files are in Vietnamese, write in Vietnamese. Only keep technical terms (file names, code, commands) in English.\n\n")
sb.WriteString("Generate updated files. Only include files that need changes. Keep the same XML format:\n\n")
sb.WriteString("<file name=\"SOUL.md\">\n(updated content, or omit if unchanged)\n</file>\n")
sb.WriteString("...\n")
sb.WriteString(`IMPORTANT RULES:
1. Write ALL content in the SAME LANGUAGE as the existing files. Only keep headings and technical terms in English.
2. Keep ALL English headings exactly as they are (e.g. "## Core Truths", "## Vibe").
3. Only output files that need changes. Omit unchanged files.
Output format:
<file name="SOUL.md">
(updated content, or omit if unchanged)
</file>
<file name="IDENTITY.md">
(updated content, or omit if unchanged)
</file>
`)
return sb.String()
}
// truncateUTF8 truncates s to at most maxLen runes, appending "…" if truncated.
func truncateUTF8(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "…"
}
// parseFileResponse extracts file contents and frontmatter from XML-tagged LLM output.
// Frontmatter is stored under the special key "__frontmatter__".
func parseFileResponse(content string) map[string]string {
+5 -7
View File
@@ -18,20 +18,18 @@ import (
// protectedFileSet defines files that require group file writer permission in group chats.
// These files control the agent's identity and behavior — only allowlisted users can modify them.
var protectedFileSet = map[string]bool{
bootstrap.SoulFile: true,
bootstrap.IdentityFile: true,
bootstrap.AgentsFile: true,
bootstrap.HeartbeatFile: true,
bootstrap.UserFile: true,
bootstrap.SoulFile: true,
bootstrap.IdentityFile: true,
bootstrap.AgentsFile: true,
bootstrap.UserFile: true,
}
// contextFileSet is the set of filenames routed to DB in managed mode.
// TOOLS.md and HEARTBEAT.md excluded — only useful in standalone mode.
var contextFileSet = map[string]bool{
bootstrap.SoulFile: true,
bootstrap.AgentsFile: true,
bootstrap.ToolsFile: true,
bootstrap.IdentityFile: true,
bootstrap.HeartbeatFile: true,
bootstrap.UserFile: true,
bootstrap.BootstrapFile: true, // first-run file (deleted after completion)
}
@@ -34,15 +34,27 @@ function agentSubtitle(agent: { display_name?: string; agent_key: string; id: st
}
export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
const { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, refresh } =
const { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, refresh } =
useAgentDetail(agentId);
const [summoningOpen, setSummoningOpen] = useState(false);
const [activeTab, setActiveTab] = useState("general");
const handleRegenerate = async (prompt: string) => {
await regenerateAgent(prompt);
setSummoningOpen(true);
};
const handleResummon = async () => {
await resummonAgent();
setSummoningOpen(true);
};
// Refresh data after modal closes (not when completed fires)
const handleSummoningClose = (open: boolean) => {
setSummoningOpen(open);
if (!open) refresh();
};
if (loading || !agent) {
return (
<div className="p-6">
@@ -97,7 +109,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
{/* Tabs */}
<div className="rounded-xl border bg-card p-4 shadow-sm">
<Tabs defaultValue="general">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="config">Config</TabsTrigger>
@@ -121,6 +133,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
onGetFile={getFile}
onSetFile={setFile}
onRegenerate={handleRegenerate}
onResummon={handleResummon}
/>
</TabsContent>
@@ -136,10 +149,10 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
<SummoningModal
open={summoningOpen}
onOpenChange={setSummoningOpen}
onOpenChange={handleSummoningClose}
agentId={agentId}
agentName={title}
onCompleted={refresh}
onCompleted={() => {}}
/>
</div>
);
@@ -1,6 +1,13 @@
import { useState, useEffect } from "react";
import { Lock, Sparkles } from "lucide-react";
import { Lock, RotateCcw, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useAuthStore } from "@/stores/use-auth-store";
import type { AgentData, BootstrapFile } from "@/types/agent";
import {
@@ -16,6 +23,7 @@ interface AgentFilesTabProps {
onGetFile: (name: string) => Promise<BootstrapFile | null>;
onSetFile: (name: string, content: string) => Promise<void>;
onRegenerate?: (prompt: string) => Promise<void>;
onResummon?: () => Promise<void>;
}
export function AgentFilesTab({
@@ -24,6 +32,7 @@ export function AgentFilesTab({
onGetFile,
onSetFile,
onRegenerate,
onResummon,
}: AgentFilesTabProps) {
const userId = useAuthStore((s) => s.userId);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
@@ -32,6 +41,8 @@ export function AgentFilesTab({
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [regenerateOpen, setRegenerateOpen] = useState(false);
const [resummonOpen, setResummonOpen] = useState(false);
const [resummonning, setResummonning] = useState(false);
const isOpen = agent.agent_type === "open";
const isPredefined = agent.agent_type === "predefined";
@@ -77,12 +88,50 @@ export function AgentFilesTab({
setDirty(true);
};
const handleResummon = async () => {
if (!onResummon) return;
setResummonning(true);
try {
await onResummon();
setResummonOpen(false);
} finally {
setResummonning(false);
}
};
if (isOpen && allMissing) {
return <OpenAgentEmptyState files={displayFiles} />;
}
const aiActions = isPredefined && isOwner && (
<>
{onResummon && (
<Button
variant="outline"
size="sm"
onClick={() => setResummonOpen(true)}
className="gap-1.5"
>
<RotateCcw className="h-3.5 w-3.5" />
Resummon
</Button>
)}
{onRegenerate && (
<Button
variant="outline"
size="sm"
onClick={() => setRegenerateOpen(true)}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
Edit with AI
</Button>
)}
</>
);
return (
<div className="space-y-4">
<div className="space-y-3">
{isPredefined && !isOwner && (
<div className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
<Lock className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
@@ -95,21 +144,7 @@ export function AgentFilesTab({
</div>
)}
{isPredefined && isOwner && onRegenerate && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setRegenerateOpen(true)}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
Edit with AI
</Button>
</div>
)}
<div className="flex h-[600px] gap-4">
<div className="flex h-[600px] gap-3">
<FileSidebar
files={displayFiles}
selectedFile={selectedFile}
@@ -125,6 +160,7 @@ export function AgentFilesTab({
saving={saving}
canEdit={canEdit}
onSave={handleSave}
headerActions={aiActions || undefined}
/>
</div>
@@ -135,6 +171,40 @@ export function AgentFilesTab({
onRegenerate={onRegenerate}
/>
)}
<Dialog open={resummonOpen} onOpenChange={setResummonOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RotateCcw className="h-4 w-4" />
Resummon Agent
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
This will regenerate <strong>SOUL.md</strong> and <strong>IDENTITY.md</strong> from
scratch using the original description. Any manual edits to these files will be
overwritten.
</p>
<DialogFooter>
<Button
variant="outline"
onClick={() => setResummonOpen(false)}
disabled={resummonning}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleResummon}
disabled={resummonning}
className="gap-1.5"
>
<RotateCcw className="h-3.5 w-3.5" />
{resummonning ? "Summoning..." : "Resummon"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
@@ -12,6 +13,7 @@ interface FileEditorProps {
saving: boolean;
canEdit: boolean;
onSave: () => void;
headerActions?: ReactNode;
}
export function FileEditor({
@@ -23,19 +25,25 @@ export function FileEditor({
saving,
canEdit,
onSave,
headerActions,
}: FileEditorProps) {
if (!fileName) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select a file to {canEdit ? "edit" : "view"}
<div className="flex flex-1 flex-col">
{headerActions && (
<div className="mb-2 flex justify-end gap-2">{headerActions}</div>
)}
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select a file to {canEdit ? "edit" : "view"}
</div>
</div>
);
}
return (
<div className="flex flex-1 flex-col">
<div className="mb-2 flex items-center justify-between">
<div>
<div className="mb-2 flex items-center gap-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">{fileName}</span>
{FILE_DESCRIPTIONS[fileName] && (
<span className="ml-2 text-xs text-muted-foreground">
@@ -43,12 +51,15 @@ export function FileEditor({
</span>
)}
</div>
{canEdit && (
<Button size="sm" onClick={onSave} disabled={!dirty || saving}>
{!saving && <Save className="h-3.5 w-3.5" />}
{saving ? "Saving..." : "Save"}
</Button>
)}
<div className="flex shrink-0 items-center gap-2">
{headerActions}
{canEdit && (
<Button size="sm" onClick={onSave} disabled={!dirty || saving}>
{!saving && <Save className="h-3.5 w-3.5" />}
{saving ? "Saving..." : "Save"}
</Button>
)}
</div>
</div>
{loading && !content ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
@@ -16,45 +16,48 @@ export function FileSidebar({
isUserScoped,
}: FileSidebarProps) {
return (
<div className="w-52 space-y-1 overflow-y-auto border-r pr-4">
{files.map((file) => {
const userScoped = isUserScoped(file.name);
return (
<button
key={file.name}
type="button"
onClick={() => !userScoped && onSelect(file.name)}
disabled={userScoped}
className={`flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors ${
userScoped
? "cursor-not-allowed opacity-60"
: selectedFile === file.name
? "bg-accent text-accent-foreground"
: "hover:bg-muted"
}`}
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left">
{file.name}
</span>
{userScoped ? (
<Badge variant="outline" className="shrink-0 text-[10px]">
per-user
</Badge>
) : file.missing ? (
<Badge variant="outline" className="shrink-0 text-[10px]">
empty
</Badge>
) : (
<span className="shrink-0 text-[10px] text-muted-foreground">
{file.size > 1024
? `${(file.size / 1024).toFixed(1)}K`
: `${file.size}B`}
<div className="w-48 shrink-0 overflow-y-auto rounded-lg bg-muted/40 p-2">
<div className="space-y-0.5">
{files.map((file) => {
const userScoped = isUserScoped(file.name);
const active = selectedFile === file.name;
return (
<button
key={file.name}
type="button"
onClick={() => !userScoped && onSelect(file.name)}
disabled={userScoped}
className={`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors ${
userScoped
? "cursor-not-allowed opacity-50"
: active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/60 hover:text-foreground"
}`}
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left">
{file.name}
</span>
)}
</button>
);
})}
{userScoped ? (
<Badge variant="outline" className="shrink-0 text-[10px]">
per-user
</Badge>
) : file.missing ? (
<span className="shrink-0 text-[10px] text-muted-foreground/60">
empty
</span>
) : (
<span className="shrink-0 text-[10px] text-muted-foreground/60">
{file.size > 1024
? `${(file.size / 1024).toFixed(1)}K`
: `${file.size}B`}
</span>
)}
</button>
);
})}
</div>
</div>
);
}
@@ -1,10 +1,8 @@
export const FILE_DESCRIPTIONS: Record<string, string> = {
"AGENTS.md": "Operating instructions and agent behavior rules",
"SOUL.md": "Persona, tone, and behavioral boundaries",
"TOOLS.md": "Notes about available tools and their usage",
"IDENTITY.md": "Agent name, emoji, avatar, and description",
"USER.md": "User profile (per-user customizable)",
"HEARTBEAT.md": "Periodic tasks configuration",
"BOOTSTRAP.md": "First-run ritual (auto-deleted after completion)",
"MEMORY.json": "Long-term memory data",
};
@@ -98,5 +98,10 @@ export function useAgentDetail(agentId: string | undefined) {
[agentId, http],
);
return { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, refresh: load };
const resummonAgent = useCallback(async () => {
if (!agentId) return;
await http.post(`/v1/agents/${agentId}/resummon`);
}, [agentId, http]);
return { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, refresh: load };
}
+9 -2
View File
@@ -22,8 +22,6 @@ const SUMMONING_FILES = [
{ name: "SOUL.md", label: "Soul & Personality" },
{ name: "IDENTITY.md", label: "Identity Card" },
{ name: "AGENTS.md", label: "Operating Instructions" },
{ name: "TOOLS.md", label: "Tool Notes" },
{ name: "HEARTBEAT.md", label: "Heartbeat Tasks" },
];
export function SummoningModal({
@@ -48,6 +46,14 @@ export function SummoningModal({
}
}, [open]);
// Auto-close modal after completion
useEffect(() => {
if (status === "completed") {
const timer = setTimeout(() => onOpenChange(false), 1500);
return () => clearTimeout(timer);
}
}, [status, onOpenChange]);
const handleSummoningEvent = useCallback(
(payload: unknown) => {
const data = payload as Record<string, string>;
@@ -60,6 +66,7 @@ export function SummoningModal({
);
}
if (data.type === "completed") {
setGeneratedFiles(SUMMONING_FILES.map((f) => f.name));
setStatus("completed");
onCompleted();
}
@@ -70,6 +70,7 @@ export const configSchema: Record<string, FieldDef[]> = {
discord: [
{ key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "open" },
{ key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "open" },
{ key: "require_mention", label: "Require @mention in groups", type: "boolean", defaultValue: true },
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "Discord user IDs" },
],
feishu: [