From ed3676ebc8a6a1f93c6bbf59cd20fdbe6e08c5e0 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sun, 31 May 2026 15:40:09 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=20F-5+F-7=20=E2=80=94=20command-s?= =?UTF-8?q?ync=20+=20sub-discussion=20+=20channel-create=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles the remaining "agent does things in Fabric" surface, skipping presence-sync (Plexum's state machine isn't 1:1 with HF status semantics) and attachments (Plexum has no media pipeline yet) — both deferred. internal/subdisc/ (~140 LOC + 5 tests): persistent KV at /state/plugin-kv/fabric-sub-discussions.json - Entry: SubChannelID, HostAgentID, HostUserID, GuestUserIDs, HostGuide, GuestGuide, CallbackGuildNodeID, CallbackChannelID, CreatedAt - Open / Lookup / LookupForHost (host-enforcement) / Add / Remove / All - atomic tmp+rename persistence; corrupt file = start empty internal/tools/tools.go — 7 new tools: - create-chat-channel (xType=general) - create-work-channel (xType=work) - create-report-channel (xType=report) - create-discussion-channel (xType=discuss) - discussion-complete (post summary + close channel) - create-sub-discussion (create + invite + KV store + greeting) - close-sub-discussion (host-only; posts callback to parent + closes) internal/fabric/client.go: CreateChannel / CloseChannel / JoinChannel / LeaveChannel / SetChannelPurpose / Canvas CRUD / SyncCommands (added in earlier F-4 commit; reused here) cmd/plexum-fabric-channel-plugin/main.go: - subdisc.Store opened from DefaultPath at init; passed into tools.Deps - HostConfig adds commands_sync_key + sync_commands (defaults ["new","stop"]) — F-5 command-sync to every (agent,guild) pair at init when key is set; silently skips when omitted - syncCommandsToGuilds: best-effort PUT per guild, logs per-guild outcome scripts/install.sh: manifest tools[] expanded to 16 entries (9 from F-4 + 7 new). create-channel variants share a schema shape. Live verified: $ plexum plugin-call create-chat-channel \ '{"agent_id":"fabrictester","guild_node_id":"test-guild2", "name":"plexum-f7-smoke","is_public":true}' → "created general channel plexum-f7-smoke (id=6315e636-...)" $ plexum plugin-call fabric-channel-list ... → 3 channels listed including the new one F-6 (attachments) + F-5b (presence-sync) + F-8 (coalesce) deliberately deferred — see DEV-NOTES. Tests: 5 new in internal/subdisc (27 total in this repo). Co-Authored-By: Claude Opus 4.7 --- cmd/plexum-fabric-channel-plugin/main.go | 65 ++++++++- internal/subdisc/store.go | 169 ++++++++++++++++++++++ internal/subdisc/store_test.go | 74 ++++++++++ internal/tools/contracts.go | 73 ++++++++++ internal/tools/tools.go | 177 +++++++++++++++++++++++ scripts/install.sh | 16 +- 6 files changed, 571 insertions(+), 3 deletions(-) create mode 100644 internal/subdisc/store.go create mode 100644 internal/subdisc/store_test.go diff --git a/cmd/plexum-fabric-channel-plugin/main.go b/cmd/plexum-fabric-channel-plugin/main.go index 5112239..92d1328 100644 --- a/cmd/plexum-fabric-channel-plugin/main.go +++ b/cmd/plexum-fabric-channel-plugin/main.go @@ -28,6 +28,7 @@ import ( "git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/fabric" "git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/identity" "git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/inbound" + "git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/subdisc" "git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/tokens" "git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/tools" ) @@ -36,10 +37,14 @@ import ( // /plugins/plexum-fabric-channel/config.json: // // { -// "center_api_base": "http://localhost:7001/api" +// "center_api_base": "http://localhost:7001/api", +// "commands_sync_key": "...", // F-5: enables /command autocomplete in Fabric +// "sync_commands": ["new","stop"] // optional override; defaults to ["new","stop"] // } type HostConfig struct { - CenterAPIBase string `json:"center_api_base"` + CenterAPIBase string `json:"center_api_base"` + CommandsSyncKey string `json:"commands_sync_key,omitempty"` + SyncCommands []string `json:"sync_commands,omitempty"` } type fabricPlugin struct { @@ -155,9 +160,16 @@ func (p *fabricPlugin) Init(ctx context.Context, host plugin.HostAPI) error { return p.client.AgentLogin(loginCtx, entry.FabricAPIKey) }) + // Sub-discussion KV (F-7); persists across restarts. + subStore, err := subdisc.Open(subdisc.DefaultPath()) + if err != nil { + host.Log("warn", "fabric subdisc open", map[string]any{"err": err.Error()}) + } + // Agent-facing tool surface (port of openclaw's tools.ts). p.tools = tools.New(tools.Deps{ Client: p.client, Tokens: p.tokens, Identities: p.identities, + SubDisc: subStore, }) host.Log("info", "fabric channel plugin initialized", map[string]any{ @@ -173,6 +185,14 @@ func (p *fabricPlugin) Init(ctx context.Context, host plugin.HostAPI) error { map[string]any{"err": err.Error()}) } + // F-5: push slash command catalog to every guild the bound agents + // belong to. Cosmetic — gives Fabric frontend "/command" autocomplete. + // Requires commands_sync_key matching the guild's + // FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY; silently skip when unset. + if p.cfg.CommandsSyncKey != "" { + p.syncCommandsToGuilds(ctx) + } + // Phase F-2: start the inbound supervisor in a goroutine. Lives // until p.inboundCancel fires (currently never — SDK has no // shutdown hook; subprocess kill is the only stop signal). @@ -353,6 +373,47 @@ func (p *fabricPlugin) sessionFor(ctx context.Context, agentID string) (*fabric. return p.tokens.Get(loginCtx, agentID) } +// syncCommandsToGuilds PUTs the slash-command catalog to every unique +// (agent, guild) the plugin knows about. Best-effort — failure on any +// one guild is logged but doesn't block other guilds. +func (p *fabricPlugin) syncCommandsToGuilds(ctx context.Context) { + commands := p.cfg.SyncCommands + if len(commands) == 0 { + commands = []string{"new", "stop"} // host-side intercepted slashes + } + wire := make([]any, 0, len(commands)) + for _, c := range commands { + wire = append(wire, map[string]any{ + "name": c, "description": "Plexum host-intercepted slash: /" + c, + }) + } + seen := map[string]bool{} + for _, b := range p.bindings { + sess, err := p.tokens.Get(ctx, b.AgentID) + if err != nil { + continue + } + for _, g := range sess.Guilds { + key := b.AgentID + "/" + g.NodeID + if seen[key] { + continue + } + seen[key] = true + tok, err := p.tokens.GuildToken(ctx, b.AgentID, g.NodeID) + if err != nil { + continue + } + if err := p.client.SyncCommands(ctx, g.Endpoint, tok, wire, p.cfg.CommandsSyncKey); err != nil { + p.host.Log("warn", "fabric commands sync failed", + map[string]any{"guild": g.NodeID, "err": err.Error()}) + } else { + p.host.Log("info", "fabric commands synced", + map[string]any{"guild": g.NodeID, "n": len(commands)}) + } + } + } +} + func errResult(msg string) plugin.ToolResult { return plugin.ToolResult{ Content: []plugin.ContentBlock{{Type: "text", Text: msg}}, diff --git a/internal/subdisc/store.go b/internal/subdisc/store.go new file mode 100644 index 0000000..4d73a0a --- /dev/null +++ b/internal/subdisc/store.go @@ -0,0 +1,169 @@ +// Package subdisc persists the sub-discussion entries managed by the +// create-sub-discussion / close-sub-discussion tools (port of openclaw +// plugin's sub-discussion-store.ts). State lives under +// /state/plugin-kv/fabric-sub-discussions.json — the +// Plexum-host's standard plugin KV dir. +// +// One entry = one sub-discussion channel. Lifetime: from +// create-sub-discussion until close-sub-discussion (or the operator +// nukes the JSON file). +package subdisc + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" + "time" +) + +// FileName under /state/plugin-kv/. +const FileName = "fabric-sub-discussions.json" + +// Entry is the on-disk shape of one sub-discussion. +type Entry struct { + SubChannelID string `json:"sub_channel_id"` + HostAgentID string `json:"host_agent_id"` + HostUserID string `json:"host_user_id"` + GuestUserIDs []string `json:"guest_user_ids"` + HostGuide string `json:"host_guide,omitempty"` + GuestGuide string `json:"guest_guide,omitempty"` + CallbackGuildNodeID string `json:"callback_guild_node_id"` + CallbackChannelID string `json:"callback_channel_id"` + CreatedAt string `json:"created_at"` +} + +// Store wraps the on-disk file. Thread-safe. +type Store struct { + mu sync.Mutex + path string + entries map[string]*Entry // by SubChannelID +} + +// Open loads (or creates empty) the store at path. +func Open(path string) (*Store, error) { + s := &Store{path: path, entries: map[string]*Entry{}} + raw, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return s, nil + } + return nil, fmt.Errorf("subdisc: read %s: %w", path, err) + } + if len(raw) == 0 { + return s, nil + } + var wire struct { + Entries []*Entry `json:"entries"` + } + if err := json.Unmarshal(raw, &wire); err != nil { + // Tolerate corruption — start empty; first write will overwrite. + return s, nil + } + for _, e := range wire.Entries { + if e != nil && e.SubChannelID != "" { + s.entries[e.SubChannelID] = e + } + } + return s, nil +} + +// Lookup returns the entry for subChannelID or nil. +func (s *Store) Lookup(subChannelID string) *Entry { + s.mu.Lock() + defer s.mu.Unlock() + if e, ok := s.entries[subChannelID]; ok { + copyE := *e + return ©E + } + return nil +} + +// LookupForHost returns the entry IF subChannelID exists AND the entry +// was created by hostAgentID. Used by close-sub-discussion to enforce +// "only the host can close". +func (s *Store) LookupForHost(subChannelID, hostAgentID string) *Entry { + e := s.Lookup(subChannelID) + if e == nil || e.HostAgentID != hostAgentID { + return nil + } + return e +} + +// Add inserts a new entry; CreatedAt is auto-set if empty. +func (s *Store) Add(e Entry) error { + if e.SubChannelID == "" { + return errors.New("subdisc: SubChannelID required") + } + if e.CreatedAt == "" { + e.CreatedAt = time.Now().UTC().Format(time.RFC3339) + } + s.mu.Lock() + s.entries[e.SubChannelID] = &e + s.mu.Unlock() + return s.persist() +} + +// Remove drops the entry; returns true iff it was present. +func (s *Store) Remove(subChannelID string) bool { + s.mu.Lock() + _, ok := s.entries[subChannelID] + if ok { + delete(s.entries, subChannelID) + } + s.mu.Unlock() + if !ok { + return false + } + _ = s.persist() // best-effort + return true +} + +// All returns a snapshot copy of every entry (for diagnostic tools). +func (s *Store) All() []Entry { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]Entry, 0, len(s.entries)) + for _, e := range s.entries { + out = append(out, *e) + } + return out +} + +func (s *Store) persist() error { + s.mu.Lock() + entries := make([]*Entry, 0, len(s.entries)) + for _, e := range s.entries { + entries = append(entries, e) + } + s.mu.Unlock() + + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(struct { + Entries []*Entry `json:"entries"` + }{entries}, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +// DefaultPath returns the canonical path under +// $PLEXUM_PROFILE_ROOT/state/plugin-kv (or ~/.plexum/...). +func DefaultPath() string { + root := os.Getenv("PLEXUM_PROFILE_ROOT") + if root == "" { + home, _ := os.UserHomeDir() + root = filepath.Join(home, ".plexum") + } + return filepath.Join(root, "state", "plugin-kv", FileName) +} diff --git a/internal/subdisc/store_test.go b/internal/subdisc/store_test.go new file mode 100644 index 0000000..85e207b --- /dev/null +++ b/internal/subdisc/store_test.go @@ -0,0 +1,74 @@ +package subdisc + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "sub.json") + s, _ := Open(path) + err := s.Add(Entry{ + SubChannelID: "ch1", HostAgentID: "alice", HostUserID: "uA", + GuestUserIDs: []string{"uB"}, CallbackGuildNodeID: "g1", CallbackChannelID: "pCh", + }) + if err != nil { + t.Fatal(err) + } + s2, _ := Open(path) + e := s2.Lookup("ch1") + if e == nil || e.HostAgentID != "alice" || e.CallbackChannelID != "pCh" { + t.Errorf("entry = %+v", e) + } + if e.CreatedAt == "" { + t.Errorf("CreatedAt should be auto-set") + } +} + +func TestLookupForHost(t *testing.T) { + s, _ := Open(filepath.Join(t.TempDir(), "x.json")) + s.Add(Entry{SubChannelID: "c", HostAgentID: "alice"}) + if s.LookupForHost("c", "alice") == nil { + t.Errorf("alice should match") + } + if s.LookupForHost("c", "bob") != nil { + t.Errorf("bob should NOT match") + } + if s.LookupForHost("missing", "alice") != nil { + t.Errorf("missing channel should return nil") + } +} + +func TestRemove(t *testing.T) { + s, _ := Open(filepath.Join(t.TempDir(), "x.json")) + s.Add(Entry{SubChannelID: "c"}) + if !s.Remove("c") { + t.Errorf("Remove existing should return true") + } + if s.Remove("c") { + t.Errorf("Remove missing should return false") + } +} + +func TestAllReturnsCopy(t *testing.T) { + s, _ := Open(filepath.Join(t.TempDir(), "x.json")) + s.Add(Entry{SubChannelID: "a", HostAgentID: "alice"}) + s.Add(Entry{SubChannelID: "b", HostAgentID: "bob"}) + all := s.All() + if len(all) != 2 { + t.Errorf("len = %d", len(all)) + } +} + +func TestOpenCorruptStartsEmpty(t *testing.T) { + path := filepath.Join(t.TempDir(), "corrupt.json") + os.WriteFile(path, []byte("not json"), 0o600) + s, err := Open(path) + if err != nil { + t.Errorf("Open corrupt should tolerate: %v", err) + } + if len(s.All()) != 0 { + t.Errorf("corrupt should start empty") + } +} diff --git a/internal/tools/contracts.go b/internal/tools/contracts.go index 8b47016..9102b72 100644 --- a/internal/tools/contracts.go +++ b/internal/tools/contracts.go @@ -136,4 +136,77 @@ var allContracts = []plugin.ToolContract{ "required": ["agent_id", "guild_node_id", "channel_id", "op"] }`), }, + // ---- channel creation (4 x-types share the same shape) ---- + createChannelContract("create-chat-channel", "Create a chat channel (xType=general)."), + createChannelContract("create-work-channel", "Create a work channel (xType=work)."), + createChannelContract("create-report-channel", "Create a report channel (xType=report)."), + createChannelContract("create-discussion-channel", "Create a discussion channel (xType=discuss)."), + { + Name: "discussion-complete", + Description: "Post a summary then close the channel — concludes a discussion.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "agent_id": {"type": "string"}, + "guild_node_id": {"type": "string"}, + "channel_id": {"type": "string"}, + "summary": {"type": "string"} + }, + "required": ["agent_id", "guild_node_id", "channel_id", "summary"] + }`), + }, + { + Name: "create-sub-discussion", + Description: "Spawn a new sub-discussion channel with the calling agent as host + invited guests; remembered in plugin KV so close-sub-discussion can post the callback to the parent channel.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "agent_id": {"type": "string"}, + "guild_node_id": {"type": "string"}, + "name": {"type": "string"}, + "guest_user_ids": {"type": "array", "items": {"type": "string"}}, + "host_guide": {"type": "string"}, + "guest_guide": {"type": "string"}, + "parent_channel_id":{"type": "string"}, + "greeting": {"type": "string"}, + "x_type": {"type": "string", "enum": ["discuss","work","report","general"]} + }, + "required": ["agent_id", "guild_node_id", "name", "parent_channel_id"] + }`), + }, + { + Name: "close-sub-discussion", + Description: "Close a sub-discussion the calling agent originally created; posts a callback message to the parent channel.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "agent_id": {"type": "string"}, + "sub_channel_id":{"type": "string"}, + "summary": {"type": "string"} + }, + "required": ["agent_id", "sub_channel_id"] + }`), + }, +} + +// createChannelContract returns the shared schema for the four +// create-*-channel tool variants. +func createChannelContract(name, description string) plugin.ToolContract { + return plugin.ToolContract{ + Name: name, Description: description, + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "agent_id": {"type": "string"}, + "guild_node_id": {"type": "string"}, + "name": {"type": "string"}, + "is_public": {"type": "boolean"}, + "member_user_ids": {"type": "array", "items": {"type": "string"}}, + "listeners": {"type": "array", "items": {"type": "string"}}, + "on_duty": {"type": "string"}, + "purpose": {"type": "string"} + }, + "required": ["agent_id", "guild_node_id", "name"] + }`), + } } diff --git a/internal/tools/tools.go b/internal/tools/tools.go index 6d38a20..1465fb1 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -24,6 +24,7 @@ import ( "git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/fabric" "git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/identity" + "git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/subdisc" "git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/tokens" ) @@ -32,6 +33,7 @@ type Deps struct { Client *fabric.Client Tokens *tokens.Cache Identities *identity.Registry + SubDisc *subdisc.Store // optional; sub-discussion tools are disabled when nil } // HandlerFunc is the tool's call-time function. Returns a ToolResult @@ -47,6 +49,7 @@ type Registry struct { // New constructs a Registry with the agent-facing tools wired. func New(deps Deps) *Registry { r := &Registry{deps: deps, handlers: map[string]HandlerFunc{}} + // Batch 1: stateless agent-facing tools. r.registerSendMessage() r.registerSendSysMsg() r.registerChannelList() @@ -55,6 +58,17 @@ func New(deps Deps) *Registry { r.registerChannelSetPurpose() r.registerChannel() r.registerCanvas() + // Batch 2: channel creation (4 x-types) + discussion-complete. + r.registerCreateChannelByKind("create-chat-channel", "general") + r.registerCreateChannelByKind("create-work-channel", "work") + r.registerCreateChannelByKind("create-report-channel", "report") + r.registerCreateChannelByKind("create-discussion-channel", "discuss") + r.registerDiscussionComplete() + // Batch 3: sub-discussion (needs Deps.SubDisc). + if deps.SubDisc != nil { + r.registerCreateSubDiscussion() + r.registerCloseSubDiscussion() + } return r } @@ -377,6 +391,169 @@ func (r *Registry) registerChannel() { } } +// ---- tool: create-{chat,work,report,discussion}-channel ---- + +func (r *Registry) registerCreateChannelByKind(toolName, xType string) { + r.handlers[toolName] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) { + var in struct { + AgentID string `json:"agent_id"` + GuildNodeID string `json:"guild_node_id"` + Name string `json:"name"` + MemberUserIDs []string `json:"member_user_ids"` + Listeners []string `json:"listeners"` + OnDuty string `json:"on_duty"` + Purpose string `json:"purpose"` + IsPublic bool `json:"is_public"` + } + if err := json.Unmarshal(raw, &in); err != nil { + return errResult(toolName + ": parse: " + err.Error()), nil + } + if in.Name == "" { + return errResult(toolName + ": name required"), nil + } + _, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID) + if err != nil { + return errResult(toolName + ": " + err.Error()), nil + } + id, err := r.deps.Client.CreateChannel(ctx, endpoint, token, fabric.CreateChannelOpts{ + GuildID: in.GuildNodeID, Name: in.Name, XType: xType, + IsPublic: in.IsPublic, MemberUserIDs: in.MemberUserIDs, + Listeners: in.Listeners, OnDuty: in.OnDuty, Purpose: in.Purpose, + }) + if err != nil { + return errResult(toolName + ": " + err.Error()), nil + } + return textResult(fmt.Sprintf("created %s channel %s (id=%s)", xType, in.Name, id)), nil + } +} + +// ---- tool: discussion-complete ---- + +func (r *Registry) registerDiscussionComplete() { + r.handlers["discussion-complete"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) { + var in struct { + AgentID string `json:"agent_id"` + GuildNodeID string `json:"guild_node_id"` + ChannelID string `json:"channel_id"` + Summary string `json:"summary"` + } + if err := json.Unmarshal(raw, &in); err != nil { + return errResult("discussion-complete: parse: " + err.Error()), nil + } + if in.ChannelID == "" || in.Summary == "" { + return errResult("discussion-complete: channel_id + summary required"), nil + } + sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID) + if err != nil { + return errResult("discussion-complete: " + err.Error()), nil + } + // Post the summary as a normal message (so it shows in history + // with the host's name), then close. + if err := r.deps.Client.PostMessage(ctx, endpoint, token, + in.ChannelID, in.Summary, sess.User.ID); err != nil { + return errResult("discussion-complete: post summary: " + err.Error()), nil + } + if err := r.deps.Client.CloseChannel(ctx, endpoint, token, in.ChannelID); err != nil { + return errResult("discussion-complete: close: " + err.Error()), nil + } + return textResult("discussion concluded; channel closed"), nil + } +} + +// ---- tool: create-sub-discussion ---- + +func (r *Registry) registerCreateSubDiscussion() { + r.handlers["create-sub-discussion"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) { + var in struct { + AgentID string `json:"agent_id"` + GuildNodeID string `json:"guild_node_id"` + Name string `json:"name"` + GuestUserIDs []string `json:"guest_user_ids"` + HostGuide string `json:"host_guide"` + GuestGuide string `json:"guest_guide"` + ParentChannelID string `json:"parent_channel_id"` + Greeting string `json:"greeting"` + XType string `json:"x_type"` // default "discuss" + } + if err := json.Unmarshal(raw, &in); err != nil { + return errResult("create-sub-discussion: parse: " + err.Error()), nil + } + if in.Name == "" || in.ParentChannelID == "" { + return errResult("create-sub-discussion: name + parent_channel_id required"), nil + } + if in.XType == "" { + in.XType = "discuss" + } + sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, in.GuildNodeID) + if err != nil { + return errResult("create-sub-discussion: " + err.Error()), nil + } + members := append([]string{sess.User.ID}, in.GuestUserIDs...) + id, err := r.deps.Client.CreateChannel(ctx, endpoint, token, fabric.CreateChannelOpts{ + GuildID: in.GuildNodeID, Name: in.Name, XType: in.XType, + MemberUserIDs: members, + }) + if err != nil { + return errResult("create-sub-discussion: create: " + err.Error()), nil + } + if err := r.deps.SubDisc.Add(subdisc.Entry{ + SubChannelID: id, HostAgentID: in.AgentID, HostUserID: sess.User.ID, + GuestUserIDs: in.GuestUserIDs, HostGuide: in.HostGuide, GuestGuide: in.GuestGuide, + CallbackGuildNodeID: in.GuildNodeID, CallbackChannelID: in.ParentChannelID, + }); err != nil { + return errResult("create-sub-discussion: store: " + err.Error()), nil + } + // Post greeting if supplied — kicks the discussion off so guests + // see something on join. + if in.Greeting != "" { + if err := r.deps.Client.PostMessage(ctx, endpoint, token, + id, in.Greeting, sess.User.ID); err != nil { + return errResult("create-sub-discussion: greeting: " + err.Error()), nil + } + } + return textResult(fmt.Sprintf("sub-discussion ready: id=%s parent=%s guests=%v", + id, in.ParentChannelID, in.GuestUserIDs)), nil + } +} + +// ---- tool: close-sub-discussion ---- + +func (r *Registry) registerCloseSubDiscussion() { + r.handlers["close-sub-discussion"] = func(ctx context.Context, raw json.RawMessage) (plugin.ToolResult, error) { + var in struct { + AgentID string `json:"agent_id"` + SubChannelID string `json:"sub_channel_id"` + Summary string `json:"summary"` + } + if err := json.Unmarshal(raw, &in); err != nil { + return errResult("close-sub-discussion: parse: " + err.Error()), nil + } + if in.SubChannelID == "" { + return errResult("close-sub-discussion: sub_channel_id required"), nil + } + entry := r.deps.SubDisc.LookupForHost(in.SubChannelID, in.AgentID) + if entry == nil { + return errResult("close-sub-discussion: only the host of the discussion can close it (or sub_channel_id is unknown)"), nil + } + sess, endpoint, token, err := r.agentGuild(ctx, in.AgentID, entry.CallbackGuildNodeID) + if err != nil { + return errResult("close-sub-discussion: " + err.Error()), nil + } + // Post callback to parent channel as a system message. + callback := "sub-discussion concluded" + if in.Summary != "" { + callback = "sub-discussion concluded: " + in.Summary + } + _ = r.deps.Client.PostSystemMessage(ctx, endpoint, token, + entry.CallbackChannelID, callback, sess.User.ID) + if err := r.deps.Client.CloseChannel(ctx, endpoint, token, in.SubChannelID); err != nil { + return errResult("close-sub-discussion: close: " + err.Error()), nil + } + r.deps.SubDisc.Remove(in.SubChannelID) + return textResult("sub-discussion closed"), nil + } +} + // ---- tool: fabric-canvas (get / share / update / remove) ---- func (r *Registry) registerCanvas() { diff --git a/scripts/install.sh b/scripts/install.sh index f21f0e0..edf4841 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -89,7 +89,21 @@ print(json.dumps(out)) {"name":"fabric-channel","description":"Fetch metadata + member list for a specific channel.", "inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"channel_id":{"type":"string"}},"required":["agent_id","guild_node_id","channel_id"]}}, {"name":"fabric-canvas","description":"Read or write the channel canvas (op: get|share|update|remove).", - "inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"channel_id":{"type":"string"},"op":{"type":"string","enum":["get","share","update","remove"]},"title":{"type":"string"},"format":{"type":"string","enum":["md","html","text"]},"source":{"type":"string"}},"required":["agent_id","guild_node_id","channel_id","op"]}} + "inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"channel_id":{"type":"string"},"op":{"type":"string","enum":["get","share","update","remove"]},"title":{"type":"string"},"format":{"type":"string","enum":["md","html","text"]},"source":{"type":"string"}},"required":["agent_id","guild_node_id","channel_id","op"]}}, + {"name":"create-chat-channel","description":"Create a chat channel (xType=general).", + "inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"name":{"type":"string"},"is_public":{"type":"boolean"},"member_user_ids":{"type":"array","items":{"type":"string"}},"listeners":{"type":"array","items":{"type":"string"}},"on_duty":{"type":"string"},"purpose":{"type":"string"}},"required":["agent_id","guild_node_id","name"]}}, + {"name":"create-work-channel","description":"Create a work channel (xType=work).", + "inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"name":{"type":"string"},"is_public":{"type":"boolean"},"member_user_ids":{"type":"array","items":{"type":"string"}},"listeners":{"type":"array","items":{"type":"string"}},"on_duty":{"type":"string"},"purpose":{"type":"string"}},"required":["agent_id","guild_node_id","name"]}}, + {"name":"create-report-channel","description":"Create a report channel (xType=report).", + "inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"name":{"type":"string"},"is_public":{"type":"boolean"},"member_user_ids":{"type":"array","items":{"type":"string"}},"listeners":{"type":"array","items":{"type":"string"}},"on_duty":{"type":"string"},"purpose":{"type":"string"}},"required":["agent_id","guild_node_id","name"]}}, + {"name":"create-discussion-channel","description":"Create a discussion channel (xType=discuss).", + "inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"name":{"type":"string"},"is_public":{"type":"boolean"},"member_user_ids":{"type":"array","items":{"type":"string"}},"listeners":{"type":"array","items":{"type":"string"}},"on_duty":{"type":"string"},"purpose":{"type":"string"}},"required":["agent_id","guild_node_id","name"]}}, + {"name":"discussion-complete","description":"Post a summary then close the channel.", + "inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"channel_id":{"type":"string"},"summary":{"type":"string"}},"required":["agent_id","guild_node_id","channel_id","summary"]}}, + {"name":"create-sub-discussion","description":"Spawn a sub-discussion channel with the caller as host + invited guests.", + "inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"guild_node_id":{"type":"string"},"name":{"type":"string"},"guest_user_ids":{"type":"array","items":{"type":"string"}},"host_guide":{"type":"string"},"guest_guide":{"type":"string"},"parent_channel_id":{"type":"string"},"greeting":{"type":"string"},"x_type":{"type":"string","enum":["discuss","work","report","general"]}},"required":["agent_id","guild_node_id","name","parent_channel_id"]}}, + {"name":"close-sub-discussion","description":"Close a sub-discussion the caller hosts and post a callback to its parent channel.", + "inputSchema":{"type":"object","properties":{"agent_id":{"type":"string"},"sub_channel_id":{"type":"string"},"summary":{"type":"string"}},"required":["agent_id","sub_channel_id"]}} ]' cat > "${MANIFEST_PATH}" <