Skip to content

feat(cli): add 'gradata forget' subcommand (GRA-1207 / GH #206)#209

Merged
Gradata merged 1 commit into
mainfrom
feat/gradata-forget-subcommand
May 20, 2026
Merged

feat(cli): add 'gradata forget' subcommand (GRA-1207 / GH #206)#209
Gradata merged 1 commit into
mainfrom
feat/gradata-forget-subcommand

Conversation

@Gradata
Copy link
Copy Markdown
Owner

@Gradata Gradata commented May 20, 2026

Summary

Adds gradata forget <selector> — undo lessons from the brain via CLI without writing Python.

Wraps the existing Brain.forget() public API. Preview-before-apply UX with interactive confirmation; --yes skips for scripts.

Selector syntax

gradata forget                  # most recent active lesson
gradata forget last 3           # last 3 active lessons
gradata forget all TONE         # every active lesson in a category
gradata forget 'casual tone'    # fuzzy description substring match
gradata forget last --yes       # skip confirmation

Soft-delete semantics

  • Lessons flipped to KILLED state (not hard-deleted)
  • LESSON_CHANGE event emitted with action='rolled_back' and kill_reason='manual_forget'
  • Rule cache invalidated; next apply_brain_rules() reflects the change
  • Sync pipeline replays the events to cloud automatically

Test plan

pytest tests/test_forget_command.py
=> 6 passed in 3.29s

6 cases: last (single), last N, all CATEGORY, fuzzy description, no-match clean error, unknown-category clean error. Tests seed via the public Brain.correct() API so the events table matches production exactly.

Epic context

GRA-1198 (kill the plugin) / GH #206. Replaces plugin /gradata:forget slash command. Companion to PR #208 (gradata status).

Layering

cmd_forget delegates to Brain.forget() (Layer 2 public API). Layer 0 imports (LessonState, parse_lessons) used for preview only, not state mutation. No Layer 0 → 2 imports.

Risk

Low. Pure additive CLI wrapper. Underlying Brain.forget() already has production usage.

Wraps Brain.forget() in a CLI surface with interactive confirmation. Lets
users undo lessons without writing Python.

Selector syntax:
  gradata forget               # most recent active lesson
  gradata forget last 3        # last 3 active lessons
  gradata forget all TONE      # every active lesson in a category
  gradata forget casual        # fuzzy description substring match
  gradata forget last --yes    # skip confirmation (scripts)

Preview-before-apply: shows matched lessons + asks 'Proceed? [y/N]'.
Pass --yes / -y to skip for scripts.

Soft-delete semantics:
- Lessons flipped to KILLED state (not hard-deleted)
- LESSON_CHANGE event emitted with action='rolled_back' and
  kill_reason='manual_forget'
- Rule cache invalidated so next apply_brain_rules() reflects the change
- Sync pipeline replays the events to cloud automatically

Replaces plugin /gradata:forget slash command (GRA-1198 epic).

Test plan: pytest tests/test_forget_command.py
=> 6 passed in 3.29s

6 cases: last (single), last N, all CATEGORY, fuzzy description match,
no-match clean error, unknown-category clean error. All seed via the
public Brain.correct() API so the events table matches production
exactly — no hand-crafted SQL inserts.

Layering: cmd_forget delegates to Brain.forget() (Layer 2 public API).
Layer 0 imports (LessonState, parse_lessons) are used for preview only,
not for state mutation. No Layer 0 -> 2 imports.
Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

Review Change Stack

📝 Walkthrough
  • New CLI subcommand: gradata forget <selector> allows users to undo lessons via CLI without writing Python, wrapping the existing Brain.forget() public API
  • Flexible selector syntax: Supports multiple formats—last, last N, all <category>, and fuzzy description substring matching
  • Interactive confirmation flow: Previews matching active lessons and prompts for confirmation; --yes/-y flag skips confirmation for scripting
  • Soft-delete semantics: Lessons marked KILLED (not hard-deleted); emits LESSON_CHANGE event with action='rolled_back' and kill_reason='manual_forget'; rule cache invalidated for subsequent applies
  • Cloud sync: Sync pipeline automatically replays forget events to cloud
  • Comprehensive test coverage: 6 new tests in test_forget_command.py covering single lesson, batch (last N), category filter, fuzzy match, no-match error, and unknown-category scenarios
  • Low-risk addition: Delegates to Layer 2 public API only; no Layer 0 imports in command handler; additive wrapper with no breaking changes
  • Minor refactoring: Install verification block simplified with generator expression while preserving write+read verification behavior

Walkthrough

This PR introduces a forget command to the Gradata CLI that lets users undo lessons via selector syntax (last, last N, all <category>, or fuzzy description). The feature includes the command implementation, full argument parser and dispatch integration, and six comprehensive tests validating selector behavior and error handling. A minor refactor also simplifies the install verification logic using generator expressions.

Changes

Forget Command Feature

Layer / File(s) Summary
Forget command core logic
Gradata/src/gradata/cli.py
cmd_forget() normalizes selectors, filters active lessons, displays a confirmation preview, prompts interactively (respecting --yes), delegates the undo to brain.forget(), and prints rolled-back lesson counts or errors.
CLI integration and routing
Gradata/src/gradata/cli.py
Argument parser adds the forget subcommand with positional what selector (defaulting to last) and --yes/-y flag; command dispatch routes args.command == "forget" to cmd_forget().
Comprehensive test suite
Gradata/tests/test_forget_command.py
Test fixture seeds three active lessons across two categories; helpers invoke cmd_forget and filter active lessons from persisted state; six tests validate last, last N, category-based all <CATEGORY>, fuzzy description matching, no-match errors, and unknown-category messaging.
Install verification refactoring
Gradata/src/gradata/cli.py
Local imports and marker-found check rewritten as a generator expression; preservation of write+read verification and warning behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

feature

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding a 'gradata forget' CLI subcommand with issue/PR references for context.
Description check ✅ Passed The description is well-structured and directly related to the changeset, covering summary, syntax examples, semantics, tests, and risk assessment.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/gradata-forget-subcommand

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 OpenGrep (1.21.0)

OpenGrep fatal error (exit code 2):
┌──────────────┐
│ Opengrep CLI │
└──────────────┘

�[32m✔�[39m �[1mOpengrep OSS�[0m
�[32m✔�[39m Basic security coverage for first-party code vulnerabilities.

�[1m Loading rules from local config...�[0m
[00.20][ERROR]: Error: exception Glob.Lexer.Syntax_error("malformed glob pattern: missing ']'")
Raised at Glob__Lexer.syntax_error in file "libs/glob/Lexer.mll", line 8, characters 2-26
Called from Glob__Lexer.__ocaml_lex_token_rec in file "libs/glob/Lexer.mll", line 29, characters 26-53
Cal


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot added the feature label May 20, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@Gradata/src/gradata/cli.py`:
- Around line 592-595: The "last N" branch in the CLI parsing (where wl ==
"last" or wl.startswith("last ")) currently parses n with int(parts[1]) but
allows 0 which makes active[-0:] return all entries; update the validation in
this branch (and the similar branch around the other occurrence referenced) to
reject non-positive values: parse parts[1], ensure it is a digit and that
int(parts[1]) >= 1, and if not raise/print a clear CLI error or return without
performing preview/apply instead of using n=1; modify the code around the wl
handling and the variable n (and the subsequent use of preview = [l for _, l in
active[-n:]]) to only execute when n >= 1.

In `@Gradata/tests/test_forget_command.py`:
- Around line 98-99: The test unpacks "brain, brain_dir = seeded_brain" but the
"brain" variable is never used (RUF059); update the test(s) that call
seeded_brain (the occurrences around the _run_forget calls) to only unpack the
needed value (e.g., "_, brain_dir = seeded_brain" or "brain_dir =
seeded_brain[1]") or change the assignment to a single-variable unpacking so
that only brain_dir is assigned where _run_forget is invoked; adjust both
occurrences referenced (around lines with _run_forget(brain_dir,
"nonexistent-phrase-zzz", ...)) accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 29524b2a-e068-4ab3-922c-7e761cc55d04

📥 Commits

Reviewing files that changed from the base of the PR and between 92a4c81 and 545017b.

📒 Files selected for processing (2)
  • Gradata/src/gradata/cli.py
  • Gradata/tests/test_forget_command.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: pytest windows-latest / py3.12
  • GitHub Check: pytest macos-latest / py3.11
  • GitHub Check: pytest ubuntu-latest / py3.11
  • GitHub Check: pytest ubuntu-latest / py3.12
  • GitHub Check: pytest windows-latest / py3.11
  • GitHub Check: pytest macos-latest / py3.12
  • GitHub Check: pytest (py3.11)
  • GitHub Check: pytest (py3.12)
🧰 Additional context used
📓 Path-based instructions (2)
Gradata/tests/**/*.py

📄 CodeRabbit inference engine (Gradata/AGENTS.md)

Gradata/tests/**/*.py: Set BRAIN_DIR environment variable via tmp_path in conftest.py for test isolation — ensure _paths.py module cache refreshes when calling Brain.init() directly inside tests
Add unit tests in tests/test_*.py for every CI push without LLM calls (deterministic); mark integration tests with @pytest.mark.integration and skip them by default (they hit real LLM APIs)

Files:

  • Gradata/tests/test_forget_command.py
Gradata/src/**/*.py

📄 CodeRabbit inference engine (Gradata/AGENTS.md)

Gradata/src/**/*.py: Prefer sentence-transformers for local embeddings, google-genai for Gemini embeddings, cryptography for AES-GCM encrypted system.db, bm25s for BM25 rule ranking, and mem0ai for external memory adapters — guard all optional dependency imports with try / except ImportError at the call site, never at module level
Maintain strict layering: Layer 0 (Primitives: _types.py, _db.py, _events.py, _paths.py, _file_lock.py; Patterns: contrib/patterns/) must never import from Layer 1 (Enhancements: enhancements/, rules/) or Layer 2 (Public API: brain.py, cli.py, daemon.py, mcp_server.py)
Never use bare except: pass — use typed exceptions or at minimum logger.warning(...) with exc_info=True to avoid silent failure in a memory product
Never import from out-of-scope sibling directories ../Sprites/ or ../Hausgem/ within gradata/* code — that is a layering bug
Never leak private-sibling paths into public docs/code — no references to ../Sprites/, ../Hausgem/, email addresses, OneDrive paths, or Sprites-specific examples from inside gradata/*
Use atomic-write helper when writing JSON files to prevent corruption from mid-write crashes

Files:

  • Gradata/src/gradata/cli.py
🪛 Ruff (0.15.13)
Gradata/tests/test_forget_command.py

[warning] 98-98: Unpacked variable brain is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)


[warning] 104-104: Unpacked variable brain is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)

🔇 Additional comments (3)
Gradata/src/gradata/cli.py (2)

340-341: LGTM!

Also applies to: 353-357


1669-1686: LGTM!

Also applies to: 1878-1878

Gradata/tests/test_forget_command.py (1)

16-97: LGTM!

Also applies to: 106-124

Comment on lines +592 to +595
if wl == "last" or wl.startswith("last "):
parts = wl.split()
n = int(parts[1]) if len(parts) == 2 and parts[1].isdigit() else 1
preview = [l for _, l in active[-n:]]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject invalid last N values before preview/apply.

last 0 currently resolves to active[-0:] (all active lessons), which can cause unintended mass rollback.

Suggested fix
     wl = what.lower()
+    normalized_what = what
     preview: list = []
     if wl == "last" or wl.startswith("last "):
         parts = wl.split()
-        n = int(parts[1]) if len(parts) == 2 and parts[1].isdigit() else 1
+        if len(parts) == 1:
+            n = 1
+        elif len(parts) == 2 and parts[1].isdigit() and int(parts[1]) >= 1:
+            n = int(parts[1])
+        else:
+            print("Error: selector must be 'last' or 'last N' where N >= 1")
+            sys.exit(2)
+        normalized_what = "last" if n == 1 else f"last {n}"
         preview = [l for _, l in active[-n:]]
@@
-    result = brain.forget(what)
+    result = brain.forget(normalized_what)

Also applies to: 627-627

🤖 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 `@Gradata/src/gradata/cli.py` around lines 592 - 595, The "last N" branch in
the CLI parsing (where wl == "last" or wl.startswith("last ")) currently parses
n with int(parts[1]) but allows 0 which makes active[-0:] return all entries;
update the validation in this branch (and the similar branch around the other
occurrence referenced) to reject non-positive values: parse parts[1], ensure it
is a digit and that int(parts[1]) >= 1, and if not raise/print a clear CLI error
or return without performing preview/apply instead of using n=1; modify the code
around the wl handling and the variable n (and the subsequent use of preview =
[l for _, l in active[-n:]]) to only execute when n >= 1.

Comment on lines +98 to +99
brain, brain_dir = seeded_brain
out = _run_forget(brain_dir, "nonexistent-phrase-zzz", yes=True, capsys=capsys)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove unused brain unpacking in no-match tests.

These unpacked variables are unused and trigger Ruff RUF059.

Suggested fix
 def test_forget_no_matches_returns_clean_error(seeded_brain, capsys):
-    brain, brain_dir = seeded_brain
+    _, brain_dir = seeded_brain
     out = _run_forget(brain_dir, "nonexistent-phrase-zzz", yes=True, capsys=capsys)
     assert "No active lessons match" in out or "No matches" in out


 def test_forget_unknown_category_says_no_matches(seeded_brain, capsys):
-    brain, brain_dir = seeded_brain
+    _, brain_dir = seeded_brain
     out = _run_forget(brain_dir, "all NONEXISTENT", yes=True, capsys=capsys)
     assert "No matches" in out or "No active lessons in" in out

Also applies to: 104-105

🧰 Tools
🪛 Ruff (0.15.13)

[warning] 98-98: Unpacked variable brain is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)

🤖 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 `@Gradata/tests/test_forget_command.py` around lines 98 - 99, The test unpacks
"brain, brain_dir = seeded_brain" but the "brain" variable is never used
(RUF059); update the test(s) that call seeded_brain (the occurrences around the
_run_forget calls) to only unpack the needed value (e.g., "_, brain_dir =
seeded_brain" or "brain_dir = seeded_brain[1]") or change the assignment to a
single-variable unpacking so that only brain_dir is assigned where _run_forget
is invoked; adjust both occurrences referenced (around lines with
_run_forget(brain_dir, "nonexistent-phrase-zzz", ...)) accordingly.

@Gradata Gradata merged commit 9ecfc66 into main May 20, 2026
9 checks passed
@Gradata Gradata deleted the feat/gradata-forget-subcommand branch May 20, 2026 07:02
Gradata pushed a commit that referenced this pull request May 20, 2026
Resolves conflicts in cli.py between cmd_prove (this branch) and cmd_forget
(PR #209 already merged to main). Both commands kept side-by-side. All
17 tests pass (status + forget + prove).
Gradata added a commit that referenced this pull request May 20, 2026
…RA-1198) (#211)

Replaces the two-path 'Claude Code via /plugin marketplace OR Python SDK
via pipx' branching with a single canonical install: pipx + gradata
install --agent <host>.

Why: the plugin marketplace path was a duplicate surface that did the
same thing the SDK install command already does (apply hooks + slash
commands to the host config). Two paths created onboarding friction
('which one am I supposed to use?') for zero functional gain. Council
voted Option A 'kill the plugin' on 2026-05-01.

Also surfaces the 6 first-class subcommands the SDK now ships:
status, correct, forget, prove, recall, doctor. Three of those were
shipped earlier today (PR #208 status, #209 forget, #210 prove) and
replaced the equivalent plugin slash commands.

Removed:
- '/plugin marketplace add Gradata/gradata' + '/plugin install gradata'
- The 'pick one' framing
- .claude-plugin/ from the repo layout (manifest stays in tree until
  PR retiring the directory ships separately — keeps the layout
  description accurate as of THIS commit)

Parent: GRA-1198 (kill the plugin epic)
GH: Gradata/gradata #206

Co-authored-by: data-engineer <data-engineer@gradata.ai>
Gradata added a commit that referenced this pull request May 20, 2026
…-1198) (#214)

The .claude-plugin/ directory itself was already removed in a prior cleanup
(see CHANGELOG: 'Remove orphaned gradata-plugin/ subdir (#54)'). What
remained were stale string references in docs and examples now that the
SDK ships all subcommands directly (PRs #208/#209/#210/#211 + #213).

Changes:
- .dockerignore: removed dead .claude-plugin exclude line
- examples/with_claude_code.py: replaced '/plugin install gradata' language
  with the canonical 'gradata install --agent claude-code'
- examples/README.md: fix broken link to .claude-plugin/README.md
- CHANGELOG.md: BREAKING entry under Unreleased documenting the retirement

This closes out the kill-the-plugin epic (GRA-1198 / GH #206) from the
references side. Anyone who installed via /plugin marketplace before
2026-05-20 must migrate to the SDK install path.

Verified:
- pip install /home/olive/work/gradata-sdk/Gradata in a fresh venv succeeds
- gradata install --agent claude-code --brain /tmp/test-brain --help works
- pytest tests/ -x -q passes (816 tests, 7 skipped, 1 known-skip on
  test_byo_key_provider for missing httpx in dev env unrelated to this)
- ruff check clean on touched files
- grep for 'claude-plugin|gradata-plugin' on src/ + docs/ shows only the
  intentional CHANGELOG entries (current BREAKING note + historical refs)

Branch authored by delegate_task subagent (hit max_iterations on PR-open);
parent agent verified + extracted clean diff + opened PR.

Co-authored-by: data-engineer <data-engineer@gradata.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant