diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fad1441..1e2487e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: pip install pytest customtkinter - name: Run deterministic test suite - # gui/live tests are excluded by the pytest config in pyproject.toml + # live tests are excluded by the pytest config in pyproject.toml run: python -m pytest -q lint: diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 4912040..1b8c869 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -1,4 +1,4 @@ -name: Publish packages +name: Publish Python wrapper on: workflow_dispatch: @@ -44,41 +44,3 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - - publish-npm: - name: Publish npm launcher after PyPI - needs: publish - runs-on: ubuntu-latest - environment: npm - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - uses: actions/setup-node@v6 - with: - node-version: "24" - registry-url: "https://registry.npmjs.org" - package-manager-cache: false - - - name: Verify synchronized release version - env: - RELEASE_VERSION: ${{ github.event.release.tag_name || inputs.version }} - run: python scripts/check_release_versions.py --tag "$RELEASE_VERSION" - - - name: Wait for the matching PyPI core - env: - RELEASE_VERSION: ${{ github.event.release.tag_name || inputs.version }} - run: python scripts/wait_for_pypi.py --version "$RELEASE_VERSION" - - - name: Test and publish wrapper-only npm package - working-directory: js - run: | - npm ci - npm test - npm publish diff --git a/MANIFEST.in b/MANIFEST.in index f7e3a02..0ec4e50 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,3 @@ -# Keep GUI and dashboard code out of the sdist; the published package is the -# command wrapper only. -prune electron -prune src/sage/gui +# Keep the hosted dashboard implementation out of the Python wrapper sdist. prune src/sage/dashboard -prune src/sage/tui -exclude src/sage/gui_server.py exclude tests/test_dashboard_render.py diff --git a/README.md b/README.md index e8818b5..dca621d 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ sage install # Activate this machine and AI-agent instruct sage doctor --activation # Verify activation npx -y psycgod-sage doctor --activation sage run -- # Wrap any command -sage run --cwd /project -- # Explicit workspace for desktop/Electron hosts +sage run --cwd /project -- # Explicit workspace for host integrations sage pytest # Shortcut for: sage run -- pytest sage npm test # Shortcut for: sage run -- npm test sage git status # Shortcut for: sage run -- git status @@ -150,7 +150,7 @@ SAGE is designed to keep prompts, source code, credentials, raw command output, |---|---| | Already-open AI-agent sessions may not reload new instructions | Restart Claude/Codex/Cursor/Windsurf/OpenCode after `sage install` | | Locked-down host apps can disable shell tools | SAGE cannot enable tools the host application has blocked | -| A desktop/Electron host starts its shell in the wrong folder | Pass `sage run --cwd -- ` or set `SAGE_WORKSPACE_CWD` | +| A host starts its shell in the wrong folder | Pass `sage run --cwd -- ` or set `SAGE_WORKSPACE_CWD` | | npm/PyPI installs cannot safely auto-run activation | Run `sage install` once after package install | | MCP can disconnect in some stdio agent sessions | Use normal `sage run -- ` by default; enable MCP manually only if needed | | Package installs are passive by design | Real activation starts with `sage install` | @@ -164,12 +164,6 @@ SAGE is designed to keep prompts, source code, credentials, raw command output, | `sage run --` | ![sage run](https://raw.githubusercontent.com/PsYcGoD/sage/main/docs/assets/sage-run.svg) | | CLI run | ![SAGE CLI demo](https://raw.githubusercontent.com/PsYcGoD/sage/main/docs/assets/demo-sage-run.gif) | -## Team View Preview - Enterprise Only - -Team View is not part of the free public CLI package. It is a future enterprise dashboard concept for organizations that need shared usage proof, team-level savings, and admin reporting. - -![SAGE Team View preview](docs/assets/team-dashboard-preview.png) - ## Links - Landing: [sage.api.marketingstudios.in](https://sage.api.marketingstudios.in/) diff --git a/docs/assets/team-dashboard-preview.png b/docs/assets/team-dashboard-preview.png deleted file mode 100644 index 11b2a46..0000000 Binary files a/docs/assets/team-dashboard-preview.png and /dev/null differ diff --git a/editors/vscode/package.json b/editors/vscode/package.json deleted file mode 100644 index 92de77e..0000000 --- a/editors/vscode/package.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "name": "sage-lsp", - "displayName": "SAGE - Smart Agent Guidance Engine", - "description": "Terminal intelligence: predict command failures, auto-fix errors, agentic retry loops", - "version": "0.1.0", - "publisher": "marketingstudios", - "engines": { - "vscode": "^1.80.0" - }, - "categories": ["Other", "Linters"], - "activationEvents": [ - "onLanguage:shellscript", - "onLanguage:powershell", - "onLanguage:bat", - "onCommand:sage.predict", - "onCommand:sage.fix", - "onCommand:sage.explain" - ], - "main": "./out/extension.js", - "contributes": { - "commands": [ - { - "command": "sage.predict", - "title": "SAGE: Predict Command Risk" - }, - { - "command": "sage.fix", - "title": "SAGE: Fix Last Error" - }, - { - "command": "sage.explain", - "title": "SAGE: Explain Last Error" - }, - { - "command": "sage.session", - "title": "SAGE: Show Session State" - } - ], - "configuration": { - "title": "SAGE", - "properties": { - "sage.transport": { - "type": "string", - "default": "stdio", - "enum": ["stdio", "tcp"], - "description": "LSP transport mode" - }, - "sage.tcpPort": { - "type": "number", - "default": 19473, - "description": "TCP port for LSP server" - }, - "sage.autonomy": { - "type": "string", - "default": "suggest", - "enum": ["suggest", "ask", "auto"], - "description": "Agentic loop autonomy level" - }, - "sage.predictOnType": { - "type": "boolean", - "default": true, - "description": "Show predictions as you type commands" - } - } - } - }, - "scripts": { - "compile": "tsc -p ./", - "watch": "tsc -watch -p ./" - }, - "dependencies": { - "vscode-languageclient": "^9.0.0" - }, - "devDependencies": { - "@types/vscode": "^1.80.0", - "typescript": "^5.0.0" - } -} diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts deleted file mode 100644 index b94b2d1..0000000 --- a/editors/vscode/src/extension.ts +++ /dev/null @@ -1,100 +0,0 @@ -import * as vscode from 'vscode'; -import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from 'vscode-languageclient/node'; - -let client: LanguageClient; - -export function activate(context: vscode.ExtensionContext) { - const config = vscode.workspace.getConfiguration('sage'); - const transport = config.get('transport', 'stdio'); - const tcpPort = config.get('tcpPort', 19473); - - let serverOptions: ServerOptions; - - if (transport === 'tcp') { - serverOptions = () => { - const net = require('net'); - return new Promise((resolve) => { - const socket = net.connect({ port: tcpPort, host: '127.0.0.1' }); - resolve({ reader: socket, writer: socket }); - }); - }; - } else { - serverOptions = { - command: 'sage', - args: ['lsp'], - transport: TransportKind.stdio, - }; - } - - const clientOptions: LanguageClientOptions = { - documentSelector: [ - { scheme: 'file', language: 'shellscript' }, - { scheme: 'file', language: 'powershell' }, - { scheme: 'file', language: 'bat' }, - ], - synchronize: { - fileEvents: vscode.workspace.createFileSystemWatcher('**/sage.toml'), - }, - }; - - client = new LanguageClient('sage-lsp', 'SAGE LSP', serverOptions, clientOptions); - client.start(); - - // Register commands - context.subscriptions.push( - vscode.commands.registerCommand('sage.predict', async () => { - const input = await vscode.window.showInputBox({ prompt: 'Command to predict' }); - if (!input) return; - const result = await client.sendRequest('sage/predict', { command: input }); - const r = result as any; - if (r.ok) { - const icon = r.will_fail ? '⚠️' : '✅'; - vscode.window.showInformationMessage( - `${icon} ${r.will_fail ? 'Likely to fail' : 'Likely to succeed'} (${Math.round(r.confidence * 100)}%) — ${r.reason}` - ); - } - }), - - vscode.commands.registerCommand('sage.fix', async () => { - const result = await client.sendRequest('sage/fix', {}); - const r = result as any; - if (r.ok && r.fix) { - const action = await vscode.window.showInformationMessage( - `Fix: ${r.fix.fix_command}\n${r.fix.explanation}`, - 'Copy to Terminal', 'Dismiss' - ); - if (action === 'Copy to Terminal') { - const terminal = vscode.window.activeTerminal || vscode.window.createTerminal('SAGE'); - terminal.sendText(r.fix.fix_command); - terminal.show(); - } - } else { - vscode.window.showInformationMessage('No fix available for last error.'); - } - }), - - vscode.commands.registerCommand('sage.explain', async () => { - const result = await client.sendRequest('sage/explain', {}); - const r = result as any; - if (r.ok) { - vscode.window.showInformationMessage(`${r.command}: ${r.summary}`); - } - }), - - vscode.commands.registerCommand('sage.session', async () => { - const result = await client.sendRequest('sage/session', {}); - const r = result as any; - if (r.ok) { - const items = r.recent_commands.map((c: any) => - `${c.exit_code === 0 ? '✅' : '❌'} ${c.command}` - ); - vscode.window.showQuickPick(items, { title: 'SAGE Session History' }); - } - }) - ); -} - -export function deactivate(): Thenable | undefined { - if (!client) return undefined; - return client.stop(); -} diff --git a/editors/vscode/tsconfig.json b/editors/vscode/tsconfig.json deleted file mode 100644 index 8b8bd23..0000000 --- a/editors/vscode/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "target": "ES2020", - "outDir": "out", - "lib": ["ES2020"], - "sourceMap": true, - "rootDir": "src", - "strict": true - }, - "include": ["src"], - "exclude": ["node_modules"] -} diff --git a/js/src/agents/code.ts b/js/src/agents/code.ts deleted file mode 100644 index 4711022..0000000 --- a/js/src/agents/code.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Code Agent - Syntax, edits, secrets detection -import type { Agent, AgentAnalysis } from './index.js'; - -export class CodeAgent implements Agent { - type = 'code'; - name = 'Code Agent'; - capabilities = ['implement', 'refactor', 'review']; - triggers = [ - 'code', 'coding', 'implement', 'refactor', 'rewrite', - 'function', 'class', 'module', 'method', 'bugfix', - 'patch', 'edit', 'modify', 'compile', 'syntax', - 'python', 'javascript', 'typescript', 'node', 'api', - 'endpoint', 'logic', 'algorithm', 'repository' - ]; - description = 'Inspects code changes: syntax, scoped edits, leaked secrets.'; - - analyze(input: string): AgentAnalysis { - const findings: string[] = []; - const suggestions: string[] = []; - - // Check for potential secrets - const secretPatterns = [ - { pattern: /api[_-]?key\s*[=:]\s*['"][^'"]+['"]/gi, type: 'API key' }, - { pattern: /password\s*[=:]\s*['"][^'"]+['"]/gi, type: 'Password' }, - { pattern: /secret\s*[=:]\s*['"][^'"]+['"]/gi, type: 'Secret' }, - { pattern: /token\s*[=:]\s*['"][^'"]+['"]/gi, type: 'Token' }, - { pattern: /sk-[a-zA-Z0-9]{20,}/g, type: 'OpenAI key' }, - { pattern: /ghp_[a-zA-Z0-9]{36}/g, type: 'GitHub token' }, - { pattern: /AKIA[A-Z0-9]{16}/g, type: 'AWS key' }, - ]; - - for (const { pattern, type } of secretPatterns) { - if (pattern.test(input)) { - findings.push(`⚠️ Potential ${type} detected in code`); - suggestions.push(`Remove ${type} and use environment variables`); - } - } - - // Check for syntax issues (basic patterns) - if (/\(\s*\)[\s\n]*{/.test(input) && !/function|if|for|while|class/.test(input)) { - findings.push('Possible syntax issue: empty parentheses before block'); - } - - // Check for common mistakes - if (/console\.log/.test(input) && /production|prod|deploy/.test(input.toLowerCase())) { - findings.push('console.log found - consider removing for production'); - suggestions.push('Remove or replace console.log with proper logging'); - } - - if (/debugger;/.test(input)) { - findings.push('debugger statement found'); - suggestions.push('Remove debugger statement before committing'); - } - - // Check for TODO/FIXME - const todos = input.match(/TODO|FIXME|HACK|XXX/gi); - if (todos && todos.length > 0) { - findings.push(`Found ${todos.length} TODO/FIXME comments`); - } - - return { - agent: this.name, - score: findings.length > 0 ? 0.8 : 1.0, - findings, - suggestions - }; - } -} diff --git a/js/src/agents/debug.ts b/js/src/agents/debug.ts deleted file mode 100644 index f97f787..0000000 --- a/js/src/agents/debug.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Debug Agent - Traceback parsing, root cause analysis -import type { Agent, AgentAnalysis } from './index.js'; - -export class DebugAgent implements Agent { - type = 'debug'; - name = 'Debug Agent'; - capabilities = ['trace', 'root_cause', 'fix_plan']; - triggers = [ - 'debug', 'error', 'traceback', 'failed', 'failure', - 'exception', 'crash', 'stacktrace', 'stack trace', - 'broken', 'hang', 'freeze', 'timeout', 'regression', - 'root cause', 'diagnose', 'issue', 'bug', 'fix', - 'panic', 'fatal', 'cannot', 'missing', 'invalid', 'slow' - ]; - description = 'Investigates failures and root causes.'; - - analyze(input: string): AgentAnalysis { - const findings: string[] = []; - const suggestions: string[] = []; - const lower = input.toLowerCase(); - - // Python tracebacks - if (lower.includes('traceback') || lower.includes('most recent call')) { - findings.push('Python traceback detected'); - - // Extract the actual error - const errorMatch = input.match(/(\w+Error): (.+)/); - if (errorMatch) { - findings.push(`Error type: ${errorMatch[1]}`); - findings.push(`Message: ${errorMatch[2]}`); - } - - // Find the file and line - const fileMatch = input.match(/File "([^"]+)", line (\d+)/); - if (fileMatch) { - findings.push(`Location: ${fileMatch[1]}:${fileMatch[2]}`); - suggestions.push(`Check ${fileMatch[1]} at line ${fileMatch[2]}`); - } - } - - // JavaScript errors - if (lower.includes('at ') && (lower.includes('error') || lower.includes('exception'))) { - findings.push('JavaScript stack trace detected'); - - const errorMatch = input.match(/(\w+Error): (.+)/); - if (errorMatch) { - findings.push(`Error type: ${errorMatch[1]}`); - findings.push(`Message: ${errorMatch[2]}`); - } - - // Extract file:line from stack - const stackMatch = input.match(/at .+ \((.+):(\d+):(\d+)\)/); - if (stackMatch) { - findings.push(`Location: ${stackMatch[1]}:${stackMatch[2]}`); - suggestions.push(`Check ${stackMatch[1]} at line ${stackMatch[2]}`); - } - } - - // Rust panics - if (lower.includes('panic') || lower.includes('thread') && lower.includes('panicked')) { - findings.push('Rust panic detected'); - const panicMatch = input.match(/panicked at '([^']+)'/); - if (panicMatch) { - findings.push(`Panic message: ${panicMatch[1]}`); - } - } - - // Common error patterns - if (lower.includes('connection refused')) { - findings.push('Connection refused error'); - suggestions.push('Check if the service is running'); - suggestions.push('Verify the port number and host'); - } - - if (lower.includes('permission denied')) { - findings.push('Permission denied error'); - suggestions.push('Check file/directory permissions'); - suggestions.push('Try running with elevated privileges'); - } - - if (lower.includes('out of memory') || lower.includes('heap')) { - findings.push('Memory exhaustion detected'); - suggestions.push('Reduce batch size or data being processed'); - suggestions.push('Increase available memory'); - } - - if (lower.includes('timeout')) { - findings.push('Timeout error detected'); - suggestions.push('Increase timeout value'); - suggestions.push('Check network connectivity'); - } - - // No specific errors found - if (findings.length === 0) { - findings.push('No specific error patterns detected'); - suggestions.push('Review the full output for clues'); - } - - return { - agent: this.name, - score: findings.length > 1 ? 0.9 : 0.5, - findings, - suggestions - }; - } -} diff --git a/js/src/agents/index.ts b/js/src/agents/index.ts deleted file mode 100644 index 0ea2aaa..0000000 --- a/js/src/agents/index.ts +++ /dev/null @@ -1,71 +0,0 @@ -// SAGE Agents - 4 core agents (code, debug, test, security) -import { CodeAgent } from './code.js'; -import { DebugAgent } from './debug.js'; -import { TestAgent } from './test.js'; -import { SecurityAgent } from './security.js'; - -export interface Agent { - type: string; - name: string; - capabilities: string[]; - triggers: string[]; - description: string; - analyze(input: string): AgentAnalysis; -} - -export interface AgentAnalysis { - agent: string; - score: number; - findings: string[]; - suggestions: string[]; -} - -// 4 Core Agents -export const agents: Agent[] = [ - new CodeAgent(), - new DebugAgent(), - new TestAgent(), - new SecurityAgent() -]; - -export function selectAgents(input: string, limit: number = 4): Agent[] { - const text = input.toLowerCase(); - const scored: { agent: Agent; score: number }[] = []; - - for (const agent of agents) { - let score = 0; - - // Check triggers - for (const trigger of agent.triggers) { - if (text.includes(trigger.toLowerCase())) { - score += 100; - } - } - - // Check capabilities - for (const capability of agent.capabilities) { - if (text.includes(capability.toLowerCase())) { - score += 50; - } - } - - if (score > 0) { - scored.push({ agent, score }); - } - } - - scored.sort((a, b) => b.score - a.score); - return scored.slice(0, limit).map(s => s.agent); -} - -export function getAgent(type: string): Agent | undefined { - return agents.find(a => a.type === type); -} - -export function listAgents(): { type: string; name: string; description: string }[] { - return agents.map(a => ({ - type: a.type, - name: a.name, - description: a.description - })); -} diff --git a/js/src/agents/security.ts b/js/src/agents/security.ts deleted file mode 100644 index be6cfd4..0000000 --- a/js/src/agents/security.ts +++ /dev/null @@ -1,117 +0,0 @@ -// Security Agent - Secrets scanning, vulnerability detection -import type { Agent, AgentAnalysis } from './index.js'; - -export class SecurityAgent implements Agent { - type = 'security'; - name = 'Security Agent'; - capabilities = ['audit', 'secrets', 'dependency_risk']; - triggers = [ - 'security', 'secure', 'secret', 'secrets', 'token', - 'password', 'auth', 'oauth', 'credential', 'credentials', - 'api key', 'vulnerability', 'exploit', 'injection', - 'xss', 'csrf', 'permission', 'permissions', 'privacy', - 'redact', 'pii', 'encrypt', 'decrypt', 'audit', 'malware' - ]; - description = 'Checks security-sensitive changes.'; - - analyze(input: string): AgentAnalysis { - const findings: string[] = []; - const suggestions: string[] = []; - - // Comprehensive secret patterns - const secretPatterns = [ - { pattern: /sk-[a-zA-Z0-9]{20,}/g, type: 'OpenAI API key', severity: 'HIGH' }, - { pattern: /ghp_[a-zA-Z0-9]{36}/g, type: 'GitHub personal access token', severity: 'HIGH' }, - { pattern: /gho_[a-zA-Z0-9]{36}/g, type: 'GitHub access token', severity: 'HIGH' }, - { pattern: /github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}/g, type: 'GitHub fine-grained token', severity: 'HIGH' }, - { pattern: /AKIA[A-Z0-9]{16}/g, type: 'AWS Access Key ID', severity: 'CRITICAL' }, - { pattern: /[a-zA-Z0-9/+=]{40}/g, type: 'Possible AWS Secret Key', severity: 'HIGH' }, - { pattern: /xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*/g, type: 'Slack token', severity: 'HIGH' }, - { pattern: /sk_live_[a-zA-Z0-9]{24,}/g, type: 'Stripe live key', severity: 'CRITICAL' }, - { pattern: /sk_test_[a-zA-Z0-9]{24,}/g, type: 'Stripe test key', severity: 'MEDIUM' }, - { pattern: /sq0atp-[a-zA-Z0-9_-]{22}/g, type: 'Square access token', severity: 'HIGH' }, - { pattern: /AIza[a-zA-Z0-9_-]{35}/g, type: 'Google API key', severity: 'MEDIUM' }, - { pattern: /[a-f0-9]{32}-us\d+/g, type: 'Mailchimp API key', severity: 'MEDIUM' }, - { pattern: /key-[a-zA-Z0-9]{32}/g, type: 'Mailgun API key', severity: 'MEDIUM' }, - { pattern: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/g, type: 'SendGrid API key', severity: 'HIGH' }, - { pattern: /-----BEGIN (RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----/g, type: 'Private key', severity: 'CRITICAL' }, - { pattern: /password\s*[=:]\s*['"][^'"]{8,}['"]/gi, type: 'Hardcoded password', severity: 'HIGH' }, - { pattern: /api[_-]?key\s*[=:]\s*['"][^'"]{16,}['"]/gi, type: 'API key in code', severity: 'HIGH' }, - { pattern: /bearer\s+[a-zA-Z0-9_-]{20,}/gi, type: 'Bearer token', severity: 'HIGH' }, - { pattern: /basic\s+[a-zA-Z0-9+/=]{20,}/gi, type: 'Basic auth credentials', severity: 'HIGH' }, - ]; - - for (const { pattern, type, severity } of secretPatterns) { - const matches = input.match(pattern); - if (matches) { - findings.push(`🔴 ${severity}: ${type} detected (${matches.length} occurrence${matches.length > 1 ? 's' : ''})`); - suggestions.push(`Remove ${type} and use environment variables or secrets manager`); - } - } - - // Check for security anti-patterns - const antiPatterns = [ - { pattern: /eval\s*\(/g, type: 'eval() usage', risk: 'Code injection vulnerability' }, - { pattern: /innerHTML\s*=/g, type: 'innerHTML assignment', risk: 'XSS vulnerability' }, - { pattern: /document\.write/g, type: 'document.write usage', risk: 'XSS vulnerability' }, - { pattern: /SELECT.*FROM.*WHERE.*\+/gi, type: 'String concatenation in SQL', risk: 'SQL injection' }, - { pattern: /exec\s*\(/g, type: 'exec() usage', risk: 'Command injection' }, - { pattern: /shell\s*=\s*True/g, type: 'shell=True in subprocess', risk: 'Command injection' }, - { pattern: /verify\s*=\s*False/gi, type: 'SSL verification disabled', risk: 'MITM vulnerability' }, - { pattern: /disable.*ssl|ssl.*disable/gi, type: 'SSL disabled', risk: 'MITM vulnerability' }, - ]; - - for (const { pattern, type, risk } of antiPatterns) { - if (pattern.test(input)) { - findings.push(`⚠️ ${type} - ${risk}`); - suggestions.push(`Review and fix: ${type}`); - } - } - - // Check for PII patterns - const piiPatterns = [ - { pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, type: 'Email addresses' }, - { pattern: /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g, type: 'Phone numbers' }, - { pattern: /\b\d{3}[-]?\d{2}[-]?\d{4}\b/g, type: 'SSN-like numbers' }, - { pattern: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g, type: 'Credit card numbers' }, - ]; - - for (const { pattern, type } of piiPatterns) { - const matches = input.match(pattern); - if (matches && matches.length > 2) { - findings.push(`⚠️ Possible PII: ${type} (${matches.length} occurrences)`); - suggestions.push(`Review if ${type} should be redacted`); - } - } - - // Dependency vulnerabilities (if npm audit or similar output) - if (input.toLowerCase().includes('vulnerabilit')) { - const critMatch = input.match(/(\d+)\s*critical/i); - const highMatch = input.match(/(\d+)\s*high/i); - const modMatch = input.match(/(\d+)\s*moderate/i); - - if (critMatch) findings.push(`🔴 ${critMatch[1]} critical vulnerabilities`); - if (highMatch) findings.push(`🟠 ${highMatch[1]} high vulnerabilities`); - if (modMatch) findings.push(`🟡 ${modMatch[1]} moderate vulnerabilities`); - - if (critMatch || highMatch) { - suggestions.push('Run npm audit fix or update vulnerable packages'); - suggestions.push('Review breaking changes before updating major versions'); - } - } - - // Summary - if (findings.length === 0) { - findings.push('✓ No security issues detected'); - } - - return { - agent: this.name, - score: findings.some(f => f.includes('CRITICAL')) ? 0.2 : - findings.some(f => f.includes('HIGH')) ? 0.5 : - findings.length > 1 ? 0.7 : 1.0, - findings, - suggestions - }; - } -} diff --git a/js/src/agents/test.ts b/js/src/agents/test.ts deleted file mode 100644 index c6ad314..0000000 --- a/js/src/agents/test.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Test Agent - Test framework detection, coverage analysis -import type { Agent, AgentAnalysis } from './index.js'; - -export class TestAgent implements Agent { - type = 'test'; - name = 'Test Agent'; - capabilities = ['pytest', 'coverage', 'regression']; - triggers = [ - 'test', 'tests', 'testing', 'pytest', 'unittest', - 'jest', 'vitest', 'playwright', 'coverage', 'regression', - 'assert', 'assertion', 'fixture', 'mock', 'snapshot', - 'ci', 'failing test', 'passed', 'failed', 'rerun', - 'spec', 'e2e', 'unit', 'integration', 'benchmark' - ]; - description = 'Runs and improves tests.'; - - analyze(input: string): AgentAnalysis { - const findings: string[] = []; - const suggestions: string[] = []; - const lower = input.toLowerCase(); - - // Pytest output - if (lower.includes('pytest') || lower.includes('===')) { - findings.push('pytest output detected'); - - // Extract pass/fail counts - const resultMatch = input.match(/(\d+) passed/); - const failMatch = input.match(/(\d+) failed/); - const errorMatch = input.match(/(\d+) error/); - - if (resultMatch) findings.push(`✓ ${resultMatch[1]} tests passed`); - if (failMatch) { - findings.push(`✗ ${failMatch[1]} tests failed`); - suggestions.push('Run pytest -v for verbose output'); - suggestions.push('Run pytest --lf to rerun only failed tests'); - } - if (errorMatch) { - findings.push(`⚠ ${errorMatch[1]} errors`); - suggestions.push('Fix collection errors before running tests'); - } - - // Check for skipped - const skipMatch = input.match(/(\d+) skipped/); - if (skipMatch) { - findings.push(`⏭ ${skipMatch[1]} tests skipped`); - } - } - - // Jest output - if (lower.includes('jest') || lower.includes('test suites')) { - findings.push('Jest output detected'); - - const passMatch = input.match(/(\d+) passed/); - const failMatch = input.match(/(\d+) failed/); - - if (passMatch) findings.push(`✓ ${passMatch[1]} tests passed`); - if (failMatch) { - findings.push(`✗ ${failMatch[1]} tests failed`); - suggestions.push('Run jest --watch to rerun on changes'); - suggestions.push('Run jest --coverage for coverage report'); - } - } - - // Vitest output - if (lower.includes('vitest')) { - findings.push('Vitest output detected'); - } - - // Coverage analysis - if (lower.includes('coverage') || lower.includes('stmts') || lower.includes('branch')) { - findings.push('Coverage report detected'); - - // Extract coverage percentage - const covMatch = input.match(/(\d+(?:\.\d+)?)\s*%/g); - if (covMatch && covMatch.length > 0) { - const percentages = covMatch.map(m => parseFloat(m)); - const avg = percentages.reduce((a, b) => a + b, 0) / percentages.length; - - if (avg < 50) { - findings.push(`⚠ Low coverage: ~${avg.toFixed(0)}%`); - suggestions.push('Add tests for uncovered code paths'); - } else if (avg < 80) { - findings.push(`Coverage: ~${avg.toFixed(0)}%`); - suggestions.push('Consider adding more edge case tests'); - } else { - findings.push(`✓ Good coverage: ~${avg.toFixed(0)}%`); - } - } - } - - // Assertion failures - if (lower.includes('assertionerror') || lower.includes('assert')) { - const assertMatch = input.match(/assert\s+(.+)/i); - if (assertMatch) { - findings.push(`Assertion failed: ${assertMatch[1].slice(0, 100)}`); - } - suggestions.push('Check expected vs actual values'); - } - - // No test output detected - if (findings.length === 0) { - findings.push('No specific test framework output detected'); - suggestions.push('Run tests with verbose output for more details'); - } - - return { - agent: this.name, - score: findings.length > 1 ? 0.85 : 0.5, - findings, - suggestions - }; - } -} diff --git a/js/src/api/connect.ts b/js/src/api/connect.ts deleted file mode 100644 index c3831ba..0000000 --- a/js/src/api/connect.ts +++ /dev/null @@ -1,158 +0,0 @@ -// SAGE API Connection - Auto-connect with machine UUID. -import { execSync } from 'child_process'; -import { homedir, platform } from 'os'; -import { v4 as uuidv4 } from 'uuid'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; -import { join } from 'path'; - -const API_BASE = 'https://sage.api.marketingstudios.in'; - -export interface ConnectResult { - ok: boolean; - keyId?: string; - apiKey?: string; - error?: string; -} - -export async function autoConnect(displayName: string): Promise { - const machineId = getMachineId(); - - try { - const response = await fetch(`${API_BASE}/v1/machine-login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - display_name: displayName, - fingerprint: machineId, - hostname: displayName || machineId.slice(0, 12), - installation_id: machineId, - platform: platform(), - client_version: '1.0.0', - source: 'sage-js', - expiry_days: 30 - }) - }); - - if (!response.ok) { - return { ok: false, error: `HTTP ${response.status}` }; - } - - const data = await response.json() as { key_id?: string; api_key?: string }; - - // Store the key - storeApiKey(data.api_key || data.key_id || machineId); - - return { ok: true, keyId: data.key_id, apiKey: data.api_key }; - } catch (e) { - // Offline - queue for later, but still return success for setup - storeApiKey(machineId); // Use machine ID as temporary key - return { ok: false, error: 'Offline' }; - } -} - -function getMachineId(): string { - // 1. Check stored ID first - const stored = getStoredId(); - if (stored) return stored; - - // 2. Try machine UUID - try { - if (platform() === 'win32') { - const output = execSync('wmic csproduct get uuid', { encoding: 'utf8', timeout: 5000 }); - const uuid = output.split('\n')[1]?.trim(); - if (uuid && uuid !== 'FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF') { - storeId(uuid); - return uuid; - } - } else if (platform() === 'darwin') { - const output = execSync('ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID', { encoding: 'utf8', timeout: 5000 }); - const match = output.match(/"([A-F0-9-]+)"/); - if (match) { - storeId(match[1]); - return match[1]; - } - } else { - // Linux - const output = execSync('cat /etc/machine-id 2>/dev/null || cat /var/lib/dbus/machine-id', { encoding: 'utf8', timeout: 5000 }); - if (output.trim()) { - storeId(output.trim()); - return output.trim(); - } - } - } catch { - // Machine UUID not available - } - - // 3. Try git email - try { - const email = execSync('git config user.email', { encoding: 'utf8', timeout: 5000 }).trim(); - if (email) { - const id = `git:${email}`; - storeId(id); - return id; - } - } catch { - // Git not configured - } - - // 4. Generate random UUID - const newId = uuidv4(); - storeId(newId); - return newId; -} - -function getDataDir(): string { - const home = homedir(); - if (platform() === 'win32') { - return join(process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'SAGE'); - } - return join(home, '.sage'); -} - -function getStoredId(): string | null { - try { - const idPath = join(getDataDir(), 'machine_id'); - if (existsSync(idPath)) { - return readFileSync(idPath, 'utf-8').trim(); - } - } catch { - // Ignore - } - return null; -} - -function storeId(id: string): void { - try { - const dir = getDataDir(); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - writeFileSync(join(dir, 'machine_id'), id, 'utf-8'); - } catch { - // Ignore - } -} - -function storeApiKey(key: string): void { - try { - const dir = getDataDir(); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - writeFileSync(join(dir, 'api_key'), key, 'utf-8'); - } catch { - // Ignore - } -} - -export function getApiKey(): string | null { - try { - const keyPath = join(getDataDir(), 'api_key'); - if (existsSync(keyPath)) { - return readFileSync(keyPath, 'utf-8').trim(); - } - } catch { - // Ignore - } - return null; -} diff --git a/js/src/cli/commands/explain.ts b/js/src/cli/commands/explain.ts deleted file mode 100644 index a14b299..0000000 --- a/js/src/cli/commands/explain.ts +++ /dev/null @@ -1,81 +0,0 @@ -// sage explain -import { Database } from '../../db/index.js'; - -export async function explainCmd(options: { failed?: boolean; id?: string }): Promise { - const db = Database.getInstance(); - - let run; - if (options.id) { - run = db.getRunById(parseInt(options.id, 10)); - if (!run) { - console.error(`Run #${options.id} not found.`); - process.exit(1); - } - } else if (options.failed) { - run = db.getLatestFailedRun(); - if (!run) { - console.log('No failed commands found.'); - return; - } - } else { - run = db.getLatestRun(); - if (!run) { - console.log('No commands in history yet.'); - return; - } - } - - console.log(`Explaining run #${run.id}:\n`); - console.log(`Command: ${run.command}`); - console.log(`Exit code: ${run.exitCode}`); - console.log(`Duration: ${run.durationMs}ms`); - console.log(); - - if (run.exitCode === 0) { - console.log('✓ Command succeeded.'); - console.log(); - console.log('Compressed output:'); - console.log(run.compressed || '(no output)'); - } else { - console.log('✗ Command failed.'); - console.log(); - console.log('Error analysis:'); - console.log(run.compressed || run.stderr || '(no output)'); - - // Pattern-based explanation - const explanation = analyzeError(run.compressed || run.stderr); - if (explanation) { - console.log(); - console.log('Likely cause:'); - console.log(explanation); - } - } -} - -function analyzeError(output: string): string | null { - const lower = output.toLowerCase(); - - if (lower.includes('modulenotfounderror') || lower.includes('cannot find module')) { - return 'Missing dependency. Run: pip install or npm install '; - } - if (lower.includes('syntaxerror')) { - return 'Syntax error in code. Check the file and line number mentioned.'; - } - if (lower.includes('permission denied')) { - return 'Permission issue. Try running with elevated privileges or check file permissions.'; - } - if (lower.includes('command not found')) { - return 'Command not installed or not in PATH.'; - } - if (lower.includes('connection refused')) { - return 'Service not running or wrong port. Check if the server is started.'; - } - if (lower.includes('timeout')) { - return 'Operation timed out. Check network or increase timeout.'; - } - if (lower.includes('out of memory') || lower.includes('heap')) { - return 'Memory exhausted. Try processing smaller batches or increase memory.'; - } - - return null; -} diff --git a/js/src/cli/commands/history.ts b/js/src/cli/commands/history.ts deleted file mode 100644 index f54b94d..0000000 --- a/js/src/cli/commands/history.ts +++ /dev/null @@ -1,39 +0,0 @@ -// sage history -import { Database } from '../../db/index.js'; - -export async function historyCmd(options: { limit: string; failed?: boolean }): Promise { - const db = Database.getInstance(); - const limit = parseInt(options.limit, 10) || 10; - - const runs = db.getRecentRuns(limit * 2); // Get more, then filter - - let filtered = runs; - if (options.failed) { - filtered = runs.filter(r => r.exitCode !== 0); - } - filtered = filtered.slice(0, limit); - - if (filtered.length === 0) { - console.log(options.failed - ? 'No failed commands in history.' - : 'No commands in history yet.'); - return; - } - - console.log('Recent commands:\n'); - - for (const run of filtered) { - const status = run.exitCode === 0 ? '✓' : '✗'; - const saved = run.originalTokens - run.compressedTokens; - const time = new Date(run.createdAt).toLocaleString(); - - console.log(`#${run.id} ${status} ${run.command}`); - console.log(` Exit: ${run.exitCode} | Saved: ${saved} tokens | ${time}`); - console.log(); - } - - // Show stats - const stats = db.getTotalStats(); - console.log('─'.repeat(50)); - console.log(`Total: ${stats.runs} runs | ${stats.savedTokens.toLocaleString()} tokens saved`); -} diff --git a/js/src/cli/commands/predict.ts b/js/src/cli/commands/predict.ts deleted file mode 100644 index bcf20f0..0000000 --- a/js/src/cli/commands/predict.ts +++ /dev/null @@ -1,59 +0,0 @@ -// sage predict -import { FailurePredictor } from '../../ml/index.js'; - -export async function predictCmd(cmdParts: string[]): Promise { - if (cmdParts.length === 0) { - console.error('Usage: sage predict '); - process.exit(1); - } - - const command = cmdParts.join(' '); - const predictor = new FailurePredictor(); - const prediction = predictor.predict(command); - - console.log('Failure Prediction'); - console.log('─'.repeat(40)); - console.log(`Command: ${command}`); - console.log(); - - // Visual risk indicator - const riskPercent = Math.round(prediction.risk * 100); - const riskBar = '█'.repeat(Math.round(riskPercent / 5)) + '░'.repeat(20 - Math.round(riskPercent / 5)); - - let riskLevel: string; - let riskColor: string; - if (riskPercent < 20) { - riskLevel = 'LOW'; - riskColor = '\x1b[32m'; // Green - } else if (riskPercent < 50) { - riskLevel = 'MEDIUM'; - riskColor = '\x1b[33m'; // Yellow - } else if (riskPercent < 80) { - riskLevel = 'HIGH'; - riskColor = '\x1b[31m'; // Red - } else { - riskLevel = 'VERY HIGH'; - riskColor = '\x1b[91m'; // Bright red - } - - console.log(`Risk: ${riskColor}${riskPercent}% ${riskLevel}\x1b[0m`); - console.log(` [${riskBar}]`); - console.log(); - console.log(`Confidence: ${Math.round(prediction.confidence * 100)}%`); - console.log(`Reason: ${prediction.reason}`); - - if (prediction.risk > 0.5) { - console.log(); - console.log('⚠️ High risk detected. Consider:'); - if (command.includes('rm -rf') || command.includes('del /')) { - console.log(' - Double-check the path before running'); - console.log(' - Consider using trash/recycle instead of permanent delete'); - } - if (command.includes('--force') || command.includes('-f')) { - console.log(' - Remove --force flag if not necessary'); - } - if (command.includes('sudo') || command.includes('admin')) { - console.log(' - Verify you need elevated privileges'); - } - } -} diff --git a/js/src/cli/commands/run.ts b/js/src/cli/commands/run.ts deleted file mode 100644 index e21ecc3..0000000 --- a/js/src/cli/commands/run.ts +++ /dev/null @@ -1,40 +0,0 @@ -// sage run -- -import { runCommand, parseCommand } from '../../runner/index.js'; -import { FailurePredictor } from '../../ml/index.js'; - -export async function runCmd( - cmdParts: string[], - options: { pty?: boolean; predict?: boolean } -): Promise { - if (cmdParts.length === 0) { - console.error('Usage: sage run -- '); - process.exit(1); - } - - const fullCommand = cmdParts.join(' '); - - // Optional prediction - if (options.predict) { - const predictor = new FailurePredictor(); - const prediction = predictor.predict(fullCommand); - - console.log(`[SAGE] Failure risk: ${Math.round(prediction.risk * 100)}%`); - console.log(`[SAGE] Confidence: ${Math.round(prediction.confidence * 100)}%`); - console.log(`[SAGE] Reason: ${prediction.reason}`); - console.log(); - } - - const { command, args } = parseCommand(fullCommand); - - const result = await runCommand(command, args, { - pty: options.pty, - cwd: process.cwd() - }); - - // Print compression stats - console.log(); - console.log(`[SAGE] Run #${result.runId} | Exit: ${result.exitCode} | ${result.durationMs}ms`); - console.log(`[SAGE] Compression: ${result.compression.originalTokens} → ${result.compression.compressedTokens} tokens (${result.compression.compressionRatio} saved)`); - - process.exit(result.exitCode); -} diff --git a/js/src/cli/commands/suggest.ts b/js/src/cli/commands/suggest.ts deleted file mode 100644 index 0d7c9ed..0000000 --- a/js/src/cli/commands/suggest.ts +++ /dev/null @@ -1,116 +0,0 @@ -// sage suggest -import { Database } from '../../db/index.js'; - -export async function suggestCmd(options: { failed?: boolean; id?: string }): Promise { - const db = Database.getInstance(); - - let run; - if (options.id) { - run = db.getRunById(parseInt(options.id, 10)); - if (!run) { - console.error(`Run #${options.id} not found.`); - process.exit(1); - } - } else if (options.failed) { - run = db.getLatestFailedRun(); - if (!run) { - console.log('No failed commands found.'); - return; - } - } else { - run = db.getLatestRun(); - if (!run) { - console.log('No commands in history yet.'); - return; - } - } - - console.log(`Suggestions for run #${run.id}:\n`); - console.log(`Command: ${run.command}`); - console.log(`Exit code: ${run.exitCode}`); - console.log(); - - const suggestions = generateSuggestions(run.command, run.exitCode, run.compressed || run.stderr); - - if (suggestions.length === 0) { - console.log('No specific suggestions. Review the output above.'); - return; - } - - console.log('Next steps:'); - suggestions.forEach((suggestion, i) => { - console.log(` ${i + 1}. ${suggestion}`); - }); -} - -function generateSuggestions(command: string, exitCode: number, output: string): string[] { - const suggestions: string[] = []; - const lower = output.toLowerCase(); - const cmdLower = command.toLowerCase(); - - // Success suggestions - if (exitCode === 0) { - if (cmdLower.includes('test') || cmdLower.includes('pytest') || cmdLower.includes('jest')) { - suggestions.push('All tests passed. Consider running with coverage: --coverage'); - } - if (cmdLower.includes('build')) { - suggestions.push('Build succeeded. Ready to deploy or run.'); - } - if (cmdLower.includes('install')) { - suggestions.push('Installation complete. Verify with --version or by importing.'); - } - return suggestions; - } - - // Error-based suggestions - if (lower.includes('modulenotfounderror') || lower.includes('cannot find module')) { - const moduleMatch = output.match(/No module named '([^']+)'|Cannot find module '([^']+)'/); - const moduleName = moduleMatch?.[1] || moduleMatch?.[2]; - if (moduleName) { - if (cmdLower.includes('python') || cmdLower.includes('pip')) { - suggestions.push(`Install missing module: pip install ${moduleName}`); - } else { - suggestions.push(`Install missing module: npm install ${moduleName}`); - } - } - } - - if (lower.includes('syntaxerror')) { - suggestions.push('Fix the syntax error at the line number shown.'); - suggestions.push('Check for missing colons, brackets, or indentation.'); - } - - if (lower.includes('permission denied')) { - suggestions.push('Try with elevated privileges: sudo (Unix) or Run as Administrator (Windows)'); - suggestions.push('Check file permissions: ls -la (Unix) or icacls (Windows)'); - } - - if (lower.includes('command not found')) { - suggestions.push('Install the missing command or add it to PATH.'); - suggestions.push('Check spelling of the command name.'); - } - - if (lower.includes('connection refused')) { - suggestions.push('Start the required service first.'); - suggestions.push('Check if the port is correct and not blocked by firewall.'); - } - - if (lower.includes('enoent') || lower.includes('no such file')) { - suggestions.push('Check if the file/directory exists.'); - suggestions.push('Verify the path is correct (relative vs absolute).'); - } - - if (lower.includes('assertion') || lower.includes('test failed')) { - suggestions.push('Review the failing test and expected vs actual values.'); - suggestions.push('Run with verbose output for more details.'); - } - - // Generic fallback - if (suggestions.length === 0) { - suggestions.push('Review the error output above.'); - suggestions.push('Search the error message online.'); - suggestions.push('Check documentation for the command.'); - } - - return suggestions; -} diff --git a/js/src/cli/postinstall.ts b/js/src/cli/postinstall.ts deleted file mode 100644 index 8523c3f..0000000 --- a/js/src/cli/postinstall.ts +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env node - -const message = ` -SAGE installed. - -Next step: - sage install - -That connects this machine, activates SAGE for local AI agents, and verifies -that future terminal commands are routed through SAGE. - -If you only want to wrap one command now: - sage run -- pytest - -Shortcuts also work: - sage pytest - sage npm test - sage git status -`; - -console.log(message.trim()); diff --git a/js/src/cli/setup.ts b/js/src/cli/setup.ts deleted file mode 100644 index 813c2fc..0000000 --- a/js/src/cli/setup.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { setupPythonSage } from '../python/bridge.js'; - -export function isSetupComplete(): boolean { - // Setup state is owned by the Python package. The npm launcher should not - // keep a second, divergent setup flag. - return false; -} - -export async function setup(_force: boolean = false, _yes: boolean = true): Promise { - setupPythonSage(); -} diff --git a/js/src/compression/index.ts b/js/src/compression/index.ts deleted file mode 100644 index 7b0f764..0000000 --- a/js/src/compression/index.ts +++ /dev/null @@ -1,155 +0,0 @@ -// SAGE Compression Engine - 97% token savings -import { ERROR_PATTERNS, NOISE_PATTERNS, DUPLICATE_THRESHOLD } from './patterns.js'; -import { countTokens } from './tokenizer.js'; - -export interface CompressionResult { - original: string; - compressed: string; - originalTokens: number; - compressedTokens: number; - savedTokens: number; - compressionRatio: string; - strategy: string; -} - -export function compress(output: string, exitCode: number): CompressionResult { - const originalTokens = countTokens(output); - - if (originalTokens < 100) { - return { - original: output, - compressed: output, - originalTokens, - compressedTokens: originalTokens, - savedTokens: 0, - compressionRatio: '0%', - strategy: 'passthrough' - }; - } - - let compressed: string; - let strategy: string; - - if (exitCode !== 0) { - // Failed command - extract errors - compressed = extractErrors(output); - strategy = 'error-extraction'; - } else { - // Success - summarize - compressed = summarizeOutput(output); - strategy = 'summarization'; - } - - // Remove duplicates - compressed = removeDuplicates(compressed); - - // Remove noise - compressed = removeNoise(compressed); - - const compressedTokens = countTokens(compressed); - const savedTokens = originalTokens - compressedTokens; - const ratio = originalTokens > 0 - ? Math.round((savedTokens / originalTokens) * 100) - : 0; - - return { - original: output, - compressed, - originalTokens, - compressedTokens, - savedTokens, - compressionRatio: `${ratio}%`, - strategy - }; -} - -function extractErrors(output: string): string { - const lines = output.split('\n'); - const errorLines: string[] = []; - const seenErrors = new Set(); - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const lowerLine = line.toLowerCase(); - - // Check if line matches error patterns - for (const pattern of ERROR_PATTERNS) { - if (pattern.test(lowerLine)) { - // Get context: 2 lines before, error line, 3 lines after - const start = Math.max(0, i - 2); - const end = Math.min(lines.length, i + 4); - const context = lines.slice(start, end).join('\n'); - - // Deduplicate similar errors - const errorKey = line.slice(0, 100); - if (!seenErrors.has(errorKey)) { - seenErrors.add(errorKey); - errorLines.push(context); - } - break; - } - } - } - - if (errorLines.length === 0) { - // No patterns matched, return last 50 lines - return lines.slice(-50).join('\n'); - } - - // Add summary header - const header = `[SAGE] Found ${errorLines.length} error(s):\n\n`; - return header + errorLines.join('\n---\n'); -} - -function summarizeOutput(output: string): string { - const lines = output.split('\n'); - - if (lines.length <= 20) { - return output; - } - - // Keep first 10 and last 10 lines - const head = lines.slice(0, 10); - const tail = lines.slice(-10); - const skipped = lines.length - 20; - - return [ - ...head, - `\n[SAGE] ... ${skipped} lines omitted ...\n`, - ...tail - ].join('\n'); -} - -function removeDuplicates(output: string): string { - const lines = output.split('\n'); - const seen = new Map(); - const result: string[] = []; - - for (const line of lines) { - const normalized = line.trim().slice(0, DUPLICATE_THRESHOLD); - const count = seen.get(normalized) || 0; - - if (count < 3) { - result.push(line); - seen.set(normalized, count + 1); - } else if (count === 3) { - result.push(`[SAGE] ... repeated ${normalized.slice(0, 50)}...`); - seen.set(normalized, count + 1); - } - } - - return result.join('\n'); -} - -function removeNoise(output: string): string { - let result = output; - - for (const pattern of NOISE_PATTERNS) { - result = result.replace(pattern, ''); - } - - // Collapse multiple blank lines - result = result.replace(/\n{3,}/g, '\n\n'); - - return result.trim(); -} diff --git a/js/src/compression/patterns.ts b/js/src/compression/patterns.ts deleted file mode 100644 index 7458ace..0000000 --- a/js/src/compression/patterns.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Error detection patterns -export const ERROR_PATTERNS: RegExp[] = [ - // General errors - /error[:\s]/i, - /exception[:\s]/i, - /failed[:\s]/i, - /failure[:\s]/i, - /fatal[:\s]/i, - /panic[:\s]/i, - - // Python - /traceback/i, - /^.*error:.*$/i, - /importerror/i, - /syntaxerror/i, - /typeerror/i, - /valueerror/i, - /keyerror/i, - /attributeerror/i, - /nameerror/i, - /indexerror/i, - /modulenotfounderror/i, - - // JavaScript/Node - /referenceerror/i, - /uncaught/i, - /unhandled/i, - /cannot find module/i, - /is not defined/i, - /is not a function/i, - /cannot read propert/i, - - // Rust - /error\[e\d+\]/i, - /cannot find/i, - /mismatched types/i, - - // Go - /undefined:/i, - /cannot use/i, - - // Build tools - /build failed/i, - /compilation failed/i, - /npm err!/i, - /yarn error/i, - /pip error/i, - /cargo error/i, - - // Tests - /assert.*failed/i, - /test.*failed/i, - /failed.*test/i, - /\d+ failed/i, - - // General - /permission denied/i, - /access denied/i, - /not found/i, - /no such file/i, - /command not found/i, - /timeout/i, - /connection refused/i, - /segmentation fault/i, - /out of memory/i, - /stack overflow/i, -]; - -// Noise patterns to remove -export const NOISE_PATTERNS: RegExp[] = [ - // Progress bars and spinners - /[⠀-⣿]+/g, // Braille patterns - /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]+/g, // Spinner chars - /\[[\s=>#-]*\]\s*\d+%/g, // Progress bars [=====> ] 50% - /\.{4,}/g, // Long dots - - // ANSI escape codes - /\x1b\[[0-9;]*m/g, - /\x1b\[\d+[A-Za-z]/g, - - // Timestamps in logs (but keep the message) - /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[.,]\d+\s*/gm, - - // npm install noise - /added \d+ packages.*$/gm, - /^npm warn.*$/gm, - - // pip install noise - /^Collecting .*/gm, - /^Downloading .*/gm, - /^Installing collected packages.*/gm, - /^Successfully installed.*/gm, - - // Git noise - /^remote: Counting objects:.*/gm, - /^remote: Compressing objects:.*/gm, - /^Receiving objects:.*/gm, - /^Resolving deltas:.*/gm, -]; - -// Threshold for duplicate detection -export const DUPLICATE_THRESHOLD = 80; diff --git a/js/src/compression/tokenizer.ts b/js/src/compression/tokenizer.ts deleted file mode 100644 index 0ccc097..0000000 --- a/js/src/compression/tokenizer.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Token counting using tiktoken or fallback estimation - -let encoder: any = null; - -export function countTokens(text: string): number { - if (!text) return 0; - - // Try tiktoken first - try { - if (!encoder) { - // Lazy load tiktoken - const tiktoken = require('tiktoken'); - encoder = tiktoken.encoding_for_model('gpt-4'); - } - return encoder.encode(text).length; - } catch { - // Fallback: estimate ~4 chars per token (conservative) - return Math.ceil(text.length / 4); - } -} - -export function truncateToTokens(text: string, maxTokens: number): string { - const tokens = countTokens(text); - - if (tokens <= maxTokens) { - return text; - } - - // Binary search for the right truncation point - let low = 0; - let high = text.length; - - while (low < high) { - const mid = Math.floor((low + high + 1) / 2); - if (countTokens(text.slice(0, mid)) <= maxTokens) { - low = mid; - } else { - high = mid - 1; - } - } - - return text.slice(0, low) + '\n[SAGE] ... truncated ...'; -} diff --git a/js/src/db/index.ts b/js/src/db/index.ts deleted file mode 100644 index 5050615..0000000 --- a/js/src/db/index.ts +++ /dev/null @@ -1,192 +0,0 @@ -// SAGE Database - SQLite storage for command history -import BetterSqlite3 from 'better-sqlite3'; -import { homedir } from 'os'; -import { join } from 'path'; -import { mkdirSync, existsSync } from 'fs'; -import { SCHEMA, MIGRATIONS } from './schema.js'; - -export interface RunRecord { - command: string; - exitCode: number; - stdout: string; - stderr: string; - compressed: string; - originalTokens: number; - compressedTokens: number; - durationMs: number; -} - -export interface SavedRun extends RunRecord { - id: number; - createdAt: string; -} - -export class Database { - private static instance: Database; - private db: BetterSqlite3.Database; - - private constructor() { - const dataDir = this.getDataDir(); - if (!existsSync(dataDir)) { - mkdirSync(dataDir, { recursive: true }); - } - - const dbPath = join(dataDir, 'sage.db'); - this.db = new BetterSqlite3(dbPath); - this.db.pragma('journal_mode = WAL'); - this.initSchema(); - } - - static getInstance(): Database { - if (!Database.instance) { - Database.instance = new Database(); - } - return Database.instance; - } - - private getDataDir(): string { - const home = homedir(); - if (process.platform === 'win32') { - return join(process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'SAGE'); - } - return join(home, '.sage'); - } - - private initSchema(): void { - this.db.exec(SCHEMA); - this.runMigrations(); - } - - private runMigrations(): void { - const currentVersion = this.getSchemaVersion(); - - for (const migration of MIGRATIONS) { - if (migration.version > currentVersion) { - this.db.exec(migration.sql); - this.setSchemaVersion(migration.version); - } - } - } - - private getSchemaVersion(): number { - try { - const row = this.db.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1').get() as { version: number } | undefined; - return row?.version || 0; - } catch { - return 0; - } - } - - private setSchemaVersion(version: number): void { - this.db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(version); - } - - saveRun(record: RunRecord): number { - const stmt = this.db.prepare(` - INSERT INTO runs (command, exit_code, stdout, stderr, compressed, original_tokens, compressed_tokens, duration_ms, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) - `); - - const result = stmt.run( - record.command, - record.exitCode, - record.stdout, - record.stderr, - record.compressed, - record.originalTokens, - record.compressedTokens, - record.durationMs - ); - - return result.lastInsertRowid as number; - } - - getLatestRun(): SavedRun | null { - const row = this.db.prepare(` - SELECT * FROM runs ORDER BY id DESC LIMIT 1 - `).get() as any; - - return row ? this.mapRow(row) : null; - } - - getLatestFailedRun(): SavedRun | null { - const row = this.db.prepare(` - SELECT * FROM runs WHERE exit_code != 0 ORDER BY id DESC LIMIT 1 - `).get() as any; - - return row ? this.mapRow(row) : null; - } - - getRecentRuns(limit: number = 10): SavedRun[] { - const rows = this.db.prepare(` - SELECT * FROM runs ORDER BY id DESC LIMIT ? - `).all(limit) as any[]; - - return rows.map(row => this.mapRow(row)); - } - - getRunById(id: number): SavedRun | null { - const row = this.db.prepare(` - SELECT * FROM runs WHERE id = ? - `).get(id) as any; - - return row ? this.mapRow(row) : null; - } - - findSimilarCommands(command: string, limit: number = 20): SavedRun[] { - // Extract base command for matching - const baseCmd = command.split(/\s+/)[0]; - - const rows = this.db.prepare(` - SELECT * FROM runs - WHERE command LIKE ? - ORDER BY id DESC - LIMIT ? - `).all(`${baseCmd}%`, limit) as any[]; - - return rows.map(row => this.mapRow(row)); - } - - findExactCommand(command: string): SavedRun[] { - const rows = this.db.prepare(` - SELECT * FROM runs WHERE command = ? ORDER BY id DESC - `).all(command) as any[]; - - return rows.map(row => this.mapRow(row)); - } - - getTotalStats(): { runs: number; savedTokens: number; totalTime: number } { - const row = this.db.prepare(` - SELECT - COUNT(*) as runs, - COALESCE(SUM(original_tokens - compressed_tokens), 0) as saved_tokens, - COALESCE(SUM(duration_ms), 0) as total_time - FROM runs - `).get() as any; - - return { - runs: row.runs || 0, - savedTokens: row.saved_tokens || 0, - totalTime: row.total_time || 0 - }; - } - - private mapRow(row: any): SavedRun { - return { - id: row.id, - command: row.command, - exitCode: row.exit_code, - stdout: row.stdout, - stderr: row.stderr, - compressed: row.compressed, - originalTokens: row.original_tokens, - compressedTokens: row.compressed_tokens, - durationMs: row.duration_ms, - createdAt: row.created_at - }; - } - - close(): void { - this.db.close(); - } -} diff --git a/js/src/db/schema.ts b/js/src/db/schema.ts deleted file mode 100644 index 43d19d1..0000000 --- a/js/src/db/schema.ts +++ /dev/null @@ -1,71 +0,0 @@ -// SAGE Database Schema - -export const SCHEMA = ` --- Schema version tracking -CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER PRIMARY KEY, - applied_at TEXT DEFAULT (datetime('now')) -); - --- Command history -CREATE TABLE IF NOT EXISTS runs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - command TEXT NOT NULL, - exit_code INTEGER NOT NULL, - stdout TEXT, - stderr TEXT, - compressed TEXT, - original_tokens INTEGER DEFAULT 0, - compressed_tokens INTEGER DEFAULT 0, - duration_ms INTEGER DEFAULT 0, - created_at TEXT DEFAULT (datetime('now')) -); - --- Agents table -CREATE TABLE IF NOT EXISTS agents ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL, - status TEXT DEFAULT 'idle', - capabilities TEXT, - created_at TEXT DEFAULT (datetime('now')), - last_active TEXT -); - --- Setup state -CREATE TABLE IF NOT EXISTS setup ( - key TEXT PRIMARY KEY, - value TEXT, - updated_at TEXT DEFAULT (datetime('now')) -); - --- Indexes for performance -CREATE INDEX IF NOT EXISTS idx_runs_command ON runs(command); -CREATE INDEX IF NOT EXISTS idx_runs_exit_code ON runs(exit_code); -CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at); -CREATE INDEX IF NOT EXISTS idx_agents_type ON agents(type); -`; - -export interface Migration { - version: number; - sql: string; -} - -export const MIGRATIONS: Migration[] = [ - { - version: 1, - sql: ` - -- Initial schema, nothing to migrate - INSERT OR IGNORE INTO schema_version (version) VALUES (1); - ` - }, - { - version: 2, - sql: ` - -- Add prediction columns - ALTER TABLE runs ADD COLUMN predicted_risk REAL DEFAULT 0; - ALTER TABLE runs ADD COLUMN prediction_correct INTEGER DEFAULT NULL; - INSERT INTO schema_version (version) VALUES (2); - ` - } -]; diff --git a/js/src/install/hooks.ts b/js/src/install/hooks.ts deleted file mode 100644 index ec98a2f..0000000 --- a/js/src/install/hooks.ts +++ /dev/null @@ -1,189 +0,0 @@ -// SAGE Hooks - enforce mandatory npm/npx SAGE wrapper for AI tools. -import { homedir } from 'os'; -import { join, dirname } from 'path'; -import { existsSync, writeFileSync, mkdirSync, chmodSync, readFileSync } from 'fs'; -import { PYPI_RUN_PREFIX, SAGE_RUN_PREFIX } from './targets.js'; - -const ENFORCE_SAGE_PY = `#!/usr/bin/env python3 -"""SAGE npm enforcement hook - blocks commands not routed through npx SAGE.""" -import json -import sys - -SAGE_PREFIXES = (${JSON.stringify(SAGE_RUN_PREFIX)}, ${JSON.stringify(PYPI_RUN_PREFIX)}) - -def main(): - try: - data = json.load(sys.stdin) - except Exception: - return 0 - - tool_name = str(data.get("tool_name") or "") - tool_input = data.get("tool_input") or {} - - if tool_name not in ["Bash", "Shell", "PowerShell", "bash_tool"]: - return 0 - - command = str(tool_input.get("command") or "").strip() - if any(command.startswith(prefix) for prefix in SAGE_PREFIXES): - return 0 - - print("SAGE enforcement: shell commands must start with one of:", ", ".join(SAGE_PREFIXES), file=sys.stderr) - print("The blocked command is intentionally not printed to avoid leaking secrets.", file=sys.stderr) - return 2 - -if __name__ == "__main__": - raise SystemExit(main()) -`; - -const ENFORCE_SAGE_JS = `#!/usr/bin/env node -const SAGE_PREFIXES = [${JSON.stringify(SAGE_RUN_PREFIX)}, ${JSON.stringify(PYPI_RUN_PREFIX)}]; - -let input = ''; -process.stdin.setEncoding('utf8'); -process.stdin.on('data', chunk => input += chunk); -process.stdin.on('end', () => { - try { - const data = JSON.parse(input || '{}'); - const toolName = data.tool_name || ''; - const toolInput = data.tool_input || {}; - if (!['Bash', 'Shell', 'PowerShell', 'bash_tool'].includes(toolName)) process.exit(0); - const command = String(toolInput.command || '').trim(); - if (SAGE_PREFIXES.some(prefix => command.startsWith(prefix))) process.exit(0); - console.error('SAGE enforcement: shell commands must start with one of: ' + SAGE_PREFIXES.join(', ')); - console.error('The blocked command is intentionally not printed to avoid leaking secrets.'); - process.exit(2); - } catch { - process.exit(0); - } -}); -`; - -const CLAUDE_HOOKS_SETTINGS = { - hooks: { - PreToolUse: [ - { - matcher: 'Bash|Shell|PowerShell', - hooks: [ - { - type: 'command', - command: 'python ~/.claude/hooks/enforce_sage.py', - }, - ], - }, - ], - }, -}; - -interface HookTarget { - name: string; - hookPath: string; - settingsPath?: string; - hookContent: string; - settingsContent?: object; -} - -const HOOK_TARGETS: HookTarget[] = [ - { name: 'Claude Code', hookPath: '~/.claude/hooks/enforce_sage.py', settingsPath: '~/.claude/settings.json', hookContent: ENFORCE_SAGE_PY, settingsContent: CLAUDE_HOOKS_SETTINGS }, - { name: 'Codex CLI', hookPath: '~/.codex/hooks/enforce_sage.py', settingsPath: '~/.codex/config.json', hookContent: ENFORCE_SAGE_PY, settingsContent: { hooks: { pre_command: ['python ~/.codex/hooks/enforce_sage.py'] } } }, - { name: 'OpenCode', hookPath: '~/.config/opencode/hooks/enforce_sage.py', settingsPath: '~/.config/opencode/settings.json', hookContent: ENFORCE_SAGE_PY, settingsContent: { hooks: { PreToolUse: [{ matcher: 'Bash|Shell|PowerShell', hooks: [{ type: 'command', command: 'python ~/.config/opencode/hooks/enforce_sage.py' }] }] } } }, - { name: 'Cline', hookPath: '~/.cline/hooks/enforce_sage.py', settingsPath: '~/.cline/settings.json', hookContent: ENFORCE_SAGE_PY, settingsContent: { hooks: { pre_command: ['python ~/.cline/hooks/enforce_sage.py'] } } }, - { name: 'Cursor', hookPath: '~/.cursor/hooks/enforce_sage.py', hookContent: ENFORCE_SAGE_PY }, - { name: 'Windsurf', hookPath: '~/.windsurf/hooks/enforce_sage.py', hookContent: ENFORCE_SAGE_PY }, - { name: 'Aider', hookPath: '~/.aider/hooks/enforce_sage.py', hookContent: ENFORCE_SAGE_PY }, - { name: 'JetBrains AI', hookPath: '~/.junie/hooks/enforce_sage.py', hookContent: ENFORCE_SAGE_PY }, -]; - -export async function installHooks(): Promise { - let installed = 0; - - for (const target of HOOK_TARGETS) { - try { - const success = await installHook(target); - if (success) installed++; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`SAGE: could not install ${target.name} hook (${message})`); - } - } - - return installed; -} - -async function installHook(target: HookTarget): Promise { - const hookPath = expandPath(target.hookPath); - const hookDir = dirname(hookPath); - - if (!existsSync(hookDir)) { - mkdirSync(hookDir, { recursive: true }); - } - - writeFileSync(hookPath, target.hookContent, 'utf-8'); - - try { - chmodSync(hookPath, '755'); - } catch { - // Windows does not need chmod. - } - - if (target.settingsPath && target.settingsContent) { - const settingsPath = expandPath(target.settingsPath); - const settingsDir = dirname(settingsPath); - if (!existsSync(settingsDir)) { - mkdirSync(settingsDir, { recursive: true }); - } - - let settings: any = {}; - if (existsSync(settingsPath)) { - try { - settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); - } catch { - settings = {}; - } - } - - settings.hooks = mergeHooks(settings.hooks, (target.settingsContent as any).hooks); - writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); - } - - return true; -} - -function mergeHooks(existing: any, incoming: any): any { - if (!existing || typeof existing !== 'object') { - return incoming; - } - const merged = { ...existing }; - for (const [key, value] of Object.entries(incoming || {})) { - if (Array.isArray(value) && Array.isArray(merged[key])) { - for (const item of value) { - const encoded = JSON.stringify(item); - if (!merged[key].some((existingItem: any) => JSON.stringify(existingItem) === encoded)) { - merged[key].push(item); - } - } - } else if (merged[key] === undefined) { - merged[key] = value; - } else if ( - merged[key] && - typeof merged[key] === 'object' && - value && - typeof value === 'object' && - !Array.isArray(value) - ) { - merged[key] = mergeHooks(merged[key], value); - } else { - merged[key] = value; - } - } - return merged; -} - -function expandPath(path: string): string { - if (path.startsWith('~/') || path.startsWith('~\\') || path === '~') { - const rest = path.slice(1).replace(/^[/\\]/, ''); - return rest ? join(homedir(), rest) : homedir(); - } - return path; -} - -export { HOOK_TARGETS, ENFORCE_SAGE_PY, ENFORCE_SAGE_JS }; diff --git a/js/src/install/index.ts b/js/src/install/index.ts deleted file mode 100644 index 336a37b..0000000 --- a/js/src/install/index.ts +++ /dev/null @@ -1,100 +0,0 @@ -// SAGE AI Agent Injection - 30 tools supported -import { homedir } from 'os'; -import { join, dirname } from 'path'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; -import { AGENT_TARGETS, SAGE_BLOCK_END, SAGE_BLOCK_START } from './targets.js'; - -export async function injectAllAgentConfigs(): Promise { - let injected = 0; - - for (const target of AGENT_TARGETS) { - try { - const success = await injectAgentConfig(target); - if (success) injected++; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`SAGE: could not update ${target.name} config (${message})`); - } - } - - return injected; -} - -interface AgentTarget { - name: string; - path: string; - instruction: string; - createIfMissing: boolean; - fileType: 'markdown' | 'json' | 'yaml'; -} - -async function injectAgentConfig(target: AgentTarget): Promise { - const fullPath = expandPath(target.path); - - // Check if file exists - if (!existsSync(fullPath)) { - if (!target.createIfMissing) return false; - - // Create parent directory - const dir = dirname(fullPath); - mkdirSync(dir, { recursive: true }); - - // Create new file with instruction - writeFileSync(fullPath, target.instruction, 'utf-8'); - return true; - } - - // Read existing content - let content = readFileSync(fullPath, 'utf-8'); - - if (content.includes(target.instruction)) { - return false; - } - - // Replace Python or older npm SAGE managed blocks so the active install path - // dictates the exact command agents must use. - if (content.includes(SAGE_BLOCK_START) && content.includes(SAGE_BLOCK_END)) { - const before = content.split(SAGE_BLOCK_START)[0].trimEnd(); - const after = content.split(SAGE_BLOCK_END).slice(1).join(SAGE_BLOCK_END).trimStart(); - content = `${before}${before ? '\n\n' : ''}${target.instruction.trim()}\n${after ? `\n${after}` : ''}`; - writeFileSync(fullPath, content, 'utf-8'); - return true; - } - - // Old unmanaged SAGE text exists. Prepend the npm managed block so new agent - // sessions see the explicit npx command first. - if (content.includes('SAGE') && (content.includes('sage run') || content.includes('psycgod-sage'))) { - content = target.instruction + '\n\n' + content; - writeFileSync(fullPath, content, 'utf-8'); - return true; - } - - // Inject based on file type - if (target.fileType === 'markdown') { - // Prepend to markdown files - content = target.instruction + '\n\n' + content; - } else if (target.fileType === 'json') { - // For JSON, we'd need to parse and add to appropriate section - // For now, skip JSON files that already exist - return false; - } else if (target.fileType === 'yaml') { - // Prepend as comment to YAML - const yamlInstruction = target.instruction - .split('\n') - .map(line => `# ${line}`) - .join('\n'); - content = yamlInstruction + '\n\n' + content; - } - - writeFileSync(fullPath, content, 'utf-8'); - return true; -} - -function expandPath(path: string): string { - if (path.startsWith('~/') || path.startsWith('~\\') || path === '~') { - // Remove ~ and any leading separator, then join with homedir - const rest = path.slice(1).replace(/^[/\\]/, ''); - return rest ? join(homedir(), rest) : homedir(); - } - return path; -} diff --git a/js/src/install/targets.ts b/js/src/install/targets.ts deleted file mode 100644 index dceee6b..0000000 --- a/js/src/install/targets.ts +++ /dev/null @@ -1,99 +0,0 @@ -// SAGE AI Agent Targets for npm/npx installs. - -export const SAGE_RUN_PREFIX = 'npx -y psycgod-sage run --'; -export const PYPI_RUN_PREFIX = 'sage run --'; -export const SAGE_BLOCK_START = '# SAGE MANAGED BLOCK START'; -export const SAGE_BLOCK_END = '# SAGE MANAGED BLOCK END'; - -export const SAGE_INSTRUCTION = `${SAGE_BLOCK_START} -## SAGE Integration - MANDATORY - -When a user asks for any coding, repo, file, debugging, documentation, or project task, start through SAGE automatically. The user should not need to type \`sage\`. - -First actions for normal repo work: -- Use native file/search/edit tools normally. -- Route every terminal command through the npm SAGE wrapper: \`${SAGE_RUN_PREFIX} \`. - -Route every shell command through SAGE. Do not run bare terminal commands. - -Allowed wrappers: -- \`${SAGE_RUN_PREFIX} \` -- Backup when npm/npx is unavailable: \`${PYPI_RUN_PREFIX} \` - -Rules: -- Python: \`${SAGE_RUN_PREFIX} python script.py\` -- Packages: \`${SAGE_RUN_PREFIX} npm install\` -- Tests: \`${SAGE_RUN_PREFIX} pytest\` -- If the wrapper fails before the target command starts, stop and report the wrapper failure. - -Why: -- npm/npx SAGE delegates to the canonical SAGE core, so behavior matches \`psycgod-sage\`. -- Tracks local command history for the user. -- Compresses noisy output before it reaches AI context. -- Captures safe proof metrics for token savings and ML V1 prediction. -- Keeps raw commands and output local unless the user enables higher telemetry. - -Examples: -- Wrong: \`python test.py\` -- Correct: \`${SAGE_RUN_PREFIX} python test.py\` -- Backup: \`${PYPI_RUN_PREFIX} python test.py\` -- Wrong: \`git status\` -- Correct: \`${SAGE_RUN_PREFIX} git status\` - -Example user request: -- User: "Please help me with my general book in this folder" -- Correct agent start: inspect/read/edit files with available native tools, then use \`${SAGE_RUN_PREFIX} \` only when a terminal command is needed. -- Wrong agent start: bare \`python\`, \`git\`, \`npm\`, or \`ls\`. - -ML V1 is included. Optional ML V2 can be installed later by the user with: -\`npx -y psycgod-sage ml setup\` -${SAGE_BLOCK_END} -`; - -export const SAGE_INSTRUCTION_SHORT = `${SAGE_BLOCK_START} -# SAGE Integration - MANDATORY -# Route all shell commands through: ${SAGE_RUN_PREFIX} -# Backup when npm/npx is unavailable: ${PYPI_RUN_PREFIX} -${SAGE_BLOCK_END} -`; - -export interface AgentTarget { - name: string; - path: string; - instruction: string; - createIfMissing: boolean; - fileType: 'markdown' | 'json' | 'yaml'; -} - -export const AGENT_TARGETS: AgentTarget[] = [ - { name: 'Claude Code', path: '~/.claude/CLAUDE.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Codex CLI', path: '~/.codex/AGENTS.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Cursor', path: '~/.cursorrules', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Windsurf', path: '~/.windsurfrules', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Cline', path: '~/.cline/AGENTS.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Cline Rules', path: '~/.clinerules', instruction: SAGE_INSTRUCTION_SHORT, createIfMissing: true, fileType: 'markdown' }, - { name: 'OpenCode', path: '~/.config/opencode/AGENTS.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'OpenCode Alt', path: '~/.opencode/AGENTS.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Aider', path: '~/.aider.conf.yml', instruction: SAGE_INSTRUCTION_SHORT, createIfMissing: false, fileType: 'yaml' }, - { name: 'JetBrains AI', path: '~/.junie/guidelines.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'GitHub Copilot', path: '~/.github/copilot-instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Amazon Q', path: '~/.aws/amazonq/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Sourcegraph Cody', path: '~/.cody/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Zed AI', path: '~/.config/zed/assistant.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Void', path: '~/.void/AGENTS.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Aide', path: '~/.aide/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Roo Code', path: '~/.roo/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Kodu AI', path: '~/.kodu/AGENTS.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Trae', path: '~/.trae/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Melty', path: '~/.melty/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'PearAI', path: '~/.pearai/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Bolt', path: '~/.bolt/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'SWE-agent', path: '~/.swe-agent/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Continue', path: '~/.continue/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Tabnine', path: '~/.tabnine/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Codeium', path: '~/.codeium/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Supermaven', path: '~/.supermaven/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Augment', path: '~/.augment/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Blackbox AI', path: '~/.blackbox/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, - { name: 'Pieces', path: '~/.pieces/instructions.md', instruction: SAGE_INSTRUCTION, createIfMissing: true, fileType: 'markdown' }, -]; diff --git a/js/src/mcp/server.ts b/js/src/mcp/server.ts deleted file mode 100644 index 123528a..0000000 --- a/js/src/mcp/server.ts +++ /dev/null @@ -1,84 +0,0 @@ -// SAGE MCP Server - Model Context Protocol integration -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from '@modelcontextprotocol/sdk/types.js'; -import { TOOLS, handleToolCall } from './tools.js'; - -const DEFAULT_IDLE_TIMEOUT_MS = 300_000; -const MIN_IDLE_TIMEOUT_MS = 10_000; - -function getIdleTimeoutMs(): number { - const raw = process.env.SAGE_MCP_IDLE_TIMEOUT_SECONDS; - if (!raw) return DEFAULT_IDLE_TIMEOUT_MS; - - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed)) return DEFAULT_IDLE_TIMEOUT_MS; - return Math.max(parsed * 1000, MIN_IDLE_TIMEOUT_MS); -} - -export async function startMcpServer(): Promise { - const idleTimeoutMs = getIdleTimeoutMs(); - let idleTimer: NodeJS.Timeout | undefined; - - const touchActivity = () => { - if (idleTimer) { - clearTimeout(idleTimer); - } - idleTimer = setTimeout(() => { - console.error(`[SAGE MCP Server] idle for ${Math.round(idleTimeoutMs / 1000)}s; exiting`); - process.exit(0); - }, idleTimeoutMs); - idleTimer.unref?.(); - }; - - const server = new Server( - { - name: 'sage', - version: '1.0.0', - }, - { - capabilities: { - tools: {}, - }, - } - ); - - // List available tools - server.setRequestHandler(ListToolsRequestSchema, async () => { - touchActivity(); - return { tools: TOOLS }; - }); - - // Handle tool calls - server.setRequestHandler(CallToolRequestSchema, async (request) => { - touchActivity(); - const { name, arguments: args } = request.params; - - try { - const result = await handleToolCall(name, args || {}); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - return { - content: [{ type: 'text', text: `Error: ${message}` }], - isError: true, - }; - } - }); - - // Start server - const transport = new StdioServerTransport(); - touchActivity(); - console.error(`[SAGE MCP Server] stdio ready (idle timeout ${Math.round(idleTimeoutMs / 1000)}s)`); - await server.connect(transport); -} - -// Run if called directly -if (process.argv[1]?.endsWith('server.js') || process.argv[1]?.endsWith('server.ts')) { - startMcpServer().catch(console.error); -} diff --git a/js/src/mcp/tools.ts b/js/src/mcp/tools.ts deleted file mode 100644 index 613635b..0000000 --- a/js/src/mcp/tools.ts +++ /dev/null @@ -1,719 +0,0 @@ -// SAGE MCP Tools - All tool definitions and handlers -import { runCommand, parseCommand } from '../runner/index.js'; -import { Database } from '../db/index.js'; -import { compress } from '../compression/index.js'; -import { FailurePredictor } from '../ml/index.js'; -import { selectAgents } from '../agents/index.js'; - -export const TOOLS = [ - { - name: 'sage_run', - description: 'Run a command with SAGE compression and tracking. Returns compressed output.', - inputSchema: { - type: 'object', - properties: { - command: { - type: 'string', - description: 'Command to execute' - } - }, - required: ['command'] - } - }, - { - name: 'sage_read_file', - description: 'Read a file with SAGE compression for large files.', - inputSchema: { - type: 'object', - properties: { - path: { type: 'string', description: 'File path to read' }, - lines: { type: 'string', description: 'Optional line range START:END' }, - raw: { type: 'boolean', description: 'Return exact content without compression' } - }, - required: ['path'] - } - }, - { - name: 'sage_write_file', - description: 'Write content to a file.', - inputSchema: { - type: 'object', - properties: { - path: { type: 'string', description: 'File path to write' }, - content: { type: 'string', description: 'Content to write' }, - overwrite: { type: 'boolean', description: 'Allow overwriting existing file' } - }, - required: ['path', 'content'] - } - }, - { - name: 'sage_edit_file', - description: 'Edit a file by replacing exact string matches.', - inputSchema: { - type: 'object', - properties: { - path: { type: 'string', description: 'File path to edit' }, - old: { type: 'string', description: 'Exact string to replace' }, - new: { type: 'string', description: 'Replacement string' } - }, - required: ['path', 'old', 'new'] - } - }, - { - name: 'sage_grep', - description: 'Search files for a pattern.', - inputSchema: { - type: 'object', - properties: { - pattern: { type: 'string', description: 'Regex pattern to search' }, - paths: { type: 'array', items: { type: 'string' }, description: 'Paths to search' }, - glob: { type: 'string', description: 'Filename filter like *.py' } - }, - required: ['pattern'] - } - }, - { - name: 'sage_glob', - description: 'Find files matching a pattern.', - inputSchema: { - type: 'object', - properties: { - pattern: { type: 'string', description: 'Glob pattern like **/*.ts' }, - root: { type: 'string', description: 'Root directory' } - }, - required: ['pattern'] - } - }, - { - name: 'sage_tree', - description: 'Show directory tree.', - inputSchema: { - type: 'object', - properties: { - root: { type: 'string', description: 'Root directory' }, - depth: { type: 'number', description: 'Max depth' } - } - } - }, - { - name: 'sage_get_history', - description: 'Get recent command history.', - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Max entries to return' }, - failed_only: { type: 'boolean', description: 'Only show failed commands' } - } - } - }, - { - name: 'sage_explain_error', - description: 'Get explanation for a command error.', - inputSchema: { - type: 'object', - properties: { - command_id: { type: 'number', description: 'Command ID to explain' } - } - } - }, - { - name: 'sage_suggest_fix', - description: 'Get suggested fix for a failed command.', - inputSchema: { - type: 'object', - properties: { - command_id: { type: 'number', description: 'Command ID to suggest fix for' } - } - } - }, - { - name: 'sage_spawn_agent', - description: 'Spawn a SAGE agent for a specific task.', - inputSchema: { - type: 'object', - properties: { - agent_type: { - type: 'string', - enum: ['code', 'debug', 'test', 'security', 'performance'], - description: 'Agent type to spawn' - }, - task: { type: 'string', description: 'Task description' } - }, - required: ['agent_type', 'task'] - } - }, - { - name: 'sage_validate', - description: 'Deep validation: AST errors, security issues (hardcoded secrets), code quality (TODO/debug code).', - inputSchema: { - type: 'object', - properties: { - path: { type: 'string', description: 'File path to validate' }, - content: { type: 'string', description: 'Optional content to validate instead of reading file' } - }, - required: ['path'] - } - }, - { - name: 'sage_analyze_context', - description: 'Analyze codebase patterns (naming, error handling, testing), style (indent, quotes), file structure.', - inputSchema: { - type: 'object', - properties: { - path: { type: 'string', description: 'File or directory to analyze' }, - sample_size: { type: 'number', description: 'Number of files to sample for pattern detection' } - } - } - }, - { - name: 'sage_rollback', - description: 'Rollback a file to its state before the last write/edit. Uses snapshot system.', - inputSchema: { - type: 'object', - properties: { - snapshot_id: { type: 'string', description: 'Snapshot ID from previous write/edit result' } - }, - required: ['snapshot_id'] - } - }, - { - name: 'sage_agentic_run', - description: 'Run command with automatic failure recovery. Diagnoses errors and retries with fixes.', - inputSchema: { - type: 'object', - properties: { - command: { type: 'string', description: 'Command to execute' }, - max_retries: { type: 'number', description: 'Max recovery attempts (default 3)' }, - autonomy: { type: 'string', enum: ['suggest', 'ask', 'auto'], description: 'Fix autonomy level' } - }, - required: ['command'] - } - }, - { - name: 'sage_agentic_fix', - description: 'Get the best fix candidate for a failed command.', - inputSchema: { - type: 'object', - properties: { - command_id: { type: 'number', description: 'Run ID of failed command (default: most recent)' } - } - } - } -]; - -export async function handleToolCall(name: string, args: Record): Promise { - const db = Database.getInstance(); - - switch (name) { - case 'sage_run': { - const command = args.command as string; - const { command: cmd, args: cmdArgs } = parseCommand(command); - const result = await runCommand(cmd, cmdArgs); - - return { - success: result.exitCode === 0, - exit_code: result.exitCode, - run_id: result.runId, - compression: { - original_tokens: result.compression.originalTokens, - compressed_tokens: result.compression.compressedTokens, - saved_tokens: result.compression.savedTokens, - compression_ratio: result.compression.compressionRatio - }, - output: result.compression.compressed, - duration_ms: result.durationMs - }; - } - - case 'sage_read_file': { - const { readFileSync, existsSync } = await import('fs'); - const path = args.path as string; - - if (!existsSync(path)) { - return { success: false, error: `File not found: ${path}` }; - } - - const content = readFileSync(path, 'utf-8'); - const lines = args.lines as string | undefined; - - if (lines) { - const [start, end] = lines.split(':').map(Number); - const allLines = content.split('\n'); - const selected = allLines.slice(start - 1, end === -1 ? undefined : end); - return { success: true, content: selected.join('\n'), lines: selected.length }; - } - - if (args.raw) { - return { success: true, content, lines: content.split('\n').length }; - } - - // Compress large files - const compression = compress(content, 0); - return { - success: true, - content: compression.compressed, - original_tokens: compression.originalTokens, - compressed_tokens: compression.compressedTokens - }; - } - - case 'sage_write_file': { - const { writeFileSync, existsSync, mkdirSync } = await import('fs'); - const { dirname } = await import('path'); - const path = args.path as string; - const content = args.content as string; - - if (existsSync(path) && !args.overwrite) { - return { success: false, error: 'File exists. Set overwrite: true to replace.' }; - } - - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, content, 'utf-8'); - - return { success: true, bytes: content.length, lines: content.split('\n').length }; - } - - case 'sage_edit_file': { - const { readFileSync, writeFileSync, existsSync } = await import('fs'); - const path = args.path as string; - const oldStr = args.old as string; - const newStr = args.new as string; - - if (!existsSync(path)) { - return { success: false, error: `File not found: ${path}` }; - } - - const content = readFileSync(path, 'utf-8'); - const count = (content.match(new RegExp(oldStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length; - - if (count === 0) { - return { success: false, error: 'String not found in file' }; - } - if (count > 1) { - return { success: false, error: `String found ${count} times. Must be unique.` }; - } - - const newContent = content.replace(oldStr, newStr); - writeFileSync(path, newContent, 'utf-8'); - - return { success: true, replaced: 1 }; - } - - case 'sage_get_history': { - const limit = (args.limit as number) || 10; - const failedOnly = args.failed_only as boolean; - - let runs = db.getRecentRuns(limit * 2); - if (failedOnly) { - runs = runs.filter(r => r.exitCode !== 0); - } - runs = runs.slice(0, limit); - - return { - success: true, - runs: runs.map(r => ({ - id: r.id, - command: r.command, - exit_code: r.exitCode, - saved_tokens: r.originalTokens - r.compressedTokens, - created_at: r.createdAt - })) - }; - } - - case 'sage_explain_error': { - const id = args.command_id as number | undefined; - const run = id ? db.getRunById(id) : db.getLatestFailedRun(); - - if (!run) { - return { success: false, error: 'No command found' }; - } - - return { - success: true, - command: run.command, - exit_code: run.exitCode, - output: run.compressed, - explanation: analyzeError(run.compressed || run.stderr) - }; - } - - case 'sage_suggest_fix': { - const id = args.command_id as number | undefined; - const run = id ? db.getRunById(id) : db.getLatestFailedRun(); - - if (!run) { - return { success: false, error: 'No command found' }; - } - - return { - success: true, - command: run.command, - suggestions: generateSuggestions(run.command, run.compressed || run.stderr) - }; - } - - case 'sage_spawn_agent': { - const agentType = args.agent_type as string; - const task = args.task as string; - const agents = selectAgents(task); - const matched = agents.find(a => a.type === agentType); - - if (!matched) { - return { success: false, error: `Unknown agent type: ${agentType}` }; - } - - return { - success: true, - agent: matched, - task, - status: 'Agent patterns applied to task analysis' - }; - } - - case 'sage_validate': { - const { existsSync, readFileSync } = await import('fs'); - const path = args.path as string; - const content = (args.content as string) || (existsSync(path) ? readFileSync(path, 'utf-8') : null); - - if (!content) { - return { success: false, error: `File not found: ${path}` }; - } - - const issues = validateCode(path, content); - const errors = issues.filter(i => i.severity === 'error'); - const warnings = issues.filter(i => i.severity === 'warning'); - - return { - success: true, - valid: errors.length === 0, - summary: `${errors.length} error(s), ${warnings.length} warning(s)`, - issues: issues.slice(0, 15) - }; - } - - case 'sage_analyze_context': { - const { existsSync, readdirSync, readFileSync, statSync } = await import('fs'); - const { join, extname } = await import('path'); - const targetPath = (args.path as string) || '.'; - const sampleSize = (args.sample_size as number) || 15; - - if (!existsSync(targetPath)) { - return { success: false, error: `Path not found: ${targetPath}` }; - } - - const patterns = detectPatterns(targetPath, sampleSize); - const style = detectStyle(targetPath, sampleSize); - - return { - success: true, - patterns, - style, - summary: `Detected ${patterns.length} patterns. Style: ${style.indent_type} (${style.indent_size}), ${style.quote_style} quotes` - }; - } - - case 'sage_rollback': { - const snapshotId = args.snapshot_id as string; - const snapshot = snapshotStore.get(snapshotId); - - if (!snapshot) { - return { success: false, error: `Snapshot not found: ${snapshotId}` }; - } - - const { writeFileSync, unlinkSync } = await import('fs'); - if (snapshot.content === null) { - // File was created, delete it - try { unlinkSync(snapshot.path); } catch {} - } else { - writeFileSync(snapshot.path, snapshot.content, 'utf-8'); - } - - snapshotStore.delete(snapshotId); - return { success: true, restored: snapshot.path }; - } - - case 'sage_agentic_run': { - const command = args.command as string; - const maxRetries = (args.max_retries as number) || 3; - const autonomy = (args.autonomy as string) || 'auto'; - - const { command: cmd, args: cmdArgs } = parseCommand(command); - let result = await runCommand(cmd, cmdArgs); - let attempts = 1; - - while (result.exitCode !== 0 && attempts < maxRetries && autonomy !== 'suggest') { - const suggestions = generateSuggestions(command, result.compression.compressed); - if (suggestions.length === 0 || suggestions[0].includes('Review')) break; - - // Try the first suggestion - const fixCmd = suggestions[0]; - const { command: fixCmdParsed, args: fixArgs } = parseCommand(fixCmd); - await runCommand(fixCmdParsed, fixArgs); - - // Retry original - result = await runCommand(cmd, cmdArgs); - attempts++; - } - - return { - success: result.exitCode === 0, - exit_code: result.exitCode, - attempts, - output: result.compression.compressed - }; - } - - case 'sage_agentic_fix': { - const id = args.command_id as number | undefined; - const run = id ? db.getRunById(id) : db.getLatestFailedRun(); - - if (!run) { - return { success: false, error: 'No failed command found' }; - } - - const suggestions = generateSuggestions(run.command, run.compressed || run.stderr); - const explanation = analyzeError(run.compressed || run.stderr); - - return { - success: true, - command: run.command, - fix_command: suggestions[0] || null, - explanation, - confidence: suggestions.length > 0 && !suggestions[0].includes('Review') ? 0.8 : 0.3 - }; - } - - default: - return { success: false, error: `Unknown tool: ${name}` }; - } -} - -// Snapshot store for rollback -const snapshotStore = new Map(); -let snapshotCounter = 0; - -function createSnapshot(path: string, content: string | null): string { - const id = `snap_${++snapshotCounter}_${Date.now()}`; - snapshotStore.set(id, { path, content }); - return id; -} - -// Code validation -interface ValidationIssue { - line: number; - severity: 'error' | 'warning' | 'info'; - category: string; - message: string; -} - -function validateCode(path: string, content: string): ValidationIssue[] { - const issues: ValidationIssue[] = []; - const lines = content.split('\n'); - const ext = path.split('.').pop()?.toLowerCase(); - - // Python-specific validation - if (ext === 'py') { - lines.forEach((line, i) => { - const lineNo = i + 1; - - // Empty function - if (/def\s+\w+\([^)]*\):\s*$/.test(line) && lines[i + 1]?.trim() === 'pass') { - issues.push({ line: lineNo, severity: 'warning', category: 'empty_function', message: 'Empty function body' }); - } - - // Bare except - if (/except\s*:/.test(line) && !/except\s+\w+/.test(line)) { - issues.push({ line: lineNo, severity: 'warning', category: 'bare_except', message: 'Bare except catches all exceptions' }); - } - - // Hardcoded secrets - if (/(?:api[_-]?key|password|secret|token)\s*[=:]\s*["'][^"']{8,}["']/i.test(line)) { - if (!/["']<[^>]+>["']|["']your[_-]|["']xxx|["']example|["']test|["']dummy|os\.environ|getenv/i.test(line)) { - issues.push({ line: lineNo, severity: 'error', category: 'hardcoded_secret', message: 'Possible hardcoded secret' }); - } - } - - // TODO/FIXME - if (/#\s*(TODO|FIXME|HACK|XXX|BUG)\b/i.test(line)) { - issues.push({ line: lineNo, severity: 'info', category: 'todo_comment', message: 'TODO/FIXME comment found' }); - } - - // Debug code - if (/\bprint\s*\(/.test(line) && !path.includes('test')) { - issues.push({ line: lineNo, severity: 'warning', category: 'debug_code', message: 'Debug print statement' }); - } - }); - } - - // JavaScript/TypeScript validation - if (ext === 'js' || ext === 'ts' || ext === 'jsx' || ext === 'tsx') { - lines.forEach((line, i) => { - const lineNo = i + 1; - - if (/console\.log\s*\(/.test(line) && !path.includes('test')) { - issues.push({ line: lineNo, severity: 'warning', category: 'debug_code', message: 'console.log statement' }); - } - - if (/\bdebugger\b/.test(line)) { - issues.push({ line: lineNo, severity: 'warning', category: 'debug_code', message: 'debugger statement' }); - } - - if (/(?:api[_-]?key|password|secret|token)\s*[=:]\s*["'][^"']{8,}["']/i.test(line)) { - if (!/["']<[^>]+>["']|["']your[_-]|process\.env/i.test(line)) { - issues.push({ line: lineNo, severity: 'error', category: 'hardcoded_secret', message: 'Possible hardcoded secret' }); - } - } - }); - } - - return issues; -} - -// Pattern detection -interface DetectedPattern { - category: string; - pattern: string; - confidence: number; -} - -function detectPatterns(rootPath: string, sampleSize: number): DetectedPattern[] { - const { readdirSync, readFileSync, statSync } = require('fs'); - const { join, extname } = require('path'); - const patterns: DetectedPattern[] = []; - - // Collect Python files - const pyFiles: string[] = []; - function collectFiles(dir: string, depth = 0) { - if (depth > 3) return; - try { - for (const entry of readdirSync(dir)) { - if (entry.startsWith('.') || entry === 'node_modules' || entry === '__pycache__') continue; - const full = join(dir, entry); - const stat = statSync(full); - if (stat.isDirectory()) collectFiles(full, depth + 1); - else if (extname(entry) === '.py') pyFiles.push(full); - } - } catch {} - } - collectFiles(rootPath); - - let snakeCount = 0, camelCount = 0; - let specificExcept = 0, bareExcept = 0; - - for (const file of pyFiles.slice(0, sampleSize)) { - try { - const content = readFileSync(file, 'utf-8'); - - // Count naming styles - const funcs = content.match(/def\s+([a-z_][a-z0-9_]*)\s*\(/gi) || []; - for (const m of funcs) { - if (/_/.test(m)) snakeCount++; - else if (/[a-z][A-Z]/.test(m)) camelCount++; - } - - // Count exception handling - specificExcept += (content.match(/except\s+\w+/g) || []).length; - bareExcept += (content.match(/except\s*:/g) || []).length; - } catch {} - } - - if (snakeCount > camelCount && snakeCount > 5) { - patterns.push({ category: 'naming', pattern: 'Functions use snake_case', confidence: snakeCount / (snakeCount + camelCount) }); - } - - if (specificExcept > bareExcept * 2) { - patterns.push({ category: 'error_handling', pattern: 'Uses specific exception types', confidence: 0.8 }); - } - - return patterns; -} - -// Style detection -interface StyleProfile { - indent_type: string; - indent_size: number; - quote_style: string; -} - -function detectStyle(rootPath: string, sampleSize: number): StyleProfile { - const { readdirSync, readFileSync, statSync } = require('fs'); - const { join, extname } = require('path'); - - let spaces = 0, tabs = 0; - let singleQuotes = 0, doubleQuotes = 0; - const indentSizes: number[] = []; - - const pyFiles: string[] = []; - function collectFiles(dir: string, depth = 0) { - if (depth > 3) return; - try { - for (const entry of readdirSync(dir)) { - if (entry.startsWith('.') || entry === 'node_modules') continue; - const full = join(dir, entry); - const stat = statSync(full); - if (stat.isDirectory()) collectFiles(full, depth + 1); - else if (extname(entry) === '.py') pyFiles.push(full); - } - } catch {} - } - collectFiles(rootPath); - - for (const file of pyFiles.slice(0, sampleSize)) { - try { - const content = readFileSync(file, 'utf-8'); - - for (const line of content.split('\n')) { - const match = line.match(/^(\s+)/); - if (match) { - if (match[1].includes('\t')) tabs++; - else { - spaces++; - if (match[1].length <= 8) indentSizes.push(match[1].length); - } - } - } - - singleQuotes += (content.match(/'/g) || []).length; - doubleQuotes += (content.match(/"/g) || []).length; - } catch {} - } - - return { - indent_type: tabs > spaces ? 'tabs' : 'spaces', - indent_size: indentSizes.length ? Math.round(indentSizes.reduce((a, b) => a + b, 0) / indentSizes.length) : 4, - quote_style: singleQuotes > doubleQuotes ? 'single' : 'double' - }; -} - -function analyzeError(output: string): string { - const lower = output.toLowerCase(); - - if (lower.includes('modulenotfounderror')) return 'Missing Python module. Install with pip.'; - if (lower.includes('cannot find module')) return 'Missing Node module. Install with npm.'; - if (lower.includes('syntaxerror')) return 'Syntax error in code. Check the file and line.'; - if (lower.includes('permission denied')) return 'Permission issue. Check file permissions or use sudo.'; - if (lower.includes('command not found')) return 'Command not installed or not in PATH.'; - if (lower.includes('connection refused')) return 'Service not running. Start the server first.'; - - return 'Review the error output for details.'; -} - -function generateSuggestions(command: string, output: string): string[] { - const suggestions: string[] = []; - const lower = output.toLowerCase(); - - if (lower.includes('modulenotfounderror')) { - const match = output.match(/No module named '([^']+)'/); - if (match) suggestions.push(`pip install ${match[1]}`); - } - if (lower.includes('cannot find module')) { - const match = output.match(/Cannot find module '([^']+)'/); - if (match) suggestions.push(`npm install ${match[1]}`); - } - if (lower.includes('permission denied')) { - suggestions.push('Run with elevated privileges (sudo or administrator)'); - } - - return suggestions.length ? suggestions : ['Review the error and fix the underlying issue']; -} diff --git a/js/src/ml/index.ts b/js/src/ml/index.ts deleted file mode 100644 index 8d3167c..0000000 --- a/js/src/ml/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -// SAGE ML V1 Predictor - Pattern-based failure prediction -import { Database } from '../db/index.js'; -import { FAILURE_PATTERNS, RISKY_PATTERNS, SAFE_PATTERNS } from './patterns.js'; - -export interface Prediction { - risk: number; // 0.0 to 1.0 - confidence: number; // 0.0 to 1.0 - reason: string; -} - -export class FailurePredictor { - private db: Database; - - constructor() { - this.db = Database.getInstance(); - } - - predict(command: string): Prediction { - // 1. Check exact match in history - const exactMatches = this.db.findExactCommand(command); - if (exactMatches.length >= 3) { - const failures = exactMatches.filter(r => r.exitCode !== 0).length; - const risk = failures / exactMatches.length; - return { - risk, - confidence: 0.95, - reason: `Exact command history: ${failures}/${exactMatches.length} failures` - }; - } - - // 2. Check similar commands (same base command) - const similar = this.db.findSimilarCommands(command, 50); - if (similar.length >= 5) { - const failures = similar.filter(r => r.exitCode !== 0).length; - const risk = failures / similar.length; - const baseCmd = command.split(/\s+/)[0]; - return { - risk, - confidence: 0.75, - reason: `Similar ${baseCmd} commands: ${failures}/${similar.length} failures` - }; - } - - // 3. Pattern-based prediction - const patternResult = this.checkPatterns(command); - if (patternResult.risk > 0 || patternResult.confidence > 0.5) { - return patternResult; - } - - // 4. Default low-risk prediction - return { - risk: 0.1, - confidence: 0.3, - reason: 'No history, baseline risk' - }; - } - - private checkPatterns(command: string): Prediction { - const lower = command.toLowerCase(); - - // Check high-risk patterns - for (const { pattern, risk, reason } of RISKY_PATTERNS) { - if (pattern.test(lower)) { - return { risk, confidence: 0.85, reason }; - } - } - - // Check failure-prone patterns - for (const { pattern, risk, reason } of FAILURE_PATTERNS) { - if (pattern.test(lower)) { - return { risk, confidence: 0.7, reason }; - } - } - - // Check safe patterns (reduce risk) - for (const { pattern, reason } of SAFE_PATTERNS) { - if (pattern.test(lower)) { - return { risk: 0.05, confidence: 0.6, reason }; - } - } - - return { risk: 0, confidence: 0, reason: '' }; - } - - // Learn from a completed command - recordOutcome(command: string, succeeded: boolean): void { - // This data is already in the database via runCommand - // Future: could adjust pattern weights based on outcomes - } -} diff --git a/js/src/ml/patterns.ts b/js/src/ml/patterns.ts deleted file mode 100644 index 69ddef9..0000000 --- a/js/src/ml/patterns.ts +++ /dev/null @@ -1,93 +0,0 @@ -// ML V1 Failure Patterns - -export interface RiskPattern { - pattern: RegExp; - risk: number; - reason: string; -} - -export interface SafePattern { - pattern: RegExp; - reason: string; -} - -// High-risk patterns - likely to cause problems -export const RISKY_PATTERNS: RiskPattern[] = [ - // Destructive commands - { pattern: /rm\s+-rf?\s+\//, risk: 0.95, reason: 'Destructive: rm -rf on root path' }, - { pattern: /rm\s+-rf?\s+~/, risk: 0.9, reason: 'Destructive: rm -rf on home directory' }, - { pattern: /rm\s+-rf?\s+\*/, risk: 0.85, reason: 'Destructive: rm -rf with wildcard' }, - { pattern: /del\s+\/[sq]/, risk: 0.85, reason: 'Destructive: Windows del with /s or /q' }, - { pattern: /format\s+[a-z]:/, risk: 0.95, reason: 'Destructive: disk format' }, - - // Force flags without safety - { pattern: /--force(?!\s+--dry)/, risk: 0.6, reason: 'Force flag without dry-run' }, - { pattern: /git\s+push\s+--force/, risk: 0.7, reason: 'Force push (may overwrite history)' }, - { pattern: /git\s+reset\s+--hard/, risk: 0.65, reason: 'Hard reset (may lose changes)' }, - - // Elevated privileges - { pattern: /sudo\s+rm/, risk: 0.75, reason: 'Sudo with delete' }, - { pattern: /sudo\s+chmod\s+777/, risk: 0.7, reason: 'Sudo chmod 777 (insecure permissions)' }, - - // Network operations without checks - { pattern: /curl.*\|\s*(bash|sh)/, risk: 0.8, reason: 'Pipe curl to shell (unsafe)' }, - { pattern: /wget.*\|\s*(bash|sh)/, risk: 0.8, reason: 'Pipe wget to shell (unsafe)' }, -]; - -// Medium-risk patterns - may fail but recoverable -export const FAILURE_PATTERNS: RiskPattern[] = [ - // Missing dependencies common - { pattern: /pip\s+install.*--upgrade/, risk: 0.35, reason: 'Upgrade may break dependencies' }, - { pattern: /npm\s+install.*--legacy-peer-deps/, risk: 0.4, reason: 'Legacy peer deps flag suggests conflicts' }, - - // Environment issues - { pattern: /python\s+.*\.py\s*$/, risk: 0.25, reason: 'Python script (may have import errors)' }, - { pattern: /node\s+.*\.js\s*$/, risk: 0.25, reason: 'Node script (may have module errors)' }, - - // Build commands - { pattern: /npm\s+run\s+build/, risk: 0.3, reason: 'Build command (may have compilation errors)' }, - { pattern: /cargo\s+build/, risk: 0.3, reason: 'Rust build (may have type errors)' }, - { pattern: /make\s+all/, risk: 0.35, reason: 'Make all (may have missing deps)' }, - - // Test commands (expected to sometimes fail) - { pattern: /pytest/, risk: 0.4, reason: 'Tests (some may fail)' }, - { pattern: /npm\s+test/, risk: 0.4, reason: 'Tests (some may fail)' }, - { pattern: /jest/, risk: 0.4, reason: 'Tests (some may fail)' }, - - // Database operations - { pattern: /drop\s+table/i, risk: 0.6, reason: 'Database drop (destructive)' }, - { pattern: /truncate/i, risk: 0.55, reason: 'Database truncate (destructive)' }, - { pattern: /migrate/, risk: 0.35, reason: 'Migration (may have schema conflicts)' }, -]; - -// Safe patterns - low risk -export const SAFE_PATTERNS: SafePattern[] = [ - // Read-only operations - { pattern: /--version/, reason: 'Version check (read-only)' }, - { pattern: /--help/, reason: 'Help command (read-only)' }, - { pattern: /ls\s/, reason: 'List directory (read-only)' }, - { pattern: /dir\s/, reason: 'List directory (read-only)' }, - { pattern: /cat\s/, reason: 'View file (read-only)' }, - { pattern: /type\s/, reason: 'View file (read-only)' }, - { pattern: /head\s/, reason: 'View file head (read-only)' }, - { pattern: /tail\s/, reason: 'View file tail (read-only)' }, - { pattern: /grep\s/, reason: 'Search (read-only)' }, - { pattern: /find\s/, reason: 'Find files (read-only)' }, - - // Git read operations - { pattern: /git\s+status/, reason: 'Git status (read-only)' }, - { pattern: /git\s+log/, reason: 'Git log (read-only)' }, - { pattern: /git\s+diff/, reason: 'Git diff (read-only)' }, - { pattern: /git\s+branch/, reason: 'Git branch (read-only)' }, - - // Safe with dry-run - { pattern: /--dry-run/, reason: 'Dry-run mode (no changes)' }, - { pattern: /--check/, reason: 'Check mode (no changes)' }, - { pattern: /-n(?:\s|$)/, reason: 'Dry-run flag (no changes)' }, - - // Info commands - { pattern: /which\s/, reason: 'Which command (read-only)' }, - { pattern: /where\s/, reason: 'Where command (read-only)' }, - { pattern: /echo\s/, reason: 'Echo (display only)' }, - { pattern: /pwd/, reason: 'Print working directory' }, -]; diff --git a/js/src/runner/index.ts b/js/src/runner/index.ts deleted file mode 100644 index a774162..0000000 --- a/js/src/runner/index.ts +++ /dev/null @@ -1,154 +0,0 @@ -// SAGE Command Runner - Execute and capture commands -import { spawn, SpawnOptions } from 'child_process'; -import { compress, CompressionResult } from '../compression/index.js'; -import { Database } from '../db/index.js'; - -export interface RunResult { - command: string; - exitCode: number; - stdout: string; - stderr: string; - combined: string; - compression: CompressionResult; - durationMs: number; - runId: number; -} - -export async function runCommand( - command: string, - args: string[] = [], - options: { pty?: boolean; cwd?: string } = {} -): Promise { - const startTime = Date.now(); - const db = Database.getInstance(); - - return new Promise((resolve) => { - const spawnOptions: SpawnOptions = { - cwd: options.cwd || process.cwd(), - shell: true, - env: { ...process.env, FORCE_COLOR: '1' } - }; - - let stdout = ''; - let stderr = ''; - - if (options.pty) { - // PTY mode - inherit stdio for interactive commands - spawnOptions.stdio = 'inherit'; - - const child = spawn(command, args, spawnOptions); - - child.on('close', (code) => { - const exitCode = code ?? 1; - const durationMs = Date.now() - startTime; - const combined = '[PTY mode - output not captured]'; - const compression = compress(combined, exitCode); - - const runId = db.saveRun({ - command: [command, ...args].join(' '), - exitCode, - stdout: '', - stderr: '', - compressed: compression.compressed, - originalTokens: compression.originalTokens, - compressedTokens: compression.compressedTokens, - durationMs - }); - - resolve({ - command: [command, ...args].join(' '), - exitCode, - stdout: '', - stderr: '', - combined, - compression, - durationMs, - runId - }); - }); - } else { - // Normal mode - capture output - const child = spawn(command, args, { - ...spawnOptions, - stdio: ['inherit', 'pipe', 'pipe'] - }); - - child.stdout?.on('data', (data) => { - const chunk = data.toString(); - stdout += chunk; - process.stdout.write(chunk); - }); - - child.stderr?.on('data', (data) => { - const chunk = data.toString(); - stderr += chunk; - process.stderr.write(chunk); - }); - - child.on('close', (code) => { - const exitCode = code ?? 1; - const durationMs = Date.now() - startTime; - const combined = stdout + stderr; - const compression = compress(combined, exitCode); - - const runId = db.saveRun({ - command: [command, ...args].join(' '), - exitCode, - stdout, - stderr, - compressed: compression.compressed, - originalTokens: compression.originalTokens, - compressedTokens: compression.compressedTokens, - durationMs - }); - - resolve({ - command: [command, ...args].join(' '), - exitCode, - stdout, - stderr, - combined, - compression, - durationMs, - runId - }); - }); - - child.on('error', (err) => { - const durationMs = Date.now() - startTime; - const errorMsg = `Command failed: ${err.message}`; - const compression = compress(errorMsg, 1); - - const runId = db.saveRun({ - command: [command, ...args].join(' '), - exitCode: 1, - stdout: '', - stderr: errorMsg, - compressed: compression.compressed, - originalTokens: compression.originalTokens, - compressedTokens: compression.compressedTokens, - durationMs - }); - - resolve({ - command: [command, ...args].join(' '), - exitCode: 1, - stdout: '', - stderr: errorMsg, - combined: errorMsg, - compression, - durationMs, - runId - }); - }); - } - }); -} - -// Parse command string into command and args -export function parseCommand(cmdString: string): { command: string; args: string[] } { - const parts = cmdString.match(/(?:[^\s"]+|"[^"]*")+/g) || []; - const command = parts[0] || ''; - const args = parts.slice(1).map(arg => arg.replace(/^"|"$/g, '')); - return { command, args }; -} diff --git a/pyproject.toml b/pyproject.toml index 8048d3e..05a4ccd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,10 +47,6 @@ ai = [ "openai>=1.50", "requests>=2.31", ] -tui = [ - "textual>=0.80", - "httpx>=0.27", -] all = [ "anthropic>=0.40", "boto3>=1.34", @@ -60,8 +56,6 @@ all = [ "sentence-transformers>=3.0.0", "faiss-cpu>=1.8.0", "torch>=2.0.0", - "textual>=0.80", - "httpx>=0.27", ] [project.scripts] @@ -73,18 +67,13 @@ Repository = "https://github.com/PsYcGoD/sage" Issues = "https://github.com/PsYcGoD/sage/issues" Dashboard = "https://sage.api.marketingstudios.in/dashboard" -# The published package is the command wrapper only. GUI/TUI code (sage.gui, -# sage.tui, gui_server) is not public and the local dashboard UI is repo-only; -# none may ship to PyPI/npm. +# The published package is the command wrapper only. The hosted dashboard is +# maintained separately and is not bundled into PyPI/npm. [tool.setuptools.packages.find] where = ["src"] exclude = [ - "sage.gui", - "sage.gui.*", "sage.dashboard", "sage.dashboard.*", - "sage.tui", - "sage.tui.*", ] [tool.pytest.ini_options] diff --git a/scripts/create_team_preview.py b/scripts/create_team_preview.py deleted file mode 100644 index 3e592ab..0000000 --- a/scripts/create_team_preview.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Create the Team Dashboard preview image used in README.""" - -from __future__ import annotations - -from pathlib import Path - -from PIL import Image, ImageDraw, ImageFont - - -def _font(size: int) -> ImageFont.ImageFont: - for name in ("arial.ttf", "CascadiaMono.ttf", "consola.ttf"): - try: - return ImageFont.truetype(name, size) - except OSError: - continue - return ImageFont.load_default() - - -def main() -> int: - width, height = 1200, 760 - image = Image.new("RGB", (width, height), "#0f172a") - draw = ImageDraw.Draw(image) - - draw.rectangle((0, 0, width, 72), fill="#111827") - draw.text((36, 24), "SAGE Team View Preview", fill="#f8fafc", font=_font(30)) - draw.text((880, 28), "Enterprise-only access", fill="#a7f3d0", font=_font(18)) - - cards = [ - ("Workspaces", "12"), - ("Tokens saved", "15.1M"), - ("Safety events", "38"), - ("Success rate", "96.7%"), - ] - x = 36 - for title, value in cards: - draw.rounded_rectangle((x, 110, x + 255, 245), radius=14, fill="#1e293b", outline="#334155") - draw.text((x + 22, 132), title, fill="#94a3b8", font=_font(18)) - draw.text((x + 22, 170), value, fill="#a7f3d0", font=_font(38)) - x += 282 - - draw.rounded_rectangle((36, 285, 1164, 690), radius=14, fill="#111827", outline="#334155") - draw.text((64, 315), "Workspace proof snapshot", fill="#f8fafc", font=_font(24)) - - rows = [ - ("api-service", "1,204 runs", "94.1% saved", "2 secrets protected"), - ("web-dashboard", "884 runs", "91.8% saved", "0 blocked commands"), - ("agent-sandbox", "2,046 runs", "93.7% saved", "12 risky commands blocked"), - ] - y = 370 - for name, runs, saved, safety in rows: - draw.rounded_rectangle((64, y, 1136, y + 78), radius=8, fill="#1e293b") - draw.text((90, y + 22), name, fill="#e5e7eb", font=_font(20)) - draw.text((360, y + 22), runs, fill="#bfdbfe", font=_font(18)) - draw.text((570, y + 22), saved, fill="#a7f3d0", font=_font(18)) - draw.text((800, y + 22), safety, fill="#fef3c7", font=_font(18)) - y += 96 - - draw.text( - (64, 716), - "Preview only: Team View is available for Enterprise customers.", - fill="#cbd5e1", - font=_font(18), - ) - - out = Path("docs/assets/team-dashboard-preview.png") - out.parent.mkdir(parents=True, exist_ok=True) - image.save(out) - print(f"Team Dashboard preview written: {out}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/wait_for_pypi.py b/scripts/wait_for_pypi.py deleted file mode 100644 index a61b7c7..0000000 --- a/scripts/wait_for_pypi.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Wait until PyPI serves the Python core required by the npm launcher.""" - -from __future__ import annotations - -import argparse -import json -import sys -import time -import urllib.request - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--version", required=True) - parser.add_argument("--timeout", type=int, default=300) - parser.add_argument("--interval", type=int, default=10) - args = parser.parse_args() - - expected = args.version.removeprefix("v") - deadline = time.monotonic() + max(1, args.timeout) - url = "https://pypi.org/pypi/psycgod-sage/json" - while time.monotonic() < deadline: - try: - with urllib.request.urlopen(f"{url}?t={time.time_ns()}", timeout=15) as response: - current = str(json.load(response)["info"]["version"]) - if current == expected: - print(f"PyPI core is ready: {current}") - return 0 - print(f"PyPI currently serves {current}; waiting for {expected}") - except Exception as exc: - print(f"PyPI check failed temporarily: {exc}") - time.sleep(max(1, args.interval)) - - print(f"Timed out waiting for psycgod-sage {expected} on PyPI", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/sage/cli.py b/src/sage/cli.py index 008a6a8..3f323c1 100644 --- a/src/sage/cli.py +++ b/src/sage/cli.py @@ -40,7 +40,6 @@ "github-login", "glob", "grep", - "gui", "history", "init", "install", @@ -51,7 +50,6 @@ "ml", "predict", "privacy", - "psycgod_gui", "read", "redact", "restore-file", @@ -63,7 +61,6 @@ "stats", "telemetry", "tree", - "tui", "whoami", "workflow", "write", @@ -127,7 +124,7 @@ def build_parser() -> argparse.ArgumentParser: run.add_argument( "--cwd", help=( - "Run from this working directory. Desktop/Electron hosts may also " + "Run from this working directory. Host integrations may also " "set SAGE_WORKSPACE_CWD." ), ) @@ -469,10 +466,6 @@ def build_parser() -> argparse.ArgumentParser: ) sub.add_parser("stats", help="Show SAGE token, ML, and agent statistics.") sub.add_parser("init", help="Create S.A.G.E instructions for developer tools.") - sub.add_parser("gui", help="Show GUI availability status.") - sub.add_parser("tui", help="Start the SAGE interactive TUI.") - sub.add_parser("psycgod_gui", help="Launch the SAGE PsYcGoD GUI desktop app.") - return parser def _add_login_args(parser: argparse.ArgumentParser) -> None: @@ -902,15 +895,6 @@ def main(argv: list[str] | None = None) -> int: if args.command_name == "init": return init_project() - if args.command_name == "gui": - return gui_command() - - if args.command_name == "tui": - return tui_command() - - if args.command_name == "psycgod_gui": - return electron_command() - parser.print_help() return 2 @@ -3189,6 +3173,7 @@ def dashboard_command(args) -> int: return 1 + def mcp_command(args) -> int: """Manage MCP server.""" import json @@ -3551,49 +3536,3 @@ def install_command(*, force: bool = False, project: bool = True, wait: bool = T _wait_for_enter_if_interactive("Press Enter to finish.") return 0 -def gui_command() -> int: - """Launch the SAGE Desktop GUI.""" - try: - from sage.gui.app import SAGEApp - app = SAGEApp() - app.mainloop() - return 0 - except ImportError: - print("[sage] The SAGE desktop GUI is not included in the public package.") - return 1 - -def tui_command() -> int: - """Launch the SAGE interactive TUI.""" - try: - from sage.tui.app import SAGETUIApp - app = SAGETUIApp() - app.run() - return 0 - except ImportError as e: - print(f"[sage] TUI requires extra dependencies: pip install psycgod-sage[tui]") - print(f" Error: {e}") - return 1 - - -def electron_command() -> int: - """Launch the SAGE Electron desktop app.""" - import subprocess - repo_root = Path(__file__).resolve().parent.parent.parent - electron_dir = repo_root / "electron" - - if not electron_dir.exists(): - print("[sage] Electron app directory not found.") - print(f" Expected: {electron_dir}") - return 1 - - node_modules = electron_dir / "node_modules" - if not node_modules.exists(): - print("[sage] Installing Electron dependencies...") - result = subprocess.run(["npm", "install"], cwd=str(electron_dir), shell=True) - if result.returncode != 0: - print("[sage] npm install failed.") - return 1 - - print("[sage] Launching SAGE Desktop...") - result = subprocess.run(["npm", "run", "start"], cwd=str(electron_dir), shell=True) - return result.returncode diff --git a/src/sage/tui/__init__.py b/src/sage/tui/__init__.py deleted file mode 100644 index d1e79e4..0000000 --- a/src/sage/tui/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""SAGE Terminal UI — Textual-based interactive coding agent.""" diff --git a/src/sage/tui/__main__.py b/src/sage/tui/__main__.py deleted file mode 100644 index 50ef3f6..0000000 --- a/src/sage/tui/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Allow running SAGE TUI directly: python -m sage.tui""" -from sage.tui.app import SAGETUIApp - -if __name__ == "__main__": - app = SAGETUIApp() - app.run() diff --git a/src/sage/tui/app.py b/src/sage/tui/app.py deleted file mode 100644 index 4636778..0000000 --- a/src/sage/tui/app.py +++ /dev/null @@ -1,622 +0,0 @@ -"""SAGE TUI main application.""" -from __future__ import annotations - -import os -import logging -from pathlib import Path - -from textual.app import App, ComposeResult -from textual.binding import Binding - -from .widgets.output import OutputPanel -from .widgets.input_bar import InputBar, InputSubmitted -from .widgets.status_bar import StatusBar -from .widgets.sidebar import Sidebar, SessionSelected, NewSessionRequested, SessionDeleteRequested, SettingsRequested -from .server.session_store import SessionStore -from .server.tools import ToolRegistry -from .server.context import ContextManager as ChatContext -from .server.providers.anthropic import AnthropicProvider -from .server.loop import AgenticLoop -from .server.migrate import migrate_if_needed -from .server import router - -log = logging.getLogger(__name__) - - -def _get_display_name() -> str: - """Get user's primary display name from SAGE config.""" - try: - from sage.telemetry import load_config - config = load_config() - profile = config.get("api_profile", {}) - return ( - profile.get("display_name") - or profile.get("username") - or profile.get("github_username") - or profile.get("email", "").split("@")[0] - or "Me" - ) - except Exception: - return "Me" - - -class SAGETUIApp(App): - """SAGE Terminal User Interface.""" - - TITLE = "SAGE" - CSS = """ - Screen { - background: #1a1b26; - } - - OutputPanel { - background: #1a1b26; - padding: 0 2; - } - - OutputPanel .user-message { - background: #1a1b26; - color: #c084fc; - padding: 0 0 1 0; - margin: 1 0 0 0; - border: none; - } - - OutputPanel .assistant-message { - background: #1a1b26; - color: #ededec; - padding: 0 0 1 0; - margin: 0; - border: none; - } - - OutputPanel .thinking { - color: #9b87f5; - margin: 0; - text-style: italic; - } - - OutputPanel .error-message { - background: #2d1b1b; - border-left: thick #f87171; - padding: 1 2; - margin: 1 0; - color: #f87171; - } - - OutputPanel .sage-summary { - color: #4ade80; - margin: 0; - padding: 0; - } - - InputBar { - dock: bottom; - height: 3; - border-top: solid #333648; - border-bottom: none; - border-left: none; - border-right: none; - background: #16161e; - } - - InputBar:focus { - border-top: solid #8b5cf6; - } - - StatusBar { - dock: bottom; - height: 1; - background: #16161e; - color: #6b6b6b; - content-align: center middle; - border-top: solid #333648; - } - - Sidebar { - dock: left; - width: 28; - background: #16161e; - border-right: solid #333648; - padding: 0; - } - - Sidebar .header { - text-align: center; - text-style: bold; - color: #8b5cf6; - padding: 1; - } - - Sidebar #new-chat-btn { - width: 100%; - margin: 0 1 1 1; - min-width: 10; - background: #8b5cf6; - color: #ededec; - border: none; - height: 1; - } - - Sidebar #settings-btn { - width: 100%; - margin: 0 1 1 1; - min-width: 10; - background: #333648; - color: #a0a0a0; - border: none; - height: 1; - } - - Sidebar ListView { - height: 1fr; - background: #16161e; - border: none; - } - - Sidebar ListItem { - padding: 0 1; - height: 2; - background: #16161e; - border: none; - } - - Sidebar ListItem:hover { - background: #24283b; - } - - Sidebar ListItem.--active { - background: #24283b; - border-left: thick #8b5cf6; - } - - Collapsible { - margin: 0; - padding: 0; - border: none; - background: #1f2335; - } - - CollapsibleTitle { - color: #6b6b6b; - padding: 0 1; - } - """ - - BINDINGS = [ - Binding("ctrl+q", "quit", "Quit", priority=True), - Binding("ctrl+d", "quit", "Quit", priority=True), - Binding("ctrl+l", "clear_output", "Clear", priority=True), - Binding("ctrl+c", "cancel", "Cancel", priority=True), - Binding("ctrl+n", "new_session", "New Session", priority=True), - Binding("escape", "try_quit", "Quit", show=False), - ] - - def __init__(self) -> None: - super().__init__() - - # UI components - self.output_panel = None - self.input_bar = None - self.status_bar = None - self.sidebar = None - - # Server components - self._store = SessionStore() - self._tools = ToolRegistry() - self._chat_context = ChatContext("claude-sonnet-4.6") - self._provider = None - self._loop = None - self._current_session_id = None - self._worker = None - - # Project context - self._project_path = Path(os.getcwd()).resolve() - self._project_name = self._project_path.name - - # Migrate old sessions on startup - migrate_if_needed(self._store) - - def compose(self) -> ComposeResult: - """Compose the app layout.""" - self.sidebar = Sidebar(self._project_name) - self.output_panel = OutputPanel() - self.input_bar = InputBar() - self.status_bar = StatusBar() - - yield self.sidebar - yield self.output_panel - yield self.input_bar - yield self.status_bar - - def on_mount(self) -> None: - """Initialize the app on mount.""" - # Set provider info in status bar - resolved = router.resolve_provider() - label = resolved.get("label", "") - model = resolved.get("model", "") - self.status_bar.set_model(f"{label}" + (f" ({model})" if model else "")) - - # Load or create session for this project - self._load_or_create_session() - - # Update sidebar - self._refresh_sidebar() - - # Check ML daemon status - self._check_daemon_status() - - # Focus the input bar - self.input_bar.focus() - - def _load_or_create_session(self): - """Load most recent session for this project, or create one.""" - sessions = self._store.list_sessions(limit=1) - if sessions: - session = sessions[0] - self._current_session_id = session.id - messages = self._store.get_messages(session.id) - if messages: - self._replay_messages(messages) - self.status_bar.set_session(session.title) - return - - # No existing session — create new - resolved = router.resolve_provider() - session = self._store.create_session( - model=resolved.get("model", "auto"), - agent="coder", - title=f"Chat – {self._project_name}", - ) - self._current_session_id = session.id - self.status_bar.set_session(session.title) - self._show_welcome() - - def _show_welcome(self): - """Show SAGE ASCII welcome banner.""" - from sage.tui.server.router import resolve_provider - resolved = resolve_provider() - provider_label = resolved.get("label", "Not configured") - model = resolved.get("model", "") - - banner = """[bold #8b5cf6] - ███████╗ █████╗ ██████╗ ███████╗ - ██╔════╝██╔══██╗██╔════╝ ██╔════╝ - ███████╗█████████║ ███╗█████╗ - ╚════██║██╔══██║██║ ██║██╔══╝ - ███████║██║ ██║╚██████╔╝███████╗ - ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ -[/bold #8b5cf6] -[dim #a0a0a0] Smart Agent Guidance Engine V2.0[/dim #a0a0a0] -[dim #6b6b6b] ═══════════════════════════════════════[/dim #6b6b6b] -""" - banner += f"\n[#3b82f6] Connected:[/#3b82f6] [#ededec]{provider_label}[/#ededec]" - if model: - banner += f" [dim]({model})[/dim]" - banner += f"\n[#3b82f6] ML Daemon:[/#3b82f6] [#4ade80]active[/#4ade80]" - banner += f"\n[#3b82f6] Project:[/#3b82f6] [#ededec]{self._project_name}[/#ededec]" - banner += "\n" - banner += "\n[dim #6b6b6b] Enter → send | Shift+Enter → newline | Ctrl+Q → quit[/dim #6b6b6b]\n" - - from textual.widgets import Static - welcome = Static(banner, markup=True) - self.output_panel.mount(welcome) - self.output_panel.scroll_end(animate=False) - - def _replay_messages(self, messages: list): - """Replay message history into the output panel.""" - for msg in messages: - if msg.role == "user": - self.output_panel.add_user_message(msg.content) - elif msg.role == "assistant": - self.output_panel.add_assistant_message(msg.content) - - def _refresh_sidebar(self): - """Refresh the sidebar with current sessions.""" - sessions = self._store.list_sessions(limit=50) - - # Build session list with previews - session_list = [] - for s in sessions: - # Get last message for preview - messages = self._store.get_messages(s.id) - preview = "" - if messages: - last_msg = messages[-1] - preview = last_msg.content[:100] - - session_list.append({ - "id": s.id, - "title": s.title, - "updated_at": s.updated_at, - "preview": preview, - }) - - self.sidebar.set_sessions(session_list, self._current_session_id) - - def _check_daemon_status(self): - """Check ML daemon status and update status bar.""" - try: - from sage.ml.daemon import _check_socket_status - status = _check_socket_status() - if status.get("ok"): - state = "sleeping" if status.get("sleeping") else "active" - self.status_bar.set_daemon_status(state) - else: - self.status_bar.set_daemon_status("off") - except Exception: - self.status_bar.set_daemon_status("off") - - async def on_input_submitted(self, message: InputSubmitted) -> None: - """Handle input submission from the input bar.""" - text = message.text - - if not text.strip(): - return - - # Disable input during processing - self.input_bar.disable() - self.status_bar.set_streaming(True) - - # Add user message to output - self.output_panel.add_user_message(text) - - # Save to session - self._store.add_message(self._current_session_id, "user", text) - - # Add to context - self._chat_context.add_user_message(text) - - # Resolve provider - resolved = router.resolve_provider() - if resolved.get("error"): - self.output_panel.add_error(resolved["error"]) - self.input_bar.enable() - self.status_bar.set_streaming(False) - return - - self.status_bar.set_model(f"{resolved['label']} / {resolved['model']}") - log.info("Using provider: %s (%s)", resolved["name"], resolved["model"]) - - # Get provider instance - provider = self._get_provider(resolved) - - # Run agentic loop in background worker - self._worker = self.run_worker(self._run_loop(provider), exclusive=True) - - def _get_provider(self, resolved: dict): - """Get or create a provider instance based on resolved config.""" - ptype = resolved.get("type", "api") - name = resolved["name"] - - # CLI agents — pipe through the CLI's stdin/stdout - if ptype == "cli": - from .server.providers.cli_agent import CLIAgentProvider - self._provider = CLIAgentProvider(binary=resolved["binary"]) - return self._provider - - # Anthropic direct API - if name == "anthropic": - if not self._provider or not isinstance(self._provider, AnthropicProvider): - self._provider = AnthropicProvider() - return self._provider - - # All other API providers use OpenAI-compatible endpoint - from .server.providers.openai_compat import OpenAICompatProvider - self._provider = OpenAICompatProvider( - base_url=resolved.get("base_url", ""), - api_key=resolved.get("api_key", ""), - model=resolved.get("model", "auto"), - ) - return self._provider - - async def _run_loop(self, provider): - """Run the agentic loop and stream events to UI.""" - loop = AgenticLoop(provider, self._tools, max_iterations=25) - self._loop = loop - - accumulated_text = "" - tool_count = 0 - - try: - # Begin streaming - self.output_panel.begin_assistant_stream() - - async for event in loop.run(self._chat_context.get_messages(), self._chat_context.model): - if event.type == "token": - self.output_panel.stream_token(event.content) - accumulated_text += event.content - - elif event.type == "thinking": - self.output_panel.add_thinking_block(event.content) - - elif event.type == "tool_execution_start": - # Tool execution started - could show in UI - tool_count += 1 - log.debug("Tool execution started: %s", event.tool_name) - - elif event.type == "tool_execution_end": - # Tool execution completed - self.output_panel.add_tool_call( - name=event.tool_name, - input_text="", # TODO: capture input - output_text=event.content, - duration_ms=0, # TODO: capture duration - status="success", - ) - log.debug("Tool execution completed: %s", event.tool_name) - - elif event.type == "tool_execution_error": - # Tool execution failed - self.output_panel.add_tool_call( - name=event.tool_name, - input_text="", # TODO: capture input - output_text=event.error, - duration_ms=0, # TODO: capture duration - status="error", - ) - log.error("Tool execution error: %s", event.error) - - elif event.type == "error": - self.output_panel.add_error(event.content) - break - - elif event.type == "done": - # Update token counts - self.status_bar.set_tokens(event.tokens_in, event.tokens_out) - break - - # End streaming - self.output_panel.end_assistant_stream() - - # Save assistant response - if accumulated_text: - self._chat_context.add_assistant_message(accumulated_text) - self._store.add_message( - self._current_session_id, - "assistant", - accumulated_text, - ) - - except Exception as e: - log.exception("Error in agentic loop") - self.output_panel.add_error(f"Error: {str(e)}") - - finally: - # Re-enable input - self.input_bar.enable() - self.status_bar.set_streaming(False) - self._loop = None - - # Refresh sidebar to update session timestamp - self._refresh_sidebar() - - def on_session_selected(self, message: SessionSelected) -> None: - """Handle session selection from sidebar.""" - if message.session_id == self._current_session_id: - return - - # Save current context - # (Already saved in real-time, so nothing to do) - - # Load selected session - self._current_session_id = message.session_id - session = self._store.get_session(message.session_id) - - if session: - # Update model - self._chat_context = ChatContext(session.model) - - # Clear output - self.output_panel.clear() - - # Replay messages - messages = self._store.get_messages(message.session_id) - self._replay_messages(messages) - - # Update sidebar - self.sidebar.set_active_session(message.session_id) - - # Update status bar - self.status_bar.set_session(session.title) - self.status_bar.set_model(session.model) - - def on_new_session_requested(self, message: NewSessionRequested) -> None: - """Handle new session request from sidebar.""" - self.action_new_session() - - def on_session_delete_requested(self, message: SessionDeleteRequested) -> None: - """Handle session deletion request.""" - # Delete from store - self._store.delete_session(message.session_id) - - # If it was the current session, create a new one - if message.session_id == self._current_session_id: - self._load_or_create_session() - - # Refresh sidebar - self._refresh_sidebar() - - def action_clear_output(self) -> None: - """Clear the output panel.""" - self.output_panel.clear() - - def action_new_session(self) -> None: - """Create a new session.""" - # Create new session - session = self._store.create_session( - model="claude-sonnet-4.6", - agent="coder", - title=f"New Chat - {self._project_name}", - ) - - # Switch to it - self._current_session_id = session.id - self._chat_context = ChatContext(session.model) - - # Clear output and show welcome - self.output_panel.clear() - self._show_welcome() - - # Refresh sidebar - self._refresh_sidebar() - - # Update status bar - self.status_bar.set_session(session.title) - self.status_bar.set_model(session.model) - - def action_cancel(self) -> None: - """Cancel the current operation.""" - if self._loop: - self._loop.cancel() - if self._worker: - self._worker.cancel() - - self.input_bar.enable() - self.status_bar.set_streaming(False) - - def on_settings_requested(self, message: SettingsRequested) -> None: - """Show settings info.""" - from .server.router import detect_cli_agents, detect_api_providers, resolve_provider - cli = detect_cli_agents() - api = detect_api_providers() - resolved = resolve_provider() - - info = "[bold #8b5cf6]━━━ Settings ━━━[/bold #8b5cf6]\n\n" - info += "[#3b82f6]Active:[/#3b82f6] " - info += f"[#ededec]{resolved['label']}[/#ededec]" - if resolved.get("model"): - info += f" ({resolved['model']})" - info += "\n\n" - - info += "[#3b82f6]CLI Agents (auto-detected):[/#3b82f6]\n" - if cli: - for a in cli: - marker = "[#4ade80]●[/#4ade80]" if a["name"] == resolved["name"] else "[dim]○[/dim]" - info += f" {marker} {a['label']} ({a['binary']})\n" - else: - info += " [dim]None found[/dim]\n" - - info += f"\n[#3b82f6]API Keys:[/#3b82f6]\n" - if api: - for p in api: - marker = "[#4ade80]●[/#4ade80]" if p["name"] == resolved.get("name") else "[dim]○[/dim]" - info += f" {marker} {p['label']}\n" - else: - info += " [dim]None configured[/dim]\n" - - info += "\n[dim]Set SAGE_LLM_PROVIDER= to force a provider.[/dim]\n" - info += "[dim]Set SAGE_LLM_BASE_URL for custom endpoints.[/dim]" - - from textual.widgets import Static - self.output_panel.mount(Static(info, markup=True)) - self.output_panel.scroll_end(animate=False) - - def action_try_quit(self) -> None: - """Escape — quit if input is empty, otherwise let input handle it.""" - if not self.input_bar.text.strip(): - self.exit() - - def action_quit(self) -> None: - """Quit the application.""" - self.exit() diff --git a/src/sage/tui/server/__init__.py b/src/sage/tui/server/__init__.py deleted file mode 100644 index d95c7c9..0000000 --- a/src/sage/tui/server/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""SAGE Chat Server — headless backend for the TUI.""" -from .models import Session, Message, ToolCall -from .session_store import SessionStore -from .loop import AgenticLoop -from .context import ContextManager - -__all__ = [ - "Session", - "Message", - "ToolCall", - "SessionStore", - "AgenticLoop", - "ContextManager", -] diff --git a/src/sage/tui/server/context.py b/src/sage/tui/server/context.py deleted file mode 100644 index 31e4008..0000000 --- a/src/sage/tui/server/context.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Conversation context management.""" -from __future__ import annotations -from typing import Any - -SYSTEM_PROMPT = """You are SAGE, a coding assistant running in the terminal. You have access to tools for reading, writing, and searching files, and executing bash commands. - -Rules: -- Be concise and direct -- Use tools to accomplish tasks -- Show your reasoning when making decisions -- Route all commands through sage run -- -- When you use a tool, explain what you're doing and why -""" - - -class ContextManager: - """Manage conversation context and messages.""" - - def __init__(self, model: str, max_tokens: int = 200000): - self.model = model - self.max_tokens = max_tokens - self._messages: list[dict[str, Any]] = [] - self._system_prompt = SYSTEM_PROMPT - - def set_system_prompt(self, prompt: str): - """Set the system prompt.""" - self._system_prompt = prompt - - def add_user_message(self, content: str): - """Add a user message.""" - self._messages.append({"role": "user", "content": content}) - - def add_assistant_message(self, content: str, tool_calls: list[dict] | None = None): - """Add an assistant message.""" - msg: dict[str, Any] = {"role": "assistant", "content": content} - if tool_calls: - msg["tool_calls"] = tool_calls - self._messages.append(msg) - - def add_tool_result(self, tool_call_id: str, content: str): - """Add a tool result message.""" - self._messages.append({ - "role": "tool", - "tool_call_id": tool_call_id, - "content": content, - }) - - def get_messages(self) -> list[dict[str, Any]]: - """Get all messages including system prompt.""" - messages = [] - - # Add system prompt first - if self._system_prompt: - messages.append({"role": "system", "content": self._system_prompt}) - - # Add conversation messages - messages.extend(self._messages) - - return messages - - def token_count(self) -> int: - """Estimate total token count.""" - # Rough approximation: 4 chars per token - total_chars = len(self._system_prompt) - for msg in self._messages: - total_chars += len(str(msg.get("content", ""))) - if "tool_calls" in msg: - total_chars += len(str(msg["tool_calls"])) - return total_chars // 4 - - def compact(self): - """Summarize old messages when approaching limit.""" - # Simple strategy: keep first message and last N messages - if self.token_count() > self.max_tokens * 0.8: - if len(self._messages) > 10: - # Keep first user message and last 8 messages - self._messages = [self._messages[0]] + self._messages[-8:] - - def clear(self): - """Clear all messages.""" - self._messages = [] diff --git a/src/sage/tui/server/loop.py b/src/sage/tui/server/loop.py deleted file mode 100644 index e3d68d1..0000000 --- a/src/sage/tui/server/loop.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Core agentic loop for SAGE chat server.""" -from __future__ import annotations -import asyncio -import json -import time -from typing import AsyncIterator, Any - -from .models import Message, ToolCall -from .providers.base import StreamEvent, BaseProvider -from .tools import ToolRegistry - - -class AgenticLoop: - """Agentic loop that streams LLM responses and executes tools.""" - - def __init__( - self, - provider: BaseProvider, - tools: ToolRegistry, - max_iterations: int = 25, - ): - self.provider = provider - self.tools = tools - self.max_iterations = max_iterations - self._cancelled = False - - def cancel(self): - """Cancel the current loop.""" - self._cancelled = True - - async def run( - self, messages: list[dict], model: str - ) -> AsyncIterator[StreamEvent]: - """Run the agentic loop. Yields StreamEvents as they happen.""" - iterations = 0 - - while iterations < self.max_iterations and not self._cancelled: - iterations += 1 - - # Collect events from this turn - assistant_text = "" - tool_calls_this_turn: list[dict] = [] - current_tool_call: dict[str, Any] | None = None - tokens_in = 0 - tokens_out = 0 - - # Stream from the LLM - async for event in self.provider.stream( - messages, self.tools.schemas(), model - ): - if self._cancelled: - return - - yield event - - # Accumulate assistant response - if event.type == "token": - assistant_text += event.content - - elif event.type == "tool_call_start": - current_tool_call = { - "id": event.tool_id, - "name": event.tool_name, - "input": "", - } - - elif event.type == "tool_call_delta": - if current_tool_call: - current_tool_call["input"] += event.tool_input - - elif event.type == "tool_call_end": - if current_tool_call: - current_tool_call["input"] = event.tool_input - tool_calls_this_turn.append(current_tool_call) - current_tool_call = None - - elif event.type == "done": - tokens_in = event.tokens_in - tokens_out = event.tokens_out - - elif event.type == "error": - yield event - return - - # If no tool calls, we're done - if not tool_calls_this_turn: - return - - # Build assistant message with tool calls - assistant_msg = { - "role": "assistant", - "content": assistant_text if assistant_text else [], - } - - # Add tool_use content blocks (Anthropic format) - content_blocks = [] - if assistant_text: - content_blocks.append({"type": "text", "text": assistant_text}) - - for tc in tool_calls_this_turn: - try: - input_dict = json.loads(tc["input"]) if isinstance(tc["input"], str) else tc["input"] - except json.JSONDecodeError: - input_dict = {} - - content_blocks.append({ - "type": "tool_use", - "id": tc["id"], - "name": tc["name"], - "input": input_dict, - }) - - assistant_msg["content"] = content_blocks - messages.append(assistant_msg) - - # Execute each tool call - tool_results = [] - for tc in tool_calls_this_turn: - tool_name = tc["name"] - tool_id = tc["id"] - - # Parse input - try: - if isinstance(tc["input"], str): - tool_input = json.loads(tc["input"]) - else: - tool_input = tc["input"] - except json.JSONDecodeError: - tool_input = {} - - # Notify execution start - yield StreamEvent( - type="tool_execution_start", - tool_id=tool_id, - tool_name=tool_name, - ) - - # Execute tool - started = time.perf_counter() - try: - result = await self.tools.execute(tool_name, tool_input) - duration_ms = int((time.perf_counter() - started) * 1000) - - # Format result as string - result_str = json.dumps(result, indent=2) - - tool_results.append({ - "type": "tool_result", - "tool_use_id": tool_id, - "content": result_str, - }) - - # Notify execution complete - yield StreamEvent( - type="tool_execution_end", - tool_id=tool_id, - tool_name=tool_name, - content=result_str, - ) - - except Exception as e: - duration_ms = int((time.perf_counter() - started) * 1000) - error_str = f"Tool execution error: {str(e)}" - tool_results.append({ - "type": "tool_result", - "tool_use_id": tool_id, - "content": error_str, - "is_error": True, - }) - - yield StreamEvent( - type="tool_execution_error", - tool_id=tool_id, - tool_name=tool_name, - error=error_str, - ) - - # Add tool results as a user message (Anthropic format) - messages.append({ - "role": "user", - "content": tool_results, - }) - - # Continue the loop - LLM will see tool results and respond diff --git a/src/sage/tui/server/migrate.py b/src/sage/tui/server/migrate.py deleted file mode 100644 index f41f496..0000000 --- a/src/sage/tui/server/migrate.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Migrate old GUI sessions from ~/.sage/sessions.json to sage.db.""" -from __future__ import annotations - -import json -import logging -from pathlib import Path - -log = logging.getLogger(__name__) - - -def migrate_if_needed(store): - """One-time migration from old JSON sessions to SQLite. - - Args: - store: SessionStore instance - """ - old_path = Path.home() / ".sage" / "sessions.json" - marker = Path.home() / ".sage" / ".sessions_migrated" - - # Skip if already migrated or no old sessions exist - if marker.exists() or not old_path.exists(): - return - - log.info("Migrating old sessions from %s to sage.db", old_path) - - try: - # Read old sessions - data = json.loads(old_path.read_text(encoding="utf-8")) - - migrated_count = 0 - # Import each project's sessions - for project_path, sessions in data.items(): - if not isinstance(sessions, list): - continue - - for session_data in sessions: - # Create session - title = session_data.get("title", "Imported Chat") - model = session_data.get("model", "claude-sonnet-4.6") - agent = session_data.get("agent", "coder") - - session = store.create_session( - model=model, - agent=agent, - title=title, - ) - - # Import messages - messages = session_data.get("messages", []) - for msg in messages: - role = msg.get("role", "user") - text = msg.get("text", msg.get("content", "")) - if text: - store.add_message(session.id, role, text) - - migrated_count += 1 - - # Mark as migrated - marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text(f"migrated {migrated_count} sessions") - - log.info("Migration complete: %d sessions imported", migrated_count) - - except Exception as e: - log.error("Failed to migrate old sessions: %s", e, exc_info=True) - # Write marker anyway to avoid repeated failures - marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text(f"migration failed: {e}") diff --git a/src/sage/tui/server/models.py b/src/sage/tui/server/models.py deleted file mode 100644 index ffe4822..0000000 --- a/src/sage/tui/server/models.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Data models for the chat server.""" -from __future__ import annotations -from dataclasses import dataclass, field -from datetime import datetime -from typing import Any - - -@dataclass -class Session: - id: str - title: str - model: str - agent: str - created_at: str - updated_at: str - - -@dataclass -class Message: - id: str - session_id: str - role: str # "user", "assistant", "system" - content: str - tool_calls: list[dict[str, Any]] = field(default_factory=list) - tokens_in: int = 0 - tokens_out: int = 0 - cost: float = 0.0 - created_at: str = "" - - -@dataclass -class ToolCall: - id: str - message_id: str - tool_name: str - input_json: str - output_json: str - duration_ms: int = 0 - status: str = "pending" # pending, running, success, error diff --git a/src/sage/tui/server/providers/__init__.py b/src/sage/tui/server/providers/__init__.py deleted file mode 100644 index a7e1434..0000000 --- a/src/sage/tui/server/providers/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""LLM Provider registry.""" -from .base import BaseProvider, StreamEvent -from .anthropic import AnthropicProvider -from .openai_compat import OpenAICompatProvider - - -def get_provider(name: str = "anthropic", **kwargs) -> BaseProvider: - """Get a provider by name or config.""" - if name == "anthropic": - return AnthropicProvider() - return OpenAICompatProvider(**kwargs) - - -__all__ = ["BaseProvider", "StreamEvent", "AnthropicProvider", "OpenAICompatProvider", "get_provider"] diff --git a/src/sage/tui/server/providers/anthropic.py b/src/sage/tui/server/providers/anthropic.py deleted file mode 100644 index 4718bb5..0000000 --- a/src/sage/tui/server/providers/anthropic.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Anthropic provider for Claude streaming.""" -from __future__ import annotations -import asyncio -import json -import os -from typing import AsyncIterator, Any - -try: - import httpx -except ImportError: - httpx = None - -try: - import tiktoken -except ImportError: - tiktoken = None - -from .base import BaseProvider, StreamEvent - - -class AnthropicProvider(BaseProvider): - """Provider for Claude via Anthropic API.""" - - def __init__(self): - if httpx is None: - raise ImportError("httpx is required for AnthropicProvider. Install with: pip install httpx") - - self.api_key = self._get_api_key() - self.base_url = "https://api.anthropic.com/v1" - self._tokenizer = None - if tiktoken: - try: - self._tokenizer = tiktoken.get_encoding("cl100k_base") - except Exception: - pass - - def _get_api_key(self) -> str: - """Get API key from environment, keyring, or config.""" - # Try environment first - key = os.environ.get("ANTHROPIC_API_KEY") - if key: - return key - - # Try keyring - try: - import keyring - key = keyring.get_password("sage", "anthropic_api_key") - if key: - return key - except Exception: - pass - - # Try config file - try: - from pathlib import Path - config_path = Path.home() / ".sage" / "config.json" - if config_path.exists(): - config = json.loads(config_path.read_text()) - key = config.get("anthropic_api_key") - if key: - return key - except Exception: - pass - - raise ValueError( - "ANTHROPIC_API_KEY not found. Set it via environment variable, " - "keyring, or ~/.sage/config.json" - ) - - async def stream( - self, messages: list[dict], tools: list[dict], model: str - ) -> AsyncIterator[StreamEvent]: - """Stream a response from Claude.""" - # Convert messages to Anthropic format - api_messages = [] - system_prompt = None - - for msg in messages: - if msg["role"] == "system": - system_prompt = msg["content"] - else: - api_messages.append(msg) - - # Build request body - body = { - "model": model, - "messages": api_messages, - "max_tokens": 8192, - "stream": True, - } - - if system_prompt: - body["system"] = system_prompt - - if tools: - body["tools"] = tools - - # Stream the response - async with httpx.AsyncClient(timeout=300.0) as client: - try: - async with client.stream( - "POST", - f"{self.base_url}/messages", - headers={ - "anthropic-version": "2023-06-01", - "x-api-key": self.api_key, - "content-type": "application/json", - }, - json=body, - ) as response: - if response.status_code != 200: - error_text = await response.aread() - yield StreamEvent( - type="error", - error=f"API error {response.status_code}: {error_text.decode()}", - ) - return - - # Parse SSE stream - current_tool_id = "" - current_tool_name = "" - current_tool_input = "" - tokens_in = 0 - tokens_out = 0 - - async for line in response.aiter_lines(): - if not line.strip(): - continue - - if line.startswith("event:"): - event_type = line[6:].strip() - continue - - if line.startswith("data:"): - data_str = line[5:].strip() - if not data_str: - continue - - try: - data = json.loads(data_str) - except json.JSONDecodeError: - continue - - event_type = data.get("type") - - # Message start - capture token counts - if event_type == "message_start": - usage = data.get("message", {}).get("usage", {}) - tokens_in = usage.get("input_tokens", 0) - - # Content block start - elif event_type == "content_block_start": - block = data.get("content_block", {}) - block_type = block.get("type") - - if block_type == "tool_use": - current_tool_id = block.get("id", "") - current_tool_name = block.get("name", "") - current_tool_input = "" - yield StreamEvent( - type="tool_call_start", - tool_id=current_tool_id, - tool_name=current_tool_name, - ) - - elif block_type == "thinking": - yield StreamEvent(type="thinking") - - # Content block delta - elif event_type == "content_block_delta": - delta = data.get("delta", {}) - delta_type = delta.get("type") - - if delta_type == "text_delta": - text = delta.get("text", "") - yield StreamEvent(type="token", content=text) - - elif delta_type == "input_json_delta": - partial_json = delta.get("partial_json", "") - current_tool_input += partial_json - yield StreamEvent( - type="tool_call_delta", - tool_id=current_tool_id, - tool_name=current_tool_name, - tool_input=partial_json, - ) - - # Content block stop - elif event_type == "content_block_stop": - if current_tool_id: - yield StreamEvent( - type="tool_call_end", - tool_id=current_tool_id, - tool_name=current_tool_name, - tool_input=current_tool_input, - ) - current_tool_id = "" - current_tool_name = "" - current_tool_input = "" - - # Message stop - capture output tokens - elif event_type == "message_delta": - usage = data.get("usage", {}) - tokens_out = usage.get("output_tokens", 0) - - elif event_type == "message_stop": - yield StreamEvent( - type="done", - tokens_in=tokens_in, - tokens_out=tokens_out, - ) - - except httpx.HTTPError as e: - yield StreamEvent(type="error", error=f"HTTP error: {str(e)}") - except Exception as e: - yield StreamEvent(type="error", error=f"Stream error: {str(e)}") - - def count_tokens(self, text: str) -> int: - """Count tokens in text using tiktoken approximation.""" - if self._tokenizer: - try: - return len(self._tokenizer.encode(text)) - except Exception: - pass - # Fallback: rough approximation - return len(text) // 4 diff --git a/src/sage/tui/server/providers/base.py b/src/sage/tui/server/providers/base.py deleted file mode 100644 index b432a52..0000000 --- a/src/sage/tui/server/providers/base.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Base provider interface for LLM streaming.""" -from __future__ import annotations -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import AsyncIterator, Any - - -@dataclass -class StreamEvent: - """A single event from the LLM stream.""" - - type: str # "token", "tool_call_start", "tool_call_delta", "tool_call_end", "thinking", "done", "error" - content: str = "" - tool_name: str = "" - tool_id: str = "" - tool_input: str = "" - error: str = "" - tokens_in: int = 0 - tokens_out: int = 0 - - -class BaseProvider(ABC): - """Base class for LLM providers.""" - - @abstractmethod - async def stream( - self, messages: list[dict], tools: list[dict], model: str - ) -> AsyncIterator[StreamEvent]: - """Stream a response from the LLM.""" - ... - - @abstractmethod - def count_tokens(self, text: str) -> int: - """Count tokens in text.""" - ... diff --git a/src/sage/tui/server/providers/cli_agent.py b/src/sage/tui/server/providers/cli_agent.py deleted file mode 100644 index d0ca32a..0000000 --- a/src/sage/tui/server/providers/cli_agent.py +++ /dev/null @@ -1,86 +0,0 @@ -"""CLI Agent Provider — pipe messages through installed AI CLIs (claude, opencode, codex, aider).""" - -from __future__ import annotations - -import asyncio -import logging -from typing import AsyncIterator - -from .base import BaseProvider, StreamEvent - -log = logging.getLogger(__name__) - - -class CLIAgentProvider(BaseProvider): - """Stream responses by piping through a CLI agent's run command.""" - - def __init__(self, binary: str = "claude"): - self.binary = binary - - async def stream( - self, messages: list[dict], tools: list[dict], model: str - ) -> AsyncIterator[StreamEvent]: - """Send the last user message through the CLI and stream output.""" - # Get the last user message - user_msg = "" - for msg in reversed(messages): - if msg.get("role") == "user": - user_msg = msg.get("content", "") - break - - if not user_msg: - yield StreamEvent(type="error", content="No user message to send") - return - - # Build command based on which CLI - cmd = self._build_command(user_msg) - - try: - process = await asyncio.create_subprocess_shell( - cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - # Stream stdout line by line - while True: - line = await process.stdout.readline() - if not line: - break - text = line.decode("utf-8", errors="replace") - yield StreamEvent(type="token", content=text) - - await process.wait() - - # Check for errors - if process.returncode != 0: - stderr = await process.stderr.read() - err_text = stderr.decode("utf-8", errors="replace").strip() - if err_text: - yield StreamEvent(type="error", content=err_text) - - yield StreamEvent(type="done") - - except FileNotFoundError: - yield StreamEvent(type="error", content=f"CLI '{self.binary}' not found in PATH") - except Exception as e: - yield StreamEvent(type="error", content=str(e)) - - def _build_command(self, message: str) -> str: - """Build the CLI command for the given message.""" - # Escape quotes in message - safe_msg = message.replace('"', '\\"') - - if self.binary == "claude": - return f'claude -p "{safe_msg}"' - elif self.binary == "opencode": - return f'opencode run "{safe_msg}"' - elif self.binary == "codex": - return f'codex -q "{safe_msg}"' - elif self.binary == "aider": - return f'aider --message "{safe_msg}" --no-git' - else: - return f'{self.binary} "{safe_msg}"' - - def count_tokens(self, text: str) -> int: - return len(text) // 4 diff --git a/src/sage/tui/server/providers/openai_compat.py b/src/sage/tui/server/providers/openai_compat.py deleted file mode 100644 index afda760..0000000 --- a/src/sage/tui/server/providers/openai_compat.py +++ /dev/null @@ -1,136 +0,0 @@ -"""OpenAI-compatible provider — works with any endpoint that speaks the OpenAI API. - -Supports: OpenAI, DeepSeek, OpenRouter, NVIDIA NIM, Kimi, Groq, Together, -FreeModel, Ollama, or any custom base_url. -""" - -from __future__ import annotations - -import json -import logging -from typing import Any, AsyncIterator - -import httpx - -from .base import BaseProvider, StreamEvent - -log = logging.getLogger(__name__) - - -class OpenAICompatProvider(BaseProvider): - """Stream from any OpenAI-compatible chat/completions endpoint.""" - - def __init__(self, base_url: str, api_key: str = "", model: str = "auto"): - self.base_url = base_url.rstrip("/") - self.api_key = api_key - self.model = model - self._client: httpx.AsyncClient | None = None - - def _get_client(self) -> httpx.AsyncClient: - if not self._client: - headers = {"Content-Type": "application/json"} - if self.api_key: - headers["Authorization"] = f"Bearer {self.api_key}" - self._client = httpx.AsyncClient( - headers=headers, - timeout=httpx.Timeout(120.0, connect=10.0), - ) - return self._client - - async def stream( - self, messages: list[dict], tools: list[dict], model: str - ) -> AsyncIterator[StreamEvent]: - """Stream a response from an OpenAI-compatible endpoint.""" - client = self._get_client() - use_model = model if model != "auto" else self.model - - body: dict[str, Any] = { - "model": use_model, - "messages": messages, - "stream": True, - } - if tools: - body["tools"] = tools - body["tool_choice"] = "auto" - - url = f"{self.base_url}/chat/completions" - - try: - async with client.stream("POST", url, json=body) as response: - if response.status_code != 200: - error_body = await response.aread() - yield StreamEvent( - type="error", - content=f"HTTP {response.status_code}: {error_body.decode('utf-8', errors='replace')[:500]}", - ) - return - - async for line in response.aiter_lines(): - if not line.startswith("data: "): - continue - data = line[6:] - if data == "[DONE]": - yield StreamEvent(type="done") - return - - try: - chunk = json.loads(data) - except json.JSONDecodeError: - continue - - choices = chunk.get("choices", []) - if not choices: - continue - - delta = choices[0].get("delta", {}) - - # Text content - content = delta.get("content") - if content: - yield StreamEvent(type="token", content=content) - - # Reasoning/thinking (DeepSeek, some providers) - reasoning = delta.get("reasoning_content") or delta.get("thinking") - if reasoning: - yield StreamEvent(type="thinking", content=reasoning) - - # Tool calls - tool_calls = delta.get("tool_calls") - if tool_calls: - for tc in tool_calls: - func = tc.get("function", {}) - if tc.get("id"): - yield StreamEvent( - type="tool_call_start", - tool_id=tc["id"], - tool_name=func.get("name", ""), - ) - if func.get("arguments"): - yield StreamEvent( - type="tool_call_delta", - tool_id=tc.get("id", ""), - tool_input=func["arguments"], - ) - - # Finish reason - finish = choices[0].get("finish_reason") - if finish == "tool_calls": - yield StreamEvent(type="tool_call_end") - elif finish == "stop": - yield StreamEvent(type="done") - return - - except httpx.ConnectError as e: - yield StreamEvent(type="error", content=f"Connection failed: {e}") - except httpx.ReadTimeout: - yield StreamEvent(type="error", content="Request timed out") - except Exception as e: - yield StreamEvent(type="error", content=f"Provider error: {e}") - - def count_tokens(self, text: str) -> int: - try: - import tiktoken - enc = tiktoken.get_encoding("cl100k_base") - return len(enc.encode(text)) - except Exception: - return len(text) // 4 diff --git a/src/sage/tui/server/router.py b/src/sage/tui/server/router.py deleted file mode 100644 index 8cea609..0000000 --- a/src/sage/tui/server/router.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Provider router — resolve which model/endpoint to use. - -Priority: -1. CLI agents (already authenticated, have tool access): Claude Code → OpenCode → Codex → Aider -2. Direct API keys as fallback: ANTHROPIC_API_KEY, OPENAI_API_KEY, etc. -3. Custom base_url endpoint: SAGE_LLM_BASE_URL -""" - -from __future__ import annotations - -import os -import shutil -from typing import Any - - -# CLI agents — checked first, already have auth + tools -CLI_AGENTS = [ - {"name": "claude", "binary": "claude", "label": "Claude Code"}, - {"name": "opencode", "binary": "opencode", "label": "OpenCode"}, - {"name": "codex", "binary": "codex", "label": "Codex"}, - {"name": "aider", "binary": "aider", "label": "Aider"}, -] - -# API providers — fallback when no CLI is available -API_PROVIDERS: dict[str, dict[str, str]] = { - "anthropic": { - "base_url": "https://api.anthropic.com", - "env_key": "ANTHROPIC_API_KEY", - "label": "Claude API", - "default_model": "claude-sonnet-4-6", - }, - "openai": { - "base_url": "https://api.openai.com/v1", - "env_key": "OPENAI_API_KEY", - "label": "OpenAI", - "default_model": "gpt-4.1", - }, - "deepseek": { - "base_url": "https://api.deepseek.com/v1", - "env_key": "DEEPSEEK_API_KEY", - "label": "DeepSeek", - "default_model": "deepseek-chat", - }, - "openrouter": { - "base_url": "https://openrouter.ai/api/v1", - "env_key": "OPENROUTER_API_KEY", - "label": "OpenRouter", - "default_model": "anthropic/claude-sonnet-4.6", - }, - "nvidia": { - "base_url": "https://integrate.api.nvidia.com/v1", - "env_key": "NVIDIA_API_KEY", - "label": "NVIDIA NIM", - "default_model": "nvidia/nemotron-3-ultra", - }, - "kimi": { - "base_url": "https://api.moonshot.cn/v1", - "env_key": "MOONSHOT_API_KEY", - "label": "Kimi (Moonshot)", - "default_model": "moonshot-v1-128k", - }, - "groq": { - "base_url": "https://api.groq.com/openai/v1", - "env_key": "GROQ_API_KEY", - "label": "Groq", - "default_model": "llama-3.3-70b-versatile", - }, - "together": { - "base_url": "https://api.together.xyz/v1", - "env_key": "TOGETHER_API_KEY", - "label": "Together AI", - "default_model": "meta-llama/Llama-3.3-70B-Instruct-Turbo", - }, - "freemodel": { - "base_url": "https://api.freemodel.dev/v1", - "env_key": "FREEMODEL_API_KEY", - "label": "FreeModel", - "default_model": "auto", - }, - "ollama": { - "base_url": "http://localhost:11434/v1", - "env_key": "", - "label": "Ollama (local)", - "default_model": "qwen2.5-coder:7b", - }, -} - - -def detect_cli_agents() -> list[dict]: - """Detect installed CLI agents.""" - available = [] - for agent in CLI_AGENTS: - if shutil.which(agent["binary"]): - available.append(agent) - return available - - -def detect_api_providers() -> list[dict]: - """Detect API providers with keys configured.""" - available = [] - for name, info in API_PROVIDERS.items(): - env_key = info["env_key"] - if not env_key and name == "ollama": - if _ollama_up(): - available.append({"name": name, **info}) - continue - if env_key and os.getenv(env_key): - available.append({"name": name, **info}) - - # Custom endpoint - custom_base = os.getenv("SAGE_LLM_BASE_URL") or os.getenv("LLM_BASE_URL") - if custom_base: - available.append({ - "name": "custom", - "base_url": custom_base, - "env_key": "", - "label": f"Custom ({custom_base.split('//')[1].split('/')[0]})", - "default_model": os.getenv("SAGE_LLM_MODEL", "auto"), - }) - - return available - - -def resolve_provider(force: str | None = None) -> dict[str, Any]: - """Resolve which provider to use. - - Priority: - 1. force parameter (user explicitly selected via settings) - 2. SAGE_LLM_PROVIDER env var - 3. CLI agents (Claude Code → OpenCode → Codex → Aider) - 4. API keys (first available) - 5. Error — nothing configured - """ - provider_name = force or os.getenv("SAGE_LLM_PROVIDER", "") - - # Forced provider - if provider_name: - # Check if it's a CLI agent - for agent in CLI_AGENTS: - if agent["name"] == provider_name and shutil.which(agent["binary"]): - return { - "name": agent["name"], - "type": "cli", - "binary": agent["binary"], - "label": agent["label"], - "model": "", - } - # Check API providers - info = API_PROVIDERS.get(provider_name) - if info: - api_key = os.getenv(info["env_key"]) if info["env_key"] else "" - return { - "name": provider_name, - "type": "api", - "base_url": info["base_url"], - "api_key": api_key, - "label": info["label"], - "model": info["default_model"], - } - - # Auto-detect: CLI agents first - cli_agents = detect_cli_agents() - if cli_agents: - agent = cli_agents[0] - return { - "name": agent["name"], - "type": "cli", - "binary": agent["binary"], - "label": agent["label"], - "model": "", - } - - # Fallback: API providers - api_providers = detect_api_providers() - if api_providers: - prov = api_providers[0] - api_key = os.getenv(prov.get("env_key", "")) if prov.get("env_key") else "" - return { - "name": prov["name"], - "type": "api", - "base_url": prov["base_url"], - "api_key": api_key, - "label": prov["label"], - "model": prov.get("default_model", "auto"), - } - - return { - "name": "none", - "type": "none", - "label": "No provider", - "model": "", - "error": "No AI provider found. Install Claude Code / OpenCode / Codex, or set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)", - } - - -def _ollama_up() -> bool: - try: - import urllib.request - urllib.request.urlopen("http://localhost:11434/api/tags", timeout=1) - return True - except Exception: - return False diff --git a/src/sage/tui/server/session_store.py b/src/sage/tui/server/session_store.py deleted file mode 100644 index 8f6ba28..0000000 --- a/src/sage/tui/server/session_store.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Session storage for the chat server.""" -from __future__ import annotations -import json -import uuid -from datetime import datetime, timezone -from typing import Any - -from sage.store import connect -from .models import Session, Message, ToolCall - - -class SessionStore: - """CRUD for sessions and messages using the existing sage.db.""" - - def __init__(self): - self._ensure_tables() - - def _ensure_tables(self): - """Create chat tables if they don't exist.""" - conn = connect() - try: - # Sessions table - conn.execute(""" - CREATE TABLE IF NOT EXISTS chat_sessions ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - model TEXT NOT NULL, - agent TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - """) - - # Messages table - conn.execute(""" - CREATE TABLE IF NOT EXISTS chat_messages ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - tool_calls TEXT DEFAULT '[]', - tokens_in INTEGER DEFAULT 0, - tokens_out INTEGER DEFAULT 0, - cost REAL DEFAULT 0.0, - created_at TEXT NOT NULL, - FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE - ) - """) - - # Tool calls table - conn.execute(""" - CREATE TABLE IF NOT EXISTS chat_tool_calls ( - id TEXT PRIMARY KEY, - message_id TEXT NOT NULL, - tool_name TEXT NOT NULL, - input_json TEXT NOT NULL, - output_json TEXT NOT NULL, - duration_ms INTEGER DEFAULT 0, - status TEXT DEFAULT 'pending', - FOREIGN KEY (message_id) REFERENCES chat_messages(id) ON DELETE CASCADE - ) - """) - - # Index for faster lookups - conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_messages_session - ON chat_messages(session_id, created_at) - """) - - conn.commit() - finally: - conn.close() - - def create_session(self, model: str, agent: str, title: str = "New Chat") -> Session: - """Create a new chat session.""" - session_id = str(uuid.uuid4()) - now = datetime.now(timezone.utc).isoformat() - - conn = connect() - try: - conn.execute( - """ - INSERT INTO chat_sessions (id, title, model, agent, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - """, - (session_id, title, model, agent, now, now), - ) - conn.commit() - finally: - conn.close() - - return Session( - id=session_id, - title=title, - model=model, - agent=agent, - created_at=now, - updated_at=now, - ) - - def get_session(self, session_id: str) -> Session | None: - """Get a session by ID.""" - conn = connect() - try: - row = conn.execute( - "SELECT * FROM chat_sessions WHERE id = ?", (session_id,) - ).fetchone() - if not row: - return None - return Session( - id=row["id"], - title=row["title"], - model=row["model"], - agent=row["agent"], - created_at=row["created_at"], - updated_at=row["updated_at"], - ) - finally: - conn.close() - - def list_sessions(self, limit: int = 50) -> list[Session]: - """List recent sessions.""" - conn = connect() - try: - rows = conn.execute( - "SELECT * FROM chat_sessions ORDER BY updated_at DESC LIMIT ?", - (limit,), - ).fetchall() - return [ - Session( - id=row["id"], - title=row["title"], - model=row["model"], - agent=row["agent"], - created_at=row["created_at"], - updated_at=row["updated_at"], - ) - for row in rows - ] - finally: - conn.close() - - def delete_session(self, session_id: str): - """Delete a session and all its messages.""" - conn = connect() - try: - conn.execute("DELETE FROM chat_sessions WHERE id = ?", (session_id,)) - conn.commit() - finally: - conn.close() - - def add_message( - self, - session_id: str, - role: str, - content: str, - tool_calls: list[dict[str, Any]] | None = None, - tokens_in: int = 0, - tokens_out: int = 0, - cost: float = 0.0, - ) -> Message: - """Add a message to a session.""" - message_id = str(uuid.uuid4()) - now = datetime.now(timezone.utc).isoformat() - tool_calls_json = json.dumps(tool_calls or []) - - conn = connect() - try: - conn.execute( - """ - INSERT INTO chat_messages - (id, session_id, role, content, tool_calls, tokens_in, tokens_out, cost, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - message_id, - session_id, - role, - content, - tool_calls_json, - tokens_in, - tokens_out, - cost, - now, - ), - ) - - # Update session updated_at - conn.execute( - "UPDATE chat_sessions SET updated_at = ? WHERE id = ?", - (now, session_id), - ) - - conn.commit() - finally: - conn.close() - - return Message( - id=message_id, - session_id=session_id, - role=role, - content=content, - tool_calls=tool_calls or [], - tokens_in=tokens_in, - tokens_out=tokens_out, - cost=cost, - created_at=now, - ) - - def get_messages(self, session_id: str) -> list[Message]: - """Get all messages for a session.""" - conn = connect() - try: - rows = conn.execute( - "SELECT * FROM chat_messages WHERE session_id = ? ORDER BY created_at ASC", - (session_id,), - ).fetchall() - return [ - Message( - id=row["id"], - session_id=row["session_id"], - role=row["role"], - content=row["content"], - tool_calls=json.loads(row["tool_calls"]), - tokens_in=row["tokens_in"], - tokens_out=row["tokens_out"], - cost=row["cost"], - created_at=row["created_at"], - ) - for row in rows - ] - finally: - conn.close() - - def update_session_title(self, session_id: str, title: str): - """Update a session's title.""" - now = datetime.now(timezone.utc).isoformat() - conn = connect() - try: - conn.execute( - "UPDATE chat_sessions SET title = ?, updated_at = ? WHERE id = ?", - (title, now, session_id), - ) - conn.commit() - finally: - conn.close() diff --git a/src/sage/tui/server/tools/__init__.py b/src/sage/tui/server/tools/__init__.py deleted file mode 100644 index b691563..0000000 --- a/src/sage/tui/server/tools/__init__.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Tool registry for the chat server.""" -from __future__ import annotations -from typing import Any - -from .base import BaseTool -from .bash import BashTool -from .read_file import ReadFileTool -from .write_file import WriteFileTool -from .edit_file import EditFileTool -from .glob import GlobTool -from .grep import GrepTool -from .validate import ValidateTool -from .analyze_context import AnalyzeContextTool -from .rollback import RollbackTool - - -class ToolRegistry: - """Registry of available tools.""" - - def __init__(self): - self._tools: dict[str, BaseTool] = {} - self._register_defaults() - - def _register_defaults(self): - """Register default tools.""" - for tool in [ - BashTool(), - ReadFileTool(), - WriteFileTool(), - EditFileTool(), - GlobTool(), - GrepTool(), - ValidateTool(), - AnalyzeContextTool(), - RollbackTool(), - ]: - self._tools[tool.name] = tool - - def get(self, name: str) -> BaseTool | None: - """Get a tool by name.""" - return self._tools.get(name) - - def schemas(self) -> list[dict]: - """Get all tool schemas in OpenAI format.""" - return [t.schema() for t in self._tools.values()] - - async def execute(self, name: str, input_data: dict) -> dict: - """Execute a tool by name.""" - tool = self._tools.get(name) - if not tool: - return {"error": f"Unknown tool: {name}"} - try: - return await tool.execute(input_data) - except Exception as e: - return {"error": str(e)} - - -__all__ = ["ToolRegistry", "BaseTool"] diff --git a/src/sage/tui/server/tools/analyze_context.py b/src/sage/tui/server/tools/analyze_context.py deleted file mode 100644 index fc768a1..0000000 --- a/src/sage/tui/server/tools/analyze_context.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Analyze context tool - pattern detection, style, file structure.""" -from __future__ import annotations - -from pathlib import Path - -from .base import BaseTool - - -class AnalyzeContextTool(BaseTool): - """Analyze codebase patterns, style, and structure.""" - - @property - def name(self) -> str: - return "sage_analyze_context" - - @property - def description(self) -> str: - return "Analyze codebase patterns (naming, error handling, testing), style (indent, quotes), file structure" - - def schema(self) -> dict: - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": self._parameters(), - }, - } - - def _parameters(self) -> dict: - return { - "type": "object", - "properties": { - "path": {"type": "string", "description": "File or directory to analyze"}, - "sample_size": {"type": "integer", "description": "Number of files to sample (default 15)"}, - }, - } - - async def execute(self, input_data: dict) -> dict: - target = Path(input_data.get("path", ".")) - sample_size = input_data.get("sample_size", 15) - - if not target.exists(): - return {"error": f"Path not found: {target}"} - - root = target if target.is_dir() else target.parent - - try: - from sage.codegen import PatternDetector, StyleEnforcer, ContextBuilder - - # Pattern detection - detector = PatternDetector(root) - patterns = detector.detect_all(sample_size=sample_size) - pattern_summary = detector.summarize_patterns(patterns) - - # Style detection - enforcer = StyleEnforcer(root) - style = enforcer.get_profile() - style_summary = enforcer.summarize_style() - - # File context if specific file - file_summary = None - related_files = [] - if target.is_file(): - builder = ContextBuilder(root) - file_summary = builder.summarize_file(target) - related = builder.find_related_files(target) - related_files = [str(p.relative_to(root)) for p in related[:5]] - - return { - "success": True, - "patterns": [ - { - "category": p.category, - "pattern": p.pattern, - "confidence": p.confidence, - } - for p in patterns - ], - "style": { - "indent_type": style.indent_type, - "indent_size": style.indent_size, - "quote_style": style.quote_style, - "max_line_length": style.max_line_length, - }, - "pattern_summary": pattern_summary, - "style_summary": style_summary, - "file_summary": file_summary, - "related_files": related_files, - } - except Exception as e: - return {"error": f"Analysis failed: {e}"} diff --git a/src/sage/tui/server/tools/base.py b/src/sage/tui/server/tools/base.py deleted file mode 100644 index ea29de7..0000000 --- a/src/sage/tui/server/tools/base.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Base tool interface.""" -from __future__ import annotations -from abc import ABC, abstractmethod -from typing import Any - - -class BaseTool(ABC): - """Base class for all tools.""" - - @property - @abstractmethod - def name(self) -> str: - """Tool name.""" - ... - - @property - @abstractmethod - def description(self) -> str: - """Tool description.""" - ... - - @abstractmethod - def schema(self) -> dict[str, Any]: - """Return OpenAI-format tool schema.""" - ... - - @abstractmethod - async def execute(self, input_data: dict[str, Any]) -> dict[str, Any]: - """Execute the tool and return results.""" - ... diff --git a/src/sage/tui/server/tools/bash.py b/src/sage/tui/server/tools/bash.py deleted file mode 100644 index 3034bbb..0000000 --- a/src/sage/tui/server/tools/bash.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Bash tool for executing commands.""" -from __future__ import annotations -import asyncio -import subprocess -import time -from typing import Any - -from .base import BaseTool - - -class BashTool(BaseTool): - """Execute bash commands through sage run, with ML prediction.""" - - @property - def name(self) -> str: - return "bash" - - @property - def description(self) -> str: - return "Execute shell commands. Runs through 'sage run --' for safety." - - def schema(self) -> dict[str, Any]: - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The shell command to execute", - } - }, - "required": ["command"], - }, - }, - } - - @staticmethod - def _predict(command: str) -> dict[str, Any] | None: - """Query the ML daemon for a failure prediction.""" - try: - from sage.ml.client import predict_fast - return predict_fast(command, timeout=0.5) - except Exception: - pass - return None - - async def execute(self, input_data: dict[str, Any]) -> dict[str, Any]: - """Execute a command through sage run.""" - command = input_data.get("command", "") - if not command: - return {"error": "No command provided", "exit_code": 1} - - # ML prediction before execution - prediction = self._predict(command) - - started = time.perf_counter() - - try: - # Run through sage run -- - process = await asyncio.create_subprocess_shell( - f"sage run -- {command}", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - stdout, stderr = await process.communicate() - duration_ms = int((time.perf_counter() - started) * 1000) - - return { - "stdout": stdout.decode("utf-8", errors="replace"), - "stderr": stderr.decode("utf-8", errors="replace"), - "exit_code": process.returncode or 0, - "duration_ms": duration_ms, - "prediction": prediction, - } - - except Exception as e: - duration_ms = int((time.perf_counter() - started) * 1000) - return { - "stdout": "", - "stderr": str(e), - "exit_code": 1, - "duration_ms": duration_ms, - "error": str(e), - } diff --git a/src/sage/tui/server/tools/edit_file.py b/src/sage/tui/server/tools/edit_file.py deleted file mode 100644 index 923cbb2..0000000 --- a/src/sage/tui/server/tools/edit_file.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Edit file tool.""" -from __future__ import annotations -from pathlib import Path -from typing import Any - -from .base import BaseTool - - -class EditFileTool(BaseTool): - """Edit file by replacing text.""" - - @property - def name(self) -> str: - return "edit_file" - - @property - def description(self) -> str: - return "Edit a file by replacing exact text matches" - - def schema(self) -> dict[str, Any]: - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the file to edit", - }, - "old": { - "type": "string", - "description": "Exact text to find and replace", - }, - "new": { - "type": "string", - "description": "New text to replace with", - }, - }, - "required": ["path", "old", "new"], - }, - }, - } - - async def execute(self, input_data: dict[str, Any]) -> dict[str, Any]: - """Edit a file.""" - path_str = input_data.get("path", "") - old_text = input_data.get("old", "") - new_text = input_data.get("new", "") - - if not path_str: - return {"error": "No path provided"} - - if not old_text: - return {"error": "No 'old' text provided"} - - try: - path = Path(path_str) - if not path.exists(): - return {"error": f"File not found: {path_str}"} - - if not path.is_file(): - return {"error": f"Not a file: {path_str}"} - - content = path.read_text(encoding="utf-8", errors="replace") - - # Count replacements - replacements = content.count(old_text) - if replacements == 0: - return { - "error": f"Text not found in file: {old_text[:50]}...", - "replacements": 0, - } - - # Replace and write back - new_content = content.replace(old_text, new_text) - path.write_text(new_content, encoding="utf-8") - - return { - "path": str(path), - "replacements": replacements, - "lines": len(new_content.splitlines()), - } - - except Exception as e: - return {"error": str(e)} diff --git a/src/sage/tui/server/tools/glob.py b/src/sage/tui/server/tools/glob.py deleted file mode 100644 index 4876322..0000000 --- a/src/sage/tui/server/tools/glob.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Glob file search tool.""" -from __future__ import annotations -from pathlib import Path -from typing import Any - -from .base import BaseTool - - -class GlobTool(BaseTool): - """Search for files using glob patterns.""" - - @property - def name(self) -> str: - return "glob" - - @property - def description(self) -> str: - return "Find files matching a glob pattern (e.g., '**/*.py')" - - def schema(self) -> dict[str, Any]: - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": { - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Glob pattern (e.g., '**/*.py', 'src/**/*.ts')", - }, - "root": { - "type": "string", - "description": "Root directory to search from (default: '.')", - }, - }, - "required": ["pattern"], - }, - }, - } - - async def execute(self, input_data: dict[str, Any]) -> dict[str, Any]: - """Execute glob search.""" - pattern = input_data.get("pattern", "") - root = input_data.get("root", ".") - - if not pattern: - return {"error": "No pattern provided"} - - try: - root_path = Path(root) - if not root_path.exists(): - return {"error": f"Root directory not found: {root}"} - - # Find matching files - matches = list(root_path.glob(pattern)) - - # Filter to files only, convert to strings - files = [str(p.resolve()) for p in matches if p.is_file()] - - # Sort by name - files.sort() - - return { - "files": files, - "count": len(files), - "pattern": pattern, - "root": str(root_path.resolve()), - } - - except Exception as e: - return {"error": str(e)} diff --git a/src/sage/tui/server/tools/grep.py b/src/sage/tui/server/tools/grep.py deleted file mode 100644 index 5eca29a..0000000 --- a/src/sage/tui/server/tools/grep.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Grep content search tool.""" -from __future__ import annotations -import re -from pathlib import Path -from typing import Any - -from .base import BaseTool - - -class GrepTool(BaseTool): - """Search file contents using regex.""" - - @property - def name(self) -> str: - return "grep" - - @property - def description(self) -> str: - return "Search for text patterns in files using regex" - - def schema(self) -> dict[str, Any]: - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": { - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Regex pattern to search for", - }, - "paths": { - "type": "array", - "items": {"type": "string"}, - "description": "List of paths to search (files or directories)", - }, - "glob_filter": { - "type": "string", - "description": "Optional glob pattern to filter files (e.g., '*.py')", - }, - "ignore_case": { - "type": "boolean", - "description": "Case-insensitive search", - }, - }, - "required": ["pattern"], - }, - }, - } - - async def execute(self, input_data: dict[str, Any]) -> dict[str, Any]: - """Execute grep search.""" - pattern_str = input_data.get("pattern", "") - paths = input_data.get("paths", ["."]) - glob_filter = input_data.get("glob_filter", "*") - ignore_case = input_data.get("ignore_case", False) - - if not pattern_str: - return {"error": "No pattern provided"} - - try: - # Compile regex - flags = re.IGNORECASE if ignore_case else 0 - pattern = re.compile(pattern_str, flags) - - matches = [] - - # Search each path - for path_str in paths: - path = Path(path_str) - - if not path.exists(): - continue - - # Get files to search - if path.is_file(): - files = [path] - else: - # Directory - glob for files - files = list(path.rglob(glob_filter)) - files = [f for f in files if f.is_file()] - - # Search each file - for file_path in files: - try: - content = file_path.read_text(encoding="utf-8", errors="replace") - for line_num, line in enumerate(content.splitlines(), start=1): - if pattern.search(line): - matches.append({ - "file": str(file_path), - "line": line_num, - "text": line.strip(), - }) - except Exception: - # Skip files that can't be read - continue - - return { - "matches": matches, - "count": len(matches), - "pattern": pattern_str, - } - - except re.error as e: - return {"error": f"Invalid regex pattern: {str(e)}"} - except Exception as e: - return {"error": str(e)} diff --git a/src/sage/tui/server/tools/read_file.py b/src/sage/tui/server/tools/read_file.py deleted file mode 100644 index da929b9..0000000 --- a/src/sage/tui/server/tools/read_file.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Read file tool.""" -from __future__ import annotations -from pathlib import Path -from typing import Any - -from .base import BaseTool - - -class ReadFileTool(BaseTool): - """Read file content.""" - - @property - def name(self) -> str: - return "read_file" - - @property - def description(self) -> str: - return "Read the contents of a file" - - def schema(self) -> dict[str, Any]: - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the file to read", - }, - "lines": { - "type": "string", - "description": "Optional line range in format 'start:end' (e.g., '10:20')", - }, - }, - "required": ["path"], - }, - }, - } - - async def execute(self, input_data: dict[str, Any]) -> dict[str, Any]: - """Read a file.""" - path_str = input_data.get("path", "") - lines_spec = input_data.get("lines") - - if not path_str: - return {"error": "No path provided"} - - try: - path = Path(path_str) - if not path.exists(): - return {"error": f"File not found: {path_str}"} - - if not path.is_file(): - return {"error": f"Not a file: {path_str}"} - - content = path.read_text(encoding="utf-8", errors="replace") - lines_list = content.splitlines() - - # Handle line range if specified - if lines_spec: - try: - start, end = map(int, lines_spec.split(":")) - lines_list = lines_list[start - 1 : end] - content = "\n".join(lines_list) - except (ValueError, IndexError) as e: - return {"error": f"Invalid line range: {lines_spec}"} - - # Detect language from extension - language = path.suffix.lstrip(".") or "text" - - return { - "content": content, - "lines": len(lines_list), - "language": language, - "path": str(path), - } - - except Exception as e: - return {"error": str(e)} diff --git a/src/sage/tui/server/tools/rollback.py b/src/sage/tui/server/tools/rollback.py deleted file mode 100644 index a2c7896..0000000 --- a/src/sage/tui/server/tools/rollback.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Rollback tool - restore files from snapshots.""" -from __future__ import annotations - -from pathlib import Path -from typing import ClassVar - -from .base import BaseTool - - -class RollbackTool(BaseTool): - """Rollback files to previous snapshots.""" - - # Shared snapshot store across all instances - _snapshots: ClassVar[dict[str, dict]] = {} - _counter: ClassVar[int] = 0 - - @property - def name(self) -> str: - return "sage_rollback" - - @property - def description(self) -> str: - return "Rollback a file to its state before the last write/edit using snapshot ID" - - def schema(self) -> dict: - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": self._parameters(), - }, - } - - def _parameters(self) -> dict: - return { - "type": "object", - "properties": { - "snapshot_id": {"type": "string", "description": "Snapshot ID from previous write/edit result"}, - }, - "required": ["snapshot_id"], - } - - @classmethod - def create_snapshot(cls, path: Path) -> str: - """Create a snapshot of a file. Returns snapshot ID.""" - cls._counter += 1 - import time - snapshot_id = f"snap_{cls._counter}_{int(time.time())}" - - content = None - if path.exists(): - content = path.read_text(encoding="utf-8", errors="replace") - - cls._snapshots[snapshot_id] = { - "path": str(path), - "content": content, - } - return snapshot_id - - @classmethod - def get_snapshot(cls, snapshot_id: str) -> dict | None: - """Get a snapshot by ID.""" - return cls._snapshots.get(snapshot_id) - - async def execute(self, input_data: dict) -> dict: - snapshot_id = input_data.get("snapshot_id", "") - - if not snapshot_id: - return {"error": "snapshot_id is required"} - - snapshot = self._snapshots.get(snapshot_id) - if not snapshot: - return {"error": f"Snapshot not found: {snapshot_id}"} - - path = Path(snapshot["path"]) - content = snapshot["content"] - - try: - if content is None: - # File was created, delete it - if path.exists(): - path.unlink() - else: - path.write_text(content, encoding="utf-8") - - del self._snapshots[snapshot_id] - return {"success": True, "restored": str(path)} - except Exception as e: - return {"error": f"Rollback failed: {e}"} diff --git a/src/sage/tui/server/tools/validate.py b/src/sage/tui/server/tools/validate.py deleted file mode 100644 index 6fb912e..0000000 --- a/src/sage/tui/server/tools/validate.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Validate tool - deep AST, security, quality validation.""" -from __future__ import annotations - -from pathlib import Path - -from .base import BaseTool - - -class ValidateTool(BaseTool): - """Deep code validation tool.""" - - @property - def name(self) -> str: - return "sage_validate" - - @property - def description(self) -> str: - return "Deep validation: AST errors, security issues (hardcoded secrets), code quality (TODO/debug code)" - - def schema(self) -> dict: - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": self._parameters(), - }, - } - - def _parameters(self) -> dict: - return { - "type": "object", - "properties": { - "path": {"type": "string", "description": "File path to validate"}, - "content": {"type": "string", "description": "Optional content to validate instead of reading file"}, - }, - "required": ["path"], - } - - async def execute(self, input_data: dict) -> dict: - path = Path(input_data.get("path", "")) - content = input_data.get("content") - - if content is None: - if not path.exists(): - return {"error": f"File not found: {path}"} - content = path.read_text(encoding="utf-8", errors="replace") - - try: - from sage.codegen import create_default_registry - - registry = create_default_registry() - result = registry.validate(path, content) - - return { - "success": True, - "valid": result.valid, - "summary": result.summary(), - "issues": [ - { - "line": i.line, - "severity": i.severity, - "category": i.category, - "message": i.message, - "suggestion": i.suggestion, - } - for i in result.issues[:15] - ], - } - except Exception as e: - return {"error": f"Validation failed: {e}"} diff --git a/src/sage/tui/server/tools/write_file.py b/src/sage/tui/server/tools/write_file.py deleted file mode 100644 index 809dd2c..0000000 --- a/src/sage/tui/server/tools/write_file.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Write file tool.""" -from __future__ import annotations -from pathlib import Path -from typing import Any - -from .base import BaseTool - - -class WriteFileTool(BaseTool): - """Write content to a file.""" - - @property - def name(self) -> str: - return "write_file" - - @property - def description(self) -> str: - return "Write content to a file, creating it if it doesn't exist" - - def schema(self) -> dict[str, Any]: - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the file to write", - }, - "content": { - "type": "string", - "description": "Content to write to the file", - }, - }, - "required": ["path", "content"], - }, - }, - } - - async def execute(self, input_data: dict[str, Any]) -> dict[str, Any]: - """Write a file.""" - path_str = input_data.get("path", "") - content = input_data.get("content", "") - - if not path_str: - return {"error": "No path provided"} - - try: - path = Path(path_str) - created = not path.exists() - - # Create parent directories if needed - path.parent.mkdir(parents=True, exist_ok=True) - - # Write the file - path.write_text(content, encoding="utf-8") - - return { - "path": str(path), - "bytes": len(content.encode("utf-8")), - "lines": len(content.splitlines()), - "created": created, - } - - except Exception as e: - return {"error": str(e)} diff --git a/src/sage/tui/widgets/__init__.py b/src/sage/tui/widgets/__init__.py deleted file mode 100644 index 58e4035..0000000 --- a/src/sage/tui/widgets/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""SAGE TUI widgets.""" - -from .output import OutputPanel -from .input_bar import InputBar, InputSubmitted -from .status_bar import StatusBar -from .sidebar import Sidebar, SessionSelected, NewSessionRequested, SessionDeleteRequested -from .tool_panel import ToolPanel - -__all__ = [ - "OutputPanel", - "InputBar", - "InputSubmitted", - "StatusBar", - "Sidebar", - "SessionSelected", - "NewSessionRequested", - "SessionDeleteRequested", - "ToolPanel", -] diff --git a/src/sage/tui/widgets/input_bar.py b/src/sage/tui/widgets/input_bar.py deleted file mode 100644 index 439aef4..0000000 --- a/src/sage/tui/widgets/input_bar.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Input bar widget for SAGE TUI.""" - -from textual.widgets import TextArea -from textual.message import Message - - -class InputSubmitted(Message): - """Message posted when user submits input.""" - - def __init__(self, text: str) -> None: - super().__init__() - self.text = text - - -class InputBar(TextArea): - """Multi-line input bar with submission on Enter.""" - - DEFAULT_CSS = """""" - - def __init__(self) -> None: - super().__init__( - text="", - language="markdown", - theme="monokai", - show_line_numbers=False, - ) - self.placeholder = "Type a message... (Enter to send, Shift+Enter for newline, Escape to clear)" - self._history: list[str] = [] - self._history_index: int = -1 - self._current_draft: str = "" - - def on_mount(self) -> None: - """Set placeholder after mount.""" - # Textual TextArea doesn't have built-in placeholder support in all versions - # We'll show it in the border title instead - self.border_title = "Message" - - def _on_key(self, event) -> None: - """Handle key events.""" - # Don't allow input if disabled - if self.read_only: - return - - # Check for Enter without Shift (in Textual, shift+enter is a separate key name) - if event.key == "enter": - event.prevent_default() - event.stop() - text = self.text.strip() - if text: - # Add to history - self._history.append(text) - self._history_index = len(self._history) - self._current_draft = "" - - self.post_message(InputSubmitted(text)) - self.clear() - # Escape clears input - elif event.key == "escape": - event.prevent_default() - event.stop() - self.clear() - self._history_index = len(self._history) - self._current_draft = "" - # Up arrow - navigate history backwards - elif event.key == "up": - cursor = self.cursor_location - if cursor[0] == 0 and self._history: # At first line - event.prevent_default() - event.stop() - - # Save current draft if at end of history - if self._history_index == len(self._history): - self._current_draft = self.text - - # Navigate backwards - if self._history_index > 0: - self._history_index -= 1 - self.text = self._history[self._history_index] - # Down arrow - navigate history forwards - elif event.key == "down": - cursor = self.cursor_location - line_count = len(self.text.split('\n')) - if cursor[0] == line_count - 1 and self._history: # At last line - event.prevent_default() - event.stop() - - # Navigate forwards - if self._history_index < len(self._history): - self._history_index += 1 - - if self._history_index == len(self._history): - # Restore draft - self.text = self._current_draft - else: - self.text = self._history[self._history_index] - - async def action_submit(self) -> None: - """Submit the current input.""" - if self.read_only: - return - - text = self.text.strip() - if text: - # Add to history - self._history.append(text) - self._history_index = len(self._history) - self._current_draft = "" - - self.post_message(InputSubmitted(text)) - self.clear() - - def disable(self) -> None: - """Disable input (during response processing).""" - self.read_only = True - self.add_class("disabled") - - def enable(self) -> None: - """Enable input (after response completes).""" - self.read_only = False - self.remove_class("disabled") diff --git a/src/sage/tui/widgets/output.py b/src/sage/tui/widgets/output.py deleted file mode 100644 index d460e80..0000000 --- a/src/sage/tui/widgets/output.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Output panel widget for SAGE TUI.""" - -from textual.containers import ScrollableContainer -from textual.widgets import Static, Markdown, Collapsible -from rich.syntax import Syntax -from rich.console import Console -from io import StringIO -from .tool_panel import ToolPanel - - -class OutputPanel(ScrollableContainer): - """Scrollable output panel showing conversation history.""" - - DEFAULT_CSS = """""" - - def __init__(self) -> None: - super().__init__() - self._thinking_widget = None - self._current_stream_widget = None - self._stream_buffer = "" - - def add_user_message(self, text: str, display_name: str = "") -> None: - """Add a user message to the output. - - Args: - text: The user's message text - display_name: User's display name (falls back to config) - """ - if not display_name: - display_name = self._get_display_name() - widget = Static( - f"[bold magenta]{display_name}:[/bold magenta]\n{text}", - classes="user-message", - markup=True, - ) - self.mount(widget) - self.scroll_end(animate=False) - - @staticmethod - def _get_display_name() -> str: - try: - from sage.telemetry import load_config - config = load_config() - profile = config.get("api_profile", {}) - return ( - profile.get("display_name") - or profile.get("username") - or profile.get("github_username") - or profile.get("email", "").split("@")[0] - or "Me" - ) - except Exception: - return "Me" - - def add_assistant_message(self, text: str) -> None: - """Add an assistant message to the output. - - Args: - text: The assistant's message text (markdown supported) - """ - # Header - header = Static( - "[bold cyan]Assistant:[/bold cyan]", - classes="assistant-message", - markup=True, - ) - self.mount(header) - - # Markdown content - content = Markdown(text) - content.add_class("assistant-message") - self.mount(content) - - self.scroll_end(animate=False) - - def add_tool_call( - self, - name: str, - input_text: str, - output_text: str, - duration_ms: int, - status: str, - ) -> None: - """Add a tool call panel to the output. - - Args: - name: Tool name - input_text: Tool input - output_text: Tool output - duration_ms: Duration in milliseconds - status: Status - 'running', 'success', or 'error' - """ - panel = ToolPanel( - name=name, - input_text=input_text, - output_text=output_text, - duration_ms=duration_ms, - status=status, - ) - self.mount(panel) - self.scroll_end(animate=False) - - def add_thinking(self) -> None: - """Show animated thinking indicator.""" - if self._thinking_widget is None: - self._thinking_widget = Static( - "[dim]Thinking...[/dim]", - classes="thinking", - markup=True, - ) - self.mount(self._thinking_widget) - self.scroll_end(animate=False) - - def remove_thinking(self) -> None: - """Remove the thinking indicator.""" - if self._thinking_widget is not None: - self._thinking_widget.remove() - self._thinking_widget = None - - def clear(self) -> None: - """Clear all output.""" - # Remove all children - for child in list(self.children): - child.remove() - self._thinking_widget = None - self._current_stream_widget = None - self._stream_buffer = "" - - def begin_assistant_stream(self) -> None: - """Begin streaming an assistant message. - - Creates a new Static widget for accumulating streamed tokens. - """ - # Header - header = Static( - "[bold cyan]Assistant:[/bold cyan]", - classes="assistant-message", - markup=True, - ) - self.mount(header) - - # Create streaming widget - self._current_stream_widget = Static("", classes="assistant-message", markup=False) - self._stream_buffer = "" - self.mount(self._current_stream_widget) - self.call_after_refresh(self.scroll_end) - - def stream_token(self, token: str) -> None: - """Append a token to the current streaming message. - - Args: - token: Text token to append - """ - if self._current_stream_widget is not None: - self._stream_buffer += token - self._current_stream_widget.update(self._stream_buffer) - self.call_after_refresh(self.scroll_end) - - def end_assistant_stream(self) -> None: - """Finalize the streaming message and convert to Markdown.""" - if self._current_stream_widget is not None: - # Remove the plain text widget - self._current_stream_widget.remove() - - # Add a proper Markdown widget with the full content - if self._stream_buffer.strip(): - content = Markdown(self._stream_buffer) - content.add_class("assistant-message") - self.mount(content) - - self._current_stream_widget = None - self._stream_buffer = "" - self.call_after_refresh(self.scroll_end) - - def add_thinking_block(self, content: str) -> None: - """Add a collapsible thinking/reasoning block. - - Args: - content: The thinking content to display - """ - thinking_block = Collapsible( - title="━━━ Thinking ━━━", - collapsed=False, - ) - thinking_block.add_class("thinking-block") - - # Add the thinking content - thinking_content = Static(f"[dim]{content}[/dim]", markup=True) - - # Mount the collapsible, then add content to it - self.mount(thinking_block) - thinking_block.mount(thinking_content) - self.call_after_refresh(self.scroll_end) - - def add_code_edit(self, file_path: str, language: str, content: str, action: str) -> None: - """Add a code edit display panel. - - Args: - file_path: Path to the file being edited - action: Type of action - 'create', 'edit', or 'delete' - language: Programming language for syntax highlighting - content: Code content - """ - # Choose icon based on action - if action == "create": - icon = "+" - elif action == "delete": - icon = "×" - else: - icon = "✎" - - # Create collapsible panel - code_panel = Collapsible( - title=f"{icon} {action.capitalize()} {file_path}", - collapsed=True, - ) - code_panel.add_class("code-edit-panel") - - # Create syntax-highlighted code - syntax = Syntax(content, language, theme="monokai", line_numbers=True) - - # Render syntax to string using Rich console - console = Console(file=StringIO(), force_terminal=True, width=100) - console.print(syntax) - rendered = console.file.getvalue() - - code_content = Static(rendered, markup=False) - - # Mount the collapsible, then add content - self.mount(code_panel) - code_panel.mount(code_content) - self.call_after_refresh(self.scroll_end) - - def add_sage_summary(self, run_id: int, exit_code: int, duration_ms: int, tokens_saved: int) -> None: - """Add SAGE execution summary footer. - - Args: - run_id: SAGE run ID - exit_code: Command exit code - duration_ms: Execution duration in milliseconds - tokens_saved: Number of tokens saved by compression - """ - # Choose color based on exit code - if exit_code == 0: - style = "dim green" - else: - style = "dim red" - - summary_text = ( - f"[{style}][sage] saved run #{run_id} exit={exit_code} " - f"time={duration_ms}ms | tokens saved: {tokens_saved:,}[/{style}]" - ) - - summary = Static(summary_text, classes="sage-summary", markup=True) - self.mount(summary) - self.call_after_refresh(self.scroll_end) - - def add_error(self, message: str) -> None: - """Add an error message display. - - Args: - message: Error message text - """ - error_widget = Static( - f"[bold red]Error:[/bold red]\n{message}", - classes="error-message", - markup=True, - ) - self.mount(error_widget) - self.call_after_refresh(self.scroll_end) diff --git a/src/sage/tui/widgets/sidebar.py b/src/sage/tui/widgets/sidebar.py deleted file mode 100644 index c2f5e08..0000000 --- a/src/sage/tui/widgets/sidebar.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Sidebar widget for session management.""" -from __future__ import annotations - -from textual.app import ComposeResult -from textual.containers import Container, Vertical -from textual.widgets import Static, Button, ListView, ListItem, Label -from textual.message import Message -from textual.binding import Binding - - -class SessionSelected(Message): - """Posted when a session is selected.""" - - def __init__(self, session_id: str) -> None: - super().__init__() - self.session_id = session_id - - -class NewSessionRequested(Message): - """Posted when the user requests a new session.""" - - -class SessionDeleteRequested(Message): - """Posted when the user requests to delete a session.""" - - def __init__(self, session_id: str) -> None: - super().__init__() - self.session_id = session_id - - -class SettingsRequested(Message): - """Posted when user clicks settings.""" - - -class Sidebar(Container): - """Sidebar panel for session management.""" - - DEFAULT_CSS = """""" - - BINDINGS = [ - Binding("delete", "delete_session", "Delete Session", show=False), - ] - - def __init__(self, project_name: str = "SAGE") -> None: - super().__init__() - self.project_name = project_name - self._sessions: list[dict] = [] - self._active_session_id: str | None = None - - def compose(self) -> ComposeResult: - """Compose the sidebar layout.""" - with Vertical(): - yield Static(f"⚡ {self.project_name}", classes="header") - yield Button("+ New Chat", id="new-chat-btn") - yield ListView(id="session-list") - yield Button("⚙ Settings", id="settings-btn") - - def set_sessions(self, sessions: list[dict], active_session_id: str | None = None): - """Update the session list. - - Args: - sessions: List of session dicts with keys: id, title, updated_at, preview - active_session_id: ID of the currently active session - """ - self._sessions = sessions - self._active_session_id = active_session_id - self._refresh_list() - - def set_active_session(self, session_id: str): - """Mark a session as active.""" - self._active_session_id = session_id - self._refresh_list() - - def _refresh_list(self): - """Refresh the session list view.""" - list_view = self.query_one("#session-list", ListView) - - # Remove all existing children - for child in list(list_view.children): - child.remove() - - if not self._sessions: - list_view.mount(Static("No sessions yet.\nClick 'New Chat' to start!", classes="empty-state")) - return - - for session in self._sessions: - session_id = session["id"] - title = session.get("title", "Untitled Chat")[:40] - date = session.get("updated_at", "") - date_short = date.split("T")[0] if "T" in date else date - - label_text = f"[b]{title}[/b]\n[dim]{date_short}[/dim]" - - item = ListItem(Label(label_text, markup=True)) - item.session_id = session_id - - if session_id == self._active_session_id: - item.add_class("--active") - - list_view.mount(item) - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button press.""" - if event.button.id == "new-chat-btn": - self.post_message(NewSessionRequested()) - elif event.button.id == "settings-btn": - self.post_message(SettingsRequested()) - - def on_list_view_selected(self, event: ListView.Selected) -> None: - """Handle session selection.""" - session_id = getattr(event.item, "session_id", None) - if session_id: - self.post_message(SessionSelected(session_id)) - - def action_delete_session(self) -> None: - """Delete the currently selected session.""" - list_view = self.query_one("#session-list", ListView) - if list_view.index is not None and list_view.highlighted_child: - session_id = getattr(list_view.highlighted_child, "session_id", None) - if session_id: - self.post_message(SessionDeleteRequested(session_id)) diff --git a/src/sage/tui/widgets/status_bar.py b/src/sage/tui/widgets/status_bar.py deleted file mode 100644 index e568cbf..0000000 --- a/src/sage/tui/widgets/status_bar.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Status bar widget for SAGE TUI.""" - -from textual.widgets import Static - - -class StatusBar(Static): - """Footer status bar showing model, session, and token info.""" - - DEFAULT_CSS = """""" - - def __init__(self) -> None: - super().__init__() - self.model = "" - self.session_name = "New Chat" - self.tokens = 0 - self.streaming = False - self.daemon_status = "unknown" - self._update_display() - - def _update_display(self) -> None: - """Update the status bar text.""" - parts = ["SAGE TUI"] - - # Model - parts.append(f"Model: {self.model}") - - # Session - parts.append(f"Session: {self.session_name}") - - # Tokens - parts.append(f"Tokens: {self.tokens}") - - # Streaming indicator - if self.streaming: - parts.append("⟳ Streaming...") - - # ML daemon status - if self.daemon_status == "active": - parts.append("ML: active") - elif self.daemon_status == "sleeping": - parts.append("ML: sleeping") - elif self.daemon_status == "off": - parts.append("ML: off") - - self.update(" | ".join(parts)) - - def set_model(self, model: str) -> None: - """Update the model name.""" - self.model = model - self._update_display() - - def set_session(self, session_name: str) -> None: - """Update the session name.""" - self.session_name = session_name - self._update_display() - - def set_tokens(self, tokens: int) -> None: - """Update the token count.""" - self.tokens = tokens - self._update_display() - - def set_streaming(self, active: bool) -> None: - """Update the streaming indicator. - - Args: - active: True if currently streaming, False otherwise - """ - self.streaming = active - self._update_display() - - def set_daemon_status(self, status: str) -> None: - """Update the ML daemon status. - - Args: - status: Status string - 'active', 'sleeping', 'off', or 'unknown' - """ - self.daemon_status = status - self._update_display() diff --git a/src/sage/tui/widgets/tool_panel.py b/src/sage/tui/widgets/tool_panel.py deleted file mode 100644 index a8ab4f8..0000000 --- a/src/sage/tui/widgets/tool_panel.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Collapsible tool call panel widget for SAGE TUI.""" - -from textual.widgets import Collapsible, Static -from rich.syntax import Syntax - - -class ToolPanel(Collapsible): - """A collapsible panel showing tool call details.""" - - DEFAULT_CSS = """ - ToolPanel { - margin: 0 1; - border: solid $primary-lighten-1; - } - - ToolPanel > Static { - padding: 1; - } - """ - - def __init__( - self, - name: str, - input_text: str, - output_text: str, - duration_ms: int, - status: str, - ) -> None: - """Initialize the tool panel. - - Args: - name: Tool name - input_text: Tool input (arguments) - output_text: Tool output - duration_ms: Duration in milliseconds - status: Status - 'running', 'success', or 'error' - """ - # Choose status icon - if status == "running": - icon = "⏳" - elif status == "success": - icon = "✓" - else: - icon = "✗" - - # Build title - title = f"{icon} {name} ({duration_ms}ms)" - - super().__init__(title=title, collapsed=True) - - # Build content - content_parts = [] - - if input_text.strip(): - content_parts.append("[bold cyan]Input:[/bold cyan]") - content_parts.append("") - # Render as code - content_parts.append(f"[dim]{input_text}[/dim]") - content_parts.append("") - - if output_text.strip(): - content_parts.append("[bold green]Output:[/bold green]") - content_parts.append("") - # Render as code - content_parts.append(f"[dim]{output_text}[/dim]") - - content = "\n".join(content_parts) - - # Add content widget - self._content_widget = Static(content, markup=True) - - def compose(self): - """Compose the collapsible content.""" - yield self._content_widget diff --git a/test_tui_startup.py b/test_tui_startup.py deleted file mode 100644 index 0b31645..0000000 --- a/test_tui_startup.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Test TUI startup without actually running it.""" -import sys -import os -sys.path.insert(0, "src") - -# Set a dummy API key for testing -os.environ["ANTHROPIC_API_KEY"] = "test-key-for-startup-check" - -try: - from sage.tui.app import SAGETUIApp - - # Try to instantiate the app - app = SAGETUIApp() - - print("✓ App instantiated successfully") - print(f"✓ Project: {app._project_name}") - print(f"✓ Project path: {app._project_path}") - print(f"✓ Session store initialized: {app._store is not None}") - print(f"✓ Tools registry initialized: {app._tools is not None}") - print(f"✓ Context manager initialized: {app._context is not None}") - - # Check components - print(f"✓ Current session ID: {app._current_session_id}") - - # Try to get sessions - sessions = app._store.list_sessions(limit=5) - print(f"✓ Found {len(sessions)} existing sessions") - - print("\nAll startup checks passed! The TUI should launch successfully.") - print("To actually run it: sage tui") - -except Exception as e: - print(f"✗ Error during startup: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/tests/test_public_package_metadata.py b/tests/test_public_package_metadata.py index 99f3ce3..de152da 100644 --- a/tests/test_public_package_metadata.py +++ b/tests/test_public_package_metadata.py @@ -15,7 +15,7 @@ def test_public_distribution_metadata(): assert "Development Status :: 4 - Beta" in data["project"]["classifiers"] assert any(dep.startswith("keyring") for dep in data["project"]["dependencies"]) excluded = set(data["tool"]["setuptools"]["packages"]["find"]["exclude"]) - assert {"sage.gui", "sage.dashboard", "sage.tui"} <= excluded + assert {"sage.dashboard"} <= excluded def test_release_versions_are_synchronized(): @@ -30,13 +30,12 @@ def test_legacy_setup_has_no_install_side_effects(): assert "PostInstallCommand" not in setup_text -def test_release_workflows_parse_and_publish_pypi_before_npm(): +def test_release_workflows_parse_and_publish_python_wrapper(): for path in (".github/workflows/ci.yml", ".github/workflows/pypi-publish.yml"): assert yaml.safe_load(pathlib.Path(path).read_text(encoding="utf-8")) workflow = pathlib.Path(".github/workflows/pypi-publish.yml").read_text( encoding="utf-8" ) - assert "publish-npm:" in workflow - assert "needs: publish" in workflow - assert "scripts/wait_for_pypi.py" in workflow + assert "pypa/gh-action-pypi-publish@release/v1" in workflow + assert "publish-npm:" not in workflow diff --git a/tests/test_public_release_docs.py b/tests/test_public_release_docs.py index 13ac724..3622d53 100644 --- a/tests/test_public_release_docs.py +++ b/tests/test_public_release_docs.py @@ -18,7 +18,6 @@ def test_public_release_docs_and_assets_exist(): "docs/assets/demo-sage-run.gif", "docs/assets/demo-sage-savings.gif", "docs/assets/demo-github-bot.gif", - "docs/assets/team-dashboard-preview.png", "docs/assets/sage-live-dashboard.png", ] @@ -41,9 +40,6 @@ def test_readme_public_positioning(): assert "Raw logs" in readme or "raw logs" in readme.lower() assert "## Known Limitations" in readme assert "raw.githubusercontent.com/PsYcGoD/sage/main/docs/assets/sage-run.svg" in readme - assert "## Team View Preview - Enterprise Only" in readme - assert "docs/assets/team-dashboard-preview.png" in readme - assert "Team View is not part of the free public CLI package" in readme hidden_team_endpoint = "/api/v1/" + "team" assert hidden_team_endpoint not in readme removed_command = "sage " + "pric" + "ing" diff --git a/tests/test_runner_desktop.py b/tests/test_runner_cwd.py similarity index 98% rename from tests/test_runner_desktop.py rename to tests/test_runner_cwd.py index 10dc36b..f846ae6 100644 --- a/tests/test_runner_desktop.py +++ b/tests/test_runner_cwd.py @@ -14,7 +14,7 @@ def _quiet_runner(monkeypatch, tmp_path) -> None: def test_explicit_cwd_supports_desktop_hosts(monkeypatch, tmp_path): _quiet_runner(monkeypatch, tmp_path) - workspace = tmp_path / "electron workspace" + workspace = tmp_path / "host workspace" workspace.mkdir() exit_code = run_command(