Skip to content
Open
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: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The loop happens **across agent turns**, explicitly controlled by the extension'
4. **Loop Continuation**: The hook evaluates state (max iterations, promises) and instructs the CLI to start a new turn using the **original prompt** and clears the agent's memory from the previous turn.
5. **Repeat**: This continues autonomously until completion (max iterations, promises) or user interruption.

The `AfterAgent` hook in `hooks/stop-hook.sh` creates a **self-referential feedback loop** where:
The `AfterAgent` hook in `hooks/stop-hook.js` creates a **self-referential feedback loop** where:
- **Stable Context & No Compaction**: The prompt never changes between iterations, and the **previous turn's conversational context is cleared**. This forces the agent to rely on the current state of the files rather than potentially stale or "compacted" chat history, ensuring maximum focus and reliability.
- **Persistent State**: The agent's previous work persists in files and git history.
- **Autonomous Improvement**: Each iteration allows the agent to see the current state of the codebase and improve upon its past work.
Expand Down Expand Up @@ -43,7 +43,7 @@ To use Ralph, you must enable hooks and preview features in your `~/.gemini/sett
}
```

> **Note**: `includeDirectories` is required so that the Gemini CLI can access and execute Ralph's internal scripts (`setup.sh`, `cancel.sh`) and hook logic located in the extension's installation directory.
> **Note**: `includeDirectories` is required so that the Gemini CLI can access and execute Ralph's internal scripts (`setup.js`, `cancel.js`) and hook logic located in the extension's installation directory.

## Usage

Expand Down
2 changes: 1 addition & 1 deletion commands/ralph/cancel.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ You are stopping the Ralph loop.

