rubyrlm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE +21 -0
  4. data/README.md +300 -0
  5. data/bin/rubyrlm +168 -0
  6. data/lib/rubyrlm/backends/base.rb +9 -0
  7. data/lib/rubyrlm/backends/gemini_rest.rb +317 -0
  8. data/lib/rubyrlm/client.rb +643 -0
  9. data/lib/rubyrlm/completion.rb +71 -0
  10. data/lib/rubyrlm/errors.rb +9 -0
  11. data/lib/rubyrlm/logger/jsonl_logger.rb +27 -0
  12. data/lib/rubyrlm/pricing.rb +88 -0
  13. data/lib/rubyrlm/prompts/system_prompt.rb +108 -0
  14. data/lib/rubyrlm/protocol/action_parser.rb +84 -0
  15. data/lib/rubyrlm/repl/code_validator.rb +113 -0
  16. data/lib/rubyrlm/repl/docker_repl/container_manager.rb +158 -0
  17. data/lib/rubyrlm/repl/docker_repl/host_rpc_server.rb +164 -0
  18. data/lib/rubyrlm/repl/docker_repl/protocol.rb +26 -0
  19. data/lib/rubyrlm/repl/docker_repl.rb +190 -0
  20. data/lib/rubyrlm/repl/execution_result.rb +41 -0
  21. data/lib/rubyrlm/repl/local_repl.rb +476 -0
  22. data/lib/rubyrlm/sub_call_cache.rb +47 -0
  23. data/lib/rubyrlm/version.rb +3 -0
  24. data/lib/rubyrlm/web/app.rb +41 -0
  25. data/lib/rubyrlm/web/public/css/components.css +649 -0
  26. data/lib/rubyrlm/web/public/css/design-system.css +1396 -0
  27. data/lib/rubyrlm/web/public/js/app.js +1016 -0
  28. data/lib/rubyrlm/web/public/js/components/charts.js +68 -0
  29. data/lib/rubyrlm/web/public/js/components/context-inspector.js +94 -0
  30. data/lib/rubyrlm/web/public/js/components/exec-chain.js +105 -0
  31. data/lib/rubyrlm/web/public/js/components/kpi-dashboard.js +187 -0
  32. data/lib/rubyrlm/web/public/js/components/query-panel.js +335 -0
  33. data/lib/rubyrlm/web/public/js/components/recursion-tree.js +83 -0
  34. data/lib/rubyrlm/web/public/js/components/session-list.js +160 -0
  35. data/lib/rubyrlm/web/public/js/components/step-navigator.js +129 -0
  36. data/lib/rubyrlm/web/public/js/components/timeline.js +281 -0
  37. data/lib/rubyrlm/web/public/js/lib/animation.js +46 -0
  38. data/lib/rubyrlm/web/public/js/lib/chart-renderer.js +116 -0
  39. data/lib/rubyrlm/web/public/js/lib/diagram-renderer.js +233 -0
  40. data/lib/rubyrlm/web/public/js/lib/sse-client.js +94 -0
  41. data/lib/rubyrlm/web/public/js/lib/theme-manager.js +39 -0
  42. data/lib/rubyrlm/web/public/js/utils.js +57 -0
  43. data/lib/rubyrlm/web/routes/api.rb +129 -0
  44. data/lib/rubyrlm/web/routes/pages.rb +365 -0
  45. data/lib/rubyrlm/web/routes/sse.rb +95 -0
  46. data/lib/rubyrlm/web/services/event_broadcaster.rb +36 -0
  47. data/lib/rubyrlm/web/services/export_service.rb +903 -0
  48. data/lib/rubyrlm/web/services/query_service.rb +221 -0
  49. data/lib/rubyrlm/web/services/session_loader.rb +356 -0
  50. data/lib/rubyrlm/web/services/streaming_logger.rb +22 -0
  51. data/lib/rubyrlm.rb +18 -0
  52. metadata +208 -0
