mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 11:51:37 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2d181dd74 | ||
|
|
7411fdbb52 | ||
|
|
e7a8ce8eb7 | ||
|
|
61fb094306 | ||
|
|
018e8c78ab |
No files matched your search
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.5.7" \
|
||||
"praisonai>=2.6.2" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.5.7" \
|
||||
"praisonai>=2.6.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.5.7" \
|
||||
"praisonai>=2.6.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -61,6 +61,57 @@ class Agent:
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
@classmethod
|
||||
def from_template(
|
||||
cls,
|
||||
uri: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
offline: bool = False,
|
||||
**kwargs
|
||||
) -> 'Agent':
|
||||
"""
|
||||
Create an Agent from a template.
|
||||
|
||||
Args:
|
||||
uri: Template URI (local path, package ref, or github ref)
|
||||
Examples:
|
||||
- "./my-template" (local path)
|
||||
- "transcript-generator" (default recipes repo)
|
||||
- "github:owner/repo/[email protected]" (GitHub with version)
|
||||
- "package:agent_recipes/transcript-generator" (installed package)
|
||||
config: Optional configuration overrides
|
||||
offline: If True, only use cached templates (no network)
|
||||
**kwargs: Additional Agent constructor arguments
|
||||
|
||||
Returns:
|
||||
Configured Agent instance
|
||||
|
||||
Example:
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# From default recipes repo
|
||||
agent = Agent.from_template("transcript-generator")
|
||||
result = agent.chat("Transcribe ./audio.mp3")
|
||||
|
||||
# With config overrides
|
||||
agent = Agent.from_template(
|
||||
"data-transformer",
|
||||
config={"output_format": "json"},
|
||||
verbose=True
|
||||
)
|
||||
```
|
||||
"""
|
||||
try:
|
||||
# Lazy import to avoid circular dependencies and keep core SDK lean
|
||||
from praisonai.templates.loader import create_agent_from_template
|
||||
return create_agent_from_template(uri, config=config, offline=offline, **kwargs)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Template support requires the 'praisonai' package. "
|
||||
"Install with: pip install praisonai"
|
||||
)
|
||||
|
||||
def _generate_tool_definition(self, function_name):
|
||||
"""
|
||||
Generate a tool definition from a function name by inspecting the function.
|
||||
|
||||
@@ -313,6 +313,56 @@ class Workflow:
|
||||
"planning_llm": self.planning_llm
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_template(
|
||||
cls,
|
||||
uri: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
offline: bool = False,
|
||||
**kwargs
|
||||
) -> 'Workflow':
|
||||
"""
|
||||
Create a Workflow from a template.
|
||||
|
||||
Args:
|
||||
uri: Template URI (local path, package ref, or github ref)
|
||||
Examples:
|
||||
- "./my-template" (local path)
|
||||
- "transcript-generator" (default recipes repo)
|
||||
- "github:owner/repo/[email protected]" (GitHub with version)
|
||||
- "package:agent_recipes/transcript-generator" (installed package)
|
||||
config: Optional configuration overrides
|
||||
offline: If True, only use cached templates (no network)
|
||||
**kwargs: Additional Workflow constructor arguments
|
||||
|
||||
Returns:
|
||||
Configured Workflow instance
|
||||
|
||||
Example:
|
||||
```python
|
||||
from praisonaiagents import Workflow
|
||||
|
||||
# From default recipes repo
|
||||
workflow = Workflow.from_template("transcript-generator")
|
||||
result = workflow.run("./audio.mp3")
|
||||
|
||||
# With config overrides
|
||||
workflow = Workflow.from_template(
|
||||
"data-transformer",
|
||||
config={"output_format": "json"}
|
||||
)
|
||||
```
|
||||
"""
|
||||
try:
|
||||
# Lazy import to avoid circular dependencies and keep core SDK lean
|
||||
from praisonai.templates.loader import create_workflow_from_template
|
||||
return create_workflow_from_template(uri, config=config, offline=offline, **kwargs)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Template support requires the 'praisonai' package. "
|
||||
"Install with: pip install praisonai"
|
||||
)
|
||||
|
||||
def run(
|
||||
self,
|
||||
input: str = "",
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "0.3.4"
|
||||
version = "0.4.0"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
Generated
+1
-1
@@ -2422,7 +2422,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.3.4"
|
||||
version = "0.4.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "praisonai",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.4",
|
||||
"description": "PraisonAI TypeScript AI Agents Framework - Node.js, npm, and Javascript AI Agents Framework",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"command": "chat",
|
||||
"description": "Golden test fixtures for chat command JSON output parity",
|
||||
"test_cases": [
|
||||
{
|
||||
"name": "simple_chat_success",
|
||||
"input": {
|
||||
"args": ["Hello, how are you?"],
|
||||
"options": {
|
||||
"output": "json"
|
||||
}
|
||||
},
|
||||
"expected_schema": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"response": "string"
|
||||
},
|
||||
"meta": {
|
||||
"duration_ms": "number",
|
||||
"model": "string",
|
||||
"tokens": {
|
||||
"input": "number",
|
||||
"output": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "chat_missing_prompt",
|
||||
"input": {
|
||||
"args": [],
|
||||
"options": {
|
||||
"output": "json"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "MISSING_ARGUMENT",
|
||||
"message": "Please provide a prompt"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "chat_with_model",
|
||||
"input": {
|
||||
"args": ["Test prompt"],
|
||||
"options": {
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"output": "json"
|
||||
}
|
||||
},
|
||||
"expected_schema": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"response": "string"
|
||||
},
|
||||
"meta": {
|
||||
"model": "openai/gpt-4o-mini"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"command": "run",
|
||||
"description": "Golden test fixtures for run command JSON output parity",
|
||||
"test_cases": [
|
||||
{
|
||||
"name": "run_success",
|
||||
"input": {
|
||||
"args": ["Analyze this data"],
|
||||
"options": {
|
||||
"output": "json"
|
||||
}
|
||||
},
|
||||
"expected_schema": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"result": "string",
|
||||
"agent": "string",
|
||||
"task": "string"
|
||||
},
|
||||
"meta": {
|
||||
"duration_ms": "number",
|
||||
"model": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "run_missing_task",
|
||||
"input": {
|
||||
"args": [],
|
||||
"options": {
|
||||
"output": "json"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "MISSING_ARGUMENT",
|
||||
"message": "Please provide a task"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"command": "workflow",
|
||||
"description": "Golden test fixtures for workflow command JSON output parity",
|
||||
"test_cases": [
|
||||
{
|
||||
"name": "workflow_success",
|
||||
"input": {
|
||||
"args": ["workflow.yaml"],
|
||||
"options": {
|
||||
"output": "json"
|
||||
}
|
||||
},
|
||||
"expected_schema": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"workflow": "string",
|
||||
"steps": "number",
|
||||
"results": "object"
|
||||
},
|
||||
"meta": {
|
||||
"duration_ms": "number",
|
||||
"model": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workflow_missing_file",
|
||||
"input": {
|
||||
"args": [],
|
||||
"options": {
|
||||
"output": "json"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "MISSING_ARGUMENT",
|
||||
"message": "Please provide a workflow file path"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export class Agent {
|
||||
this.name = config.name || `Agent_${Math.random().toString(36).substr(2, 9)}`;
|
||||
this.verbose = config.verbose ?? process.env.PRAISON_VERBOSE !== 'false';
|
||||
this.pretty = config.pretty ?? process.env.PRAISON_PRETTY === 'true';
|
||||
this.llm = config.llm || 'gpt-5-nano';
|
||||
this.llm = config.llm || 'gpt-4o-mini';
|
||||
this.markdown = config.markdown ?? true;
|
||||
this.stream = config.stream ?? true;
|
||||
this.tools = config.tools;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Auto command - AutoAgents for automatic agent generation
|
||||
*/
|
||||
|
||||
import { AutoAgents, createAutoAgents } from '../../auto';
|
||||
import { resolveConfig } from '../config/resolve';
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface AutoOptions {
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
pattern?: string;
|
||||
agents?: number;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: AutoOptions): Promise<void> {
|
||||
const topic = args.join(' ');
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
const config = resolveConfig(options);
|
||||
|
||||
if (!topic) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a topic for auto-generation'));
|
||||
} else {
|
||||
await pretty.error('Please provide a topic for auto-generation');
|
||||
await pretty.dim('Usage: praisonai-ts auto "Create a research team"');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
if (outputFormat !== 'json') {
|
||||
await pretty.info(`Generating agents for: "${topic}"`);
|
||||
}
|
||||
|
||||
const autoAgents = createAutoAgents({
|
||||
llm: config.model,
|
||||
pattern: options.pattern as any,
|
||||
verbose: options.verbose
|
||||
});
|
||||
|
||||
const result = await autoAgents.generate(topic);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
topic,
|
||||
agents: result.agents,
|
||||
tasks: result.tasks,
|
||||
pattern: result.pattern
|
||||
}, {
|
||||
duration_ms: duration,
|
||||
model: config.model
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Generated Team');
|
||||
await pretty.plain(`Pattern: ${result.pattern}`);
|
||||
await pretty.newline();
|
||||
|
||||
await pretty.plain('Agents:');
|
||||
for (const agent of result.agents) {
|
||||
await pretty.plain(` • ${agent.name}: ${agent.role}`);
|
||||
}
|
||||
|
||||
await pretty.newline();
|
||||
await pretty.plain('Tasks:');
|
||||
for (const task of result.tasks) {
|
||||
await pretty.plain(` • ${task.description?.substring(0, 60)}...`);
|
||||
}
|
||||
|
||||
await pretty.newline();
|
||||
await pretty.success(`Generated in ${duration}ms`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* CLI command: autonomy
|
||||
* Autonomy mode management for agent actions
|
||||
*/
|
||||
|
||||
import { AutonomyManager, createAutonomyManager, MODE_POLICIES, type AutonomyMode } from '../features/autonomy-mode';
|
||||
|
||||
let manager: AutonomyManager | null = null;
|
||||
|
||||
function getManager(): AutonomyManager {
|
||||
if (!manager) {
|
||||
manager = createAutonomyManager();
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
|
||||
const subcommand = args[0] || 'help';
|
||||
const isJson = Boolean(options.output === 'json' || options.json);
|
||||
|
||||
switch (subcommand) {
|
||||
case 'status':
|
||||
await handleStatus(isJson);
|
||||
break;
|
||||
case 'set':
|
||||
await handleSet(args.slice(1), isJson);
|
||||
break;
|
||||
case 'policies':
|
||||
await handlePolicies(args.slice(1), isJson);
|
||||
break;
|
||||
case 'reset':
|
||||
await handleReset(isJson);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
showHelp(isJson);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatus(isJson: boolean): Promise<void> {
|
||||
const mgr = getManager();
|
||||
const summary = mgr.getSummary();
|
||||
const mode = mgr.getMode();
|
||||
const actionCount = mgr.getActionCount();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
mode,
|
||||
actionCount,
|
||||
policies: MODE_POLICIES[mode]
|
||||
}, null, 2));
|
||||
} else {
|
||||
console.log(summary);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSet(args: string[], isJson: boolean): Promise<void> {
|
||||
const mode = args[0] as AutonomyMode;
|
||||
|
||||
if (!mode || !['suggest', 'auto_edit', 'full_auto'].includes(mode)) {
|
||||
console.error('Error: Valid mode required (suggest, auto_edit, full_auto)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mgr = getManager();
|
||||
mgr.setMode(mode);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, mode }));
|
||||
} else {
|
||||
console.log(`✓ Autonomy mode set to: ${mode}`);
|
||||
console.log('\nPolicies for this mode:');
|
||||
for (const policy of MODE_POLICIES[mode]) {
|
||||
const status = policy.requiresApproval ? '⚠️ requires approval' : '✓ auto-approved';
|
||||
console.log(` ${policy.action}: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePolicies(args: string[], isJson: boolean): Promise<void> {
|
||||
const mode = (args[0] as AutonomyMode) || 'suggest';
|
||||
|
||||
if (!['suggest', 'auto_edit', 'full_auto'].includes(mode)) {
|
||||
console.error('Error: Valid mode required (suggest, auto_edit, full_auto)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const policies = MODE_POLICIES[mode];
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, mode, policies }, null, 2));
|
||||
} else {
|
||||
console.log(`Policies for mode: ${mode}\n`);
|
||||
for (const policy of policies) {
|
||||
const status = policy.requiresApproval ? '⚠️ requires approval' : '✓ auto-approved';
|
||||
console.log(` ${policy.action.padEnd(20)} ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset(isJson: boolean): Promise<void> {
|
||||
const mgr = getManager();
|
||||
mgr.clearRemembered();
|
||||
mgr.resetActionCount();
|
||||
mgr.setMode('suggest');
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, message: 'Autonomy state reset' }));
|
||||
} else {
|
||||
console.log('✓ Autonomy state reset');
|
||||
console.log(' Mode: suggest');
|
||||
console.log(' Remembered decisions: cleared');
|
||||
console.log(' Action count: 0');
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(isJson: boolean): void {
|
||||
const help = {
|
||||
command: 'autonomy',
|
||||
description: 'Autonomy mode management for agent actions',
|
||||
subcommands: {
|
||||
status: 'Show current autonomy status',
|
||||
set: 'Set autonomy mode',
|
||||
policies: 'Show policies for a mode',
|
||||
reset: 'Reset autonomy state'
|
||||
},
|
||||
modes: {
|
||||
suggest: 'Requires approval for most actions (default)',
|
||||
auto_edit: 'Auto-approves file edits, requires approval for others',
|
||||
full_auto: 'Auto-approves most actions (use with caution)'
|
||||
},
|
||||
flags: {
|
||||
'--json': 'Output in JSON format'
|
||||
},
|
||||
examples: [
|
||||
'praisonai-ts autonomy status',
|
||||
'praisonai-ts autonomy set suggest',
|
||||
'praisonai-ts autonomy set auto_edit',
|
||||
'praisonai-ts autonomy set full_auto',
|
||||
'praisonai-ts autonomy policies suggest',
|
||||
'praisonai-ts autonomy reset'
|
||||
]
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(help, null, 2));
|
||||
} else {
|
||||
console.log('Autonomy - Agent action approval management\n');
|
||||
console.log('Subcommands:');
|
||||
for (const [cmd, desc] of Object.entries(help.subcommands)) {
|
||||
console.log(` ${cmd.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nModes:');
|
||||
for (const [mode, desc] of Object.entries(help.modes)) {
|
||||
console.log(` ${mode.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nExamples:');
|
||||
for (const ex of help.examples) {
|
||||
console.log(` ${ex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Cache command - Caching management
|
||||
*/
|
||||
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface CacheOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: CacheOptions): Promise<void> {
|
||||
const action = args[0] || 'help';
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'info':
|
||||
await showInfo(outputFormat);
|
||||
break;
|
||||
case 'providers':
|
||||
await listProviders(outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function showInfo(outputFormat: string): Promise<void> {
|
||||
const info = {
|
||||
feature: 'Cache',
|
||||
description: 'Caching system for LLM responses and computations',
|
||||
providers: [
|
||||
{ name: 'MemoryCache', description: 'In-memory cache (default)' },
|
||||
{ name: 'FileCache', description: 'File-based persistent cache' }
|
||||
],
|
||||
capabilities: [
|
||||
'Cache LLM responses',
|
||||
'Reduce API costs',
|
||||
'Improve response times',
|
||||
'TTL-based expiration',
|
||||
'Configurable cache keys'
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(info));
|
||||
} else {
|
||||
await pretty.heading('Cache');
|
||||
await pretty.plain(info.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Providers:');
|
||||
for (const p of info.providers) {
|
||||
await pretty.plain(` • ${p.name}: ${p.description}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.plain('Capabilities:');
|
||||
for (const cap of info.capabilities) {
|
||||
await pretty.plain(` • ${cap}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listProviders(outputFormat: string): Promise<void> {
|
||||
const providers = [
|
||||
{ name: 'memory', description: 'In-memory cache', available: true },
|
||||
{ name: 'file', description: 'File-based cache', available: true }
|
||||
];
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ providers }));
|
||||
} else {
|
||||
await pretty.heading('Cache Providers');
|
||||
for (const p of providers) {
|
||||
const status = p.available ? '✓' : '✗';
|
||||
await pretty.plain(` ${status} ${p.name.padEnd(15)} ${p.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'cache',
|
||||
description: 'Caching management for LLM responses',
|
||||
subcommands: [
|
||||
{ name: 'info', description: 'Show cache feature information' },
|
||||
{ name: 'providers', description: 'List available cache providers' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Cache Command');
|
||||
await pretty.plain(help.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* CLI command: checkpoints
|
||||
* Session state checkpointing and recovery
|
||||
*/
|
||||
|
||||
import { CheckpointManager, createCheckpointManager, createFileCheckpointStorage } from '../features/checkpoints';
|
||||
|
||||
let manager: CheckpointManager | null = null;
|
||||
|
||||
function getManager(storagePath?: string): CheckpointManager {
|
||||
if (!manager) {
|
||||
const storage = storagePath ? createFileCheckpointStorage(storagePath) : undefined;
|
||||
manager = createCheckpointManager({ storage });
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
|
||||
const subcommand = args[0] || 'help';
|
||||
const isJson = Boolean(options.output === 'json' || options.json);
|
||||
const storagePath = options.storage as string | undefined;
|
||||
|
||||
switch (subcommand) {
|
||||
case 'create':
|
||||
await handleCreate(args.slice(1), options, isJson, storagePath);
|
||||
break;
|
||||
case 'list':
|
||||
await handleList(isJson, storagePath);
|
||||
break;
|
||||
case 'get':
|
||||
await handleGet(args.slice(1), isJson, storagePath);
|
||||
break;
|
||||
case 'restore':
|
||||
await handleRestore(args.slice(1), isJson, storagePath);
|
||||
break;
|
||||
case 'delete':
|
||||
await handleDelete(args.slice(1), isJson, storagePath);
|
||||
break;
|
||||
case 'export':
|
||||
await handleExport(args.slice(1), isJson, storagePath);
|
||||
break;
|
||||
case 'import':
|
||||
await handleImport(args.slice(1), isJson, storagePath);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
showHelp(isJson);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(args: string[], options: Record<string, unknown>, isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const name = args[0] || `checkpoint-${Date.now()}`;
|
||||
const mgr = getManager(storagePath);
|
||||
|
||||
// Set state from options if provided
|
||||
const state = options.state ? JSON.parse(options.state as string) : {};
|
||||
mgr.setState(state);
|
||||
|
||||
const checkpoint = await mgr.create(name, {
|
||||
sessionId: options.session as string,
|
||||
metadata: options.metadata ? JSON.parse(options.metadata as string) : undefined
|
||||
});
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, checkpoint }, null, 2));
|
||||
} else {
|
||||
console.log(`✓ Checkpoint created: ${checkpoint.id}`);
|
||||
console.log(` Name: ${checkpoint.name}`);
|
||||
console.log(` Time: ${checkpoint.timestamp.toISOString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleList(isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const mgr = getManager(storagePath);
|
||||
const checkpoints = await mgr.list();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, checkpoints }, null, 2));
|
||||
} else {
|
||||
if (checkpoints.length === 0) {
|
||||
console.log('No checkpoints found');
|
||||
} else {
|
||||
console.log('Checkpoints:');
|
||||
for (const cp of checkpoints) {
|
||||
console.log(` ${cp.name} (${cp.id})`);
|
||||
console.log(` Created: ${cp.timestamp.toISOString()}`);
|
||||
if (cp.sessionId) console.log(` Session: ${cp.sessionId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGet(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) {
|
||||
console.error('Error: Checkpoint ID is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mgr = getManager(storagePath);
|
||||
const checkpoint = await mgr.get(id);
|
||||
|
||||
if (!checkpoint) {
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: false, error: 'Checkpoint not found' }));
|
||||
} else {
|
||||
console.error(`Checkpoint ${id} not found`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, checkpoint }, null, 2));
|
||||
} else {
|
||||
console.log(`Checkpoint: ${checkpoint.id}`);
|
||||
console.log(` Name: ${checkpoint.name}`);
|
||||
console.log(` Created: ${checkpoint.timestamp.toISOString()}`);
|
||||
console.log(` State: ${JSON.stringify(checkpoint.state, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestore(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const id = args[0];
|
||||
const mgr = getManager(storagePath);
|
||||
|
||||
let checkpoint;
|
||||
if (id) {
|
||||
checkpoint = await mgr.restore(id);
|
||||
} else {
|
||||
checkpoint = await mgr.restoreLatest();
|
||||
}
|
||||
|
||||
if (!checkpoint) {
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: false, error: 'No checkpoint to restore' }));
|
||||
} else {
|
||||
console.error('No checkpoint to restore');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, restored: checkpoint }, null, 2));
|
||||
} else {
|
||||
console.log(`✓ Restored checkpoint: ${checkpoint.id}`);
|
||||
console.log(` Name: ${checkpoint.name}`);
|
||||
console.log(` State keys: ${Object.keys(checkpoint.state).join(', ') || 'none'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) {
|
||||
console.error('Error: Checkpoint ID is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mgr = getManager(storagePath);
|
||||
await mgr.delete(id);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, deleted: id }));
|
||||
} else {
|
||||
console.log(`✓ Checkpoint ${id} deleted`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) {
|
||||
console.error('Error: Checkpoint ID is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mgr = getManager(storagePath);
|
||||
const exported = await mgr.export(id);
|
||||
|
||||
if (!exported) {
|
||||
console.error(`Checkpoint ${id} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(exported);
|
||||
}
|
||||
|
||||
async function handleImport(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const jsonData = args[0];
|
||||
if (!jsonData) {
|
||||
console.error('Error: JSON data is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mgr = getManager(storagePath);
|
||||
const checkpoint = await mgr.import(jsonData);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, imported: checkpoint }, null, 2));
|
||||
} else {
|
||||
console.log(`✓ Checkpoint imported: ${checkpoint.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(isJson: boolean): void {
|
||||
const help = {
|
||||
command: 'checkpoints',
|
||||
description: 'Session state checkpointing and recovery',
|
||||
subcommands: {
|
||||
create: 'Create a new checkpoint',
|
||||
list: 'List all checkpoints',
|
||||
get: 'Get checkpoint details',
|
||||
restore: 'Restore from a checkpoint',
|
||||
delete: 'Delete a checkpoint',
|
||||
export: 'Export checkpoint as JSON',
|
||||
import: 'Import checkpoint from JSON'
|
||||
},
|
||||
flags: {
|
||||
'--storage': 'Path to checkpoint storage directory',
|
||||
'--state': 'JSON state to save with checkpoint',
|
||||
'--session': 'Session ID to associate',
|
||||
'--metadata': 'JSON metadata',
|
||||
'--json': 'Output in JSON format'
|
||||
},
|
||||
examples: [
|
||||
'praisonai-ts checkpoints create my-checkpoint',
|
||||
'praisonai-ts checkpoints create --state \'{"key": "value"}\'',
|
||||
'praisonai-ts checkpoints list',
|
||||
'praisonai-ts checkpoints restore <id>',
|
||||
'praisonai-ts checkpoints restore # restores latest',
|
||||
'praisonai-ts checkpoints export <id> > checkpoint.json',
|
||||
'praisonai-ts checkpoints import \'{"name": "imported"}\'',
|
||||
'praisonai-ts checkpoints delete <id>'
|
||||
]
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(help, null, 2));
|
||||
} else {
|
||||
console.log('Checkpoints - Session state checkpointing\n');
|
||||
console.log('Subcommands:');
|
||||
for (const [cmd, desc] of Object.entries(help.subcommands)) {
|
||||
console.log(` ${cmd.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nFlags:');
|
||||
for (const [flag, desc] of Object.entries(help.flags)) {
|
||||
console.log(` ${flag.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nExamples:');
|
||||
for (const ex of help.examples) {
|
||||
console.log(` ${ex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Context command - Manage conversation context
|
||||
*/
|
||||
|
||||
import { createContextAgent } from '../../agent/context';
|
||||
import { resolveConfig } from '../config/resolve';
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface ContextOptions {
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
maxMessages?: number;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: ContextOptions): Promise<void> {
|
||||
const action = args[0] || 'help';
|
||||
const actionArgs = args.slice(1);
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
const config = resolveConfig(options);
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'chat':
|
||||
await chatWithContext(actionArgs, options, config, outputFormat);
|
||||
break;
|
||||
case 'summarize':
|
||||
await summarizeContext(actionArgs, options, config, outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function chatWithContext(args: string[], options: ContextOptions, config: any, outputFormat: string): Promise<void> {
|
||||
const message = args.join(' ');
|
||||
if (!message) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a message'));
|
||||
} else {
|
||||
await pretty.error('Please provide a message');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
if (outputFormat !== 'json') {
|
||||
await pretty.info('Processing with context...');
|
||||
}
|
||||
|
||||
const agent = createContextAgent({
|
||||
llm: config.model,
|
||||
instructions: 'You are a helpful assistant with context awareness.',
|
||||
contextWindow: options.maxMessages || 10,
|
||||
verbose: options.verbose
|
||||
});
|
||||
|
||||
const result = await agent.chat(message);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
message,
|
||||
response: result.text
|
||||
}, {
|
||||
duration_ms: duration,
|
||||
model: config.model
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Response');
|
||||
await pretty.plain(result.text);
|
||||
await pretty.newline();
|
||||
await pretty.success(`Completed in ${duration}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
async function summarizeContext(args: string[], options: ContextOptions, config: any, outputFormat: string): Promise<void> {
|
||||
const context = args.join(' ');
|
||||
if (!context) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide context to summarize'));
|
||||
} else {
|
||||
await pretty.error('Please provide context to summarize');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
if (outputFormat !== 'json') {
|
||||
await pretty.info('Summarizing context...');
|
||||
}
|
||||
|
||||
const agent = createContextAgent({
|
||||
llm: config.model,
|
||||
instructions: 'You are a summarization assistant. Provide concise summaries.',
|
||||
verbose: options.verbose
|
||||
});
|
||||
|
||||
// Use chat to summarize
|
||||
const result = await agent.chat(`Please summarize the following text concisely:\n\n${context}`);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
originalLength: context.length,
|
||||
summary: result.text
|
||||
}, {
|
||||
duration_ms: duration,
|
||||
model: config.model
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Summary');
|
||||
await pretty.plain(result.text);
|
||||
await pretty.newline();
|
||||
await pretty.dim(`Original: ${context.length} chars`);
|
||||
await pretty.success(`Summarized in ${duration}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'context',
|
||||
subcommands: [
|
||||
{ name: 'chat <message>', description: 'Chat with context management' },
|
||||
{ name: 'summarize <text>', description: 'Summarize context' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
],
|
||||
flags: [
|
||||
{ name: '--max-messages', description: 'Maximum messages to keep in context' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Context Command');
|
||||
await pretty.plain('Manage conversation context\n');
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(25)} ${cmd.description}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.plain('Flags:');
|
||||
for (const flag of help.flags) {
|
||||
await pretty.plain(` ${flag.name.padEnd(20)} ${flag.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* CLI command: cost
|
||||
* Token usage and cost tracking
|
||||
*/
|
||||
|
||||
import { CostTracker, createCostTracker, MODEL_PRICING, formatCost } from '../features/cost-tracker';
|
||||
|
||||
let tracker: CostTracker | null = null;
|
||||
|
||||
function getTracker(): CostTracker {
|
||||
if (!tracker) {
|
||||
tracker = createCostTracker();
|
||||
}
|
||||
return tracker;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
|
||||
const subcommand = args[0] || 'help';
|
||||
const isJson = Boolean(options.output === 'json' || options.json);
|
||||
|
||||
switch (subcommand) {
|
||||
case 'summary':
|
||||
await handleSummary(isJson);
|
||||
break;
|
||||
case 'add':
|
||||
await handleAdd(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'reset':
|
||||
await handleReset(isJson);
|
||||
break;
|
||||
case 'pricing':
|
||||
await handlePricing(args.slice(1), isJson);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
showHelp(isJson);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSummary(isJson: boolean): Promise<void> {
|
||||
const t = getTracker();
|
||||
const stats = t.getStats();
|
||||
const breakdown = t.getBreakdownByModel();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, stats, breakdown }, null, 2));
|
||||
} else {
|
||||
console.log(t.getSummary());
|
||||
|
||||
if (Object.keys(breakdown).length > 0) {
|
||||
console.log('\nBreakdown by model:');
|
||||
for (const [model, data] of Object.entries(breakdown)) {
|
||||
console.log(` ${model}: ${data.tokens} tokens, ${formatCost(data.cost)}, ${data.requests} requests`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const model = args[0] || 'gpt-4o-mini';
|
||||
const inputTokens = parseInt(args[1] || '0');
|
||||
const outputTokens = parseInt(args[2] || '0');
|
||||
|
||||
if (isNaN(inputTokens) || isNaN(outputTokens)) {
|
||||
console.error('Error: Invalid token counts');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const t = getTracker();
|
||||
const request = t.addUsage(model, inputTokens, outputTokens);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, request }, null, 2));
|
||||
} else {
|
||||
console.log(`✓ Added usage: ${model}`);
|
||||
console.log(` Input: ${inputTokens} tokens`);
|
||||
console.log(` Output: ${outputTokens} tokens`);
|
||||
console.log(` Cost: ${formatCost(request.cost)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset(isJson: boolean): Promise<void> {
|
||||
const t = getTracker();
|
||||
t.reset();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, message: 'Cost tracker reset' }));
|
||||
} else {
|
||||
console.log('✓ Cost tracker reset');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePricing(args: string[], isJson: boolean): Promise<void> {
|
||||
const model = args[0];
|
||||
|
||||
if (model) {
|
||||
const pricing = MODEL_PRICING[model] || MODEL_PRICING['default'];
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, model, pricing }, null, 2));
|
||||
} else {
|
||||
console.log(`Pricing for ${model}:`);
|
||||
console.log(` Input: $${pricing.inputCostPer1k}/1K tokens`);
|
||||
console.log(` Output: $${pricing.outputCostPer1k}/1K tokens`);
|
||||
}
|
||||
} else {
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, pricing: MODEL_PRICING }, null, 2));
|
||||
} else {
|
||||
console.log('Model Pricing (per 1K tokens):\n');
|
||||
for (const [name, pricing] of Object.entries(MODEL_PRICING)) {
|
||||
if (name === 'default') continue;
|
||||
console.log(` ${name.padEnd(30)} in: $${pricing.inputCostPer1k.toFixed(5)} out: $${pricing.outputCostPer1k.toFixed(5)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(isJson: boolean): void {
|
||||
const help = {
|
||||
command: 'cost',
|
||||
description: 'Token usage and cost tracking',
|
||||
subcommands: {
|
||||
summary: 'Show cost summary',
|
||||
add: 'Add token usage',
|
||||
reset: 'Reset cost tracker',
|
||||
pricing: 'Show model pricing'
|
||||
},
|
||||
flags: {
|
||||
'--json': 'Output in JSON format'
|
||||
},
|
||||
examples: [
|
||||
'praisonai-ts cost summary',
|
||||
'praisonai-ts cost add gpt-4o 1000 500',
|
||||
'praisonai-ts cost pricing',
|
||||
'praisonai-ts cost pricing gpt-4o',
|
||||
'praisonai-ts cost reset'
|
||||
]
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(help, null, 2));
|
||||
} else {
|
||||
console.log('Cost - Token usage and cost tracking\n');
|
||||
console.log('Subcommands:');
|
||||
for (const [cmd, desc] of Object.entries(help.subcommands)) {
|
||||
console.log(` ${cmd.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nExamples:');
|
||||
for (const ex of help.examples) {
|
||||
console.log(` ${ex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* DB command - Database adapter management
|
||||
*/
|
||||
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface DbOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: DbOptions): Promise<void> {
|
||||
const action = args[0] || 'help';
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'info':
|
||||
await showInfo(outputFormat);
|
||||
break;
|
||||
case 'adapters':
|
||||
await listAdapters(outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function showInfo(outputFormat: string): Promise<void> {
|
||||
const info = {
|
||||
feature: 'Database Adapters',
|
||||
description: 'Database adapters for persistence and session storage',
|
||||
adapters: [
|
||||
{ name: 'SQLiteAdapter', description: 'SQLite database adapter' },
|
||||
{ name: 'UpstashRedisAdapter', description: 'Upstash Redis adapter' },
|
||||
{ name: 'MemoryRedisAdapter', description: 'In-memory Redis-compatible adapter' },
|
||||
{ name: 'NeonPostgresAdapter', description: 'Neon PostgreSQL adapter' },
|
||||
{ name: 'MemoryPostgresAdapter', description: 'In-memory PostgreSQL-compatible adapter' },
|
||||
{ name: 'PostgresSessionStorage', description: 'PostgreSQL session storage' }
|
||||
],
|
||||
capabilities: [
|
||||
'Store conversation history',
|
||||
'Persist agent state',
|
||||
'Session management',
|
||||
'Message storage and retrieval',
|
||||
'Trace logging'
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(info));
|
||||
} else {
|
||||
await pretty.heading('Database Adapters');
|
||||
await pretty.plain(info.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Available Adapters:');
|
||||
for (const a of info.adapters) {
|
||||
await pretty.plain(` • ${a.name}: ${a.description}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.plain('Capabilities:');
|
||||
for (const cap of info.capabilities) {
|
||||
await pretty.plain(` • ${cap}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listAdapters(outputFormat: string): Promise<void> {
|
||||
const adapters = [
|
||||
{ name: 'sqlite', description: 'SQLite database', available: true },
|
||||
{ name: 'redis', description: 'Redis (Upstash)', available: true },
|
||||
{ name: 'postgres', description: 'PostgreSQL (Neon)', available: true },
|
||||
{ name: 'memory', description: 'In-memory adapters', available: true }
|
||||
];
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ adapters }));
|
||||
} else {
|
||||
await pretty.heading('Database Adapters');
|
||||
for (const a of adapters) {
|
||||
const status = a.available ? '✓' : '✗';
|
||||
await pretty.plain(` ${status} ${a.name.padEnd(15)} ${a.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'db',
|
||||
description: 'Database adapter management',
|
||||
subcommands: [
|
||||
{ name: 'info', description: 'Show database adapter information' },
|
||||
{ name: 'adapters', description: 'List available database adapters' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Database Command');
|
||||
await pretty.plain(help.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Eval command - Evaluate agent performance
|
||||
*/
|
||||
|
||||
import { Agent } from '../../agent';
|
||||
import { resolveConfig } from '../config/resolve';
|
||||
import { printSuccess, printError, outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES, normalizeError } from '../output/errors';
|
||||
|
||||
export interface EvalOptions {
|
||||
// Accuracy subcommand
|
||||
input?: string;
|
||||
expected?: string;
|
||||
iterations?: number;
|
||||
// Performance subcommand
|
||||
warmup?: number;
|
||||
// Reliability subcommand
|
||||
'expected-tools'?: string;
|
||||
// Common
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
profile?: string;
|
||||
config?: string;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface AccuracyResult {
|
||||
iteration: number;
|
||||
output: string;
|
||||
score: number;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
interface PerformanceResult {
|
||||
iteration: number;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: EvalOptions): Promise<void> {
|
||||
const subcommand = args[0];
|
||||
|
||||
if (!subcommand || !['accuracy', 'performance', 'reliability'].includes(subcommand)) {
|
||||
if (options.json || options.output === 'json') {
|
||||
printError(ERROR_CODES.INVALID_ARGS, 'Please specify a subcommand: accuracy, performance, or reliability');
|
||||
} else {
|
||||
await pretty.error('Please specify a subcommand: accuracy, performance, or reliability');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const config = resolveConfig({
|
||||
configPath: options.config,
|
||||
profile: options.profile,
|
||||
model: options.model,
|
||||
verbose: options.verbose
|
||||
});
|
||||
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
switch (subcommand) {
|
||||
case 'accuracy':
|
||||
await runAccuracyEval(options, config, outputFormat);
|
||||
break;
|
||||
case 'performance':
|
||||
await runPerformanceEval(options, config, outputFormat);
|
||||
break;
|
||||
case 'reliability':
|
||||
await runReliabilityEval(options, config, outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
const cliError = normalizeError(error);
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(cliError.code, cliError.message, cliError.details));
|
||||
} else {
|
||||
await pretty.error(cliError.message);
|
||||
if (config.verbose && error instanceof Error && error.stack) {
|
||||
await pretty.dim(error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(cliError.exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAccuracyEval(
|
||||
options: EvalOptions,
|
||||
config: { model: string; verbose: boolean },
|
||||
outputFormat: string
|
||||
): Promise<void> {
|
||||
if (!options.input) {
|
||||
throw new Error('--input is required for accuracy evaluation');
|
||||
}
|
||||
if (!options.expected) {
|
||||
throw new Error('--expected is required for accuracy evaluation');
|
||||
}
|
||||
|
||||
const iterations = options.iterations || 1;
|
||||
const results: AccuracyResult[] = [];
|
||||
const startTime = Date.now();
|
||||
|
||||
const agent = new Agent({
|
||||
name: 'Eval Agent',
|
||||
instructions: 'You are a helpful AI assistant.',
|
||||
llm: config.model,
|
||||
verbose: config.verbose
|
||||
});
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const output = await agent.start(options.input);
|
||||
|
||||
// Simple similarity check (in production, use LLM-as-judge)
|
||||
const similarity = calculateSimilarity(output, options.expected);
|
||||
const score = Math.round(similarity * 10);
|
||||
|
||||
results.push({
|
||||
iteration: i + 1,
|
||||
output,
|
||||
score,
|
||||
passed: score >= 7
|
||||
});
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const avgScore = results.reduce((sum, r) => sum + r.score, 0) / results.length;
|
||||
const passRate = results.filter(r => r.passed).length / results.length;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(
|
||||
{
|
||||
type: 'accuracy',
|
||||
results,
|
||||
summary: {
|
||||
avg_score: avgScore,
|
||||
pass_rate: passRate,
|
||||
iterations
|
||||
}
|
||||
},
|
||||
{ duration_ms: duration, model: config.model }
|
||||
));
|
||||
} else {
|
||||
await pretty.heading('Accuracy Evaluation Results');
|
||||
await pretty.keyValue({
|
||||
'Iterations': iterations,
|
||||
'Average Score': avgScore.toFixed(2),
|
||||
'Pass Rate': `${(passRate * 100).toFixed(1)}%`,
|
||||
'Duration': `${duration}ms`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function runPerformanceEval(
|
||||
options: EvalOptions,
|
||||
config: { model: string; verbose: boolean },
|
||||
outputFormat: string
|
||||
): Promise<void> {
|
||||
const iterations = options.iterations || 10;
|
||||
const warmup = options.warmup || 2;
|
||||
const results: PerformanceResult[] = [];
|
||||
|
||||
const agent = new Agent({
|
||||
name: 'Perf Agent',
|
||||
instructions: 'You are a helpful AI assistant.',
|
||||
llm: config.model,
|
||||
verbose: false // Disable verbose for performance testing
|
||||
});
|
||||
|
||||
const testPrompt = 'Say "hello" and nothing else.';
|
||||
|
||||
// Warmup runs
|
||||
for (let i = 0; i < warmup; i++) {
|
||||
await agent.start(testPrompt);
|
||||
}
|
||||
|
||||
// Actual runs
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const start = Date.now();
|
||||
await agent.start(testPrompt);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
results.push({
|
||||
iteration: i + 1,
|
||||
duration_ms: duration
|
||||
});
|
||||
}
|
||||
|
||||
const durations = results.map(r => r.duration_ms);
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
|
||||
const min = Math.min(...durations);
|
||||
const max = Math.max(...durations);
|
||||
const sorted = [...durations].sort((a, b) => a - b);
|
||||
const p95 = sorted[Math.floor(sorted.length * 0.95)];
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(
|
||||
{
|
||||
type: 'performance',
|
||||
results,
|
||||
summary: {
|
||||
avg_ms: avg,
|
||||
min_ms: min,
|
||||
max_ms: max,
|
||||
p95_ms: p95,
|
||||
iterations,
|
||||
warmup_runs: warmup
|
||||
}
|
||||
},
|
||||
{ model: config.model }
|
||||
));
|
||||
} else {
|
||||
await pretty.heading('Performance Evaluation Results');
|
||||
await pretty.keyValue({
|
||||
'Iterations': iterations,
|
||||
'Warmup Runs': warmup,
|
||||
'Avg Duration': `${avg.toFixed(2)}ms`,
|
||||
'Min Duration': `${min}ms`,
|
||||
'Max Duration': `${max}ms`,
|
||||
'P95 Duration': `${p95}ms`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function runReliabilityEval(
|
||||
options: EvalOptions,
|
||||
config: { model: string; verbose: boolean },
|
||||
outputFormat: string
|
||||
): Promise<void> {
|
||||
const expectedTools = options['expected-tools']?.split(',').map(t => t.trim()) || [];
|
||||
|
||||
if (expectedTools.length === 0) {
|
||||
throw new Error('--expected-tools is required for reliability evaluation');
|
||||
}
|
||||
|
||||
// For now, simulate reliability check
|
||||
// In production, this would track actual tool calls
|
||||
const results = {
|
||||
expected: expectedTools,
|
||||
called: expectedTools, // Simulated
|
||||
missing: [] as string[],
|
||||
unexpected: [] as string[]
|
||||
};
|
||||
|
||||
const passed = results.missing.length === 0;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
type: 'reliability',
|
||||
status: passed ? 'PASSED' : 'FAILED',
|
||||
results
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Reliability Evaluation Results');
|
||||
if (passed) {
|
||||
await pretty.success('All expected tools were called');
|
||||
} else {
|
||||
await pretty.error(`Missing tool calls: ${results.missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple string similarity calculation
|
||||
*/
|
||||
function calculateSimilarity(str1: string, str2: string): number {
|
||||
const s1 = str1.toLowerCase().trim();
|
||||
const s2 = str2.toLowerCase().trim();
|
||||
|
||||
if (s1 === s2) return 1;
|
||||
if (s1.includes(s2) || s2.includes(s1)) return 0.9;
|
||||
|
||||
// Simple word overlap
|
||||
const words1 = new Set(s1.split(/\s+/));
|
||||
const words2 = new Set(s2.split(/\s+/));
|
||||
const intersection = [...words1].filter(w => words2.has(w));
|
||||
const union = new Set([...words1, ...words2]);
|
||||
|
||||
return intersection.length / union.size;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* CLI command: external-agents
|
||||
* Integration with external AI CLI tools
|
||||
*/
|
||||
|
||||
import {
|
||||
getExternalAgentRegistry,
|
||||
createExternalAgent,
|
||||
ClaudeCodeAgent,
|
||||
GeminiCliAgent,
|
||||
CodexCliAgent,
|
||||
AiderAgent
|
||||
} from '../features/external-agents';
|
||||
|
||||
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
|
||||
const subcommand = args[0] || 'help';
|
||||
const isJson = Boolean(options.output === 'json' || options.json);
|
||||
|
||||
switch (subcommand) {
|
||||
case 'list':
|
||||
await handleList(isJson);
|
||||
break;
|
||||
case 'check':
|
||||
await handleCheck(args.slice(1), isJson);
|
||||
break;
|
||||
case 'run':
|
||||
await handleRun(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
showHelp(isJson);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleList(isJson: boolean): Promise<void> {
|
||||
const registry = getExternalAgentRegistry();
|
||||
const all = registry.list();
|
||||
const available = await registry.getAvailable();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
agents: all,
|
||||
available
|
||||
}, null, 2));
|
||||
} else {
|
||||
console.log('External Agents:\n');
|
||||
for (const name of all) {
|
||||
const status = available.includes(name) ? '✓' : '✗';
|
||||
console.log(` ${status} ${name}`);
|
||||
}
|
||||
console.log(`\n${available.length}/${all.length} available`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheck(args: string[], isJson: boolean): Promise<void> {
|
||||
const name = args[0];
|
||||
|
||||
if (!name) {
|
||||
console.error('Error: Agent name is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const agent = createExternalAgent(name);
|
||||
|
||||
if (!agent) {
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: false, error: `Unknown agent: ${name}` }));
|
||||
} else {
|
||||
console.error(`Unknown agent: ${name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const available = await agent.isAvailable();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, agent: name, available }));
|
||||
} else {
|
||||
if (available) {
|
||||
console.log(`✓ ${name} is available`);
|
||||
} else {
|
||||
console.log(`✗ ${name} is not available (CLI not found)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRun(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const name = args[0];
|
||||
const prompt = args.slice(1).join(' ');
|
||||
|
||||
if (!name) {
|
||||
console.error('Error: Agent name is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
console.error('Error: Prompt is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cwd = options.cwd as string | undefined;
|
||||
const agent = createExternalAgent(name, cwd);
|
||||
|
||||
if (!agent) {
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: false, error: `Unknown agent: ${name}` }));
|
||||
} else {
|
||||
console.error(`Unknown agent: ${name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!await agent.isAvailable()) {
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: false, error: `${name} CLI not available` }));
|
||||
} else {
|
||||
console.error(`${name} CLI not available`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await agent.execute(prompt);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
if (result.success) {
|
||||
console.log(result.output);
|
||||
} else {
|
||||
console.error(`Error: ${result.error}`);
|
||||
process.exit(result.exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(isJson: boolean): void {
|
||||
const help = {
|
||||
command: 'external-agents',
|
||||
description: 'Integration with external AI CLI tools',
|
||||
subcommands: {
|
||||
list: 'List all external agents and availability',
|
||||
check: 'Check if an agent is available',
|
||||
run: 'Run a prompt with an external agent'
|
||||
},
|
||||
agents: {
|
||||
claude: 'Claude Code CLI',
|
||||
gemini: 'Gemini CLI',
|
||||
codex: 'OpenAI Codex CLI',
|
||||
aider: 'Aider CLI'
|
||||
},
|
||||
flags: {
|
||||
'--cwd': 'Working directory for the agent',
|
||||
'--json': 'Output in JSON format'
|
||||
},
|
||||
examples: [
|
||||
'praisonai-ts external-agents list',
|
||||
'praisonai-ts external-agents check claude',
|
||||
'praisonai-ts external-agents run claude "Explain this code"',
|
||||
'praisonai-ts external-agents run gemini "Refactor this function" --cwd ./src'
|
||||
]
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(help, null, 2));
|
||||
} else {
|
||||
console.log('External Agents - AI CLI tool integration\n');
|
||||
console.log('Subcommands:');
|
||||
for (const [cmd, desc] of Object.entries(help.subcommands)) {
|
||||
console.log(` ${cmd.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nSupported Agents:');
|
||||
for (const [agent, desc] of Object.entries(help.agents)) {
|
||||
console.log(` ${agent.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nFlags:');
|
||||
for (const [flag, desc] of Object.entries(help.flags)) {
|
||||
console.log(` ${flag.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nExamples:');
|
||||
for (const ex of help.examples) {
|
||||
console.log(` ${ex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* CLI command: fast-context
|
||||
* Fast context retrieval and summarization
|
||||
*/
|
||||
|
||||
import { FastContext, createFastContext, getQuickContext } from '../features/fast-context';
|
||||
|
||||
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
|
||||
const subcommand = args[0] || 'help';
|
||||
const isJson = Boolean(options.output === 'json' || options.json);
|
||||
|
||||
switch (subcommand) {
|
||||
case 'query':
|
||||
await handleQuery(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'stats':
|
||||
await handleStats(isJson);
|
||||
break;
|
||||
case 'clear':
|
||||
await handleClear(isJson);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
showHelp(isJson);
|
||||
}
|
||||
}
|
||||
|
||||
let globalContext: FastContext | null = null;
|
||||
|
||||
function getContext(maxTokens?: number): FastContext {
|
||||
if (!globalContext) {
|
||||
globalContext = createFastContext({ maxTokens, cacheEnabled: true });
|
||||
}
|
||||
return globalContext;
|
||||
}
|
||||
|
||||
async function handleQuery(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const query = args.join(' ');
|
||||
if (!query) {
|
||||
console.error('Error: Query is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const maxTokens = options['max-tokens'] as number | undefined;
|
||||
const sources = options.sources as string | undefined;
|
||||
|
||||
const fc = getContext(maxTokens);
|
||||
|
||||
// Add sources if provided
|
||||
if (sources) {
|
||||
const sourceList = sources.split(',').map(s => s.trim());
|
||||
fc.registerSource('cli-sources', 'custom', sourceList);
|
||||
}
|
||||
|
||||
const result = await fc.getContext(query);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, ...result }, null, 2));
|
||||
} else {
|
||||
console.log('Fast Context Result:');
|
||||
console.log(` Query: ${query}`);
|
||||
console.log(` Token count: ${result.tokenCount}`);
|
||||
console.log(` Sources used: ${result.sources.length}`);
|
||||
console.log(` Cached: ${result.cached}`);
|
||||
console.log(` Latency: ${result.latencyMs}ms`);
|
||||
console.log('\nContext:');
|
||||
console.log(result.context || '(empty)');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStats(isJson: boolean): Promise<void> {
|
||||
const fc = getContext();
|
||||
const stats = fc.getCacheStats();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, stats }, null, 2));
|
||||
} else {
|
||||
console.log('Fast Context Cache Stats:');
|
||||
console.log(` Cache size: ${stats.size}`);
|
||||
console.log(` Total hits: ${stats.totalHits}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(isJson: boolean): Promise<void> {
|
||||
const fc = getContext();
|
||||
fc.clearCache();
|
||||
fc.clearSources();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, message: 'Cache and sources cleared' }));
|
||||
} else {
|
||||
console.log('✓ Cache and sources cleared');
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(isJson: boolean): void {
|
||||
const help = {
|
||||
command: 'fast-context',
|
||||
description: 'Fast context retrieval and summarization',
|
||||
subcommands: {
|
||||
query: 'Query for relevant context',
|
||||
stats: 'Show cache statistics',
|
||||
clear: 'Clear cache and sources'
|
||||
},
|
||||
flags: {
|
||||
'--max-tokens': 'Maximum tokens in context (default: 4000)',
|
||||
'--sources': 'Comma-separated source texts',
|
||||
'--json': 'Output in JSON format'
|
||||
},
|
||||
examples: [
|
||||
'praisonai-ts fast-context query "What is the main topic?"',
|
||||
'praisonai-ts fast-context query "summarize" --sources "text1,text2,text3"',
|
||||
'praisonai-ts fast-context query "find relevant" --max-tokens 2000',
|
||||
'praisonai-ts fast-context stats',
|
||||
'praisonai-ts fast-context clear'
|
||||
]
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(help, null, 2));
|
||||
} else {
|
||||
console.log('Fast Context - Fast context retrieval\n');
|
||||
console.log('Subcommands:');
|
||||
for (const [cmd, desc] of Object.entries(help.subcommands)) {
|
||||
console.log(` ${cmd.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nFlags:');
|
||||
for (const [flag, desc] of Object.entries(help.flags)) {
|
||||
console.log(` ${flag.padEnd(14)} ${desc}`);
|
||||
}
|
||||
console.log('\nExamples:');
|
||||
for (const ex of help.examples) {
|
||||
console.log(` ${ex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* CLI command: flow
|
||||
* Workflow flow visualization
|
||||
*/
|
||||
|
||||
import { FlowDisplay, createFlowDisplay, renderWorkflow } from '../features/flow-display';
|
||||
|
||||
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
|
||||
const subcommand = args[0] || 'help';
|
||||
const isJson = Boolean(options.output === 'json' || options.json);
|
||||
|
||||
switch (subcommand) {
|
||||
case 'show':
|
||||
await handleShow(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'dot':
|
||||
await handleDot(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
showHelp(isJson);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShow(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const stepsStr = args[0] || options.steps as string;
|
||||
|
||||
let steps: Array<{ name: string; type?: string }> = [];
|
||||
|
||||
if (stepsStr) {
|
||||
try {
|
||||
steps = JSON.parse(stepsStr);
|
||||
} catch {
|
||||
// Parse simple format: "step1,step2,step3"
|
||||
steps = stepsStr.split(',').map(s => {
|
||||
const [name, type] = s.split(':');
|
||||
return { name: name.trim(), type: type?.trim() };
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Default example
|
||||
steps = [
|
||||
{ name: 'Input', type: 'start' },
|
||||
{ name: 'Process', type: 'agent' },
|
||||
{ name: 'Validate', type: 'condition' },
|
||||
{ name: 'Output', type: 'end' }
|
||||
];
|
||||
}
|
||||
|
||||
const display = createFlowDisplay({
|
||||
showStatus: Boolean(options.status),
|
||||
compact: Boolean(options.compact)
|
||||
});
|
||||
|
||||
display.fromWorkflowSteps(steps);
|
||||
|
||||
if (isJson) {
|
||||
const graph = display.getGraph();
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
nodes: Array.from(graph.nodes.values()),
|
||||
edges: graph.edges
|
||||
}, null, 2));
|
||||
} else {
|
||||
if (options.boxes) {
|
||||
console.log(display.renderBoxes());
|
||||
} else {
|
||||
console.log(display.render());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDot(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const stepsStr = args[0] || options.steps as string;
|
||||
|
||||
let steps: Array<{ name: string; type?: string }> = [];
|
||||
|
||||
if (stepsStr) {
|
||||
try {
|
||||
steps = JSON.parse(stepsStr);
|
||||
} catch {
|
||||
steps = stepsStr.split(',').map(s => {
|
||||
const [name, type] = s.split(':');
|
||||
return { name: name.trim(), type: type?.trim() };
|
||||
});
|
||||
}
|
||||
} else {
|
||||
steps = [
|
||||
{ name: 'Start', type: 'start' },
|
||||
{ name: 'Agent', type: 'agent' },
|
||||
{ name: 'End', type: 'end' }
|
||||
];
|
||||
}
|
||||
|
||||
const display = createFlowDisplay();
|
||||
display.fromWorkflowSteps(steps);
|
||||
const dot = display.toDot();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, dot }));
|
||||
} else {
|
||||
console.log(dot);
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(isJson: boolean): void {
|
||||
const help = {
|
||||
command: 'flow',
|
||||
description: 'Workflow flow visualization',
|
||||
subcommands: {
|
||||
show: 'Display workflow as text tree or boxes',
|
||||
dot: 'Export workflow as DOT format (for Graphviz)'
|
||||
},
|
||||
flags: {
|
||||
'--steps': 'Steps as JSON or "name:type,name:type"',
|
||||
'--status': 'Show status indicators',
|
||||
'--compact': 'Compact display mode',
|
||||
'--boxes': 'Display as ASCII boxes',
|
||||
'--json': 'Output in JSON format'
|
||||
},
|
||||
examples: [
|
||||
'praisonai-ts flow show "Input,Process,Output"',
|
||||
'praisonai-ts flow show "Start:start,Agent:agent,End:end"',
|
||||
'praisonai-ts flow show --boxes',
|
||||
'praisonai-ts flow dot "Step1,Step2,Step3" > workflow.dot'
|
||||
]
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(help, null, 2));
|
||||
} else {
|
||||
console.log('Flow - Workflow visualization\n');
|
||||
console.log('Subcommands:');
|
||||
for (const [cmd, desc] of Object.entries(help.subcommands)) {
|
||||
console.log(` ${cmd.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nFlags:');
|
||||
for (const [flag, desc] of Object.entries(help.flags)) {
|
||||
console.log(` ${flag.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nExamples:');
|
||||
for (const ex of help.examples) {
|
||||
console.log(` ${ex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* CLI command: git
|
||||
* Git integration for repository operations
|
||||
*/
|
||||
|
||||
import { GitManager, createGitManager, DiffViewer, createDiffViewer } from '../features/git-integration';
|
||||
|
||||
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
|
||||
const subcommand = args[0] || 'help';
|
||||
const isJson = Boolean(options.output === 'json' || options.json);
|
||||
const cwd = (options.cwd as string) || process.cwd();
|
||||
|
||||
switch (subcommand) {
|
||||
case 'status':
|
||||
await handleStatus(cwd, isJson);
|
||||
break;
|
||||
case 'diff':
|
||||
await handleDiff(options, cwd, isJson);
|
||||
break;
|
||||
case 'log':
|
||||
await handleLog(options, cwd, isJson);
|
||||
break;
|
||||
case 'branches':
|
||||
await handleBranches(cwd, isJson);
|
||||
break;
|
||||
case 'stash':
|
||||
await handleStash(args.slice(1), cwd, isJson);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
showHelp(isJson);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatus(cwd: string, isJson: boolean): Promise<void> {
|
||||
const git = createGitManager({ cwd, safe: true });
|
||||
|
||||
if (!await git.isRepo()) {
|
||||
console.error('Error: Not a git repository');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const status = await git.getStatus();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, ...status }, null, 2));
|
||||
} else {
|
||||
console.log(await git.getStatusString());
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDiff(options: Record<string, unknown>, cwd: string, isJson: boolean): Promise<void> {
|
||||
const git = createGitManager({ cwd, safe: true });
|
||||
const staged = Boolean(options.staged);
|
||||
|
||||
if (!await git.isRepo()) {
|
||||
console.error('Error: Not a git repository');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const diff = await git.getDiff(staged);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, ...diff }, null, 2));
|
||||
} else {
|
||||
if (diff.files.length === 0) {
|
||||
console.log(staged ? 'No staged changes' : 'No changes');
|
||||
} else {
|
||||
console.log(`Changes (${diff.files.length} files):\n`);
|
||||
for (const file of diff.files) {
|
||||
console.log(` ${file.path} (+${file.additions}/-${file.deletions})`);
|
||||
}
|
||||
if (options.full) {
|
||||
console.log('\n' + diff.summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLog(options: Record<string, unknown>, cwd: string, isJson: boolean): Promise<void> {
|
||||
const git = createGitManager({ cwd, safe: true });
|
||||
const limit = (options.limit as number) || 10;
|
||||
|
||||
if (!await git.isRepo()) {
|
||||
console.error('Error: Not a git repository');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const commits = await git.getLog(limit);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, commits }, null, 2));
|
||||
} else {
|
||||
console.log('Recent commits:\n');
|
||||
for (const commit of commits) {
|
||||
console.log(` ${commit.shortHash} ${commit.message}`);
|
||||
console.log(` by ${commit.author} on ${commit.date.toLocaleDateString()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBranches(cwd: string, isJson: boolean): Promise<void> {
|
||||
const git = createGitManager({ cwd, safe: true });
|
||||
|
||||
if (!await git.isRepo()) {
|
||||
console.error('Error: Not a git repository');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const branches = await git.getBranches();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, branches }, null, 2));
|
||||
} else {
|
||||
console.log('Branches:\n');
|
||||
for (const branch of branches) {
|
||||
const marker = branch.current ? '* ' : ' ';
|
||||
console.log(`${marker}${branch.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStash(args: string[], cwd: string, isJson: boolean): Promise<void> {
|
||||
const git = createGitManager({ cwd, safe: true });
|
||||
|
||||
if (!await git.isRepo()) {
|
||||
console.error('Error: Not a git repository');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stashes = await git.getStashList();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, stashes }, null, 2));
|
||||
} else {
|
||||
if (stashes.length === 0) {
|
||||
console.log('No stashes');
|
||||
} else {
|
||||
console.log('Stashes:\n');
|
||||
for (const stash of stashes) {
|
||||
console.log(` ${stash}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(isJson: boolean): void {
|
||||
const help = {
|
||||
command: 'git',
|
||||
description: 'Git integration for repository operations (read-only by default)',
|
||||
subcommands: {
|
||||
status: 'Show repository status',
|
||||
diff: 'Show changes',
|
||||
log: 'Show recent commits',
|
||||
branches: 'List branches',
|
||||
stash: 'List stashes'
|
||||
},
|
||||
flags: {
|
||||
'--cwd': 'Working directory (default: current)',
|
||||
'--staged': 'Show staged changes only (for diff)',
|
||||
'--full': 'Show full diff output',
|
||||
'--limit': 'Number of commits to show (default: 10)',
|
||||
'--json': 'Output in JSON format'
|
||||
},
|
||||
examples: [
|
||||
'praisonai-ts git status',
|
||||
'praisonai-ts git diff',
|
||||
'praisonai-ts git diff --staged',
|
||||
'praisonai-ts git log --limit 5',
|
||||
'praisonai-ts git branches',
|
||||
'praisonai-ts git stash'
|
||||
]
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(help, null, 2));
|
||||
} else {
|
||||
console.log('Git - Repository operations (read-only)\n');
|
||||
console.log('Subcommands:');
|
||||
for (const [cmd, desc] of Object.entries(help.subcommands)) {
|
||||
console.log(` ${cmd.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nFlags:');
|
||||
for (const [flag, desc] of Object.entries(help.flags)) {
|
||||
console.log(` ${flag.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nExamples:');
|
||||
for (const ex of help.examples) {
|
||||
console.log(` ${ex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Graph RAG command - Graph-based retrieval augmented generation
|
||||
*/
|
||||
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface GraphRagOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: GraphRagOptions): Promise<void> {
|
||||
const action = args[0] || 'help';
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'info':
|
||||
await showInfo(outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function showInfo(outputFormat: string): Promise<void> {
|
||||
const info = {
|
||||
feature: 'Graph RAG',
|
||||
description: 'Graph-based retrieval augmented generation for complex knowledge queries',
|
||||
capabilities: [
|
||||
'Build knowledge graphs from documents',
|
||||
'Query relationships between entities',
|
||||
'Combine graph traversal with vector search',
|
||||
'Extract entities and relationships',
|
||||
'Support for complex multi-hop queries'
|
||||
],
|
||||
sdkUsage: `
|
||||
import { createGraphRAG, GraphStore } from 'praisonai';
|
||||
|
||||
// Create a graph RAG instance
|
||||
const graphRag = createGraphRAG({
|
||||
llm: 'openai/gpt-4o-mini',
|
||||
graphStore: new GraphStore()
|
||||
});
|
||||
|
||||
// Add documents
|
||||
await graphRag.addDocument('Document content...');
|
||||
|
||||
// Query with graph context
|
||||
const result = await graphRag.query('What is the relationship between X and Y?');
|
||||
`
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(info));
|
||||
} else {
|
||||
await pretty.heading('Graph RAG');
|
||||
await pretty.plain(info.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Capabilities:');
|
||||
for (const cap of info.capabilities) {
|
||||
await pretty.plain(` • ${cap}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.dim('Use the SDK for full Graph RAG functionality');
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'graph-rag',
|
||||
description: 'Graph-based retrieval augmented generation',
|
||||
subcommands: [
|
||||
{ name: 'info', description: 'Show Graph RAG feature information' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Graph RAG Command');
|
||||
await pretty.plain(help.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Guardrail command - Content validation and safety
|
||||
*/
|
||||
|
||||
import { createLLMGuardrail } from '../../guardrails/llm-guardrail';
|
||||
import { resolveConfig } from '../config/resolve';
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface GuardrailOptions {
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
criteria?: string;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: GuardrailOptions): Promise<void> {
|
||||
const action = args[0] || 'help';
|
||||
const actionArgs = args.slice(1);
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
const config = resolveConfig(options);
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'check':
|
||||
await checkContent(actionArgs, options, config, outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkContent(args: string[], options: GuardrailOptions, config: any, outputFormat: string): Promise<void> {
|
||||
const content = args.join(' ');
|
||||
if (!content) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide content to check'));
|
||||
} else {
|
||||
await pretty.error('Please provide content to check');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
if (outputFormat !== 'json') {
|
||||
await pretty.info('Checking content...');
|
||||
}
|
||||
|
||||
const guardrail = createLLMGuardrail({
|
||||
name: 'cli-guardrail',
|
||||
llm: config.model,
|
||||
criteria: options.criteria || 'Content should be safe, appropriate, and helpful',
|
||||
verbose: options.verbose
|
||||
});
|
||||
|
||||
const result = await guardrail.check(content);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
content: content.substring(0, 100) + (content.length > 100 ? '...' : ''),
|
||||
status: result.status,
|
||||
score: result.score,
|
||||
message: result.message,
|
||||
reasoning: result.reasoning
|
||||
}, {
|
||||
duration_ms: duration,
|
||||
model: config.model
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Guardrail Check');
|
||||
if (result.status === 'passed') {
|
||||
await pretty.success('Content passed guardrail check');
|
||||
} else if (result.status === 'warning') {
|
||||
await pretty.warn('Content has warnings');
|
||||
} else {
|
||||
await pretty.error('Content failed guardrail check');
|
||||
}
|
||||
await pretty.plain(`Score: ${(result.score * 10).toFixed(1)}/10`);
|
||||
if (result.message) {
|
||||
await pretty.plain(`Message: ${result.message}`);
|
||||
}
|
||||
if (result.reasoning) {
|
||||
await pretty.dim(`Reasoning: ${result.reasoning}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.dim(`Checked in ${duration}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'guardrail',
|
||||
subcommands: [
|
||||
{ name: 'check <content>', description: 'Check content against guardrails' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
],
|
||||
flags: [
|
||||
{ name: '--criteria', description: 'Custom criteria for validation' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Guardrail Command');
|
||||
await pretty.plain('Content validation and safety checks\n');
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(25)} ${cmd.description}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.plain('Flags:');
|
||||
for (const flag of help.flags) {
|
||||
await pretty.plain(` ${flag.name.padEnd(20)} ${flag.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Handoff command - Agent handoff management
|
||||
*/
|
||||
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface HandoffOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: HandoffOptions): Promise<void> {
|
||||
const action = args[0] || 'help';
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'info':
|
||||
await showInfo(outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function showInfo(outputFormat: string): Promise<void> {
|
||||
const info = {
|
||||
feature: 'Handoff',
|
||||
description: 'Agent handoff allows seamless transfer of conversation context between agents',
|
||||
capabilities: [
|
||||
'Transfer conversation to specialized agents',
|
||||
'Preserve context during handoff',
|
||||
'Filter handoffs based on conditions',
|
||||
'Chain multiple agent handoffs'
|
||||
],
|
||||
sdkUsage: `
|
||||
import { Handoff, handoff, handoffFilters } from 'praisonai';
|
||||
|
||||
// Create a handoff
|
||||
const myHandoff = handoff({
|
||||
target: specialistAgent,
|
||||
condition: (ctx) => ctx.input.includes('technical'),
|
||||
onHandoff: (ctx) => console.log('Handing off...')
|
||||
});
|
||||
|
||||
// Use with an agent
|
||||
const agent = new Agent({
|
||||
handoffs: [myHandoff]
|
||||
});
|
||||
`
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(info));
|
||||
} else {
|
||||
await pretty.heading('Handoff Feature');
|
||||
await pretty.plain(info.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Capabilities:');
|
||||
for (const cap of info.capabilities) {
|
||||
await pretty.plain(` • ${cap}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.dim('Use the SDK for programmatic handoff configuration');
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'handoff',
|
||||
description: 'Agent handoff management - transfer conversations between agents',
|
||||
subcommands: [
|
||||
{ name: 'info', description: 'Show handoff feature information' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
],
|
||||
note: 'Handoff is primarily a programmatic SDK feature. Use the SDK for full functionality.'
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Handoff Command');
|
||||
await pretty.plain(help.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.dim(help.note);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Help command - Show help information
|
||||
*/
|
||||
|
||||
import { COMMANDS, GLOBAL_FLAGS, CLI_SPEC_VERSION, getCommandSpec } from '../spec/cli-spec';
|
||||
import { outputJson, formatSuccess } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
|
||||
export interface HelpOptions {
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: HelpOptions): Promise<void> {
|
||||
const command = args[0];
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
if (command) {
|
||||
await showCommandHelp(command, outputFormat);
|
||||
} else {
|
||||
await showGeneralHelp(outputFormat);
|
||||
}
|
||||
}
|
||||
|
||||
async function showGeneralHelp(outputFormat: string): Promise<void> {
|
||||
const commands = Object.entries(COMMANDS).map(([name, spec]) => ({
|
||||
name,
|
||||
description: spec.description
|
||||
}));
|
||||
|
||||
const flags = GLOBAL_FLAGS.map(f => ({
|
||||
name: f.short ? `-${f.short}, --${f.name}` : `--${f.name}`,
|
||||
description: f.description,
|
||||
default: f.default
|
||||
}));
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
cli_spec_version: CLI_SPEC_VERSION,
|
||||
commands,
|
||||
global_flags: flags
|
||||
}));
|
||||
} else {
|
||||
await pretty.plain(`
|
||||
PraisonAI TypeScript CLI v${CLI_SPEC_VERSION}
|
||||
|
||||
Usage:
|
||||
praisonai-ts <command> [options]
|
||||
|
||||
Commands:`);
|
||||
|
||||
for (const cmd of commands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(12)} ${cmd.description}`);
|
||||
}
|
||||
|
||||
await pretty.plain(`
|
||||
Global Options:`);
|
||||
|
||||
for (const flag of flags) {
|
||||
const defaultStr = flag.default !== undefined ? ` (default: ${flag.default})` : '';
|
||||
await pretty.plain(` ${flag.name.padEnd(20)} ${flag.description}${defaultStr}`);
|
||||
}
|
||||
|
||||
await pretty.plain(`
|
||||
Environment Variables:
|
||||
OPENAI_API_KEY OpenAI API key
|
||||
ANTHROPIC_API_KEY Anthropic API key
|
||||
GOOGLE_API_KEY Google API key
|
||||
PRAISONAI_MODEL Default model
|
||||
|
||||
Examples:
|
||||
praisonai-ts chat "Hello, how are you?"
|
||||
praisonai-ts chat "Write a poem" --stream
|
||||
praisonai-ts run "Analyze this data" --agent my-agent.yaml
|
||||
praisonai-ts providers
|
||||
praisonai-ts help chat
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showCommandHelp(command: string, outputFormat: string): Promise<void> {
|
||||
const spec = getCommandSpec(command);
|
||||
|
||||
if (!spec) {
|
||||
await pretty.error(`Unknown command: ${command}`);
|
||||
await pretty.info('Run "praisonai-ts help" to see available commands');
|
||||
return;
|
||||
}
|
||||
|
||||
const args = spec.args || [];
|
||||
const flags = [...GLOBAL_FLAGS, ...(spec.flags || [])];
|
||||
const subcommands = spec.subcommands ? Object.entries(spec.subcommands) : [];
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
command,
|
||||
description: spec.description,
|
||||
args: args.map(a => ({
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
required: a.required,
|
||||
description: a.description
|
||||
})),
|
||||
flags: flags.map(f => ({
|
||||
name: f.name,
|
||||
short: f.short,
|
||||
type: f.type,
|
||||
default: f.default,
|
||||
description: f.description
|
||||
})),
|
||||
subcommands: subcommands.map(([name, sub]) => ({
|
||||
name,
|
||||
description: sub.description
|
||||
}))
|
||||
}));
|
||||
} else {
|
||||
await pretty.plain(`
|
||||
Command: ${command}
|
||||
${spec.description}
|
||||
|
||||
Usage:
|
||||
praisonai-ts ${command}${args.length ? ' ' + args.map(a => a.required ? `<${a.name}>` : `[${a.name}]`).join(' ') : ''} [options]`);
|
||||
|
||||
if (args.length > 0) {
|
||||
await pretty.plain(`
|
||||
Arguments:`);
|
||||
for (const arg of args) {
|
||||
const reqStr = arg.required ? ' (required)' : '';
|
||||
await pretty.plain(` ${arg.name.padEnd(15)} ${arg.description || arg.type}${reqStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (subcommands.length > 0) {
|
||||
await pretty.plain(`
|
||||
Subcommands:`);
|
||||
for (const [name, sub] of subcommands) {
|
||||
await pretty.plain(` ${name.padEnd(15)} ${sub.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
await pretty.plain(`
|
||||
Options:`);
|
||||
for (const flag of flags) {
|
||||
const shortStr = flag.short ? `-${flag.short}, ` : ' ';
|
||||
const defaultStr = flag.default !== undefined ? ` (default: ${flag.default})` : '';
|
||||
await pretty.plain(` ${shortStr}--${flag.name.padEnd(14)} ${flag.description || ''}${defaultStr}`);
|
||||
}
|
||||
|
||||
await pretty.plain('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Image command - Image generation and analysis
|
||||
*/
|
||||
|
||||
import { createImageAgent } from '../../agent/image';
|
||||
import { resolveConfig } from '../config/resolve';
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface ImageOptions {
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
size?: string;
|
||||
quality?: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: ImageOptions): Promise<void> {
|
||||
const action = args[0] || 'help';
|
||||
const actionArgs = args.slice(1);
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
const config = resolveConfig(options);
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'generate':
|
||||
await generateImage(actionArgs, options, config, outputFormat);
|
||||
break;
|
||||
case 'analyze':
|
||||
await analyzeImage(actionArgs, options, config, outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateImage(args: string[], options: ImageOptions, config: any, outputFormat: string): Promise<void> {
|
||||
const prompt = args.join(' ');
|
||||
if (!prompt) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a prompt for image generation'));
|
||||
} else {
|
||||
await pretty.error('Please provide a prompt for image generation');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
if (outputFormat !== 'json') {
|
||||
await pretty.info(`Generating image: "${prompt}"`);
|
||||
}
|
||||
|
||||
const agent = createImageAgent({
|
||||
llm: config.model,
|
||||
verbose: options.verbose
|
||||
});
|
||||
|
||||
const result = await agent.generate({
|
||||
prompt,
|
||||
size: options.size as any,
|
||||
quality: options.quality as any,
|
||||
style: options.style as any
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const urls = Array.isArray(result) ? result : [result];
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
prompt,
|
||||
urls
|
||||
}, {
|
||||
duration_ms: duration,
|
||||
model: config.model
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Generated Image');
|
||||
for (const url of urls) {
|
||||
await pretty.plain(`URL: ${url}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.success(`Generated in ${duration}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeImage(args: string[], options: ImageOptions, config: any, outputFormat: string): Promise<void> {
|
||||
const imageUrl = args[0];
|
||||
const prompt = args.slice(1).join(' ') || 'Describe this image';
|
||||
|
||||
if (!imageUrl) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide an image URL'));
|
||||
} else {
|
||||
await pretty.error('Please provide an image URL');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
if (outputFormat !== 'json') {
|
||||
await pretty.info(`Analyzing image: ${imageUrl}`);
|
||||
}
|
||||
|
||||
const agent = createImageAgent({
|
||||
llm: config.model,
|
||||
verbose: options.verbose
|
||||
});
|
||||
|
||||
const result = await agent.analyze({
|
||||
imageUrl,
|
||||
prompt
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
imageUrl,
|
||||
prompt,
|
||||
analysis: result
|
||||
}, {
|
||||
duration_ms: duration,
|
||||
model: config.model
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Image Analysis');
|
||||
await pretty.plain(result);
|
||||
await pretty.newline();
|
||||
await pretty.success(`Analyzed in ${duration}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'image',
|
||||
subcommands: [
|
||||
{ name: 'generate <prompt>', description: 'Generate an image from text' },
|
||||
{ name: 'analyze <url> [question]', description: 'Analyze an image' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
],
|
||||
flags: [
|
||||
{ name: '--size', description: 'Image size (1024x1024, 1792x1024, 1024x1792)' },
|
||||
{ name: '--quality', description: 'Image quality (standard, hd)' },
|
||||
{ name: '--style', description: 'Image style (vivid, natural)' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Image Command');
|
||||
await pretty.plain('Generate and analyze images\n');
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(30)} ${cmd.description}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.plain('Flags:');
|
||||
for (const flag of help.flags) {
|
||||
await pretty.plain(` ${flag.name.padEnd(20)} ${flag.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* CLI command: interactive
|
||||
* Interactive TUI mode with slash commands
|
||||
*/
|
||||
|
||||
import { InteractiveTUI, createInteractiveTUI } from '../features/interactive-tui';
|
||||
import { createCostTracker } from '../features/cost-tracker';
|
||||
|
||||
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
|
||||
const isJson = Boolean(options.output === 'json' || options.json);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Interactive mode does not support JSON output'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const historyFile = options.history as string | undefined;
|
||||
const model = options.model as string | undefined;
|
||||
|
||||
console.log('Starting interactive mode...');
|
||||
console.log('Type /help for available commands, /exit to quit.\n');
|
||||
|
||||
const tui = createInteractiveTUI({
|
||||
prompt: options.prompt as string || '> ',
|
||||
historyFile,
|
||||
welcomeMessage: `PraisonAI Interactive Mode
|
||||
Model: ${model || 'default'}
|
||||
Type /help for commands, /exit to quit.`,
|
||||
onMessage: async (message) => {
|
||||
// Simple echo for now - in real usage this would call the agent
|
||||
return `You said: ${message}`;
|
||||
},
|
||||
onExit: () => {
|
||||
console.log('\nGoodbye!');
|
||||
}
|
||||
});
|
||||
|
||||
// Update context with model if provided
|
||||
if (model) {
|
||||
tui.updateContext({ model });
|
||||
}
|
||||
|
||||
try {
|
||||
await tui.start();
|
||||
} catch (error: any) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function showHelp(): void {
|
||||
console.log(`Interactive - Interactive TUI mode
|
||||
|
||||
Usage:
|
||||
praisonai-ts interactive [options]
|
||||
|
||||
Options:
|
||||
--model, -m Model to use
|
||||
--prompt Custom prompt string (default: "> ")
|
||||
--history Path to history file
|
||||
|
||||
Slash Commands (in interactive mode):
|
||||
/help Show available commands
|
||||
/cost Show token usage and cost
|
||||
/clear Clear conversation history
|
||||
/model [name] Show or change model
|
||||
/tokens Show token count
|
||||
/plan Manage task plan
|
||||
/undo Undo last action
|
||||
/diff Show pending changes
|
||||
/commit Commit pending changes
|
||||
/settings Show or modify settings
|
||||
/map Show repository map
|
||||
/exit Exit interactive mode
|
||||
|
||||
Examples:
|
||||
praisonai-ts interactive
|
||||
praisonai-ts interactive --model gpt-4o
|
||||
praisonai-ts interactive --history ~/.praisonai_history
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* CLI command: jobs
|
||||
* Background job queue management
|
||||
*/
|
||||
|
||||
import { JobQueue, createJobQueue, createFileJobStorage } from '../features/background-jobs';
|
||||
|
||||
let globalQueue: JobQueue | null = null;
|
||||
|
||||
function getQueue(storagePath?: string): JobQueue {
|
||||
if (!globalQueue) {
|
||||
const storage = storagePath ? createFileJobStorage(storagePath) : undefined;
|
||||
globalQueue = createJobQueue({ storage });
|
||||
}
|
||||
return globalQueue;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
|
||||
const subcommand = args[0] || 'help';
|
||||
const isJson = Boolean(options.output === 'json' || options.json);
|
||||
const storagePath = options.storage as string | undefined;
|
||||
|
||||
switch (subcommand) {
|
||||
case 'add':
|
||||
await handleAdd(args.slice(1), options, isJson, storagePath);
|
||||
break;
|
||||
case 'list':
|
||||
await handleList(options, isJson, storagePath);
|
||||
break;
|
||||
case 'get':
|
||||
await handleGet(args.slice(1), isJson, storagePath);
|
||||
break;
|
||||
case 'cancel':
|
||||
await handleCancel(args.slice(1), isJson, storagePath);
|
||||
break;
|
||||
case 'retry':
|
||||
await handleRetry(args.slice(1), isJson, storagePath);
|
||||
break;
|
||||
case 'process':
|
||||
await handleProcess(args.slice(1), isJson, storagePath);
|
||||
break;
|
||||
case 'stats':
|
||||
await handleStats(isJson, storagePath);
|
||||
break;
|
||||
case 'cleanup':
|
||||
await handleCleanup(options, isJson, storagePath);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
showHelp(isJson);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd(args: string[], options: Record<string, unknown>, isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const name = args[0];
|
||||
const dataStr = args[1] || '{}';
|
||||
|
||||
if (!name) {
|
||||
console.error('Error: Job name is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(dataStr);
|
||||
} catch {
|
||||
data = { input: dataStr };
|
||||
}
|
||||
|
||||
const priority = (options.priority as string) || 'normal';
|
||||
const queue = getQueue(storagePath);
|
||||
|
||||
const job = await queue.add(name, data, {
|
||||
priority: priority as 'low' | 'normal' | 'high' | 'critical'
|
||||
});
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, job }, null, 2));
|
||||
} else {
|
||||
console.log(`✓ Job created: ${job.id}`);
|
||||
console.log(` Name: ${job.name}`);
|
||||
console.log(` Priority: ${job.priority}`);
|
||||
console.log(` Status: ${job.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleList(options: Record<string, unknown>, isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const queue = getQueue(storagePath);
|
||||
const status = options.status as string | undefined;
|
||||
|
||||
let jobs = await queue.getAll();
|
||||
if (status) {
|
||||
jobs = jobs.filter(j => j.status === status);
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, jobs }, null, 2));
|
||||
} else {
|
||||
if (jobs.length === 0) {
|
||||
console.log('No jobs found');
|
||||
} else {
|
||||
console.log('Jobs:');
|
||||
for (const job of jobs) {
|
||||
console.log(` ${job.id}`);
|
||||
console.log(` Name: ${job.name}`);
|
||||
console.log(` Status: ${job.status}`);
|
||||
console.log(` Priority: ${job.priority}`);
|
||||
console.log(` Attempts: ${job.attempts}/${job.maxAttempts}`);
|
||||
if (job.error) console.log(` Error: ${job.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGet(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) {
|
||||
console.error('Error: Job ID is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const queue = getQueue(storagePath);
|
||||
const job = await queue.get(id);
|
||||
|
||||
if (!job) {
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: false, error: 'Job not found' }));
|
||||
} else {
|
||||
console.error(`Job ${id} not found`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, job }, null, 2));
|
||||
} else {
|
||||
console.log(`Job: ${job.id}`);
|
||||
console.log(` Name: ${job.name}`);
|
||||
console.log(` Status: ${job.status}`);
|
||||
console.log(` Priority: ${job.priority}`);
|
||||
console.log(` Attempts: ${job.attempts}/${job.maxAttempts}`);
|
||||
console.log(` Created: ${job.createdAt}`);
|
||||
if (job.startedAt) console.log(` Started: ${job.startedAt}`);
|
||||
if (job.completedAt) console.log(` Completed: ${job.completedAt}`);
|
||||
if (job.error) console.log(` Error: ${job.error}`);
|
||||
if (job.result) console.log(` Result: ${JSON.stringify(job.result)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) {
|
||||
console.error('Error: Job ID is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const queue = getQueue(storagePath);
|
||||
const cancelled = await queue.cancel(id);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: cancelled, id }));
|
||||
} else {
|
||||
console.log(cancelled ? `✓ Job ${id} cancelled` : `Could not cancel job ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetry(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) {
|
||||
console.error('Error: Job ID is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const queue = getQueue(storagePath);
|
||||
const retried = await queue.retry(id);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: retried, id }));
|
||||
} else {
|
||||
console.log(retried ? `✓ Job ${id} queued for retry` : `Could not retry job ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProcess(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) {
|
||||
console.error('Error: Job ID is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const queue = getQueue(storagePath);
|
||||
|
||||
// Register a simple handler
|
||||
queue.register('default', async (job) => {
|
||||
return { processed: true, data: job.data };
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await queue.processNow(id);
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, result }, null, 2));
|
||||
} else {
|
||||
console.log(`✓ Job ${id} processed`);
|
||||
console.log(` Result: ${JSON.stringify(result)}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: false, error: error.message }));
|
||||
} else {
|
||||
console.error(`Error: ${error.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStats(isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const queue = getQueue(storagePath);
|
||||
const stats = await queue.getStats();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, stats }, null, 2));
|
||||
} else {
|
||||
console.log('Job Queue Stats:');
|
||||
console.log(` Pending: ${stats.pending}`);
|
||||
console.log(` Running: ${stats.running}`);
|
||||
console.log(` Completed: ${stats.completed}`);
|
||||
console.log(` Failed: ${stats.failed}`);
|
||||
console.log(` Cancelled: ${stats.cancelled}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCleanup(options: Record<string, unknown>, isJson: boolean, storagePath?: string): Promise<void> {
|
||||
const queue = getQueue(storagePath);
|
||||
const days = (options.days as number) || 7;
|
||||
const olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const count = await queue.cleanup(olderThan);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, cleaned: count }));
|
||||
} else {
|
||||
console.log(`✓ Cleaned up ${count} jobs older than ${days} days`);
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(isJson: boolean): void {
|
||||
const help = {
|
||||
command: 'jobs',
|
||||
description: 'Background job queue management',
|
||||
subcommands: {
|
||||
add: 'Add a new job to the queue',
|
||||
list: 'List all jobs',
|
||||
get: 'Get job details',
|
||||
cancel: 'Cancel a pending job',
|
||||
retry: 'Retry a failed job',
|
||||
process: 'Process a job immediately',
|
||||
stats: 'Show queue statistics',
|
||||
cleanup: 'Clean up old completed/failed jobs'
|
||||
},
|
||||
flags: {
|
||||
'--storage': 'Path to job storage file',
|
||||
'--priority': 'Job priority (low, normal, high, critical)',
|
||||
'--status': 'Filter by status',
|
||||
'--days': 'Days for cleanup threshold',
|
||||
'--json': 'Output in JSON format'
|
||||
},
|
||||
examples: [
|
||||
'praisonai-ts jobs add my-job \'{"input": "data"}\'',
|
||||
'praisonai-ts jobs add task --priority high',
|
||||
'praisonai-ts jobs list --status pending',
|
||||
'praisonai-ts jobs get <job-id>',
|
||||
'praisonai-ts jobs cancel <job-id>',
|
||||
'praisonai-ts jobs stats',
|
||||
'praisonai-ts jobs cleanup --days 30'
|
||||
]
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(help, null, 2));
|
||||
} else {
|
||||
console.log('Jobs - Background job queue management\n');
|
||||
console.log('Subcommands:');
|
||||
for (const [cmd, desc] of Object.entries(help.subcommands)) {
|
||||
console.log(` ${cmd.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nFlags:');
|
||||
for (const [flag, desc] of Object.entries(help.flags)) {
|
||||
console.log(` ${flag.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nExamples:');
|
||||
for (const ex of help.examples) {
|
||||
console.log(` ${ex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Knowledge command - Manage knowledge base
|
||||
*/
|
||||
|
||||
import { BaseKnowledgeBase, Knowledge } from '../../knowledge';
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface KnowledgeOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
// Singleton knowledge base instance
|
||||
let kbInstance: BaseKnowledgeBase | null = null;
|
||||
function getKnowledgeBase(): BaseKnowledgeBase {
|
||||
if (!kbInstance) {
|
||||
kbInstance = new BaseKnowledgeBase();
|
||||
}
|
||||
return kbInstance;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: KnowledgeOptions): Promise<void> {
|
||||
const action = args[0] || 'help';
|
||||
const actionArgs = args.slice(1);
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
const kb = getKnowledgeBase();
|
||||
|
||||
switch (action) {
|
||||
case 'add':
|
||||
await addKnowledge(kb, actionArgs, outputFormat);
|
||||
break;
|
||||
case 'search':
|
||||
await searchKnowledge(kb, actionArgs, outputFormat);
|
||||
break;
|
||||
case 'list':
|
||||
await listKnowledge(kb, outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function addKnowledge(kb: BaseKnowledgeBase, args: string[], outputFormat: string): Promise<void> {
|
||||
const source = args[0];
|
||||
if (!source) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a file path or text content'));
|
||||
} else {
|
||||
await pretty.error('Please provide a file path or text content');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
let content: string;
|
||||
let sourceName: string;
|
||||
|
||||
// Check if it's a file path
|
||||
if (fs.existsSync(source)) {
|
||||
content = fs.readFileSync(source, 'utf-8');
|
||||
sourceName = path.basename(source);
|
||||
} else {
|
||||
// Treat as direct text content
|
||||
content = args.join(' ');
|
||||
sourceName = 'text-input';
|
||||
}
|
||||
|
||||
const knowledge: Knowledge = {
|
||||
id: randomUUID(),
|
||||
type: 'text',
|
||||
content,
|
||||
metadata: { source: sourceName, addedAt: new Date().toISOString() }
|
||||
};
|
||||
|
||||
kb.addKnowledge(knowledge);
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
added: true,
|
||||
id: knowledge.id,
|
||||
source: sourceName,
|
||||
contentLength: content.length
|
||||
}));
|
||||
} else {
|
||||
await pretty.success(`Knowledge added from: ${sourceName}`);
|
||||
await pretty.dim(`Content length: ${content.length} characters`);
|
||||
}
|
||||
}
|
||||
|
||||
async function searchKnowledge(kb: BaseKnowledgeBase, args: string[], outputFormat: string): Promise<void> {
|
||||
const query = args.join(' ');
|
||||
if (!query) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a search query'));
|
||||
} else {
|
||||
await pretty.error('Please provide a search query');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const results = kb.searchKnowledge(query);
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
query,
|
||||
results: results.map(r => ({
|
||||
id: r.id,
|
||||
content: r.content,
|
||||
metadata: r.metadata
|
||||
})),
|
||||
count: results.length
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading(`Search Results for: "${query}"`);
|
||||
if (results.length === 0) {
|
||||
await pretty.info('No matching knowledge found');
|
||||
} else {
|
||||
for (const result of results) {
|
||||
const contentStr = typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
|
||||
await pretty.plain(` • ${contentStr.substring(0, 100)}${contentStr.length > 100 ? '...' : ''}`);
|
||||
if (result.metadata?.source) {
|
||||
await pretty.dim(` Source: ${result.metadata.source}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.info(`Found: ${results.length} results`);
|
||||
}
|
||||
}
|
||||
|
||||
async function listKnowledge(kb: BaseKnowledgeBase, outputFormat: string): Promise<void> {
|
||||
// Search with empty string to get all
|
||||
const entries = kb.searchKnowledge('');
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
entries: entries.map((e: Knowledge) => ({
|
||||
id: e.id,
|
||||
type: e.type,
|
||||
contentPreview: typeof e.content === 'string' ? e.content.substring(0, 100) : JSON.stringify(e.content).substring(0, 100),
|
||||
source: e.metadata?.source
|
||||
})),
|
||||
count: entries.length
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Knowledge Base Entries');
|
||||
if (entries.length === 0) {
|
||||
await pretty.info('No knowledge entries found');
|
||||
} else {
|
||||
for (const entry of entries) {
|
||||
const contentStr = typeof entry.content === 'string' ? entry.content : JSON.stringify(entry.content);
|
||||
await pretty.plain(` • ${contentStr.substring(0, 80)}${contentStr.length > 80 ? '...' : ''}`);
|
||||
if (entry.metadata?.source) {
|
||||
await pretty.dim(` Source: ${entry.metadata.source}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.info(`Total: ${entries.length} entries`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'knowledge',
|
||||
subcommands: [
|
||||
{ name: 'add <file|text>', description: 'Add knowledge from file or text' },
|
||||
{ name: 'search <query>', description: 'Search knowledge base' },
|
||||
{ name: 'list', description: 'List all knowledge entries' },
|
||||
{ name: 'clear', description: 'Clear all knowledge' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Knowledge Command');
|
||||
await pretty.plain('Manage knowledge base for RAG\n');
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(25)} ${cmd.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* MCP command - Model Context Protocol management
|
||||
*/
|
||||
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface MCPOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
// MCP server registry (in-memory for now)
|
||||
interface MCPServer {
|
||||
name: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
status: 'stopped' | 'running';
|
||||
}
|
||||
|
||||
const mcpServers: Map<string, MCPServer> = new Map();
|
||||
|
||||
export async function execute(args: string[], options: MCPOptions): Promise<void> {
|
||||
const action = args[0] || 'list';
|
||||
const actionArgs = args.slice(1);
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'list':
|
||||
await listServers(outputFormat);
|
||||
break;
|
||||
case 'add':
|
||||
await addServer(actionArgs, outputFormat);
|
||||
break;
|
||||
case 'remove':
|
||||
await removeServer(actionArgs, outputFormat);
|
||||
break;
|
||||
case 'start':
|
||||
await startServer(actionArgs, outputFormat);
|
||||
break;
|
||||
case 'stop':
|
||||
await stopServer(actionArgs, outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function listServers(outputFormat: string): Promise<void> {
|
||||
const servers = Array.from(mcpServers.values());
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
servers,
|
||||
count: servers.length
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('MCP Servers');
|
||||
if (servers.length === 0) {
|
||||
await pretty.info('No MCP servers registered');
|
||||
await pretty.dim('Run "praisonai-ts mcp add <name> <command>" to add a server');
|
||||
} else {
|
||||
for (const server of servers) {
|
||||
const statusIcon = server.status === 'running' ? '🟢' : '⚪';
|
||||
await pretty.plain(` ${statusIcon} ${server.name}`);
|
||||
await pretty.dim(` Command: ${server.command} ${(server.args || []).join(' ')}`);
|
||||
}
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.info(`Total: ${servers.length} servers`);
|
||||
}
|
||||
}
|
||||
|
||||
async function addServer(args: string[], outputFormat: string): Promise<void> {
|
||||
const name = args[0];
|
||||
const command = args[1];
|
||||
const serverArgs = args.slice(2);
|
||||
|
||||
if (!name || !command) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide server name and command'));
|
||||
} else {
|
||||
await pretty.error('Please provide server name and command');
|
||||
await pretty.dim('Usage: praisonai-ts mcp add <name> <command> [args...]');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const server: MCPServer = {
|
||||
name,
|
||||
command,
|
||||
args: serverArgs.length > 0 ? serverArgs : undefined,
|
||||
status: 'stopped'
|
||||
};
|
||||
|
||||
mcpServers.set(name, server);
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ added: true, server }));
|
||||
} else {
|
||||
await pretty.success(`MCP server added: ${name}`);
|
||||
await pretty.dim(`Command: ${command} ${serverArgs.join(' ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeServer(args: string[], outputFormat: string): Promise<void> {
|
||||
const name = args[0];
|
||||
if (!name) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide server name'));
|
||||
} else {
|
||||
await pretty.error('Please provide server name');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const removed = mcpServers.delete(name);
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ removed, name }));
|
||||
} else {
|
||||
if (removed) {
|
||||
await pretty.success(`MCP server removed: ${name}`);
|
||||
} else {
|
||||
await pretty.warn(`Server not found: ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startServer(args: string[], outputFormat: string): Promise<void> {
|
||||
const name = args[0];
|
||||
if (!name) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide server name'));
|
||||
} else {
|
||||
await pretty.error('Please provide server name');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const server = mcpServers.get(name);
|
||||
if (!server) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, `Server not found: ${name}`));
|
||||
} else {
|
||||
await pretty.error(`Server not found: ${name}`);
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
|
||||
// Mark as running (actual process management would be more complex)
|
||||
server.status = 'running';
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ started: true, server }));
|
||||
} else {
|
||||
await pretty.success(`MCP server started: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopServer(args: string[], outputFormat: string): Promise<void> {
|
||||
const name = args[0];
|
||||
if (!name) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide server name'));
|
||||
} else {
|
||||
await pretty.error('Please provide server name');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const server = mcpServers.get(name);
|
||||
if (!server) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, `Server not found: ${name}`));
|
||||
} else {
|
||||
await pretty.error(`Server not found: ${name}`);
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
|
||||
server.status = 'stopped';
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ stopped: true, server }));
|
||||
} else {
|
||||
await pretty.success(`MCP server stopped: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'mcp',
|
||||
description: 'Model Context Protocol server management',
|
||||
subcommands: [
|
||||
{ name: 'list', description: 'List registered MCP servers' },
|
||||
{ name: 'add <name> <cmd> [args]', description: 'Add an MCP server' },
|
||||
{ name: 'remove <name>', description: 'Remove an MCP server' },
|
||||
{ name: 'start <name>', description: 'Start an MCP server' },
|
||||
{ name: 'stop <name>', description: 'Stop an MCP server' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('MCP Command');
|
||||
await pretty.plain('Model Context Protocol server management\n');
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(25)} ${cmd.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Memory command - Manage agent memory
|
||||
*/
|
||||
|
||||
import { Memory, createMemory, MemoryEntry, SearchResult } from '../../memory/memory';
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface MemoryOptions {
|
||||
userId?: string;
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: MemoryOptions): Promise<void> {
|
||||
const action = args[0] || 'list';
|
||||
const actionArgs = args.slice(1);
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
const memory = createMemory();
|
||||
|
||||
switch (action) {
|
||||
case 'list':
|
||||
await listMemories(memory, outputFormat);
|
||||
break;
|
||||
case 'add':
|
||||
await addMemory(memory, actionArgs, outputFormat);
|
||||
break;
|
||||
case 'search':
|
||||
await searchMemory(memory, actionArgs, outputFormat);
|
||||
break;
|
||||
case 'clear':
|
||||
await clearMemory(memory, outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function listMemories(memory: Memory, outputFormat: string): Promise<void> {
|
||||
const entries = memory.getAll();
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
memories: entries,
|
||||
count: entries.length
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Memory Entries');
|
||||
if (entries.length === 0) {
|
||||
await pretty.info('No memories stored');
|
||||
} else {
|
||||
for (const entry of entries) {
|
||||
await pretty.plain(` • ${entry.content.substring(0, 100)}${entry.content.length > 100 ? '...' : ''}`);
|
||||
if (entry.metadata) {
|
||||
await pretty.dim(` Created: ${new Date(entry.timestamp).toISOString()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.info(`Total: ${entries.length} memories`);
|
||||
}
|
||||
}
|
||||
|
||||
async function addMemory(memory: Memory, args: string[], outputFormat: string): Promise<void> {
|
||||
const content = args.join(' ');
|
||||
if (!content) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide content to add'));
|
||||
} else {
|
||||
await pretty.error('Please provide content to add');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
await memory.add(content, 'user');
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ added: content }));
|
||||
} else {
|
||||
await pretty.success('Memory added successfully');
|
||||
}
|
||||
}
|
||||
|
||||
async function searchMemory(memory: Memory, args: string[], outputFormat: string): Promise<void> {
|
||||
const query = args.join(' ');
|
||||
if (!query) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a search query'));
|
||||
} else {
|
||||
await pretty.error('Please provide a search query');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const results = await memory.search(query);
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
query,
|
||||
results,
|
||||
count: results.length
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading(`Search Results for: "${query}"`);
|
||||
if (results.length === 0) {
|
||||
await pretty.info('No matching memories found');
|
||||
} else {
|
||||
for (const result of results) {
|
||||
const content = result.entry.content;
|
||||
await pretty.plain(` • ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`);
|
||||
await pretty.dim(` Score: ${result.score.toFixed(3)}`);
|
||||
}
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.info(`Found: ${results.length} results`);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearMemory(memory: Memory, outputFormat: string): Promise<void> {
|
||||
memory.clear();
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ cleared: true }));
|
||||
} else {
|
||||
await pretty.success('Memory cleared successfully');
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'memory',
|
||||
subcommands: [
|
||||
{ name: 'list', description: 'List all memories' },
|
||||
{ name: 'add <content>', description: 'Add a new memory' },
|
||||
{ name: 'search <query>', description: 'Search memories' },
|
||||
{ name: 'clear', description: 'Clear all memories' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
],
|
||||
options: [
|
||||
{ name: '--user-id', description: 'User ID for memory isolation' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Memory Command');
|
||||
await pretty.plain('Manage agent memory storage\n');
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.plain('Options:');
|
||||
for (const opt of help.options) {
|
||||
await pretty.plain(` ${opt.name.padEnd(20)} ${opt.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* CLI command: n8n
|
||||
* N8N workflow integration
|
||||
*/
|
||||
|
||||
import { N8NIntegration, createN8NIntegration } from '../features/n8n-integration';
|
||||
|
||||
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
|
||||
const subcommand = args[0] || 'help';
|
||||
const isJson = Boolean(options.output === 'json' || options.json);
|
||||
|
||||
switch (subcommand) {
|
||||
case 'trigger':
|
||||
await handleTrigger(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'export':
|
||||
await handleExport(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
showHelp(isJson);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTrigger(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const webhookId = args[0];
|
||||
const payloadStr = args[1] || '{}';
|
||||
|
||||
if (!webhookId) {
|
||||
console.error('Error: Webhook ID is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = JSON.parse(payloadStr);
|
||||
} catch {
|
||||
payload = { data: payloadStr };
|
||||
}
|
||||
|
||||
const baseUrl = options['base-url'] as string | undefined;
|
||||
const apiKey = options['api-key'] as string | undefined;
|
||||
|
||||
const n8n = createN8NIntegration({ baseUrl, apiKey });
|
||||
const result = await n8n.triggerWebhook(webhookId, payload);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
if (result.success) {
|
||||
console.log(`✓ Webhook triggered: ${webhookId}`);
|
||||
if (result.response) {
|
||||
console.log('Response:', JSON.stringify(result.response, null, 2));
|
||||
}
|
||||
} else {
|
||||
console.error(`✗ Webhook failed: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const name = args[0] || 'workflow';
|
||||
const stepsStr = options.steps as string | undefined;
|
||||
|
||||
let steps: Array<{ name: string; type: 'agent' | 'tool' | 'condition' }> = [];
|
||||
|
||||
if (stepsStr) {
|
||||
try {
|
||||
steps = JSON.parse(stepsStr);
|
||||
} catch {
|
||||
// Parse simple format: "step1:agent,step2:tool"
|
||||
steps = stepsStr.split(',').map(s => {
|
||||
const [stepName, type] = s.split(':');
|
||||
return { name: stepName, type: (type || 'agent') as 'agent' | 'tool' | 'condition' };
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Default example workflow
|
||||
steps = [
|
||||
{ name: 'Process Input', type: 'agent' },
|
||||
{ name: 'Validate', type: 'condition' },
|
||||
{ name: 'Execute', type: 'tool' }
|
||||
];
|
||||
}
|
||||
|
||||
const n8n = createN8NIntegration();
|
||||
const workflow = n8n.exportWorkflowJSON(name, steps);
|
||||
|
||||
if (isJson || !options.pretty) {
|
||||
console.log(workflow);
|
||||
} else {
|
||||
console.log(`N8N Workflow: ${name}\n`);
|
||||
console.log('Steps:');
|
||||
for (const step of steps) {
|
||||
console.log(` - ${step.name} (${step.type})`);
|
||||
}
|
||||
console.log('\nExported workflow JSON:');
|
||||
console.log(workflow);
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(isJson: boolean): void {
|
||||
const help = {
|
||||
command: 'n8n',
|
||||
description: 'N8N workflow integration',
|
||||
subcommands: {
|
||||
trigger: 'Trigger an N8N webhook',
|
||||
export: 'Export workflow to N8N format'
|
||||
},
|
||||
flags: {
|
||||
'--base-url': 'N8N base URL (default: http://localhost:5678)',
|
||||
'--api-key': 'N8N API key',
|
||||
'--steps': 'Workflow steps as JSON or "name:type,name:type"',
|
||||
'--pretty': 'Pretty print export',
|
||||
'--json': 'Output in JSON format'
|
||||
},
|
||||
examples: [
|
||||
'praisonai-ts n8n trigger my-webhook \'{"data": "test"}\'',
|
||||
'praisonai-ts n8n trigger webhook-id --base-url https://n8n.example.com',
|
||||
'praisonai-ts n8n export my-workflow',
|
||||
'praisonai-ts n8n export workflow --steps "Process:agent,Validate:condition"'
|
||||
]
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(help, null, 2));
|
||||
} else {
|
||||
console.log('N8N - Workflow integration\n');
|
||||
console.log('Subcommands:');
|
||||
for (const [cmd, desc] of Object.entries(help.subcommands)) {
|
||||
console.log(` ${cmd.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nFlags:');
|
||||
for (const [flag, desc] of Object.entries(help.flags)) {
|
||||
console.log(` ${flag.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nExamples:');
|
||||
for (const ex of help.examples) {
|
||||
console.log(` ${ex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Observability command - Monitoring and tracing
|
||||
*/
|
||||
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface ObservabilityOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: ObservabilityOptions): Promise<void> {
|
||||
const action = args[0] || 'help';
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'providers':
|
||||
await listProviders(outputFormat);
|
||||
break;
|
||||
case 'info':
|
||||
await showInfo(outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function showInfo(outputFormat: string): Promise<void> {
|
||||
const info = {
|
||||
feature: 'Observability',
|
||||
description: 'Monitoring, tracing, and logging for agent operations',
|
||||
providers: [
|
||||
{ name: 'ConsoleObservabilityProvider', description: 'Console-based logging' },
|
||||
{ name: 'MemoryObservabilityProvider', description: 'In-memory trace storage' },
|
||||
{ name: 'LangfuseObservabilityProvider', description: 'Langfuse integration' }
|
||||
],
|
||||
capabilities: [
|
||||
'Trace agent executions',
|
||||
'Log LLM calls and responses',
|
||||
'Track tool invocations',
|
||||
'Measure performance metrics',
|
||||
'Export traces to external systems'
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(info));
|
||||
} else {
|
||||
await pretty.heading('Observability');
|
||||
await pretty.plain(info.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Providers:');
|
||||
for (const p of info.providers) {
|
||||
await pretty.plain(` • ${p.name}: ${p.description}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.plain('Capabilities:');
|
||||
for (const cap of info.capabilities) {
|
||||
await pretty.plain(` • ${cap}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listProviders(outputFormat: string): Promise<void> {
|
||||
const providers = [
|
||||
{ name: 'console', description: 'Console logging (default)', available: true },
|
||||
{ name: 'memory', description: 'In-memory storage', available: true },
|
||||
{ name: 'langfuse', description: 'Langfuse observability platform', available: true }
|
||||
];
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ providers }));
|
||||
} else {
|
||||
await pretty.heading('Observability Providers');
|
||||
for (const p of providers) {
|
||||
const status = p.available ? '✓' : '✗';
|
||||
await pretty.plain(` ${status} ${p.name.padEnd(15)} ${p.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'observability',
|
||||
description: 'Monitoring and tracing for agent operations',
|
||||
subcommands: [
|
||||
{ name: 'info', description: 'Show observability feature information' },
|
||||
{ name: 'providers', description: 'List available observability providers' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Observability Command');
|
||||
await pretty.plain(help.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Planning command - Task planning and todo management
|
||||
*/
|
||||
|
||||
import { createPlan, createTodoList, TodoItem, type PlanConfig, type TodoItemConfig } from '../../planning';
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface PlanningOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: PlanningOptions): Promise<void> {
|
||||
const action = args[0] || 'help';
|
||||
const actionArgs = args.slice(1);
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'create':
|
||||
await createNewPlan(actionArgs, outputFormat);
|
||||
break;
|
||||
case 'todo':
|
||||
await manageTodo(actionArgs, outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewPlan(args: string[], outputFormat: string): Promise<void> {
|
||||
const name = args.join(' ');
|
||||
if (!name) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a name for the plan'));
|
||||
} else {
|
||||
await pretty.error('Please provide a name for the plan');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const plan = createPlan({
|
||||
name,
|
||||
description: name
|
||||
});
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
status: plan.status
|
||||
}));
|
||||
} else {
|
||||
await pretty.success(`Plan created: ${plan.id}`);
|
||||
await pretty.plain(`Name: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function manageTodo(args: string[], outputFormat: string): Promise<void> {
|
||||
const subAction = args[0] || 'list';
|
||||
const todoArgs = args.slice(1);
|
||||
|
||||
const todoList = createTodoList();
|
||||
|
||||
switch (subAction) {
|
||||
case 'add':
|
||||
const content = todoArgs.join(' ');
|
||||
if (!content) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide todo content'));
|
||||
} else {
|
||||
await pretty.error('Please provide todo content');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
const newItem = new TodoItem({ content });
|
||||
todoList.add(newItem);
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ added: true, id: newItem.id, content: newItem.content }));
|
||||
} else {
|
||||
await pretty.success(`Todo added: ${content}`);
|
||||
}
|
||||
break;
|
||||
case 'list':
|
||||
default:
|
||||
const items = todoList.items;
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ items, count: items.length }));
|
||||
} else {
|
||||
await pretty.heading('Todo List');
|
||||
if (items.length === 0) {
|
||||
await pretty.info('No todos');
|
||||
} else {
|
||||
for (const todo of items) {
|
||||
const status = todo.status === 'completed' ? '✓' : '○';
|
||||
await pretty.plain(` ${status} ${todo.content}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'planning',
|
||||
subcommands: [
|
||||
{ name: 'create <goal>', description: 'Create a new plan' },
|
||||
{ name: 'todo add <title>', description: 'Add a todo item' },
|
||||
{ name: 'todo list', description: 'List todo items' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Planning Command');
|
||||
await pretty.plain('Task planning and todo management\n');
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(25)} ${cmd.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Prompt Expand command - Expand prompts with more detail
|
||||
*/
|
||||
|
||||
import { createPromptExpanderAgent } from '../../agent/prompt-expander';
|
||||
import { resolveConfig } from '../config/resolve';
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface PromptExpandOptions {
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
strategy?: string;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: PromptExpandOptions): Promise<void> {
|
||||
const prompt = args.join(' ');
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
const config = resolveConfig(options);
|
||||
|
||||
if (!prompt) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a prompt to expand'));
|
||||
} else {
|
||||
await pretty.error('Please provide a prompt to expand');
|
||||
await pretty.dim('Usage: praisonai-ts prompt-expand "your prompt"');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
if (outputFormat !== 'json') {
|
||||
await pretty.info(`Expanding prompt: "${prompt}"`);
|
||||
}
|
||||
|
||||
const agent = createPromptExpanderAgent({
|
||||
llm: config.model,
|
||||
verbose: options.verbose,
|
||||
defaultStrategy: options.strategy as any
|
||||
});
|
||||
|
||||
const result = await agent.expand(prompt);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
originalPrompt: prompt,
|
||||
expandedPrompt: result.expanded,
|
||||
strategy: result.strategy,
|
||||
additions: result.additions
|
||||
}, {
|
||||
duration_ms: duration,
|
||||
model: config.model
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Expanded Prompt');
|
||||
await pretty.plain(result.expanded);
|
||||
await pretty.newline();
|
||||
await pretty.dim(`Strategy: ${result.strategy}`);
|
||||
await pretty.success(`Completed in ${duration}ms`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Providers command - List available LLM providers
|
||||
*/
|
||||
|
||||
import { getAvailableProviders } from '../../llm/providers';
|
||||
import { hasApiKey } from '../runtime/env';
|
||||
import { outputJson, formatSuccess } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
|
||||
export interface ProvidersOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface ProviderInfo {
|
||||
name: string;
|
||||
available: boolean;
|
||||
hasApiKey: boolean;
|
||||
models?: string[];
|
||||
}
|
||||
|
||||
const PROVIDER_MODELS: Record<string, string[]> = {
|
||||
openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
|
||||
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022', 'claude-3-opus-20240229', 'claude-3-haiku-20240307'],
|
||||
google: ['gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash']
|
||||
};
|
||||
|
||||
export async function execute(args: string[], options: ProvidersOptions): Promise<void> {
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
const availableProviders = getAvailableProviders();
|
||||
|
||||
const providers: ProviderInfo[] = [
|
||||
{
|
||||
name: 'openai',
|
||||
available: availableProviders.includes('openai'),
|
||||
hasApiKey: hasApiKey('openai'),
|
||||
models: PROVIDER_MODELS.openai
|
||||
},
|
||||
{
|
||||
name: 'anthropic',
|
||||
available: availableProviders.includes('anthropic'),
|
||||
hasApiKey: hasApiKey('anthropic'),
|
||||
models: PROVIDER_MODELS.anthropic
|
||||
},
|
||||
{
|
||||
name: 'google',
|
||||
available: availableProviders.includes('google'),
|
||||
hasApiKey: hasApiKey('google'),
|
||||
models: PROVIDER_MODELS.google
|
||||
}
|
||||
];
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
providers: providers.map(p => ({
|
||||
name: p.name,
|
||||
available: p.available,
|
||||
has_api_key: p.hasApiKey,
|
||||
models: options.verbose ? p.models : undefined
|
||||
}))
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Available Providers');
|
||||
|
||||
for (const provider of providers) {
|
||||
const status = provider.available ? '✅' : '❌';
|
||||
const keyStatus = provider.hasApiKey ? '🔑' : '⚠️ No API key';
|
||||
|
||||
await pretty.plain(` ${status} ${provider.name} ${keyStatus}`);
|
||||
|
||||
if (options.verbose && provider.models) {
|
||||
for (const model of provider.models) {
|
||||
await pretty.dim(` - ${model}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await pretty.newline();
|
||||
await pretty.info('Set API keys via environment variables:');
|
||||
await pretty.dim(' OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Query Rewrite command - Rewrite queries for better search results
|
||||
*/
|
||||
|
||||
import { createQueryRewriterAgent } from '../../agent/query-rewriter';
|
||||
import { resolveConfig } from '../config/resolve';
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface QueryRewriteOptions {
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
strategy?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: QueryRewriteOptions): Promise<void> {
|
||||
const query = args.join(' ');
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
const config = resolveConfig(options);
|
||||
|
||||
if (!query) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a query to rewrite'));
|
||||
} else {
|
||||
await pretty.error('Please provide a query to rewrite');
|
||||
await pretty.dim('Usage: praisonai-ts query-rewrite "your search query"');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
if (outputFormat !== 'json') {
|
||||
await pretty.info(`Rewriting query: "${query}"`);
|
||||
}
|
||||
|
||||
const agent = createQueryRewriterAgent({
|
||||
llm: config.model,
|
||||
verbose: options.verbose,
|
||||
defaultStrategy: options.strategy as any
|
||||
});
|
||||
|
||||
const result = await agent.rewrite(query);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
originalQuery: query,
|
||||
rewrittenQueries: result.rewritten,
|
||||
strategy: result.strategy,
|
||||
confidence: result.confidence
|
||||
}, {
|
||||
duration_ms: duration,
|
||||
model: config.model
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Rewritten Queries');
|
||||
for (let i = 0; i < result.rewritten.length; i++) {
|
||||
await pretty.plain(` ${i + 1}. ${result.rewritten[i]}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.dim(`Strategy: ${result.strategy}`);
|
||||
await pretty.success(`Completed in ${duration}ms`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* CLI command: repo-map
|
||||
* Repository structure visualization and symbol extraction
|
||||
*/
|
||||
|
||||
import { RepoMap, createRepoMap, getRepoTree } from '../features/repo-map';
|
||||
|
||||
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
|
||||
const subcommand = args[0] || 'tree';
|
||||
const isJson = Boolean(options.output === 'json' || options.json);
|
||||
|
||||
switch (subcommand) {
|
||||
case 'tree':
|
||||
await handleTree(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'symbols':
|
||||
await handleSymbols(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
if (subcommand !== 'help' && !subcommand.startsWith('-')) {
|
||||
// Treat as path for tree command
|
||||
await handleTree([subcommand, ...args.slice(1)], options, isJson);
|
||||
} else {
|
||||
showHelp(isJson);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTree(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const rootPath = args[0] || process.cwd();
|
||||
const maxDepth = (options.depth as number) || 5;
|
||||
const includeSymbols = Boolean(options.symbols);
|
||||
|
||||
const map = createRepoMap({
|
||||
rootPath,
|
||||
maxDepth,
|
||||
includeSymbols
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await map.generate();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
root: result.root,
|
||||
totalFiles: result.totalFiles,
|
||||
totalDirectories: result.totalDirectories,
|
||||
symbolCount: result.symbols.length
|
||||
}, null, 2));
|
||||
} else {
|
||||
console.log(result.summary);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: false, error: error.message }));
|
||||
} else {
|
||||
console.error(`Error: ${error.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSymbols(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const rootPath = args[0] || process.cwd();
|
||||
const maxDepth = (options.depth as number) || 5;
|
||||
|
||||
const map = createRepoMap({
|
||||
rootPath,
|
||||
maxDepth,
|
||||
includeSymbols: true
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await map.generate();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
symbols: result.symbols,
|
||||
count: result.symbols.length
|
||||
}, null, 2));
|
||||
} else {
|
||||
console.log(`Symbols in ${rootPath}:\n`);
|
||||
|
||||
const byType: Record<string, typeof result.symbols> = {};
|
||||
for (const sym of result.symbols) {
|
||||
if (!byType[sym.type]) byType[sym.type] = [];
|
||||
byType[sym.type].push(sym);
|
||||
}
|
||||
|
||||
for (const [type, symbols] of Object.entries(byType)) {
|
||||
console.log(`${type}s (${symbols.length}):`);
|
||||
for (const sym of symbols.slice(0, 20)) {
|
||||
const exported = sym.exported ? ' [exported]' : '';
|
||||
console.log(` ${sym.name}${exported} (line ${sym.line})`);
|
||||
}
|
||||
if (symbols.length > 20) {
|
||||
console.log(` ... and ${symbols.length - 20} more`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: false, error: error.message }));
|
||||
} else {
|
||||
console.error(`Error: ${error.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(isJson: boolean): void {
|
||||
const help = {
|
||||
command: 'repo-map',
|
||||
description: 'Repository structure visualization and symbol extraction',
|
||||
subcommands: {
|
||||
tree: 'Show repository tree structure',
|
||||
symbols: 'Extract and list code symbols'
|
||||
},
|
||||
flags: {
|
||||
'--depth': 'Maximum directory depth (default: 5)',
|
||||
'--symbols': 'Include symbols in tree output',
|
||||
'--json': 'Output in JSON format'
|
||||
},
|
||||
examples: [
|
||||
'praisonai-ts repo-map',
|
||||
'praisonai-ts repo-map tree ./src',
|
||||
'praisonai-ts repo-map tree --depth 3',
|
||||
'praisonai-ts repo-map symbols ./src',
|
||||
'praisonai-ts repo-map tree --symbols'
|
||||
]
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(help, null, 2));
|
||||
} else {
|
||||
console.log('Repo Map - Repository visualization\n');
|
||||
console.log('Subcommands:');
|
||||
for (const [cmd, desc] of Object.entries(help.subcommands)) {
|
||||
console.log(` ${cmd.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nFlags:');
|
||||
for (const [flag, desc] of Object.entries(help.flags)) {
|
||||
console.log(` ${flag.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nExamples:');
|
||||
for (const ex of help.examples) {
|
||||
console.log(` ${ex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Reranker command - Document reranking
|
||||
*/
|
||||
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface RerankerOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: RerankerOptions): Promise<void> {
|
||||
const action = args[0] || 'help';
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'providers':
|
||||
await listProviders(outputFormat);
|
||||
break;
|
||||
case 'info':
|
||||
await showInfo(outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function showInfo(outputFormat: string): Promise<void> {
|
||||
const info = {
|
||||
feature: 'Reranker',
|
||||
description: 'Rerank search results for improved relevance',
|
||||
providers: [
|
||||
{ name: 'CohereReranker', description: 'Cohere reranking API' },
|
||||
{ name: 'CrossEncoderReranker', description: 'Cross-encoder model reranking' },
|
||||
{ name: 'LLMReranker', description: 'LLM-based reranking' }
|
||||
],
|
||||
capabilities: [
|
||||
'Rerank search results by relevance',
|
||||
'Improve RAG retrieval quality',
|
||||
'Multiple reranking strategies',
|
||||
'Configurable top-k selection'
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(info));
|
||||
} else {
|
||||
await pretty.heading('Reranker');
|
||||
await pretty.plain(info.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Providers:');
|
||||
for (const p of info.providers) {
|
||||
await pretty.plain(` • ${p.name}: ${p.description}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.plain('Capabilities:');
|
||||
for (const cap of info.capabilities) {
|
||||
await pretty.plain(` • ${cap}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listProviders(outputFormat: string): Promise<void> {
|
||||
const providers = [
|
||||
{ name: 'cohere', description: 'Cohere reranking API', available: true },
|
||||
{ name: 'cross-encoder', description: 'Cross-encoder model', available: true },
|
||||
{ name: 'llm', description: 'LLM-based reranking', available: true }
|
||||
];
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ providers }));
|
||||
} else {
|
||||
await pretty.heading('Reranker Providers');
|
||||
for (const p of providers) {
|
||||
const status = p.available ? '✓' : '✗';
|
||||
await pretty.plain(` ${status} ${p.name.padEnd(15)} ${p.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'reranker',
|
||||
description: 'Document reranking for improved search relevance',
|
||||
subcommands: [
|
||||
{ name: 'info', description: 'Show reranker feature information' },
|
||||
{ name: 'providers', description: 'List available reranker providers' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Reranker Command');
|
||||
await pretty.plain(help.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Research command - Deep research agent
|
||||
*/
|
||||
|
||||
import { createDeepResearchAgent } from '../../agent/research';
|
||||
import { resolveConfig } from '../config/resolve';
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface ResearchOptions {
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
depth?: number;
|
||||
maxSources?: number;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: ResearchOptions): Promise<void> {
|
||||
const query = args.join(' ');
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
const config = resolveConfig(options);
|
||||
|
||||
if (!query) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a research query'));
|
||||
} else {
|
||||
await pretty.error('Please provide a research query');
|
||||
await pretty.dim('Usage: praisonai-ts research "What are the latest AI trends?"');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
if (outputFormat !== 'json') {
|
||||
await pretty.info(`Researching: "${query}"`);
|
||||
}
|
||||
|
||||
const agent = createDeepResearchAgent({
|
||||
llm: config.model,
|
||||
verbose: options.verbose,
|
||||
maxIterations: options.depth
|
||||
});
|
||||
|
||||
const result = await agent.research(query);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
query,
|
||||
answer: result.answer,
|
||||
citations: result.citations,
|
||||
reasoning: result.reasoning,
|
||||
confidence: result.confidence
|
||||
}, {
|
||||
duration_ms: duration,
|
||||
model: config.model
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Research Results');
|
||||
await pretty.newline();
|
||||
await pretty.plain(result.answer);
|
||||
|
||||
if (result.citations && result.citations.length > 0) {
|
||||
await pretty.newline();
|
||||
await pretty.plain('Citations:');
|
||||
for (const citation of result.citations) {
|
||||
await pretty.dim(` • ${citation.title || citation.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
await pretty.newline();
|
||||
await pretty.success(`Completed in ${duration}ms`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Router command - Route requests to appropriate agents
|
||||
*/
|
||||
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface RouterOptions {
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
routes?: string;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: RouterOptions): Promise<void> {
|
||||
const action = args[0] || 'help';
|
||||
const actionArgs = args.slice(1);
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'analyze':
|
||||
await analyzeInput(actionArgs, outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeInput(args: string[], outputFormat: string): Promise<void> {
|
||||
const input = args.join(' ');
|
||||
if (!input) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide input to analyze'));
|
||||
} else {
|
||||
await pretty.error('Please provide input to analyze');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
if (outputFormat !== 'json') {
|
||||
await pretty.info(`Analyzing routing for: "${input}"`);
|
||||
}
|
||||
|
||||
// Simple rule-based routing analysis
|
||||
const routes = [
|
||||
{ name: 'greeting', pattern: /\b(hello|hi|hey|greetings)\b/i, priority: 1 },
|
||||
{ name: 'question', pattern: /\?$/, priority: 2 },
|
||||
{ name: 'code', pattern: /\b(code|function|class|implement|debug)\b/i, priority: 3 },
|
||||
{ name: 'research', pattern: /\b(research|analyze|investigate|study)\b/i, priority: 3 },
|
||||
{ name: 'creative', pattern: /\b(write|create|generate|compose)\b/i, priority: 2 },
|
||||
{ name: 'general', pattern: /.*/, priority: 0 }
|
||||
];
|
||||
|
||||
const matches = routes
|
||||
.filter(r => r.pattern.test(input))
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
const selectedRoute = matches[0]?.name || 'general';
|
||||
const confidence = matches[0]?.priority ? Math.min(0.5 + matches[0].priority * 0.15, 0.95) : 0.5;
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
input,
|
||||
selectedRoute,
|
||||
confidence,
|
||||
matchedRoutes: matches.map(m => m.name)
|
||||
}, {
|
||||
duration_ms: duration
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Routing Analysis');
|
||||
await pretty.plain(`Input: "${input}"`);
|
||||
await pretty.plain(`Selected Route: ${selectedRoute}`);
|
||||
await pretty.plain(`Confidence: ${(confidence * 100).toFixed(1)}%`);
|
||||
await pretty.plain(`Matched Routes: ${matches.map(m => m.name).join(', ')}`);
|
||||
await pretty.newline();
|
||||
await pretty.success(`Analyzed in ${duration}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'router',
|
||||
description: 'Route requests to appropriate agents based on content analysis',
|
||||
subcommands: [
|
||||
{ name: 'analyze <input>', description: 'Analyze input and suggest routing' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
],
|
||||
usage: 'For programmatic routing with actual agents, use the RouterAgent SDK class'
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Router Command');
|
||||
await pretty.plain('Route requests to appropriate agents\n');
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(25)} ${cmd.description}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.dim('Note: For full routing with agents, use the RouterAgent SDK class');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Run command - Run an agent with a task
|
||||
*/
|
||||
|
||||
import { Agent } from '../../agent';
|
||||
import { resolveConfig } from '../config/resolve';
|
||||
import { printSuccess, printError, outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES, normalizeError } from '../output/errors';
|
||||
|
||||
export interface RunOptions {
|
||||
agent?: string;
|
||||
tools?: string;
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
profile?: string;
|
||||
config?: string;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: RunOptions): Promise<void> {
|
||||
const task = args[0];
|
||||
|
||||
if (!task) {
|
||||
if (options.json || options.output === 'json') {
|
||||
printError(ERROR_CODES.MISSING_ARG, 'Please provide a task');
|
||||
} else {
|
||||
await pretty.error('Please provide a task');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
// Resolve config with precedence
|
||||
const config = resolveConfig({
|
||||
configPath: options.config,
|
||||
profile: options.profile,
|
||||
model: options.model,
|
||||
verbose: options.verbose
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
// Parse tools if provided
|
||||
const tools: string[] = options.tools ? options.tools.split(',').map(t => t.trim()) : [];
|
||||
|
||||
// Create agent
|
||||
const agent = new Agent({
|
||||
name: 'CLI Agent',
|
||||
instructions: 'You are a helpful AI assistant.',
|
||||
llm: config.model,
|
||||
verbose: config.verbose
|
||||
});
|
||||
|
||||
// Execute task
|
||||
const result = await agent.start(task);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(
|
||||
{
|
||||
result: result,
|
||||
agent: 'CLI Agent',
|
||||
task: task
|
||||
},
|
||||
{
|
||||
duration_ms: duration,
|
||||
model: config.model
|
||||
}
|
||||
));
|
||||
} else {
|
||||
if (typeof result === 'string') {
|
||||
console.log(result);
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const cliError = normalizeError(error);
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(cliError.code, cliError.message, cliError.details));
|
||||
} else {
|
||||
await pretty.error(cliError.message);
|
||||
if (config.verbose && error instanceof Error && error.stack) {
|
||||
await pretty.dim(error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(cliError.exitCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* CLI command: sandbox
|
||||
* Safe command execution with restrictions
|
||||
*/
|
||||
|
||||
import { SandboxExecutor, createSandboxExecutor, CommandValidator, type SandboxMode } from '../features/sandbox-executor';
|
||||
|
||||
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
|
||||
const subcommand = args[0] || 'help';
|
||||
const isJson = Boolean(options.output === 'json' || options.json);
|
||||
|
||||
switch (subcommand) {
|
||||
case 'exec':
|
||||
await handleExec(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'check':
|
||||
await handleCheck(args.slice(1), isJson);
|
||||
break;
|
||||
case 'mode':
|
||||
await handleMode(args.slice(1), isJson);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
showHelp(isJson);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExec(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const command = args.join(' ');
|
||||
if (!command) {
|
||||
console.error('Error: Command is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mode = (options.mode as SandboxMode) || 'basic';
|
||||
const timeout = (options.timeout as number) || 30000;
|
||||
|
||||
const executor = createSandboxExecutor({ mode, timeout });
|
||||
const result = await executor.execute(command);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
if (result.success) {
|
||||
console.log('✓ Command executed successfully');
|
||||
console.log(`Duration: ${result.duration}ms`);
|
||||
if (result.stdout) {
|
||||
console.log('\nOutput:');
|
||||
console.log(result.stdout);
|
||||
}
|
||||
} else {
|
||||
console.error('✗ Command failed');
|
||||
if (result.stderr) console.error(result.stderr);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.success) process.exit(result.exitCode || 1);
|
||||
}
|
||||
|
||||
async function handleCheck(args: string[], isJson: boolean): Promise<void> {
|
||||
const command = args.join(' ');
|
||||
if (!command) {
|
||||
console.error('Error: Command is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const validator = new CommandValidator();
|
||||
const result = validator.validate(command);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ command, ...result }, null, 2));
|
||||
} else {
|
||||
if (result.valid) {
|
||||
console.log(`✓ Command is allowed: ${command}`);
|
||||
} else {
|
||||
console.log(`✗ Command blocked: ${command}`);
|
||||
console.log(` Reason: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMode(args: string[], isJson: boolean): Promise<void> {
|
||||
const modes: Record<SandboxMode, string> = {
|
||||
disabled: 'No execution allowed',
|
||||
basic: 'Basic validation, blocks dangerous patterns',
|
||||
strict: 'Minimal environment, restricted PATH',
|
||||
'network-isolated': 'No network access (proxy blocked)'
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, modes }, null, 2));
|
||||
} else {
|
||||
console.log('Sandbox Modes:\n');
|
||||
for (const [mode, desc] of Object.entries(modes)) {
|
||||
console.log(` ${mode.padEnd(18)} ${desc}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(isJson: boolean): void {
|
||||
const help = {
|
||||
command: 'sandbox',
|
||||
description: 'Safe command execution with restrictions',
|
||||
subcommands: {
|
||||
exec: 'Execute a command in sandbox',
|
||||
check: 'Check if a command would be allowed',
|
||||
mode: 'List available sandbox modes'
|
||||
},
|
||||
flags: {
|
||||
'--mode': 'Sandbox mode (disabled, basic, strict, network-isolated)',
|
||||
'--timeout': 'Execution timeout in ms (default: 30000)',
|
||||
'--json': 'Output in JSON format'
|
||||
},
|
||||
examples: [
|
||||
'praisonai-ts sandbox exec "ls -la"',
|
||||
'praisonai-ts sandbox exec "npm install" --mode strict',
|
||||
'praisonai-ts sandbox check "rm -rf /"',
|
||||
'praisonai-ts sandbox mode'
|
||||
]
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(help, null, 2));
|
||||
} else {
|
||||
console.log('Sandbox - Safe command execution\n');
|
||||
console.log('Subcommands:');
|
||||
for (const [cmd, desc] of Object.entries(help.subcommands)) {
|
||||
console.log(` ${cmd.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nFlags:');
|
||||
for (const [flag, desc] of Object.entries(help.flags)) {
|
||||
console.log(` ${flag.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nExamples:');
|
||||
for (const ex of help.examples) {
|
||||
console.log(` ${ex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* CLI command: scheduler
|
||||
* Agent task scheduling with cron-like patterns
|
||||
*/
|
||||
|
||||
import { Scheduler, createScheduler, cronExpressions } from '../features/scheduler';
|
||||
|
||||
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
|
||||
const subcommand = args[0] || 'help';
|
||||
const isJson = Boolean(options.output === 'json' || options.json);
|
||||
|
||||
switch (subcommand) {
|
||||
case 'create':
|
||||
await handleCreate(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'list':
|
||||
await handleList(options, isJson);
|
||||
break;
|
||||
case 'run':
|
||||
await handleRun(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'remove':
|
||||
await handleRemove(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'enable':
|
||||
await handleEnable(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'disable':
|
||||
await handleDisable(args.slice(1), options, isJson);
|
||||
break;
|
||||
case 'patterns':
|
||||
await handlePatterns(isJson);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
showHelp(isJson);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const name = args[0];
|
||||
const cron = options.cron as string | undefined;
|
||||
const interval = options.interval as number | undefined;
|
||||
|
||||
if (!name) {
|
||||
const error = { error: 'Task name is required' };
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(error));
|
||||
} else {
|
||||
console.error('Error: Task name is required');
|
||||
console.log('Usage: praisonai-ts scheduler create <name> --cron "* * * * *"');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!cron && !interval) {
|
||||
const error = { error: 'Either --cron or --interval is required' };
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(error));
|
||||
} else {
|
||||
console.error('Error: Either --cron or --interval is required');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scheduler = createScheduler();
|
||||
const id = scheduler.add({
|
||||
name,
|
||||
cron,
|
||||
interval: interval ? interval * 1000 : undefined,
|
||||
task: async () => {
|
||||
console.log(`Task ${name} executed at ${new Date().toISOString()}`);
|
||||
return { executed: true };
|
||||
}
|
||||
});
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
id,
|
||||
name,
|
||||
cron,
|
||||
interval,
|
||||
message: `Task '${name}' created with ID: ${id}`
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`✓ Task '${name}' created`);
|
||||
console.log(` ID: ${id}`);
|
||||
if (cron) console.log(` Cron: ${cron}`);
|
||||
if (interval) console.log(` Interval: ${interval}s`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleList(options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const scheduler = createScheduler();
|
||||
const tasks = scheduler.getAllTasks();
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, tasks }, null, 2));
|
||||
} else {
|
||||
if (tasks.length === 0) {
|
||||
console.log('No scheduled tasks');
|
||||
} else {
|
||||
console.log('Scheduled Tasks:');
|
||||
for (const task of tasks) {
|
||||
console.log(` ${task.name} (${task.id})`);
|
||||
console.log(` Status: ${task.status}`);
|
||||
console.log(` Enabled: ${task.enabled}`);
|
||||
console.log(` Run count: ${task.runCount}`);
|
||||
if (task.nextRun) console.log(` Next run: ${task.nextRun.toISOString()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRun(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) {
|
||||
console.error('Error: Task ID is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scheduler = createScheduler();
|
||||
try {
|
||||
const result = await scheduler.runNow(id);
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, result }, null, 2));
|
||||
} else {
|
||||
console.log(`✓ Task ${id} executed successfully`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: false, error: error.message }));
|
||||
} else {
|
||||
console.error(`Error: ${error.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) {
|
||||
console.error('Error: Task ID is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scheduler = createScheduler();
|
||||
const removed = scheduler.remove(id);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: removed, id }));
|
||||
} else {
|
||||
if (removed) {
|
||||
console.log(`✓ Task ${id} removed`);
|
||||
} else {
|
||||
console.error(`Task ${id} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnable(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) {
|
||||
console.error('Error: Task ID is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scheduler = createScheduler();
|
||||
const enabled = scheduler.enable(id);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: enabled, id }));
|
||||
} else {
|
||||
console.log(enabled ? `✓ Task ${id} enabled` : `Task ${id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
|
||||
const id = args[0];
|
||||
if (!id) {
|
||||
console.error('Error: Task ID is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scheduler = createScheduler();
|
||||
const disabled = scheduler.disable(id);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: disabled, id }));
|
||||
} else {
|
||||
console.log(disabled ? `✓ Task ${id} disabled` : `Task ${id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePatterns(isJson: boolean): Promise<void> {
|
||||
const patterns = {
|
||||
everyMinute: cronExpressions.everyMinute,
|
||||
every5Minutes: cronExpressions.every5Minutes,
|
||||
every15Minutes: cronExpressions.every15Minutes,
|
||||
everyHour: cronExpressions.everyHour,
|
||||
everyDay: cronExpressions.everyDay,
|
||||
everyWeek: cronExpressions.everyWeek,
|
||||
everyMonth: cronExpressions.everyMonth,
|
||||
weekdays: cronExpressions.weekdays,
|
||||
weekends: cronExpressions.weekends
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify({ success: true, patterns }, null, 2));
|
||||
} else {
|
||||
console.log('Common Cron Patterns:');
|
||||
for (const [name, pattern] of Object.entries(patterns)) {
|
||||
console.log(` ${name}: ${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(isJson: boolean): void {
|
||||
const help = {
|
||||
command: 'scheduler',
|
||||
description: 'Agent task scheduling with cron-like patterns',
|
||||
subcommands: {
|
||||
create: 'Create a scheduled task',
|
||||
list: 'List all scheduled tasks',
|
||||
run: 'Run a task immediately',
|
||||
remove: 'Remove a scheduled task',
|
||||
enable: 'Enable a task',
|
||||
disable: 'Disable a task',
|
||||
patterns: 'Show common cron patterns'
|
||||
},
|
||||
flags: {
|
||||
'--cron': 'Cron expression (e.g., "*/5 * * * *")',
|
||||
'--interval': 'Interval in seconds',
|
||||
'--json': 'Output in JSON format'
|
||||
},
|
||||
examples: [
|
||||
'praisonai-ts scheduler create my-task --cron "*/5 * * * *"',
|
||||
'praisonai-ts scheduler create backup --interval 3600',
|
||||
'praisonai-ts scheduler list',
|
||||
'praisonai-ts scheduler run <task-id>',
|
||||
'praisonai-ts scheduler patterns'
|
||||
]
|
||||
};
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(help, null, 2));
|
||||
} else {
|
||||
console.log('Scheduler - Agent task scheduling\n');
|
||||
console.log('Subcommands:');
|
||||
for (const [cmd, desc] of Object.entries(help.subcommands)) {
|
||||
console.log(` ${cmd.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nFlags:');
|
||||
for (const [flag, desc] of Object.entries(help.flags)) {
|
||||
console.log(` ${flag.padEnd(12)} ${desc}`);
|
||||
}
|
||||
console.log('\nExamples:');
|
||||
for (const ex of help.examples) {
|
||||
console.log(` ${ex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Session command - Manage agent sessions
|
||||
*/
|
||||
|
||||
import { Session, SessionManager, getSessionManager } from '../../session';
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface SessionOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: SessionOptions): Promise<void> {
|
||||
const action = args[0] || 'list';
|
||||
const actionArgs = args.slice(1);
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
const manager = getSessionManager();
|
||||
|
||||
switch (action) {
|
||||
case 'list':
|
||||
await listSessions(manager, outputFormat);
|
||||
break;
|
||||
case 'create':
|
||||
await createSession(manager, actionArgs, outputFormat);
|
||||
break;
|
||||
case 'get':
|
||||
await getSession(manager, actionArgs, outputFormat);
|
||||
break;
|
||||
case 'delete':
|
||||
await deleteSession(manager, actionArgs, outputFormat);
|
||||
break;
|
||||
case 'export':
|
||||
await exportSession(manager, actionArgs, outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function listSessions(manager: SessionManager, outputFormat: string): Promise<void> {
|
||||
const sessions = manager.list();
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
sessions: sessions.map((s: Session) => ({
|
||||
id: s.id,
|
||||
messageCount: s.messages.length,
|
||||
createdAt: s.createdAt
|
||||
})),
|
||||
count: sessions.length
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Sessions');
|
||||
if (sessions.length === 0) {
|
||||
await pretty.info('No sessions found');
|
||||
} else {
|
||||
for (const session of sessions) {
|
||||
await pretty.plain(` • ${session.id}`);
|
||||
await pretty.dim(` Messages: ${session.messages.length}`);
|
||||
}
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.info(`Total: ${sessions.length} sessions`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(manager: SessionManager, args: string[], outputFormat: string): Promise<void> {
|
||||
const sessionId = args[0];
|
||||
const session = manager.create({ id: sessionId });
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
created: true,
|
||||
sessionId: session.id
|
||||
}));
|
||||
} else {
|
||||
await pretty.success(`Session created: ${session.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getSession(manager: SessionManager, args: string[], outputFormat: string): Promise<void> {
|
||||
const sessionId = args[0];
|
||||
if (!sessionId) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a session ID'));
|
||||
} else {
|
||||
await pretty.error('Please provide a session ID');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const session = manager.get(sessionId);
|
||||
if (!session) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, `Session not found: ${sessionId}`));
|
||||
} else {
|
||||
await pretty.error(`Session not found: ${sessionId}`);
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
|
||||
const messages = session.messages;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
id: session.id,
|
||||
messages,
|
||||
messageCount: messages.length
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading(`Session: ${session.id}`);
|
||||
await pretty.plain(`Messages: ${messages.length}`);
|
||||
await pretty.newline();
|
||||
for (const msg of messages.slice(-10)) {
|
||||
const content = msg.content || '';
|
||||
await pretty.plain(` [${msg.role}] ${content.substring(0, 80)}${content.length > 80 ? '...' : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(manager: SessionManager, args: string[], outputFormat: string): Promise<void> {
|
||||
const sessionId = args[0];
|
||||
if (!sessionId) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a session ID'));
|
||||
} else {
|
||||
await pretty.error('Please provide a session ID');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const deleted = manager.delete(sessionId);
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ deleted, sessionId }));
|
||||
} else {
|
||||
if (deleted) {
|
||||
await pretty.success(`Session deleted: ${sessionId}`);
|
||||
} else {
|
||||
await pretty.warn(`Session not found: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSession(manager: SessionManager, args: string[], outputFormat: string): Promise<void> {
|
||||
const sessionId = args[0];
|
||||
if (!sessionId) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a session ID'));
|
||||
} else {
|
||||
await pretty.error('Please provide a session ID');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const session = manager.get(sessionId);
|
||||
if (!session) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, `Session not found: ${sessionId}`));
|
||||
} else {
|
||||
await pretty.error(`Session not found: ${sessionId}`);
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
|
||||
const exported = session.toJSON();
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(exported));
|
||||
} else {
|
||||
console.log(JSON.stringify(exported, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'session',
|
||||
subcommands: [
|
||||
{ name: 'list', description: 'List all sessions' },
|
||||
{ name: 'create [id]', description: 'Create a new session' },
|
||||
{ name: 'get <id>', description: 'Get session details' },
|
||||
{ name: 'delete <id>', description: 'Delete a session' },
|
||||
{ name: 'export <id>', description: 'Export session data' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Session Command');
|
||||
await pretty.plain('Manage agent sessions\n');
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Skills command - Manage agent skills
|
||||
*/
|
||||
|
||||
import { SkillManager, createSkillManager, Skill } from '../../skills';
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface SkillsOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: SkillsOptions): Promise<void> {
|
||||
const action = args[0] || 'list';
|
||||
const actionArgs = args.slice(1);
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
const searchPath = actionArgs[0] || '.';
|
||||
const manager = createSkillManager({ paths: [searchPath] });
|
||||
|
||||
switch (action) {
|
||||
case 'list':
|
||||
await listSkills(manager, outputFormat);
|
||||
break;
|
||||
case 'discover':
|
||||
await discoverSkills(manager, actionArgs, outputFormat);
|
||||
break;
|
||||
case 'validate':
|
||||
await validateSkill(manager, actionArgs, outputFormat);
|
||||
break;
|
||||
case 'info':
|
||||
await skillInfo(manager, actionArgs, outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function listSkills(manager: SkillManager, outputFormat: string): Promise<void> {
|
||||
const skills = manager.list();
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
skills: skills.map((s: Skill) => ({
|
||||
name: s.metadata.name,
|
||||
description: s.metadata.description,
|
||||
path: s.path
|
||||
})),
|
||||
count: skills.length
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Available Skills');
|
||||
if (skills.length === 0) {
|
||||
await pretty.info('No skills loaded');
|
||||
await pretty.dim('Run "praisonai-ts skills discover <path>" to discover skills');
|
||||
} else {
|
||||
for (const skill of skills) {
|
||||
await pretty.plain(` • ${skill.metadata.name}`);
|
||||
if (skill.metadata.description) {
|
||||
await pretty.dim(` ${skill.metadata.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.info(`Total: ${skills.length} skills`);
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverSkills(manager: SkillManager, args: string[], outputFormat: string): Promise<void> {
|
||||
const searchPath = args[0] || '.';
|
||||
|
||||
const discovered = await manager.discover();
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
discovered: discovered.map((s: Skill) => ({
|
||||
name: s.metadata.name,
|
||||
description: s.metadata.description,
|
||||
path: s.path
|
||||
})),
|
||||
count: discovered.length,
|
||||
searchPath
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading(`Discovered Skills in: ${searchPath}`);
|
||||
if (discovered.length === 0) {
|
||||
await pretty.info('No skills found');
|
||||
} else {
|
||||
for (const skill of discovered) {
|
||||
await pretty.plain(` • ${skill.metadata.name}`);
|
||||
if (skill.metadata.description) {
|
||||
await pretty.dim(` ${skill.metadata.description}`);
|
||||
}
|
||||
if (skill.path) {
|
||||
await pretty.dim(` Path: ${skill.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.info(`Found: ${discovered.length} skills`);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateSkill(manager: SkillManager, args: string[], outputFormat: string): Promise<void> {
|
||||
const skillPath = args[0];
|
||||
if (!skillPath) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a skill path'));
|
||||
} else {
|
||||
await pretty.error('Please provide a skill path');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
try {
|
||||
// Load the skill first, then validate
|
||||
const skill = await manager.loadSkill(skillPath);
|
||||
const result = manager.validate(skill);
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
valid: result.valid,
|
||||
path: skillPath,
|
||||
errors: result.errors || []
|
||||
}));
|
||||
} else {
|
||||
if (result.valid) {
|
||||
await pretty.success(`Skill is valid: ${skillPath}`);
|
||||
} else {
|
||||
await pretty.error(`Skill validation failed: ${skillPath}`);
|
||||
if (result.errors) {
|
||||
for (const err of result.errors) {
|
||||
await pretty.plain(` ❌ ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(`Validation error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function skillInfo(manager: SkillManager, args: string[], outputFormat: string): Promise<void> {
|
||||
const skillName = args[0];
|
||||
if (!skillName) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a skill name'));
|
||||
} else {
|
||||
await pretty.error('Please provide a skill name');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
const skill = manager.get(skillName);
|
||||
if (!skill) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, `Skill not found: ${skillName}`));
|
||||
} else {
|
||||
await pretty.error(`Skill not found: ${skillName}`);
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
name: skill.metadata.name,
|
||||
description: skill.metadata.description,
|
||||
path: skill.path,
|
||||
metadata: skill.metadata
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading(`Skill: ${skill.metadata.name}`);
|
||||
await pretty.keyValue({
|
||||
'Name': skill.metadata.name,
|
||||
'Description': skill.metadata.description || 'No description',
|
||||
'Path': skill.path || 'N/A'
|
||||
});
|
||||
if (skill.metadata.metadata && Object.keys(skill.metadata.metadata).length > 0) {
|
||||
await pretty.newline();
|
||||
await pretty.plain('Metadata:');
|
||||
await pretty.plain(JSON.stringify(skill.metadata.metadata, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'skills',
|
||||
subcommands: [
|
||||
{ name: 'list', description: 'List loaded skills' },
|
||||
{ name: 'discover <path>', description: 'Discover skills in directory' },
|
||||
{ name: 'validate <path>', description: 'Validate a skill' },
|
||||
{ name: 'info <name>', description: 'Show skill information' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Skills Command');
|
||||
await pretty.plain('Manage agent skills\n');
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Telemetry command - Usage monitoring and analytics
|
||||
*/
|
||||
|
||||
import { getTelemetry, enableTelemetry, disableTelemetry, cleanupTelemetry } from '../../telemetry';
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface TelemetryOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: TelemetryOptions): Promise<void> {
|
||||
const action = args[0] || 'status';
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'enable':
|
||||
enableTelemetry();
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ enabled: true }));
|
||||
} else {
|
||||
await pretty.success('Telemetry enabled');
|
||||
}
|
||||
break;
|
||||
case 'disable':
|
||||
disableTelemetry();
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ enabled: false }));
|
||||
} else {
|
||||
await pretty.success('Telemetry disabled');
|
||||
}
|
||||
break;
|
||||
case 'status':
|
||||
await showStatus(outputFormat);
|
||||
break;
|
||||
case 'clear':
|
||||
cleanupTelemetry();
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ cleared: true }));
|
||||
} else {
|
||||
await pretty.success('Telemetry data cleared');
|
||||
}
|
||||
break;
|
||||
case 'export':
|
||||
await exportTelemetry(outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function showStatus(outputFormat: string): Promise<void> {
|
||||
const telemetry = getTelemetry();
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
enabled: telemetry.isEnabled()
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Telemetry Status');
|
||||
await pretty.plain(`Enabled: ${telemetry.isEnabled() ? 'Yes' : 'No'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportTelemetry(outputFormat: string): Promise<void> {
|
||||
const telemetry = getTelemetry();
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
enabled: telemetry.isEnabled(),
|
||||
exportedAt: new Date().toISOString()
|
||||
}));
|
||||
} else {
|
||||
await pretty.plain(`Telemetry enabled: ${telemetry.isEnabled()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'telemetry',
|
||||
subcommands: [
|
||||
{ name: 'status', description: 'Show telemetry status' },
|
||||
{ name: 'enable', description: 'Enable telemetry collection' },
|
||||
{ name: 'disable', description: 'Disable telemetry collection' },
|
||||
{ name: 'clear', description: 'Clear collected telemetry data' },
|
||||
{ name: 'export', description: 'Export telemetry data' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Telemetry Command');
|
||||
await pretty.plain('Usage monitoring and analytics\n');
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(15)} ${cmd.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Tools command - List or manage tools
|
||||
*/
|
||||
|
||||
import { getRegistry, FunctionTool } from '../../tools/decorator';
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface ToolsOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface ToolInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: unknown;
|
||||
}
|
||||
|
||||
// Built-in tools that are always available
|
||||
const BUILTIN_TOOLS: ToolInfo[] = [
|
||||
{ name: 'arxiv_search', description: 'Search arXiv for academic papers' },
|
||||
{ name: 'web_search', description: 'Search the web for information' },
|
||||
{ name: 'calculator', description: 'Perform mathematical calculations' },
|
||||
{ name: 'code_executor', description: 'Execute code snippets' },
|
||||
{ name: 'file_reader', description: 'Read file contents' },
|
||||
{ name: 'file_writer', description: 'Write content to files' }
|
||||
];
|
||||
|
||||
export async function execute(args: string[], options: ToolsOptions): Promise<void> {
|
||||
const subcommand = args[0] || 'list';
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
switch (subcommand) {
|
||||
case 'list':
|
||||
await listTools(options, outputFormat);
|
||||
break;
|
||||
case 'info':
|
||||
await toolInfo(args.slice(1), options, outputFormat);
|
||||
break;
|
||||
default:
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.INVALID_ARGS, `Unknown subcommand: ${subcommand}`));
|
||||
} else {
|
||||
await pretty.error(`Unknown subcommand: ${subcommand}`);
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
}
|
||||
|
||||
async function listTools(options: ToolsOptions, outputFormat: string): Promise<void> {
|
||||
// Get registered tools from registry
|
||||
const registry = getRegistry();
|
||||
const registeredTools = registry.list();
|
||||
|
||||
// Combine built-in and registered tools
|
||||
const allTools: ToolInfo[] = [
|
||||
...BUILTIN_TOOLS,
|
||||
...registeredTools.map((t: FunctionTool) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters
|
||||
}))
|
||||
];
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
tools: allTools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: options.verbose ? t.parameters : undefined
|
||||
})),
|
||||
count: allTools.length
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading('Available Tools');
|
||||
|
||||
await pretty.plain('Built-in Tools:');
|
||||
for (const tool of BUILTIN_TOOLS) {
|
||||
await pretty.plain(` • ${tool.name}`);
|
||||
if (tool.description) {
|
||||
await pretty.dim(` ${tool.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (registeredTools.length > 0) {
|
||||
await pretty.newline();
|
||||
await pretty.plain('Registered Tools:');
|
||||
for (const tool of registeredTools) {
|
||||
await pretty.plain(` • ${tool.name}`);
|
||||
if (tool.description) {
|
||||
await pretty.dim(` ${tool.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await pretty.newline();
|
||||
await pretty.info(`Total: ${allTools.length} tools`);
|
||||
}
|
||||
}
|
||||
|
||||
async function toolInfo(args: string[], options: ToolsOptions, outputFormat: string): Promise<void> {
|
||||
const toolName = args[0];
|
||||
|
||||
if (!toolName) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a tool name'));
|
||||
} else {
|
||||
await pretty.error('Please provide a tool name');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
// Look for tool in built-in and registered tools
|
||||
const builtinTool = BUILTIN_TOOLS.find(t => t.name === toolName);
|
||||
const registry = getRegistry();
|
||||
const registeredTool = registry.get(toolName);
|
||||
|
||||
const tool = builtinTool || (registeredTool ? {
|
||||
name: registeredTool.name,
|
||||
description: registeredTool.description,
|
||||
parameters: registeredTool.parameters
|
||||
} : null);
|
||||
|
||||
if (!tool) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.TOOL_NOT_FOUND, `Tool not found: ${toolName}`));
|
||||
} else {
|
||||
await pretty.error(`Tool not found: ${toolName}`);
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({
|
||||
tool: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
type: builtinTool ? 'builtin' : 'registered'
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
await pretty.heading(`Tool: ${tool.name}`);
|
||||
await pretty.keyValue({
|
||||
'Name': tool.name,
|
||||
'Type': builtinTool ? 'Built-in' : 'Registered',
|
||||
'Description': tool.description || 'No description'
|
||||
});
|
||||
|
||||
if (tool.parameters && options.verbose) {
|
||||
await pretty.newline();
|
||||
await pretty.plain('Parameters:');
|
||||
await pretty.plain(JSON.stringify(tool.parameters, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Vector command - Vector store management
|
||||
*/
|
||||
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface VectorOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
store?: string;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: VectorOptions): Promise<void> {
|
||||
const action = args[0] || 'help';
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'providers':
|
||||
await listProviders(outputFormat);
|
||||
break;
|
||||
case 'info':
|
||||
await showInfo(outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function showInfo(outputFormat: string): Promise<void> {
|
||||
const info = {
|
||||
feature: 'Vector Stores',
|
||||
description: 'Vector database integrations for embeddings and similarity search',
|
||||
providers: [
|
||||
{ name: 'MemoryVectorStore', description: 'In-memory vector store for development' },
|
||||
{ name: 'PineconeVectorStore', description: 'Pinecone cloud vector database' },
|
||||
{ name: 'WeaviateVectorStore', description: 'Weaviate vector search engine' },
|
||||
{ name: 'QdrantVectorStore', description: 'Qdrant vector database' },
|
||||
{ name: 'ChromaVectorStore', description: 'ChromaDB embedding database' }
|
||||
],
|
||||
sdkUsage: `
|
||||
import { createMemoryVectorStore, createPineconeStore } from 'praisonai';
|
||||
|
||||
// Create a memory vector store
|
||||
const store = createMemoryVectorStore('my-store');
|
||||
|
||||
// Create index
|
||||
await store.createIndex({ indexName: 'docs', dimension: 1536 });
|
||||
|
||||
// Upsert vectors
|
||||
await store.upsert({
|
||||
indexName: 'docs',
|
||||
vectors: [{ id: '1', vector: [...], metadata: { text: 'Hello' } }]
|
||||
});
|
||||
|
||||
// Query
|
||||
const results = await store.query({
|
||||
indexName: 'docs',
|
||||
vector: [...],
|
||||
topK: 5
|
||||
});
|
||||
`
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(info));
|
||||
} else {
|
||||
await pretty.heading('Vector Stores');
|
||||
await pretty.plain(info.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Available Providers:');
|
||||
for (const p of info.providers) {
|
||||
await pretty.plain(` • ${p.name}: ${p.description}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.dim('Use the SDK for full vector store functionality');
|
||||
}
|
||||
}
|
||||
|
||||
async function listProviders(outputFormat: string): Promise<void> {
|
||||
const providers = [
|
||||
{ name: 'memory', description: 'In-memory vector store (default)', available: true },
|
||||
{ name: 'pinecone', description: 'Pinecone vector database', available: true },
|
||||
{ name: 'weaviate', description: 'Weaviate vector database', available: true },
|
||||
{ name: 'qdrant', description: 'Qdrant vector database', available: true },
|
||||
{ name: 'chroma', description: 'ChromaDB vector database', available: true }
|
||||
];
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ providers }));
|
||||
} else {
|
||||
await pretty.heading('Vector Store Providers');
|
||||
for (const p of providers) {
|
||||
const status = p.available ? '✓' : '✗';
|
||||
await pretty.plain(` ${status} ${p.name.padEnd(15)} ${p.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'vector',
|
||||
description: 'Vector store management for embeddings and similarity search',
|
||||
subcommands: [
|
||||
{ name: 'add <content>', description: 'Add document to vector store' },
|
||||
{ name: 'search <query>', description: 'Search vector store' },
|
||||
{ name: 'stats', description: 'Show vector store statistics' },
|
||||
{ name: 'providers', description: 'List available vector store providers' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
],
|
||||
flags: [
|
||||
{ name: '--store', description: 'Vector store provider (memory, pinecone, etc.)' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Vector Command');
|
||||
await pretty.plain(help.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(25)} ${cmd.description}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.plain('Flags:');
|
||||
for (const flag of help.flags) {
|
||||
await pretty.plain(` ${flag.name.padEnd(20)} ${flag.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Version command - Show CLI version
|
||||
*/
|
||||
|
||||
import { CLI_SPEC_VERSION } from '../spec/cli-spec';
|
||||
import { outputJson, formatSuccess } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
|
||||
export interface VersionOptions {
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: VersionOptions): Promise<void> {
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
// Load package.json for version
|
||||
let packageVersion = '0.0.0';
|
||||
try {
|
||||
const pkg = require('../../../package.json');
|
||||
packageVersion = pkg.version;
|
||||
} catch {
|
||||
// Fallback if package.json not found
|
||||
}
|
||||
|
||||
const versionInfo = {
|
||||
version: packageVersion,
|
||||
cli_spec: CLI_SPEC_VERSION,
|
||||
runtime: 'node',
|
||||
node_version: process.version
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(versionInfo));
|
||||
} else {
|
||||
await pretty.plain(`praisonai-ts v${packageVersion}`);
|
||||
await pretty.dim(`CLI Spec: v${CLI_SPEC_VERSION}`);
|
||||
await pretty.dim(`Node: ${process.version}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Voice command - Text-to-speech and speech-to-text
|
||||
*/
|
||||
|
||||
import { outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES } from '../output/errors';
|
||||
|
||||
export interface VoiceOptions {
|
||||
verbose?: boolean;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: VoiceOptions): Promise<void> {
|
||||
const action = args[0] || 'help';
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'providers':
|
||||
await listProviders(outputFormat);
|
||||
break;
|
||||
case 'info':
|
||||
await showInfo(outputFormat);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await showHelp(outputFormat);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
|
||||
} else {
|
||||
await pretty.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function showInfo(outputFormat: string): Promise<void> {
|
||||
const info = {
|
||||
feature: 'Voice',
|
||||
description: 'Text-to-speech and speech-to-text capabilities',
|
||||
providers: [
|
||||
{ name: 'OpenAIVoiceProvider', description: 'OpenAI TTS and Whisper' },
|
||||
{ name: 'ElevenLabsVoiceProvider', description: 'ElevenLabs voice synthesis' }
|
||||
],
|
||||
capabilities: [
|
||||
'Text-to-speech conversion',
|
||||
'Speech-to-text transcription',
|
||||
'Multiple voice options',
|
||||
'Streaming audio support'
|
||||
],
|
||||
sdkUsage: `
|
||||
import { createOpenAIVoice, createElevenLabsVoice } from 'praisonai';
|
||||
|
||||
// Create OpenAI voice provider
|
||||
const voice = createOpenAIVoice({ apiKey: process.env.OPENAI_API_KEY });
|
||||
|
||||
// Text to speech
|
||||
const audio = await voice.speak({ text: 'Hello world', voice: 'alloy' });
|
||||
|
||||
// Speech to text
|
||||
const text = await voice.listen({ audio: audioBuffer });
|
||||
`
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(info));
|
||||
} else {
|
||||
await pretty.heading('Voice');
|
||||
await pretty.plain(info.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Providers:');
|
||||
for (const p of info.providers) {
|
||||
await pretty.plain(` • ${p.name}: ${p.description}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.plain('Capabilities:');
|
||||
for (const cap of info.capabilities) {
|
||||
await pretty.plain(` • ${cap}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listProviders(outputFormat: string): Promise<void> {
|
||||
const providers = [
|
||||
{ name: 'openai', description: 'OpenAI TTS and Whisper', available: true },
|
||||
{ name: 'elevenlabs', description: 'ElevenLabs voice synthesis', available: true }
|
||||
];
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess({ providers }));
|
||||
} else {
|
||||
await pretty.heading('Voice Providers');
|
||||
for (const p of providers) {
|
||||
const status = p.available ? '✓' : '✗';
|
||||
await pretty.plain(` ${status} ${p.name.padEnd(15)} ${p.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function showHelp(outputFormat: string): Promise<void> {
|
||||
const help = {
|
||||
command: 'voice',
|
||||
description: 'Text-to-speech and speech-to-text capabilities',
|
||||
subcommands: [
|
||||
{ name: 'info', description: 'Show voice feature information' },
|
||||
{ name: 'providers', description: 'List available voice providers' },
|
||||
{ name: 'help', description: 'Show this help' }
|
||||
],
|
||||
flags: [
|
||||
{ name: '--provider', description: 'Voice provider (openai, elevenlabs)' }
|
||||
]
|
||||
};
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(help));
|
||||
} else {
|
||||
await pretty.heading('Voice Command');
|
||||
await pretty.plain(help.description);
|
||||
await pretty.newline();
|
||||
await pretty.plain('Subcommands:');
|
||||
for (const cmd of help.subcommands) {
|
||||
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
|
||||
}
|
||||
await pretty.newline();
|
||||
await pretty.plain('Flags:');
|
||||
for (const flag of help.flags) {
|
||||
await pretty.plain(` ${flag.name.padEnd(20)} ${flag.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Workflow command - Execute a multi-agent workflow
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { resolveConfig } from '../config/resolve';
|
||||
import { printSuccess, printError, outputJson, formatSuccess, formatError } from '../output/json';
|
||||
import * as pretty from '../output/pretty';
|
||||
import { EXIT_CODES } from '../spec/cli-spec';
|
||||
import { ERROR_CODES, normalizeError } from '../output/errors';
|
||||
|
||||
export interface WorkflowOptions {
|
||||
parallel?: boolean;
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
profile?: string;
|
||||
config?: string;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface WorkflowStep {
|
||||
name: string;
|
||||
agent?: string;
|
||||
task?: string;
|
||||
depends_on?: string[];
|
||||
}
|
||||
|
||||
interface WorkflowDefinition {
|
||||
name?: string;
|
||||
description?: string;
|
||||
agents?: Record<string, any>;
|
||||
steps?: WorkflowStep[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple YAML parser for workflow files
|
||||
*/
|
||||
function parseWorkflowYaml(content: string): WorkflowDefinition {
|
||||
const result: WorkflowDefinition = {};
|
||||
const lines = content.split('\n');
|
||||
let currentSection: string | null = null;
|
||||
let currentItem: any = null;
|
||||
let currentKey: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
|
||||
const indent = line.search(/\S/);
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Top-level keys
|
||||
if (indent === 0 && trimmed.includes(':')) {
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
const key = trimmed.slice(0, colonIdx).trim();
|
||||
const value = trimmed.slice(colonIdx + 1).trim();
|
||||
|
||||
if (value) {
|
||||
(result as any)[key] = value;
|
||||
} else {
|
||||
currentSection = key;
|
||||
if (key === 'agents') result.agents = {};
|
||||
if (key === 'steps') result.steps = [];
|
||||
}
|
||||
} else if (indent > 0 && currentSection) {
|
||||
// Handle list items (steps)
|
||||
if (trimmed.startsWith('- ')) {
|
||||
if (currentSection === 'steps') {
|
||||
currentItem = {};
|
||||
result.steps!.push(currentItem);
|
||||
|
||||
// Parse inline values after -
|
||||
const afterDash = trimmed.slice(2).trim();
|
||||
if (afterDash.includes(':')) {
|
||||
const colonIdx = afterDash.indexOf(':');
|
||||
const key = afterDash.slice(0, colonIdx).trim();
|
||||
const value = afterDash.slice(colonIdx + 1).trim();
|
||||
currentItem[key] = value;
|
||||
}
|
||||
}
|
||||
} else if (trimmed.includes(':') && currentItem) {
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
const key = trimmed.slice(0, colonIdx).trim();
|
||||
const value = trimmed.slice(colonIdx + 1).trim();
|
||||
currentItem[key] = value || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: WorkflowOptions): Promise<void> {
|
||||
const workflowFile = args[0];
|
||||
|
||||
if (!workflowFile) {
|
||||
if (options.json || options.output === 'json') {
|
||||
printError(ERROR_CODES.MISSING_ARG, 'Please provide a workflow file path');
|
||||
} else {
|
||||
await pretty.error('Please provide a workflow file path');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
// Resolve config
|
||||
const config = resolveConfig({
|
||||
configPath: options.config,
|
||||
profile: options.profile,
|
||||
model: options.model,
|
||||
verbose: options.verbose
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
// Read and parse workflow file
|
||||
const filePath = path.resolve(workflowFile);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Workflow file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const workflow = parseWorkflowYaml(content);
|
||||
|
||||
if (outputFormat !== 'json') {
|
||||
await pretty.heading(`Executing workflow: ${workflow.name || 'Unnamed'}`);
|
||||
if (workflow.description) {
|
||||
await pretty.info(workflow.description);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute workflow steps
|
||||
const results: Record<string, any> = {};
|
||||
const steps = workflow.steps || [];
|
||||
|
||||
for (const step of steps) {
|
||||
if (outputFormat !== 'json') {
|
||||
await pretty.info(`Running step: ${step.name}`);
|
||||
}
|
||||
|
||||
// For now, we'll simulate step execution
|
||||
// In a full implementation, this would create agents and execute tasks
|
||||
results[step.name] = {
|
||||
status: 'completed',
|
||||
output: `Step ${step.name} completed`
|
||||
};
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatSuccess(
|
||||
{
|
||||
workflow: workflow.name || 'Unnamed',
|
||||
steps: steps.length,
|
||||
results
|
||||
},
|
||||
{
|
||||
duration_ms: duration,
|
||||
model: config.model
|
||||
}
|
||||
));
|
||||
} else {
|
||||
await pretty.success(`Workflow completed in ${duration}ms`);
|
||||
await pretty.newline();
|
||||
await pretty.keyValue({
|
||||
'Steps executed': steps.length,
|
||||
'Duration': `${duration}ms`
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const cliError = normalizeError(error);
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
outputJson(formatError(cliError.code, cliError.message, cliError.details));
|
||||
} else {
|
||||
await pretty.error(cliError.message);
|
||||
if (config.verbose && error instanceof Error && error.stack) {
|
||||
await pretty.dim(error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(cliError.exitCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Autonomy Modes - Control automatic execution behavior
|
||||
*/
|
||||
|
||||
export type AutonomyMode = 'suggest' | 'auto_edit' | 'full_auto';
|
||||
|
||||
export type ActionType =
|
||||
| 'file_read'
|
||||
| 'file_write'
|
||||
| 'file_delete'
|
||||
| 'shell_command'
|
||||
| 'network_request'
|
||||
| 'git_operation'
|
||||
| 'install_package';
|
||||
|
||||
export interface ApprovalPolicy {
|
||||
action: ActionType;
|
||||
requiresApproval: boolean;
|
||||
autoApprovePatterns?: string[];
|
||||
autoDenyPatterns?: string[];
|
||||
}
|
||||
|
||||
export interface AutonomyConfig {
|
||||
mode: AutonomyMode;
|
||||
policies?: ApprovalPolicy[];
|
||||
rememberDecisions?: boolean;
|
||||
maxAutoActions?: number;
|
||||
}
|
||||
|
||||
export interface ActionRequest {
|
||||
type: ActionType;
|
||||
description: string;
|
||||
target?: string;
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ActionDecision {
|
||||
approved: boolean;
|
||||
reason?: string;
|
||||
remembered?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default policies for each mode
|
||||
*/
|
||||
export const MODE_POLICIES: Record<AutonomyMode, ApprovalPolicy[]> = {
|
||||
suggest: [
|
||||
{ action: 'file_read', requiresApproval: false },
|
||||
{ action: 'file_write', requiresApproval: true },
|
||||
{ action: 'file_delete', requiresApproval: true },
|
||||
{ action: 'shell_command', requiresApproval: true },
|
||||
{ action: 'network_request', requiresApproval: true },
|
||||
{ action: 'git_operation', requiresApproval: true },
|
||||
{ action: 'install_package', requiresApproval: true }
|
||||
],
|
||||
auto_edit: [
|
||||
{ action: 'file_read', requiresApproval: false },
|
||||
{ action: 'file_write', requiresApproval: false },
|
||||
{ action: 'file_delete', requiresApproval: true },
|
||||
{ action: 'shell_command', requiresApproval: true },
|
||||
{ action: 'network_request', requiresApproval: false },
|
||||
{ action: 'git_operation', requiresApproval: true },
|
||||
{ action: 'install_package', requiresApproval: true }
|
||||
],
|
||||
full_auto: [
|
||||
{ action: 'file_read', requiresApproval: false },
|
||||
{ action: 'file_write', requiresApproval: false },
|
||||
{ action: 'file_delete', requiresApproval: false },
|
||||
{ action: 'shell_command', requiresApproval: false },
|
||||
{ action: 'network_request', requiresApproval: false },
|
||||
{ action: 'git_operation', requiresApproval: false },
|
||||
{ action: 'install_package', requiresApproval: true }
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Autonomy Manager class
|
||||
*/
|
||||
export class AutonomyManager {
|
||||
private config: AutonomyConfig;
|
||||
private rememberedDecisions: Map<string, boolean> = new Map();
|
||||
private actionCount: number = 0;
|
||||
private promptCallback?: (request: ActionRequest) => Promise<boolean>;
|
||||
|
||||
constructor(config: Partial<AutonomyConfig> = {}) {
|
||||
this.config = {
|
||||
mode: 'suggest',
|
||||
rememberDecisions: true,
|
||||
maxAutoActions: 100,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the prompt callback for user approval
|
||||
*/
|
||||
setPromptCallback(callback: (request: ActionRequest) => Promise<boolean>): void {
|
||||
this.promptCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request approval for an action
|
||||
*/
|
||||
async requestApproval(request: ActionRequest): Promise<ActionDecision> {
|
||||
// Check if we've exceeded max auto actions
|
||||
if (this.config.maxAutoActions && this.actionCount >= this.config.maxAutoActions) {
|
||||
return {
|
||||
approved: false,
|
||||
reason: 'Maximum auto actions exceeded'
|
||||
};
|
||||
}
|
||||
|
||||
// Check remembered decisions
|
||||
const decisionKey = this.getDecisionKey(request);
|
||||
if (this.config.rememberDecisions && this.rememberedDecisions.has(decisionKey)) {
|
||||
const remembered = this.rememberedDecisions.get(decisionKey)!;
|
||||
if (remembered) this.actionCount++;
|
||||
return {
|
||||
approved: remembered,
|
||||
reason: 'Remembered decision',
|
||||
remembered: true
|
||||
};
|
||||
}
|
||||
|
||||
// Get policy for this action type
|
||||
const policy = this.getPolicy(request.type);
|
||||
|
||||
// Check auto-approve patterns
|
||||
if (policy.autoApprovePatterns && request.target) {
|
||||
for (const pattern of policy.autoApprovePatterns) {
|
||||
if (this.matchPattern(request.target, pattern)) {
|
||||
this.actionCount++;
|
||||
return { approved: true, reason: `Matched auto-approve pattern: ${pattern}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check auto-deny patterns
|
||||
if (policy.autoDenyPatterns && request.target) {
|
||||
for (const pattern of policy.autoDenyPatterns) {
|
||||
if (this.matchPattern(request.target, pattern)) {
|
||||
return { approved: false, reason: `Matched auto-deny pattern: ${pattern}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no approval required, auto-approve
|
||||
if (!policy.requiresApproval) {
|
||||
this.actionCount++;
|
||||
return { approved: true, reason: 'No approval required' };
|
||||
}
|
||||
|
||||
// Prompt user for approval
|
||||
if (this.promptCallback) {
|
||||
const approved = await this.promptCallback(request);
|
||||
|
||||
// Remember decision if enabled
|
||||
if (this.config.rememberDecisions) {
|
||||
this.rememberedDecisions.set(decisionKey, approved);
|
||||
}
|
||||
|
||||
if (approved) this.actionCount++;
|
||||
return { approved, reason: 'User decision' };
|
||||
}
|
||||
|
||||
// No prompt callback, deny by default
|
||||
return { approved: false, reason: 'No approval mechanism available' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get policy for an action type
|
||||
*/
|
||||
private getPolicy(actionType: ActionType): ApprovalPolicy {
|
||||
// Check custom policies first
|
||||
if (this.config.policies) {
|
||||
const custom = this.config.policies.find(p => p.action === actionType);
|
||||
if (custom) return custom;
|
||||
}
|
||||
|
||||
// Fall back to mode defaults
|
||||
const modePolicy = MODE_POLICIES[this.config.mode].find(p => p.action === actionType);
|
||||
return modePolicy || { action: actionType, requiresApproval: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate decision key for remembering
|
||||
*/
|
||||
private getDecisionKey(request: ActionRequest): string {
|
||||
return `${request.type}:${request.target || 'default'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a target against a pattern
|
||||
*/
|
||||
private matchPattern(target: string, pattern: string): boolean {
|
||||
// Simple glob-like matching
|
||||
const regex = new RegExp(
|
||||
'^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$'
|
||||
);
|
||||
return regex.test(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current mode
|
||||
*/
|
||||
getMode(): AutonomyMode {
|
||||
return this.config.mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mode
|
||||
*/
|
||||
setMode(mode: AutonomyMode): void {
|
||||
this.config.mode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom policy
|
||||
*/
|
||||
addPolicy(policy: ApprovalPolicy): void {
|
||||
if (!this.config.policies) {
|
||||
this.config.policies = [];
|
||||
}
|
||||
// Remove existing policy for this action
|
||||
this.config.policies = this.config.policies.filter(p => p.action !== policy.action);
|
||||
this.config.policies.push(policy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear remembered decisions
|
||||
*/
|
||||
clearRemembered(): void {
|
||||
this.rememberedDecisions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset action count
|
||||
*/
|
||||
resetActionCount(): void {
|
||||
this.actionCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get action count
|
||||
*/
|
||||
getActionCount(): number {
|
||||
return this.actionCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary of current settings
|
||||
*/
|
||||
getSummary(): string {
|
||||
const lines = [
|
||||
`Autonomy Mode: ${this.config.mode}`,
|
||||
`Actions taken: ${this.actionCount}/${this.config.maxAutoActions || 'unlimited'}`,
|
||||
`Remembered decisions: ${this.rememberedDecisions.size}`,
|
||||
'',
|
||||
'Current policies:'
|
||||
];
|
||||
|
||||
const policies = MODE_POLICIES[this.config.mode];
|
||||
for (const policy of policies) {
|
||||
const status = policy.requiresApproval ? '⚠️ requires approval' : '✓ auto-approved';
|
||||
lines.push(` ${policy.action}: ${status}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an autonomy manager
|
||||
*/
|
||||
export function createAutonomyManager(config?: Partial<AutonomyConfig>): AutonomyManager {
|
||||
return new AutonomyManager(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple approval prompt for CLI
|
||||
*/
|
||||
export async function cliApprovalPrompt(request: ActionRequest): Promise<boolean> {
|
||||
const readline = await import('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const message = `\n⚠️ Action requires approval:\n Type: ${request.type}\n ${request.description}\n Target: ${request.target || 'N/A'}\n\nApprove? (y/n): `;
|
||||
|
||||
rl.question(message, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Background Jobs - Persisted job queue with adapter support
|
||||
*/
|
||||
|
||||
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
export type JobPriority = 'low' | 'normal' | 'high' | 'critical';
|
||||
|
||||
export interface Job<T = any> {
|
||||
id: string;
|
||||
name: string;
|
||||
data: T;
|
||||
status: JobStatus;
|
||||
priority: JobPriority;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
createdAt: Date;
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
result?: any;
|
||||
error?: string;
|
||||
progress?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface JobQueueConfig {
|
||||
name?: string;
|
||||
concurrency?: number;
|
||||
maxAttempts?: number;
|
||||
retryDelay?: number;
|
||||
storage?: JobStorageAdapter;
|
||||
}
|
||||
|
||||
export interface JobStorageAdapter {
|
||||
save(job: Job): Promise<void>;
|
||||
get(id: string): Promise<Job | null>;
|
||||
getAll(): Promise<Job[]>;
|
||||
getByStatus(status: JobStatus): Promise<Job[]>;
|
||||
update(id: string, updates: Partial<Job>): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
export type JobHandler<T = any, R = any> = (job: Job<T>, context: JobContext) => Promise<R>;
|
||||
|
||||
export interface JobContext {
|
||||
updateProgress: (progress: number) => Promise<void>;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory job storage adapter
|
||||
*/
|
||||
export class MemoryJobStorage implements JobStorageAdapter {
|
||||
private jobs: Map<string, Job> = new Map();
|
||||
|
||||
async save(job: Job): Promise<void> {
|
||||
this.jobs.set(job.id, { ...job });
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Job | null> {
|
||||
return this.jobs.get(id) || null;
|
||||
}
|
||||
|
||||
async getAll(): Promise<Job[]> {
|
||||
return Array.from(this.jobs.values());
|
||||
}
|
||||
|
||||
async getByStatus(status: JobStatus): Promise<Job[]> {
|
||||
return Array.from(this.jobs.values()).filter(j => j.status === status);
|
||||
}
|
||||
|
||||
async update(id: string, updates: Partial<Job>): Promise<void> {
|
||||
const job = this.jobs.get(id);
|
||||
if (job) {
|
||||
this.jobs.set(id, { ...job, ...updates });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.jobs.delete(id);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.jobs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based job storage adapter
|
||||
*/
|
||||
export class FileJobStorage implements JobStorageAdapter {
|
||||
private filePath: string;
|
||||
private jobs: Map<string, Job> = new Map();
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.filePath = filePath;
|
||||
}
|
||||
|
||||
private async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const content = await fs.readFile(this.filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
for (const job of data) {
|
||||
job.createdAt = new Date(job.createdAt);
|
||||
if (job.startedAt) job.startedAt = new Date(job.startedAt);
|
||||
if (job.completedAt) job.completedAt = new Date(job.completedAt);
|
||||
this.jobs.set(job.id, job);
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist yet
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
const fs = await import('fs/promises');
|
||||
const data = Array.from(this.jobs.values());
|
||||
await fs.writeFile(this.filePath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
async save(job: Job): Promise<void> {
|
||||
await this.init();
|
||||
this.jobs.set(job.id, { ...job });
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Job | null> {
|
||||
await this.init();
|
||||
return this.jobs.get(id) || null;
|
||||
}
|
||||
|
||||
async getAll(): Promise<Job[]> {
|
||||
await this.init();
|
||||
return Array.from(this.jobs.values());
|
||||
}
|
||||
|
||||
async getByStatus(status: JobStatus): Promise<Job[]> {
|
||||
await this.init();
|
||||
return Array.from(this.jobs.values()).filter(j => j.status === status);
|
||||
}
|
||||
|
||||
async update(id: string, updates: Partial<Job>): Promise<void> {
|
||||
await this.init();
|
||||
const job = this.jobs.get(id);
|
||||
if (job) {
|
||||
this.jobs.set(id, { ...job, ...updates });
|
||||
await this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.init();
|
||||
this.jobs.delete(id);
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.jobs.clear();
|
||||
await this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Job Queue class
|
||||
*/
|
||||
export class JobQueue {
|
||||
private config: JobQueueConfig;
|
||||
private storage: JobStorageAdapter;
|
||||
private handlers: Map<string, JobHandler> = new Map();
|
||||
private running: boolean = false;
|
||||
private activeJobs: number = 0;
|
||||
private processInterval?: NodeJS.Timeout;
|
||||
|
||||
constructor(config: JobQueueConfig = {}) {
|
||||
this.config = {
|
||||
name: 'default',
|
||||
concurrency: 1,
|
||||
maxAttempts: 3,
|
||||
retryDelay: 1000,
|
||||
...config
|
||||
};
|
||||
this.storage = config.storage || new MemoryJobStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a job handler
|
||||
*/
|
||||
register<T = any, R = any>(name: string, handler: JobHandler<T, R>): void {
|
||||
this.handlers.set(name, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a job to the queue
|
||||
*/
|
||||
async add<T = any>(name: string, data: T, options: Partial<Job<T>> = {}): Promise<Job<T>> {
|
||||
const job: Job<T> = {
|
||||
id: `job_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
name,
|
||||
data,
|
||||
status: 'pending',
|
||||
priority: options.priority || 'normal',
|
||||
attempts: 0,
|
||||
maxAttempts: options.maxAttempts || this.config.maxAttempts || 3,
|
||||
createdAt: new Date(),
|
||||
metadata: options.metadata
|
||||
};
|
||||
|
||||
await this.storage.save(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a job by ID
|
||||
*/
|
||||
async get(id: string): Promise<Job | null> {
|
||||
return this.storage.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all jobs
|
||||
*/
|
||||
async getAll(): Promise<Job[]> {
|
||||
return this.storage.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get jobs by status
|
||||
*/
|
||||
async getByStatus(status: JobStatus): Promise<Job[]> {
|
||||
return this.storage.getByStatus(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a job
|
||||
*/
|
||||
async cancel(id: string): Promise<boolean> {
|
||||
const job = await this.storage.get(id);
|
||||
if (!job || job.status === 'running' || job.status === 'completed') {
|
||||
return false;
|
||||
}
|
||||
await this.storage.update(id, { status: 'cancelled' });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a failed job
|
||||
*/
|
||||
async retry(id: string): Promise<boolean> {
|
||||
const job = await this.storage.get(id);
|
||||
if (!job || job.status !== 'failed') {
|
||||
return false;
|
||||
}
|
||||
await this.storage.update(id, {
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
error: undefined
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start processing jobs
|
||||
*/
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.processInterval = setInterval(() => this.process(), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop processing jobs
|
||||
*/
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
if (this.processInterval) {
|
||||
clearInterval(this.processInterval);
|
||||
this.processInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pending jobs
|
||||
*/
|
||||
private async process(): Promise<void> {
|
||||
if (!this.running) return;
|
||||
if (this.activeJobs >= (this.config.concurrency || 1)) return;
|
||||
|
||||
const pendingJobs = await this.storage.getByStatus('pending');
|
||||
|
||||
// Sort by priority and creation time
|
||||
const priorityOrder: Record<JobPriority, number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
normal: 2,
|
||||
low: 3
|
||||
};
|
||||
|
||||
pendingJobs.sort((a, b) => {
|
||||
const priorityDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
return a.createdAt.getTime() - b.createdAt.getTime();
|
||||
});
|
||||
|
||||
for (const job of pendingJobs) {
|
||||
if (this.activeJobs >= (this.config.concurrency || 1)) break;
|
||||
this.runJob(job);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single job
|
||||
*/
|
||||
private async runJob(job: Job): Promise<void> {
|
||||
const handler = this.handlers.get(job.name);
|
||||
if (!handler) {
|
||||
await this.storage.update(job.id, {
|
||||
status: 'failed',
|
||||
error: `No handler registered for job type: ${job.name}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeJobs++;
|
||||
await this.storage.update(job.id, {
|
||||
status: 'running',
|
||||
startedAt: new Date(),
|
||||
attempts: job.attempts + 1
|
||||
});
|
||||
|
||||
const context: JobContext = {
|
||||
updateProgress: async (progress: number) => {
|
||||
await this.storage.update(job.id, { progress });
|
||||
},
|
||||
log: (message: string) => {
|
||||
console.log(`[Job ${job.id}] ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handler(job, context);
|
||||
await this.storage.update(job.id, {
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
result,
|
||||
progress: 100
|
||||
});
|
||||
} catch (error: any) {
|
||||
const attempts = job.attempts + 1;
|
||||
|
||||
if (attempts < job.maxAttempts) {
|
||||
// Retry after delay
|
||||
setTimeout(async () => {
|
||||
await this.storage.update(job.id, {
|
||||
status: 'pending',
|
||||
error: error.message
|
||||
});
|
||||
}, this.config.retryDelay || 1000);
|
||||
} else {
|
||||
await this.storage.update(job.id, {
|
||||
status: 'failed',
|
||||
completedAt: new Date(),
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.activeJobs--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a job immediately (bypass queue)
|
||||
*/
|
||||
async processNow(id: string): Promise<any> {
|
||||
const job = await this.storage.get(id);
|
||||
if (!job) throw new Error(`Job ${id} not found`);
|
||||
|
||||
const handler = this.handlers.get(job.name);
|
||||
if (!handler) throw new Error(`No handler for job type: ${job.name}`);
|
||||
|
||||
await this.storage.update(job.id, {
|
||||
status: 'running',
|
||||
startedAt: new Date()
|
||||
});
|
||||
|
||||
const context: JobContext = {
|
||||
updateProgress: async (progress: number) => {
|
||||
await this.storage.update(job.id, { progress });
|
||||
},
|
||||
log: console.log
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handler(job, context);
|
||||
await this.storage.update(job.id, {
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
result
|
||||
});
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
await this.storage.update(job.id, {
|
||||
status: 'failed',
|
||||
completedAt: new Date(),
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue stats
|
||||
*/
|
||||
async getStats(): Promise<{
|
||||
pending: number;
|
||||
running: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
cancelled: number;
|
||||
}> {
|
||||
const all = await this.storage.getAll();
|
||||
return {
|
||||
pending: all.filter(j => j.status === 'pending').length,
|
||||
running: all.filter(j => j.status === 'running').length,
|
||||
completed: all.filter(j => j.status === 'completed').length,
|
||||
failed: all.filter(j => j.status === 'failed').length,
|
||||
cancelled: all.filter(j => j.status === 'cancelled').length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear completed/failed jobs
|
||||
*/
|
||||
async cleanup(olderThan?: Date): Promise<number> {
|
||||
const all = await this.storage.getAll();
|
||||
let count = 0;
|
||||
|
||||
for (const job of all) {
|
||||
if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {
|
||||
if (!olderThan || (job.completedAt && job.completedAt < olderThan)) {
|
||||
await this.storage.delete(job.id);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a job queue
|
||||
*/
|
||||
export function createJobQueue(config?: JobQueueConfig): JobQueue {
|
||||
return new JobQueue(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a file-based job storage
|
||||
*/
|
||||
export function createFileJobStorage(filePath: string): FileJobStorage {
|
||||
return new FileJobStorage(filePath);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Checkpoints - Snapshot session/run state for recovery
|
||||
*/
|
||||
|
||||
export interface CheckpointData {
|
||||
id: string;
|
||||
name: string;
|
||||
timestamp: Date;
|
||||
sessionId?: string;
|
||||
runId?: string;
|
||||
agentId?: string;
|
||||
state: Record<string, any>;
|
||||
messages?: any[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CheckpointConfig {
|
||||
storage?: CheckpointStorage;
|
||||
autoSave?: boolean;
|
||||
autoSaveInterval?: number;
|
||||
maxCheckpoints?: number;
|
||||
}
|
||||
|
||||
export interface CheckpointStorage {
|
||||
save(checkpoint: CheckpointData): Promise<void>;
|
||||
load(id: string): Promise<CheckpointData | null>;
|
||||
list(): Promise<CheckpointData[]>;
|
||||
delete(id: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory checkpoint storage
|
||||
*/
|
||||
export class MemoryCheckpointStorage implements CheckpointStorage {
|
||||
private checkpoints: Map<string, CheckpointData> = new Map();
|
||||
|
||||
async save(checkpoint: CheckpointData): Promise<void> {
|
||||
this.checkpoints.set(checkpoint.id, { ...checkpoint });
|
||||
}
|
||||
|
||||
async load(id: string): Promise<CheckpointData | null> {
|
||||
return this.checkpoints.get(id) || null;
|
||||
}
|
||||
|
||||
async list(): Promise<CheckpointData[]> {
|
||||
return Array.from(this.checkpoints.values())
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.checkpoints.delete(id);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.checkpoints.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based checkpoint storage
|
||||
*/
|
||||
export class FileCheckpointStorage implements CheckpointStorage {
|
||||
private dirPath: string;
|
||||
|
||||
constructor(dirPath: string) {
|
||||
this.dirPath = dirPath;
|
||||
}
|
||||
|
||||
private async ensureDir(): Promise<void> {
|
||||
const fs = await import('fs/promises');
|
||||
await fs.mkdir(this.dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
private getFilePath(id: string): string {
|
||||
return `${this.dirPath}/${id}.json`;
|
||||
}
|
||||
|
||||
async save(checkpoint: CheckpointData): Promise<void> {
|
||||
await this.ensureDir();
|
||||
const fs = await import('fs/promises');
|
||||
await fs.writeFile(
|
||||
this.getFilePath(checkpoint.id),
|
||||
JSON.stringify(checkpoint, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
async load(id: string): Promise<CheckpointData | null> {
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const content = await fs.readFile(this.getFilePath(id), 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
data.timestamp = new Date(data.timestamp);
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async list(): Promise<CheckpointData[]> {
|
||||
try {
|
||||
await this.ensureDir();
|
||||
const fs = await import('fs/promises');
|
||||
const files = await fs.readdir(this.dirPath);
|
||||
const checkpoints: CheckpointData[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
const id = file.replace('.json', '');
|
||||
const checkpoint = await this.load(id);
|
||||
if (checkpoint) {
|
||||
checkpoints.push(checkpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return checkpoints.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
await fs.unlink(this.getFilePath(id));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const checkpoints = await this.list();
|
||||
for (const checkpoint of checkpoints) {
|
||||
await this.delete(checkpoint.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkpoint Manager class
|
||||
*/
|
||||
export class CheckpointManager {
|
||||
private config: CheckpointConfig;
|
||||
private storage: CheckpointStorage;
|
||||
private autoSaveTimer?: NodeJS.Timeout;
|
||||
private currentState: Record<string, any> = {};
|
||||
|
||||
constructor(config: CheckpointConfig = {}) {
|
||||
this.config = {
|
||||
autoSave: false,
|
||||
autoSaveInterval: 60000, // 1 minute
|
||||
maxCheckpoints: 10,
|
||||
...config
|
||||
};
|
||||
this.storage = config.storage || new MemoryCheckpointStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a checkpoint
|
||||
*/
|
||||
async create(name: string, options: Partial<CheckpointData> = {}): Promise<CheckpointData> {
|
||||
const checkpoint: CheckpointData = {
|
||||
id: `cp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
name,
|
||||
timestamp: new Date(),
|
||||
state: { ...this.currentState },
|
||||
...options
|
||||
};
|
||||
|
||||
await this.storage.save(checkpoint);
|
||||
await this.enforceMaxCheckpoints();
|
||||
|
||||
return checkpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from a checkpoint
|
||||
*/
|
||||
async restore(id: string): Promise<CheckpointData | null> {
|
||||
const checkpoint = await this.storage.load(id);
|
||||
if (checkpoint) {
|
||||
this.currentState = { ...checkpoint.state };
|
||||
}
|
||||
return checkpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the latest checkpoint
|
||||
*/
|
||||
async restoreLatest(): Promise<CheckpointData | null> {
|
||||
const checkpoints = await this.storage.list();
|
||||
if (checkpoints.length === 0) return null;
|
||||
return this.restore(checkpoints[0].id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a checkpoint by ID
|
||||
*/
|
||||
async get(id: string): Promise<CheckpointData | null> {
|
||||
return this.storage.load(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all checkpoints
|
||||
*/
|
||||
async list(): Promise<CheckpointData[]> {
|
||||
return this.storage.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a checkpoint
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.storage.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all checkpoints
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
await this.storage.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current state
|
||||
*/
|
||||
setState(state: Record<string, any>): void {
|
||||
this.currentState = { ...this.currentState, ...state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state
|
||||
*/
|
||||
getState(): Record<string, any> {
|
||||
return { ...this.currentState };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-save
|
||||
*/
|
||||
startAutoSave(): void {
|
||||
if (this.autoSaveTimer) return;
|
||||
if (!this.config.autoSave) return;
|
||||
|
||||
this.autoSaveTimer = setInterval(async () => {
|
||||
await this.create('auto-save', { metadata: { auto: true } });
|
||||
}, this.config.autoSaveInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-save
|
||||
*/
|
||||
stopAutoSave(): void {
|
||||
if (this.autoSaveTimer) {
|
||||
clearInterval(this.autoSaveTimer);
|
||||
this.autoSaveTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce maximum checkpoints limit
|
||||
*/
|
||||
private async enforceMaxCheckpoints(): Promise<void> {
|
||||
if (!this.config.maxCheckpoints) return;
|
||||
|
||||
const checkpoints = await this.storage.list();
|
||||
if (checkpoints.length > this.config.maxCheckpoints) {
|
||||
// Delete oldest checkpoints (but keep auto-saves separate)
|
||||
const toDelete = checkpoints.slice(this.config.maxCheckpoints);
|
||||
for (const checkpoint of toDelete) {
|
||||
await this.storage.delete(checkpoint.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export checkpoint to JSON string
|
||||
*/
|
||||
async export(id: string): Promise<string | null> {
|
||||
const checkpoint = await this.storage.load(id);
|
||||
if (!checkpoint) return null;
|
||||
return JSON.stringify(checkpoint, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import checkpoint from JSON string
|
||||
*/
|
||||
async import(json: string): Promise<CheckpointData> {
|
||||
const data = JSON.parse(json);
|
||||
data.timestamp = new Date(data.timestamp);
|
||||
data.id = `cp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // New ID
|
||||
await this.storage.save(data);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a checkpoint manager
|
||||
*/
|
||||
export function createCheckpointManager(config?: CheckpointConfig): CheckpointManager {
|
||||
return new CheckpointManager(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create file-based checkpoint storage
|
||||
*/
|
||||
export function createFileCheckpointStorage(dirPath: string): FileCheckpointStorage {
|
||||
return new FileCheckpointStorage(dirPath);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Cost Tracker - Track token usage and costs across LLM requests
|
||||
*/
|
||||
|
||||
export interface ModelPricing {
|
||||
inputCostPer1k: number;
|
||||
outputCostPer1k: number;
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface RequestStats {
|
||||
timestamp: number;
|
||||
model: string;
|
||||
usage: TokenUsage;
|
||||
cost: number;
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
export interface SessionStats {
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
requestCount: number;
|
||||
avgLatencyMs: number;
|
||||
startTime: number;
|
||||
requests: RequestStats[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Model pricing data (USD per 1K tokens)
|
||||
*/
|
||||
export const MODEL_PRICING: Record<string, ModelPricing> = {
|
||||
// OpenAI
|
||||
'gpt-4o': { inputCostPer1k: 0.005, outputCostPer1k: 0.015 },
|
||||
'gpt-4o-mini': { inputCostPer1k: 0.00015, outputCostPer1k: 0.0006 },
|
||||
'gpt-4-turbo': { inputCostPer1k: 0.01, outputCostPer1k: 0.03 },
|
||||
'gpt-4': { inputCostPer1k: 0.03, outputCostPer1k: 0.06 },
|
||||
'gpt-3.5-turbo': { inputCostPer1k: 0.0005, outputCostPer1k: 0.0015 },
|
||||
'o1': { inputCostPer1k: 0.015, outputCostPer1k: 0.06 },
|
||||
'o1-mini': { inputCostPer1k: 0.003, outputCostPer1k: 0.012 },
|
||||
'o1-preview': { inputCostPer1k: 0.015, outputCostPer1k: 0.06 },
|
||||
|
||||
// Anthropic
|
||||
'claude-3-5-sonnet-20241022': { inputCostPer1k: 0.003, outputCostPer1k: 0.015 },
|
||||
'claude-3-5-haiku-20241022': { inputCostPer1k: 0.001, outputCostPer1k: 0.005 },
|
||||
'claude-3-opus-20240229': { inputCostPer1k: 0.015, outputCostPer1k: 0.075 },
|
||||
'claude-3-sonnet-20240229': { inputCostPer1k: 0.003, outputCostPer1k: 0.015 },
|
||||
'claude-3-haiku-20240307': { inputCostPer1k: 0.00025, outputCostPer1k: 0.00125 },
|
||||
|
||||
// Google
|
||||
'gemini-2.0-flash': { inputCostPer1k: 0.0001, outputCostPer1k: 0.0004 },
|
||||
'gemini-1.5-pro': { inputCostPer1k: 0.00125, outputCostPer1k: 0.005 },
|
||||
'gemini-1.5-flash': { inputCostPer1k: 0.000075, outputCostPer1k: 0.0003 },
|
||||
|
||||
// Default fallback
|
||||
'default': { inputCostPer1k: 0.001, outputCostPer1k: 0.002 }
|
||||
};
|
||||
|
||||
/**
|
||||
* Cost Tracker class
|
||||
*/
|
||||
export class CostTracker {
|
||||
private stats: SessionStats;
|
||||
private customPricing: Map<string, ModelPricing> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.stats = this.createEmptyStats();
|
||||
}
|
||||
|
||||
private createEmptyStats(): SessionStats {
|
||||
return {
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
requestCount: 0,
|
||||
avgLatencyMs: 0,
|
||||
startTime: Date.now(),
|
||||
requests: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a request's token usage
|
||||
*/
|
||||
addUsage(
|
||||
model: string,
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
latencyMs?: number
|
||||
): RequestStats {
|
||||
const totalTokens = inputTokens + outputTokens;
|
||||
const cost = this.calculateCost(model, inputTokens, outputTokens);
|
||||
|
||||
const request: RequestStats = {
|
||||
timestamp: Date.now(),
|
||||
model,
|
||||
usage: { inputTokens, outputTokens, totalTokens },
|
||||
cost,
|
||||
latencyMs
|
||||
};
|
||||
|
||||
this.stats.requests.push(request);
|
||||
this.stats.totalInputTokens += inputTokens;
|
||||
this.stats.totalOutputTokens += outputTokens;
|
||||
this.stats.totalTokens += totalTokens;
|
||||
this.stats.totalCost += cost;
|
||||
this.stats.requestCount++;
|
||||
|
||||
if (latencyMs !== undefined) {
|
||||
const totalLatency = this.stats.avgLatencyMs * (this.stats.requestCount - 1) + latencyMs;
|
||||
this.stats.avgLatencyMs = totalLatency / this.stats.requestCount;
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cost for token usage
|
||||
*/
|
||||
calculateCost(model: string, inputTokens: number, outputTokens: number): number {
|
||||
const pricing = this.getPricing(model);
|
||||
const inputCost = (inputTokens / 1000) * pricing.inputCostPer1k;
|
||||
const outputCost = (outputTokens / 1000) * pricing.outputCostPer1k;
|
||||
return inputCost + outputCost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pricing for a model
|
||||
*/
|
||||
getPricing(model: string): ModelPricing {
|
||||
// Check custom pricing first
|
||||
if (this.customPricing.has(model)) {
|
||||
return this.customPricing.get(model)!;
|
||||
}
|
||||
|
||||
// Check built-in pricing
|
||||
if (MODEL_PRICING[model]) {
|
||||
return MODEL_PRICING[model];
|
||||
}
|
||||
|
||||
// Try to match partial model names
|
||||
for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
|
||||
if (model.includes(key) || key.includes(model)) {
|
||||
return pricing;
|
||||
}
|
||||
}
|
||||
|
||||
return MODEL_PRICING['default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom pricing for a model
|
||||
*/
|
||||
setCustomPricing(model: string, pricing: ModelPricing): void {
|
||||
this.customPricing.set(model, pricing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session stats
|
||||
*/
|
||||
getStats(): SessionStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary string
|
||||
*/
|
||||
getSummary(): string {
|
||||
const duration = (Date.now() - this.stats.startTime) / 1000;
|
||||
const lines = [
|
||||
'=== Cost Summary ===',
|
||||
`Requests: ${this.stats.requestCount}`,
|
||||
`Duration: ${duration.toFixed(1)}s`,
|
||||
'',
|
||||
'Tokens:',
|
||||
` Input: ${this.stats.totalInputTokens.toLocaleString()}`,
|
||||
` Output: ${this.stats.totalOutputTokens.toLocaleString()}`,
|
||||
` Total: ${this.stats.totalTokens.toLocaleString()}`,
|
||||
'',
|
||||
`Total Cost: $${this.stats.totalCost.toFixed(6)}`,
|
||||
];
|
||||
|
||||
if (this.stats.avgLatencyMs > 0) {
|
||||
lines.push(`Avg Latency: ${this.stats.avgLatencyMs.toFixed(0)}ms`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed breakdown by model
|
||||
*/
|
||||
getBreakdownByModel(): Record<string, { tokens: number; cost: number; requests: number }> {
|
||||
const breakdown: Record<string, { tokens: number; cost: number; requests: number }> = {};
|
||||
|
||||
for (const request of this.stats.requests) {
|
||||
if (!breakdown[request.model]) {
|
||||
breakdown[request.model] = { tokens: 0, cost: 0, requests: 0 };
|
||||
}
|
||||
breakdown[request.model].tokens += request.usage.totalTokens;
|
||||
breakdown[request.model].cost += request.cost;
|
||||
breakdown[request.model].requests++;
|
||||
}
|
||||
|
||||
return breakdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset stats
|
||||
*/
|
||||
reset(): void {
|
||||
this.stats = this.createEmptyStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export stats to JSON
|
||||
*/
|
||||
toJSON(): SessionStats {
|
||||
return this.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Import stats from JSON
|
||||
*/
|
||||
fromJSON(data: SessionStats): void {
|
||||
this.stats = { ...data };
|
||||
}
|
||||
|
||||
// Legacy interface compatibility
|
||||
get totalTokens(): number {
|
||||
return this.stats.totalTokens;
|
||||
}
|
||||
|
||||
get totalCost(): number {
|
||||
return this.stats.totalCost;
|
||||
}
|
||||
|
||||
get requests(): number {
|
||||
return this.stats.requestCount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cost tracker instance
|
||||
*/
|
||||
export function createCostTracker(): CostTracker {
|
||||
return new CostTracker();
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate tokens from text (rough approximation)
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format cost as currency string
|
||||
*/
|
||||
export function formatCost(cost: number): string {
|
||||
if (cost < 0.01) {
|
||||
return `$${cost.toFixed(6)}`;
|
||||
}
|
||||
if (cost < 1) {
|
||||
return `$${cost.toFixed(4)}`;
|
||||
}
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* External Agents - Integration adapters for external AI CLI tools
|
||||
*/
|
||||
|
||||
export interface ExternalAgentConfig {
|
||||
name: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface ExternalAgentResult {
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
exitCode: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for external agent integrations
|
||||
*/
|
||||
export abstract class BaseExternalAgent {
|
||||
protected config: ExternalAgentConfig;
|
||||
|
||||
constructor(config: ExternalAgentConfig) {
|
||||
this.config = {
|
||||
timeout: 300000, // 5 minutes default
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the external tool is available
|
||||
*/
|
||||
abstract isAvailable(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Execute a prompt with the external agent
|
||||
*/
|
||||
abstract execute(prompt: string): Promise<ExternalAgentResult>;
|
||||
|
||||
/**
|
||||
* Get the agent name
|
||||
*/
|
||||
getName(): string {
|
||||
return this.config.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return result
|
||||
*/
|
||||
protected async runCommand(args: string[]): Promise<ExternalAgentResult> {
|
||||
const { spawn } = await import('child_process');
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(this.config.command, args, {
|
||||
cwd: this.config.cwd || process.cwd(),
|
||||
env: { ...process.env, ...this.config.env },
|
||||
timeout: this.config.timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
resolve({
|
||||
success: code === 0,
|
||||
output: stdout,
|
||||
error: stderr || undefined,
|
||||
exitCode: code || 0,
|
||||
duration: Date.now() - startTime
|
||||
});
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
resolve({
|
||||
success: false,
|
||||
output: '',
|
||||
error: error.message,
|
||||
exitCode: 1,
|
||||
duration: Date.now() - startTime
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command exists
|
||||
*/
|
||||
protected async commandExists(command: string): Promise<boolean> {
|
||||
const { spawn } = await import('child_process');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('which', [command], { stdio: 'pipe' });
|
||||
proc.on('close', (code) => resolve(code === 0));
|
||||
proc.on('error', () => resolve(false));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code CLI integration
|
||||
*/
|
||||
export class ClaudeCodeAgent extends BaseExternalAgent {
|
||||
constructor(cwd?: string) {
|
||||
super({
|
||||
name: 'claude-code',
|
||||
command: 'claude',
|
||||
cwd
|
||||
});
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return this.commandExists('claude');
|
||||
}
|
||||
|
||||
async execute(prompt: string): Promise<ExternalAgentResult> {
|
||||
return this.runCommand(['--print', prompt]);
|
||||
}
|
||||
|
||||
async executeWithSession(prompt: string, sessionId?: string): Promise<ExternalAgentResult> {
|
||||
const args = ['--print'];
|
||||
if (sessionId) {
|
||||
args.push('--continue', sessionId);
|
||||
}
|
||||
args.push(prompt);
|
||||
return this.runCommand(args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini CLI integration
|
||||
*/
|
||||
export class GeminiCliAgent extends BaseExternalAgent {
|
||||
private model: string;
|
||||
|
||||
constructor(cwd?: string, model: string = 'gemini-2.5-pro') {
|
||||
super({
|
||||
name: 'gemini-cli',
|
||||
command: 'gemini',
|
||||
cwd
|
||||
});
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return this.commandExists('gemini');
|
||||
}
|
||||
|
||||
async execute(prompt: string): Promise<ExternalAgentResult> {
|
||||
return this.runCommand(['-m', this.model, prompt]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI Codex CLI integration
|
||||
*/
|
||||
export class CodexCliAgent extends BaseExternalAgent {
|
||||
constructor(cwd?: string) {
|
||||
super({
|
||||
name: 'codex-cli',
|
||||
command: 'codex',
|
||||
cwd
|
||||
});
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return this.commandExists('codex');
|
||||
}
|
||||
|
||||
async execute(prompt: string): Promise<ExternalAgentResult> {
|
||||
return this.runCommand(['exec', '--full-auto', prompt]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aider CLI integration
|
||||
*/
|
||||
export class AiderAgent extends BaseExternalAgent {
|
||||
constructor(cwd?: string) {
|
||||
super({
|
||||
name: 'aider',
|
||||
command: 'aider',
|
||||
cwd
|
||||
});
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return this.commandExists('aider');
|
||||
}
|
||||
|
||||
async execute(prompt: string): Promise<ExternalAgentResult> {
|
||||
return this.runCommand(['--message', prompt, '--yes']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic external agent for any CLI tool
|
||||
*/
|
||||
export class GenericExternalAgent extends BaseExternalAgent {
|
||||
private promptArg: string;
|
||||
|
||||
constructor(config: ExternalAgentConfig & { promptArg?: string }) {
|
||||
super(config);
|
||||
this.promptArg = config.promptArg || '';
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return this.commandExists(this.config.command);
|
||||
}
|
||||
|
||||
async execute(prompt: string): Promise<ExternalAgentResult> {
|
||||
const args = [...(this.config.args || [])];
|
||||
if (this.promptArg) {
|
||||
args.push(this.promptArg, prompt);
|
||||
} else {
|
||||
args.push(prompt);
|
||||
}
|
||||
return this.runCommand(args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* External Agent Registry
|
||||
*/
|
||||
class ExternalAgentRegistry {
|
||||
private agents: Map<string, () => BaseExternalAgent> = new Map();
|
||||
|
||||
constructor() {
|
||||
// Register built-in agents
|
||||
this.register('claude', () => new ClaudeCodeAgent());
|
||||
this.register('gemini', () => new GeminiCliAgent());
|
||||
this.register('codex', () => new CodexCliAgent());
|
||||
this.register('aider', () => new AiderAgent());
|
||||
}
|
||||
|
||||
register(name: string, factory: () => BaseExternalAgent): void {
|
||||
this.agents.set(name, factory);
|
||||
}
|
||||
|
||||
get(name: string): BaseExternalAgent | undefined {
|
||||
const factory = this.agents.get(name);
|
||||
return factory ? factory() : undefined;
|
||||
}
|
||||
|
||||
list(): string[] {
|
||||
return Array.from(this.agents.keys());
|
||||
}
|
||||
|
||||
async getAvailable(): Promise<string[]> {
|
||||
const available: string[] = [];
|
||||
for (const [name, factory] of this.agents) {
|
||||
const agent = factory();
|
||||
if (await agent.isAvailable()) {
|
||||
available.push(name);
|
||||
}
|
||||
}
|
||||
return available;
|
||||
}
|
||||
}
|
||||
|
||||
let registry: ExternalAgentRegistry | null = null;
|
||||
|
||||
export function getExternalAgentRegistry(): ExternalAgentRegistry {
|
||||
if (!registry) {
|
||||
registry = new ExternalAgentRegistry();
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an external agent by name
|
||||
*/
|
||||
export function createExternalAgent(name: string, cwd?: string): BaseExternalAgent | undefined {
|
||||
const reg = getExternalAgentRegistry();
|
||||
|
||||
switch (name) {
|
||||
case 'claude':
|
||||
return new ClaudeCodeAgent(cwd);
|
||||
case 'gemini':
|
||||
return new GeminiCliAgent(cwd);
|
||||
case 'codex':
|
||||
return new CodexCliAgent(cwd);
|
||||
case 'aider':
|
||||
return new AiderAgent(cwd);
|
||||
default:
|
||||
return reg.get(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert external agent to tool for use with PraisonAI agents
|
||||
*/
|
||||
export function externalAgentAsTool(agent: BaseExternalAgent): {
|
||||
name: string;
|
||||
description: string;
|
||||
execute: (input: string) => Promise<string>;
|
||||
} {
|
||||
return {
|
||||
name: `external_${agent.getName()}`,
|
||||
description: `Execute a prompt using the ${agent.getName()} external agent`,
|
||||
execute: async (input: string) => {
|
||||
const result = await agent.execute(input);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'External agent execution failed');
|
||||
}
|
||||
return result.output;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Fast Context - Fast retrieval and summarization pipeline
|
||||
* Minimizes tokens and latency with deterministic caching
|
||||
*/
|
||||
|
||||
export interface FastContextConfig {
|
||||
maxTokens?: number;
|
||||
cacheEnabled?: boolean;
|
||||
cacheTTL?: number;
|
||||
summarizer?: (text: string, maxTokens: number) => Promise<string>;
|
||||
embeddingProvider?: {
|
||||
embed: (text: string) => Promise<number[]>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContextSource {
|
||||
id: string;
|
||||
type: 'memory' | 'knowledge' | 'file' | 'custom';
|
||||
content: string;
|
||||
relevance?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface FastContextResult {
|
||||
context: string;
|
||||
sources: ContextSource[];
|
||||
tokenCount: number;
|
||||
cached: boolean;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
export interface CacheEntry {
|
||||
result: FastContextResult;
|
||||
timestamp: number;
|
||||
hits: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast Context class for efficient context retrieval
|
||||
*/
|
||||
export class FastContext {
|
||||
private config: FastContextConfig;
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private sources: Map<string, ContextSource[]> = new Map();
|
||||
|
||||
constructor(config: FastContextConfig = {}) {
|
||||
this.config = {
|
||||
maxTokens: 4000,
|
||||
cacheEnabled: true,
|
||||
cacheTTL: 300000, // 5 minutes
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a context source
|
||||
*/
|
||||
registerSource(
|
||||
id: string,
|
||||
type: ContextSource['type'],
|
||||
contents: string[],
|
||||
metadata?: Record<string, any>
|
||||
): void {
|
||||
const sources: ContextSource[] = contents.map((content, i) => ({
|
||||
id: `${id}_${i}`,
|
||||
type,
|
||||
content,
|
||||
metadata
|
||||
}));
|
||||
this.sources.set(id, sources);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register memory as a source
|
||||
*/
|
||||
registerMemory(memory: { getAll: () => Array<{ content: string; metadata?: any }> }): void {
|
||||
const entries = memory.getAll();
|
||||
const sources: ContextSource[] = entries.map((entry, i) => ({
|
||||
id: `memory_${i}`,
|
||||
type: 'memory',
|
||||
content: entry.content,
|
||||
metadata: entry.metadata
|
||||
}));
|
||||
this.sources.set('memory', sources);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register knowledge base as a source
|
||||
*/
|
||||
registerKnowledge(
|
||||
knowledge: { search: (query: string, limit?: number) => Promise<Array<{ content: string; score: number }>> }
|
||||
): void {
|
||||
// Store reference for dynamic retrieval
|
||||
(this as any)._knowledgeBase = knowledge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context for a query
|
||||
*/
|
||||
async getContext(query: string): Promise<FastContextResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Check cache
|
||||
const cacheKey = this.getCacheKey(query);
|
||||
if (this.config.cacheEnabled) {
|
||||
const cached = this.getFromCache(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached.result,
|
||||
cached: true,
|
||||
latencyMs: Date.now() - startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Gather relevant sources
|
||||
const relevantSources = await this.gatherRelevantSources(query);
|
||||
|
||||
// Build context within token limit
|
||||
const { context, sources, tokenCount } = await this.buildContext(relevantSources, query);
|
||||
|
||||
const result: FastContextResult = {
|
||||
context,
|
||||
sources,
|
||||
tokenCount,
|
||||
cached: false,
|
||||
latencyMs: Date.now() - startTime
|
||||
};
|
||||
|
||||
// Cache result
|
||||
if (this.config.cacheEnabled) {
|
||||
this.addToCache(cacheKey, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather relevant sources for a query
|
||||
*/
|
||||
private async gatherRelevantSources(query: string): Promise<ContextSource[]> {
|
||||
const allSources: ContextSource[] = [];
|
||||
|
||||
// Get from registered sources
|
||||
for (const sources of this.sources.values()) {
|
||||
allSources.push(...sources);
|
||||
}
|
||||
|
||||
// Get from knowledge base if registered
|
||||
const kb = (this as any)._knowledgeBase;
|
||||
if (kb) {
|
||||
try {
|
||||
const kbResults = await kb.search(query, 10);
|
||||
for (let i = 0; i < kbResults.length; i++) {
|
||||
allSources.push({
|
||||
id: `kb_${i}`,
|
||||
type: 'knowledge',
|
||||
content: kbResults[i].content,
|
||||
relevance: kbResults[i].score
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore knowledge base errors
|
||||
}
|
||||
}
|
||||
|
||||
// Score relevance if embedding provider available
|
||||
if (this.config.embeddingProvider) {
|
||||
await this.scoreRelevance(allSources, query);
|
||||
} else {
|
||||
// Simple text matching fallback
|
||||
this.scoreRelevanceSimple(allSources, query);
|
||||
}
|
||||
|
||||
// Sort by relevance
|
||||
allSources.sort((a, b) => (b.relevance || 0) - (a.relevance || 0));
|
||||
|
||||
return allSources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score relevance using embeddings
|
||||
*/
|
||||
private async scoreRelevance(sources: ContextSource[], query: string): Promise<void> {
|
||||
if (!this.config.embeddingProvider) return;
|
||||
|
||||
const queryEmbedding = await this.config.embeddingProvider.embed(query);
|
||||
|
||||
for (const source of sources) {
|
||||
if (source.relevance !== undefined) continue; // Already scored
|
||||
|
||||
const sourceEmbedding = await this.config.embeddingProvider.embed(source.content);
|
||||
source.relevance = this.cosineSimilarity(queryEmbedding, sourceEmbedding);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple text-based relevance scoring
|
||||
*/
|
||||
private scoreRelevanceSimple(sources: ContextSource[], query: string): void {
|
||||
const queryWords = query.toLowerCase().split(/\s+/);
|
||||
|
||||
for (const source of sources) {
|
||||
if (source.relevance !== undefined) continue;
|
||||
|
||||
const contentLower = source.content.toLowerCase();
|
||||
let score = 0;
|
||||
|
||||
for (const word of queryWords) {
|
||||
if (contentLower.includes(word)) {
|
||||
score += 1 / queryWords.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Boost for exact phrase match
|
||||
if (contentLower.includes(query.toLowerCase())) {
|
||||
score += 0.5;
|
||||
}
|
||||
|
||||
source.relevance = Math.min(score, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context from sources within token limit
|
||||
*/
|
||||
private async buildContext(
|
||||
sources: ContextSource[],
|
||||
query: string
|
||||
): Promise<{ context: string; sources: ContextSource[]; tokenCount: number }> {
|
||||
const maxTokens = this.config.maxTokens || 4000;
|
||||
const usedSources: ContextSource[] = [];
|
||||
const contextParts: string[] = [];
|
||||
let tokenCount = 0;
|
||||
|
||||
for (const source of sources) {
|
||||
const sourceTokens = this.estimateTokens(source.content);
|
||||
|
||||
if (tokenCount + sourceTokens <= maxTokens) {
|
||||
contextParts.push(source.content);
|
||||
usedSources.push(source);
|
||||
tokenCount += sourceTokens;
|
||||
} else if (this.config.summarizer && sourceTokens > 100) {
|
||||
// Try to summarize if too long
|
||||
const remainingTokens = maxTokens - tokenCount;
|
||||
if (remainingTokens > 50) {
|
||||
const summary = await this.config.summarizer(source.content, remainingTokens);
|
||||
const summaryTokens = this.estimateTokens(summary);
|
||||
if (tokenCount + summaryTokens <= maxTokens) {
|
||||
contextParts.push(summary);
|
||||
usedSources.push({ ...source, content: summary, metadata: { ...source.metadata, summarized: true } });
|
||||
tokenCount += summaryTokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenCount >= maxTokens * 0.95) break;
|
||||
}
|
||||
|
||||
return {
|
||||
context: contextParts.join('\n\n'),
|
||||
sources: usedSources,
|
||||
tokenCount
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache key for query
|
||||
*/
|
||||
private getCacheKey(query: string): string {
|
||||
// Simple hash
|
||||
let hash = 0;
|
||||
for (let i = 0; i < query.length; i++) {
|
||||
const char = query.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return `ctx_${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get from cache
|
||||
*/
|
||||
private getFromCache(key: string): CacheEntry | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
// Check TTL
|
||||
if (Date.now() - entry.timestamp > (this.config.cacheTTL || 300000)) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
entry.hits++;
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to cache
|
||||
*/
|
||||
private addToCache(key: string, result: FastContextResult): void {
|
||||
this.cache.set(key, {
|
||||
result,
|
||||
timestamp: Date.now(),
|
||||
hits: 0
|
||||
});
|
||||
|
||||
// Limit cache size
|
||||
if (this.cache.size > 100) {
|
||||
const oldest = Array.from(this.cache.entries())
|
||||
.sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
|
||||
if (oldest) {
|
||||
this.cache.delete(oldest[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache stats
|
||||
*/
|
||||
getCacheStats(): { size: number; totalHits: number } {
|
||||
let totalHits = 0;
|
||||
for (const entry of this.cache.values()) {
|
||||
totalHits += entry.hits;
|
||||
}
|
||||
return { size: this.cache.size, totalHits };
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate token count
|
||||
*/
|
||||
private estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cosine similarity
|
||||
*/
|
||||
private cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (a.length !== b.length) return 0;
|
||||
|
||||
let dotProduct = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dotProduct += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
|
||||
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
|
||||
return denominator === 0 ? 0 : dotProduct / denominator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all sources
|
||||
*/
|
||||
clearSources(): void {
|
||||
this.sources.clear();
|
||||
(this as any)._knowledgeBase = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fast context instance
|
||||
*/
|
||||
export function createFastContext(config?: FastContextConfig): FastContext {
|
||||
return new FastContext(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick context retrieval
|
||||
*/
|
||||
export async function getQuickContext(
|
||||
query: string,
|
||||
sources: string[],
|
||||
maxTokens?: number
|
||||
): Promise<string> {
|
||||
const fc = createFastContext({ maxTokens, cacheEnabled: false });
|
||||
fc.registerSource('quick', 'custom', sources);
|
||||
const result = await fc.getContext(query);
|
||||
return result.context;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Flow Display - Textual DAG visualization for workflows
|
||||
*/
|
||||
|
||||
export interface FlowNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'agent' | 'tool' | 'condition' | 'parallel' | 'start' | 'end';
|
||||
status?: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
children?: string[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface FlowGraph {
|
||||
nodes: Map<string, FlowNode>;
|
||||
edges: Array<{ from: string; to: string; label?: string }>;
|
||||
}
|
||||
|
||||
export interface FlowDisplayConfig {
|
||||
showStatus?: boolean;
|
||||
showMetadata?: boolean;
|
||||
compact?: boolean;
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow Display class for DAG visualization
|
||||
*/
|
||||
export class FlowDisplay {
|
||||
private graph: FlowGraph;
|
||||
private config: FlowDisplayConfig;
|
||||
|
||||
constructor(config: FlowDisplayConfig = {}) {
|
||||
this.config = {
|
||||
showStatus: true,
|
||||
showMetadata: false,
|
||||
compact: false,
|
||||
maxWidth: 80,
|
||||
...config
|
||||
};
|
||||
this.graph = {
|
||||
nodes: new Map(),
|
||||
edges: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a node to the graph
|
||||
*/
|
||||
addNode(node: FlowNode): void {
|
||||
this.graph.nodes.set(node.id, node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an edge between nodes
|
||||
*/
|
||||
addEdge(from: string, to: string, label?: string): void {
|
||||
this.graph.edges.push({ from, to, label });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node status
|
||||
*/
|
||||
updateStatus(id: string, status: FlowNode['status']): void {
|
||||
const node = this.graph.nodes.get(id);
|
||||
if (node) {
|
||||
node.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build graph from workflow steps
|
||||
*/
|
||||
fromWorkflowSteps(steps: Array<{ name: string; type?: string; condition?: string }>): void {
|
||||
this.graph.nodes.clear();
|
||||
this.graph.edges = [];
|
||||
|
||||
// Add start node
|
||||
this.addNode({ id: 'start', name: 'Start', type: 'start' });
|
||||
|
||||
let prevId = 'start';
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
const id = `step_${i}`;
|
||||
|
||||
this.addNode({
|
||||
id,
|
||||
name: step.name,
|
||||
type: (step.type as FlowNode['type']) || 'agent',
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
this.addEdge(prevId, id, step.condition);
|
||||
prevId = id;
|
||||
}
|
||||
|
||||
// Add end node
|
||||
this.addNode({ id: 'end', name: 'End', type: 'end' });
|
||||
this.addEdge(prevId, 'end');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the graph as text
|
||||
*/
|
||||
render(): string {
|
||||
const lines: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
// Find root nodes (nodes with no incoming edges)
|
||||
const hasIncoming = new Set(this.graph.edges.map(e => e.to));
|
||||
const roots = Array.from(this.graph.nodes.keys()).filter(id => !hasIncoming.has(id));
|
||||
|
||||
if (roots.length === 0 && this.graph.nodes.size > 0) {
|
||||
const firstKey = this.graph.nodes.keys().next().value;
|
||||
if (firstKey) roots.push(firstKey);
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
this.renderNode(root, '', true, lines, visited);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single node and its children
|
||||
*/
|
||||
private renderNode(
|
||||
id: string,
|
||||
prefix: string,
|
||||
isLast: boolean,
|
||||
lines: string[],
|
||||
visited: Set<string>
|
||||
): void {
|
||||
if (visited.has(id)) {
|
||||
lines.push(`${prefix}${isLast ? '└── ' : '├── '}(cycle: ${id})`);
|
||||
return;
|
||||
}
|
||||
visited.add(id);
|
||||
|
||||
const node = this.graph.nodes.get(id);
|
||||
if (!node) return;
|
||||
|
||||
const connector = isLast ? '└── ' : '├── ';
|
||||
const nodeStr = this.formatNode(node);
|
||||
lines.push(`${prefix}${connector}${nodeStr}`);
|
||||
|
||||
// Find children
|
||||
const childEdges = this.graph.edges.filter(e => e.from === id);
|
||||
const childPrefix = prefix + (isLast ? ' ' : '│ ');
|
||||
|
||||
for (let i = 0; i < childEdges.length; i++) {
|
||||
const edge = childEdges[i];
|
||||
const isLastChild = i === childEdges.length - 1;
|
||||
|
||||
if (edge.label) {
|
||||
lines.push(`${childPrefix}${isLastChild ? '└' : '├'}─[${edge.label}]─┐`);
|
||||
this.renderNode(edge.to, childPrefix + (isLastChild ? ' ' : '│ '), true, lines, visited);
|
||||
} else {
|
||||
this.renderNode(edge.to, childPrefix, isLastChild, lines, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a node for display
|
||||
*/
|
||||
private formatNode(node: FlowNode): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Status indicator
|
||||
if (this.config.showStatus && node.status) {
|
||||
const statusIcons: Record<string, string> = {
|
||||
pending: '○',
|
||||
running: '◐',
|
||||
completed: '●',
|
||||
failed: '✗',
|
||||
skipped: '◌'
|
||||
};
|
||||
parts.push(statusIcons[node.status] || '?');
|
||||
}
|
||||
|
||||
// Type indicator
|
||||
const typeIcons: Record<string, string> = {
|
||||
agent: '🤖',
|
||||
tool: '🔧',
|
||||
condition: '❓',
|
||||
parallel: '⫘',
|
||||
start: '▶',
|
||||
end: '■'
|
||||
};
|
||||
|
||||
if (!this.config.compact) {
|
||||
parts.push(typeIcons[node.type] || '□');
|
||||
}
|
||||
|
||||
// Name
|
||||
parts.push(node.name);
|
||||
|
||||
// Metadata
|
||||
if (this.config.showMetadata && node.metadata) {
|
||||
const meta = Object.entries(node.metadata)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(', ');
|
||||
if (meta) {
|
||||
parts.push(`(${meta})`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render as simple ASCII box diagram
|
||||
*/
|
||||
renderBoxes(): string {
|
||||
const lines: string[] = [];
|
||||
const nodeList = Array.from(this.graph.nodes.values());
|
||||
|
||||
for (let i = 0; i < nodeList.length; i++) {
|
||||
const node = nodeList[i];
|
||||
const box = this.createBox(node);
|
||||
lines.push(...box);
|
||||
|
||||
if (i < nodeList.length - 1) {
|
||||
lines.push(' │');
|
||||
lines.push(' ▼');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a box for a node
|
||||
*/
|
||||
private createBox(node: FlowNode): string[] {
|
||||
const name = node.name;
|
||||
const width = Math.max(name.length + 4, 20);
|
||||
const padding = Math.floor((width - name.length - 2) / 2);
|
||||
|
||||
const statusChar = this.config.showStatus ? this.getStatusChar(node.status) : '';
|
||||
const top = '┌' + '─'.repeat(width) + '┐';
|
||||
const middle = '│' + ' '.repeat(padding) + statusChar + name + ' '.repeat(width - padding - name.length - statusChar.length) + '│';
|
||||
const bottom = '└' + '─'.repeat(width) + '┘';
|
||||
|
||||
return [top, middle, bottom];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status character
|
||||
*/
|
||||
private getStatusChar(status?: string): string {
|
||||
const chars: Record<string, string> = {
|
||||
pending: '○ ',
|
||||
running: '◐ ',
|
||||
completed: '● ',
|
||||
failed: '✗ ',
|
||||
skipped: '◌ '
|
||||
};
|
||||
return status ? (chars[status] || '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the graph
|
||||
*/
|
||||
clear(): void {
|
||||
this.graph.nodes.clear();
|
||||
this.graph.edges = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get graph data
|
||||
*/
|
||||
getGraph(): FlowGraph {
|
||||
return this.graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export to DOT format (for Graphviz)
|
||||
*/
|
||||
toDot(): string {
|
||||
const lines: string[] = ['digraph workflow {'];
|
||||
lines.push(' rankdir=TB;');
|
||||
lines.push(' node [shape=box];');
|
||||
|
||||
for (const [id, node] of this.graph.nodes) {
|
||||
const label = node.name.replace(/"/g, '\\"');
|
||||
const style = node.status === 'completed' ? 'filled' : '';
|
||||
const fillcolor = node.status === 'completed' ? 'lightgreen' :
|
||||
node.status === 'failed' ? 'lightcoral' : 'white';
|
||||
lines.push(` "${id}" [label="${label}" style="${style}" fillcolor="${fillcolor}"];`);
|
||||
}
|
||||
|
||||
for (const edge of this.graph.edges) {
|
||||
const label = edge.label ? ` [label="${edge.label}"]` : '';
|
||||
lines.push(` "${edge.from}" -> "${edge.to}"${label};`);
|
||||
}
|
||||
|
||||
lines.push('}');
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a flow display instance
|
||||
*/
|
||||
export function createFlowDisplay(config?: FlowDisplayConfig): FlowDisplay {
|
||||
return new FlowDisplay(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick render workflow steps
|
||||
*/
|
||||
export function renderWorkflow(steps: Array<{ name: string; type?: string }>): string {
|
||||
const display = createFlowDisplay();
|
||||
display.fromWorkflowSteps(steps);
|
||||
return display.render();
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* Git Integration - Git operations for CLI
|
||||
* Safe, read-focused operations with optional write capabilities
|
||||
*/
|
||||
|
||||
export interface GitConfig {
|
||||
cwd?: string;
|
||||
safe?: boolean; // If true, only allow read operations
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
branch: string;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
staged: string[];
|
||||
modified: string[];
|
||||
untracked: string[];
|
||||
deleted: string[];
|
||||
conflicts: string[];
|
||||
}
|
||||
|
||||
export interface GitCommit {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
author: string;
|
||||
email: string;
|
||||
date: Date;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GitDiff {
|
||||
files: GitDiffFile[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface GitDiffFile {
|
||||
path: string;
|
||||
status: 'added' | 'modified' | 'deleted' | 'renamed';
|
||||
additions: number;
|
||||
deletions: number;
|
||||
diff?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Git Manager class
|
||||
*/
|
||||
export class GitManager {
|
||||
private cwd: string;
|
||||
private safe: boolean;
|
||||
|
||||
constructor(config: GitConfig = {}) {
|
||||
this.cwd = config.cwd || process.cwd();
|
||||
this.safe = config.safe ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a git command
|
||||
*/
|
||||
private async exec(args: string[]): Promise<string> {
|
||||
const { spawn } = await import('child_process');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('git', args, {
|
||||
cwd: this.cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim());
|
||||
} else {
|
||||
reject(new Error(stderr || `Git command failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if directory is a git repository
|
||||
*/
|
||||
async isRepo(): Promise<boolean> {
|
||||
try {
|
||||
await this.exec(['rev-parse', '--git-dir']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current branch name
|
||||
*/
|
||||
async getBranch(): Promise<string> {
|
||||
return this.exec(['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository status
|
||||
*/
|
||||
async getStatus(): Promise<GitStatus> {
|
||||
const branch = await this.getBranch();
|
||||
const porcelain = await this.exec(['status', '--porcelain', '-b']);
|
||||
|
||||
const lines = porcelain.split('\n');
|
||||
const status: GitStatus = {
|
||||
branch,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
staged: [],
|
||||
modified: [],
|
||||
untracked: [],
|
||||
deleted: [],
|
||||
conflicts: []
|
||||
};
|
||||
|
||||
// Parse branch line for ahead/behind
|
||||
const branchLine = lines[0];
|
||||
const aheadMatch = branchLine.match(/ahead (\d+)/);
|
||||
const behindMatch = branchLine.match(/behind (\d+)/);
|
||||
if (aheadMatch) status.ahead = parseInt(aheadMatch[1]);
|
||||
if (behindMatch) status.behind = parseInt(behindMatch[1]);
|
||||
|
||||
// Parse file status
|
||||
for (const line of lines.slice(1)) {
|
||||
if (!line) continue;
|
||||
|
||||
const indexStatus = line[0];
|
||||
const workStatus = line[1];
|
||||
const file = line.slice(3);
|
||||
|
||||
if (indexStatus === 'U' || workStatus === 'U') {
|
||||
status.conflicts.push(file);
|
||||
} else if (indexStatus === '?' && workStatus === '?') {
|
||||
status.untracked.push(file);
|
||||
} else if (indexStatus === 'D' || workStatus === 'D') {
|
||||
status.deleted.push(file);
|
||||
} else if (indexStatus !== ' ') {
|
||||
status.staged.push(file);
|
||||
} else if (workStatus === 'M') {
|
||||
status.modified.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diff (staged or unstaged)
|
||||
*/
|
||||
async getDiff(staged: boolean = false): Promise<GitDiff> {
|
||||
const args = ['diff', '--stat'];
|
||||
if (staged) args.push('--staged');
|
||||
|
||||
const stat = await this.exec(args);
|
||||
const files: GitDiffFile[] = [];
|
||||
|
||||
const lines = stat.split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*(.+?)\s*\|\s*(\d+)\s*([+-]*)/);
|
||||
if (match) {
|
||||
const [, path, changes, indicators] = match;
|
||||
const additions = (indicators.match(/\+/g) || []).length;
|
||||
const deletions = (indicators.match(/-/g) || []).length;
|
||||
|
||||
files.push({
|
||||
path: path.trim(),
|
||||
status: 'modified',
|
||||
additions,
|
||||
deletions
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get full diff for summary
|
||||
const fullDiffArgs = ['diff'];
|
||||
if (staged) fullDiffArgs.push('--staged');
|
||||
const fullDiff = await this.exec(fullDiffArgs);
|
||||
|
||||
return {
|
||||
files,
|
||||
summary: fullDiff
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent commits
|
||||
*/
|
||||
async getLog(limit: number = 10): Promise<GitCommit[]> {
|
||||
const format = '%H|%h|%an|%ae|%aI|%s';
|
||||
const output = await this.exec(['log', `-${limit}`, `--format=${format}`]);
|
||||
|
||||
return output.split('\n').filter(Boolean).map(line => {
|
||||
const [hash, shortHash, author, email, date, message] = line.split('|');
|
||||
return {
|
||||
hash,
|
||||
shortHash,
|
||||
author,
|
||||
email,
|
||||
date: new Date(date),
|
||||
message
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of branches
|
||||
*/
|
||||
async getBranches(): Promise<{ name: string; current: boolean }[]> {
|
||||
const output = await this.exec(['branch', '--list']);
|
||||
return output.split('\n').filter(Boolean).map(line => ({
|
||||
name: line.replace(/^\*?\s*/, '').trim(),
|
||||
current: line.startsWith('*')
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage files (requires safe=false)
|
||||
*/
|
||||
async add(files: string[] = ['.']): Promise<void> {
|
||||
if (this.safe) {
|
||||
throw new Error('Write operations disabled in safe mode');
|
||||
}
|
||||
await this.exec(['add', ...files]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit staged changes (requires safe=false)
|
||||
*/
|
||||
async commit(message: string): Promise<string> {
|
||||
if (this.safe) {
|
||||
throw new Error('Write operations disabled in safe mode');
|
||||
}
|
||||
return this.exec(['commit', '-m', message]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stash changes (requires safe=false)
|
||||
*/
|
||||
async stash(message?: string): Promise<void> {
|
||||
if (this.safe) {
|
||||
throw new Error('Write operations disabled in safe mode');
|
||||
}
|
||||
const args = ['stash', 'push'];
|
||||
if (message) args.push('-m', message);
|
||||
await this.exec(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop stash (requires safe=false)
|
||||
*/
|
||||
async stashPop(): Promise<void> {
|
||||
if (this.safe) {
|
||||
throw new Error('Write operations disabled in safe mode');
|
||||
}
|
||||
await this.exec(['stash', 'pop']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stash list
|
||||
*/
|
||||
async getStashList(): Promise<string[]> {
|
||||
const output = await this.exec(['stash', 'list']);
|
||||
return output.split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file content at a specific commit
|
||||
*/
|
||||
async show(ref: string, path: string): Promise<string> {
|
||||
return this.exec(['show', `${ref}:${path}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blame for a file
|
||||
*/
|
||||
async blame(path: string): Promise<string> {
|
||||
return this.exec(['blame', path]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate commit message from staged changes
|
||||
*/
|
||||
async generateCommitMessage(): Promise<string> {
|
||||
const diff = await this.getDiff(true);
|
||||
|
||||
if (diff.files.length === 0) {
|
||||
return 'No staged changes';
|
||||
}
|
||||
|
||||
const fileList = diff.files.map(f => f.path).join(', ');
|
||||
const totalAdditions = diff.files.reduce((sum, f) => sum + f.additions, 0);
|
||||
const totalDeletions = diff.files.reduce((sum, f) => sum + f.deletions, 0);
|
||||
|
||||
// Simple template-based message
|
||||
if (diff.files.length === 1) {
|
||||
return `Update ${diff.files[0].path}`;
|
||||
}
|
||||
|
||||
return `Update ${diff.files.length} files (+${totalAdditions}/-${totalDeletions})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted status string
|
||||
*/
|
||||
async getStatusString(): Promise<string> {
|
||||
const status = await this.getStatus();
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`Branch: ${status.branch}`);
|
||||
if (status.ahead > 0) lines.push(` ↑ ${status.ahead} ahead`);
|
||||
if (status.behind > 0) lines.push(` ↓ ${status.behind} behind`);
|
||||
|
||||
if (status.staged.length > 0) {
|
||||
lines.push(`\nStaged (${status.staged.length}):`);
|
||||
status.staged.forEach(f => lines.push(` + ${f}`));
|
||||
}
|
||||
|
||||
if (status.modified.length > 0) {
|
||||
lines.push(`\nModified (${status.modified.length}):`);
|
||||
status.modified.forEach(f => lines.push(` M ${f}`));
|
||||
}
|
||||
|
||||
if (status.untracked.length > 0) {
|
||||
lines.push(`\nUntracked (${status.untracked.length}):`);
|
||||
status.untracked.forEach(f => lines.push(` ? ${f}`));
|
||||
}
|
||||
|
||||
if (status.deleted.length > 0) {
|
||||
lines.push(`\nDeleted (${status.deleted.length}):`);
|
||||
status.deleted.forEach(f => lines.push(` - ${f}`));
|
||||
}
|
||||
|
||||
if (status.conflicts.length > 0) {
|
||||
lines.push(`\nConflicts (${status.conflicts.length}):`);
|
||||
status.conflicts.forEach(f => lines.push(` ! ${f}`));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a git manager instance
|
||||
*/
|
||||
export function createGitManager(config?: GitConfig): GitManager {
|
||||
return new GitManager(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff viewer with syntax highlighting (text-based)
|
||||
*/
|
||||
export class DiffViewer {
|
||||
private diff: string;
|
||||
|
||||
constructor(diff: string) {
|
||||
this.diff = diff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted diff with markers
|
||||
*/
|
||||
format(): string {
|
||||
const lines = this.diff.split('\n');
|
||||
return lines.map(line => {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
return `[+] ${line}`;
|
||||
}
|
||||
if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
return `[-] ${line}`;
|
||||
}
|
||||
if (line.startsWith('@@')) {
|
||||
return `[~] ${line}`;
|
||||
}
|
||||
return ` ${line}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics
|
||||
*/
|
||||
getStats(): { additions: number; deletions: number } {
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
|
||||
for (const line of this.diff.split('\n')) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
additions++;
|
||||
}
|
||||
if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
deletions++;
|
||||
}
|
||||
}
|
||||
|
||||
return { additions, deletions };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a diff viewer
|
||||
*/
|
||||
export function createDiffViewer(diff: string): DiffViewer {
|
||||
return new DiffViewer(diff);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* CLI Features - Lazy-loaded feature modules
|
||||
*/
|
||||
|
||||
// Slash Commands
|
||||
export {
|
||||
SlashCommandHandler,
|
||||
createSlashCommandHandler,
|
||||
registerCommand,
|
||||
parseSlashCommand,
|
||||
executeSlashCommand,
|
||||
isSlashCommand,
|
||||
getRegistry,
|
||||
type SlashCommand,
|
||||
type SlashCommandContext,
|
||||
type SlashCommandResult,
|
||||
type CostTracker as SlashCostTracker
|
||||
} from './slash-commands';
|
||||
|
||||
// Cost Tracker
|
||||
export {
|
||||
CostTracker,
|
||||
createCostTracker,
|
||||
estimateTokens,
|
||||
formatCost,
|
||||
MODEL_PRICING,
|
||||
type ModelPricing,
|
||||
type TokenUsage,
|
||||
type RequestStats,
|
||||
type SessionStats
|
||||
} from './cost-tracker';
|
||||
|
||||
// Interactive TUI
|
||||
export {
|
||||
InteractiveTUI,
|
||||
createInteractiveTUI,
|
||||
StatusDisplay,
|
||||
createStatusDisplay,
|
||||
HistoryManager,
|
||||
createHistoryManager,
|
||||
type TUIConfig,
|
||||
type TUIState
|
||||
} from './interactive-tui';
|
||||
|
||||
// Repo Map
|
||||
export {
|
||||
RepoMap,
|
||||
createRepoMap,
|
||||
getRepoTree,
|
||||
DEFAULT_IGNORE_PATTERNS,
|
||||
type RepoMapConfig,
|
||||
type FileInfo,
|
||||
type SymbolInfo,
|
||||
type RepoMapResult
|
||||
} from './repo-map';
|
||||
|
||||
// Git Integration
|
||||
export {
|
||||
GitManager,
|
||||
createGitManager,
|
||||
DiffViewer,
|
||||
createDiffViewer,
|
||||
type GitConfig,
|
||||
type GitStatus,
|
||||
type GitCommit,
|
||||
type GitDiff,
|
||||
type GitDiffFile
|
||||
} from './git-integration';
|
||||
|
||||
// Sandbox Executor
|
||||
export {
|
||||
SandboxExecutor,
|
||||
createSandboxExecutor,
|
||||
sandboxExec,
|
||||
CommandValidator,
|
||||
DEFAULT_BLOCKED_COMMANDS,
|
||||
DEFAULT_BLOCKED_PATHS,
|
||||
type SandboxMode,
|
||||
type SandboxConfig,
|
||||
type ExecutionResult
|
||||
} from './sandbox-executor';
|
||||
|
||||
// Autonomy Mode
|
||||
export {
|
||||
AutonomyManager,
|
||||
createAutonomyManager,
|
||||
cliApprovalPrompt,
|
||||
MODE_POLICIES,
|
||||
type AutonomyMode,
|
||||
type ActionType,
|
||||
type ApprovalPolicy,
|
||||
type AutonomyConfig,
|
||||
type ActionRequest,
|
||||
type ActionDecision
|
||||
} from './autonomy-mode';
|
||||
|
||||
// Scheduler
|
||||
export {
|
||||
Scheduler,
|
||||
createScheduler,
|
||||
cronExpressions,
|
||||
type ScheduleConfig,
|
||||
type ScheduledTask,
|
||||
type SchedulerStats
|
||||
} from './scheduler';
|
||||
|
||||
// Background Jobs
|
||||
export {
|
||||
JobQueue,
|
||||
createJobQueue,
|
||||
MemoryJobStorage,
|
||||
FileJobStorage,
|
||||
createFileJobStorage,
|
||||
type Job,
|
||||
type JobStatus,
|
||||
type JobPriority,
|
||||
type JobQueueConfig,
|
||||
type JobStorageAdapter,
|
||||
type JobHandler,
|
||||
type JobContext
|
||||
} from './background-jobs';
|
||||
|
||||
// Checkpoints
|
||||
export {
|
||||
CheckpointManager,
|
||||
createCheckpointManager,
|
||||
MemoryCheckpointStorage,
|
||||
FileCheckpointStorage,
|
||||
createFileCheckpointStorage,
|
||||
type CheckpointData,
|
||||
type CheckpointConfig,
|
||||
type CheckpointStorage
|
||||
} from './checkpoints';
|
||||
|
||||
// Flow Display
|
||||
export {
|
||||
FlowDisplay,
|
||||
createFlowDisplay,
|
||||
renderWorkflow,
|
||||
type FlowNode,
|
||||
type FlowGraph,
|
||||
type FlowDisplayConfig
|
||||
} from './flow-display';
|
||||
|
||||
// External Agents
|
||||
export {
|
||||
BaseExternalAgent,
|
||||
ClaudeCodeAgent,
|
||||
GeminiCliAgent,
|
||||
CodexCliAgent,
|
||||
AiderAgent,
|
||||
GenericExternalAgent,
|
||||
getExternalAgentRegistry,
|
||||
createExternalAgent,
|
||||
externalAgentAsTool,
|
||||
type ExternalAgentConfig,
|
||||
type ExternalAgentResult
|
||||
} from './external-agents';
|
||||
|
||||
// N8N Integration
|
||||
export {
|
||||
N8NIntegration,
|
||||
createN8NIntegration,
|
||||
triggerN8NWebhook,
|
||||
type N8NConfig,
|
||||
type N8NWebhookPayload,
|
||||
type N8NWorkflow,
|
||||
type N8NWorkflowNode
|
||||
} from './n8n-integration';
|
||||
|
||||
// Fast Context
|
||||
export {
|
||||
FastContext,
|
||||
createFastContext,
|
||||
getQuickContext,
|
||||
type FastContextConfig,
|
||||
type ContextSource,
|
||||
type FastContextResult
|
||||
} from './fast-context';
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Interactive TUI - Terminal User Interface for CLI
|
||||
* Minimal but functional interactive mode with readline
|
||||
*/
|
||||
|
||||
import { SlashCommandHandler, isSlashCommand, SlashCommandContext } from './slash-commands';
|
||||
import { CostTracker, createCostTracker } from './cost-tracker';
|
||||
|
||||
export interface TUIConfig {
|
||||
prompt?: string;
|
||||
welcomeMessage?: string;
|
||||
historyFile?: string;
|
||||
maxHistorySize?: number;
|
||||
onMessage?: (message: string) => Promise<string>;
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
export interface TUIState {
|
||||
running: boolean;
|
||||
history: string[];
|
||||
context: SlashCommandContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive TUI class
|
||||
*/
|
||||
export class InteractiveTUI {
|
||||
private config: TUIConfig;
|
||||
private state: TUIState;
|
||||
private slashHandler: SlashCommandHandler;
|
||||
private costTracker: CostTracker;
|
||||
private readline: any;
|
||||
private rl: any;
|
||||
|
||||
constructor(config: TUIConfig = {}) {
|
||||
this.config = {
|
||||
prompt: '> ',
|
||||
welcomeMessage: 'PraisonAI Interactive Mode. Type /help for commands, /exit to quit.',
|
||||
maxHistorySize: 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.costTracker = createCostTracker();
|
||||
this.state = {
|
||||
running: false,
|
||||
history: [],
|
||||
context: {
|
||||
history: [],
|
||||
settings: {},
|
||||
costTracker: this.costTracker,
|
||||
onOutput: (msg) => this.output(msg)
|
||||
}
|
||||
};
|
||||
|
||||
this.slashHandler = new SlashCommandHandler(this.state.context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the interactive session
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.state.running) return;
|
||||
|
||||
// Lazy load readline
|
||||
this.readline = await import('readline');
|
||||
this.rl = this.readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: true
|
||||
});
|
||||
|
||||
this.state.running = true;
|
||||
|
||||
// Load history if available
|
||||
await this.loadHistory();
|
||||
|
||||
// Show welcome message
|
||||
if (this.config.welcomeMessage) {
|
||||
this.output(this.config.welcomeMessage);
|
||||
}
|
||||
|
||||
// Start the input loop
|
||||
await this.inputLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the interactive session
|
||||
*/
|
||||
stop(): void {
|
||||
this.state.running = false;
|
||||
if (this.rl) {
|
||||
this.rl.close();
|
||||
}
|
||||
this.saveHistory();
|
||||
this.config.onExit?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main input loop
|
||||
*/
|
||||
private async inputLoop(): Promise<void> {
|
||||
while (this.state.running) {
|
||||
const input = await this.prompt();
|
||||
|
||||
if (input === null) {
|
||||
// EOF or Ctrl+C
|
||||
this.stop();
|
||||
break;
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Add to history
|
||||
this.addToHistory(trimmed);
|
||||
|
||||
// Handle slash commands
|
||||
if (isSlashCommand(trimmed)) {
|
||||
const result = await this.slashHandler.handle(trimmed);
|
||||
if (result.shouldExit) {
|
||||
this.stop();
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle regular message
|
||||
if (this.config.onMessage) {
|
||||
try {
|
||||
const response = await this.config.onMessage(trimmed);
|
||||
this.output(response);
|
||||
} catch (error: any) {
|
||||
this.output(`Error: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
this.output('No message handler configured. Use /help for available commands.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for input
|
||||
*/
|
||||
private prompt(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.state.running || !this.rl) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.rl.question(this.config.prompt, (answer: string) => {
|
||||
resolve(answer);
|
||||
});
|
||||
|
||||
// Handle Ctrl+C
|
||||
this.rl.once('close', () => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Output a message
|
||||
*/
|
||||
private output(message: string): void {
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add input to history
|
||||
*/
|
||||
private addToHistory(input: string): void {
|
||||
this.state.history.push(input);
|
||||
if (this.state.history.length > (this.config.maxHistorySize || 1000)) {
|
||||
this.state.history.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load history from file
|
||||
*/
|
||||
private async loadHistory(): Promise<void> {
|
||||
if (!this.config.historyFile) return;
|
||||
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const content = await fs.readFile(this.config.historyFile, 'utf-8');
|
||||
this.state.history = content.trim().split('\n').filter(Boolean);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save history to file
|
||||
*/
|
||||
private async saveHistory(): Promise<void> {
|
||||
if (!this.config.historyFile) return;
|
||||
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
await fs.writeFile(this.config.historyFile, this.state.history.join('\n'));
|
||||
} catch {
|
||||
// Ignore save errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state
|
||||
*/
|
||||
getState(): TUIState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cost tracker
|
||||
*/
|
||||
getCostTracker(): CostTracker {
|
||||
return this.costTracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update context
|
||||
*/
|
||||
updateContext(updates: Partial<SlashCommandContext>): void {
|
||||
this.slashHandler.updateContext(updates);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an interactive TUI instance
|
||||
*/
|
||||
export function createInteractiveTUI(config?: TUIConfig): InteractiveTUI {
|
||||
return new InteractiveTUI(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple status display helper
|
||||
*/
|
||||
export class StatusDisplay {
|
||||
private lines: string[] = [];
|
||||
|
||||
add(label: string, value: string | number): this {
|
||||
this.lines.push(`${label}: ${value}`);
|
||||
return this;
|
||||
}
|
||||
|
||||
addSeparator(): this {
|
||||
this.lines.push('─'.repeat(40));
|
||||
return this;
|
||||
}
|
||||
|
||||
addHeader(text: string): this {
|
||||
this.lines.push(`\n=== ${text} ===`);
|
||||
return this;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.lines.join('\n');
|
||||
}
|
||||
|
||||
print(): void {
|
||||
console.log(this.toString());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.lines = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a status display
|
||||
*/
|
||||
export function createStatusDisplay(): StatusDisplay {
|
||||
return new StatusDisplay();
|
||||
}
|
||||
|
||||
/**
|
||||
* History manager for persistent command history
|
||||
*/
|
||||
export class HistoryManager {
|
||||
private history: string[] = [];
|
||||
private maxSize: number;
|
||||
private filePath?: string;
|
||||
|
||||
constructor(maxSize: number = 1000, filePath?: string) {
|
||||
this.maxSize = maxSize;
|
||||
this.filePath = filePath;
|
||||
}
|
||||
|
||||
add(entry: string): void {
|
||||
// Don't add duplicates of the last entry
|
||||
if (this.history[this.history.length - 1] === entry) return;
|
||||
|
||||
this.history.push(entry);
|
||||
if (this.history.length > this.maxSize) {
|
||||
this.history.shift();
|
||||
}
|
||||
}
|
||||
|
||||
getAll(): string[] {
|
||||
return [...this.history];
|
||||
}
|
||||
|
||||
search(query: string): string[] {
|
||||
const lower = query.toLowerCase();
|
||||
return this.history.filter(h => h.toLowerCase().includes(lower));
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
if (!this.filePath) return;
|
||||
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const content = await fs.readFile(this.filePath, 'utf-8');
|
||||
this.history = content.trim().split('\n').filter(Boolean);
|
||||
} catch {
|
||||
// File doesn't exist yet
|
||||
}
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
if (!this.filePath) return;
|
||||
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
await fs.writeFile(this.filePath, this.history.join('\n'));
|
||||
} catch {
|
||||
// Ignore save errors
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.history = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a history manager
|
||||
*/
|
||||
export function createHistoryManager(maxSize?: number, filePath?: string): HistoryManager {
|
||||
return new HistoryManager(maxSize, filePath);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* N8N Integration - Webhook triggers and workflow export for n8n
|
||||
*/
|
||||
|
||||
export interface N8NConfig {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
webhookPath?: string;
|
||||
}
|
||||
|
||||
export interface N8NWebhookPayload {
|
||||
event: string;
|
||||
data: Record<string, any>;
|
||||
timestamp: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface N8NWorkflowNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
position: [number, number];
|
||||
parameters: Record<string, any>;
|
||||
credentials?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface N8NWorkflowConnection {
|
||||
node: string;
|
||||
type: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface N8NWorkflow {
|
||||
name: string;
|
||||
nodes: N8NWorkflowNode[];
|
||||
connections: Record<string, { main: N8NWorkflowConnection[][] }>;
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* N8N Integration class
|
||||
*/
|
||||
export class N8NIntegration {
|
||||
private config: N8NConfig;
|
||||
|
||||
constructor(config: N8NConfig = {}) {
|
||||
this.config = {
|
||||
baseUrl: config.baseUrl || process.env.N8N_BASE_URL || 'http://localhost:5678',
|
||||
apiKey: config.apiKey || process.env.N8N_API_KEY,
|
||||
webhookPath: config.webhookPath || '/webhook',
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a webhook
|
||||
*/
|
||||
async triggerWebhook(
|
||||
webhookId: string,
|
||||
payload: Record<string, any>
|
||||
): Promise<{ success: boolean; response?: any; error?: string }> {
|
||||
const url = `${this.config.baseUrl}${this.config.webhookPath}/${webhookId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.config.apiKey ? { 'X-N8N-API-KEY': this.config.apiKey } : {})
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${error}` };
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return { success: true, response: data };
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a webhook payload for agent events
|
||||
*/
|
||||
createAgentEventPayload(
|
||||
event: string,
|
||||
agentId: string,
|
||||
data: Record<string, any>
|
||||
): N8NWebhookPayload {
|
||||
return {
|
||||
event,
|
||||
data: {
|
||||
agentId,
|
||||
...data
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'praisonai'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export PraisonAI workflow to n8n format
|
||||
*/
|
||||
exportWorkflow(
|
||||
name: string,
|
||||
steps: Array<{
|
||||
name: string;
|
||||
type: 'agent' | 'tool' | 'condition';
|
||||
config?: Record<string, any>;
|
||||
}>
|
||||
): N8NWorkflow {
|
||||
const nodes: N8NWorkflowNode[] = [];
|
||||
const connections: Record<string, { main: N8NWorkflowConnection[][] }> = {};
|
||||
|
||||
// Add trigger node
|
||||
nodes.push({
|
||||
id: 'trigger',
|
||||
name: 'Webhook Trigger',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'POST',
|
||||
path: name.toLowerCase().replace(/\s+/g, '-'),
|
||||
responseMode: 'onReceived'
|
||||
}
|
||||
});
|
||||
|
||||
let prevNodeId = 'trigger';
|
||||
let yPosition = 300;
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
const nodeId = `node_${i}`;
|
||||
yPosition += 150;
|
||||
|
||||
// Map step type to n8n node type
|
||||
const nodeType = this.mapStepTypeToN8N(step.type);
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
name: step.name,
|
||||
type: nodeType,
|
||||
position: [250, yPosition],
|
||||
parameters: this.mapStepParameters(step)
|
||||
});
|
||||
|
||||
// Add connection from previous node
|
||||
if (!connections[prevNodeId]) {
|
||||
connections[prevNodeId] = { main: [[]] };
|
||||
}
|
||||
connections[prevNodeId].main[0].push({
|
||||
node: nodeId,
|
||||
type: 'main',
|
||||
index: 0
|
||||
});
|
||||
|
||||
prevNodeId = nodeId;
|
||||
}
|
||||
|
||||
// Add response node
|
||||
const responseNodeId = 'response';
|
||||
nodes.push({
|
||||
id: responseNodeId,
|
||||
name: 'Response',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
position: [250, yPosition + 150],
|
||||
parameters: {
|
||||
respondWith: 'json',
|
||||
responseBody: '={{ $json }}'
|
||||
}
|
||||
});
|
||||
|
||||
connections[prevNodeId] = {
|
||||
main: [[{ node: responseNodeId, type: 'main', index: 0 }]]
|
||||
};
|
||||
|
||||
return {
|
||||
name,
|
||||
nodes,
|
||||
connections,
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map PraisonAI step type to n8n node type
|
||||
*/
|
||||
private mapStepTypeToN8N(type: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
agent: 'n8n-nodes-base.httpRequest',
|
||||
tool: 'n8n-nodes-base.function',
|
||||
condition: 'n8n-nodes-base.if'
|
||||
};
|
||||
return mapping[type] || 'n8n-nodes-base.noOp';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map step parameters to n8n format
|
||||
*/
|
||||
private mapStepParameters(step: { type: string; config?: Record<string, any> }): Record<string, any> {
|
||||
switch (step.type) {
|
||||
case 'agent':
|
||||
return {
|
||||
method: 'POST',
|
||||
url: '={{ $env.PRAISONAI_API_URL }}/agent/execute',
|
||||
sendBody: true,
|
||||
bodyParameters: {
|
||||
parameters: [
|
||||
{ name: 'input', value: '={{ $json.input }}' }
|
||||
]
|
||||
},
|
||||
...step.config
|
||||
};
|
||||
case 'tool':
|
||||
return {
|
||||
functionCode: `
|
||||
// Tool execution
|
||||
const input = $input.all();
|
||||
// Add your tool logic here
|
||||
return input;
|
||||
`.trim(),
|
||||
...step.config
|
||||
};
|
||||
case 'condition':
|
||||
return {
|
||||
conditions: {
|
||||
boolean: [
|
||||
{
|
||||
value1: '={{ $json.success }}',
|
||||
value2: true
|
||||
}
|
||||
]
|
||||
},
|
||||
...step.config
|
||||
};
|
||||
default:
|
||||
return step.config || {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate n8n workflow JSON string
|
||||
*/
|
||||
exportWorkflowJSON(
|
||||
name: string,
|
||||
steps: Array<{ name: string; type: 'agent' | 'tool' | 'condition'; config?: Record<string, any> }>
|
||||
): string {
|
||||
const workflow = this.exportWorkflow(name, steps);
|
||||
return JSON.stringify(workflow, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import n8n workflow and convert to PraisonAI format
|
||||
*/
|
||||
importWorkflow(n8nWorkflow: N8NWorkflow): Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
config: Record<string, any>;
|
||||
}> {
|
||||
const steps: Array<{ name: string; type: string; config: Record<string, any> }> = [];
|
||||
|
||||
// Skip trigger and response nodes
|
||||
const workflowNodes = n8nWorkflow.nodes.filter(
|
||||
n => !n.type.includes('webhook') && !n.type.includes('respondToWebhook')
|
||||
);
|
||||
|
||||
for (const node of workflowNodes) {
|
||||
steps.push({
|
||||
name: node.name,
|
||||
type: this.mapN8NTypeToStep(node.type),
|
||||
config: node.parameters
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map n8n node type to PraisonAI step type
|
||||
*/
|
||||
private mapN8NTypeToStep(n8nType: string): string {
|
||||
if (n8nType.includes('httpRequest')) return 'agent';
|
||||
if (n8nType.includes('function') || n8nType.includes('code')) return 'tool';
|
||||
if (n8nType.includes('if') || n8nType.includes('switch')) return 'condition';
|
||||
return 'tool';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a webhook listener (for local development)
|
||||
*/
|
||||
async createWebhookListener(
|
||||
port: number,
|
||||
handler: (payload: N8NWebhookPayload) => Promise<any>
|
||||
): Promise<{ close: () => void }> {
|
||||
const http = await import('http');
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405);
|
||||
res.end('Method not allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; });
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const payload = JSON.parse(body) as N8NWebhookPayload;
|
||||
const result = await handler(payload);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (error: any) {
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ error: error.message }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port);
|
||||
console.log(`N8N webhook listener started on port ${port}`);
|
||||
|
||||
return {
|
||||
close: () => server.close()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an n8n integration instance
|
||||
*/
|
||||
export function createN8NIntegration(config?: N8NConfig): N8NIntegration {
|
||||
return new N8NIntegration(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick function to trigger an n8n webhook
|
||||
*/
|
||||
export async function triggerN8NWebhook(
|
||||
webhookId: string,
|
||||
payload: Record<string, any>,
|
||||
config?: N8NConfig
|
||||
): Promise<{ success: boolean; response?: any; error?: string }> {
|
||||
const integration = createN8NIntegration(config);
|
||||
return integration.triggerWebhook(webhookId, payload);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Repo Map - Repository structure visualization and symbol extraction
|
||||
*/
|
||||
|
||||
export interface RepoMapConfig {
|
||||
rootPath: string;
|
||||
maxDepth?: number;
|
||||
ignorePatterns?: string[];
|
||||
includeSymbols?: boolean;
|
||||
maxFiles?: number;
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
size: number;
|
||||
isDirectory: boolean;
|
||||
children?: FileInfo[];
|
||||
symbols?: SymbolInfo[];
|
||||
}
|
||||
|
||||
export interface SymbolInfo {
|
||||
name: string;
|
||||
type: 'function' | 'class' | 'method' | 'variable' | 'interface' | 'type' | 'export';
|
||||
line: number;
|
||||
exported?: boolean;
|
||||
}
|
||||
|
||||
export interface RepoMapResult {
|
||||
root: string;
|
||||
tree: FileInfo;
|
||||
totalFiles: number;
|
||||
totalDirectories: number;
|
||||
symbols: SymbolInfo[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default ignore patterns
|
||||
*/
|
||||
export const DEFAULT_IGNORE_PATTERNS = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'coverage',
|
||||
'.cache',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
'venv',
|
||||
'.venv',
|
||||
'*.pyc',
|
||||
'*.pyo',
|
||||
'.DS_Store',
|
||||
'Thumbs.db',
|
||||
'*.log',
|
||||
'*.lock'
|
||||
];
|
||||
|
||||
/**
|
||||
* RepoMap class for repository analysis
|
||||
*/
|
||||
export class RepoMap {
|
||||
private config: RepoMapConfig;
|
||||
private fs: any;
|
||||
private path: any;
|
||||
|
||||
constructor(config: RepoMapConfig) {
|
||||
this.config = {
|
||||
maxDepth: 10,
|
||||
ignorePatterns: DEFAULT_IGNORE_PATTERNS,
|
||||
includeSymbols: true,
|
||||
maxFiles: 1000,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate repository map
|
||||
*/
|
||||
async generate(): Promise<RepoMapResult> {
|
||||
this.fs = await import('fs/promises');
|
||||
this.path = await import('path');
|
||||
|
||||
const tree = await this.scanDirectory(this.config.rootPath, 0);
|
||||
const { files, directories } = this.countItems(tree);
|
||||
const symbols = this.config.includeSymbols ? this.collectSymbols(tree) : [];
|
||||
|
||||
return {
|
||||
root: this.config.rootPath,
|
||||
tree,
|
||||
totalFiles: files,
|
||||
totalDirectories: directories,
|
||||
symbols,
|
||||
summary: this.generateSummary(tree, files, directories, symbols.length)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a directory recursively
|
||||
*/
|
||||
private async scanDirectory(dirPath: string, depth: number): Promise<FileInfo> {
|
||||
const relativePath = this.path.relative(this.config.rootPath, dirPath);
|
||||
const stats = await this.fs.stat(dirPath);
|
||||
|
||||
const info: FileInfo = {
|
||||
path: dirPath,
|
||||
relativePath: relativePath || '.',
|
||||
size: stats.size,
|
||||
isDirectory: stats.isDirectory()
|
||||
};
|
||||
|
||||
if (!stats.isDirectory() || depth >= (this.config.maxDepth || 10)) {
|
||||
return info;
|
||||
}
|
||||
|
||||
const entries = await this.fs.readdir(dirPath, { withFileTypes: true });
|
||||
info.children = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (this.shouldIgnore(entry.name)) continue;
|
||||
|
||||
const entryPath = this.path.join(dirPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const child = await this.scanDirectory(entryPath, depth + 1);
|
||||
info.children.push(child);
|
||||
} else {
|
||||
const fileStats = await this.fs.stat(entryPath).catch(() => null);
|
||||
if (!fileStats) continue;
|
||||
|
||||
const fileInfo: FileInfo = {
|
||||
path: entryPath,
|
||||
relativePath: this.path.relative(this.config.rootPath, entryPath),
|
||||
size: fileStats.size,
|
||||
isDirectory: false
|
||||
};
|
||||
|
||||
if (this.config.includeSymbols && this.isCodeFile(entry.name)) {
|
||||
fileInfo.symbols = await this.extractSymbols(entryPath);
|
||||
}
|
||||
|
||||
info.children.push(fileInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: directories first, then files alphabetically
|
||||
info.children.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1;
|
||||
}
|
||||
return a.relativePath.localeCompare(b.relativePath);
|
||||
});
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path should be ignored
|
||||
*/
|
||||
private shouldIgnore(name: string): boolean {
|
||||
for (const pattern of this.config.ignorePatterns || []) {
|
||||
if (pattern.startsWith('*')) {
|
||||
if (name.endsWith(pattern.slice(1))) return true;
|
||||
} else if (name === pattern) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is a code file
|
||||
*/
|
||||
private isCodeFile(name: string): boolean {
|
||||
const codeExtensions = ['.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs', '.java', '.rb', '.php'];
|
||||
return codeExtensions.some(ext => name.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract symbols from a file
|
||||
*/
|
||||
private async extractSymbols(filePath: string): Promise<SymbolInfo[]> {
|
||||
try {
|
||||
const content = await this.fs.readFile(filePath, 'utf-8');
|
||||
const ext = this.path.extname(filePath);
|
||||
|
||||
switch (ext) {
|
||||
case '.ts':
|
||||
case '.tsx':
|
||||
case '.js':
|
||||
case '.jsx':
|
||||
return this.extractJSSymbols(content);
|
||||
case '.py':
|
||||
return this.extractPythonSymbols(content);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract symbols from JavaScript/TypeScript
|
||||
*/
|
||||
private extractJSSymbols(content: string): SymbolInfo[] {
|
||||
const symbols: SymbolInfo[] = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
const patterns = [
|
||||
{ regex: /^export\s+(?:async\s+)?function\s+(\w+)/m, type: 'function' as const, exported: true },
|
||||
{ regex: /^export\s+class\s+(\w+)/m, type: 'class' as const, exported: true },
|
||||
{ regex: /^export\s+interface\s+(\w+)/m, type: 'interface' as const, exported: true },
|
||||
{ regex: /^export\s+type\s+(\w+)/m, type: 'type' as const, exported: true },
|
||||
{ regex: /^export\s+const\s+(\w+)/m, type: 'variable' as const, exported: true },
|
||||
{ regex: /^(?:async\s+)?function\s+(\w+)/m, type: 'function' as const },
|
||||
{ regex: /^class\s+(\w+)/m, type: 'class' as const },
|
||||
{ regex: /^interface\s+(\w+)/m, type: 'interface' as const },
|
||||
{ regex: /^type\s+(\w+)/m, type: 'type' as const },
|
||||
{ regex: /^const\s+(\w+)\s*=/m, type: 'variable' as const },
|
||||
];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
for (const { regex, type, exported } of patterns) {
|
||||
const match = line.match(regex);
|
||||
if (match) {
|
||||
symbols.push({
|
||||
name: match[1],
|
||||
type,
|
||||
line: i + 1,
|
||||
exported
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return symbols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract symbols from Python
|
||||
*/
|
||||
private extractPythonSymbols(content: string): SymbolInfo[] {
|
||||
const symbols: SymbolInfo[] = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
const patterns = [
|
||||
{ regex: /^def\s+(\w+)\s*\(/m, type: 'function' as const },
|
||||
{ regex: /^async\s+def\s+(\w+)\s*\(/m, type: 'function' as const },
|
||||
{ regex: /^class\s+(\w+)/m, type: 'class' as const },
|
||||
];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Skip indented definitions (methods)
|
||||
if (line.startsWith(' ') || line.startsWith('\t')) continue;
|
||||
|
||||
for (const { regex, type } of patterns) {
|
||||
const match = line.match(regex);
|
||||
if (match) {
|
||||
symbols.push({
|
||||
name: match[1],
|
||||
type,
|
||||
line: i + 1
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return symbols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count files and directories
|
||||
*/
|
||||
private countItems(tree: FileInfo): { files: number; directories: number } {
|
||||
let files = 0;
|
||||
let directories = 0;
|
||||
|
||||
const count = (node: FileInfo) => {
|
||||
if (node.isDirectory) {
|
||||
directories++;
|
||||
for (const child of node.children || []) {
|
||||
count(child);
|
||||
}
|
||||
} else {
|
||||
files++;
|
||||
}
|
||||
};
|
||||
|
||||
count(tree);
|
||||
return { files, directories };
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all symbols from tree
|
||||
*/
|
||||
private collectSymbols(tree: FileInfo): SymbolInfo[] {
|
||||
const symbols: SymbolInfo[] = [];
|
||||
|
||||
const collect = (node: FileInfo) => {
|
||||
if (node.symbols) {
|
||||
symbols.push(...node.symbols);
|
||||
}
|
||||
for (const child of node.children || []) {
|
||||
collect(child);
|
||||
}
|
||||
};
|
||||
|
||||
collect(tree);
|
||||
return symbols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text summary
|
||||
*/
|
||||
private generateSummary(tree: FileInfo, files: number, directories: number, symbolCount: number): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`Repository: ${this.config.rootPath}`);
|
||||
lines.push(`Files: ${files}, Directories: ${directories}, Symbols: ${symbolCount}`);
|
||||
lines.push('');
|
||||
lines.push(this.renderTree(tree, ''));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render tree as text
|
||||
*/
|
||||
private renderTree(node: FileInfo, prefix: string): string {
|
||||
const lines: string[] = [];
|
||||
const name = this.path.basename(node.path);
|
||||
|
||||
if (node.isDirectory) {
|
||||
lines.push(`${prefix}${name}/`);
|
||||
const children = node.children || [];
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const isLast = i === children.length - 1;
|
||||
const childPrefix = prefix + (isLast ? '└── ' : '├── ');
|
||||
const nextPrefix = prefix + (isLast ? ' ' : '│ ');
|
||||
lines.push(this.renderTree(children[i], childPrefix).replace(childPrefix, ''));
|
||||
|
||||
// Add children lines with proper prefix
|
||||
const childLines = this.renderTree(children[i], nextPrefix).split('\n').slice(1);
|
||||
lines.push(...childLines);
|
||||
}
|
||||
} else {
|
||||
const symbolCount = node.symbols?.length || 0;
|
||||
const symbolSuffix = symbolCount > 0 ? ` (${symbolCount} symbols)` : '';
|
||||
lines.push(`${prefix}${name}${symbolSuffix}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tree as simple text format
|
||||
*/
|
||||
async getTreeText(): Promise<string> {
|
||||
const result = await this.generate();
|
||||
return result.summary;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a repo map instance
|
||||
*/
|
||||
export function createRepoMap(config: RepoMapConfig): RepoMap {
|
||||
return new RepoMap(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick function to get repo tree
|
||||
*/
|
||||
export async function getRepoTree(rootPath: string, maxDepth?: number): Promise<string> {
|
||||
const map = createRepoMap({ rootPath, maxDepth, includeSymbols: false });
|
||||
return map.getTreeText();
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Sandbox Executor - Safe command execution with restrictions
|
||||
*/
|
||||
|
||||
export type SandboxMode = 'disabled' | 'basic' | 'strict' | 'network-isolated';
|
||||
|
||||
export interface SandboxConfig {
|
||||
mode: SandboxMode;
|
||||
allowedCommands?: string[];
|
||||
blockedCommands?: string[];
|
||||
blockedPaths?: string[];
|
||||
timeout?: number;
|
||||
maxOutputSize?: number;
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ExecutionResult {
|
||||
success: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
duration: number;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default blocked commands (dangerous operations)
|
||||
*/
|
||||
export const DEFAULT_BLOCKED_COMMANDS = [
|
||||
'rm -rf /',
|
||||
'rm -rf /*',
|
||||
'rm -rf ~',
|
||||
'rm -rf ~/*',
|
||||
'mkfs',
|
||||
'dd if=/dev/zero',
|
||||
'dd if=/dev/random',
|
||||
':(){ :|:& };:',
|
||||
'chmod -R 777 /',
|
||||
'chown -R',
|
||||
'sudo rm',
|
||||
'sudo dd',
|
||||
'sudo mkfs',
|
||||
'shutdown',
|
||||
'reboot',
|
||||
'halt',
|
||||
'poweroff',
|
||||
'init 0',
|
||||
'init 6'
|
||||
];
|
||||
|
||||
/**
|
||||
* Default blocked paths
|
||||
*/
|
||||
export const DEFAULT_BLOCKED_PATHS = [
|
||||
'/etc/passwd',
|
||||
'/etc/shadow',
|
||||
'/etc/sudoers',
|
||||
'/root',
|
||||
'~/.ssh',
|
||||
'~/.gnupg',
|
||||
'~/.aws',
|
||||
'~/.config/gcloud',
|
||||
'/var/log',
|
||||
'/boot',
|
||||
'/sys',
|
||||
'/proc'
|
||||
];
|
||||
|
||||
/**
|
||||
* Command validator
|
||||
*/
|
||||
export class CommandValidator {
|
||||
private blockedCommands: string[];
|
||||
private blockedPaths: string[];
|
||||
private allowedCommands?: string[];
|
||||
|
||||
constructor(config: Partial<SandboxConfig> = {}) {
|
||||
this.blockedCommands = config.blockedCommands || DEFAULT_BLOCKED_COMMANDS;
|
||||
this.blockedPaths = config.blockedPaths || DEFAULT_BLOCKED_PATHS;
|
||||
this.allowedCommands = config.allowedCommands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a command
|
||||
*/
|
||||
validate(command: string): { valid: boolean; reason?: string } {
|
||||
const normalized = command.toLowerCase().trim();
|
||||
|
||||
// Check allowlist first if specified
|
||||
if (this.allowedCommands) {
|
||||
const baseCmd = normalized.split(/\s+/)[0];
|
||||
if (!this.allowedCommands.includes(baseCmd)) {
|
||||
return { valid: false, reason: `Command '${baseCmd}' not in allowlist` };
|
||||
}
|
||||
}
|
||||
|
||||
// Check blocked commands
|
||||
for (const blocked of this.blockedCommands) {
|
||||
if (normalized.includes(blocked.toLowerCase())) {
|
||||
return { valid: false, reason: `Blocked command pattern: ${blocked}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Check blocked paths
|
||||
for (const blockedPath of this.blockedPaths) {
|
||||
const expandedPath = blockedPath.replace('~', process.env.HOME || '');
|
||||
if (normalized.includes(expandedPath.toLowerCase())) {
|
||||
return { valid: false, reason: `Blocked path: ${blockedPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for shell injection patterns
|
||||
const dangerousPatterns = [
|
||||
/;\s*rm\s/i,
|
||||
/\|\s*rm\s/i,
|
||||
/`.*`/,
|
||||
/\$\(.*\)/,
|
||||
/>\s*\/dev\/sd/i,
|
||||
/>\s*\/dev\/null.*2>&1.*&/i
|
||||
];
|
||||
|
||||
for (const pattern of dangerousPatterns) {
|
||||
if (pattern.test(command)) {
|
||||
return { valid: false, reason: 'Potentially dangerous pattern detected' };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox Executor class
|
||||
*/
|
||||
export class SandboxExecutor {
|
||||
private config: SandboxConfig;
|
||||
private validator: CommandValidator;
|
||||
|
||||
constructor(config: Partial<SandboxConfig> = {}) {
|
||||
this.config = {
|
||||
mode: 'basic',
|
||||
timeout: 30000,
|
||||
maxOutputSize: 1024 * 1024, // 1MB
|
||||
cwd: process.cwd(),
|
||||
...config
|
||||
};
|
||||
this.validator = new CommandValidator(this.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command in the sandbox
|
||||
*/
|
||||
async execute(command: string): Promise<ExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Disabled mode - no execution
|
||||
if (this.config.mode === 'disabled') {
|
||||
return {
|
||||
success: false,
|
||||
stdout: '',
|
||||
stderr: 'Sandbox execution is disabled',
|
||||
exitCode: 1,
|
||||
duration: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Validate command
|
||||
const validation = this.validator.validate(command);
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
success: false,
|
||||
stdout: '',
|
||||
stderr: `Command rejected: ${validation.reason}`,
|
||||
exitCode: 1,
|
||||
duration: 0
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.spawn(command);
|
||||
return {
|
||||
...result,
|
||||
duration: Date.now() - startTime
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
stdout: '',
|
||||
stderr: error.message,
|
||||
exitCode: 1,
|
||||
duration: Date.now() - startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the command
|
||||
*/
|
||||
private async spawn(command: string): Promise<Omit<ExecutionResult, 'duration'>> {
|
||||
const { spawn } = await import('child_process');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const env = this.buildEnv();
|
||||
|
||||
const proc = spawn('sh', ['-c', command], {
|
||||
cwd: this.config.cwd,
|
||||
env,
|
||||
timeout: this.config.timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let truncated = false;
|
||||
const maxSize = this.config.maxOutputSize || 1024 * 1024;
|
||||
|
||||
proc.stdout?.on('data', (data) => {
|
||||
if (stdout.length < maxSize) {
|
||||
stdout += data.toString();
|
||||
if (stdout.length > maxSize) {
|
||||
stdout = stdout.slice(0, maxSize);
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr?.on('data', (data) => {
|
||||
if (stderr.length < maxSize) {
|
||||
stderr += data.toString();
|
||||
if (stderr.length > maxSize) {
|
||||
stderr = stderr.slice(0, maxSize);
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
resolve({
|
||||
success: code === 0,
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: code || 0,
|
||||
truncated
|
||||
});
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
resolve({
|
||||
success: false,
|
||||
stdout,
|
||||
stderr: error.message,
|
||||
exitCode: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build environment variables based on sandbox mode
|
||||
*/
|
||||
private buildEnv(): Record<string, string> {
|
||||
const baseEnv = { ...process.env, ...this.config.env };
|
||||
|
||||
switch (this.config.mode) {
|
||||
case 'strict':
|
||||
// Minimal environment
|
||||
return {
|
||||
PATH: '/usr/local/bin:/usr/bin:/bin',
|
||||
HOME: process.env.HOME || '',
|
||||
USER: process.env.USER || '',
|
||||
SHELL: '/bin/sh',
|
||||
TERM: 'dumb',
|
||||
...this.config.env
|
||||
};
|
||||
|
||||
case 'network-isolated':
|
||||
// No network access (requires additional OS-level setup)
|
||||
return {
|
||||
...baseEnv,
|
||||
http_proxy: 'http://localhost:0',
|
||||
https_proxy: 'http://localhost:0',
|
||||
HTTP_PROXY: 'http://localhost:0',
|
||||
HTTPS_PROXY: 'http://localhost:0',
|
||||
no_proxy: '',
|
||||
NO_PROXY: ''
|
||||
};
|
||||
|
||||
case 'basic':
|
||||
default:
|
||||
return baseEnv as Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command would be allowed
|
||||
*/
|
||||
wouldAllow(command: string): { allowed: boolean; reason?: string } {
|
||||
if (this.config.mode === 'disabled') {
|
||||
return { allowed: false, reason: 'Sandbox execution is disabled' };
|
||||
}
|
||||
const validation = this.validator.validate(command);
|
||||
return { allowed: validation.valid, reason: validation.reason };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current sandbox mode
|
||||
*/
|
||||
getMode(): SandboxMode {
|
||||
return this.config.mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sandbox mode
|
||||
*/
|
||||
setMode(mode: SandboxMode): void {
|
||||
this.config.mode = mode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sandbox executor
|
||||
*/
|
||||
export function createSandboxExecutor(config?: Partial<SandboxConfig>): SandboxExecutor {
|
||||
return new SandboxExecutor(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick execute with default sandbox
|
||||
*/
|
||||
export async function sandboxExec(command: string, config?: Partial<SandboxConfig>): Promise<ExecutionResult> {
|
||||
const executor = createSandboxExecutor(config);
|
||||
return executor.execute(command);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Agent Scheduler - Cron-like scheduling for agent tasks
|
||||
*/
|
||||
|
||||
export interface ScheduleConfig {
|
||||
id?: string;
|
||||
name: string;
|
||||
cron?: string;
|
||||
interval?: number; // milliseconds
|
||||
task: () => Promise<any>;
|
||||
enabled?: boolean;
|
||||
maxRuns?: number;
|
||||
onComplete?: (result: any) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export interface ScheduledTask {
|
||||
id: string;
|
||||
name: string;
|
||||
cron?: string;
|
||||
interval?: number;
|
||||
enabled: boolean;
|
||||
lastRun?: Date;
|
||||
nextRun?: Date;
|
||||
runCount: number;
|
||||
maxRuns?: number;
|
||||
status: 'idle' | 'running' | 'completed' | 'error';
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface SchedulerStats {
|
||||
totalTasks: number;
|
||||
activeTasks: number;
|
||||
totalRuns: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cron expression (simplified: minute hour day month weekday)
|
||||
*/
|
||||
function parseCron(cron: string): { minute: number[]; hour: number[]; day: number[]; month: number[]; weekday: number[] } {
|
||||
const parts = cron.split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
throw new Error('Invalid cron expression. Expected: minute hour day month weekday');
|
||||
}
|
||||
|
||||
const parseField = (field: string, min: number, max: number): number[] => {
|
||||
if (field === '*') {
|
||||
return Array.from({ length: max - min + 1 }, (_, i) => min + i);
|
||||
}
|
||||
if (field.includes('/')) {
|
||||
const [, step] = field.split('/');
|
||||
const stepNum = parseInt(step);
|
||||
return Array.from({ length: Math.ceil((max - min + 1) / stepNum) }, (_, i) => min + i * stepNum);
|
||||
}
|
||||
if (field.includes(',')) {
|
||||
return field.split(',').map(n => parseInt(n));
|
||||
}
|
||||
if (field.includes('-')) {
|
||||
const [start, end] = field.split('-').map(n => parseInt(n));
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||
}
|
||||
return [parseInt(field)];
|
||||
};
|
||||
|
||||
return {
|
||||
minute: parseField(parts[0], 0, 59),
|
||||
hour: parseField(parts[1], 0, 23),
|
||||
day: parseField(parts[2], 1, 31),
|
||||
month: parseField(parts[3], 1, 12),
|
||||
weekday: parseField(parts[4], 0, 6)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next run time from cron expression
|
||||
*/
|
||||
function getNextCronRun(cron: string, from: Date = new Date()): Date {
|
||||
const parsed = parseCron(cron);
|
||||
const next = new Date(from);
|
||||
next.setSeconds(0);
|
||||
next.setMilliseconds(0);
|
||||
next.setMinutes(next.getMinutes() + 1);
|
||||
|
||||
for (let i = 0; i < 366 * 24 * 60; i++) { // Max 1 year search
|
||||
const minute = next.getMinutes();
|
||||
const hour = next.getHours();
|
||||
const day = next.getDate();
|
||||
const month = next.getMonth() + 1;
|
||||
const weekday = next.getDay();
|
||||
|
||||
if (
|
||||
parsed.minute.includes(minute) &&
|
||||
parsed.hour.includes(hour) &&
|
||||
parsed.day.includes(day) &&
|
||||
parsed.month.includes(month) &&
|
||||
parsed.weekday.includes(weekday)
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
|
||||
next.setMinutes(next.getMinutes() + 1);
|
||||
}
|
||||
|
||||
throw new Error('Could not find next run time');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduler class
|
||||
*/
|
||||
export class Scheduler {
|
||||
private tasks: Map<string, { config: ScheduleConfig; task: ScheduledTask; timer?: NodeJS.Timeout }> = new Map();
|
||||
private running: boolean = false;
|
||||
private stats: SchedulerStats = {
|
||||
totalTasks: 0,
|
||||
activeTasks: 0,
|
||||
totalRuns: 0,
|
||||
errors: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a scheduled task
|
||||
*/
|
||||
add(config: ScheduleConfig): string {
|
||||
const id = config.id || `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const task: ScheduledTask = {
|
||||
id,
|
||||
name: config.name,
|
||||
cron: config.cron,
|
||||
interval: config.interval,
|
||||
enabled: config.enabled ?? true,
|
||||
runCount: 0,
|
||||
maxRuns: config.maxRuns,
|
||||
status: 'idle'
|
||||
};
|
||||
|
||||
if (config.cron) {
|
||||
task.nextRun = getNextCronRun(config.cron);
|
||||
} else if (config.interval) {
|
||||
task.nextRun = new Date(Date.now() + config.interval);
|
||||
}
|
||||
|
||||
this.tasks.set(id, { config, task });
|
||||
this.stats.totalTasks++;
|
||||
|
||||
if (this.running && task.enabled) {
|
||||
this.scheduleTask(id);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a task
|
||||
*/
|
||||
remove(id: string): boolean {
|
||||
const entry = this.tasks.get(id);
|
||||
if (!entry) return false;
|
||||
|
||||
if (entry.timer) {
|
||||
clearTimeout(entry.timer);
|
||||
}
|
||||
|
||||
this.tasks.delete(id);
|
||||
this.stats.totalTasks--;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a task
|
||||
*/
|
||||
enable(id: string): boolean {
|
||||
const entry = this.tasks.get(id);
|
||||
if (!entry) return false;
|
||||
|
||||
entry.task.enabled = true;
|
||||
if (this.running) {
|
||||
this.scheduleTask(id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a task
|
||||
*/
|
||||
disable(id: string): boolean {
|
||||
const entry = this.tasks.get(id);
|
||||
if (!entry) return false;
|
||||
|
||||
entry.task.enabled = false;
|
||||
if (entry.timer) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.timer = undefined;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the scheduler
|
||||
*/
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
|
||||
for (const [id, entry] of this.tasks) {
|
||||
if (entry.task.enabled) {
|
||||
this.scheduleTask(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the scheduler
|
||||
*/
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
|
||||
for (const entry of this.tasks.values()) {
|
||||
if (entry.timer) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.timer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a task's next run
|
||||
*/
|
||||
private scheduleTask(id: string): void {
|
||||
const entry = this.tasks.get(id);
|
||||
if (!entry || !entry.task.enabled || !this.running) return;
|
||||
|
||||
// Check if max runs reached
|
||||
if (entry.task.maxRuns && entry.task.runCount >= entry.task.maxRuns) {
|
||||
entry.task.status = 'completed';
|
||||
return;
|
||||
}
|
||||
|
||||
let delay: number;
|
||||
|
||||
if (entry.config.cron) {
|
||||
const nextRun = getNextCronRun(entry.config.cron);
|
||||
entry.task.nextRun = nextRun;
|
||||
delay = nextRun.getTime() - Date.now();
|
||||
} else if (entry.config.interval) {
|
||||
entry.task.nextRun = new Date(Date.now() + entry.config.interval);
|
||||
delay = entry.config.interval;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.timer = setTimeout(() => this.runTask(id), delay);
|
||||
this.stats.activeTasks = Array.from(this.tasks.values()).filter(e => e.timer).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a task
|
||||
*/
|
||||
private async runTask(id: string): Promise<void> {
|
||||
const entry = this.tasks.get(id);
|
||||
if (!entry) return;
|
||||
|
||||
entry.task.status = 'running';
|
||||
entry.task.lastRun = new Date();
|
||||
|
||||
try {
|
||||
const result = await entry.config.task();
|
||||
entry.task.runCount++;
|
||||
entry.task.status = 'idle';
|
||||
entry.task.lastError = undefined;
|
||||
this.stats.totalRuns++;
|
||||
|
||||
entry.config.onComplete?.(result);
|
||||
} catch (error: any) {
|
||||
entry.task.status = 'error';
|
||||
entry.task.lastError = error.message;
|
||||
this.stats.errors++;
|
||||
|
||||
entry.config.onError?.(error);
|
||||
}
|
||||
|
||||
// Schedule next run
|
||||
this.scheduleTask(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a task immediately
|
||||
*/
|
||||
async runNow(id: string): Promise<any> {
|
||||
const entry = this.tasks.get(id);
|
||||
if (!entry) throw new Error(`Task ${id} not found`);
|
||||
|
||||
entry.task.status = 'running';
|
||||
entry.task.lastRun = new Date();
|
||||
|
||||
try {
|
||||
const result = await entry.config.task();
|
||||
entry.task.runCount++;
|
||||
entry.task.status = 'idle';
|
||||
this.stats.totalRuns++;
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
entry.task.status = 'error';
|
||||
entry.task.lastError = error.message;
|
||||
this.stats.errors++;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task info
|
||||
*/
|
||||
getTask(id: string): ScheduledTask | undefined {
|
||||
return this.tasks.get(id)?.task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tasks
|
||||
*/
|
||||
getAllTasks(): ScheduledTask[] {
|
||||
return Array.from(this.tasks.values()).map(e => e.task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler stats
|
||||
*/
|
||||
getStats(): SchedulerStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if scheduler is running
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scheduler instance
|
||||
*/
|
||||
export function createScheduler(): Scheduler {
|
||||
return new Scheduler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create common cron expressions
|
||||
*/
|
||||
export const cronExpressions = {
|
||||
everyMinute: '* * * * *',
|
||||
every5Minutes: '*/5 * * * *',
|
||||
every15Minutes: '*/15 * * * *',
|
||||
everyHour: '0 * * * *',
|
||||
everyDay: '0 0 * * *',
|
||||
everyWeek: '0 0 * * 0',
|
||||
everyMonth: '0 0 1 * *',
|
||||
weekdays: '0 9 * * 1-5',
|
||||
weekends: '0 10 * * 0,6'
|
||||
};
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Slash Commands - Interactive command system for CLI
|
||||
* Supports /help, /cost, /clear, /model, /tokens, /plan, /undo, /diff, /commit, /exit, /settings, /map
|
||||
*/
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
description: string;
|
||||
usage?: string;
|
||||
execute: (args: string[], context: SlashCommandContext) => Promise<SlashCommandResult>;
|
||||
}
|
||||
|
||||
export interface SlashCommandContext {
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
tokenCount?: number;
|
||||
costTracker?: CostTracker;
|
||||
history?: string[];
|
||||
settings?: Record<string, any>;
|
||||
onOutput?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface SlashCommandResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: any;
|
||||
shouldExit?: boolean;
|
||||
}
|
||||
|
||||
export interface CostTracker {
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
requests: number;
|
||||
addUsage(model: string, inputTokens: number, outputTokens: number, latencyMs?: number): any;
|
||||
reset(): void;
|
||||
getSummary(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in slash commands
|
||||
*/
|
||||
const BUILTIN_COMMANDS: SlashCommand[] = [
|
||||
{
|
||||
name: 'help',
|
||||
aliases: ['h', '?'],
|
||||
description: 'Show available commands',
|
||||
execute: async (_args, context) => {
|
||||
const commands = getRegistry().getAll();
|
||||
const lines = ['Available commands:', ''];
|
||||
for (const cmd of commands) {
|
||||
const aliases = cmd.aliases ? ` (${cmd.aliases.map(a => '/' + a).join(', ')})` : '';
|
||||
lines.push(` /${cmd.name}${aliases} - ${cmd.description}`);
|
||||
if (cmd.usage) {
|
||||
lines.push(` Usage: ${cmd.usage}`);
|
||||
}
|
||||
}
|
||||
context.onOutput?.(lines.join('\n'));
|
||||
return { success: true, message: lines.join('\n') };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'cost',
|
||||
aliases: ['c'],
|
||||
description: 'Show token usage and cost',
|
||||
execute: async (_args, context) => {
|
||||
if (!context.costTracker) {
|
||||
return { success: false, message: 'Cost tracking not enabled' };
|
||||
}
|
||||
const summary = context.costTracker.getSummary();
|
||||
context.onOutput?.(summary);
|
||||
return { success: true, message: summary };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
description: 'Clear conversation history',
|
||||
execute: async (_args, context) => {
|
||||
if (context.history) {
|
||||
context.history.length = 0;
|
||||
}
|
||||
context.onOutput?.('Conversation cleared');
|
||||
return { success: true, message: 'Conversation cleared' };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'model',
|
||||
aliases: ['m'],
|
||||
description: 'Show or change the current model',
|
||||
usage: '/model [model-name]',
|
||||
execute: async (args, context) => {
|
||||
if (args.length === 0) {
|
||||
const msg = `Current model: ${context.model || 'default'}`;
|
||||
context.onOutput?.(msg);
|
||||
return { success: true, message: msg, data: { model: context.model } };
|
||||
}
|
||||
const newModel = args[0];
|
||||
context.model = newModel;
|
||||
const msg = `Model changed to: ${newModel}`;
|
||||
context.onOutput?.(msg);
|
||||
return { success: true, message: msg, data: { model: newModel } };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tokens',
|
||||
aliases: ['t'],
|
||||
description: 'Show token count for current session',
|
||||
execute: async (_args, context) => {
|
||||
const count = context.tokenCount || 0;
|
||||
const msg = `Token count: ${count}`;
|
||||
context.onOutput?.(msg);
|
||||
return { success: true, message: msg, data: { tokens: count } };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'plan',
|
||||
aliases: ['p'],
|
||||
description: 'Show or manage task plan',
|
||||
usage: '/plan [add|remove|clear] [task]',
|
||||
execute: async (args, context) => {
|
||||
const plans = context.settings?.plans || [];
|
||||
|
||||
if (args.length === 0) {
|
||||
if (plans.length === 0) {
|
||||
context.onOutput?.('No plans set');
|
||||
return { success: true, message: 'No plans set' };
|
||||
}
|
||||
const msg = ['Current plan:', ...plans.map((p: string, i: number) => ` ${i + 1}. ${p}`)].join('\n');
|
||||
context.onOutput?.(msg);
|
||||
return { success: true, message: msg, data: { plans } };
|
||||
}
|
||||
|
||||
const action = args[0];
|
||||
const task = args.slice(1).join(' ');
|
||||
|
||||
if (action === 'add' && task) {
|
||||
plans.push(task);
|
||||
context.settings = { ...context.settings, plans };
|
||||
context.onOutput?.(`Added: ${task}`);
|
||||
return { success: true, message: `Added: ${task}` };
|
||||
}
|
||||
if (action === 'remove' && task) {
|
||||
const index = parseInt(task) - 1;
|
||||
if (index >= 0 && index < plans.length) {
|
||||
const removed = plans.splice(index, 1)[0];
|
||||
context.settings = { ...context.settings, plans };
|
||||
context.onOutput?.(`Removed: ${removed}`);
|
||||
return { success: true, message: `Removed: ${removed}` };
|
||||
}
|
||||
}
|
||||
if (action === 'clear') {
|
||||
context.settings = { ...context.settings, plans: [] };
|
||||
context.onOutput?.('Plans cleared');
|
||||
return { success: true, message: 'Plans cleared' };
|
||||
}
|
||||
|
||||
return { success: false, message: 'Invalid plan command' };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'undo',
|
||||
aliases: ['u'],
|
||||
description: 'Undo last action',
|
||||
execute: async (_args, context) => {
|
||||
if (context.history && context.history.length > 0) {
|
||||
const removed = context.history.pop();
|
||||
context.onOutput?.(`Undone: ${removed?.substring(0, 50)}...`);
|
||||
return { success: true, message: 'Last action undone' };
|
||||
}
|
||||
return { success: false, message: 'Nothing to undo' };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'diff',
|
||||
aliases: ['d'],
|
||||
description: 'Show pending changes',
|
||||
execute: async (_args, context) => {
|
||||
const diff = context.settings?.pendingDiff || 'No pending changes';
|
||||
context.onOutput?.(diff);
|
||||
return { success: true, message: diff };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'commit',
|
||||
description: 'Commit pending changes',
|
||||
execute: async (args, context) => {
|
||||
const message = args.join(' ') || 'Auto-commit';
|
||||
context.onOutput?.(`Committed with message: ${message}`);
|
||||
context.settings = { ...context.settings, pendingDiff: null };
|
||||
return { success: true, message: `Committed: ${message}` };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'exit',
|
||||
aliases: ['quit', 'q'],
|
||||
description: 'Exit the session',
|
||||
execute: async (_args, context) => {
|
||||
context.onOutput?.('Goodbye!');
|
||||
return { success: true, message: 'Exiting', shouldExit: true };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
aliases: ['s'],
|
||||
description: 'Show or modify settings',
|
||||
usage: '/settings [key] [value]',
|
||||
execute: async (args, context) => {
|
||||
if (args.length === 0) {
|
||||
const settings = JSON.stringify(context.settings || {}, null, 2);
|
||||
context.onOutput?.(settings);
|
||||
return { success: true, message: settings, data: context.settings };
|
||||
}
|
||||
if (args.length === 1) {
|
||||
const value = context.settings?.[args[0]];
|
||||
const msg = `${args[0]}: ${JSON.stringify(value)}`;
|
||||
context.onOutput?.(msg);
|
||||
return { success: true, message: msg, data: { [args[0]]: value } };
|
||||
}
|
||||
const [key, ...valueParts] = args;
|
||||
let value: any = valueParts.join(' ');
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
// Keep as string
|
||||
}
|
||||
context.settings = { ...context.settings, [key]: value };
|
||||
context.onOutput?.(`Set ${key} = ${JSON.stringify(value)}`);
|
||||
return { success: true, message: `Set ${key}` };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'map',
|
||||
description: 'Show repository map',
|
||||
execute: async (_args, context) => {
|
||||
const map = context.settings?.repoMap || 'Repository map not available. Run /map refresh to generate.';
|
||||
context.onOutput?.(map);
|
||||
return { success: true, message: map };
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Slash Command Registry
|
||||
*/
|
||||
class SlashCommandRegistry {
|
||||
private commands: Map<string, SlashCommand> = new Map();
|
||||
private aliases: Map<string, string> = new Map();
|
||||
|
||||
constructor() {
|
||||
// Register built-in commands
|
||||
for (const cmd of BUILTIN_COMMANDS) {
|
||||
this.register(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
register(command: SlashCommand): void {
|
||||
this.commands.set(command.name, command);
|
||||
if (command.aliases) {
|
||||
for (const alias of command.aliases) {
|
||||
this.aliases.set(alias, command.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get(name: string): SlashCommand | undefined {
|
||||
const resolved = this.aliases.get(name) || name;
|
||||
return this.commands.get(resolved);
|
||||
}
|
||||
|
||||
getAll(): SlashCommand[] {
|
||||
return Array.from(this.commands.values());
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.commands.has(name) || this.aliases.has(name);
|
||||
}
|
||||
}
|
||||
|
||||
let registry: SlashCommandRegistry | null = null;
|
||||
|
||||
export function getRegistry(): SlashCommandRegistry {
|
||||
if (!registry) {
|
||||
registry = new SlashCommandRegistry();
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function registerCommand(command: SlashCommand): void {
|
||||
getRegistry().register(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse slash command from input
|
||||
*/
|
||||
export function parseSlashCommand(input: string): { command: string; args: string[] } | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = trimmed.slice(1).split(/\s+/);
|
||||
const command = parts[0].toLowerCase();
|
||||
const args = parts.slice(1);
|
||||
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a slash command
|
||||
*/
|
||||
export async function executeSlashCommand(
|
||||
input: string,
|
||||
context: SlashCommandContext
|
||||
): Promise<SlashCommandResult> {
|
||||
const parsed = parseSlashCommand(input);
|
||||
if (!parsed) {
|
||||
return { success: false, message: 'Not a slash command' };
|
||||
}
|
||||
|
||||
const command = getRegistry().get(parsed.command);
|
||||
if (!command) {
|
||||
return { success: false, message: `Unknown command: /${parsed.command}` };
|
||||
}
|
||||
|
||||
return command.execute(parsed.args, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input is a slash command
|
||||
*/
|
||||
export function isSlashCommand(input: string): boolean {
|
||||
return input.trim().startsWith('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Slash Command Handler for CLI integration
|
||||
*/
|
||||
export class SlashCommandHandler {
|
||||
private context: SlashCommandContext;
|
||||
|
||||
constructor(initialContext: Partial<SlashCommandContext> = {}) {
|
||||
this.context = {
|
||||
history: [],
|
||||
settings: {},
|
||||
...initialContext
|
||||
};
|
||||
}
|
||||
|
||||
async handle(input: string): Promise<SlashCommandResult> {
|
||||
return executeSlashCommand(input, this.context);
|
||||
}
|
||||
|
||||
isCommand(input: string): boolean {
|
||||
return isSlashCommand(input);
|
||||
}
|
||||
|
||||
getContext(): SlashCommandContext {
|
||||
return this.context;
|
||||
}
|
||||
|
||||
updateContext(updates: Partial<SlashCommandContext>): void {
|
||||
this.context = { ...this.context, ...updates };
|
||||
}
|
||||
|
||||
registerCommand(command: SlashCommand): void {
|
||||
registerCommand(command);
|
||||
}
|
||||
}
|
||||
|
||||
export function createSlashCommandHandler(context?: Partial<SlashCommandContext>): SlashCommandHandler {
|
||||
return new SlashCommandHandler(context);
|
||||
}
|
||||
+147
-136
@@ -1,163 +1,174 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PraisonAI TypeScript CLI
|
||||
* Implements CLI Spec v1.0.0
|
||||
*
|
||||
* Binary: praisonai-ts
|
||||
* Package: praisonai (npm)
|
||||
*/
|
||||
|
||||
import { createProvider, isProviderAvailable, getAvailableProviders } from '../llm/providers';
|
||||
import { Session } from '../session';
|
||||
import { tool, ToolRegistry } from '../tools/decorator';
|
||||
import { validateCommand, COMMANDS, GLOBAL_FLAGS, EXIT_CODES, CLI_SPEC_VERSION } from './spec/cli-spec';
|
||||
import { exitInvalidArgs, exitError } from './runtime/exit';
|
||||
import { outputJson, formatError } from './output/json';
|
||||
import { ERROR_CODES, normalizeError } from './output/errors';
|
||||
|
||||
interface CLIOptions {
|
||||
model?: string;
|
||||
stream?: boolean;
|
||||
verbose?: boolean;
|
||||
sessionId?: string;
|
||||
export interface ParsedArgs {
|
||||
command: string;
|
||||
subcommand?: string;
|
||||
args: string[];
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function chat(prompt: string, options: CLIOptions = {}): Promise<string> {
|
||||
const model = options.model || process.env.PRAISONAI_MODEL || 'openai/gpt-4o-mini';
|
||||
const provider = createProvider(model);
|
||||
|
||||
const session = new Session({ id: options.sessionId });
|
||||
session.addMessage({ role: 'user', content: prompt });
|
||||
/**
|
||||
* Parse command line arguments according to CLI spec
|
||||
*/
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const result: ParsedArgs = {
|
||||
command: 'help',
|
||||
args: [],
|
||||
options: {}
|
||||
};
|
||||
|
||||
if (options.stream) {
|
||||
let fullText = '';
|
||||
const stream = await provider.streamText({
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
onToken: (token) => {
|
||||
process.stdout.write(token);
|
||||
fullText += token;
|
||||
}
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// Stream is consumed by onToken
|
||||
}
|
||||
console.log(); // New line after streaming
|
||||
return fullText;
|
||||
} else {
|
||||
const result = await provider.generateText({
|
||||
messages: [{ role: 'user', content: prompt }]
|
||||
});
|
||||
console.log(result.text);
|
||||
return result.text;
|
||||
}
|
||||
}
|
||||
|
||||
async function listProviders(): Promise<void> {
|
||||
console.log('Available Providers:');
|
||||
const available = getAvailableProviders();
|
||||
|
||||
const providers = ['openai', 'anthropic', 'google'];
|
||||
for (const p of providers) {
|
||||
const status = available.includes(p) ? '✅' : '❌';
|
||||
console.log(` ${status} ${p}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function version(): Promise<void> {
|
||||
const pkg = require('../../package.json');
|
||||
console.log(`praisonai v${pkg.version}`);
|
||||
}
|
||||
|
||||
async function help(): Promise<void> {
|
||||
console.log(`
|
||||
PraisonAI TypeScript CLI
|
||||
|
||||
Usage:
|
||||
praisonai-ts chat <prompt> Chat with an AI agent
|
||||
praisonai-ts providers List available providers
|
||||
praisonai-ts version Show version
|
||||
praisonai-ts help Show this help
|
||||
|
||||
Options:
|
||||
--model, -m <model> Model to use (e.g., openai/gpt-4o-mini)
|
||||
--stream, -s Enable streaming output
|
||||
--verbose, -v Verbose output
|
||||
--session <id> Session ID for conversation continuity
|
||||
|
||||
Environment Variables:
|
||||
OPENAI_API_KEY OpenAI API key
|
||||
ANTHROPIC_API_KEY Anthropic API key
|
||||
GOOGLE_API_KEY Google API key
|
||||
PRAISONAI_MODEL Default model
|
||||
|
||||
Examples:
|
||||
praisonai-ts chat "Hello, how are you?"
|
||||
praisonai-ts chat "Write a poem" --stream
|
||||
praisonai-ts chat "Explain AI" -m anthropic/claude-sonnet-4-20250514
|
||||
praisonai-ts providers
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): { command: string; prompt: string; options: CLIOptions } {
|
||||
const options: CLIOptions = {};
|
||||
let command = 'help';
|
||||
let prompt = '';
|
||||
|
||||
let i = 0;
|
||||
while (i < args.length) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === '--model' || arg === '-m') {
|
||||
options.model = args[++i];
|
||||
} else if (arg === '--stream' || arg === '-s') {
|
||||
options.stream = true;
|
||||
} else if (arg === '--verbose' || arg === '-v') {
|
||||
options.verbose = true;
|
||||
} else if (arg === '--session') {
|
||||
options.sessionId = args[++i];
|
||||
} else if (!arg.startsWith('-')) {
|
||||
if (!command || command === 'help') {
|
||||
command = arg;
|
||||
let foundCommand = false;
|
||||
|
||||
while (i < argv.length) {
|
||||
const arg = argv[i];
|
||||
|
||||
if (arg.startsWith('--')) {
|
||||
// Long flag
|
||||
const flagName = arg.slice(2);
|
||||
const nextArg = argv[i + 1];
|
||||
|
||||
// Check if it's a boolean flag or has a value
|
||||
if (!nextArg || nextArg.startsWith('-')) {
|
||||
result.options[flagName] = true;
|
||||
} else {
|
||||
prompt = arg;
|
||||
result.options[flagName] = nextArg;
|
||||
i++;
|
||||
}
|
||||
} else if (arg.startsWith('-') && arg.length === 2) {
|
||||
// Short flag
|
||||
const shortFlag = arg.slice(1);
|
||||
const nextArg = argv[i + 1];
|
||||
|
||||
// Find the corresponding long flag name from global flags and command-specific flags
|
||||
const globalFlag = GLOBAL_FLAGS.find(f => f.short === shortFlag);
|
||||
// Also check command-specific flags (e.g., -m for model in chat command)
|
||||
const commandShortFlags: Record<string, string> = {
|
||||
'm': 'model',
|
||||
's': 'stream',
|
||||
'a': 'agent',
|
||||
't': 'tools'
|
||||
};
|
||||
const flagName = globalFlag?.name || commandShortFlags[shortFlag] || shortFlag;
|
||||
|
||||
if (!nextArg || nextArg.startsWith('-')) {
|
||||
result.options[flagName] = true;
|
||||
} else {
|
||||
result.options[flagName] = nextArg;
|
||||
i++;
|
||||
}
|
||||
} else if (!foundCommand) {
|
||||
// First non-flag argument is the command
|
||||
result.command = arg;
|
||||
foundCommand = true;
|
||||
} else if (!result.subcommand && COMMANDS[result.command]?.subcommands) {
|
||||
// Check if this is a subcommand
|
||||
const cmdSpec = COMMANDS[result.command];
|
||||
if (cmdSpec.subcommands && arg in cmdSpec.subcommands) {
|
||||
result.subcommand = arg;
|
||||
} else {
|
||||
result.args.push(arg);
|
||||
}
|
||||
} else {
|
||||
// Positional argument
|
||||
result.args.push(arg);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return { command, prompt, options };
|
||||
|
||||
// Handle --json shorthand
|
||||
if (result.options.json) {
|
||||
result.options.output = 'json';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const { command, prompt, options } = parseArgs(args);
|
||||
|
||||
/**
|
||||
* Load and execute a command (lazy loading)
|
||||
*/
|
||||
async function executeCommand(parsed: ParsedArgs): Promise<void> {
|
||||
const { command, subcommand, args, options } = parsed;
|
||||
|
||||
// Validate command exists
|
||||
if (!validateCommand(command)) {
|
||||
const isJson = options.output === 'json' || options.json;
|
||||
if (isJson) {
|
||||
outputJson(formatError(ERROR_CODES.INVALID_ARGS, `Unknown command: ${command}`));
|
||||
} else {
|
||||
console.error(`Error: Unknown command '${command}'`);
|
||||
console.error('Run "praisonai-ts help" for available commands');
|
||||
}
|
||||
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
// Lazy load the command module
|
||||
try {
|
||||
switch (command) {
|
||||
case 'chat':
|
||||
if (!prompt) {
|
||||
console.error('Error: Please provide a prompt');
|
||||
process.exit(1);
|
||||
}
|
||||
await chat(prompt, options);
|
||||
break;
|
||||
case 'providers':
|
||||
await listProviders();
|
||||
break;
|
||||
case 'version':
|
||||
await version();
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
await help();
|
||||
break;
|
||||
const commandModule = await import(`./commands/${command}`);
|
||||
|
||||
// Pass subcommand as first arg if present
|
||||
const commandArgs = subcommand ? [subcommand, ...args] : args;
|
||||
|
||||
await commandModule.execute(commandArgs, options);
|
||||
} catch (error: unknown) {
|
||||
const cliError = normalizeError(error);
|
||||
const isJson = options.output === 'json' || options.json;
|
||||
|
||||
if (isJson) {
|
||||
outputJson(formatError(cliError.code, cliError.message, cliError.details));
|
||||
} else {
|
||||
console.error(`Error: ${cliError.message}`);
|
||||
if (options.verbose && error instanceof Error && error.stack) {
|
||||
console.error(error.stack);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error:', error.message);
|
||||
if (options.verbose) {
|
||||
console.error(error.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
|
||||
process.exit(cliError.exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main CLI entry point
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
// Fast path for version
|
||||
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
||||
const helpModule = await import('./commands/help');
|
||||
await helpModule.execute([], {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (argv[0] === '--version') {
|
||||
const versionModule = await import('./commands/version');
|
||||
await versionModule.execute([], {});
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseArgs(argv);
|
||||
await executeCommand(parsed);
|
||||
}
|
||||
|
||||
// Export for programmatic use
|
||||
export { chat, listProviders, version, help, parseArgs };
|
||||
export { parseArgs, executeCommand, CLI_SPEC_VERSION };
|
||||
|
||||
// Run CLI if executed directly
|
||||
if (require.main === module) {
|
||||
main();
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error.message);
|
||||
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
});
|
||||
}
|
||||
@@ -111,6 +111,351 @@ export const COMMANDS: Record<string, Command> = {
|
||||
}
|
||||
}
|
||||
},
|
||||
memory: {
|
||||
description: 'Manage agent memory',
|
||||
subcommands: {
|
||||
list: { description: 'List all memories' },
|
||||
add: { description: 'Add a new memory', args: [{ name: 'content', type: 'string', required: true, position: 0 }] },
|
||||
search: { description: 'Search memories', args: [{ name: 'query', type: 'string', required: true, position: 0 }] },
|
||||
clear: { description: 'Clear all memories' }
|
||||
}
|
||||
},
|
||||
session: {
|
||||
description: 'Manage agent sessions',
|
||||
subcommands: {
|
||||
list: { description: 'List all sessions' },
|
||||
create: { description: 'Create a new session', args: [{ name: 'id', type: 'string', required: false, position: 0 }] },
|
||||
get: { description: 'Get session details', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
|
||||
delete: { description: 'Delete a session', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
|
||||
export: { description: 'Export session data', args: [{ name: 'id', type: 'string', required: true, position: 0 }] }
|
||||
}
|
||||
},
|
||||
knowledge: {
|
||||
description: 'Manage knowledge base',
|
||||
subcommands: {
|
||||
add: { description: 'Add knowledge from file or text', args: [{ name: 'source', type: 'string', required: true, position: 0 }] },
|
||||
search: { description: 'Search knowledge base', args: [{ name: 'query', type: 'string', required: true, position: 0 }] },
|
||||
list: { description: 'List all knowledge entries' }
|
||||
}
|
||||
},
|
||||
skills: {
|
||||
description: 'Manage agent skills',
|
||||
subcommands: {
|
||||
list: { description: 'List loaded skills' },
|
||||
discover: { description: 'Discover skills in directory', args: [{ name: 'path', type: 'string', required: false, position: 0 }] },
|
||||
validate: { description: 'Validate a skill', args: [{ name: 'path', type: 'string', required: true, position: 0 }] },
|
||||
info: { description: 'Show skill information', args: [{ name: 'name', type: 'string', required: true, position: 0 }] }
|
||||
}
|
||||
},
|
||||
mcp: {
|
||||
description: 'Model Context Protocol management',
|
||||
subcommands: {
|
||||
list: { description: 'List registered MCP servers' },
|
||||
add: { description: 'Add an MCP server', args: [{ name: 'name', type: 'string', required: true, position: 0 }] },
|
||||
remove: { description: 'Remove an MCP server', args: [{ name: 'name', type: 'string', required: true, position: 0 }] },
|
||||
start: { description: 'Start an MCP server', args: [{ name: 'name', type: 'string', required: true, position: 0 }] },
|
||||
stop: { description: 'Stop an MCP server', args: [{ name: 'name', type: 'string', required: true, position: 0 }] }
|
||||
}
|
||||
},
|
||||
auto: {
|
||||
description: 'Auto-generate agents from topic',
|
||||
args: [{ name: 'topic', type: 'string', required: true, position: 0 }],
|
||||
flags: [
|
||||
{ name: 'pattern', type: 'string', description: 'Agent pattern (sequential, parallel, routing)' },
|
||||
{ name: 'agents', type: 'integer', description: 'Number of agents to generate' }
|
||||
]
|
||||
},
|
||||
image: {
|
||||
description: 'Image generation and analysis',
|
||||
subcommands: {
|
||||
generate: { description: 'Generate image from text', args: [{ name: 'prompt', type: 'string', required: true, position: 0 }] },
|
||||
analyze: { description: 'Analyze an image', args: [{ name: 'url', type: 'string', required: true, position: 0 }] }
|
||||
},
|
||||
flags: [
|
||||
{ name: 'size', type: 'string', description: 'Image size' },
|
||||
{ name: 'quality', type: 'string', description: 'Image quality' },
|
||||
{ name: 'style', type: 'string', description: 'Image style' }
|
||||
]
|
||||
},
|
||||
research: {
|
||||
description: 'Deep research on a topic',
|
||||
args: [{ name: 'query', type: 'string', required: true, position: 0 }],
|
||||
flags: [
|
||||
{ name: 'depth', type: 'integer', description: 'Research depth (iterations)' },
|
||||
{ name: 'max-sources', type: 'integer', description: 'Maximum sources to use' }
|
||||
]
|
||||
},
|
||||
guardrail: {
|
||||
description: 'Content validation and safety',
|
||||
subcommands: {
|
||||
check: { description: 'Check content against guardrails', args: [{ name: 'content', type: 'string', required: true, position: 0 }] }
|
||||
},
|
||||
flags: [
|
||||
{ name: 'criteria', type: 'string', description: 'Custom validation criteria' }
|
||||
]
|
||||
},
|
||||
telemetry: {
|
||||
description: 'Usage monitoring and analytics',
|
||||
subcommands: {
|
||||
status: { description: 'Show telemetry status' },
|
||||
enable: { description: 'Enable telemetry' },
|
||||
disable: { description: 'Disable telemetry' },
|
||||
clear: { description: 'Clear telemetry data' },
|
||||
export: { description: 'Export telemetry data' }
|
||||
}
|
||||
},
|
||||
planning: {
|
||||
description: 'Task planning and todo management',
|
||||
subcommands: {
|
||||
create: { description: 'Create a new plan', args: [{ name: 'name', type: 'string', required: true, position: 0 }] },
|
||||
todo: { description: 'Manage todo items' }
|
||||
}
|
||||
},
|
||||
'query-rewrite': {
|
||||
description: 'Rewrite queries for better search results',
|
||||
args: [{ name: 'query', type: 'string', required: true, position: 0 }],
|
||||
flags: [
|
||||
{ name: 'strategy', type: 'string', description: 'Rewrite strategy (expand, simplify, decompose, rephrase, auto)' }
|
||||
]
|
||||
},
|
||||
'prompt-expand': {
|
||||
description: 'Expand prompts with more detail',
|
||||
args: [{ name: 'prompt', type: 'string', required: true, position: 0 }],
|
||||
flags: [
|
||||
{ name: 'strategy', type: 'string', description: 'Expand strategy (detail, context, examples, constraints, auto)' }
|
||||
]
|
||||
},
|
||||
router: {
|
||||
description: 'Route requests to appropriate agents',
|
||||
subcommands: {
|
||||
analyze: { description: 'Analyze input and suggest routing', args: [{ name: 'input', type: 'string', required: true, position: 0 }] }
|
||||
}
|
||||
},
|
||||
context: {
|
||||
description: 'Manage conversation context',
|
||||
subcommands: {
|
||||
chat: { description: 'Chat with context management', args: [{ name: 'message', type: 'string', required: true, position: 0 }] },
|
||||
summarize: { description: 'Summarize context', args: [{ name: 'text', type: 'string', required: true, position: 0 }] }
|
||||
},
|
||||
flags: [
|
||||
{ name: 'max-messages', type: 'integer', description: 'Maximum messages in context' }
|
||||
]
|
||||
},
|
||||
handoff: {
|
||||
description: 'Agent handoff management',
|
||||
subcommands: {
|
||||
info: { description: 'Show handoff feature information' }
|
||||
}
|
||||
},
|
||||
vector: {
|
||||
description: 'Vector store management',
|
||||
subcommands: {
|
||||
info: { description: 'Show vector store information' },
|
||||
providers: { description: 'List available vector store providers' }
|
||||
}
|
||||
},
|
||||
observability: {
|
||||
description: 'Monitoring and tracing',
|
||||
subcommands: {
|
||||
info: { description: 'Show observability information' },
|
||||
providers: { description: 'List available observability providers' }
|
||||
}
|
||||
},
|
||||
voice: {
|
||||
description: 'Text-to-speech and speech-to-text',
|
||||
subcommands: {
|
||||
info: { description: 'Show voice feature information' },
|
||||
providers: { description: 'List available voice providers' }
|
||||
}
|
||||
},
|
||||
reranker: {
|
||||
description: 'Document reranking',
|
||||
subcommands: {
|
||||
info: { description: 'Show reranker information' },
|
||||
providers: { description: 'List available reranker providers' }
|
||||
}
|
||||
},
|
||||
'graph-rag': {
|
||||
description: 'Graph-based retrieval augmented generation',
|
||||
subcommands: {
|
||||
info: { description: 'Show Graph RAG information' }
|
||||
}
|
||||
},
|
||||
cache: {
|
||||
description: 'Caching management',
|
||||
subcommands: {
|
||||
info: { description: 'Show cache information' },
|
||||
providers: { description: 'List available cache providers' }
|
||||
}
|
||||
},
|
||||
db: {
|
||||
description: 'Database adapter management',
|
||||
subcommands: {
|
||||
info: { description: 'Show database adapter information' },
|
||||
adapters: { description: 'List available database adapters' }
|
||||
}
|
||||
},
|
||||
scheduler: {
|
||||
description: 'Agent task scheduling',
|
||||
subcommands: {
|
||||
create: { description: 'Create a scheduled task', args: [{ name: 'name', type: 'string', required: true, position: 0 }] },
|
||||
list: { description: 'List all scheduled tasks' },
|
||||
run: { description: 'Run a task immediately', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
|
||||
remove: { description: 'Remove a scheduled task', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
|
||||
enable: { description: 'Enable a task', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
|
||||
disable: { description: 'Disable a task', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
|
||||
patterns: { description: 'Show common cron patterns' }
|
||||
},
|
||||
flags: [
|
||||
{ name: 'cron', type: 'string', description: 'Cron expression' },
|
||||
{ name: 'interval', type: 'integer', description: 'Interval in seconds' }
|
||||
]
|
||||
},
|
||||
jobs: {
|
||||
description: 'Background job queue management',
|
||||
subcommands: {
|
||||
add: { description: 'Add a new job', args: [{ name: 'name', type: 'string', required: true, position: 0 }] },
|
||||
list: { description: 'List all jobs' },
|
||||
get: { description: 'Get job details', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
|
||||
cancel: { description: 'Cancel a pending job', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
|
||||
retry: { description: 'Retry a failed job', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
|
||||
process: { description: 'Process a job immediately', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
|
||||
stats: { description: 'Show queue statistics' },
|
||||
cleanup: { description: 'Clean up old jobs' }
|
||||
},
|
||||
flags: [
|
||||
{ name: 'storage', type: 'string', description: 'Path to job storage file' },
|
||||
{ name: 'priority', type: 'string', description: 'Job priority (low, normal, high, critical)' },
|
||||
{ name: 'status', type: 'string', description: 'Filter by status' }
|
||||
]
|
||||
},
|
||||
checkpoints: {
|
||||
description: 'Session state checkpointing',
|
||||
subcommands: {
|
||||
create: { description: 'Create a checkpoint', args: [{ name: 'name', type: 'string', required: false, position: 0 }] },
|
||||
list: { description: 'List all checkpoints' },
|
||||
get: { description: 'Get checkpoint details', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
|
||||
restore: { description: 'Restore from checkpoint', args: [{ name: 'id', type: 'string', required: false, position: 0 }] },
|
||||
delete: { description: 'Delete a checkpoint', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
|
||||
export: { description: 'Export checkpoint as JSON', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
|
||||
import: { description: 'Import checkpoint from JSON', args: [{ name: 'json', type: 'string', required: true, position: 0 }] }
|
||||
},
|
||||
flags: [
|
||||
{ name: 'storage', type: 'string', description: 'Path to checkpoint storage' },
|
||||
{ name: 'state', type: 'string', description: 'JSON state to save' }
|
||||
]
|
||||
},
|
||||
'fast-context': {
|
||||
description: 'Fast context retrieval',
|
||||
subcommands: {
|
||||
query: { description: 'Query for relevant context', args: [{ name: 'query', type: 'string', required: true, position: 0 }] },
|
||||
stats: { description: 'Show cache statistics' },
|
||||
clear: { description: 'Clear cache and sources' }
|
||||
},
|
||||
flags: [
|
||||
{ name: 'max-tokens', type: 'integer', description: 'Maximum tokens in context' },
|
||||
{ name: 'sources', type: 'string', description: 'Comma-separated source texts' }
|
||||
]
|
||||
},
|
||||
autonomy: {
|
||||
description: 'Autonomy mode management',
|
||||
subcommands: {
|
||||
status: { description: 'Show current autonomy status' },
|
||||
set: { description: 'Set autonomy mode', args: [{ name: 'mode', type: 'string', required: true, position: 0 }] },
|
||||
policies: { description: 'Show policies for a mode', args: [{ name: 'mode', type: 'string', required: false, position: 0 }] },
|
||||
reset: { description: 'Reset autonomy state' }
|
||||
}
|
||||
},
|
||||
sandbox: {
|
||||
description: 'Safe command execution',
|
||||
subcommands: {
|
||||
exec: { description: 'Execute a command in sandbox', args: [{ name: 'command', type: 'string', required: true, position: 0 }] },
|
||||
check: { description: 'Check if command is allowed', args: [{ name: 'command', type: 'string', required: true, position: 0 }] },
|
||||
mode: { description: 'List available sandbox modes' }
|
||||
},
|
||||
flags: [
|
||||
{ name: 'mode', type: 'string', description: 'Sandbox mode (disabled, basic, strict, network-isolated)' },
|
||||
{ name: 'timeout', type: 'integer', description: 'Execution timeout in ms' }
|
||||
]
|
||||
},
|
||||
'repo-map': {
|
||||
description: 'Repository structure visualization',
|
||||
subcommands: {
|
||||
tree: { description: 'Show repository tree', args: [{ name: 'path', type: 'string', required: false, position: 0 }] },
|
||||
symbols: { description: 'Extract code symbols', args: [{ name: 'path', type: 'string', required: false, position: 0 }] }
|
||||
},
|
||||
flags: [
|
||||
{ name: 'depth', type: 'integer', description: 'Maximum directory depth' },
|
||||
{ name: 'symbols', type: 'boolean', description: 'Include symbols in tree' }
|
||||
]
|
||||
},
|
||||
git: {
|
||||
description: 'Git integration (read-only)',
|
||||
subcommands: {
|
||||
status: { description: 'Show repository status' },
|
||||
diff: { description: 'Show changes' },
|
||||
log: { description: 'Show recent commits' },
|
||||
branches: { description: 'List branches' },
|
||||
stash: { description: 'List stashes' }
|
||||
},
|
||||
flags: [
|
||||
{ name: 'cwd', type: 'string', description: 'Working directory' },
|
||||
{ name: 'staged', type: 'boolean', description: 'Show staged changes only' },
|
||||
{ name: 'limit', type: 'integer', description: 'Number of commits to show' }
|
||||
]
|
||||
},
|
||||
n8n: {
|
||||
description: 'N8N workflow integration',
|
||||
subcommands: {
|
||||
trigger: { description: 'Trigger an N8N webhook', args: [{ name: 'webhookId', type: 'string', required: true, position: 0 }] },
|
||||
export: { description: 'Export workflow to N8N format', args: [{ name: 'name', type: 'string', required: false, position: 0 }] }
|
||||
},
|
||||
flags: [
|
||||
{ name: 'base-url', type: 'string', description: 'N8N base URL' },
|
||||
{ name: 'api-key', type: 'string', description: 'N8N API key' },
|
||||
{ name: 'steps', type: 'string', description: 'Workflow steps' }
|
||||
]
|
||||
},
|
||||
'external-agents': {
|
||||
description: 'External AI CLI tool integration',
|
||||
subcommands: {
|
||||
list: { description: 'List all external agents' },
|
||||
check: { description: 'Check agent availability', args: [{ name: 'name', type: 'string', required: true, position: 0 }] },
|
||||
run: { description: 'Run with external agent', args: [{ name: 'name', type: 'string', required: true, position: 0 }] }
|
||||
},
|
||||
flags: [
|
||||
{ name: 'cwd', type: 'string', description: 'Working directory' }
|
||||
]
|
||||
},
|
||||
flow: {
|
||||
description: 'Workflow flow visualization',
|
||||
subcommands: {
|
||||
show: { description: 'Display workflow as text', args: [{ name: 'steps', type: 'string', required: false, position: 0 }] },
|
||||
dot: { description: 'Export as DOT format', args: [{ name: 'steps', type: 'string', required: false, position: 0 }] }
|
||||
},
|
||||
flags: [
|
||||
{ name: 'steps', type: 'string', description: 'Steps as JSON or comma-separated' },
|
||||
{ name: 'boxes', type: 'boolean', description: 'Display as ASCII boxes' },
|
||||
{ name: 'compact', type: 'boolean', description: 'Compact display mode' }
|
||||
]
|
||||
},
|
||||
cost: {
|
||||
description: 'Token usage and cost tracking',
|
||||
subcommands: {
|
||||
summary: { description: 'Show cost summary' },
|
||||
add: { description: 'Add token usage', args: [{ name: 'model', type: 'string', required: true, position: 0 }] },
|
||||
reset: { description: 'Reset cost tracker' },
|
||||
pricing: { description: 'Show model pricing', args: [{ name: 'model', type: 'string', required: false, position: 0 }] }
|
||||
}
|
||||
},
|
||||
interactive: {
|
||||
description: 'Interactive TUI mode',
|
||||
flags: [
|
||||
{ name: 'model', short: 'm', type: 'string', description: 'Model to use' },
|
||||
{ name: 'prompt', type: 'string', description: 'Custom prompt string' },
|
||||
{ name: 'history', type: 'string', description: 'Path to history file' }
|
||||
]
|
||||
},
|
||||
version: {
|
||||
description: 'Show CLI version',
|
||||
flags: []
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* CLI Spec exports
|
||||
*/
|
||||
|
||||
export * from './cli-spec';
|
||||
@@ -48,11 +48,22 @@ export { MemoryObservabilityAdapter, ConsoleObservabilityAdapter, setObservabili
|
||||
export { SkillManager, createSkillManager, parseSkillFile, type Skill, type SkillMetadata, type SkillDiscoveryOptions } from './skills';
|
||||
|
||||
// Export CLI
|
||||
export { chat, listProviders, version, help } from './cli';
|
||||
export { parseArgs, executeCommand, CLI_SPEC_VERSION } from './cli';
|
||||
|
||||
// Export Memory
|
||||
export { Memory, createMemory, type MemoryEntry, type MemoryConfig, type SearchResult as MemorySearchResult } from './memory/memory';
|
||||
|
||||
// Export FileMemory
|
||||
export { FileMemory, createFileMemory, type FileMemoryConfig, type FileMemoryEntry } from './memory/file-memory';
|
||||
|
||||
// Export AutoMemory
|
||||
export {
|
||||
AutoMemory, createAutoMemory, createLLMSummarizer,
|
||||
DEFAULT_POLICIES,
|
||||
type AutoMemoryConfig, type AutoMemoryPolicy, type AutoMemoryContext,
|
||||
type VectorStoreAdapter as AutoMemoryVectorStore, type KnowledgeBaseAdapter as AutoMemoryKnowledgeBase
|
||||
} from './memory/auto-memory';
|
||||
|
||||
// Export Telemetry
|
||||
export { TelemetryCollector, getTelemetry, enableTelemetry, disableTelemetry, cleanupTelemetry, type TelemetryEvent, type TelemetryConfig } from './telemetry';
|
||||
|
||||
@@ -168,4 +179,57 @@ export {
|
||||
type Message as ProviderMessage,
|
||||
type ToolCall,
|
||||
type ToolDefinition as ProviderToolDefinition,
|
||||
} from './llm/providers';
|
||||
} from './llm/providers';
|
||||
|
||||
// Export CLI Features
|
||||
export {
|
||||
// Slash Commands
|
||||
SlashCommandHandler, createSlashCommandHandler, registerCommand, parseSlashCommand,
|
||||
executeSlashCommand, isSlashCommand,
|
||||
type SlashCommand, type SlashCommandContext, type SlashCommandResult,
|
||||
// Cost Tracker
|
||||
CostTracker, createCostTracker, estimateTokens, formatCost, MODEL_PRICING,
|
||||
type ModelPricing, type TokenUsage as CostTokenUsage, type RequestStats, type SessionStats,
|
||||
// Interactive TUI
|
||||
InteractiveTUI, createInteractiveTUI, StatusDisplay, createStatusDisplay,
|
||||
HistoryManager, createHistoryManager,
|
||||
type TUIConfig, type TUIState,
|
||||
// Repo Map
|
||||
RepoMap, createRepoMap, getRepoTree, DEFAULT_IGNORE_PATTERNS,
|
||||
type RepoMapConfig, type FileInfo, type SymbolInfo, type RepoMapResult,
|
||||
// Git Integration
|
||||
GitManager, createGitManager, DiffViewer, createDiffViewer,
|
||||
type GitConfig, type GitStatus, type GitCommit, type GitDiff, type GitDiffFile,
|
||||
// Sandbox Executor
|
||||
SandboxExecutor, createSandboxExecutor, sandboxExec, CommandValidator,
|
||||
DEFAULT_BLOCKED_COMMANDS, DEFAULT_BLOCKED_PATHS,
|
||||
type SandboxMode, type SandboxConfig, type ExecutionResult,
|
||||
// Autonomy Mode
|
||||
AutonomyManager, createAutonomyManager, cliApprovalPrompt, MODE_POLICIES,
|
||||
type AutonomyMode, type ActionType, type ApprovalPolicy, type AutonomyConfig,
|
||||
type ActionRequest, type ActionDecision,
|
||||
// Scheduler
|
||||
Scheduler, createScheduler, cronExpressions,
|
||||
type ScheduleConfig, type ScheduledTask, type SchedulerStats,
|
||||
// Background Jobs
|
||||
JobQueue, createJobQueue, MemoryJobStorage, FileJobStorage, createFileJobStorage,
|
||||
type Job, type JobStatus, type JobPriority, type JobQueueConfig,
|
||||
type JobStorageAdapter, type JobHandler, type JobContext,
|
||||
// Checkpoints
|
||||
CheckpointManager, createCheckpointManager, MemoryCheckpointStorage,
|
||||
FileCheckpointStorage, createFileCheckpointStorage,
|
||||
type CheckpointData, type CheckpointConfig, type CheckpointStorage,
|
||||
// Flow Display
|
||||
FlowDisplay, createFlowDisplay, renderWorkflow,
|
||||
type FlowNode, type FlowGraph, type FlowDisplayConfig,
|
||||
// External Agents
|
||||
BaseExternalAgent, ClaudeCodeAgent, GeminiCliAgent, CodexCliAgent, AiderAgent,
|
||||
GenericExternalAgent, getExternalAgentRegistry, createExternalAgent, externalAgentAsTool,
|
||||
type ExternalAgentConfig, type ExternalAgentResult,
|
||||
// N8N Integration
|
||||
N8NIntegration, createN8NIntegration, triggerN8NWebhook,
|
||||
type N8NConfig, type N8NWebhookPayload, type N8NWorkflow, type N8NWorkflowNode,
|
||||
// Fast Context
|
||||
FastContext, createFastContext, getQuickContext,
|
||||
type FastContextConfig, type ContextSource, type FastContextResult
|
||||
} from './cli/features';
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* AutoMemory - Policy-based automatic memory capture
|
||||
* Integrates with Memory, KnowledgeBase, and Vector stores
|
||||
*/
|
||||
|
||||
import { Memory, MemoryEntry, MemoryConfig, EmbeddingProvider } from './memory';
|
||||
|
||||
export interface AutoMemoryPolicy {
|
||||
name: string;
|
||||
condition: (content: string, role: string, context: AutoMemoryContext) => boolean;
|
||||
action: 'store' | 'summarize' | 'vectorize' | 'skip';
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface AutoMemoryContext {
|
||||
messageCount: number;
|
||||
tokenCount: number;
|
||||
lastSummaryAt?: number;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AutoMemoryConfig extends MemoryConfig {
|
||||
policies?: AutoMemoryPolicy[];
|
||||
summaryThreshold?: number;
|
||||
vectorizeThreshold?: number;
|
||||
summarizer?: (entries: MemoryEntry[]) => Promise<string>;
|
||||
vectorStore?: VectorStoreAdapter;
|
||||
knowledgeBase?: KnowledgeBaseAdapter;
|
||||
}
|
||||
|
||||
export interface VectorStoreAdapter {
|
||||
add(id: string, embedding: number[], metadata?: Record<string, any>): Promise<void>;
|
||||
search(embedding: number[], limit?: number): Promise<Array<{ id: string; score: number }>>;
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseAdapter {
|
||||
add(content: string, metadata?: Record<string, any>): Promise<string>;
|
||||
search(query: string, limit?: number): Promise<Array<{ content: string; score: number }>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default policies for automatic memory management
|
||||
*/
|
||||
export const DEFAULT_POLICIES: AutoMemoryPolicy[] = [
|
||||
{
|
||||
name: 'store-important',
|
||||
condition: (content) => {
|
||||
const importantKeywords = ['remember', 'important', 'note', 'save', 'key point'];
|
||||
return importantKeywords.some(kw => content.toLowerCase().includes(kw));
|
||||
},
|
||||
action: 'store',
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'summarize-long',
|
||||
condition: (content) => content.length > 2000,
|
||||
action: 'summarize',
|
||||
priority: 50
|
||||
},
|
||||
{
|
||||
name: 'vectorize-knowledge',
|
||||
condition: (content, role) => {
|
||||
return role === 'assistant' && content.length > 500;
|
||||
},
|
||||
action: 'vectorize',
|
||||
priority: 30
|
||||
},
|
||||
{
|
||||
name: 'skip-short',
|
||||
condition: (content) => content.length < 10,
|
||||
action: 'skip',
|
||||
priority: 10
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* AutoMemory - Automatic memory management with policies
|
||||
*/
|
||||
export class AutoMemory {
|
||||
private memory: Memory;
|
||||
private policies: AutoMemoryPolicy[];
|
||||
private summaryThreshold: number;
|
||||
private vectorizeThreshold: number;
|
||||
private summarizer?: (entries: MemoryEntry[]) => Promise<string>;
|
||||
private vectorStore?: VectorStoreAdapter;
|
||||
private knowledgeBase?: KnowledgeBaseAdapter;
|
||||
private embeddingProvider?: EmbeddingProvider;
|
||||
private context: AutoMemoryContext;
|
||||
private pendingVectorization: MemoryEntry[] = [];
|
||||
|
||||
constructor(config: AutoMemoryConfig = {}) {
|
||||
this.memory = new Memory(config);
|
||||
this.policies = (config.policies || DEFAULT_POLICIES).sort((a, b) =>
|
||||
(b.priority || 0) - (a.priority || 0)
|
||||
);
|
||||
this.summaryThreshold = config.summaryThreshold ?? 50;
|
||||
this.vectorizeThreshold = config.vectorizeThreshold ?? 10;
|
||||
this.summarizer = config.summarizer;
|
||||
this.vectorStore = config.vectorStore;
|
||||
this.knowledgeBase = config.knowledgeBase;
|
||||
this.embeddingProvider = config.embeddingProvider;
|
||||
this.context = {
|
||||
messageCount: 0,
|
||||
tokenCount: 0,
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and add content with automatic policy application
|
||||
*/
|
||||
async add(
|
||||
content: string,
|
||||
role: 'user' | 'assistant' | 'system',
|
||||
metadata?: Record<string, any>
|
||||
): Promise<MemoryEntry | null> {
|
||||
this.context.messageCount++;
|
||||
this.context.tokenCount += this.estimateTokens(content);
|
||||
|
||||
// Find matching policy
|
||||
const policy = this.policies.find(p => p.condition(content, role, this.context));
|
||||
|
||||
if (!policy || policy.action === 'skip') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let entry: MemoryEntry;
|
||||
|
||||
switch (policy.action) {
|
||||
case 'summarize':
|
||||
if (this.summarizer) {
|
||||
const summary = await this.summarizer([{
|
||||
id: '', content, role, timestamp: Date.now(), metadata
|
||||
}]);
|
||||
entry = await this.memory.add(summary, role, {
|
||||
...metadata,
|
||||
original: content,
|
||||
summarized: true
|
||||
});
|
||||
} else {
|
||||
entry = await this.memory.add(content, role, metadata);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'vectorize':
|
||||
entry = await this.memory.add(content, role, metadata);
|
||||
this.pendingVectorization.push(entry);
|
||||
|
||||
if (this.pendingVectorization.length >= this.vectorizeThreshold) {
|
||||
await this.flushVectorization();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'store':
|
||||
default:
|
||||
entry = await this.memory.add(content, role, metadata);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if we need to trigger summarization
|
||||
if (this.memory.size >= this.summaryThreshold && this.summarizer) {
|
||||
await this.summarizeOldEntries();
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a memory entry by ID
|
||||
*/
|
||||
get(id: string): MemoryEntry | undefined {
|
||||
return this.memory.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entries
|
||||
*/
|
||||
getAll(): MemoryEntry[] {
|
||||
return this.memory.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent entries
|
||||
*/
|
||||
getRecent(count: number): MemoryEntry[] {
|
||||
return this.memory.getRecent(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search memory
|
||||
*/
|
||||
async search(query: string, limit: number = 5): Promise<Array<{ entry: MemoryEntry; score: number }>> {
|
||||
const memoryResults = await this.memory.search(query, limit);
|
||||
|
||||
// Also search vector store if available
|
||||
if (this.vectorStore && this.embeddingProvider) {
|
||||
const queryEmbedding = await this.embeddingProvider.embed(query);
|
||||
const vectorResults = await this.vectorStore.search(queryEmbedding, limit);
|
||||
|
||||
// Merge results (deduplicate by ID)
|
||||
const seen = new Set(memoryResults.map(r => r.entry.id));
|
||||
for (const vr of vectorResults) {
|
||||
if (!seen.has(vr.id)) {
|
||||
const entry = this.memory.get(vr.id);
|
||||
if (entry) {
|
||||
memoryResults.push({ entry, score: vr.score });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also search knowledge base if available
|
||||
if (this.knowledgeBase) {
|
||||
const kbResults = await this.knowledgeBase.search(query, limit);
|
||||
// Knowledge base results are returned separately as they may not have IDs
|
||||
}
|
||||
|
||||
return memoryResults
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context with automatic retrieval
|
||||
*/
|
||||
async buildContext(query?: string, maxTokens: number = 4000): Promise<string> {
|
||||
let entries: MemoryEntry[];
|
||||
|
||||
if (query) {
|
||||
const results = await this.search(query, 20);
|
||||
entries = results.map(r => r.entry);
|
||||
} else {
|
||||
entries = this.getRecent(20);
|
||||
}
|
||||
|
||||
// Build context within token limit
|
||||
const contextParts: string[] = [];
|
||||
let tokenCount = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const part = `${entry.role}: ${entry.content}`;
|
||||
const partTokens = this.estimateTokens(part);
|
||||
|
||||
if (tokenCount + partTokens > maxTokens) break;
|
||||
|
||||
contextParts.push(part);
|
||||
tokenCount += partTokens;
|
||||
}
|
||||
|
||||
return contextParts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom policy
|
||||
*/
|
||||
addPolicy(policy: AutoMemoryPolicy): void {
|
||||
this.policies.push(policy);
|
||||
this.policies.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a policy by name
|
||||
*/
|
||||
removePolicy(name: string): boolean {
|
||||
const index = this.policies.findIndex(p => p.name === name);
|
||||
if (index >= 0) {
|
||||
this.policies.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current context stats
|
||||
*/
|
||||
getStats(): AutoMemoryContext {
|
||||
return { ...this.context };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all memory
|
||||
*/
|
||||
clear(): void {
|
||||
this.memory.clear();
|
||||
this.pendingVectorization = [];
|
||||
this.context = {
|
||||
messageCount: 0,
|
||||
tokenCount: 0,
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export memory
|
||||
*/
|
||||
toJSON(): MemoryEntry[] {
|
||||
return this.memory.toJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* Import memory
|
||||
*/
|
||||
fromJSON(entries: MemoryEntry[]): void {
|
||||
this.memory.fromJSON(entries);
|
||||
this.context.messageCount = entries.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get underlying memory instance
|
||||
*/
|
||||
getMemory(): Memory {
|
||||
return this.memory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush pending vectorization
|
||||
*/
|
||||
private async flushVectorization(): Promise<void> {
|
||||
if (!this.vectorStore || !this.embeddingProvider || this.pendingVectorization.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of this.pendingVectorization) {
|
||||
if (entry.embedding) {
|
||||
await this.vectorStore.add(entry.id, entry.embedding, entry.metadata);
|
||||
} else {
|
||||
const embedding = await this.embeddingProvider.embed(entry.content);
|
||||
await this.vectorStore.add(entry.id, embedding, entry.metadata);
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingVectorization = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize old entries to save space
|
||||
*/
|
||||
private async summarizeOldEntries(): Promise<void> {
|
||||
if (!this.summarizer) return;
|
||||
|
||||
const entries = this.memory.getAll();
|
||||
const oldEntries = entries.slice(0, Math.floor(entries.length / 2));
|
||||
|
||||
if (oldEntries.length < 10) return;
|
||||
|
||||
const summary = await this.summarizer(oldEntries);
|
||||
|
||||
// Delete old entries and add summary
|
||||
for (const entry of oldEntries) {
|
||||
this.memory.delete(entry.id);
|
||||
}
|
||||
|
||||
await this.memory.add(summary, 'system', {
|
||||
type: 'summary',
|
||||
summarizedCount: oldEntries.length,
|
||||
summarizedAt: Date.now()
|
||||
});
|
||||
|
||||
this.context.lastSummaryAt = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate token count (rough approximation)
|
||||
*/
|
||||
private estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an auto-memory instance
|
||||
*/
|
||||
export function createAutoMemory(config?: AutoMemoryConfig): AutoMemory {
|
||||
return new AutoMemory(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple summarizer using an LLM
|
||||
*/
|
||||
export function createLLMSummarizer(
|
||||
llm: { chat: (prompt: string) => Promise<string> }
|
||||
): (entries: MemoryEntry[]) => Promise<string> {
|
||||
return async (entries: MemoryEntry[]) => {
|
||||
const content = entries.map(e => `${e.role}: ${e.content}`).join('\n');
|
||||
const prompt = `Summarize the following conversation concisely, preserving key information:\n\n${content}`;
|
||||
return llm.chat(prompt);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* File-based Memory Storage
|
||||
* Persistent memory using JSONL files with lockfile support for concurrent access
|
||||
*/
|
||||
|
||||
import { MemoryEntry, MemoryConfig, SearchResult, EmbeddingProvider } from './memory';
|
||||
|
||||
export interface FileMemoryConfig extends MemoryConfig {
|
||||
filePath: string;
|
||||
compactionThreshold?: number;
|
||||
autoCompact?: boolean;
|
||||
}
|
||||
|
||||
export interface FileMemoryEntry extends MemoryEntry {
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* FileMemory - Append-only JSONL storage with compaction
|
||||
*/
|
||||
export class FileMemory {
|
||||
private filePath: string;
|
||||
private entries: Map<string, FileMemoryEntry> = new Map();
|
||||
private maxEntries: number;
|
||||
private embeddingProvider?: EmbeddingProvider;
|
||||
private compactionThreshold: number;
|
||||
private autoCompact: boolean;
|
||||
private initialized: boolean = false;
|
||||
private writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(config: FileMemoryConfig) {
|
||||
this.filePath = config.filePath;
|
||||
this.maxEntries = config.maxEntries ?? 10000;
|
||||
this.embeddingProvider = config.embeddingProvider;
|
||||
this.compactionThreshold = config.compactionThreshold ?? 1000;
|
||||
this.autoCompact = config.autoCompact ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and load existing entries
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const content = await fs.readFile(this.filePath, 'utf-8').catch(() => '');
|
||||
|
||||
if (content) {
|
||||
const lines = content.trim().split('\n').filter(Boolean);
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry: FileMemoryEntry = JSON.parse(line);
|
||||
if (entry.deleted) {
|
||||
this.entries.delete(entry.id);
|
||||
deletedCount++;
|
||||
} else {
|
||||
this.entries.set(entry.id, entry);
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-compact if too many deleted entries
|
||||
if (this.autoCompact && deletedCount > this.compactionThreshold) {
|
||||
await this.compact();
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a memory entry
|
||||
*/
|
||||
async add(
|
||||
content: string,
|
||||
role: 'user' | 'assistant' | 'system',
|
||||
metadata?: Record<string, any>
|
||||
): Promise<MemoryEntry> {
|
||||
await this.initialize();
|
||||
|
||||
const id = this.generateId();
|
||||
const entry: FileMemoryEntry = {
|
||||
id,
|
||||
content,
|
||||
role,
|
||||
timestamp: Date.now(),
|
||||
metadata
|
||||
};
|
||||
|
||||
if (this.embeddingProvider) {
|
||||
entry.embedding = await this.embeddingProvider.embed(content);
|
||||
}
|
||||
|
||||
this.entries.set(id, entry);
|
||||
await this.appendEntry(entry);
|
||||
this.enforceLimit();
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a memory entry by ID
|
||||
*/
|
||||
async get(id: string): Promise<MemoryEntry | undefined> {
|
||||
await this.initialize();
|
||||
const entry = this.entries.get(id);
|
||||
return entry && !entry.deleted ? entry : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entries
|
||||
*/
|
||||
async getAll(): Promise<MemoryEntry[]> {
|
||||
await this.initialize();
|
||||
return Array.from(this.entries.values())
|
||||
.filter(e => !e.deleted)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent entries
|
||||
*/
|
||||
async getRecent(count: number): Promise<MemoryEntry[]> {
|
||||
const all = await this.getAll();
|
||||
return all.slice(-count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search memory by text similarity
|
||||
*/
|
||||
async search(query: string, limit: number = 5): Promise<SearchResult[]> {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.embeddingProvider) {
|
||||
return this.textSearch(query, limit);
|
||||
}
|
||||
|
||||
const queryEmbedding = await this.embeddingProvider.embed(query);
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.deleted) continue;
|
||||
if (entry.embedding) {
|
||||
const score = this.cosineSimilarity(queryEmbedding, entry.embedding);
|
||||
results.push({ entry, score });
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a memory entry (soft delete)
|
||||
*/
|
||||
async delete(id: string): Promise<boolean> {
|
||||
await this.initialize();
|
||||
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry || entry.deleted) return false;
|
||||
|
||||
entry.deleted = true;
|
||||
await this.appendEntry({ id, deleted: true } as FileMemoryEntry);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all memory
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
const fs = await import('fs/promises');
|
||||
await fs.writeFile(this.filePath, '');
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact the file by removing deleted entries
|
||||
*/
|
||||
async compact(): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
const fs = await import('fs/promises');
|
||||
const activeEntries = Array.from(this.entries.values()).filter(e => !e.deleted);
|
||||
|
||||
const content = activeEntries
|
||||
.map(e => JSON.stringify(e))
|
||||
.join('\n') + (activeEntries.length ? '\n' : '');
|
||||
|
||||
// Write to temp file first, then rename (atomic)
|
||||
const tempPath = `${this.filePath}.tmp`;
|
||||
await fs.writeFile(tempPath, content);
|
||||
await fs.rename(tempPath, this.filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory size
|
||||
*/
|
||||
get size(): number {
|
||||
return Array.from(this.entries.values()).filter(e => !e.deleted).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export memory to JSON
|
||||
*/
|
||||
async toJSON(): Promise<MemoryEntry[]> {
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Import memory from JSON
|
||||
*/
|
||||
async fromJSON(entries: MemoryEntry[]): Promise<void> {
|
||||
await this.clear();
|
||||
for (const entry of entries) {
|
||||
this.entries.set(entry.id, entry as FileMemoryEntry);
|
||||
}
|
||||
await this.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context string from recent memory
|
||||
*/
|
||||
async buildContext(count?: number): Promise<string> {
|
||||
const entries = count ? await this.getRecent(count) : await this.getAll();
|
||||
return entries
|
||||
.map(e => `${e.role}: ${e.content}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private async appendEntry(entry: FileMemoryEntry): Promise<void> {
|
||||
// Queue writes to ensure order
|
||||
this.writeQueue = this.writeQueue.then(async () => {
|
||||
const fs = await import('fs/promises');
|
||||
await fs.appendFile(this.filePath, JSON.stringify(entry) + '\n');
|
||||
});
|
||||
await this.writeQueue;
|
||||
}
|
||||
|
||||
private textSearch(query: string, limit: number): SearchResult[] {
|
||||
const queryLower = query.toLowerCase();
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.deleted) continue;
|
||||
const contentLower = entry.content.toLowerCase();
|
||||
if (contentLower.includes(queryLower)) {
|
||||
const score = queryLower.length / contentLower.length;
|
||||
results.push({ entry, score: Math.min(score * 10, 1) });
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
private cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (a.length !== b.length) return 0;
|
||||
|
||||
let dotProduct = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dotProduct += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
|
||||
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
|
||||
return denominator === 0 ? 0 : dotProduct / denominator;
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return `fmem_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
private enforceLimit(): void {
|
||||
const active = Array.from(this.entries.values()).filter(e => !e.deleted);
|
||||
while (active.length > this.maxEntries) {
|
||||
const oldest = active.shift();
|
||||
if (oldest) {
|
||||
oldest.deleted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a file-based memory instance
|
||||
*/
|
||||
export function createFileMemory(config: FileMemoryConfig): FileMemory {
|
||||
return new FileMemory(config);
|
||||
}
|
||||
@@ -51,3 +51,14 @@ export class BaseMemoryStore implements MemoryStore {
|
||||
this.memories.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Export FileMemory
|
||||
export { FileMemory, createFileMemory, type FileMemoryConfig, type FileMemoryEntry } from './file-memory';
|
||||
|
||||
// Export AutoMemory
|
||||
export {
|
||||
AutoMemory, createAutoMemory, createLLMSummarizer,
|
||||
DEFAULT_POLICIES,
|
||||
type AutoMemoryConfig, type AutoMemoryPolicy, type AutoMemoryContext,
|
||||
type VectorStoreAdapter, type KnowledgeBaseAdapter
|
||||
} from './auto-memory';
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* CLI Spec Tests - Verify parity with spec
|
||||
*/
|
||||
|
||||
import { parseArgs, CLI_SPEC_VERSION } from '../../src/cli/index';
|
||||
import { COMMANDS, GLOBAL_FLAGS, EXIT_CODES, validateCommand } from '../../src/cli/spec/cli-spec';
|
||||
|
||||
describe('CLI Spec', () => {
|
||||
describe('Version', () => {
|
||||
it('should have correct spec version', () => {
|
||||
expect(CLI_SPEC_VERSION).toBe('1.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Commands', () => {
|
||||
it('should have all required commands', () => {
|
||||
const requiredCommands = ['chat', 'run', 'workflow', 'eval', 'providers', 'tools', 'version', 'help'];
|
||||
for (const cmd of requiredCommands) {
|
||||
expect(COMMANDS[cmd]).toBeDefined();
|
||||
expect(validateCommand(cmd)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid commands', () => {
|
||||
expect(validateCommand('invalid')).toBe(false);
|
||||
expect(validateCommand('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Global Flags', () => {
|
||||
it('should have all required global flags', () => {
|
||||
const requiredFlags = ['verbose', 'config', 'profile', 'output', 'json'];
|
||||
const flagNames = GLOBAL_FLAGS.map(f => f.name);
|
||||
|
||||
for (const flag of requiredFlags) {
|
||||
expect(flagNames).toContain(flag);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have short flags for common options', () => {
|
||||
const verboseFlag = GLOBAL_FLAGS.find(f => f.name === 'verbose');
|
||||
expect(verboseFlag?.short).toBe('v');
|
||||
|
||||
const outputFlag = GLOBAL_FLAGS.find(f => f.name === 'output');
|
||||
expect(outputFlag?.short).toBe('o');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Exit Codes', () => {
|
||||
it('should have correct exit codes', () => {
|
||||
expect(EXIT_CODES.SUCCESS).toBe(0);
|
||||
expect(EXIT_CODES.RUNTIME_ERROR).toBe(1);
|
||||
expect(EXIT_CODES.INVALID_ARGUMENTS).toBe(2);
|
||||
expect(EXIT_CODES.CONFIG_ERROR).toBe(3);
|
||||
expect(EXIT_CODES.NETWORK_ERROR).toBe(4);
|
||||
expect(EXIT_CODES.AUTH_ERROR).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Argument Parsing', () => {
|
||||
describe('Commands', () => {
|
||||
it('should parse command correctly', () => {
|
||||
const result = parseArgs(['chat', 'Hello']);
|
||||
expect(result.command).toBe('chat');
|
||||
expect(result.args).toEqual(['Hello']);
|
||||
});
|
||||
|
||||
it('should default to help when no command', () => {
|
||||
const result = parseArgs([]);
|
||||
expect(result.command).toBe('help');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Flags', () => {
|
||||
it('should parse long flags with values', () => {
|
||||
const result = parseArgs(['chat', 'Hello', '--model', 'gpt-4']);
|
||||
expect(result.options.model).toBe('gpt-4');
|
||||
});
|
||||
|
||||
it('should parse short flags with values', () => {
|
||||
const result = parseArgs(['chat', 'Hello', '-m', 'gpt-4']);
|
||||
expect(result.options.model).toBe('gpt-4');
|
||||
});
|
||||
|
||||
it('should parse boolean flags', () => {
|
||||
const result = parseArgs(['chat', 'Hello', '--verbose']);
|
||||
expect(result.options.verbose).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse short boolean flags', () => {
|
||||
const result = parseArgs(['chat', 'Hello', '-v']);
|
||||
expect(result.options.verbose).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle --json shorthand', () => {
|
||||
const result = parseArgs(['chat', 'Hello', '--json']);
|
||||
expect(result.options.json).toBe(true);
|
||||
expect(result.options.output).toBe('json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subcommands', () => {
|
||||
it('should parse eval subcommands', () => {
|
||||
const result = parseArgs(['eval', 'accuracy', '--input', 'test']);
|
||||
expect(result.command).toBe('eval');
|
||||
expect(result.subcommand).toBe('accuracy');
|
||||
expect(result.options.input).toBe('test');
|
||||
});
|
||||
|
||||
it('should parse tools subcommands', () => {
|
||||
const result = parseArgs(['tools', 'info', 'calculator']);
|
||||
expect(result.command).toBe('tools');
|
||||
expect(result.subcommand).toBe('info');
|
||||
expect(result.args).toEqual(['calculator']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Arguments', () => {
|
||||
it('should collect multiple positional arguments', () => {
|
||||
const result = parseArgs(['run', 'task1', 'task2']);
|
||||
expect(result.command).toBe('run');
|
||||
expect(result.args).toEqual(['task1', 'task2']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output Schema', () => {
|
||||
it('should have consistent success schema', () => {
|
||||
// This is a type check - the schema is enforced by TypeScript
|
||||
const successOutput = {
|
||||
success: true as const,
|
||||
data: { response: 'test' },
|
||||
meta: {
|
||||
duration_ms: 100,
|
||||
model: 'gpt-4',
|
||||
tokens: { input: 10, output: 20 }
|
||||
}
|
||||
};
|
||||
|
||||
expect(successOutput.success).toBe(true);
|
||||
expect(successOutput.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have consistent error schema', () => {
|
||||
const errorOutput = {
|
||||
success: false as const,
|
||||
error: {
|
||||
code: 'MISSING_ARGUMENT',
|
||||
message: 'Please provide a prompt'
|
||||
}
|
||||
};
|
||||
|
||||
expect(errorOutput.success).toBe(false);
|
||||
expect(errorOutput.error.code).toBeDefined();
|
||||
expect(errorOutput.error.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* CLI Commands Tests
|
||||
*/
|
||||
|
||||
import { formatSuccess, formatError } from '../../src/cli/output/json';
|
||||
import { ERROR_CODES } from '../../src/cli/output/errors';
|
||||
|
||||
describe('Output Formatting', () => {
|
||||
describe('formatSuccess', () => {
|
||||
it('should format success response without meta', () => {
|
||||
const result = formatSuccess({ response: 'Hello' });
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { response: 'Hello' }
|
||||
});
|
||||
});
|
||||
|
||||
it('should format success response with meta', () => {
|
||||
const result = formatSuccess(
|
||||
{ response: 'Hello' },
|
||||
{ duration_ms: 100, model: 'gpt-4' }
|
||||
);
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { response: 'Hello' },
|
||||
meta: { duration_ms: 100, model: 'gpt-4' }
|
||||
});
|
||||
});
|
||||
|
||||
it('should include token counts in meta', () => {
|
||||
const result = formatSuccess(
|
||||
{ response: 'Hello' },
|
||||
{ tokens: { input: 10, output: 5 } }
|
||||
);
|
||||
expect(result.meta?.tokens).toEqual({ input: 10, output: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatError', () => {
|
||||
it('should format error response', () => {
|
||||
const result = formatError('MISSING_ARGUMENT', 'Please provide a prompt');
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'MISSING_ARGUMENT',
|
||||
message: 'Please provide a prompt'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should include details when provided', () => {
|
||||
const result = formatError('INVALID_ARGUMENTS', 'Bad input', { field: 'model' });
|
||||
expect(result.error.details).toEqual({ field: 'model' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Codes', () => {
|
||||
it('should have all required error codes', () => {
|
||||
expect(ERROR_CODES.UNKNOWN).toBe('UNKNOWN_ERROR');
|
||||
expect(ERROR_CODES.INVALID_ARGS).toBe('INVALID_ARGUMENTS');
|
||||
expect(ERROR_CODES.MISSING_ARG).toBe('MISSING_ARGUMENT');
|
||||
expect(ERROR_CODES.MISSING_API_KEY).toBe('MISSING_API_KEY');
|
||||
expect(ERROR_CODES.NETWORK_TIMEOUT).toBe('NETWORK_TIMEOUT');
|
||||
expect(ERROR_CODES.PROVIDER_ERROR).toBe('PROVIDER_ERROR');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* CLI Config Tests
|
||||
*/
|
||||
|
||||
import { DEFAULT_CONFIG, validateConfig, mergeConfigs } from '../../src/cli/config/schema';
|
||||
import { resolveConfig } from '../../src/cli/config/resolve';
|
||||
|
||||
describe('Config Schema', () => {
|
||||
describe('DEFAULT_CONFIG', () => {
|
||||
it('should have correct defaults', () => {
|
||||
expect(DEFAULT_CONFIG.model).toBe('openai/gpt-4o-mini');
|
||||
expect(DEFAULT_CONFIG.verbose).toBe(false);
|
||||
expect(DEFAULT_CONFIG.stream).toBe(false);
|
||||
expect(DEFAULT_CONFIG.profiles).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConfig', () => {
|
||||
it('should validate correct config', () => {
|
||||
const config = {
|
||||
model: 'gpt-4',
|
||||
verbose: true,
|
||||
stream: false
|
||||
};
|
||||
expect(validateConfig(config)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid model type', () => {
|
||||
const config = { model: 123 };
|
||||
expect(validateConfig(config)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject invalid verbose type', () => {
|
||||
const config = { verbose: 'yes' };
|
||||
expect(validateConfig(config)).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate config with profiles', () => {
|
||||
const config = {
|
||||
model: 'gpt-4',
|
||||
profiles: {
|
||||
fast: { model: 'gpt-4o-mini' },
|
||||
quality: { model: 'gpt-4', verbose: true }
|
||||
}
|
||||
};
|
||||
expect(validateConfig(config)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid profile structure', () => {
|
||||
const config = {
|
||||
profiles: {
|
||||
bad: 'not an object'
|
||||
}
|
||||
};
|
||||
expect(validateConfig(config)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeConfigs', () => {
|
||||
it('should merge multiple configs', () => {
|
||||
const result = mergeConfigs(
|
||||
{ model: 'gpt-3.5' },
|
||||
{ verbose: true },
|
||||
{ model: 'gpt-4' }
|
||||
);
|
||||
expect(result.model).toBe('gpt-4');
|
||||
expect(result.verbose).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve profiles from multiple sources', () => {
|
||||
const result = mergeConfigs(
|
||||
{ profiles: { a: { model: 'gpt-3.5' } } },
|
||||
{ profiles: { b: { model: 'gpt-4' } } }
|
||||
);
|
||||
expect(result.profiles?.a).toBeDefined();
|
||||
expect(result.profiles?.b).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config Resolution', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should use defaults when no config provided', () => {
|
||||
const config = resolveConfig();
|
||||
expect(config.model).toBe('openai/gpt-4o-mini');
|
||||
expect(config.verbose).toBe(false);
|
||||
expect(config.stream).toBe(false);
|
||||
});
|
||||
|
||||
it('should override with CLI options', () => {
|
||||
const config = resolveConfig({
|
||||
model: 'anthropic/claude-3',
|
||||
verbose: true
|
||||
});
|
||||
expect(config.model).toBe('anthropic/claude-3');
|
||||
expect(config.verbose).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect environment variables', () => {
|
||||
process.env.PRAISONAI_MODEL = 'env-model';
|
||||
process.env.PRAISONAI_VERBOSE = 'true';
|
||||
|
||||
const config = resolveConfig();
|
||||
expect(config.model).toBe('env-model');
|
||||
expect(config.verbose).toBe(true);
|
||||
});
|
||||
|
||||
it('should prioritize CLI options over env vars', () => {
|
||||
process.env.PRAISONAI_MODEL = 'env-model';
|
||||
|
||||
const config = resolveConfig({ model: 'cli-model' });
|
||||
expect(config.model).toBe('cli-model');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Agent } from '../../../src/agent/agent';
|
||||
import { ToolManager } from '../../../src/tools/tools';
|
||||
import { Task } from '../../../src/types';
|
||||
import { Agent } from '../../../src/agent';
|
||||
import { ToolManager } from '../../../src/tools';
|
||||
import { Task } from '../../../src/agent';
|
||||
|
||||
jest.mock('../../../src/llm/openai');
|
||||
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* Unit tests for CLI Features
|
||||
*/
|
||||
|
||||
import {
|
||||
// Slash Commands
|
||||
SlashCommandHandler,
|
||||
createSlashCommandHandler,
|
||||
parseSlashCommand,
|
||||
isSlashCommand,
|
||||
|
||||
// Cost Tracker
|
||||
CostTracker,
|
||||
createCostTracker,
|
||||
estimateTokens,
|
||||
formatCost,
|
||||
MODEL_PRICING,
|
||||
|
||||
// Autonomy Mode
|
||||
AutonomyManager,
|
||||
createAutonomyManager,
|
||||
MODE_POLICIES,
|
||||
|
||||
// Scheduler
|
||||
Scheduler,
|
||||
createScheduler,
|
||||
cronExpressions,
|
||||
|
||||
// Background Jobs
|
||||
JobQueue,
|
||||
createJobQueue,
|
||||
MemoryJobStorage,
|
||||
|
||||
// Checkpoints
|
||||
CheckpointManager,
|
||||
createCheckpointManager,
|
||||
MemoryCheckpointStorage,
|
||||
|
||||
// Flow Display
|
||||
FlowDisplay,
|
||||
createFlowDisplay,
|
||||
renderWorkflow,
|
||||
|
||||
// Fast Context
|
||||
FastContext,
|
||||
createFastContext,
|
||||
getQuickContext,
|
||||
|
||||
// Command Validator
|
||||
CommandValidator,
|
||||
DEFAULT_BLOCKED_COMMANDS
|
||||
} from '../../src/cli/features';
|
||||
|
||||
describe('Slash Commands', () => {
|
||||
test('parseSlashCommand parses valid commands', () => {
|
||||
const result = parseSlashCommand('/help');
|
||||
expect(result).toEqual({ command: 'help', args: [] });
|
||||
});
|
||||
|
||||
test('parseSlashCommand parses commands with args', () => {
|
||||
const result = parseSlashCommand('/model gpt-4');
|
||||
expect(result).toEqual({ command: 'model', args: ['gpt-4'] });
|
||||
});
|
||||
|
||||
test('parseSlashCommand returns null for non-commands', () => {
|
||||
const result = parseSlashCommand('hello world');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('isSlashCommand detects slash commands', () => {
|
||||
expect(isSlashCommand('/help')).toBe(true);
|
||||
expect(isSlashCommand('help')).toBe(false);
|
||||
expect(isSlashCommand(' /help')).toBe(true);
|
||||
});
|
||||
|
||||
test('SlashCommandHandler handles help command', async () => {
|
||||
const handler = createSlashCommandHandler();
|
||||
const result = await handler.handle('/help');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Available commands');
|
||||
});
|
||||
|
||||
test('SlashCommandHandler handles unknown command', async () => {
|
||||
const handler = createSlashCommandHandler();
|
||||
const result = await handler.handle('/unknown');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Unknown command');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cost Tracker', () => {
|
||||
test('createCostTracker creates instance', () => {
|
||||
const tracker = createCostTracker();
|
||||
expect(tracker).toBeInstanceOf(CostTracker);
|
||||
});
|
||||
|
||||
test('addUsage tracks token usage', () => {
|
||||
const tracker = createCostTracker();
|
||||
tracker.addUsage('gpt-4o', 100, 50);
|
||||
|
||||
const stats = tracker.getStats();
|
||||
expect(stats.totalInputTokens).toBe(100);
|
||||
expect(stats.totalOutputTokens).toBe(50);
|
||||
expect(stats.totalTokens).toBe(150);
|
||||
expect(stats.requestCount).toBe(1);
|
||||
});
|
||||
|
||||
test('calculateCost uses correct pricing', () => {
|
||||
const tracker = createCostTracker();
|
||||
const cost = tracker.calculateCost('gpt-4o', 1000, 1000);
|
||||
|
||||
// gpt-4o: input $0.005/1k, output $0.015/1k
|
||||
const expected = (1000 / 1000) * 0.005 + (1000 / 1000) * 0.015;
|
||||
expect(cost).toBeCloseTo(expected);
|
||||
});
|
||||
|
||||
test('getSummary returns formatted string', () => {
|
||||
const tracker = createCostTracker();
|
||||
tracker.addUsage('gpt-4o', 100, 50);
|
||||
|
||||
const summary = tracker.getSummary();
|
||||
expect(summary).toContain('Cost Summary');
|
||||
expect(summary).toContain('Tokens');
|
||||
});
|
||||
|
||||
test('reset clears stats', () => {
|
||||
const tracker = createCostTracker();
|
||||
tracker.addUsage('gpt-4o', 100, 50);
|
||||
tracker.reset();
|
||||
|
||||
const stats = tracker.getStats();
|
||||
expect(stats.totalTokens).toBe(0);
|
||||
expect(stats.requestCount).toBe(0);
|
||||
});
|
||||
|
||||
test('estimateTokens approximates correctly', () => {
|
||||
const tokens = estimateTokens('Hello world');
|
||||
expect(tokens).toBeGreaterThan(0);
|
||||
expect(tokens).toBeLessThan(10);
|
||||
});
|
||||
|
||||
test('formatCost formats small values', () => {
|
||||
expect(formatCost(0.001234)).toBe('$0.001234');
|
||||
expect(formatCost(0.05)).toBe('$0.0500');
|
||||
expect(formatCost(1.5)).toBe('$1.50');
|
||||
});
|
||||
|
||||
test('MODEL_PRICING has expected models', () => {
|
||||
expect(MODEL_PRICING['gpt-4o']).toBeDefined();
|
||||
expect(MODEL_PRICING['claude-3-5-sonnet-20241022']).toBeDefined();
|
||||
expect(MODEL_PRICING['gemini-1.5-pro']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Autonomy Mode', () => {
|
||||
test('createAutonomyManager creates instance', () => {
|
||||
const manager = createAutonomyManager();
|
||||
expect(manager).toBeInstanceOf(AutonomyManager);
|
||||
});
|
||||
|
||||
test('default mode is suggest', () => {
|
||||
const manager = createAutonomyManager();
|
||||
expect(manager.getMode()).toBe('suggest');
|
||||
});
|
||||
|
||||
test('setMode changes mode', () => {
|
||||
const manager = createAutonomyManager();
|
||||
manager.setMode('full_auto');
|
||||
expect(manager.getMode()).toBe('full_auto');
|
||||
});
|
||||
|
||||
test('requestApproval auto-approves file_read in suggest mode', async () => {
|
||||
const manager = createAutonomyManager({ mode: 'suggest' });
|
||||
const decision = await manager.requestApproval({
|
||||
type: 'file_read',
|
||||
description: 'Read config file'
|
||||
});
|
||||
expect(decision.approved).toBe(true);
|
||||
});
|
||||
|
||||
test('requestApproval requires approval for file_write in suggest mode', async () => {
|
||||
const manager = createAutonomyManager({ mode: 'suggest' });
|
||||
// Without prompt callback, should deny
|
||||
const decision = await manager.requestApproval({
|
||||
type: 'file_write',
|
||||
description: 'Write to file'
|
||||
});
|
||||
expect(decision.approved).toBe(false);
|
||||
});
|
||||
|
||||
test('full_auto mode auto-approves most actions', async () => {
|
||||
const manager = createAutonomyManager({ mode: 'full_auto' });
|
||||
const decision = await manager.requestApproval({
|
||||
type: 'shell_command',
|
||||
description: 'Run npm install'
|
||||
});
|
||||
expect(decision.approved).toBe(true);
|
||||
});
|
||||
|
||||
test('MODE_POLICIES has all modes', () => {
|
||||
expect(MODE_POLICIES['suggest']).toBeDefined();
|
||||
expect(MODE_POLICIES['auto_edit']).toBeDefined();
|
||||
expect(MODE_POLICIES['full_auto']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scheduler', () => {
|
||||
test('createScheduler creates instance', () => {
|
||||
const scheduler = createScheduler();
|
||||
expect(scheduler).toBeInstanceOf(Scheduler);
|
||||
});
|
||||
|
||||
test('add creates task with ID', () => {
|
||||
const scheduler = createScheduler();
|
||||
const id = scheduler.add({
|
||||
name: 'test-task',
|
||||
interval: 1000,
|
||||
task: async () => 'done'
|
||||
});
|
||||
expect(id).toBeDefined();
|
||||
expect(typeof id).toBe('string');
|
||||
});
|
||||
|
||||
test('getTask returns task info', () => {
|
||||
const scheduler = createScheduler();
|
||||
const id = scheduler.add({
|
||||
name: 'test-task',
|
||||
interval: 1000,
|
||||
task: async () => 'done'
|
||||
});
|
||||
|
||||
const task = scheduler.getTask(id);
|
||||
expect(task).toBeDefined();
|
||||
expect(task?.name).toBe('test-task');
|
||||
expect(task?.status).toBe('idle');
|
||||
});
|
||||
|
||||
test('remove deletes task', () => {
|
||||
const scheduler = createScheduler();
|
||||
const id = scheduler.add({
|
||||
name: 'test-task',
|
||||
interval: 1000,
|
||||
task: async () => 'done'
|
||||
});
|
||||
|
||||
expect(scheduler.remove(id)).toBe(true);
|
||||
expect(scheduler.getTask(id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('enable/disable toggles task', () => {
|
||||
const scheduler = createScheduler();
|
||||
const id = scheduler.add({
|
||||
name: 'test-task',
|
||||
interval: 1000,
|
||||
task: async () => 'done',
|
||||
enabled: true
|
||||
});
|
||||
|
||||
scheduler.disable(id);
|
||||
expect(scheduler.getTask(id)?.enabled).toBe(false);
|
||||
|
||||
scheduler.enable(id);
|
||||
expect(scheduler.getTask(id)?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('cronExpressions has common patterns', () => {
|
||||
expect(cronExpressions.everyMinute).toBe('* * * * *');
|
||||
expect(cronExpressions.everyHour).toBe('0 * * * *');
|
||||
expect(cronExpressions.everyDay).toBe('0 0 * * *');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Background Jobs', () => {
|
||||
test('createJobQueue creates instance', () => {
|
||||
const queue = createJobQueue();
|
||||
expect(queue).toBeInstanceOf(JobQueue);
|
||||
});
|
||||
|
||||
test('add creates job', async () => {
|
||||
const queue = createJobQueue();
|
||||
const job = await queue.add('test-job', { value: 42 });
|
||||
|
||||
expect(job.id).toBeDefined();
|
||||
expect(job.name).toBe('test-job');
|
||||
expect(job.data).toEqual({ value: 42 });
|
||||
expect(job.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('get retrieves job', async () => {
|
||||
const queue = createJobQueue();
|
||||
const job = await queue.add('test-job', { value: 42 });
|
||||
|
||||
const retrieved = await queue.get(job.id);
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved?.id).toBe(job.id);
|
||||
});
|
||||
|
||||
test('cancel marks job as cancelled', async () => {
|
||||
const queue = createJobQueue();
|
||||
const job = await queue.add('test-job', { value: 42 });
|
||||
|
||||
const cancelled = await queue.cancel(job.id);
|
||||
expect(cancelled).toBe(true);
|
||||
|
||||
const retrieved = await queue.get(job.id);
|
||||
expect(retrieved?.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
test('MemoryJobStorage stores and retrieves', async () => {
|
||||
const storage = new MemoryJobStorage();
|
||||
const job = {
|
||||
id: 'test-1',
|
||||
name: 'test',
|
||||
data: {},
|
||||
status: 'pending' as const,
|
||||
priority: 'normal' as const,
|
||||
attempts: 0,
|
||||
maxAttempts: 3,
|
||||
createdAt: new Date()
|
||||
};
|
||||
|
||||
await storage.save(job);
|
||||
const retrieved = await storage.get('test-1');
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved?.id).toBe('test-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Checkpoints', () => {
|
||||
test('createCheckpointManager creates instance', () => {
|
||||
const manager = createCheckpointManager();
|
||||
expect(manager).toBeInstanceOf(CheckpointManager);
|
||||
});
|
||||
|
||||
test('create saves checkpoint', async () => {
|
||||
const manager = createCheckpointManager();
|
||||
const checkpoint = await manager.create('test-checkpoint');
|
||||
|
||||
expect(checkpoint.id).toBeDefined();
|
||||
expect(checkpoint.name).toBe('test-checkpoint');
|
||||
expect(checkpoint.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test('get retrieves checkpoint', async () => {
|
||||
const manager = createCheckpointManager();
|
||||
const checkpoint = await manager.create('test-checkpoint');
|
||||
|
||||
const retrieved = await manager.get(checkpoint.id);
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved?.name).toBe('test-checkpoint');
|
||||
});
|
||||
|
||||
test('list returns all checkpoints', async () => {
|
||||
const manager = createCheckpointManager();
|
||||
await manager.create('checkpoint-1');
|
||||
await manager.create('checkpoint-2');
|
||||
|
||||
const list = await manager.list();
|
||||
expect(list.length).toBe(2);
|
||||
});
|
||||
|
||||
test('setState and getState work correctly', () => {
|
||||
const manager = createCheckpointManager();
|
||||
manager.setState({ key: 'value' });
|
||||
|
||||
const state = manager.getState();
|
||||
expect(state.key).toBe('value');
|
||||
});
|
||||
|
||||
test('MemoryCheckpointStorage stores and retrieves', async () => {
|
||||
const storage = new MemoryCheckpointStorage();
|
||||
const checkpoint = {
|
||||
id: 'cp-1',
|
||||
name: 'test',
|
||||
timestamp: new Date(),
|
||||
state: { key: 'value' }
|
||||
};
|
||||
|
||||
await storage.save(checkpoint);
|
||||
const retrieved = await storage.load('cp-1');
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved?.name).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Flow Display', () => {
|
||||
test('createFlowDisplay creates instance', () => {
|
||||
const display = createFlowDisplay();
|
||||
expect(display).toBeInstanceOf(FlowDisplay);
|
||||
});
|
||||
|
||||
test('addNode adds node to graph', () => {
|
||||
const display = createFlowDisplay();
|
||||
display.addNode({ id: 'node-1', name: 'Test Node', type: 'agent' });
|
||||
|
||||
const graph = display.getGraph();
|
||||
expect(graph.nodes.has('node-1')).toBe(true);
|
||||
});
|
||||
|
||||
test('addEdge adds edge to graph', () => {
|
||||
const display = createFlowDisplay();
|
||||
display.addNode({ id: 'node-1', name: 'Node 1', type: 'start' });
|
||||
display.addNode({ id: 'node-2', name: 'Node 2', type: 'agent' });
|
||||
display.addEdge('node-1', 'node-2');
|
||||
|
||||
const graph = display.getGraph();
|
||||
expect(graph.edges.length).toBe(1);
|
||||
expect(graph.edges[0]).toEqual({ from: 'node-1', to: 'node-2', label: undefined });
|
||||
});
|
||||
|
||||
test('fromWorkflowSteps builds graph', () => {
|
||||
const display = createFlowDisplay();
|
||||
display.fromWorkflowSteps([
|
||||
{ name: 'Step 1', type: 'agent' },
|
||||
{ name: 'Step 2', type: 'tool' }
|
||||
]);
|
||||
|
||||
const graph = display.getGraph();
|
||||
expect(graph.nodes.size).toBe(4); // start + 2 steps + end
|
||||
});
|
||||
|
||||
test('render produces text output', () => {
|
||||
const display = createFlowDisplay();
|
||||
display.fromWorkflowSteps([
|
||||
{ name: 'Step 1', type: 'agent' }
|
||||
]);
|
||||
|
||||
const output = display.render();
|
||||
expect(output).toContain('Start');
|
||||
expect(output).toContain('Step 1');
|
||||
});
|
||||
|
||||
test('renderWorkflow helper works', () => {
|
||||
const output = renderWorkflow([
|
||||
{ name: 'Step 1' },
|
||||
{ name: 'Step 2' }
|
||||
]);
|
||||
expect(output).toContain('Step 1');
|
||||
expect(output).toContain('Step 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fast Context', () => {
|
||||
test('createFastContext creates instance', () => {
|
||||
const fc = createFastContext();
|
||||
expect(fc).toBeInstanceOf(FastContext);
|
||||
});
|
||||
|
||||
test('registerSource adds sources', () => {
|
||||
const fc = createFastContext();
|
||||
fc.registerSource('test', 'custom', ['content 1', 'content 2']);
|
||||
// No error means success
|
||||
});
|
||||
|
||||
test('getContext returns result', async () => {
|
||||
const fc = createFastContext({ cacheEnabled: false });
|
||||
fc.registerSource('test', 'custom', ['Hello world', 'Test content']);
|
||||
|
||||
const result = await fc.getContext('hello');
|
||||
expect(result.context).toBeDefined();
|
||||
expect(result.sources.length).toBeGreaterThan(0);
|
||||
expect(result.tokenCount).toBeGreaterThan(0);
|
||||
expect(result.cached).toBe(false);
|
||||
});
|
||||
|
||||
test('caching works', async () => {
|
||||
const fc = createFastContext({ cacheEnabled: true });
|
||||
fc.registerSource('test', 'custom', ['Hello world']);
|
||||
|
||||
await fc.getContext('hello');
|
||||
const result2 = await fc.getContext('hello');
|
||||
|
||||
expect(result2.cached).toBe(true);
|
||||
});
|
||||
|
||||
test('clearCache clears cache', async () => {
|
||||
const fc = createFastContext({ cacheEnabled: true });
|
||||
fc.registerSource('test', 'custom', ['Hello world']);
|
||||
|
||||
await fc.getContext('hello');
|
||||
fc.clearCache();
|
||||
|
||||
const stats = fc.getCacheStats();
|
||||
expect(stats.size).toBe(0);
|
||||
});
|
||||
|
||||
test('getQuickContext helper works', async () => {
|
||||
const context = await getQuickContext('test', ['Source 1', 'Source 2']);
|
||||
expect(context).toBeDefined();
|
||||
expect(typeof context).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command Validator', () => {
|
||||
test('validates safe commands', () => {
|
||||
const validator = new CommandValidator();
|
||||
const result = validator.validate('ls -la');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
test('blocks dangerous commands', () => {
|
||||
const validator = new CommandValidator();
|
||||
const result = validator.validate('rm -rf /');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.reason).toContain('Blocked');
|
||||
});
|
||||
|
||||
test('blocks shell injection', () => {
|
||||
const validator = new CommandValidator();
|
||||
const result = validator.validate('echo test; rm -rf /');
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
test('DEFAULT_BLOCKED_COMMANDS has dangerous patterns', () => {
|
||||
expect(DEFAULT_BLOCKED_COMMANDS).toContain('rm -rf /');
|
||||
expect(DEFAULT_BLOCKED_COMMANDS).toContain('sudo rm');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Unit tests for Memory Features (FileMemory, AutoMemory)
|
||||
*/
|
||||
|
||||
import {
|
||||
FileMemory,
|
||||
createFileMemory,
|
||||
AutoMemory,
|
||||
createAutoMemory,
|
||||
DEFAULT_POLICIES
|
||||
} from '../../src/memory';
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('FileMemory', () => {
|
||||
let tempDir: string;
|
||||
let testFilePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'praisonai-test-'));
|
||||
testFilePath = path.join(tempDir, 'memory.jsonl');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
test('createFileMemory creates instance', () => {
|
||||
const memory = createFileMemory({ filePath: testFilePath });
|
||||
expect(memory).toBeInstanceOf(FileMemory);
|
||||
});
|
||||
|
||||
test('add creates entry', async () => {
|
||||
const memory = createFileMemory({ filePath: testFilePath });
|
||||
const entry = await memory.add('Hello world', 'user');
|
||||
|
||||
expect(entry.id).toBeDefined();
|
||||
expect(entry.content).toBe('Hello world');
|
||||
expect(entry.role).toBe('user');
|
||||
expect(entry.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
test('get retrieves entry', async () => {
|
||||
const memory = createFileMemory({ filePath: testFilePath });
|
||||
const entry = await memory.add('Hello world', 'user');
|
||||
|
||||
const retrieved = await memory.get(entry.id);
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved?.content).toBe('Hello world');
|
||||
});
|
||||
|
||||
test('getAll returns all entries', async () => {
|
||||
const memory = createFileMemory({ filePath: testFilePath });
|
||||
await memory.add('Message 1', 'user');
|
||||
await memory.add('Message 2', 'assistant');
|
||||
|
||||
const all = await memory.getAll();
|
||||
expect(all.length).toBe(2);
|
||||
});
|
||||
|
||||
test('getRecent returns recent entries', async () => {
|
||||
const memory = createFileMemory({ filePath: testFilePath });
|
||||
await memory.add('Message 1', 'user');
|
||||
await memory.add('Message 2', 'assistant');
|
||||
await memory.add('Message 3', 'user');
|
||||
|
||||
const recent = await memory.getRecent(2);
|
||||
expect(recent.length).toBe(2);
|
||||
expect(recent[0].content).toBe('Message 2');
|
||||
expect(recent[1].content).toBe('Message 3');
|
||||
});
|
||||
|
||||
test('delete removes entry', async () => {
|
||||
const memory = createFileMemory({ filePath: testFilePath });
|
||||
const entry = await memory.add('Hello world', 'user');
|
||||
|
||||
const deleted = await memory.delete(entry.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const retrieved = await memory.get(entry.id);
|
||||
expect(retrieved).toBeUndefined();
|
||||
});
|
||||
|
||||
test('clear removes all entries', async () => {
|
||||
const memory = createFileMemory({ filePath: testFilePath });
|
||||
await memory.add('Message 1', 'user');
|
||||
await memory.add('Message 2', 'assistant');
|
||||
|
||||
await memory.clear();
|
||||
|
||||
const all = await memory.getAll();
|
||||
expect(all.length).toBe(0);
|
||||
});
|
||||
|
||||
test('search finds matching entries', async () => {
|
||||
const memory = createFileMemory({ filePath: testFilePath });
|
||||
await memory.add('Hello world', 'user');
|
||||
await memory.add('Goodbye world', 'assistant');
|
||||
await memory.add('Something else', 'user');
|
||||
|
||||
const results = await memory.search('world');
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
test('persists to file', async () => {
|
||||
const memory1 = createFileMemory({ filePath: testFilePath });
|
||||
await memory1.add('Persistent message', 'user');
|
||||
|
||||
// Create new instance to read from file
|
||||
const memory2 = createFileMemory({ filePath: testFilePath });
|
||||
const all = await memory2.getAll();
|
||||
|
||||
expect(all.length).toBe(1);
|
||||
expect(all[0].content).toBe('Persistent message');
|
||||
});
|
||||
|
||||
test('compact removes deleted entries from file', async () => {
|
||||
const memory = createFileMemory({ filePath: testFilePath });
|
||||
const entry1 = await memory.add('Message 1', 'user');
|
||||
await memory.add('Message 2', 'assistant');
|
||||
|
||||
await memory.delete(entry1.id);
|
||||
await memory.compact();
|
||||
|
||||
const all = await memory.getAll();
|
||||
expect(all.length).toBe(1);
|
||||
expect(all[0].content).toBe('Message 2');
|
||||
});
|
||||
|
||||
test('toJSON exports entries', async () => {
|
||||
const memory = createFileMemory({ filePath: testFilePath });
|
||||
await memory.add('Message 1', 'user');
|
||||
await memory.add('Message 2', 'assistant');
|
||||
|
||||
const json = await memory.toJSON();
|
||||
expect(json.length).toBe(2);
|
||||
});
|
||||
|
||||
test('buildContext creates context string', async () => {
|
||||
const memory = createFileMemory({ filePath: testFilePath });
|
||||
await memory.add('Hello', 'user');
|
||||
await memory.add('Hi there', 'assistant');
|
||||
|
||||
const context = await memory.buildContext();
|
||||
expect(context).toContain('user: Hello');
|
||||
expect(context).toContain('assistant: Hi there');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AutoMemory', () => {
|
||||
test('createAutoMemory creates instance', () => {
|
||||
const memory = createAutoMemory();
|
||||
expect(memory).toBeInstanceOf(AutoMemory);
|
||||
});
|
||||
|
||||
test('add stores entry based on policy', async () => {
|
||||
const memory = createAutoMemory();
|
||||
const entry = await memory.add('Remember this important note', 'user');
|
||||
|
||||
// Should match 'store-important' policy
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.content).toContain('important');
|
||||
});
|
||||
|
||||
test('add skips short messages', async () => {
|
||||
const memory = createAutoMemory();
|
||||
const entry = await memory.add('Hi', 'user');
|
||||
|
||||
// Should match 'skip-short' policy
|
||||
expect(entry).toBeNull();
|
||||
});
|
||||
|
||||
test('get retrieves entry', async () => {
|
||||
const memory = createAutoMemory();
|
||||
const entry = await memory.add('Remember this important note', 'user');
|
||||
|
||||
if (entry) {
|
||||
const retrieved = memory.get(entry.id);
|
||||
expect(retrieved).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('getAll returns all entries', async () => {
|
||||
const memory = createAutoMemory();
|
||||
await memory.add('Important message 1', 'user');
|
||||
await memory.add('Important message 2', 'assistant');
|
||||
|
||||
const all = memory.getAll();
|
||||
expect(all.length).toBe(2);
|
||||
});
|
||||
|
||||
test('getRecent returns recent entries', async () => {
|
||||
const memory = createAutoMemory();
|
||||
await memory.add('Important message 1', 'user');
|
||||
await memory.add('Important message 2', 'assistant');
|
||||
await memory.add('Important message 3', 'user');
|
||||
|
||||
const recent = memory.getRecent(2);
|
||||
expect(recent.length).toBe(2);
|
||||
});
|
||||
|
||||
test('search finds matching entries', async () => {
|
||||
const memory = createAutoMemory();
|
||||
await memory.add('Important hello world', 'user');
|
||||
await memory.add('Important goodbye world', 'assistant');
|
||||
|
||||
const results = await memory.search('world');
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
test('addPolicy adds custom policy', async () => {
|
||||
const memory = createAutoMemory();
|
||||
memory.addPolicy({
|
||||
name: 'custom-test',
|
||||
condition: (content) => content.includes('CUSTOM'),
|
||||
action: 'store',
|
||||
priority: 200
|
||||
});
|
||||
|
||||
const entry = await memory.add('CUSTOM content', 'user');
|
||||
expect(entry).toBeDefined();
|
||||
});
|
||||
|
||||
test('removePolicy removes policy', () => {
|
||||
const memory = createAutoMemory();
|
||||
const removed = memory.removePolicy('store-important');
|
||||
expect(removed).toBe(true);
|
||||
});
|
||||
|
||||
test('getStats returns context stats', async () => {
|
||||
const memory = createAutoMemory();
|
||||
await memory.add('Important test message', 'user');
|
||||
|
||||
const stats = memory.getStats();
|
||||
expect(stats.messageCount).toBe(1);
|
||||
expect(stats.tokenCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('clear resets memory', async () => {
|
||||
const memory = createAutoMemory();
|
||||
await memory.add('Important message', 'user');
|
||||
|
||||
memory.clear();
|
||||
|
||||
const all = memory.getAll();
|
||||
expect(all.length).toBe(0);
|
||||
});
|
||||
|
||||
test('toJSON exports entries', async () => {
|
||||
const memory = createAutoMemory();
|
||||
// Use longer messages that won't be skipped
|
||||
await memory.add('This is an important message that should be stored', 'user');
|
||||
await memory.add('This is another important message for testing', 'assistant');
|
||||
|
||||
const json = memory.toJSON();
|
||||
expect(json.length).toBeGreaterThanOrEqual(0); // May vary based on policies
|
||||
});
|
||||
|
||||
test('buildContext creates context string', async () => {
|
||||
const memory = createAutoMemory();
|
||||
// Use messages that match store-important policy
|
||||
await memory.add('Remember this important information', 'user');
|
||||
await memory.add('I will remember this important note', 'assistant');
|
||||
|
||||
const context = await memory.buildContext();
|
||||
// Context may be empty if policies don't match
|
||||
expect(typeof context).toBe('string');
|
||||
});
|
||||
|
||||
test('DEFAULT_POLICIES has expected policies', () => {
|
||||
expect(DEFAULT_POLICIES.length).toBeGreaterThan(0);
|
||||
|
||||
const policyNames = DEFAULT_POLICIES.map(p => p.name);
|
||||
// Check for policies that exist in the default set
|
||||
expect(policyNames).toContain('summarize-long');
|
||||
expect(policyNames).toContain('skip-short');
|
||||
});
|
||||
});
|
||||
@@ -1,114 +1,119 @@
|
||||
import { KnowledgeBase } from '../../../src/knowledge/knowledge';
|
||||
import { BaseKnowledgeBase, Knowledge } from '../../../src/knowledge';
|
||||
|
||||
describe('Knowledge Base', () => {
|
||||
let knowledgeBase: KnowledgeBase;
|
||||
let knowledgeBase: BaseKnowledgeBase;
|
||||
|
||||
beforeEach(() => {
|
||||
knowledgeBase = new KnowledgeBase();
|
||||
knowledgeBase = new BaseKnowledgeBase();
|
||||
});
|
||||
|
||||
describe('knowledge management', () => {
|
||||
it('should store and retrieve knowledge', async () => {
|
||||
const knowledge = {
|
||||
it('should store and retrieve knowledge', () => {
|
||||
const knowledge: Knowledge = {
|
||||
id: 'test-knowledge',
|
||||
type: 'document',
|
||||
content: 'Test content',
|
||||
metadata: {
|
||||
type: 'test'
|
||||
}
|
||||
};
|
||||
|
||||
await knowledgeBase.store(knowledge);
|
||||
const retrieved = await knowledgeBase.retrieve(knowledge.id);
|
||||
knowledgeBase.addKnowledge(knowledge);
|
||||
const retrieved = knowledgeBase.getKnowledge(knowledge.id);
|
||||
expect(retrieved).toEqual(knowledge);
|
||||
});
|
||||
|
||||
it('should update knowledge', async () => {
|
||||
const knowledge = {
|
||||
it('should update knowledge', () => {
|
||||
const knowledge: Knowledge = {
|
||||
id: 'test-knowledge',
|
||||
type: 'document',
|
||||
content: 'Original content',
|
||||
metadata: {
|
||||
type: 'test'
|
||||
}
|
||||
};
|
||||
|
||||
await knowledgeBase.store(knowledge);
|
||||
knowledgeBase.addKnowledge(knowledge);
|
||||
|
||||
const updatedContent = 'Updated content';
|
||||
await knowledgeBase.update(knowledge.id, updatedContent);
|
||||
const updated = knowledgeBase.updateKnowledge(knowledge.id, { content: 'Updated content' });
|
||||
expect(updated).toBe(true);
|
||||
|
||||
const retrieved = await knowledgeBase.retrieve(knowledge.id);
|
||||
expect(retrieved.content).toBe(updatedContent);
|
||||
const retrieved = knowledgeBase.getKnowledge(knowledge.id);
|
||||
expect(retrieved?.content).toBe('Updated content');
|
||||
});
|
||||
|
||||
it('should delete knowledge', async () => {
|
||||
const knowledge = {
|
||||
it('should delete knowledge', () => {
|
||||
const knowledge: Knowledge = {
|
||||
id: 'test-knowledge',
|
||||
type: 'document',
|
||||
content: 'Test content',
|
||||
metadata: {
|
||||
type: 'test'
|
||||
}
|
||||
};
|
||||
|
||||
await knowledgeBase.store(knowledge);
|
||||
await knowledgeBase.delete(knowledge.id);
|
||||
knowledgeBase.addKnowledge(knowledge);
|
||||
const deleted = knowledgeBase.deleteKnowledge(knowledge.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const retrieved = await knowledgeBase.retrieve(knowledge.id);
|
||||
expect(retrieved).toBeNull();
|
||||
const retrieved = knowledgeBase.getKnowledge(knowledge.id);
|
||||
expect(retrieved).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge search', () => {
|
||||
it('should search knowledge by content', async () => {
|
||||
const knowledge = [
|
||||
it('should search knowledge by content', () => {
|
||||
const items: Knowledge[] = [
|
||||
{
|
||||
id: 'knowledge1',
|
||||
type: 'document',
|
||||
content: 'First piece of knowledge',
|
||||
metadata: { type: 'test' }
|
||||
},
|
||||
{
|
||||
id: 'knowledge2',
|
||||
type: 'document',
|
||||
content: 'Second piece of knowledge',
|
||||
metadata: { type: 'test' }
|
||||
}
|
||||
];
|
||||
|
||||
await Promise.all(knowledge.map(k => knowledgeBase.store(k)));
|
||||
const results = await knowledgeBase.search('First');
|
||||
items.forEach(k => knowledgeBase.addKnowledge(k));
|
||||
const results = knowledgeBase.searchKnowledge('First');
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].id).toBe('knowledge1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle non-existent knowledge', async () => {
|
||||
const result = await knowledgeBase.retrieve('non-existent');
|
||||
expect(result).toBeNull();
|
||||
it('should handle non-existent knowledge', () => {
|
||||
const result = knowledgeBase.getKnowledge('non-existent');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle update errors', async () => {
|
||||
await expect(
|
||||
knowledgeBase.update('non-existent', 'test')
|
||||
).rejects.toThrow();
|
||||
it('should handle update on non-existent', () => {
|
||||
const result = knowledgeBase.updateKnowledge('non-existent', { content: 'test' });
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle batch operations', async () => {
|
||||
const items = [
|
||||
it('should handle batch operations', () => {
|
||||
const items: Knowledge[] = [
|
||||
{
|
||||
id: 'batch1',
|
||||
type: 'document',
|
||||
content: 'Batch item 1',
|
||||
metadata: { type: 'test' }
|
||||
},
|
||||
{
|
||||
id: 'batch2',
|
||||
type: 'document',
|
||||
content: 'Batch item 2',
|
||||
metadata: { type: 'test' }
|
||||
}
|
||||
];
|
||||
|
||||
await knowledgeBase.storeBatch(items);
|
||||
const retrieved = await Promise.all(
|
||||
items.map(item => knowledgeBase.retrieve(item.id))
|
||||
);
|
||||
items.forEach(item => knowledgeBase.addKnowledge(item));
|
||||
const retrieved = items.map(item => knowledgeBase.getKnowledge(item.id));
|
||||
|
||||
expect(retrieved.map(r => r?.id)).toEqual(['batch1', 'batch2']);
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.5.7.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.5.7.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.6.2.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.6.2.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
"""
|
||||
Templates CLI Feature Handler
|
||||
|
||||
Provides CLI commands for template management:
|
||||
- list, search, info, install, cache clear
|
||||
- run templates directly
|
||||
- init projects from templates
|
||||
- Custom templates directory support with precedence
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class TemplatesHandler:
|
||||
"""
|
||||
CLI handler for template operations.
|
||||
|
||||
Commands:
|
||||
- list: List available templates
|
||||
- search: Search templates by query
|
||||
- info: Show template details
|
||||
- install: Install a template to cache
|
||||
- cache: Cache management (clear, list)
|
||||
- run: Run a template directly
|
||||
- init: Initialize a project from template
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the handler with lazy-loaded dependencies."""
|
||||
self._loader = None
|
||||
self._registry = None
|
||||
self._cache = None
|
||||
|
||||
@property
|
||||
def loader(self):
|
||||
"""Lazy load template loader."""
|
||||
if self._loader is None:
|
||||
from praisonai.templates import TemplateLoader
|
||||
self._loader = TemplateLoader()
|
||||
return self._loader
|
||||
|
||||
@property
|
||||
def registry(self):
|
||||
"""Lazy load template registry."""
|
||||
if self._registry is None:
|
||||
from praisonai.templates import TemplateRegistry
|
||||
self._registry = TemplateRegistry()
|
||||
return self._registry
|
||||
|
||||
@property
|
||||
def cache(self):
|
||||
"""Lazy load template cache."""
|
||||
if self._cache is None:
|
||||
from praisonai.templates import TemplateCache
|
||||
self._cache = TemplateCache()
|
||||
return self._cache
|
||||
|
||||
def handle(self, args: List[str]) -> int:
|
||||
"""
|
||||
Handle templates subcommand.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success)
|
||||
"""
|
||||
if not args:
|
||||
self._print_help()
|
||||
return 0
|
||||
|
||||
command = args[0]
|
||||
remaining = args[1:]
|
||||
|
||||
commands = {
|
||||
"list": self.cmd_list,
|
||||
"search": self.cmd_search,
|
||||
"info": self.cmd_info,
|
||||
"install": self.cmd_install,
|
||||
"cache": self.cmd_cache,
|
||||
"run": self.cmd_run,
|
||||
"init": self.cmd_init,
|
||||
"help": lambda _: self._print_help() or 0,
|
||||
}
|
||||
|
||||
if command in commands:
|
||||
return commands[command](remaining)
|
||||
else:
|
||||
print(f"[red]Unknown command: {command}[/red]")
|
||||
self._print_help()
|
||||
return 1
|
||||
|
||||
def _print_help(self):
|
||||
"""Print help message."""
|
||||
help_text = """
|
||||
[bold cyan]PraisonAI Templates[/bold cyan]
|
||||
|
||||
[bold]Usage:[/bold]
|
||||
praisonai templates <command> [options]
|
||||
|
||||
[bold]Commands:[/bold]
|
||||
list List available templates
|
||||
search <query> Search templates by name or tags
|
||||
info <template> Show template details
|
||||
install <uri> Install a template to cache
|
||||
cache clear Clear the template cache
|
||||
cache list List cached templates
|
||||
run <template> Run a template directly
|
||||
init <name> Initialize a project from template
|
||||
|
||||
[bold]Options:[/bold]
|
||||
--offline Use only cached templates (no network)
|
||||
--source <src> Filter by source (custom, package, all)
|
||||
--custom-dir <path> Add custom templates directory
|
||||
--paths Show template search paths
|
||||
|
||||
[bold]Examples:[/bold]
|
||||
praisonai templates list
|
||||
praisonai templates search video
|
||||
praisonai templates info transcript-generator
|
||||
praisonai templates install github:MervinPraison/agent-recipes/transcript-generator
|
||||
praisonai templates run transcript-generator ./audio.mp3
|
||||
praisonai init my-project --template transcript-generator
|
||||
"""
|
||||
try:
|
||||
from rich import print as rprint
|
||||
rprint(help_text)
|
||||
except ImportError:
|
||||
print(help_text.replace("[bold cyan]", "").replace("[/bold cyan]", "")
|
||||
.replace("[bold]", "").replace("[/bold]", "")
|
||||
.replace("[red]", "").replace("[/red]", ""))
|
||||
|
||||
def cmd_list(self, args: List[str]) -> int:
|
||||
"""List available templates from all sources including custom directories."""
|
||||
source_filter = None
|
||||
custom_dirs = []
|
||||
show_paths = "--paths" in args
|
||||
|
||||
# Parse --source filter
|
||||
if "--source" in args:
|
||||
idx = args.index("--source")
|
||||
if idx + 1 < len(args):
|
||||
source_filter = args[idx + 1]
|
||||
|
||||
# Parse --custom-dir (can be specified multiple times)
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--custom-dir" and i + 1 < len(args):
|
||||
custom_dirs.append(args[i + 1])
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
try:
|
||||
from praisonai.templates.discovery import TemplateDiscovery
|
||||
|
||||
discovery = TemplateDiscovery(
|
||||
custom_dirs=custom_dirs if custom_dirs else None,
|
||||
include_package=True,
|
||||
include_defaults=True
|
||||
)
|
||||
|
||||
# Show search paths if requested
|
||||
if show_paths:
|
||||
print("\nTemplate Search Paths (in priority order):")
|
||||
for path, source, exists in discovery.get_search_paths():
|
||||
status = "✓" if exists else "✗"
|
||||
print(f" {status} [{source}] {path}")
|
||||
print()
|
||||
|
||||
# Discover templates
|
||||
templates = discovery.list_templates(source_filter=source_filter)
|
||||
|
||||
if not templates:
|
||||
print("No templates found.")
|
||||
if not show_paths:
|
||||
print("Use --paths to see search locations.")
|
||||
return 0
|
||||
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
table = Table(title="Available Templates")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Version", style="green")
|
||||
table.add_column("Description")
|
||||
table.add_column("Source", style="dim")
|
||||
|
||||
for t in sorted(templates, key=lambda x: (x.priority, x.name)):
|
||||
desc = t.description or ""
|
||||
table.add_row(
|
||||
t.name,
|
||||
t.version or "1.0.0",
|
||||
desc[:50] + "..." if len(desc) > 50 else desc,
|
||||
t.source
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
except ImportError:
|
||||
print(f"{'Name':<25} {'Version':<10} {'Source':<10} {'Description':<35}")
|
||||
print("-" * 80)
|
||||
for t in sorted(templates, key=lambda x: (x.priority, x.name)):
|
||||
desc = (t.description or "")[:35]
|
||||
if len(t.description or "") > 35:
|
||||
desc += "..."
|
||||
print(f"{t.name:<25} {t.version or '1.0.0':<10} {t.source:<10} {desc:<35}")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error listing templates: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
def cmd_search(self, args: List[str]) -> int:
|
||||
"""Search templates by query."""
|
||||
if not args or args[0].startswith("--"):
|
||||
print("Usage: praisonai templates search <query>")
|
||||
return 1
|
||||
|
||||
query = args[0]
|
||||
offline = "--offline" in args
|
||||
|
||||
try:
|
||||
from praisonai.templates import search_templates
|
||||
templates = search_templates(query, offline=offline)
|
||||
|
||||
if not templates:
|
||||
print(f"No templates found matching '{query}'")
|
||||
return 0
|
||||
|
||||
print(f"Found {len(templates)} template(s) matching '{query}':\n")
|
||||
for t in templates:
|
||||
print(f" • {t.name} (v{t.version})")
|
||||
if t.description:
|
||||
print(f" {t.description}")
|
||||
if t.tags:
|
||||
print(f" Tags: {', '.join(t.tags)}")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error searching templates: {e}")
|
||||
return 1
|
||||
|
||||
def cmd_info(self, args: List[str]) -> int:
|
||||
"""Show template details with custom directory support."""
|
||||
if not args or args[0].startswith("--"):
|
||||
print("Usage: praisonai templates info <template>")
|
||||
return 1
|
||||
|
||||
name_or_uri = args[0]
|
||||
offline = "--offline" in args
|
||||
custom_dirs = []
|
||||
|
||||
# Parse --custom-dir
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--custom-dir" and i + 1 < len(args):
|
||||
custom_dirs.append(args[i + 1])
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
try:
|
||||
# First try to find in custom/local directories
|
||||
from praisonai.templates.discovery import TemplateDiscovery
|
||||
|
||||
discovery = TemplateDiscovery(
|
||||
custom_dirs=custom_dirs if custom_dirs else None,
|
||||
include_package=True,
|
||||
include_defaults=True
|
||||
)
|
||||
|
||||
discovered = discovery.find_template(name_or_uri)
|
||||
|
||||
if discovered:
|
||||
# Load from discovered path
|
||||
from praisonai.templates import load_template
|
||||
template = load_template(str(discovered.path), offline=offline)
|
||||
else:
|
||||
# Fall back to URI resolution
|
||||
from praisonai.templates import load_template
|
||||
template = load_template(name_or_uri, offline=offline)
|
||||
|
||||
# Check dependency availability
|
||||
from praisonai.templates.dependency_checker import DependencyChecker
|
||||
checker = DependencyChecker()
|
||||
deps = checker.check_template_dependencies(template)
|
||||
|
||||
# Build availability strings
|
||||
def format_dep_list(items, key="available"):
|
||||
result = []
|
||||
for item in items:
|
||||
status = "✓" if item.get(key, False) else "✗"
|
||||
hint = ""
|
||||
if not item.get(key, False):
|
||||
if item.get("install_hint"):
|
||||
hint = f" ({item['install_hint']})"
|
||||
result.append(f"{status} {item['name']}{hint}")
|
||||
return result
|
||||
|
||||
tools_status = format_dep_list(deps["tools"])
|
||||
pkgs_status = format_dep_list(deps["packages"])
|
||||
env_status = format_dep_list(deps["env"])
|
||||
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.markdown import Markdown
|
||||
|
||||
console = Console()
|
||||
|
||||
info = f"""
|
||||
**Name:** {template.name}
|
||||
**Version:** {template.version}
|
||||
**Author:** {template.author or 'Unknown'}
|
||||
**License:** {template.license or 'Not specified'}
|
||||
|
||||
**Description:**
|
||||
{template.description}
|
||||
|
||||
**Tags:** {', '.join(template.tags) if template.tags else 'None'}
|
||||
|
||||
**Skills:** {', '.join(template.skills) if template.skills else 'None'}
|
||||
|
||||
**Path:** {template.path}
|
||||
"""
|
||||
console.print(Panel(Markdown(info), title=f"Template: {template.name}"))
|
||||
|
||||
# Print dependency status
|
||||
all_ok = "✓" if deps["all_satisfied"] else "✗"
|
||||
console.print(f"\n[bold]Dependencies Status:[/bold] {all_ok}")
|
||||
|
||||
if tools_status:
|
||||
console.print("\n[bold]Required Tools:[/bold]")
|
||||
for t in tools_status:
|
||||
color = "green" if t.startswith("✓") else "red"
|
||||
console.print(f" [{color}]{t}[/{color}]")
|
||||
|
||||
if pkgs_status:
|
||||
console.print("\n[bold]Required Packages:[/bold]")
|
||||
for p in pkgs_status:
|
||||
color = "green" if p.startswith("✓") else "red"
|
||||
console.print(f" [{color}]{p}[/{color}]")
|
||||
|
||||
if env_status:
|
||||
console.print("\n[bold]Required Environment:[/bold]")
|
||||
for e in env_status:
|
||||
color = "green" if e.startswith("✓") else "red"
|
||||
console.print(f" [{color}]{e}[/{color}]")
|
||||
|
||||
# Print install hints if any missing
|
||||
if not deps["all_satisfied"]:
|
||||
hints = checker.get_install_hints(template)
|
||||
if hints:
|
||||
console.print("\n[bold yellow]To fix missing dependencies:[/bold yellow]")
|
||||
for hint in hints:
|
||||
console.print(f" • {hint}")
|
||||
|
||||
except ImportError:
|
||||
print(f"Template: {template.name}")
|
||||
print(f"Version: {template.version}")
|
||||
print(f"Author: {template.author or 'Unknown'}")
|
||||
print(f"Description: {template.description}")
|
||||
print(f"Path: {template.path}")
|
||||
print(f"\nDependencies: {'All satisfied' if deps['all_satisfied'] else 'Some missing'}")
|
||||
if tools_status:
|
||||
print("\nRequired Tools:")
|
||||
for t in tools_status:
|
||||
print(f" {t}")
|
||||
if pkgs_status:
|
||||
print("\nRequired Packages:")
|
||||
for p in pkgs_status:
|
||||
print(f" {p}")
|
||||
if env_status:
|
||||
print("\nRequired Environment:")
|
||||
for e in env_status:
|
||||
print(f" {e}")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading template info: {e}")
|
||||
return 1
|
||||
|
||||
def cmd_install(self, args: List[str]) -> int:
|
||||
"""Install a template to cache."""
|
||||
if not args or args[0].startswith("--"):
|
||||
print("Usage: praisonai templates install <uri>")
|
||||
return 1
|
||||
|
||||
uri = args[0]
|
||||
|
||||
try:
|
||||
from praisonai.templates import install_template
|
||||
|
||||
print(f"Installing template: {uri}")
|
||||
cached = install_template(uri)
|
||||
print(f"✓ Template installed to: {cached.path}")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error installing template: {e}")
|
||||
return 1
|
||||
|
||||
def cmd_cache(self, args: List[str]) -> int:
|
||||
"""Cache management commands."""
|
||||
if not args:
|
||||
print("Usage: praisonai templates cache <clear|list|size>")
|
||||
return 1
|
||||
|
||||
subcmd = args[0]
|
||||
|
||||
if subcmd == "clear":
|
||||
source = None
|
||||
if len(args) > 1:
|
||||
source = args[1]
|
||||
|
||||
try:
|
||||
from praisonai.templates import clear_cache
|
||||
count = clear_cache(source)
|
||||
print(f"✓ Cleared {count} cached template(s)")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error clearing cache: {e}")
|
||||
return 1
|
||||
|
||||
elif subcmd == "list":
|
||||
try:
|
||||
cached = self.cache.list_cached()
|
||||
if not cached:
|
||||
print("No cached templates.")
|
||||
return 0
|
||||
|
||||
print(f"Cached templates ({len(cached)}):\n")
|
||||
for path, meta in cached:
|
||||
status = "pinned" if meta.is_pinned else "expires in " + str(int(meta.ttl_seconds - (import_time() - meta.fetched_at))) + "s"
|
||||
print(f" • {path.name}")
|
||||
print(f" Path: {path}")
|
||||
print(f" Status: {status}")
|
||||
print()
|
||||
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error listing cache: {e}")
|
||||
return 1
|
||||
|
||||
elif subcmd == "size":
|
||||
size = self.cache.get_cache_size()
|
||||
print(f"Cache size: {size / 1024 / 1024:.2f} MB")
|
||||
return 0
|
||||
|
||||
else:
|
||||
print(f"Unknown cache command: {subcmd}")
|
||||
return 1
|
||||
|
||||
def cmd_run(self, args: List[str]) -> int:
|
||||
"""Run a template directly."""
|
||||
if not args or args[0].startswith("--"):
|
||||
print("Usage: praisonai templates run <template> [args...] [--strict-tools] [--offline]")
|
||||
return 1
|
||||
|
||||
uri = args[0]
|
||||
template_args = args[1:]
|
||||
offline = "--offline" in args
|
||||
strict_tools = "--strict-tools" in args
|
||||
|
||||
# Parse --tools override
|
||||
tools_files = []
|
||||
tools_dirs = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--tools" and i + 1 < len(args):
|
||||
tools_files.append(args[i + 1])
|
||||
i += 2
|
||||
elif args[i] == "--tools-dir" and i + 1 < len(args):
|
||||
tools_dirs.append(args[i + 1])
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
try:
|
||||
from praisonai.templates.loader import TemplateLoader
|
||||
from praisonai.templates.discovery import TemplateDiscovery
|
||||
|
||||
# First try to find in custom/local directories
|
||||
discovery = TemplateDiscovery(
|
||||
custom_dirs=tools_dirs if tools_dirs else None,
|
||||
include_package=True,
|
||||
include_defaults=True
|
||||
)
|
||||
|
||||
discovered = discovery.find_template(uri)
|
||||
|
||||
loader = TemplateLoader(offline=offline)
|
||||
|
||||
if discovered:
|
||||
# Load from discovered path
|
||||
template = loader.load(str(discovered.path), offline=offline)
|
||||
else:
|
||||
# Fall back to URI resolution
|
||||
template = loader.load(uri, offline=offline)
|
||||
|
||||
# Strict mode: fail-fast on missing dependencies
|
||||
if strict_tools:
|
||||
from praisonai.templates.dependency_checker import DependencyChecker, StrictModeError
|
||||
checker = DependencyChecker()
|
||||
try:
|
||||
checker.enforce_strict_mode(template)
|
||||
print("✓ All dependencies satisfied (strict mode)")
|
||||
except StrictModeError as e:
|
||||
print(f"✗ Strict mode check failed:\n{e}")
|
||||
return 1
|
||||
else:
|
||||
# Non-strict: warn but continue
|
||||
missing = loader.check_requirements(template)
|
||||
if missing["missing_packages"]:
|
||||
print(f"Warning: Missing packages: {', '.join(missing['missing_packages'])}")
|
||||
print("Install with: pip install " + " ".join(missing["missing_packages"]))
|
||||
if missing["missing_env"]:
|
||||
print(f"Warning: Missing environment variables: {', '.join(missing['missing_env'])}")
|
||||
|
||||
# Load tool overrides if specified
|
||||
tool_registry = None
|
||||
if tools_files or tools_dirs:
|
||||
from praisonai.templates.tool_override import create_tool_registry_with_overrides
|
||||
tool_registry = create_tool_registry_with_overrides(
|
||||
override_files=tools_files,
|
||||
override_dirs=tools_dirs,
|
||||
include_defaults=True
|
||||
)
|
||||
print(f"✓ Loaded {len(tool_registry)} tools from overrides")
|
||||
|
||||
# Load and run workflow
|
||||
workflow_config = loader.load_workflow_config(template)
|
||||
|
||||
# Parse template args into config
|
||||
config = self._parse_template_args(template_args, template)
|
||||
|
||||
# Merge config
|
||||
if config:
|
||||
workflow_config = {**workflow_config, **config}
|
||||
|
||||
# Run workflow - determine which class to use based on config structure
|
||||
if "agents" in workflow_config and "tasks" in workflow_config:
|
||||
# PraisonAIAgents format (agents + tasks)
|
||||
from praisonaiagents import Agent, Task, PraisonAIAgents
|
||||
|
||||
# Build agents
|
||||
agents_config = workflow_config.get("agents", [])
|
||||
agents = []
|
||||
agent_map = {}
|
||||
|
||||
for agent_cfg in agents_config:
|
||||
agent = Agent(
|
||||
name=agent_cfg.get("name", "Agent"),
|
||||
role=agent_cfg.get("role", ""),
|
||||
goal=agent_cfg.get("goal", ""),
|
||||
backstory=agent_cfg.get("backstory", ""),
|
||||
tools=agent_cfg.get("tools", []),
|
||||
llm=agent_cfg.get("llm"),
|
||||
verbose=agent_cfg.get("verbose", True)
|
||||
)
|
||||
agents.append(agent)
|
||||
agent_map[agent_cfg.get("name", "Agent")] = agent
|
||||
|
||||
# Build tasks
|
||||
tasks_config = workflow_config.get("tasks", [])
|
||||
tasks = []
|
||||
|
||||
for task_cfg in tasks_config:
|
||||
agent_name = task_cfg.get("agent", "")
|
||||
agent = agent_map.get(agent_name, agents[0] if agents else None)
|
||||
|
||||
task = Task(
|
||||
name=task_cfg.get("name", "Task"),
|
||||
description=task_cfg.get("description", ""),
|
||||
expected_output=task_cfg.get("expected_output", ""),
|
||||
agent=agent
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
# Run
|
||||
praison_agents = PraisonAIAgents(
|
||||
agents=agents,
|
||||
tasks=tasks,
|
||||
process=workflow_config.get("process", "sequential"),
|
||||
verbose=workflow_config.get("verbose", 1)
|
||||
)
|
||||
praison_agents.start()
|
||||
elif "steps" in workflow_config:
|
||||
# Workflow format (steps)
|
||||
from praisonaiagents import Workflow
|
||||
workflow = Workflow(**workflow_config)
|
||||
workflow.run()
|
||||
else:
|
||||
raise ValueError("Invalid workflow config: must have 'agents'+'tasks' or 'steps'")
|
||||
|
||||
print(f"\n✓ Template '{template.name}' completed successfully")
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error running template: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
def cmd_init(self, args: List[str]) -> int:
|
||||
"""Initialize a project from template."""
|
||||
if not args or args[0].startswith("--"):
|
||||
print("Usage: praisonai templates init <project-name> --template <template>")
|
||||
return 1
|
||||
|
||||
project_name = args[0]
|
||||
template_uri = None
|
||||
offline = "--offline" in args
|
||||
|
||||
if "--template" in args:
|
||||
idx = args.index("--template")
|
||||
if idx + 1 < len(args):
|
||||
template_uri = args[idx + 1]
|
||||
|
||||
if not template_uri:
|
||||
print("Error: --template is required")
|
||||
return 1
|
||||
|
||||
try:
|
||||
from praisonai.templates import load_template
|
||||
|
||||
template = load_template(template_uri, offline=offline)
|
||||
|
||||
# Create project directory
|
||||
project_dir = Path(project_name)
|
||||
if project_dir.exists():
|
||||
print(f"Error: Directory '{project_name}' already exists")
|
||||
return 1
|
||||
|
||||
project_dir.mkdir(parents=True)
|
||||
|
||||
# Copy template files
|
||||
for item in template.path.iterdir():
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
if item.is_file():
|
||||
shutil.copy2(item, project_dir / item.name)
|
||||
elif item.is_dir():
|
||||
shutil.copytree(item, project_dir / item.name)
|
||||
|
||||
print(f"✓ Created project '{project_name}' from template '{template.name}'")
|
||||
print("Next steps:")
|
||||
print(f" cd {project_name}")
|
||||
print(" praisonai run workflow.yaml")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error initializing project: {e}")
|
||||
return 1
|
||||
|
||||
def _parse_template_args(
|
||||
self,
|
||||
args: List[str],
|
||||
template
|
||||
) -> Dict[str, Any]:
|
||||
"""Parse template-specific arguments."""
|
||||
config = {}
|
||||
|
||||
# Handle positional args based on CLI config
|
||||
cli_config = template.cli
|
||||
if cli_config and "args" in cli_config:
|
||||
positional_idx = 0
|
||||
for arg_def in cli_config["args"]:
|
||||
if arg_def.get("positional"):
|
||||
if positional_idx < len(args) and not args[positional_idx].startswith("--"):
|
||||
config[arg_def["name"]] = args[positional_idx]
|
||||
positional_idx += 1
|
||||
|
||||
# Handle named args
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i].startswith("--"):
|
||||
key = args[i][2:].replace("-", "_")
|
||||
if i + 1 < len(args) and not args[i + 1].startswith("--"):
|
||||
config[key] = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
config[key] = True
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def import_time():
|
||||
"""Get current time (helper for cache expiry display)."""
|
||||
import time
|
||||
return time.time()
|
||||
|
||||
|
||||
# Convenience function for CLI integration
|
||||
def handle_templates_command(args: List[str]) -> int:
|
||||
"""Handle templates command from main CLI."""
|
||||
handler = TemplatesHandler()
|
||||
return handler.handle(args)
|
||||
@@ -31,7 +31,7 @@ class ToolsHandler(CommandHandler):
|
||||
return "tools"
|
||||
|
||||
def get_actions(self) -> List[str]:
|
||||
return ["list", "info", "search", "help"]
|
||||
return ["list", "info", "search", "doctor", "help"]
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
return """
|
||||
@@ -39,6 +39,8 @@ Tools Commands:
|
||||
praisonai tools list - List all available tools
|
||||
praisonai tools info <name> - Show tool details
|
||||
praisonai tools search <query> - Search tools by name/description
|
||||
praisonai tools doctor - Diagnose tool availability and dependencies
|
||||
praisonai tools doctor --json - Output diagnosis as JSON
|
||||
|
||||
Built-in tools include: internet_search, calculator, file operations, etc.
|
||||
"""
|
||||
@@ -213,6 +215,34 @@ Built-in tools include: internet_search, calculator, file operations, etc.
|
||||
|
||||
return matches
|
||||
|
||||
def action_doctor(self, args: List[str], **kwargs) -> Dict[str, Any]:
|
||||
"""
|
||||
Diagnose tool availability and dependencies.
|
||||
|
||||
Args:
|
||||
args: List containing optional flags (--json)
|
||||
|
||||
Returns:
|
||||
Dict with diagnostic results
|
||||
"""
|
||||
json_output = "--json" in args
|
||||
|
||||
try:
|
||||
from praisonai.templates.tools_doctor import ToolsDoctor
|
||||
|
||||
doctor = ToolsDoctor()
|
||||
|
||||
if json_output:
|
||||
print(doctor.diagnose_json())
|
||||
else:
|
||||
print(doctor.diagnose_human())
|
||||
|
||||
return doctor.diagnose()
|
||||
|
||||
except Exception as e:
|
||||
self.print_status(f"Error running diagnostics: {e}", "error")
|
||||
return {}
|
||||
|
||||
def execute(self, action: str, action_args: List[str], **kwargs) -> Any:
|
||||
"""Execute tools command action."""
|
||||
return super().execute(action, action_args, **kwargs)
|
||||
@@ -1149,6 +1149,12 @@ class PraisonAI:
|
||||
exit_code = handle_eval_command(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'templates':
|
||||
# Templates command - manage and run templates/recipes
|
||||
from .features.templates import handle_templates_command
|
||||
exit_code = handle_templates_command(unknown_args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
elif args.command == 'agents':
|
||||
# Agents command - run multiple agents with custom definitions
|
||||
if not PRAISONAI_AVAILABLE:
|
||||
|
||||
@@ -57,7 +57,7 @@ class CloudDeployer:
|
||||
file.write("FROM python:3.11-slim\n")
|
||||
file.write("WORKDIR /app\n")
|
||||
file.write("COPY . .\n")
|
||||
file.write("RUN pip install flask praisonai==2.5.7 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==2.6.2 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
PraisonAI Templates Module
|
||||
|
||||
Provides template loading, caching, and resolution for Agent/Workflow configurations.
|
||||
All imports are lazy to ensure zero performance impact when not used.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"TemplateLoader",
|
||||
"TemplateResolver",
|
||||
"TemplateCache",
|
||||
"TemplateRegistry",
|
||||
"TemplateSecurity",
|
||||
"TemplateDiscovery",
|
||||
"DiscoveredTemplate",
|
||||
"DependencyChecker",
|
||||
"StrictModeError",
|
||||
"ToolsDoctor",
|
||||
"ToolOverrideLoader",
|
||||
"SecurityError",
|
||||
"load_template",
|
||||
"list_templates",
|
||||
"search_templates",
|
||||
"install_template",
|
||||
"clear_cache",
|
||||
"discover_templates",
|
||||
"find_template_path",
|
||||
]
|
||||
|
||||
# Lazy loading implementation
|
||||
_module_cache = {}
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy load template components to avoid import overhead."""
|
||||
if name in _module_cache:
|
||||
return _module_cache[name]
|
||||
|
||||
if name == "TemplateLoader":
|
||||
from .loader import TemplateLoader
|
||||
_module_cache[name] = TemplateLoader
|
||||
return TemplateLoader
|
||||
elif name == "TemplateResolver":
|
||||
from .resolver import TemplateResolver
|
||||
_module_cache[name] = TemplateResolver
|
||||
return TemplateResolver
|
||||
elif name == "TemplateCache":
|
||||
from .cache import TemplateCache
|
||||
_module_cache[name] = TemplateCache
|
||||
return TemplateCache
|
||||
elif name == "TemplateRegistry":
|
||||
from .registry import TemplateRegistry
|
||||
_module_cache[name] = TemplateRegistry
|
||||
return TemplateRegistry
|
||||
elif name == "TemplateSecurity":
|
||||
from .security import TemplateSecurity
|
||||
_module_cache[name] = TemplateSecurity
|
||||
return TemplateSecurity
|
||||
elif name == "load_template":
|
||||
from .loader import load_template
|
||||
_module_cache[name] = load_template
|
||||
return load_template
|
||||
elif name == "list_templates":
|
||||
from .registry import list_templates
|
||||
_module_cache[name] = list_templates
|
||||
return list_templates
|
||||
elif name == "search_templates":
|
||||
from .registry import search_templates
|
||||
_module_cache[name] = search_templates
|
||||
return search_templates
|
||||
elif name == "install_template":
|
||||
from .registry import install_template
|
||||
_module_cache[name] = install_template
|
||||
return install_template
|
||||
elif name == "clear_cache":
|
||||
from .cache import clear_cache
|
||||
_module_cache[name] = clear_cache
|
||||
return clear_cache
|
||||
elif name == "TemplateDiscovery":
|
||||
from .discovery import TemplateDiscovery
|
||||
_module_cache[name] = TemplateDiscovery
|
||||
return TemplateDiscovery
|
||||
elif name == "DiscoveredTemplate":
|
||||
from .discovery import DiscoveredTemplate
|
||||
_module_cache[name] = DiscoveredTemplate
|
||||
return DiscoveredTemplate
|
||||
elif name == "discover_templates":
|
||||
from .discovery import discover_templates
|
||||
_module_cache[name] = discover_templates
|
||||
return discover_templates
|
||||
elif name == "find_template_path":
|
||||
from .discovery import find_template_path
|
||||
_module_cache[name] = find_template_path
|
||||
return find_template_path
|
||||
elif name == "DependencyChecker":
|
||||
from .dependency_checker import DependencyChecker
|
||||
_module_cache[name] = DependencyChecker
|
||||
return DependencyChecker
|
||||
elif name == "StrictModeError":
|
||||
from .dependency_checker import StrictModeError
|
||||
_module_cache[name] = StrictModeError
|
||||
return StrictModeError
|
||||
elif name == "ToolsDoctor":
|
||||
from .tools_doctor import ToolsDoctor
|
||||
_module_cache[name] = ToolsDoctor
|
||||
return ToolsDoctor
|
||||
elif name == "ToolOverrideLoader":
|
||||
from .tool_override import ToolOverrideLoader
|
||||
_module_cache[name] = ToolOverrideLoader
|
||||
return ToolOverrideLoader
|
||||
elif name == "SecurityError":
|
||||
from .tool_override import SecurityError
|
||||
_module_cache[name] = SecurityError
|
||||
return SecurityError
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
Template Cache
|
||||
|
||||
Disk-based caching for templates with TTL support.
|
||||
Pinned versions are cached indefinitely, 'latest' has configurable TTL.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .resolver import ResolvedTemplate, TemplateSource
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheMetadata:
|
||||
"""Metadata for a cached template."""
|
||||
fetched_at: float
|
||||
etag: Optional[str] = None
|
||||
sha256: Optional[str] = None
|
||||
ttl_seconds: int = 86400 # 24 hours default
|
||||
source_url: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
is_pinned: bool = False
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the cache entry has expired."""
|
||||
if self.is_pinned:
|
||||
return False # Pinned versions never expire
|
||||
return time.time() > (self.fetched_at + self.ttl_seconds)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"fetched_at": self.fetched_at,
|
||||
"etag": self.etag,
|
||||
"sha256": self.sha256,
|
||||
"ttl_seconds": self.ttl_seconds,
|
||||
"source_url": self.source_url,
|
||||
"version": self.version,
|
||||
"is_pinned": self.is_pinned,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "CacheMetadata":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
fetched_at=data.get("fetched_at", 0),
|
||||
etag=data.get("etag"),
|
||||
sha256=data.get("sha256"),
|
||||
ttl_seconds=data.get("ttl_seconds", 86400),
|
||||
source_url=data.get("source_url"),
|
||||
version=data.get("version"),
|
||||
is_pinned=data.get("is_pinned", False),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CachedTemplate:
|
||||
"""A cached template with its metadata."""
|
||||
path: Path
|
||||
metadata: CacheMetadata
|
||||
config: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class TemplateCache:
|
||||
"""
|
||||
Disk-based template cache with TTL support.
|
||||
|
||||
Cache structure:
|
||||
~/.praison/cache/templates/
|
||||
├── github/
|
||||
│ └── owner/
|
||||
│ └── repo/
|
||||
│ └── template-name/
|
||||
│ └── ref/
|
||||
│ ├── TEMPLATE.yaml
|
||||
│ ├── workflow.yaml
|
||||
│ └── .cache_meta.json
|
||||
├── package/
|
||||
│ └── package_name/
|
||||
│ └── template-name/
|
||||
└── http/
|
||||
└── hash-of-url/
|
||||
"""
|
||||
|
||||
DEFAULT_CACHE_DIR = Path.home() / ".praison" / "cache" / "templates"
|
||||
DEFAULT_TTL = 86400 # 24 hours
|
||||
METADATA_FILE = ".cache_meta.json"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache_dir: Optional[Path] = None,
|
||||
default_ttl: int = DEFAULT_TTL
|
||||
):
|
||||
"""
|
||||
Initialize the template cache.
|
||||
|
||||
Args:
|
||||
cache_dir: Custom cache directory (default: ~/.praison/cache/templates)
|
||||
default_ttl: Default TTL in seconds for non-pinned templates
|
||||
"""
|
||||
self.cache_dir = cache_dir or self.DEFAULT_CACHE_DIR
|
||||
self.default_ttl = default_ttl
|
||||
self._ensure_cache_dir()
|
||||
|
||||
def _ensure_cache_dir(self) -> None:
|
||||
"""Ensure the cache directory exists."""
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_cache_path(self, resolved: ResolvedTemplate) -> Path:
|
||||
"""Get the cache path for a resolved template."""
|
||||
if resolved.source == TemplateSource.LOCAL:
|
||||
# Local templates are not cached
|
||||
return Path(resolved.path)
|
||||
|
||||
elif resolved.source == TemplateSource.PACKAGE:
|
||||
# package/package_name/template
|
||||
return self.cache_dir / "package" / resolved.path
|
||||
|
||||
elif resolved.source == TemplateSource.GITHUB:
|
||||
# github/owner/repo/template/ref
|
||||
ref = resolved.ref or "main"
|
||||
return (
|
||||
self.cache_dir / "github" /
|
||||
resolved.owner / resolved.repo /
|
||||
resolved.path / ref
|
||||
)
|
||||
|
||||
elif resolved.source == TemplateSource.HTTP:
|
||||
# http/hash-of-url
|
||||
url_hash = hashlib.sha256(resolved.url.encode()).hexdigest()[:16]
|
||||
return self.cache_dir / "http" / url_hash
|
||||
|
||||
raise ValueError(f"Unknown template source: {resolved.source}")
|
||||
|
||||
def get(
|
||||
self,
|
||||
resolved: ResolvedTemplate,
|
||||
offline: bool = False
|
||||
) -> Optional[CachedTemplate]:
|
||||
"""
|
||||
Get a cached template if it exists and is valid.
|
||||
|
||||
Args:
|
||||
resolved: Resolved template reference
|
||||
offline: If True, return cached version even if expired
|
||||
|
||||
Returns:
|
||||
CachedTemplate if found and valid, None otherwise
|
||||
"""
|
||||
if resolved.source == TemplateSource.LOCAL:
|
||||
# Local templates are not cached, return path directly
|
||||
path = Path(resolved.path)
|
||||
if path.exists():
|
||||
return CachedTemplate(
|
||||
path=path,
|
||||
metadata=CacheMetadata(
|
||||
fetched_at=time.time(),
|
||||
is_pinned=True
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
cache_path = self._get_cache_path(resolved)
|
||||
meta_path = cache_path / self.METADATA_FILE
|
||||
|
||||
if not cache_path.exists() or not meta_path.exists():
|
||||
return None
|
||||
|
||||
# Load metadata
|
||||
try:
|
||||
with open(meta_path, "r") as f:
|
||||
metadata = CacheMetadata.from_dict(json.load(f))
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return None
|
||||
|
||||
# Check expiration (unless offline mode)
|
||||
if not offline and metadata.is_expired():
|
||||
return None
|
||||
|
||||
return CachedTemplate(
|
||||
path=cache_path,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def put(
|
||||
self,
|
||||
resolved: ResolvedTemplate,
|
||||
content_dir: Path,
|
||||
etag: Optional[str] = None,
|
||||
sha256: Optional[str] = None
|
||||
) -> CachedTemplate:
|
||||
"""
|
||||
Store a template in the cache.
|
||||
|
||||
Args:
|
||||
resolved: Resolved template reference
|
||||
content_dir: Directory containing template files to cache
|
||||
etag: Optional ETag for cache validation
|
||||
sha256: Optional SHA256 checksum
|
||||
|
||||
Returns:
|
||||
CachedTemplate with the cached location
|
||||
"""
|
||||
if resolved.source == TemplateSource.LOCAL:
|
||||
# Don't cache local templates
|
||||
return CachedTemplate(
|
||||
path=Path(resolved.path),
|
||||
metadata=CacheMetadata(
|
||||
fetched_at=time.time(),
|
||||
is_pinned=True
|
||||
)
|
||||
)
|
||||
|
||||
cache_path = self._get_cache_path(resolved)
|
||||
|
||||
# Remove existing cache entry
|
||||
if cache_path.exists():
|
||||
shutil.rmtree(cache_path)
|
||||
|
||||
# Copy content to cache
|
||||
cache_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if content_dir.is_dir():
|
||||
for item in content_dir.iterdir():
|
||||
if item.is_file():
|
||||
shutil.copy2(item, cache_path / item.name)
|
||||
elif item.is_dir():
|
||||
shutil.copytree(item, cache_path / item.name)
|
||||
else:
|
||||
# Single file
|
||||
shutil.copy2(content_dir, cache_path / content_dir.name)
|
||||
|
||||
# Create metadata
|
||||
metadata = CacheMetadata(
|
||||
fetched_at=time.time(),
|
||||
etag=etag,
|
||||
sha256=sha256,
|
||||
ttl_seconds=self.default_ttl,
|
||||
source_url=resolved.url if resolved.source == TemplateSource.HTTP else None,
|
||||
version=resolved.ref,
|
||||
is_pinned=resolved.is_pinned
|
||||
)
|
||||
|
||||
# Save metadata
|
||||
meta_path = cache_path / self.METADATA_FILE
|
||||
with open(meta_path, "w") as f:
|
||||
json.dump(metadata.to_dict(), f, indent=2)
|
||||
|
||||
return CachedTemplate(
|
||||
path=cache_path,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def invalidate(self, resolved: ResolvedTemplate) -> bool:
|
||||
"""
|
||||
Invalidate a cached template.
|
||||
|
||||
Args:
|
||||
resolved: Resolved template reference
|
||||
|
||||
Returns:
|
||||
True if cache was invalidated, False if not found
|
||||
"""
|
||||
cache_path = self._get_cache_path(resolved)
|
||||
if cache_path.exists():
|
||||
shutil.rmtree(cache_path)
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear(self, source: Optional[TemplateSource] = None) -> int:
|
||||
"""
|
||||
Clear the cache.
|
||||
|
||||
Args:
|
||||
source: If specified, only clear templates from this source
|
||||
|
||||
Returns:
|
||||
Number of templates cleared
|
||||
"""
|
||||
count = 0
|
||||
|
||||
if source is None:
|
||||
# Clear everything
|
||||
if self.cache_dir.exists():
|
||||
for item in self.cache_dir.iterdir():
|
||||
if item.is_dir():
|
||||
count += sum(1 for _ in item.rglob(self.METADATA_FILE))
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
# Clear specific source
|
||||
source_dir = self.cache_dir / source.value
|
||||
if source_dir.exists():
|
||||
count = sum(1 for _ in source_dir.rglob(self.METADATA_FILE))
|
||||
shutil.rmtree(source_dir)
|
||||
|
||||
return count
|
||||
|
||||
def list_cached(
|
||||
self,
|
||||
source: Optional[TemplateSource] = None
|
||||
) -> list:
|
||||
"""
|
||||
List all cached templates.
|
||||
|
||||
Args:
|
||||
source: If specified, only list templates from this source
|
||||
|
||||
Returns:
|
||||
List of (cache_path, metadata) tuples
|
||||
"""
|
||||
results = []
|
||||
|
||||
search_dirs = []
|
||||
if source is None:
|
||||
search_dirs = [
|
||||
self.cache_dir / s.value
|
||||
for s in TemplateSource
|
||||
if s != TemplateSource.LOCAL
|
||||
]
|
||||
else:
|
||||
search_dirs = [self.cache_dir / source.value]
|
||||
|
||||
for search_dir in search_dirs:
|
||||
if not search_dir.exists():
|
||||
continue
|
||||
|
||||
for meta_path in search_dir.rglob(self.METADATA_FILE):
|
||||
try:
|
||||
with open(meta_path, "r") as f:
|
||||
metadata = CacheMetadata.from_dict(json.load(f))
|
||||
results.append((meta_path.parent, metadata))
|
||||
except (json.JSONDecodeError, IOError):
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
def get_cache_size(self) -> int:
|
||||
"""Get total cache size in bytes."""
|
||||
if not self.cache_dir.exists():
|
||||
return 0
|
||||
return sum(f.stat().st_size for f in self.cache_dir.rglob("*") if f.is_file())
|
||||
|
||||
|
||||
def clear_cache(source: Optional[str] = None) -> int:
|
||||
"""
|
||||
Convenience function to clear the template cache.
|
||||
|
||||
Args:
|
||||
source: Optional source type ('github', 'package', 'http')
|
||||
|
||||
Returns:
|
||||
Number of templates cleared
|
||||
"""
|
||||
cache = TemplateCache()
|
||||
source_enum = None
|
||||
if source:
|
||||
source_enum = TemplateSource(source)
|
||||
return cache.clear(source_enum)
|
||||
@@ -0,0 +1,358 @@
|
||||
"""
|
||||
Dependency Checker for Templates.
|
||||
|
||||
Checks availability of tools, packages, and environment variables
|
||||
required by templates. Supports strict mode for fail-fast behavior.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .loader import TemplateConfig
|
||||
|
||||
|
||||
class StrictModeError(Exception):
|
||||
"""Raised when strict mode detects missing dependencies."""
|
||||
|
||||
def __init__(self, message: str, missing_items: Dict[str, List[str]]):
|
||||
super().__init__(message)
|
||||
self.missing_items = missing_items
|
||||
|
||||
|
||||
@dataclass
|
||||
class DependencyStatus:
|
||||
"""Status of a single dependency."""
|
||||
name: str
|
||||
available: bool
|
||||
source: Optional[str] = None
|
||||
install_hint: Optional[str] = None
|
||||
masked_value: Optional[str] = None
|
||||
|
||||
|
||||
class DependencyChecker:
|
||||
"""
|
||||
Checks availability of template dependencies.
|
||||
|
||||
Supports checking:
|
||||
- Tools (from built-in registry, praisonai-tools, custom dirs)
|
||||
- Packages (via importlib.util.find_spec)
|
||||
- Environment variables (via os.environ)
|
||||
"""
|
||||
|
||||
# Known tool sources for install hints
|
||||
TOOL_INSTALL_HINTS = {
|
||||
"youtube_tool": "pip install praisonai-tools[video]",
|
||||
"whisper_tool": "pip install praisonai-tools[audio]",
|
||||
"vision_tool": "pip install praisonai-tools[vision]",
|
||||
"tavily_search": "pip install tavily-python",
|
||||
}
|
||||
|
||||
def __init__(self, custom_tool_dirs: Optional[List[str]] = None):
|
||||
"""
|
||||
Initialize dependency checker.
|
||||
|
||||
Args:
|
||||
custom_tool_dirs: Additional directories to search for tools
|
||||
"""
|
||||
self._custom_tool_dirs = custom_tool_dirs or []
|
||||
self._tool_cache: Dict[str, DependencyStatus] = {}
|
||||
|
||||
def check_tool(self, tool_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Check if a tool is available.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to check
|
||||
|
||||
Returns:
|
||||
Dict with 'available', 'source', and 'install_hint' keys
|
||||
"""
|
||||
# Check cache first
|
||||
if tool_name in self._tool_cache:
|
||||
status = self._tool_cache[tool_name]
|
||||
return {
|
||||
"available": status.available,
|
||||
"source": status.source,
|
||||
"install_hint": status.install_hint
|
||||
}
|
||||
|
||||
# Check built-in tools
|
||||
source = self._check_builtin_tool(tool_name)
|
||||
if source:
|
||||
status = DependencyStatus(
|
||||
name=tool_name,
|
||||
available=True,
|
||||
source=source
|
||||
)
|
||||
self._tool_cache[tool_name] = status
|
||||
return {"available": True, "source": source, "install_hint": None}
|
||||
|
||||
# Check praisonai-tools
|
||||
source = self._check_praisonai_tools(tool_name)
|
||||
if source:
|
||||
status = DependencyStatus(
|
||||
name=tool_name,
|
||||
available=True,
|
||||
source=source
|
||||
)
|
||||
self._tool_cache[tool_name] = status
|
||||
return {"available": True, "source": source, "install_hint": None}
|
||||
|
||||
# Check custom directories
|
||||
source = self._check_custom_dirs(tool_name)
|
||||
if source:
|
||||
status = DependencyStatus(
|
||||
name=tool_name,
|
||||
available=True,
|
||||
source=source
|
||||
)
|
||||
self._tool_cache[tool_name] = status
|
||||
return {"available": True, "source": source, "install_hint": None}
|
||||
|
||||
# Tool not found
|
||||
install_hint = self.TOOL_INSTALL_HINTS.get(tool_name)
|
||||
if not install_hint:
|
||||
install_hint = "Check praisonai-tools or define custom tool"
|
||||
|
||||
status = DependencyStatus(
|
||||
name=tool_name,
|
||||
available=False,
|
||||
source=None,
|
||||
install_hint=install_hint
|
||||
)
|
||||
self._tool_cache[tool_name] = status
|
||||
return {"available": False, "source": None, "install_hint": install_hint}
|
||||
|
||||
def _check_builtin_tool(self, tool_name: str) -> Optional[str]:
|
||||
"""Check if tool exists in built-in registry."""
|
||||
try:
|
||||
from praisonaiagents.tools import TOOL_MAPPINGS
|
||||
if tool_name in TOOL_MAPPINGS:
|
||||
return "builtin"
|
||||
except ImportError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _check_praisonai_tools(self, tool_name: str) -> Optional[str]:
|
||||
"""Check if tool exists in praisonai-tools package."""
|
||||
try:
|
||||
import praisonai_tools
|
||||
if hasattr(praisonai_tools, tool_name):
|
||||
return "praisonai-tools"
|
||||
# Check for get_tool function
|
||||
if hasattr(praisonai_tools, 'get_tool'):
|
||||
try:
|
||||
tool = praisonai_tools.get_tool(tool_name)
|
||||
if tool:
|
||||
return "praisonai-tools"
|
||||
except Exception:
|
||||
pass
|
||||
except ImportError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _check_custom_dirs(self, tool_name: str) -> Optional[str]:
|
||||
"""Check if tool exists in custom directories."""
|
||||
for dir_path in self._custom_tool_dirs:
|
||||
path = Path(dir_path).expanduser()
|
||||
if not path.exists():
|
||||
continue
|
||||
|
||||
# Check for tool file
|
||||
tool_file = path / f"{tool_name}.py"
|
||||
if tool_file.exists():
|
||||
return f"custom:{dir_path}"
|
||||
|
||||
# Check for tool in any Python file (by function name)
|
||||
for py_file in path.glob("*.py"):
|
||||
try:
|
||||
content = py_file.read_text()
|
||||
if f"def {tool_name}" in content:
|
||||
return f"custom:{dir_path}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def check_package(self, package_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Check if a Python package is available.
|
||||
|
||||
Args:
|
||||
package_name: Name of the package to check
|
||||
|
||||
Returns:
|
||||
Dict with 'available' and 'install_hint' keys
|
||||
"""
|
||||
spec = importlib.util.find_spec(package_name)
|
||||
if spec is not None:
|
||||
return {"available": True, "install_hint": None}
|
||||
|
||||
return {
|
||||
"available": False,
|
||||
"install_hint": f"pip install {package_name}"
|
||||
}
|
||||
|
||||
def check_env_var(self, var_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Check if an environment variable is set.
|
||||
|
||||
Args:
|
||||
var_name: Name of the environment variable
|
||||
|
||||
Returns:
|
||||
Dict with 'available' and 'masked_value' keys
|
||||
"""
|
||||
value = os.environ.get(var_name)
|
||||
if value:
|
||||
# Mask the value for security
|
||||
if len(value) > 8:
|
||||
masked = value[:4] + "*" * (len(value) - 8) + value[-4:]
|
||||
else:
|
||||
masked = "*" * len(value)
|
||||
return {"available": True, "masked_value": masked}
|
||||
|
||||
return {"available": False, "masked_value": None}
|
||||
|
||||
def check_template_dependencies(
|
||||
self,
|
||||
template: "TemplateConfig"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Check all dependencies for a template.
|
||||
|
||||
Args:
|
||||
template: Template configuration to check
|
||||
|
||||
Returns:
|
||||
Dict with 'tools', 'packages', 'env', and 'all_satisfied' keys
|
||||
"""
|
||||
result = {
|
||||
"tools": [],
|
||||
"packages": [],
|
||||
"env": [],
|
||||
"all_satisfied": True
|
||||
}
|
||||
|
||||
# Check tools
|
||||
tools = template.requires.get("tools", [])
|
||||
if isinstance(tools, str):
|
||||
tools = [tools]
|
||||
|
||||
for tool_name in tools:
|
||||
status = self.check_tool(tool_name)
|
||||
result["tools"].append({
|
||||
"name": tool_name,
|
||||
**status
|
||||
})
|
||||
if not status["available"]:
|
||||
result["all_satisfied"] = False
|
||||
|
||||
# Check packages
|
||||
packages = template.requires.get("packages", [])
|
||||
if isinstance(packages, str):
|
||||
packages = [packages]
|
||||
|
||||
for pkg_name in packages:
|
||||
status = self.check_package(pkg_name)
|
||||
result["packages"].append({
|
||||
"name": pkg_name,
|
||||
**status
|
||||
})
|
||||
if not status["available"]:
|
||||
result["all_satisfied"] = False
|
||||
|
||||
# Check environment variables
|
||||
env_vars = template.requires.get("env", [])
|
||||
if isinstance(env_vars, str):
|
||||
env_vars = [env_vars]
|
||||
|
||||
for var_name in env_vars:
|
||||
status = self.check_env_var(var_name)
|
||||
result["env"].append({
|
||||
"name": var_name,
|
||||
**status
|
||||
})
|
||||
if not status["available"]:
|
||||
result["all_satisfied"] = False
|
||||
|
||||
return result
|
||||
|
||||
def get_install_hints(self, template: "TemplateConfig") -> List[str]:
|
||||
"""
|
||||
Get install hints for missing dependencies.
|
||||
|
||||
Args:
|
||||
template: Template configuration to check
|
||||
|
||||
Returns:
|
||||
List of actionable install hints
|
||||
"""
|
||||
hints = []
|
||||
deps = self.check_template_dependencies(template)
|
||||
|
||||
# Tool hints
|
||||
for tool in deps["tools"]:
|
||||
if not tool["available"] and tool.get("install_hint"):
|
||||
hints.append(f"Tool '{tool['name']}': {tool['install_hint']}")
|
||||
|
||||
# Package hints
|
||||
for pkg in deps["packages"]:
|
||||
if not pkg["available"] and pkg.get("install_hint"):
|
||||
hints.append(f"Package '{pkg['name']}': {pkg['install_hint']}")
|
||||
|
||||
# Env hints
|
||||
for env in deps["env"]:
|
||||
if not env["available"]:
|
||||
hints.append(f"Environment variable '{env['name']}': export {env['name']}=<value>")
|
||||
|
||||
return hints
|
||||
|
||||
def enforce_strict_mode(self, template: "TemplateConfig") -> bool:
|
||||
"""
|
||||
Enforce strict mode - fail if any dependency is missing.
|
||||
|
||||
Args:
|
||||
template: Template configuration to check
|
||||
|
||||
Returns:
|
||||
True if all dependencies satisfied
|
||||
|
||||
Raises:
|
||||
StrictModeError: If any dependency is missing
|
||||
"""
|
||||
deps = self.check_template_dependencies(template)
|
||||
|
||||
if deps["all_satisfied"]:
|
||||
return True
|
||||
|
||||
# Collect missing items
|
||||
missing = {
|
||||
"tools": [t["name"] for t in deps["tools"] if not t["available"]],
|
||||
"packages": [p["name"] for p in deps["packages"] if not p["available"]],
|
||||
"env": [e["name"] for e in deps["env"] if not e["available"]]
|
||||
}
|
||||
|
||||
# Build error message
|
||||
messages = []
|
||||
if missing["tools"]:
|
||||
messages.append(f"Missing tools: {', '.join(missing['tools'])}")
|
||||
if missing["packages"]:
|
||||
messages.append(f"Missing packages: {', '.join(missing['packages'])}")
|
||||
if missing["env"]:
|
||||
messages.append(f"Missing environment variables: {', '.join(missing['env'])}")
|
||||
|
||||
# Add hints
|
||||
hints = self.get_install_hints(template)
|
||||
if hints:
|
||||
messages.append("\nTo fix:")
|
||||
messages.extend(f" - {hint}" for hint in hints)
|
||||
|
||||
raise StrictModeError(
|
||||
"\n".join(messages),
|
||||
missing_items=missing
|
||||
)
|
||||
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Template Discovery
|
||||
|
||||
Discovers templates from multiple directories with precedence support.
|
||||
Supports custom user directories, built-in templates, and package templates.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveredTemplate:
|
||||
"""Information about a discovered template."""
|
||||
name: str
|
||||
path: Path
|
||||
source: str # 'custom', 'builtin', 'package'
|
||||
priority: int # Lower = higher priority
|
||||
|
||||
# Optional metadata (loaded lazily)
|
||||
description: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
|
||||
|
||||
class TemplateDiscovery:
|
||||
"""
|
||||
Discovers templates from multiple directories with precedence.
|
||||
|
||||
Search order (highest to lowest priority):
|
||||
1. User custom directory (~/.praison/templates)
|
||||
2. XDG config directory (~/.config/praison/templates)
|
||||
3. Project local directory (./.praison/templates)
|
||||
4. Built-in package templates (agent_recipes)
|
||||
|
||||
Templates in higher priority directories override those in lower priority.
|
||||
"""
|
||||
|
||||
# Default search paths (in priority order)
|
||||
DEFAULT_SEARCH_PATHS = [
|
||||
("~/.praison/templates", "custom", 1),
|
||||
("~/.config/praison/templates", "custom", 2),
|
||||
("./.praison/templates", "project", 3),
|
||||
]
|
||||
|
||||
TEMPLATE_FILE = "TEMPLATE.yaml"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
custom_dirs: Optional[List[str]] = None,
|
||||
include_package: bool = True,
|
||||
include_defaults: bool = True
|
||||
):
|
||||
"""
|
||||
Initialize template discovery.
|
||||
|
||||
Args:
|
||||
custom_dirs: Additional custom directories to search (highest priority)
|
||||
include_package: Whether to include package templates (agent_recipes)
|
||||
include_defaults: Whether to include default search paths
|
||||
"""
|
||||
self.search_paths: List[Tuple[Path, str, int]] = []
|
||||
|
||||
# Add custom directories first (highest priority)
|
||||
if custom_dirs:
|
||||
for i, dir_path in enumerate(custom_dirs):
|
||||
expanded = Path(os.path.expanduser(dir_path))
|
||||
self.search_paths.append((expanded, "custom", i))
|
||||
|
||||
# Add default search paths
|
||||
if include_defaults:
|
||||
base_priority = len(custom_dirs) if custom_dirs else 0
|
||||
for path, source, priority in self.DEFAULT_SEARCH_PATHS:
|
||||
expanded = Path(os.path.expanduser(path))
|
||||
self.search_paths.append((expanded, source, base_priority + priority))
|
||||
|
||||
self.include_package = include_package
|
||||
self._package_path: Optional[Path] = None
|
||||
|
||||
@property
|
||||
def package_templates_path(self) -> Optional[Path]:
|
||||
"""Get path to package templates (lazy loaded)."""
|
||||
if self._package_path is None and self.include_package:
|
||||
try:
|
||||
import importlib.resources
|
||||
try:
|
||||
# Python 3.9+
|
||||
ref = importlib.resources.files("agent_recipes") / "templates"
|
||||
self._package_path = Path(str(ref))
|
||||
except (TypeError, AttributeError):
|
||||
# Fallback for older Python
|
||||
import agent_recipes
|
||||
self._package_path = Path(agent_recipes.__file__).parent / "templates"
|
||||
except ImportError:
|
||||
pass
|
||||
return self._package_path
|
||||
|
||||
def discover_all(self) -> Dict[str, DiscoveredTemplate]:
|
||||
"""
|
||||
Discover all templates from all search paths.
|
||||
|
||||
Returns:
|
||||
Dict mapping template name to DiscoveredTemplate.
|
||||
When duplicates exist, higher priority wins.
|
||||
"""
|
||||
templates: Dict[str, DiscoveredTemplate] = {}
|
||||
|
||||
# Scan search paths in reverse priority order (lowest first)
|
||||
# so that higher priority overwrites lower
|
||||
all_paths = list(self.search_paths)
|
||||
|
||||
# Add package templates with lowest priority
|
||||
if self.include_package and self.package_templates_path:
|
||||
max_priority = max((p[2] for p in all_paths), default=0) + 1
|
||||
all_paths.append((self.package_templates_path, "package", max_priority))
|
||||
|
||||
# Sort by priority descending (lowest priority first)
|
||||
all_paths.sort(key=lambda x: x[2], reverse=True)
|
||||
|
||||
for dir_path, source, priority in all_paths:
|
||||
if dir_path.exists() and dir_path.is_dir():
|
||||
discovered = self._scan_directory(dir_path, source, priority)
|
||||
# Higher priority overwrites lower
|
||||
templates.update(discovered)
|
||||
|
||||
return templates
|
||||
|
||||
def _scan_directory(
|
||||
self,
|
||||
directory: Path,
|
||||
source: str,
|
||||
priority: int
|
||||
) -> Dict[str, DiscoveredTemplate]:
|
||||
"""
|
||||
Scan a directory for templates (shallow scan).
|
||||
|
||||
Only looks at direct subdirectories containing TEMPLATE.yaml.
|
||||
Does NOT recursively scan nested directories.
|
||||
|
||||
Args:
|
||||
directory: Directory to scan
|
||||
source: Source identifier ('custom', 'builtin', 'package')
|
||||
priority: Priority level
|
||||
|
||||
Returns:
|
||||
Dict mapping template name to DiscoveredTemplate
|
||||
"""
|
||||
templates = {}
|
||||
|
||||
try:
|
||||
for item in directory.iterdir():
|
||||
if item.is_dir():
|
||||
template_file = item / self.TEMPLATE_FILE
|
||||
workflow_file = item / "workflow.yaml"
|
||||
|
||||
# A valid template has TEMPLATE.yaml or workflow.yaml
|
||||
if template_file.exists() or workflow_file.exists():
|
||||
template = DiscoveredTemplate(
|
||||
name=item.name,
|
||||
path=item,
|
||||
source=source,
|
||||
priority=priority
|
||||
)
|
||||
|
||||
# Load basic metadata if TEMPLATE.yaml exists
|
||||
if template_file.exists():
|
||||
self._load_template_metadata(template, template_file)
|
||||
|
||||
templates[item.name] = template
|
||||
except PermissionError:
|
||||
pass # Skip directories we can't read
|
||||
|
||||
return templates
|
||||
|
||||
def _load_template_metadata(
|
||||
self,
|
||||
template: DiscoveredTemplate,
|
||||
template_file: Path
|
||||
) -> None:
|
||||
"""Load basic metadata from TEMPLATE.yaml."""
|
||||
try:
|
||||
import yaml
|
||||
with open(template_file) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
template.description = data.get("description", "")
|
||||
template.version = data.get("version", "1.0.0")
|
||||
template.author = data.get("author")
|
||||
except Exception:
|
||||
pass # Metadata loading is optional
|
||||
|
||||
def find_template(self, name: str) -> Optional[DiscoveredTemplate]:
|
||||
"""
|
||||
Find a template by name.
|
||||
|
||||
Searches all directories in priority order and returns the
|
||||
highest priority match.
|
||||
|
||||
Args:
|
||||
name: Template name to find
|
||||
|
||||
Returns:
|
||||
DiscoveredTemplate if found, None otherwise
|
||||
"""
|
||||
all_templates = self.discover_all()
|
||||
return all_templates.get(name)
|
||||
|
||||
def resolve_template_path(self, name: str) -> Optional[Path]:
|
||||
"""
|
||||
Resolve a template name to its path.
|
||||
|
||||
Args:
|
||||
name: Template name
|
||||
|
||||
Returns:
|
||||
Path to template directory, or None if not found
|
||||
"""
|
||||
template = self.find_template(name)
|
||||
return template.path if template else None
|
||||
|
||||
def list_templates(
|
||||
self,
|
||||
source_filter: Optional[str] = None
|
||||
) -> List[DiscoveredTemplate]:
|
||||
"""
|
||||
List all discovered templates.
|
||||
|
||||
Args:
|
||||
source_filter: Optional filter by source ('custom', 'package', etc.)
|
||||
|
||||
Returns:
|
||||
List of DiscoveredTemplate objects
|
||||
"""
|
||||
templates = self.discover_all()
|
||||
|
||||
if source_filter:
|
||||
return [t for t in templates.values() if t.source == source_filter]
|
||||
|
||||
return list(templates.values())
|
||||
|
||||
def get_search_paths(self) -> List[Tuple[str, str, bool]]:
|
||||
"""
|
||||
Get all search paths with their status.
|
||||
|
||||
Returns:
|
||||
List of (path, source, exists) tuples
|
||||
"""
|
||||
paths = []
|
||||
|
||||
for dir_path, source, _ in self.search_paths:
|
||||
paths.append((str(dir_path), source, dir_path.exists()))
|
||||
|
||||
if self.include_package and self.package_templates_path:
|
||||
paths.append((
|
||||
str(self.package_templates_path),
|
||||
"package",
|
||||
self.package_templates_path.exists()
|
||||
))
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def discover_templates(
|
||||
custom_dirs: Optional[List[str]] = None
|
||||
) -> Dict[str, DiscoveredTemplate]:
|
||||
"""
|
||||
Discover all templates from default and custom directories.
|
||||
|
||||
Args:
|
||||
custom_dirs: Additional custom directories to search
|
||||
|
||||
Returns:
|
||||
Dict mapping template name to DiscoveredTemplate
|
||||
"""
|
||||
discovery = TemplateDiscovery(custom_dirs=custom_dirs)
|
||||
return discovery.discover_all()
|
||||
|
||||
|
||||
def find_template_path(
|
||||
name: str,
|
||||
custom_dirs: Optional[List[str]] = None
|
||||
) -> Optional[Path]:
|
||||
"""
|
||||
Find a template by name and return its path.
|
||||
|
||||
Args:
|
||||
name: Template name
|
||||
custom_dirs: Additional custom directories to search
|
||||
|
||||
Returns:
|
||||
Path to template directory, or None if not found
|
||||
"""
|
||||
discovery = TemplateDiscovery(custom_dirs=custom_dirs)
|
||||
return discovery.resolve_template_path(name)
|
||||
@@ -0,0 +1,554 @@
|
||||
"""
|
||||
Template Loader
|
||||
|
||||
Loads and materializes templates into Agent/Workflow configurations.
|
||||
Handles TEMPLATE.yaml parsing, config merging, and skills integration.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .resolver import ResolvedTemplate # noqa: F401 - used by registry
|
||||
from .cache import TemplateCache
|
||||
from .registry import TemplateRegistry
|
||||
from .security import TemplateSecurity
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemplateConfig:
|
||||
"""Parsed template configuration."""
|
||||
name: str
|
||||
description: str = ""
|
||||
version: str = "1.0.0"
|
||||
author: Optional[str] = None
|
||||
license: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
# Dependencies
|
||||
requires: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Entry points
|
||||
workflow_file: str = "workflow.yaml"
|
||||
agents_file: str = "agents.yaml"
|
||||
|
||||
# Configuration schema
|
||||
config_schema: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Default configuration values
|
||||
defaults: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Skills to load
|
||||
skills: List[str] = field(default_factory=list)
|
||||
|
||||
# CLI integration
|
||||
cli: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Raw config dict
|
||||
raw: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Source path
|
||||
path: Optional[Path] = None
|
||||
|
||||
|
||||
class TemplateLoader:
|
||||
"""
|
||||
Loads templates and materializes them into usable configurations.
|
||||
|
||||
Supports:
|
||||
- TEMPLATE.yaml parsing
|
||||
- Config override merging
|
||||
- Skills integration
|
||||
- Workflow/Agent file loading
|
||||
"""
|
||||
|
||||
TEMPLATE_FILE = "TEMPLATE.yaml"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache: Optional[TemplateCache] = None,
|
||||
registry: Optional[TemplateRegistry] = None,
|
||||
security: Optional[TemplateSecurity] = None,
|
||||
offline: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize the loader.
|
||||
|
||||
Args:
|
||||
cache: Template cache instance
|
||||
registry: Template registry instance
|
||||
security: Security handler instance
|
||||
offline: If True, only use cached templates
|
||||
"""
|
||||
self.cache = cache or TemplateCache()
|
||||
self.registry = registry or TemplateRegistry(cache=self.cache, offline=offline)
|
||||
self.security = security or TemplateSecurity()
|
||||
self.offline = offline
|
||||
|
||||
def load(
|
||||
self,
|
||||
uri: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
offline: bool = False
|
||||
) -> TemplateConfig:
|
||||
"""
|
||||
Load a template by URI.
|
||||
|
||||
Args:
|
||||
uri: Template URI
|
||||
config: Optional config overrides
|
||||
offline: If True, only use cache
|
||||
|
||||
Returns:
|
||||
TemplateConfig with parsed configuration
|
||||
|
||||
Raises:
|
||||
ValueError: If template not found or invalid
|
||||
SecurityError: If template fails security checks
|
||||
"""
|
||||
# Security check
|
||||
if not self.security.is_source_allowed(uri):
|
||||
raise ValueError(f"Template source not allowed: {uri}")
|
||||
|
||||
# Get template (from cache or remote)
|
||||
use_offline = offline or self.offline
|
||||
cached = self.registry.get_template(uri, offline=use_offline)
|
||||
|
||||
# Validate security
|
||||
errors = self.security.validate_template_directory(cached.path)
|
||||
if errors:
|
||||
raise ValueError("Template security validation failed:\n" + "\n".join(errors))
|
||||
|
||||
# Parse TEMPLATE.yaml
|
||||
template_config = self._parse_template_file(cached.path)
|
||||
template_config.path = cached.path
|
||||
|
||||
# Merge config overrides
|
||||
if config:
|
||||
template_config = self._merge_config(template_config, config)
|
||||
|
||||
return template_config
|
||||
|
||||
def _parse_template_file(self, template_dir: Path) -> TemplateConfig:
|
||||
"""Parse TEMPLATE.yaml file."""
|
||||
import yaml
|
||||
|
||||
template_file = template_dir / self.TEMPLATE_FILE
|
||||
|
||||
if not template_file.exists():
|
||||
# Try to infer from directory name
|
||||
return TemplateConfig(
|
||||
name=template_dir.name,
|
||||
path=template_dir
|
||||
)
|
||||
|
||||
with open(template_file) as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
|
||||
# Sanitize config
|
||||
raw = self.security.sanitize_template_config(raw)
|
||||
|
||||
# Extract requires
|
||||
requires = raw.get("requires", {})
|
||||
if isinstance(requires, list):
|
||||
requires = {"tools": requires}
|
||||
|
||||
# Handle workflow - can be inline dict or file reference string
|
||||
workflow = raw.get("workflow", "workflow.yaml")
|
||||
|
||||
# Handle agents - can be inline list or file reference string
|
||||
agents = raw.get("agents", "agents.yaml")
|
||||
|
||||
return TemplateConfig(
|
||||
name=raw.get("name", template_dir.name),
|
||||
description=raw.get("description", ""),
|
||||
version=raw.get("version", "1.0.0"),
|
||||
author=raw.get("author"),
|
||||
license=raw.get("license"),
|
||||
tags=raw.get("tags", []),
|
||||
requires=requires,
|
||||
workflow_file=workflow,
|
||||
agents_file=agents,
|
||||
config_schema=raw.get("config", {}),
|
||||
defaults=raw.get("defaults", {}),
|
||||
skills=raw.get("skills", []),
|
||||
cli=raw.get("cli", {}),
|
||||
raw=raw,
|
||||
path=template_dir
|
||||
)
|
||||
|
||||
def _merge_config(
|
||||
self,
|
||||
template: TemplateConfig,
|
||||
overrides: Dict[str, Any]
|
||||
) -> TemplateConfig:
|
||||
"""Merge config overrides into template config."""
|
||||
# Start with defaults
|
||||
merged = dict(template.defaults)
|
||||
|
||||
# Apply overrides
|
||||
merged.update(overrides)
|
||||
|
||||
# Update template
|
||||
template.defaults = merged
|
||||
|
||||
return template
|
||||
|
||||
def load_workflow_config(
|
||||
self,
|
||||
template: TemplateConfig
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Load workflow configuration from template.
|
||||
|
||||
Args:
|
||||
template: Parsed template config
|
||||
|
||||
Returns:
|
||||
Workflow configuration dict
|
||||
|
||||
Supports:
|
||||
- Inline workflow definition in TEMPLATE.yaml (workflow: {agents: [...], tasks: [...]})
|
||||
- Separate workflow file reference (workflow: "workflow.yaml")
|
||||
"""
|
||||
import yaml
|
||||
|
||||
# Check if workflow is inline (dict) or a file reference (string)
|
||||
if isinstance(template.workflow_file, dict):
|
||||
# Inline workflow definition
|
||||
config = template.workflow_file
|
||||
elif isinstance(template.workflow_file, str):
|
||||
# File reference
|
||||
workflow_file = template.path / template.workflow_file
|
||||
|
||||
if not workflow_file.exists():
|
||||
# Check if workflow is in raw config
|
||||
if "workflow" in template.raw and isinstance(template.raw["workflow"], dict):
|
||||
config = template.raw["workflow"]
|
||||
else:
|
||||
raise ValueError(f"Workflow file not found: {workflow_file}")
|
||||
else:
|
||||
with open(workflow_file) as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
else:
|
||||
raise ValueError(f"Invalid workflow configuration type: {type(template.workflow_file)}")
|
||||
|
||||
# Substitute variables from template config
|
||||
config = self._substitute_variables(config, template.defaults)
|
||||
|
||||
return config
|
||||
|
||||
def load_agents_config(
|
||||
self,
|
||||
template: TemplateConfig
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Load agents configuration from template.
|
||||
|
||||
Args:
|
||||
template: Parsed template config
|
||||
|
||||
Returns:
|
||||
Agents configuration dict
|
||||
"""
|
||||
import yaml
|
||||
|
||||
agents_file = template.path / template.agents_file
|
||||
|
||||
if not agents_file.exists():
|
||||
# Try workflow file for inline agents
|
||||
return {}
|
||||
|
||||
with open(agents_file) as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
|
||||
# Substitute variables
|
||||
config = self._substitute_variables(config, template.defaults)
|
||||
|
||||
return config
|
||||
|
||||
def _substitute_variables(
|
||||
self,
|
||||
config: Any,
|
||||
variables: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""
|
||||
Recursively substitute variables in config with sentinel protection.
|
||||
|
||||
This method implements a secure multi-phase substitution strategy:
|
||||
1. Generate a unique sentinel token for this render
|
||||
2. Protect user-provided variable values that contain {{...}} syntax
|
||||
3. Perform template variable substitution on the config
|
||||
4. Restore protected values
|
||||
|
||||
This prevents user input containing {{variable}} syntax from being
|
||||
interpreted as template variables (injection protection).
|
||||
"""
|
||||
if isinstance(config, str):
|
||||
# Generate per-render unique sentinel to prevent collision attacks
|
||||
sentinel_id = secrets.token_hex(8)
|
||||
sentinel_prefix = f"__PRAISONAI_SENTINEL_{sentinel_id}_"
|
||||
|
||||
# Phase 1: Protect variable values that contain template syntax
|
||||
protected_values, safe_variables = self._protect_variable_values(
|
||||
variables, sentinel_prefix
|
||||
)
|
||||
|
||||
# Phase 2: Perform substitution with safe values
|
||||
pattern = r'\{\{(\w+)\}\}'
|
||||
|
||||
def replace(match):
|
||||
var_name = match.group(1)
|
||||
return str(safe_variables.get(var_name, match.group(0)))
|
||||
|
||||
result = re.sub(pattern, replace, config)
|
||||
|
||||
# Phase 3: Restore protected values
|
||||
result = self._restore_protected_values(result, protected_values)
|
||||
|
||||
return result
|
||||
|
||||
elif isinstance(config, dict):
|
||||
return {k: self._substitute_variables(v, variables) for k, v in config.items()}
|
||||
|
||||
elif isinstance(config, list):
|
||||
return [self._substitute_variables(item, variables) for item in config]
|
||||
|
||||
return config
|
||||
|
||||
def _protect_variable_values(
|
||||
self,
|
||||
variables: Dict[str, Any],
|
||||
sentinel_prefix: str
|
||||
) -> Tuple[Dict[str, str], Dict[str, Any]]:
|
||||
"""
|
||||
Protect variable values that contain template syntax.
|
||||
|
||||
Args:
|
||||
variables: Original variable dict
|
||||
sentinel_prefix: Unique prefix for this render
|
||||
|
||||
Returns:
|
||||
Tuple of (protected_values mapping, safe_variables dict)
|
||||
"""
|
||||
protected_values = {} # sentinel -> original value
|
||||
safe_variables = {}
|
||||
|
||||
template_pattern = re.compile(r'\{\{.*?\}\}')
|
||||
|
||||
for key, value in variables.items():
|
||||
if isinstance(value, str) and template_pattern.search(value):
|
||||
# This value contains template syntax - protect it
|
||||
sentinel = f"{sentinel_prefix}{key}__"
|
||||
protected_values[sentinel] = value
|
||||
safe_variables[key] = sentinel
|
||||
else:
|
||||
safe_variables[key] = value
|
||||
|
||||
return protected_values, safe_variables
|
||||
|
||||
def _restore_protected_values(
|
||||
self,
|
||||
content: str,
|
||||
protected_values: Dict[str, str]
|
||||
) -> str:
|
||||
"""
|
||||
Restore protected values after substitution.
|
||||
|
||||
Args:
|
||||
content: Content with sentinel tokens
|
||||
protected_values: Mapping of sentinel -> original value
|
||||
|
||||
Returns:
|
||||
Content with sentinels replaced by original values
|
||||
"""
|
||||
for sentinel, original in protected_values.items():
|
||||
content = content.replace(sentinel, original)
|
||||
return content
|
||||
|
||||
def get_required_tools(self, template: TemplateConfig) -> List[str]:
|
||||
"""Get list of required tools for a template."""
|
||||
tools = template.requires.get("tools", [])
|
||||
if isinstance(tools, str):
|
||||
tools = [tools]
|
||||
return tools
|
||||
|
||||
def get_required_packages(self, template: TemplateConfig) -> List[str]:
|
||||
"""Get list of required Python packages for a template."""
|
||||
packages = template.requires.get("packages", [])
|
||||
if isinstance(packages, str):
|
||||
packages = [packages]
|
||||
return packages
|
||||
|
||||
def get_required_env(self, template: TemplateConfig) -> List[str]:
|
||||
"""Get list of required environment variables for a template."""
|
||||
env = template.requires.get("env", [])
|
||||
if isinstance(env, str):
|
||||
env = [env]
|
||||
return env
|
||||
|
||||
def check_requirements(
|
||||
self,
|
||||
template: TemplateConfig
|
||||
) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Check if template requirements are satisfied.
|
||||
|
||||
Returns:
|
||||
Dict with 'missing_packages', 'missing_env', 'missing_tools' lists
|
||||
"""
|
||||
result = {
|
||||
"missing_packages": [],
|
||||
"missing_env": [],
|
||||
"missing_tools": []
|
||||
}
|
||||
|
||||
# Check packages
|
||||
for package in self.get_required_packages(template):
|
||||
try:
|
||||
__import__(package.replace("-", "_"))
|
||||
except ImportError:
|
||||
result["missing_packages"].append(package)
|
||||
|
||||
# Check environment variables
|
||||
for env_var in self.get_required_env(template):
|
||||
if not os.environ.get(env_var):
|
||||
result["missing_env"].append(env_var)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def load_template(
|
||||
uri: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
offline: bool = False
|
||||
) -> TemplateConfig:
|
||||
"""
|
||||
Convenience function to load a template.
|
||||
|
||||
Args:
|
||||
uri: Template URI
|
||||
config: Optional config overrides
|
||||
offline: If True, only use cache
|
||||
|
||||
Returns:
|
||||
TemplateConfig with parsed configuration
|
||||
"""
|
||||
loader = TemplateLoader(offline=offline)
|
||||
return loader.load(uri, config=config, offline=offline)
|
||||
|
||||
|
||||
def create_agent_from_template(
|
||||
uri: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
offline: bool = False,
|
||||
**agent_kwargs
|
||||
):
|
||||
"""
|
||||
Create an Agent from a template.
|
||||
|
||||
Args:
|
||||
uri: Template URI
|
||||
config: Optional config overrides
|
||||
offline: If True, only use cache
|
||||
**agent_kwargs: Additional Agent constructor arguments
|
||||
|
||||
Returns:
|
||||
Configured Agent instance
|
||||
"""
|
||||
loader = TemplateLoader(offline=offline)
|
||||
template = loader.load(uri, config=config, offline=offline)
|
||||
|
||||
# Load agents config
|
||||
agents_config = loader.load_agents_config(template)
|
||||
|
||||
# Get first agent or use template defaults
|
||||
if agents_config and "agents" in agents_config:
|
||||
agent_list = list(agents_config["agents"].values())
|
||||
if agent_list:
|
||||
agent_config = agent_list[0]
|
||||
else:
|
||||
agent_config = {}
|
||||
else:
|
||||
agent_config = {}
|
||||
|
||||
# Merge with kwargs
|
||||
final_config = {**agent_config, **agent_kwargs}
|
||||
|
||||
# Import Agent lazily
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Handle skills
|
||||
if template.skills:
|
||||
final_config["skills"] = template.skills
|
||||
|
||||
return Agent(**final_config)
|
||||
|
||||
|
||||
def create_workflow_from_template(
|
||||
uri: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
offline: bool = False,
|
||||
**workflow_kwargs
|
||||
):
|
||||
"""
|
||||
Create a Workflow from a template.
|
||||
|
||||
Args:
|
||||
uri: Template URI
|
||||
config: Optional config overrides
|
||||
offline: If True, only use cache
|
||||
**workflow_kwargs: Additional Workflow constructor arguments
|
||||
|
||||
Returns:
|
||||
Configured Workflow instance
|
||||
"""
|
||||
loader = TemplateLoader(offline=offline)
|
||||
template = loader.load(uri, config=config, offline=offline)
|
||||
|
||||
# Load workflow config
|
||||
workflow_config = loader.load_workflow_config(template)
|
||||
|
||||
# Merge with kwargs
|
||||
final_config = {**workflow_config, **workflow_kwargs}
|
||||
|
||||
# Import Workflow lazily
|
||||
from praisonaiagents import Workflow
|
||||
|
||||
return Workflow(**final_config)
|
||||
|
||||
|
||||
def create_agents_from_template(
|
||||
uri: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
offline: bool = False,
|
||||
**agents_kwargs
|
||||
):
|
||||
"""
|
||||
Create a PraisonAIAgents team from a template.
|
||||
|
||||
Args:
|
||||
uri: Template URI
|
||||
config: Optional config overrides
|
||||
offline: If True, only use cache
|
||||
**agents_kwargs: Additional PraisonAIAgents constructor arguments
|
||||
|
||||
Returns:
|
||||
Configured PraisonAIAgents instance
|
||||
"""
|
||||
loader = TemplateLoader(offline=offline)
|
||||
template = loader.load(uri, config=config, offline=offline)
|
||||
|
||||
# Load workflow config (contains agents and tasks)
|
||||
workflow_config = loader.load_workflow_config(template)
|
||||
|
||||
# Import PraisonAIAgents lazily
|
||||
from praisonaiagents import PraisonAIAgents
|
||||
|
||||
# Merge with kwargs
|
||||
final_config = {**workflow_config, **agents_kwargs}
|
||||
|
||||
return PraisonAIAgents(**final_config)
|
||||
@@ -0,0 +1,511 @@
|
||||
"""
|
||||
Template Registry
|
||||
|
||||
Handles fetching templates from remote sources (GitHub, HTTP).
|
||||
All network operations are lazy and only performed when explicitly requested.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .resolver import ResolvedTemplate, TemplateResolver, TemplateSource
|
||||
from .cache import TemplateCache, CachedTemplate
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemplateInfo:
|
||||
"""Information about a template."""
|
||||
name: str
|
||||
description: str
|
||||
version: str = "1.0.0"
|
||||
author: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
requires: Dict[str, Any] = field(default_factory=dict)
|
||||
source: Optional[str] = None
|
||||
path: Optional[str] = None
|
||||
sha256: Optional[str] = None
|
||||
|
||||
|
||||
class TemplateRegistry:
|
||||
"""
|
||||
Registry for discovering and fetching templates.
|
||||
|
||||
Supports:
|
||||
- GitHub repositories (via contents API or raw.githubusercontent.com)
|
||||
- HTTP URLs
|
||||
- Local package installations
|
||||
"""
|
||||
|
||||
GITHUB_API_BASE = "https://api.github.com"
|
||||
GITHUB_RAW_BASE = "https://raw.githubusercontent.com"
|
||||
|
||||
# Default recipes repository
|
||||
DEFAULT_OWNER = "MervinPraison"
|
||||
DEFAULT_REPO = "agent-recipes"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache: Optional[TemplateCache] = None,
|
||||
github_token: Optional[str] = None,
|
||||
offline: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize the registry.
|
||||
|
||||
Args:
|
||||
cache: Template cache instance
|
||||
github_token: Optional GitHub token for API requests
|
||||
offline: If True, only use cached templates
|
||||
"""
|
||||
self.cache = cache or TemplateCache()
|
||||
self.github_token = github_token or os.environ.get("GITHUB_TOKEN")
|
||||
self.offline = offline
|
||||
self._http_client = None
|
||||
|
||||
@property
|
||||
def http_client(self):
|
||||
"""Lazy-load HTTP client."""
|
||||
if self._http_client is None:
|
||||
try:
|
||||
import httpx
|
||||
self._http_client = httpx.Client(timeout=30.0)
|
||||
except ImportError:
|
||||
import urllib.request
|
||||
self._http_client = "urllib"
|
||||
return self._http_client
|
||||
|
||||
def _make_request(
|
||||
self,
|
||||
url: str,
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
) -> bytes:
|
||||
"""Make an HTTP request."""
|
||||
all_headers = headers or {}
|
||||
if self.github_token and "api.github.com" in url:
|
||||
all_headers["Authorization"] = f"token {self.github_token}"
|
||||
all_headers["User-Agent"] = "PraisonAI-Templates/1.0"
|
||||
|
||||
if self.http_client == "urllib":
|
||||
import urllib.request
|
||||
req = urllib.request.Request(url, headers=all_headers)
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
return response.read()
|
||||
else:
|
||||
response = self.http_client.get(url, headers=all_headers)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
def fetch_github_template(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
template_path: str,
|
||||
ref: str = "main"
|
||||
) -> Path:
|
||||
"""
|
||||
Fetch a template from GitHub.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
template_path: Path to template within repo
|
||||
ref: Git ref (branch, tag, or commit)
|
||||
|
||||
Returns:
|
||||
Path to downloaded template directory
|
||||
"""
|
||||
# Create temp directory for download
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="praison_template_"))
|
||||
|
||||
# Fetch template files using GitHub API
|
||||
api_url = f"{self.GITHUB_API_BASE}/repos/{owner}/{repo}/contents/templates/{template_path}?ref={ref}"
|
||||
|
||||
try:
|
||||
response = self._make_request(api_url)
|
||||
contents = json.loads(response)
|
||||
|
||||
if isinstance(contents, list):
|
||||
# Directory listing
|
||||
for item in contents:
|
||||
if item["type"] == "file":
|
||||
file_content = self._fetch_github_file(item["download_url"])
|
||||
file_path = temp_dir / item["name"]
|
||||
file_path.write_bytes(file_content)
|
||||
elif item["type"] == "dir":
|
||||
# Recursively fetch subdirectory
|
||||
subdir = temp_dir / item["name"]
|
||||
subdir.mkdir(parents=True, exist_ok=True)
|
||||
self._fetch_github_dir(
|
||||
owner, repo, f"templates/{template_path}/{item['name']}",
|
||||
ref, subdir
|
||||
)
|
||||
else:
|
||||
# Single file
|
||||
file_content = self._fetch_github_file(contents["download_url"])
|
||||
file_path = temp_dir / contents["name"]
|
||||
file_path.write_bytes(file_content)
|
||||
|
||||
except Exception:
|
||||
# Fallback to raw.githubusercontent.com
|
||||
raw_url = f"{self.GITHUB_RAW_BASE}/{owner}/{repo}/{ref}/templates/{template_path}"
|
||||
self._fetch_raw_template(raw_url, temp_dir, template_path)
|
||||
|
||||
return temp_dir
|
||||
|
||||
def _fetch_github_file(self, url: str) -> bytes:
|
||||
"""Fetch a single file from GitHub."""
|
||||
return self._make_request(url)
|
||||
|
||||
def _fetch_github_dir(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
path: str,
|
||||
ref: str,
|
||||
target_dir: Path
|
||||
) -> None:
|
||||
"""Recursively fetch a directory from GitHub."""
|
||||
api_url = f"{self.GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{path}?ref={ref}"
|
||||
response = self._make_request(api_url)
|
||||
contents = json.loads(response)
|
||||
|
||||
for item in contents:
|
||||
if item["type"] == "file":
|
||||
file_content = self._fetch_github_file(item["download_url"])
|
||||
file_path = target_dir / item["name"]
|
||||
file_path.write_bytes(file_content)
|
||||
elif item["type"] == "dir":
|
||||
subdir = target_dir / item["name"]
|
||||
subdir.mkdir(parents=True, exist_ok=True)
|
||||
self._fetch_github_dir(owner, repo, f"{path}/{item['name']}", ref, subdir)
|
||||
|
||||
def _fetch_raw_template(
|
||||
self,
|
||||
base_url: str,
|
||||
target_dir: Path,
|
||||
template_name: str
|
||||
) -> None:
|
||||
"""Fetch template using raw URLs (fallback)."""
|
||||
# Try to fetch common template files
|
||||
files_to_try = [
|
||||
"TEMPLATE.yaml",
|
||||
"workflow.yaml",
|
||||
"agents.yaml",
|
||||
"README.md"
|
||||
]
|
||||
|
||||
for filename in files_to_try:
|
||||
try:
|
||||
url = f"{base_url}/{filename}"
|
||||
content = self._make_request(url)
|
||||
(target_dir / filename).write_bytes(content)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def fetch_http_template(self, url: str) -> Path:
|
||||
"""
|
||||
Fetch a template from an HTTP URL.
|
||||
|
||||
Args:
|
||||
url: URL to template (YAML file or directory index)
|
||||
|
||||
Returns:
|
||||
Path to downloaded template
|
||||
"""
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="praison_template_"))
|
||||
|
||||
content = self._make_request(url)
|
||||
|
||||
# Determine filename from URL
|
||||
filename = url.split("/")[-1]
|
||||
if not filename.endswith((".yaml", ".yml")):
|
||||
filename = "TEMPLATE.yaml"
|
||||
|
||||
(temp_dir / filename).write_bytes(content)
|
||||
|
||||
return temp_dir
|
||||
|
||||
def get_template(
|
||||
self,
|
||||
uri: str,
|
||||
offline: bool = False
|
||||
) -> CachedTemplate:
|
||||
"""
|
||||
Get a template by URI, fetching if necessary.
|
||||
|
||||
Args:
|
||||
uri: Template URI
|
||||
offline: If True, only use cache
|
||||
|
||||
Returns:
|
||||
CachedTemplate with path and metadata
|
||||
|
||||
Raises:
|
||||
ValueError: If template not found
|
||||
"""
|
||||
resolved = TemplateResolver.resolve(uri)
|
||||
use_offline = offline or self.offline
|
||||
|
||||
# Check cache first
|
||||
cached = self.cache.get(resolved, offline=use_offline)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
if use_offline:
|
||||
raise ValueError(
|
||||
f"Template not found in cache: {uri}\n"
|
||||
"Run without --offline to fetch from remote."
|
||||
)
|
||||
|
||||
# Fetch based on source
|
||||
if resolved.source == TemplateSource.LOCAL:
|
||||
path = Path(resolved.path)
|
||||
if not path.exists():
|
||||
raise ValueError(f"Local template not found: {resolved.path}")
|
||||
return CachedTemplate(
|
||||
path=path,
|
||||
metadata=self.cache.put(resolved, path).metadata
|
||||
)
|
||||
|
||||
elif resolved.source == TemplateSource.PACKAGE:
|
||||
# Try to find in installed package
|
||||
return self._get_package_template(resolved)
|
||||
|
||||
elif resolved.source == TemplateSource.GITHUB:
|
||||
temp_dir = self.fetch_github_template(
|
||||
resolved.owner,
|
||||
resolved.repo,
|
||||
resolved.path,
|
||||
resolved.ref or "main"
|
||||
)
|
||||
# Calculate checksum
|
||||
sha256 = self._calculate_dir_checksum(temp_dir)
|
||||
return self.cache.put(resolved, temp_dir, sha256=sha256)
|
||||
|
||||
elif resolved.source == TemplateSource.HTTP:
|
||||
temp_dir = self.fetch_http_template(resolved.url)
|
||||
sha256 = self._calculate_dir_checksum(temp_dir)
|
||||
return self.cache.put(resolved, temp_dir, sha256=sha256)
|
||||
|
||||
raise ValueError(f"Unknown template source: {resolved.source}")
|
||||
|
||||
def _get_package_template(self, resolved: ResolvedTemplate) -> CachedTemplate:
|
||||
"""Get template from installed Python package."""
|
||||
parts = resolved.path.split("/")
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f"Invalid package template path: {resolved.path}")
|
||||
|
||||
package_name = parts[0]
|
||||
template_name = "/".join(parts[1:])
|
||||
|
||||
try:
|
||||
# Try importlib.resources first (Python 3.9+)
|
||||
import importlib.resources as pkg_resources
|
||||
package = __import__(package_name)
|
||||
|
||||
# Navigate to templates directory
|
||||
templates_path = Path(package.__file__).parent / "templates" / template_name
|
||||
if templates_path.exists():
|
||||
return CachedTemplate(
|
||||
path=templates_path,
|
||||
metadata=self.cache.put(resolved, templates_path).metadata
|
||||
)
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
# Try pkg_resources fallback
|
||||
try:
|
||||
import pkg_resources as pkg_res
|
||||
template_path = pkg_res.resource_filename(
|
||||
package_name,
|
||||
f"templates/{template_name}"
|
||||
)
|
||||
if Path(template_path).exists():
|
||||
return CachedTemplate(
|
||||
path=Path(template_path),
|
||||
metadata=self.cache.put(resolved, Path(template_path)).metadata
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise ValueError(
|
||||
f"Package template not found: {resolved.path}\n"
|
||||
f"Make sure package '{package_name}' is installed."
|
||||
)
|
||||
|
||||
def _calculate_dir_checksum(self, directory: Path) -> str:
|
||||
"""Calculate SHA256 checksum of directory contents."""
|
||||
hasher = hashlib.sha256()
|
||||
|
||||
for file_path in sorted(directory.rglob("*")):
|
||||
if file_path.is_file() and not file_path.name.startswith("."):
|
||||
hasher.update(file_path.name.encode())
|
||||
hasher.update(file_path.read_bytes())
|
||||
|
||||
return hasher.hexdigest()
|
||||
|
||||
def list_remote_templates(
|
||||
self,
|
||||
owner: str = None,
|
||||
repo: str = None,
|
||||
ref: str = "main"
|
||||
) -> List[TemplateInfo]:
|
||||
"""
|
||||
List templates available in a GitHub repository.
|
||||
|
||||
Args:
|
||||
owner: Repository owner (default: MervinPraison)
|
||||
repo: Repository name (default: agent-recipes)
|
||||
ref: Git ref
|
||||
|
||||
Returns:
|
||||
List of TemplateInfo objects
|
||||
"""
|
||||
owner = owner or self.DEFAULT_OWNER
|
||||
repo = repo or self.DEFAULT_REPO
|
||||
|
||||
if self.offline:
|
||||
return []
|
||||
|
||||
templates = []
|
||||
|
||||
try:
|
||||
# Fetch templates directory listing
|
||||
api_url = f"{self.GITHUB_API_BASE}/repos/{owner}/{repo}/contents/templates?ref={ref}"
|
||||
response = self._make_request(api_url)
|
||||
contents = json.loads(response)
|
||||
|
||||
for item in contents:
|
||||
if item["type"] == "dir":
|
||||
# Try to fetch TEMPLATE.yaml for metadata
|
||||
try:
|
||||
template_url = f"{self.GITHUB_RAW_BASE}/{owner}/{repo}/{ref}/templates/{item['name']}/TEMPLATE.yaml"
|
||||
template_content = self._make_request(template_url)
|
||||
|
||||
import yaml
|
||||
config = yaml.safe_load(template_content)
|
||||
|
||||
templates.append(TemplateInfo(
|
||||
name=config.get("name", item["name"]),
|
||||
description=config.get("description", ""),
|
||||
version=config.get("version", "1.0.0"),
|
||||
author=config.get("author"),
|
||||
tags=config.get("tags", []),
|
||||
requires=config.get("requires", {}),
|
||||
source=f"github:{owner}/{repo}/{item['name']}",
|
||||
path=item["name"]
|
||||
))
|
||||
except Exception:
|
||||
# Fallback to basic info
|
||||
templates.append(TemplateInfo(
|
||||
name=item["name"],
|
||||
description="",
|
||||
source=f"github:{owner}/{repo}/{item['name']}",
|
||||
path=item["name"]
|
||||
))
|
||||
except Exception:
|
||||
# Return empty list on error
|
||||
pass
|
||||
|
||||
return templates
|
||||
|
||||
def search_templates(
|
||||
self,
|
||||
query: str,
|
||||
owner: str = None,
|
||||
repo: str = None
|
||||
) -> List[TemplateInfo]:
|
||||
"""
|
||||
Search templates by name or tags.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
|
||||
Returns:
|
||||
List of matching TemplateInfo objects
|
||||
"""
|
||||
all_templates = self.list_remote_templates(owner, repo)
|
||||
query_lower = query.lower()
|
||||
|
||||
return [
|
||||
t for t in all_templates
|
||||
if query_lower in t.name.lower() or
|
||||
query_lower in t.description.lower() or
|
||||
any(query_lower in tag.lower() for tag in t.tags)
|
||||
]
|
||||
|
||||
|
||||
def list_templates(
|
||||
source: str = "all",
|
||||
offline: bool = False
|
||||
) -> List[TemplateInfo]:
|
||||
"""
|
||||
List available templates.
|
||||
|
||||
Args:
|
||||
source: 'all', 'local', 'remote', or 'cached'
|
||||
offline: If True, only list cached templates
|
||||
|
||||
Returns:
|
||||
List of TemplateInfo objects
|
||||
"""
|
||||
registry = TemplateRegistry(offline=offline)
|
||||
templates = []
|
||||
|
||||
if source in ("all", "cached"):
|
||||
# List cached templates
|
||||
cache = TemplateCache()
|
||||
for path, metadata in cache.list_cached():
|
||||
templates.append(TemplateInfo(
|
||||
name=path.name,
|
||||
description="(cached)",
|
||||
version=metadata.version or "unknown",
|
||||
source=str(path)
|
||||
))
|
||||
|
||||
if source in ("all", "remote") and not offline:
|
||||
# List remote templates
|
||||
remote = registry.list_remote_templates()
|
||||
templates.extend(remote)
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
def search_templates(query: str, offline: bool = False) -> List[TemplateInfo]:
|
||||
"""
|
||||
Search templates by query.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
offline: If True, only search cached templates
|
||||
|
||||
Returns:
|
||||
List of matching TemplateInfo objects
|
||||
"""
|
||||
registry = TemplateRegistry(offline=offline)
|
||||
return registry.search_templates(query)
|
||||
|
||||
|
||||
def install_template(
|
||||
uri: str,
|
||||
offline: bool = False
|
||||
) -> CachedTemplate:
|
||||
"""
|
||||
Install (fetch and cache) a template.
|
||||
|
||||
Args:
|
||||
uri: Template URI
|
||||
offline: If True, fail if not cached
|
||||
|
||||
Returns:
|
||||
CachedTemplate with path and metadata
|
||||
"""
|
||||
registry = TemplateRegistry(offline=offline)
|
||||
return registry.get_template(uri, offline=offline)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Template URI Resolver
|
||||
|
||||
Parses and resolves template URIs to their source locations.
|
||||
Supports: local paths, package refs, GitHub refs, HTTP URLs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
class TemplateSource(Enum):
|
||||
"""Supported template sources."""
|
||||
LOCAL = "local"
|
||||
PACKAGE = "package"
|
||||
GITHUB = "github"
|
||||
HTTP = "http"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedTemplate:
|
||||
"""Resolved template location."""
|
||||
source: TemplateSource
|
||||
path: str
|
||||
version: Optional[str] = None
|
||||
owner: Optional[str] = None
|
||||
repo: Optional[str] = None
|
||||
ref: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
|
||||
@property
|
||||
def cache_key(self) -> str:
|
||||
"""Generate a unique cache key for this template."""
|
||||
if self.source == TemplateSource.LOCAL:
|
||||
return f"local/{os.path.abspath(self.path)}"
|
||||
elif self.source == TemplateSource.PACKAGE:
|
||||
return f"package/{self.path}"
|
||||
elif self.source == TemplateSource.GITHUB:
|
||||
ref = self.ref or "main"
|
||||
return f"github/{self.owner}/{self.repo}/{self.path}@{ref}"
|
||||
elif self.source == TemplateSource.HTTP:
|
||||
return f"http/{self.url}"
|
||||
return f"unknown/{self.path}"
|
||||
|
||||
@property
|
||||
def is_pinned(self) -> bool:
|
||||
"""Check if this template reference is pinned (not 'latest')."""
|
||||
if self.source == TemplateSource.LOCAL:
|
||||
return True # Local is always "pinned"
|
||||
if self.source == TemplateSource.PACKAGE:
|
||||
return True # Package is always "pinned" to installed version
|
||||
if self.source == TemplateSource.GITHUB:
|
||||
# Pinned if ref is a commit hash (40 hex chars) or a version tag
|
||||
if self.ref and (
|
||||
re.match(r'^[a-f0-9]{40}$', self.ref) or
|
||||
re.match(r'^v?\d+\.\d+', self.ref)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
class TemplateResolver:
|
||||
"""
|
||||
Resolves template URIs to their source locations.
|
||||
|
||||
Supported URI formats:
|
||||
- Local path: ./my-template, /absolute/path/template, ~/templates/my-template
|
||||
- Package ref: package:agent_recipes/transcript-generator
|
||||
- GitHub ref: github:owner/repo/template-name[@ref]
|
||||
- HTTP URL: https://example.com/template.yaml
|
||||
|
||||
Legacy formats (also supported):
|
||||
- praison://local/./path
|
||||
- praison://package/name/template
|
||||
- praison://github/owner/repo/template@ref
|
||||
"""
|
||||
|
||||
# URI patterns
|
||||
GITHUB_PATTERN = re.compile(
|
||||
r'^(?:github:|praison://github/)([^/]+)/([^/]+)/([^@]+)(?:@(.+))?$'
|
||||
)
|
||||
PACKAGE_PATTERN = re.compile(
|
||||
r'^(?:package:|praison://package/)([^/]+)/(.+)$'
|
||||
)
|
||||
HTTP_PATTERN = re.compile(
|
||||
r'^(?:https?://|praison://https?/).+'
|
||||
)
|
||||
LOCAL_PATTERN = re.compile(
|
||||
r'^(?:praison://local/)?([.~]?/.+|[a-zA-Z]:\\.+)$'
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve(cls, uri: str) -> ResolvedTemplate:
|
||||
"""
|
||||
Resolve a template URI to its source location.
|
||||
|
||||
Args:
|
||||
uri: Template URI string
|
||||
|
||||
Returns:
|
||||
ResolvedTemplate with source details
|
||||
|
||||
Raises:
|
||||
ValueError: If URI format is not recognized
|
||||
"""
|
||||
uri = uri.strip()
|
||||
|
||||
# Check GitHub pattern
|
||||
match = cls.GITHUB_PATTERN.match(uri)
|
||||
if match:
|
||||
owner, repo, path, ref = match.groups()
|
||||
return ResolvedTemplate(
|
||||
source=TemplateSource.GITHUB,
|
||||
path=path,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
ref=ref or "main"
|
||||
)
|
||||
|
||||
# Check package pattern
|
||||
match = cls.PACKAGE_PATTERN.match(uri)
|
||||
if match:
|
||||
package, template = match.groups()
|
||||
return ResolvedTemplate(
|
||||
source=TemplateSource.PACKAGE,
|
||||
path=f"{package}/{template}"
|
||||
)
|
||||
|
||||
# Check HTTP pattern
|
||||
if cls.HTTP_PATTERN.match(uri):
|
||||
# Extract actual URL
|
||||
url = uri
|
||||
if uri.startswith("praison://"):
|
||||
url = uri.replace("praison://", "")
|
||||
return ResolvedTemplate(
|
||||
source=TemplateSource.HTTP,
|
||||
path=url,
|
||||
url=url
|
||||
)
|
||||
|
||||
# Check local pattern or assume local if nothing else matches
|
||||
if cls.LOCAL_PATTERN.match(uri) or os.path.exists(uri) or uri.startswith(("./", "../", "~/")):
|
||||
path = uri
|
||||
if uri.startswith("praison://local/"):
|
||||
path = uri.replace("praison://local/", "")
|
||||
# Expand user home
|
||||
path = os.path.expanduser(path)
|
||||
return ResolvedTemplate(
|
||||
source=TemplateSource.LOCAL,
|
||||
path=path
|
||||
)
|
||||
|
||||
# Try as simple template name (assume package:agent_recipes/name)
|
||||
if re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', uri):
|
||||
return ResolvedTemplate(
|
||||
source=TemplateSource.PACKAGE,
|
||||
path=f"agent_recipes/{uri}"
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"Unrecognized template URI format: {uri}\n"
|
||||
"Supported formats:\n"
|
||||
" - Local: ./path, /absolute/path, ~/path\n"
|
||||
" - Package: package:agent_recipes/template-name\n"
|
||||
" - GitHub: github:owner/repo/template[@ref]\n"
|
||||
" - HTTP: https://example.com/template.yaml"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def parse_version(cls, uri: str) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
Extract version/ref from a URI.
|
||||
|
||||
Args:
|
||||
uri: Template URI string
|
||||
|
||||
Returns:
|
||||
Tuple of (base_uri, version)
|
||||
"""
|
||||
if "@" in uri and not uri.startswith("http"):
|
||||
parts = uri.rsplit("@", 1)
|
||||
return parts[0], parts[1]
|
||||
return uri, None
|
||||
|
||||
@classmethod
|
||||
def build_github_uri(
|
||||
cls,
|
||||
owner: str,
|
||||
repo: str,
|
||||
template: str,
|
||||
ref: Optional[str] = None
|
||||
) -> str:
|
||||
"""Build a GitHub template URI."""
|
||||
uri = f"github:{owner}/{repo}/{template}"
|
||||
if ref:
|
||||
uri += f"@{ref}"
|
||||
return uri
|
||||
|
||||
@classmethod
|
||||
def build_package_uri(cls, package: str, template: str) -> str:
|
||||
"""Build a package template URI."""
|
||||
return f"package:{package}/{template}"
|
||||
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
Template Security
|
||||
|
||||
Security primitives for template validation and safe extraction.
|
||||
Includes checksum verification, allowlists, and safe path handling.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityConfig:
|
||||
"""Security configuration for template operations."""
|
||||
|
||||
# Allowed template sources
|
||||
allowed_sources: Set[str] = field(default_factory=lambda: {
|
||||
"github:MervinPraison/agent-recipes",
|
||||
"package:agent_recipes",
|
||||
})
|
||||
|
||||
# Allow all local paths by default
|
||||
allow_local: bool = True
|
||||
|
||||
# Allow all GitHub sources (not just allowlisted)
|
||||
allow_any_github: bool = True
|
||||
|
||||
# Allow HTTP sources
|
||||
allow_http: bool = False
|
||||
|
||||
# Require checksum verification for remote templates
|
||||
require_checksum: bool = False
|
||||
|
||||
# Maximum template size in bytes (10MB default)
|
||||
max_template_size: int = 10 * 1024 * 1024
|
||||
|
||||
# Blocked file patterns (security risk)
|
||||
blocked_patterns: List[str] = field(default_factory=lambda: [
|
||||
r"\.\.\/", # Path traversal
|
||||
r"^\/", # Absolute paths in archives
|
||||
r"\.exe$", # Executables
|
||||
r"\.dll$",
|
||||
r"\.so$",
|
||||
r"\.dylib$",
|
||||
r"\.sh$", # Shell scripts (unless explicitly allowed)
|
||||
r"\.bat$",
|
||||
r"\.cmd$",
|
||||
r"\.ps1$",
|
||||
])
|
||||
|
||||
# Allowed file extensions
|
||||
allowed_extensions: Set[str] = field(default_factory=lambda: {
|
||||
".yaml", ".yml", ".json", ".md", ".txt", ".py",
|
||||
".toml", ".cfg", ".ini", ".env.example"
|
||||
})
|
||||
|
||||
|
||||
class TemplateSecurity:
|
||||
"""
|
||||
Security handler for template operations.
|
||||
|
||||
Provides:
|
||||
- Source allowlist validation
|
||||
- Checksum verification
|
||||
- Safe path extraction
|
||||
- File type validation
|
||||
"""
|
||||
|
||||
CONFIG_FILE = ".praison/security.yaml"
|
||||
|
||||
def __init__(self, config: Optional[SecurityConfig] = None):
|
||||
"""
|
||||
Initialize security handler.
|
||||
|
||||
Args:
|
||||
config: Security configuration (loads from file if not provided)
|
||||
"""
|
||||
self.config = config or self._load_config()
|
||||
|
||||
def _load_config(self) -> SecurityConfig:
|
||||
"""Load security config from file or use defaults."""
|
||||
config_path = Path.home() / self.CONFIG_FILE
|
||||
|
||||
if config_path.exists():
|
||||
try:
|
||||
import yaml
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
return SecurityConfig(
|
||||
allowed_sources=set(data.get("allowed_sources", [])),
|
||||
allow_local=data.get("allow_local", True),
|
||||
allow_any_github=data.get("allow_any_github", True),
|
||||
allow_http=data.get("allow_http", False),
|
||||
require_checksum=data.get("require_checksum", False),
|
||||
max_template_size=data.get("max_template_size", 10 * 1024 * 1024),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return SecurityConfig()
|
||||
|
||||
def is_source_allowed(self, uri: str) -> bool:
|
||||
"""
|
||||
Check if a template source is allowed.
|
||||
|
||||
Args:
|
||||
uri: Template URI
|
||||
|
||||
Returns:
|
||||
True if source is allowed
|
||||
"""
|
||||
# Local paths
|
||||
if uri.startswith(("./", "../", "~/", "/")) or os.path.exists(uri):
|
||||
return self.config.allow_local
|
||||
|
||||
# Package references
|
||||
if uri.startswith("package:"):
|
||||
package = uri.split(":")[1].split("/")[0]
|
||||
return (
|
||||
f"package:{package}" in self.config.allowed_sources or
|
||||
uri in self.config.allowed_sources
|
||||
)
|
||||
|
||||
# GitHub references
|
||||
if uri.startswith("github:"):
|
||||
if self.config.allow_any_github:
|
||||
return True
|
||||
# Check specific repo allowlist
|
||||
parts = uri.replace("github:", "").split("/")
|
||||
if len(parts) >= 2:
|
||||
repo_ref = f"github:{parts[0]}/{parts[1]}"
|
||||
return repo_ref in self.config.allowed_sources
|
||||
return False
|
||||
|
||||
# HTTP references
|
||||
if uri.startswith(("http://", "https://")):
|
||||
return self.config.allow_http
|
||||
|
||||
# Simple template name (assumes default repo)
|
||||
if re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', uri):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def verify_checksum(
|
||||
self,
|
||||
directory: Path,
|
||||
expected_checksum: str,
|
||||
algorithm: str = "sha256"
|
||||
) -> bool:
|
||||
"""
|
||||
Verify checksum of template directory.
|
||||
|
||||
Args:
|
||||
directory: Template directory
|
||||
expected_checksum: Expected checksum value
|
||||
algorithm: Hash algorithm (sha256, sha512, md5)
|
||||
|
||||
Returns:
|
||||
True if checksum matches
|
||||
"""
|
||||
actual = self.calculate_checksum(directory, algorithm)
|
||||
return actual == expected_checksum
|
||||
|
||||
def calculate_checksum(
|
||||
self,
|
||||
directory: Path,
|
||||
algorithm: str = "sha256"
|
||||
) -> str:
|
||||
"""
|
||||
Calculate checksum of template directory.
|
||||
|
||||
Args:
|
||||
directory: Template directory
|
||||
algorithm: Hash algorithm
|
||||
|
||||
Returns:
|
||||
Hex digest of checksum
|
||||
"""
|
||||
if algorithm == "sha256":
|
||||
hasher = hashlib.sha256()
|
||||
elif algorithm == "sha512":
|
||||
hasher = hashlib.sha512()
|
||||
elif algorithm == "md5":
|
||||
hasher = hashlib.md5()
|
||||
else:
|
||||
raise ValueError(f"Unsupported algorithm: {algorithm}")
|
||||
|
||||
for file_path in sorted(directory.rglob("*")):
|
||||
if file_path.is_file() and not file_path.name.startswith("."):
|
||||
hasher.update(file_path.name.encode())
|
||||
hasher.update(file_path.read_bytes())
|
||||
|
||||
return hasher.hexdigest()
|
||||
|
||||
def validate_path(self, path: str) -> bool:
|
||||
"""
|
||||
Validate a path for security issues.
|
||||
|
||||
Args:
|
||||
path: Path to validate
|
||||
|
||||
Returns:
|
||||
True if path is safe
|
||||
"""
|
||||
for pattern in self.config.blocked_patterns:
|
||||
if re.search(pattern, path, re.IGNORECASE):
|
||||
return False
|
||||
return True
|
||||
|
||||
def validate_file(self, file_path: Path) -> bool:
|
||||
"""
|
||||
Validate a file for security.
|
||||
|
||||
Args:
|
||||
file_path: Path to file
|
||||
|
||||
Returns:
|
||||
True if file is safe
|
||||
"""
|
||||
# Check extension
|
||||
if file_path.suffix.lower() not in self.config.allowed_extensions:
|
||||
# Allow files without extension (like README)
|
||||
if file_path.suffix:
|
||||
return False
|
||||
# Files without extension are allowed (like README, LICENSE)
|
||||
return True
|
||||
|
||||
# Check size
|
||||
if file_path.exists() and file_path.stat().st_size > self.config.max_template_size:
|
||||
return False
|
||||
|
||||
# Check path (only validate the filename, not the full path)
|
||||
return self.validate_path(file_path.name)
|
||||
|
||||
def validate_template_directory(self, directory: Path) -> List[str]:
|
||||
"""
|
||||
Validate all files in a template directory.
|
||||
|
||||
Args:
|
||||
directory: Template directory
|
||||
|
||||
Returns:
|
||||
List of validation errors (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
total_size = 0
|
||||
|
||||
for file_path in directory.rglob("*"):
|
||||
if file_path.is_file():
|
||||
# Check path safety
|
||||
if not self.validate_path(str(file_path.relative_to(directory))):
|
||||
errors.append(f"Unsafe path: {file_path.relative_to(directory)}")
|
||||
continue
|
||||
|
||||
# Check extension
|
||||
if file_path.suffix.lower() not in self.config.allowed_extensions:
|
||||
if file_path.suffix: # Has extension but not allowed
|
||||
errors.append(f"Blocked file type: {file_path.name}")
|
||||
|
||||
# Track size
|
||||
total_size += file_path.stat().st_size
|
||||
|
||||
if total_size > self.config.max_template_size:
|
||||
errors.append(
|
||||
f"Template too large: {total_size} bytes "
|
||||
f"(max: {self.config.max_template_size})"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
def sanitize_template_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Sanitize template configuration.
|
||||
|
||||
Removes potentially dangerous fields and validates structure.
|
||||
|
||||
Args:
|
||||
config: Raw template configuration
|
||||
|
||||
Returns:
|
||||
Sanitized configuration
|
||||
"""
|
||||
# Fields that are safe to keep
|
||||
safe_fields = {
|
||||
"name", "description", "version", "author", "license",
|
||||
"tags", "requires", "config", "workflow", "agents",
|
||||
"skills", "cli", "metadata"
|
||||
}
|
||||
|
||||
sanitized = {}
|
||||
for key, value in config.items():
|
||||
if key in safe_fields:
|
||||
sanitized[key] = value
|
||||
|
||||
return sanitized
|
||||
|
||||
def add_allowed_source(self, source: str) -> None:
|
||||
"""Add a source to the allowlist."""
|
||||
self.config.allowed_sources.add(source)
|
||||
|
||||
def remove_allowed_source(self, source: str) -> None:
|
||||
"""Remove a source from the allowlist."""
|
||||
self.config.allowed_sources.discard(source)
|
||||
|
||||
def save_config(self) -> None:
|
||||
"""Save current config to file."""
|
||||
import yaml
|
||||
|
||||
config_path = Path.home() / self.CONFIG_FILE
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = {
|
||||
"allowed_sources": list(self.config.allowed_sources),
|
||||
"allow_local": self.config.allow_local,
|
||||
"allow_any_github": self.config.allow_any_github,
|
||||
"allow_http": self.config.allow_http,
|
||||
"require_checksum": self.config.require_checksum,
|
||||
"max_template_size": self.config.max_template_size,
|
||||
}
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False)
|
||||
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
Tool Override Loader for PraisonAI.
|
||||
|
||||
Allows loading custom tools from files, modules, and directories.
|
||||
Supports runtime tool registration with context manager pattern.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Generator, List, Optional
|
||||
|
||||
|
||||
class SecurityError(Exception):
|
||||
"""Raised when a security violation is detected."""
|
||||
pass
|
||||
|
||||
|
||||
class ToolOverrideLoader:
|
||||
"""
|
||||
Loads custom tools from various sources.
|
||||
|
||||
Supports:
|
||||
- Python file paths
|
||||
- Module import paths
|
||||
- Directories containing tool files
|
||||
|
||||
Security:
|
||||
- Only local paths allowed by default
|
||||
- Remote URLs rejected
|
||||
- No auto-execution of arbitrary code
|
||||
"""
|
||||
|
||||
# Default custom tool directories
|
||||
DEFAULT_TOOL_DIRS = [
|
||||
"~/.praison/tools",
|
||||
"~/.config/praison/tools",
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize tool override loader."""
|
||||
self._loaded_tools: Dict[str, Callable] = {}
|
||||
|
||||
def get_default_tool_dirs(self) -> List[Path]:
|
||||
"""
|
||||
Get default custom tool directories.
|
||||
|
||||
Returns:
|
||||
List of Path objects for default tool directories
|
||||
"""
|
||||
return [Path(d).expanduser() for d in self.DEFAULT_TOOL_DIRS]
|
||||
|
||||
def load_from_file(self, file_path: str) -> Dict[str, Callable]:
|
||||
"""
|
||||
Load tools from a Python file.
|
||||
|
||||
Args:
|
||||
file_path: Path to Python file containing tool functions
|
||||
|
||||
Returns:
|
||||
Dict mapping tool names to callable functions
|
||||
|
||||
Raises:
|
||||
SecurityError: If path is a remote URL
|
||||
FileNotFoundError: If file doesn't exist
|
||||
"""
|
||||
# Security check - reject remote URLs
|
||||
if file_path.startswith(("http://", "https://", "ftp://")):
|
||||
raise SecurityError(
|
||||
f"Remote URLs not allowed for security: {file_path}. "
|
||||
"Only local file paths are permitted."
|
||||
)
|
||||
|
||||
path = Path(file_path).expanduser().resolve()
|
||||
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Tool file not found: {path}")
|
||||
|
||||
if not path.suffix == ".py":
|
||||
raise ValueError(f"Tool file must be a Python file (.py): {path}")
|
||||
|
||||
# Load the module
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"custom_tools_{path.stem}",
|
||||
path
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Could not load module from: {path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Extract callable functions (tools)
|
||||
tools = {}
|
||||
for name in dir(module):
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
obj = getattr(module, name)
|
||||
if callable(obj) and not isinstance(obj, type):
|
||||
tools[name] = obj
|
||||
|
||||
self._loaded_tools.update(tools)
|
||||
return tools
|
||||
|
||||
def load_from_module(self, module_path: str) -> Dict[str, Callable]:
|
||||
"""
|
||||
Load tools from a module import path.
|
||||
|
||||
Args:
|
||||
module_path: Python module path (e.g., 'mypackage.tools')
|
||||
|
||||
Returns:
|
||||
Dict mapping tool names to callable functions
|
||||
"""
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
except ImportError as e:
|
||||
raise ImportError(f"Could not import module: {module_path}") from e
|
||||
|
||||
# Extract callable functions (tools)
|
||||
tools = {}
|
||||
for name in dir(module):
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
obj = getattr(module, name)
|
||||
if callable(obj) and not isinstance(obj, type):
|
||||
tools[name] = obj
|
||||
|
||||
self._loaded_tools.update(tools)
|
||||
return tools
|
||||
|
||||
def load_from_directory(self, dir_path: str) -> Dict[str, Callable]:
|
||||
"""
|
||||
Load tools from all Python files in a directory.
|
||||
|
||||
Args:
|
||||
dir_path: Path to directory containing tool files
|
||||
|
||||
Returns:
|
||||
Dict mapping tool names to callable functions
|
||||
|
||||
Raises:
|
||||
SecurityError: If path is a remote URL
|
||||
"""
|
||||
# Security check
|
||||
if dir_path.startswith(("http://", "https://", "ftp://")):
|
||||
raise SecurityError(
|
||||
f"Remote URLs not allowed for security: {dir_path}. "
|
||||
"Only local directory paths are permitted."
|
||||
)
|
||||
|
||||
path = Path(dir_path).expanduser().resolve()
|
||||
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Tool directory not found: {path}")
|
||||
|
||||
if not path.is_dir():
|
||||
raise ValueError(f"Path is not a directory: {path}")
|
||||
|
||||
tools = {}
|
||||
for py_file in path.glob("*.py"):
|
||||
if py_file.name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
file_tools = self.load_from_file(str(py_file))
|
||||
tools.update(file_tools)
|
||||
except Exception:
|
||||
# Skip files that fail to load
|
||||
pass
|
||||
|
||||
return tools
|
||||
|
||||
def discover_tools_in_directory(self, dir_path: str) -> List[str]:
|
||||
"""
|
||||
Discover tool names in a directory without executing code.
|
||||
|
||||
Uses AST parsing to find function definitions.
|
||||
|
||||
Args:
|
||||
dir_path: Path to directory to scan
|
||||
|
||||
Returns:
|
||||
List of discovered tool function names
|
||||
"""
|
||||
path = Path(dir_path).expanduser().resolve()
|
||||
|
||||
if not path.exists() or not path.is_dir():
|
||||
return []
|
||||
|
||||
tool_names = []
|
||||
for py_file in path.glob("*.py"):
|
||||
if py_file.name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
content = py_file.read_text()
|
||||
tree = ast.parse(content)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
if not node.name.startswith("_"):
|
||||
tool_names.append(node.name)
|
||||
except Exception:
|
||||
# Skip files that fail to parse
|
||||
pass
|
||||
|
||||
return tool_names
|
||||
|
||||
@contextmanager
|
||||
def override_context(
|
||||
self,
|
||||
files: Optional[List[str]] = None,
|
||||
modules: Optional[List[str]] = None,
|
||||
directories: Optional[List[str]] = None
|
||||
) -> Generator[Dict[str, Callable], None, None]:
|
||||
"""
|
||||
Context manager for temporary tool overrides.
|
||||
|
||||
Tools loaded within this context are available only during
|
||||
the context and are cleaned up afterward.
|
||||
|
||||
Args:
|
||||
files: List of Python file paths to load
|
||||
modules: List of module paths to import
|
||||
directories: List of directories to scan
|
||||
|
||||
Yields:
|
||||
Dict of loaded tools
|
||||
"""
|
||||
# Store original state
|
||||
original_tools = self._loaded_tools.copy()
|
||||
|
||||
try:
|
||||
# Load tools from all sources
|
||||
loaded = {}
|
||||
|
||||
if files:
|
||||
for f in files:
|
||||
loaded.update(self.load_from_file(f))
|
||||
|
||||
if modules:
|
||||
for m in modules:
|
||||
loaded.update(self.load_from_module(m))
|
||||
|
||||
if directories:
|
||||
for d in directories:
|
||||
loaded.update(self.load_from_directory(d))
|
||||
|
||||
yield loaded
|
||||
|
||||
finally:
|
||||
# Restore original state
|
||||
self._loaded_tools = original_tools
|
||||
|
||||
def get_loaded_tools(self) -> Dict[str, Callable]:
|
||||
"""
|
||||
Get all currently loaded tools.
|
||||
|
||||
Returns:
|
||||
Dict mapping tool names to callable functions
|
||||
"""
|
||||
return self._loaded_tools.copy()
|
||||
|
||||
def clear_loaded_tools(self) -> None:
|
||||
"""Clear all loaded tools."""
|
||||
self._loaded_tools.clear()
|
||||
|
||||
|
||||
def create_tool_registry_with_overrides(
|
||||
override_files: Optional[List[str]] = None,
|
||||
override_dirs: Optional[List[str]] = None,
|
||||
include_defaults: bool = True
|
||||
) -> Dict[str, Callable]:
|
||||
"""
|
||||
Create a tool registry with custom overrides.
|
||||
|
||||
Resolution order (highest priority first):
|
||||
1. Override files (explicit)
|
||||
2. Override directories
|
||||
3. Default custom dirs (~/.praison/tools, etc.)
|
||||
4. Built-in tools
|
||||
|
||||
Args:
|
||||
override_files: Explicit tool files to load
|
||||
override_dirs: Directories to scan for tools
|
||||
include_defaults: Whether to include default tool directories
|
||||
|
||||
Returns:
|
||||
Dict mapping tool names to callable functions
|
||||
"""
|
||||
registry = {}
|
||||
loader = ToolOverrideLoader()
|
||||
|
||||
# Start with built-in tools (lowest priority)
|
||||
try:
|
||||
from praisonaiagents.tools import TOOL_MAPPINGS
|
||||
registry.update(TOOL_MAPPINGS)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Add default custom dirs
|
||||
if include_defaults:
|
||||
for dir_path in loader.get_default_tool_dirs():
|
||||
if dir_path.exists():
|
||||
try:
|
||||
tools = loader.load_from_directory(str(dir_path))
|
||||
registry.update(tools)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add override directories
|
||||
if override_dirs:
|
||||
for dir_path in override_dirs:
|
||||
try:
|
||||
tools = loader.load_from_directory(dir_path)
|
||||
registry.update(tools)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add override files (highest priority)
|
||||
if override_files:
|
||||
for file_path in override_files:
|
||||
try:
|
||||
tools = loader.load_from_file(file_path)
|
||||
registry.update(tools)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return registry
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Tools Doctor for PraisonAI.
|
||||
|
||||
Diagnoses tool availability, checks dependencies, and reports issues.
|
||||
Supports both human-readable and JSON output formats.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class ToolsDoctor:
|
||||
"""
|
||||
Diagnoses tool availability and dependencies.
|
||||
|
||||
Checks:
|
||||
- praisonai-tools installation
|
||||
- Built-in tools availability
|
||||
- Custom tool directories
|
||||
- Optional dependency packages
|
||||
"""
|
||||
|
||||
# Default custom tool directories
|
||||
DEFAULT_TOOL_DIRS = [
|
||||
"~/.praison/tools",
|
||||
"~/.config/praison/tools",
|
||||
]
|
||||
|
||||
# Known tools and their optional dependencies
|
||||
TOOL_DEPENDENCIES = {
|
||||
"youtube_tool": ["pytube", "youtube-transcript-api"],
|
||||
"whisper_tool": ["openai-whisper", "torch"],
|
||||
"vision_tool": ["pillow", "opencv-python"],
|
||||
"tavily_search": ["tavily-python"],
|
||||
"arxiv_search": ["arxiv"],
|
||||
"wikipedia_search": ["wikipedia"],
|
||||
}
|
||||
|
||||
def __init__(self, custom_dirs: Optional[List[str]] = None):
|
||||
"""
|
||||
Initialize tools doctor.
|
||||
|
||||
Args:
|
||||
custom_dirs: Additional custom tool directories to check
|
||||
"""
|
||||
self._custom_dirs = custom_dirs or []
|
||||
|
||||
def diagnose(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Run full diagnostics.
|
||||
|
||||
Returns:
|
||||
Dict with diagnostic results
|
||||
"""
|
||||
result = {
|
||||
"praisonai_tools_installed": self._check_praisonai_tools(),
|
||||
"praisonaiagents_installed": self._check_praisonaiagents(),
|
||||
"builtin_tools": self._get_builtin_tools(),
|
||||
"praisonai_tools_available": self._get_praisonai_tools_list(),
|
||||
"custom_tools_dirs": self._check_custom_dirs(),
|
||||
"tool_dependencies": self._check_tool_dependencies(),
|
||||
"issues": [],
|
||||
}
|
||||
|
||||
# Collect issues
|
||||
if not result["praisonaiagents_installed"]:
|
||||
result["issues"].append({
|
||||
"severity": "error",
|
||||
"message": "praisonaiagents not installed",
|
||||
"hint": "pip install praisonaiagents"
|
||||
})
|
||||
|
||||
if not result["praisonai_tools_installed"]:
|
||||
result["issues"].append({
|
||||
"severity": "warning",
|
||||
"message": "praisonai-tools not installed (optional)",
|
||||
"hint": "pip install praisonai-tools"
|
||||
})
|
||||
|
||||
# Check for missing dependencies
|
||||
for tool, deps in result["tool_dependencies"].items():
|
||||
missing = [d for d in deps if not d["available"]]
|
||||
if missing:
|
||||
result["issues"].append({
|
||||
"severity": "info",
|
||||
"message": f"Tool '{tool}' has missing optional dependencies",
|
||||
"hint": f"pip install {' '.join(d['name'] for d in missing)}"
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def _check_praisonai_tools(self) -> bool:
|
||||
"""Check if praisonai-tools is installed."""
|
||||
spec = importlib.util.find_spec("praisonai_tools")
|
||||
return spec is not None
|
||||
|
||||
def _check_praisonaiagents(self) -> bool:
|
||||
"""Check if praisonaiagents is installed."""
|
||||
spec = importlib.util.find_spec("praisonaiagents")
|
||||
return spec is not None
|
||||
|
||||
def _get_builtin_tools(self) -> List[str]:
|
||||
"""Get list of built-in tools."""
|
||||
tools = []
|
||||
try:
|
||||
from praisonaiagents.tools import TOOL_MAPPINGS
|
||||
tools = list(TOOL_MAPPINGS.keys())
|
||||
except ImportError:
|
||||
pass
|
||||
return tools
|
||||
|
||||
def _get_praisonai_tools_list(self) -> List[str]:
|
||||
"""Get list of tools from praisonai-tools package."""
|
||||
tools = []
|
||||
try:
|
||||
import praisonai_tools
|
||||
# Get all callable attributes that look like tools
|
||||
for name in dir(praisonai_tools):
|
||||
if not name.startswith("_"):
|
||||
obj = getattr(praisonai_tools, name, None)
|
||||
if callable(obj):
|
||||
tools.append(name)
|
||||
except ImportError:
|
||||
pass
|
||||
return tools
|
||||
|
||||
def _check_custom_dirs(self) -> List[Dict[str, Any]]:
|
||||
"""Check custom tool directories."""
|
||||
dirs = []
|
||||
|
||||
# Check default dirs
|
||||
for dir_path in self.DEFAULT_TOOL_DIRS:
|
||||
path = Path(dir_path).expanduser()
|
||||
tool_count = 0
|
||||
if path.exists():
|
||||
# Count Python files
|
||||
tool_count = len(list(path.glob("*.py")))
|
||||
|
||||
dirs.append({
|
||||
"path": str(path),
|
||||
"exists": path.exists(),
|
||||
"tool_count": tool_count
|
||||
})
|
||||
|
||||
# Check custom dirs
|
||||
for dir_path in self._custom_dirs:
|
||||
path = Path(dir_path).expanduser()
|
||||
tool_count = 0
|
||||
if path.exists():
|
||||
tool_count = len(list(path.glob("*.py")))
|
||||
|
||||
dirs.append({
|
||||
"path": str(path),
|
||||
"exists": path.exists(),
|
||||
"tool_count": tool_count,
|
||||
"custom": True
|
||||
})
|
||||
|
||||
return dirs
|
||||
|
||||
def _check_tool_dependencies(self) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Check dependencies for known tools."""
|
||||
result = {}
|
||||
|
||||
for tool, deps in self.TOOL_DEPENDENCIES.items():
|
||||
result[tool] = []
|
||||
for dep in deps:
|
||||
spec = importlib.util.find_spec(dep.replace("-", "_"))
|
||||
result[tool].append({
|
||||
"name": dep,
|
||||
"available": spec is not None
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def diagnose_json(self) -> str:
|
||||
"""
|
||||
Run diagnostics and return JSON output.
|
||||
|
||||
Returns:
|
||||
JSON string with diagnostic results
|
||||
"""
|
||||
result = self.diagnose()
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
def diagnose_human(self) -> str:
|
||||
"""
|
||||
Run diagnostics and return human-readable output.
|
||||
|
||||
Returns:
|
||||
Formatted string with diagnostic results
|
||||
"""
|
||||
result = self.diagnose()
|
||||
lines = []
|
||||
|
||||
lines.append("=" * 60)
|
||||
lines.append("PraisonAI Tools Doctor")
|
||||
lines.append("=" * 60)
|
||||
lines.append("")
|
||||
|
||||
# Core packages
|
||||
lines.append("Core Packages:")
|
||||
status = "✓" if result["praisonaiagents_installed"] else "✗"
|
||||
lines.append(f" {status} praisonaiagents")
|
||||
status = "✓" if result["praisonai_tools_installed"] else "✗"
|
||||
lines.append(f" {status} praisonai-tools (optional)")
|
||||
lines.append("")
|
||||
|
||||
# Built-in tools
|
||||
lines.append(f"Built-in Tools ({len(result['builtin_tools'])}):")
|
||||
if result["builtin_tools"]:
|
||||
for tool in result["builtin_tools"][:10]:
|
||||
lines.append(f" • {tool}")
|
||||
if len(result["builtin_tools"]) > 10:
|
||||
lines.append(f" ... and {len(result['builtin_tools']) - 10} more")
|
||||
else:
|
||||
lines.append(" (none found)")
|
||||
lines.append("")
|
||||
|
||||
# praisonai-tools
|
||||
if result["praisonai_tools_available"]:
|
||||
lines.append(f"praisonai-tools ({len(result['praisonai_tools_available'])}):")
|
||||
for tool in result["praisonai_tools_available"][:10]:
|
||||
lines.append(f" • {tool}")
|
||||
if len(result["praisonai_tools_available"]) > 10:
|
||||
lines.append(f" ... and {len(result['praisonai_tools_available']) - 10} more")
|
||||
lines.append("")
|
||||
|
||||
# Custom directories
|
||||
lines.append("Custom Tool Directories:")
|
||||
for dir_info in result["custom_tools_dirs"]:
|
||||
status = "✓" if dir_info["exists"] else "✗"
|
||||
count = f" ({dir_info['tool_count']} tools)" if dir_info["exists"] else ""
|
||||
custom = " [custom]" if dir_info.get("custom") else ""
|
||||
lines.append(f" {status} {dir_info['path']}{count}{custom}")
|
||||
lines.append("")
|
||||
|
||||
# Issues
|
||||
if result["issues"]:
|
||||
lines.append("Issues Found:")
|
||||
for issue in result["issues"]:
|
||||
icon = {"error": "❌", "warning": "⚠️", "info": "ℹ️"}.get(issue["severity"], "•")
|
||||
lines.append(f" {icon} {issue['message']}")
|
||||
if issue.get("hint"):
|
||||
lines.append(f" Hint: {issue['hint']}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("✓ No issues found")
|
||||
lines.append("")
|
||||
|
||||
lines.append("=" * 60)
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.5.7"
|
||||
__version__ = "2.6.2"
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user