feat(vault): tenant-wide rescan with nullable agent_id + media preview

Vault rescan redesigned from per-agent to tenant-wide:
- POST /v1/vault/rescan replaces POST /v1/agents/{id}/vault/rescan
- agent_id nullable in vault_documents (PG migration 046 + SQLite v14)
- Path-based scope inference: agents/{key}/ → personal, teams/{uuid}/ → team, root → shared
- Interceptor sets agent_id=NULL for team-scoped file writes
- Enrichment worker batch key handles empty agent_id
- web-fetch/ directory excluded from vault scan at any depth
- Media preview: images render via authenticated blob URL, binary files show metadata
- Scan button no longer requires agent selection
This commit is contained in:
viettranx committed 2026-04-10 16:32:54 +07:00
1 parent 139b454efd
commit 9f77bfe711
49 files changed
+1550 -312

No files matched your search

+60 -29
View File
@@ -181,17 +181,16 @@ func runGateway() {
}
setupMemoryEmbeddings(pgStores, providerRegistry)
// Resolve background provider for consolidation + vault enrichment.
// Fallback: background.provider → agent.default_provider → first registered provider.
bgProvider, bgModel := resolveBackgroundProvider(cfg, providerRegistry)
// V3: Wire consolidation pipeline (episodic → semantic → KG → dreaming)
if pgStores.Episodic != nil {
var consolidationProvider providers.Provider
if names := providerRegistry.ListForTenant(providers.MasterTenantID); len(names) > 0 {
consolidationProvider, _ = providerRegistry.GetForTenant(providers.MasterTenantID, names[0])
}
if consolidationProvider != nil {
// Create KG extractor for semantic worker (entity/relation extraction from episodic summaries)
if bgProvider != nil {
var kgExtractor *kg.Extractor
if pgStores.KnowledgeGraph != nil {
kgExtractor = kg.NewExtractor(consolidationProvider, consolidationProvider.DefaultModel(), 0)
kgExtractor = kg.NewExtractor(bgProvider, bgModel, 0)
}
cleanupConsolidation := consolidation.Register(consolidation.ConsolidationDeps{
EpisodicStore: pgStores.Episodic,
@@ -199,36 +198,28 @@ func runGateway() {
KGStore: pgStores.KnowledgeGraph,
SessionStore: pgStores.Sessions,
EventBus: domainBus,
Provider: consolidationProvider,
Model: consolidationProvider.DefaultModel(),
Provider: bgProvider,
Model: bgModel,
Extractor: kgExtractor,
// Per-agent dreaming overrides (MemoryConfig.Dreaming JSONB).
AgentStore: pgStores.Agents,
AgentStore: pgStores.Agents,
})
defer cleanupConsolidation()
slog.Info("consolidation pipeline registered")
slog.Info("consolidation pipeline registered", "provider", bgProvider.Name(), "model", bgModel)
} else {
slog.Warn("consolidation pipeline skipped: no provider available")
}
}
// V3: Wire vault enrichment worker (async summary + embedding + auto-linking).
// Resolves provider independently from consolidation pipeline.
if pgStores.Vault != nil {
var vaultProvider providers.Provider
if names := providerRegistry.ListForTenant(providers.MasterTenantID); len(names) > 0 {
vaultProvider, _ = providerRegistry.GetForTenant(providers.MasterTenantID, names[0])
}
if vaultProvider != nil {
cleanupVaultEnrich := vault.RegisterEnrichWorker(vault.EnrichWorkerDeps{
VaultStore: pgStores.Vault,
Provider: vaultProvider,
Model: vaultProvider.DefaultModel(),
EventBus: domainBus,
})
defer cleanupVaultEnrich()
slog.Info("vault enrichment worker registered")
}
if pgStores.Vault != nil && bgProvider != nil {
cleanupVaultEnrich := vault.RegisterEnrichWorker(vault.EnrichWorkerDeps{
VaultStore: pgStores.Vault,
Provider: bgProvider,
Model: bgModel,
EventBus: domainBus,
})
defer cleanupVaultEnrich()
slog.Info("vault enrichment worker registered", "provider", bgProvider.Name(), "model", bgModel)
}
loadBootstrapFiles(pgStores, workspace, agentCfg)
@@ -319,7 +310,11 @@ func runGateway() {
// Wire dependencies for system prompt preview parity.
if agentsH != nil {
agentsH.SetPreviewDeps(toolsReg, skillsLoader)
agentsH.SetPreviewStores(pgStores.Teams, pgStores.AgentLinks)
var skillAccess store.SkillAccessStore
if pgStores.Skills != nil {
skillAccess, _ = pgStores.Skills.(store.SkillAccessStore)
}
agentsH.SetPreviewStores(pgStores.Teams, pgStores.AgentLinks, skillAccess)
}
// External wake/trigger API
@@ -543,3 +538,39 @@ func runGateway() {
sigCh: sigCh,
})
}
// resolveBackgroundProvider picks the LLM provider+model for background workers
// (vault enrichment, consolidation). Fallback chain:
//
// background.provider/model → agent.default_provider/model → first registered provider.
func resolveBackgroundProvider(cfg *config.Config, reg *providers.Registry) (providers.Provider, string) {
try := func(name, model string) (providers.Provider, string, bool) {
if name == "" {
return nil, "", false
}
p, err := reg.GetForTenant(providers.MasterTenantID, name)
if err != nil || p == nil {
return nil, "", false
}
if model == "" {
model = p.DefaultModel()
}
return p, model, true
}
// 1. Explicit background config
if p, m, ok := try(cfg.Gateway.BackgroundProvider, cfg.Gateway.BackgroundModel); ok {
return p, m
}
// 2. Agent default provider
if p, m, ok := try(cfg.Agents.Defaults.Provider, cfg.Agents.Defaults.Model); ok {
return p, m
}
// 3. First registered provider (legacy fallback)
if names := reg.ListForTenant(providers.MasterTenantID); len(names) > 0 {
if p, m, ok := try(names[0], ""); ok {
return p, m
}
}
return nil, ""
}
+1 -1
View File
@@ -175,7 +175,7 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
// V3: Knowledge Vault document API
if d.pgStores != nil && d.pgStores.Vault != nil {
d.server.SetVaultHandler(httpapi.NewVaultHandler(d.pgStores.Vault, d.pgStores.Teams, d.workspace, d.domainBus))
d.server.SetVaultHandler(httpapi.NewVaultHandler(d.pgStores.Vault, d.pgStores.Teams, d.workspace, d.domainBus, d.pgStores.Agents, d.pgStores.Teams))
}
// V3: Episodic memory summaries API
+4
View File
@@ -106,6 +106,10 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg *
setBool("gateway.tool_status", cfg.Gateway.ToolStatus)
setInt("gateway.task_recovery_interval_sec", cfg.Gateway.TaskRecoveryIntervalSec)
// Background workers
set("background.provider", cfg.Gateway.BackgroundProvider)
set("background.model", cfg.Gateway.BackgroundModel)
// Tools
set("tools.profile", cfg.Tools.Profile)
setInt("tools.rate_limit_per_hour", cfg.Tools.RateLimitPerHour)
+133 -9
View File
@@ -4,34 +4,49 @@ import (
"context"
"path/filepath"
"slices"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tokencount"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// PreviewDeps holds optional dependencies for building a preview system prompt.
// All fields are nil-safe — missing deps simply skip resolution for that section.
type PreviewDeps struct {
AgentStore store.AgentStore
TeamStore store.TeamStore
AgentLinks store.AgentLinkStore
ProviderReg *providers.Registry
ToolLister interface{ List() []string }
SkillsLoader interface {
AgentStore store.AgentStore
TeamStore store.TeamStore
AgentLinks store.AgentLinkStore
ProviderReg *providers.Registry
SkillAccessStore store.SkillAccessStore
ToolLister interface {
List() []string
Get(name string) (tools.Tool, bool)
Aliases() map[string]string
}
SkillsLoader interface {
BuildPinnedSummary(ctx context.Context, names []string) string
BuildSummary(ctx context.Context, allowList []string) string
}
DataDir string // for team workspace path construction
}
// PreviewResult holds the output of BuildPreviewPrompt.
type PreviewResult struct {
Prompt string
ToolDefs []providers.ToolDefinition // tool definitions (schemas) as sent to the LLM
}
// BuildPreviewPrompt builds a system prompt for preview purposes.
// Reuses the same BuildSystemPrompt() as the LLM pipeline, resolving as many
// fields as possible from agent data + DB stores. Runtime-only fields
// (channel, peer kind, session context, credentials) are left at zero values —
// BuildSystemPrompt already nil-checks every field.
func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMode, userID string, deps PreviewDeps) string {
func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMode, userID string, deps PreviewDeps) PreviewResult {
// --- Context files ---
var contextFiles []bootstrap.ContextFile
if deps.AgentStore != nil {
@@ -62,6 +77,63 @@ func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMod
toolNames = fallbackPreviewToolNames
}
// --- skill_manage gating (matches loop_history.go:124-131) ---
if !ag.ParseSkillEvolve() {
filtered := make([]string, 0, len(toolNames))
for _, n := range toolNames {
if n != "skill_manage" {
filtered = append(filtered, n)
}
}
toolNames = filtered
}
// --- Basic agent tool policy: deny-only (Allow/AlsoAllow/ByProvider not applied).
// Full PolicyEngine requires runtime state (provider name, channel) not available in preview. ---
if toolPolicy := ag.ParseToolsConfig(); toolPolicy != nil && len(toolPolicy.Deny) > 0 {
denySet := make(map[string]bool, len(toolPolicy.Deny))
for _, d := range toolPolicy.Deny {
denySet[d] = true
}
filtered := make([]string, 0, len(toolNames))
for _, n := range toolNames {
if !denySet[n] {
filtered = append(filtered, n)
}
}
toolNames = filtered
}
// --- Alias exclusion (matches loop_history.go:136-146) ---
if deps.ToolLister != nil {
if aliasSet := deps.ToolLister.Aliases(); len(aliasSet) > 0 {
filtered := make([]string, 0, len(toolNames))
for _, n := range toolNames {
if _, isAlias := aliasSet[n]; !isAlias {
filtered = append(filtered, n)
}
}
toolNames = filtered
}
}
// --- MCP tool descriptions (matches loop_history_supplement.go:44-58) ---
var mcpToolDescs map[string]string
if deps.ToolLister != nil {
descs := make(map[string]string)
for _, name := range toolNames {
if !strings.HasPrefix(name, "mcp_") || name == "mcp_tool_search" {
continue
}
if tool, ok := deps.ToolLister.Get(name); ok {
descs[name] = tool.Description()
}
}
if len(descs) > 0 {
mcpToolDescs = descs
}
}
// --- Sandbox ---
sandboxCfg := ag.ParseSandboxConfig()
sandboxEnabled := sandboxCfg != nil && sandboxCfg.Mode != "" && sandboxCfg.Mode != "off"
@@ -76,6 +148,32 @@ func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMod
pinnedSummary = deps.SkillsLoader.BuildPinnedSummary(ctx, pinnedSkills)
}
// --- Skills summary (BuildSummary + token count) ---
var skillsSummary string
if deps.SkillsLoader != nil {
var skillAllowList []string
if deps.SkillAccessStore != nil {
if accessible, err := deps.SkillAccessStore.ListAccessible(ctx, ag.ID, userID); err == nil {
skillAllowList = make([]string, 0, len(accessible))
for _, sk := range accessible {
skillAllowList = append(skillAllowList, sk.Slug)
}
} else {
// On error: empty list (no skills). Preview is diagnostic; safer than showing all.
skillAllowList = []string{}
}
}
summary := deps.SkillsLoader.BuildSummary(ctx, skillAllowList)
if summary != "" {
tokens := tokencount.NewFallbackCounter().Count("claude-3", summary)
if tokens <= skillInlineMaxTokens {
skillsSummary = summary
}
// Over threshold → search-only mode (skillsSummary stays empty)
}
}
// --- Provider contribution ---
var providerContrib *providers.PromptContribution
if deps.ProviderReg != nil && ag.Provider != "" {
@@ -121,8 +219,31 @@ func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMod
}
}
// --- Tool definitions (schemas sent to LLM alongside the system prompt) ---
var toolDefs []providers.ToolDefinition
if deps.ToolLister != nil {
for _, name := range toolNames {
if tool, ok := deps.ToolLister.Get(name); ok {
toolDefs = append(toolDefs, tools.ToProviderDef(tool))
}
}
// Include alias definitions (LLM receives both canonical + aliases)
for alias, canonical := range deps.ToolLister.Aliases() {
if tool, ok := deps.ToolLister.Get(canonical); ok {
toolDefs = append(toolDefs, providers.ToolDefinition{
Type: "function",
Function: providers.ToolFunctionSchema{
Name: alias,
Description: tool.Description(),
Parameters: tool.Parameters(),
},
})
}
}
}
// --- Build system prompt (same function as LLM pipeline) ---
return BuildSystemPrompt(SystemPromptConfig{
prompt := BuildSystemPrompt(SystemPromptConfig{
AgentID: ag.AgentKey,
AgentUUID: ag.ID.String(),
DisplayName: ag.DisplayName,
@@ -146,6 +267,8 @@ func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMod
SandboxEnabled: sandboxEnabled,
SandboxContainerDir: sandboxContainerDir,
PinnedSkillsSummary: pinnedSummary,
SkillsSummary: skillsSummary,
MCPToolDescs: mcpToolDescs,
IsTeamContext: isTeamCtx,
TeamWorkspace: teamWorkspace,
TeamMembers: teamMembers,
@@ -154,8 +277,9 @@ func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMod
OrchMode: orchMode,
// Runtime-only fields left at zero: Channel, ChannelType, ChatTitle,
// PeerKind, OwnerIDs, ExtraPrompt, CredentialCLIContext, IsBootstrap,
// MCPToolDescs, SkillsSummary, SandboxWorkspaceAccess
// SandboxWorkspaceAccess
})
return PreviewResult{Prompt: prompt, ToolDefs: toolDefs}
}
// mergePreviewUserFiles overlays per-user files onto base agent-level files.
+238
View File
@@ -0,0 +1,238 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func baseAgent() *store.AgentData {
return &store.AgentData{
BaseModel: store.BaseModel{ID: uuid.New()},
AgentKey: "test-agent",
AgentType: store.AgentTypePredefined,
Workspace: "/workspace",
}
}
func TestBuildPreviewPrompt_NilDeps(t *testing.T) {
r := BuildPreviewPrompt(context.Background(), baseAgent(), PromptFull, "", PreviewDeps{})
if r.Prompt == "" {
t.Fatal("expected non-empty prompt with nil deps")
}
if !strings.Contains(r.Prompt, "read_file") {
t.Error("expected fallback tool names in prompt")
}
// No tool lister → no tool defs
if len(r.ToolDefs) != 0 {
t.Errorf("expected no tool defs with nil ToolLister, got %d", len(r.ToolDefs))
}
}
func TestBuildPreviewPrompt_SkillsInline(t *testing.T) {
r := BuildPreviewPrompt(context.Background(), baseAgent(), PromptFull, "", PreviewDeps{
SkillsLoader: &mockSkillsLoader{
summary: "<available_skills>\n<skill name=\"git\">Git operations</skill>\n</available_skills>",
},
})
if !strings.Contains(r.Prompt, "<available_skills>") {
t.Error("expected skills XML inlined in prompt")
}
}
func TestBuildPreviewPrompt_SkillsSearchMode(t *testing.T) {
bigSummary := strings.Repeat("x", 10000)
r := BuildPreviewPrompt(context.Background(), baseAgent(), PromptFull, "", PreviewDeps{
SkillsLoader: &mockSkillsLoader{summary: bigSummary},
})
if strings.Contains(r.Prompt, bigSummary) {
t.Error("expected large summary to be excluded (search-only mode)")
}
}
func TestBuildPreviewPrompt_PinnedSkillsHybrid(t *testing.T) {
ag := baseAgent()
ag.OtherConfig = []byte(`{"pinned_skills":["deploy"]}`)
r := BuildPreviewPrompt(context.Background(), ag, PromptFull, "", PreviewDeps{
SkillsLoader: &mockSkillsLoader{
pinned: "<skill name=\"deploy\">Deploy to prod</skill>",
summary: "<available_skills>\n<skill name=\"git\">Git ops</skill>\n</available_skills>",
},
})
if !strings.Contains(r.Prompt, "deploy") || !strings.Contains(r.Prompt, "Pinned skills") {
t.Error("expected pinned skills section in prompt")
}
}
func TestBuildPreviewPrompt_SkillAllowList(t *testing.T) {
ag := baseAgent()
loader := &mockSkillsLoader{
summary: "<available_skills><skill name=\"allowed\">ok</skill></available_skills>",
}
r := BuildPreviewPrompt(context.Background(), ag, PromptFull, "user1", PreviewDeps{
SkillsLoader: loader,
SkillAccessStore: &mockSkillAccessStore{
accessible: []store.SkillInfo{{Slug: "allowed-skill"}},
},
})
if !strings.Contains(r.Prompt, "<available_skills>") {
t.Error("expected filtered skills in prompt")
}
if len(loader.capturedAllow) != 1 || loader.capturedAllow[0] != "allowed-skill" {
t.Errorf("expected allow list [allowed-skill], got %v", loader.capturedAllow)
}
}
func TestBuildPreviewPrompt_SkillAccessStoreError(t *testing.T) {
ag := baseAgent()
loader := &mockSkillsLoader{
summary: "<available_skills><skill name=\"s\">desc</skill></available_skills>",
}
r := BuildPreviewPrompt(context.Background(), ag, PromptFull, "user1", PreviewDeps{
SkillsLoader: loader,
SkillAccessStore: &mockSkillAccessStore{err: errors.New("db error")},
})
if r.Prompt == "" {
t.Fatal("expected non-empty prompt on SkillAccessStore error")
}
if loader.capturedAllow == nil || len(loader.capturedAllow) != 0 {
t.Errorf("expected empty (non-nil) allow list on error, got %v", loader.capturedAllow)
}
}
func TestBuildPreviewPrompt_MCPToolDescs(t *testing.T) {
r := BuildPreviewPrompt(context.Background(), baseAgent(), PromptFull, "", PreviewDeps{
ToolLister: &mockToolLister{
tools: map[string]string{
"read_file": "Read a file",
"mcp_pg_query": "Run PostgreSQL queries",
},
},
})
if !strings.Contains(r.Prompt, "mcp_pg_query") {
t.Error("expected MCP tool description in prompt")
}
}
func TestBuildPreviewPrompt_MCPToolSearchExcluded(t *testing.T) {
r := BuildPreviewPrompt(context.Background(), baseAgent(), PromptFull, "", PreviewDeps{
ToolLister: &mockToolLister{
tools: map[string]string{
"read_file": "Read a file",
"mcp_tool_search": "Search MCP tools",
},
},
})
if strings.Contains(r.Prompt, "Search MCP tools") {
t.Error("mcp_tool_search should not appear in MCP tool descriptions")
}
}
func TestBuildPreviewPrompt_AliasExclusion(t *testing.T) {
r := BuildPreviewPrompt(context.Background(), baseAgent(), PromptFull, "", PreviewDeps{
ToolLister: &mockToolLister{
tools: map[string]string{
"read_file": "Read a file",
"Read": "Alias for read_file",
"exec": "Execute shell",
"Bash": "Alias for exec",
},
aliases: map[string]string{
"Read": "read_file",
"Bash": "exec",
},
},
})
if strings.Contains(r.Prompt, "- Read\n") || strings.Contains(r.Prompt, "- Bash\n") {
t.Error("aliases should be excluded from tool list")
}
}
func TestBuildPreviewPrompt_SkillManageGating(t *testing.T) {
ag := baseAgent()
r := BuildPreviewPrompt(context.Background(), ag, PromptFull, "", PreviewDeps{
ToolLister: &mockToolLister{
tools: map[string]string{
"read_file": "Read a file",
"skill_manage": "Manage skills",
},
},
})
if strings.Contains(r.Prompt, "skill_manage") {
t.Error("skill_manage should be excluded when skill_evolve is off")
}
}
func TestBuildPreviewPrompt_SkillManageEnabled(t *testing.T) {
ag := baseAgent()
ag.SkillEvolve = true
r := BuildPreviewPrompt(context.Background(), ag, PromptFull, "", PreviewDeps{
ToolLister: &mockToolLister{
tools: map[string]string{
"read_file": "Read a file",
"skill_manage": "Manage skills",
"skill_search": "Search skills",
},
},
})
if !strings.Contains(r.Prompt, "skill_manage") {
t.Error("skill_manage should be present when skill_evolve is on")
}
}
func TestBuildPreviewPrompt_ToolPolicyDeny(t *testing.T) {
ag := baseAgent()
ag.ToolsConfig = []byte(`{"deny":["exec","web_fetch"]}`)
r := BuildPreviewPrompt(context.Background(), ag, PromptFull, "", PreviewDeps{
ToolLister: &mockToolLister{
tools: map[string]string{
"read_file": "Read a file",
"exec": "Execute shell",
"web_fetch": "Fetch web page",
},
},
})
if strings.Contains(r.Prompt, "- exec\n") {
t.Error("denied tool 'exec' should be excluded")
}
if strings.Contains(r.Prompt, "- web_fetch\n") {
t.Error("denied tool 'web_fetch' should be excluded")
}
if !strings.Contains(r.Prompt, "read_file") {
t.Error("non-denied tool 'read_file' should be present")
}
}
func TestBuildPreviewPrompt_ToolDefs(t *testing.T) {
r := BuildPreviewPrompt(context.Background(), baseAgent(), PromptFull, "", PreviewDeps{
ToolLister: &mockToolLister{
tools: map[string]string{
"read_file": "Read a file",
"exec": "Execute shell",
},
aliases: map[string]string{
"Read": "read_file",
},
},
})
// Should have canonical tools + aliases in tool defs
if len(r.ToolDefs) != 3 { // read_file + exec + Read alias
t.Errorf("expected 3 tool defs (2 canonical + 1 alias), got %d", len(r.ToolDefs))
}
// Verify alias is included in defs even though excluded from system prompt
found := false
for _, td := range r.ToolDefs {
if td.Function.Name == "Read" {
found = true
break
}
}
if !found {
t.Error("expected alias 'Read' in tool defs")
}
}
@@ -0,0 +1,78 @@
package agent
import (
"context"
"sort"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// mockToolLister implements the widened ToolLister interface for testing.
type mockToolLister struct {
tools map[string]string // name → description
aliases map[string]string // alias → canonical
}
func (m *mockToolLister) List() []string {
names := make([]string, 0, len(m.tools))
for n := range m.tools {
names = append(names, n)
}
sort.Strings(names)
return names
}
func (m *mockToolLister) Get(name string) (tools.Tool, bool) {
desc, ok := m.tools[name]
if !ok {
return nil, false
}
return &mockTool{name: name, desc: desc}, true
}
func (m *mockToolLister) Aliases() map[string]string {
if m.aliases == nil {
return nil
}
return m.aliases
}
// mockTool is a minimal tools.Tool implementation for testing.
type mockTool struct {
name string
desc string
}
func (t *mockTool) Name() string { return t.name }
func (t *mockTool) Description() string { return t.desc }
func (t *mockTool) Parameters() map[string]any { return nil }
func (t *mockTool) Execute(_ context.Context, _ map[string]any) *tools.Result { return nil }
// mockSkillsLoader implements the widened SkillsLoader interface.
type mockSkillsLoader struct {
pinned string // pre-built pinned XML
summary string // pre-built full summary
capturedAllow []string // set by BuildSummary for test assertions
}
func (m *mockSkillsLoader) BuildPinnedSummary(_ context.Context, _ []string) string {
return m.pinned
}
func (m *mockSkillsLoader) BuildSummary(_ context.Context, allowList []string) string {
m.capturedAllow = allowList
return m.summary
}
// mockSkillAccessStore returns canned skill access lists.
type mockSkillAccessStore struct {
accessible []store.SkillInfo
err error
}
func (m *mockSkillAccessStore) ListAccessible(_ context.Context, _ uuid.UUID, _ string) ([]store.SkillInfo, error) {
return m.accessible, m.err
}
+2
View File
@@ -359,6 +359,8 @@ type GatewayConfig struct {
BlockReply *bool `json:"block_reply,omitempty"` // deliver intermediate text during tool iterations (default false)
ToolStatus *bool `json:"tool_status,omitempty"` // show tool name in streaming preview during tool execution (default true)
TaskRecoveryIntervalSec int `json:"task_recovery_interval_sec,omitempty"` // team task recovery ticker interval in seconds (default 300 = 5min)
BackgroundProvider string `json:"background_provider,omitempty"` // LLM provider for background workers (vault enrichment, consolidation)
BackgroundModel string `json:"background_model,omitempty"` // LLM model for background workers
}
// ToolsConfig controls tool availability, policy, and web search.
+4
View File
@@ -52,6 +52,10 @@ func (c *Config) ApplySystemConfigs(configs map[string]string) {
boolean("gateway.tool_status", &c.Gateway.ToolStatus)
integer("gateway.task_recovery_interval_sec", &c.Gateway.TaskRecoveryIntervalSec)
// Background workers (vault enrichment, consolidation)
str("background.provider", &c.Gateway.BackgroundProvider)
str("background.model", &c.Gateway.BackgroundModel)
// Tools
str("tools.profile", &c.Tools.Profile)
integer("tools.rate_limit_per_hour", &c.Tools.RateLimitPerHour)
+16 -8
View File
@@ -18,6 +18,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -32,8 +33,9 @@ type AgentsHandler struct {
kgStore store.KnowledgeGraphStore // for import (nil = disabled)
episodicStore store.EpisodicStore // for import (nil in SQLite/lite builds)
vaultStore store.VaultStore // for vault import (nil = disabled)
toolsReg ToolLister // for system prompt preview tool resolution (nil = fallback)
skillsLoader SkillPinnedBuilder // for system prompt preview pinned skills (nil = skip)
toolsReg ToolPreviewLister // for system prompt preview tool resolution (nil = fallback)
skillsLoader SkillPreviewBuilder // for system prompt preview pinned skills (nil = skip)
skillAccessStore store.SkillAccessStore // for system prompt preview skill filtering (nil = skip)
teamStore store.TeamStore // for system prompt preview team context (nil = skip)
agentLinkStore store.AgentLinkStore // for system prompt preview delegation targets (nil = skip)
defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace")
@@ -82,24 +84,30 @@ func (h *AgentsHandler) SetVaultStore(vs store.VaultStore) {
h.vaultStore = vs
}
// ToolLister is satisfied by tools.Registry for system prompt preview.
type ToolLister interface{ List() []string }
// ToolPreviewLister is satisfied by tools.Registry for system prompt preview.
type ToolPreviewLister interface {
List() []string
Get(name string) (tools.Tool, bool)
Aliases() map[string]string
}
// SkillPinnedBuilder is satisfied by skills.Loader for pinned skills summary.
type SkillPinnedBuilder interface {
// SkillPreviewBuilder is satisfied by skills.Loader for system prompt preview.
type SkillPreviewBuilder interface {
BuildPinnedSummary(ctx context.Context, names []string) string
BuildSummary(ctx context.Context, allowList []string) string
}
// SetPreviewDeps attaches optional dependencies for system prompt preview.
func (h *AgentsHandler) SetPreviewDeps(tl ToolLister, sl SkillPinnedBuilder) {
func (h *AgentsHandler) SetPreviewDeps(tl ToolPreviewLister, sl SkillPreviewBuilder) {
h.toolsReg = tl
h.skillsLoader = sl
}
// SetPreviewStores attaches team + agent link stores for system prompt preview.
func (h *AgentsHandler) SetPreviewStores(ts store.TeamStore, als store.AgentLinkStore) {
func (h *AgentsHandler) SetPreviewStores(ts store.TeamStore, als store.AgentLinkStore, sas store.SkillAccessStore) {
h.teamStore = ts
h.agentLinkStore = als
h.skillAccessStore = sas
}
// isOwnerUser checks if the given user ID is a system owner.
+2 -1
View File
@@ -354,9 +354,10 @@ func (h *AgentsHandler) importVault(ctx context.Context, ag *store.AgentData, ar
progressFn(ProgressEvent{Phase: "vault_documents", Status: "running", Total: len(arc.vaultDocuments)})
}
for _, d := range arc.vaultDocuments {
agentIDStr := ag.ID.String()
doc := &store.VaultDocument{
TenantID: tid.String(),
AgentID: ag.ID.String(),
AgentID: &agentIDStr,
TeamID: nil, // team_id not portable
Scope: d.Scope,
CustomScope: d.CustomScope,
+19 -15
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tokencount"
)
@@ -18,10 +19,11 @@ type promptPreviewSection struct {
// promptPreviewResponse is the API response for system prompt preview.
type promptPreviewResponse struct {
Mode string `json:"mode"`
Prompt string `json:"prompt"`
TokenCount int `json:"token_count"`
Sections []promptPreviewSection `json:"sections"`
Mode string `json:"mode"`
Prompt string `json:"prompt"`
TokenCount int `json:"token_count"`
Sections []promptPreviewSection `json:"sections"`
Tools []providers.ToolDefinition `json:"tools,omitempty"`
}
// handleSystemPromptPreview renders the actual system prompt for an agent in a given mode.
@@ -49,26 +51,28 @@ func (h *AgentsHandler) handleSystemPromptPreview(w http.ResponseWriter, r *http
// Build preview prompt — reuses same BuildSystemPrompt() as LLM pipeline.
// Runtime-only fields (channel, peer kind, credentials) are zero-valued;
// BuildSystemPrompt nil-checks every field so these sections are simply skipped.
prompt := agent.BuildPreviewPrompt(ctx, ag, mode, r.URL.Query().Get("user_id"), agent.PreviewDeps{
AgentStore: h.agents,
TeamStore: h.teamStore,
AgentLinks: h.agentLinkStore,
ProviderReg: h.providerReg,
ToolLister: h.toolsReg,
SkillsLoader: h.skillsLoader,
DataDir: h.dataDir,
result := agent.BuildPreviewPrompt(ctx, ag, mode, r.URL.Query().Get("user_id"), agent.PreviewDeps{
AgentStore: h.agents,
TeamStore: h.teamStore,
AgentLinks: h.agentLinkStore,
ProviderReg: h.providerReg,
ToolLister: h.toolsReg,
SkillsLoader: h.skillsLoader,
SkillAccessStore: h.skillAccessStore,
DataDir: h.dataDir,
})
counter := tokencount.NewFallbackCounter()
tokens := counter.Count("claude-3", prompt)
sections := parseSections(prompt)
tokens := counter.Count("claude-3", result.Prompt)
sections := parseSections(result.Prompt)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(promptPreviewResponse{
Mode: string(mode),
Prompt: prompt,
Prompt: result.Prompt,
TokenCount: tokens,
Sections: sections,
Tools: result.ToolDefs,
})
}
+85 -32
View File
@@ -4,7 +4,6 @@ import (
"context"
"log/slog"
"net/http"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -23,17 +22,29 @@ type vaultDocListResponse struct {
Total int `json:"total"`
}
// AgentLister is the subset of AgentStore needed by VaultHandler (rescan agent_key→UUID mapping).
type AgentLister interface {
List(ctx context.Context, ownerID string) ([]store.AgentData, error)
}
// TeamLister is the subset of TeamStore needed by VaultHandler (rescan team validation).
type TeamLister interface {
ListTeams(ctx context.Context) ([]store.TeamData, error)
}
// VaultHandler serves Knowledge Vault document and link endpoints.
type VaultHandler struct {
store store.VaultStore
teamAccess store.TeamAccessStore // nil = skip team membership validation (e.g. lite edition)
agents AgentLister // nil = rescan skips agent resolution
teams TeamLister // nil = rescan skips team resolution
workspace string
eventBus eventbus.DomainEventBus
rescanMu sync.Map // key: agentID → struct{}, per-agent concurrency guard
rescanMu sync.Map // key: tenantID → struct{}, per-tenant concurrency guard
}
func NewVaultHandler(s store.VaultStore, ta store.TeamAccessStore, workspace string, bus eventbus.DomainEventBus) *VaultHandler {
return &VaultHandler{store: s, teamAccess: ta, workspace: workspace, eventBus: bus}
func NewVaultHandler(s store.VaultStore, ta store.TeamAccessStore, workspace string, bus eventbus.DomainEventBus, agents AgentLister, teams TeamLister) *VaultHandler {
return &VaultHandler{store: s, teamAccess: ta, agents: agents, teams: teams, workspace: workspace, eventBus: bus}
}
// validateTeamMembership checks that the requesting user belongs to the given team.
@@ -100,7 +111,8 @@ func (h *VaultHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/agents/{agentID}/vault/documents", h.auth(h.handleCreateDocument))
mux.HandleFunc("PUT /v1/agents/{agentID}/vault/documents/{docID}", h.auth(h.handleUpdateDocument))
mux.HandleFunc("DELETE /v1/agents/{agentID}/vault/documents/{docID}", h.auth(h.handleDeleteDocument))
mux.HandleFunc("POST /v1/agents/{agentID}/vault/rescan", h.auth(h.handleRescan))
mux.HandleFunc("POST /v1/vault/rescan", h.auth(h.handleRescan))
mux.HandleFunc("POST /v1/vault/search", h.auth(h.handleSearchAll))
mux.HandleFunc("POST /v1/agents/{agentID}/vault/search", h.auth(h.handleSearch))
mux.HandleFunc("GET /v1/agents/{agentID}/vault/documents/{docID}/links", h.auth(h.handleGetLinks))
mux.HandleFunc("POST /v1/agents/{agentID}/vault/links", h.auth(h.handleCreateLink))
@@ -209,7 +221,7 @@ func (h *VaultHandler) handleGetDocument(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if doc == nil || doc.AgentID != agentID {
if doc == nil || (doc.AgentID != nil && *doc.AgentID != agentID) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
@@ -222,14 +234,24 @@ func (h *VaultHandler) handleGetDocument(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, doc)
}
// handleSearch runs hybrid FTS+vector search on vault documents.
// handleSearchAll runs tenant-wide search (agent_id optional in body).
func (h *VaultHandler) handleSearchAll(w http.ResponseWriter, r *http.Request) {
h.doSearch(w, r, "")
}
// handleSearch runs hybrid FTS+vector search scoped to a specific agent.
func (h *VaultHandler) handleSearch(w http.ResponseWriter, r *http.Request) {
h.doSearch(w, r, r.PathValue("agentID"))
}
// doSearch is the shared search implementation for both per-agent and tenant-wide endpoints.
func (h *VaultHandler) doSearch(w http.ResponseWriter, r *http.Request, agentID string) {
locale := extractLocale(r)
tenantID := store.TenantIDFromContext(r.Context())
agentID := r.PathValue("agentID")
var body struct {
Query string `json:"query"`
AgentID string `json:"agent_id"`
Scope string `json:"scope"`
DocTypes []string `json:"doc_types"`
MaxResults int `json:"max_results"`
@@ -245,6 +267,10 @@ func (h *VaultHandler) handleSearch(w http.ResponseWriter, r *http.Request) {
if body.MaxResults <= 0 {
body.MaxResults = 10
}
// Body agent_id only used when path doesn't provide one (tenant-wide endpoint).
if agentID == "" {
agentID = body.AgentID
}
searchOpts := store.VaultSearchOptions{
Query: body.Query,
@@ -374,7 +400,7 @@ func (h *VaultHandler) handleCreateDocument(w http.ResponseWriter, r *http.Reque
doc := &store.VaultDocument{
TenantID: tenantID.String(),
AgentID: agentID,
AgentID: &agentID,
Path: body.Path,
Title: body.Title,
DocType: body.DocType,
@@ -412,7 +438,7 @@ func (h *VaultHandler) handleUpdateDocument(w http.ResponseWriter, r *http.Reque
docID := r.PathValue("docID")
existing, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID)
if err != nil || existing == nil || existing.AgentID != agentID {
if err != nil || existing == nil || (existing.AgentID != nil && *existing.AgentID != agentID) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
@@ -475,7 +501,7 @@ func (h *VaultHandler) handleDeleteDocument(w http.ResponseWriter, r *http.Reque
docID := r.PathValue("docID")
existing, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID)
if err != nil || existing == nil || existing.AgentID != agentID {
if err != nil || existing == nil || (existing.AgentID != nil && *existing.AgentID != agentID) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
@@ -489,7 +515,12 @@ func (h *VaultHandler) handleDeleteDocument(w http.ResponseWriter, r *http.Reque
// DeleteDocument without RunContext applies no team_id filter (broad match on tenant+agent+path).
// This is safe because we pre-validated team membership above and use server-derived existing.Path.
if err := h.store.DeleteDocument(r.Context(), tenantID.String(), agentID, existing.Path); err != nil {
// Use the doc's actual agent_id (may be empty for team/shared docs).
deleteAgentID := ""
if existing.AgentID != nil {
deleteAgentID = *existing.AgentID
}
if err := h.store.DeleteDocument(r.Context(), tenantID.String(), deleteAgentID, existing.Path); err != nil {
slog.Warn("vault.delete failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
@@ -527,7 +558,7 @@ func (h *VaultHandler) handleCreateLink(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusNotFound, map[string]string{"error": "one or both documents not found"})
return
}
if from.AgentID != agentID {
if from.AgentID != nil && *from.AgentID != agentID {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "source document does not belong to this agent"})
return
}
@@ -564,38 +595,38 @@ func (h *VaultHandler) handleDeleteLink(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNoContent)
}
// handleRescan walks agent workspace and registers missing/changed files in vault.
// handleRescan walks the entire tenant workspace and registers missing/changed files in vault.
// Infers agent/team ownership from directory structure: agents/{key}/, teams/{uuid}/, or root shared.
func (h *VaultHandler) handleRescan(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("agentID")
if _, err := uuid.Parse(agentID); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id"})
return
}
tenantID := store.TenantIDFromContext(r.Context()).String()
// Per-agent concurrency guard: only one rescan at a time.
if _, loaded := h.rescanMu.LoadOrStore(agentID, struct{}{}); loaded {
// Per-tenant concurrency guard.
if _, loaded := h.rescanMu.LoadOrStore(tenantID, struct{}{}); loaded {
writeJSON(w, http.StatusConflict, map[string]string{"error": "rescan already in progress"})
return
}
defer h.rescanMu.Delete(agentID)
defer h.rescanMu.Delete(tenantID)
// Resolve workspace path for this agent.
wsPath := h.resolveAgentWorkspace(r.Context(), agentID)
wsPath := h.resolveTenantWorkspace(r.Context())
if wsPath == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace not available"})
return
}
// Build agent_key→UUID map and team UUID set for path inference.
agentMap, teamSet := h.buildRescanMaps(r.Context())
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
result, err := vault.RescanWorkspace(ctx, vault.RescanParams{
TenantID: store.TenantIDFromContext(r.Context()).String(),
AgentID: agentID,
TenantID: tenantID,
Workspace: wsPath,
AgentMap: agentMap,
TeamSet: teamSet,
}, h.store, h.eventBus)
if err != nil {
slog.Warn("vault.rescan failed", "agent", agentID, "error", err)
slog.Warn("vault.rescan failed", "tenant", tenantID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
@@ -603,16 +634,38 @@ func (h *VaultHandler) handleRescan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, result)
}
// resolveAgentWorkspace returns the filesystem path to an agent's workspace.
// Scopes by tenant to prevent cross-tenant file access.
func (h *VaultHandler) resolveAgentWorkspace(ctx context.Context, agentID string) string {
// resolveTenantWorkspace returns the tenant-scoped workspace root.
func (h *VaultHandler) resolveTenantWorkspace(ctx context.Context) string {
if h.workspace == "" {
return ""
}
tenantID := store.TenantIDFromContext(ctx)
slug := store.TenantSlugFromContext(ctx)
ws := config.TenantWorkspace(h.workspace, tenantID, slug)
return filepath.Join(ws, agentID)
return config.TenantWorkspace(h.workspace, tenantID, slug)
}
// buildRescanMaps pre-loads agent_key→UUID and team UUID sets for the current tenant.
func (h *VaultHandler) buildRescanMaps(ctx context.Context) (map[string]string, map[string]bool) {
agentMap := make(map[string]string)
teamSet := make(map[string]bool)
if h.agents != nil {
agents, err := h.agents.List(ctx, "")
if err == nil {
for _, a := range agents {
agentMap[a.AgentKey] = a.ID.String()
}
}
}
if h.teams != nil {
teams, err := h.teams.ListTeams(ctx)
if err == nil {
for _, t := range teams {
teamSet[t.ID.String()] = true
}
}
}
return agentMap, teamSet
}
var allowedDocTypes = map[string]bool{"context": true, "memory": true, "note": true, "skill": true, "episodic": true, "media": true}
+52 -19
View File
@@ -54,10 +54,19 @@ func (s *PGVaultStore) SetEmbeddingProvider(provider store.EmbeddingProvider) {
func (s *PGVaultStore) Close() error { return nil }
// optAgentUUID converts a nullable *string agent_id to *uuid.UUID for SQL.
func optAgentUUID(agentID *string) *uuid.UUID {
if agentID == nil || *agentID == "" {
return nil
}
u := mustParseUUID(*agentID)
return &u
}
// UpsertDocument inserts or updates a vault document.
func (s *PGVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultDocument) error {
tid := mustParseUUID(doc.TenantID)
aid := mustParseUUID(doc.AgentID)
aid := optAgentUUID(doc.AgentID)
now := time.Now().UTC()
meta, err := json.Marshal(doc.Metadata)
@@ -91,7 +100,7 @@ func (s *PGVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultDocum
INSERT INTO vault_documents
(id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, embedding, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $14)
ON CONFLICT (agent_id, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'), scope, path) DO UPDATE SET
ON CONFLICT (tenant_id, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid), COALESCE(team_id, '00000000-0000-0000-0000-000000000000'::uuid), scope, path) DO UPDATE SET
title = EXCLUDED.title,
doc_type = EXCLUDED.doc_type,
content_hash = EXCLUDED.content_hash,
@@ -112,15 +121,21 @@ func (s *PGVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultDocum
}
// GetDocument retrieves a vault document by tenant, agent, and path.
// Empty agentID means no agent filter (match any agent).
// Team scoping via RunContext: present+TeamID → filter; present+empty → personal; nil → any match.
func (s *PGVaultStore) GetDocument(ctx context.Context, tenantID, agentID, path string) (*store.VaultDocument, error) {
tid := mustParseUUID(tenantID)
aid := mustParseUUID(agentID)
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at
FROM vault_documents WHERE tenant_id = $1 AND agent_id = $2 AND path = $3`
args := []any{tid, aid, path}
p := 4
FROM vault_documents WHERE tenant_id = $1 AND path = $2`
args := []any{tid, path}
p := 3
if agentID != "" {
q += fmt.Sprintf(" AND agent_id = $%d", p)
args = append(args, mustParseUUID(agentID))
p++
}
if rc := store.RunContextFromCtx(ctx); rc != nil {
if rc.TeamID != "" {
@@ -162,14 +177,20 @@ func (s *PGVaultStore) GetDocumentByID(ctx context.Context, tenantID, id string)
}
// DeleteDocument removes a vault document by tenant, agent, and path.
// Empty agentID means no agent filter.
// Team scoping via RunContext (same rules as GetDocument).
func (s *PGVaultStore) DeleteDocument(ctx context.Context, tenantID, agentID, path string) error {
tid := mustParseUUID(tenantID)
aid := mustParseUUID(agentID)
q := `DELETE FROM vault_documents WHERE tenant_id = $1 AND agent_id = $2 AND path = $3`
args := []any{tid, aid, path}
p := 4
q := `DELETE FROM vault_documents WHERE tenant_id = $1 AND path = $2`
args := []any{tid, path}
p := 3
if agentID != "" {
q += fmt.Sprintf(" AND agent_id = $%d", p)
args = append(args, mustParseUUID(agentID))
p++
}
if rc := store.RunContextFromCtx(ctx); rc != nil {
if rc.TeamID != "" {
@@ -287,7 +308,7 @@ func (s *PGVaultStore) UpdateHash(ctx context.Context, tenantID, id, newHash str
// Search performs hybrid FTS + vector search on vault_documents.
func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions) ([]store.VaultSearchResult, error) {
tid := mustParseUUID(opts.TenantID)
aid := mustParseUUID(opts.AgentID)
aid := optAgentUUID(&opts.AgentID) // empty string → nil → no agent filter
// Build team filter for search sub-queries.
tf := buildSearchTeamFilter(opts.TeamID, opts.TeamIDs)
@@ -381,13 +402,19 @@ func (tf searchTeamFilter) append(q string, args []any, p int) (string, []any, i
return q, args, p
}
func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, agentID uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) {
func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID uuid.UUID, agentID *uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) {
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at,
ts_rank(tsv, plainto_tsquery('simple', $1)) AS score
FROM vault_documents
WHERE tenant_id = $2 AND agent_id = $3 AND tsv @@ plainto_tsquery('simple', $1)`
args := []any{query, tenantID, agentID}
p := 4
WHERE tenant_id = $2 AND tsv @@ plainto_tsquery('simple', $1)`
args := []any{query, tenantID}
p := 3
if agentID != nil {
q += fmt.Sprintf(" AND agent_id = $%d", p)
args = append(args, *agentID)
p++
}
q, args, p = tf.append(q, args, p)
@@ -412,14 +439,20 @@ func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, ag
return vaultSearchRowsToResults(scanned, "vault"), nil
}
func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID, agentID uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) {
func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID uuid.UUID, agentID *uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) {
vecStr := vectorToString(embedding)
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at,
1 - (embedding <=> $1) AS score
FROM vault_documents
WHERE tenant_id = $2 AND agent_id = $3 AND embedding IS NOT NULL`
args := []any{vecStr, tenantID, agentID}
p := 4
WHERE tenant_id = $2 AND embedding IS NOT NULL`
args := []any{vecStr, tenantID}
p := 3
if agentID != nil {
q += fmt.Sprintf(" AND agent_id = $%d", p)
args = append(args, *agentID)
p++
}
q, args, p = tf.append(q, args, p)
@@ -44,9 +44,10 @@ func (s *PGVaultStore) UpdateSummaryAndReembed(ctx context.Context, tenantID, do
// FindSimilarDocs finds documents with similar embeddings to the given docID.
// Returns top-N neighbors excluding the source doc itself.
// Empty agentID means no agent filter.
func (s *PGVaultStore) FindSimilarDocs(ctx context.Context, tenantID, agentID, docID string, limit int) ([]store.VaultSearchResult, error) {
tid := mustParseUUID(tenantID)
aid := mustParseUUID(agentID)
aid := optAgentUUID(&agentID)
did := mustParseUUID(docID)
// Fetch source embedding.
@@ -63,12 +64,20 @@ func (s *PGVaultStore) FindSimilarDocs(ctx context.Context, tenantID, agentID, d
content_hash, summary, metadata, created_at, updated_at,
1 - (embedding <=> $1::vector) AS score
FROM vault_documents
WHERE tenant_id = $2 AND agent_id = $3 AND id != $4 AND embedding IS NOT NULL
ORDER BY embedding <=> $1::vector
LIMIT $5`
WHERE tenant_id = $2 AND id != $3 AND embedding IS NOT NULL`
args := []any{*embStr, tid, did}
p := 4
if aid != nil {
q += fmt.Sprintf(" AND agent_id = $%d", p)
args = append(args, *aid)
p++
}
q += fmt.Sprintf(" ORDER BY embedding <=> $1::vector LIMIT $%d", p)
args = append(args, limit)
var scanned []vaultSearchRow
if err := pkgSqlxDB.SelectContext(ctx, &scanned, q, *embStr, tid, aid, did, limit); err != nil {
if err := pkgSqlxDB.SelectContext(ctx, &scanned, q, args...); err != nil {
return nil, fmt.Errorf("vault.find_similar: %w", err)
}
return vaultSearchRowsToResults(scanned, "vault"), nil
+5 -2
View File
@@ -14,7 +14,7 @@ import (
type vaultDocRow struct {
ID uuid.UUID `db:"id"`
TenantID uuid.UUID `db:"tenant_id"`
AgentID uuid.UUID `db:"agent_id"`
AgentID *uuid.UUID `db:"agent_id"`
TeamID *uuid.UUID `db:"team_id"`
Scope string `db:"scope"`
CustomScope *string `db:"custom_scope"`
@@ -33,7 +33,6 @@ func (r *vaultDocRow) toVaultDocument() store.VaultDocument {
doc := store.VaultDocument{
ID: r.ID.String(),
TenantID: r.TenantID.String(),
AgentID: r.AgentID.String(),
Scope: r.Scope,
CustomScope: r.CustomScope,
Path: r.Path,
@@ -44,6 +43,10 @@ func (r *vaultDocRow) toVaultDocument() store.VaultDocument {
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
if r.AgentID != nil {
s := r.AgentID.String()
doc.AgentID = &s
}
if r.TeamID != nil {
s := r.TeamID.String()
doc.TeamID = &s
+31 -1
View File
@@ -15,7 +15,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 13
const SchemaVersion = 14
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
@@ -374,6 +374,36 @@ ALTER TABLE episodic_summaries ADD COLUMN recall_score REAL NOT NULL DEFAULT 0;
ALTER TABLE episodic_summaries ADD COLUMN last_recalled_at TEXT;
CREATE INDEX IF NOT EXISTS idx_episodic_recall_unpromoted ON episodic_summaries(agent_id, user_id, recall_score DESC)
WHERE promoted_at IS NULL;`,
// Version 13 → 14: vault_documents agent_id nullable + unique index with tenant_id.
// SQLite requires table recreation to drop NOT NULL. Preserve all data.
13: `CREATE TABLE vault_documents_new (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
scope TEXT NOT NULL DEFAULT 'personal',
custom_scope TEXT,
path TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
doc_type TEXT NOT NULL DEFAULT 'note',
content_hash TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
metadata TEXT DEFAULT '{}',
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
INSERT INTO vault_documents_new SELECT * FROM vault_documents;
DROP TABLE vault_documents;
ALTER TABLE vault_documents_new RENAME TO vault_documents;
DROP INDEX IF EXISTS idx_vault_docs_unique_path;
CREATE UNIQUE INDEX idx_vault_docs_unique_path
ON vault_documents(tenant_id, COALESCE(agent_id, ''), COALESCE(team_id, ''), scope, path);
CREATE INDEX IF NOT EXISTS idx_vault_docs_tenant ON vault_documents(tenant_id);
CREATE INDEX IF NOT EXISTS idx_vault_docs_agent_scope ON vault_documents(agent_id, scope);
CREATE INDEX IF NOT EXISTS idx_vault_docs_type ON vault_documents(agent_id, doc_type);
CREATE INDEX IF NOT EXISTS idx_vault_docs_hash ON vault_documents(content_hash);
CREATE INDEX IF NOT EXISTS idx_vault_docs_team ON vault_documents(team_id);`,
}
// EnsureSchema creates tables if they don't exist and applies incremental migrations.
+2 -2
View File
@@ -1516,7 +1516,7 @@ CREATE INDEX IF NOT EXISTS idx_scuc_binary ON secure_cli_user_credentials(binary
CREATE TABLE IF NOT EXISTS vault_documents (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
scope TEXT NOT NULL DEFAULT 'personal',
custom_scope TEXT,
@@ -1531,7 +1531,7 @@ CREATE TABLE IF NOT EXISTS vault_documents (
);
-- SQLite prohibits expressions in inline UNIQUE constraints; use a unique index instead.
CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_docs_unique_path
ON vault_documents(agent_id, COALESCE(team_id, ''), scope, path);
ON vault_documents(tenant_id, COALESCE(agent_id, ''), COALESCE(team_id, ''), scope, path);
CREATE INDEX IF NOT EXISTS idx_vault_docs_tenant ON vault_documents(tenant_id);
CREATE INDEX IF NOT EXISTS idx_vault_docs_agent_scope ON vault_documents(agent_id, scope);
CREATE INDEX IF NOT EXISTS idx_vault_docs_type ON vault_documents(agent_id, doc_type);
+36 -10
View File
@@ -58,11 +58,16 @@ func (s *SQLiteVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultD
meta = []byte("{}")
}
// Convert nullable *string AgentID to nil for SQL.
var agentIDVal any
if doc.AgentID != nil && *doc.AgentID != "" {
agentIDVal = *doc.AgentID
}
err = s.db.QueryRowContext(ctx, `
INSERT INTO vault_documents
(id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (agent_id, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'), scope, path) DO UPDATE SET
ON CONFLICT (tenant_id, COALESCE(agent_id,''), COALESCE(team_id,''), scope, path) DO UPDATE SET
title = excluded.title,
doc_type = excluded.doc_type,
content_hash = excluded.content_hash,
@@ -71,7 +76,7 @@ func (s *SQLiteVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultD
tenant_id = excluded.tenant_id,
updated_at = excluded.updated_at
RETURNING id`,
id, doc.TenantID, doc.AgentID, doc.TeamID, doc.Scope, doc.CustomScope,
id, doc.TenantID, agentIDVal, doc.TeamID, doc.Scope, doc.CustomScope,
doc.Path, doc.Title, doc.DocType, doc.ContentHash, doc.Summary, string(meta), now, now,
).Scan(&doc.ID)
if err != nil {
@@ -81,11 +86,17 @@ func (s *SQLiteVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultD
}
// GetDocument retrieves a vault document by tenant, agent, and path.
// Empty agentID means no agent filter.
// Team scoping via RunContext: present+TeamID → filter; present+empty → personal; nil → any match.
func (s *SQLiteVaultStore) GetDocument(ctx context.Context, tenantID, agentID, path string) (*store.VaultDocument, error) {
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at
FROM vault_documents WHERE tenant_id = ? AND agent_id = ? AND path = ?`
args := []any{tenantID, agentID, path}
FROM vault_documents WHERE tenant_id = ? AND path = ?`
args := []any{tenantID, path}
if agentID != "" {
q += " AND agent_id = ?"
args = append(args, agentID)
}
if rc := store.RunContextFromCtx(ctx); rc != nil {
if rc.TeamID != "" {
@@ -109,10 +120,16 @@ func (s *SQLiteVaultStore) GetDocumentByID(ctx context.Context, tenantID, id str
}
// DeleteDocument removes a vault document (FK cascades delete vault_links).
// Empty agentID means no agent filter.
// Team scoping via RunContext (same rules as GetDocument).
func (s *SQLiteVaultStore) DeleteDocument(ctx context.Context, tenantID, agentID, path string) error {
q := `DELETE FROM vault_documents WHERE tenant_id = ? AND agent_id = ? AND path = ?`
args := []any{tenantID, agentID, path}
q := `DELETE FROM vault_documents WHERE tenant_id = ? AND path = ?`
args := []any{tenantID, path}
if agentID != "" {
q += " AND agent_id = ?"
args = append(args, agentID)
}
if rc := store.RunContextFromCtx(ctx); rc != nil {
if rc.TeamID != "" {
@@ -249,9 +266,14 @@ func (s *SQLiteVaultStore) Search(ctx context.Context, opts store.VaultSearchOpt
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at
FROM vault_documents
WHERE tenant_id = ? AND agent_id = ?
WHERE tenant_id = ?
AND (title LIKE ? ESCAPE '\' OR path LIKE ? ESCAPE '\')`
args := []any{opts.TenantID, opts.AgentID, pattern, pattern}
args := []any{opts.TenantID, pattern, pattern}
if opts.AgentID != "" {
q += " AND agent_id = ?"
args = append(args, opts.AgentID)
}
q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs)
@@ -304,12 +326,14 @@ func (s *SQLiteVaultStore) Search(ctx context.Context, opts store.VaultSearchOpt
func scanVaultDoc(row *sql.Row) (*store.VaultDocument, error) {
var doc store.VaultDocument
var meta []byte
var agentID *string
ca, ua := &sqliteTime{}, &sqliteTime{}
err := row.Scan(&doc.ID, &doc.TenantID, &doc.AgentID, &doc.TeamID, &doc.Scope, &doc.CustomScope,
err := row.Scan(&doc.ID, &doc.TenantID, &agentID, &doc.TeamID, &doc.Scope, &doc.CustomScope,
&doc.Path, &doc.Title, &doc.DocType, &doc.ContentHash, &doc.Summary, &meta, ca, ua)
if err != nil {
return nil, err
}
doc.AgentID = agentID
doc.CreatedAt = ca.Time
doc.UpdatedAt = ua.Time
if len(meta) > 2 {
@@ -321,12 +345,14 @@ func scanVaultDoc(row *sql.Row) (*store.VaultDocument, error) {
func scanVaultDocRow(rows *sql.Rows) (*store.VaultDocument, error) {
var doc store.VaultDocument
var meta []byte
var agentID *string
ca, ua := &sqliteTime{}, &sqliteTime{}
err := rows.Scan(&doc.ID, &doc.TenantID, &doc.AgentID, &doc.TeamID, &doc.Scope, &doc.CustomScope,
err := rows.Scan(&doc.ID, &doc.TenantID, &agentID, &doc.TeamID, &doc.Scope, &doc.CustomScope,
&doc.Path, &doc.Title, &doc.DocType, &doc.ContentHash, &doc.Summary, &meta, ca, ua)
if err != nil {
return nil, err
}
doc.AgentID = agentID
doc.CreatedAt = ca.Time
doc.UpdatedAt = ua.Time
if len(meta) > 2 {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
type VaultDocument struct {
ID string `json:"id" db:"id"`
TenantID string `json:"tenant_id" db:"tenant_id"`
AgentID string `json:"agent_id" db:"agent_id"`
AgentID *string `json:"agent_id,omitempty" db:"agent_id"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
Scope string `json:"scope" db:"scope"` // personal, team, shared
CustomScope *string `json:"custom_scope,omitempty" db:"custom_scope"`
+34 -14
View File
@@ -25,14 +25,15 @@ func NewVaultInterceptor(vs store.VaultStore, workspace string, bus eventbus.Dom
return &VaultInterceptor{vaultStore: vs, workspace: workspace, eventBus: bus}
}
// inferScopeFromContext returns scope and team_id based on RunContext.
// TeamID present → scope="team", teamID=&rc.TeamID. Absent → "personal", nil.
func inferScopeFromContext(ctx context.Context) (scope string, teamID *string) {
// inferScopeFromContext returns scope, team_id, and whether agent_id should be set.
// TeamID present → scope="team", teamID=&rc.TeamID, agentOwned=false.
// Absent → "personal", nil, agentOwned=true.
func inferScopeFromContext(ctx context.Context) (scope string, teamID *string, agentOwned bool) {
rc := store.RunContextFromCtx(ctx)
if rc != nil && rc.TeamID != "" {
return "team", &rc.TeamID
return "team", &rc.TeamID, false
}
return "personal", nil
return "personal", nil, true
}
// AfterWrite registers or updates a vault document after a file write.
@@ -58,11 +59,19 @@ func (v *VaultInterceptor) AfterWrite(ctx context.Context, resolvedPath, content
hash := vault.ContentHash([]byte(content))
title := vault.InferTitle(relPath)
docType := vault.InferDocType(relPath)
scope, teamID := inferScopeFromContext(ctx)
scope, teamID, agentOwned := inferScopeFromContext(ctx)
// Team-scoped files belong to the team, not the creating agent.
var agentIDPtr *string
eventAgentID := ""
if agentOwned {
agentIDPtr = &agentID
eventAgentID = agentID
}
doc := &store.VaultDocument{
TenantID: tenantID,
AgentID: agentID,
AgentID: agentIDPtr,
TeamID: teamID,
Scope: scope,
Path: relPath,
@@ -80,14 +89,14 @@ func (v *VaultInterceptor) AfterWrite(ctx context.Context, resolvedPath, content
v.eventBus.Publish(eventbus.DomainEvent{
ID: uuid.Must(uuid.NewV7()).String(),
Type: eventbus.EventVaultDocUpserted,
SourceID: doc.ID + ":" + hash, // unique per content version, avoids bus-level dedup suppression
SourceID: doc.ID + ":" + hash,
TenantID: tenantID,
AgentID: agentID,
AgentID: eventAgentID,
Timestamp: time.Now(),
Payload: eventbus.VaultDocUpsertedPayload{
DocID: doc.ID,
TenantID: tenantID,
AgentID: agentID,
AgentID: eventAgentID,
Path: relPath,
ContentHash: hash,
Workspace: v.workspace,
@@ -124,11 +133,18 @@ func (v *VaultInterceptor) AfterWriteMedia(ctx context.Context, resolvedPath, su
}
title := vault.InferTitle(relPath)
scope, teamID := inferScopeFromContext(ctx)
scope, teamID, agentOwned := inferScopeFromContext(ctx)
var agentIDPtr *string
eventAgentID := ""
if agentOwned {
agentIDPtr = &agentID
eventAgentID = agentID
}
doc := &store.VaultDocument{
TenantID: tenantID,
AgentID: agentID,
AgentID: agentIDPtr,
TeamID: teamID,
Scope: scope,
Path: relPath,
@@ -150,12 +166,12 @@ func (v *VaultInterceptor) AfterWriteMedia(ctx context.Context, resolvedPath, su
Type: eventbus.EventVaultDocUpserted,
SourceID: doc.ID + ":" + hash,
TenantID: tenantID,
AgentID: agentID,
AgentID: eventAgentID,
Timestamp: time.Now(),
Payload: eventbus.VaultDocUpsertedPayload{
DocID: doc.ID,
TenantID: tenantID,
AgentID: agentID,
AgentID: eventAgentID,
Path: relPath,
ContentHash: hash,
Workspace: v.workspace,
@@ -183,7 +199,11 @@ func (v *VaultInterceptor) BeforeRead(ctx context.Context, resolvedPath string)
return
}
// Try agent-scoped first, then tenant-wide (team/shared docs have no agent_id).
doc, err := v.vaultStore.GetDocument(ctx, tenantID, agentID, relPath)
if err != nil {
doc, err = v.vaultStore.GetDocument(ctx, tenantID, "", relPath)
}
if err != nil {
return // not registered yet — skip
}
+1 -1
View File
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
const RequiredSchemaVersion uint = 45
const RequiredSchemaVersion uint = 46
+7 -2
View File
@@ -74,7 +74,13 @@ func (w *enrichWorker) Handle(ctx context.Context, event eventbus.DomainEvent) e
}
w.dedupMu.Unlock()
key := payload.TenantID + ":" + payload.AgentID
// Batch key: tenant + agent for agent-scoped docs. For team/shared docs
// (empty AgentID), use tenant + docID to avoid collapsing all into one queue.
batchScope := payload.AgentID
if batchScope == "" {
batchScope = payload.DocID
}
key := payload.TenantID + ":" + batchScope
if !w.queue.Enqueue(key, payload) {
return nil // another goroutine already processing this agent's queue
}
@@ -145,7 +151,6 @@ func (w *enrichWorker) processBatch(ctx context.Context, key string) {
}
// Phase 3 — Classify links (replaces autoLink).
// All items share same tenantID:agentID (batch queue key guarantees this).
if len(embedded) > 0 {
first := embedded[0].payload
w.classifyLinks(ctx, first.TenantID, first.AgentID, embedded)
+75 -29
View File
@@ -12,11 +12,13 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// RescanParams holds input for workspace rescan.
// RescanParams holds input for tenant-wide workspace rescan.
// AgentMap and TeamSet are pre-loaded by the caller to avoid per-file DB lookups.
type RescanParams struct {
TenantID string
AgentID string
Workspace string // absolute path to agent's workspace root
Workspace string // absolute path to tenant's workspace root
AgentMap map[string]string // agent_key → agent UUID
TeamSet map[string]bool // team UUID → exists (for validation)
}
// RescanResult holds the outcome of a workspace rescan.
@@ -30,9 +32,10 @@ type RescanResult struct {
Truncated bool `json:"truncated"`
}
// RescanWorkspace walks the agent workspace and registers missing or changed
// files in vault_documents. Publishes EventVaultDocUpserted for each new or
// updated file so the enrichment worker can process them asynchronously.
// RescanWorkspace walks the tenant workspace and registers missing or changed
// files in vault_documents. Ownership (agent/team/scope) is inferred from path.
// Publishes EventVaultDocUpserted for each new or updated file so the
// enrichment worker can process them asynchronously.
func RescanWorkspace(ctx context.Context, params RescanParams, vs store.VaultStore, bus eventbus.DomainEventBus) (*RescanResult, error) {
entries, walkStats, err := SafeWalkWorkspace(ctx, params.Workspace, DefaultWalkOptions())
if err != nil {
@@ -46,28 +49,40 @@ func RescanWorkspace(ctx context.Context, params RescanParams, vs store.VaultSto
}
for _, entry := range entries {
agentID, teamID, scope, strippedPath := inferOwnerFromPath(entry.RelPath, params.AgentMap, params.TeamSet)
if scope == "" {
// Unknown agent key or invalid team UUID — skip.
result.Skipped++
continue
}
hash, hashErr := ContentHashFile(entry.AbsPath)
if hashErr != nil {
result.Errors++
continue
}
// Resolve the agent ID string for store lookup (empty string = no agent filter).
agentIDStr := ""
if agentID != nil {
agentIDStr = *agentID
}
// Check if document already exists with same hash.
existing, _ := vs.GetDocument(ctx, params.TenantID, params.AgentID, entry.RelPath)
existing, _ := vs.GetDocument(ctx, params.TenantID, agentIDStr, strippedPath)
if existing != nil && existing.ContentHash == hash {
result.Unchanged++
continue
}
scope, teamID := inferScopeFromPath(entry.RelPath)
doc := &store.VaultDocument{
TenantID: params.TenantID,
AgentID: params.AgentID,
AgentID: agentID,
TeamID: teamID,
Scope: scope,
Path: entry.RelPath,
Title: InferTitle(entry.RelPath),
DocType: InferDocType(entry.RelPath),
Path: strippedPath,
Title: InferTitle(strippedPath),
DocType: InferDocType(strippedPath),
ContentHash: hash,
}
@@ -83,20 +98,20 @@ func RescanWorkspace(ctx context.Context, params RescanParams, vs store.VaultSto
result.New++
}
// Publish enrichment event.
// Publish enrichment event. AgentID in payload stays string for serialization.
if bus != nil {
bus.Publish(eventbus.DomainEvent{
ID: uuid.Must(uuid.NewV7()).String(),
Type: eventbus.EventVaultDocUpserted,
SourceID: doc.ID + ":" + hash,
TenantID: params.TenantID,
AgentID: params.AgentID,
AgentID: agentIDStr,
Timestamp: time.Now(),
Payload: eventbus.VaultDocUpsertedPayload{
DocID: doc.ID,
TenantID: params.TenantID,
AgentID: params.AgentID,
Path: entry.RelPath,
AgentID: agentIDStr,
Path: strippedPath,
ContentHash: hash,
Workspace: params.Workspace,
},
@@ -104,26 +119,57 @@ func RescanWorkspace(ctx context.Context, params RescanParams, vs store.VaultSto
}
}
slog.Info("vault.rescan", "agent", params.AgentID,
slog.Info("vault.rescan",
"tenant", params.TenantID,
"scanned", result.Scanned, "new", result.New,
"updated", result.Updated, "unchanged", result.Unchanged,
"errors", result.Errors, "truncated", result.Truncated)
"skipped", result.Skipped, "errors", result.Errors,
"truncated", result.Truncated)
return result, nil
}
// inferScopeFromPath detects scope and team from workspace-relative path.
// Paths starting with "teams/{id}/" are team-scoped; everything else is personal.
func inferScopeFromPath(relPath string) (scope string, teamID *string) {
if !strings.HasPrefix(relPath, "teams/") {
return "personal", nil
// inferOwnerFromPath parses a tenant-relative path to determine ownership.
// Returns: agentID (*string), teamID (*string), scope (string), strippedPath (string).
//
// Path patterns:
//
// agents/{agent_key}/rest/of/path → agentID=lookup(key), scope="personal", path="rest/of/path"
// teams/{team_uuid}/rest/of/path → teamID=uuid, scope="team", path="rest/of/path"
// anything/else → scope="shared", path unchanged
//
// Returns scope="" to signal the file should be skipped (unknown agent or invalid team).
func inferOwnerFromPath(relPath string, agentMap map[string]string, teamSet map[string]bool) (agentID *string, teamID *string, scope string, strippedPath string) {
switch {
case strings.HasPrefix(relPath, "agents/"):
rest := relPath[len("agents/"):]
key, remainder, hasSlash := strings.Cut(rest, "/")
if !hasSlash || key == "" || strings.Contains(remainder, "..") {
return nil, nil, "", relPath // malformed or path traversal
}
agentUUID, ok := agentMap[key]
if !ok {
return nil, nil, "", relPath // unknown agent key → skip
}
return &agentUUID, nil, "personal", remainder
case strings.HasPrefix(relPath, "teams/"):
rest := relPath[len("teams/"):]
id, remainder, hasSlash := strings.Cut(rest, "/")
if !hasSlash || id == "" || strings.Contains(remainder, "..") {
return nil, nil, "", relPath // malformed or path traversal
}
if _, parseErr := uuid.Parse(id); parseErr != nil {
return nil, nil, "", relPath // not a valid UUID → skip
}
if !teamSet[id] {
return nil, nil, "", relPath // team not found → skip
}
return nil, &id, "team", remainder
default:
return nil, nil, "shared", relPath
}
rest := relPath[len("teams/"):]
id, _, hasSlash := strings.Cut(rest, "/")
if !hasSlash || id == "" {
return "personal", nil
}
return "team", &id
}
// InferDocType guesses doc_type from path conventions.
+103 -23
View File
@@ -2,34 +2,112 @@ package vault
import "testing"
func TestInferScopeFromPath(t *testing.T) {
tests := []struct {
path string
scope string
hasTeam bool
teamID string
}{
{"notes/doc.md", "personal", false, ""},
{"web-fetch/page.txt", "personal", false, ""},
{"report.md", "personal", false, ""},
{"teams/abc-def-123/report.md", "team", true, "abc-def-123"},
{"teams/abc-def-123/deep/nested.md", "team", true, "abc-def-123"},
{"teams/", "personal", false, ""}, // malformed, no team ID
{"teams", "personal", false, ""}, // no slash
{"teamsfoo/bar.md", "personal", false, ""}, // not teams/ prefix
{"telegram/123/teams/x/y.md", "personal", false, ""}, // teams not at root
// TestInferOwnerFromPath covers the new tenant-wide path parser.
func TestInferOwnerFromPath(t *testing.T) {
agentMap := map[string]string{
"my-bot": "uuid-1",
"other-bot": "uuid-2",
}
validUUID := "550e8400-e29b-41d4-a716-446655440000"
teamSet := map[string]bool{
validUUID: true,
}
tests := []struct {
path string
wantAgentID *string
wantTeamID *string
wantScope string
wantStrippedPath string
}{
// agents/{key}/... → personal scope, strip prefix
{
path: "agents/my-bot/notes/todo.md",
wantAgentID: strPtr("uuid-1"),
wantScope: "personal",
wantStrippedPath: "notes/todo.md",
},
// agents/{key} with no trailing path → personal, empty stripped path
{
path: "agents/my-bot/file.md",
wantAgentID: strPtr("uuid-1"),
wantScope: "personal",
wantStrippedPath: "file.md",
},
// teams/{uuid}/... → team scope, strip prefix
{
path: "teams/" + validUUID + "/doc.md",
wantTeamID: strPtr(validUUID),
wantScope: "team",
wantStrippedPath: "doc.md",
},
// teams/{uuid}/deep/nested → team scope
{
path: "teams/" + validUUID + "/deep/nested.md",
wantTeamID: strPtr(validUUID),
wantScope: "team",
wantStrippedPath: "deep/nested.md",
},
// root-level file → shared scope, path unchanged
{
path: "README.md",
wantScope: "shared",
wantStrippedPath: "README.md",
},
// nested file not under agents/ or teams/ → shared
{
path: "docs/guide.md",
wantScope: "shared",
wantStrippedPath: "docs/guide.md",
},
// unknown agent → skip (scope="")
{
path: "agents/unknown-bot/file.md",
wantScope: "",
},
// invalid team UUID → skip
{
path: "teams/not-a-uuid/file.md",
wantScope: "",
},
// valid UUID but not in teamSet → skip
{
path: "teams/11111111-2222-3333-4444-555555555555/file.md",
wantScope: "",
},
// malformed agents path (no trailing file) is still an unknown agent key check
{
path: "agents/unknown/",
wantScope: "",
},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
scope, teamID := inferScopeFromPath(tt.path)
if scope != tt.scope {
t.Errorf("scope = %q, want %q", scope, tt.scope)
gotAgentID, gotTeamID, gotScope, gotPath := inferOwnerFromPath(tt.path, agentMap, teamSet)
if gotScope != tt.wantScope {
t.Errorf("scope = %q, want %q", gotScope, tt.wantScope)
}
if tt.hasTeam && (teamID == nil || *teamID != tt.teamID) {
t.Errorf("teamID = %v, want %q", teamID, tt.teamID)
if tt.wantScope == "" {
return // skip is signaled; remaining fields don't matter
}
if !tt.hasTeam && teamID != nil {
t.Errorf("teamID = %v, want nil", teamID)
if tt.wantStrippedPath != "" && gotPath != tt.wantStrippedPath {
t.Errorf("strippedPath = %q, want %q", gotPath, tt.wantStrippedPath)
}
if tt.wantAgentID != nil {
if gotAgentID == nil || *gotAgentID != *tt.wantAgentID {
t.Errorf("agentID = %v, want %q", gotAgentID, *tt.wantAgentID)
}
} else if gotAgentID != nil {
t.Errorf("agentID = %v, want nil", gotAgentID)
}
if tt.wantTeamID != nil {
if gotTeamID == nil || *gotTeamID != *tt.wantTeamID {
t.Errorf("teamID = %v, want %q", gotTeamID, *tt.wantTeamID)
}
} else if gotTeamID != nil {
t.Errorf("teamID = %v, want nil", gotTeamID)
}
})
}
@@ -78,3 +156,5 @@ func TestInferTitle(t *testing.T) {
})
}
}
func strPtr(s string) *string { return &s }
+23 -16
View File
@@ -158,16 +158,23 @@ func SafeWalkWorkspace(ctx context.Context, root string, opts WalkOptions) ([]Wa
}
// isExcludedDir returns true if an entire directory subtree should be skipped.
// relPath is the walk-relative path of the directory (e.g. "agents/my-bot/web-fetch").
func isExcludedDir(relPath string) bool {
first, _, _ := strings.Cut(relPath, "/")
// Get the directory's own name (last segment).
dirName := filepath.Base(relPath)
// Skip memory/ subtree entirely.
if first == "memory" {
// Skip memory/ at any depth.
if dirName == "memory" {
return true
}
// Skip hidden dirs (. prefix) EXCEPT .uploads (user content).
if strings.HasPrefix(first, ".") && first != ".uploads" {
if strings.HasPrefix(dirName, ".") && dirName != ".uploads" {
return true
}
// Skip web-fetch/ at any depth — content from external URLs may be dangerous.
if dirName == "web-fetch" {
return true
}
@@ -189,19 +196,19 @@ var contextFiles = map[string]bool{
}
// isExcludedPath returns true if a file should be excluded from vault registration.
// Defense-in-depth: also checks parent directory exclusions for callers that bypass dir walk.
// Defense-in-depth: checks ALL parent directory segments for exclusions.
func isExcludedPath(relPath string) bool {
// Check first path segment for excluded directories.
first, _, _ := strings.Cut(relPath, "/")
// memory/ prefix.
if first == "memory" {
return true
}
// Hidden dirs (. prefix) except .uploads.
if strings.HasPrefix(first, ".") && first != ".uploads" {
return true
// Check every directory segment in the path.
dir := filepath.Dir(relPath)
for dir != "." && dir != "/" {
seg := filepath.Base(dir)
if seg == "memory" || seg == "web-fetch" {
return true
}
if strings.HasPrefix(seg, ".") && seg != ".uploads" {
return true
}
dir = filepath.Dir(dir)
}
base := filepath.Base(relPath)
+4 -3
View File
@@ -73,9 +73,9 @@ func TestSafeWalkWorkspace_ExcludedPaths(t *testing.T) {
writeFile(t, dir, "data.db", "x")
writeFile(t, dir, "data.db-wal", "x")
writeFile(t, dir, "data.db-shm", "x")
writeFile(t, dir, "web-fetch/page.txt", "x") // excluded: external content
// Should NOT be excluded
writeFile(t, dir, "notes/meeting.md", "x")
writeFile(t, dir, "web-fetch/page.txt", "x")
writeFile(t, dir, ".uploads/photo.jpg", "x")
writeFile(t, dir, "soul-notes.md", "x")
writeFile(t, dir, "deep/SOUL.md", "x") // not root level
@@ -85,7 +85,7 @@ func TestSafeWalkWorkspace_ExcludedPaths(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(entries) != 6 {
if len(entries) != 5 {
names := make([]string, len(entries))
for i, e := range entries {
names[i] = e.RelPath
@@ -201,7 +201,8 @@ func TestIsExcludedPath(t *testing.T) {
{"data.db-shm", true},
// NOT excluded:
{"notes/meeting.md", false},
{"web-fetch/page.txt", false},
{"web-fetch/page.txt", true},
{"agents/my-bot/web-fetch/data.txt", true},
{"images/screenshot.png", false},
{"teams/abc-123/doc.md", false},
{"soul-notes.md", false},
@@ -0,0 +1,18 @@
-- Revert: delete orphaned docs, restore NOT NULL + CASCADE.
DELETE FROM vault_documents WHERE agent_id IS NULL;
ALTER TABLE vault_documents ALTER COLUMN agent_id SET NOT NULL;
ALTER TABLE vault_documents DROP CONSTRAINT vault_documents_agent_id_fkey;
ALTER TABLE vault_documents ADD CONSTRAINT vault_documents_agent_id_fkey
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE;
DROP INDEX IF EXISTS uq_vault_docs_agent_team_scope_path;
CREATE UNIQUE INDEX uq_vault_docs_agent_team_scope_path
ON vault_documents (agent_id, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'), scope, path);
DROP TRIGGER IF EXISTS trg_vault_docs_agent_null_scope ON vault_documents;
DROP FUNCTION IF EXISTS vault_docs_agent_null_scope_fix();
DROP INDEX IF EXISTS idx_vault_docs_agent_scope;
CREATE INDEX idx_vault_docs_agent_scope ON vault_documents(agent_id, scope);
@@ -0,0 +1,41 @@
-- Make agent_id nullable so team-scoped and tenant-shared files can exist
-- without an owning agent.
-- 1. Drop NOT NULL on agent_id.
ALTER TABLE vault_documents ALTER COLUMN agent_id DROP NOT NULL;
-- 2. Change FK from CASCADE to SET NULL (agent deletion preserves docs).
ALTER TABLE vault_documents DROP CONSTRAINT vault_documents_agent_id_fkey;
ALTER TABLE vault_documents ADD CONSTRAINT vault_documents_agent_id_fkey
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL;
-- 3. Replace unique index: add tenant_id as leading column, COALESCE both nullable cols.
DROP INDEX IF EXISTS uq_vault_docs_agent_team_scope_path;
CREATE UNIQUE INDEX uq_vault_docs_agent_team_scope_path
ON vault_documents (
tenant_id,
COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'),
COALESCE(team_id, '00000000-0000-0000-0000-000000000000'),
scope,
path
);
-- 4. Trigger: when agent deleted (SET NULL) and no team -> scope='shared'.
CREATE OR REPLACE FUNCTION vault_docs_agent_null_scope_fix()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.agent_id IS NULL AND OLD.agent_id IS NOT NULL AND NEW.team_id IS NULL THEN
NEW.scope := 'shared';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_vault_docs_agent_null_scope
BEFORE UPDATE OF agent_id ON vault_documents
FOR EACH ROW
EXECUTE FUNCTION vault_docs_agent_null_scope_fix();
-- 5. Partial index for agent-scoped queries (skip NULLs).
DROP INDEX IF EXISTS idx_vault_docs_agent_scope;
CREATE INDEX idx_vault_docs_agent_scope ON vault_documents(agent_id, scope) WHERE agent_id IS NOT NULL;
@@ -56,6 +56,8 @@ export function SigmaGraphContainer({
}: SigmaGraphContainerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const internalSigmaRef = useRef<Sigma | null>(null);
// Incremented when sigma instance changes — used to trigger event handler registration.
const [sigmaVersion, setSigmaVersion] = useState(0);
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
// Pulse phase for animated highlighted edges (0..1, cycles)
const [pulsePhase, setPulsePhase] = useState(0);
@@ -64,6 +66,7 @@ export function SigmaGraphContainer({
const setSigmaRef = useCallback(
(instance: Sigma | null) => {
internalSigmaRef.current = instance;
setSigmaVersion((v) => v + 1);
onSigmaReady?.(instance);
},
[onSigmaReady],
@@ -337,7 +340,15 @@ export function SigmaGraphContainer({
sigma.refresh({ skipIndexation: true });
}, [pulsePhase]);
// --- Event handlers: use Sigma v3 native doubleClickNode (snappier than 280ms timer) ---
// --- Event handlers: use refs for values that change frequently to avoid
// re-registering Sigma listeners (which drops in-flight double-click events) ---
const selectedNodeIdRef = useRef(selectedNodeId);
selectedNodeIdRef.current = selectedNodeId;
const onNodeSelectRef = useRef(onNodeSelect);
onNodeSelectRef.current = onNodeSelect;
const onNodeDoubleClickRef = useRef(onNodeDoubleClick);
onNodeDoubleClickRef.current = onNodeDoubleClick;
useEffect(() => {
const sigma = internalSigmaRef.current;
if (!sigma) return;
@@ -353,17 +364,17 @@ export function SigmaGraphContainer({
};
const handleClickNode = ({ node }: { node: string }) => {
onNodeSelect?.(node === selectedNodeId ? null : node);
onNodeSelectRef.current?.(node === selectedNodeIdRef.current ? null : node);
};
const handleDoubleClickNode = ({ node, event }: { node: string; event: { preventSigmaDefault?: () => void } }) => {
// Prevent Sigma's default zoom-in behavior on double-click
event.preventSigmaDefault?.();
onNodeDoubleClick?.(node);
onNodeDoubleClickRef.current?.(node);
};
const handleClickStage = () => {
onNodeSelect?.(null);
onNodeSelectRef.current?.(null);
};
sigma.on("enterNode", handleEnterNode);
@@ -379,7 +390,7 @@ export function SigmaGraphContainer({
sigma.off("doubleClickNode", handleDoubleClickNode);
sigma.off("clickStage", handleClickStage);
};
}, [selectedNodeId, onNodeSelect, onNodeDoubleClick]);
}, [sigmaVersion]); // re-register only when sigma instance changes
// NOTE: Click on node NO LONGER moves camera.
// Camera only animates for explicit user actions (search, fit-to-view, keyboard F).
@@ -42,6 +42,8 @@ export interface InitState {
kgProvider: string;
kgModel: string;
kgMinConfidence: string;
bgProvider: string;
bgModel: string;
}
export const DEFAULTS: InitState = {
@@ -51,6 +53,7 @@ export const DEFAULTS: InitState = {
compProvider: "", compModel: "",
compThreshold: "", compKeepRecent: "", compMaxTokens: "",
kgProvider: "", kgModel: "", kgMinConfidence: "0.75",
bgProvider: "", bgModel: "",
};
export function parseBool(v: string | undefined, fallback: boolean): boolean {
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Settings2, Loader2, Save, AlertTriangle, Info, ExternalLink, Network } from "lucide-react";
import { Settings2, Loader2, Save, AlertTriangle, Info, ExternalLink, Network, Cog } from "lucide-react";
import { Link } from "react-router";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
@@ -57,6 +57,10 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
const [kgModel, setKgModel] = useState("");
const [kgMinConfidence, setKgMinConfidence] = useState("0.75");
// Background Workers
const [bgProvider, setBgProvider] = useState("");
const [bgModel, setBgModel] = useState("");
const applyConfigs = useCallback((
configs: Record<string, string>,
kgSettings?: { extraction_provider?: string; extraction_model?: string; min_confidence?: number },
@@ -71,12 +75,14 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
compMaxTokens: configs["compaction.max_tokens"] ?? "",
kgProvider: kgSettings?.extraction_provider ?? "", kgModel: kgSettings?.extraction_model ?? "",
kgMinConfidence: String(kgSettings?.min_confidence ?? 0.75),
bgProvider: configs["background.provider"] ?? "", bgModel: configs["background.model"] ?? "",
};
setInit(s);
setEmbProvider(s.embProvider); setEmbModel(s.embModel); setEmbMaxChunkLen(s.embMaxChunkLen); setEmbChunkOverlap(s.embChunkOverlap);
setToolStatus(s.toolStatus); setBlockReply(s.blockReply); setIntentClassify(s.intentClassify);
setCompProvider(s.compProvider); setCompModel(s.compModel); setCompThreshold(s.compThreshold); setCompKeepRecent(s.compKeepRecent); setCompMaxTokens(s.compMaxTokens);
setKgProvider(s.kgProvider); setKgModel(s.kgModel); setKgMinConfidence(s.kgMinConfidence);
setBgProvider(s.bgProvider); setBgModel(s.bgModel);
resetEmb();
}, [resetEmb]);
@@ -118,6 +124,8 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
if (compThreshold !== init.compThreshold) updates["compaction.threshold"] = compThreshold;
if (compKeepRecent !== init.compKeepRecent) updates["compaction.keep_recent"] = compKeepRecent;
if (compMaxTokens !== init.compMaxTokens) updates["compaction.max_tokens"] = compMaxTokens;
if (bgProvider !== init.bgProvider) updates["background.provider"] = bgProvider;
if (bgModel !== init.bgModel) updates["background.model"] = bgModel;
for (const [key, value] of Object.entries(updates)) await http.put(`/v1/system-configs/${key}`, { value });
const kgChanged = kgProvider !== init.kgProvider || kgModel !== init.kgModel || kgMinConfidence !== init.kgMinConfidence;
if (kgChanged) {
@@ -179,6 +187,20 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
</CardContent>
</Card>
{/* Background Workers */}
<Card className="border-slate-200 dark:border-slate-700">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base"><Cog className="h-4 w-4 text-slate-500" />{t("bg.title")}</CardTitle>
<CardDescription>{t("bg.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<ProviderModelSelect provider={bgProvider} onProviderChange={(v) => { setBgProvider(v); setBgModel(""); }} model={bgModel} onModelChange={setBgModel} allowEmpty providerLabel={t("bg.provider")} modelLabel={t("bg.model")} providerTip={t("bg.providerTip")} modelTip={t("bg.modelTip")} providerPlaceholder={t("bg.providerPlaceholder")} modelPlaceholder={t("bg.modelPlaceholder")} />
<div className="flex items-start gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-700 dark:border-slate-700 dark:bg-slate-950/30 dark:text-slate-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" /><span>{t("bg.info")}</span>
</div>
</CardContent>
</Card>
<FeatureSwitchGroup title={t("ux.title")} description={t("ux.description")} items={uxItems} />
<SystemSettingsCompactionCard
+13 -1
View File
@@ -273,7 +273,19 @@
"confirmRollback": "Revert this applied suggestion?",
"confirmDescription": "This action will update the suggestion status.",
"notEnabled": "Evolution not enabled",
"notEnabledHint": "Enable 'Evolution Metrics' in the Evolution section of the Agent tab to start recording metrics."
"notEnabledHint": "Enable 'Evolution Metrics' in the Evolution section of the Agent tab to start recording metrics.",
"colType": "Type",
"colSuggestion": "Suggestion",
"colStatus": "Status",
"colCreated": "Created",
"colActions": "Actions",
"cancel": "Cancel",
"confirm": "Confirm",
"confirming": "...",
"successRate": "Success Rate",
"usageRate": "Usage Rate",
"tooltipCalls": "{{count}} calls, {{ms}}ms avg",
"tooltipQueries": "{{count}} queries, score {{score}}"
}
},
"general": {
@@ -53,6 +53,17 @@
"minConfidenceHint": "Minimum confidence threshold (0–1) for extracted entities.",
"info": "Automatically extracts entities and relationships when agents write to memory. Leave provider empty to disable."
},
"bg": {
"title": "Background Workers",
"description": "Provider and model for background tasks (vault enrichment, consolidation, dreaming).",
"provider": "Provider",
"model": "Model",
"providerTip": "LLM provider for background tasks. Leave empty to use agent default.",
"modelTip": "Model for background tasks. Leave empty to use provider default.",
"providerPlaceholder": "(agent default)",
"modelPlaceholder": "(default)",
"info": "Used for vault enrichment (document summarization), consolidation (session summaries), and dreaming. Leave empty to fall back to the agent default provider."
},
"compaction": {
"title": "Pending Message Compaction",
"description": "Summarize long pending message history to stay within context limits.",
+5 -2
View File
@@ -5,6 +5,7 @@
"searchPlaceholder": "Search documents...",
"filterAgent": "Agent",
"allAgents": "All agents",
"allTypes": "All",
"noDocuments": "No documents in vault",
"noResults": "No search results",
"searchFailed": "Search failed",
@@ -36,7 +37,8 @@
"noLinks": "No links",
"contentPreview": "Content Preview",
"fileNotFound": "File not found or inaccessible",
"emptyContent": "No content available"
"emptyContent": "No content available",
"binaryFile": "Binary file — preview not available"
},
"fields": {
"title": "Title",
@@ -62,7 +64,8 @@
"memory": "Memory",
"note": "Note",
"skill": "Skill",
"episodic": "Episodic"
"episodic": "Episodic",
"media": "Media"
},
"rescanTooltip": "Rescan workspace",
"rescanNew": "{{count}} new",
+43 -4
View File
@@ -181,7 +181,46 @@
"personality": "Cá tính Agent",
"modelBudget": "Mô hình & Ngân sách",
"skills": "Kỹ năng",
"evolution": { "title": "Tiến hóa" },
"evolution": {
"title": "Tiến hóa",
"metricsLabel": "Số liệu Tiến hóa",
"metricsHint": "Ghi lại hiệu quả tool, chất lượng truy xuất và phản hồi phản hồi",
"suggestionsLabel": "Đề xuất Tiến hóa",
"suggestionsHint": "Tạo đề xuất cải thiện dựa trên dữ liệu từ số liệu đã ghi",
"toolSuccess": "Tỉ lệ thành công Tool",
"retrievalQuality": "Chất lượng truy xuất",
"feedback": "Phản hồi",
"noMetrics": "Chưa có số liệu. Số liệu sẽ xuất hiện khi agent xử lý yêu cầu.",
"noSuggestions": "Chưa có đề xuất. Đề xuất được tạo bởi cron phân tích hàng ngày.",
"suggestions": "Đề xuất",
"guardrails": "Rào chắn thích nghi",
"maxDelta": "Delta tối đa / chu kỳ",
"minDataPoints": "Điểm dữ liệu tối thiểu",
"rollbackDrop": "Rollback khi giảm",
"lockedParams": "Tham số bị khóa",
"timeRange": "Khoảng thời gian",
"approve": "Duyệt",
"reject": "Từ chối",
"rollback": "Hoàn tác",
"confirmApprove": "Áp dụng đề xuất này cho agent?",
"confirmReject": "Từ chối đề xuất này?",
"confirmRollback": "Hoàn tác đề xuất đã áp dụng này?",
"confirmDescription": "Hành động này sẽ cập nhật trạng thái đề xuất.",
"notEnabled": "Tiến hóa chưa bật",
"notEnabledHint": "Bật 'Số liệu Tiến hóa' trong phần Tiến hóa của tab Agent để bắt đầu ghi số liệu.",
"colType": "Loại",
"colSuggestion": "Đề xuất",
"colStatus": "Trạng thái",
"colCreated": "Tạo lúc",
"colActions": "Thao tác",
"cancel": "Hủy",
"confirm": "Xác nhận",
"confirming": "...",
"successRate": "Tỉ lệ thành công",
"usageRate": "Tỉ lệ sử dụng",
"tooltipCalls": "{{count}} lệnh, trung bình {{ms}}ms",
"tooltipQueries": "{{count}} truy vấn, điểm {{score}}"
},
"capabilities": "Khả năng",
"llmSeesAs": "LLM nhìn thấy đây là",
"prompt": {
@@ -211,9 +250,9 @@
"memory": "Memory",
"memorySm": "Memory (sm)",
"sandbox": "Sandbox",
"evolution": "Evolution",
"channelHints": "Channel Hints",
"pinnedSkills": "Pinned Skills",
"evolution": "Tiến hóa",
"channelHints": "Gợi ý kênh",
"pinnedSkills": "Skill ghim",
"skillsHybrid": "Skills (hybrid)",
"memoryMin": "Memory (min)",
"domainCtx": "Domain Context",
@@ -53,6 +53,17 @@
"minConfidenceHint": "Ngưỡng độ tin cậy (0–1) cho thực thể được trích xuất.",
"info": "Tự động trích xuất thực thể và quan hệ khi agent ghi vào bộ nhớ. Để trống provider để tắt."
},
"bg": {
"title": "Background Workers",
"description": "Provider và model cho các tác vụ nền (vault enrichment, consolidation, dreaming).",
"provider": "Provider",
"model": "Model",
"providerTip": "Provider LLM cho tác vụ nền. Để trống sẽ dùng agent default.",
"modelTip": "Model cho tác vụ nền. Để trống sẽ dùng model mặc định của provider.",
"providerPlaceholder": "(agent default)",
"modelPlaceholder": "(mặc định)",
"info": "Dùng cho vault enrichment (tóm tắt tài liệu), consolidation (tóm tắt phiên) và dreaming. Để trống sẽ fallback về agent default provider."
},
"compaction": {
"title": "Nén tin nhắn chờ",
"description": "Tóm tắt lịch sử tin nhắn chờ dài để nằm trong giới hạn context.",
+5 -2
View File
@@ -5,6 +5,7 @@
"searchPlaceholder": "Tìm tài liệu...",
"filterAgent": "Agent",
"allAgents": "Tất cả agent",
"allTypes": "Tất cả",
"noDocuments": "Không có tài liệu trong kho",
"noResults": "Không có kết quả",
"searchFailed": "Tìm kiếm thất bại",
@@ -36,7 +37,8 @@
"noLinks": "Không có liên kết",
"contentPreview": "Xem nội dung",
"fileNotFound": "Không tìm thấy tệp",
"emptyContent": "Không có nội dung"
"emptyContent": "Không có nội dung",
"binaryFile": "Tệp nhị phân — không thể xem trước"
},
"fields": {
"title": "Tiêu đề",
@@ -62,7 +64,8 @@
"memory": "Bộ nhớ",
"note": "Ghi chú",
"skill": "Kỹ năng",
"episodic": "Giai thoại"
"episodic": "Giai thoại",
"media": "Media"
},
"rescanTooltip": "Quét lại workspace",
"rescanNew": "{{count}} mới",
+43 -4
View File
@@ -181,7 +181,46 @@
"personality": "Agent个性",
"modelBudget": "模型与预算",
"skills": "技能",
"evolution": { "title": "进化" },
"evolution": {
"title": "进化",
"metricsLabel": "进化指标",
"metricsHint": "记录工具效果、检索质量和响应反馈",
"suggestionsLabel": "进化建议",
"suggestionsHint": "根据已记录的指标生成数据驱动的改进建议",
"toolSuccess": "工具成功率",
"retrievalQuality": "检索质量",
"feedback": "反馈",
"noMetrics": "尚无指标记录。Agent 处理请求后指标将出现。",
"noSuggestions": "尚无建议。建议由每日分析 cron 生成。",
"suggestions": "建议",
"guardrails": "适应护栏",
"maxDelta": "每周期最大变化",
"minDataPoints": "最少数据点",
"rollbackDrop": "下降时回滚",
"lockedParams": "锁定参数",
"timeRange": "时间范围",
"approve": "批准",
"reject": "拒绝",
"rollback": "回滚",
"confirmApprove": "将此建议应用到 Agent?",
"confirmReject": "拒绝此建议?",
"confirmRollback": "撤销此已应用的建议?",
"confirmDescription": "此操作将更新建议状态。",
"notEnabled": "进化未启用",
"notEnabledHint": "在 Agent 选项卡的进化部分启用「进化指标」以开始记录指标。",
"colType": "类型",
"colSuggestion": "建议",
"colStatus": "状态",
"colCreated": "创建时间",
"colActions": "操作",
"cancel": "取消",
"confirm": "确认",
"confirming": "...",
"successRate": "成功率",
"usageRate": "使用率",
"tooltipCalls": "{{count}} 次调用,平均 {{ms}}ms",
"tooltipQueries": "{{count}} 次查询,得分 {{score}}"
},
"capabilities": "能力",
"llmSeesAs": "LLM会看到这个为",
"prompt": {
@@ -211,9 +250,9 @@
"memory": "Memory",
"memorySm": "Memory (sm)",
"sandbox": "Sandbox",
"evolution": "Evolution",
"channelHints": "Channel Hints",
"pinnedSkills": "Pinned Skills",
"evolution": "进化",
"channelHints": "频道提示",
"pinnedSkills": "固定技能",
"skillsHybrid": "Skills (hybrid)",
"memoryMin": "Memory (min)",
"domainCtx": "Domain Context",
@@ -53,6 +53,17 @@
"minConfidenceHint": "提取实体的最低置信度阈值(0–1)。",
"info": "当Agent写入记忆时自动提取实体和关系。留空Provider以禁用。"
},
"bg": {
"title": "后台工作者",
"description": "用于后台任务(知识库摘要、会话整合、梦境)的Provider和模型。",
"provider": "Provider",
"model": "模型",
"providerTip": "后台任务使用的LLM Provider。留空则使用Agent默认。",
"modelTip": "后台任务使用的模型。留空则使用Provider默认。",
"providerPlaceholder": "(Agent默认)",
"modelPlaceholder": "(默认)",
"info": "用于知识库文档摘要、会话整合和梦境。留空则回退到Agent默认Provider。"
},
"compaction": {
"title": "待处理消息压缩",
"description": "压缩长待处理消息历史以保持在上下文限制内。",
+5 -2
View File
@@ -5,6 +5,7 @@
"searchPlaceholder": "搜索文档...",
"filterAgent": "智能体",
"allAgents": "所有智能体",
"allTypes": "全部",
"noDocuments": "知识库中没有文档",
"noResults": "没有搜索结果",
"searchFailed": "搜索失败",
@@ -36,7 +37,8 @@
"noLinks": "无链接",
"contentPreview": "内容预览",
"fileNotFound": "文件未找到",
"emptyContent": "无内容"
"emptyContent": "无内容",
"binaryFile": "二进制文件 — 无法预览"
},
"fields": {
"title": "标题",
@@ -62,7 +64,8 @@
"memory": "记忆",
"note": "笔记",
"skill": "技能",
"episodic": "情景记忆"
"episodic": "情景记忆",
"media": "Media"
},
"rescanTooltip": "重新扫描工作区",
"rescanNew": "{{count}} 个新文件",
+9
View File
@@ -367,3 +367,12 @@
.dark .hljs-template-variable { color: #ffa657; }
.dark .hljs-deletion { color: #ffa198; background: rgba(255,129,130,0.15); }
.dark .hljs-addition { background: rgba(63,185,80,0.15); }
/* Hide scrollbar but keep scrolling */
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
@@ -144,7 +144,7 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) {
const currentDescKey = CONFIG_TYPES.find((c) => c.value === configType)?.descKey ?? "";
return (
<div className="space-y-4">
<section className="space-y-4 rounded-lg border p-3 sm:p-4">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div>
@@ -305,6 +305,6 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) {
)}
</div>
)}
</div>
</section>
);
}
@@ -18,16 +18,20 @@ export function EvolutionMetricsCharts({ toolAggs, retrievalAggs, loading }: Evo
return <div className="h-[200px] animate-pulse rounded-md bg-muted" />;
}
// API returns rates as 0-1 fractions; convert to 0-100 for chart display.
const toolData = toolAggs.map((a) => ({ ...a, success_rate: a.success_rate * 100 }));
const retrievalData = retrievalAggs.map((a) => ({ ...a, usage_rate: a.usage_rate * 100 }));
return (
<div className="space-y-6">
{/* Tool Success Rate */}
<div className="space-y-2">
<h4 className="text-sm font-medium">{t("detail.evolution.toolSuccess")}</h4>
{toolAggs.length === 0 ? (
{toolData.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("detail.evolution.noMetrics")}</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={toolAggs} margin={{ top: 4, right: 20, left: 0, bottom: 0 }}>
<BarChart data={toolData} margin={{ top: 4, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="tool_name" tick={{ fontSize: 11 }} tickLine={false} />
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} width={36} tickFormatter={(v) => `${v}%`} />
@@ -35,12 +39,12 @@ export function EvolutionMetricsCharts({ toolAggs, retrievalAggs, loading }: Evo
formatter={(value, _name, props) => {
const v = Number(value ?? 0);
const p = props?.payload as ToolAggregate | undefined;
return [`${v.toFixed(1)}%`, p ? `${p.call_count} calls, ${p.avg_duration_ms.toFixed(0)}ms avg` : ""];
return [`${v.toFixed(1)}%`, p ? t("detail.evolution.tooltipCalls", { count: p.call_count, ms: p.avg_duration_ms.toFixed(0) }) : ""];
}}
/>
<Bar
dataKey="success_rate"
name="Success Rate"
name={t("detail.evolution.successRate")}
radius={[3, 3, 0, 0]}
isAnimationActive={false}
fill="#22c55e"
@@ -53,11 +57,11 @@ export function EvolutionMetricsCharts({ toolAggs, retrievalAggs, loading }: Evo
{/* Retrieval Quality */}
<div className="space-y-2">
<h4 className="text-sm font-medium">{t("detail.evolution.retrievalQuality")}</h4>
{retrievalAggs.length === 0 ? (
{retrievalData.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("detail.evolution.noMetrics")}</p>
) : (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={retrievalAggs} margin={{ top: 4, right: 20, left: 0, bottom: 0 }}>
<BarChart data={retrievalData} margin={{ top: 4, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="source" tick={{ fontSize: 11 }} tickLine={false} />
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} width={36} tickFormatter={(v) => `${v}%`} />
@@ -65,12 +69,12 @@ export function EvolutionMetricsCharts({ toolAggs, retrievalAggs, loading }: Evo
formatter={(value, _name, props) => {
const v = Number(value ?? 0);
const p = props?.payload as RetrievalAggregate | undefined;
return [`${v.toFixed(1)}%`, p ? `${p.query_count} queries, score ${p.avg_score.toFixed(2)}` : ""];
return [`${v.toFixed(1)}%`, p ? t("detail.evolution.tooltipQueries", { count: p.query_count, score: p.avg_score.toFixed(2) }) : ""];
}}
/>
<Bar
dataKey="usage_rate"
name="Usage Rate"
name={t("detail.evolution.usageRate")}
fill="#3b82f6"
radius={[3, 3, 0, 0]}
isAnimationActive={false}
@@ -61,11 +61,11 @@ export function EvolutionSuggestionsTable({ suggestions, loading, onUpdateStatus
<table className="w-full text-sm min-w-[600px]">
<thead>
<tr className="border-b bg-muted/50 text-left">
<th className="px-3 py-2 font-medium">Type</th>
<th className="px-3 py-2 font-medium">Suggestion</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Created</th>
<th className="px-3 py-2 font-medium text-right">Actions</th>
<th className="px-3 py-2 font-medium">{t("detail.evolution.colType")}</th>
<th className="px-3 py-2 font-medium">{t("detail.evolution.colSuggestion")}</th>
<th className="px-3 py-2 font-medium">{t("detail.evolution.colStatus")}</th>
<th className="px-3 py-2 font-medium">{t("detail.evolution.colCreated")}</th>
<th className="px-3 py-2 font-medium text-right">{t("detail.evolution.colActions")}</th>
</tr>
</thead>
<tbody>
@@ -144,10 +144,10 @@ export function EvolutionSuggestionsTable({ suggestions, loading, onUpdateStatus
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirm(null)} disabled={acting}>
Cancel
{t("detail.evolution.cancel")}
</Button>
<Button onClick={handleConfirm} disabled={acting}>
{acting ? "..." : "Confirm"}
{acting ? t("detail.evolution.confirming") : t("detail.evolution.confirm")}
</Button>
</DialogFooter>
</DialogContent>
@@ -107,17 +107,16 @@ export function useCreateLink(agentId: string) {
return { create };
}
/** Rescan workspace to sync vault documents from filesystem. */
export function useRescanWorkspace(agentId: string) {
/** Rescan workspace to sync vault documents from filesystem (tenant-wide). */
export function useRescanWorkspace() {
const http = useHttp();
const queryClient = useQueryClient();
const [isPending, setIsPending] = useState(false);
const rescan = useCallback(async () => {
if (!agentId) return;
setIsPending(true);
try {
const result = await http.post<RescanResult>(`/v1/agents/${agentId}/vault/rescan`, {});
const result = await http.post<RescanResult>(`/v1/vault/rescan`, {});
await queryClient.invalidateQueries({ queryKey: [VAULT_KEY] });
const parts: string[] = [];
@@ -140,7 +139,7 @@ export function useRescanWorkspace(agentId: string) {
} finally {
setIsPending(false);
}
}, [http, agentId, queryClient]);
}, [http, queryClient]);
return { rescan, isPending };
}
+40 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import type { VaultDocument, VaultLink, VaultSearchResult } from "@/types/vault";
@@ -72,6 +72,26 @@ export function useVaultSearch(agentId: string) {
return { search, invalidate };
}
/** Tenant-wide vault search (agent_id optional). */
export function useVaultSearchAll() {
const http = useHttp();
const search = useCallback(
async (query: string, opts?: { agentId?: string; docTypes?: string[]; teamId?: string; maxResults?: number }) => {
return http.post<VaultSearchResult[]>("/v1/vault/search", {
query,
agent_id: opts?.agentId || undefined,
doc_types: opts?.docTypes,
team_id: opts?.teamId || undefined,
max_results: opts?.maxResults ?? 20,
});
},
[http],
);
return { search };
}
/** Fetch all links for a set of vault documents (for graph view). */
export function useVaultAllLinks(agentId: string, documents: { id: string }[]) {
const http = useHttp();
@@ -219,6 +239,25 @@ export function useVaultFileContent(path: string | null) {
return { content: data?.content ?? null, size: data?.size ?? 0, loading: isLoading, error: !!error };
}
/** Fetch an image file as blob URL for authenticated rendering in <img> tags. */
export function useVaultImageUrl(path: string | null): { url: string | null; error: boolean } {
const http = useHttp();
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
if (!path) { setUrl(null); setError(false); return; }
let revoke: string | null = null;
setError(false);
http.downloadBlob(`/v1/storage/files/${encodeURIComponent(path)}?raw=true`)
.then((blob) => { revoke = URL.createObjectURL(blob); setUrl(revoke); })
.catch(() => { setUrl(null); setError(true); });
return () => { if (revoke) URL.revokeObjectURL(revoke); };
}, [path, http]);
return { url, error };
}
// Re-export mutations for convenience — consumers can import from this single file
export {
useCreateDocument,
+48 -6
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pencil, Plus, FileText, Link2 } from "lucide-react";
import { Pencil, Plus, FileText, Link2, FileQuestion } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -8,7 +8,7 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
import { useVaultLinks, useVaultFileContent, useUpdateDocument, useDeleteDocument } from "./hooks/use-vault";
import { useVaultLinks, useVaultFileContent, useVaultImageUrl, useUpdateDocument, useDeleteDocument } from "./hooks/use-vault";
import { VaultLinkDialog } from "./vault-link-dialog";
import {
VaultEditControls, DocTypeSelect, ScopeSelect, LinkBadge,
@@ -34,10 +34,22 @@ export function VaultDetailDialog({ doc, open, onOpenChange, onDeleted }: Props)
const [confirmDelete, setConfirmDelete] = useState(false);
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
// Content always loaded when dialog is open
// Determine if this is an image that can be rendered.
const isImage = useMemo(() => {
const mime = doc?.metadata?.mime_type as string | undefined;
if (mime?.startsWith("image/")) return true;
const ext = doc?.path.split(".").pop()?.toLowerCase() ?? "";
return ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico"].includes(ext);
}, [doc?.metadata, doc?.path]);
const isMedia = doc?.doc_type === "media";
// Only fetch text content for non-media files (media/binary cannot be rendered as markdown).
const { content: fileContent, loading: contentLoading, error: contentError } = useVaultFileContent(
open && doc ? doc.path : null,
open && doc && !isMedia ? doc.path : null,
);
// Fetch image as authenticated blob URL for <img> rendering.
const { url: imageUrl, error: imageError } = useVaultImageUrl(open && isMedia && isImage && doc ? doc.path : null);
const { update } = useUpdateDocument(doc?.agent_id ?? "", doc?.id ?? "");
const { remove: removeDoc } = useDeleteDocument(doc?.agent_id ?? "", doc?.id ?? "");
@@ -149,7 +161,37 @@ export function VaultDetailDialog({ doc, open, onOpenChange, onDeleted }: Props)
{/* Content preview — always visible, scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto rounded-md border bg-muted/30 p-4">
{contentLoading ? (
{isMedia ? (
isImage ? (
<div className="flex items-center justify-center">
{imageUrl ? (
<img
src={imageUrl}
alt={doc.title || doc.path}
className="max-w-full max-h-[60vh] object-contain rounded"
/>
) : imageError ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="h-4 w-4" />
<span>{t("detail.fileNotFound")}</span>
</div>
) : (
<div className="h-32 w-32 animate-pulse rounded bg-muted" />
)}
</div>
) : (
<div className="flex flex-col items-center justify-center gap-3 py-8 text-muted-foreground">
<FileQuestion className="h-10 w-10" />
<div className="text-center space-y-1">
<p className="text-sm font-medium">{t("detail.binaryFile")}</p>
<p className="text-xs">{(doc.metadata?.mime_type as string) || doc.path.split(".").pop()?.toUpperCase()}</p>
</div>
{doc.summary && (
<p className="text-xs text-center max-w-md mt-2">{doc.summary}</p>
)}
</div>
)
) : contentLoading ? (
<div className="space-y-2">
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
<div className="h-4 w-1/2 animate-pulse rounded bg-muted" />
+132 -26
View File
@@ -1,24 +1,26 @@
import { useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Link2, ChevronLeft, ChevronRight,
Link2, ChevronLeft, ChevronRight, Search, X, Loader2,
FileText, Brain, StickyNote, Sparkles, Clock, Image,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatRelativeTime } from "@/lib/format";
import type { VaultDocument } from "@/types/vault";
import type { VaultDocument, VaultSearchResult } from "@/types/vault";
import { useVaultSearchAll } from "./hooks/use-vault";
const DOC_TYPE_CONFIG: Record<string, { color: string; bg: string; icon: typeof FileText }> = {
context: { color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-500/10", icon: FileText },
memory: { color: "text-purple-600 dark:text-purple-400", bg: "bg-purple-500/10", icon: Brain },
note: { color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-500/10", icon: StickyNote },
skill: { color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-500/10", icon: Sparkles },
episodic: { color: "text-orange-600 dark:text-orange-400", bg: "bg-orange-500/10", icon: Clock },
media: { color: "text-rose-600 dark:text-rose-400", bg: "bg-rose-500/10", icon: Image },
const DOC_TYPE_CONFIG: Record<string, { color: string; bg: string; icon: typeof FileText; dotColor: string }> = {
context: { color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-500/10", icon: FileText, dotColor: "bg-blue-500" },
memory: { color: "text-purple-600 dark:text-purple-400", bg: "bg-purple-500/10", icon: Brain, dotColor: "bg-purple-500" },
note: { color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-500/10", icon: StickyNote, dotColor: "bg-amber-500" },
skill: { color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-500/10", icon: Sparkles, dotColor: "bg-emerald-500" },
episodic: { color: "text-orange-600 dark:text-orange-400", bg: "bg-orange-500/10", icon: Clock, dotColor: "bg-orange-500" },
media: { color: "text-rose-600 dark:text-rose-400", bg: "bg-rose-500/10", icon: Image, dotColor: "bg-rose-500" },
};
const DEFAULT_CONFIG = { color: "text-muted-foreground", bg: "bg-muted", icon: FileText };
const DEFAULT_CONFIG = { color: "text-muted-foreground", bg: "bg-muted", icon: FileText, dotColor: "bg-muted-foreground" };
const DOC_TYPES = ["context", "memory", "note", "skill", "episodic", "media"] as const;
interface Props {
documents: VaultDocument[];
@@ -30,6 +32,10 @@ interface Props {
totalPages: number;
total: number;
onPageChange: (page: number) => void;
docType: string;
onDocTypeChange: (type: string) => void;
agentId: string;
teamId: string;
}
function DocCard({ doc, selected, linkCount, onClick }: {
@@ -54,17 +60,13 @@ function DocCard({ doc, selected, linkCount, onClick }: {
}`}
onClick={onClick}
>
{/* Type icon */}
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded ${cfg.bg}`}>
<Icon className={`h-3 w-3 ${cfg.color}`} />
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium leading-snug">
{doc.title || doc.path.split("/").pop()}
</span>
<div className="mt-0.5 flex items-center gap-1.5 text-2xs text-muted-foreground">
<span>{t(`type.${doc.doc_type}`)}</span>
<span>·</span>
@@ -87,21 +89,125 @@ function DocCard({ doc, selected, linkCount, onClick }: {
}
export function VaultDocumentSidebar({
documents, selectedId, linkCounts, onSelect, loading, page, totalPages, total, onPageChange,
documents, selectedId, linkCounts, onSelect, loading,
page, totalPages, total, onPageChange,
docType, onDocTypeChange, agentId, teamId,
}: Props) {
const { t } = useTranslation("vault");
const [query, setQuery] = useState("");
const [searchResults, setSearchResults] = useState<VaultSearchResult[] | null>(null);
const [searching, setSearching] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const { search } = useVaultSearchAll();
const doSearch = useCallback(
async (q: string) => {
if (!q.trim()) {
setSearchResults(null);
return;
}
setSearching(true);
try {
const results = await search(q, {
agentId: agentId || undefined,
docTypes: docType ? [docType] : undefined,
teamId: teamId || undefined,
maxResults: 30,
});
setSearchResults(results);
} catch {
setSearchResults([]);
} finally {
setSearching(false);
}
},
[search, agentId, docType, teamId],
);
const handleQueryChange = (value: string) => {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!value.trim()) {
setSearchResults(null);
return;
}
debounceRef.current = setTimeout(() => doSearch(value), 300);
};
const clearSearch = () => {
setQuery("");
setSearchResults(null);
inputRef.current?.focus();
};
// Show search results or regular document list
const isSearchMode = query.trim().length > 0;
const displayDocs = isSearchMode && searchResults
? searchResults.map((r) => r.document)
: documents;
return (
<div className="flex h-full flex-col border-r bg-background">
{/* Header */}
<div className="flex h-10 items-center justify-between px-3 border-b shrink-0">
<span className="text-sm font-semibold">{t("title")}</span>
<Badge variant="secondary" className="text-2xs tabular-nums">{total}</Badge>
{/* Filter bar: type chips + search */}
<div className="shrink-0 border-b px-2 py-1.5 space-y-1.5">
{/* Type filter chips — horizontal scroll */}
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none">
<button
onClick={() => onDocTypeChange("")}
className={`shrink-0 px-1.5 py-0.5 rounded text-2xs font-medium transition-colors ${
!docType ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80 text-muted-foreground"
}`}
>
{t("allTypes")}
</button>
{DOC_TYPES.map((dt) => {
const cfg = DOC_TYPE_CONFIG[dt] ?? DEFAULT_CONFIG;
const active = docType === dt;
return (
<button
key={dt}
onClick={() => onDocTypeChange(active ? "" : dt)}
className={`shrink-0 flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs font-medium transition-colors ${
active ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80 text-muted-foreground"
}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${active ? "bg-primary-foreground" : cfg.dotColor}`} />
{t(`type.${dt}`)}
</button>
);
})}
<Badge variant="secondary" className="text-2xs tabular-nums ml-auto shrink-0">{total}</Badge>
</div>
{/* Search input */}
<div className="relative">
{searching
? <Loader2 className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground animate-spin" />
: <Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
}
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder={t("searchPlaceholder")}
className="w-full rounded-md border bg-background pl-7 pr-7 py-1 text-base md:text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
{query && (
<button
onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
{/* Doc list */}
<div className="flex-1 overflow-y-auto py-1">
{loading && documents.length === 0 ? (
{(loading || searching) && displayDocs.length === 0 ? (
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="mx-1.5 my-0.5 flex items-center gap-2 rounded-md px-2 py-1.5">
<div className="h-6 w-6 shrink-0 animate-pulse rounded bg-muted" />
@@ -111,13 +217,13 @@ export function VaultDocumentSidebar({
</div>
</div>
))
) : documents.length === 0 ? (
) : displayDocs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 gap-1 text-muted-foreground">
<FileText className="h-5 w-5" />
<span className="text-sm">{t("noDocuments")}</span>
<span className="text-sm">{isSearchMode ? t("noResults") : t("noDocuments")}</span>
</div>
) : (
documents.map((doc) => (
displayDocs.map((doc) => (
<DocCard
key={doc.id}
doc={doc}
@@ -129,8 +235,8 @@ export function VaultDocumentSidebar({
)}
</div>
{/* Pagination footer */}
{totalPages > 1 && (
{/* Pagination footer — hidden during search */}
{!isSearchMode && totalPages > 1 && (
<div className="flex items-center justify-center gap-2 px-3 py-1.5 border-t text-xs text-muted-foreground">
<Button variant="ghost" size="xs" disabled={page === 0} onClick={() => onPageChange(page - 1)}>
<ChevronLeft className="h-3.5 w-3.5" />
+18 -13
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
import { useState, useEffect, useMemo, useCallback, lazy, Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Search, FileArchive, Plus, PanelLeftOpen, FolderSync } from "lucide-react";
import { Search, FileArchive, Plus, PanelLeftOpen, FolderSync, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useAgents } from "@/pages/agents/hooks/use-agents";
@@ -31,6 +31,7 @@ export function VaultPage() {
const [selectedAgent, setSelectedAgent] = useState("");
const [selectedTeam, setSelectedTeam] = useState("");
const [docType, setDocType] = useState("");
const [detailDoc, setDetailDoc] = useState<VaultDocument | null>(null);
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
@@ -38,10 +39,11 @@ export function VaultPage() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [page, setPage] = useState(0);
const { rescan, isPending: rescanPending } = useRescanWorkspace(selectedAgent);
const { rescan, isPending: rescanPending } = useRescanWorkspace();
const { documents, total, loading } = useVaultDocuments(selectedAgent, {
teamId: selectedTeam || undefined,
docType: docType || undefined,
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
});
@@ -60,6 +62,7 @@ export function VaultPage() {
const handleAgentChange = (v: string) => { setSelectedAgent(v); setPage(0); };
const handleTeamChange = (v: string) => { setSelectedTeam(v); setPage(0); };
const handleDocTypeChange = (v: string) => { setDocType(v); setPage(0); };
// Sidebar click → open detail modal + highlight graph node
const handleSidebarSelect = (doc: VaultDocument) => {
@@ -69,15 +72,15 @@ export function VaultPage() {
};
// Graph single-click → highlight only
const handleNodeSelect = (docId: string | null) => {
const handleNodeSelect = useCallback((docId: string | null) => {
setSelectedDocId(docId);
};
}, []);
// Graph double-click → open detail modal + highlight
const handleNodeDoubleClick = (doc: VaultDocument) => {
const handleNodeDoubleClick = useCallback((doc: VaultDocument) => {
setDetailDoc(doc);
setSelectedDocId(doc.id);
};
}, []);
const handleCloseDetail = () => { setDetailDoc(null); };
@@ -104,6 +107,10 @@ export function VaultPage() {
totalPages={totalPages}
total={total}
onPageChange={setPage}
docType={docType}
onDocTypeChange={handleDocTypeChange}
agentId={selectedAgent}
teamId={selectedTeam}
/>
</div>
@@ -136,13 +143,11 @@ export function VaultPage() {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button size="sm" variant="outline" onClick={() => rescan()} disabled={!selectedAgent || rescanPending}>
<FolderSync className={`h-3.5 w-3.5${rescanPending ? " animate-spin" : ""}`} />
</Button>
</span>
<Button size="sm" variant="outline" onClick={() => rescan()} disabled={rescanPending}>
{rescanPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FolderSync className="h-3.5 w-3.5" />}
</Button>
</TooltipTrigger>
<TooltipContent>{!selectedAgent ? t("selectAgentFirst", "Select an agent first") : t("rescanTooltip", "Rescan workspace")}</TooltipContent>
<TooltipContent>{t("rescanTooltip", "Rescan workspace")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>