@masyv/relay 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/capture/mod.rs +10 -0
- package/core/src/capture/session.rs +205 -72
- package/core/src/handoff/mod.rs +16 -0
- package/core/src/lib.rs +9 -0
- package/core/src/main.rs +22 -0
- package/package.json +1 -1
package/core/src/capture/mod.rs
CHANGED
|
@@ -26,6 +26,15 @@ pub fn capture_snapshot(
|
|
|
26
26
|
|
|
27
27
|
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
28
28
|
|
|
29
|
+
// Convert conversation turns
|
|
30
|
+
let conversation: Vec<crate::ConversationTurn> = session_info.conversation
|
|
31
|
+
.into_iter()
|
|
32
|
+
.map(|t| crate::ConversationTurn {
|
|
33
|
+
role: t.role,
|
|
34
|
+
content: t.content,
|
|
35
|
+
})
|
|
36
|
+
.collect();
|
|
37
|
+
|
|
29
38
|
Ok(SessionSnapshot {
|
|
30
39
|
current_task: session_info.current_task,
|
|
31
40
|
todos,
|
|
@@ -37,5 +46,6 @@ pub fn capture_snapshot(
|
|
|
37
46
|
recent_files,
|
|
38
47
|
timestamp,
|
|
39
48
|
deadline: deadline.map(String::from),
|
|
49
|
+
conversation,
|
|
40
50
|
})
|
|
41
51
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
//! Read Claude Code session state from .jsonl transcript files.
|
|
2
|
+
//! Extracts the FULL conversation context — user messages, assistant reasoning,
|
|
3
|
+
//! tool calls with results, errors, and decisions.
|
|
2
4
|
|
|
3
5
|
use std::path::Path;
|
|
4
6
|
|
|
@@ -8,13 +10,19 @@ pub struct SessionInfo {
|
|
|
8
10
|
pub decisions: Vec<String>,
|
|
9
11
|
pub last_error: Option<String>,
|
|
10
12
|
pub last_output: Option<String>,
|
|
13
|
+
/// Full conversation turns (compressed) — the real context
|
|
14
|
+
pub conversation: Vec<ConversationTurn>,
|
|
11
15
|
}
|
|
12
16
|
|
|
13
|
-
///
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
/// A single turn in the conversation.
|
|
18
|
+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
19
|
+
pub struct ConversationTurn {
|
|
20
|
+
pub role: String, // "user", "assistant", "tool_result"
|
|
21
|
+
pub content: String,
|
|
22
|
+
}
|
|
17
23
|
|
|
24
|
+
/// Read the latest Claude session transcript and extract full context.
|
|
25
|
+
pub fn read_latest_session(project_dir: &Path) -> SessionInfo {
|
|
18
26
|
let claude_dir = find_claude_project_dir(project_dir);
|
|
19
27
|
|
|
20
28
|
if let Some(dir) = claude_dir {
|
|
@@ -29,17 +37,18 @@ pub fn read_latest_session(project_dir: &Path) -> SessionInfo {
|
|
|
29
37
|
decisions: Vec::new(),
|
|
30
38
|
last_error: None,
|
|
31
39
|
last_output: None,
|
|
40
|
+
conversation: Vec::new(),
|
|
32
41
|
}
|
|
33
42
|
}
|
|
34
43
|
|
|
35
|
-
fn find_claude_project_dir(project_dir: &Path) -> Option<std::path::PathBuf> {
|
|
44
|
+
pub fn find_claude_project_dir(project_dir: &Path) -> Option<std::path::PathBuf> {
|
|
36
45
|
let home = std::env::var("HOME").ok()?;
|
|
37
46
|
let claude_projects = std::path::PathBuf::from(&home).join(".claude/projects");
|
|
38
47
|
if !claude_projects.exists() {
|
|
39
48
|
return None;
|
|
40
49
|
}
|
|
41
50
|
|
|
42
|
-
// Claude encodes
|
|
51
|
+
// Claude encodes: /Users/user/myproject -> -Users-user-myproject
|
|
43
52
|
let proj_str = project_dir.to_string_lossy().replace('/', "-");
|
|
44
53
|
let candidate = claude_projects.join(&proj_str);
|
|
45
54
|
if candidate.exists() {
|
|
@@ -48,10 +57,10 @@ fn find_claude_project_dir(project_dir: &Path) -> Option<std::path::PathBuf> {
|
|
|
48
57
|
|
|
49
58
|
// Try matching by suffix
|
|
50
59
|
if let Ok(entries) = std::fs::read_dir(&claude_projects) {
|
|
51
|
-
let dir_name = project_dir.file_name()?.to_string_lossy();
|
|
60
|
+
let dir_name = project_dir.file_name()?.to_string_lossy().to_string();
|
|
52
61
|
for entry in entries.flatten() {
|
|
53
62
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
54
|
-
if name.ends_with(
|
|
63
|
+
if name.ends_with(&dir_name) && entry.path().is_dir() {
|
|
55
64
|
return Some(entry.path());
|
|
56
65
|
}
|
|
57
66
|
}
|
|
@@ -85,82 +94,131 @@ fn parse_session_transcript(path: &std::path::Path) -> SessionInfo {
|
|
|
85
94
|
Err(_) => return default_session_info(),
|
|
86
95
|
};
|
|
87
96
|
|
|
97
|
+
let lines: Vec<&str> = content.lines().collect();
|
|
98
|
+
|
|
88
99
|
let mut current_task = String::new();
|
|
89
100
|
let mut decisions = Vec::new();
|
|
90
101
|
let mut last_error = None;
|
|
91
102
|
let mut last_output = None;
|
|
103
|
+
let mut conversation: Vec<ConversationTurn> = Vec::new();
|
|
92
104
|
|
|
93
|
-
|
|
94
|
-
let lines: Vec<&str> = content.lines().collect();
|
|
95
|
-
let start = lines.len().saturating_sub(200);
|
|
96
|
-
|
|
97
|
-
for line in &lines[start..] {
|
|
105
|
+
for line in &lines {
|
|
98
106
|
let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else { continue };
|
|
107
|
+
let msg_type = val.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
|
108
|
+
let message = val.get("message").cloned().unwrap_or_default();
|
|
109
|
+
let msg_content = message.get("content");
|
|
99
110
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
111
|
+
match msg_type {
|
|
112
|
+
// ── User messages ──────────────────────────────────────────
|
|
113
|
+
"user" => {
|
|
114
|
+
// Check if this is a tool result
|
|
115
|
+
if let Some(tool_result) = val.get("toolUseResult") {
|
|
116
|
+
// Tool result content is in message.content[].content
|
|
117
|
+
if let Some(items) = msg_content.and_then(|c| c.as_array()) {
|
|
118
|
+
for item in items {
|
|
119
|
+
if item.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
|
|
120
|
+
let result_text = item.get("content")
|
|
121
|
+
.and_then(|c| {
|
|
122
|
+
if let Some(s) = c.as_str() {
|
|
123
|
+
Some(s.to_string())
|
|
124
|
+
} else if let Some(arr) = c.as_array() {
|
|
125
|
+
Some(arr.iter()
|
|
126
|
+
.filter_map(|i| i.get("text").and_then(|t| t.as_str()))
|
|
127
|
+
.collect::<Vec<_>>()
|
|
128
|
+
.join("\n"))
|
|
129
|
+
} else {
|
|
130
|
+
None
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
.unwrap_or_default();
|
|
126
134
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
if !result_text.is_empty() {
|
|
136
|
+
// Check for errors
|
|
137
|
+
let lower = result_text.to_lowercase();
|
|
138
|
+
if lower.contains("error") || lower.contains("failed") ||
|
|
139
|
+
lower.contains("panic") || lower.contains("exit code") {
|
|
140
|
+
last_error = Some(truncate(&result_text, 800));
|
|
141
|
+
}
|
|
142
|
+
last_output = Some(truncate(&result_text, 800));
|
|
143
|
+
|
|
144
|
+
conversation.push(ConversationTurn {
|
|
145
|
+
role: "tool_result".into(),
|
|
146
|
+
content: truncate(&result_text, 600),
|
|
147
|
+
});
|
|
139
148
|
}
|
|
140
|
-
last_output = Some(truncate(output, 500));
|
|
141
149
|
}
|
|
142
150
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
151
|
+
}
|
|
152
|
+
// Also check stdout/stderr from toolUseResult
|
|
153
|
+
let stdout = tool_result.get("stdout").and_then(|s| s.as_str()).unwrap_or("");
|
|
154
|
+
let stderr = tool_result.get("stderr").and_then(|s| s.as_str()).unwrap_or("");
|
|
155
|
+
if !stdout.is_empty() {
|
|
156
|
+
last_output = Some(truncate(stdout, 800));
|
|
157
|
+
}
|
|
158
|
+
if !stderr.is_empty() {
|
|
159
|
+
last_error = Some(truncate(stderr, 800));
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
// Regular user message
|
|
163
|
+
let user_text = extract_text_content(msg_content);
|
|
164
|
+
if !user_text.is_empty() && user_text.len() > 3 {
|
|
165
|
+
// Update current task from the last substantive user message
|
|
166
|
+
if user_text.len() > 10 && !user_text.starts_with('/') {
|
|
167
|
+
current_task = truncate(&user_text, 500);
|
|
168
|
+
}
|
|
169
|
+
conversation.push(ConversationTurn {
|
|
170
|
+
role: "user".into(),
|
|
171
|
+
content: truncate(&user_text, 400),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Assistant messages ──────────────────────────────────────
|
|
178
|
+
"assistant" => {
|
|
179
|
+
if let Some(items) = msg_content.and_then(|c| c.as_array()) {
|
|
180
|
+
for item in items {
|
|
181
|
+
let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
|
182
|
+
match item_type {
|
|
183
|
+
"text" => {
|
|
184
|
+
let text = item.get("text").and_then(|t| t.as_str()).unwrap_or("");
|
|
185
|
+
if !text.is_empty() {
|
|
186
|
+
conversation.push(ConversationTurn {
|
|
187
|
+
role: "assistant".into(),
|
|
188
|
+
content: truncate(text, 500),
|
|
189
|
+
});
|
|
190
|
+
// Extract decisions
|
|
191
|
+
for line in text.lines() {
|
|
192
|
+
let trimmed = line.trim();
|
|
193
|
+
if (trimmed.starts_with("I'll ") ||
|
|
194
|
+
trimmed.starts_with("I chose") ||
|
|
195
|
+
trimmed.starts_with("Using ") ||
|
|
196
|
+
trimmed.starts_with("Decision:") ||
|
|
197
|
+
trimmed.starts_with("Approach:") ||
|
|
198
|
+
trimmed.starts_with("The fix is") ||
|
|
199
|
+
trimmed.starts_with("The issue"))
|
|
200
|
+
&& trimmed.len() > 20
|
|
201
|
+
{
|
|
202
|
+
decisions.push(truncate(trimmed, 200));
|
|
203
|
+
}
|
|
157
204
|
}
|
|
158
205
|
}
|
|
159
206
|
}
|
|
207
|
+
"tool_use" => {
|
|
208
|
+
let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("?");
|
|
209
|
+
let input = item.get("input").cloned().unwrap_or_default();
|
|
210
|
+
let summary = summarize_tool_call(name, &input);
|
|
211
|
+
conversation.push(ConversationTurn {
|
|
212
|
+
role: "assistant_tool".into(),
|
|
213
|
+
content: summary,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
_ => {}
|
|
160
217
|
}
|
|
161
218
|
}
|
|
162
219
|
}
|
|
163
220
|
}
|
|
221
|
+
_ => {} // skip system, queue-operation, etc.
|
|
164
222
|
}
|
|
165
223
|
}
|
|
166
224
|
|
|
@@ -168,20 +226,90 @@ fn parse_session_transcript(path: &std::path::Path) -> SessionInfo {
|
|
|
168
226
|
current_task = "Could not determine current task from session transcript".into();
|
|
169
227
|
}
|
|
170
228
|
|
|
171
|
-
// Deduplicate
|
|
229
|
+
// Deduplicate decisions
|
|
172
230
|
decisions.dedup();
|
|
173
|
-
decisions.truncate(
|
|
231
|
+
decisions.truncate(15);
|
|
232
|
+
|
|
233
|
+
// Keep last N conversation turns to fit context — prioritize recent
|
|
234
|
+
let max_turns = 80;
|
|
235
|
+
if conversation.len() > max_turns {
|
|
236
|
+
let skip = conversation.len() - max_turns;
|
|
237
|
+
conversation = conversation.into_iter().skip(skip).collect();
|
|
238
|
+
}
|
|
174
239
|
|
|
175
240
|
SessionInfo {
|
|
176
241
|
current_task,
|
|
177
242
|
decisions,
|
|
178
243
|
last_error,
|
|
179
244
|
last_output,
|
|
245
|
+
conversation,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/// Summarize a tool call into a human-readable line.
|
|
250
|
+
fn summarize_tool_call(name: &str, input: &serde_json::Value) -> String {
|
|
251
|
+
match name {
|
|
252
|
+
"Write" => {
|
|
253
|
+
let path = input.get("file_path").and_then(|p| p.as_str()).unwrap_or("?");
|
|
254
|
+
let content_len = input.get("content").and_then(|c| c.as_str()).map(|s| s.len()).unwrap_or(0);
|
|
255
|
+
format!("[Write] {} ({} chars)", path, content_len)
|
|
256
|
+
}
|
|
257
|
+
"Edit" => {
|
|
258
|
+
let path = input.get("file_path").and_then(|p| p.as_str()).unwrap_or("?");
|
|
259
|
+
let old = input.get("old_string").and_then(|o| o.as_str()).unwrap_or("");
|
|
260
|
+
format!("[Edit] {} (replacing {} chars)", path, old.len())
|
|
261
|
+
}
|
|
262
|
+
"Read" => {
|
|
263
|
+
let path = input.get("file_path").and_then(|p| p.as_str()).unwrap_or("?");
|
|
264
|
+
format!("[Read] {}", path)
|
|
265
|
+
}
|
|
266
|
+
"Bash" => {
|
|
267
|
+
let cmd = input.get("command").and_then(|c| c.as_str()).unwrap_or("?");
|
|
268
|
+
format!("[Bash] {}", truncate(cmd, 120))
|
|
269
|
+
}
|
|
270
|
+
"Glob" => {
|
|
271
|
+
let pat = input.get("pattern").and_then(|p| p.as_str()).unwrap_or("?");
|
|
272
|
+
format!("[Glob] {}", pat)
|
|
273
|
+
}
|
|
274
|
+
"Grep" => {
|
|
275
|
+
let pat = input.get("pattern").and_then(|p| p.as_str()).unwrap_or("?");
|
|
276
|
+
format!("[Grep] {}", pat)
|
|
277
|
+
}
|
|
278
|
+
"TodoWrite" => {
|
|
279
|
+
let todos = input.get("todos").and_then(|t| t.as_array());
|
|
280
|
+
let count = todos.map(|t| t.len()).unwrap_or(0);
|
|
281
|
+
format!("[TodoWrite] {} items", count)
|
|
282
|
+
}
|
|
283
|
+
"Agent" => {
|
|
284
|
+
let desc = input.get("description").and_then(|d| d.as_str()).unwrap_or("?");
|
|
285
|
+
format!("[Agent] {}", desc)
|
|
286
|
+
}
|
|
287
|
+
_ => format!("[{}]", name),
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// Extract text content from a message content field.
|
|
292
|
+
fn extract_text_content(content: Option<&serde_json::Value>) -> String {
|
|
293
|
+
let Some(c) = content else { return String::new() };
|
|
294
|
+
if let Some(s) = c.as_str() {
|
|
295
|
+
return s.to_string();
|
|
180
296
|
}
|
|
297
|
+
if let Some(arr) = c.as_array() {
|
|
298
|
+
let texts: Vec<&str> = arr.iter()
|
|
299
|
+
.filter_map(|item| {
|
|
300
|
+
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
|
|
301
|
+
item.get("text").and_then(|t| t.as_str())
|
|
302
|
+
} else {
|
|
303
|
+
None
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
.collect();
|
|
307
|
+
return texts.join("\n");
|
|
308
|
+
}
|
|
309
|
+
String::new()
|
|
181
310
|
}
|
|
182
311
|
|
|
183
312
|
fn infer_task_from_git(project_dir: &Path) -> String {
|
|
184
|
-
// Infer from recent commit messages
|
|
185
313
|
let output = std::process::Command::new("git")
|
|
186
314
|
.current_dir(project_dir)
|
|
187
315
|
.args(["log", "--oneline", "-1", "--no-decorate"])
|
|
@@ -201,10 +329,14 @@ fn infer_task_from_git(project_dir: &Path) -> String {
|
|
|
201
329
|
|
|
202
330
|
fn truncate(s: &str, max: usize) -> String {
|
|
203
331
|
if s.len() <= max {
|
|
204
|
-
s.to_string()
|
|
205
|
-
}
|
|
206
|
-
|
|
332
|
+
return s.to_string();
|
|
333
|
+
}
|
|
334
|
+
// Find a valid char boundary at or before max
|
|
335
|
+
let mut end = max;
|
|
336
|
+
while end > 0 && !s.is_char_boundary(end) {
|
|
337
|
+
end -= 1;
|
|
207
338
|
}
|
|
339
|
+
format!("{}...", &s[..end])
|
|
208
340
|
}
|
|
209
341
|
|
|
210
342
|
fn default_session_info() -> SessionInfo {
|
|
@@ -213,5 +345,6 @@ fn default_session_info() -> SessionInfo {
|
|
|
213
345
|
decisions: Vec::new(),
|
|
214
346
|
last_error: None,
|
|
215
347
|
last_output: None,
|
|
348
|
+
conversation: Vec::new(),
|
|
216
349
|
}
|
|
217
350
|
}
|
package/core/src/handoff/mod.rs
CHANGED
|
@@ -100,6 +100,22 @@ pub fn build_handoff(
|
|
|
100
100
|
sections.push(files);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// ── Full Conversation Context ──────────────────────────────
|
|
104
|
+
if !snapshot.conversation.is_empty() {
|
|
105
|
+
let mut convo = String::from("## CONVERSATION CONTEXT\n\nBelow is the full conversation from the Claude session (most recent turns).\nThis is the actual context that was in Claude's window when it was interrupted.\n\n");
|
|
106
|
+
for turn in &snapshot.conversation {
|
|
107
|
+
let prefix = match turn.role.as_str() {
|
|
108
|
+
"user" => "USER",
|
|
109
|
+
"assistant" => "CLAUDE",
|
|
110
|
+
"assistant_tool" => "CLAUDE_TOOL",
|
|
111
|
+
"tool_result" => "TOOL_OUTPUT",
|
|
112
|
+
_ => &turn.role,
|
|
113
|
+
};
|
|
114
|
+
convo.push_str(&format!("[{prefix}] {}\n\n", turn.content));
|
|
115
|
+
}
|
|
116
|
+
sections.push(convo);
|
|
117
|
+
}
|
|
118
|
+
|
|
103
119
|
// ── Instructions for agent ─────────────────────────────────
|
|
104
120
|
let instructions = format!(
|
|
105
121
|
"## INSTRUCTIONS\n\n\
|
package/core/src/lib.rs
CHANGED
|
@@ -162,6 +162,15 @@ pub struct SessionSnapshot {
|
|
|
162
162
|
pub timestamp: String,
|
|
163
163
|
/// Deadline if set
|
|
164
164
|
pub deadline: Option<String>,
|
|
165
|
+
/// FULL conversation context from Claude session transcript
|
|
166
|
+
pub conversation: Vec<ConversationTurn>,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// A single turn in the conversation (user message, assistant text, or tool call/result).
|
|
170
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
171
|
+
pub struct ConversationTurn {
|
|
172
|
+
pub role: String,
|
|
173
|
+
pub content: String,
|
|
165
174
|
}
|
|
166
175
|
|
|
167
176
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
package/core/src/main.rs
CHANGED
|
@@ -206,6 +206,28 @@ fn main() -> Result<()> {
|
|
|
206
206
|
}
|
|
207
207
|
println!();
|
|
208
208
|
}
|
|
209
|
+
|
|
210
|
+
if !snapshot.conversation.is_empty() {
|
|
211
|
+
println!("{}", format!("── Conversation ({} turns) ──", snapshot.conversation.len()).cyan());
|
|
212
|
+
// Show last 15 turns
|
|
213
|
+
let start = snapshot.conversation.len().saturating_sub(15);
|
|
214
|
+
for turn in &snapshot.conversation[start..] {
|
|
215
|
+
let prefix = match turn.role.as_str() {
|
|
216
|
+
"user" => "👤 USER".to_string(),
|
|
217
|
+
"assistant" => "🤖 CLAUDE".to_string(),
|
|
218
|
+
"assistant_tool" => "🔧 TOOL".to_string(),
|
|
219
|
+
"tool_result" => "📤 RESULT".to_string(),
|
|
220
|
+
_ => turn.role.clone(),
|
|
221
|
+
};
|
|
222
|
+
let content = if turn.content.len() > 120 {
|
|
223
|
+
format!("{}...", &turn.content[..117])
|
|
224
|
+
} else {
|
|
225
|
+
turn.content.clone()
|
|
226
|
+
};
|
|
227
|
+
println!(" {}: {}", prefix, content);
|
|
228
|
+
}
|
|
229
|
+
println!();
|
|
230
|
+
}
|
|
209
231
|
}
|
|
210
232
|
|
|
211
233
|
Commands::Agents => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@masyv/relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Relay — When Claude's rate limit hits, another agent picks up exactly where you left off. Captures session state and hands off to Codex, Gemini, Ollama, or GPT-4.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"build": "./scripts/build.sh",
|