feat: seed initial memories from org template and create payload (#1050)

Add MemorySeed model and initial_memories support at three levels:
- POST /workspaces payload: seed memories on workspace creation
- org.yaml workspace config: per-workspace initial_memories with
  defaults fallback
- org.yaml global_memories: org-wide GLOBAL scope memories seeded
  on the first root workspace during import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
rabbitblood 2026-04-20 00:35:49 -07:00
parent e345aa832a
commit ff7ac87b97
4 changed files with 84 additions and 4 deletions

View File

@ -74,10 +74,13 @@ func NewOrgHandler(wh *WorkspaceHandler, b *events.Broadcaster, p *provisioner.P
// OrgTemplate is the YAML structure for an org hierarchy.
type OrgTemplate struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Defaults OrgDefaults `yaml:"defaults" json:"defaults"`
Workspaces []OrgWorkspace `yaml:"workspaces" json:"workspaces"`
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Defaults OrgDefaults `yaml:"defaults" json:"defaults"`
Workspaces []OrgWorkspace `yaml:"workspaces" json:"workspaces"`
// GlobalMemories is a list of org-wide memories seeded as GLOBAL scope
// on the first root workspace (PM) during org import. Issue #1050.
GlobalMemories []models.MemorySeed `yaml:"global_memories" json:"global_memories"`
}
type OrgDefaults struct {
@ -106,6 +109,9 @@ type OrgDefaults struct {
// Rendered into each workspace's config.yaml so agent prompts can read it
// generically (no hardcoded role names in prompts). See issue #51.
CategoryRouting map[string][]string `yaml:"category_routing" json:"category_routing"`
// InitialMemories are default memories seeded into every workspace at
// creation time unless the workspace overrides them. Issue #1050.
InitialMemories []models.MemorySeed `yaml:"initial_memories" json:"initial_memories"`
}
type OrgSchedule struct {
@ -170,6 +176,9 @@ type OrgWorkspace struct {
// (empty list drops the category entirely); new keys are added. See
// mergeCategoryRouting.
CategoryRouting map[string][]string `yaml:"category_routing" json:"category_routing"`
// InitialMemories are memories seeded into this workspace at creation
// time. If empty, defaults.initial_memories are used. Issue #1050.
InitialMemories []models.MemorySeed `yaml:"initial_memories" json:"initial_memories"`
Schedules []OrgSchedule `yaml:"schedules" json:"schedules"`
Channels []OrgChannel `yaml:"channels" json:"channels"`
External bool `yaml:"external" json:"external"`
@ -290,6 +299,22 @@ func (h *OrgHandler) Import(c *gin.Context) {
}
}
// Seed org-wide global_memories on the first root workspace (issue #1050).
// These are GLOBAL scope memories visible to all workspaces in the org.
if len(tmpl.GlobalMemories) > 0 && len(results) > 0 {
rootID, _ := results[0]["id"].(string)
if rootID != "" {
rootNS := workspaceAwarenessNamespace(rootID)
// Force scope to GLOBAL regardless of what the YAML says.
globalSeeds := make([]models.MemorySeed, len(tmpl.GlobalMemories))
for i, gm := range tmpl.GlobalMemories {
globalSeeds[i] = models.MemorySeed{Content: gm.Content, Scope: "GLOBAL"}
}
seedInitialMemories(context.Background(), rootID, globalSeeds, rootNS)
log.Printf("Org import: seeded %d global memories on root workspace %s", len(globalSeeds), rootID)
}
}
// Hot-reload channel manager once after all channels are inserted
// (instead of per-workspace, avoiding N redundant DB queries + diffs).
if h.channelMgr != nil {
@ -408,6 +433,15 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, defa
"name": ws.Name, "tier": tier,
})
// Seed initial memories from workspace config or defaults (issue #1050).
// Per-workspace initial_memories override defaults; if workspace has none,
// fall back to defaults.initial_memories.
wsMemories := ws.InitialMemories
if len(wsMemories) == 0 {
wsMemories = defaults.InitialMemories
}
seedInitialMemories(ctx, id, wsMemories, awarenessNS)
// Handle external workspaces
if ws.External {
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'online', url = $1 WHERE id = $2`, ws.URL, id); err != nil {

View File

@ -209,6 +209,10 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
log.Printf("Create: canvas layout insert failed for %s (workspace will appear at 0,0): %v", id, err)
}
// Seed initial memories from the create payload (issue #1050).
// Non-fatal: failures are logged but don't block workspace creation.
seedInitialMemories(ctx, id, payload.InitialMemories, awarenessNamespace)
// Broadcast provisioning event
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", id, map[string]interface{}{
"name": payload.Name,

View File

@ -187,6 +187,36 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
// which transitions status to 'online' and broadcasts WORKSPACE_ONLINE
}
// seedInitialMemories inserts a list of MemorySeed entries into agent_memories
// for the given workspace. Called during workspace creation and org import to
// pre-populate memories from config/template. Non-fatal: each insert is
// attempted independently and failures are logged. Issue #1050.
func seedInitialMemories(ctx context.Context, workspaceID string, memories []models.MemorySeed, awarenessNamespace string) {
if len(memories) == 0 {
return
}
for _, mem := range memories {
scope := strings.ToUpper(mem.Scope)
if scope == "" {
scope = "LOCAL"
}
if scope != "LOCAL" && scope != "TEAM" && scope != "GLOBAL" {
log.Printf("seedInitialMemories: skipping memory for %s — invalid scope %q", workspaceID, scope)
continue
}
if mem.Content == "" {
continue
}
if _, err := db.DB.ExecContext(ctx, `
INSERT INTO agent_memories (workspace_id, content, scope, namespace)
VALUES ($1, $2, $3, $4)
`, workspaceID, mem.Content, scope, awarenessNamespace); err != nil {
log.Printf("seedInitialMemories: failed to insert memory for %s (scope=%s): %v", workspaceID, scope, err)
}
}
log.Printf("seedInitialMemories: seeded %d memories for workspace %s", len(memories), workspaceID)
}
func workspaceAwarenessNamespace(workspaceID string) string {
return fmt.Sprintf("workspace:%s", workspaceID)
}

View File

@ -57,6 +57,14 @@ type UpdateCardPayload struct {
AgentCard json.RawMessage `json:"agent_card" binding:"required"`
}
// MemorySeed represents an initial memory to seed into a workspace at creation time.
// Used by both the POST /workspaces API and org template import to pre-populate
// agent memories from config (issue #1050).
type MemorySeed struct {
Content string `json:"content" yaml:"content"`
Scope string `json:"scope" yaml:"scope"` // LOCAL, TEAM, GLOBAL
}
type CreateWorkspacePayload struct {
Name string `json:"name" binding:"required"`
Role string `json:"role"`
@ -80,6 +88,10 @@ type CreateWorkspacePayload struct {
X float64 `json:"x"`
Y float64 `json:"y"`
} `json:"canvas"`
// InitialMemories is an optional list of memories to seed into the
// workspace immediately after creation. Each entry is inserted into
// agent_memories with the workspace's awareness namespace. Issue #1050.
InitialMemories []MemorySeed `json:"initial_memories"`
}
type CheckAccessPayload struct {