@@ -26,6 +26,7 @@ import (
2626 "github.com/BackendStack21/odek/internal/danger"
2727 "github.com/BackendStack21/odek/internal/embedding"
2828 "github.com/BackendStack21/odek/internal/guard"
29+ "github.com/BackendStack21/odek/internal/maintenance"
2930 "github.com/BackendStack21/odek/internal/mcpclient"
3031 "github.com/BackendStack21/odek/internal/memory"
3132 "github.com/BackendStack21/odek/internal/memory/extended"
@@ -101,11 +102,11 @@ type CLIFlags struct {
101102 MemoryExtendedFollowUpAnticipationEnabled * bool // nil = not set
102103
103104 // Guard subsystem CLI overrides.
104- GuardProvider string // "" = not set
105- GuardURL string // "" = not set
106- GuardBatchURL string // "" = not set
107- GuardLongURL string // "" = not set
108- GuardSocketPath string // "" = not set
105+ GuardProvider string // "" = not set
106+ GuardURL string // "" = not set
107+ GuardBatchURL string // "" = not set
108+ GuardLongURL string // "" = not set
109+ GuardSocketPath string // "" = not set
109110 GuardThreshold float64 // 0 = not set
110111 GuardTimeoutSeconds int // 0 = not set
111112 GuardFallbackToLocal * bool // nil = not set
@@ -202,6 +203,21 @@ type ToolConfig struct {
202203 Disabled []string `json:"disabled,omitempty"`
203204}
204205
206+ // MaintenanceConfig is the file-level "maintenance" section. Pointer fields
207+ // distinguish "not set" (inherit the default) from an explicit 0, which is
208+ // meaningful for the retention knobs (0 = keep forever / disable).
209+ // Operator-controlled: rejected from project-level ./odek.json because it
210+ // governs DELETION of user data.
211+ type MaintenanceConfig struct {
212+ Enabled * bool `json:"enabled,omitempty"`
213+ IntervalMinutes * int `json:"interval_minutes,omitempty"`
214+ SessionsMaxAgeDays * int `json:"sessions_max_age_days,omitempty"`
215+ AuditMaxAgeDays * int `json:"audit_max_age_days,omitempty"`
216+ LogMaxMB * int64 `json:"log_max_mb,omitempty"`
217+ PlansMaxAgeDays * int `json:"plans_max_age_days,omitempty"`
218+ SkillsSkipMaxAgeDays * int `json:"skills_skip_max_age_days,omitempty"`
219+ }
220+
205221// ToolsConfig is the "tools" section of odek.json. It is intentionally a
206222// pointer in FileConfig so "not set" can be distinguished from an explicit
207223// empty list.
@@ -303,6 +319,11 @@ type FileConfig struct {
303319 // Schedules configures the native in-process task scheduler.
304320 Schedules * SchedulesConfig `json:"schedules,omitempty"`
305321
322+ // Maintenance configures the storage-maintenance janitor (retention and
323+ // deletion of sessions, audit records, plans, logs, skip-list entries).
324+ // Operator-controlled: rejected from project-level ./odek.json.
325+ Maintenance * MaintenanceConfig `json:"maintenance,omitempty"`
326+
306327 // Tools controls which tools are exposed to the LLM.
307328 // Project-level ./odek.json may only disable tools, not enable them.
308329 Tools * ToolsConfig `json:"tools,omitempty"`
@@ -464,6 +485,11 @@ type ResolvedConfig struct {
464485 // Default: enabled=true, max_concurrent=2, timezone="UTC", catchup=false.
465486 Schedules ScheduleConfig
466487
488+ // Maintenance is the resolved storage-maintenance config.
489+ // Default: maintenance.DefaultConfig() (enabled, 60min tick, sessions 30d,
490+ // audit 14d, logs 50MB, plans 30d, skip-list 90d).
491+ Maintenance maintenance.Config
492+
467493 // Tools is the resolved tool-list configuration.
468494 // Empty Enabled/Disabled means "no restriction" for that direction.
469495 Tools ToolConfig
@@ -666,6 +692,35 @@ func envInt(key string) int {
666692 return n
667693}
668694
695+ // envIntPtr parses a ODEK_* env var as an integer. Returns nil if unset or
696+ // unparseable, so an explicit 0 (meaningful for retention knobs) stays
697+ // distinguishable from "not set".
698+ func envIntPtr (key string ) * int {
699+ v := os .Getenv ("ODEK_" + key )
700+ if v == "" {
701+ return nil
702+ }
703+ n , err := strconv .Atoi (v )
704+ if err != nil {
705+ return nil
706+ }
707+ return & n
708+ }
709+
710+ // envInt64Ptr parses a ODEK_* env var as an int64. Returns nil if unset or
711+ // unparseable, like envIntPtr.
712+ func envInt64Ptr (key string ) * int64 {
713+ v := os .Getenv ("ODEK_" + key )
714+ if v == "" {
715+ return nil
716+ }
717+ n , err := strconv .ParseInt (v , 10 , 64 )
718+ if err != nil {
719+ return nil
720+ }
721+ return & n
722+ }
723+
669724// envFloat parses a ODEK_* env var as a float64. Returns 0 if unset/unparseable.
670725func envFloat (key string ) float64 {
671726 v := os .Getenv ("ODEK_" + key )
@@ -737,6 +792,46 @@ func ensureGuard(cfg *guard.Config) *guard.Config {
737792 return cfg
738793}
739794
795+ // ensureMaintenance returns a non-nil *MaintenanceConfig, allocating one if needed.
796+ func ensureMaintenance (cfg * MaintenanceConfig ) * MaintenanceConfig {
797+ if cfg == nil {
798+ return & MaintenanceConfig {}
799+ }
800+ return cfg
801+ }
802+
803+ // resolveMaintenance merges the file-level maintenance section over the
804+ // package defaults. Unset (nil) fields inherit the default; explicit 0 keeps
805+ // its meaning (keep forever / disable).
806+ func resolveMaintenance (cfg * MaintenanceConfig ) maintenance.Config {
807+ def := maintenance .DefaultConfig ()
808+ if cfg == nil {
809+ return def
810+ }
811+ if cfg .Enabled != nil {
812+ def .Enabled = * cfg .Enabled
813+ }
814+ if cfg .IntervalMinutes != nil {
815+ def .IntervalMinutes = * cfg .IntervalMinutes
816+ }
817+ if cfg .SessionsMaxAgeDays != nil {
818+ def .SessionsMaxAgeDays = * cfg .SessionsMaxAgeDays
819+ }
820+ if cfg .AuditMaxAgeDays != nil {
821+ def .AuditMaxAgeDays = * cfg .AuditMaxAgeDays
822+ }
823+ if cfg .LogMaxMB != nil {
824+ def .LogMaxMB = * cfg .LogMaxMB
825+ }
826+ if cfg .PlansMaxAgeDays != nil {
827+ def .PlansMaxAgeDays = * cfg .PlansMaxAgeDays
828+ }
829+ if cfg .SkillsSkipMaxAgeDays != nil {
830+ def .SkillsSkipMaxAgeDays = * cfg .SkillsSkipMaxAgeDays
831+ }
832+ return def
833+ }
834+
740835// envScheduleDangerousConfig parses ODEK_SCHEDULES_DANGEROUS_* env vars into a
741836// DangerousConfig. Returns nil if none are set.
742837func envScheduleDangerousConfig (prefix string ) * danger.DangerousConfig {
@@ -857,6 +952,12 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
857952 fmt .Fprintf (os .Stderr , "odek: WARNING: ignoring memory from project config (%s); set it via ~/.odek/config.json\n " , ProjectConfigPath ())
858953 project .Memory = nil
859954 }
955+ // The maintenance section governs DELETION of user data (sessions, audit
956+ // records, plans, logs). A malicious repo must not be able to set it.
957+ if project .Maintenance != nil {
958+ fmt .Fprintf (os .Stderr , "odek: WARNING: ignoring maintenance from project config (%s); set it via ~/.odek/config.json or ODEK_MAINTENANCE_*\n " , ProjectConfigPath ())
959+ project .Maintenance = nil
960+ }
860961 if project .Guard != nil {
861962 fmt .Fprintf (os .Stderr , "odek: WARNING: ignoring guard from project config (%s); set it via ~/.odek/config.json, ODEK_GUARD_*, or the CLI\n " , ProjectConfigPath ())
862963 project .Guard = nil
@@ -1245,6 +1346,38 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
12451346 }
12461347 }
12471348
1349+ // Maintenance env overrides (ODEK_MAINTENANCE_*). Explicit 0 is meaningful
1350+ // for the retention knobs (0 = keep forever / disable), so they parse via
1351+ // the pointer helpers rather than envInt.
1352+ if v := envBool ("MAINTENANCE_ENABLED" ); v != nil {
1353+ cfg .Maintenance = ensureMaintenance (cfg .Maintenance )
1354+ cfg .Maintenance .Enabled = v
1355+ }
1356+ if v := envIntPtr ("MAINTENANCE_INTERVAL_MINUTES" ); v != nil {
1357+ cfg .Maintenance = ensureMaintenance (cfg .Maintenance )
1358+ cfg .Maintenance .IntervalMinutes = v
1359+ }
1360+ if v := envIntPtr ("MAINTENANCE_SESSIONS_MAX_AGE_DAYS" ); v != nil {
1361+ cfg .Maintenance = ensureMaintenance (cfg .Maintenance )
1362+ cfg .Maintenance .SessionsMaxAgeDays = v
1363+ }
1364+ if v := envIntPtr ("MAINTENANCE_AUDIT_MAX_AGE_DAYS" ); v != nil {
1365+ cfg .Maintenance = ensureMaintenance (cfg .Maintenance )
1366+ cfg .Maintenance .AuditMaxAgeDays = v
1367+ }
1368+ if v := envInt64Ptr ("MAINTENANCE_LOG_MAX_MB" ); v != nil {
1369+ cfg .Maintenance = ensureMaintenance (cfg .Maintenance )
1370+ cfg .Maintenance .LogMaxMB = v
1371+ }
1372+ if v := envIntPtr ("MAINTENANCE_PLANS_MAX_AGE_DAYS" ); v != nil {
1373+ cfg .Maintenance = ensureMaintenance (cfg .Maintenance )
1374+ cfg .Maintenance .PlansMaxAgeDays = v
1375+ }
1376+ if v := envIntPtr ("MAINTENANCE_SKILLS_SKIP_MAX_AGE_DAYS" ); v != nil {
1377+ cfg .Maintenance = ensureMaintenance (cfg .Maintenance )
1378+ cfg .Maintenance .SkillsSkipMaxAgeDays = v
1379+ }
1380+
12481381 // Telegram env overrides: merge env vars on top of file config.
12491382 baseTelegram := telegram .DefaultConfig ()
12501383 if cfg .Telegram != nil {
@@ -1489,29 +1622,30 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
14891622 MaxIter : cfg .MaxIter ,
14901623 System : cfg .System ,
14911624
1492- SandboxImage : cfg .SandboxImage , // empty = resolve at call site (Dockerfile.odek or alpine:latest)
1493- SandboxNetwork : ifZero (cfg .SandboxNetwork , DefaultSandboxNetwork ),
1494- SandboxMemory : cfg .SandboxMemory ,
1495- SandboxCPUs : cfg .SandboxCPUs ,
1496- SandboxUser : cfg .SandboxUser ,
1497- SandboxEnv : cfg .SandboxEnv ,
1498- SandboxVolumes : cfg .SandboxVolumes ,
1499- Skills : resolveSkills (cfg .Skills ),
1500- Dangerous : resolveDangerous (cfg .Dangerous , true ),
1501- Memory : resolveMemory (cfg .Memory ),
1502- Guard : resolveGuard (cfg .Guard ),
1503- Embedding : cfg .Embedding ,
1625+ SandboxImage : cfg .SandboxImage , // empty = resolve at call site (Dockerfile.odek or alpine:latest)
1626+ SandboxNetwork : ifZero (cfg .SandboxNetwork , DefaultSandboxNetwork ),
1627+ SandboxMemory : cfg .SandboxMemory ,
1628+ SandboxCPUs : cfg .SandboxCPUs ,
1629+ SandboxUser : cfg .SandboxUser ,
1630+ SandboxEnv : cfg .SandboxEnv ,
1631+ SandboxVolumes : cfg .SandboxVolumes ,
1632+ Skills : resolveSkills (cfg .Skills ),
1633+ Dangerous : resolveDangerous (cfg .Dangerous , true ),
1634+ Memory : resolveMemory (cfg .Memory ),
1635+ Guard : resolveGuard (cfg .Guard ),
1636+ Embedding : cfg .Embedding ,
15041637 MCPServers : cfg .MCPServers ,
15051638 ProjectMCPServerNames : projectMCPNames ,
15061639 ProjectSandboxOverride : projectSandboxOverride ,
15071640 Telegram : resolveTelegram (cfg .Telegram ),
1508- Transcription : resolveTranscription (cfg .Transcription ),
1509- Vision : resolveVision (cfg .Vision ),
1510- WebSearch : resolveWebSearch (cfg .WebSearch ),
1511- Schedules : resolveSchedules (cfg .Schedules ),
1512- Tools : resolveTools (cfg .Tools ),
1513- InteractionMode : ifZero (cfg .InteractionMode , "engaging" ),
1514- ToolProgress : ifZero (cfg .ToolProgress , "all" ),
1641+ Transcription : resolveTranscription (cfg .Transcription ),
1642+ Vision : resolveVision (cfg .Vision ),
1643+ WebSearch : resolveWebSearch (cfg .WebSearch ),
1644+ Schedules : resolveSchedules (cfg .Schedules ),
1645+ Maintenance : resolveMaintenance (cfg .Maintenance ),
1646+ Tools : resolveTools (cfg .Tools ),
1647+ InteractionMode : ifZero (cfg .InteractionMode , "engaging" ),
1648+ ToolProgress : ifZero (cfg .ToolProgress , "all" ),
15151649 }
15161650
15171651 // Every subsystem inherits the shared top-level embedding default unless it
@@ -2114,6 +2248,9 @@ func overlayFile(base, override FileConfig) FileConfig {
21142248 if override .Memory != nil {
21152249 base .Memory = override .Memory
21162250 }
2251+ if override .Maintenance != nil {
2252+ base .Maintenance = override .Maintenance
2253+ }
21172254 if override .Guard != nil {
21182255 base .Guard = override .Guard
21192256 }
0 commit comments