Run the cancel script to deactivate the stop hook and clean up state:
```bash
bash "${extensionPath}/scripts/cancel.sh"
node "${extensionPath}/scripts/cancel.js"
```
"""
4 changes: 2 additions & 2 deletions commands/ralph/loop.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ You are starting the Ralph loop.
**Step 1: Initialization**
Run the setup script to initialize the loop state:
```bash
bash "${extensionPath}/scripts/setup.sh" $ARGUMENTS
node "${extensionPath}/scripts/setup.js" $ARGUMENTS
```

**Supported Arguments for setup.sh:**
**Supported Arguments for setup.js:**
- `--max-iterations <N>`: Maximum number of loop iterations.
- `--completion-promise <TEXT>`: A text token that must be output to finish.

Expand Down
2 changes: 1 addition & 1 deletion hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
{
"name": "ralph-loop",
"type": "command",
"command": "${extensionPath}/hooks/stop-hook.sh",
"command": "node ${extensionPath}/hooks/stop-hook.js",
"description": "The Ralph infinite loop mechanism"
}
]
Expand Down
160 changes: 160 additions & 0 deletions hooks/stop-hook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#!/usr/bin/env node
// Copyright 2026 Google LLC
//
// 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
//
// https://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.

const fs = require('fs');
const path = require('path');

const STATE_DIR = path.join('.gemini', 'ralph');
const STATE_FILE = path.join(STATE_DIR, 'state.json');

function log(message) {
console.error(`Ralph: ${message}`);
}

function cleanupStateDir() {
if (fs.existsSync(STATE_DIR)) {
try {
fs.rmdirSync(STATE_DIR);
} catch {
// Directory not empty, ignore
}
}
}

function readStdin() {
return new Promise((resolve) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
data += chunk;
});
process.stdin.on('end', () => {
resolve(data);
});
});
}

async function main() {
const input = await readStdin();
let lastMessage = '';
let currentPrompt = '';

try {
const parsed = JSON.parse(input);
lastMessage = parsed.prompt_response || '';
currentPrompt = parsed.prompt || '';
} catch {
// Ignore parse errors
}

// Check if loop is active
if (!fs.existsSync(STATE_FILE)) {
console.log(JSON.stringify({ decision: 'allow' }));
process.exit(0);
}

// Load state
let state;
try {
state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
} catch {
console.log(JSON.stringify({ decision: 'allow' }));
process.exit(0);
}

const originalPrompt = state.original_prompt || '';

// Validate that this turn belongs to the Ralph loop
if (currentPrompt !== originalPrompt) {
// Normalize prompts for comparison by stripping prefix and extra whitespace
const cleanCurrent = currentPrompt
.replace(/^\/ralph:loop\s+/, '')
.replace(/--max-iterations\s+\S+\s*/g, '')
.replace(/--completion-promise\s+\S+\s*/g, '')
.trim();
const cleanOriginal = originalPrompt.trim();

// Only perform mismatch check if a prompt was actually provided.
// Automated retries (like loop iterations) often have an empty prompt in the hook input.
if (cleanCurrent !== '' && cleanCurrent !== cleanOriginal) {
fs.unlinkSync(STATE_FILE);
cleanupStateDir();
console.log(JSON.stringify({
decision: 'allow',
systemMessage: `🚨 Ralph detected a prompt mismatch.\nExpected: '${cleanOriginal}'\nGot: '${cleanCurrent}'`
}));
process.exit(0);
}
}

const active = state.active;

if (active !== true) {
console.log(JSON.stringify({ decision: 'allow' }));
process.exit(0);
}

// Check for completion promise BEFORE incrementing/continuing
const completionPromise = state.completion_promise || '';
if (completionPromise && lastMessage.includes(`<promise>${completionPromise}</promise>`)) {
fs.unlinkSync(STATE_FILE);
cleanupStateDir();
log(`I found a shiny penny! It says ${completionPromise}. The computer is sleeping now.`);
console.log(JSON.stringify({
decision: 'allow',
continue: false,
stopReason: `✅ Ralph found the completion promise: ${completionPromise}`,
systemMessage: `✅ Ralph found the completion promise: ${completionPromise}`
}));
process.exit(0);
}

const currentIteration = state.current_iteration || 0;
const maxIterations = state.max_iterations || 5;

// Check for max iterations
if (currentIteration >= maxIterations) {
fs.unlinkSync(STATE_FILE);
cleanupStateDir();
log(`I'm tired. I've gone around ${currentIteration} times. The computer is sleeping now.`);
console.log(JSON.stringify({
decision: 'allow',
continue: false,
stopReason: '✅ Ralph has reached the iteration limit.',
systemMessage: '✅ Ralph has reached the iteration limit.'
}));
process.exit(0);
}

// Increment iteration
const newIteration = currentIteration + 1;
state.current_iteration = newIteration;
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));

// Log progress (persona)
log(`I'm doing a circle! Iteration ${currentIteration} is done.`);

// Maintain the loop by forcing a retry with the original prompt
console.log(JSON.stringify({
decision: 'deny',
reason: originalPrompt,
systemMessage: `🔄 Ralph is starting iteration ${newIteration}...`,
clearContext: true
}));

process.exit(0);
}

main();
126 changes: 0 additions & 126 deletions hooks/stop-hook.sh

This file was deleted.

36 changes: 36 additions & 0 deletions scripts/cancel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env node
// Copyright 2026 Google LLC
//
// 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
//
// https://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.

const fs = require('fs');
const path = require('path');

const STATE_DIR = path.join('.gemini', 'ralph');
const STATE_FILE = path.join(STATE_DIR, 'state.json');

if (fs.existsSync(STATE_FILE)) {
fs.unlinkSync(STATE_FILE);
console.error("Ralph: I've stopped my loop and cleaned up my toys.");
} else {
console.error("Ralph: I wasn't doing anything anyway!");
}

// Only remove directory if it is empty
if (fs.existsSync(STATE_DIR)) {
try {
fs.rmdirSync(STATE_DIR);
} catch {
// Directory not empty, ignore
}
}
Loading