llm_gateway 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +565 -129
- data/Rakefile +8 -3
- data/docs/migration-guide.md +135 -0
- data/lib/llm_gateway/adapters/adapter.rb +173 -0
- data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +23 -0
- data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +111 -0
- data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +12 -10
- data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +50 -0
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +110 -0
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +53 -0
- data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +47 -0
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +27 -0
- data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
- data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +22 -0
- data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +31 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +110 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +105 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +40 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +242 -0
- data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +20 -0
- data/lib/llm_gateway/adapters/openai/file_output_mapper.rb +25 -0
- data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +120 -0
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +106 -0
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +41 -0
- data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +47 -0
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +340 -0
- data/lib/llm_gateway/adapters/openai/responses_adapter.rb +20 -0
- data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
- data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
- data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +38 -0
- data/lib/llm_gateway/adapters/{open_ai/output_mapper.rb → option_mapper.rb} +5 -2
- data/lib/llm_gateway/adapters/stream_accumulator.rb +91 -0
- data/lib/llm_gateway/adapters/structs.rb +145 -0
- data/lib/llm_gateway/base_client.rb +97 -1
- data/lib/llm_gateway/client.rb +66 -54
- data/lib/llm_gateway/clients/anthropic.rb +167 -0
- data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
- data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
- data/lib/llm_gateway/clients/groq.rb +54 -0
- data/lib/llm_gateway/clients/openai.rb +208 -0
- data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
- data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
- data/lib/llm_gateway/errors.rb +23 -0
- data/lib/llm_gateway/prompt.rb +12 -1
- data/lib/llm_gateway/provider_registry.rb +37 -0
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +169 -10
- data/scripts/create_anthropic_credentials.rb +106 -0
- data/scripts/create_openai_codex_credentials.rb +116 -0
- data/scripts/generate_handoff_live_fixture.rb +169 -0
- data/scripts/generate_handoff_media_fixture.rb +167 -0
- metadata +64 -21
- data/lib/llm_gateway/adapters/claude/client.rb +0 -56
- data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -30
- data/lib/llm_gateway/adapters/groq/client.rb +0 -58
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -105
- data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -62
- data/lib/llm_gateway/adapters/open_ai/client.rb +0 -59
- data/lib/llm_gateway/adapters/open_ai/input_mapper.rb +0 -63
- data/sample/claude_code_clone/agent.rb +0 -65
- data/sample/claude_code_clone/claude_code_clone.rb +0 -40
- data/sample/claude_code_clone/prompt.rb +0 -79
- data/sample/claude_code_clone/run.rb +0 -47
- data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
- data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
- data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
- data/sample/claude_code_clone/tools/read_tool.rb +0 -61
- data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
class BashTool < LlmGateway::Tool
|
|
2
|
-
name 'Bash'
|
|
3
|
-
description 'Execute shell commands'
|
|
4
|
-
input_schema({
|
|
5
|
-
type: 'object',
|
|
6
|
-
properties: {
|
|
7
|
-
command: { type: 'string', description: 'Shell command to execute' },
|
|
8
|
-
description: { type: 'string', description: 'Human-readable description' },
|
|
9
|
-
timeout: { type: 'integer', description: 'Timeout in milliseconds' }
|
|
10
|
-
},
|
|
11
|
-
required: [ 'command' ]
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
def execute(input)
|
|
15
|
-
command = input[:command]
|
|
16
|
-
description = input[:description]
|
|
17
|
-
timeout = input[:timeout] || 120000 # Default 2 minutes
|
|
18
|
-
|
|
19
|
-
if description
|
|
20
|
-
puts "Executing: #{command}"
|
|
21
|
-
puts "Description: #{description}\n\n"
|
|
22
|
-
else
|
|
23
|
-
puts "Executing: #{command}\n\n"
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
begin
|
|
27
|
-
# Convert timeout from milliseconds to seconds
|
|
28
|
-
timeout_seconds = timeout / 1000.0
|
|
29
|
-
|
|
30
|
-
# Use timeout command if available, otherwise use Ruby's timeout
|
|
31
|
-
if system('which timeout > /dev/null 2>&1')
|
|
32
|
-
result = `timeout #{timeout_seconds}s #{command} 2>&1`
|
|
33
|
-
exit_status = $?
|
|
34
|
-
else
|
|
35
|
-
require 'timeout'
|
|
36
|
-
result = Timeout.timeout(timeout_seconds) do
|
|
37
|
-
`#{command} 2>&1`
|
|
38
|
-
end
|
|
39
|
-
exit_status = $?
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
if exit_status.success?
|
|
43
|
-
result.empty? ? "Command completed successfully (no output)" : result
|
|
44
|
-
else
|
|
45
|
-
"Command failed with exit code #{exit_status.exitstatus}:\n#{result}"
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
rescue Timeout::Error
|
|
49
|
-
"Command timed out after #{timeout_seconds} seconds"
|
|
50
|
-
rescue => e
|
|
51
|
-
"Error executing command: #{e.message}"
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
class EditTool < LlmGateway::Tool
|
|
2
|
-
name 'Edit'
|
|
3
|
-
description 'Modify existing files by replacing specific text strings'
|
|
4
|
-
input_schema({
|
|
5
|
-
type: 'object',
|
|
6
|
-
properties: {
|
|
7
|
-
file_path: { type: 'string', description: 'Absolute path to file to modify' },
|
|
8
|
-
old_string: { type: 'string', description: 'Exact text to replace' },
|
|
9
|
-
new_string: { type: 'string', description: 'Replacement text' },
|
|
10
|
-
replace_all: { type: 'boolean', description: 'Replace all occurrences (default: false)' }
|
|
11
|
-
},
|
|
12
|
-
required: [ 'file_path', 'old_string', 'new_string' ]
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
def execute(input)
|
|
16
|
-
file_path = input[:file_path]
|
|
17
|
-
old_string = input[:old_string]
|
|
18
|
-
new_string = input[:new_string]
|
|
19
|
-
replace_all = input[:replace_all] || false
|
|
20
|
-
|
|
21
|
-
# Validate file exists
|
|
22
|
-
unless File.exist?(file_path)
|
|
23
|
-
return "Error: File not found at #{file_path}"
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Read file content
|
|
27
|
-
begin
|
|
28
|
-
content = File.read(file_path)
|
|
29
|
-
rescue => e
|
|
30
|
-
return "Error reading file: #{e.message}"
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# Check if old_string exists in file
|
|
34
|
-
unless content.include?(old_string)
|
|
35
|
-
return "Error: Text '#{old_string}' not found in file"
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Perform replacement
|
|
39
|
-
if replace_all
|
|
40
|
-
updated_content = content.gsub(old_string, new_string)
|
|
41
|
-
occurrences = content.scan(old_string).length
|
|
42
|
-
else
|
|
43
|
-
# Replace only first occurrence
|
|
44
|
-
updated_content = content.sub(old_string, new_string)
|
|
45
|
-
occurrences = 1
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Check if replacement would result in same content
|
|
49
|
-
if content == updated_content
|
|
50
|
-
return "Error: old_string and new_string are identical, no changes made"
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Write updated content back to file
|
|
54
|
-
begin
|
|
55
|
-
File.write(file_path, updated_content)
|
|
56
|
-
"Successfully replaced #{occurrences} occurrence(s) in #{file_path}"
|
|
57
|
-
rescue => e
|
|
58
|
-
"Error writing file: #{e.message}"
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
class GrepTool < LlmGateway::Tool
|
|
2
|
-
name 'Grep'
|
|
3
|
-
description 'Search for patterns in files using regex'
|
|
4
|
-
input_schema({
|
|
5
|
-
type: 'object',
|
|
6
|
-
properties: {
|
|
7
|
-
pattern: { type: 'string', description: 'Regex pattern to search for' },
|
|
8
|
-
path: { type: 'string', description: 'File or directory path' },
|
|
9
|
-
output_mode: {
|
|
10
|
-
type: 'string',
|
|
11
|
-
enum: [ 'content', 'files_with_matches', 'count' ],
|
|
12
|
-
description: 'Output mode: content, files_with_matches, or count'
|
|
13
|
-
},
|
|
14
|
-
glob: { type: 'string', description: 'File pattern filter (e.g., "*.rb")' },
|
|
15
|
-
'-n': { type: 'boolean', description: 'Show line numbers' },
|
|
16
|
-
'-i': { type: 'boolean', description: 'Case insensitive search' },
|
|
17
|
-
'-C': { type: 'integer', description: 'Context lines around matches' }
|
|
18
|
-
},
|
|
19
|
-
required: [ 'pattern' ]
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
def execute(input)
|
|
23
|
-
pattern = input[:pattern]
|
|
24
|
-
path = input[:path] || '.'
|
|
25
|
-
output_mode = input[:output_mode] || 'files_with_matches'
|
|
26
|
-
glob = input[:glob]
|
|
27
|
-
show_line_numbers = input['-n'] || false
|
|
28
|
-
case_insensitive = input['-i'] || false
|
|
29
|
-
context_lines = input['-C'] || 0
|
|
30
|
-
|
|
31
|
-
# Build grep command
|
|
32
|
-
cmd_parts = [ 'grep' ]
|
|
33
|
-
|
|
34
|
-
# Add flags
|
|
35
|
-
cmd_parts << '-r' unless File.file?(path) # Recursive for directories
|
|
36
|
-
cmd_parts << '-n' if show_line_numbers && output_mode == 'content'
|
|
37
|
-
cmd_parts << '-i' if case_insensitive
|
|
38
|
-
cmd_parts << "-C#{context_lines}" if context_lines > 0 && output_mode == 'content'
|
|
39
|
-
|
|
40
|
-
# Output mode flags
|
|
41
|
-
case output_mode
|
|
42
|
-
when 'files_with_matches'
|
|
43
|
-
cmd_parts << '-l'
|
|
44
|
-
when 'count'
|
|
45
|
-
cmd_parts << '-c'
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Add pattern and path
|
|
49
|
-
cmd_parts << "'#{pattern}'"
|
|
50
|
-
|
|
51
|
-
# Handle glob pattern
|
|
52
|
-
if glob
|
|
53
|
-
if File.directory?(path)
|
|
54
|
-
cmd_parts << "#{path}/**/*"
|
|
55
|
-
# Use shell globbing with find for better glob support
|
|
56
|
-
find_cmd = "find #{path} -name '#{glob}' -type f"
|
|
57
|
-
files_result = `#{find_cmd} 2>/dev/null`
|
|
58
|
-
if files_result.empty?
|
|
59
|
-
return "No files found matching pattern '#{glob}' in #{path}"
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Run grep on each matching file
|
|
63
|
-
files = files_result.strip.split("\n")
|
|
64
|
-
results = []
|
|
65
|
-
|
|
66
|
-
files.each do |file|
|
|
67
|
-
grep_cmd = cmd_parts[0..-2].join(' ') + " '#{pattern}' '#{file}'"
|
|
68
|
-
result = `#{grep_cmd} 2>/dev/null`
|
|
69
|
-
results << result unless result.empty?
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
return results.empty? ? "No matches found" : results.join("\n")
|
|
73
|
-
else
|
|
74
|
-
cmd_parts << path
|
|
75
|
-
end
|
|
76
|
-
else
|
|
77
|
-
cmd_parts << path
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
command = cmd_parts.join(' ')
|
|
81
|
-
|
|
82
|
-
begin
|
|
83
|
-
puts "Executing: #{command}"
|
|
84
|
-
result = `#{command} 2>&1`
|
|
85
|
-
exit_status = $?
|
|
86
|
-
|
|
87
|
-
if exit_status.success?
|
|
88
|
-
if result.empty?
|
|
89
|
-
"No matches found"
|
|
90
|
-
else
|
|
91
|
-
case output_mode
|
|
92
|
-
when 'content'
|
|
93
|
-
result
|
|
94
|
-
when 'files_with_matches'
|
|
95
|
-
result
|
|
96
|
-
when 'count'
|
|
97
|
-
result
|
|
98
|
-
else
|
|
99
|
-
result
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
elsif exit_status.exitstatus == 1
|
|
103
|
-
# grep returns 1 when no matches found, which is normal
|
|
104
|
-
"No matches found"
|
|
105
|
-
else
|
|
106
|
-
"Error: #{result}"
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
rescue => e
|
|
110
|
-
"Error executing grep: #{e.message}"
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
end
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
class ReadTool < LlmGateway::Tool
|
|
2
|
-
name 'Read'
|
|
3
|
-
description 'Read file contents with optional pagination'
|
|
4
|
-
input_schema({
|
|
5
|
-
type: 'object',
|
|
6
|
-
properties: {
|
|
7
|
-
file_path: { type: 'string', description: 'Absolute path to file' },
|
|
8
|
-
limit: { type: 'integer', description: 'Number of lines to read' },
|
|
9
|
-
offset: { type: 'integer', description: 'Starting line number' }
|
|
10
|
-
},
|
|
11
|
-
required: [ 'file_path' ]
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
def execute(input)
|
|
15
|
-
file_path = input[:file_path]
|
|
16
|
-
limit = input[:limit]
|
|
17
|
-
offset = input[:offset] || 0
|
|
18
|
-
|
|
19
|
-
# Validate file exists
|
|
20
|
-
unless File.exist?(file_path)
|
|
21
|
-
return "Error: File not found at #{file_path}"
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
# Check if it's a directory
|
|
25
|
-
if File.directory?(file_path)
|
|
26
|
-
return "Error: #{file_path} is a directory, not a file"
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
begin
|
|
30
|
-
lines = File.readlines(file_path, chomp: true)
|
|
31
|
-
|
|
32
|
-
# Apply offset
|
|
33
|
-
if offset > 0
|
|
34
|
-
if offset >= lines.length
|
|
35
|
-
return "Error: Offset #{offset} exceeds file length (#{lines.length} lines)"
|
|
36
|
-
end
|
|
37
|
-
lines = lines[offset..-1]
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Apply limit
|
|
41
|
-
if limit && limit > 0
|
|
42
|
-
lines = lines[0, limit]
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Format output with line numbers (similar to cat -n)
|
|
46
|
-
output = lines.each_with_index.map do |line, index|
|
|
47
|
-
line_number = offset + index + 1
|
|
48
|
-
"#{line_number.to_s.rjust(6)}→#{line}"
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
if output.empty?
|
|
52
|
-
"File is empty or no lines in specified range"
|
|
53
|
-
else
|
|
54
|
-
output.join("\n")
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
rescue => e
|
|
58
|
-
"Error reading file: #{e.message}"
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
require 'json'
|
|
2
|
-
|
|
3
|
-
class TodoWriteTool < LlmGateway::Tool
|
|
4
|
-
name 'TodoWrite'
|
|
5
|
-
description 'Create and manage structured task lists'
|
|
6
|
-
input_schema({
|
|
7
|
-
type: 'object',
|
|
8
|
-
properties: {
|
|
9
|
-
todos: {
|
|
10
|
-
type: 'array',
|
|
11
|
-
description: 'Array of todo objects',
|
|
12
|
-
items: {
|
|
13
|
-
type: 'object',
|
|
14
|
-
properties: {
|
|
15
|
-
id: { type: 'string', description: 'Unique identifier' },
|
|
16
|
-
content: { type: 'string', description: 'Task description' },
|
|
17
|
-
status: {
|
|
18
|
-
type: 'string',
|
|
19
|
-
enum: [ 'pending', 'in_progress', 'completed' ],
|
|
20
|
-
description: 'Task status'
|
|
21
|
-
},
|
|
22
|
-
priority: {
|
|
23
|
-
type: 'string',
|
|
24
|
-
enum: [ 'high', 'medium', 'low' ],
|
|
25
|
-
description: 'Task priority'
|
|
26
|
-
}
|
|
27
|
-
},
|
|
28
|
-
required: [ 'id', 'content', 'status', 'priority' ]
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
},
|
|
32
|
-
required: [ 'todos' ]
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
def execute(input)
|
|
36
|
-
todos = input[:todos]
|
|
37
|
-
|
|
38
|
-
# Validate todos structure
|
|
39
|
-
todos.each_with_index do |todo, index|
|
|
40
|
-
unless todo.is_a?(Hash)
|
|
41
|
-
return "Error: Todo at index #{index} is not a hash"
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
required_fields = [ 'id', 'content', 'status', 'priority' ]
|
|
45
|
-
missing_fields = required_fields - todo.keys.map(&:to_s)
|
|
46
|
-
unless missing_fields.empty?
|
|
47
|
-
return "Error: Todo at index #{index} missing required fields: #{missing_fields.join(', ')}"
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
valid_statuses = [ 'pending', 'in_progress', 'completed' ]
|
|
51
|
-
unless valid_statuses.include?(todo['status'])
|
|
52
|
-
return "Error: Invalid status '#{todo['status']}' in todo #{todo['id']}. Must be one of: #{valid_statuses.join(', ')}"
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
valid_priorities = [ 'high', 'medium', 'low' ]
|
|
56
|
-
unless valid_priorities.include?(todo['priority'])
|
|
57
|
-
return "Error: Invalid priority '#{todo['priority']}' in todo #{todo['id']}. Must be one of: #{valid_priorities.join(', ')}"
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Store todos (in practice, this might be saved to a file or database)
|
|
62
|
-
@todos = todos
|
|
63
|
-
|
|
64
|
-
# Generate summary
|
|
65
|
-
total = todos.length
|
|
66
|
-
pending = todos.count { |t| t['status'] == 'pending' }
|
|
67
|
-
in_progress = todos.count { |t| t['status'] == 'in_progress' }
|
|
68
|
-
completed = todos.count { |t| t['status'] == 'completed' }
|
|
69
|
-
|
|
70
|
-
summary = "Todo list updated successfully:\n"
|
|
71
|
-
summary += "Total tasks: #{total}\n"
|
|
72
|
-
summary += "Pending: #{pending}, In Progress: #{in_progress}, Completed: #{completed}\n\n"
|
|
73
|
-
|
|
74
|
-
# List current todos
|
|
75
|
-
summary += "Current tasks:\n"
|
|
76
|
-
todos.each do |todo|
|
|
77
|
-
status_icon = case todo['status']
|
|
78
|
-
when 'pending' then '⏳'
|
|
79
|
-
when 'in_progress' then '🔄'
|
|
80
|
-
when 'completed' then '✅'
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
priority_icon = case todo['priority']
|
|
84
|
-
when 'high' then '🔴'
|
|
85
|
-
when 'medium' then '🟡'
|
|
86
|
-
when 'low' then '🟢'
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
summary += "#{status_icon} #{priority_icon} [#{todo['id']}] #{todo['content']}\n"
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
summary
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def self.current_todos
|
|
96
|
-
@todos || []
|
|
97
|
-
end
|
|
98
|
-
end
|