gemini-coder 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/cli.js +63 -0
- package/dist/commands/commit.js +70 -0
- package/dist/commands/context.js +26 -0
- package/dist/commands/diff.js +22 -0
- package/dist/commands/index.js +22 -0
- package/dist/commands/plan.js +17 -0
- package/dist/commands/review.js +22 -0
- package/dist/commands/simplify.js +11 -0
- package/dist/commands/test.js +12 -0
- package/dist/commands/undo.js +22 -0
- package/dist/commands/usage.js +20 -0
- package/dist/core/ai.js +33 -0
- package/dist/core/commands.js +12 -0
- package/dist/core/context.js +47 -0
- package/dist/core/diff.js +113 -0
- package/dist/core/orchestrator.js +398 -0
- package/dist/core/session.js +69 -0
- package/dist/core/tools.js +71 -0
- package/dist/test/test-diff-command.js +7 -0
- package/dist/ui/terminal.js +62 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Gemini Coder Pro
|
|
2
|
+
|
|
3
|
+
Gemini Coder Pro is a TypeScript CLI agent for local code navigation, review, and surgical edits.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g gemini-coder
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Run
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
gemini-coder chat
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Build from source
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install
|
|
21
|
+
npm run build
|
|
22
|
+
npm start
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Authentication
|
|
26
|
+
|
|
27
|
+
This tool expects a local `gemini.json` service account file in the project root when run from source. Do not publish that file with the package.
|
|
28
|
+
|
|
29
|
+
## Notes
|
|
30
|
+
|
|
31
|
+
- The published package includes the compiled `dist/` output only.
|
|
32
|
+
- Users should run the CLI inside their own workspace because the agent reads local files and git context.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import Table from 'cli-table3';
|
|
5
|
+
import { SessionManager } from './core/session.js';
|
|
6
|
+
import { Orchestrator } from './core/orchestrator.js';
|
|
7
|
+
import { printBootScreen } from './ui/terminal.js';
|
|
8
|
+
const program = new Command();
|
|
9
|
+
const sessionManager = new SessionManager();
|
|
10
|
+
program
|
|
11
|
+
.name('gemini')
|
|
12
|
+
.description('Gemini Coder Pro CLI - Advanced AI Coding Agent')
|
|
13
|
+
.version('0.2.0');
|
|
14
|
+
program
|
|
15
|
+
.command('chat', { isDefault: true })
|
|
16
|
+
.description('Start an interactive chat session')
|
|
17
|
+
.option('-p, --prompt <query>', 'Start with an initial prompt')
|
|
18
|
+
.option('-c, --continue', 'Continue the most recent session')
|
|
19
|
+
.option('-m, --model <name>', 'Specify the model to use', 'gemini-3.1-pro-preview')
|
|
20
|
+
.action(async (options) => {
|
|
21
|
+
printBootScreen('Gemini Coder Pro', 'v0.2.0');
|
|
22
|
+
let session;
|
|
23
|
+
if (options.continue) {
|
|
24
|
+
session = await sessionManager.getLatestSession();
|
|
25
|
+
if (session) {
|
|
26
|
+
console.log(chalk.green(`ā Resuming session: ${session.id}`));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!session) {
|
|
30
|
+
session = await sessionManager.createSession();
|
|
31
|
+
}
|
|
32
|
+
const orchestrator = new Orchestrator(session, sessionManager, options.model);
|
|
33
|
+
await orchestrator.initialize();
|
|
34
|
+
if (options.prompt) {
|
|
35
|
+
orchestrator['session'].history.push({ role: 'user', parts: [{ text: options.prompt }] });
|
|
36
|
+
await orchestrator['processTurn'](0);
|
|
37
|
+
}
|
|
38
|
+
await orchestrator.chat();
|
|
39
|
+
});
|
|
40
|
+
program
|
|
41
|
+
.command('list')
|
|
42
|
+
.description('List recent chat sessions')
|
|
43
|
+
.action(async () => {
|
|
44
|
+
const sessions = await sessionManager.listSessions();
|
|
45
|
+
if (sessions.length === 0) {
|
|
46
|
+
console.log(chalk.yellow('No sessions found.'));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
console.log(chalk.bold.blue('\nš Recent Sessions:'));
|
|
50
|
+
const table = new Table({
|
|
51
|
+
head: [chalk.cyan('Session ID'), chalk.cyan('Last Updated'), chalk.cyan('Tokens (Total)')],
|
|
52
|
+
style: { head: [], border: [] }
|
|
53
|
+
});
|
|
54
|
+
sessions.forEach(s => {
|
|
55
|
+
table.push([
|
|
56
|
+
s.id,
|
|
57
|
+
new Date(s.updatedAt).toLocaleString(),
|
|
58
|
+
s.tokens?.total?.toLocaleString() || '0'
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
console.log(table.toString());
|
|
62
|
+
});
|
|
63
|
+
program.parse();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { rl } from '../core/orchestrator.js';
|
|
2
|
+
import { tools } from '../core/tools.js';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
export class CommitHandler {
|
|
5
|
+
name = 'commit';
|
|
6
|
+
description = 'Auto-generate a commit message and commit changes';
|
|
7
|
+
async execute(orchestrator) {
|
|
8
|
+
try {
|
|
9
|
+
let diffOutput = await tools.run_command({ command: 'git diff --cached' });
|
|
10
|
+
if (!diffOutput.stdout || diffOutput.stdout.trim() === '') {
|
|
11
|
+
const unstageDiff = await tools.run_command({ command: 'git diff' });
|
|
12
|
+
if (!unstageDiff.stdout || unstageDiff.stdout.trim() === '') {
|
|
13
|
+
console.log(chalk.yellow('No changes to commit.'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const answer = await rl.question(chalk.yellow('No staged changes. Stage all tracked changes (git add -u)? (y/n) '));
|
|
17
|
+
if (answer.toLowerCase() === 'y') {
|
|
18
|
+
await tools.run_command({ command: 'git add -u' });
|
|
19
|
+
diffOutput = await tools.run_command({ command: 'git diff --cached' });
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
console.log(chalk.yellow('Aborting.'));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
console.log(chalk.cyan('ā Generating commit message...'));
|
|
27
|
+
await orchestrator.injectMessage({
|
|
28
|
+
role: 'user',
|
|
29
|
+
parts: [{ text: `Generate a concise, conventional commit message for these changes:\n\n${diffOutput.stdout}\n\nFormat: <type>(<scope>): <subject>\n\n<body>` }]
|
|
30
|
+
});
|
|
31
|
+
await orchestrator.processTurn(0);
|
|
32
|
+
const lastMessage = orchestrator.session.history[orchestrator.session.history.length - 1];
|
|
33
|
+
let proposedMessage = '';
|
|
34
|
+
if (lastMessage.role === 'model' && lastMessage.parts) {
|
|
35
|
+
proposedMessage = lastMessage.parts.filter((p) => p.text).map((p) => p.text).join('').trim();
|
|
36
|
+
}
|
|
37
|
+
if (!proposedMessage) {
|
|
38
|
+
console.log(chalk.red('ā Failed to generate message.'));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
console.log(chalk.bold('\nProposed Message:'));
|
|
42
|
+
console.log(chalk.green(proposedMessage));
|
|
43
|
+
const answer = await rl.question(chalk.yellow('\n[y] Commit [e] Edit [n] Abort: '));
|
|
44
|
+
const cmd = answer.toLowerCase().trim();
|
|
45
|
+
if (cmd === 'y') {
|
|
46
|
+
const fs = await import('fs/promises');
|
|
47
|
+
const tmpPath = '.gemini-commit-msg.tmp';
|
|
48
|
+
await fs.writeFile(tmpPath, proposedMessage);
|
|
49
|
+
const result = await tools.run_command({ command: `git commit -F ${tmpPath}` });
|
|
50
|
+
await fs.unlink(tmpPath).catch(() => { });
|
|
51
|
+
if (result.exitCode === 0) {
|
|
52
|
+
console.log(chalk.green('ā Commit successful'));
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
console.log(chalk.red('ā Commit failed'));
|
|
56
|
+
console.log(result.stderr || result.stdout);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else if (cmd === 'e') {
|
|
60
|
+
console.log(chalk.yellow('Manual commit required.'));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
console.log(chalk.yellow('Aborted.'));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
console.error(chalk.red(`ā Error: ${error.message}`));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
export class ContextHandler {
|
|
3
|
+
name = 'context';
|
|
4
|
+
description = 'Show which files you have read this session';
|
|
5
|
+
async execute(orchestrator) {
|
|
6
|
+
const readFiles = new Set();
|
|
7
|
+
for (const message of orchestrator.session.history) {
|
|
8
|
+
if (message.role === 'model' && message.parts) {
|
|
9
|
+
for (const part of message.parts) {
|
|
10
|
+
if (part.functionCall && part.functionCall.name === 'read_files' && part.functionCall.args) {
|
|
11
|
+
const paths = part.functionCall.args.paths;
|
|
12
|
+
paths.forEach(p => readFiles.add(p));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (readFiles.size === 0) {
|
|
18
|
+
console.log(chalk.yellow('No files read this session.'));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
console.log(chalk.bold('\nFiles read this session:'));
|
|
22
|
+
Array.from(readFiles).sort().forEach(f => {
|
|
23
|
+
console.log(chalk.cyan(` ⢠${f}`));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { tools } from '../core/tools.js';
|
|
2
|
+
import { showInteractiveDiff } from '../core/diff.js';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
export class DiffHandler {
|
|
5
|
+
name = 'diff';
|
|
6
|
+
description = 'Show interactive diff of all uncommitted changes';
|
|
7
|
+
async execute(orchestrator) {
|
|
8
|
+
try {
|
|
9
|
+
const cachedDiff = await tools.run_command({ command: 'git diff --cached' });
|
|
10
|
+
const unstagedDiff = await tools.run_command({ command: 'git diff' });
|
|
11
|
+
const fullDiff = (cachedDiff.stdout || '') + (unstagedDiff.stdout || '');
|
|
12
|
+
if (!fullDiff.trim()) {
|
|
13
|
+
console.log(chalk.yellow('No uncommitted changes.'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
await showInteractiveDiff(fullDiff);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
console.error(chalk.red(`[Diff Error]: ${error.message}`));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { CommandRegistry } from '../core/commands.js';
|
|
2
|
+
import { PlanHandler } from './plan.js';
|
|
3
|
+
import { DiffHandler } from './diff.js';
|
|
4
|
+
import { ReviewHandler, SecurityReviewHandler } from './review.js';
|
|
5
|
+
import { SimplifyHandler } from './simplify.js';
|
|
6
|
+
import { UsageHandler } from './usage.js';
|
|
7
|
+
import { CommitHandler } from './commit.js';
|
|
8
|
+
import { TestHandler } from './test.js';
|
|
9
|
+
import { UndoHandler } from './undo.js';
|
|
10
|
+
import { ContextHandler } from './context.js';
|
|
11
|
+
export function registerAllCommands() {
|
|
12
|
+
CommandRegistry.register(new PlanHandler());
|
|
13
|
+
CommandRegistry.register(new DiffHandler());
|
|
14
|
+
CommandRegistry.register(new ReviewHandler());
|
|
15
|
+
CommandRegistry.register(new SecurityReviewHandler());
|
|
16
|
+
CommandRegistry.register(new SimplifyHandler());
|
|
17
|
+
CommandRegistry.register(new UsageHandler());
|
|
18
|
+
CommandRegistry.register(new CommitHandler());
|
|
19
|
+
CommandRegistry.register(new TestHandler());
|
|
20
|
+
CommandRegistry.register(new UndoHandler());
|
|
21
|
+
CommandRegistry.register(new ContextHandler());
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { OrchestratorMode } from '../core/orchestrator.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
export class PlanHandler {
|
|
4
|
+
name = 'plan';
|
|
5
|
+
description = 'Enter read-only mode. Research and plan only, no edits.';
|
|
6
|
+
async execute(orchestrator) {
|
|
7
|
+
const currentMode = orchestrator.getMode();
|
|
8
|
+
const newMode = currentMode === OrchestratorMode.PLAN
|
|
9
|
+
? OrchestratorMode.NORMAL
|
|
10
|
+
: OrchestratorMode.PLAN;
|
|
11
|
+
orchestrator.setMode(newMode);
|
|
12
|
+
console.log(chalk.blue.bold(`\n[Mode]: Switched to ${newMode}`));
|
|
13
|
+
if (newMode === OrchestratorMode.PLAN) {
|
|
14
|
+
console.log(chalk.blue('Tool calls that modify state will be intercepted. Edits will not be applied.'));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export class ReviewHandler {
|
|
2
|
+
name = 'review';
|
|
3
|
+
description = 'Deep security and code quality review. Read-only.';
|
|
4
|
+
async execute(orchestrator) {
|
|
5
|
+
await orchestrator.injectMessage({
|
|
6
|
+
role: 'user',
|
|
7
|
+
parts: [{ text: "Perform a deep read-only code review. Focus on maintainability, performance, and idiomatic TypeScript. Output an analytical report. Do not propose edits." }]
|
|
8
|
+
});
|
|
9
|
+
await orchestrator.processTurn(0);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class SecurityReviewHandler {
|
|
13
|
+
name = 'security-review';
|
|
14
|
+
description = 'Focused security review. Read-only.';
|
|
15
|
+
async execute(orchestrator) {
|
|
16
|
+
await orchestrator.injectMessage({
|
|
17
|
+
role: 'user',
|
|
18
|
+
parts: [{ text: "Perform a focused security review. Identify vulnerabilities (injection, secrets, insecure config). Output an analytical report. Do not propose edits." }]
|
|
19
|
+
});
|
|
20
|
+
await orchestrator.processTurn(0);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export class SimplifyHandler {
|
|
2
|
+
name = 'simplify';
|
|
3
|
+
description = 'Analyze and simplify complex code areas';
|
|
4
|
+
async execute(orchestrator) {
|
|
5
|
+
await orchestrator.injectMessage({
|
|
6
|
+
role: 'user',
|
|
7
|
+
parts: [{ text: "Analyze recently edited files. Identify high complexity or 'code smells'. Propose simplified refactor for one area. Verify with tests." }]
|
|
8
|
+
});
|
|
9
|
+
await orchestrator.processTurn(0);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class TestHandler {
|
|
2
|
+
name = 'test';
|
|
3
|
+
description = 'Trigger self-healing test loop until all tests pass';
|
|
4
|
+
async execute(orchestrator, args) {
|
|
5
|
+
const testCommand = args.length > 0 ? args.join(' ') : 'npm test';
|
|
6
|
+
await orchestrator.injectMessage({
|
|
7
|
+
role: 'user',
|
|
8
|
+
parts: [{ text: `Execute test command: \`${testCommand}\`. If failure occurs, diagnose error, apply surgical fixes, and retry until pass.` }]
|
|
9
|
+
});
|
|
10
|
+
await orchestrator.processTurn(0);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
export class UndoHandler {
|
|
4
|
+
name = 'undo';
|
|
5
|
+
description = 'Revert last applied edit';
|
|
6
|
+
async execute(orchestrator) {
|
|
7
|
+
const lastEdit = orchestrator.appliedEdits.pop();
|
|
8
|
+
if (!lastEdit) {
|
|
9
|
+
console.log(chalk.yellow('No edits to undo.'));
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
await fs.writeFile(lastEdit.path, lastEdit.originalContent, 'utf8');
|
|
14
|
+
console.log(chalk.green(`\nā Reverted change to ${lastEdit.path}`));
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
console.error(chalk.red(`\nā Failed to undo: ${error.message}`));
|
|
18
|
+
// Put it back if it failed?
|
|
19
|
+
orchestrator.appliedEdits.push(lastEdit);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
export class UsageHandler {
|
|
3
|
+
name = 'usage';
|
|
4
|
+
description = 'Show token usage and estimated cost for the current session';
|
|
5
|
+
async execute(orchestrator) {
|
|
6
|
+
const tokens = orchestrator.session.tokens || { prompt: 0, candidates: 0, total: 0 };
|
|
7
|
+
// Cost estimation based on standard Gemini 1.5 Pro/Flash pricing (rough estimate)
|
|
8
|
+
// Adjust logic as needed. Assume $3.50 per 1M prompt, $10.50 per 1M candidates for Pro
|
|
9
|
+
const costPerMillionPrompt = 3.50;
|
|
10
|
+
const costPerMillionCandidates = 10.50;
|
|
11
|
+
const promptCost = (tokens.prompt / 1_000_000) * costPerMillionPrompt;
|
|
12
|
+
const candidatesCost = (tokens.candidates / 1_000_000) * costPerMillionCandidates;
|
|
13
|
+
const totalCost = promptCost + candidatesCost;
|
|
14
|
+
console.log(chalk.bold('\nTOKEN USAGE'));
|
|
15
|
+
console.log(chalk.cyan(`⢠Input: ${tokens.prompt.toLocaleString()}`));
|
|
16
|
+
console.log(chalk.cyan(`⢠Output: ${tokens.candidates.toLocaleString()}`));
|
|
17
|
+
console.log(chalk.cyan(`⢠Total: ${tokens.total.toLocaleString()}`));
|
|
18
|
+
console.log(chalk.green(`⢠Cost: $${totalCost.toFixed(4)}\n`));
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/core/ai.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { GoogleGenAI } from '@google/genai';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
function findProjectRoot(startPath) {
|
|
6
|
+
let currentPath = startPath;
|
|
7
|
+
while (currentPath !== path.parse(currentPath).root) {
|
|
8
|
+
if (fs.existsSync(path.join(currentPath, 'package.json'))) {
|
|
9
|
+
return currentPath;
|
|
10
|
+
}
|
|
11
|
+
currentPath = path.dirname(currentPath);
|
|
12
|
+
}
|
|
13
|
+
throw new Error('Could not find project root containing package.json');
|
|
14
|
+
}
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = path.dirname(__filename);
|
|
17
|
+
const projectRoot = findProjectRoot(__dirname);
|
|
18
|
+
const credentialsPath = path.join(projectRoot, 'gemini.json');
|
|
19
|
+
if (!fs.existsSync(credentialsPath)) {
|
|
20
|
+
console.error(`Error: gemini.json not found at expected path: ${credentialsPath}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const config = JSON.parse(fs.readFileSync(credentialsPath, 'utf8'));
|
|
24
|
+
const location = config.location ?? process.env.GOOGLE_CLOUD_LOCATION ?? 'global';
|
|
25
|
+
export const client = new GoogleGenAI({
|
|
26
|
+
project: config.project_id,
|
|
27
|
+
location,
|
|
28
|
+
vertexai: true,
|
|
29
|
+
googleAuthOptions: {
|
|
30
|
+
keyFile: credentialsPath,
|
|
31
|
+
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
|
32
|
+
}
|
|
33
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class CommandRegistry {
|
|
2
|
+
static commands = new Map();
|
|
3
|
+
static register(handler) {
|
|
4
|
+
this.commands.set(handler.name, handler);
|
|
5
|
+
}
|
|
6
|
+
static get(name) {
|
|
7
|
+
return this.commands.get(name);
|
|
8
|
+
}
|
|
9
|
+
static getAll() {
|
|
10
|
+
return Array.from(this.commands.values());
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { readFile } from 'fs/promises';
|
|
4
|
+
import { glob } from 'glob';
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
async function getSignature(filePath) {
|
|
7
|
+
const keyExtensions = ['.ts', '.js', '.json', '.md'];
|
|
8
|
+
if (!keyExtensions.some(ext => filePath.endsWith(ext))) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
const content = await readFile(filePath, 'utf-8');
|
|
13
|
+
const lines = content.split('\n').slice(0, 10);
|
|
14
|
+
return lines.join('\n');
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function getContextMap() {
|
|
21
|
+
let files = [];
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await execAsync('git ls-files --cached --others --exclude-standard');
|
|
24
|
+
files = stdout.split('\n').filter(f => f.trim().length > 0);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
// Fallback to glob if not a git repository
|
|
28
|
+
files = await glob('**/*', {
|
|
29
|
+
ignore: ['node_modules/**', 'dist/**', '.git/**', 'package-lock.json'],
|
|
30
|
+
nodir: true
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const results = [];
|
|
34
|
+
const batchSize = 50;
|
|
35
|
+
for (let i = 0; i < files.length; i += batchSize) {
|
|
36
|
+
const batch = files.slice(i, i + batchSize);
|
|
37
|
+
const batchResults = await Promise.all(batch.map(async (file) => {
|
|
38
|
+
const signature = await getSignature(file);
|
|
39
|
+
if (signature) {
|
|
40
|
+
return `${file}:\n[Signature]\n${signature}\n---`;
|
|
41
|
+
}
|
|
42
|
+
return file;
|
|
43
|
+
}));
|
|
44
|
+
results.push(...batchResults);
|
|
45
|
+
}
|
|
46
|
+
return results.join('\n');
|
|
47
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import * as diff from 'diff';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { rl } from './orchestrator.js';
|
|
5
|
+
function escapeRegExp(string) {
|
|
6
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
7
|
+
}
|
|
8
|
+
function flexibleSearch(content, search) {
|
|
9
|
+
const exactIndex = content.indexOf(search);
|
|
10
|
+
if (exactIndex !== -1) {
|
|
11
|
+
return { start: exactIndex, end: exactIndex + search.length };
|
|
12
|
+
}
|
|
13
|
+
const tokens = search.trim().split(/\s+/);
|
|
14
|
+
if (tokens.length === 0)
|
|
15
|
+
return null;
|
|
16
|
+
const pattern = tokens.map(escapeRegExp).join('\\s+');
|
|
17
|
+
const regex = new RegExp(pattern);
|
|
18
|
+
const match = content.match(regex);
|
|
19
|
+
if (match && match.index !== undefined) {
|
|
20
|
+
return { start: match.index, end: match.index + match[0].length };
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
export async function showDiff(path, search, replace, action = 'Replace content', reason = 'Requested change') {
|
|
25
|
+
try {
|
|
26
|
+
const content = await fs.readFile(path, 'utf8');
|
|
27
|
+
const match = flexibleSearch(content, search);
|
|
28
|
+
if (!match) {
|
|
29
|
+
console.log(chalk.red(`\n[Diff Error]: Search block not found in ${path}`));
|
|
30
|
+
return { success: false };
|
|
31
|
+
}
|
|
32
|
+
const newContent = content.slice(0, match.start) + replace + content.slice(match.end);
|
|
33
|
+
while (true) {
|
|
34
|
+
// ... same boxed UI code ...
|
|
35
|
+
const boxWidth = 50;
|
|
36
|
+
const border = chalk.cyan('ā' + 'ā'.repeat(boxWidth - 2) + 'ā');
|
|
37
|
+
const divider = chalk.cyan('ā' + 'ā'.repeat(boxWidth - 2) + 'ā¤');
|
|
38
|
+
const bottom = chalk.cyan('ā' + 'ā'.repeat(boxWidth - 2) + 'ā');
|
|
39
|
+
const pipe = chalk.cyan('ā');
|
|
40
|
+
const padLine = (label, value) => {
|
|
41
|
+
const line = ` ${label}: ${value}`;
|
|
42
|
+
const remaining = boxWidth - 3 - line.length;
|
|
43
|
+
return pipe + line + (remaining > 0 ? ' '.repeat(remaining) : '') + pipe;
|
|
44
|
+
};
|
|
45
|
+
console.log('\n' + border);
|
|
46
|
+
console.log(pipe + chalk.bold(' PROPOSED EDIT'.padEnd(boxWidth - 3)) + pipe);
|
|
47
|
+
console.log(padLine('File', path));
|
|
48
|
+
console.log(padLine('Action', action));
|
|
49
|
+
console.log(padLine('Reason', reason));
|
|
50
|
+
console.log(divider);
|
|
51
|
+
console.log(pipe + chalk.yellow(' [y] Apply [n] Skip [d] Diff'.padEnd(boxWidth - 3)) + pipe);
|
|
52
|
+
console.log(bottom);
|
|
53
|
+
const answer = await rl.question(chalk.yellow('Choice: '));
|
|
54
|
+
const cmd = answer.toLowerCase().trim();
|
|
55
|
+
if (cmd === 'y') {
|
|
56
|
+
await fs.writeFile(path, newContent, 'utf8');
|
|
57
|
+
return { success: true, originalContent: content };
|
|
58
|
+
}
|
|
59
|
+
else if (cmd === 'n') {
|
|
60
|
+
return { success: false };
|
|
61
|
+
}
|
|
62
|
+
else if (cmd === 'd') {
|
|
63
|
+
// ... same diff code ...
|
|
64
|
+
const patch = diff.createPatch(path, content, newContent);
|
|
65
|
+
console.log(chalk.bold(`\n${chalk.cyan(path)}`));
|
|
66
|
+
const lines = patch.split('\n');
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('Index:')) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (line.startsWith('+')) {
|
|
72
|
+
process.stdout.write(chalk.green(line + '\n'));
|
|
73
|
+
}
|
|
74
|
+
else if (line.startsWith('-')) {
|
|
75
|
+
process.stdout.write(chalk.red(line + '\n'));
|
|
76
|
+
}
|
|
77
|
+
else if (line.startsWith('@@')) {
|
|
78
|
+
process.stdout.write(chalk.cyan(line + '\n'));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
process.stdout.write(line + '\n');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
console.error(chalk.red(`\n[Diff Error]: ${error.message}`));
|
|
89
|
+
return { success: false };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export async function showInteractiveDiff(gitDiffOutput) {
|
|
93
|
+
if (!gitDiffOutput)
|
|
94
|
+
return;
|
|
95
|
+
const lines = gitDiffOutput.split('\n');
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('Index:') || line.startsWith('diff --git')) {
|
|
98
|
+
process.stdout.write(chalk.bold(line + '\n'));
|
|
99
|
+
}
|
|
100
|
+
else if (line.startsWith('+')) {
|
|
101
|
+
process.stdout.write(chalk.green(line + '\n'));
|
|
102
|
+
}
|
|
103
|
+
else if (line.startsWith('-')) {
|
|
104
|
+
process.stdout.write(chalk.red(line + '\n'));
|
|
105
|
+
}
|
|
106
|
+
else if (line.startsWith('@@')) {
|
|
107
|
+
process.stdout.write(chalk.cyan(line + '\n'));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
process.stdout.write(line + '\n');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { client as aiClient } from './ai.js';
|
|
2
|
+
import { getContextMap } from './context.js';
|
|
3
|
+
import { tools } from './tools.js';
|
|
4
|
+
import { showDiff } from './diff.js';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
import { Type } from '@google/genai';
|
|
8
|
+
import * as readline from 'readline/promises';
|
|
9
|
+
import { CommandRegistry } from './commands.js';
|
|
10
|
+
import { registerAllCommands } from '../commands/index.js';
|
|
11
|
+
import fs from 'fs/promises';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { marked } from 'marked';
|
|
14
|
+
import { markedTerminal } from 'marked-terminal';
|
|
15
|
+
import inquirer from 'inquirer';
|
|
16
|
+
import { getPromptText, printAssistantResponse, printHelp, printModeChange, printBox } from '../ui/terminal.js';
|
|
17
|
+
// Configure marked to use terminal rendering
|
|
18
|
+
marked.use(markedTerminal());
|
|
19
|
+
export const rl = readline.createInterface({
|
|
20
|
+
input: process.stdin,
|
|
21
|
+
output: process.stdout,
|
|
22
|
+
});
|
|
23
|
+
export var OrchestratorMode;
|
|
24
|
+
(function (OrchestratorMode) {
|
|
25
|
+
OrchestratorMode["NORMAL"] = "NORMAL";
|
|
26
|
+
OrchestratorMode["PLAN"] = "PLAN";
|
|
27
|
+
})(OrchestratorMode || (OrchestratorMode = {}));
|
|
28
|
+
const functionDeclarations = [
|
|
29
|
+
{
|
|
30
|
+
name: 'read_files',
|
|
31
|
+
description: 'Read the contents of one or more files.',
|
|
32
|
+
parameters: {
|
|
33
|
+
type: Type.OBJECT,
|
|
34
|
+
properties: {
|
|
35
|
+
paths: {
|
|
36
|
+
type: Type.ARRAY,
|
|
37
|
+
items: { type: Type.STRING },
|
|
38
|
+
description: 'The paths of the files to read.',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
required: ['paths'],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'run_command',
|
|
46
|
+
description: 'Execute a shell command in the local terminal.',
|
|
47
|
+
parameters: {
|
|
48
|
+
type: Type.OBJECT,
|
|
49
|
+
properties: {
|
|
50
|
+
command: {
|
|
51
|
+
type: Type.STRING,
|
|
52
|
+
description: 'The shell command to execute.',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
required: ['command'],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'grep_search',
|
|
60
|
+
description: 'Search for a pattern across the codebase (grep).',
|
|
61
|
+
parameters: {
|
|
62
|
+
type: Type.OBJECT,
|
|
63
|
+
properties: {
|
|
64
|
+
pattern: { type: Type.STRING, description: 'The regex pattern to search for.' },
|
|
65
|
+
include: { type: Type.STRING, description: 'Optional glob for files to include (e.g. "*.ts").' },
|
|
66
|
+
},
|
|
67
|
+
required: ['pattern'],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'list_directory',
|
|
72
|
+
description: 'List the contents of a specific directory.',
|
|
73
|
+
parameters: {
|
|
74
|
+
type: Type.OBJECT,
|
|
75
|
+
properties: {
|
|
76
|
+
path: { type: Type.STRING, description: 'The directory path (default ".").' },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'propose_edits',
|
|
82
|
+
description: 'Propose surgical edits to files using search/replace blocks.',
|
|
83
|
+
parameters: {
|
|
84
|
+
type: Type.OBJECT,
|
|
85
|
+
properties: {
|
|
86
|
+
edits: {
|
|
87
|
+
type: Type.ARRAY,
|
|
88
|
+
items: {
|
|
89
|
+
type: Type.OBJECT,
|
|
90
|
+
properties: {
|
|
91
|
+
path: { type: Type.STRING },
|
|
92
|
+
search: { type: Type.STRING, description: 'The EXACT literal text to find.' },
|
|
93
|
+
replace: { type: Type.STRING, description: 'The text to replace it with.' },
|
|
94
|
+
action: { type: Type.STRING, description: 'Brief description of the action (e.g. "Replace lines 10-15").' },
|
|
95
|
+
reason: { type: Type.STRING, description: 'Technical reason for this change.' },
|
|
96
|
+
},
|
|
97
|
+
required: ['path', 'search', 'replace', 'action', 'reason'],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
required: ['edits'],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
const MAX_TURNS = 50;
|
|
106
|
+
const MAX_RETRIES = 3;
|
|
107
|
+
export class Orchestrator {
|
|
108
|
+
session;
|
|
109
|
+
sessionManager;
|
|
110
|
+
mode = OrchestratorMode.NORMAL;
|
|
111
|
+
model;
|
|
112
|
+
appliedEdits = [];
|
|
113
|
+
constructor(session, sessionManager, model = 'gemini-3.1-pro-preview') {
|
|
114
|
+
this.session = session;
|
|
115
|
+
this.sessionManager = sessionManager;
|
|
116
|
+
this.model = model;
|
|
117
|
+
}
|
|
118
|
+
async initialize() {
|
|
119
|
+
// Register all slash commands
|
|
120
|
+
registerAllCommands();
|
|
121
|
+
// Initialize system prompt if new session
|
|
122
|
+
if (this.session.history.length === 0) {
|
|
123
|
+
const systemPrompt = await this.loadSystemPrompt();
|
|
124
|
+
this.session.history.push({
|
|
125
|
+
role: 'user',
|
|
126
|
+
parts: [{
|
|
127
|
+
text: systemPrompt
|
|
128
|
+
}]
|
|
129
|
+
});
|
|
130
|
+
this.session.history.push({
|
|
131
|
+
role: 'model',
|
|
132
|
+
parts: [{ text: "Gemini Coder Pro initialized. Precision coding agent active. Objective?" }]
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async loadSystemPrompt() {
|
|
137
|
+
try {
|
|
138
|
+
const promptPath = path.join(process.cwd(), '.gemini-coder', 'system-prompt.md');
|
|
139
|
+
return await fs.readFile(promptPath, 'utf8');
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
return `You are Gemini Coder Pro, an autonomous engineering agent.`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
getMode() {
|
|
146
|
+
return this.mode;
|
|
147
|
+
}
|
|
148
|
+
setMode(mode) {
|
|
149
|
+
this.mode = mode;
|
|
150
|
+
printModeChange(mode);
|
|
151
|
+
}
|
|
152
|
+
injectMessage(message) {
|
|
153
|
+
this.session.history.push(message);
|
|
154
|
+
}
|
|
155
|
+
async withRetry(fn, retries = MAX_RETRIES) {
|
|
156
|
+
let delay = 2000;
|
|
157
|
+
for (let i = 0; i < retries; i++) {
|
|
158
|
+
try {
|
|
159
|
+
return await fn();
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
const errorMsg = String(error.message || error);
|
|
163
|
+
const isRateLimit = errorMsg.includes('429') || error.status === 429;
|
|
164
|
+
if (isRateLimit && i < retries - 1) {
|
|
165
|
+
console.log(chalk.yellow(`\n[System]: Rate limit hit (429). Retrying in ${delay / 1000}s... (Attempt ${i + 1}/${retries})`));
|
|
166
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
167
|
+
delay *= 2;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
throw new Error('Max retries reached');
|
|
174
|
+
}
|
|
175
|
+
async chat() {
|
|
176
|
+
while (true) {
|
|
177
|
+
const userInput = await rl.question(getPromptText(this.mode));
|
|
178
|
+
if (userInput.trim() === '')
|
|
179
|
+
continue;
|
|
180
|
+
if (userInput.toLowerCase() === 'exit')
|
|
181
|
+
break;
|
|
182
|
+
if (userInput.startsWith('/')) {
|
|
183
|
+
await this.handleSlashCommand(userInput);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
this.session.history.push({ role: 'user', parts: [{ text: userInput }] });
|
|
187
|
+
await this.processTurn(0);
|
|
188
|
+
// Save session after every exchange
|
|
189
|
+
this.session.updatedAt = new Date().toISOString();
|
|
190
|
+
await this.sessionManager.saveSession(this.session);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async handleSlashCommand(command) {
|
|
194
|
+
const [cmd, ...args] = command.slice(1).split(' ');
|
|
195
|
+
// Check registry first
|
|
196
|
+
const handler = CommandRegistry.get(cmd);
|
|
197
|
+
if (handler) {
|
|
198
|
+
await handler.execute(this, args);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
switch (cmd) {
|
|
202
|
+
case 'clear':
|
|
203
|
+
case 'new':
|
|
204
|
+
console.log(chalk.yellow('Starting new session...'));
|
|
205
|
+
this.session = await this.sessionManager.createSession();
|
|
206
|
+
break;
|
|
207
|
+
case 'help':
|
|
208
|
+
printHelp(CommandRegistry.getAll());
|
|
209
|
+
break;
|
|
210
|
+
case 'actions': {
|
|
211
|
+
printBox('Actions', ['Choose an interactive command.'], 'blue');
|
|
212
|
+
const answer = await inquirer.prompt([{
|
|
213
|
+
type: 'expand',
|
|
214
|
+
name: 'command',
|
|
215
|
+
message: 'Action',
|
|
216
|
+
choices: [
|
|
217
|
+
{ key: 'p', name: 'Plan', value: '/plan' },
|
|
218
|
+
{ key: 'd', name: 'Diff', value: '/diff' },
|
|
219
|
+
{ key: 'r', name: 'Review', value: '/review' },
|
|
220
|
+
{ key: 's', name: 'Simplify', value: '/simplify' },
|
|
221
|
+
{ key: 't', name: 'Test', value: '/test' },
|
|
222
|
+
{ key: 'c', name: 'Cancel', value: null },
|
|
223
|
+
],
|
|
224
|
+
}]);
|
|
225
|
+
if (answer.command) {
|
|
226
|
+
await this.handleSlashCommand(answer.command);
|
|
227
|
+
}
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
default:
|
|
231
|
+
console.log(chalk.red(`Unknown command: /${cmd}`));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async processTurn(turnCount) {
|
|
235
|
+
if (turnCount >= MAX_TURNS) {
|
|
236
|
+
console.log(chalk.red(`\n[System]: Max turns (${MAX_TURNS}) reached. Stopping this loop.`));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const contextMap = await getContextMap();
|
|
241
|
+
const contents = this.session.history.map(item => ({
|
|
242
|
+
role: item.role,
|
|
243
|
+
parts: item.parts
|
|
244
|
+
}));
|
|
245
|
+
// Inject context map into last user message
|
|
246
|
+
for (let i = contents.length - 1; i >= 0; i--) {
|
|
247
|
+
if (contents[i].role === 'user') {
|
|
248
|
+
const parts = contents[i].parts;
|
|
249
|
+
if (parts && parts.length > 0 && 'text' in parts[0]) {
|
|
250
|
+
parts[0].text = `CONTEXT_MAP:\n${contextMap}\n\nUSER_REQUEST: ${parts[0].text}`;
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const spinner = ora(chalk.cyan('Thinking...')).start();
|
|
256
|
+
let responseStream;
|
|
257
|
+
try {
|
|
258
|
+
responseStream = await this.withRetry(async () => await aiClient.models.generateContentStream({
|
|
259
|
+
model: this.model,
|
|
260
|
+
contents,
|
|
261
|
+
config: {
|
|
262
|
+
tools: [{ functionDeclarations }],
|
|
263
|
+
}
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
spinner.fail(chalk.red('API Error'));
|
|
268
|
+
console.error(chalk.red(`\n[API Error]: ${err.message || err}`));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
let responseParts = [];
|
|
272
|
+
let finalUsageMetadata = null;
|
|
273
|
+
let isFirstChunk = true;
|
|
274
|
+
const functionCallsByKey = new Map();
|
|
275
|
+
let assistantText = '';
|
|
276
|
+
for await (const chunk of responseStream) {
|
|
277
|
+
if (isFirstChunk) {
|
|
278
|
+
spinner.stop();
|
|
279
|
+
isFirstChunk = false;
|
|
280
|
+
}
|
|
281
|
+
const candidate = chunk.candidates?.[0];
|
|
282
|
+
const parts = candidate?.content?.parts ?? [];
|
|
283
|
+
if (parts.length > 0) {
|
|
284
|
+
responseParts.push(...parts);
|
|
285
|
+
const textParts = parts
|
|
286
|
+
.map(part => ('text' in part ? part.text : undefined))
|
|
287
|
+
.filter((text) => typeof text === 'string' && text.length > 0)
|
|
288
|
+
.join('');
|
|
289
|
+
if (textParts) {
|
|
290
|
+
assistantText += textParts;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (chunk.functionCalls) {
|
|
294
|
+
for (const call of chunk.functionCalls) {
|
|
295
|
+
const key = call.id ?? `${call.name ?? 'unknown'}:${JSON.stringify(call.args ?? call.partialArgs ?? {})}`;
|
|
296
|
+
functionCallsByKey.set(key, call);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (chunk.usageMetadata) {
|
|
300
|
+
finalUsageMetadata = chunk.usageMetadata;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (!isFirstChunk && assistantText.trim()) {
|
|
304
|
+
const formattedText = marked.parse(assistantText);
|
|
305
|
+
printAssistantResponse(formattedText.trim());
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
spinner.stop();
|
|
309
|
+
}
|
|
310
|
+
if (responseParts.length === 0)
|
|
311
|
+
return;
|
|
312
|
+
// Update token tracking
|
|
313
|
+
if (finalUsageMetadata) {
|
|
314
|
+
if (!this.session.tokens) {
|
|
315
|
+
this.session.tokens = { prompt: 0, candidates: 0, total: 0 };
|
|
316
|
+
}
|
|
317
|
+
this.session.tokens.prompt += (finalUsageMetadata.promptTokenCount || 0);
|
|
318
|
+
this.session.tokens.candidates += (finalUsageMetadata.candidatesTokenCount || 0);
|
|
319
|
+
this.session.tokens.total += (finalUsageMetadata.totalTokenCount || 0);
|
|
320
|
+
}
|
|
321
|
+
this.session.history.push({ role: 'model', parts: responseParts });
|
|
322
|
+
const functionCalls = Array.from(functionCallsByKey.values());
|
|
323
|
+
if (functionCalls.length > 0) {
|
|
324
|
+
const toolResponses = new Array(functionCalls.length);
|
|
325
|
+
const executeCall = async (call, index) => {
|
|
326
|
+
const { name, args } = call;
|
|
327
|
+
const progressLabel = chalk.blue(`ā Executing ${name}...`);
|
|
328
|
+
process.stdout.write(progressLabel);
|
|
329
|
+
let functionResponse;
|
|
330
|
+
try {
|
|
331
|
+
// Centralized Plan Mode interception
|
|
332
|
+
if (this.mode === OrchestratorMode.PLAN && ['run_command', 'propose_edits'].includes(name)) {
|
|
333
|
+
functionResponse = { error: `Plan Mode: ${name} is intercepted and not applied.` };
|
|
334
|
+
}
|
|
335
|
+
// Specialized interactive tool
|
|
336
|
+
else if (name === 'propose_edits') {
|
|
337
|
+
const { edits } = args;
|
|
338
|
+
const results = [];
|
|
339
|
+
for (const edit of edits || []) {
|
|
340
|
+
const { success, originalContent } = await showDiff(edit.path, edit.search, edit.replace, edit.action, edit.reason);
|
|
341
|
+
if (success && originalContent) {
|
|
342
|
+
this.appliedEdits.push({ path: edit.path, originalContent });
|
|
343
|
+
}
|
|
344
|
+
results.push({ path: edit.path, applied: success });
|
|
345
|
+
}
|
|
346
|
+
functionResponse = { results };
|
|
347
|
+
}
|
|
348
|
+
// Dynamic routing for all other registered tools
|
|
349
|
+
else if (name in tools) {
|
|
350
|
+
functionResponse = await tools[name](args || {});
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
functionResponse = { error: `Unknown tool: ${name}` };
|
|
354
|
+
}
|
|
355
|
+
// Clear progress line and show success
|
|
356
|
+
process.stdout.clearLine(0);
|
|
357
|
+
process.stdout.cursorTo(0);
|
|
358
|
+
console.log(chalk.green(`ā ${name} complete`));
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
process.stdout.clearLine(0);
|
|
362
|
+
process.stdout.cursorTo(0);
|
|
363
|
+
console.log(chalk.red(`ā ${name} failed`));
|
|
364
|
+
functionResponse = { error: error.message || String(error) };
|
|
365
|
+
}
|
|
366
|
+
toolResponses[index] = {
|
|
367
|
+
functionResponse: {
|
|
368
|
+
name: name,
|
|
369
|
+
response: { result: functionResponse }
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
};
|
|
373
|
+
const parallelPromises = [];
|
|
374
|
+
for (let i = 0; i < functionCalls.length; i++) {
|
|
375
|
+
const call = functionCalls[i];
|
|
376
|
+
if (['read_files', 'list_directory', 'grep_search'].includes(call.name || '')) {
|
|
377
|
+
parallelPromises.push(executeCall(call, i));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
await Promise.all(parallelPromises);
|
|
381
|
+
for (let i = 0; i < functionCalls.length; i++) {
|
|
382
|
+
const call = functionCalls[i];
|
|
383
|
+
if (!['read_files', 'list_directory', 'grep_search'].includes(call.name || '')) {
|
|
384
|
+
await executeCall(call, i);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
this.session.history.push({
|
|
388
|
+
role: 'user',
|
|
389
|
+
parts: toolResponses
|
|
390
|
+
});
|
|
391
|
+
await this.processTurn(turnCount + 1);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
console.error(chalk.red(`\n[Error]: ${error.message || error}`));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
const SESSIONS_DIR = path.join(process.cwd(), '.gemini-coder', 'sessions');
|
|
4
|
+
export class SessionManager {
|
|
5
|
+
currentSessionId = null;
|
|
6
|
+
constructor() {
|
|
7
|
+
this.ensureDirectory();
|
|
8
|
+
}
|
|
9
|
+
async ensureDirectory() {
|
|
10
|
+
try {
|
|
11
|
+
await fs.mkdir(SESSIONS_DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
catch (e) {
|
|
14
|
+
console.warn(`\n[System Warning]: Failed to ensure sessions directory at ${SESSIONS_DIR}. Error: ${e.message}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async createSession(name = 'default') {
|
|
18
|
+
const id = Date.now().toString();
|
|
19
|
+
const session = {
|
|
20
|
+
id,
|
|
21
|
+
name,
|
|
22
|
+
history: [],
|
|
23
|
+
updatedAt: new Date().toISOString(),
|
|
24
|
+
tokens: { prompt: 0, candidates: 0, total: 0 }
|
|
25
|
+
};
|
|
26
|
+
await this.saveSession(session);
|
|
27
|
+
this.currentSessionId = id;
|
|
28
|
+
return session;
|
|
29
|
+
}
|
|
30
|
+
async saveSession(session) {
|
|
31
|
+
const filePath = path.join(SESSIONS_DIR, `${session.id}.json`);
|
|
32
|
+
await fs.writeFile(filePath, JSON.stringify(session, null, 2));
|
|
33
|
+
}
|
|
34
|
+
async loadSession(id) {
|
|
35
|
+
try {
|
|
36
|
+
const filePath = path.join(SESSIONS_DIR, `${id}.json`);
|
|
37
|
+
const data = await fs.readFile(filePath, 'utf8');
|
|
38
|
+
const session = JSON.parse(data);
|
|
39
|
+
this.currentSessionId = id;
|
|
40
|
+
return session;
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async getLatestSession() {
|
|
47
|
+
const files = await fs.readdir(SESSIONS_DIR);
|
|
48
|
+
if (files.length === 0)
|
|
49
|
+
return null;
|
|
50
|
+
const sessions = await Promise.all(files.filter(f => f.endsWith('.json')).map(async (f) => {
|
|
51
|
+
const data = await fs.readFile(path.join(SESSIONS_DIR, f), 'utf8');
|
|
52
|
+
return JSON.parse(data);
|
|
53
|
+
}));
|
|
54
|
+
return sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0];
|
|
55
|
+
}
|
|
56
|
+
async listSessions() {
|
|
57
|
+
try {
|
|
58
|
+
const files = await fs.readdir(SESSIONS_DIR);
|
|
59
|
+
const sessions = await Promise.all(files.filter(f => f.endsWith('.json')).map(async (f) => {
|
|
60
|
+
const data = await fs.readFile(path.join(SESSIONS_DIR, f), 'utf8');
|
|
61
|
+
return JSON.parse(data);
|
|
62
|
+
}));
|
|
63
|
+
return sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { exec } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
export const tools = {
|
|
6
|
+
read_files: async ({ paths }) => {
|
|
7
|
+
const contents = await Promise.all(paths.map(async (p) => {
|
|
8
|
+
try {
|
|
9
|
+
return {
|
|
10
|
+
path: p,
|
|
11
|
+
content: await fs.readFile(p, 'utf8')
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
return {
|
|
16
|
+
path: p,
|
|
17
|
+
error: error.message || String(error)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}));
|
|
21
|
+
return contents;
|
|
22
|
+
},
|
|
23
|
+
propose_edits: async ({ edits }) => {
|
|
24
|
+
// This will be handled by the orchestrator for the approval gate
|
|
25
|
+
return { status: "pending_approval", edits };
|
|
26
|
+
},
|
|
27
|
+
run_command: async ({ command }) => {
|
|
28
|
+
try {
|
|
29
|
+
// 5MB buffer to prevent ERR_CHILD_PROCESS_STDIO_MAXBUFFER
|
|
30
|
+
const { stdout, stderr } = await execAsync(command, { timeout: 60000, maxBuffer: 1024 * 1024 * 5 });
|
|
31
|
+
// Truncate massive outputs so they don't blow out the LLM context window
|
|
32
|
+
return {
|
|
33
|
+
stdout: stdout ? stdout.slice(0, 50000) : '',
|
|
34
|
+
stderr: stderr ? stderr.slice(0, 50000) : '',
|
|
35
|
+
exitCode: 0
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return {
|
|
40
|
+
stdout: error.stdout ? error.stdout.slice(0, 50000) : '',
|
|
41
|
+
stderr: (error.stderr || error.message).slice(0, 50000),
|
|
42
|
+
exitCode: error.code || 1
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
grep_search: async ({ pattern, include }) => {
|
|
47
|
+
try {
|
|
48
|
+
// Use grep -rnIE (recursive, line numbers, ignore binary, extended regex)
|
|
49
|
+
const includeFlag = include ? `--include="${include}"` : '';
|
|
50
|
+
const command = `grep -rnIE "${pattern}" . ${includeFlag} --exclude-dir={node_modules,dist,.git}`;
|
|
51
|
+
const { stdout, stderr } = await execAsync(command, { timeout: 30000 });
|
|
52
|
+
return { results: stdout, stderr };
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
// grep returns exit code 1 if no matches found
|
|
56
|
+
if (error.code === 1)
|
|
57
|
+
return { results: '', message: 'No matches found.' };
|
|
58
|
+
return { results: '', error: error.message };
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
list_directory: async ({ path = '.' }) => {
|
|
62
|
+
try {
|
|
63
|
+
const entries = await fs.readdir(path, { withFileTypes: true });
|
|
64
|
+
const results = entries.map(e => `${e.isDirectory() ? 'DIR ' : 'FILE'} ${e.name}`).join('\n');
|
|
65
|
+
return { path, results };
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
return { error: error.message };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { OrchestratorMode } from '../core/orchestrator.js';
|
|
3
|
+
export const QUICK_ACTIONS = [
|
|
4
|
+
{ label: 'Plan', command: '/plan' },
|
|
5
|
+
{ label: 'Diff', command: '/diff' },
|
|
6
|
+
{ label: 'Review', command: '/review' },
|
|
7
|
+
{ label: 'Simplify', command: '/simplify' },
|
|
8
|
+
{ label: 'Test', command: '/test' },
|
|
9
|
+
];
|
|
10
|
+
function getBoxWidth() {
|
|
11
|
+
return Math.max(60, Math.min(process.stdout.columns || 80, 96));
|
|
12
|
+
}
|
|
13
|
+
function boxLine(content, width) {
|
|
14
|
+
const innerWidth = width - 2;
|
|
15
|
+
const text = content.length > innerWidth ? content.slice(0, innerWidth) : content;
|
|
16
|
+
const padding = ' '.repeat(Math.max(0, innerWidth - text.length));
|
|
17
|
+
return `ā${text}${padding}ā`;
|
|
18
|
+
}
|
|
19
|
+
export function printBox(title, lines, accent = 'blue') {
|
|
20
|
+
const width = getBoxWidth();
|
|
21
|
+
const borderColor = accent === 'green' ? chalk.green : accent === 'magenta' ? chalk.magenta : chalk.blue;
|
|
22
|
+
const titleText = ` ${title} `;
|
|
23
|
+
const titlePad = Math.max(0, width - 2 - titleText.length);
|
|
24
|
+
const left = 'ā'.repeat(Math.floor(titlePad / 2));
|
|
25
|
+
const right = 'ā'.repeat(titlePad - left.length);
|
|
26
|
+
console.log(borderColor(`ā${left}${titleText}${right}ā`));
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
console.log(borderColor(boxLine(` ${line}`, width)));
|
|
29
|
+
}
|
|
30
|
+
console.log(borderColor(`ā${'ā'.repeat(width - 2)}ā`));
|
|
31
|
+
}
|
|
32
|
+
export function printBootScreen(appName, version) {
|
|
33
|
+
console.clear();
|
|
34
|
+
printBox(`${appName} ${version}`, [
|
|
35
|
+
'Initializing autonomous engineering agent...',
|
|
36
|
+
'Type "exit" to quit, "/" for commands.',
|
|
37
|
+
'',
|
|
38
|
+
'Quick actions:',
|
|
39
|
+
QUICK_ACTIONS.map(action => `[${action.command}]`).join(' '),
|
|
40
|
+
'Type /actions for the interactive menu.',
|
|
41
|
+
], 'blue');
|
|
42
|
+
}
|
|
43
|
+
export function getPromptText(mode) {
|
|
44
|
+
const promptColor = mode === OrchestratorMode.PLAN ? chalk.magenta : chalk.green;
|
|
45
|
+
return chalk.bold(`${promptColor('>')} `);
|
|
46
|
+
}
|
|
47
|
+
export function printAssistantResponse(text) {
|
|
48
|
+
const lines = text.split(/\r?\n/);
|
|
49
|
+
printBox('Gemini', lines.length > 0 ? lines : [''], 'blue');
|
|
50
|
+
}
|
|
51
|
+
export function printModeChange(mode) {
|
|
52
|
+
const label = mode === OrchestratorMode.PLAN ? chalk.magenta('Plan mode') : chalk.green('Normal mode');
|
|
53
|
+
printBox('Mode', [`${label}`], mode === OrchestratorMode.PLAN ? 'magenta' : 'green');
|
|
54
|
+
}
|
|
55
|
+
export function printHelp(commands) {
|
|
56
|
+
printBox('Commands', [
|
|
57
|
+
...commands.map(command => `/${command.name} - ${command.description}`),
|
|
58
|
+
'/clear - Start a new conversation',
|
|
59
|
+
'/help - Show this help',
|
|
60
|
+
'/exit - Exit the CLI',
|
|
61
|
+
], 'blue');
|
|
62
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gemini-coder",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"gemini-coder": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc -p tsconfig.json",
|
|
15
|
+
"dev": "tsx src/cli.ts chat",
|
|
16
|
+
"start": "node dist/cli.js chat",
|
|
17
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@google/genai": "^2.6.0",
|
|
21
|
+
"chalk": "^5.3.0",
|
|
22
|
+
"cli-highlight": "^2.1.11",
|
|
23
|
+
"cli-table3": "^0.6.5",
|
|
24
|
+
"commander": "^12.0.0",
|
|
25
|
+
"diff": "^5.2.0",
|
|
26
|
+
"glob": "^10.3.12",
|
|
27
|
+
"google-auth-library": "^10.6.2",
|
|
28
|
+
"highlight.js": "^11.11.1",
|
|
29
|
+
"inquirer": "^13.4.3",
|
|
30
|
+
"marked": "^15.0.12",
|
|
31
|
+
"marked-terminal": "^7.3.0",
|
|
32
|
+
"ora": "^9.4.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/diff": "^7.0.2",
|
|
36
|
+
"@types/inquirer": "^9.0.9",
|
|
37
|
+
"@types/marked-terminal": "^6.1.1",
|
|
38
|
+
"@types/node": "^20.12.7",
|
|
39
|
+
"esm": "^3.2.25",
|
|
40
|
+
"tsx": "^4.7.2",
|
|
41
|
+
"typescript": "^5.4.5"
|
|
42
|
+
}
|
|
43
|
+
}
|