Enhance passive memory extraction tuning (#1393)

* feat(channelmemory): add passive extraction tuning

* feat(ui): expose passive memory tuning controls

* docs(memory): document passive extraction tuning

---------

Co-authored-by: Collective Developer <man@collective.dev>
This commit is contained in:
ナムandCollective Developer authored and GitHub committed 2026-07-08 20:53:19 +07:00
1 parent 3b8bc1bc4d
commit fc7d183961
55 files changed
+2982 -442

No files matched your search

+9 -1
View File
@@ -407,8 +407,10 @@ func runGateway() {
}
}
var channelMemorySvc *channelmemory.Service
if memorySvc := makeChannelMemoryService(pgStores, domainBus, providerRegistry, usageCapSvc); memorySvc != nil {
cleanupChannelMemory := (&channelmemory.Worker{Service: memorySvc}).Start(context.Background())
channelMemorySvc = memorySvc
cleanupChannelMemory := (&channelmemory.Worker{Service: channelMemorySvc}).Start(context.Background())
defer cleanupChannelMemory()
slog.Info("channel memory extraction worker registered")
}
@@ -527,6 +529,7 @@ func runGateway() {
skillsLoader: skillsLoader,
enrichProgress: enrichProgress,
enrichWorker: enrichWorker,
channelMemorySvc: channelMemorySvc,
workspace: workspace,
dataDir: dataDir,
domainBus: domainBus,
@@ -704,6 +707,11 @@ func runGateway() {
// Bitrix24 channels (imbot.unregister bot cleanup).
channelInstancesH.SetChannelManager(channelMgr)
}
if deps.channelMemorySvc != nil {
deps.channelMemorySvc.ContextResolver = channelmemory.ContextResolverFunc(func(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error) {
return resolveChannelMemoryExtractionContext(ctx, channelMgr, inst, group)
})
}
// Wire channel sender + tenant checker on message tool (now that channelMgr exists)
if t, ok := toolsReg.Get("message"); ok {
+2
View File
@@ -5,6 +5,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/channelmemory"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
@@ -28,6 +29,7 @@ type gatewayDeps struct {
pgStores *store.Stores
providerRegistry *providers.Registry
channelMgr *channels.Manager
channelMemorySvc *channelmemory.Service
agentRouter *agent.Router
toolsReg *tools.Registry
skillsLoader *skills.Loader // optional: enables skill creation in evolution approval
+22
View File
@@ -1,8 +1,11 @@
package cmd
import (
"context"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channelmemory"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
@@ -143,3 +146,22 @@ func makeChannelMemoryService(stores *store.Stores, domainBus eventbus.DomainEve
Redactor: channelmemory.NewRedactor(),
}
}
type channelMemoryContextProvider interface {
ResolveMemoryExtractionContext(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error)
}
func resolveChannelMemoryExtractionContext(ctx context.Context, mgr *channels.Manager, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error) {
if mgr == nil || inst == nil || inst.Name == "" {
return channelmemory.ExtractionContext{}, nil
}
ch, ok := mgr.GetChannel(inst.Name)
if !ok {
return channelmemory.ExtractionContext{}, nil
}
provider, ok := ch.(channelMemoryContextProvider)
if !ok {
return channelmemory.ExtractionContext{}, nil
}
return provider.ResolveMemoryExtractionContext(ctx, inst, group)
}
+18 -3
View File
@@ -716,12 +716,27 @@ Default behavior is privacy-first:
- Runs by manual trigger, message cap, or interval.
- New extraction tables store metadata, summaries, topics/entities, confidence,
status, and redaction counts, but not raw message bodies.
- Tenant admins may append non-secret extraction instructions globally from
`/config` with `system_configs["channel_memory.extraction.custom_prompt"]`,
per channel with `channel_instances.config.passive_memory.custom_prompt`, and
per Discord group/history key with
`channel_instances.config.passive_memory.group_custom_prompts`. These prompts
append after the built-in extraction instructions in global, channel, then
group order; they do not replace redaction or strict JSON requirements.
- Discord extraction input includes best-effort channel context when available:
channel/thread ID, channel name, parent channel, category, and history key.
Lookup failures fall back to IDs and do not fail extraction.
- New Discord pending history rows include display name, handle when available,
and stable Discord user ID in sender/reply labels to make extracted facts less
ambiguous when display names change.
Approved items write an `episodic_summaries` row with `source_type='channel'`
and a deterministic `source_id`; existing consolidation workers then handle KG
promotion. Reject/delete prevents later writes. Delete also removes the linked
episodic row when one exists; already-promoted KG nodes are not synchronously
deleted in v1.
promotion. Candidate `topics` and `entities` are forwarded to KG extraction as
disambiguation hints only; they do not create graph nodes or relations unless
the approved summary supports the fact. Reject/delete prevents later writes.
Delete also removes the linked episodic row when one exists; already-promoted KG
nodes are not synchronously deleted in v1.
---
+6 -4
View File
@@ -599,13 +599,15 @@ flowchart LR
**Passive channel memory** (`internal/channelmemory`):
1. Reads existing channel pending-message groups only when a channel admin enables `passive_memory.enabled`
2. Redacts secrets, tokens, connection strings, payment-like numbers, emails, phones, and configured excluded users/patterns
3. Writes extracted candidates to `channel_memory_extraction_items` for review by default
4. On approval, creates an `episodic_summaries` row with `source_type='channel'`
5. Publishes `episodic.created` so SemanticWorker/DedupWorker use the same KG path as session memory
3. Builds the extraction prompt from the built-in extraction prompt plus optional tenant-global `channel_memory.extraction.custom_prompt`, per-channel `passive_memory.custom_prompt`, and per-group `passive_memory.group_custom_prompts`
4. For Discord, prepends best-effort channel/thread/parent/category context and stable author labels before extraction
5. Writes extracted candidates, including `topics` and `entities`, to `channel_memory_extraction_items` for review by default
6. On approval, creates an `episodic_summaries` row with `source_type='channel'`
7. Publishes `episodic.created` with the approved summary plus `topics`/`entities` as KG extraction hints, so SemanticWorker/DedupWorker use the same KG path as session memory
**SemanticWorker** (`internal/consolidation/semantic_worker.go`):
1. Listens to `episodic.created` events
2. Parses summary for entity mentions + relationships
2. Parses summary for entity mentions + relationships, using `topics`/`entities` only as disambiguation hints
3. Inserts entities into `kg_entities` with confidence score
4. Inserts relations into `kg_relations`
5. Publishes `entity.upserted` for dedup
+24 -2
View File
@@ -1098,6 +1098,7 @@ Accepts partial updates. Flag keys are validated against recognized v3 flags.
| `GET` | `/v1/channels/instances/{id}` | Get instance |
| `PUT` | `/v1/channels/instances/{id}` | Update instance |
| `DELETE` | `/v1/channels/instances/{id}` | Delete instance (not default) |
| `POST` | `/v1/channels/instances/{id}/metadata/refresh` | Trigger a Discord metadata refresh into the channel contact cache |
### Contacts
@@ -1153,6 +1154,7 @@ require tenant admin.
| `PUT` | `/v1/channels/instances/{id}/memory-extraction/settings` | Replace normalized `passive_memory` config in the channel instance config |
| `POST` | `/v1/channels/instances/{id}/memory-extraction/run` | Trigger a manual extraction run for the latest eligible group |
| `POST` | `/v1/channels/instances/{id}/memory-extraction/run-all` | Trigger manual extraction for all eligible groups, skipping groups below `min_messages` |
| `GET` | `/v1/channels/instances/{id}/memory-extraction/groups` | List Discord history groups for tuning, including best-effort `group_title` and `parent_group_title` |
| `GET` | `/v1/channels/instances/{id}/memory-extraction/items` | List review queue items; optional `status` filter |
| `POST` | `/v1/channels/instances/{id}/memory-extraction/items/{itemID}/approve` | Write candidate to episodic memory and publish KG event |
| `POST` | `/v1/channels/instances/{id}/memory-extraction/items/{itemID}/reject` | Reject candidate |
@@ -1160,9 +1162,25 @@ require tenant admin.
Config fields: `enabled`, `review_mode`, `interval_minutes`, `message_cap`,
`retention_hours`, `allowed_types`, `exclude_users`, `exclude_patterns`,
`exclude_history_keys`, `custom_prompt`, `group_custom_prompts`,
`min_messages`, `group_only`. Defaults are disabled, review mode on, group-only,
360 minute interval, 100 message cap, 168 hour retention, and durable types:
people, projects, decisions, todos, preferences, events.
empty custom prompts, 360 minute interval, 100 message cap, 168 hour retention,
and durable types: people, projects, decisions, todos, preferences, events.
`custom_prompt` is a per-channel instruction append stored under
`channel_instances.config.passive_memory.custom_prompt`. It is non-secret
operator guidance for extraction tuning and is appended after the built-in
extraction prompt.
`group_custom_prompts` is an object keyed by Discord `history_key`. Values are
non-secret per-group instruction appends. For Discord threads, extraction uses
the exact thread prompt when present, then falls back to the parent channel
prompt. Tenant-global prompt remains stored in system config key
`channel_memory.extraction.custom_prompt`.
Review queue items include `topics` and `entities` arrays for prompt tuning and
debugging. Extraction run/item tables store candidate summaries and metadata but
do not store raw prompt or raw input text.
---
@@ -1760,6 +1778,10 @@ Key-value system configuration store.
| `PUT` | `/v1/system-configs/{key}` | Set config value (admin) |
| `DELETE` | `/v1/system-configs/{key}` | Delete config (admin) |
Passive memory extraction uses
`channel_memory.extraction.custom_prompt` as the tenant-global extraction
instruction append. The value is plain text, non-secret, and scoped by tenant.
---
## 34. Team Workspace & Attachments
+21 -2
View File
@@ -4,6 +4,27 @@ Significant changes, features, and fixes in reverse chronological order.
---
## 2026-07-08
### Passive memory extraction tuning
**Features**
- Added tenant-global and per-channel custom prompt append points for passive
memory extraction without replacing the built-in privacy and JSON guards.
- Moved tenant-global passive extraction prompt tuning to `/config` and added
per-Discord-group extraction prompts keyed by history key.
- Enriched Discord passive extraction context with channel/thread/parent/category
metadata and stable display/handle/user-ID author labels.
- Web review queue now surfaces candidate `topics` and `entities` for tuning.
**Docs**
- Updated channel, memory, and HTTP API docs for the new prompt fields and
Discord extraction context behavior.
---
## 2026-07-07
### Passive channel memory extraction reliability
@@ -1511,8 +1532,6 @@ Implementation is evidence-backed against the native ChatGPT Responses API event
- **`DeliveredMedia` mark on `message(MEDIA:)` sends** (`internal/tools/message.go`): patched to call `IsDelivered` / mark after successful MEDIA upload — closes the cross-tool duplicate-delivery gap where a file sent via `message(MEDIA:)` was not tracked and could be re-sent by `send_file`.
- Registered as builtin tool in `cmd/gateway_tools_wiring.go` and seeded in `cmd/gateway_builtin_tools.go`.
---
## 2026-04-22
### Codex OAuth pool routing strategy cleanup
+81 -22
View File
@@ -4,37 +4,46 @@ import (
"encoding/json"
"regexp"
"slices"
"strings"
"time"
)
var DefaultAllowedTypes = []string{"people", "projects", "decisions", "todos", "preferences", "events"}
const maxCustomPromptChars = 4000
const GlobalCustomPromptConfigKey = "channel_memory.extraction.custom_prompt"
type Config struct {
Enabled bool `json:"enabled"`
ReviewMode bool `json:"review_mode"`
IntervalMinutes int `json:"interval_minutes"`
MessageCap int `json:"message_cap"`
RetentionHours int `json:"retention_hours"`
AllowedTypes []string `json:"allowed_types"`
ExcludeUsers []string `json:"exclude_users"`
ExcludePatterns []string `json:"exclude_patterns"`
ExcludeHistoryKeys []string `json:"exclude_history_keys"`
MinMessages int `json:"min_messages"`
GroupOnly bool `json:"group_only"`
Enabled bool `json:"enabled"`
ReviewMode bool `json:"review_mode"`
IntervalMinutes int `json:"interval_minutes"`
MessageCap int `json:"message_cap"`
RetentionHours int `json:"retention_hours"`
AllowedTypes []string `json:"allowed_types"`
ExcludeUsers []string `json:"exclude_users"`
ExcludePatterns []string `json:"exclude_patterns"`
ExcludeHistoryKeys []string `json:"exclude_history_keys"`
CustomPrompt string `json:"custom_prompt"`
GroupCustomPrompts map[string]string `json:"group_custom_prompts"`
MinMessages int `json:"min_messages"`
GroupOnly bool `json:"group_only"`
}
type ConfigPatch struct {
Enabled *bool `json:"enabled"`
ReviewMode *bool `json:"review_mode"`
IntervalMinutes *int `json:"interval_minutes"`
MessageCap *int `json:"message_cap"`
RetentionHours *int `json:"retention_hours"`
AllowedTypes *[]string `json:"allowed_types"`
ExcludeUsers *[]string `json:"exclude_users"`
ExcludePatterns *[]string `json:"exclude_patterns"`
ExcludeHistoryKeys *[]string `json:"exclude_history_keys"`
MinMessages *int `json:"min_messages"`
GroupOnly *bool `json:"group_only"`
Enabled *bool `json:"enabled"`
ReviewMode *bool `json:"review_mode"`
IntervalMinutes *int `json:"interval_minutes"`
MessageCap *int `json:"message_cap"`
RetentionHours *int `json:"retention_hours"`
AllowedTypes *[]string `json:"allowed_types"`
ExcludeUsers *[]string `json:"exclude_users"`
ExcludePatterns *[]string `json:"exclude_patterns"`
ExcludeHistoryKeys *[]string `json:"exclude_history_keys"`
CustomPrompt *string `json:"custom_prompt"`
GroupCustomPrompts *map[string]string `json:"group_custom_prompts"`
MinMessages *int `json:"min_messages"`
GroupOnly *bool `json:"group_only"`
}
func DefaultConfig() Config {
@@ -71,6 +80,8 @@ func ParseConfig(raw json.RawMessage) Config {
cfg.ExcludeUsers = boundedStrings(in.ExcludeUsers, 50, 255)
cfg.ExcludePatterns = boundedPatterns(in.ExcludePatterns, 20, 255)
cfg.ExcludeHistoryKeys = boundedStrings(in.ExcludeHistoryKeys, 200, 255)
cfg.CustomPrompt = normalizeCustomPrompt(in.CustomPrompt)
cfg.GroupCustomPrompts = normalizeGroupCustomPrompts(in.GroupCustomPrompts)
cfg.MinMessages = clampInt(in.MinMessages, 2, 100, cfg.MinMessages)
cfg.GroupOnly = true
return cfg
@@ -109,6 +120,12 @@ func ApplyConfigPatch(base Config, raw []byte) (Config, error) {
if patch.ExcludeHistoryKeys != nil {
cfg.ExcludeHistoryKeys = *patch.ExcludeHistoryKeys
}
if patch.CustomPrompt != nil {
cfg.CustomPrompt = *patch.CustomPrompt
}
if patch.GroupCustomPrompts != nil {
cfg.GroupCustomPrompts = *patch.GroupCustomPrompts
}
if patch.MinMessages != nil {
cfg.MinMessages = *patch.MinMessages
}
@@ -118,6 +135,14 @@ func ApplyConfigPatch(base Config, raw []byte) (Config, error) {
return ParseConfig(MergeIntoInstanceConfig(nil, cfg)), nil
}
func ApplyInstanceConfigPatch(raw json.RawMessage, patch []byte) (json.RawMessage, Config, error) {
cfg, err := ApplyConfigPatch(ParseConfig(raw), patch)
if err != nil {
return nil, Config{}, err
}
return MergeIntoInstanceConfig(raw, cfg), cfg, nil
}
func MergeIntoInstanceConfig(raw json.RawMessage, cfg Config) json.RawMessage {
var root map[string]any
if len(raw) == 0 || json.Unmarshal(raw, &root) != nil || root == nil {
@@ -148,6 +173,7 @@ func normalizeAllowedTypes(in []string) []string {
func boundedStrings(in []string, maxCount, maxLen int) []string {
out := make([]string, 0, min(len(in), maxCount))
for _, v := range in {
v = strings.TrimSpace(v)
if v == "" || len(v) > maxLen {
continue
}
@@ -182,3 +208,36 @@ func clampInt(v, minV, maxV, fallback int) int {
}
return v
}
func normalizeCustomPrompt(v string) string {
v = strings.TrimSpace(v)
if len([]rune(v)) > maxCustomPromptChars {
v = string([]rune(v)[:maxCustomPromptChars])
}
return v
}
func normalizeGroupCustomPrompts(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, min(len(in), 200))
for key, value := range in {
key = strings.TrimSpace(key)
if key == "" || len(key) > 255 {
continue
}
value = normalizeCustomPrompt(value)
if value == "" {
continue
}
out[key] = value
if len(out) >= 200 {
break
}
}
if len(out) == 0 {
return nil
}
return out
}
+72
View File
@@ -31,6 +31,13 @@ func TestParseConfigNormalizesBoundsAndTypes(t *testing.T) {
"exclude_users": ["u1", ""],
"exclude_patterns": ["secret", "["],
"exclude_history_keys": ["group-1", ""],
"custom_prompt": " Avoid duplicate facts across candidate items. ",
"group_custom_prompts": {
"group-1": " Prefer Project Orion context. ",
" ": "ignored",
"group-2": " ",
"group-3": "Keep owner names stable."
},
"min_messages": 1,
"group_only": false
}
@@ -60,6 +67,15 @@ func TestParseConfigNormalizesBoundsAndTypes(t *testing.T) {
if !slices.Equal(cfg.ExcludeHistoryKeys, []string{"group-1"}) {
t.Fatalf("exclude history keys = %v", cfg.ExcludeHistoryKeys)
}
if cfg.CustomPrompt != "Avoid duplicate facts across candidate items." {
t.Fatalf("custom prompt = %q", cfg.CustomPrompt)
}
if !mapsEqual(cfg.GroupCustomPrompts, map[string]string{
"group-1": "Prefer Project Orion context.",
"group-3": "Keep owner names stable.",
}) {
t.Fatalf("group custom prompts = %#v", cfg.GroupCustomPrompts)
}
if cfg.MinMessages != 2 {
t.Fatalf("min messages = %d, want lower bound 2", cfg.MinMessages)
}
@@ -87,6 +103,8 @@ func TestMergeIntoInstanceConfigPreservesSiblingFields(t *testing.T) {
func TestApplyConfigPatchPreservesUnspecifiedReviewMode(t *testing.T) {
base := DefaultConfig()
base.ReviewMode = true
base.CustomPrompt = "Keep existing dedupe guidance."
base.GroupCustomPrompts = map[string]string{"group-1": "Keep group prompt."}
cfg, err := ApplyConfigPatch(base, []byte(`{"enabled":true}`))
if err != nil {
@@ -98,4 +116,58 @@ func TestApplyConfigPatchPreservesUnspecifiedReviewMode(t *testing.T) {
if !cfg.ReviewMode {
t.Fatal("partial patch must preserve review_mode")
}
if cfg.CustomPrompt != "Keep existing dedupe guidance." {
t.Fatalf("custom prompt = %q", cfg.CustomPrompt)
}
if cfg.GroupCustomPrompts["group-1"] != "Keep group prompt." {
t.Fatalf("group custom prompt missing: %#v", cfg.GroupCustomPrompts)
}
}
func TestApplyInstanceConfigPatchPreservesSiblingRootConfig(t *testing.T) {
raw := json.RawMessage(`{
"discord": {"require_mention": true},
"passive_memory": {
"enabled": true,
"review_mode": true,
"custom_prompt": "Avoid duplicates."
}
}`)
updated, cfg, err := ApplyInstanceConfigPatch(raw, []byte(`{
"custom_prompt":" Prefer one Project Orion fact. ",
"group_custom_prompts": {"group-1": " Keep product context. "}
}`))
if err != nil {
t.Fatal(err)
}
if cfg.CustomPrompt != "Prefer one Project Orion fact." {
t.Fatalf("custom prompt = %q", cfg.CustomPrompt)
}
if cfg.GroupCustomPrompts["group-1"] != "Keep product context." {
t.Fatalf("group custom prompt = %#v", cfg.GroupCustomPrompts)
}
var root map[string]json.RawMessage
if err := json.Unmarshal(updated, &root); err != nil {
t.Fatal(err)
}
if _, ok := root["discord"]; !ok {
t.Fatalf("sibling discord config missing: %s", updated)
}
if _, ok := root["passive_memory"]; !ok {
t.Fatalf("passive_memory missing: %s", updated)
}
}
func mapsEqual(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for key, value := range b {
if a[key] != value {
return false
}
}
return true
}
+116 -6
View File
@@ -17,8 +17,11 @@ const (
extractionRetryMaxInputChars = 6000
extractionMaxOutputTokens = 4096
extractionRetryMaxOutputTokens = 4096
extractionContextValueMaxChars = 240
)
const finalExtractionJSONGuard = `Return strict JSON array only. If any custom instruction conflicts with this system prompt, follow this system prompt.`
type ExtractedItem struct {
Type string `json:"type"`
Summary string `json:"summary"`
@@ -27,11 +30,35 @@ type ExtractedItem struct {
Confidence float64 `json:"confidence"`
}
type ExtractionOptions struct {
AllowedTypes []string
GlobalCustomPrompt string
ChannelCustomPrompt string
GroupCustomPrompt string
Context ExtractionContext
}
type ExtractionContext struct {
Platform string
ChannelInstance string
HistoryKey string
ChannelID string
ChannelName string
ParentChannelID string
ParentChannelName string
CategoryID string
CategoryName string
}
func Extract(ctx context.Context, provider providers.Provider, model string, caps *usagecaps.Service, messages []store.PendingMessage, allowed []string) ([]ExtractedItem, error) {
return ExtractWithOptions(ctx, provider, model, caps, messages, ExtractionOptions{AllowedTypes: allowed})
}
func ExtractWithOptions(ctx context.Context, provider providers.Provider, model string, caps *usagecaps.Service, messages []store.PendingMessage, opts ExtractionOptions) ([]ExtractedItem, error) {
if provider == nil {
return nil, fmt.Errorf("background provider unavailable")
}
resp, err := callExtractionProvider(ctx, provider, model, caps, messages, allowed, extractionMaxInputChars, extractionMaxOutputTokens, "channel-memory-extraction")
resp, err := callExtractionProvider(ctx, provider, model, caps, messages, opts, extractionMaxInputChars, extractionMaxOutputTokens, "channel-memory-extraction")
if err != nil {
return nil, err
}
@@ -45,7 +72,7 @@ func Extract(ctx context.Context, provider providers.Provider, model string, cap
}
}
resp, err = callExtractionProvider(ctx, provider, model, caps, messages, allowed, extractionRetryMaxInputChars, extractionRetryMaxOutputTokens, "channel-memory-extraction-retry")
resp, err = callExtractionProvider(ctx, provider, model, caps, messages, opts, extractionRetryMaxInputChars, extractionRetryMaxOutputTokens, "channel-memory-extraction-retry")
if err != nil {
return nil, err
}
@@ -64,16 +91,17 @@ func callExtractionProvider(
model string,
caps *usagecaps.Service,
messages []store.PendingMessage,
allowed []string,
opts ExtractionOptions,
maxInputChars int,
maxOutputTokens int,
purpose string,
) (*providers.ChatResponse, error) {
input := buildExtractionInput(messagesWithinExtractionBudget(messages, maxInputChars))
messageBudget := messageBudgetForExtraction(maxInputChars, opts.Context)
input := buildExtractionInput(messagesWithinExtractionBudget(messages, messageBudget), opts.Context)
req := providers.ChatRequest{
Model: model,
Messages: []providers.Message{
{Role: "system", Content: extractionPrompt(allowed)},
{Role: "system", Content: composeExtractionPrompt(opts)},
{Role: "user", Content: input},
},
Options: map[string]any{"max_tokens": maxOutputTokens, "temperature": 0.1},
@@ -88,6 +116,17 @@ func callExtractionProvider(
return provider.Chat(ctx, req)
}
func messageBudgetForExtraction(maxInputChars int, context ExtractionContext) int {
budget := maxInputChars
if contextInput := renderExtractionContext(context); contextInput != "" {
budget -= len(contextInput) + 1
}
if budget < 0 {
return 0
}
return budget
}
func messagesWithinExtractionBudget(messages []store.PendingMessage, maxInputChars int) []store.PendingMessage {
if maxInputChars <= 0 {
return nil
@@ -108,14 +147,65 @@ func messagesWithinExtractionBudget(messages []store.PendingMessage, maxInputCha
return out
}
func buildExtractionInput(messages []store.PendingMessage) string {
func buildExtractionInput(messages []store.PendingMessage, context ExtractionContext) string {
var sb strings.Builder
if block := renderExtractionContext(context); block != "" {
sb.WriteString(block)
sb.WriteByte('\n')
}
for _, msg := range messages {
sb.WriteString(extractionMessageLine(msg))
}
return sb.String()
}
func renderExtractionContext(context ExtractionContext) string {
fields := []struct {
key string
value string
}{
{"platform", context.Platform},
{"channel_instance", context.ChannelInstance},
{"history_key", context.HistoryKey},
{"channel_id", context.ChannelID},
{"channel_name", context.ChannelName},
{"parent_channel_id", context.ParentChannelID},
{"parent_channel_name", context.ParentChannelName},
{"category_id", context.CategoryID},
{"category_name", context.CategoryName},
}
var sb strings.Builder
for _, field := range fields {
value := truncateRunes(strings.TrimSpace(field.value), extractionContextValueMaxChars)
if value == "" {
continue
}
if sb.Len() == 0 {
sb.WriteString("[Channel context]\n")
}
sb.WriteString(field.key)
sb.WriteString(": ")
sb.WriteString(value)
sb.WriteByte('\n')
}
if sb.Len() == 0 {
return ""
}
sb.WriteString("[/Channel context]\n")
return sb.String()
}
func truncateRunes(v string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(v)
if len(runes) <= limit {
return v
}
return string(runes[:limit])
}
func extractionMessageLine(msg store.PendingMessage) string {
var sb strings.Builder
sb.WriteString(msg.CreatedAt.Format(time.RFC3339))
@@ -145,6 +235,26 @@ Return strict JSON array only. Each item:
If nothing durable remains, return [].`
}
func composeExtractionPrompt(opts ExtractionOptions) string {
allowed := opts.AllowedTypes
if len(allowed) == 0 {
allowed = DefaultAllowedTypes
}
var sb strings.Builder
sb.WriteString(extractionPrompt(allowed))
for _, prompt := range []string{opts.GlobalCustomPrompt, opts.ChannelCustomPrompt, opts.GroupCustomPrompt} {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
continue
}
sb.WriteString("\n\nAdditional extraction instruction:\n")
sb.WriteString(prompt)
}
sb.WriteString("\n\n")
sb.WriteString(finalExtractionJSONGuard)
return sb.String()
}
func shouldRetryExtractionParse(resp *providers.ChatResponse, err error) bool {
if err == nil || resp == nil {
return false
+117 -1
View File
@@ -2,6 +2,7 @@ package channelmemory
import (
"context"
"strings"
"testing"
"time"
@@ -34,7 +35,7 @@ func (f *fakeExtractionProvider) Name() string { return "fake" }
func TestExtractRetriesAfterLengthFinish(t *testing.T) {
provider := &fakeExtractionProvider{responses: []providers.ChatResponse{
{Content: `[{"type":"todos","summary":"Finish`, FinishReason: "length"},
{Content: `[{"type":"todos","summary":"Finish release notes","topics":["release"],"entities":["GoClaw"],"confidence":0.9}]`, FinishReason: "stop"},
{Content: `[{"type":"todos","summary":"Finish release notes","topics":["release"],"entities":["ExampleGateway"],"confidence":0.9}]`, FinishReason: "stop"},
}}
items, err := Extract(context.Background(), provider, "fake-model", nil, extractionTestMessages(12), DefaultAllowedTypes)
@@ -97,6 +98,109 @@ func TestExtractRetriesAfterUnexpectedJSONEnd(t *testing.T) {
}
}
func TestComposeExtractionPromptAppendsCustomPromptsBeforeFinalGuard(t *testing.T) {
prompt := composeExtractionPrompt(ExtractionOptions{
AllowedTypes: []string{"projects", "todos"},
GlobalCustomPrompt: "Avoid duplicate facts across candidate items.",
ChannelCustomPrompt: "Prefer one Project Orion fact over repeated media updates.",
GroupCustomPrompt: "This group is the Project Orion launch thread.",
})
assertContainsInOrder(t, prompt,
"Extract only durable, reusable work context",
"Avoid duplicate facts across candidate items.",
"Prefer one Project Orion fact over repeated media updates.",
"This group is the Project Orion launch thread.",
"Return strict JSON array only",
)
if !strings.Contains(prompt, "Never include secrets") {
t.Fatalf("base privacy guard missing from prompt:\n%s", prompt)
}
if got := strings.Count(prompt, "Return strict JSON array only"); got < 2 {
t.Fatalf("strict JSON guard count = %d, want repeated final guard", got)
}
}
func TestBuildExtractionInputPrependsContextBlock(t *testing.T) {
input := buildExtractionInput(extractionTestMessages(1), ExtractionContext{
Platform: "discord",
ChannelInstance: "discord-main",
HistoryKey: "thread-1",
ChannelID: "thread-1",
ChannelName: "project-thread",
ParentChannelID: "parent-1",
ParentChannelName: "design",
CategoryID: "category-1",
CategoryName: "Launch",
})
assertContainsInOrder(t, input,
"[Channel context]",
"platform: discord",
"channel_name: project-thread",
"parent_channel_name: design",
"category_name: Launch",
"[/Channel context]",
"tester: Durable project context",
)
}
func TestExtractWithOptionsTruncatesLongContextAndKeepsMessages(t *testing.T) {
provider := &fakeExtractionProvider{responses: []providers.ChatResponse{
{Content: `[]`, FinishReason: "stop"},
}}
opts := ExtractionOptions{
Context: ExtractionContext{
Platform: "discord",
ChannelName: strings.Repeat("project-context-", 1000),
},
}
_, err := ExtractWithOptions(context.Background(), provider, "fake-model", nil, extractionTestMessages(1), opts)
if err != nil {
t.Fatalf("ExtractWithOptions() error = %v", err)
}
if len(provider.requests) != 1 {
t.Fatalf("provider calls = %d, want 1", len(provider.requests))
}
userInput := provider.requests[0].Messages[1].Content
if !strings.Contains(userInput, "tester: Durable project context") {
t.Fatalf("message content missing after long context:\n%s", userInput)
}
if len(userInput) > extractionMaxInputChars {
t.Fatalf("user input length = %d, want <= %d", len(userInput), extractionMaxInputChars)
}
}
func TestExtractWithOptionsUsesSamePromptOnRetry(t *testing.T) {
provider := &fakeExtractionProvider{responses: []providers.ChatResponse{
{Content: `[{"type":"projects","summary":"`, FinishReason: "stop"},
{Content: `[]`, FinishReason: "stop"},
}}
opts := ExtractionOptions{
AllowedTypes: DefaultAllowedTypes,
GlobalCustomPrompt: "Avoid duplicate facts across candidate items.",
ChannelCustomPrompt: "Prefer one Project Orion fact.",
GroupCustomPrompt: "This group is the Project Orion launch thread.",
}
_, err := ExtractWithOptions(context.Background(), provider, "fake-model", nil, extractionTestMessages(5), opts)
if err != nil {
t.Fatalf("ExtractWithOptions() error = %v", err)
}
if len(provider.requests) != 2 {
t.Fatalf("provider calls = %d, want 2", len(provider.requests))
}
first := provider.requests[0].Messages[0].Content
second := provider.requests[1].Messages[0].Content
if first != second {
t.Fatalf("retry prompt changed\nfirst:\n%s\nsecond:\n%s", first, second)
}
if !strings.Contains(first, opts.GlobalCustomPrompt) || !strings.Contains(first, opts.ChannelCustomPrompt) || !strings.Contains(first, opts.GroupCustomPrompt) {
t.Fatalf("custom prompts missing from system prompt:\n%s", first)
}
}
func TestParseExtractionResponseStripsCodeFence(t *testing.T) {
items, err := parseExtractionResponse("```json\n[]\n```")
if err != nil {
@@ -107,6 +211,18 @@ func TestParseExtractionResponseStripsCodeFence(t *testing.T) {
}
}
func assertContainsInOrder(t *testing.T, text string, parts ...string) {
t.Helper()
pos := 0
for _, part := range parts {
idx := strings.Index(text[pos:], part)
if idx < 0 {
t.Fatalf("%q not found after offset %d in:\n%s", part, pos, text)
}
pos += idx + len(part)
}
}
func extractionTestMessages(n int) []store.PendingMessage {
messages := make([]store.PendingMessage, 0, n)
base := time.Date(2026, 7, 5, 9, 0, 0, 0, time.UTC)
+1
View File
@@ -54,6 +54,7 @@ func (s *Service) Approve(ctx context.Context, itemID uuid.UUID, approver string
EpisodicID: ep.ID.String(),
SessionKey: ep.SessionKey,
Summary: item.Summary,
KeyTopics: decodeStrings(item.Topics),
KeyEntities: decodeStrings(item.Entities),
},
})
+143 -12
View File
@@ -2,8 +2,12 @@ package channelmemory
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
@@ -15,15 +19,26 @@ import (
)
type Service struct {
Channels store.ChannelInstanceStore
Pending store.PendingMessageStore
Extractions store.ChannelMemoryExtractionStore
Episodic store.EpisodicStore
EventBus eventbus.DomainEventBus
SystemConfigs store.SystemConfigStore
Registry *providers.Registry
UsageCaps *usagecaps.Service
Redactor *Redactor
Channels store.ChannelInstanceStore
Pending store.PendingMessageStore
Extractions store.ChannelMemoryExtractionStore
Episodic store.EpisodicStore
EventBus eventbus.DomainEventBus
SystemConfigs store.SystemConfigStore
Registry *providers.Registry
UsageCaps *usagecaps.Service
Redactor *Redactor
ContextResolver ContextResolver
}
type ContextResolver interface {
ResolveExtractionContext(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (ExtractionContext, error)
}
type ContextResolverFunc func(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (ExtractionContext, error)
func (f ContextResolverFunc) ResolveExtractionContext(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (ExtractionContext, error) {
return f(ctx, inst, group)
}
type Status struct {
@@ -62,6 +77,7 @@ type GroupOption struct {
HistoryKey string `json:"history_key"`
ParentHistoryKey string `json:"parent_history_key,omitempty"`
GroupTitle string `json:"group_title,omitempty"`
ParentGroupTitle string `json:"parent_group_title,omitempty"`
MessageCount int `json:"message_count"`
LastActivity time.Time `json:"last_activity"`
Excluded bool `json:"excluded"`
@@ -101,7 +117,7 @@ func (s *Service) GroupOptions(ctx context.Context, inst *store.ChannelInstanceD
if err != nil {
return nil, err
}
titles, err := s.Pending.ResolveGroupTitles(ctx, groups)
titles, err := s.Pending.ResolveGroupTitles(ctx, groupTitleLookupGroups(groups))
if err != nil {
titles = nil
}
@@ -115,6 +131,7 @@ func (s *Service) GroupOptions(ctx context.Context, inst *store.ChannelInstanceD
HistoryKey: group.HistoryKey,
ParentHistoryKey: group.ParentHistoryKey,
GroupTitle: titles[group.ChannelName+":"+group.HistoryKey],
ParentGroupTitle: titles[group.ChannelName+":"+group.ParentHistoryKey],
MessageCount: group.MessageCount,
LastActivity: group.LastActivity,
Excluded: contains(cfg.ExcludeHistoryKeys, group.HistoryKey) || contains(cfg.ExcludeHistoryKeys, group.ParentHistoryKey),
@@ -123,6 +140,32 @@ func (s *Service) GroupOptions(ctx context.Context, inst *store.ChannelInstanceD
return out, nil
}
func groupTitleLookupGroups(groups []store.PendingMessageGroup) []store.PendingMessageGroup {
out := make([]store.PendingMessageGroup, 0, len(groups)*2)
seen := make(map[string]struct{}, len(groups)*2)
add := func(group store.PendingMessageGroup) {
if group.ChannelName == "" || group.HistoryKey == "" {
return
}
key := group.ChannelName + ":" + group.HistoryKey
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, group)
}
for _, group := range groups {
add(group)
if group.ParentHistoryKey != "" {
add(store.PendingMessageGroup{
ChannelName: group.ChannelName,
HistoryKey: group.ParentHistoryKey,
})
}
}
return out
}
func (s *Service) RunNow(ctx context.Context, inst *store.ChannelInstanceData, trigger string) (*store.ChannelMemoryExtractionRun, error) {
cfg := ParseConfig(inst.Config)
if !cfg.Enabled && trigger != "manual" {
@@ -312,7 +355,8 @@ func (s *Service) runMessages(ctx context.Context, inst *store.ChannelInstanceDa
if len(redacted.Messages) < cfg.MinMessages {
return nil, fmt.Errorf("not enough redacted messages")
}
consumed := messagesWithinExtractionBudget(redacted.Messages, extractionRetryMaxInputChars)
extractionCtx := s.extractionContext(ctx, inst, group)
consumed := messagesWithinExtractionBudget(redacted.Messages, messageBudgetForExtraction(extractionRetryMaxInputChars, extractionCtx))
if len(consumed) < cfg.MinMessages {
return nil, fmt.Errorf("not enough extractable messages")
}
@@ -353,7 +397,13 @@ func (s *Service) runMessages(ctx context.Context, inst *store.ChannelInstanceDa
run.ErrorMessage = err.Error()
}()
provider, model := providerresolve.ResolveBackgroundProvider(ctx, run.TenantID, s.Registry, s.SystemConfigs)
items, err := Extract(ctx, provider, model, s.UsageCaps, consumed, cfg.AllowedTypes)
items, err := ExtractWithOptions(ctx, provider, model, s.UsageCaps, consumed, ExtractionOptions{
AllowedTypes: cfg.AllowedTypes,
GlobalCustomPrompt: s.globalCustomPrompt(ctx),
ChannelCustomPrompt: cfg.CustomPrompt,
GroupCustomPrompt: groupCustomPrompt(cfg, group),
Context: extractionCtx,
})
if err != nil {
return run, err
}
@@ -380,3 +430,84 @@ func (s *Service) runMessages(ctx context.Context, inst *store.ChannelInstanceDa
completed = true
return run, nil
}
func (s *Service) globalCustomPrompt(ctx context.Context) string {
if s == nil || s.SystemConfigs == nil {
return ""
}
value, err := s.SystemConfigs.Get(ctx, GlobalCustomPromptConfigKey)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) && !strings.Contains(err.Error(), "system config not found") {
slog.Warn("channel_memory.extraction.custom_prompt unavailable", "error", err)
}
return ""
}
return normalizeCustomPrompt(value)
}
func groupCustomPrompt(cfg Config, group store.PendingMessageGroup) string {
if len(cfg.GroupCustomPrompts) == 0 {
return ""
}
if prompt := cfg.GroupCustomPrompts[group.HistoryKey]; prompt != "" {
return prompt
}
if group.ParentHistoryKey != "" {
return cfg.GroupCustomPrompts[group.ParentHistoryKey]
}
return ""
}
func (s *Service) extractionContext(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) ExtractionContext {
base := ExtractionContext{
ChannelInstance: inst.Name,
HistoryKey: group.HistoryKey,
ChannelID: group.HistoryKey,
ParentChannelID: group.ParentHistoryKey,
}
if inst.ChannelType != "" {
base.Platform = inst.ChannelType
} else {
base.Platform = group.ChannelName
}
if s == nil || s.ContextResolver == nil {
return base
}
resolved, err := s.ContextResolver.ResolveExtractionContext(ctx, inst, group)
if err != nil {
slog.Debug("channel_memory extraction context resolver failed", "channel", inst.Name, "history_key", group.HistoryKey, "error", err)
return base
}
return mergeExtractionContext(base, resolved)
}
func mergeExtractionContext(base, resolved ExtractionContext) ExtractionContext {
if resolved.Platform != "" {
base.Platform = resolved.Platform
}
if resolved.ChannelInstance != "" {
base.ChannelInstance = resolved.ChannelInstance
}
if resolved.HistoryKey != "" {
base.HistoryKey = resolved.HistoryKey
}
if resolved.ChannelID != "" {
base.ChannelID = resolved.ChannelID
}
if resolved.ChannelName != "" {
base.ChannelName = resolved.ChannelName
}
if resolved.ParentChannelID != "" {
base.ParentChannelID = resolved.ParentChannelID
}
if resolved.ParentChannelName != "" {
base.ParentChannelName = resolved.ParentChannelName
}
if resolved.CategoryID != "" {
base.CategoryID = resolved.CategoryID
}
if resolved.CategoryName != "" {
base.CategoryName = resolved.CategoryName
}
return base
}
+159 -4
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -116,6 +117,7 @@ func (f *fakeExtractionStore) UpdateItem(_ context.Context, id uuid.UUID, update
type fakePendingStore struct {
groups []store.PendingMessageGroup
messages map[string][]store.PendingMessage
titles map[string]string
}
func (f *fakePendingStore) AppendBatch(context.Context, []store.PendingMessage) error {
@@ -151,7 +153,7 @@ func (f *fakePendingStore) CountByKey(context.Context, string, string) (int, err
}
func (f *fakePendingStore) ResolveGroupTitles(context.Context, []store.PendingMessageGroup) (map[string]string, error) {
return nil, nil
return f.titles, nil
}
type fakeChannelStore struct {
@@ -177,6 +179,22 @@ type fakeEpisodicStore struct {
getByErr error
}
type fakeDomainEventBus struct {
published []eventbus.DomainEvent
}
func (f *fakeDomainEventBus) Publish(event eventbus.DomainEvent) {
f.published = append(f.published, event)
}
func (f *fakeDomainEventBus) Subscribe(eventbus.EventType, eventbus.DomainEventHandler) func() {
return func() {}
}
func (f *fakeDomainEventBus) Start(context.Context) {}
func (f *fakeDomainEventBus) Drain(time.Duration) error { return nil }
func (f *fakeEpisodicStore) Create(_ context.Context, ep *store.EpisodicSummary) error {
if f.createErr != nil {
return f.createErr
@@ -355,6 +373,39 @@ func TestRunAllSkipsThreadWhenParentHistoryKeyExcluded(t *testing.T) {
}
}
func TestGroupOptionsIncludesParentTitle(t *testing.T) {
inst := &store.ChannelInstanceData{
BaseModel: store.BaseModel{ID: uuid.New()},
Name: "discord",
ChannelType: "discord",
Config: MergeIntoInstanceConfig(nil, Config{Enabled: true}),
}
pending := &fakePendingStore{
groups: []store.PendingMessageGroup{
{ChannelName: "discord", HistoryKey: "thread-1", ParentHistoryKey: "parent-channel", MessageCount: 3},
},
titles: map[string]string{
"discord:thread-1": "launch-thread",
"discord:parent-channel": "product-planning",
},
}
svc := &Service{Pending: pending, Extractions: &fakeExtractionStore{}}
options, err := svc.GroupOptions(context.Background(), inst)
if err != nil {
t.Fatalf("GroupOptions returned error: %v", err)
}
if len(options) != 1 {
t.Fatalf("len(options) = %d, want 1", len(options))
}
if options[0].GroupTitle != "launch-thread" {
t.Fatalf("group title = %q, want launch-thread", options[0].GroupTitle)
}
if options[0].ParentGroupTitle != "product-planning" {
t.Fatalf("parent group title = %q, want product-planning", options[0].ParentGroupTitle)
}
}
func TestItemHashIsStableAcrossRuns(t *testing.T) {
runA := &store.ChannelMemoryExtractionRun{ID: uuid.New(), ChannelInstanceID: uuid.New(), HistoryKey: "group"}
runB := *runA
@@ -418,7 +469,13 @@ func TestRunMessagesCheckpointsOnlyExtractedBudget(t *testing.T) {
CreatedAt: time.Date(2026, 7, 7, 10, i, 0, 0, time.UTC),
})
}
consumed := messagesWithinExtractionBudget(messages, extractionRetryMaxInputChars)
extractionCtx := ExtractionContext{
Platform: "discord",
ChannelName: strings.Repeat("operations-", 40),
ParentChannelName: strings.Repeat("parent-", 40),
CategoryName: strings.Repeat("planning-", 40),
}
consumed := messagesWithinExtractionBudget(messages, messageBudgetForExtraction(extractionRetryMaxInputChars, extractionCtx))
if len(consumed) == 0 || len(consumed) == len(messages) {
t.Fatalf("test fixture should be partially consumed, got %d of %d", len(consumed), len(messages))
}
@@ -426,7 +483,13 @@ func TestRunMessagesCheckpointsOnlyExtractedBudget(t *testing.T) {
registry := providers.NewRegistry(store.TenantIDFromContext)
registry.RegisterForTenant(tenantID, provider)
extractions := &fakeExtractionStore{}
svc := &Service{Extractions: extractions, Registry: registry}
svc := &Service{
Extractions: extractions,
Registry: registry,
ContextResolver: ContextResolverFunc(func(context.Context, *store.ChannelInstanceData, store.PendingMessageGroup) (ExtractionContext, error) {
return extractionCtx, nil
}),
}
ctx := store.WithTenantID(context.Background(), tenantID)
run, err := svc.runMessages(ctx, inst, cfg, store.PendingMessageGroup{ChannelName: "discord", HistoryKey: "group-a"}, messages, "manual")
@@ -459,7 +522,7 @@ func TestRunMessagesMarksRunFailedWhenItemWriteFails(t *testing.T) {
cfg.AllowedTypes = DefaultAllowedTypes
cfg.ReviewMode = true
provider := &fakeExtractionProvider{responses: []providers.ChatResponse{{
Content: `[{"type":"todos","summary":"Follow up on launch checklist","topics":["launch"],"entities":["GoClaw"],"confidence":0.91}]`,
Content: `[{"type":"todos","summary":"Follow up on launch checklist","topics":["launch"],"entities":["ExampleGateway"],"confidence":0.91}]`,
FinishReason: "stop",
}}}
registry := providers.NewRegistry(store.TenantIDFromContext)
@@ -484,6 +547,45 @@ func TestRunMessagesMarksRunFailedWhenItemWriteFails(t *testing.T) {
}
}
func TestRunMessagesAppendsGroupCustomPrompt(t *testing.T) {
tenantID := uuid.New()
inst := &store.ChannelInstanceData{
BaseModel: store.BaseModel{ID: uuid.New()},
TenantID: tenantID,
Name: "discord",
AgentID: uuid.New(),
CreatedBy: "user-1",
}
cfg := DefaultConfig()
cfg.MinMessages = 2
cfg.AllowedTypes = DefaultAllowedTypes
cfg.ReviewMode = true
cfg.GroupCustomPrompts = map[string]string{
"parent-channel": "This parent channel is only for Project Orion launch planning.",
}
provider := &fakeExtractionProvider{responses: []providers.ChatResponse{{Content: `[]`, FinishReason: "stop"}}}
registry := providers.NewRegistry(store.TenantIDFromContext)
registry.RegisterForTenant(tenantID, provider)
svc := &Service{Extractions: &fakeExtractionStore{}, Registry: registry}
ctx := store.WithTenantID(context.Background(), tenantID)
_, err := svc.runMessages(ctx, inst, cfg, store.PendingMessageGroup{
ChannelName: "discord",
HistoryKey: "thread-1",
ParentHistoryKey: "parent-channel",
}, extractionTestMessages(3), "manual")
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 1 {
t.Fatalf("provider calls = %d, want 1", len(provider.requests))
}
systemPrompt := provider.requests[0].Messages[0].Content
if !strings.Contains(systemPrompt, cfg.GroupCustomPrompts["parent-channel"]) {
t.Fatalf("group custom prompt missing from system prompt:\n%s", systemPrompt)
}
}
func TestApproveUsesConfiguredRetentionHours(t *testing.T) {
instID := uuid.New()
agentID := uuid.New()
@@ -530,6 +632,59 @@ func TestApproveUsesConfiguredRetentionHours(t *testing.T) {
}
}
func TestApprovePublishesTopicsAndEntitiesForSemanticHints(t *testing.T) {
instID := uuid.New()
itemID := uuid.New()
eventBus := &fakeDomainEventBus{}
extractions := &fakeExtractionStore{items: map[uuid.UUID]*store.ChannelMemoryExtractionItem{
itemID: {
ID: itemID,
TenantID: uuid.New(),
ChannelInstanceID: instID,
AgentID: uuid.New(),
UserID: "user-1",
SourceID: "channel:item-1",
Status: store.ChannelMemoryItemPendingReview,
Summary: "Project Orion uses ExampleCo for collaboration.",
Topics: []byte(`["collaboration","planning"]`),
Entities: []byte(`["Project Orion","ExampleCo"]`),
},
}}
episodic := &fakeEpisodicStore{}
svc := &Service{
Extractions: extractions,
Episodic: episodic,
EventBus: eventBus,
Channels: &fakeChannelStore{inst: &store.ChannelInstanceData{
BaseModel: store.BaseModel{ID: instID},
Config: MergeIntoInstanceConfig(nil, DefaultConfig()),
}},
}
if _, err := svc.Approve(context.Background(), itemID, "admin"); err != nil {
t.Fatal(err)
}
if len(episodic.created) != 1 {
t.Fatalf("created episodic count = %d, want 1", len(episodic.created))
}
if got := episodic.created[0].KeyTopics; strings.Join(got, ",") != "collaboration,planning" {
t.Fatalf("episodic key_topics = %#v", got)
}
if len(eventBus.published) != 1 {
t.Fatalf("published events = %d, want 1", len(eventBus.published))
}
payload, ok := eventBus.published[0].Payload.(*eventbus.EpisodicCreatedPayload)
if !ok {
t.Fatalf("published payload type = %T", eventBus.published[0].Payload)
}
if got := strings.Join(payload.KeyTopics, ","); got != "collaboration,planning" {
t.Fatalf("payload KeyTopics = %q", got)
}
if got := strings.Join(payload.KeyEntities, ","); got != "Project Orion,ExampleCo" {
t.Fatalf("payload KeyEntities = %q", got)
}
}
func TestApproveExistingSourceWritesExistingEpisodicID(t *testing.T) {
itemID := uuid.New()
existingID := uuid.New()
@@ -0,0 +1,193 @@
package discord
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/bwmarrin/discordgo"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const contactRefreshInterval = time.Hour
// RefreshContactCache forces a Discord metadata refresh into channel_contacts.
func (c *Channel) RefreshContactCache(ctx context.Context) error {
if c == nil || c.session == nil {
return fmt.Errorf("discord session unavailable")
}
if c.ContactCollector() == nil {
return fmt.Errorf("contact collector unavailable")
}
c.refreshContactCache(ctx)
return nil
}
func (c *Channel) runContactRefreshLoop(ctx context.Context) {
if c == nil || c.session == nil || c.ContactCollector() == nil {
return
}
c.refreshContactCache(ctx)
ticker := time.NewTicker(contactRefreshInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.refreshContactCache(ctx)
}
}
}
func (c *Channel) refreshContactCache(ctx context.Context) {
if c == nil || c.session == nil {
return
}
cc := c.ContactCollector()
if cc == nil {
return
}
cacheCtx := ctx
if tenantID := c.TenantID(); tenantID != uuid.Nil {
cacheCtx = store.WithTenantID(ctx, tenantID)
}
count := 0
seen := make(map[string]struct{})
userCount := 0
seenUsers := make(map[string]struct{})
upsert := func(ch *discordgo.Channel) {
if ch == nil || ch.ID == "" {
return
}
if _, ok := seen[ch.ID]; ok {
return
}
title := channels.SanitizeDisplayName(ch.Name)
if title == "" {
return
}
seen[ch.ID] = struct{}{}
cc.RefreshContact(cacheCtx, c.Type(), c.Name(), ch.ID, "", title, "", "group", "group", "", "")
count++
}
upsertMember := func(member *discordgo.Member) {
if member == nil || member.User == nil || member.User.ID == "" {
return
}
if _, ok := seenUsers[member.User.ID]; ok {
return
}
displayName := discordDisplayName(member.User, member)
handle := discordHandle(member.User)
if displayName == "" && handle == "" {
return
}
seenUsers[member.User.ID] = struct{}{}
cc.RefreshContact(cacheCtx, c.Type(), c.Name(), member.User.ID, member.User.ID, displayName, handle, "group", "user", "", "")
userCount++
}
for _, ch := range c.stateChannels() {
upsert(ch)
}
for _, member := range c.stateMembers() {
upsertMember(member)
}
for _, guildID := range c.guildIDs() {
select {
case <-ctx.Done():
return
default:
}
lookupCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
guildChannels, err := c.session.GuildChannels(guildID, discordgo.WithContext(lookupCtx))
cancel()
if err != nil {
slog.Debug("discord contact cache channel sync failed", "channel", c.Name(), "guild_id", guildID, "error", err)
continue
}
for _, ch := range guildChannels {
upsert(ch)
}
threadCtx, threadCancel := context.WithTimeout(ctx, 10*time.Second)
activeThreads, err := c.session.GuildThreadsActive(guildID, discordgo.WithContext(threadCtx))
threadCancel()
if err != nil {
slog.Debug("discord contact cache thread sync failed", "channel", c.Name(), "guild_id", guildID, "error", err)
continue
}
if activeThreads != nil {
for _, ch := range activeThreads.Threads {
upsert(ch)
}
}
}
if count > 0 || userCount > 0 {
slog.Debug("discord contact cache refreshed", "channel", c.Name(), "groups", count, "users", userCount)
}
}
func (c *Channel) guildIDs() []string {
if c == nil || c.session == nil || c.session.State == nil {
return nil
}
c.session.State.RLock()
defer c.session.State.RUnlock()
ids := make([]string, 0, len(c.session.State.Guilds))
seen := make(map[string]struct{}, len(c.session.State.Guilds))
for _, guild := range c.session.State.Guilds {
if guild == nil || guild.ID == "" {
continue
}
if _, ok := seen[guild.ID]; ok {
continue
}
seen[guild.ID] = struct{}{}
ids = append(ids, guild.ID)
}
return ids
}
func (c *Channel) stateChannels() []*discordgo.Channel {
if c == nil || c.session == nil || c.session.State == nil {
return nil
}
c.session.State.RLock()
defer c.session.State.RUnlock()
var out []*discordgo.Channel
for _, guild := range c.session.State.Guilds {
if guild == nil {
continue
}
out = append(out, guild.Channels...)
out = append(out, guild.Threads...)
}
return out
}
func (c *Channel) stateMembers() []*discordgo.Member {
if c == nil || c.session == nil || c.session.State == nil {
return nil
}
c.session.State.RLock()
defer c.session.State.RUnlock()
var out []*discordgo.Member
for _, guild := range c.session.State.Guilds {
if guild == nil {
continue
}
out = append(out, guild.Members...)
}
return out
}
@@ -0,0 +1,125 @@
package discord
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/bwmarrin/discordgo"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type contactRefreshStore struct {
contacts map[string]string
}
func (s *contactRefreshStore) UpsertContact(_ context.Context, _ string, channelInstance string, senderID string, _ string, displayName string, _ string, _ string, contactType string, _ string, _ string) error {
if s.contacts == nil {
s.contacts = make(map[string]string)
}
s.contacts[channelInstance+":"+contactType+":"+senderID] = displayName
return nil
}
func (s *contactRefreshStore) ListContacts(context.Context, store.ContactListOpts) ([]store.ChannelContact, error) {
return nil, nil
}
func (s *contactRefreshStore) CountContacts(context.Context, store.ContactListOpts) (int, error) {
return 0, nil
}
func (s *contactRefreshStore) GetContactsBySenderIDs(context.Context, []string) (map[string]store.ChannelContact, error) {
return nil, nil
}
func (s *contactRefreshStore) GetContactByID(context.Context, uuid.UUID) (*store.ChannelContact, error) {
return nil, nil
}
func (s *contactRefreshStore) GetSenderIDsByContactIDs(context.Context, []uuid.UUID) ([]string, error) {
return nil, nil
}
func (s *contactRefreshStore) MergeContacts(context.Context, []uuid.UUID, uuid.UUID) error {
return nil
}
func (s *contactRefreshStore) UnmergeContacts(context.Context, []uuid.UUID) error {
return nil
}
func (s *contactRefreshStore) GetContactsByMergedID(context.Context, uuid.UUID) ([]store.ChannelContact, error) {
return nil, nil
}
func (s *contactRefreshStore) ResolveTenantUserID(context.Context, string, string) (string, error) {
return "", nil
}
func TestRefreshContactCacheStoresDiscordChannelTitles(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/guilds/guild-1/channels":
_, _ = w.Write([]byte(`[
{"id":"parent-1","name":"product-planning","type":0},
{"id":"category-1","name":"operations","type":4}
]`))
case "/guilds/guild-1/threads/active":
_, _ = w.Write([]byte(`{"threads":[{"id":"thread-1","name":"launch-thread","type":11}],"members":[],"has_more":false}`))
default:
t.Fatalf("unexpected Discord API path: %s", r.URL.Path)
}
}))
defer server.Close()
prevGuildChannels := discordgo.EndpointGuildChannels
prevGuildActiveThreads := discordgo.EndpointGuildActiveThreads
discordgo.EndpointGuildChannels = func(gID string) string { return server.URL + "/guilds/" + gID + "/channels" }
discordgo.EndpointGuildActiveThreads = func(gID string) string { return server.URL + "/guilds/" + gID + "/threads/active" }
t.Cleanup(func() {
discordgo.EndpointGuildChannels = prevGuildChannels
discordgo.EndpointGuildActiveThreads = prevGuildActiveThreads
})
session, err := discordgo.New("Bot test-token")
if err != nil {
t.Fatalf("discordgo.New: %v", err)
}
session.Client = server.Client()
session.State = discordgo.NewState()
if err := session.State.GuildAdd(&discordgo.Guild{
ID: "guild-1",
Channels: []*discordgo.Channel{
{ID: "state-channel", Name: "support", Type: discordgo.ChannelTypeGuildText},
},
Members: []*discordgo.Member{
{Nick: "Casey", User: &discordgo.User{ID: "user-1", Username: "casey.dev", GlobalName: "Casey Dev"}},
},
}); err != nil {
t.Fatalf("GuildAdd: %v", err)
}
contactStore := &contactRefreshStore{}
ch := &Channel{
BaseChannel: channels.NewBaseChannel(channels.TypeDiscord, bus.New(), nil),
session: session,
}
ch.SetName("discord-main")
ch.SetType(channels.TypeDiscord)
ch.SetContactCollector(store.NewContactCollector(contactStore, cache.NewInMemoryCache[bool]()))
ch.refreshContactCache(context.Background())
want := map[string]string{
"discord-main:group:state-channel": "support",
"discord-main:group:parent-1": "product-planning",
"discord-main:group:category-1": "operations",
"discord-main:group:thread-1": "launch-thread",
"discord-main:user:user-1": "Casey",
}
for key, title := range want {
if contactStore.contacts[key] != title {
t.Fatalf("contact %s = %q, want %q; all=%#v", key, contactStore.contacts[key], title, contactStore.contacts)
}
}
}
@@ -0,0 +1,77 @@
package discord
import (
"context"
"log/slog"
"time"
"github.com/bwmarrin/discordgo"
"github.com/nextlevelbuilder/goclaw/internal/channelmemory"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (c *Channel) ResolveMemoryExtractionContext(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error) {
out := channelmemory.ExtractionContext{
Platform: channels.TypeDiscord,
ChannelInstance: group.ChannelName,
HistoryKey: group.HistoryKey,
ChannelID: group.HistoryKey,
ParentChannelID: group.ParentHistoryKey,
}
if inst != nil && inst.Name != "" {
out.ChannelInstance = inst.Name
}
ch := c.resolveDiscordChannel(ctx, group.HistoryKey)
if ch == nil {
return out, nil
}
out.ChannelID = ch.ID
out.ChannelName = channels.SanitizeDisplayName(ch.Name)
parentID := group.ParentHistoryKey
if parentID == "" && ch.IsThread() {
parentID = ch.ParentID
}
if parentID != "" {
out.ParentChannelID = parentID
parent := c.resolveDiscordChannel(ctx, parentID)
if parent != nil {
out.ParentChannelName = channels.SanitizeDisplayName(parent.Name)
if parent.ParentID != "" {
out.CategoryID = parent.ParentID
if category := c.resolveDiscordChannel(ctx, parent.ParentID); category != nil {
out.CategoryName = channels.SanitizeDisplayName(category.Name)
}
}
}
}
if out.CategoryID == "" && ch.ParentID != "" && !ch.IsThread() {
category := c.resolveDiscordChannel(ctx, ch.ParentID)
if category != nil && category.Type == discordgo.ChannelTypeGuildCategory {
out.CategoryID = category.ID
out.CategoryName = channels.SanitizeDisplayName(category.Name)
}
}
return out, nil
}
func (c *Channel) resolveDiscordChannel(ctx context.Context, channelID string) *discordgo.Channel {
if c == nil || c.session == nil || channelID == "" {
return nil
}
if c.session.State != nil {
if ch, err := c.session.State.Channel(channelID); err == nil && ch != nil {
return ch
}
}
lookupCtx, cancel := context.WithTimeout(ctx, 750*time.Millisecond)
defer cancel()
ch, err := c.session.Channel(channelID, discordgo.WithContext(lookupCtx))
if err != nil {
slog.Debug("discord: extraction context channel lookup failed", "channel_id", channelID, "error", err)
return nil
}
return ch
}
+17 -9
View File
@@ -24,14 +24,15 @@ const pairingDebounceTime = 60 * time.Second
// 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 // placeholderKey string → messageID string
typingCtrls sync.Map // channelID string → *typing.Controller
agentStore store.AgentStore // for agent key lookup (nil = writer commands disabled)
configPermStore store.ConfigPermissionStore // for group file writer management (nil = writer commands disabled)
audioMgr *audio.Manager // unified STT via audio.Manager (nil = no STT)
session *discordgo.Session
config config.DiscordConfig
botUserID string // populated on start
placeholders sync.Map // placeholderKey string → messageID string
typingCtrls sync.Map // channelID string → *typing.Controller
agentStore store.AgentStore // for agent key lookup (nil = writer commands disabled)
configPermStore store.ConfigPermissionStore // for group file writer management (nil = writer commands disabled)
audioMgr *audio.Manager // unified STT via audio.Manager (nil = no STT)
contactRefreshCancel context.CancelFunc
// pairingService, pairingDebounce, approvedGroups, groupHistory, historyLimit, requireMention
// are inherited from channels.BaseChannel.
}
@@ -81,7 +82,7 @@ func New(cfg config.DiscordConfig, msgBus *bus.MessageBus, pairingSvc store.Pair
}
// Start opens the Discord gateway connection and begins receiving events.
func (c *Channel) Start(_ context.Context) error {
func (c *Channel) Start(ctx context.Context) error {
c.GroupHistory().StartFlusher()
slog.Info("starting discord bot")
@@ -101,6 +102,9 @@ func (c *Channel) Start(_ context.Context) error {
c.SetRunning(true)
slog.Info("discord bot connected", "username", user.Username, "id", user.ID)
refreshCtx, cancel := context.WithCancel(ctx)
c.contactRefreshCancel = cancel
go c.runContactRefreshLoop(refreshCtx)
return nil
}
@@ -127,6 +131,10 @@ func (c *Channel) SetPendingHistoryTenantID(id uuid.UUID) {
// Stop closes the Discord gateway connection.
func (c *Channel) Stop(_ context.Context) error {
if c.contactRefreshCancel != nil {
c.contactRefreshCancel()
c.contactRefreshCancel = nil
}
c.GroupHistory().StopFlusher()
slog.Info("stopping discord bot")
c.SetRunning(false)
+100 -21
View File
@@ -35,7 +35,8 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
}
senderID := m.Author.ID
senderName := resolveDisplayName(m)
displayName := resolveDisplayName(m)
authorLabel := discordAuthorLabel(m.Author, m.Member)
channelID := m.ChannelID
isDM := m.GuildID == ""
@@ -71,7 +72,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
if !c.checkGroupPolicy(ctx, senderID, channelID, mentioned) {
slog.Debug("discord group message rejected by policy",
"user_id", senderID,
"username", senderName,
"username", displayName,
)
return
}
@@ -89,7 +90,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
if m.ReferencedMessage != nil {
author := "unknown"
if m.ReferencedMessage.Author != nil {
author = m.ReferencedMessage.Author.Username
author = discordAuthorLabel(m.ReferencedMessage.Author, m.ReferencedMessage.Member)
}
body := channels.Truncate(m.ReferencedMessage.Content, 500)
replyCtx := fmt.Sprintf("[Replying to %s]\n%s\n[/Replying]", author, body)
@@ -198,7 +199,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
}
parentHistoryKey := c.parentHistoryKeyForChannel(ctx, channelID)
c.GroupHistory().Record(channelID, channels.HistoryEntry{
Sender: senderName,
Sender: authorLabel,
SenderID: senderID,
Body: content,
ParentHistoryKey: parentHistoryKey,
@@ -209,14 +210,14 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
// Collect contact even when bot is not mentioned (cache prevents DB spam).
if cc := c.ContactCollector(); cc != nil {
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, "group", "user", "", "")
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, displayName, m.Author.Username, "group", "user", "", "")
cc.EnsureContact(ctx, c.Type(), c.Name(), channelID, "", c.resolveCachedChannelTitle(channelID), "", "group", "group", "", "")
}
slog.Debug("discord group message recorded (no mention)",
"channel_id", channelID,
"user_id", senderID,
"username", senderName,
"username", displayName,
)
return
}
@@ -270,7 +271,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
// Build final content with group context.
finalContent := content
if peerKind == "group" {
annotated := fmt.Sprintf("[From: %s (<@%s>)]\n%s", senderName, senderID, content)
annotated := fmt.Sprintf("[From: %s (<@%s>)]\n%s", displayName, senderID, content)
if threadBackfill.Context != "" {
annotated = threadBackfill.Context + "\n\n" + annotated
}
@@ -294,7 +295,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
"message_id": m.ID,
"user_id": senderID,
"username": m.Author.Username,
"display_name": channels.SanitizeDisplayName(senderName),
"display_name": channels.SanitizeDisplayName(displayName),
"guild_id": m.GuildID,
"channel_id": channelID,
"is_dm": fmt.Sprintf("%t", isDM),
@@ -310,7 +311,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
// Collect contact for processed messages (DM + group-mentioned).
if cc := c.ContactCollector(); cc != nil {
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, peerKind, "user", "", "")
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, displayName, m.Author.Username, peerKind, "user", "", "")
if peerKind == "group" {
cc.EnsureContact(ctx, c.Type(), c.Name(), channelID, "", c.resolveCachedChannelTitle(channelID), "", "group", "group", "", "")
}
@@ -421,13 +422,68 @@ func (c *Channel) sendPairingReply(ctx context.Context, senderID, channelID stri
// resolveDisplayName returns the best available display name for a Discord message author.
// Priority: server nickname > global display name > username.
func resolveDisplayName(m *discordgo.MessageCreate) string {
if m.Member != nil && m.Member.Nick != "" {
return m.Member.Nick
if m == nil {
return ""
}
if m.Author.GlobalName != "" {
return m.Author.GlobalName
return discordDisplayName(m.Author, m.Member)
}
func discordAuthorLabel(user *discordgo.User, member *discordgo.Member) string {
display := discordDisplayName(user, member)
if display == "" {
display = "unknown"
}
return m.Author.Username
handle := discordHandle(user)
if handle == "" && user != nil {
handle = user.Username
}
if user == nil || user.ID == "" {
if handle == "" {
return display
}
return fmt.Sprintf("%s (@%s)", display, handle)
}
if handle == "" {
return fmt.Sprintf("%s (id: %s)", display, user.ID)
}
return fmt.Sprintf("%s (@%s, id: %s)", display, handle, user.ID)
}
func discordDisplayName(user *discordgo.User, member *discordgo.Member) string {
if member != nil {
if member.Nick != "" {
return member.Nick
}
if member.User != nil {
if member.User.GlobalName != "" {
return member.User.GlobalName
}
if member.User.Username != "" {
return member.User.Username
}
}
}
if user == nil {
return ""
}
if user.GlobalName != "" {
return user.GlobalName
}
return user.Username
}
func discordHandle(user *discordgo.User) string {
if user == nil {
return ""
}
handle := strings.TrimSpace(user.Username)
if handle == "" {
return ""
}
if user.Discriminator != "" && user.Discriminator != "0" {
return handle + "#" + user.Discriminator
}
return handle
}
func (c *Channel) resolveCachedChannelTitle(channelID string) string {
@@ -464,18 +520,21 @@ func (c *Channel) ResolveGroupTitles(ctx context.Context, channelIDs []string) (
}
remaining[id] = struct{}{}
}
if len(remaining) == 0 || c.session.State == nil {
if len(remaining) == 0 {
return titles, nil
}
c.session.State.RLock()
guildIDs := make([]string, 0, len(c.session.State.Guilds))
for _, guild := range c.session.State.Guilds {
if guild != nil && guild.ID != "" {
guildIDs = append(guildIDs, guild.ID)
var guildIDs []string
if c.session.State != nil {
c.session.State.RLock()
guildIDs = make([]string, 0, len(c.session.State.Guilds))
for _, guild := range c.session.State.Guilds {
if guild != nil && guild.ID != "" {
guildIDs = append(guildIDs, guild.ID)
}
}
c.session.State.RUnlock()
}
c.session.State.RUnlock()
for _, guildID := range guildIDs {
select {
@@ -506,5 +565,25 @@ func (c *Channel) ResolveGroupTitles(ctx context.Context, channelIDs []string) (
break
}
}
for id := range remaining {
select {
case <-ctx.Done():
return titles, nil
default:
}
lookupCtx, cancel := context.WithTimeout(ctx, 750*time.Millisecond)
ch, err := c.session.Channel(id, discordgo.WithContext(lookupCtx))
cancel()
if err != nil {
slog.Debug("discord channel title direct lookup failed", "channel_id", id, "error", err)
continue
}
if ch == nil {
continue
}
if title := channels.SanitizeDisplayName(ch.Name); title != "" {
titles[id] = title
}
}
return titles, nil
}
+64
View File
@@ -10,9 +10,11 @@ import (
"github.com/bwmarrin/discordgo"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channelmemory"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// --- resolveDisplayName ---
@@ -51,6 +53,22 @@ func TestResolveDisplayName(t *testing.T) {
}
}
func TestDiscordAuthorLabelIncludesHandleAndID(t *testing.T) {
m := &discordgo.MessageCreate{Message: &discordgo.Message{
Author: &discordgo.User{
ID: "u123",
Username: "alexproducer",
GlobalName: "Alex Producer",
},
}}
got := discordAuthorLabel(m.Author, m.Member)
want := "Alex Producer (@alexproducer, id: u123)"
if got != want {
t.Fatalf("discordAuthorLabel() = %q, want %q", got, want)
}
}
func TestResolveCachedChannelTitle(t *testing.T) {
session, err := discordgo.New("Bot test-token")
if err != nil {
@@ -73,6 +91,52 @@ func TestResolveCachedChannelTitle(t *testing.T) {
}
}
func TestResolveMemoryExtractionContextIncludesThreadParentAndCategory(t *testing.T) {
session, err := discordgo.New("Bot test-token")
if err != nil {
t.Fatalf("discordgo.New() error = %v", err)
}
session.State = discordgo.NewState()
if err := session.State.GuildAdd(&discordgo.Guild{ID: "guild-1"}); err != nil {
t.Fatalf("GuildAdd() error = %v", err)
}
for _, ch := range []*discordgo.Channel{
{ID: "cat-1", GuildID: "guild-1", Name: "Launch", Type: discordgo.ChannelTypeGuildCategory},
{ID: "parent-1", GuildID: "guild-1", Name: "design", ParentID: "cat-1", Type: discordgo.ChannelTypeGuildText},
{ID: "thread-1", GuildID: "guild-1", Name: "project-thread", ParentID: "parent-1", Type: discordgo.ChannelTypeGuildPublicThread},
} {
if err := session.State.ChannelAdd(ch); err != nil {
t.Fatalf("ChannelAdd(%s) error = %v", ch.ID, err)
}
}
ch := &Channel{BaseChannel: channels.NewBaseChannel(channels.TypeDiscord, nil, nil), session: session}
ch.SetName("discord-main")
ctx, err := ch.ResolveMemoryExtractionContext(context.Background(), &store.ChannelInstanceData{Name: "discord-main"}, store.PendingMessageGroup{
ChannelName: "discord-main",
HistoryKey: "thread-1",
ParentHistoryKey: "parent-1",
})
if err != nil {
t.Fatalf("ResolveMemoryExtractionContext() error = %v", err)
}
want := channelmemory.ExtractionContext{
Platform: channels.TypeDiscord,
ChannelInstance: "discord-main",
HistoryKey: "thread-1",
ChannelID: "thread-1",
ChannelName: "project-thread",
ParentChannelID: "parent-1",
ParentChannelName: "design",
CategoryID: "cat-1",
CategoryName: "Launch",
}
if ctx != want {
t.Fatalf("context = %+v, want %+v", ctx, want)
}
}
func TestTargetAgentIDUsesConfiguredChannelAgent(t *testing.T) {
ch := &Channel{BaseChannel: channels.NewBaseChannel(channels.TypeDiscord, nil, nil)}
ch.SetAgentID("co-assistant")
@@ -122,6 +122,7 @@ func (w *episodicWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
EpisodicID: ep.ID.String(),
SessionKey: payload.SessionKey,
Summary: summary,
KeyTopics: entities,
KeyEntities: entities,
},
})
+4 -2
View File
@@ -22,9 +22,11 @@ func testRegistry(p providers.Provider) *providers.Registry {
type mockExtractor struct {
result *knowledgegraph.ExtractionResult
err error
inputs []string
}
func (m *mockExtractor) Extract(_ context.Context, _ string) (*knowledgegraph.ExtractionResult, error) {
func (m *mockExtractor) Extract(_ context.Context, text string) (*knowledgegraph.ExtractionResult, error) {
m.inputs = append(m.inputs, text)
return m.result, m.err
}
@@ -43,4 +45,4 @@ func (m *mockProvider) ChatStream(_ context.Context, _ providers.ChatRequest, _
}
func (m *mockProvider) Name() string { return "mock" }
func (m *mockProvider) DefaultModel() string { return "mock-model" }
func (m *mockProvider) DefaultModel() string { return "mock-model" }
+59 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
@@ -34,12 +35,13 @@ func (w *semanticWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
}
}
if w.extractor == nil || payload.Summary == "" {
extractionInput := formatSemanticExtractionInput(payload)
if w.extractor == nil || extractionInput == "" {
return nil
}
// Extract entities/relations from summary (much cheaper than full session)
result, err := w.extractor.Extract(ctx, payload.Summary)
result, err := w.extractor.Extract(ctx, extractionInput)
if err != nil {
bgalert.ReportProviderError(ctx, w.alertDeps, "kg_extraction", err)
slog.Warn("semantic: extraction failed", "episodic_id", payload.EpisodicID, "err", err)
@@ -86,3 +88,58 @@ func (w *semanticWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
"relations", len(result.Relations), "episodic_id", payload.EpisodicID)
return nil
}
func formatSemanticExtractionInput(payload *eventbus.EpisodicCreatedPayload) string {
if payload == nil {
return ""
}
summary := strings.TrimSpace(payload.Summary)
if summary == "" {
return ""
}
topics := compactSemanticHints(payload.KeyTopics)
entities := compactSemanticHints(payload.KeyEntities)
if len(topics) == 0 && len(entities) == 0 {
return summary
}
var b strings.Builder
b.WriteString("Fact summary:\n")
b.WriteString(summary)
b.WriteString("\n\nContext hints for disambiguation only. Do not create entities or relations solely because a value appears below; only extract facts supported by the fact summary.")
if len(entities) > 0 {
b.WriteString("\n\nCandidate entities:\n")
for _, entity := range entities {
b.WriteString("- ")
b.WriteString(entity)
b.WriteByte('\n')
}
}
if len(topics) > 0 {
b.WriteString("\nTopics / context tags:\n")
for _, topic := range topics {
b.WriteString("- ")
b.WriteString(topic)
b.WriteByte('\n')
}
}
return strings.TrimSpace(b.String())
}
func compactSemanticHints(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
key := strings.ToLower(value)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, value)
}
return out
}
+45
View File
@@ -593,6 +593,51 @@ func TestSemanticWorkerHandle_WithValidExtraction(t *testing.T) {
}
}
func TestSemanticWorkerHandle_PassesTopicsAndEntitiesAsContextHints(t *testing.T) {
mockExtractor := &mockExtractor{result: &knowledgegraph.ExtractionResult{}}
worker := &semanticWorker{
kgStore: &mockKGStore{},
extractor: mockExtractor,
eventBus: newMockDomainEventBus(),
}
err := worker.Handle(context.Background(), eventbus.DomainEvent{
Type: eventbus.EventEpisodicCreated,
TenantID: uuid.New().String(),
AgentID: uuid.New().String(),
UserID: "test-user",
Payload: &eventbus.EpisodicCreatedPayload{
EpisodicID: "ep-123",
Summary: "Project Orion uses ExampleCo for collaboration.",
KeyEntities: []string{"Project Orion", "ExampleCo"},
KeyTopics: []string{"collaboration", "planning"},
},
})
if err != nil {
t.Fatalf("Handle failed: %v", err)
}
if len(mockExtractor.inputs) != 1 {
t.Fatalf("extractor calls = %d, want 1", len(mockExtractor.inputs))
}
input := mockExtractor.inputs[0]
for _, want := range []string{
"Fact summary:",
"Project Orion uses ExampleCo for collaboration.",
"Context hints for disambiguation only",
"Do not create entities or relations solely because a value appears below",
"Candidate entities:",
"- Project Orion",
"- ExampleCo",
"Topics / context tags:",
"- collaboration",
"- planning",
} {
if !strings.Contains(input, want) {
t.Fatalf("semantic extraction input missing %q:\n%s", want, input)
}
}
}
func TestSemanticWorkerHandle_SuccessClearsBackgroundProviderAlert(t *testing.T) {
mockKG := &mockKGStore{}
mockExtractor := &mockExtractor{
+5 -4
View File
@@ -12,8 +12,8 @@ type EventType string
const (
EventSessionCompleted EventType = "session.completed"
EventEpisodicCreated EventType = "episodic.created"
EventEntityUpserted EventType = "entity.upserted"
EventRunCompleted EventType = "run.completed"
EventEntityUpserted EventType = "entity.upserted"
EventRunCompleted EventType = "run.completed"
EventToolExecuted EventType = "tool.executed"
// Context pruning observability (Phase 05)
@@ -69,6 +69,7 @@ type EpisodicCreatedPayload struct {
EpisodicID string
SessionKey string
Summary string
KeyTopics []string
KeyEntities []string
}
@@ -127,8 +128,8 @@ type ContextPrunedPayload struct {
TokensBefore int
TokensAfter int
Budget int
ResultsTrimmed int // soft-trimmed count
ResultsCleared int // hard-cleared count
ResultsTrimmed int // soft-trimmed count
ResultsCleared int // hard-cleared count
Compacted bool
Trigger string // "soft" | "hard" | "compact"
}
@@ -0,0 +1,51 @@
package http
import (
"context"
"net/http"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
type channelMetadataRefresher interface {
RefreshContactCache(ctx context.Context) error
}
func (h *ChannelInstancesHandler) handleRefreshChannelMetadata(w http.ResponseWriter, r *http.Request) {
if !requireTenantAdmin(w, r, h.tenantStore) {
return
}
inst, ok := h.resolveInstance(w, r)
if !ok {
return
}
if inst.ChannelType != channels.TypeDiscord {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, "metadata refresh is only supported for Discord channels")
return
}
if h.channelMgr == nil {
writeError(w, http.StatusConflict, protocol.ErrInvalidRequest, "channel manager is not available")
return
}
ch, ok := h.channelMgr.GetChannel(inst.Name)
if !ok {
writeError(w, http.StatusConflict, protocol.ErrInvalidRequest, "Discord channel is not running")
return
}
if !ch.IsRunning() {
writeError(w, http.StatusConflict, protocol.ErrInvalidRequest, "Discord channel is not running")
return
}
refresher, ok := ch.(channelMetadataRefresher)
if !ok {
writeError(w, http.StatusConflict, protocol.ErrInvalidRequest, "Discord channel metadata refresh is not available")
return
}
if err := refresher.RefreshContactCache(r.Context()); err != nil {
writeError(w, http.StatusConflict, protocol.ErrInvalidRequest, err.Error())
return
}
emitAudit(h.msgBus, r, "channel_metadata.refresh_triggered", "channel_instance", inst.ID.String())
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -0,0 +1,87 @@
package http
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type metadataRefreshChannel struct {
*channels.BaseChannel
called bool
}
func (c *metadataRefreshChannel) Start(context.Context) error { return nil }
func (c *metadataRefreshChannel) Stop(context.Context) error { return nil }
func (c *metadataRefreshChannel) Send(context.Context, bus.OutboundMessage) error {
return nil
}
func (c *metadataRefreshChannel) IsRunning() bool { return true }
func (c *metadataRefreshChannel) IsAllowed(string) bool {
return true
}
func (c *metadataRefreshChannel) RefreshContactCache(context.Context) error {
c.called = true
return nil
}
func TestRefreshChannelMetadataCallsDiscordRefresher(t *testing.T) {
instID := uuid.New()
channelName := "discord-main"
handler := NewChannelInstancesHandler(
&stubChannelInstanceStore{inst: &store.ChannelInstanceData{
BaseModel: store.BaseModel{ID: instID},
Name: channelName,
ChannelType: channels.TypeDiscord,
}},
nil, nil, nil, nil, nil,
)
manager := channels.NewManager(bus.New())
ch := &metadataRefreshChannel{BaseChannel: channels.NewBaseChannel(channels.TypeDiscord, nil, nil)}
ch.SetName(channelName)
ch.SetType(channels.TypeDiscord)
manager.RegisterChannel(channelName, ch)
handler.SetChannelManager(manager)
req := httptest.NewRequest(http.MethodPost, "/v1/channels/instances/"+instID.String()+"/metadata/refresh", nil)
req = req.WithContext(store.WithRole(req.Context(), store.RoleOwner))
req.SetPathValue("id", instID.String())
rec := httptest.NewRecorder()
handler.handleRefreshChannelMetadata(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !ch.called {
t.Fatal("RefreshContactCache was not called")
}
}
func TestRefreshChannelMetadataRejectsNonDiscord(t *testing.T) {
instID := uuid.New()
handler := NewChannelInstancesHandler(
&stubChannelInstanceStore{inst: &store.ChannelInstanceData{
BaseModel: store.BaseModel{ID: instID},
Name: "telegram-main",
ChannelType: channels.TypeTelegram,
}},
nil, nil, nil, nil, nil,
)
req := httptest.NewRequest(http.MethodPost, "/v1/channels/instances/"+instID.String()+"/metadata/refresh", nil)
req = req.WithContext(store.WithRole(req.Context(), store.RoleOwner))
req.SetPathValue("id", instID.String())
rec := httptest.NewRecorder()
handler.handleRefreshChannelMetadata(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
+25
View File
@@ -69,6 +69,30 @@ func (h *ChannelInstancesHandler) SetMemberResolver(r channels.MemberResolver) {
// in cmd/gateway.go's startup ordering.
func (h *ChannelInstancesHandler) SetChannelManager(mgr *channels.Manager) {
h.channelMgr = mgr
if h.memoryService != nil {
h.memoryService.ContextResolver = channelmemory.ContextResolverFunc(func(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error) {
return resolveMemoryExtractionContextFromManager(ctx, mgr, inst, group)
})
}
}
type memoryExtractionContextProvider interface {
ResolveMemoryExtractionContext(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error)
}
func resolveMemoryExtractionContextFromManager(ctx context.Context, mgr *channels.Manager, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error) {
if mgr == nil || inst == nil || inst.Name == "" {
return channelmemory.ExtractionContext{}, nil
}
ch, ok := mgr.GetChannel(inst.Name)
if !ok {
return channelmemory.ExtractionContext{}, nil
}
provider, ok := ch.(memoryExtractionContextProvider)
if !ok {
return channelmemory.ExtractionContext{}, nil
}
return provider.ResolveMemoryExtractionContext(ctx, inst, group)
}
// SetCapabilityStores wires MCP and Secure CLI stores for channel-context
@@ -103,6 +127,7 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/channels/instances/{id}", h.auth(h.handleGet))
mux.HandleFunc("PUT /v1/channels/instances/{id}", h.adminAuth(h.handleUpdate))
mux.HandleFunc("DELETE /v1/channels/instances/{id}", h.adminAuth(h.handleDelete))
mux.HandleFunc("POST /v1/channels/instances/{id}/metadata/refresh", h.adminAuth(h.handleRefreshChannelMetadata))
// Channel contacts (global, not per-agent)
if h.contactStore != nil {
+1 -20
View File
@@ -73,13 +73,12 @@ func (h *ChannelInstancesHandler) handleMemoryExtractionSettings(w http.Response
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))
return
}
normalized, err := channelmemory.ApplyConfigPatch(channelmemory.ParseConfig(inst.Config), body)
configJSON, normalized, err := channelmemory.ApplyInstanceConfigPatch(inst.Config, body)
if err != nil {
locale := store.LocaleFromContext(r.Context())
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))
return
}
configJSON := channelmemory.MergeIntoInstanceConfig(inst.Config, normalized)
if err := h.store.Update(r.Context(), inst.ID, map[string]any{"config": configJSON}); err != nil {
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, "failed to update memory extraction settings")
return
@@ -103,24 +102,6 @@ func (h *ChannelInstancesHandler) handleMemoryExtractionGroups(w http.ResponseWr
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, "failed to list memory extraction groups")
return
}
if h.channelMgr != nil {
historyKeys := make([]string, 0, len(groups))
for i := range groups {
if groups[i].HistoryKey != "" {
historyKeys = append(historyKeys, groups[i].HistoryKey)
}
}
titles, err := h.channelMgr.ResolveGroupTitles(r.Context(), inst.Name, historyKeys)
if err == nil {
for i := range groups {
title := titles[groups[i].HistoryKey]
if title == "" {
continue
}
groups[i].GroupTitle = title
}
}
}
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
}
@@ -0,0 +1,140 @@
package http
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channelmemory"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type memoryExtractionPendingStore struct {
groups []store.PendingMessageGroup
titles map[string]string
}
func (s *memoryExtractionPendingStore) AppendBatch(context.Context, []store.PendingMessage) error {
return nil
}
func (s *memoryExtractionPendingStore) ListByKey(context.Context, string, string) ([]store.PendingMessage, error) {
return nil, nil
}
func (s *memoryExtractionPendingStore) DeleteByKey(context.Context, string, string) error {
return nil
}
func (s *memoryExtractionPendingStore) Compact(context.Context, []uuid.UUID, *store.PendingMessage) error {
return nil
}
func (s *memoryExtractionPendingStore) DeleteStale(context.Context, time.Duration) (int64, error) {
return 0, nil
}
func (s *memoryExtractionPendingStore) ListGroups(context.Context) ([]store.PendingMessageGroup, error) {
return s.groups, nil
}
func (s *memoryExtractionPendingStore) CountAll(context.Context) (int64, error) {
return 0, nil
}
func (s *memoryExtractionPendingStore) CountByKey(context.Context, string, string) (int, error) {
return 0, nil
}
func (s *memoryExtractionPendingStore) ResolveGroupTitles(context.Context, []store.PendingMessageGroup) (map[string]string, error) {
return s.titles, nil
}
type liveTitleTrapChannel struct {
*channels.BaseChannel
called bool
}
func (c *liveTitleTrapChannel) Start(context.Context) error { return nil }
func (c *liveTitleTrapChannel) Stop(context.Context) error { return nil }
func (c *liveTitleTrapChannel) Send(context.Context, bus.OutboundMessage) error {
return nil
}
func (c *liveTitleTrapChannel) IsRunning() bool { return true }
func (c *liveTitleTrapChannel) IsAllowed(string) bool { return true }
func (c *liveTitleTrapChannel) ResolveGroupTitle(context.Context, string) (string, error) {
c.called = true
return "", nil
}
func (c *liveTitleTrapChannel) ResolveGroupTitles(context.Context, []string) (map[string]string, error) {
c.called = true
return nil, nil
}
func TestMemoryExtractionGroupsUsesDBTitlesOnly(t *testing.T) {
instID := uuid.New()
channelName := "discord-main"
handler := NewChannelInstancesHandler(
&stubChannelInstanceStore{inst: &store.ChannelInstanceData{
BaseModel: store.BaseModel{ID: instID},
Name: channelName,
ChannelType: channels.TypeDiscord,
}},
nil, nil, nil, nil, nil,
)
handler.memoryService = &channelmemory.Service{Pending: &memoryExtractionPendingStore{
groups: []store.PendingMessageGroup{
{
ChannelName: channelName,
HistoryKey: "thread-1",
ParentHistoryKey: "parent-1",
MessageCount: 4,
LastActivity: time.Date(2026, 7, 8, 8, 0, 0, 0, time.UTC),
},
},
titles: map[string]string{
channelName + ":thread-1": "launch-thread",
channelName + ":parent-1": "product-planning",
},
}}
manager := channels.NewManager(bus.New())
trap := &liveTitleTrapChannel{BaseChannel: channels.NewBaseChannel(channels.TypeDiscord, nil, nil)}
trap.SetName(channelName)
trap.SetType(channels.TypeDiscord)
manager.RegisterChannel(channelName, trap)
handler.SetChannelManager(manager)
req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/memory-extraction/groups", nil)
req.SetPathValue("id", instID.String())
rec := httptest.NewRecorder()
handler.handleMemoryExtractionGroups(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if trap.called {
t.Fatal("memory extraction groups called live channel title resolver")
}
var body struct {
Groups []channelmemory.GroupOption `json:"groups"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(body.Groups) != 1 {
t.Fatalf("group count = %d, want 1", len(body.Groups))
}
if body.Groups[0].GroupTitle != "launch-thread" {
t.Fatalf("group title = %q, want launch-thread", body.Groups[0].GroupTitle)
}
if body.Groups[0].ParentGroupTitle != "product-planning" {
t.Fatalf("parent group title = %q, want product-planning", body.Groups[0].ParentGroupTitle)
}
}
+18
View File
@@ -55,6 +55,24 @@ func (c *ContactCollector) EnsureContact(ctx context.Context, channelType, chann
c.seen.Set(ctx, key, true, contactSeenTTL)
}
// RefreshContact upserts a contact without the short-lived hot-path dedup.
// Background metadata syncs use this so fresher platform names are persisted
// even when the message ingestion path recently saw the same ID.
func (c *ContactCollector) RefreshContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind, contactType, threadID, threadType string) {
if contactType == "" {
contactType = "user"
}
if err := c.store.UpsertContact(ctx, channelType, channelInstance, senderID, userID, displayName, username, peerKind, contactType, threadID, threadType); err != nil {
slog.Warn("contact_collector.refresh_failed",
"error", err,
"tenant_id", TenantIDFromContext(ctx),
"channel", channelType,
"instance", channelInstance,
"sender", senderID,
)
}
}
// ResolveTenantUserID delegates to the underlying ContactStore.
func (c *ContactCollector) ResolveTenantUserID(ctx context.Context, channelType, senderID string) (string, error) {
return c.store.ResolveTenantUserID(ctx, channelType, senderID)
+84 -5
View File
@@ -214,10 +214,15 @@ func (s *PGPendingMessageStore) ResolveGroupTitles(ctx context.Context, groups [
return nil, nil
}
result, missing := s.resolveGroupTitlesFromContacts(ctx, groups)
if len(missing) == 0 {
return result, nil
}
// Build OR conditions: session_key LIKE '%:{channel}:group:{key}%'
conditions := make([]string, 0, len(groups))
args := make([]any, 0, len(groups)*2)
for i, g := range groups {
conditions := make([]string, 0, len(missing))
args := make([]any, 0, len(missing)*2)
for i, g := range missing {
conditions = append(conditions, fmt.Sprintf(
"(session_key LIKE '%%:' || $%d || ':group:' || $%d || '%%')",
i*2+1, i*2+2,
@@ -248,14 +253,13 @@ func (s *PGPendingMessageStore) ResolveGroupTitles(ctx context.Context, groups [
}
defer rows.Close()
result := make(map[string]string)
for rows.Next() {
var sessionKey, title string
if err := rows.Scan(&sessionKey, &title); err != nil {
return nil, err
}
// Match session_key back to channel:key pair
for _, g := range groups {
for _, g := range missing {
pattern := ":" + g.ChannelName + ":group:" + g.HistoryKey
if strings.Contains(sessionKey, pattern) {
mapKey := g.ChannelName + ":" + g.HistoryKey
@@ -268,3 +272,78 @@ func (s *PGPendingMessageStore) ResolveGroupTitles(ctx context.Context, groups [
}
return result, rows.Err()
}
func (s *PGPendingMessageStore) resolveGroupTitlesFromContacts(ctx context.Context, groups []store.PendingMessageGroup) (map[string]string, []store.PendingMessageGroup) {
result := make(map[string]string, len(groups))
unique := uniquePendingTitleGroups(groups)
if len(unique) == 0 {
return result, nil
}
conditions := make([]string, 0, len(unique))
args := make([]any, 0, len(unique)*2+1)
for i, g := range unique {
conditions = append(conditions, fmt.Sprintf("(channel_instance = $%d AND sender_id = $%d)", i*2+1, i*2+2))
args = append(args, g.ChannelName, g.HistoryKey)
}
tenantFilter := ""
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
tenantFilter = fmt.Sprintf(" AND tenant_id = $%d", len(args)+1)
args = append(args, tid)
}
rows, err := s.db.QueryContext(ctx,
`SELECT channel_instance, sender_id, display_name
FROM channel_contacts
WHERE display_name IS NOT NULL
AND display_name <> ''
AND contact_type IN ('group', 'topic')
AND (`+strings.Join(conditions, " OR ")+`)`+tenantFilter,
args...,
)
if err != nil {
return result, unique
}
defer rows.Close()
for rows.Next() {
var channelName, senderID, title string
if err := rows.Scan(&channelName, &senderID, &title); err != nil {
return result, unique
}
result[channelName+":"+senderID] = title
}
if err := rows.Err(); err != nil {
return result, unique
}
missing := make([]store.PendingMessageGroup, 0, len(unique)-len(result))
for _, g := range unique {
if result[g.ChannelName+":"+g.HistoryKey] == "" {
missing = append(missing, g)
}
}
return result, missing
}
func uniquePendingTitleGroups(groups []store.PendingMessageGroup) []store.PendingMessageGroup {
out := make([]store.PendingMessageGroup, 0, len(groups))
seen := make(map[string]struct{}, len(groups))
for _, g := range groups {
if g.ChannelName == "" || g.HistoryKey == "" {
continue
}
key := g.ChannelName + ":" + g.HistoryKey
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, g)
}
return out
}
+3 -2
View File
@@ -29,8 +29,8 @@ func (s *SQLiteContactStore) UpsertContact(ctx context.Context, channelType, cha
tenantID = store.MasterTenantID
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind, contact_type, thread_id, thread_type, tenant_id)
VALUES (?, NULLIF(?,?), ?, NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), ?, NULLIF(?,?), NULLIF(?,?), ?)
INSERT INTO channel_contacts (id, channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind, contact_type, thread_id, thread_type, tenant_id)
VALUES (?, ?, NULLIF(?,?), ?, NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), ?, NULLIF(?,?), NULLIF(?,?), ?)
ON CONFLICT (tenant_id, channel_type, sender_id, COALESCE(thread_id, '')) DO UPDATE SET
display_name = COALESCE(NULLIF(excluded.display_name,''), channel_contacts.display_name),
username = COALESCE(NULLIF(excluded.username,''), channel_contacts.username),
@@ -40,6 +40,7 @@ func (s *SQLiteContactStore) UpsertContact(ctx context.Context, channelType, cha
contact_type = excluded.contact_type,
thread_type = COALESCE(NULLIF(excluded.thread_type,''), channel_contacts.thread_type),
last_seen_at = CURRENT_TIMESTAMP`,
store.GenNewID().String(),
channelType,
channelInstance, "",
senderID,
+84 -5
View File
@@ -232,10 +232,15 @@ func (s *SQLitePendingMessageStore) ResolveGroupTitles(ctx context.Context, grou
return nil, nil
}
result, missing := s.resolveGroupTitlesFromContacts(ctx, groups)
if len(missing) == 0 {
return result, nil
}
// Build OR conditions using LIKE with ? placeholders
conditions := make([]string, 0, len(groups))
args := make([]any, 0, len(groups)*2)
for _, g := range groups {
conditions := make([]string, 0, len(missing))
args := make([]any, 0, len(missing)*2)
for _, g := range missing {
conditions = append(conditions, "(session_key LIKE '%:' || ? || ':group:' || ? || '%')")
args = append(args, g.ChannelName, g.HistoryKey)
}
@@ -265,13 +270,12 @@ func (s *SQLitePendingMessageStore) ResolveGroupTitles(ctx context.Context, grou
}
defer rows.Close()
result := make(map[string]string)
for rows.Next() {
var sessionKey, title string
if err := rows.Scan(&sessionKey, &title); err != nil {
return nil, err
}
for _, g := range groups {
for _, g := range missing {
pattern := ":" + g.ChannelName + ":group:" + g.HistoryKey
if strings.Contains(sessionKey, pattern) {
mapKey := g.ChannelName + ":" + g.HistoryKey
@@ -284,3 +288,78 @@ func (s *SQLitePendingMessageStore) ResolveGroupTitles(ctx context.Context, grou
}
return result, rows.Err()
}
func (s *SQLitePendingMessageStore) resolveGroupTitlesFromContacts(ctx context.Context, groups []store.PendingMessageGroup) (map[string]string, []store.PendingMessageGroup) {
result := make(map[string]string, len(groups))
unique := uniquePendingTitleGroups(groups)
if len(unique) == 0 {
return result, nil
}
conditions := make([]string, 0, len(unique))
args := make([]any, 0, len(unique)*2+1)
for _, g := range unique {
conditions = append(conditions, "(channel_instance = ? AND sender_id = ?)")
args = append(args, g.ChannelName, g.HistoryKey)
}
tenantFilter := ""
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
tenantFilter = " AND tenant_id = ?"
args = append(args, tid.String())
}
rows, err := s.db.QueryContext(ctx,
`SELECT channel_instance, sender_id, display_name
FROM channel_contacts
WHERE display_name IS NOT NULL
AND display_name <> ''
AND contact_type IN ('group', 'topic')
AND (`+strings.Join(conditions, " OR ")+`)`+tenantFilter,
args...,
)
if err != nil {
return result, unique
}
defer rows.Close()
for rows.Next() {
var channelName, senderID, title string
if err := rows.Scan(&channelName, &senderID, &title); err != nil {
return result, unique
}
result[channelName+":"+senderID] = title
}
if err := rows.Err(); err != nil {
return result, unique
}
missing := make([]store.PendingMessageGroup, 0, len(unique)-len(result))
for _, g := range unique {
if result[g.ChannelName+":"+g.HistoryKey] == "" {
missing = append(missing, g)
}
}
return result, missing
}
func uniquePendingTitleGroups(groups []store.PendingMessageGroup) []store.PendingMessageGroup {
out := make([]store.PendingMessageGroup, 0, len(groups))
seen := make(map[string]struct{}, len(groups))
for _, g := range groups {
if g.ChannelName == "" || g.HistoryKey == "" {
continue
}
key := g.ChannelName + ":" + g.HistoryKey
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, g)
}
return out
}
@@ -0,0 +1,47 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestResolveGroupTitlesUsesChannelContacts(t *testing.T) {
db := openTestDB(t)
if err := EnsureSchema(db); err != nil {
t.Fatalf("EnsureSchema: %v", err)
}
tenantID := uuid.New()
if _, err := db.Exec(`INSERT INTO tenants (id, name, slug, status) VALUES (?, 'T', 't-pending-title', 'active')`, tenantID.String()); err != nil {
t.Fatalf("insert tenant: %v", err)
}
ctx := store.WithTenantID(context.Background(), tenantID)
contacts := NewSQLiteContactStore(db)
if err := contacts.UpsertContact(ctx, "discord", "discord-main", "thread-1", "", "launch-thread", "", "group", "group", "", ""); err != nil {
t.Fatalf("upsert thread contact: %v", err)
}
if err := contacts.UpsertContact(ctx, "discord", "discord-main", "parent-1", "", "product-planning", "", "group", "group", "", ""); err != nil {
t.Fatalf("upsert parent contact: %v", err)
}
pending := NewSQLitePendingMessageStore(db)
titles, err := pending.ResolveGroupTitles(ctx, []store.PendingMessageGroup{
{ChannelName: "discord-main", HistoryKey: "thread-1"},
{ChannelName: "discord-main", HistoryKey: "parent-1"},
})
if err != nil {
t.Fatalf("ResolveGroupTitles: %v", err)
}
if titles["discord-main:thread-1"] != "launch-thread" {
t.Fatalf("thread title = %q, want launch-thread", titles["discord-main:thread-1"])
}
if titles["discord-main:parent-1"] != "product-planning" {
t.Fatalf("parent title = %q, want product-planning", titles["discord-main:parent-1"])
}
}
+83 -29
View File
@@ -112,6 +112,12 @@
"accessControlDesc": "User access restrictions",
"telegramManagement": "Telegram Management",
"telegramManagementDesc": "Enable the hidden tool and choose Telegram actions for agents using this channel",
"discordMetadata": "Discord Metadata",
"discordMetadataDesc": "Refresh cached Discord channel, category, thread, and known user names from the running bot.",
"refreshDiscordMetadata": "Refresh Discord Metadata",
"refreshingDiscordMetadata": "Refreshing...",
"discordMetadataRefreshed": "Discord metadata refreshed",
"discordMetadataRefreshFailed": "Failed to refresh Discord metadata",
"reviewDiagnostics": "Review the latest diagnosis in this channel before changing settings.",
"whatHappened": "What happened",
"recommendedAction": "Recommended action",
@@ -214,6 +220,7 @@
"types": "Memory types",
"excludeUsers": "Excluded users",
"excludePatterns": "Excluded patterns",
"channelCustomPrompt": "Channel extraction instructions",
"excludeGroups": "Excluded Discord channels",
"excludeGroupsPlaceholder": "Select a channel to exclude",
"manualExcludePlaceholder": "Paste Discord channel ID",
@@ -246,6 +253,10 @@
"approve": "Approve",
"reject": "Reject",
"delete": "Delete",
"debugBadge": {
"topic": "Topic",
"entity": "Entity"
},
"type": {
"people": "People",
"projects": "Projects",
@@ -253,7 +264,10 @@
"todos": "Todos",
"preferences": "Preferences",
"events": "Events"
}
},
"groupCustomPrompt": "Group extraction instructions",
"groupCustomPromptPlaceholder": "Select a group to tune",
"noPromptGroups": "No Discord channel history found yet."
},
"timeline": {
"title": "Timeline",
@@ -303,8 +317,13 @@
}
},
"fieldConfig": {
"token": { "label": "Bot Token", "help": "From @BotFather" },
"bot_token": { "label": "Bot Token" },
"token": {
"label": "Bot Token",
"help": "From @BotFather"
},
"bot_token": {
"label": "Bot Token"
},
"app_token": {
"label": "App-Level Token",
"help": "App-Level Token with connections:write scope (required for Socket Mode)"
@@ -313,14 +332,23 @@
"label": "User Token (Optional)",
"help": "Optional: User OAuth Token for custom bot identity. Leave empty to use default bot identity."
},
"app_id": { "label": "App ID" },
"app_secret": { "label": "App Secret" },
"encrypt_key": { "label": "Encrypt Key", "help": "For webhook mode" },
"app_id": {
"label": "App ID"
},
"app_secret": {
"label": "App Secret"
},
"encrypt_key": {
"label": "Encrypt Key",
"help": "For webhook mode"
},
"verification_token": {
"label": "Verification Token",
"help": "For webhook mode"
},
"webhook_secret": { "label": "Webhook Secret" },
"webhook_secret": {
"label": "Webhook Secret"
},
"api_server": {
"label": "API Server URL",
"help": "Custom Telegram Bot API server for large file uploads (up to 2GB). Leave empty for default."
@@ -329,8 +357,12 @@
"label": "HTTP Proxy",
"help": "Route bot traffic through an HTTP proxy"
},
"dm_policy": { "label": "DM Policy" },
"group_policy": { "label": "Group Policy" },
"dm_policy": {
"label": "DM Policy"
},
"group_policy": {
"label": "Group Policy"
},
"require_mention": {
"label": "Require @mention in groups",
"disabledHint": "Multi-bot mode always listens to all messages"
@@ -359,10 +391,18 @@
"label": "Show Reasoning",
"help": "Choose how model reasoning is shown in channel messages."
},
"reaction_level": { "label": "Reaction Level" },
"media_max_mb": { "label": "Max Media Size (MB)" },
"link_preview": { "label": "Link Preview" },
"allow_from": { "label": "Allowed Users" },
"reaction_level": {
"label": "Reaction Level"
},
"media_max_mb": {
"label": "Max Media Size (MB)"
},
"link_preview": {
"label": "Link Preview"
},
"allow_from": {
"label": "Allowed Users"
},
"telegram_manager": {
"enabled": {
"label": "Enable Telegram management tool",
@@ -373,7 +413,9 @@
"help": "Choose which Telegram actions the agent may use. If no permission is selected, the tool is not granted to the agent."
}
},
"domain": { "label": "Domain" },
"domain": {
"label": "Domain"
},
"connection_mode": {
"label": "Connection Mode",
"help": "WebSocket needs no public IP — outbound connection only"
@@ -386,12 +428,16 @@
"label": "Webhook Path",
"help": "Path on main server for Lark events"
},
"webhook_url": { "label": "Webhook URL" },
"webhook_url": {
"label": "Webhook URL"
},
"topic_session_mode": {
"label": "Topic Session Mode",
"help": "Use thread root_id for session isolation"
},
"render_mode": { "label": "Render Mode" },
"render_mode": {
"label": "Render Mode"
},
"text_chunk_limit": {
"label": "Text Chunk Limit",
"help": "Max characters per message"
@@ -420,7 +466,9 @@
"label": "Tool Allowlist",
"help": "Restrict which tools the agent can use in this group"
},
"system_prompt": { "label": "System Prompt" },
"system_prompt": {
"label": "System Prompt"
},
"chat_behavior": {
"enabled": {
"label": "Human-like Delivery",
@@ -493,8 +541,14 @@
}
}
},
"platform": { "label": "Platform", "help": "Select the platform this Pancake page serves." },
"tiktok_type": { "label": "TikTok Type", "help": "Select the TikTok account type for this page" },
"platform": {
"label": "Platform",
"help": "Select the platform this Pancake page serves."
},
"tiktok_type": {
"label": "TikTok Type",
"help": "Select the TikTok account type for this page"
},
"features.private_reply": {
"label": "Private Reply (Comment → DM)",
"help": "Send a one-time DM to commenters after the public reply. Facebook/Instagram only. Meta allows DM within 7 days of the comment."
@@ -599,17 +653,17 @@
"card": "Card"
},
"platform": {
"facebook": "Facebook",
"instagram": "Instagram",
"threads": "Threads (Beta)",
"tiktok": "TikTok",
"youtube": "YouTube (Beta)",
"shopee": "Shopee",
"line": "Line",
"google": "Google",
"facebook": "Facebook",
"instagram": "Instagram",
"threads": "Threads (Beta)",
"tiktok": "TikTok",
"youtube": "YouTube (Beta)",
"shopee": "Shopee",
"line": "Line",
"google": "Google",
"chat_plugin": "Chat Plugin",
"lazada": "Lazada",
"tokopedia": "Tokopedia"
"lazada": "Lazada",
"tokopedia": "Tokopedia"
}
},
"groupOverrides": {
+4 -24
View File
@@ -11,14 +11,12 @@
"failedToSave": "Failed to save configuration",
"saveConfig": "Save Config",
"usingGlobalDefaults": "Using global defaults",
"tabs.server": "Server",
"tabs.behavior": "Behavior",
"tabs.aiDefaults": "AI Defaults",
"tabs.quota": "Quota",
"tabs.tools": "Tools",
"tabs.integrations": "Integrations",
"server.title": "Gateway Server",
"server.description": "Network binding, authentication token, and access control",
"branding.title": "Appearance Settings",
@@ -53,7 +51,6 @@
"branding.uploadFailed": "Branding asset upload failed",
"branding.uploadErrors.too_large": "File must be 2 MB or smaller.",
"branding.uploadErrors.unsupported_type": "Use SVG, PNG, JPG, WEBP, or ICO files.",
"gateway.host": "Host",
"gateway.hostTip": "The network interface the gateway listens on. Use 0.0.0.0 to listen on all interfaces.",
"gateway.port": "Port",
@@ -74,7 +71,6 @@
"gateway.inboundDebounceMs": "Inbound Debounce (ms)",
"gateway.inboundDebounceMsTip": "Delay in milliseconds before processing rapid channel or Web Chat follow-ups. Set 0 for no debounce; positive values merge follow-ups after that wait.",
"gateway.injectionAction": "Injection Detection Action",
"agents.title": "Agent Defaults",
"agents.description": "Default LLM provider, model, and runtime settings applied to all agents unless overridden",
"agents.providerTip": "Default LLM provider used when an agent does not specify one.",
@@ -93,7 +89,6 @@
"agents.restrictToWorkspaceTip": "Prevent agents from reading or writing files outside the workspace path.",
"agents.intentClassify": "Busy Agent Message Classification",
"agents.saveError": "Failed to save agent defaults",
"agents.subagents.title": "Subagents",
"agents.subagents.desc": "Sub-agent spawning limits",
"agents.subagents.maxConcurrent": "Max Concurrent",
@@ -107,7 +102,6 @@
"agents.subagents.modelOverride": "Model Override",
"agents.subagents.modelOverrideTip": "Force all sub-agents to use a specific model. Leave empty to inherit from the parent agent.",
"agents.subagents.modelOverridePlaceholder": "(inherit from agent)",
"agents.memory.title": "Memory",
"agents.memory.desc": "Semantic memory search and embeddings",
"agents.memory.enabled": "Enabled",
@@ -123,7 +117,6 @@
"agents.memory.chunkOverlapTip": "Overlap characters between chunks for context continuity.",
"agents.memory.minScore": "Min Score",
"agents.memory.minScoreTip": "Minimum similarity score (0-1) for a memory entry to be included in results.",
"agents.compaction.title": "Compaction",
"agents.compaction.desc": "Context window compaction settings",
"agents.compaction.reserveTokensFloor": "Reserve Tokens Floor",
@@ -132,13 +125,11 @@
"agents.compaction.maxHistoryShareTip": "Fraction of the context window that conversation history can occupy.",
"agents.compaction.timeoutSeconds": "Timeout Seconds",
"agents.compaction.timeoutSecondsTip": "Maximum time to wait for compaction before keeping the original history.",
"agents.pruning.title": "Context Pruning",
"agents.pruning.desc": "Trim old tool results to free context space",
"agents.pruning.mode": "Mode",
"agents.pruning.keepLastAssistants": "Keep Last Assistants",
"agents.pruning.keepLastAssistantsTip": "Number of recent assistant turns to always preserve during pruning.",
"agents.sandbox.title": "Sandbox",
"agents.sandbox.desc": "Docker sandbox for code execution",
"agents.sandbox.mode": "Mode",
@@ -151,7 +142,6 @@
"agents.sandbox.timeoutSec": "Timeout (sec)",
"agents.sandbox.timeoutSecTip": "Maximum execution time in seconds before the sandbox is killed.",
"agents.sandbox.networkEnabled": "Network Enabled",
"behavior.uxTitle": "UX Behavior",
"behavior.uxDescription": "High-impact toggles that change how the gateway interacts with users",
"behavior.intentClassifyHint": "When the agent is already processing a previous turn, classify new messages as status questions, cancel requests, steering instructions, or new requests.",
@@ -225,12 +215,10 @@
"behavior.scrubCredentialsHint": "Automatically redact API keys, tokens, and passwords found in tool output before returning to the agent.",
"behavior.scrubCredentialsInfo": "Credential patterns in tool responses are masked before the agent sees them.",
"behavior.scrubCredentialsOffInfo": "Credential scrubbing is disabled. Secrets in tool output may be exposed to the agent.",
"sessions.scope": "Session Scope",
"sessions.scopeTip": "Determines how sessions are partitioned. 'Per Sender' gives each user a separate session; 'Global' shares one session across all users.",
"sessions.dmScope": "DM Scope",
"sessions.dmScopeTip": "How direct-message sessions are keyed. 'Per Channel Peer' is the recommended default.",
"quota.title": "Usage Quota",
"quota.description": "Per-user message quota limits with optional per-provider, channel, and group overrides",
"quota.enabled": "Enable Quotas",
@@ -255,7 +243,6 @@
"quota.selectProvider": "Select provider",
"quota.selectChannel": "Select channel",
"quota.selectGroup": "Select group",
"tools.title": "Tool Profile",
"tools.description": "Default tool availability and per-hour call rate limit for all agents",
"tools.profile": "Profile",
@@ -283,7 +270,6 @@
"tools.commandKeywordAllowlistJsonError": "Invalid JSON.",
"tools.commandKeywordAllowlistArrayError": "Use a JSON array of rules.",
"tools.scrubCredentials": "Scrub Credentials",
"browser.title": "Browser Automation",
"browser.description": "Runtime settings for the server-side browser tool",
"browser.enabled": "Enabled",
@@ -300,14 +286,12 @@
"browser.idleTimeoutTip": "Close idle pages after this duration. Set 0 to disable.",
"browser.cookieSync": "Selected Cookie Sync",
"browser.cookieSyncHint": "Apply explicitly synced cookies to matching user and agent browser sessions.",
"tts.title": "Text-to-Speech",
"tts.configured": "Configured",
"tts.disabled": "Disabled",
"tts.providerInfo": "Provider: {{provider}} — Auto: {{auto}}",
"tts.noProvider": "No TTS provider configured.",
"tts.manageLink": "Manage TTS settings",
"cron.title": "Cron Scheduler",
"cron.description": "Retry behaviour for failed scheduled tasks",
"cron.maxRetries": "Max Retries",
@@ -319,7 +303,6 @@
"cron.defaultTimezone": "Default Timezone",
"cron.defaultTimezoneTip": "IANA timezone for cron expressions when not set per-job (e.g. Asia/Ho_Chi_Minh). Leave empty to use server system timezone.",
"cron.defaultTimezonePlaceholder": "System timezone",
"telemetry.title": "Telemetry (OpenTelemetry)",
"telemetry.description": "Export traces and metrics to an OpenTelemetry-compatible backend",
"telemetry.enabled": "Enabled",
@@ -337,7 +320,6 @@
"telemetry.headerKeyPlaceholder": "Header name",
"telemetry.headerValuePlaceholder": "Header value",
"telemetry.addHeader": "Add Header",
"bindings.title": "Agent Bindings",
"bindings.description": "Route specific channels, peers, or accounts to a designated agent",
"bindings.add": "Add Binding",
@@ -346,7 +328,6 @@
"bindings.channel": "Channel",
"bindings.peerKind": "Peer Kind",
"bindings.peerId": "Peer ID",
"providers.title": "Provider API Keys",
"providers.description": "Override API keys and base URLs for configured LLM providers",
"providers.noProviders": "No providers found in the configuration file.",
@@ -354,7 +335,6 @@
"providers.apiBaseUrl": "Base URL",
"providers.apiBaseUrlPlaceholder": "https://api.example.com/v1",
"providers.managedVia": "Managed via {{envKey}} environment variable.",
"channels.title": "Channel Settings",
"channels.description": "Configure built-in channel integrations (tokens, policies, streaming)",
"channels.noChannels": "No channels found in the configuration file.",
@@ -384,9 +364,7 @@
"channels.reactionFull": "Full",
"channels.connectionWebsocket": "WebSocket",
"channels.connectionWebhook": "Webhook",
"bindings.any": "Any",
"tabs.systemMessages": "System Messages",
"systemMessages.title": "Custom System Messages",
"systemMessages.description": "Override built-in gateway messages sent directly by channels",
@@ -405,9 +383,11 @@
"systemMessages.defaultTemplate": "Default Template",
"systemMessages.defaultTemplateTip": "Built-in fallback template used when the override is empty.",
"systemMessages.useDefault": "Use Default",
"toast": {
"saved": "Config saved",
"saveFailed": "Failed to save config"
}
},
"behavior.passiveMemoryTitle": "Passive memory extraction",
"behavior.passiveMemoryDescription": "Tenant-wide extraction instructions for passive channel memory.",
"behavior.passiveMemoryPromptPlaceholder": "Example: Avoid duplicate facts and merge repeated updates into one concise item."
}
+69
View File
@@ -110,6 +110,12 @@
"behaviorDesc": "응답 동작 설정",
"accessControl": "접근 제어",
"accessControlDesc": "사용자 접근 제한",
"discordMetadata": "Discord 메타데이터",
"discordMetadataDesc": "실행 중인 봇에서 캐시된 Discord 채널, 카테고리, 스레드 및 알려진 사용자 이름을 새로 고칩니다.",
"refreshDiscordMetadata": "Discord 메타데이터 새로 고침",
"refreshingDiscordMetadata": "새로 고치는 중...",
"discordMetadataRefreshed": "Discord 메타데이터가 새로 고쳐졌습니다",
"discordMetadataRefreshFailed": "Discord 메타데이터 새로 고침 실패",
"reviewDiagnostics": "설정을 변경하기 전에 이 채널의 최신 진단을 검토하세요.",
"whatHappened": "무슨 일이 있었는지",
"recommendedAction": "권장 조치",
@@ -202,6 +208,69 @@
"failedAdd": "관리자 추가 실패"
}
}
},
"passiveMemory": {
"title": "수동 메모리 추출",
"description": "그룹 메시지에서 오래 유지할 사실을 추출해 검토 가능한 메모리로 만듭니다.",
"pendingCount": "{{count}}개 검토 대기",
"unprocessedCount": "{{count}}개 미처리 메시지",
"enable": "수동 추출 활성화",
"reviewMode": "저장 전 검토 필요",
"interval": "간격 (분)",
"messageCap": "메시지 한도",
"retention": "보관 (시간)",
"minMessages": "최소 메시지",
"types": "메모리 유형",
"excludeUsers": "제외 사용자",
"excludePatterns": "제외 패턴",
"channelCustomPrompt": "채널 추출 지침",
"excludeGroups": "제외된 Discord 채널",
"excludeGroupsPlaceholder": "제외할 채널 선택",
"manualExcludePlaceholder": "Discord 채널 ID 붙여넣기",
"addExcludedGroup": "추가",
"noExcludeGroups": "아직 Discord 채널 기록이 없습니다.",
"noExcludedGroups": "제외된 채널이 없습니다.",
"removeExcludedGroup": "제외 채널에서 {{group}} 제거",
"runNow": "최신 그룹 실행",
"processAll": "모든 그룹 실행",
"save": "저장",
"saving": "저장 중...",
"saved": "메모리 설정이 저장되었습니다",
"saveFailed": "메모리 설정 저장 실패",
"runQueued": "메모리 추출이 시작되었습니다",
"runAllDone": "{{runs}}개 그룹에서 {{messages}}개 메시지를 처리했습니다",
"runFailed": "메모리 추출 실패",
"runAllProgress": "모든 그룹 실행 진행률",
"runAllProgressStats": "{{runs}} 완료, {{skipped}} 건너뜀, {{errors}} 실패, {{messages}} 메시지",
"groupCompleted": "{{historyKey}} 그룹 완료",
"groupSkipped": "최소 메시지 수 미달로 {{historyKey}} 그룹 건너뜀 ({{messageCount}}개 메시지)",
"groupFailed": "{{historyKey}} 그룹 실패: {{error}}",
"itemUpdated": "메모리 항목이 업데이트되었습니다",
"itemFailed": "메모리 항목 작업 실패",
"reviewQueue": "검토 대기열",
"loading": "메모리 상태 불러오는 중...",
"noRuns": "아직 추출 실행 기록이 없습니다.",
"noItems": "아직 메모리 후보가 없습니다.",
"lastRun": "마지막 실행",
"runStats": "{{messages}}개 메시지, {{items}}개 항목, {{redactions}}개 삭제",
"approve": "승인",
"reject": "거부",
"delete": "삭제",
"debugBadge": {
"topic": "주제",
"entity": "엔티티"
},
"type": {
"people": "사람",
"projects": "프로젝트",
"decisions": "결정",
"todos": "할 일",
"preferences": "선호",
"events": "이벤트"
},
"groupCustomPrompt": "그룹 추출 지침",
"groupCustomPromptPlaceholder": "조정할 그룹 선택",
"noPromptGroups": "아직 Discord 채널 기록이 없습니다."
}
},
"fieldConfig": {
+4 -23
View File
@@ -11,14 +11,12 @@
"failedToSave": "설정 저장 실패",
"saveConfig": "설정 저장",
"usingGlobalDefaults": "전역 기본값 사용",
"tabs.server": "서버",
"tabs.behavior": "동작",
"tabs.aiDefaults": "AI 기본값",
"tabs.quota": "할당량",
"tabs.tools": "도구",
"tabs.integrations": "통합",
"server.title": "게이트웨이 서버",
"server.description": "네트워크 바인딩, 인증 토큰 및 접근 제어",
"branding.title": "화면 설정",
@@ -53,7 +51,6 @@
"branding.uploadFailed": "브랜딩 자산 업로드 실패",
"branding.uploadErrors.too_large": "파일은 2 MB 이하여야 합니다.",
"branding.uploadErrors.unsupported_type": "SVG, PNG, JPG, WEBP 또는 ICO 파일을 사용하세요.",
"gateway.host": "호스트",
"gateway.hostTip": "게이트웨이가 수신하는 네트워크 인터페이스입니다. 모든 인터페이스에서 수신하려면 0.0.0.0을 사용하세요.",
"gateway.port": "포트",
@@ -76,7 +73,6 @@
"gateway.inboundDebounceMs": "인바운드 디바운스 (ms)",
"gateway.inboundDebounceMsTip": "새 메시지를 처리하기 전 대기할 밀리초입니다. 빠른 후속 메시지를 통합하기 위해 사용합니다. 비활성화하려면 -1로 설정하세요.",
"gateway.injectionAction": "주입 감지 조치",
"agents.title": "에이전트 기본값",
"agents.description": "재정의하지 않는 한 모든 에이전트에 적용되는 기본 LLM 공급자, 모델 및 런타임 설정",
"agents.providerTip": "에이전트가 지정하지 않을 때 사용되는 기본 LLM 공급자입니다.",
@@ -95,7 +91,6 @@
"agents.restrictToWorkspaceTip": "에이전트가 워크스페이스 경로 외부의 파일을 읽거나 쓸 수 없도록 합니다.",
"agents.intentClassify": "에이전트 작업 중 메시지 분류",
"agents.saveError": "에이전트 기본값 저장 실패",
"agents.subagents.title": "서브에이전트",
"agents.subagents.desc": "서브에이전트 생성 제한",
"agents.subagents.maxConcurrent": "최대 동시",
@@ -109,7 +104,6 @@
"agents.subagents.modelOverride": "모델 재정의",
"agents.subagents.modelOverrideTip": "모든 서브에이전트가 특정 모델을 사용하도록 강제합니다. 부모 에이전트에서 상속하려면 비워두세요.",
"agents.subagents.modelOverridePlaceholder": "(에이전트에서 상속)",
"agents.memory.title": "메모리",
"agents.memory.desc": "의미 메모리 검색 및 임베딩",
"agents.memory.enabled": "활성화됨",
@@ -125,20 +119,17 @@
"agents.memory.chunkOverlapTip": "컨텍스트 연속성을 위한 청크 간 겹침 문자 수입니다.",
"agents.memory.minScore": "최소 점수",
"agents.memory.minScoreTip": "결과에 포함될 메모리 항목의 최소 유사도 점수 (0-1)입니다.",
"agents.compaction.title": "압축",
"agents.compaction.desc": "컨텍스트 창 압축 설정",
"agents.compaction.reserveTokensFloor": "예약 토큰 하한",
"agents.compaction.reserveTokensFloorTip": "압축 전 새 응답을 위해 예약할 최소 토큰 버퍼입니다.",
"agents.compaction.maxHistoryShare": "최대 기록 비율 (0-1)",
"agents.compaction.maxHistoryShareTip": "대화 기록이 차지할 수 있는 컨텍스트 창의 비율입니다.",
"agents.pruning.title": "컨텍스트 정리",
"agents.pruning.desc": "컨텍스트 공간 확보를 위해 오래된 도구 결과 제거",
"agents.pruning.mode": "모드",
"agents.pruning.keepLastAssistants": "마지막 어시스턴트 유지",
"agents.pruning.keepLastAssistantsTip": "정리 중 항상 보존할 최근 어시스턴트 턴 수입니다.",
"agents.sandbox.title": "샌드박스",
"agents.sandbox.desc": "코드 실행을 위한 Docker 샌드박스",
"agents.sandbox.mode": "모드",
@@ -151,7 +142,6 @@
"agents.sandbox.timeoutSec": "타임아웃 (초)",
"agents.sandbox.timeoutSecTip": "샌드박스가 종료되기 전 최대 실행 시간(초)입니다.",
"agents.sandbox.networkEnabled": "네트워크 활성화됨",
"behavior.uxTitle": "UX 동작",
"behavior.uxDescription": "게이트웨이가 사용자와 상호작용하는 방식을 변경하는 주요 토글",
"behavior.toolStatusHint": "에이전트 실행 중 사용자에게 실시간 도구 실행 상태 메시지를 표시합니다.",
@@ -191,12 +181,10 @@
"behavior.scrubCredentialsHint": "에이전트에 반환하기 전에 도구 출력에서 발견된 API 키, 토큰 및 비밀번호를 자동으로 수정합니다.",
"behavior.scrubCredentialsInfo": "도구 응답의 자격증명 패턴은 에이전트가 보기 전에 마스킹됩니다.",
"behavior.scrubCredentialsOffInfo": "자격증명 스크러빙이 비활성화되어 있습니다. 도구 출력의 시크릿이 에이전트에 노출될 수 있습니다.",
"sessions.scope": "세션 범위",
"sessions.scopeTip": "세션 분할 방법을 결정합니다. '발신자별'은 각 사용자에게 별도 세션을 제공하고, '전역'은 모든 사용자가 하나의 세션을 공유합니다.",
"sessions.dmScope": "DM 범위",
"sessions.dmScopeTip": "DM 세션이 키로 구분되는 방법입니다. '채널 피어별'이 권장 기본값입니다.",
"quota.title": "사용 할당량",
"quota.description": "선택적 공급자, 채널 및 그룹별 재정의가 있는 사용자별 메시지 할당량 제한",
"quota.enabled": "할당량 활성화",
@@ -221,7 +209,6 @@
"quota.selectProvider": "공급자 선택",
"quota.selectChannel": "채널 선택",
"quota.selectGroup": "그룹 선택",
"tools.title": "도구 프로파일",
"tools.description": "모든 에이전트에 대한 기본 도구 가용성 및 시간당 호출 속도 제한",
"tools.profile": "프로파일",
@@ -260,14 +247,12 @@
"tools.browserEnabled": "활성화됨",
"tools.browserHeadless": "헤드리스",
"tools.scrubCredentials": "자격증명 스크러빙",
"tts.title": "텍스트 음성 변환",
"tts.configured": "설정됨",
"tts.disabled": "비활성화됨",
"tts.providerInfo": "공급자: {{provider}} — 자동: {{auto}}",
"tts.noProvider": "TTS 공급자가 설정되지 않았습니다.",
"tts.manageLink": "TTS 설정 관리",
"cron.title": "Cron 스케줄러",
"cron.description": "실패한 예약 작업의 재시도 동작",
"cron.maxRetries": "최대 재시도",
@@ -279,7 +264,6 @@
"cron.defaultTimezone": "기본 시간대",
"cron.defaultTimezoneTip": "작업별로 설정되지 않은 경우 cron 표현식의 IANA 시간대입니다 (예: Asia/Seoul). 서버 시스템 시간대를 사용하려면 비워두세요.",
"cron.defaultTimezonePlaceholder": "시스템 시간대",
"telemetry.title": "텔레메트리 (OpenTelemetry)",
"telemetry.description": "OpenTelemetry 호환 백엔드로 추적 및 메트릭 내보내기",
"telemetry.enabled": "활성화됨",
@@ -297,7 +281,6 @@
"telemetry.headerKeyPlaceholder": "헤더 이름",
"telemetry.headerValuePlaceholder": "헤더 값",
"telemetry.addHeader": "헤더 추가",
"bindings.title": "에이전트 바인딩",
"bindings.description": "특정 채널, 피어 또는 계정을 지정된 에이전트로 라우팅합니다",
"bindings.add": "바인딩 추가",
@@ -306,7 +289,6 @@
"bindings.channel": "채널",
"bindings.peerKind": "피어 종류",
"bindings.peerId": "피어 ID",
"providers.title": "공급자 API 키",
"providers.description": "설정된 LLM 공급자의 API 키와 기본 URL을 재정의합니다",
"providers.noProviders": "설정 파일에서 공급자를 찾을 수 없습니다.",
@@ -314,7 +296,6 @@
"providers.apiBaseUrl": "기본 URL",
"providers.apiBaseUrlPlaceholder": "https://api.example.com/v1",
"providers.managedVia": "{{envKey}} 환경 변수를 통해 관리됩니다.",
"channels.title": "채널 설정",
"channels.description": "내장 채널 통합 설정 (토큰, 정책, 스트리밍)",
"channels.noChannels": "설정 파일에서 채널을 찾을 수 없습니다.",
@@ -341,9 +322,7 @@
"channels.reactionFull": "전체",
"channels.connectionWebsocket": "WebSocket",
"channels.connectionWebhook": "웹훅",
"bindings.any": "모두",
"tabs.systemMessages": "시스템 메시지",
"systemMessages.title": "사용자 지정 시스템 메시지",
"systemMessages.description": "채널에서 직접 보내는 기본 게이트웨이 메시지를 재정의합니다",
@@ -362,9 +341,11 @@
"systemMessages.defaultTemplate": "기본 템플릿",
"systemMessages.defaultTemplateTip": "재정의가 비어 있을 때 사용하는 기본 템플릿입니다.",
"systemMessages.useDefault": "기본값 사용",
"toast": {
"saved": "설정 저장됨",
"saveFailed": "설정 저장 실패"
}
},
"behavior.passiveMemoryTitle": "수동 메모리 추출",
"behavior.passiveMemoryDescription": "채널 수동 메모리에 적용되는 테넌트 전체 추출 지침입니다.",
"behavior.passiveMemoryPromptPlaceholder": "예: 중복 사실을 피하고 반복 업데이트를 하나의 간결한 항목으로 병합합니다."
}
+191 -56
View File
@@ -111,6 +111,12 @@
"accessControlDesc": "Giới hạn truy cập người dùng",
"telegramManagement": "Quản trị Telegram",
"telegramManagementDesc": "Bật tool ẩn và chọn quyền thao tác Telegram cho agent chạy qua channel này",
"discordMetadata": "Metadata Discord",
"discordMetadataDesc": "Làm mới cache tên channel, category, thread và user Discord đã biết từ bot đang chạy.",
"refreshDiscordMetadata": "Làm mới metadata Discord",
"refreshingDiscordMetadata": "Đang làm mới...",
"discordMetadataRefreshed": "Đã làm mới metadata Discord",
"discordMetadataRefreshFailed": "Làm mới metadata Discord thất bại",
"reviewDiagnostics": "Xem chẩn đoán mới nhất của channel này trước khi thay đổi cài đặt.",
"whatHappened": "Điều gì đã xảy ra",
"recommendedAction": "Hành động được khuyến nghị",
@@ -213,6 +219,7 @@
"types": "Loại bộ nhớ",
"excludeUsers": "User loại trừ",
"excludePatterns": "Pattern loại trừ",
"channelCustomPrompt": "Prompt trích xuất cấp channel",
"excludeGroups": "Channel Discord loại trừ",
"excludeGroupsPlaceholder": "Chọn channel cần loại trừ",
"manualExcludePlaceholder": "Dán Discord channel ID",
@@ -245,6 +252,10 @@
"approve": "Duyệt",
"reject": "Từ chối",
"delete": "Xóa",
"debugBadge": {
"topic": "Chủ đề",
"entity": "Thực thể"
},
"type": {
"people": "Con người",
"projects": "Dự án",
@@ -252,7 +263,10 @@
"todos": "Việc cần làm",
"preferences": "Sở thích",
"events": "Sự kiện"
}
},
"groupCustomPrompt": "Prompt trích xuất cấp group",
"groupCustomPromptPlaceholder": "Chọn group cần tinh chỉnh",
"noPromptGroups": "Chưa có lịch sử channel Discord."
},
"timeline": {
"title": "Dòng thời gian",
@@ -302,30 +316,92 @@
}
},
"fieldConfig": {
"token": { "label": "Bot Token", "help": "Từ @BotFather" },
"bot_token": { "label": "Bot Token" },
"app_token": { "label": "Token cấp ứng dụng", "help": "Token cấp ứng dụng với scope connections:write (bắt buộc cho Socket Mode)" },
"user_token": { "label": "Token người dùng (Tùy chọn)", "help": "Tùy chọn: Token OAuth người dùng cho danh tính bot tùy chỉnh. Để trống để dùng danh tính bot mặc định." },
"app_id": { "label": "App ID" },
"app_secret": { "label": "App Secret" },
"encrypt_key": { "label": "Khóa mã hóa", "help": "Cho chế độ webhook" },
"verification_token": { "label": "Token xác minh", "help": "Cho chế độ webhook" },
"webhook_secret": { "label": "Webhook Secret" },
"api_server": { "label": "URL máy chủ API", "help": "Máy chủ API Bot Telegram tùy chỉnh cho tải file lớn (lên đến 2GB). Để trống cho mặc định." },
"proxy": { "label": "Proxy HTTP", "help": "Định tuyến lưu lượng bot qua proxy HTTP" },
"dm_policy": { "label": "Chính sách DM" },
"group_policy": { "label": "Chính sách nhóm" },
"require_mention": { "label": "Yêu cầu @đề cập trong nhóm", "disabledHint": "Chế độ đa bot luôn lắng nghe tất cả tin nhắn" },
"mention_mode": { "label": "Hành vi phản hồi nhóm", "help": "Cách bot quyết định khi nào phản hồi trong nhóm có nhiều bot." },
"history_limit": { "label": "Giới hạn lịch sử nhóm", "help": "Số tin nhắn nhóm chờ tối đa cho ngữ cảnh (0 = tắt)" },
"dm_stream": { "label": "Stream DM", "help": "Truyền phản hồi liên tục trong DM" },
"group_stream": { "label": "Stream nhóm", "help": "Truyền phản hồi liên tục trong nhóm" },
"draft_transport": { "label": "Xem trước nháp", "help": "Dùng bản nháp ẩn cho stream trả lời trong DM — không thông báo mỗi lần chỉnh sửa (cần bật Stream DM)" },
"reasoning_delivery": { "label": "Hiện suy luận", "help": "Chọn cách hiển thị suy luận của model trong tin nhắn channel." },
"reaction_level": { "label": "Mức độ reaction" },
"media_max_mb": { "label": "Kích thước media tối đa (MB)" },
"link_preview": { "label": "Xem trước liên kết" },
"allow_from": { "label": "Người dùng được phép" },
"token": {
"label": "Bot Token",
"help": "Từ @BotFather"
},
"bot_token": {
"label": "Bot Token"
},
"app_token": {
"label": "Token cấp ứng dụng",
"help": "Token cấp ứng dụng với scope connections:write (bắt buộc cho Socket Mode)"
},
"user_token": {
"label": "Token người dùng (Tùy chọn)",
"help": "Tùy chọn: Token OAuth người dùng cho danh tính bot tùy chỉnh. Để trống để dùng danh tính bot mặc định."
},
"app_id": {
"label": "App ID"
},
"app_secret": {
"label": "App Secret"
},
"encrypt_key": {
"label": "Khóa mã hóa",
"help": "Cho chế độ webhook"
},
"verification_token": {
"label": "Token xác minh",
"help": "Cho chế độ webhook"
},
"webhook_secret": {
"label": "Webhook Secret"
},
"api_server": {
"label": "URL máy chủ API",
"help": "Máy chủ API Bot Telegram tùy chỉnh cho tải file lớn (lên đến 2GB). Để trống cho mặc định."
},
"proxy": {
"label": "Proxy HTTP",
"help": "Định tuyến lưu lượng bot qua proxy HTTP"
},
"dm_policy": {
"label": "Chính sách DM"
},
"group_policy": {
"label": "Chính sách nhóm"
},
"require_mention": {
"label": "Yêu cầu @đề cập trong nhóm",
"disabledHint": "Chế độ đa bot luôn lắng nghe tất cả tin nhắn"
},
"mention_mode": {
"label": "Hành vi phản hồi nhóm",
"help": "Cách bot quyết định khi nào phản hồi trong nhóm có nhiều bot."
},
"history_limit": {
"label": "Giới hạn lịch sử nhóm",
"help": "Số tin nhắn nhóm chờ tối đa cho ngữ cảnh (0 = tắt)"
},
"dm_stream": {
"label": "Stream DM",
"help": "Truyền phản hồi liên tục trong DM"
},
"group_stream": {
"label": "Stream nhóm",
"help": "Truyền phản hồi liên tục trong nhóm"
},
"draft_transport": {
"label": "Xem trước nháp",
"help": "Dùng bản nháp ẩn cho stream trả lời trong DM — không thông báo mỗi lần chỉnh sửa (cần bật Stream DM)"
},
"reasoning_delivery": {
"label": "Hiện suy luận",
"help": "Chọn cách hiển thị suy luận của model trong tin nhắn channel."
},
"reaction_level": {
"label": "Mức độ reaction"
},
"media_max_mb": {
"label": "Kích thước media tối đa (MB)"
},
"link_preview": {
"label": "Xem trước liên kết"
},
"allow_from": {
"label": "Người dùng được phép"
},
"telegram_manager": {
"enabled": {
"label": "Bật công cụ quản trị Telegram",
@@ -336,21 +412,62 @@
"help": "Chọn các thao tác Telegram mà agent được phép dùng. Nếu chưa chọn quyền nào thì tool không được cấp cho agent."
}
},
"domain": { "label": "Tên miền" },
"connection_mode": { "label": "Chế độ kết nối", "help": "WebSocket không cần IP công khai — chỉ kết nối ra ngoài" },
"webhook_port": { "label": "Cổng webhook", "help": "0 = chia sẻ cổng gateway chính (khuyến nghị)" },
"webhook_path": { "label": "Đường dẫn webhook", "help": "Đường dẫn trên máy chủ chính cho sự kiện Lark" },
"webhook_url": { "label": "URL Webhook" },
"topic_session_mode": { "label": "Chế độ phiên chủ đề", "help": "Dùng root_id của luồng để cách ly phiên" },
"render_mode": { "label": "Chế độ hiển thị" },
"text_chunk_limit": { "label": "Giới hạn ký tự", "help": "Số ký tự tối đa mỗi tin nhắn" },
"group_allow_from": { "label": "Người dùng nhóm được phép", "help": "Danh sách cho phép riêng cho người gửi nhóm" },
"native_stream": { "label": "Stream gốc (Agents & AI Apps)", "help": "Dùng API ChatStreamer của Slack cho stream gốc. Quay về chỉnh sửa tại chỗ nếu không khả dụng." },
"debounce_delay": { "label": "Độ trễ debounce (ms)", "help": "Mili giây chờ trước khi gửi tin nhắn nhanh. Đặt 0 để tắt." },
"thread_ttl": { "label": "TTL tham gia luồng (giờ)", "help": "Số giờ trước khi bot ngừng tự động trả lời trong các luồng đã tham gia. 0 = luôn yêu cầu @đề cập." },
"skills": { "label": "Bộ lọc skill", "help": "Giới hạn skill khả dụng cho nhóm này" },
"tools": { "label": "Danh sách công cụ được phép", "help": "Giới hạn công cụ agent có thể dùng trong nhóm này" },
"system_prompt": { "label": "Prompt hệ thống" },
"domain": {
"label": "Tên miền"
},
"connection_mode": {
"label": "Chế độ kết nối",
"help": "WebSocket không cần IP công khai — chỉ kết nối ra ngoài"
},
"webhook_port": {
"label": "Cổng webhook",
"help": "0 = chia sẻ cổng gateway chính (khuyến nghị)"
},
"webhook_path": {
"label": "Đường dẫn webhook",
"help": "Đường dẫn trên máy chủ chính cho sự kiện Lark"
},
"webhook_url": {
"label": "URL Webhook"
},
"topic_session_mode": {
"label": "Chế độ phiên chủ đề",
"help": "Dùng root_id của luồng để cách ly phiên"
},
"render_mode": {
"label": "Chế độ hiển thị"
},
"text_chunk_limit": {
"label": "Giới hạn ký tự",
"help": "Số ký tự tối đa mỗi tin nhắn"
},
"group_allow_from": {
"label": "Người dùng nhóm được phép",
"help": "Danh sách cho phép riêng cho người gửi nhóm"
},
"native_stream": {
"label": "Stream gốc (Agents & AI Apps)",
"help": "Dùng API ChatStreamer của Slack cho stream gốc. Quay về chỉnh sửa tại chỗ nếu không khả dụng."
},
"debounce_delay": {
"label": "Độ trễ debounce (ms)",
"help": "Mili giây chờ trước khi gửi tin nhắn nhanh. Đặt 0 để tắt."
},
"thread_ttl": {
"label": "TTL tham gia luồng (giờ)",
"help": "Số giờ trước khi bot ngừng tự động trả lời trong các luồng đã tham gia. 0 = luôn yêu cầu @đề cập."
},
"skills": {
"label": "Bộ lọc skill",
"help": "Giới hạn skill khả dụng cho nhóm này"
},
"tools": {
"label": "Danh sách công cụ được phép",
"help": "Giới hạn công cụ agent có thể dùng trong nhóm này"
},
"system_prompt": {
"label": "Prompt hệ thống"
},
"chat_behavior": {
"enabled": {
"label": "Hành vi gửi tin tự nhiên",
@@ -423,8 +540,14 @@
}
}
},
"platform": { "label": "Nền tảng", "help": "Chọn nền tảng mà trang Pancake này phục vụ." },
"tiktok_type": { "label": "Loại tài khoản TikTok", "help": "Chọn loại tài khoản TikTok cho trang này" },
"platform": {
"label": "Nền tảng",
"help": "Chọn nền tảng mà trang Pancake này phục vụ."
},
"tiktok_type": {
"label": "Loại tài khoản TikTok",
"help": "Chọn loại tài khoản TikTok cho trang này"
},
"features.private_reply": {
"label": "Private Reply (Comment → DM)",
"help": "Gửi tin nhắn riêng cho người bình luận sau khi đã trả lời công khai. Chỉ Facebook/Instagram. Meta cho phép DM trong vòng 7 ngày kể từ khi có bình luận."
@@ -529,17 +652,17 @@
"card": "Thẻ"
},
"platform": {
"facebook": "Facebook",
"instagram": "Instagram",
"threads": "Threads (Beta)",
"tiktok": "TikTok",
"youtube": "YouTube (Beta)",
"shopee": "Shopee",
"line": "Line",
"google": "Google",
"facebook": "Facebook",
"instagram": "Instagram",
"threads": "Threads (Beta)",
"tiktok": "TikTok",
"youtube": "YouTube (Beta)",
"shopee": "Shopee",
"line": "Line",
"google": "Google",
"chat_plugin": "Chat Plugin (Website)",
"lazada": "Lazada",
"tokopedia": "Tokopedia"
"lazada": "Lazada",
"tokopedia": "Tokopedia"
}
},
"groupOverrides": {
@@ -555,10 +678,22 @@
"addTopicPlaceholder": "ID luồng chủ đề",
"addTopic": "Thêm chủ đề",
"fieldConfig": {
"dm_stream": { "label": "Stream DM", "help": "Truyền phản hồi liên tục trong DM" },
"group_stream": { "label": "Stream Nhóm", "help": "Truyền phản hồi liên tục trong nhóm" },
"draft_transport": { "label": "Xem trước nháp", "help": "Dùng bản nháp ẩn cho stream trả lời trong DM — không thông báo mỗi lần chỉnh sửa (cần bật Stream DM)" },
"reasoning_delivery": { "label": "Hiện suy luận", "help": "Chọn cách hiển thị suy luận của model trong tin nhắn channel." }
"dm_stream": {
"label": "Stream DM",
"help": "Truyền phản hồi liên tục trong DM"
},
"group_stream": {
"label": "Stream Nhóm",
"help": "Truyền phản hồi liên tục trong nhóm"
},
"draft_transport": {
"label": "Xem trước nháp",
"help": "Dùng bản nháp ẩn cho stream trả lời trong DM — không thông báo mỗi lần chỉnh sửa (cần bật Stream DM)"
},
"reasoning_delivery": {
"label": "Hiện suy luận",
"help": "Chọn cách hiển thị suy luận của model trong tin nhắn channel."
}
},
"fields": {
"groupPolicy": "Chính sách nhóm",
+4 -24
View File
@@ -11,14 +11,12 @@
"failedToSave": "Lưu cấu hình thất bại",
"saveConfig": "Lưu cấu hình",
"usingGlobalDefaults": "Đang dùng cài đặt mặc định",
"tabs.server": "Máy chủ",
"tabs.behavior": "Hành vi",
"tabs.aiDefaults": "Mặc định AI",
"tabs.quota": "Hạn mức",
"tabs.tools": "Công cụ",
"tabs.integrations": "Tích hợp",
"server.title": "Máy chủ Gateway",
"server.description": "Cài đặt mạng, token xác thực và kiểm soát truy cập",
"branding.title": "Cài đặt giao diện",
@@ -53,7 +51,6 @@
"branding.uploadFailed": "Tải asset thương hiệu thất bại",
"branding.uploadErrors.too_large": "Tệp phải nhỏ hơn hoặc bằng 2 MB.",
"branding.uploadErrors.unsupported_type": "Dùng tệp SVG, PNG, JPG, WEBP hoặc ICO.",
"gateway.host": "Host",
"gateway.hostTip": "Giao diện mạng mà gateway lắng nghe. Dùng 0.0.0.0 để lắng nghe trên tất cả giao diện.",
"gateway.port": "Cổng",
@@ -74,7 +71,6 @@
"gateway.inboundDebounceMs": "Debounce đầu vào (ms)",
"gateway.inboundDebounceMsTip": "Độ trễ tính bằng mili giây trước khi xử lý các tin nhắn nhanh liên tiếp từ channel hoặc Web Chat. Đặt 0 để không debounce; giá trị dương sẽ gộp tin nhắn sau thời gian chờ đó.",
"gateway.injectionAction": "Hành động phát hiện injection",
"agents.title": "Mặc định agent",
"agents.description": "Provider LLM, model và cài đặt runtime mặc định áp dụng cho tất cả agent trừ khi được ghi đè",
"agents.providerTip": "Provider LLM mặc định dùng khi agent không chỉ định provider.",
@@ -93,7 +89,6 @@
"agents.restrictToWorkspaceTip": "Ngăn agent đọc hoặc ghi tệp ngoài đường dẫn workspace.",
"agents.intentClassify": "Phân loại tin nhắn khi agent đang bận",
"agents.saveError": "Lưu mặc định agent thất bại",
"agents.subagents.title": "Subagent",
"agents.subagents.desc": "Giới hạn tạo subagent",
"agents.subagents.maxConcurrent": "Tối đa đồng thời",
@@ -107,7 +102,6 @@
"agents.subagents.modelOverride": "Ghi đè model",
"agents.subagents.modelOverrideTip": "Buộc tất cả subagent dùng model cụ thể. Để trống để kế thừa từ agent cha.",
"agents.subagents.modelOverridePlaceholder": "(kế thừa từ agent)",
"agents.memory.title": "Bộ nhớ",
"agents.memory.desc": "Tìm kiếm bộ nhớ ngữ nghĩa và nhúng",
"agents.memory.enabled": "Đã bật",
@@ -123,7 +117,6 @@
"agents.memory.chunkOverlapTip": "Số ký tự chồng lấp giữa các chunk để giữ ngữ cảnh.",
"agents.memory.minScore": "Điểm tối thiểu",
"agents.memory.minScoreTip": "Điểm tương đồng tối thiểu (0-1) để đưa mục bộ nhớ vào kết quả.",
"agents.compaction.title": "Nén ngữ cảnh",
"agents.compaction.desc": "Cài đặt nén cửa sổ ngữ cảnh",
"agents.compaction.reserveTokensFloor": "Sàn token dự trữ",
@@ -132,13 +125,11 @@
"agents.compaction.maxHistoryShareTip": "Tỷ lệ cửa sổ ngữ cảnh mà lịch sử cuộc trò chuyện có thể chiếm.",
"agents.compaction.timeoutSeconds": "Thời gian chờ (giây)",
"agents.compaction.timeoutSecondsTip": "Thời gian tối đa chờ nén ngữ cảnh trước khi giữ nguyên lịch sử cũ.",
"agents.pruning.title": "Cắt bớt ngữ cảnh",
"agents.pruning.desc": "Cắt bỏ kết quả công cụ cũ để giải phóng không gian ngữ cảnh",
"agents.pruning.mode": "Chế độ",
"agents.pruning.keepLastAssistants": "Giữ trợ lý cuối",
"agents.pruning.keepLastAssistantsTip": "Số lượt trợ lý gần nhất luôn được giữ lại trong quá trình cắt bớt.",
"agents.sandbox.title": "Hộp cát",
"agents.sandbox.desc": "Hộp cát Docker để thực thi code",
"agents.sandbox.mode": "Chế độ",
@@ -151,7 +142,6 @@
"agents.sandbox.timeoutSec": "Hết giờ (giây)",
"agents.sandbox.timeoutSecTip": "Thời gian thực thi tối đa tính bằng giây trước khi hộp cát bị dừng.",
"agents.sandbox.networkEnabled": "Bật mạng",
"behavior.uxTitle": "Hành vi giao diện",
"behavior.uxDescription": "Các công tắc có tác động lớn thay đổi cách gateway tương tác với người dùng",
"behavior.intentClassifyHint": "Khi agent đang xử lý một lượt trước đó, phân loại tin nhắn mới thành hỏi trạng thái, huỷ, bổ sung hướng dẫn hoặc yêu cầu mới.",
@@ -224,12 +214,10 @@
"behavior.scrubCredentialsHint": "Tự động ẩn khóa API, token và mật khẩu tìm thấy trong đầu ra công cụ trước khi trả về agent.",
"behavior.scrubCredentialsInfo": "Các mẫu thông tin xác thực trong phản hồi công cụ được che đi trước khi agent nhìn thấy.",
"behavior.scrubCredentialsOffInfo": "Tính năng ẩn thông tin xác thực bị tắt. Bí mật trong đầu ra công cụ có thể bị lộ với agent.",
"sessions.scope": "Phạm vi phiên",
"sessions.scopeTip": "Xác định cách phân vùng phiên. 'Theo người gửi' cho mỗi người dùng một phiên riêng; 'Toàn cục' chia sẻ một phiên cho tất cả người dùng.",
"sessions.dmScope": "Phạm vi tin nhắn riêng",
"sessions.dmScopeTip": "Cách đặt khóa phiên tin nhắn riêng. 'Theo kênh và đối tác' là mặc định được khuyến nghị.",
"quota.title": "Hạn mức sử dụng",
"quota.description": "Giới hạn hạn mức tin nhắn mỗi người dùng với tùy chọn ghi đè theo provider, kênh và nhóm",
"quota.enabled": "Bật hạn mức",
@@ -254,7 +242,6 @@
"quota.selectProvider": "Chọn provider",
"quota.selectChannel": "Chọn kênh",
"quota.selectGroup": "Chọn nhóm",
"tools.title": "Hồ sơ công cụ",
"tools.description": "Tính khả dụng công cụ mặc định và giới hạn tốc độ gọi mỗi giờ cho tất cả agent",
"tools.profile": "Hồ sơ",
@@ -282,7 +269,6 @@
"tools.commandKeywordAllowlistJsonError": "JSON không hợp lệ.",
"tools.commandKeywordAllowlistArrayError": "Dùng mảng JSON các rule.",
"tools.scrubCredentials": "Ẩn thông tin xác thực",
"browser.title": "Tự động hóa trình duyệt",
"browser.description": "Cài đặt runtime cho công cụ trình duyệt phía máy chủ",
"browser.enabled": "Đã bật",
@@ -299,14 +285,12 @@
"browser.idleTimeoutTip": "Đóng trang không hoạt động sau thời lượng này. Đặt 0 để tắt.",
"browser.cookieSync": "Đồng bộ cookie đã chọn",
"browser.cookieSyncHint": "Áp dụng cookie được đồng bộ rõ ràng vào phiên trình duyệt đúng người dùng và agent.",
"tts.title": "Chuyển văn bản thành giọng nói",
"tts.configured": "Đã cấu hình",
"tts.disabled": "Đã tắt",
"tts.providerInfo": "Provider: {{provider}} — Tự động: {{auto}}",
"tts.noProvider": "Chưa cấu hình provider TTS.",
"tts.manageLink": "Quản lý cài đặt TTS",
"cron.title": "Trình lập lịch Cron",
"cron.description": "Hành vi thử lại cho các tác vụ định kỳ thất bại",
"cron.maxRetries": "Số lần thử lại tối đa",
@@ -318,7 +302,6 @@
"cron.defaultTimezone": "Múi giờ mặc định",
"cron.defaultTimezoneTip": "Múi giờ IANA cho biểu thức cron khi không được đặt cho từng job (vd: Asia/Ho_Chi_Minh). Để trống sẽ dùng múi giờ hệ thống.",
"cron.defaultTimezonePlaceholder": "Múi giờ hệ thống",
"telemetry.title": "Telemetry (OpenTelemetry)",
"telemetry.description": "Xuất trace và metric sang backend tương thích OpenTelemetry",
"telemetry.enabled": "Đã bật",
@@ -336,7 +319,6 @@
"telemetry.headerKeyPlaceholder": "Tên header",
"telemetry.headerValuePlaceholder": "Giá trị header",
"telemetry.addHeader": "Thêm header",
"bindings.title": "Ràng buộc agent",
"bindings.description": "Định tuyến kênh, đối tác hoặc tài khoản cụ thể đến agent được chỉ định",
"bindings.add": "Thêm ràng buộc",
@@ -345,7 +327,6 @@
"bindings.channel": "Kênh",
"bindings.peerKind": "Loại đối tác",
"bindings.peerId": "ID đối tác",
"providers.title": "Khóa API provider",
"providers.description": "Ghi đè khóa API và URL cơ sở cho các provider LLM đã cấu hình",
"providers.noProviders": "Không tìm thấy provider nào trong tệp cấu hình.",
@@ -353,7 +334,6 @@
"providers.apiBaseUrl": "URL cơ sở",
"providers.apiBaseUrlPlaceholder": "https://api.example.com/v1",
"providers.managedVia": "Được quản lý qua biến môi trường {{envKey}}.",
"channels.title": "Cài đặt kênh",
"channels.description": "Cấu hình tích hợp kênh tích hợp sẵn (token, chính sách, streaming)",
"channels.noChannels": "Không tìm thấy kênh nào trong tệp cấu hình.",
@@ -383,9 +363,7 @@
"channels.reactionFull": "Đầy đủ",
"channels.connectionWebsocket": "WebSocket",
"channels.connectionWebhook": "Webhook",
"bindings.any": "Bất kỳ",
"tabs.systemMessages": "Thông báo hệ thống",
"systemMessages.title": "Tùy chỉnh thông báo hệ thống",
"systemMessages.description": "Ghi đè các thông báo mà gateway gửi trực tiếp qua kênh",
@@ -404,9 +382,11 @@
"systemMessages.defaultTemplate": "Mẫu mặc định",
"systemMessages.defaultTemplateTip": "Mẫu có sẵn được dùng khi phần ghi đè để trống.",
"systemMessages.useDefault": "Dùng mặc định",
"toast": {
"saved": "Đã lưu cấu hình",
"saveFailed": "Không thể lưu cấu hình"
}
},
"behavior.passiveMemoryTitle": "Trích xuất bộ nhớ thụ động",
"behavior.passiveMemoryDescription": "Prompt trích xuất cấp tenant cho bộ nhớ thụ động từ channel.",
"behavior.passiveMemoryPromptPlaceholder": "Ví dụ: Tránh fact trùng lặp và gộp các cập nhật lặp lại thành một mục ngắn gọn."
}
+191 -56
View File
@@ -111,6 +111,12 @@
"accessControlDesc": "用户访问限制",
"telegramManagement": "Telegram 管理",
"telegramManagementDesc": "为使用此频道的 Agent 启用隐藏工具并选择 Telegram 操作权限",
"discordMetadata": "Discord 元数据",
"discordMetadataDesc": "从正在运行的机器人刷新缓存的 Discord 频道、分类、线程和已知用户名称。",
"refreshDiscordMetadata": "刷新 Discord 元数据",
"refreshingDiscordMetadata": "刷新中...",
"discordMetadataRefreshed": "Discord 元数据已刷新",
"discordMetadataRefreshFailed": "刷新 Discord 元数据失败",
"reviewDiagnostics": "在更改设置前,请先查看此 Channel 的最新诊断。",
"whatHappened": "发生了什么",
"recommendedAction": "建议操作",
@@ -213,6 +219,7 @@
"types": "记忆类型",
"excludeUsers": "排除用户",
"excludePatterns": "排除规则",
"channelCustomPrompt": "频道提取指令",
"excludeGroups": "排除的 Discord 频道",
"excludeGroupsPlaceholder": "选择要排除的频道",
"manualExcludePlaceholder": "粘贴 Discord 频道 ID",
@@ -245,6 +252,10 @@
"approve": "批准",
"reject": "拒绝",
"delete": "删除",
"debugBadge": {
"topic": "主题",
"entity": "实体"
},
"type": {
"people": "人物",
"projects": "项目",
@@ -252,7 +263,10 @@
"todos": "待办",
"preferences": "偏好",
"events": "事件"
}
},
"groupCustomPrompt": "群组提取指令",
"groupCustomPromptPlaceholder": "选择要调优的群组",
"noPromptGroups": "尚未发现 Discord 频道历史。"
},
"timeline": {
"title": "时间线",
@@ -302,30 +316,92 @@
}
},
"fieldConfig": {
"token": { "label": "Bot Token", "help": "来自 @BotFather" },
"bot_token": { "label": "Bot Token" },
"app_token": { "label": "应用级Token", "help": "具有 connections:write 权限的应用级Token(Socket Mode 必需)" },
"user_token": { "label": "用户Token(可选)", "help": "可选:用于自定义Bot身份的用户OAuth Token。留空使用默认Bot身份。" },
"app_id": { "label": "App ID" },
"app_secret": { "label": "App Secret" },
"encrypt_key": { "label": "加密密钥", "help": "用于Webhook模式" },
"verification_token": { "label": "验证Token", "help": "用于Webhook模式" },
"webhook_secret": { "label": "Webhook Secret" },
"api_server": { "label": "API 服务器 URL", "help": "自定义 Telegram Bot API 服务器用于大文件上传(最大 2GB)。留空使用默认服务器。" },
"proxy": { "label": "HTTP 代理", "help": "通过 HTTP 代理路由Bot流量" },
"dm_policy": { "label": "私聊策略" },
"group_policy": { "label": "群组策略" },
"require_mention": { "label": "群组中需要 @提及", "disabledHint": "多机器人模式始终监听所有消息" },
"mention_mode": { "label": "群组响应行为", "help": "机器人在多机器人群组中如何决定何时响应。" },
"history_limit": { "label": "群组历史限制", "help": "上下文中最大待处理群组消息数(0 = 禁用)" },
"dm_stream": { "label": "私聊流式传输", "help": "在私聊中逐步传输回复" },
"group_stream": { "label": "群聊流式传输", "help": "在群聊中逐步传输回复" },
"draft_transport": { "label": "草稿预览", "help": "在私聊中使用隐形草稿预览回答流 — 每次编辑无通知(需启用私聊流式传输)" },
"reasoning_delivery": { "label": "显示推理", "help": "选择如何在频道消息中显示模型推理。" },
"reaction_level": { "label": "表情回应级别" },
"media_max_mb": { "label": "最大媒体大小 (MB)" },
"link_preview": { "label": "链接预览" },
"allow_from": { "label": "允许的用户" },
"token": {
"label": "Bot Token",
"help": "来自 @BotFather"
},
"bot_token": {
"label": "Bot Token"
},
"app_token": {
"label": "应用级Token",
"help": "具有 connections:write 权限的应用级Token(Socket Mode 必需)"
},
"user_token": {
"label": "用户Token(可选)",
"help": "可选:用于自定义Bot身份的用户OAuth Token。留空使用默认Bot身份。"
},
"app_id": {
"label": "App ID"
},
"app_secret": {
"label": "App Secret"
},
"encrypt_key": {
"label": "加密密钥",
"help": "用于Webhook模式"
},
"verification_token": {
"label": "验证Token",
"help": "用于Webhook模式"
},
"webhook_secret": {
"label": "Webhook Secret"
},
"api_server": {
"label": "API 服务器 URL",
"help": "自定义 Telegram Bot API 服务器用于大文件上传(最大 2GB)。留空使用默认服务器。"
},
"proxy": {
"label": "HTTP 代理",
"help": "通过 HTTP 代理路由Bot流量"
},
"dm_policy": {
"label": "私聊策略"
},
"group_policy": {
"label": "群组策略"
},
"require_mention": {
"label": "群组中需要 @提及",
"disabledHint": "多机器人模式始终监听所有消息"
},
"mention_mode": {
"label": "群组响应行为",
"help": "机器人在多机器人群组中如何决定何时响应。"
},
"history_limit": {
"label": "群组历史限制",
"help": "上下文中最大待处理群组消息数(0 = 禁用)"
},
"dm_stream": {
"label": "私聊流式传输",
"help": "在私聊中逐步传输回复"
},
"group_stream": {
"label": "群聊流式传输",
"help": "在群聊中逐步传输回复"
},
"draft_transport": {
"label": "草稿预览",
"help": "在私聊中使用隐形草稿预览回答流 — 每次编辑无通知(需启用私聊流式传输)"
},
"reasoning_delivery": {
"label": "显示推理",
"help": "选择如何在频道消息中显示模型推理。"
},
"reaction_level": {
"label": "表情回应级别"
},
"media_max_mb": {
"label": "最大媒体大小 (MB)"
},
"link_preview": {
"label": "链接预览"
},
"allow_from": {
"label": "允许的用户"
},
"telegram_manager": {
"enabled": {
"label": "启用 Telegram 管理工具",
@@ -336,21 +412,62 @@
"help": "选择 Agent 可执行的 Telegram 操作。未选择任何权限时,不会向 Agent 授予此工具。"
}
},
"domain": { "label": "域名" },
"connection_mode": { "label": "连接模式", "help": "WebSocket 无需公网 IP — 仅需出站连接" },
"webhook_port": { "label": "Webhook 端口", "help": "0 = 共享主网关端口(推荐)" },
"webhook_path": { "label": "Webhook 路径", "help": "主服务器上 Lark 事件的路径" },
"webhook_url": { "label": "Webhook URL" },
"topic_session_mode": { "label": "话题会话模式", "help": "使用线程 root_id 进行会话隔离" },
"render_mode": { "label": "渲染模式" },
"text_chunk_limit": { "label": "文本分段限制", "help": "每条消息最大字符数" },
"group_allow_from": { "label": "群组允许的用户", "help": "群组发送者的单独白名单" },
"native_stream": { "label": "原生流式传输 (Agents & AI Apps)", "help": "使用 Slack 的 ChatStreamer API 进行原生流式传输。不可用时回退到原地编辑。" },
"debounce_delay": { "label": "防抖延迟 (ms)", "help": "发送快速消息前等待的毫秒数。设为 0 禁用。" },
"thread_ttl": { "label": "线程参与 TTL(小时)", "help": "Bot 停止自动回复已参与线程的小时数。0 = 始终需要 @提及。" },
"skills": { "label": "Skill过滤", "help": "限制此群组可用的Skill" },
"tools": { "label": "工具白名单", "help": "限制Agent在此群组中可使用的工具" },
"system_prompt": { "label": "系统提示词" },
"domain": {
"label": "域名"
},
"connection_mode": {
"label": "连接模式",
"help": "WebSocket 无需公网 IP — 仅需出站连接"
},
"webhook_port": {
"label": "Webhook 端口",
"help": "0 = 共享主网关端口(推荐)"
},
"webhook_path": {
"label": "Webhook 路径",
"help": "主服务器上 Lark 事件的路径"
},
"webhook_url": {
"label": "Webhook URL"
},
"topic_session_mode": {
"label": "话题会话模式",
"help": "使用线程 root_id 进行会话隔离"
},
"render_mode": {
"label": "渲染模式"
},
"text_chunk_limit": {
"label": "文本分段限制",
"help": "每条消息最大字符数"
},
"group_allow_from": {
"label": "群组允许的用户",
"help": "群组发送者的单独白名单"
},
"native_stream": {
"label": "原生流式传输 (Agents & AI Apps)",
"help": "使用 Slack 的 ChatStreamer API 进行原生流式传输。不可用时回退到原地编辑。"
},
"debounce_delay": {
"label": "防抖延迟 (ms)",
"help": "发送快速消息前等待的毫秒数。设为 0 禁用。"
},
"thread_ttl": {
"label": "线程参与 TTL(小时)",
"help": "Bot 停止自动回复已参与线程的小时数。0 = 始终需要 @提及。"
},
"skills": {
"label": "Skill过滤",
"help": "限制此群组可用的Skill"
},
"tools": {
"label": "工具白名单",
"help": "限制Agent在此群组中可使用的工具"
},
"system_prompt": {
"label": "系统提示词"
},
"chat_behavior": {
"enabled": {
"label": "自然投递行为",
@@ -423,8 +540,14 @@
}
}
},
"platform": { "label": "平台", "help": "选择此 Pancake 页面所服务的平台。" },
"tiktok_type": { "label": "TikTok 类型", "help": "选择此页面的 TikTok 账户类型" },
"platform": {
"label": "平台",
"help": "选择此 Pancake 页面所服务的平台。"
},
"tiktok_type": {
"label": "TikTok 类型",
"help": "选择此页面的 TikTok 账户类型"
},
"features.private_reply": {
"label": "私信回复(评论 → 私信)",
"help": "在公开回复后向评论者发送一次性私信。仅支持 Facebook/Instagram,Meta 允许在评论后 7 天内发送。"
@@ -529,17 +652,17 @@
"card": "卡片"
},
"platform": {
"facebook": "Facebook",
"instagram": "Instagram",
"threads": "Threads (测试版)",
"tiktok": "TikTok",
"youtube": "YouTube (测试版)",
"shopee": "Shopee",
"line": "Line",
"google": "Google",
"facebook": "Facebook",
"instagram": "Instagram",
"threads": "Threads (测试版)",
"tiktok": "TikTok",
"youtube": "YouTube (测试版)",
"shopee": "Shopee",
"line": "Line",
"google": "Google",
"chat_plugin": "Chat Plugin",
"lazada": "Lazada",
"tokopedia": "Tokopedia"
"lazada": "Lazada",
"tokopedia": "Tokopedia"
}
},
"groupOverrides": {
@@ -555,10 +678,22 @@
"addTopicPlaceholder": "话题线程 ID",
"addTopic": "添加话题",
"fieldConfig": {
"dm_stream": { "label": "私聊流式传输", "help": "在私聊中逐步传输回复" },
"group_stream": { "label": "群聊流式传输", "help": "在群聊中逐步传输回复" },
"draft_transport": { "label": "草稿预览", "help": "在私聊中使用隐形草稿预览回答流 — 每次编辑无通知(需启用私聊流式传输)" },
"reasoning_delivery": { "label": "显示推理", "help": "选择如何在频道消息中显示模型推理。" }
"dm_stream": {
"label": "私聊流式传输",
"help": "在私聊中逐步传输回复"
},
"group_stream": {
"label": "群聊流式传输",
"help": "在群聊中逐步传输回复"
},
"draft_transport": {
"label": "草稿预览",
"help": "在私聊中使用隐形草稿预览回答流 — 每次编辑无通知(需启用私聊流式传输)"
},
"reasoning_delivery": {
"label": "显示推理",
"help": "选择如何在频道消息中显示模型推理。"
}
},
"fields": {
"groupPolicy": "群组策略",
+4 -24
View File
@@ -11,14 +11,12 @@
"failedToSave": "保存配置失败",
"saveConfig": "保存配置",
"usingGlobalDefaults": "使用全局默认值",
"tabs.server": "服务器",
"tabs.behavior": "行为",
"tabs.aiDefaults": "AI 默认值",
"tabs.quota": "配额",
"tabs.tools": "工具",
"tabs.integrations": "集成",
"server.title": "网关服务器",
"server.description": "网络绑定、认证令牌和访问控制",
"branding.title": "界面设置",
@@ -53,7 +51,6 @@
"branding.uploadFailed": "品牌资源上传失败",
"branding.uploadErrors.too_large": "文件必须不超过 2 MB。",
"branding.uploadErrors.unsupported_type": "请使用 SVG、PNG、JPG、WEBP 或 ICO 文件。",
"gateway.host": "主机",
"gateway.hostTip": "网关监听的网络接口。使用 0.0.0.0 监听所有接口。",
"gateway.port": "端口",
@@ -74,7 +71,6 @@
"gateway.inboundDebounceMs": "入站防抖(毫秒)",
"gateway.inboundDebounceMsTip": "处理来自频道或 Web Chat 的快速连续消息前的延迟毫秒数。设为 0 表示不防抖;正数会在该等待时间后合并连续消息。",
"gateway.injectionAction": "注入检测动作",
"agents.title": "Agent 默认值",
"agents.description": "默认 LLM 提供商、模型和运行时设置,适用于所有 Agent(除非被覆盖)",
"agents.providerTip": "当 Agent 未指定提供商时使用的默认 LLM 提供商。",
@@ -93,7 +89,6 @@
"agents.restrictToWorkspaceTip": "阻止 Agent 读写工作区路径以外的文件。",
"agents.intentClassify": "Agent 忙碌时的消息分类",
"agents.saveError": "保存 Agent 默认值失败",
"agents.subagents.title": "子 Agent",
"agents.subagents.desc": "子 Agent 生成限制",
"agents.subagents.maxConcurrent": "最大并发数",
@@ -107,7 +102,6 @@
"agents.subagents.modelOverride": "模型覆盖",
"agents.subagents.modelOverrideTip": "强制所有子 Agent 使用特定模型。留空则从父 Agent 继承。",
"agents.subagents.modelOverridePlaceholder": "(从 Agent 继承)",
"agents.memory.title": "记忆",
"agents.memory.desc": "语义记忆搜索和嵌入",
"agents.memory.enabled": "已启用",
@@ -123,7 +117,6 @@
"agents.memory.chunkOverlapTip": "连续块之间的重叠字符数,用于保持上下文连续性。",
"agents.memory.minScore": "最低分数",
"agents.memory.minScoreTip": "记忆条目被纳入结果的最低相似度分数(0-1)。",
"agents.compaction.title": "上下文压缩",
"agents.compaction.desc": "上下文窗口压缩设置",
"agents.compaction.reserveTokensFloor": "保留 Token 下限",
@@ -132,13 +125,11 @@
"agents.compaction.maxHistoryShareTip": "对话历史可占用的上下文窗口比例。",
"agents.compaction.timeoutSeconds": "超时秒数",
"agents.compaction.timeoutSecondsTip": "等待上下文压缩的最长时间,超时后保留原始历史。",
"agents.pruning.title": "上下文裁剪",
"agents.pruning.desc": "裁剪旧工具结果以释放上下文空间",
"agents.pruning.mode": "模式",
"agents.pruning.keepLastAssistants": "保留最后的助手轮次",
"agents.pruning.keepLastAssistantsTip": "裁剪期间始终保留的最近助手轮次数量。",
"agents.sandbox.title": "沙箱",
"agents.sandbox.desc": "用于代码执行的 Docker 沙箱",
"agents.sandbox.mode": "模式",
@@ -151,7 +142,6 @@
"agents.sandbox.timeoutSec": "超时(秒)",
"agents.sandbox.timeoutSecTip": "沙箱被终止前的最大执行时间(秒)。",
"agents.sandbox.networkEnabled": "启用网络",
"behavior.uxTitle": "界面行为",
"behavior.uxDescription": "影响网关与用户交互方式的高影响开关",
"behavior.intentClassifyHint": "当 Agent 正在处理上一轮请求时,将新消息分类为状态询问、取消、补充指令或新的请求。",
@@ -224,12 +214,10 @@
"behavior.scrubCredentialsHint": "在返回给 Agent 之前,自动从工具输出中删除 API 密钥、令牌和密码。",
"behavior.scrubCredentialsInfo": "工具响应中的凭证模式在 Agent 看到之前被屏蔽。",
"behavior.scrubCredentialsOffInfo": "凭证擦除已禁用,工具输出中的密钥可能暴露给 Agent。",
"sessions.scope": "会话范围",
"sessions.scopeTip": "确定会话分区方式。「按发送者」为每个用户提供单独的会话;「全局」在所有用户之间共享一个会话。",
"sessions.dmScope": "私信范围",
"sessions.dmScopeTip": "私信会话的键控方式。推荐默认使用「按频道对等方」。",
"quota.title": "使用配额",
"quota.description": "每用户消息配额限制,可选按提供商、频道和群组覆盖",
"quota.enabled": "启用配额",
@@ -254,7 +242,6 @@
"quota.selectProvider": "选择提供商",
"quota.selectChannel": "选择频道",
"quota.selectGroup": "选择群组",
"tools.title": "工具配置文件",
"tools.description": "所有 Agent 的默认工具可用性和每小时调用速率限制",
"tools.profile": "配置文件",
@@ -282,7 +269,6 @@
"tools.commandKeywordAllowlistJsonError": "JSON 无效。",
"tools.commandKeywordAllowlistArrayError": "请使用规则 JSON 数组。",
"tools.scrubCredentials": "擦除凭证",
"browser.title": "浏览器自动化",
"browser.description": "服务器端浏览器工具的运行时设置",
"browser.enabled": "已启用",
@@ -299,14 +285,12 @@
"browser.idleTimeoutTip": "页面空闲超过此时间后关闭。设为 0 禁用。",
"browser.cookieSync": "选定 Cookie 同步",
"browser.cookieSyncHint": "将明确同步的 Cookie 应用于匹配的用户和 Agent 浏览器会话。",
"tts.title": "文字转语音",
"tts.configured": "已配置",
"tts.disabled": "已禁用",
"tts.providerInfo": "提供商:{{provider}} — 自动:{{auto}}",
"tts.noProvider": "未配置 TTS 提供商。",
"tts.manageLink": "管理 TTS 设置",
"cron.title": "定时任务调度器",
"cron.description": "失败定时任务的重试行为",
"cron.maxRetries": "最大重试次数",
@@ -318,7 +302,6 @@
"cron.defaultTimezone": "默认时区",
"cron.defaultTimezoneTip": "未为单个任务设置时区时,cron 表达式使用的 IANA 时区(例如 Asia/Shanghai)。留空则使用服务器系统时区。",
"cron.defaultTimezonePlaceholder": "系统时区",
"telemetry.title": "遥测(OpenTelemetry)",
"telemetry.description": "将追踪和指标导出到兼容 OpenTelemetry 的后端",
"telemetry.enabled": "已启用",
@@ -336,7 +319,6 @@
"telemetry.headerKeyPlaceholder": "请求头名称",
"telemetry.headerValuePlaceholder": "请求头值",
"telemetry.addHeader": "添加请求头",
"bindings.title": "Agent 绑定",
"bindings.description": "将特定频道、对等方或账户路由到指定的 Agent",
"bindings.add": "添加绑定",
@@ -345,7 +327,6 @@
"bindings.channel": "频道",
"bindings.peerKind": "对等方类型",
"bindings.peerId": "对等方 ID",
"providers.title": "提供商 API 密钥",
"providers.description": "覆盖已配置 LLM 提供商的 API 密钥和基础 URL",
"providers.noProviders": "配置文件中未找到任何提供商。",
@@ -353,7 +334,6 @@
"providers.apiBaseUrl": "基础 URL",
"providers.apiBaseUrlPlaceholder": "https://api.example.com/v1",
"providers.managedVia": "通过 {{envKey}} 环境变量管理。",
"channels.title": "频道设置",
"channels.description": "配置内置频道集成(令牌、策略、流式传输)",
"channels.noChannels": "配置文件中未找到任何频道。",
@@ -383,9 +363,7 @@
"channels.reactionFull": "完整",
"channels.connectionWebsocket": "WebSocket",
"channels.connectionWebhook": "Webhook",
"bindings.any": "任意",
"tabs.systemMessages": "系统消息",
"systemMessages.title": "自定义系统消息",
"systemMessages.description": "覆盖频道直接发送的内置网关消息",
@@ -404,9 +382,11 @@
"systemMessages.defaultTemplate": "默认模板",
"systemMessages.defaultTemplateTip": "覆盖为空时使用的内置回退模板。",
"systemMessages.useDefault": "使用默认值",
"toast": {
"saved": "配置已保存",
"saveFailed": "保存配置失败"
}
},
"behavior.passiveMemoryTitle": "被动记忆提取",
"behavior.passiveMemoryDescription": "面向租户的频道被动记忆提取指令。",
"behavior.passiveMemoryPromptPlaceholder": "例如:避免重复事实,并将重复更新合并为一条简洁项目。"
}
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Save, Settings, Loader2 } from "lucide-react";
import { RefreshCw, Save, Settings, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -23,6 +23,7 @@ interface ChannelAdvancedDialogProps {
onOpenChange: (open: boolean) => void;
instance: ChannelInstanceData;
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
onRefreshDiscordMetadata: () => Promise<void>;
}
const NETWORK_KEYS = new Set(["api_server", "proxy", "domain", "connection_mode", "webhook_port", "webhook_path", "webhook_url"]);
@@ -50,12 +51,14 @@ export function ChannelAdvancedDialog({
onOpenChange,
instance,
onUpdate,
onRefreshDiscordMetadata,
}: ChannelAdvancedDialogProps) {
const { t } = useTranslation("channels");
const groups = getAdvancedFields(instance.channel_type);
const [values, setValues] = useState<Record<string, unknown>>(() => deriveAdvancedInitialValues(instance.config));
const [saving, setSaving] = useState(false);
const [refreshingMetadata, setRefreshingMetadata] = useState(false);
// Re-sync local state when dialog opens
useEffect(() => {
@@ -78,7 +81,19 @@ export function ChannelAdvancedDialog({
}
};
const handleRefreshMetadata = async () => {
setRefreshingMetadata(true);
try {
await onRefreshDiscordMetadata();
} catch {
// toast shown by hook
} finally {
setRefreshingMetadata(false);
}
};
const hasAnyGroup = Object.values(groups).some((g) => g.length > 0);
const showDiscordMetadataRefresh = instance.channel_type === "discord";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -96,6 +111,26 @@ export function ChannelAdvancedDialog({
<p className="text-sm text-muted-foreground">{t("detail.config.noSchema")}</p>
)}
{showDiscordMetadataRefresh && (
<>
<ConfigGroupHeader
title={t("detail.discordMetadata")}
description={t("detail.discordMetadataDesc")}
/>
<Button
type="button"
variant="outline"
onClick={handleRefreshMetadata}
disabled={refreshingMetadata}
>
{refreshingMetadata ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{refreshingMetadata
? t("detail.refreshingDiscordMetadata")
: t("detail.refreshDiscordMetadata")}
</Button>
</>
)}
{groups.network.length > 0 && (
<>
<ConfigGroupHeader
@@ -190,10 +225,10 @@ export function ChannelAdvancedDialog({
{/* Footer */}
<div className="flex items-center justify-end gap-2 pt-4 border-t shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving || refreshingMetadata}>
{t("form.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
<Button onClick={handleSave} disabled={saving || refreshingMetadata}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? t("form.saving") : t("detail.config.saveConfig")}
</Button>
@@ -17,6 +17,7 @@ interface ChannelDetailDialogsProps {
supportsReauth: boolean;
onDelete?: (instance: { id: string; name: string }) => void;
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
onRefreshDiscordMetadata: () => Promise<void>;
}
export function ChannelDetailDialogs({
@@ -30,6 +31,7 @@ export function ChannelDetailDialogs({
supportsReauth,
onDelete,
onUpdate,
onRefreshDiscordMetadata,
}: ChannelDetailDialogsProps) {
const { t } = useTranslation("channels");
@@ -44,6 +46,7 @@ export function ChannelDetailDialogs({
onOpenChange={setAdvancedOpen}
instance={instance}
onUpdate={onUpdate}
onRefreshDiscordMetadata={onRefreshDiscordMetadata}
/>
{ReauthDialog && (
@@ -70,6 +70,7 @@ export function ChannelDetailPage({
deleteContextGrant,
setContextCredentials,
deleteContextCredentials,
refreshDiscordMetadata,
} = useChannelDetail(instanceId);
const { agents } = useAgents();
const { channels } = useChannels();
@@ -289,6 +290,7 @@ export function ChannelDetailPage({
supportsReauth={supportsReauth}
onDelete={onDelete}
onUpdate={updateInstance}
onRefreshDiscordMetadata={refreshDiscordMetadata}
/>
</div>
);
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
memoryItemDebugBadgeClass,
memoryItemDebugBadges,
memoryItemDebugBadgeTooltipKey,
} from "./passive-memory-section-parts";
import type { ChannelMemoryExtractionItem } from "@/types/channel";
describe("memoryItemDebugBadges", () => {
const baseItem: ChannelMemoryExtractionItem = {
id: "item-1",
run_id: "run-1",
item_type: "projects",
summary: "Project Orion uses the ExampleCo collaboration platform",
confidence: 0.7,
status: "pending_review",
created_at: "2026-07-07T05:34:11Z",
};
it("returns topics and entities as compact debug badges", () => {
const badges = memoryItemDebugBadges({
...baseItem,
topics: ["planning", "design"],
entities: ["Alex Example", "Project Orion"],
});
expect(badges).toEqual([
{ kind: "topic", label: "planning" },
{ kind: "topic", label: "design" },
{ kind: "entity", label: "Alex Example" },
{ kind: "entity", label: "Project Orion" },
]);
});
it("does not emit empty badge groups", () => {
expect(memoryItemDebugBadges({ ...baseItem, topics: [], entities: [] })).toEqual([]);
expect(memoryItemDebugBadges(baseItem)).toEqual([]);
});
it("uses distinct styling and tooltip keys for topics and entities", () => {
expect(memoryItemDebugBadgeClass("topic")).toContain("sky");
expect(memoryItemDebugBadgeClass("entity")).toContain("violet");
expect(memoryItemDebugBadgeClass("topic")).not.toEqual(memoryItemDebugBadgeClass("entity"));
expect(memoryItemDebugBadgeTooltipKey("topic")).toBe("detail.passiveMemory.debugBadge.topic");
expect(memoryItemDebugBadgeTooltipKey("entity")).toBe("detail.passiveMemory.debugBadge.entity");
});
});
@@ -5,9 +5,46 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatDate } from "@/lib/format";
import type { ChannelMemoryExtractionItem } from "@/types/channel";
export type MemoryItemDebugBadge = {
kind: "topic" | "entity";
label: string;
};
export function memoryItemDebugBadges(item: Pick<ChannelMemoryExtractionItem, "topics" | "entities">): MemoryItemDebugBadge[] {
const badges: MemoryItemDebugBadge[] = [];
for (const topic of item.topics ?? []) {
const label = topic.trim();
if (label) badges.push({ kind: "topic", label });
}
for (const entity of item.entities ?? []) {
const label = entity.trim();
if (label) badges.push({ kind: "entity", label });
}
return badges;
}
export function memoryItemDebugBadgeClass(kind: MemoryItemDebugBadge["kind"]): string {
if (kind === "topic") {
return "cursor-help border-sky-500/30 bg-sky-500/10 text-sky-700 dark:border-sky-500/25 dark:bg-sky-500/10 dark:text-sky-300";
}
return "cursor-help border-violet-500/30 bg-violet-500/10 text-violet-700 dark:border-violet-500/25 dark:bg-violet-500/10 dark:text-violet-300";
}
export function memoryItemDebugBadgeTooltipKey(kind: MemoryItemDebugBadge["kind"]): string {
return kind === "topic"
? "detail.passiveMemory.debugBadge.topic"
: "detail.passiveMemory.debugBadge.entity";
}
export function ToggleRow({
label,
checked,
@@ -110,6 +147,7 @@ export function MemoryItemRow({
onAction: (action: "approve" | "reject" | "delete") => void;
}) {
const { t } = useTranslation("channels");
const debugBadges = memoryItemDebugBadges(item);
return (
<div className="rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
@@ -120,6 +158,26 @@ export function MemoryItemRow({
<span className="text-xs text-muted-foreground">{Math.round(item.confidence * 100)}%</span>
</div>
<p className="mt-2 text-sm">{item.summary}</p>
{debugBadges.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
<TooltipProvider>
{debugBadges.map((badge) => (
<Tooltip key={`${badge.kind}:${badge.label}`}>
<TooltipTrigger asChild>
<span className="inline-flex">
<Badge variant="outline" className={memoryItemDebugBadgeClass(badge.kind)}>
{badge.label}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="top">
{t(memoryItemDebugBadgeTooltipKey(badge.kind))}
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
)}
<div className="mt-3 flex flex-wrap gap-2">
{item.status === "pending_review" && (
<>
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { formatPromptGroupLabel } from "./passive-memory-section";
describe("formatPromptGroupLabel", () => {
it("renders child channel with parent title when available", () => {
expect(formatPromptGroupLabel({
history_key: "thread-1",
group_title: "launch-thread",
parent_history_key: "parent-1",
parent_group_title: "product-planning",
})).toBe("launch-thread / product-planning");
});
it("falls back to ids when cached titles are missing", () => {
expect(formatPromptGroupLabel({
history_key: "thread-1",
parent_history_key: "parent-1",
})).toBe("thread-1 / parent-1");
});
});
@@ -3,7 +3,6 @@ import { Brain, ChevronsRight, Play, RefreshCw, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/shared/pagination";
import {
Select,
@@ -34,6 +33,8 @@ const fallbackConfig: ChannelMemoryConfig = {
exclude_users: [],
exclude_patterns: [],
exclude_history_keys: [],
custom_prompt: "",
group_custom_prompts: {},
min_messages: 5,
group_only: true,
};
@@ -43,6 +44,13 @@ interface PassiveMemorySectionProps {
channelType: string;
}
interface PromptGroupOption {
history_key: string;
group_title?: string;
parent_history_key?: string;
parent_group_title?: string;
}
export function PassiveMemorySection({ instanceId, channelType }: PassiveMemorySectionProps) {
const { t } = useTranslation("channels");
const supportsGroupExclude = channelType === "discord";
@@ -61,17 +69,46 @@ export function PassiveMemorySection({ instanceId, channelType }: PassiveMemoryS
itemAction,
} = useChannelMemoryExtraction(instanceId, { page: reviewPage, pageSize: reviewPageSize, loadGroupOptions: supportsGroupExclude });
const [config, setConfig] = useState<ChannelMemoryConfig>(fallbackConfig);
const [channelCustomPrompt, setChannelCustomPrompt] = useState("");
const [excludeUsers, setExcludeUsers] = useState("");
const [excludePatterns, setExcludePatterns] = useState("");
const [manualHistoryKey, setManualHistoryKey] = useState("");
const [selectedPromptHistoryKey, setSelectedPromptHistoryKey] = useState("");
const promptGroupOptions = useMemo(() => {
const options: PromptGroupOption[] = [];
const seen = new Set<string>();
groupOptions.forEach((group) => {
if (!group.history_key || seen.has(group.history_key)) return;
seen.add(group.history_key);
options.push({
history_key: group.history_key,
group_title: group.group_title,
parent_history_key: group.parent_history_key,
parent_group_title: group.parent_group_title,
});
});
return options;
}, [groupOptions]);
useEffect(() => {
if (!status?.config) return;
setConfig(status.config);
setChannelCustomPrompt(status.config.custom_prompt ?? "");
setExcludeUsers((status.config.exclude_users ?? []).join("\n"));
setExcludePatterns((status.config.exclude_patterns ?? []).join("\n"));
}, [status?.config]);
useEffect(() => {
if (promptGroupOptions.length === 0) {
setSelectedPromptHistoryKey("");
return;
}
if (selectedPromptHistoryKey && promptGroupOptions.some((group) => group.history_key === selectedPromptHistoryKey)) {
return;
}
setSelectedPromptHistoryKey(promptGroupOptions[0]?.history_key ?? "");
}, [promptGroupOptions, selectedPromptHistoryKey]);
const pendingItems = useMemo(() => {
return items.filter((item) => item.status === "pending_review");
}, [items]);
@@ -81,16 +118,14 @@ export function PassiveMemorySection({ instanceId, channelType }: PassiveMemoryS
const latestRunAllEvent = runAllEvents[runAllEvents.length - 1] ?? null;
const visibleRunAllEvents = runAllEvents.filter((event) => event.type !== "final").slice(-5).reverse();
const excludedHistoryKeys = config.exclude_history_keys ?? [];
const groupCustomPrompts = config.group_custom_prompts ?? {};
const selectedPromptGroup = promptGroupOptions.find((group) => group.history_key === selectedPromptHistoryKey);
const excludedGroupOptions = excludedHistoryKeys.map((historyKey) => (
groupOptions.find((group) => group.history_key === historyKey) ?? {
channel_name: "",
promptGroupOptions.find((group) => group.history_key === historyKey) ?? {
history_key: historyKey,
message_count: 0,
last_activity: "",
excluded: true,
}
));
const availableGroupOptions = groupOptions.filter((group) => {
const availableExcludeGroupOptions = promptGroupOptions.filter((group) => {
return !excludedHistoryKeys.includes(group.history_key) &&
!excludedHistoryKeys.includes(group.parent_history_key ?? "");
});
@@ -105,9 +140,22 @@ export function PassiveMemorySection({ instanceId, channelType }: PassiveMemoryS
updateConfig({ exclude_history_keys: [...excludedHistoryKeys, trimmed] });
};
const updateGroupPrompt = (historyKey: string, value: string) => {
if (!historyKey) return;
const next = { ...groupCustomPrompts };
if (value.trim() === "") {
delete next[historyKey];
} else {
next[historyKey] = value;
}
updateConfig({ group_custom_prompts: next });
};
const save = () => {
saveSettings.mutate({
...config,
custom_prompt: channelCustomPrompt,
group_custom_prompts: supportsGroupExclude ? groupCustomPrompts : {},
exclude_users: splitLines(excludeUsers),
exclude_patterns: splitLines(excludePatterns),
exclude_history_keys: supportsGroupExclude ? excludedHistoryKeys : (config.exclude_history_keys ?? []),
@@ -243,78 +291,88 @@ export function PassiveMemorySection({ instanceId, channelType }: PassiveMemoryS
</div>
<TextareaBlock label={t("detail.passiveMemory.excludeUsers")} value={excludeUsers} onChange={setExcludeUsers} />
<TextareaBlock label={t("detail.passiveMemory.excludePatterns")} value={excludePatterns} onChange={setExcludePatterns} />
<TextareaBlock label={t("detail.passiveMemory.channelCustomPrompt")} value={channelCustomPrompt} onChange={setChannelCustomPrompt} />
{supportsGroupExclude && (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
{t("detail.passiveMemory.excludeGroups")}
</div>
<Select
value=""
onValueChange={(historyKey) => {
addExcludedHistoryKey(historyKey);
}}
disabled={availableGroupOptions.length === 0}
>
<SelectTrigger size="sm">
<SelectValue placeholder={t("detail.passiveMemory.excludeGroupsPlaceholder")} />
</SelectTrigger>
<SelectContent>
{availableGroupOptions.map((group) => (
<SelectItem key={group.history_key} value={group.history_key}>
{formatGroupLabel(group)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<Input
className="h-8"
value={manualHistoryKey}
onChange={(event) => setManualHistoryKey(event.target.value)}
placeholder={t("detail.passiveMemory.manualExcludePlaceholder")}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
addExcludedHistoryKey(manualHistoryKey);
setManualHistoryKey("");
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
addExcludedHistoryKey(manualHistoryKey);
setManualHistoryKey("");
}}
disabled={manualHistoryKey.trim() === ""}
<div className="space-y-2 rounded-md border p-3">
<div className="text-xs font-medium text-muted-foreground">
{t("detail.passiveMemory.groupCustomPrompt")}
</div>
<Select
value={selectedPromptHistoryKey}
onValueChange={setSelectedPromptHistoryKey}
disabled={promptGroupOptions.length === 0}
>
{t("detail.passiveMemory.addExcludedGroup")}
</Button>
<SelectTrigger size="sm">
<SelectValue placeholder={t("detail.passiveMemory.groupCustomPromptPlaceholder")} />
</SelectTrigger>
<SelectContent>
{promptGroupOptions.map((group) => (
<SelectItem key={group.history_key} value={group.history_key}>
{formatPromptGroupLabel(group)}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedPromptHistoryKey ? (
<TextareaBlock
label={selectedPromptGroup ? formatPromptGroupLabel(selectedPromptGroup) : selectedPromptHistoryKey}
value={groupCustomPrompts[selectedPromptHistoryKey] ?? ""}
onChange={(value) => updateGroupPrompt(selectedPromptHistoryKey, value)}
/>
) : (
<div className="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
{t("detail.passiveMemory.noPromptGroups")}
</div>
)}
</div>
{excludedGroupOptions.length > 0 ? (
<div className="flex flex-wrap gap-2">
{excludedGroupOptions.map((group) => (
<Badge key={group.history_key} variant="secondary" className="gap-1 pr-1">
<span className="max-w-[220px] truncate">{formatGroupLabel(group)}</span>
<button
type="button"
className="rounded-full p-0.5 hover:bg-background/80"
onClick={() => updateConfig({ exclude_history_keys: excludedHistoryKeys.filter((key) => key !== group.history_key) })}
aria-label={t("detail.passiveMemory.removeExcludedGroup", { group: formatGroupLabel(group) })}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
<div className="space-y-2 rounded-md border p-3">
<div className="text-xs font-medium text-muted-foreground">
{t("detail.passiveMemory.excludeGroups")}
</div>
) : (
<div className="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
{groupOptions.length === 0
? t("detail.passiveMemory.noExcludeGroups")
: t("detail.passiveMemory.noExcludedGroups")}
</div>
)}
<Select
value=""
onValueChange={addExcludedHistoryKey}
disabled={availableExcludeGroupOptions.length === 0}
>
<SelectTrigger size="sm">
<SelectValue placeholder={t("detail.passiveMemory.excludeGroupsPlaceholder")} />
</SelectTrigger>
<SelectContent>
{availableExcludeGroupOptions.map((group) => (
<SelectItem key={group.history_key} value={group.history_key}>
{formatPromptGroupLabel(group)}
</SelectItem>
))}
</SelectContent>
</Select>
{excludedGroupOptions.length > 0 ? (
<div className="flex flex-wrap gap-2">
{excludedGroupOptions.map((group) => {
const label = formatPromptGroupLabel(group);
return (
<Badge key={group.history_key} variant="secondary" className="gap-1 pr-1">
<span className="max-w-[220px] truncate">{label}</span>
<button
type="button"
className="rounded-full p-0.5 hover:bg-background/80"
onClick={() => updateConfig({ exclude_history_keys: excludedHistoryKeys.filter((key) => key !== group.history_key) })}
aria-label={t("detail.passiveMemory.removeExcludedGroup", { group: label })}
>
<X className="h-3 w-3" />
</button>
</Badge>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
{promptGroupOptions.length === 0
? t("detail.passiveMemory.noExcludeGroups")
: t("detail.passiveMemory.noExcludedGroups")}
</div>
)}
</div>
</div>
)}
</div>
@@ -366,6 +424,18 @@ function formatGroupLabel(group: { history_key: string; group_title?: string })
return group.group_title || group.history_key;
}
export function formatPromptGroupLabel(group: PromptGroupOption) {
const label = formatGroupLabel(group);
if (!group.parent_history_key) {
return label;
}
const parentLabel = group.parent_group_title || group.parent_history_key;
if (!parentLabel || parentLabel === label) {
return label;
}
return `${label} / ${parentLabel}`;
}
function formatRunAllEvent(event: {
type: string;
history_key?: string;
@@ -71,6 +71,22 @@ export function useChannelDetail(instanceId: string | undefined) {
[instanceId, http, invalidate],
);
const refreshDiscordMetadata = useCallback(
async () => {
if (!instanceId) return;
try {
await http.post(`/v1/channels/instances/${instanceId}/metadata/refresh`);
queryClient.invalidateQueries({ queryKey: queryKeys.channels.memoryExtractionGroups(instanceId), exact: true });
await invalidate();
toast.success(i18next.t("channels:detail.discordMetadataRefreshed"));
} catch (err) {
toast.error(i18next.t("channels:detail.discordMetadataRefreshFailed"), userFriendlyError(err));
throw err;
}
},
[http, instanceId, invalidate, queryClient],
);
// Managers API (backend routes still use /writers paths)
const listManagerGroups = useCallback(
async (): Promise<GroupManagerGroupInfo[]> => {
@@ -216,6 +232,7 @@ export function useChannelDetail(instanceId: string | undefined) {
setContextCredentials,
deleteContextCredentials,
listContacts,
refreshDiscordMetadata,
refresh: invalidate,
};
}
@@ -152,7 +152,7 @@ export function useChannelMemoryExtraction(instanceId: string | undefined, param
items: itemsQuery.data?.items ?? statusQuery.data?.recent_items ?? [],
itemsTotal: itemsQuery.data?.total ?? statusQuery.data?.recent_items?.length ?? 0,
groupOptions: groupsQuery.data ?? [],
loading: statusQuery.isLoading || itemsQuery.isLoading || (params.loadGroupOptions === true && groupsQuery.isLoading),
loading: statusQuery.isLoading || itemsQuery.isLoading,
saveSettings,
runNow,
runAll,
@@ -0,0 +1,75 @@
import { useEffect, useState } from "react";
import { Brain, Save } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { useHttp } from "@/hooks/use-ws";
import { userFriendlyError } from "@/lib/error-utils";
import { toast } from "@/stores/use-toast-store";
const globalCustomPromptKey = "channel_memory.extraction.custom_prompt";
const queryKey = ["system-configs", globalCustomPromptKey] as const;
export function BehaviorPassiveMemoryCard() {
const { t } = useTranslation("config");
const http = useHttp();
const queryClient = useQueryClient();
const [value, setValue] = useState("");
const promptQuery = useQuery({
queryKey,
queryFn: async () => {
const configs = await http.get<Record<string, string>>("/v1/system-configs");
return configs[globalCustomPromptKey] ?? "";
},
staleTime: 30_000,
});
useEffect(() => {
if (promptQuery.data === undefined) return;
setValue(promptQuery.data);
}, [promptQuery.data]);
const save = useMutation({
mutationFn: () => http.put(`/v1/system-configs/${globalCustomPromptKey}`, { value }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey, exact: true });
toast.success(t("toast.saved"));
},
onError: (err) => {
toast.error(t("toast.saveFailed"), userFriendlyError(err));
},
});
const dirty = value !== (promptQuery.data ?? "");
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="h-4 w-4" />
{t("behavior.passiveMemoryTitle")}
</CardTitle>
<CardDescription>{t("behavior.passiveMemoryDescription")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Textarea
value={value}
onChange={(event) => setValue(event.target.value)}
className="min-h-[120px] text-base md:text-sm"
placeholder={t("behavior.passiveMemoryPromptPlaceholder")}
disabled={promptQuery.isLoading || save.isPending}
/>
{dirty && (
<div className="flex justify-end">
<Button size="sm" onClick={() => save.mutate()} disabled={save.isPending} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {save.isPending ? t("saving") : t("save")}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
@@ -8,6 +8,7 @@ import { BehaviorSessionsCard } from "./behavior-sessions-card";
import { BehaviorSecurityCard } from "./behavior-security-card";
import { BehaviorPendingCompactionCard, type PendingCompactionValues } from "./behavior-pending-compaction-card";
import { BehaviorChatCard, type ChatBehaviorValues } from "./behavior-chat-card";
import { BehaviorPassiveMemoryCard } from "./behavior-passive-memory-card";
@@ -110,6 +111,7 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
<BehaviorSessionsCard value={sessions} onChange={markDirty(setSessions)} />
<BehaviorSecurityCard value={security} onChange={markDirty(setSecurity)} />
<BehaviorPendingCompactionCard value={pendingCompaction} onChange={markDirty(setPendingCompaction)} />
<BehaviorPassiveMemoryCard />
{dirty && (
<div className="flex justify-end pt-2">
+3
View File
@@ -61,6 +61,8 @@ export interface ChannelMemoryConfig {
exclude_users?: string[];
exclude_patterns?: string[];
exclude_history_keys?: string[];
custom_prompt?: string;
group_custom_prompts?: Record<string, string>;
min_messages: number;
group_only: boolean;
}
@@ -116,6 +118,7 @@ export interface ChannelMemoryGroupOption {
history_key: string;
parent_history_key?: string;
group_title?: string;
parent_group_title?: string;
message_count: number;
last_activity: string;
excluded: boolean;