Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ jobs:
if: github.actor != 'release-please[bot]'
services:
ollama:
image: ollama/ollama:latest
# Keep semantic-search snapshots reproducible. New Ollama runtimes can
# change embedding execution even when the all-minilm model is unchanged.
image: ollama/ollama@sha256:a6149234667efc71d37766d61c1a16f24c33e4cd7a0bf4125c44a7e47e2419c4
ports:
- 11434:11434
steps:
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ Codex, Cursor, and OpenCode reuse the same repo-root `skills/`, `hooks/`, and
│ ├── root.go # Cobra root command
│ ├── stdio.go # MCP server
│ ├── hook.go # Hook handlers
│ ├── purge.go # Index data cleanup
│ ├── clean.go # Age-based index data cleanup
│ └── index.go # CLI indexing
├── internal/
│ ├── config/ # Config loading & paths
Expand All @@ -168,7 +168,7 @@ Codex, Cursor, and OpenCode reuse the same repo-root `skills/`, `hooks/`, and

Lumen has two execution contexts with distinct output strategies:

**Interactive** (`lumen index`, `lumen purge`, `lumen search`):
**Interactive** (`lumen index`, `lumen clean`, `lumen search`):
- Progress and status → `tui.Progress` (pterm) on **stderr**
- Completion summaries → `fmt.Printf` on **stdout**
- Errors → `fmt.Fprintf(os.Stderr, ...)`
Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,18 @@ and binary version. Different models or Lumen versions automatically get
separate indexes. No files are added to your repo, no `.gitignore` modifications
needed.

You can safely delete the entire `lumen` directory to clear all indexes, or use
`lumen purge` to do it automatically.
You can safely delete the entire `lumen` directory to clear all indexes, or let
Lumen reclaim the space for you:

```bash
lumen clean # remove indexes unused for 30 days or whose project is gone
lumen clean --days 7 # tighten the cutoff to a week
lumen clean --days 0 # remove every cached index on this host

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

lumen clean --days 0 is not an unconditional full wipe.

cmd/clean.go:84-133 preserves indexes with active locks. Update both descriptions to state that the command removes all eligible indexes while keeping indexes used by active indexers.

  • README.md#L401-L401: replace “remove every cached index” with the eligible-index behavior.
  • skills/reindex/SKILL.md#L22-L23: replace “deletes every cached index” with the same active-lock exception.
📍 Affects 2 files
  • README.md#L401-L401 (this comment)
  • skills/reindex/SKILL.md#L22-L23
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 401, Update the descriptions of `lumen clean --days 0` in
README.md lines 401-401 and skills/reindex/SKILL.md lines 22-23 to state that it
removes all eligible cached indexes while preserving indexes held by active
indexers through active locks.

```

An index counts as used every time Lumen opens it (search, indexing, status, or
session start), so indexes for projects you still work on are never removed.
Indexes with an indexer currently running are always kept.

**Git worktrees** are detected automatically. When you create a new worktree
(`git worktree add` or `claude --worktree`), Lumen finds a sibling worktree's
Expand Down Expand Up @@ -432,7 +442,7 @@ In Cursor, Codex, or OpenCode, use the shared `doctor` skill or call
Run `/lumen:reindex` inside Claude Code to force a full re-index, or:

```bash
lumen purge && lumen index .
lumen index --force .
```

In Codex, use the bundled `reindex` skill to refresh the index through the MCP
Expand Down
198 changes: 198 additions & 0 deletions cmd/clean.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Copyright 2026 Aeneas Rekkas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"fmt"
"io"
"os"
"path/filepath"
"time"

"github.com/ory/lumen/internal/config"
"github.com/ory/lumen/internal/indexlock"
"github.com/ory/lumen/internal/store"
"github.com/spf13/cobra"
)

// defaultCleanDays is how long an index may go unused before `lumen clean`
// removes it.
const defaultCleanDays = 30

func init() {
addCleanFlags(cleanCmd)
rootCmd.AddCommand(cleanCmd)
}

