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
}