Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
122 commits
Select commit Hold shift + click to select a range
1e4fb9c
fix(loop): args_summary argv0 never reports subshell or substitution …
jkyberneees Oct 14, 2026
450004e
fix(loop): rune-aligned cuts in progress summary, tool-output compres…
jkyberneees Oct 14, 2026
632494f
fix(loop): drop superseded memory blocks on resume and cap Anthropic …
jkyberneees Oct 14, 2026
d9c09f0
fix(llmclient): deliver corrective system messages after a draft as a…
jkyberneees Oct 14, 2026
ca045e9
fix(loop): argv0 keeps the program name before an unquoted operator
jkyberneees Oct 14, 2026
2d0497e
fix(loop): argv0 is empty when an assignment is cut by an unquoted op…
jkyberneees Oct 14, 2026
15d4e51
fix(telegram): /stop and /new release a pending clarify or approval p…
jkyberneees Oct 14, 2026
103d7c9
fix(telegram): scope media paths case-insensitively
jkyberneees Oct 14, 2026
fb3880a
fix(telegram): /resume drops the previous conversation's read ledger
jkyberneees Oct 14, 2026
66f7009
fix(telegram): /new and /resume reset off the update loop
jkyberneees Oct 14, 2026
fa818f8
fix(telegram): cancelled approval prompts lose their buttons
jkyberneees Oct 14, 2026
a90fa7c
fix(telegram): fold media path case only on case-insensitive platforms
jkyberneees Oct 14, 2026
07ecbe8
fix(serve): bound REST run event tail by bytes and clip recorded fram…
jkyberneees Oct 14, 2026
780feb2
fix(serve): refuse unknown session ids on POST /api/prompt through th…
jkyberneees Oct 14, 2026
2903af1
fix(serve): clamp approval_timeout_seconds before converting to a Dur…
jkyberneees Oct 14, 2026
bbd284a
fix(serve): count every started prompt that fails as prompts_failed
jkyberneees Oct 14, 2026
7d7f765
fix(serve): delete the freshly created upload session when an upload …
jkyberneees Oct 14, 2026
0b63f9c
fix(serve): heartbeat pong reports the connection's current model
jkyberneees Oct 14, 2026
9203a44
fix(serve): reject never-matching cron in /api/schedules/preview
jkyberneees Oct 14, 2026
9ac25aa
fix(serve): accept bracketed IPv6 loopback Host without a port
jkyberneees Oct 14, 2026
06e64a0
docs(serve): document run event tail byte bounds and unknown-session …
jkyberneees Oct 14, 2026
ca4f72a
fix(serve): clip run event strings at rune boundaries and add clip/pr…
jkyberneees Oct 14, 2026
d96b0a9
fix(subagent): grandchildren inherit the spawning sub-agent's risk cap
jkyberneees Oct 14, 2026
2c3c1d4
fix(audit): divergence heuristic recognises bare IP endpoints
jkyberneees Oct 14, 2026
d8a949b
fix(mcpclient): drop the Bearer token on cross-origin redirects
jkyberneees Oct 14, 2026
04f11aa
fix(mcpclient): registered MCP tool names are unambiguous
jkyberneees Oct 14, 2026
cda4d92
docs: MCP redirect/name rules, delegation risk cap, audit IP resources
jkyberneees Oct 14, 2026
32932d2
fix(subagent): parent risk cap also bounds profile allowlists, lockdo…
jkyberneees Oct 14, 2026
f37322d
fix(mcpclient): redirect origin check normalises default ports
jkyberneees Oct 14, 2026
bb4e8d3
fix(mcp): test the duplicate registered-name error and document suffi…
jkyberneees Oct 14, 2026
c90dc35
fix(memory): consolidate fits an embedder on the live corpus
jkyberneees Oct 14, 2026
c6c8c0e
fix(memory): maintenance open no longer rewrites user_model.json
jkyberneees Oct 14, 2026
75a7655
fix(cli): memory extended consolidate/nudges use the memory LLM
jkyberneees Oct 14, 2026
1daada4
fix(cli): escape follow-up suggestions before printing
jkyberneees Oct 14, 2026
95fa4ed
fix(memory): backend-dependent consolidate threshold, bounded context…
jkyberneees Oct 14, 2026
fadae75
fix(tools): protect the Telegram daily token budget counter as a trus…
jkyberneees Oct 14, 2026
b8c2f3c
fix(web_search): do not echo base_url in model-facing errors
jkyberneees Oct 14, 2026
f2758e3
fix(vision): restrict ffprobe/ffmpeg video input to file protocol and…
jkyberneees Oct 14, 2026
9b47844
fix(session): treat patch as an episode path tool
jkyberneees Oct 14, 2026
67f89a3
fix(artifact): accept artifacts under a filesystem-root artifact root
jkyberneees Oct 14, 2026
3f38bf8
test(memory): give the patch provenance fixture real workspace args
jkyberneees Oct 14, 2026
f8e1ea6
fix(tools): file tools and path classifier share one ~/.odek anchor t…
jkyberneees Oct 14, 2026
c5446bf
fix(web_search): report a cause class instead of the dial error
jkyberneees Oct 14, 2026
0f47149
fix(config): ignore unpriced project model that would disable the glo…
jkyberneees Oct 14, 2026
d29b720
fix(agent): close an unterminated HTML comment before appending the s…
jkyberneees Oct 14, 2026
e489730
fix(agent): neutralise look-alike close tags in the default untrusted…
jkyberneees Oct 14, 2026
3cce93d
fix(redact): redact password-only redis URLs and the rediss scheme
jkyberneees Oct 14, 2026
4de1b8e
fix(redact): redact driver-qualified database URLs
jkyberneees Oct 14, 2026
8b345f3
fix(redact): redact Slack and Discord incoming-webhook URLs
jkyberneees Oct 14, 2026
9095d38
fix(redact): redact the password of curl -u/--user USER:PASSWORD
jkyberneees Oct 14, 2026
4116269
fix(redact): redact session values on Cookie and Set-Cookie header lines
jkyberneees Oct 14, 2026
96aca4d
fix(redact): judge bare token/secret values before a JSON-escaped new…
jkyberneees Oct 14, 2026
43ae3e1
test(redact): over-redaction corpus for webhook and database URLs
jkyberneees Oct 14, 2026
c389b22
fix(config): gate the project model on the effective cost cap from ev…
jkyberneees Oct 14, 2026
d4f2ce8
fix(redact): scope curl credential rule, redact only values for cooki…
jkyberneees Oct 14, 2026
7f30a3d
fix(agent): close every unterminated raw HTML construct before the se…
jkyberneees Oct 14, 2026
3b42aef
fix(redact): tokenise curl/wget/HTTPie commands shell-style and match…
jkyberneees Oct 14, 2026
96cf30d
fix(agent): close unterminated HTML declarations and ignore raw HTML …
jkyberneees Oct 14, 2026
7152a31
fix(cli): session cleanup and trim reject malformed numbers
jkyberneees Oct 14, 2026
76a8b48
fix(cli): session show/list escape terminal controls and number turns…
jkyberneees Oct 14, 2026
576e663
fix(schedule): escape terminal controls in stdout and log delivery
jkyberneees Oct 14, 2026
ce96176
fix(repl): /jobs redacts and escapes commands and fixes the runtime unit
jkyberneees Oct 14, 2026
7f6c828
fix(init): odek init never writes through a project odek.json symlink
jkyberneees Oct 14, 2026
71007e7
fix(repl): history persists on first run, keeps multi-line entries an…
jkyberneees Oct 14, 2026
ac2f32f
fix(repl): page and rxvt home/end keys no longer leak a tilde into th…
jkyberneees Oct 14, 2026
7604096
fix(repl): bracketed paste keeps tabs and ignores pasted control keys
jkyberneees Oct 14, 2026
addbb4b
fix(schedule): next <expr> previews in the configured timezone
jkyberneees Oct 14, 2026
2efb162
fix(maintenance): media sweep resolves a symlinked media root and nev…
jkyberneees Oct 14, 2026
817e168
docs: ops fixes (session numbers, init symlink, terminal escaping, sc…
jkyberneees Oct 14, 2026
10ffc5b
fix(repl): keep Ctrl+C and Ctrl+D working inside a bracketed paste
jkyberneees Oct 14, 2026
6978449
fix(maintenance): media sweep never follows a symlinked media root
jkyberneees Oct 14, 2026
f651f23
fix(cli): session cleanup bounds days so the cutoff cannot overflow
jkyberneees Oct 14, 2026
ff357bd
refactor(cli): use danger.SanitizeInline/SanitizeForDisplay for CLI o…
jkyberneees Oct 14, 2026
9c687b0
fix(repl): history files carry a format marker so legacy files load v…
jkyberneees Oct 14, 2026
8567817
fix(init): init --global --force writes through the operator's linked…
jkyberneees Oct 14, 2026
28dc76a
fix(repl): bracketed paste drops control bytes and C1 runes
jkyberneees Oct 14, 2026
f273dd5
fix(cli): escape full-length transcripts and job results without the …
jkyberneees Oct 14, 2026
f7a7d5c
docs(serve): state unknown session_id is 404 on /api/prompt; reword t…
jkyberneees Oct 14, 2026
5911ea6
fix(subagent): allowlist entries cannot outrank any applied cap
jkyberneees Oct 14, 2026
08ad217
fix(repl): oversized history entries no longer truncate or erase the …
jkyberneees Oct 14, 2026
1f02d44
fix(telegram): session commands queue behind a pending /new or /resume
jkyberneees Oct 14, 2026
e30699a
fix(agent): close fences and raw HTML blocks with one CommonMark-awar…
jkyberneees Oct 14, 2026
c8840a3
fix(redact): handle JSON-encoded text and stray quotes in command cre…
jkyberneees Oct 14, 2026
9e1e276
fix(danger): fold squared letters, control chars, braille blank, smal…
jkyberneees Oct 14, 2026
8e96ee6
test(danger): reference injection scan mirrors the markdown-marker va…
jkyberneees Oct 14, 2026
74f89e2
fix(danger): argv composers fed dynamic input run an unknown program
jkyberneees Oct 14, 2026
825b5da
fix(danger): curl config/file-backed options and fd -x in short clust…
jkyberneees Oct 14, 2026
9564ee9
fix(danger): package registry publish is a network_upload
jkyberneees Oct 14, 2026
137da1e
fix(danger): read ledger voids licences after patch, uniq, extraction…
jkyberneees Oct 14, 2026
886e2b0
fix(danger): python -m NAME gates the workspace module it would run
jkyberneees Oct 14, 2026
b538d42
fix(danger): a nameref to a secret variable is a secret read
jkyberneees Oct 14, 2026
574a91e
fix(danger): env signal/debug options with no command dump the enviro…
jkyberneees Oct 14, 2026
9b26998
fix(danger): find/fd -exec payloads and project runners classify the …
jkyberneees Oct 14, 2026
5a212c8
fix(danger): command strings carried by tool options are classified
jkyberneees Oct 14, 2026
7a56cf2
fix(danger): denylist scans command carriers beyond shell -c
jkyberneees Oct 14, 2026
beabb2b
fix(danger): literal here-string and process-substitution shell paylo…
jkyberneees Oct 14, 2026
f45f877
fix(danger): bound the exec-action walk of nested find/fd chains
jkyberneees Oct 14, 2026
9768d0f
docs: document the classifier, denylist and injection-scan hardening
jkyberneees Oct 14, 2026
6960417
fix(danger): control characters are dropped before scanning, not repo…
jkyberneees Oct 14, 2026
c1ce9ef
fix(danger): ledger voids more unnamed writers, keeps read-only variants
jkyberneees Oct 14, 2026
c1244c1
fix(danger): python -m gate reads the interpreter option grammar
jkyberneees Oct 14, 2026
d62b920
fix(danger): nameref targets use the shared secret-name predicate
jkyberneees Oct 14, 2026
c3cbdcf
fix(danger): scan reads C0 separators and ANSI sequences both ways, f…
jkyberneees Oct 14, 2026
68ff933
fix(danger): dynamic argv inversion, runner spellings, startup files,…
jkyberneees Oct 14, 2026
5f79884
docs: document the review follow-up rules
jkyberneees Oct 14, 2026
8e7fde8
fix(danger): gate python -m NAME whatever follows the module name
jkyberneees Oct 14, 2026
217498e
fix(danger): forced branch creation and merge --abort void the read l…
jkyberneees Oct 14, 2026
175d921
fix(danger): scan reads escape strings, 8-bit CSI and all controls as…
jkyberneees Oct 14, 2026
6ace691
fix(danger): git ext:: via insteadOf, parallel option grammar, packag…
jkyberneees Oct 14, 2026
7a589ae
test(danger): cover escape-sequence spans and control readings
jkyberneees Oct 14, 2026
8e4cd35
fix(redact): treat quotes and backslash escapes as terminators only i…
jkyberneees Oct 14, 2026
18aab64
fix(repl): history load skips over-long lines instead of disabling sa…
jkyberneees Oct 14, 2026
8948455
fix(subagent): filter the allowlist only against caps that bind the c…
jkyberneees Oct 14, 2026
ef5eb2f
fix(danger): unterminated escape strings consume the rest of the input
jkyberneees Oct 14, 2026
ebf9cc0
fix(danger): parallel option table, dynamic-argv worst case, find -ex…
jkyberneees Oct 14, 2026
eaa171c
fix(danger): an unlisted parallel option makes the command unknown
jkyberneees Oct 14, 2026
636b9ad
test(redact): gofmt the webhook fixtures
jkyberneees Oct 14, 2026
81a95b2
test(memory): cover consolidate preconditions, cancellation and defau…
jkyberneees Oct 14, 2026
0813a4f
fix: staticcheck De Morgan rewrites; webhook redaction patterns start…
jkyberneees Oct 14, 2026
631928c
fix(redact): ignore double quotes inside shell single quotes when det…
jkyberneees Oct 14, 2026
145704f
fix(danger): parallel option values are classified; dry-run last sett…
jkyberneees Oct 14, 2026
d97454a
test: write the bidi override as an escape instead of a literal chara…
jkyberneees Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ internal/
gh_adapter.go gh classified by command and verb (reads egress, mutations system_write, deletes destructive)
git_repo_arming.go Repo-aware git escalation: ordinary verbs are code_execution only when the repo is armed
readledger.go Unread-script gate: fingerprinted, bounded read ledger; ForgetReadLedger per session key
command_strings.go Command strings carried by tool options (git -c core.pager, ssh -o ProxyCommand, tar --to-command, rsync -e, su -c) → classified and denylist-scanned
denylist_carriers.go Denylist scan of su/runuser/at/tmux/screen command carriers and git -c alias expansion
ledger_indirect.go Scripts delivered indirectly (pipes, substitutions, eval, find -exec, program-file options)
path_identity.go Symlink-resolving path targets for classification (snapshot, not an execution boundary)
sensitive_read.go IsSensitiveReadPath: paths @-references refuse (shares the home credential table with ClassifyPath)
Expand Down
46 changes: 2 additions & 44 deletions cmd/odek/file_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -1624,51 +1624,9 @@ func confineToCWD(path string) (string, error) {
// isProtectedOdekPath reports whether rel (a path relative to ~/.odek/,
// already cleaned by confineToCWD) names one of odek's trust anchors that
// must not be writable through the generic file tools. Keep in sync with
// the SystemWrite escalation in danger.ClassifyPath.
// the SystemWrite escalation in danger.ClassifyPath (both use one table).
func isProtectedOdekPath(rel string) bool {
// Case-folding defends against case-insensitive filesystems (macOS APFS):
// ~/.odek/CONFIG.JSON and ~/.odek/config.json are the same file.
rel = danger.FoldPathCase(filepath.Clean(rel))

// Exact-file trust anchors. Rewriting any of these can disable safety
// policy, exfiltrate secrets, or persist attacker control.
protectedExact := []string{
"config.json",
"secrets.env",
"identity.md",
"schedules.json",
"schedule-state.json",
"schedules.lock",
"mcp_approvals.json",
"mcp_tool_approvals.json",
"project_sandbox_approvals.json",
"restart.json",
"telegram.lock",
"telegram.pid",
"schedule.pid",
"schedule.log",
"runtime.log",
}
for _, p := range protectedExact {
if rel == p {
return true
}
}

// Directory trust anchors. Anything inside these directories is protected.
protectedDirs := []string{
"skills", // auto-loaded into future prompts
"sessions", // conversation history & auth tokens
"audit", // forensic audit log
"plans", // persisted task plans
"memory", // persisted memory state re-enters future system prompts
}
for _, d := range protectedDirs {
if rel == d || strings.HasPrefix(rel, d+string(filepath.Separator)) {
return true
}
}
return false
return danger.IsOdekTrustAnchorRel(rel)
}

// patchToCRLF turns every bare '\n' in s into "\r\n", leaving existing
Expand Down
88 changes: 72 additions & 16 deletions cmd/odek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/BackendStack21/odek/internal/danger"
"github.com/BackendStack21/odek/internal/diagnostics"
"github.com/BackendStack21/odek/internal/events"
"github.com/BackendStack21/odek/internal/fsatomic"
"github.com/BackendStack21/odek/internal/guard"
"github.com/BackendStack21/odek/internal/llmclient"
"github.com/BackendStack21/odek/internal/loop"
Expand Down Expand Up @@ -1812,6 +1813,15 @@ func initConfig(args []string) error {
template = localConfigTemplate
}

// A project directory is untrusted: ./odek.json may be a link planted by
// a cloned repository. Never write through it, with or without --force,
// and treat a dangling link as existing so it cannot create its target.
if !global {
if fi, err := os.Lstat(configPath); err == nil && !fi.Mode().IsRegular() {
return fmt.Errorf("refusing to write %s: it is not a regular file (symlink or special file); remove it first", configPath)
}
}

// Check if file already exists
if _, err := os.Stat(configPath); err == nil && !force {
fmt.Fprintf(os.Stderr, "odek: %s config already exists at %s\n", scope, configPath)
Expand All @@ -1825,7 +1835,19 @@ func initConfig(args []string) error {
return fmt.Errorf("create directory %s: %w", dir, err)
}

if err := os.WriteFile(configPath, []byte(template+"\n"), 0600); err != nil {
// The operator's own global config may be a link into a dotfiles tree:
// write atomically to the file it points at, keeping its mode, so the
// link survives. (A project ./odek.json link was refused above.)
writePath, mode := configPath, os.FileMode(0600)
if global {
if resolved, err := filepath.EvalSymlinks(configPath); err == nil {
writePath = resolved
}
if fi, err := os.Stat(writePath); err == nil && fi.Mode().IsRegular() {
mode = fi.Mode().Perm()
}
}
if err := fsatomic.WriteFile(writePath, []byte(template+"\n"), mode); err != nil {
return fmt.Errorf("write config: %w", err)
}

Expand Down Expand Up @@ -2683,6 +2705,16 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d
// Top-level runs (odek run/repl/serve/telegram) are operator-trusted.
selfTrust = "trusted"
}
// A sub-agent hands its own effective risk cap to the children it
// spawns; top-level runs (SelfTrust empty) have no inherited cap.
selfMaxRisk := ""
if tcfg.SelfTrust != "" {
selfMaxRisk = effectiveMaxRisk(&dc)
if selfMaxRisk == "" {
// Every class denied: the children get no authority either.
selfMaxRisk = parentRiskCapNone
}
}
// Artifacts home for the delegate_tasks result channel; empty disables
// the channel (never fail agent startup over it).
artifactsRoot, _ := artifactsHome()
Expand Down Expand Up @@ -2712,6 +2744,7 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d
maxDepth: subDepth,
budgetInherit: subInherit,
selfTrust: selfTrust,
selfMaxRisk: selfMaxRisk,
profiles: tcfg.Profiles,
artifactsRoot: artifactsRoot, // empty ⇒ no artifact dirs created
artifactReadAvailable: artifactReadEnabled(tcfg),
Expand Down Expand Up @@ -2875,6 +2908,16 @@ func reservedBuiltinToolNames() map[string]bool {
return names
}

// claimMCPToolName records a registered MCP tool name, failing when another
// tool already uses it (the registry would otherwise panic or shadow).
func claimMCPToolName(seen map[string]struct{}, server, tool, registered string) error {
if _, dup := seen[registered]; dup {
return fmt.Errorf("mcp server %q: tool %q registers as %q, which another MCP tool already uses", server, tool, registered)
}
seen[registered] = struct{}{}
return nil
}

// loadMCPTools calls approveMCPServers, which requires explicit user approval
// (interactive prompt or ODEK_APPROVE_MCP=1) and persists approvals in
// ~/.odek/mcp_approvals.json. After discovery, each advertised tool is checked
Expand Down Expand Up @@ -2909,6 +2952,7 @@ func loadMCPTools(resolved config.ResolvedConfig, tools *[]odek.Tool) (func(), e
projectServers[n] = true
}
var cleaners []func()
mcpRegistered := map[string]struct{}{}
for name, cfg := range resolved.MCPServers {
if cfg.IsDisabled() {
// Configured but off: keep the entry (approval hashes, later
Expand Down Expand Up @@ -2979,7 +3023,15 @@ func loadMCPTools(resolved config.ResolvedConfig, tools *[]odek.Tool) (func(), e
// Server-controlled description and schema text is made
// non-authoritative before it enters the tool catalogue (see
// newMCPModelTool).
*tools = append(*tools, newMCPModelTool(client, name, def, injectionGuard, resolved.Guard))
mt := newMCPModelTool(client, name, def, injectionGuard, resolved.Guard)
if err := claimMCPToolName(mcpRegistered, name, def.Name, mt.Name()); err != nil {
client.Close()
for _, c := range cleaners {
c()
}
return nil, err
}
*tools = append(*tools, mt)
}

cleaners = append(cleaners, func() {
Expand Down Expand Up @@ -3730,8 +3782,8 @@ func listSessions(store *session.Store) error {
fmt.Printf("%-22s %-5s %-30s %s\n", "ID", "Turns", "Model", "Task")
fmt.Println(strings.Repeat("─", 80))
for _, s := range sessions {
task := shorten(s.Task, 30)
model := shorten(s.Model, 20)
task := danger.SanitizeInline(shorten(s.Task, 30))
model := danger.SanitizeInline(shorten(s.Model, 20))
fmt.Printf("%-22s %-5d %-30s %s\n", s.ID, s.Turns, model, task)
}
return nil
Expand All @@ -3755,11 +3807,11 @@ func showSession(store *session.Store, args []string) error {
}

fmt.Printf("Session: %s\n", sess.ID)
fmt.Printf("Model: %s\n", sess.Model)
fmt.Printf("Model: %s\n", danger.SanitizeInline(sess.Model))
fmt.Printf("Turns: %d\n", sess.Turns)
fmt.Printf("Created: %s\n", sess.CreatedAt.Format("2006-01-02 15:04:05 UTC"))
fmt.Printf("Updated: %s\n", sess.UpdatedAt.Format("2006-01-02 15:04:05 UTC"))
fmt.Printf("Task: %s\n", sess.Task)
fmt.Printf("Task: %s\n", danger.SanitizeInline(sess.Task))
fmt.Println()

// Call-ID correlation: parallel tool calls are stored as
Expand Down Expand Up @@ -3796,21 +3848,22 @@ func showSession(store *session.Store, args []string) error {

for i, msg := range sess.Messages {
content := strings.TrimSpace(msg.Content)
shown := danger.EscapeTerminalText(content)
switch msg.Role {
case "system":
fmt.Printf("── [SYSTEM] ──\n%s\n\n", content)
fmt.Printf("── [SYSTEM] ──\n%s\n\n", shown)
case "user":
fmt.Printf("── [USER Turn %d] ──\n%s\n\n", countUserTurnsUpTo(sess.Messages, i), content)
fmt.Printf("── [USER Turn %d] ──\n%s\n\n", countUserTurnsUpTo(sess.Messages, i)+1, shown)
case "assistant":
if len(msg.ToolCalls) > 0 {
for _, tc := range msg.ToolCalls {
fmt.Printf("── [TOOL CALL: %s %s] ──\n%s\n\n", tc.Function.Name, nextLabel(callCursor, tc.ID), tc.Function.Arguments)
fmt.Printf("── [TOOL CALL: %s %s] ──\n%s\n\n", danger.SanitizeInline(tc.Function.Name), danger.SanitizeInline(nextLabel(callCursor, tc.ID)), danger.EscapeTerminalText(tc.Function.Arguments))
}
} else {
fmt.Printf("── [ASSISTANT] ──\n%s\n\n", content)
fmt.Printf("── [ASSISTANT] ──\n%s\n\n", shown)
}
case "tool":
fmt.Printf("── [TOOL RESULT: %s %s] ──\n%s\n\n", msg.Name, nextLabel(resultCursor, msg.ToolCallID), shorten(content, 200))
fmt.Printf("── [TOOL RESULT: %s %s] ──\n%s\n\n", danger.SanitizeInline(msg.Name), danger.SanitizeInline(nextLabel(resultCursor, msg.ToolCallID)), danger.SanitizeForDisplay(shorten(content, 200)))
}
}
return nil
Expand Down Expand Up @@ -3848,8 +3901,8 @@ func trimSession(store *session.Store, args []string) error {
return fmt.Errorf("usage: odek session trim <id> <n>")
}
id := args[0]
var n int
if _, err := fmt.Sscanf(args[1], "%d", &n); err != nil || n < 2 {
n, nerr := strconv.Atoi(args[1])
if nerr != nil || n < 2 {
return fmt.Errorf("n must be at least 2 (system + at least 1 message), got %q", args[1])
}

Expand Down Expand Up @@ -3898,15 +3951,18 @@ func trimSession(store *session.Store, args []string) error {
return nil
}

// maxCleanupDays bounds `session cleanup <days>` so the cutoff date cannot overflow.
const maxCleanupDays = 36500

// cleanupSessions deletes all sessions older than the given number of days.
// Usage: odek session cleanup <days>
func cleanupSessions(store *session.Store, args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: odek session cleanup <days>")
}
var days int
if _, err := fmt.Sscanf(args[0], "%d", &days); err != nil || days < 0 {
return fmt.Errorf("invalid days %q — must be a non-negative integer", args[0])
days, derr := strconv.Atoi(args[0])
if derr != nil || days < 0 || days > maxCleanupDays {
return fmt.Errorf("invalid days %q — must be an integer between 0 and %d", args[0], maxCleanupDays)
}

before := time.Now().UTC().AddDate(0, 0, -days)
Expand Down
13 changes: 9 additions & 4 deletions cmd/odek/memory_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -579,15 +579,16 @@ func extendedMemoryCmd(dir string, args []string) error {
return err
}
// Merging near-duplicate atoms needs an LLM; the operator backend
// is resolved the same way the agent does.
// is resolved the same way the agent does (memory.extended.llm overrides
// the main model).
if resolved.APIKey == "" {
return fmt.Errorf("memory extended consolidate requires an LLM backend (no API key resolved)")
}
llmClient, err := llmclient.Dial(resolved.Provider, resolved.Model, resolved.APIKey, resolved.BaseURL)
if err != nil {
return err
}
emLLM := extended.New(extDir, llmClient, cfg)
emLLM := extended.New(extDir, extended.ResolveLLM(cfg, llmClient, resolved.Thinking), cfg)
defer emLLM.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
Expand All @@ -610,7 +611,7 @@ func extendedMemoryCmd(dir string, args []string) error {
if err != nil {
return err
}
emLLM := extended.New(extDir, llmClient, cfg)
emLLM := extended.New(extDir, extended.ResolveLLM(cfg, llmClient, resolved.Thinking), cfg)
defer emLLM.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
Expand All @@ -626,7 +627,11 @@ func extendedMemoryCmd(dir string, args []string) error {
for _, n := range nudges {
fmt.Printf("• [%s] %s\n", danger.SanitizeInline(n.Kind), danger.SanitizeInline(n.Text))
if len(n.SourceAtomIDs) > 0 {
fmt.Printf(" atoms: %s\n", strings.Join(n.SourceAtomIDs, ", "))
ids := make([]string, len(n.SourceAtomIDs))
for i, id := range n.SourceAtomIDs {
ids[i] = danger.SanitizeInline(id)
}
fmt.Printf(" atoms: %s\n", strings.Join(ids, ", "))
}
}
return nil
Expand Down
3 changes: 2 additions & 1 deletion cmd/odek/proactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"time"

"github.com/BackendStack21/odek/internal/danger"
"github.com/BackendStack21/odek/internal/memory"
)

Expand Down Expand Up @@ -100,7 +101,7 @@ func formatFollowUpSuggestions(suggestions []string) string {
var b strings.Builder
b.WriteString("── You might also want to ──\n")
for _, s := range suggestions {
fmt.Fprintf(&b, "• %s\n", s)
fmt.Fprintf(&b, "• %s\n", danger.SanitizeInline(s))
}
return b.String()
}
Loading
Loading