// addCleanFlags registers the clean flags. Shared with the tests so the flag
// definition never drifts from what runClean reads.
func addCleanFlags(cmd *cobra.Command) {
cmd.Flags().Int("days", defaultCleanDays,
"remove indexes not used in the last N days (0 removes every index that is not currently being written)")
}

var cleanCmd = &cobra.Command{
Use: "clean",
Short: "Remove unused or orphaned lumen indexes",
Long: fmt.Sprintf(`Deletes unused lumen index databases under ~/.local/share/lumen/.

An index is removed when it has not been opened for --days days (default %d),
or when the project it was built for no longer exists — indexes are keyed by
project path, embedding model, and index version, so renamed projects, deleted
checkouts, and abandoned models leave behind data that is never read again.

Indexes written by older binaries that never recorded an access time fall back
to their last indexing time; those without any usable timestamp are removed.

Use "lumen clean --days 0" to drop every cached index on this host, and
"lumen index --force <project-path>" to rebuild a single project from scratch.

Indexes with an indexer currently running are always kept.`, defaultCleanDays),
Args: cobra.NoArgs,
RunE: runClean,
}

func runClean(cmd *cobra.Command, _ []string) error {
days, err := cmd.Flags().GetInt("days")
if err != nil {
return err
}
if days < 0 {
return fmt.Errorf("--days must not be negative, got %d", days)
}
dataDir := filepath.Join(config.XDGDataDir(), "lumen")
return cleanIndexes(cmd.ErrOrStderr(), cmd.OutOrStdout(), dataDir, days, time.Now())
}

// cleanIndexes removes every stale index directory directly under dataDir,
// reporting each decision on stderr and a summary on stdout. now is injected so
// the age cutoff is testable. Failures to remove a single directory are
// reported and the sweep continues; the first such failure is returned once
// every directory has been considered.
func cleanIndexes(stderr, stdout io.Writer, dataDir string, days int, now time.Time) error {
entries, err := os.ReadDir(dataDir)
if err != nil {
if os.IsNotExist(err) {
_, _ = fmt.Fprintln(stderr, "No index data found — nothing to clean.")
return nil
}
return fmt.Errorf("read data dir: %w", err)
}

cutoff := now.Add(-time.Duration(days) * 24 * time.Hour)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Bound --days to prevent a time.Duration overflow that deletes every index.

time.Duration holds nanoseconds in an int64, so it saturates at about 106751 days. time.Duration(days) * 24 * time.Hour overflows for a larger value. The cutoff then wraps to a time far in the future, isIndexStale marks every index stale, and the sweep deletes all of them.

A larger retention window must never delete more. runClean already rejects negative values at Line 72. Add an upper bound there or clamp the cutoff here.

🐛 Proposed fix in runClean
 	if days < 0 {
 		return fmt.Errorf("--days must not be negative, got %d", days)
 	}
+	const maxCleanDays = 100000 // keeps days*24h inside time.Duration's range
+	if days > maxCleanDays {
+		return fmt.Errorf("--days must not exceed %d, got %d", maxCleanDays, days)
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/clean.go` at line 94, Bound the positive days value in runClean before
calculating cutoff, using a maximum that keeps time.Duration(days) * 24 *
time.Hour from overflowing; preserve the existing negative-value validation and
ensure larger retention windows are rejected or safely clamped rather than
allowing cutoff to wrap and mark every index stale.

removed, skipped := 0, 0
var firstErr error

for _, entry := range entries {
// Only hash-named index directories are candidates; the shared
// debug.log lives in the same data dir.
if !entry.IsDir() {
continue
}
hashDir := filepath.Join(dataDir, entry.Name())
dbPath := filepath.Join(hashDir, "index.db")

if indexlock.IsHeld(indexlock.LockPathForDB(dbPath)) {
_, _ = fmt.Fprintf(stderr, "Keeping %s: an indexer is currently running.\n", entry.Name())
skipped++
continue
}

stale, reason := isIndexStale(dbPath, days, cutoff)
if !stale {
skipped++
continue
}
if err := os.RemoveAll(hashDir); err != nil {
_, _ = fmt.Fprintf(stderr, "Failed to remove %s: %v\n", hashDir, err)
if firstErr == nil {
firstErr = fmt.Errorf("remove %s: %w", hashDir, err)
}
skipped++
continue
}
Comment on lines +107 to +125

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the indexlock API: TryAcquire, IsHeld, Release, and where LockPathForDB places the lock file.
rg -nP --type=go -B 2 -A 25 'func (TryAcquire|IsHeld|LockPathForDB)\(|func \(.*Lock\) Release\(' internal/indexlock

Repository: ory/lumen

Length of output: 3971


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== cleanIndexes implementation =="
sed -n '80,135p' cmd/clean.go

echo
echo "== relevant indexlock tests =="
rg -n --type=go -B 4 -A 20 'TryAcquire|IsHeld|lock file|lock path|LockPathForDB|flock' cmd/clean_test.go internal/indexlock

echo
echo "== lock implementations =="
sed -n '1,90p' internal/indexlock/lock.go

Repository: ory/lumen

Length of output: 20577


Hold the index lock while cleaning the index directory.

indexlock.IsHeld checks the lock before os.RemoveAll deletes hashDir. An indexer can acquire the lock in that window, so clean can remove the index files it is currently writing to.

Use indexlock.TryAcquire(indexlock.LockPathForDB(dbPath)) instead and keep any acquired lock for the whole stale/delete decision. cmd/clean_test.go already exercises the held-lock case with TryAcquire.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/clean.go` around lines 107 - 125, Update the cleanup flow around
isIndexStale and os.RemoveAll to call indexlock.TryAcquire for the database lock
instead of checking indexlock.IsHeld. Retain each successfully acquired lock
through the stale check and hashDir deletion, release it on every path, and
treat acquisition failure as the existing held-lock skip case.

_, _ = fmt.Fprintf(stderr, "Removed %s (%s).\n", entry.Name(), reason)
removed++
}

_, _ = fmt.Fprintf(stdout, "Removed %d index director%s, skipped %d.\n",
removed, pluralY(removed), skipped)
return firstErr
}

