diff --git a/CLAUDE.md b/CLAUDE.md index 6c97006..c09d0ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,64 +282,86 @@ mission "expensive_research" { The scheduler lives in `scheduler/` but its lifecycle (creation, config updates, shutdown) is managed by `cmd/serve.go`, not wsbridge. The wsbridge client receives a `ConcurrencyTracker` interface for enforcing `max_parallel` on all mission starts. The cron library used is `robfig/cron/v3`. -### Folders +### Memory + Scratchpad -Folders are sandboxed filesystem locations that agents access via the `file_list`, -`file_read`, `file_create`, `file_delete`, `file_search`, and `file_grep` tools. -The `folder` parameter is required on every tool call — there is no implicit -default. +Two kinds of file storage: -Three kinds exist, with strict naming rules: +- **Memory** — persistent storage that survives across runs. Declared with + a top-level `memory "name" { description = "..." }` (shared, multiple) + or a mission-scoped `memory { description = "..." }` (private to the + mission, at most one). The two block forms take the same fields. Every + memory is writable. +- **Scratchpad** — ephemeral per-run working space. A mission opts in + with `scratchpad = true`; auto-deleted 7 days after the run starts. + Nothing to configure. -| Kind | HCL | Registered name | Scope | Persistence | -|------|-----|-----------------|-------|-------------| -| Shared | top-level `shared_folder "name" { ... }` | user-chosen name | referenced by any mission via `folders = [shared_folders.name]` | persists | -| Mission | `folder { ... }` inside a mission | literal `"mission"` | one per mission | persists across runs | -| Run | `run_folder { ... }` inside a mission | literal `"run"` | one per mission per run | ephemeral; path is `//` | +Agents access both kinds via the same `file_list`, `file_read`, `file_create`, +`file_delete`, `file_search`, `file_grep` tools. Each call takes a required +`slot` parameter naming which slot to operate in — there is no implicit +default. If a mission declares no `memory { ... }` and no `scratchpad = true`, +agents only see the shared memories listed in `memories = [...]`. -The names `"mission"` and `"run"` are reserved — `shared_folder` cannot use them -(enforced in `config/shared_folder.go`). +Squadron owns the on-disk paths; the HCL never accepts a `path` attribute: + +| Kind | HCL | `slot` agents pass | On-disk path | +|------|-----|---------------------|--------------| +| Shared memory | top-level `memory "name" { ... }` | the HCL label | `/memories/shared//` | +| Mission memory | `memory { ... }` inside a mission | literal `"memory"` | `/memories/mission//` | +| Mission scratchpad | `scratchpad = true` inside a mission | literal `"scratchpad"` | `/scratchpads///` | + +The slot names `"memory"` and `"scratchpad"` are reserved — a top-level +`memory "memory"` or `memory "scratchpad"` block is rejected. + +The old DSL surfaces — `shared_folder` blocks, `folder` / `run_folder` +blocks, the `folders = ...` attribute — are **not** accepted. The parser +surfaces a clear error pointing at the new syntax. Path attributes anywhere +on a memory block are rejected too. ```hcl -shared_folder "reference" { - path = "./data/reference" - editable = false # default read-only +memory "reference" { + description = "Shared reference materials" } mission "analyze" { - folders = [shared_folders.reference] + memories = [memories.reference] - folder { - path = "./analyses" + memory { description = "Cumulative reports — persists across runs" } - run_folder { - base = "./runs" # optional, default ".squadron/runs" - description = "Per-run scratch" - cleanup = 7 # optional, auto-delete after N days; defaults to 7, set 0 to keep forever - } + scratchpad = true } ``` **Implementation:** -- `config.SharedFolder`, `config.MissionFolder`, `config.MissionRunFolder` in - [config/shared_folder.go](config/shared_folder.go) and [config/mission.go](config/mission.go). -- Shared folders are parsed in Stage 1.5 (with `vars` context). The mission's - `folder` and `run_folder` blocks are parsed inside the mission block in Stage 5. -- [mission/folder_store.go](mission/folder_store.go) is the authoritative - runtime resolver. `buildFolderStore(mission, sharedFolders, missionID)` must be - called **after** the mission ID is assigned in `Runner.Run()`, because the run - folder path depends on it. There is no implicit default folder — every - tool call must name the folder explicitly. -- Run folders are materialized at `//` and a sidecar - `.squadron-run.json` records `created_at` + `cleanup_days`. -- `mission.SweepExpiredRunFolders(base)` walks a base directory and deletes any - subfolder whose sidecar says it is past its cleanup deadline. It runs - opportunistically at the start of every `Runner.Run()` (for that mission's - base) and on an hourly ticker in `cmd/engage.go` (across every mission in the - current config). +- `config.Memory` (top-level shared) and `config.MissionMemory` (mission's + persistent) both live in [config/memory.go](config/memory.go); both have + a required `Description` field. On a parsed mission they land as + `Memories []string`, `Memory *MissionMemory`, and `Scratchpad bool` + (`config.Mission` in [config/mission.go](config/mission.go)). +- Top-level `memory "name"` blocks are parsed in Stage 1.5 (with `vars` + context); each mission's `memory { ... }` block and `scratchpad = bool` + attribute are parsed inside the mission block in Stage 5. The + `memories.NAME` HCL namespace exposes the shared-memory labels to + mission attributes. +- [mission/memory_store.go](mission/memory_store.go) is the authoritative + runtime resolver. Paths are derived from `paths.SquadronHome()`: + `SharedMemoryPath(name)`, `MissionMemoryPath(missionName)`, and + `MissionScratchpadPath(missionName, missionInstanceID)`. + `buildMemoryStore(mission, memories, missionInstanceID)` must be called + **after** the mission instance ID is assigned in `Runner.Run()` because + the scratchpad path depends on it. `MemoryStore.ResolvePath` returns + just `(string, error)` — there is no read-only mode at this layer. +- Each scratchpad directory gets a sidecar `.squadron-run.json` recording + `created_at` + `cleanup_days` (always `config.ScratchpadCleanupDays = 7`) + so the sweep can find expired ones. +- `mission.SweepExpiredScratchpads()` walks + `/scratchpads/*/*`, deleting any per-run directory whose + sidecar is past its 7-day deadline. It runs opportunistically at the + start of every `Runner.Run()` (for missions with a scratchpad) and on + an hourly ticker in `cmd/engage.go`. No config lookup needed — the + filesystem layout is self-describing. ### Mission-Scoped Agents diff --git a/README.md b/README.md index 378cbe1..63a8b25 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Honest tradeoffs on each comparison page. - **[MCP host](https://docs.squadron.sh/config/mcp_host)** — run Squadron as an MCP server for Claude Desktop, Claude Code, Cursor, and other MCP clients. - **[Schedules & triggers](https://docs.squadron.sh/missions/schedules)** — cron-based or HTTP-triggered missions, with per-mission concurrency limits. - **[Budgets](https://docs.squadron.sh/missions/budgets)** — token + dollar caps per mission or per task. -- **[Folders](https://docs.squadron.sh/missions/folders)** — sandboxed filesystem locations agents can read/write, with shared, mission-scoped, and ephemeral run-scoped variants. +- **[Memory & Scratchpad](https://docs.squadron.sh/missions/folders)** — sandboxed filesystem locations agents can read/write. Persistent memory (shared with `memory "name"` or per-mission with `memory { }`) plus per-run `scratchpad = true` opt-in. Squadron owns the paths; scratchpads auto-clean after 7 days. - **[Gateways](https://docs.squadron.sh/config/gateways)** — managed subprocesses that bridge Squadron to Slack, Discord, Teams, or any custom system via the Gateway SDK. - **Encrypted vault** — secrets stored at rest with AES-256-GCM + Argon2id; passphrase in the OS keychain. - **Single binary, no runtime deps.** Docker images on every release. diff --git a/agent/agent.go b/agent/agent.go index 47b0d0f..cf1bcd4 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -77,8 +77,8 @@ type Options struct { SecretInfos []SecretInfo // SecretValues contains actual secret values for tool call injection SecretValues map[string]string - // FolderStore provides file folder access for the mission (optional) - FolderStore aitools.FolderStore + // MemoryStore provides file memory access for the mission (optional) + MemoryStore aitools.MemoryStore // OnCompaction is called when context compaction occurs (optional, mission context only) OnCompaction func(inputTokens int, tokenLimit int, messagesCompacted int, turnRetention int) // OnSessionTurn is called after each LLM turn with telemetry data (optional) @@ -178,14 +178,14 @@ func New(ctx context.Context, opts Options) (*Agent, error) { } } - // Add folder tools if FolderStore is available - if opts.FolderStore != nil { - tools["file_list"] = &aitools.FolderListTool{Store: opts.FolderStore} - tools["file_read"] = &aitools.FolderReadTool{Store: opts.FolderStore} - tools["file_create"] = &aitools.FolderCreateTool{Store: opts.FolderStore} - tools["file_delete"] = &aitools.FolderDeleteTool{Store: opts.FolderStore} - tools["file_search"] = &aitools.FolderSearchTool{Store: opts.FolderStore} - tools["file_grep"] = &aitools.FolderGrepTool{Store: opts.FolderStore} + // Add memory tools if MemoryStore is available + if opts.MemoryStore != nil { + tools["file_list"] = &aitools.MemoryListTool{Store: opts.MemoryStore} + tools["file_read"] = &aitools.MemoryReadTool{Store: opts.MemoryStore} + tools["file_create"] = &aitools.MemoryCreateTool{Store: opts.MemoryStore} + tools["file_delete"] = &aitools.MemoryDeleteTool{Store: opts.MemoryStore} + tools["file_search"] = &aitools.MemorySearchTool{Store: opts.MemoryStore} + tools["file_grep"] = &aitools.MemoryGrepTool{Store: opts.MemoryStore} } // Resolve skills and add load_skill tool @@ -243,10 +243,10 @@ func New(ctx context.Context, opts Options) (*Agent, error) { } } - // Add folder context if FolderStore is available - if opts.FolderStore != nil { - if folderPrompt := prompts.FormatFolderContext(opts.FolderStore); folderPrompt != "" { - systemPrompts = append(systemPrompts, folderPrompt) + // Add memory context if MemoryStore is available + if opts.MemoryStore != nil { + if memoryPrompt := prompts.FormatMemoryContext(opts.MemoryStore); memoryPrompt != "" { + systemPrompts = append(systemPrompts, memoryPrompt) } } diff --git a/agent/agent_manager.go b/agent/agent_manager.go index 42bdac4..aeb4a70 100644 --- a/agent/agent_manager.go +++ b/agent/agent_manager.go @@ -34,7 +34,7 @@ type AgentManager struct { cfg *config.Config secretInfos []SecretInfo secretValues map[string]string - folderStore aitools.FolderStore + memoryStore aitools.MemoryStore sessionLogger SessionLogger taskID string missionID string @@ -55,7 +55,7 @@ type AgentManagerConfig struct { Config *config.Config SecretInfos []SecretInfo SecretValues map[string]string - FolderStore aitools.FolderStore + MemoryStore aitools.MemoryStore SessionLogger SessionLogger TaskID string MissionID string @@ -83,7 +83,7 @@ func NewAgentManager(cfg AgentManagerConfig) *AgentManager { cfg: cfg.Config, secretInfos: cfg.SecretInfos, secretValues: cfg.SecretValues, - folderStore: cfg.FolderStore, + memoryStore: cfg.MemoryStore, sessionLogger: cfg.SessionLogger, taskID: cfg.TaskID, missionID: cfg.MissionID, @@ -278,7 +278,7 @@ func (m *AgentManager) createAgent(ctx context.Context, agentCfg *config.Agent) DatasetStore: datasetStore, SecretInfos: m.secretInfos, SecretValues: m.secretValues, - FolderStore: m.folderStore, + MemoryStore: m.memoryStore, OnCompaction: onCompaction, OnSessionTurn: onSessionTurn, PricingOverrides: m.pricingOverrides, diff --git a/agent/commander.go b/agent/commander.go index 4b00b37..b3f362c 100644 --- a/agent/commander.go +++ b/agent/commander.go @@ -75,8 +75,8 @@ type CommanderOptions struct { // SequentialDataset contains all items for sequential iteration processing // When set, the commander handles all items in a single session using dataset_next/submit_output tools SequentialDataset []cty.Value - // FolderStore provides file folder access for the mission (optional) - FolderStore aitools.FolderStore + // MemoryStore provides file memory access for the mission (optional) + MemoryStore aitools.MemoryStore // Compaction settings for the commander session (nil if disabled) Compaction *CompactionConfig // PruneOn triggers pruning when conversation reaches this many turns (0 = disabled) @@ -342,7 +342,7 @@ type Commander struct { agentMgr *AgentManager // Manages agent lifecycle (creation, session, resume) pricingOverrides map[string]*llm.ModelPricing subtasksSet bool // Whether set_subtasks has been called - folderStore aitools.FolderStore // Folder access for missions (nil if not configured) + memoryStore aitools.MemoryStore // Memory access for missions (nil if not configured) compaction *CompactionConfig // Compaction settings (nil if disabled) pruneOn int // Trigger pruning at this many turns (0 = disabled) pruneTo int // Prune down to this many turns @@ -482,17 +482,17 @@ func NewCommander(ctx context.Context, opts CommanderOptions) (*Commander, error sup.tools["result_keys"] = &aitools.ResultKeysTool{Store: resultStore} sup.tools["result_chunk"] = &aitools.ResultChunkTool{Store: resultStore} - // Add folder tools if FolderStore is available - if opts.FolderStore != nil { - sup.folderStore = opts.FolderStore - sup.tools["file_list"] = &aitools.FolderListTool{Store: opts.FolderStore} - sup.tools["file_read"] = &aitools.FolderReadTool{Store: opts.FolderStore} - sup.tools["file_create"] = &aitools.FolderCreateTool{Store: opts.FolderStore} - sup.tools["file_delete"] = &aitools.FolderDeleteTool{Store: opts.FolderStore} - sup.tools["file_search"] = &aitools.FolderSearchTool{Store: opts.FolderStore} - sup.tools["file_grep"] = &aitools.FolderGrepTool{Store: opts.FolderStore} - if folderPrompt := prompts.FormatFolderContext(opts.FolderStore); folderPrompt != "" { - session.AddSystemPrompt(folderPrompt) + // Add memory tools if MemoryStore is available + if opts.MemoryStore != nil { + sup.memoryStore = opts.MemoryStore + sup.tools["file_list"] = &aitools.MemoryListTool{Store: opts.MemoryStore} + sup.tools["file_read"] = &aitools.MemoryReadTool{Store: opts.MemoryStore} + sup.tools["file_create"] = &aitools.MemoryCreateTool{Store: opts.MemoryStore} + sup.tools["file_delete"] = &aitools.MemoryDeleteTool{Store: opts.MemoryStore} + sup.tools["file_search"] = &aitools.MemorySearchTool{Store: opts.MemoryStore} + sup.tools["file_grep"] = &aitools.MemoryGrepTool{Store: opts.MemoryStore} + if memoryPrompt := prompts.FormatMemoryContext(opts.MemoryStore); memoryPrompt != "" { + session.AddSystemPrompt(memoryPrompt) } } @@ -725,7 +725,7 @@ func (s *Commander) SetToolCallbacks(callbacks *CommanderToolCallbacks, depSumma Config: s.cfg, SecretInfos: s.secretInfos, SecretValues: s.secretValues, - FolderStore: s.folderStore, + MemoryStore: s.memoryStore, SessionLogger: s.sessionLogger, TaskID: s.callbacksTaskID, MissionID: s.callbacksMissionID, diff --git a/agent/internal/prompts/prompts.go b/agent/internal/prompts/prompts.go index eb732f5..d24a2cd 100644 --- a/agent/internal/prompts/prompts.go +++ b/agent/internal/prompts/prompts.go @@ -231,38 +231,34 @@ func formatAgents(agents []AgentInfo) string { return sb.String() } -// FormatFolderContext builds a system prompt section describing available folders. -func FormatFolderContext(store aitools.FolderStore) string { +// FormatMemoryContext builds a system prompt section describing available memory slots. +func FormatMemoryContext(store aitools.MemoryStore) string { if store == nil { return "" } - infos := store.FolderInfos() + infos := store.MemoryInfos() if len(infos) == 0 { return "" } var sb strings.Builder - sb.WriteString("## Available Folders\n\n") - sb.WriteString("You have access to file folders via the file_list, file_read, file_create, file_delete, file_search, and file_grep tools.\n") - sb.WriteString("The `folder` parameter is required on every call — pick one of the names below.\n\n") + sb.WriteString("## Available Slots\n\n") + sb.WriteString("You have access to file storage slots via the file_list, file_read, file_create, file_delete, file_search, and file_grep tools.\n") + sb.WriteString("The `slot` parameter is required on every call — pick one of the slot names below.\n\n") for _, info := range infos { - access := "read-only" - if info.Writable { - access = "read/write" - } label := "" switch info.Name { - case aitools.MissionFolderName: - label = " (persistent mission folder — survives across runs)" - case aitools.RunFolderName: - label = " (per-run folder — fresh directory for this mission run)" + case aitools.MemorySlotName: + label = " (persistent mission memory — survives across runs)" + case aitools.ScratchpadSlotName: + label = " (ephemeral per-run scratchpad — fresh for this mission run)" } desc := "" if info.Description != "" { desc = " — " + info.Description } - sb.WriteString(fmt.Sprintf("- **%s**%s%s (%s)\n", info.Name, label, desc, access)) + sb.WriteString(fmt.Sprintf("- **%s**%s%s\n", info.Name, label, desc)) } return sb.String() diff --git a/aitools/folder_tools.go b/aitools/memory_tools.go similarity index 70% rename from aitools/folder_tools.go rename to aitools/memory_tools.go index 3b1c7a6..5aae5cf 100644 --- a/aitools/folder_tools.go +++ b/aitools/memory_tools.go @@ -1,8 +1,8 @@ package aitools import ( - "context" "bufio" + "context" "encoding/json" "fmt" "io" @@ -12,31 +12,32 @@ import ( "strings" ) -// Reserved folder names for mission-scoped folders. Callers addressing the -// persistent mission folder and the per-run folder must use these names. +// Reserved slot names for the mission-scoped storage slots. Agents address +// them via the `slot` parameter on the file tools (alongside any shared +// memory names declared at the top level). const ( - MissionFolderName = "mission" - RunFolderName = "run" + MemorySlotName = "memory" + ScratchpadSlotName = "scratchpad" ) -// FolderStore provides folder access for missions. -// Implementations resolve folder names to paths and enforce security. -type FolderStore interface { - // ResolvePath resolves a folder name + relative path to an absolute path. - // Returns: absolute path, writable flag, error. - ResolvePath(folderName string, relPath string) (string, bool, error) - // FolderInfos returns info about all available folders. - FolderInfos() []FolderInfo +// MemoryStore provides slot access for missions. Implementations resolve a +// slot name (the mission's "memory" / "scratchpad", or a shared memory's +// label) to an absolute path. Every slot is read+write — there is no +// read-only mode at this layer. +type MemoryStore interface { + // ResolvePath resolves a slot name + relative path to an absolute path. + ResolvePath(slotName string, relPath string) (string, error) + // MemoryInfos returns info about all available slots. + MemoryInfos() []MemoryInfo } -// FolderInfo describes an available folder. -type FolderInfo struct { +// MemoryInfo describes an available slot. +type MemoryInfo struct { Name string Description string - Writable bool } -// validateRelPath ensures a path is relative and doesn't escape the folder root. +// validateRelPath ensures a path is relative and doesn't escape the slot root. func validateRelPath(relPath string) error { if relPath == "" { return fmt.Errorf("path is required") @@ -44,44 +45,49 @@ func validateRelPath(relPath string) error { cleaned := filepath.Clean(relPath) if filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "..") || strings.Contains(cleaned, string(filepath.Separator)+"..") { - return fmt.Errorf("invalid path: must be relative and within folder") + return fmt.Errorf("invalid path: must be relative and within the slot root") } return nil } -// resolveFolderPath is a helper that resolves the folder and validates the path. -func resolveFolderPath(store FolderStore, folderName, relPath string) (string, bool, error) { +// resolveSlotPath is a helper that resolves the slot and validates the +// relative path. +func resolveSlotPath(store MemoryStore, name, relPath string) (string, error) { if err := validateRelPath(relPath); err != nil { - return "", false, err + return "", err } - return store.ResolvePath(folderName, relPath) + return store.ResolvePath(name, relPath) } +// slotParamDescription is reused across every file tool's `slot` parameter +// so the agent sees a consistent description. +const slotParamDescription = "Slot to operate in. Use \"memory\" for the mission's persistent memory, \"scratchpad\" for its ephemeral per-run scratchpad, or a shared memory name." + // ============================================================================= -// folder_list — List files and directories +// file_list — List files and directories // ============================================================================= -type FolderListTool struct { - Store FolderStore +type MemoryListTool struct { + Store MemoryStore } -func (t *FolderListTool) ToolName() string { return "file_list" } +func (t *MemoryListTool) ToolName() string { return "file_list" } -func (t *FolderListTool) ToolDescription() string { - return "List files and directories in a folder. Returns names, types (file/dir), and sizes. Results are paginated (default 100 per page). Use 'offset' to get subsequent pages." +func (t *MemoryListTool) ToolDescription() string { + return "List files and directories in a slot. Returns names, types (file/dir), and sizes. Results are paginated (default 100 per page). Use 'offset' to get subsequent pages." } -func (t *FolderListTool) ToolPayloadSchema() Schema { +func (t *MemoryListTool) ToolPayloadSchema() Schema { return Schema{ Type: TypeObject, Properties: PropertyMap{ - "folder": { + "slot": { Type: TypeString, - Description: "Folder name. Use \"mission\" for the persistent mission folder, \"run\" for the per-run ephemeral folder, or a shared folder name.", + Description: slotParamDescription, }, "path": { Type: TypeString, - Description: "Relative subdirectory path within the folder. Omit to list the root.", + Description: "Relative subdirectory path within the slot. Omit to list the root.", }, "recursive": { Type: TypeBoolean, @@ -96,12 +102,12 @@ func (t *FolderListTool) ToolPayloadSchema() Schema { Description: "Number of entries to skip (for pagination). Default 0.", }, }, - Required: []string{"folder"}, + Required: []string{"slot"}, } } -type folderListParams struct { - Folder string `json:"folder"` +type memoryListParams struct { + Slot string `json:"slot"` Path string `json:"path"` Recursive bool `json:"recursive"` Limit int `json:"limit"` @@ -110,8 +116,8 @@ type folderListParams struct { const defaultListLimit = 100 -func (t *FolderListTool) Call(ctx context.Context, params string) string { - var p folderListParams +func (t *MemoryListTool) Call(ctx context.Context, params string) string { + var p memoryListParams if err := json.Unmarshal([]byte(params), &p); err != nil { return "Error: invalid parameters - " + err.Error() } @@ -124,9 +130,9 @@ func (t *FolderListTool) Call(ctx context.Context, params string) string { var absPath string var err error if p.Path == "" { - absPath, _, err = t.Store.ResolvePath(p.Folder, ".") + absPath, err = t.Store.ResolvePath(p.Slot, ".") } else { - absPath, _, err = resolveFolderPath(t.Store, p.Folder, p.Path) + absPath, err = resolveSlotPath(t.Store, p.Slot, p.Path) } if err != nil { return "Error: " + err.Error() @@ -248,30 +254,30 @@ func formatSize(bytes int64) string { } // ============================================================================= -// folder_read — Read file content +// file_read — Read file content // ============================================================================= -type FolderReadTool struct { - Store FolderStore +type MemoryReadTool struct { + Store MemoryStore } -func (t *FolderReadTool) ToolName() string { return "file_read" } +func (t *MemoryReadTool) ToolName() string { return "file_read" } -func (t *FolderReadTool) ToolDescription() string { - return "Read the contents of a file in a folder. Optionally limit to the first N lines or N bytes." +func (t *MemoryReadTool) ToolDescription() string { + return "Read the contents of a file in a slot. Optionally limit to the first N lines or N bytes." } -func (t *FolderReadTool) ToolPayloadSchema() Schema { +func (t *MemoryReadTool) ToolPayloadSchema() Schema { return Schema{ Type: TypeObject, Properties: PropertyMap{ - "folder": { + "slot": { Type: TypeString, - Description: "Folder name. Use \"mission\" for the persistent mission folder, \"run\" for the per-run ephemeral folder, or a shared folder name.", + Description: slotParamDescription, }, "path": { Type: TypeString, - Description: "Relative file path within the folder.", + Description: "Relative file path within the slot.", }, "max_lines": { Type: TypeInteger, @@ -282,12 +288,12 @@ func (t *FolderReadTool) ToolPayloadSchema() Schema { Description: "Return only the first N bytes. 0 or omit for no limit.", }, }, - Required: []string{"folder", "path"}, + Required: []string{"slot", "path"}, } } -type folderReadParams struct { - Folder string `json:"folder"` +type memoryReadParams struct { + Slot string `json:"slot"` Path string `json:"path"` MaxLines int `json:"max_lines"` MaxBytes int `json:"max_bytes"` @@ -295,8 +301,8 @@ type folderReadParams struct { const maxReadSize = 10 * 1024 * 1024 // 10MB -func (t *FolderReadTool) Call(ctx context.Context, params string) string { - var p folderReadParams +func (t *MemoryReadTool) Call(ctx context.Context, params string) string { + var p memoryReadParams if err := json.Unmarshal([]byte(params), &p); err != nil { return "Error: invalid parameters - " + err.Error() } @@ -305,7 +311,7 @@ func (t *FolderReadTool) Call(ctx context.Context, params string) string { return "Error: path is required" } - absPath, _, err := resolveFolderPath(t.Store, p.Folder, p.Path) + absPath, err := resolveSlotPath(t.Store, p.Slot, p.Path) if err != nil { return "Error: " + err.Error() } @@ -356,30 +362,30 @@ func (t *FolderReadTool) Call(ctx context.Context, params string) string { } // ============================================================================= -// folder_create — Create or write to a file +// file_create — Create or write to a file // ============================================================================= -type FolderCreateTool struct { - Store FolderStore +type MemoryCreateTool struct { + Store MemoryStore } -func (t *FolderCreateTool) ToolName() string { return "file_create" } +func (t *MemoryCreateTool) ToolName() string { return "file_create" } -func (t *FolderCreateTool) ToolDescription() string { - return "Create or write to a file in a folder. By default, creates a new file (fails if it already exists). Use 'overwrite' to replace an existing file, or 'append' to add to an existing file." +func (t *MemoryCreateTool) ToolDescription() string { + return "Create or write to a file in a slot. By default, creates a new file (fails if it already exists). Use 'overwrite' to replace an existing file, or 'append' to add to an existing file." } -func (t *FolderCreateTool) ToolPayloadSchema() Schema { +func (t *MemoryCreateTool) ToolPayloadSchema() Schema { return Schema{ Type: TypeObject, Properties: PropertyMap{ - "folder": { + "slot": { Type: TypeString, - Description: "Folder name. Use \"mission\" for the persistent mission folder, \"run\" for the per-run ephemeral folder, or a shared folder name.", + Description: slotParamDescription, }, "path": { Type: TypeString, - Description: "Relative file path within the folder.", + Description: "Relative file path within the slot.", }, "content": { Type: TypeString, @@ -394,20 +400,20 @@ func (t *FolderCreateTool) ToolPayloadSchema() Schema { Description: "If true, overwrite the file if it already exists. Ignored when 'append' is true.", }, }, - Required: []string{"folder", "path", "content"}, + Required: []string{"slot", "path", "content"}, } } -type folderCreateParams struct { - Folder string `json:"folder"` +type memoryCreateParams struct { + Slot string `json:"slot"` Path string `json:"path"` Content string `json:"content"` Append bool `json:"append"` Overwrite bool `json:"overwrite"` } -func (t *FolderCreateTool) Call(ctx context.Context, params string) string { - var p folderCreateParams +func (t *MemoryCreateTool) Call(ctx context.Context, params string) string { + var p memoryCreateParams if err := json.Unmarshal([]byte(params), &p); err != nil { return "Error: invalid parameters - " + err.Error() } @@ -416,15 +422,11 @@ func (t *FolderCreateTool) Call(ctx context.Context, params string) string { return "Error: path is required" } - absPath, writable, err := resolveFolderPath(t.Store, p.Folder, p.Path) + absPath, err := resolveSlotPath(t.Store, p.Slot, p.Path) if err != nil { return "Error: " + err.Error() } - if !writable { - return "Error: folder is read-only" - } - if p.Append { // Append mode: file must exist f, err := os.OpenFile(absPath, os.O_APPEND|os.O_WRONLY, 0644) @@ -459,43 +461,43 @@ func (t *FolderCreateTool) Call(ctx context.Context, params string) string { } // ============================================================================= -// folder_delete — Delete a file +// file_delete — Delete a file // ============================================================================= -type FolderDeleteTool struct { - Store FolderStore +type MemoryDeleteTool struct { + Store MemoryStore } -func (t *FolderDeleteTool) ToolName() string { return "file_delete" } +func (t *MemoryDeleteTool) ToolName() string { return "file_delete" } -func (t *FolderDeleteTool) ToolDescription() string { - return "Delete a file in a folder. Only files can be deleted, not directories." +func (t *MemoryDeleteTool) ToolDescription() string { + return "Delete a file in a slot. Only files can be deleted, not directories." } -func (t *FolderDeleteTool) ToolPayloadSchema() Schema { +func (t *MemoryDeleteTool) ToolPayloadSchema() Schema { return Schema{ Type: TypeObject, Properties: PropertyMap{ - "folder": { + "slot": { Type: TypeString, - Description: "Folder name. Use \"mission\" for the persistent mission folder, \"run\" for the per-run ephemeral folder, or a shared folder name.", + Description: slotParamDescription, }, "path": { Type: TypeString, - Description: "Relative file path within the folder.", + Description: "Relative file path within the slot.", }, }, - Required: []string{"folder", "path"}, + Required: []string{"slot", "path"}, } } -type folderDeleteParams struct { - Folder string `json:"folder"` +type memoryDeleteParams struct { + Slot string `json:"slot"` Path string `json:"path"` } -func (t *FolderDeleteTool) Call(ctx context.Context, params string) string { - var p folderDeleteParams +func (t *MemoryDeleteTool) Call(ctx context.Context, params string) string { + var p memoryDeleteParams if err := json.Unmarshal([]byte(params), &p); err != nil { return "Error: invalid parameters - " + err.Error() } @@ -504,15 +506,11 @@ func (t *FolderDeleteTool) Call(ctx context.Context, params string) string { return "Error: path is required" } - absPath, writable, err := resolveFolderPath(t.Store, p.Folder, p.Path) + absPath, err := resolveSlotPath(t.Store, p.Slot, p.Path) if err != nil { return "Error: " + err.Error() } - if !writable { - return "Error: folder is read-only" - } - info, err := os.Stat(absPath) if err != nil { return "Error: " + err.Error() @@ -532,27 +530,27 @@ func (t *FolderDeleteTool) Call(ctx context.Context, params string) string { // file_search — Search for files by name pattern // ============================================================================= -type FolderSearchTool struct { - Store FolderStore +type MemorySearchTool struct { + Store MemoryStore } -func (t *FolderSearchTool) ToolName() string { return "file_search" } +func (t *MemorySearchTool) ToolName() string { return "file_search" } -func (t *FolderSearchTool) ToolDescription() string { - return "Search for files by name using a regex pattern. Returns matching file paths with sizes. Searches recursively by default. Results are paginated (default 50)." +func (t *MemorySearchTool) ToolDescription() string { + return "Search for files by name within a slot using a regex pattern. Returns matching file paths with sizes. Searches recursively by default. Results are paginated (default 50)." } -func (t *FolderSearchTool) ToolPayloadSchema() Schema { +func (t *MemorySearchTool) ToolPayloadSchema() Schema { return Schema{ Type: TypeObject, Properties: PropertyMap{ - "folder": { + "slot": { Type: TypeString, - Description: "Folder name. Use \"mission\" for the persistent mission folder, \"run\" for the per-run ephemeral folder, or a shared folder name.", + Description: slotParamDescription, }, "path": { Type: TypeString, - Description: "Relative path to search within. Omit to search the folder root.", + Description: "Relative path to search within. Omit to search the slot root.", }, "pattern": { Type: TypeString, @@ -567,12 +565,12 @@ func (t *FolderSearchTool) ToolPayloadSchema() Schema { Description: "Number of results to skip (for pagination). Default 0.", }, }, - Required: []string{"folder", "pattern"}, + Required: []string{"slot", "pattern"}, } } -type folderSearchParams struct { - Folder string `json:"folder"` +type memorySearchParams struct { + Slot string `json:"slot"` Path string `json:"path"` Pattern string `json:"pattern"` Limit int `json:"limit"` @@ -581,8 +579,8 @@ type folderSearchParams struct { const defaultSearchLimit = 50 -func (t *FolderSearchTool) Call(ctx context.Context, params string) string { - var p folderSearchParams +func (t *MemorySearchTool) Call(ctx context.Context, params string) string { + var p memorySearchParams if err := json.Unmarshal([]byte(params), &p); err != nil { return "Error: invalid parameters - " + err.Error() } @@ -603,9 +601,9 @@ func (t *FolderSearchTool) Call(ctx context.Context, params string) string { // Resolve search root var absPath string if p.Path == "" { - absPath, _, err = t.Store.ResolvePath(p.Folder, ".") + absPath, err = t.Store.ResolvePath(p.Slot, ".") } else { - absPath, _, err = resolveFolderPath(t.Store, p.Folder, p.Path) + absPath, err = resolveSlotPath(t.Store, p.Slot, p.Path) } if err != nil { return "Error: " + err.Error() @@ -681,27 +679,27 @@ func (t *FolderSearchTool) Call(ctx context.Context, params string) string { // file_grep — Search file contents with regex // ============================================================================= -type FolderGrepTool struct { - Store FolderStore +type MemoryGrepTool struct { + Store MemoryStore } -func (t *FolderGrepTool) ToolName() string { return "file_grep" } +func (t *MemoryGrepTool) ToolName() string { return "file_grep" } -func (t *FolderGrepTool) ToolDescription() string { - return "Search file contents using a regex pattern. Returns matching lines with file paths and line numbers. Results are paginated (default 50 matches)." +func (t *MemoryGrepTool) ToolDescription() string { + return "Search file contents within a slot using a regex pattern. Returns matching lines with file paths and line numbers. Results are paginated (default 50 matches)." } -func (t *FolderGrepTool) ToolPayloadSchema() Schema { +func (t *MemoryGrepTool) ToolPayloadSchema() Schema { return Schema{ Type: TypeObject, Properties: PropertyMap{ - "folder": { + "slot": { Type: TypeString, - Description: "Folder name. Use \"mission\" for the persistent mission folder, \"run\" for the per-run ephemeral folder, or a shared folder name.", + Description: slotParamDescription, }, "path": { Type: TypeString, - Description: "Relative path to search within. Omit to search the folder root.", + Description: "Relative path to search within. Omit to search the slot root.", }, "pattern": { Type: TypeString, @@ -720,12 +718,12 @@ func (t *FolderGrepTool) ToolPayloadSchema() Schema { Description: "Number of matches to skip (for pagination). Default 0.", }, }, - Required: []string{"folder", "pattern"}, + Required: []string{"slot", "pattern"}, } } -type folderGrepParams struct { - Folder string `json:"folder"` +type memoryGrepParams struct { + Slot string `json:"slot"` Path string `json:"path"` Pattern string `json:"pattern"` Recursive bool `json:"recursive"` @@ -735,8 +733,8 @@ type folderGrepParams struct { const defaultGrepLimit = 50 -func (t *FolderGrepTool) Call(ctx context.Context, params string) string { - var p folderGrepParams +func (t *MemoryGrepTool) Call(ctx context.Context, params string) string { + var p memoryGrepParams if err := json.Unmarshal([]byte(params), &p); err != nil { return "Error: invalid parameters - " + err.Error() } @@ -757,9 +755,9 @@ func (t *FolderGrepTool) Call(ctx context.Context, params string) string { // Resolve search root var absPath string if p.Path == "" { - absPath, _, err = t.Store.ResolvePath(p.Folder, ".") + absPath, err = t.Store.ResolvePath(p.Slot, ".") } else { - absPath, _, err = resolveFolderPath(t.Store, p.Folder, p.Path) + absPath, err = resolveSlotPath(t.Store, p.Slot, p.Path) } if err != nil { return "Error: " + err.Error() diff --git a/cmd/engage.go b/cmd/engage.go index 108be90..ba86e34 100644 --- a/cmd/engage.go +++ b/cmd/engage.go @@ -392,9 +392,9 @@ func runEngage(cmd *cobra.Command, args []string) { client.Close() }() - // Periodic sweep of expired per-run mission folders. Runs hourly, reads - // the live config so new run_folder bases show up after a reload. - go runFolderCleanupLoop(shutdown, client.GetConfig) + // Periodic sweep of expired per-run ephemeral memory directories. + // Runs hourly; walks the filesystem so the live config isn't needed. + go runScratchpadCleanupLoop(shutdown) // Even without valid config we still try to connect — the command center // can show vars and config files so the user can fix things from the UI. @@ -901,33 +901,14 @@ func openBrowser(url string) { browser.Open(url) } -// runFolderCleanupLoop periodically sweeps expired per-run mission folders -// for every mission in the current config that declares a run_folder with -// cleanup > 0. It runs once immediately, then hourly, and exits when -// shutdown is closed. -func runFolderCleanupLoop(shutdown <-chan struct{}, getCfg func() *config.Config) { +// runScratchpadCleanupLoop periodically sweeps expired per-run scratchpad +// directories. The sweep walks the entire scratchpads tree, so it doesn't +// need to know which missions are configured. It runs once immediately, +// then hourly, and exits when shutdown is closed. +func runScratchpadCleanupLoop(shutdown <-chan struct{}) { sweep := func() { - cfg := getCfg() - if cfg == nil { - return - } - seen := make(map[string]bool) - for i := range cfg.Missions { - rf := cfg.Missions[i].RunFolder - if rf == nil { - continue - } - if rf.Cleanup != nil && *rf.Cleanup == 0 { - continue // user explicitly opted out of cleanup - } - base := mission.ResolvedRunFolderBase(rf) - if seen[base] { - continue - } - seen[base] = true - if _, err := mission.SweepExpiredRunFolders(base); err != nil { - log.Printf("run folder cleanup: sweep %q: %v", base, err) - } + if _, err := mission.SweepExpiredScratchpads(); err != nil { + log.Printf("scratchpad cleanup: %v", err) } } diff --git a/config/config.go b/config/config.go index 63b1e44..847bd9c 100644 --- a/config/config.go +++ b/config/config.go @@ -47,8 +47,8 @@ type Config struct { // renamed to `mcp_host { ... }`). nil when the block is absent. MCPHost *MCPHostConfig `hcl:"-"` - // File browser configurations (optional) - SharedFolders []SharedFolder `hcl:"-"` + // Top-level shared memory blocks (memory "name" { ... }). + Memories []Memory `hcl:"-"` // LoadedPlugins holds the loaded plugin clients, keyed by plugin name LoadedPlugins map[string]*plugin.PluginClient `hcl:"-"` @@ -437,9 +437,23 @@ func (c *Config) Validate() error { } } - for _, fb := range c.SharedFolders { - if err := fb.Validate(); err != nil { - return fmt.Errorf("shared_folder '%s': %w", fb.Name, err) + for _, m := range c.Memories { + if err := m.Validate(); err != nil { + return fmt.Errorf("memory '%s': %w", m.Name, err) + } + } + + // A shared-memory label sharing a mission name silently masks that + // mission's persistent memory in the file-browser UI (resolveMemoryPath + // matches shared first by string-equal name). Reject the collision at + // load so users can't tie themselves in that knot. + missionNamesForMemoryCheck := make(map[string]bool, len(c.Missions)) + for _, mn := range c.Missions { + missionNamesForMemoryCheck[mn.Name] = true + } + for _, m := range c.Memories { + if missionNamesForMemoryCheck[m.Name] { + return fmt.Errorf("memory '%s': name conflicts with mission '%s' — both are exposed under the same name in the file browser", m.Name, m.Name) } } @@ -615,7 +629,7 @@ func (c *Config) Validate() error { // Validate missions for i := range c.Missions { - if err := c.Missions[i].Validate(c.Models, c.Agents, c.SharedFolders, allMissionNames); err != nil { + if err := c.Missions[i].Validate(c.Models, c.Agents, c.Memories, allMissionNames); err != nil { return fmt.Errorf("mission '%s': %w", c.Missions[i].Name, err) } } @@ -684,7 +698,7 @@ type parsedBlocks struct { Missions []*hcl.Block Storage []*hcl.Block CommandCenter []*hcl.Block - SharedFolders []*hcl.Block + Memories []*hcl.Block MCPHost []*hcl.Block Skills []*hcl.Block Gateways []*hcl.Block @@ -720,6 +734,9 @@ func loadFromFiles(files []string) (*Config, error) { {Type: "mission", LabelNames: []string{"name"}}, {Type: "storage"}, {Type: "command_center"}, + {Type: "memory", LabelNames: []string{"name"}}, + // Detected for a nicer error only — the parse-pass below + // rejects it with a pointer to the new `memory` block. {Type: "shared_folder", LabelNames: []string{"name"}}, {Type: "mcp_host"}, {Type: "mcp", LabelNames: []string{"name"}}, @@ -752,8 +769,12 @@ func loadFromFiles(files []string) (*Config, error) { pb.Storage = append(pb.Storage, block) case "command_center": pb.CommandCenter = append(pb.CommandCenter, block) + case "memory": + pb.Memories = append(pb.Memories, block) case "shared_folder": - pb.SharedFolders = append(pb.SharedFolders, block) + // Collected only so we can produce a clear error in the + // parse pass below. + pb.Memories = append(pb.Memories, block) case "mcp_host": pb.MCPHost = append(pb.MCPHost, block) case "mcp": @@ -949,17 +970,22 @@ func loadFromFiles(files []string) (*Config, error) { } } - // Parse shared_folder blocks (optional, with vars context) - var allSharedFolders []SharedFolder + // Parse top-level `memory "name" { ... }` blocks (with vars context). + // `shared_folder` is no longer supported — if a user writes one we + // surface an explicit error pointing at the new syntax. + var allMemories []Memory for _, pb := range allParsedBlocks { - for _, block := range pb.SharedFolders { - var fb SharedFolder - fb.Name = block.Labels[0] - diags := gohcl.DecodeBody(block.Body, varsCtx, &fb) + for _, block := range pb.Memories { + if block.Type != "memory" { + return nil, fmt.Errorf("%s %q at %s: this block type is no longer supported — use `memory %q { ... }` instead (path is now derived automatically; remove the `path` attribute)", block.Type, block.Labels[0], block.DefRange, block.Labels[0]) + } + var m Memory + m.Name = block.Labels[0] + diags := gohcl.DecodeBody(block.Body, varsCtx, &m) if diags.HasErrors() { - return nil, fmt.Errorf("shared_folder '%s': %w", fb.Name, diags) + return nil, fmt.Errorf("memory '%s': %w", m.Name, diags) } - allSharedFolders = append(allSharedFolders, fb) + allMemories = append(allMemories, m) } } @@ -1144,16 +1170,23 @@ func loadFromFiles(files []string) (*Config, error) { // Build agents context (add to full context) agentsCtx := buildAgentsContext(skillsCtx, allAgents) - // Add shared_folders namespace for mission references - if len(allSharedFolders) > 0 { - folderMap := make(map[string]cty.Value) - for _, f := range allSharedFolders { - folderMap[f.Name] = cty.StringVal(f.Name) - } - agentsCtx.Variables["shared_folders"] = cty.ObjectVal(folderMap) + // Add `memories` namespace for mission references: `memories.NAME` resolves + // to the memory's name as a string. Register even when empty so that a + // reference to an unknown shared memory produces "object has no attribute + // NAME" (pointing at the bad reference) rather than the generic HCL + // "unknown variable memories" (which is mystifying when the user simply + // forgot to declare the shared memory). + memMap := make(map[string]cty.Value, len(allMemories)) + for _, m := range allMemories { + memMap[m.Name] = cty.StringVal(m.Name) + } + if len(memMap) > 0 { + agentsCtx.Variables["memories"] = cty.ObjectVal(memMap) + } else { + agentsCtx.Variables["memories"] = cty.EmptyObjectVal } - // Stage 5: Load missions (with vars + models + tools + agents + shared_folders context) + // Stage 5: Load missions (with vars + models + tools + agents + memories context) // First pass: collect all mission names so router targets can reference missions.* missionNames := make(map[string]cty.Value) for _, pb := range allParsedBlocks { @@ -1203,7 +1236,7 @@ func loadFromFiles(files []string) (*Config, error) { Storage: &storageConfig, CommandCenter: commandCenterConfig, MCPHost: mcpHostConfig, - SharedFolders: allSharedFolders, + Memories: allMemories, LoadedPlugins: loadedPlugins, LoadedMCPClients: loadedMCPClients, LoadedMCPErrors: loadedMCPErrors, @@ -1771,9 +1804,12 @@ func parseMissionBlock(block *hcl.Block, ctx *hcl.EvalContext) (*Mission, error) Attributes: []hcl.AttributeSchema{ {Name: "agents", Required: true}, {Name: "directive"}, - {Name: "folders"}, + {Name: "memories"}, // shared memory references: memories = [memories.foo] + {Name: "scratchpad"}, // bool: opt the mission into a per-run scratchpad slot {Name: "max_parallel"}, {Name: "inputs"}, // shorthand: inputs = { field = string("desc", { default = "val" }) } + // Detected so we can produce a nicer error than "unsupported argument". + {Name: "folders"}, }, Blocks: []hcl.BlockHeaderSchema{ {Type: "commander"}, @@ -1782,11 +1818,13 @@ func parseMissionBlock(block *hcl.Block, ctx *hcl.EvalContext) (*Mission, error) {Type: "input", LabelNames: []string{"name"}}, // verbose input blocks still supported {Type: "dataset", LabelNames: []string{"name"}}, {Type: "secret", LabelNames: []string{"name"}}, - {Type: "folder"}, - {Type: "run_folder"}, + {Type: "memory"}, // mission-scoped persistent memory (slot "memory") {Type: "schedule"}, {Type: "trigger"}, {Type: "budget"}, + // Detected so we can produce a nicer error than the parser's default. + {Type: "folder"}, + {Type: "run_folder"}, }, }) if diags.HasErrors() { @@ -1934,55 +1972,61 @@ func parseMissionBlock(block *hcl.Block, ctx *hcl.EvalContext) (*Mission, error) directive = val.AsString() } - // Parse optional folders attribute (list of shared folder names) - var missionFolders []string - if foldersAttr, ok := missionContent.Attributes["folders"]; ok { - foldersVal, diags := foldersAttr.Expr.Value(ctx) + // Reject the old `folders = ...` attribute with a clear pointer at the new + // syntax — we deliberately broke compatibility here. + if _, ok := missionContent.Attributes["folders"]; ok { + return nil, fmt.Errorf("mission '%s': the `folders` attribute is no longer supported — use `memories = [memories.NAME, ...]` instead", missionName) + } + + // Parse optional memories attribute (list of shared memory names). + var missionMemories []string + if attr, ok := missionContent.Attributes["memories"]; ok { + v, diags := attr.Expr.Value(ctx) if diags.HasErrors() { - return nil, fmt.Errorf("mission '%s' folders: %w", missionName, diags) + return nil, fmt.Errorf("mission '%s' memories: %w", missionName, diags) } - for it := foldersVal.ElementIterator(); it.Next(); { - _, v := it.Element() - missionFolders = append(missionFolders, v.AsString()) + for it := v.ElementIterator(); it.Next(); { + _, e := it.Element() + missionMemories = append(missionMemories, e.AsString()) } } - // Parse optional folder block (dedicated mission folder, reserved name "mission") - var missionFolder *MissionFolder - for _, folderBlock := range missionContent.Blocks { - if folderBlock.Type != "folder" { - continue - } - if missionFolder != nil { - return nil, fmt.Errorf("mission '%s': only one folder block allowed", missionName) - } - var mf MissionFolder - diags := gohcl.DecodeBody(folderBlock.Body, ctx, &mf) - if diags.HasErrors() { - return nil, fmt.Errorf("mission '%s' folder: %w", missionName, diags) + // Reject the old `folder { ... }` / `run_folder { ... }` blocks with a + // clear pointer at the new syntax. + for _, b := range missionContent.Blocks { + switch b.Type { + case "folder": + return nil, fmt.Errorf("mission '%s': the `folder { ... }` block is no longer supported — use `memory { description = \"...\" }` instead (path is now derived automatically)", missionName) + case "run_folder": + return nil, fmt.Errorf("mission '%s': the `run_folder { ... }` block is no longer supported — use `scratchpad = true` on the mission instead (auto-cleaned after 7 days)", missionName) } - missionFolder = &mf } - // Parse optional run_folder block (per-run ephemeral folder, reserved name "run") - var missionRunFolder *MissionRunFolder - for _, rfBlock := range missionContent.Blocks { - if rfBlock.Type != "run_folder" { + // Parse the optional `memory { ... }` block (persistent, one per mission). + var missionMemory *MissionMemory + for _, mb := range missionContent.Blocks { + if mb.Type != "memory" { continue } - if missionRunFolder != nil { - return nil, fmt.Errorf("mission '%s': only one run_folder block allowed", missionName) + if missionMemory != nil { + return nil, fmt.Errorf("mission '%s': only one memory block allowed", missionName) } - var rf MissionRunFolder - diags := gohcl.DecodeBody(rfBlock.Body, ctx, &rf) - if diags.HasErrors() { - return nil, fmt.Errorf("mission '%s' run_folder: %w", missionName, diags) + var mm MissionMemory + if diags := gohcl.DecodeBody(mb.Body, ctx, &mm); diags.HasErrors() { + return nil, fmt.Errorf("mission '%s' memory: %w", missionName, diags) } - if rf.Cleanup == nil { - v := DefaultRunFolderCleanupDays - rf.Cleanup = &v + missionMemory = &mm + } + + // Parse optional `scratchpad = true` attribute. Default false — agents + // only get a scratchpad slot when the mission explicitly opts in. + var missionScratchpad bool + if attr, ok := missionContent.Attributes["scratchpad"]; ok { + v, diags := attr.Expr.Value(ctx) + if diags.HasErrors() { + return nil, fmt.Errorf("mission '%s' scratchpad: %w", missionName, diags) } - missionRunFolder = &rf + missionScratchpad = v.True() } // Parse schedule blocks (optional, multiple allowed) @@ -2049,9 +2093,9 @@ func parseMissionBlock(block *hcl.Block, ctx *hcl.EvalContext) (*Mission, error) Commander: missionCommander, Agents: missionAgents, LocalAgents: localAgents, - Folders: missionFolders, - Folder: missionFolder, - RunFolder: missionRunFolder, + Memories: missionMemories, + Memory: missionMemory, + Scratchpad: missionScratchpad, Schedules: schedules, Trigger: trigger, MaxParallel: maxParallel, diff --git a/config/folder_test.go b/config/folder_test.go deleted file mode 100644 index 6622fa9..0000000 --- a/config/folder_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package config_test - -import ( - "squadron/config" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Folders", func() { - - Describe("shared_folder", func() { - It("parses a shared_folder block", func() { - hcl := fullBaseHCL() + ` -shared_folder "research" { - path = "./data" - description = "Research docs" - editable = true -} -mission "m" { - commander { model = models.anthropic.claude_sonnet_4 } - agents = [agents.test_agent] - folders = [shared_folders.research] - task "t" { objective = "go" } -} -` - _, f := writeFixture("config.hcl", hcl) - cfg, err := config.LoadFile(f) - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.SharedFolders).To(HaveLen(1)) - Expect(cfg.SharedFolders[0].Name).To(Equal("research")) - Expect(cfg.SharedFolders[0].Editable).To(BeTrue()) - Expect(cfg.Missions[0].Folders).To(ConsistOf("research")) - }) - - It("rejects the reserved name 'mission'", func() { - sf := config.SharedFolder{Name: "mission", Path: "./x"} - err := sf.Validate() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("reserved")) - }) - - It("rejects the reserved name 'run'", func() { - sf := config.SharedFolder{Name: "run", Path: "./x"} - err := sf.Validate() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("reserved")) - }) - - It("rejects an empty path", func() { - sf := config.SharedFolder{Name: "ok", Path: ""} - err := sf.Validate() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("path is required")) - }) - }) - - Describe("mission folder block", func() { - It("parses a dedicated folder block", func() { - hcl := fullBaseHCL() + ` -mission "m" { - commander { model = models.anthropic.claude_sonnet_4 } - agents = [agents.test_agent] - folder { - path = "./persistent" - description = "Persistent" - } - task "t" { objective = "go" } -} -` - _, f := writeFixture("config.hcl", hcl) - cfg, err := config.LoadFile(f) - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.Missions[0].Folder).NotTo(BeNil()) - Expect(cfg.Missions[0].Folder.Path).To(Equal("./persistent")) - Expect(cfg.Missions[0].Folder.Description).To(Equal("Persistent")) - }) - - It("rejects multiple folder blocks on the same mission", func() { - hcl := fullBaseHCL() + ` -mission "m" { - commander { model = models.anthropic.claude_sonnet_4 } - agents = [agents.test_agent] - folder { path = "./a" } - folder { path = "./b" } - task "t" { objective = "go" } -} -` - _, f := writeFixture("config.hcl", hcl) - _, err := config.LoadFile(f) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("only one folder block allowed")) - }) - }) - - Describe("mission run_folder block", func() { - It("parses a run_folder with defaults", func() { - hcl := fullBaseHCL() + ` -mission "m" { - commander { model = models.anthropic.claude_sonnet_4 } - agents = [agents.test_agent] - run_folder { - description = "Per-run scratch" - } - task "t" { objective = "go" } -} -` - _, f := writeFixture("config.hcl", hcl) - cfg, err := config.LoadFile(f) - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.Missions[0].RunFolder).NotTo(BeNil()) - Expect(cfg.Missions[0].RunFolder.Base).To(Equal("")) - Expect(cfg.Missions[0].RunFolder.Description).To(Equal("Per-run scratch")) - // Cleanup omitted → Validate() filled in the default - Expect(cfg.Missions[0].RunFolder.Cleanup).NotTo(BeNil()) - Expect(*cfg.Missions[0].RunFolder.Cleanup).To(Equal(config.DefaultRunFolderCleanupDays)) - }) - - It("parses a run_folder with custom base and cleanup", func() { - hcl := fullBaseHCL() + ` -mission "m" { - commander { model = models.anthropic.claude_sonnet_4 } - agents = [agents.test_agent] - run_folder { - base = "./custom_runs" - cleanup = 14 - } - task "t" { objective = "go" } -} -` - _, f := writeFixture("config.hcl", hcl) - cfg, err := config.LoadFile(f) - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.Missions[0].RunFolder.Base).To(Equal("./custom_runs")) - Expect(*cfg.Missions[0].RunFolder.Cleanup).To(Equal(14)) - }) - - It("preserves an explicit cleanup of zero (never delete)", func() { - hcl := fullBaseHCL() + ` -mission "m" { - commander { model = models.anthropic.claude_sonnet_4 } - agents = [agents.test_agent] - run_folder { - cleanup = 0 - } - task "t" { objective = "go" } -} -` - _, f := writeFixture("config.hcl", hcl) - cfg, err := config.LoadFile(f) - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.Missions[0].RunFolder.Cleanup).NotTo(BeNil()) - Expect(*cfg.Missions[0].RunFolder.Cleanup).To(Equal(0)) - }) - - It("rejects multiple run_folder blocks", func() { - hcl := fullBaseHCL() + ` -mission "m" { - commander { model = models.anthropic.claude_sonnet_4 } - agents = [agents.test_agent] - run_folder { base = "./a" } - run_folder { base = "./b" } - task "t" { objective = "go" } -} -` - _, f := writeFixture("config.hcl", hcl) - _, err := config.LoadFile(f) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("only one run_folder block allowed")) - }) - - It("rejects a negative cleanup value", func() { - neg := -1 - rf := config.MissionRunFolder{Cleanup: &neg} - err := rf.Validate() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("cleanup")) - }) - - It("allows cleanup of zero (never)", func() { - zero := 0 - rf := config.MissionRunFolder{Cleanup: &zero} - Expect(rf.Validate()).To(Succeed()) - Expect(*rf.Cleanup).To(Equal(0)) - }) - - It("Validate accepts an unset Cleanup (parser fills the default)", func() { - rf := config.MissionRunFolder{} - Expect(rf.Validate()).To(Succeed()) - }) - - It("allows both folder and run_folder on the same mission", func() { - hcl := fullBaseHCL() + ` -mission "m" { - commander { model = models.anthropic.claude_sonnet_4 } - agents = [agents.test_agent] - folder { - path = "./persist" - } - run_folder { - base = "./runs" - cleanup = 7 - } - task "t" { objective = "go" } -} -` - _, f := writeFixture("config.hcl", hcl) - cfg, err := config.LoadFile(f) - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.Missions[0].Folder).NotTo(BeNil()) - Expect(cfg.Missions[0].RunFolder).NotTo(BeNil()) - }) - }) -}) diff --git a/config/memory.go b/config/memory.go new file mode 100644 index 0000000..5a8deca --- /dev/null +++ b/config/memory.go @@ -0,0 +1,87 @@ +package config + +import ( + "fmt" + "strings" +) + +// Reserved slot names for the mission-scoped storage slots. Agents address +// them via the `slot` parameter on the file tools, alongside any shared +// memory names declared at the top level. +const ( + MemorySlotName = "memory" + ScratchpadSlotName = "scratchpad" +) + +// ScratchpadCleanupDays is the auto-delete window applied to every +// mission's scratchpad. Not user-configurable — scratchpads are deliberately +// short-lived. +const ScratchpadCleanupDays = 7 + +// Memory describes a top-level shared memory block: +// +// memory "research" { +// description = "..." +// } +// +// The storage path is derived by the runtime — it lives at +// `/memories/shared//`. Description is required so +// agents can tell what each shared slot is for. All memories are writable +// by their agents. +type Memory struct { + Name string `hcl:"name,label"` + Description string `hcl:"description"` +} + +// Validate enforces naming rules + the required description. +func (m *Memory) Validate() error { + if m.Name == MemorySlotName || m.Name == ScratchpadSlotName { + return fmt.Errorf("name %q is reserved for mission-scoped slots", m.Name) + } + if err := validateSlotName(m.Name); err != nil { + return err + } + if m.Description == "" { + return fmt.Errorf("description is required") + } + return nil +} + +// validateSlotName rejects HCL labels that would break filesystem layout: +// path separators, parent-dir traversal, or leading dot. Used by both shared +// memory labels and mission names (since both become directory names under +// ). +func validateSlotName(name string) error { + if name == "" { + return fmt.Errorf("name is required") + } + if strings.ContainsAny(name, `/\`) { + return fmt.Errorf("name %q must not contain path separators", name) + } + if name == "." || name == ".." || strings.HasPrefix(name, ".") { + return fmt.Errorf("name %q must not start with '.'", name) + } + if strings.Contains(name, "..") { + return fmt.Errorf("name %q must not contain '..'", name) + } + return nil +} + +// MissionMemory describes the `memory { ... }` block inside a mission — +// persistent storage that survives across runs. At most one per mission. +// Same surface as the top-level Memory block minus the label: a required +// description. +// +// The storage path is derived by the runtime from the mission name, so no +// `path` is accepted from HCL. +type MissionMemory struct { + Description string `hcl:"description"` +} + +// Validate enforces the required description. +func (mm *MissionMemory) Validate() error { + if mm.Description == "" { + return fmt.Errorf("description is required") + } + return nil +} diff --git a/config/memory_test.go b/config/memory_test.go new file mode 100644 index 0000000..109a3b3 --- /dev/null +++ b/config/memory_test.go @@ -0,0 +1,341 @@ +package config_test + +import ( + "squadron/config" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Memory + Scratchpad", func() { + + Describe("top-level memory block", func() { + It("parses a memory block and exposes it via memories.NAME", func() { + hcl := fullBaseHCL() + ` +memory "research" { + description = "Research docs" +} +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + memories = [memories.research] + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + cfg, err := config.LoadFile(f) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Memories).To(HaveLen(1)) + Expect(cfg.Memories[0].Name).To(Equal("research")) + Expect(cfg.Memories[0].Description).To(Equal("Research docs")) + Expect(cfg.Missions[0].Memories).To(ConsistOf("research")) + }) + + It("requires a description", func() { + hcl := fullBaseHCL() + ` +memory "research" {} +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + _, err := config.LoadFile(f) + Expect(err).To(HaveOccurred()) + }) + + It("rejects the reserved name 'memory'", func() { + m := config.Memory{Name: "memory", Description: "x"} + err := m.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("reserved")) + }) + + It("rejects the reserved name 'scratchpad'", func() { + m := config.Memory{Name: "scratchpad", Description: "x"} + err := m.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("reserved")) + }) + + It("rejects names with path separators or '..' (path-traversal guard)", func() { + for _, bad := range []string{"../escape", "foo/bar", "foo\\bar", "..", ".", ".hidden", "a..b"} { + m := config.Memory{Name: bad, Description: "x"} + err := m.Validate() + Expect(err).To(HaveOccurred(), "expected %q to be rejected", bad) + } + }) + + It("rejects a shared memory whose label collides with a mission name", func() { + hcl := fullBaseHCL() + ` +memory "analyze" { + description = "shared" +} +mission "analyze" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + // Cross-name collision is caught by Config.Validate (the wsbridge + // browser keys both under the same string). + _, err := config.LoadAndValidate(f) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("conflicts with mission")) + }) + + It("gives a clear error when a mission references an unknown shared memory", func() { + hcl := fullBaseHCL() + ` +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + memories = [memories.does_not_exist] + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + _, err := config.LoadFile(f) + Expect(err).To(HaveOccurred()) + // Must reference the missing name — not be a generic 'unknown + // variable memories' (which is what happens without the always- + // register-empty-namespace fix). + Expect(err.Error()).To(ContainSubstring("does_not_exist")) + }) + + It("rejects an `editable` attribute (all memories are editable now)", func() { + hcl := fullBaseHCL() + ` +memory "research" { + description = "x" + editable = true +} +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + _, err := config.LoadFile(f) + Expect(err).To(HaveOccurred()) + }) + + It("rejects the old shared_folder block with a pointer at the new syntax", func() { + hcl := fullBaseHCL() + ` +shared_folder "research" { + path = "./data" +} +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + _, err := config.LoadFile(f) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no longer supported")) + Expect(err.Error()).To(ContainSubstring("memory \"research\"")) + }) + + It("rejects the old `path` attribute on a memory block", func() { + hcl := fullBaseHCL() + ` +memory "research" { + description = "x" + path = "./data" +} +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + _, err := config.LoadFile(f) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("mission memory block (persistent)", func() { + It("parses a memory block with a description", func() { + hcl := fullBaseHCL() + ` +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + memory { + description = "Long-term notes" + } + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + cfg, err := config.LoadFile(f) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Missions[0].Memory).NotTo(BeNil()) + Expect(cfg.Missions[0].Memory.Description).To(Equal("Long-term notes")) + Expect(cfg.Missions[0].Scratchpad).To(BeFalse()) + }) + + It("requires a description", func() { + hcl := fullBaseHCL() + ` +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + memory {} + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + _, err := config.LoadFile(f) + Expect(err).To(HaveOccurred()) + }) + + It("rejects two memory blocks on the same mission", func() { + hcl := fullBaseHCL() + ` +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + memory { description = "a" } + memory { description = "b" } + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + _, err := config.LoadFile(f) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("only one memory block allowed")) + }) + + It("rejects a `path` attribute", func() { + hcl := fullBaseHCL() + ` +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + memory { description = "x"; path = "./x" } + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + _, err := config.LoadFile(f) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("mission scratchpad attribute", func() { + It("defaults to false when not set", func() { + hcl := fullBaseHCL() + ` +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + cfg, err := config.LoadFile(f) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Missions[0].Scratchpad).To(BeFalse()) + }) + + It("accepts scratchpad = true", func() { + hcl := fullBaseHCL() + ` +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + scratchpad = true + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + cfg, err := config.LoadFile(f) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Missions[0].Scratchpad).To(BeTrue()) + }) + + }) + + Describe("memory + scratchpad on the same mission", func() { + It("allows both", func() { + hcl := fullBaseHCL() + ` +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + memory { description = "long-term" } + scratchpad = true + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + cfg, err := config.LoadFile(f) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Missions[0].Memory).NotTo(BeNil()) + Expect(cfg.Missions[0].Scratchpad).To(BeTrue()) + }) + }) + + Describe("deprecated DSL surfaces", func() { + It("rejects the old `folder { ... }` block with a pointer at the new syntax", func() { + hcl := fullBaseHCL() + ` +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + folder { path = "./x" } + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + _, err := config.LoadFile(f) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("`folder { ... }` block is no longer supported")) + }) + + It("rejects the old `run_folder { ... }` block", func() { + hcl := fullBaseHCL() + ` +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + run_folder { base = "./x" } + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + _, err := config.LoadFile(f) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("`run_folder { ... }` block is no longer supported")) + // Remediation must point at the actual new DSL, not a defunct + // intermediate from earlier in this PR. + Expect(err.Error()).To(ContainSubstring("scratchpad = true")) + }) + + It("points at the new memory DSL when rejecting the old `folder { ... }` block", func() { + hcl := fullBaseHCL() + ` +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + folder { path = "./x" } + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + _, err := config.LoadFile(f) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(`memory { description`)) + }) + + It("rejects the old `folders = ...` attribute", func() { + hcl := fullBaseHCL() + ` +memory "ref" { description = "x" } +mission "m" { + commander { model = models.anthropic.claude_sonnet_4 } + agents = [agents.test_agent] + folders = [memories.ref] + task "t" { objective = "go" } +} +` + _, f := writeFixture("config.hcl", hcl) + _, err := config.LoadFile(f) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("`folders` attribute is no longer supported")) + }) + }) +}) diff --git a/config/mission.go b/config/mission.go index 14438a2..e7324b9 100644 --- a/config/mission.go +++ b/config/mission.go @@ -74,46 +74,7 @@ type OutputField struct { Properties []OutputField `json:"properties,omitempty"` } -// MissionFolder represents a dedicated folder for a mission. -// Registered under the reserved name "mission". Persists across runs. -type MissionFolder struct { - Path string `hcl:"path"` - Description string `hcl:"description,optional"` -} - -// Validate checks that the mission folder configuration is valid -func (mf *MissionFolder) Validate() error { - if mf.Path == "" { - return fmt.Errorf("path is required") - } - return nil -} - -// DefaultRunFolderCleanupDays is the auto-delete window applied when a -// run_folder block does not specify `cleanup`. -const DefaultRunFolderCleanupDays = 7 - -// MissionRunFolder represents a per-run ephemeral folder for a mission. -// Registered under the reserved name "run". A fresh subdirectory is created -// under Base for each mission run, keyed by mission ID. -// -// Cleanup is a pointer so we can distinguish "user didn't set it" (apply -// default of 7 days) from "user set 0" (keep forever). -type MissionRunFolder struct { - Base string `hcl:"base,optional"` // parent directory; defaults to ".squadron/runs" - Description string `hcl:"description,optional"` - Cleanup *int `hcl:"cleanup,optional"` // days after creation before auto-delete; nil = default (7), 0 = never -} - -// Validate rejects negative cleanup values. Default-filling happens at parse -// time (see config.go) so callers reading a parsed Mission see a complete -// struct without needing to validate first. -func (rf *MissionRunFolder) Validate() error { - if rf.Cleanup != nil && *rf.Cleanup < 0 { - return fmt.Errorf("cleanup must be >= 0 (days)") - } - return nil -} +// (Memory and MissionMemory live in memory.go.) // Schedule defines a time-based trigger for a mission. // Three modes (mutually exclusive): @@ -348,9 +309,9 @@ type Mission struct { Tasks []Task `hcl:"task,block"` Inputs []MissionInput // Parsed from input blocks Datasets []Dataset // Parsed from dataset blocks - Folders []string // Shared folder names referenced by this mission - Folder *MissionFolder // Optional dedicated mission folder (reserved name "mission") - RunFolder *MissionRunFolder // Optional per-run ephemeral folder (reserved name "run") + Memories []string // Shared memory names referenced by this mission + Memory *MissionMemory // Optional persistent mission memory (slot "memory") + Scratchpad bool // If true, mission gets an ephemeral per-run scratchpad (slot "scratchpad") Schedules []Schedule `json:"schedules,omitempty"` Trigger *Trigger `json:"trigger,omitempty"` MaxParallel int `json:"maxParallel,omitempty"` // default 3 @@ -394,9 +355,9 @@ type TaskRoute struct { } // Validate checks that the mission configuration is valid -func (w *Mission) Validate(models []Model, agents []Agent, sharedFolders []SharedFolder, allMissionNames map[string]bool) error { - if w.Name == "" { - return fmt.Errorf("mission name is required") +func (w *Mission) Validate(models []Model, agents []Agent, memories []Memory, allMissionNames map[string]bool) error { + if err := validateSlotName(w.Name); err != nil { + return fmt.Errorf("mission name: %w", err) } if w.Commander == nil || w.Commander.Model == "" { @@ -513,28 +474,21 @@ func (w *Mission) Validate(models []Model, agents []Agent, sharedFolders []Share } } - // Validate folder references - folderNames := make(map[string]bool) - for _, sf := range sharedFolders { - folderNames[sf.Name] = true - } - for _, folderRef := range w.Folders { - if !folderNames[folderRef] { - return fmt.Errorf("shared folder '%s' not found", folderRef) - } + // Validate shared memory references + memoryNames := make(map[string]bool) + for _, m := range memories { + memoryNames[m.Name] = true } - - // Validate dedicated folder if present - if w.Folder != nil { - if err := w.Folder.Validate(); err != nil { - return fmt.Errorf("folder: %w", err) + for _, ref := range w.Memories { + if !memoryNames[ref] { + return fmt.Errorf("shared memory '%s' not found", ref) } } - // Validate run folder if present - if w.RunFolder != nil { - if err := w.RunFolder.Validate(); err != nil { - return fmt.Errorf("run_folder: %w", err) + // Validate the mission memory block if present. + if w.Memory != nil { + if err := w.Memory.Validate(); err != nil { + return fmt.Errorf("memory: %w", err) } } diff --git a/config/shared_folder.go b/config/shared_folder.go deleted file mode 100644 index b29f409..0000000 --- a/config/shared_folder.go +++ /dev/null @@ -1,35 +0,0 @@ -package config - -import ( - "fmt" - "os" - - "squadron/internal/paths" -) - -type SharedFolder struct { - Name string `hcl:"name,label"` - Path string `hcl:"path"` - Label string `hcl:"label,optional"` - Description string `hcl:"description,optional"` - Editable bool `hcl:"editable,optional"` -} - -func (fb *SharedFolder) Validate() error { - if fb.Name == "mission" || fb.Name == "run" { - return fmt.Errorf("name %q is reserved for mission-scoped folders", fb.Name) - } - if fb.Path == "" { - return fmt.Errorf("path is required") - } - absPath, err := paths.ResolveFolderPath(fb.Path) - if err != nil { - return fmt.Errorf("invalid path: %w", err) - } - // Create the directory if it doesn't exist (common in container mode) - if err := os.MkdirAll(absPath, 0755); err != nil { - return fmt.Errorf("failed to create folder path: %w", err) - } - fb.Path = absPath - return nil -} diff --git a/docs/content/config/overview.mdx b/docs/content/config/overview.mdx index efe0788..5e2cc1c 100644 --- a/docs/content/config/overview.mdx +++ b/docs/content/config/overview.mdx @@ -60,7 +60,7 @@ agent "assistant" { | `mcp_host` | Start a built-in [MCP host](/config/mcp_host) to expose Squadron to AI clients | | `mission` | Define multi-task missions | | `commander` | Commander server connection config | -| `shared_folder` | Shared file folders accessible to missions | +| `memory` | Shared filesystem locations accessible to missions (paths managed under `/memories/shared/`) | ## Expressions diff --git a/docs/content/declarative-agent-framework.mdx b/docs/content/declarative-agent-framework.mdx index 167ca72..32e5c57 100644 --- a/docs/content/declarative-agent-framework.mdx +++ b/docs/content/declarative-agent-framework.mdx @@ -18,7 +18,7 @@ A framework is declarative when: 1. **The workflow is data, not code.** You can serialize the whole thing as a config file, send it to someone else, and they can run it without the source repo. 2. **A runtime — not your code — drives execution.** The runtime decides when to call the model, what to retry, what to checkpoint, when to fan out, when to resume. 3. **Changes are reviewable as config diffs.** Adding a step, reordering dependencies, swapping a model — all show up as a small, readable patch. -4. **There is no hidden state.** All state is either declared (variables, datasets, folders) or persisted by the runtime (sessions, route decisions, outputs). +4. **There is no hidden state.** All state is either declared (variables, datasets, memory) or persisted by the runtime (sessions, route decisions, outputs). This is the same shift that happened in infrastructure (Terraform replaced Bash + Ansible scripts), in CI (declarative YAML replaced Makefile-driven pipelines), and in deployment (Kubernetes manifests replaced bespoke deploy scripts). The pattern: when the orchestration part of a workflow is gnarlier than the work itself, push the orchestration into a runtime and let humans edit data. diff --git a/docs/content/guides/no-code-multi-agent-workflow.mdx b/docs/content/guides/no-code-multi-agent-workflow.mdx index 73b1bde..3abfe9d 100644 --- a/docs/content/guides/no-code-multi-agent-workflow.mdx +++ b/docs/content/guides/no-code-multi-agent-workflow.mdx @@ -69,7 +69,7 @@ mission "daily_brief" { topic = string("Topic to brief on", { default = "AI agent frameworks" }) } - folder { path = "./briefs"; description = "Daily brief output" } + memory { description = "Daily brief output (persistent across runs)" } budget { tokens = 200000; dollars = 5 } @@ -96,12 +96,12 @@ mission "daily_brief" { task "deep_dive" { agents = [agents.summarizer] - objective = "Write a 500-word deep-dive on the most important item. Save to the briefs folder." + objective = "Write a 500-word deep-dive on the most important item. Save it into the mission memory." } task "summarize" { agents = [agents.summarizer] - objective = "Write a 200-word digest of all items. Save to the briefs folder." + objective = "Write a 200-word digest of all items. Save it into the mission memory." } } ``` diff --git a/docs/content/missions/_meta.js b/docs/content/missions/_meta.js index f483d7f..5ed6486 100644 --- a/docs/content/missions/_meta.js +++ b/docs/content/missions/_meta.js @@ -5,7 +5,7 @@ export default { routing: 'Routing', datasets: 'Datasets', iteration: 'Iteration', - folders: 'Folders', + folders: 'Memory & Scratchpad', 'internal-tools': 'Internal Tools', budgets: 'Budgets', schedules: 'Schedules & Triggers', diff --git a/docs/content/missions/folders.mdx b/docs/content/missions/folders.mdx index dd7557b..09dacea 100644 --- a/docs/content/missions/folders.mdx +++ b/docs/content/missions/folders.mdx @@ -1,109 +1,119 @@ --- -title: Folders +title: Memory & Scratchpad --- -# Folders +# Memory & Scratchpad -Folders are filesystem locations that agents can read from and write to. Squadron gives you three kinds, each suited to a different use case: +Squadron gives missions two kinds of file storage: -| Kind | Use it for | Lifetime | -|------|------------|----------| -| **Shared** | Reference data or output that many missions touch | Persists | -| **Mission** | A mission's own long-lived state — archives, accumulated output | Persists | -| **Run** | Scratch space for the work happening right now | One per run | +| Kind | What it's for | Lifetime | Slot name agents use | +|------|---------------|----------|-----------------------| +| **Shared memory** | Reference or output data many missions touch | Persists | the HCL label | +| **Mission memory** | A mission's own long-lived state — archives, accumulated output | Persists | `"memory"` | +| **Mission scratchpad** | Working space for one mission run — intermediate files, drafts | One per run, auto-deleted after 7 days | `"scratchpad"` | -Agents reach folders through the `file_list`, `file_read`, `file_create`, `file_delete`, `file_search`, and `file_grep` tools, naming the folder on each call. +Agents reach all three through the same `file_list`, `file_read`, `file_create`, `file_delete`, `file_search`, and `file_grep` tools, naming the slot on each call via the `slot` parameter. **Every slot is writable** — there is no read-only mode. -A mission can use any number of shared folders, plus one of each mission-scoped kind. The names `mission` and `run` are reserved. +> **No `path` attribute.** Squadron owns the on-disk layout — you declare what storage you need and Squadron picks the path. Everything lives under `/`. -## Shared Folders +## Shared Memory Declared at the top level so multiple missions can share the same data: ```hcl -shared_folder "research" { - path = "./data/research" +memory "research" { description = "Shared research documents" - editable = true } -shared_folder "reference" { - path = "./data/reference" - # no editable flag → read-only +memory "reference" { + description = "Reference materials" } ``` | Attribute | Type | Description | |-----------|------|-------------| -| `path` | string | Directory path (created if missing) | -| `description` | string | Human-readable description shown to agents | -| `label` | string | Optional label (falls back to description) | -| `editable` | bool | If `true`, agents may write. Default `false` (read-only) | +| `description` | string (required) | Human-readable description shown to agents | Missions opt in by listing them, then refer to them by name in tool calls: ```hcl mission "analyze" { - folders = [shared_folders.research, shared_folders.reference] - # agents call e.g. file_read with folder = "research" + memories = [memories.research, memories.reference] + # agents call e.g. file_read with slot = "research" } ``` -## The Mission Folder +The names `"memory"` and `"scratchpad"` are reserved — a shared `memory "memory"` or `memory "scratchpad"` block is rejected. -The mission's own dedicated folder. Use it for things you want to keep around: a running log, an archive of finished reports, accumulated state across runs. +## Mission Memory + +The mission's own dedicated long-lived slot. Use it for things you want to keep around: a running log, an archive of finished reports, accumulated state across runs. ```hcl mission "analyze" { - folder { - path = "./analyses" + memory { description = "Cumulative analysis output across every run" } } ``` -Agents reach it under the name `mission`: +Same shape as the top-level memory block (just a required `description`) — no label because the slot name is always `"memory"`. Agents reach it under that name: ```json -{ "folder": "mission", "path": "2026-04-23/report.md", "content": "..." } +{ "slot": "memory", "path": "2026-04-23/report.md", "content": "..." } ``` | Attribute | Type | Description | |-----------|------|-------------| -| `path` | string | Directory path (created if missing) | -| `description` | string | Shown to agents alongside the folder name | +| `description` | string (required) | What this mission's memory is for | + +A mission with no `memory { ... }` block has no `"memory"` slot — agents only see whatever shared memories the mission lists (plus the scratchpad, if enabled). -## The Run Folder +## Mission Scratchpad A clean workspace for one mission run. Each invocation gets its own subdirectory, isolated from previous and concurrent runs — perfect for intermediate files, working drafts, or anything you don't want bleeding into the next run. +Opt in with a single attribute: + ```hcl mission "analyze" { - run_folder { - description = "Scratch space for this run" - } + scratchpad = true } ``` -By default, run folders are deleted 7 days after the run started. Set `cleanup` to override the window, or `cleanup = 0` to keep folders forever. +That's the whole configuration. Scratchpads are auto-deleted 7 days after the run started — long enough to inspect what an agent produced and to resume a mission with `squadron mission --resume `, short enough to keep disk usage in check. -Agents reach it under the name `run`: +Agents reach it under the name `scratchpad`: ```json -{ "folder": "run", "path": "notes.txt", "content": "..." } +{ "slot": "scratchpad", "path": "notes.txt", "content": "..." } ``` -| Attribute | Type | Description | -|-----------|------|-------------| -| `base` | string | Where run folders live. Defaults to `.squadron/runs` — change it if you want them somewhere else | -| `description` | string | Shown to agents alongside the folder name | -| `cleanup` | integer | Delete the folder this many days after the run started. Defaults to `7`; set `0` to keep forever | +### When to use a scratchpad + +Reach for the scratchpad when one run needs working storage that the *next* run shouldn't see: -Run folders stick around after the mission ends — useful for inspecting what an agent produced, and required for `squadron mission --resume ` to find its workspace. `cleanup` is just a max age; nothing is deleted while a run is in flight. +- **Multi-step or multi-agent pipelines.** Agent A scrapes 50 pages and writes them to `scratchpad/raw/`; agent B reads, filters, and summarizes; agent C writes only the final summary to `memory/`. Each run starts clean — no stale `raw/` files leaking between runs. +- **Intermediate computation you don't want to ship.** Partial JSON, debug dumps, retry attempts, half-rendered output — anything the LLM produces while figuring out the right answer but that shouldn't accumulate forever. +- **Per-run isolation under concurrency.** Two simultaneous runs of the same mission can both write to `scratchpad` without colliding; each gets its own `//` directory. +- **Resume material.** If a long-running mission crashes mid-flight, its scratchpad stays on disk so `squadron mission --resume ` can pick up where it left off. + +### Memory vs scratchpad + +If you're not sure which to use, this is the rule: + +| Question | Use | +|----------|-----| +| Should the *next* run of this mission see this file? | `memory { }` | +| Is this a working draft, intermediate result, or temp file? | `scratchpad = true` | +| Will multiple missions need to read this? | top-level `memory "name" { ... }` | +| Does this need to survive across runs *and* be writable by agents? | `memory { }` (every memory is writable) | + +A mission can declare both — the persistent `memory { }` for things worth keeping, and `scratchpad = true` for everything else. ## Tool Reference -All six folder tools take a required `folder` parameter: +All six file tools take a required `slot` parameter — that's the slot name (`"memory"`, `"scratchpad"`, or a shared memory's label): | Tool | Purpose | |------|---------| @@ -114,38 +124,30 @@ All six folder tools take a required `folder` parameter: | `file_search` | Recursively search for files by filename regex | | `file_grep` | Search file contents by regex | -Paths are always relative to the folder root. Absolute paths and `..` escapes are rejected. +Paths are always relative to the slot's root directory. Absolute paths and `..` escapes are rejected. ## Full Example ```hcl -shared_folder "reference" { - path = "./data/reference" +memory "reference" { description = "Reference materials shared across missions" } mission "research" { commander { model = models.anthropic.claude_sonnet_4 } agents = [agents.researcher] - folders = [shared_folders.reference] - folder { - path = "./research_archive" - description = "Finished reports, one per run" - } - - run_folder { - description = "Working files for the in-flight run" - cleanup = 14 # override the 7-day default - } + memories = [memories.reference] + memory { description = "Finished reports, one per run" } + scratchpad = true task "gather" { - objective = "Gather sources from the reference folder and save notes to the run folder" + objective = "Gather sources from the reference memory and save notes to the scratchpad" } task "publish" { depends_on = [tasks.gather] - objective = "Write the final report into the mission folder" + objective = "Write the final report into the mission memory" } } ``` diff --git a/docs/content/missions/internal-tools.mdx b/docs/content/missions/internal-tools.mdx index 9d02abe..ac61064 100644 --- a/docs/content/missions/internal-tools.mdx +++ b/docs/content/missions/internal-tools.mdx @@ -336,3 +336,26 @@ When running inside a mission, agents automatically get these dataset tools: | `result_to_dataset` | Convert a large intercepted result into a dataset for iteration | See [Datasets](/missions/datasets) for details and examples. + +### File Tools + +When a mission declares at least one storage slot — a top-level `memory "name"` referenced in `memories = [...]`, a mission-scoped `memory { }`, or `scratchpad = true` — every agent in that mission automatically gets these six file tools: + +| Tool | Description | +|------|-------------| +| `file_list` | List files and directories in a slot, with optional recursion and pagination | +| `file_read` | Read a file's contents (optionally capped by lines or bytes) | +| `file_create` | Create, overwrite, or append to a file | +| `file_delete` | Delete a file (directories are not allowed) | +| `file_search` | Recursively search for files by filename regex | +| `file_grep` | Search file contents by regex | + +Every call takes a required `slot` parameter naming which slot to operate in: + +- `"memory"` — the mission's persistent memory (only available if the mission declares `memory { }`) +- `"scratchpad"` — the mission's ephemeral per-run scratchpad (only available if the mission sets `scratchpad = true`; auto-cleaned after 7 days) +- a shared memory's HCL label — for any memory the mission lists in `memories = [...]` + +Paths are always relative to the slot's root. Absolute paths and `..` escapes are rejected. + +A mission with no `memories =`, no `memory { }`, and no `scratchpad = true` does NOT get the file tools — they appear only when there's somewhere to put files. See [Memory & Scratchpad](/missions/folders) for the full slot model and the storage paths Squadron picks. diff --git a/docs/content/missions/overview.mdx b/docs/content/missions/overview.mdx index 3f97de8..597c360 100644 --- a/docs/content/missions/overview.mdx +++ b/docs/content/missions/overview.mdx @@ -43,9 +43,9 @@ mission "data_pipeline" { | `input` | block | Mission input parameters (repeatable) | | `task` | block | Task definitions (repeatable) | | `dataset` | block | Dataset definitions (optional) | -| `folders` | list | Shared folder references, e.g. `[shared_folders.data]` (see [Folders](/missions/folders)) | -| `folder` | block | Persistent mission-scoped folder, registered as `"mission"` | -| `run_folder` | block | Per-run ephemeral folder, registered as `"run"` | +| `memories` | list | Shared memory references, e.g. `[memories.data]` (see [Memory & Scratchpad](/missions/folders)) | +| `memory` | block | Mission-scoped persistent memory (slot `"memory"`). Required `description`. At most one per mission. | +| `scratchpad` | bool | If `true`, the mission gets an ephemeral per-run scratchpad (slot `"scratchpad"`); auto-deleted after 7 days. | | `schedule` | block | Automatic run schedules (optional, repeatable) | | `trigger` | block | Webhook trigger (optional) | | `max_parallel` | number | Max concurrent instances (default: 3) | diff --git a/mission/folder_store.go b/mission/folder_store.go deleted file mode 100644 index ef8bd03..0000000 --- a/mission/folder_store.go +++ /dev/null @@ -1,257 +0,0 @@ -package mission - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "squadron/aitools" - "squadron/config" - "squadron/internal/paths" -) - -// DefaultRunFolderBase is the parent directory used when a run_folder block -// does not specify `base`. -const DefaultRunFolderBase = ".squadron/runs" - -// runMetadataFile is the sidecar written inside each materialized run folder -// so the cleanup sweep can tell when the folder was created. -const runMetadataFile = ".squadron-run.json" - -type runMetadata struct { - Mission string `json:"mission"` - MissionID string `json:"mission_id"` - CreatedAt time.Time `json:"created_at"` - CleanupDays int `json:"cleanup_days,omitempty"` -} - -// ResolvedRunFolderBase returns the base directory for a run_folder, -// substituting the default when unset. -func ResolvedRunFolderBase(rf *config.MissionRunFolder) string { - if rf == nil || rf.Base == "" { - return DefaultRunFolderBase - } - return rf.Base -} - -// resolvedCleanup returns the cleanup window in days for a run_folder. -// Reads the parsed pointer when set (Validate fills in the default at config -// load time); falls back to the default for callers that hand-build a struct -// and skip Validate (notably tests). -func resolvedCleanup(rf *config.MissionRunFolder) int { - if rf == nil || rf.Cleanup == nil { - return config.DefaultRunFolderCleanupDays - } - return *rf.Cleanup -} - -type missionFolderStore struct { - folders map[string]*folderEntry -} - -type folderEntry struct { - absPath string - description string - writable bool -} - -// buildFolderStore creates a FolderStore from the mission config. -// missionID scopes the per-run folder path; it must be non-empty when -// mission.RunFolder is set. Returns nil if no folders are configured. -func buildFolderStore(mission *config.Mission, sharedFolders []config.SharedFolder, missionID string) (aitools.FolderStore, error) { - store := &missionFolderStore{ - folders: make(map[string]*folderEntry), - } - - foldersByName := make(map[string]*config.SharedFolder) - for i := range sharedFolders { - foldersByName[sharedFolders[i].Name] = &sharedFolders[i] - } - - for _, name := range mission.Folders { - if name == aitools.MissionFolderName || name == aitools.RunFolderName { - return nil, fmt.Errorf("shared folder %q uses a reserved name", name) - } - sf, ok := foldersByName[name] - if !ok { - return nil, fmt.Errorf("shared folder %q not found", name) - } - absPath, err := paths.ResolveFolderPath(sf.Path) - if err != nil { - return nil, fmt.Errorf("shared folder %q: invalid path: %w", name, err) - } - desc := sf.Description - if desc == "" { - desc = sf.Label - } - store.folders[name] = &folderEntry{ - absPath: absPath, - description: desc, - writable: sf.Editable, - } - } - - if mission.Folder != nil { - absPath, err := paths.ResolveFolderPath(mission.Folder.Path) - if err != nil { - return nil, fmt.Errorf("mission folder: invalid path: %w", err) - } - if err := os.MkdirAll(absPath, 0755); err != nil { - return nil, fmt.Errorf("mission folder: create directory: %w", err) - } - store.folders[aitools.MissionFolderName] = &folderEntry{ - absPath: absPath, - description: mission.Folder.Description, - writable: true, - } - } - - if mission.RunFolder != nil { - if missionID == "" { - return nil, fmt.Errorf("run_folder requires a mission ID") - } - absBase, err := paths.ResolveFolderPath(ResolvedRunFolderBase(mission.RunFolder)) - if err != nil { - return nil, fmt.Errorf("run_folder: invalid base: %w", err) - } - absPath := filepath.Join(absBase, missionID) - if err := os.MkdirAll(absPath, 0755); err != nil { - return nil, fmt.Errorf("run_folder: create directory: %w", err) - } - if err := writeRunMetadata(absPath, mission.Name, missionID, resolvedCleanup(mission.RunFolder)); err != nil { - return nil, fmt.Errorf("run_folder: write metadata: %w", err) - } - store.folders[aitools.RunFolderName] = &folderEntry{ - absPath: absPath, - description: mission.RunFolder.Description, - writable: true, - } - } - - if len(store.folders) == 0 { - return nil, nil - } - - return store, nil -} - -// writeRunMetadata records when the run folder was created so the sweep can -// decide when to delete it. Uses O_CREATE|O_EXCL so concurrent starts and -// resumes never clobber the original timestamp — exactly one writer wins, -// others observe EEXIST and skip. -func writeRunMetadata(dir, missionName, missionID string, cleanupDays int) error { - path := filepath.Join(dir, runMetadataFile) - f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) - if err != nil { - if errors.Is(err, os.ErrExist) { - return nil - } - return err - } - defer f.Close() - meta := runMetadata{ - Mission: missionName, - MissionID: missionID, - CreatedAt: time.Now().UTC(), - CleanupDays: cleanupDays, - } - b, err := json.MarshalIndent(&meta, "", " ") - if err != nil { - return err - } - _, err = f.Write(b) - return err -} - -func (s *missionFolderStore) ResolvePath(folderName string, relPath string) (string, bool, error) { - if folderName == "" { - return "", false, fmt.Errorf("folder name is required (available: %v)", s.availableNames()) - } - - entry, ok := s.folders[folderName] - if !ok { - return "", false, fmt.Errorf("folder %q not found. Available: %v", folderName, s.availableNames()) - } - - cleaned := filepath.Clean(relPath) - if cleaned == "." { - return entry.absPath, entry.writable, nil - } - - fullPath := filepath.Join(entry.absPath, cleaned) - if !strings.HasPrefix(fullPath, entry.absPath) { - return "", false, fmt.Errorf("path escapes folder root") - } - - return fullPath, entry.writable, nil -} - -func (s *missionFolderStore) availableNames() []string { - names := make([]string, 0, len(s.folders)) - for name := range s.folders { - names = append(names, name) - } - return names -} - -func (s *missionFolderStore) FolderInfos() []aitools.FolderInfo { - var infos []aitools.FolderInfo - for name, entry := range s.folders { - infos = append(infos, aitools.FolderInfo{ - Name: name, - Description: entry.description, - Writable: entry.writable, - }) - } - return infos -} - -// SweepExpiredRunFolders deletes any subfolder of base whose sidecar -// (.squadron-run.json) records a created_at older than its cleanup_days. -// Folders without a sidecar, or with cleanup_days == 0, are left alone. -// Missing base is not an error — returns (nil, nil). -func SweepExpiredRunFolders(base string) (removed []string, err error) { - absBase, err := paths.ResolveFolderPath(base) - if err != nil { - return nil, err - } - entries, err := os.ReadDir(absBase) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - now := time.Now().UTC() - for _, e := range entries { - if !e.IsDir() { - continue - } - runDir := filepath.Join(absBase, e.Name()) - metaPath := filepath.Join(runDir, runMetadataFile) - b, err := os.ReadFile(metaPath) - if err != nil { - continue - } - var meta runMetadata - if err := json.Unmarshal(b, &meta); err != nil { - continue - } - if meta.CleanupDays <= 0 { - continue - } - deadline := meta.CreatedAt.Add(time.Duration(meta.CleanupDays) * 24 * time.Hour) - if now.Before(deadline) { - continue - } - if err := os.RemoveAll(runDir); err != nil { - continue - } - removed = append(removed, runDir) - } - return removed, nil -} diff --git a/mission/folder_store_test.go b/mission/folder_store_test.go deleted file mode 100644 index 4f9148d..0000000 --- a/mission/folder_store_test.go +++ /dev/null @@ -1,444 +0,0 @@ -package mission - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - "time" - - "squadron/aitools" - "squadron/config" -) - -func TestBuildFolderStore_NoFolders(t *testing.T) { - m := &config.Mission{Name: "m"} - store, err := buildFolderStore(m, nil, "mid-1") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if store != nil { - t.Fatalf("expected nil store when no folders configured, got %+v", store) - } -} - -func TestBuildFolderStore_MissionFolder(t *testing.T) { - dir := t.TempDir() - missionDir := filepath.Join(dir, "persistent") - - m := &config.Mission{ - Name: "m", - Folder: &config.MissionFolder{ - Path: missionDir, - Description: "persistent", - }, - } - - store, err := buildFolderStore(m, nil, "mid-1") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if store == nil { - t.Fatal("expected non-nil store") - } - - // Registered under reserved name "mission", not the mission name - abs, writable, err := store.ResolvePath(aitools.MissionFolderName, ".") - if err != nil { - t.Fatalf("ResolvePath: %v", err) - } - if !writable { - t.Fatal("mission folder must be writable") - } - if !filepath.IsAbs(abs) { - t.Fatalf("resolved path should be absolute: %s", abs) - } - // Directory was created - if info, err := os.Stat(abs); err != nil || !info.IsDir() { - t.Fatalf("mission folder not created at %s: %v", abs, err) - } - - // The mission name is NOT a valid folder key — prevents regression - if _, _, err := store.ResolvePath(m.Name, "."); err == nil { - t.Fatal("expected error when resolving by mission name") - } -} - -func TestBuildFolderStore_RunFolder_CreatesSidecar(t *testing.T) { - dir := t.TempDir() - cleanup := 7 - m := &config.Mission{ - Name: "m", - RunFolder: &config.MissionRunFolder{ - Base: filepath.Join(dir, "runs"), - Cleanup: &cleanup, - }, - } - - store, err := buildFolderStore(m, nil, "mid-abc") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - abs, writable, err := store.ResolvePath(aitools.RunFolderName, ".") - if err != nil { - t.Fatalf("ResolvePath: %v", err) - } - if !writable { - t.Fatal("run folder must be writable") - } - if filepath.Base(abs) != "mid-abc" { - t.Fatalf("run folder should be keyed by missionID, got %s", abs) - } - - // Sidecar written with CleanupDays preserved - metaBytes, err := os.ReadFile(filepath.Join(abs, runMetadataFile)) - if err != nil { - t.Fatalf("sidecar not written: %v", err) - } - var meta runMetadata - if err := json.Unmarshal(metaBytes, &meta); err != nil { - t.Fatalf("sidecar not valid JSON: %v", err) - } - if meta.CleanupDays != 7 { - t.Fatalf("CleanupDays: want 7, got %d", meta.CleanupDays) - } - if meta.MissionID != "mid-abc" { - t.Fatalf("MissionID: want mid-abc, got %q", meta.MissionID) - } - if meta.CreatedAt.IsZero() { - t.Fatal("CreatedAt should be set") - } -} - -func TestBuildFolderStore_RunFolder_SidecarPreservedOnResume(t *testing.T) { - dir := t.TempDir() - m := &config.Mission{ - Name: "m", - RunFolder: &config.MissionRunFolder{ - Base: filepath.Join(dir, "runs"), - }, - } - - // First build: sidecar written - if _, err := buildFolderStore(m, nil, "mid-1"); err != nil { - t.Fatalf("first build: %v", err) - } - runDir := filepath.Join(dir, "runs", "mid-1") - firstMetaBytes, _ := os.ReadFile(filepath.Join(runDir, runMetadataFile)) - var first runMetadata - _ = json.Unmarshal(firstMetaBytes, &first) - - // Sleep a touch so a re-written timestamp would differ - time.Sleep(10 * time.Millisecond) - - // Second build (same missionID = resume): sidecar must NOT be overwritten - if _, err := buildFolderStore(m, nil, "mid-1"); err != nil { - t.Fatalf("second build: %v", err) - } - secondMetaBytes, _ := os.ReadFile(filepath.Join(runDir, runMetadataFile)) - var second runMetadata - _ = json.Unmarshal(secondMetaBytes, &second) - - if !first.CreatedAt.Equal(second.CreatedAt) { - t.Fatalf("CreatedAt should be preserved on resume: first=%v second=%v", first.CreatedAt, second.CreatedAt) - } -} - -func TestBuildFolderStore_RunFolder_RequiresMissionID(t *testing.T) { - m := &config.Mission{ - Name: "m", - RunFolder: &config.MissionRunFolder{ - Base: t.TempDir(), - }, - } - _, err := buildFolderStore(m, nil, "") - if err == nil { - t.Fatal("expected error when missionID is empty") - } -} - -func TestBuildFolderStore_RejectsReservedSharedFolderNames(t *testing.T) { - for _, reserved := range []string{"mission", "run"} { - m := &config.Mission{ - Name: "m", - Folders: []string{reserved}, - } - shared := []config.SharedFolder{ - {Name: reserved, Path: t.TempDir()}, - } - _, err := buildFolderStore(m, shared, "mid-1") - if err == nil { - t.Fatalf("expected error for reserved shared folder name %q", reserved) - } - } -} - -func TestBuildFolderStore_BothMissionAndRunFolder(t *testing.T) { - dir := t.TempDir() - m := &config.Mission{ - Name: "m", - Folder: &config.MissionFolder{ - Path: filepath.Join(dir, "persist"), - }, - RunFolder: &config.MissionRunFolder{ - Base: filepath.Join(dir, "runs"), - }, - } - store, err := buildFolderStore(m, nil, "mid-1") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if _, _, err := store.ResolvePath(aitools.MissionFolderName, "."); err != nil { - t.Fatalf("mission folder should resolve: %v", err) - } - if _, _, err := store.ResolvePath(aitools.RunFolderName, "."); err != nil { - t.Fatalf("run folder should resolve: %v", err) - } -} - -func TestResolvePath_EmptyFolderNameRejected(t *testing.T) { - dir := t.TempDir() - m := &config.Mission{ - Name: "m", - Folder: &config.MissionFolder{Path: dir}, - } - store, err := buildFolderStore(m, nil, "mid-1") - if err != nil { - t.Fatalf("build: %v", err) - } - if _, _, err := store.ResolvePath("", "."); err == nil { - t.Fatal("expected error when folder name is empty") - } -} - -func TestResolvePath_RejectsPathEscape(t *testing.T) { - dir := t.TempDir() - m := &config.Mission{ - Name: "m", - Folder: &config.MissionFolder{Path: dir}, - } - store, err := buildFolderStore(m, nil, "mid-1") - if err != nil { - t.Fatalf("build: %v", err) - } - if _, _, err := store.ResolvePath("mission", "../outside"); err == nil { - t.Fatal("expected path-escape error") - } -} - -func TestResolvePath_UnknownFolder(t *testing.T) { - dir := t.TempDir() - m := &config.Mission{ - Name: "m", - Folder: &config.MissionFolder{Path: dir}, - } - store, err := buildFolderStore(m, nil, "mid-1") - if err != nil { - t.Fatalf("build: %v", err) - } - if _, _, err := store.ResolvePath("does_not_exist", "."); err == nil { - t.Fatal("expected error for unknown folder") - } -} - -// --- SweepExpiredRunFolders ------------------------------------------------ - -// writeRun creates a run folder with a sidecar recording the given created_at -// and cleanup_days. Useful for driving the sweep. -func writeRun(t *testing.T, base, name string, createdAt time.Time, cleanupDays int) string { - t.Helper() - dir := filepath.Join(base, name) - if err := os.MkdirAll(dir, 0755); err != nil { - t.Fatal(err) - } - meta := runMetadata{ - Mission: "m", - MissionID: name, - CreatedAt: createdAt, - CleanupDays: cleanupDays, - } - b, _ := json.Marshal(&meta) - if err := os.WriteFile(filepath.Join(dir, runMetadataFile), b, 0644); err != nil { - t.Fatal(err) - } - return dir -} - -func TestSweepExpiredRunFolders_DeletesExpired(t *testing.T) { - base := t.TempDir() - expired := writeRun(t, base, "old", time.Now().Add(-8*24*time.Hour), 7) - - removed, err := SweepExpiredRunFolders(base) - if err != nil { - t.Fatalf("sweep: %v", err) - } - if len(removed) != 1 { - t.Fatalf("expected 1 removal, got %v", removed) - } - if _, err := os.Stat(expired); !os.IsNotExist(err) { - t.Fatalf("expired folder should be gone: %v", err) - } -} - -func TestSweepExpiredRunFolders_KeepsUnexpired(t *testing.T) { - base := t.TempDir() - fresh := writeRun(t, base, "new", time.Now().Add(-2*24*time.Hour), 7) - - removed, err := SweepExpiredRunFolders(base) - if err != nil { - t.Fatalf("sweep: %v", err) - } - if len(removed) != 0 { - t.Fatalf("expected nothing removed, got %v", removed) - } - if _, err := os.Stat(fresh); err != nil { - t.Fatalf("fresh folder should still exist: %v", err) - } -} - -func TestSweepExpiredRunFolders_IgnoresZeroCleanup(t *testing.T) { - base := t.TempDir() - keep := writeRun(t, base, "forever", time.Now().Add(-365*24*time.Hour), 0) - - removed, err := SweepExpiredRunFolders(base) - if err != nil { - t.Fatalf("sweep: %v", err) - } - if len(removed) != 0 { - t.Fatalf("expected nothing removed when cleanup=0, got %v", removed) - } - if _, err := os.Stat(keep); err != nil { - t.Fatalf("folder with cleanup=0 should be preserved: %v", err) - } -} - -func TestSweepExpiredRunFolders_IgnoresFoldersWithoutSidecar(t *testing.T) { - base := t.TempDir() - manual := filepath.Join(base, "hand_made") - if err := os.MkdirAll(manual, 0755); err != nil { - t.Fatal(err) - } - - removed, err := SweepExpiredRunFolders(base) - if err != nil { - t.Fatalf("sweep: %v", err) - } - if len(removed) != 0 { - t.Fatalf("sweep must leave un-marked folders alone, got %v", removed) - } - if _, err := os.Stat(manual); err != nil { - t.Fatalf("manually created folder should still exist: %v", err) - } -} - -func TestSweepExpiredRunFolders_MissingBaseIsNotAnError(t *testing.T) { - base := filepath.Join(t.TempDir(), "does", "not", "exist") - removed, err := SweepExpiredRunFolders(base) - if err != nil { - t.Fatalf("sweep should tolerate missing base: %v", err) - } - if removed != nil { - t.Fatalf("expected nil removed, got %v", removed) - } -} - -// TestSweepThenRebuildRoundTrip mirrors the real flow: an old run folder -// exists with a sidecar backdated past its cleanup window, the sweep deletes -// it, then a new buildFolderStore (different missionID) creates a fresh run -// folder with a current sidecar — and the old one is gone. -func TestSweepThenRebuildRoundTrip(t *testing.T) { - base := filepath.Join(t.TempDir(), "runs") - - // Simulate a run from days ago that's past its cleanup deadline. - stale := writeRun(t, base, "old-run", time.Now().Add(-5*24*time.Hour), 2) - - if _, err := SweepExpiredRunFolders(base); err != nil { - t.Fatalf("sweep: %v", err) - } - if _, err := os.Stat(stale); !os.IsNotExist(err) { - t.Fatalf("stale run should have been deleted: %v", err) - } - - cleanup := 2 - m := &config.Mission{ - Name: "folders_demo", - RunFolder: &config.MissionRunFolder{ - Base: base, - Cleanup: &cleanup, - }, - } - store, err := buildFolderStore(m, nil, "new-run") - if err != nil { - t.Fatalf("build: %v", err) - } - fresh, _, err := store.ResolvePath(aitools.RunFolderName, ".") - if err != nil { - t.Fatalf("resolve: %v", err) - } - if filepath.Base(fresh) != "new-run" { - t.Fatalf("fresh run should be keyed by new missionID, got %s", fresh) - } - - metaBytes, err := os.ReadFile(filepath.Join(fresh, runMetadataFile)) - if err != nil { - t.Fatalf("fresh sidecar: %v", err) - } - var meta runMetadata - if err := json.Unmarshal(metaBytes, &meta); err != nil { - t.Fatalf("decode sidecar: %v", err) - } - if time.Since(meta.CreatedAt) > time.Minute { - t.Fatalf("fresh sidecar CreatedAt should be ~now, got %v", meta.CreatedAt) - } -} - -func TestResolvedRunFolderBase(t *testing.T) { - if got := ResolvedRunFolderBase(nil); got != DefaultRunFolderBase { - t.Fatalf("nil rf: want %q, got %q", DefaultRunFolderBase, got) - } - if got := ResolvedRunFolderBase(&config.MissionRunFolder{}); got != DefaultRunFolderBase { - t.Fatalf("empty base: want %q, got %q", DefaultRunFolderBase, got) - } - if got := ResolvedRunFolderBase(&config.MissionRunFolder{Base: "/custom"}); got != "/custom" { - t.Fatalf("explicit base: want %q, got %q", "/custom", got) - } -} - -func TestBuildFolderStore_CreatesNestedBase(t *testing.T) { - // Base directory with nested missing parents — MkdirAll should create them all. - base := filepath.Join(t.TempDir(), "a", "b", "c", "runs") - m := &config.Mission{ - Name: "m", - RunFolder: &config.MissionRunFolder{ - Base: base, - }, - } - if _, err := buildFolderStore(m, nil, "mid-1"); err != nil { - t.Fatalf("build: %v", err) - } - if info, err := os.Stat(filepath.Join(base, "mid-1")); err != nil || !info.IsDir() { - t.Fatalf("nested run folder not created: %v", err) - } -} - -func TestWriteRunMetadata_PreservesOnReentry(t *testing.T) { - // Direct test of the O_CREATE|O_EXCL sidecar write: calling twice with - // different cleanupDays must not overwrite the original. - dir := t.TempDir() - if err := writeRunMetadata(dir, "m", "id-1", 7); err != nil { - t.Fatalf("first write: %v", err) - } - first, _ := os.ReadFile(filepath.Join(dir, runMetadataFile)) - - time.Sleep(10 * time.Millisecond) - - if err := writeRunMetadata(dir, "m", "id-1", 99); err != nil { - t.Fatalf("second write: %v", err) - } - second, _ := os.ReadFile(filepath.Join(dir, runMetadataFile)) - - if string(first) != string(second) { - t.Fatalf("sidecar must not be rewritten:\nfirst: %s\nsecond: %s", first, second) - } -} diff --git a/mission/memory_store.go b/mission/memory_store.go new file mode 100644 index 0000000..0f0e377 --- /dev/null +++ b/mission/memory_store.go @@ -0,0 +1,311 @@ +package mission + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "squadron/aitools" + "squadron/config" + "squadron/internal/paths" +) + +// On-disk layout under SquadronHome: +// +// /memories/shared// — shared memory +// /memories/mission// — mission memory +// /scratchpads/// — mission scratchpad +const ( + memoriesSubdir = "memories" + scratchpadSubdir = "scratchpads" +) + +// runMetadataFile is the sidecar written inside each materialized scratchpad +// directory so the cleanup sweep can tell when it was created. +const runMetadataFile = ".squadron-run.json" + +type runMetadata struct { + Mission string `json:"mission"` + MissionID string `json:"mission_id"` + CreatedAt time.Time `json:"created_at"` + CleanupDays int `json:"cleanup_days,omitempty"` +} + +// MemoriesRoot returns `/memories`, the parent of every +// materialized memory slot (shared + per-mission). +func MemoriesRoot() (string, error) { + home, err := paths.SquadronHome() + if err != nil { + return "", err + } + return filepath.Join(home, memoriesSubdir), nil +} + +// ScratchpadsRoot returns `/scratchpads`, the parent of every +// materialized per-run scratchpad. Used by the cleanup sweep. +func ScratchpadsRoot() (string, error) { + home, err := paths.SquadronHome() + if err != nil { + return "", err + } + return filepath.Join(home, scratchpadSubdir), nil +} + +// SharedMemoryPath returns the on-disk path for a top-level shared memory +// named `name`. +func SharedMemoryPath(name string) (string, error) { + root, err := MemoriesRoot() + if err != nil { + return "", err + } + return filepath.Join(root, "shared", name), nil +} + +// MissionMemoryPath returns the on-disk path for a mission's persistent +// memory slot. Stable across runs of the same mission. +func MissionMemoryPath(missionName string) (string, error) { + root, err := MemoriesRoot() + if err != nil { + return "", err + } + return filepath.Join(root, "mission", missionName), nil +} + +// MissionScratchpadPath returns the on-disk path for one run's scratchpad. +// Unique per mission run instance. +func MissionScratchpadPath(missionName, missionInstanceID string) (string, error) { + root, err := ScratchpadsRoot() + if err != nil { + return "", err + } + return filepath.Join(root, missionName, missionInstanceID), nil +} + +type missionMemoryStore struct { + slots map[string]*memorySlot +} + +type memorySlot struct { + absPath string + description string +} + +// buildMemoryStore creates an aitools.MemoryStore from the mission config and +// the declared top-level memories. missionInstanceID scopes the scratchpad +// path; it must be non-empty when mission.Scratchpad is true. Returns nil if +// no slots are configured. +func buildMemoryStore(mission *config.Mission, memories []config.Memory, missionInstanceID string) (aitools.MemoryStore, error) { + store := &missionMemoryStore{ + slots: make(map[string]*memorySlot), + } + + memByName := make(map[string]*config.Memory) + for i := range memories { + memByName[memories[i].Name] = &memories[i] + } + + for _, name := range mission.Memories { + if name == config.MemorySlotName || name == config.ScratchpadSlotName { + return nil, fmt.Errorf("shared memory %q uses a reserved slot name", name) + } + mem, ok := memByName[name] + if !ok { + return nil, fmt.Errorf("shared memory %q not found", name) + } + absPath, err := SharedMemoryPath(name) + if err != nil { + return nil, fmt.Errorf("shared memory %q: resolve path: %w", name, err) + } + if err := os.MkdirAll(absPath, 0755); err != nil { + return nil, fmt.Errorf("shared memory %q: create directory: %w", name, err) + } + store.slots[name] = &memorySlot{ + absPath: absPath, + description: mem.Description, + } + } + + if mission.Memory != nil { + absPath, err := MissionMemoryPath(mission.Name) + if err != nil { + return nil, fmt.Errorf("memory: resolve path: %w", err) + } + if err := os.MkdirAll(absPath, 0755); err != nil { + return nil, fmt.Errorf("memory: create directory: %w", err) + } + store.slots[config.MemorySlotName] = &memorySlot{ + absPath: absPath, + description: mission.Memory.Description, + } + } + + if mission.Scratchpad { + if missionInstanceID == "" { + return nil, fmt.Errorf("scratchpad requires a mission instance ID") + } + absPath, err := MissionScratchpadPath(mission.Name, missionInstanceID) + if err != nil { + return nil, fmt.Errorf("scratchpad: resolve path: %w", err) + } + if err := os.MkdirAll(absPath, 0755); err != nil { + return nil, fmt.Errorf("scratchpad: create directory: %w", err) + } + if err := writeRunMetadata(absPath, mission.Name, missionInstanceID, config.ScratchpadCleanupDays); err != nil { + return nil, fmt.Errorf("scratchpad: write metadata: %w", err) + } + store.slots[config.ScratchpadSlotName] = &memorySlot{ + absPath: absPath, + // No user-supplied description — the agent prompt explains + // what the scratchpad is for. + } + } + + if len(store.slots) == 0 { + return nil, nil + } + + return store, nil +} + +// writeRunMetadata records when the scratchpad directory was created so the +// sweep can decide when to delete it. Uses O_CREATE|O_EXCL so concurrent +// starts and resumes never clobber the original timestamp — exactly one +// writer wins, others observe EEXIST and skip. +func writeRunMetadata(dir, missionName, missionID string, cleanupDays int) error { + path := filepath.Join(dir, runMetadataFile) + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err != nil { + if errors.Is(err, os.ErrExist) { + return nil + } + return err + } + defer f.Close() + meta := runMetadata{ + Mission: missionName, + MissionID: missionID, + CreatedAt: time.Now().UTC(), + CleanupDays: cleanupDays, + } + b, err := json.MarshalIndent(&meta, "", " ") + if err != nil { + return err + } + _, err = f.Write(b) + return err +} + +func (s *missionMemoryStore) ResolvePath(slotName string, relPath string) (string, error) { + if slotName == "" { + return "", fmt.Errorf("slot name is required (available: %v)", s.availableNames()) + } + + entry, ok := s.slots[slotName] + if !ok { + return "", fmt.Errorf("slot %q not found. Available: %v", slotName, s.availableNames()) + } + + cleaned := filepath.Clean(relPath) + if cleaned == "." { + return entry.absPath, nil + } + + fullPath := filepath.Join(entry.absPath, cleaned) + if !strings.HasPrefix(fullPath, entry.absPath) { + return "", fmt.Errorf("path escapes slot root") + } + + return fullPath, nil +} + +func (s *missionMemoryStore) availableNames() []string { + names := make([]string, 0, len(s.slots)) + for name := range s.slots { + names = append(names, name) + } + return names +} + +func (s *missionMemoryStore) MemoryInfos() []aitools.MemoryInfo { + infos := make([]aitools.MemoryInfo, 0, len(s.slots)) + for name, entry := range s.slots { + infos = append(infos, aitools.MemoryInfo{ + Name: name, + Description: entry.description, + }) + } + // Stable order — the result feeds the agent's system prompt + // (prompts.FormatMemoryContext). Go map iteration is randomized, so + // without this sort the prompt bytes change run-to-run and Anthropic + // prompt caching misses on otherwise-identical missions. + sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name }) + return infos +} + +// SweepExpiredScratchpads deletes any per-run scratchpad directory whose +// sidecar (.squadron-run.json) records a created_at older than its +// cleanup_days. Directories without a sidecar, or with cleanup_days == 0, +// are left alone. +// +// Walks `/scratchpads/*/*` and considers every per-run +// directory — no per-mission filtering, so callers don't need to know which +// missions exist. +func SweepExpiredScratchpads() (removed []string, err error) { + root, err := ScratchpadsRoot() + if err != nil { + return nil, err + } + entries, err := os.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + now := time.Now().UTC() + for _, missionEntry := range entries { + if !missionEntry.IsDir() { + continue + } + runBase := filepath.Join(root, missionEntry.Name()) + runEntries, err := os.ReadDir(runBase) + if err != nil { + // Skip this mission on any IO error (permission denied, transient + // stat failure, etc.) so one bad subdir doesn't halt the sweep + // for every other mission. NotExist is just the empty case. + continue + } + for _, e := range runEntries { + if !e.IsDir() { + continue + } + runDir := filepath.Join(runBase, e.Name()) + metaPath := filepath.Join(runDir, runMetadataFile) + b, err := os.ReadFile(metaPath) + if err != nil { + continue + } + var meta runMetadata + if err := json.Unmarshal(b, &meta); err != nil { + continue + } + if meta.CleanupDays <= 0 { + continue + } + deadline := meta.CreatedAt.Add(time.Duration(meta.CleanupDays) * 24 * time.Hour) + if now.Before(deadline) { + continue + } + if err := os.RemoveAll(runDir); err != nil { + continue + } + removed = append(removed, runDir) + } + } + return removed, nil +} diff --git a/mission/memory_store_test.go b/mission/memory_store_test.go new file mode 100644 index 0000000..2127b12 --- /dev/null +++ b/mission/memory_store_test.go @@ -0,0 +1,506 @@ +package mission + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "squadron/aitools" + "squadron/config" + "squadron/internal/paths" +) + +// withTempHome installs a fresh SquadronHome under a t.TempDir and registers +// cleanup so the next test starts from a clean cache. +func withTempHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + paths.ResetHome() + if err := paths.SetHome(home); err != nil { + t.Fatalf("set home: %v", err) + } + t.Cleanup(paths.ResetHome) + return home +} + +func TestBuildMemoryStore_NoSlots(t *testing.T) { + withTempHome(t) + m := &config.Mission{Name: "m"} + store, err := buildMemoryStore(m, nil, "mid-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if store != nil { + t.Fatalf("expected nil store when no slots configured, got %+v", store) + } +} + +func TestBuildMemoryStore_MissionMemory(t *testing.T) { + home := withTempHome(t) + + m := &config.Mission{ + Name: "m", + Memory: &config.MissionMemory{ + Description: "persistent", + }, + } + + store, err := buildMemoryStore(m, nil, "mid-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if store == nil { + t.Fatal("expected non-nil store") + } + + abs, err := store.ResolvePath(aitools.MemorySlotName, ".") + if err != nil { + t.Fatalf("ResolvePath: %v", err) + } + want := filepath.Join(home, "memories", "mission", "m") + if abs != want { + t.Fatalf("mission memory path: want %s, got %s", want, abs) + } + if info, err := os.Stat(abs); err != nil || !info.IsDir() { + t.Fatalf("mission memory directory not created at %s: %v", abs, err) + } + + // The mission name is NOT a valid slot key — prevents regression. + if _, err := store.ResolvePath(m.Name, "."); err == nil { + t.Fatal("expected error when resolving by mission name") + } +} + +func TestBuildMemoryStore_Scratchpad_CreatesSidecar(t *testing.T) { + home := withTempHome(t) + m := &config.Mission{ + Name: "m", + Scratchpad: true, + } + + store, err := buildMemoryStore(m, nil, "mid-abc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + abs, err := store.ResolvePath(aitools.ScratchpadSlotName, ".") + if err != nil { + t.Fatalf("ResolvePath: %v", err) + } + want := filepath.Join(home, "scratchpads", "m", "mid-abc") + if abs != want { + t.Fatalf("scratchpad path: want %s, got %s", want, abs) + } + + metaBytes, err := os.ReadFile(filepath.Join(abs, runMetadataFile)) + if err != nil { + t.Fatalf("sidecar not written: %v", err) + } + var meta runMetadata + if err := json.Unmarshal(metaBytes, &meta); err != nil { + t.Fatalf("sidecar not valid JSON: %v", err) + } + if meta.CleanupDays != config.ScratchpadCleanupDays { + t.Fatalf("CleanupDays: want %d, got %d", config.ScratchpadCleanupDays, meta.CleanupDays) + } + if meta.MissionID != "mid-abc" { + t.Fatalf("MissionID: want mid-abc, got %q", meta.MissionID) + } + if meta.CreatedAt.IsZero() { + t.Fatal("CreatedAt should be set") + } +} + +func TestBuildMemoryStore_Scratchpad_SidecarPreservedOnResume(t *testing.T) { + home := withTempHome(t) + m := &config.Mission{ + Name: "m", + Scratchpad: true, + } + + if _, err := buildMemoryStore(m, nil, "mid-1"); err != nil { + t.Fatalf("first build: %v", err) + } + runDir := filepath.Join(home, "scratchpads", "m", "mid-1") + firstMetaBytes, _ := os.ReadFile(filepath.Join(runDir, runMetadataFile)) + var first runMetadata + _ = json.Unmarshal(firstMetaBytes, &first) + + time.Sleep(10 * time.Millisecond) + + // Second build (same missionID = resume): sidecar must NOT be overwritten + if _, err := buildMemoryStore(m, nil, "mid-1"); err != nil { + t.Fatalf("second build: %v", err) + } + secondMetaBytes, _ := os.ReadFile(filepath.Join(runDir, runMetadataFile)) + var second runMetadata + _ = json.Unmarshal(secondMetaBytes, &second) + + if !first.CreatedAt.Equal(second.CreatedAt) { + t.Fatalf("CreatedAt should be preserved on resume: first=%v second=%v", first.CreatedAt, second.CreatedAt) + } +} + +func TestBuildMemoryStore_Scratchpad_RequiresMissionID(t *testing.T) { + withTempHome(t) + m := &config.Mission{ + Name: "m", + Scratchpad: true, + } + _, err := buildMemoryStore(m, nil, "") + if err == nil { + t.Fatal("expected error when missionID is empty") + } +} + +func TestBuildMemoryStore_RejectsReservedSharedMemoryNames(t *testing.T) { + withTempHome(t) + for _, reserved := range []string{"memory", "scratchpad"} { + m := &config.Mission{ + Name: "m", + Memories: []string{reserved}, + } + mems := []config.Memory{{Name: reserved, Description: "x"}} + _, err := buildMemoryStore(m, mems, "mid-1") + if err == nil { + t.Fatalf("expected error for reserved shared memory name %q", reserved) + } + } +} + +func TestMemoryInfos_IsAlphabeticallySorted(t *testing.T) { + // MemoryInfos feeds the agent's system prompt; map-iteration order would + // bust prompt caching on every run, so the order must be deterministic. + withTempHome(t) + m := &config.Mission{ + Name: "m", + Memories: []string{"zeta", "alpha", "mu"}, + Memory: &config.MissionMemory{Description: "x"}, + Scratchpad: true, + } + mems := []config.Memory{ + {Name: "zeta", Description: "z"}, + {Name: "alpha", Description: "a"}, + {Name: "mu", Description: "m"}, + } + store, err := buildMemoryStore(m, mems, "mid-1") + if err != nil { + t.Fatalf("build: %v", err) + } + + // Run a few times — random map seed should not change the output order. + want := []string{"alpha", "memory", "mu", "scratchpad", "zeta"} + for i := 0; i < 5; i++ { + infos := store.MemoryInfos() + got := make([]string, len(infos)) + for j, info := range infos { + got[j] = info.Name + } + if len(got) != len(want) { + t.Fatalf("iter %d: want %d slots, got %d: %v", i, len(want), len(got), got) + } + for j := range got { + if got[j] != want[j] { + t.Fatalf("iter %d: order: want %v, got %v", i, want, got) + } + } + } +} + +func TestBuildMemoryStore_BothMemoryAndScratchpad(t *testing.T) { + withTempHome(t) + m := &config.Mission{ + Name: "m", + Memory: &config.MissionMemory{Description: "x"}, + Scratchpad: true, + } + store, err := buildMemoryStore(m, nil, "mid-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := store.ResolvePath(aitools.MemorySlotName, "."); err != nil { + t.Fatalf("memory slot should resolve: %v", err) + } + if _, err := store.ResolvePath(aitools.ScratchpadSlotName, "."); err != nil { + t.Fatalf("scratchpad slot should resolve: %v", err) + } +} + +func TestBuildMemoryStore_SharedMemory(t *testing.T) { + home := withTempHome(t) + m := &config.Mission{ + Name: "m", + Memories: []string{"research"}, + } + mems := []config.Memory{ + {Name: "research", Description: "Research notes"}, + } + + store, err := buildMemoryStore(m, mems, "mid-1") + if err != nil { + t.Fatalf("build: %v", err) + } + + abs, err := store.ResolvePath("research", ".") + if err != nil { + t.Fatalf("resolve: %v", err) + } + want := filepath.Join(home, "memories", "shared", "research") + if abs != want { + t.Fatalf("shared memory path: want %s, got %s", want, abs) + } + if info, err := os.Stat(abs); err != nil || !info.IsDir() { + t.Fatalf("shared memory directory not created: %v", err) + } +} + +func TestResolvePath_EmptySlotNameRejected(t *testing.T) { + withTempHome(t) + m := &config.Mission{ + Name: "m", + Memory: &config.MissionMemory{Description: "x"}, + } + store, err := buildMemoryStore(m, nil, "mid-1") + if err != nil { + t.Fatalf("build: %v", err) + } + if _, err := store.ResolvePath("", "."); err == nil { + t.Fatal("expected error when slot name is empty") + } +} + +func TestResolvePath_RejectsPathEscape(t *testing.T) { + withTempHome(t) + m := &config.Mission{ + Name: "m", + Memory: &config.MissionMemory{Description: "x"}, + } + store, err := buildMemoryStore(m, nil, "mid-1") + if err != nil { + t.Fatalf("build: %v", err) + } + if _, err := store.ResolvePath("memory", "../outside"); err == nil { + t.Fatal("expected path-escape error") + } +} + +func TestResolvePath_UnknownSlot(t *testing.T) { + withTempHome(t) + m := &config.Mission{ + Name: "m", + Memory: &config.MissionMemory{Description: "x"}, + } + store, err := buildMemoryStore(m, nil, "mid-1") + if err != nil { + t.Fatalf("build: %v", err) + } + if _, err := store.ResolvePath("does_not_exist", "."); err == nil { + t.Fatal("expected error for unknown slot") + } +} + +// --- SweepExpiredScratchpads ---------------------------------------------- + +// writeScratchpad builds a fake per-run scratchpad directory under +// /scratchpads//, with a sidecar recording the given +// created_at and cleanup_days. +func writeScratchpad(t *testing.T, home, missionName, runID string, createdAt time.Time, cleanupDays int) string { + t.Helper() + dir := filepath.Join(home, "scratchpads", missionName, runID) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + meta := runMetadata{ + Mission: missionName, + MissionID: runID, + CreatedAt: createdAt, + CleanupDays: cleanupDays, + } + b, _ := json.Marshal(&meta) + if err := os.WriteFile(filepath.Join(dir, runMetadataFile), b, 0644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestSweep_DeletesExpired(t *testing.T) { + home := withTempHome(t) + expired := writeScratchpad(t, home, "m", "old", time.Now().Add(-8*24*time.Hour), config.ScratchpadCleanupDays) + + removed, err := SweepExpiredScratchpads() + if err != nil { + t.Fatalf("sweep: %v", err) + } + if len(removed) != 1 { + t.Fatalf("expected 1 removal, got %v", removed) + } + if _, err := os.Stat(expired); !os.IsNotExist(err) { + t.Fatalf("expired directory should be gone: %v", err) + } +} + +func TestSweep_KeepsUnexpired(t *testing.T) { + home := withTempHome(t) + fresh := writeScratchpad(t, home, "m", "new", time.Now().Add(-2*24*time.Hour), config.ScratchpadCleanupDays) + + removed, err := SweepExpiredScratchpads() + if err != nil { + t.Fatalf("sweep: %v", err) + } + if len(removed) != 0 { + t.Fatalf("expected nothing removed, got %v", removed) + } + if _, err := os.Stat(fresh); err != nil { + t.Fatalf("fresh directory should still exist: %v", err) + } +} + +func TestSweep_IgnoresDirectoriesWithoutSidecar(t *testing.T) { + home := withTempHome(t) + manual := filepath.Join(home, "scratchpads", "m", "hand_made") + if err := os.MkdirAll(manual, 0755); err != nil { + t.Fatal(err) + } + + removed, err := SweepExpiredScratchpads() + if err != nil { + t.Fatalf("sweep: %v", err) + } + if len(removed) != 0 { + t.Fatalf("sweep must leave un-marked directories alone, got %v", removed) + } + if _, err := os.Stat(manual); err != nil { + t.Fatalf("manually created directory should still exist: %v", err) + } +} + +func TestSweep_MissingRootIsNotAnError(t *testing.T) { + withTempHome(t) // home is set, but scratchpads/ subtree doesn't exist yet + removed, err := SweepExpiredScratchpads() + if err != nil { + t.Fatalf("sweep should tolerate missing root: %v", err) + } + if removed != nil { + t.Fatalf("expected nil removed, got %v", removed) + } +} + +func TestSweep_WalksAcrossMissions(t *testing.T) { + home := withTempHome(t) + a := writeScratchpad(t, home, "alpha", "run1", time.Now().Add(-10*24*time.Hour), 2) + b := writeScratchpad(t, home, "beta", "run1", time.Now().Add(-10*24*time.Hour), 2) + keep := writeScratchpad(t, home, "alpha", "run2", time.Now().Add(-1*24*time.Hour), 2) + + removed, err := SweepExpiredScratchpads() + if err != nil { + t.Fatalf("sweep: %v", err) + } + if len(removed) != 2 { + t.Fatalf("expected 2 removals across missions, got %v", removed) + } + for _, gone := range []string{a, b} { + if _, err := os.Stat(gone); !os.IsNotExist(err) { + t.Fatalf("expected %s gone: %v", gone, err) + } + } + if _, err := os.Stat(keep); err != nil { + t.Fatalf("unexpired directory should remain: %v", err) + } +} + +func TestSweep_OneBadSubdirDoesNotHaltOthers(t *testing.T) { + home := withTempHome(t) + expired := writeScratchpad(t, home, "alpha", "old", time.Now().Add(-30*24*time.Hour), config.ScratchpadCleanupDays) + + // Create a sibling mission dir whose run subdir is unreadable. Use chmod + // 000 on the parent so os.ReadDir on `/broken/` fails with EACCES. + broken := filepath.Join(home, "scratchpads", "broken") + if err := os.MkdirAll(broken, 0755); err != nil { + t.Fatal(err) + } + // Put a file under it that ReadDir-on-a-file would error on. + if err := os.WriteFile(filepath.Join(broken, "not_a_dir"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(broken, 0000); err != nil { + t.Skipf("can't chmod 0000 in this environment: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(broken, 0755) }) + + removed, err := SweepExpiredScratchpads() + if err != nil { + t.Fatalf("sweep should not error when a sibling mission's dir is unreadable: %v", err) + } + // The expired alpha dir must still get cleaned up. + if _, statErr := os.Stat(expired); !os.IsNotExist(statErr) { + t.Fatalf("expired alpha scratchpad should be gone (got stat err %v, removed=%v)", statErr, removed) + } +} + +// TestSweepThenRebuildRoundTrip mirrors the real flow: an old scratchpad +// exists with a sidecar backdated past its cleanup window, the sweep deletes +// it, then a new buildMemoryStore (different missionID) creates a fresh +// scratchpad with a current sidecar — and the old one is gone. +func TestSweepThenRebuildRoundTrip(t *testing.T) { + home := withTempHome(t) + stale := writeScratchpad(t, home, "demo", "old-run", time.Now().Add(-30*24*time.Hour), config.ScratchpadCleanupDays) + + if _, err := SweepExpiredScratchpads(); err != nil { + t.Fatalf("sweep: %v", err) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Fatalf("stale scratchpad should have been deleted: %v", err) + } + + m := &config.Mission{ + Name: "demo", + Scratchpad: true, + } + store, err := buildMemoryStore(m, nil, "new-run") + if err != nil { + t.Fatalf("build: %v", err) + } + fresh, err := store.ResolvePath(aitools.ScratchpadSlotName, ".") + if err != nil { + t.Fatalf("resolve: %v", err) + } + want := filepath.Join(home, "scratchpads", "demo", "new-run") + if fresh != want { + t.Fatalf("fresh path: want %s, got %s", want, fresh) + } + + metaBytes, err := os.ReadFile(filepath.Join(fresh, runMetadataFile)) + if err != nil { + t.Fatalf("fresh sidecar: %v", err) + } + var meta runMetadata + if err := json.Unmarshal(metaBytes, &meta); err != nil { + t.Fatalf("decode sidecar: %v", err) + } + if time.Since(meta.CreatedAt) > time.Minute { + t.Fatalf("fresh sidecar CreatedAt should be ~now, got %v", meta.CreatedAt) + } +} + +func TestWriteRunMetadata_PreservesOnReentry(t *testing.T) { + // Direct test of the O_CREATE|O_EXCL sidecar write: calling twice with + // different cleanupDays must not overwrite the original. + dir := t.TempDir() + if err := writeRunMetadata(dir, "m", "id-1", 7); err != nil { + t.Fatalf("first write: %v", err) + } + first, _ := os.ReadFile(filepath.Join(dir, runMetadataFile)) + + time.Sleep(10 * time.Millisecond) + + if err := writeRunMetadata(dir, "m", "id-1", 99); err != nil { + t.Fatalf("second write: %v", err) + } + second, _ := os.ReadFile(filepath.Join(dir, runMetadataFile)) + + if string(first) != string(second) { + t.Fatalf("sidecar must not be rewritten:\nfirst: %s\nsecond: %s", first, second) + } +} diff --git a/mission/runner.go b/mission/runner.go index 3b820e1..5fccce2 100644 --- a/mission/runner.go +++ b/mission/runner.go @@ -63,8 +63,8 @@ type Runner struct { resumeMissionID string // Non-empty when resuming a prior mission rawInputs map[string]string // Raw input strings for persistence/resume - // Folder access for mission - folderStore aitools.FolderStore + // Memory access for mission + memoryStore aitools.MemoryStore // Conditional routing state routerPending []routerActivation // queue of tasks activated by routers @@ -288,9 +288,10 @@ func NewRunner(cfg *config.Config, configPath string, missionName string, inputs r.secretInfos = secretInfos } - // Folder store is built later in Run() once missionID is known — the per-run - // folder path depends on it. Shared + persistent folders only would let us - // build here, but deferring is simpler than branching on mission.RunFolder. + // Memory store is built later in Run() once missionID is known — the + // scratchpad path depends on it. Shared + mission memory alone would let + // us build here, but deferring is simpler than branching on + // mission.Scratchpad. return r, nil } @@ -567,18 +568,17 @@ func (r *Runner) Run(ctx context.Context, streamer streamers.MissionHandler) err r.resolvedDatasets = nil } - // Folder store depends on missionID (for run_folder path), so build it - // here rather than in NewRunner. Sweep expired run folders async — the - // result doesn't affect this run's correctness, only disk usage. - if r.mission.RunFolder != nil { - base := ResolvedRunFolderBase(r.mission.RunFolder) - go func() { _, _ = SweepExpiredRunFolders(base) }() + // Memory store depends on missionID (for the scratchpad path), so build + // it here rather than in NewRunner. Sweep expired scratchpads async — + // the result doesn't affect this run's correctness, only disk usage. + if r.mission.Scratchpad { + go func() { _, _ = SweepExpiredScratchpads() }() } - folderStore, err := buildFolderStore(r.mission, r.cfg.SharedFolders, missionID) + memoryStore, err := buildMemoryStore(r.mission, r.cfg.Memories, missionID) if err != nil { - return fmt.Errorf("mission '%s': build folder store: %w", r.mission.Name, err) + return fmt.Errorf("mission '%s': build memory store: %w", r.mission.Name, err) } - r.folderStore = folderStore + r.memoryStore = memoryStore streamer.MissionStarted(r.mission.Name, missionID, len(r.mission.Tasks)) @@ -1001,7 +1001,7 @@ func (r *Runner) resaturateCommanders(ctx context.Context, completedTaskNames [] SecretInfos: r.secretInfos, SecretValues: r.secretValues, IsIteration: isIterated, - FolderStore: r.folderStore, + MemoryStore: r.memoryStore, Compaction: r.commanderCompaction(), PruneOn: r.commanderPruneOn(), PruneTo: r.commanderPruneTo(), @@ -1055,7 +1055,7 @@ func (r *Runner) resaturateCommanders(ctx context.Context, completedTaskNames [] SecretInfos: r.secretInfos, SecretValues: r.secretValues, DatasetStore: r, - FolderStore: r.folderStore, + MemoryStore: r.memoryStore, HumanBridge: r.humanBridge, }, agentLLMMsgs) if err != nil { @@ -1139,7 +1139,7 @@ func (r *Runner) restoreAgentSessions(ctx context.Context, sup *agent.Commander, SecretInfos: r.secretInfos, SecretValues: r.secretValues, DatasetStore: r, - FolderStore: r.folderStore, + MemoryStore: r.memoryStore, HumanBridge: r.humanBridge, }, llmMsgs) if err != nil { @@ -1262,7 +1262,7 @@ func (r *Runner) runTask(ctx context.Context, task config.Task, missionID string SecretValues: r.secretValues, IsIteration: false, DebugFile: debugFile, - FolderStore: r.folderStore, + MemoryStore: r.memoryStore, Compaction: r.commanderCompaction(), PruneOn: r.commanderPruneOn(), PruneTo: r.commanderPruneTo(), @@ -2044,7 +2044,7 @@ Continue until dataset_next returns "exhausted".`, len(items), taskObjective) IsParallel: false, DebugFile: debugFile, SequentialDataset: items, - FolderStore: r.folderStore, + MemoryStore: r.memoryStore, Compaction: r.commanderCompaction(), PruneOn: r.commanderPruneOn(), PruneTo: r.commanderPruneTo(), @@ -2498,7 +2498,7 @@ Continue until dataset_next returns "exhausted".`, len(remainingItems), taskObje IsParallel: false, DebugFile: debugFile, SequentialDataset: remainingItems, - FolderStore: r.folderStore, + MemoryStore: r.memoryStore, Compaction: r.commanderCompaction(), PruneOn: r.commanderPruneOn(), PruneTo: r.commanderPruneTo(), @@ -2741,7 +2741,7 @@ func (r *Runner) runSingleIteration(ctx context.Context, task config.Task, index IsIteration: true, IsParallel: task.Iterator.Parallel, DebugFile: debugFile, - FolderStore: r.folderStore, + MemoryStore: r.memoryStore, Compaction: r.commanderCompaction(), PruneOn: r.commanderPruneOn(), PruneTo: r.commanderPruneTo(), diff --git a/wsbridge/convert.go b/wsbridge/convert.go index 4974798..8f2f81c 100644 --- a/wsbridge/convert.go +++ b/wsbridge/convert.go @@ -252,43 +252,10 @@ func ConfigToInstanceConfig(cfg *config.Config) protocol.InstanceConfig { }) } - // Build shared folder → missions map - sharedMissions := map[string][]string{} - for _, m := range cfg.Missions { - for _, folderName := range m.Folders { - sharedMissions[folderName] = append(sharedMissions[folderName], m.Name) - } - } - - for _, fb := range cfg.SharedFolders { - label := fb.Label - if label == "" { - label = fb.Name - } - ic.SharedFolders = append(ic.SharedFolders, protocol.SharedFolderInfo{ - Name: fb.Name, - Path: fb.Path, - Label: label, - Description: fb.Description, - Editable: fb.Editable, - IsShared: true, - Missions: sharedMissions[fb.Name], - }) - } - - // Add dedicated mission folders - for _, m := range cfg.Missions { - if m.Folder != nil { - ic.SharedFolders = append(ic.SharedFolders, protocol.SharedFolderInfo{ - Name: m.Name, - Path: m.Folder.Path, - Label: m.Name, - Description: m.Folder.Description, - Editable: true, - IsShared: false, - Missions: []string{m.Name}, - }) - } + // Collect shared memories + per-mission persistent memories. Paths are + // derived from SquadronHome so we don't need user-supplied paths. + if mems, err := collectMemoryInfos(cfg); err == nil { + ic.SharedFolders = append(ic.SharedFolders, mems...) } return ic diff --git a/wsbridge/shared_folder.go b/wsbridge/memory_browser.go similarity index 67% rename from wsbridge/shared_folder.go rename to wsbridge/memory_browser.go index c585d65..95241b7 100644 --- a/wsbridge/shared_folder.go +++ b/wsbridge/memory_browser.go @@ -13,42 +13,56 @@ import ( "github.com/mlund01/squadron-wire/protocol" "squadron/config" + "squadron/mission" ) -// resolveSharedFolderPath looks up a folder by name (shared folders + mission folders) +// resolvedMemory describes one materialized memory slot for the UI: a +// human-friendly name and the absolute path it lives at. Every slot is +// writable — there is no read-only mode. +type resolvedMemory struct { + name string + path string +} + +// resolveMemoryPath looks up a memory slot by name (top-level shared +// memories first, then per-mission persistent memories keyed by mission name) // and safely resolves a relative path within it. -func (c *Client) resolveSharedFolderPath(folderName, relPath string) (*config.SharedFolder, string, error) { +func (c *Client) resolveMemoryPath(memoryName, relPath string) (*resolvedMemory, string, error) { cfg := c.getConfig() - // Check shared folders first - for i := range cfg.SharedFolders { - if cfg.SharedFolders[i].Name == folderName { - folder := &cfg.SharedFolders[i] - path, err := c.resolveSafePath(folder.Path, relPath) - return folder, path, err + // Check shared memories first. + for i := range cfg.Memories { + if cfg.Memories[i].Name == memoryName { + absPath, err := mission.SharedMemoryPath(memoryName) + if err != nil { + return nil, "", fmt.Errorf("resolve shared memory %q: %w", memoryName, err) + } + rm := &resolvedMemory{name: cfg.Memories[i].Name, path: absPath} + path, err := c.resolveSafePath(absPath, relPath) + return rm, path, err } } - // Check mission dedicated folders (keyed by mission name) + // Check the mission's persistent memory (keyed by mission name). for _, m := range cfg.Missions { - if m.Folder != nil && m.Name == folderName { - sf := &config.SharedFolder{ - Name: m.Name, - Path: m.Folder.Path, - Editable: true, + if m.Memory != nil && m.Name == memoryName { + absPath, err := mission.MissionMemoryPath(m.Name) + if err != nil { + return nil, "", fmt.Errorf("resolve mission memory for %q: %w", m.Name, err) } - path, err := c.resolveSafePath(m.Folder.Path, relPath) - return sf, path, err + rm := &resolvedMemory{name: m.Name, path: absPath} + path, err := c.resolveSafePath(absPath, relPath) + return rm, path, err } } - return nil, "", fmt.Errorf("folder %q not found", folderName) + return nil, "", fmt.Errorf("memory %q not found", memoryName) } func (c *Client) resolveSafePath(basePath, relPath string) (string, error) { rootPath, err := filepath.Abs(basePath) if err != nil { - return "", fmt.Errorf("invalid folder path: %w", err) + return "", fmt.Errorf("invalid memory path: %w", err) } if relPath == "" { @@ -62,7 +76,7 @@ func (c *Client) resolveSafePath(basePath, relPath string) (string, error) { fullPath := filepath.Join(rootPath, cleaned) if !strings.HasPrefix(fullPath, rootPath) { - return "", fmt.Errorf("path escapes folder root") + return "", fmt.Errorf("path escapes memory root") } return fullPath, nil @@ -71,50 +85,66 @@ func (c *Client) resolveSafePath(basePath, relPath string) (string, error) { func (c *Client) handleListSharedFolders(env *protocol.Envelope) (*protocol.Envelope, error) { cfg := c.getConfig() - // Build shared folder → missions map + folders, err := collectMemoryInfos(cfg) + if err != nil { + return nil, err + } + + return protocol.NewResponse(env.RequestID, protocol.TypeListSharedFoldersResult, + &protocol.ListSharedFoldersResultPayload{Folders: folders}) +} + +// collectMemoryInfos walks the config and turns every memory slot +// (shared + per-mission persistent) into a protocol.SharedFolderInfo. Used +// by both the standalone list_shared_folders RPC and the bulk +// instance-info payload in convert.go. +func collectMemoryInfos(cfg *config.Config) ([]protocol.SharedFolderInfo, error) { + // Build shared memory → missions map sharedMissions := map[string][]string{} for _, m := range cfg.Missions { - for _, folderName := range m.Folders { - sharedMissions[folderName] = append(sharedMissions[folderName], m.Name) + for _, name := range m.Memories { + sharedMissions[name] = append(sharedMissions[name], m.Name) } } var folders []protocol.SharedFolderInfo - // Add shared folders - for _, fb := range cfg.SharedFolders { - label := fb.Label - if label == "" { - label = fb.Name + for _, mem := range cfg.Memories { + path, err := mission.SharedMemoryPath(mem.Name) + if err != nil { + return nil, fmt.Errorf("shared memory %q: %w", mem.Name, err) } folders = append(folders, protocol.SharedFolderInfo{ - Name: fb.Name, - Path: fb.Path, - Label: label, - Description: fb.Description, - Editable: fb.Editable, + Name: mem.Name, + Path: path, + Label: mem.Name, + Description: mem.Description, + Editable: true, // every memory is writable IsShared: true, - Missions: sharedMissions[fb.Name], + Missions: sharedMissions[mem.Name], }) } - // Add dedicated mission folders for _, m := range cfg.Missions { - if m.Folder != nil { - folders = append(folders, protocol.SharedFolderInfo{ - Name: m.Name, - Path: m.Folder.Path, - Label: m.Name, - Description: m.Folder.Description, - Editable: true, - IsShared: false, - Missions: []string{m.Name}, - }) + if m.Memory == nil { + continue + } + path, err := mission.MissionMemoryPath(m.Name) + if err != nil { + return nil, fmt.Errorf("mission memory for %q: %w", m.Name, err) } + folders = append(folders, protocol.SharedFolderInfo{ + Name: m.Name, + Path: path, + Label: m.Name, + Description: m.Memory.Description, + Editable: true, + IsShared: false, + Missions: []string{m.Name}, + }) } - return protocol.NewResponse(env.RequestID, protocol.TypeListSharedFoldersResult, - &protocol.ListSharedFoldersResultPayload{Folders: folders}) + return folders, nil } func (c *Client) handleBrowseDirectory(env *protocol.Envelope) (*protocol.Envelope, error) { @@ -123,7 +153,7 @@ func (c *Client) handleBrowseDirectory(env *protocol.Envelope) (*protocol.Envelo return nil, fmt.Errorf("decode browse_directory: %w", err) } - _, fullPath, err := c.resolveSharedFolderPath(payload.BrowserName, payload.RelPath) + _, fullPath, err := c.resolveMemoryPath(payload.BrowserName, payload.RelPath) if err != nil { return nil, err } @@ -164,7 +194,7 @@ func (c *Client) handleReadBrowseFile(env *protocol.Envelope) (*protocol.Envelop return nil, fmt.Errorf("decode read_browse_file: %w", err) } - _, fullPath, err := c.resolveSharedFolderPath(payload.BrowserName, payload.RelPath) + _, fullPath, err := c.resolveMemoryPath(payload.BrowserName, payload.RelPath) if err != nil { return nil, err } @@ -220,17 +250,12 @@ func (c *Client) handleWriteBrowseFile(env *protocol.Envelope) (*protocol.Envelo return nil, fmt.Errorf("decode write_browse_file: %w", err) } - folder, fullPath, err := c.resolveSharedFolderPath(payload.BrowserName, payload.RelPath) + _, fullPath, err := c.resolveMemoryPath(payload.BrowserName, payload.RelPath) if err != nil { return protocol.NewResponse(env.RequestID, protocol.TypeWriteBrowseFileResult, &protocol.WriteBrowseFileResultPayload{Success: false, Error: err.Error()}) } - if !folder.Editable { - return protocol.NewResponse(env.RequestID, protocol.TypeWriteBrowseFileResult, - &protocol.WriteBrowseFileResultPayload{Success: false, Error: "shared folder is read-only"}) - } - if err := os.WriteFile(fullPath, []byte(payload.Content), 0644); err != nil { return protocol.NewResponse(env.RequestID, protocol.TypeWriteBrowseFileResult, &protocol.WriteBrowseFileResultPayload{Success: false, Error: err.Error()}) @@ -246,7 +271,7 @@ func (c *Client) handleDownloadFile(env *protocol.Envelope) (*protocol.Envelope, return nil, fmt.Errorf("decode download_file: %w", err) } - _, fullPath, err := c.resolveSharedFolderPath(payload.BrowserName, payload.RelPath) + _, fullPath, err := c.resolveMemoryPath(payload.BrowserName, payload.RelPath) if err != nil { return nil, err } @@ -272,7 +297,7 @@ func (c *Client) handleDownloadDirectory(env *protocol.Envelope) (*protocol.Enve return nil, fmt.Errorf("decode download_directory: %w", err) } - _, fullPath, err := c.resolveSharedFolderPath(payload.BrowserName, payload.RelPath) + _, fullPath, err := c.resolveMemoryPath(payload.BrowserName, payload.RelPath) if err != nil { return nil, err }