@@ -0,0 +1,68 @@
1
+ // Charts component - session-level charts
2
+
3
+ const Charts = {
4
+ renderSessionCharts(session) {
5
+ // Render a per-iteration token usage stacked bar chart
6
+ const iterations = session.iterations || [];
7
+
8
+ // Gather per-iteration usage data
9
+ const usageData = [];
10
+ iterations.forEach(it => {
11
+ const d = it.data || it;
12
+ if (d.usage && (d.usage.prompt_tokens || d.usage.candidate_tokens)) {
13
+ usageData.push({
14
+ label: 'Iter ' + (d.iteration || usageData.length + 1),
15
+ prompt: d.usage.prompt_tokens || 0,
16
+ candidate: d.usage.candidate_tokens || 0
17
+ });
18
+ }
19
+ });
20
+
21
+ if (usageData.length === 0) return;
22
+
23
+ // Create or reuse the canvas element inside the usage-summary section
24
+ const summaryDiv = document.getElementById('usage-summary');
25
+ if (!summaryDiv) return;
26
+
27
+ let chartWrapper = document.getElementById('session-token-chart-wrapper');
28
+ if (!chartWrapper) {
29
+ chartWrapper = document.createElement('div');
30
+ chartWrapper.id = 'session-token-chart-wrapper';
31
+ chartWrapper.className = 'chart-container chart-container--small';
32
+ chartWrapper.style.marginTop = '1rem';
33
+
34
+ const canvas = document.createElement('canvas');
35
+ canvas.id = 'session-token-chart';
36
+ chartWrapper.appendChild(canvas);
37
+ summaryDiv.appendChild(chartWrapper);
38
+ }
39
+
40
+ // Render stacked bar chart via ChartRenderer
41
+ ChartRenderer.bar('session-token-chart', {
42
+ stacked: true,
43
+ data: {
44
+ labels: usageData.map(d => d.label),
45
+ datasets: [
46
+ {
47
+ label: 'Prompt Tokens',
48
+ data: usageData.map(d => d.prompt),
49
+ backgroundColor: '#10b981',
50
+ stack: 'tokens'
51
+ },
52
+ {
53
+ label: 'Candidate Tokens',
54
+ data: usageData.map(d => d.candidate),
55
+ backgroundColor: '#3b82f6',
56
+ stack: 'tokens'
57
+ }
58
+ ]
59
+ },
60
+ options: {
61
+ scales: {
62
+ x: { stacked: true },
63
+ y: { stacked: true }
64
+ }
65
+ }
66
+ });
67
+ }
68
+ };
@@ -0,0 +1,94 @@
1
+ // Context data inspector - tree view for exploring context
2
+
3
+ const ContextInspector = {
4
+ render(containerId, data) {
5
+ const container = document.getElementById(containerId);
6
+ if (!container) return;
7
+ container.textContent = '';
8
+
9
+ if (data == null) {
10
+ container.textContent = 'No context data';
11
+ return;
12
+ }
13
+
14
+ const tree = document.createElement('div');
15
+ tree.className = 'context-tree';
16
+ this.buildTree(tree, data, 0);
17
+ container.appendChild(tree);
18
+ },
19
+
20
+ buildTree(parent, data, depth) {
21
+ if (depth > 5) {
22
+ const item = document.createElement('div');
23
+ item.className = 'context-tree__item';
24
+ item.style.setProperty('--depth', depth);
25
+ item.textContent = '...';
26
+ parent.appendChild(item);
27
+ return;
28
+ }
29
+
30
+ if (typeof data === 'object' && data !== null) {
31
+ const entries = Array.isArray(data)
32
+ ? data.map((v, i) => [i, v])
33
+ : Object.entries(data);
34
+
35
+ entries.forEach(([key, value]) => {
36
+ const item = document.createElement('div');
37
+ item.className = 'context-tree__item';
38
+ item.style.setProperty('--depth', depth);
39
+
40
+ const isExpandable = typeof value === 'object' && value !== null;
41
+
42
+ if (isExpandable) {
43
+ const toggle = document.createElement('span');
44
+ toggle.className = 'context-tree__toggle';
45
+ toggle.textContent = '\u25B6';
46
+ item.appendChild(toggle);
47
+
48
+ const keySpan = document.createElement('span');
49
+ keySpan.className = 'context-tree__key';
50
+ keySpan.textContent = key;
51
+ item.appendChild(keySpan);
52
+
53
+ const typeSpan = document.createElement('span');
54
+ typeSpan.className = 'context-tree__type';
55
+ typeSpan.textContent = Array.isArray(value)
56
+ ? ' Array[' + value.length + ']'
57
+ : ' Object{' + Object.keys(value).length + '}';
58
+ item.appendChild(typeSpan);
59
+
60
+ const childContainer = document.createElement('div');
61
+ childContainer.style.display = 'none';
62
+
63
+ toggle.addEventListener('click', () => {
64
+ const isOpen = childContainer.style.display !== 'none';
65
+ childContainer.style.display = isOpen ? 'none' : 'block';
66
+ toggle.textContent = isOpen ? '\u25B6' : '\u25BC';
67
+ });
68
+
69
+ parent.appendChild(item);
70
+ this.buildTree(childContainer, value, depth + 1);
71
+ parent.appendChild(childContainer);
72
+ } else {
73
+ const keySpan = document.createElement('span');
74
+ keySpan.className = 'context-tree__key';
75
+ keySpan.textContent = key + ': ';
76
+ item.appendChild(keySpan);
77
+
78
+ const valueSpan = document.createElement('span');
79
+ valueSpan.className = 'context-tree__value';
80
+ valueSpan.textContent = truncate(String(value), 100);
81
+ item.appendChild(valueSpan);
82
+
83
+ parent.appendChild(item);
84
+ }
85
+ });
86
+ } else {
87
+ const item = document.createElement('div');
88
+ item.className = 'context-tree__item';
89
+ item.style.setProperty('--depth', depth);
90
+ item.textContent = String(data);
91
+ parent.appendChild(item);
92
+ }
93
+ }
94
+ };
@@ -0,0 +1,105 @@
1
+ // Exec chain Mermaid flowchart component
2
+
3
+ const ExecChain = {
4
+ async render(session) {
5
+ const iterations = session.iterations || [];
6
+ if (iterations.length === 0) return;
7
+
8
+ const definition = this.buildDefinition(session);
9
+ await DiagramRenderer.render('exec-chain-diagram', definition);
10
+ this.setupClickHandlers();
11
+ },
12
+
13
+ setupClickHandlers() {
14
+ const container = document.getElementById('exec-chain-diagram');
15
+ if (!container) return;
16
+
17
+ // Mermaid creates SVG nodes with class 'node'
18
+ const nodes = container.querySelectorAll('.node');
19
+ nodes.forEach(node => {
20
+ node.style.cursor = 'pointer';
21
+ node.addEventListener('click', () => {
22
+ // Extract sequence number from node ID
23
+ const id = node.id || '';
24
+ const match = id.match(/N(\d+)/);
25
+ if (match) {
26
+ const stepId = 'step-' + match[1];
27
+ const target = document.getElementById(stepId);
28
+ if (target) {
29
+ // Switch to timeline view if in flow view
30
+ if (App.currentView === 'flow') {
31
+ setView('timeline');
32
+ }
33
+ setTimeout(() => {
34
+ target.scrollIntoView({ behavior: 'smooth' });
35
+ target.classList.add('timeline-card--expanded');
36
+ // Flash highlight
37
+ target.style.boxShadow = '0 0 0 2px var(--color-accent)';
38
+ setTimeout(() => { target.style.boxShadow = ''; }, 2000);
39
+ }, 100);
40
+ }
41
+ }
42
+ });
43
+ });
44
+ },
45
+
46
+ buildDefinition(session) {
47
+ const iterations = session.iterations || [];
48
+ const prompt = session.run_start?.prompt || 'Query';
49
+ let lines = ['graph TD'];
50
+
51
+ lines.push(' S["' + this.escape(truncate(prompt, 40)) + '"]');
52
+
53
+ iterations.forEach((it, i) => {
54
+ const d = it.data || it;
55
+ const sequence = i + 1
56
+ const nodeId = 'N' + sequence;
57
+ const isSubmit = d.action === 'final' || d.action === 'forced_final';
58
+ const isError = !isSubmit && d.execution && !d.execution.ok;
59
+
60
+ if (isSubmit) {
61
+ const label = this.escape(truncate(d.answer || 'Final', 30));
62
+ lines.push(' ' + nodeId + '["' + label + '"]');
63
+ lines.push(' style ' + nodeId + ' fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd');
64
+ } else {
65
+ const codeLine = (d.code || '').split('\n')[0];
66
+ const label = this.escape(truncate(codeLine, 35));
67
+ lines.push(' ' + nodeId + '["Exec ' + d.iteration + ': ' + label + '"]');
68
+
69
+ if (isError) {
70
+ lines.push(' style ' + nodeId + ' fill:#3b1010,stroke:#ef4444,color:#fca5a5');
71
+ } else {
72
+ lines.push(' style ' + nodeId + ' fill:#0a2e1a,stroke:#10b981,color:#6ee7b7');
73
+ }
74
+ }
75
+
76
+ // Edges
77
+ const prevId = i === 0 ? 'S' : ('N' + i);
78
+ let edgeLabel = '';
79
+
80
+ if (i > 0) {
81
+ const prevD = iterations[i - 1].data || iterations[i - 1];
82
+ if (prevD.execution) {
83
+ if (!prevD.execution.ok) {
84
+ edgeLabel = this.escape(truncate(prevD.execution.error_class || 'error', 20));
85
+ } else if (prevD.execution.value_preview) {
86
+ edgeLabel = this.escape(truncate(prevD.execution.value_preview, 20));
87
+ }
88
+ }
89
+ }
90
+
91
+ if (edgeLabel) {
92
+ lines.push(' ' + prevId + ' -->|"' + edgeLabel + '"| ' + nodeId);
93
+ } else {
94
+ lines.push(' ' + prevId + ' --> ' + nodeId);
95
+ }
96
+ });
97
+
98
+ lines.push(' style S fill:#1a1a1a,stroke:#888,color:#e5e5e5');
99
+ return lines.join('\n');
100
+ },
101
+
102
+ escape(str) {
103
+ return (str || '').replace(/"/g, "'").replace(/[<>{}|]/g, ' ');
104
+ }
105
+ };
@@ -0,0 +1,187 @@
1
+ // KPI Dashboard component for analytics view
2
+
3
+ const KPIDashboard = {
4
+ async load(days = null) {
5
+ try {
6
+ const url = days && days > 0 ? `/api/analytics?days=${days}` : '/api/analytics';
7
+ const data = await fetchJSON(url);
8
+ this.render(data);
9
+ } catch (err) {
10
+ const grid = document.getElementById('kpi-section');
11
+ grid.textContent = '';
12
+ const errDiv = document.createElement('div');
13
+ errDiv.className = 'node';
14
+ errDiv.textContent = 'Failed to load analytics: ' + err.message;
15
+ grid.appendChild(errDiv);
16
+ }
17
+ },
18
+
19
+ render(data) {
20
+ if (data.total_sessions === 0) {
21
+ const grid = document.getElementById('kpi-section');
22
+ grid.textContent = '';
23
+ const empty = document.createElement('div');
24
+ empty.className = 'main-view__empty';
25
+ empty.style.gridColumn = '1 / -1';
26
+ const icon = document.createElement('i');
27
+ icon.className = 'fa-solid fa-chart-pie';
28
+ empty.appendChild(icon);
29
+ const msg = document.createElement('p');
30
+ msg.textContent = 'No session data available. Run some queries to see analytics.';
31
+ empty.appendChild(msg);
32
+ grid.appendChild(empty);
33
+ return;
34
+ }
35
+ this.renderKPIs(data);
36
+ this.renderTokenChart(data);
37
+ this.renderModelChart(data);
38
+ this.renderErrorChart(data);
39
+ },
40
+
41
+ renderKPIs(data) {
42
+ const grid = document.getElementById('kpi-section');
43
+ grid.textContent = '';
44
+
45
+ const kpis = [
46
+ { label: 'Total Sessions', value: data.total_sessions, format: 'number', status: null, icon: 'fa-list-check' },
47
+ { label: 'Avg Steps/Session', value: data.avg_iterations_per_session, format: 'decimal', status: null, icon: 'fa-shoe-prints' },
48
+ { label: 'Total Tokens', value: data.total_tokens, format: 'number', status: null, icon: 'fa-coins' },
49
+ { label: 'Total Cost', value: data.total_cost, format: 'cost', status: null, icon: 'fa-dollar-sign' },
50
+ {
51
+ label: 'Success Rate', value: data.success_rate, format: 'percent',
52
+ status: data.success_rate >= 90 ? 'healthy' : data.success_rate >= 70 ? 'warning' : 'critical', icon: 'fa-circle-check'
53
+ },
54
+ { label: 'Avg Latency', value: data.avg_latency_per_iteration, format: 'duration', status: null, icon: 'fa-clock' },
55
+ {
56
+ label: 'Repair Rate', value: data.repair_rate, format: 'percent',
57
+ status: data.repair_rate <= 5 ? 'healthy' : data.repair_rate <= 15 ? 'warning' : 'critical', icon: 'fa-wrench'
58
+ }
59
+ ];
60
+
61
+ kpis.forEach((kpi, i) => {
62
+ const card = document.createElement('div');
63
+ card.className = 'kpi-card';
64
+ card.style.setProperty('--i', i);
65
+ card.classList.add('animate-scale');
66
+
67
+ const label = document.createElement('div');
68
+ label.className = 'kpi-card__label';
69
+ if (kpi.icon) {
70
+ const icon = document.createElement('i');
71
+ icon.className = 'fa-solid ' + kpi.icon;
72
+ icon.style.marginRight = '0.5rem';
73
+ label.appendChild(icon);
74
+ }
75
+ label.appendChild(document.createTextNode(kpi.label));
76
+ card.appendChild(label);
77
+
78
+ const value = document.createElement('div');
79
+ value.className = 'kpi-card__value';
80
+ switch (kpi.format) {
81
+ case 'number': value.textContent = formatNumber(kpi.value); break;
82
+ case 'decimal': value.textContent = (kpi.value || 0).toFixed(1); break;
83
+ case 'percent': value.textContent = (kpi.value || 0).toFixed(1) + '%'; break;
84
+ case 'duration': value.textContent = formatDuration(kpi.value); break;
85
+ case 'cost': value.textContent = '$' + (kpi.value || 0).toFixed(4); break;
86
+ default: value.textContent = kpi.value;
87
+ }
88
+ card.appendChild(value);
89
+
90
+ if (kpi.status) {
91
+ const badge = document.createElement('div');
92
+ badge.className = 'kpi-card__status kpi-card__status--' + kpi.status;
93
+ badge.textContent = kpi.status.charAt(0).toUpperCase() + kpi.status.slice(1);
94
+ card.appendChild(badge);
95
+ }
96
+
97
+ grid.appendChild(card);
98
+ });
99
+ },
100
+
101
+ renderTokenChart(data) {
102
+ const series = data.time_series || [];
103
+ if (series.length === 0) return;
104
+
105
+ const colors = ChartRenderer.getThemeColors();
106
+ ChartRenderer.bar('token-chart', {
107
+ stacked: true,
108
+ data: {
109
+ labels: series.map(s => s.date),
110
+ datasets: [
111
+ {
112
+ label: 'Prompt Tokens',
113
+ data: series.map(s => s.prompt_tokens || 0),
114
+ backgroundColor: colors.accent + '80'
115
+ },
116
+ {
117
+ label: 'Candidate Tokens',
118
+ data: series.map(s => s.candidate_tokens || 0),
119
+ backgroundColor: colors.info + '60'
120
+ },
121
+ {
122
+ label: 'Cached Tokens',
123
+ data: series.map(s => s.cached_content_tokens || 0),
124
+ backgroundColor: colors.success + '60'
125
+ }
126
+ ]
127
+ }
128
+ });
129
+ },
130
+
131
+ renderModelChart(data) {
132
+ const models = data.model_breakdown || {};
133
+ const entries = Object.entries(models);
134
+ if (entries.length === 0) return;
135
+
136
+ const palette = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6'];
137
+ ChartRenderer.pie('model-chart', {
138
+ data: {
139
+ labels: entries.map(([m]) => m),
140
+ datasets: [{
141
+ data: entries.map(([, v]) => v.sessions),
142
+ backgroundColor: entries.map((_, i) => palette[i % palette.length] + 'cc')
143
+ }]
144
+ }
145
+ });
146
+ },
147
+
148
+ renderErrorChart(data) {
149
+ const errors = data.top_error_classes || {};
150
+ const entries = Object.entries(errors);
151
+ if (entries.length === 0) {
152
+ const container = document.getElementById('error-analysis');
153
+ if (container) {
154
+ const msg = document.createElement('div');
155
+ msg.style.cssText = 'padding:2rem;text-align:center;color:var(--color-text-muted);';
156
+ msg.textContent = 'No errors recorded';
157
+ container.querySelector('.chart-container')?.appendChild(msg);
158
+ }
159
+ return;
160
+ }
161
+
162
+ const colors = ChartRenderer.getThemeColors();
163
+ ChartRenderer.bar('error-chart', {
164
+ data: {
165
+ labels: entries.map(([cls]) => cls),
166
+ datasets: [{
167
+ label: 'Count',
168
+ data: entries.map(([, count]) => count),
169
+ backgroundColor: colors.error + '80'
170
+ }]
171
+ }
172
+ });
173
+ },
174
+
175
+ init() {
176
+ const filter = document.getElementById('kpi-time-filter');
177
+ if (filter) {
178
+ filter.addEventListener('change', (e) => {
179
+ this.load(e.target.value);
180
+ });
181
+ }
182
+ }
183
+ };
184
+
185
+ document.addEventListener('DOMContentLoaded', () => {
186
+ KPIDashboard.init();
187
+ });