// isIndexStale reports whether the index at dbPath is no longer worth keeping,
// along with a human-readable reason. The metadata read is read-only so it does
// not itself count as an access.
func isIndexStale(dbPath string, days int, cutoff time.Time) (bool, string) {
if days == 0 {
return true, "--days 0"
}

meta, err := store.ReadMetaAt(dbPath, "project_path", store.MetaLastAccessedAt, "last_indexed_at")
if err != nil {
// Missing, truncated, or non-lumen database: nothing here can be read
// again, so it is pure waste.
return true, "no readable index metadata"
}

projectPath := meta["project_path"]
if projectPath == "" {
return true, "no project path recorded"
}
info, statErr := os.Stat(projectPath)
switch {
case statErr == nil && !info.IsDir():
return true, fmt.Sprintf("project path %s is not a directory", projectPath)
case os.IsNotExist(statErr):
return true, fmt.Sprintf("project %s no longer exists", projectPath)
}
// Any other stat error (e.g. an unreadable parent directory) is
// inconclusive — the project may well still be there, so fall through to
// the age check rather than deleting a live index.

if ts, ok := parseIndexTime(meta[store.MetaLastAccessedAt]); ok {
if ts.After(cutoff) {
return false, ""
}
return true, fmt.Sprintf("not accessed since %s", ts.Format(time.RFC3339))
}
if ts, ok := parseIndexTime(meta["last_indexed_at"]); ok {
if ts.After(cutoff) {
return false, ""
}
return true, fmt.Sprintf("not indexed since %s", ts.Format(time.RFC3339))
}
return true, "no usable access timestamp"
}

// parseIndexTime parses an RFC3339 metadata timestamp, reporting whether the
// value was present and well-formed.
func parseIndexTime(value string) (time.Time, bool) {
if value == "" {
return time.Time{}, false
}
ts, err := time.Parse(time.RFC3339, value)
if err != nil {
return time.Time{}, false
}
return ts, true
}

func pluralY(n int) string {
if n == 1 {
return "y"
}
return "ies"
}
Loading
Loading