@masyv/relay 0.1.1 → 0.3.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/agents/codex.rs +12 -18
- package/core/src/capture/mod.rs +10 -0
- package/core/src/capture/session.rs +209 -72
- package/core/src/handoff/mod.rs +25 -5
- package/core/src/lib.rs +9 -0
- package/core/src/main.rs +51 -2
- package/package.json +1 -1
package/core/src/agents/codex.rs
CHANGED
|
@@ -69,33 +69,27 @@ impl Agent for CodexAgent {
|
|
|
69
69
|
let tmp = std::env::temp_dir().join("relay_handoff.md");
|
|
70
70
|
std::fs::write(&tmp, handoff_prompt)?;
|
|
71
71
|
|
|
72
|
-
//
|
|
73
|
-
|
|
72
|
+
// Launch Codex INTERACTIVELY with the handoff as the initial prompt.
|
|
73
|
+
// This opens the Codex TUI so the user can keep working with it.
|
|
74
|
+
// stdin/stdout/stderr are inherited so the user sees the Codex UI.
|
|
75
|
+
let status = Command::new(&binary)
|
|
74
76
|
.current_dir(project_dir)
|
|
75
|
-
.arg("exec")
|
|
76
77
|
.arg("--full-auto")
|
|
77
78
|
.arg("-m")
|
|
78
79
|
.arg(&self.model)
|
|
79
80
|
.arg(handoff_prompt)
|
|
80
|
-
.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if !stdout.is_empty() {
|
|
86
|
-
println!("{stdout}");
|
|
87
|
-
}
|
|
88
|
-
if !stderr.is_empty() {
|
|
89
|
-
eprintln!("{stderr}");
|
|
90
|
-
}
|
|
81
|
+
.stdin(std::process::Stdio::inherit())
|
|
82
|
+
.stdout(std::process::Stdio::inherit())
|
|
83
|
+
.stderr(std::process::Stdio::inherit())
|
|
84
|
+
.status()?;
|
|
91
85
|
|
|
92
86
|
Ok(HandoffResult {
|
|
93
87
|
agent: "codex".into(),
|
|
94
|
-
success:
|
|
95
|
-
message: if
|
|
96
|
-
format!("Codex ({})
|
|
88
|
+
success: status.success(),
|
|
89
|
+
message: if status.success() {
|
|
90
|
+
format!("Codex ({}) session ended", self.model)
|
|
97
91
|
} else {
|
|
98
|
-
format!("Codex exited with code {:?}",
|
|
92
|
+
format!("Codex exited with code {:?}", status.code())
|
|
99
93
|
},
|
|
100
94
|
handoff_file: Some(tmp.to_string_lossy().to_string()),
|
|
101
95
|
})
|
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,6 +1,12 @@
|
|
|
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;
|
|
6
|
+
use std::sync::atomic::AtomicUsize;
|
|
7
|
+
|
|
8
|
+
/// Controls how many conversation turns to keep. Set before calling read_latest_session.
|
|
9
|
+
pub static MAX_CONVERSATION_TURNS: AtomicUsize = AtomicUsize::new(25);
|
|
4
10
|
|
|
5
11
|
/// Extracted session info from Claude's transcript.
|
|
6
12
|
pub struct SessionInfo {
|
|
@@ -8,13 +14,19 @@ pub struct SessionInfo {
|
|
|
8
14
|
pub decisions: Vec<String>,
|
|
9
15
|
pub last_error: Option<String>,
|
|
10
16
|
pub last_output: Option<String>,
|
|
17
|
+
/// Full conversation turns (compressed) — the real context
|
|
18
|
+
pub conversation: Vec<ConversationTurn>,
|
|
11
19
|
}
|
|
12
20
|
|
|
13
|
-
///
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
/// A single turn in the conversation.
|
|
22
|
+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
23
|
+
pub struct ConversationTurn {
|
|
24
|
+
pub role: String, // "user", "assistant", "tool_result"
|
|
25
|
+
pub content: String,
|
|
26
|
+
}
|
|
17
27
|
|
|
28
|
+
/// Read the latest Claude session transcript and extract full context.
|
|
29
|
+
pub fn read_latest_session(project_dir: &Path) -> SessionInfo {
|
|
18
30
|
let claude_dir = find_claude_project_dir(project_dir);
|
|
19
31
|
|
|
20
32
|
if let Some(dir) = claude_dir {
|
|
@@ -29,17 +41,18 @@ pub fn read_latest_session(project_dir: &Path) -> SessionInfo {
|
|
|
29
41
|
decisions: Vec::new(),
|
|
30
42
|
last_error: None,
|
|
31
43
|
last_output: None,
|
|
44
|
+
conversation: Vec::new(),
|
|
32
45
|
}
|
|
33
46
|
}
|
|
34
47
|
|
|
35
|
-
fn find_claude_project_dir(project_dir: &Path) -> Option<std::path::PathBuf> {
|
|
48
|
+
pub fn find_claude_project_dir(project_dir: &Path) -> Option<std::path::PathBuf> {
|
|
36
49
|
let home = std::env::var("HOME").ok()?;
|
|
37
50
|
let claude_projects = std::path::PathBuf::from(&home).join(".claude/projects");
|
|
38
51
|
if !claude_projects.exists() {
|
|
39
52
|
return None;
|
|
40
53
|
}
|
|
41
54
|
|
|
42
|
-
// Claude encodes
|
|
55
|
+
// Claude encodes: /Users/user/myproject -> -Users-user-myproject
|
|
43
56
|
let proj_str = project_dir.to_string_lossy().replace('/', "-");
|
|
44
57
|
let candidate = claude_projects.join(&proj_str);
|
|
45
58
|
if candidate.exists() {
|
|
@@ -48,10 +61,10 @@ fn find_claude_project_dir(project_dir: &Path) -> Option<std::path::PathBuf> {
|
|
|
48
61
|
|
|
49
62
|
// Try matching by suffix
|
|
50
63
|
if let Ok(entries) = std::fs::read_dir(&claude_projects) {
|
|
51
|
-
let dir_name = project_dir.file_name()?.to_string_lossy();
|
|
64
|
+
let dir_name = project_dir.file_name()?.to_string_lossy().to_string();
|
|
52
65
|
for entry in entries.flatten() {
|
|
53
66
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
54
|
-
if name.ends_with(
|
|
67
|
+
if name.ends_with(&dir_name) && entry.path().is_dir() {
|
|
55
68
|
return Some(entry.path());
|
|
56
69
|
}
|
|
57
70
|
}
|
|
@@ -85,82 +98,131 @@ fn parse_session_transcript(path: &std::path::Path) -> SessionInfo {
|
|
|
85
98
|
Err(_) => return default_session_info(),
|
|
86
99
|
};
|
|
87
100
|
|
|
101
|
+
let lines: Vec<&str> = content.lines().collect();
|
|
102
|
+
|
|
88
103
|
let mut current_task = String::new();
|
|
89
104
|
let mut decisions = Vec::new();
|
|
90
105
|
let mut last_error = None;
|
|
91
106
|
let mut last_output = None;
|
|
107
|
+
let mut conversation: Vec<ConversationTurn> = Vec::new();
|
|
92
108
|
|
|
93
|
-
|
|
94
|
-
let lines: Vec<&str> = content.lines().collect();
|
|
95
|
-
let start = lines.len().saturating_sub(200);
|
|
96
|
-
|
|
97
|
-
for line in &lines[start..] {
|
|
109
|
+
for line in &lines {
|
|
98
110
|
let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else { continue };
|
|
111
|
+
let msg_type = val.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
|
112
|
+
let message = val.get("message").cloned().unwrap_or_default();
|
|
113
|
+
let msg_content = message.get("content");
|
|
99
114
|
|
|
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
|
-
|
|
115
|
+
match msg_type {
|
|
116
|
+
// ── User messages ──────────────────────────────────────────
|
|
117
|
+
"user" => {
|
|
118
|
+
// Check if this is a tool result
|
|
119
|
+
if let Some(tool_result) = val.get("toolUseResult") {
|
|
120
|
+
// Tool result content is in message.content[].content
|
|
121
|
+
if let Some(items) = msg_content.and_then(|c| c.as_array()) {
|
|
122
|
+
for item in items {
|
|
123
|
+
if item.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
|
|
124
|
+
let result_text = item.get("content")
|
|
125
|
+
.and_then(|c| {
|
|
126
|
+
if let Some(s) = c.as_str() {
|
|
127
|
+
Some(s.to_string())
|
|
128
|
+
} else if let Some(arr) = c.as_array() {
|
|
129
|
+
Some(arr.iter()
|
|
130
|
+
.filter_map(|i| i.get("text").and_then(|t| t.as_str()))
|
|
131
|
+
.collect::<Vec<_>>()
|
|
132
|
+
.join("\n"))
|
|
133
|
+
} else {
|
|
134
|
+
None
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
.unwrap_or_default();
|
|
138
|
+
|
|
139
|
+
if !result_text.is_empty() {
|
|
140
|
+
// Check for errors
|
|
141
|
+
let lower = result_text.to_lowercase();
|
|
142
|
+
if lower.contains("error") || lower.contains("failed") ||
|
|
143
|
+
lower.contains("panic") || lower.contains("exit code") {
|
|
144
|
+
last_error = Some(truncate(&result_text, 800));
|
|
145
|
+
}
|
|
146
|
+
last_output = Some(truncate(&result_text, 800));
|
|
126
147
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
for item in arr {
|
|
132
|
-
// Look for tool results
|
|
133
|
-
if item.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
|
|
134
|
-
if let Some(output) = item.get("content").and_then(|c| c.as_str()) {
|
|
135
|
-
// Check for errors
|
|
136
|
-
let lower = output.to_lowercase();
|
|
137
|
-
if lower.contains("error") || lower.contains("failed") || lower.contains("panic") {
|
|
138
|
-
last_error = Some(truncate(output, 500));
|
|
148
|
+
conversation.push(ConversationTurn {
|
|
149
|
+
role: "tool_result".into(),
|
|
150
|
+
content: truncate(&result_text, 200),
|
|
151
|
+
});
|
|
139
152
|
}
|
|
140
|
-
last_output = Some(truncate(output, 500));
|
|
141
153
|
}
|
|
142
154
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
}
|
|
156
|
+
// Also check stdout/stderr from toolUseResult
|
|
157
|
+
let stdout = tool_result.get("stdout").and_then(|s| s.as_str()).unwrap_or("");
|
|
158
|
+
let stderr = tool_result.get("stderr").and_then(|s| s.as_str()).unwrap_or("");
|
|
159
|
+
if !stdout.is_empty() {
|
|
160
|
+
last_output = Some(truncate(stdout, 800));
|
|
161
|
+
}
|
|
162
|
+
if !stderr.is_empty() {
|
|
163
|
+
last_error = Some(truncate(stderr, 800));
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
// Regular user message
|
|
167
|
+
let user_text = extract_text_content(msg_content);
|
|
168
|
+
if !user_text.is_empty() && user_text.len() > 3 {
|
|
169
|
+
// Update current task from the last substantive user message
|
|
170
|
+
if user_text.len() > 10 && !user_text.starts_with('/') {
|
|
171
|
+
current_task = truncate(&user_text, 500);
|
|
172
|
+
}
|
|
173
|
+
conversation.push(ConversationTurn {
|
|
174
|
+
role: "user".into(),
|
|
175
|
+
content: truncate(&user_text, 300),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Assistant messages ──────────────────────────────────────
|
|
182
|
+
"assistant" => {
|
|
183
|
+
if let Some(items) = msg_content.and_then(|c| c.as_array()) {
|
|
184
|
+
for item in items {
|
|
185
|
+
let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
|
186
|
+
match item_type {
|
|
187
|
+
"text" => {
|
|
188
|
+
let text = item.get("text").and_then(|t| t.as_str()).unwrap_or("");
|
|
189
|
+
if !text.is_empty() {
|
|
190
|
+
conversation.push(ConversationTurn {
|
|
191
|
+
role: "assistant".into(),
|
|
192
|
+
content: truncate(text, 200),
|
|
193
|
+
});
|
|
194
|
+
// Extract decisions
|
|
195
|
+
for line in text.lines() {
|
|
196
|
+
let trimmed = line.trim();
|
|
197
|
+
if (trimmed.starts_with("I'll ") ||
|
|
198
|
+
trimmed.starts_with("I chose") ||
|
|
199
|
+
trimmed.starts_with("Using ") ||
|
|
200
|
+
trimmed.starts_with("Decision:") ||
|
|
201
|
+
trimmed.starts_with("Approach:") ||
|
|
202
|
+
trimmed.starts_with("The fix is") ||
|
|
203
|
+
trimmed.starts_with("The issue"))
|
|
204
|
+
&& trimmed.len() > 20
|
|
205
|
+
{
|
|
206
|
+
decisions.push(truncate(trimmed, 200));
|
|
207
|
+
}
|
|
157
208
|
}
|
|
158
209
|
}
|
|
159
210
|
}
|
|
211
|
+
"tool_use" => {
|
|
212
|
+
let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("?");
|
|
213
|
+
let input = item.get("input").cloned().unwrap_or_default();
|
|
214
|
+
let summary = summarize_tool_call(name, &input);
|
|
215
|
+
conversation.push(ConversationTurn {
|
|
216
|
+
role: "assistant_tool".into(),
|
|
217
|
+
content: summary,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
_ => {}
|
|
160
221
|
}
|
|
161
222
|
}
|
|
162
223
|
}
|
|
163
224
|
}
|
|
225
|
+
_ => {} // skip system, queue-operation, etc.
|
|
164
226
|
}
|
|
165
227
|
}
|
|
166
228
|
|
|
@@ -168,20 +230,90 @@ fn parse_session_transcript(path: &std::path::Path) -> SessionInfo {
|
|
|
168
230
|
current_task = "Could not determine current task from session transcript".into();
|
|
169
231
|
}
|
|
170
232
|
|
|
171
|
-
// Deduplicate
|
|
233
|
+
// Deduplicate decisions
|
|
172
234
|
decisions.dedup();
|
|
173
|
-
decisions.truncate(
|
|
235
|
+
decisions.truncate(15);
|
|
236
|
+
|
|
237
|
+
// Keep last N conversation turns — caller can override via max_conversation_turns
|
|
238
|
+
let max_turns = MAX_CONVERSATION_TURNS.load(std::sync::atomic::Ordering::Relaxed);
|
|
239
|
+
if conversation.len() > max_turns {
|
|
240
|
+
let skip = conversation.len() - max_turns;
|
|
241
|
+
conversation = conversation.into_iter().skip(skip).collect();
|
|
242
|
+
}
|
|
174
243
|
|
|
175
244
|
SessionInfo {
|
|
176
245
|
current_task,
|
|
177
246
|
decisions,
|
|
178
247
|
last_error,
|
|
179
248
|
last_output,
|
|
249
|
+
conversation,
|
|
180
250
|
}
|
|
181
251
|
}
|
|
182
252
|
|
|
253
|
+
/// Summarize a tool call into a human-readable line.
|
|
254
|
+
fn summarize_tool_call(name: &str, input: &serde_json::Value) -> String {
|
|
255
|
+
match name {
|
|
256
|
+
"Write" => {
|
|
257
|
+
let path = input.get("file_path").and_then(|p| p.as_str()).unwrap_or("?");
|
|
258
|
+
let content_len = input.get("content").and_then(|c| c.as_str()).map(|s| s.len()).unwrap_or(0);
|
|
259
|
+
format!("[Write] {} ({} chars)", path, content_len)
|
|
260
|
+
}
|
|
261
|
+
"Edit" => {
|
|
262
|
+
let path = input.get("file_path").and_then(|p| p.as_str()).unwrap_or("?");
|
|
263
|
+
let old = input.get("old_string").and_then(|o| o.as_str()).unwrap_or("");
|
|
264
|
+
format!("[Edit] {} (replacing {} chars)", path, old.len())
|
|
265
|
+
}
|
|
266
|
+
"Read" => {
|
|
267
|
+
let path = input.get("file_path").and_then(|p| p.as_str()).unwrap_or("?");
|
|
268
|
+
format!("[Read] {}", path)
|
|
269
|
+
}
|
|
270
|
+
"Bash" => {
|
|
271
|
+
let cmd = input.get("command").and_then(|c| c.as_str()).unwrap_or("?");
|
|
272
|
+
format!("[Bash] {}", truncate(cmd, 120))
|
|
273
|
+
}
|
|
274
|
+
"Glob" => {
|
|
275
|
+
let pat = input.get("pattern").and_then(|p| p.as_str()).unwrap_or("?");
|
|
276
|
+
format!("[Glob] {}", pat)
|
|
277
|
+
}
|
|
278
|
+
"Grep" => {
|
|
279
|
+
let pat = input.get("pattern").and_then(|p| p.as_str()).unwrap_or("?");
|
|
280
|
+
format!("[Grep] {}", pat)
|
|
281
|
+
}
|
|
282
|
+
"TodoWrite" => {
|
|
283
|
+
let todos = input.get("todos").and_then(|t| t.as_array());
|
|
284
|
+
let count = todos.map(|t| t.len()).unwrap_or(0);
|
|
285
|
+
format!("[TodoWrite] {} items", count)
|
|
286
|
+
}
|
|
287
|
+
"Agent" => {
|
|
288
|
+
let desc = input.get("description").and_then(|d| d.as_str()).unwrap_or("?");
|
|
289
|
+
format!("[Agent] {}", desc)
|
|
290
|
+
}
|
|
291
|
+
_ => format!("[{}]", name),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/// Extract text content from a message content field.
|
|
296
|
+
fn extract_text_content(content: Option<&serde_json::Value>) -> String {
|
|
297
|
+
let Some(c) = content else { return String::new() };
|
|
298
|
+
if let Some(s) = c.as_str() {
|
|
299
|
+
return s.to_string();
|
|
300
|
+
}
|
|
301
|
+
if let Some(arr) = c.as_array() {
|
|
302
|
+
let texts: Vec<&str> = arr.iter()
|
|
303
|
+
.filter_map(|item| {
|
|
304
|
+
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
|
|
305
|
+
item.get("text").and_then(|t| t.as_str())
|
|
306
|
+
} else {
|
|
307
|
+
None
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
.collect();
|
|
311
|
+
return texts.join("\n");
|
|
312
|
+
}
|
|
313
|
+
String::new()
|
|
314
|
+
}
|
|
315
|
+
|
|
183
316
|
fn infer_task_from_git(project_dir: &Path) -> String {
|
|
184
|
-
// Infer from recent commit messages
|
|
185
317
|
let output = std::process::Command::new("git")
|
|
186
318
|
.current_dir(project_dir)
|
|
187
319
|
.args(["log", "--oneline", "-1", "--no-decorate"])
|
|
@@ -201,10 +333,14 @@ fn infer_task_from_git(project_dir: &Path) -> String {
|
|
|
201
333
|
|
|
202
334
|
fn truncate(s: &str, max: usize) -> String {
|
|
203
335
|
if s.len() <= max {
|
|
204
|
-
s.to_string()
|
|
205
|
-
}
|
|
206
|
-
|
|
336
|
+
return s.to_string();
|
|
337
|
+
}
|
|
338
|
+
// Find a valid char boundary at or before max
|
|
339
|
+
let mut end = max;
|
|
340
|
+
while end > 0 && !s.is_char_boundary(end) {
|
|
341
|
+
end -= 1;
|
|
207
342
|
}
|
|
343
|
+
format!("{}...", &s[..end])
|
|
208
344
|
}
|
|
209
345
|
|
|
210
346
|
fn default_session_info() -> SessionInfo {
|
|
@@ -213,5 +349,6 @@ fn default_session_info() -> SessionInfo {
|
|
|
213
349
|
decisions: Vec::new(),
|
|
214
350
|
last_error: None,
|
|
215
351
|
last_output: None,
|
|
352
|
+
conversation: Vec::new(),
|
|
216
353
|
}
|
|
217
354
|
}
|
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\
|
|
@@ -119,11 +135,15 @@ pub fn build_handoff(
|
|
|
119
135
|
|
|
120
136
|
let mut full = sections.join("\n\n");
|
|
121
137
|
|
|
122
|
-
//
|
|
123
|
-
let
|
|
124
|
-
if
|
|
125
|
-
|
|
126
|
-
|
|
138
|
+
// Hard cap at max_tokens (rough estimate: chars / 3.5)
|
|
139
|
+
let max_chars = (max_tokens as f64 * 3.5) as usize;
|
|
140
|
+
if full.len() > max_chars {
|
|
141
|
+
// Find a valid UTF-8 char boundary
|
|
142
|
+
let mut end = max_chars;
|
|
143
|
+
while end > 0 && !full.is_char_boundary(end) {
|
|
144
|
+
end -= 1;
|
|
145
|
+
}
|
|
146
|
+
full.truncate(end);
|
|
127
147
|
full.push_str("\n\n[...truncated to fit context limit]");
|
|
128
148
|
}
|
|
129
149
|
|
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
|
@@ -44,6 +44,14 @@ enum Commands {
|
|
|
44
44
|
/// Don't execute — just print the handoff package
|
|
45
45
|
#[arg(long)]
|
|
46
46
|
dry_run: bool,
|
|
47
|
+
|
|
48
|
+
/// How many conversation turns to include (default: 25)
|
|
49
|
+
#[arg(long, default_value = "25")]
|
|
50
|
+
turns: usize,
|
|
51
|
+
|
|
52
|
+
/// What to include: all, conversation, git, todos (comma-separated)
|
|
53
|
+
#[arg(long, default_value = "all")]
|
|
54
|
+
include: String,
|
|
47
55
|
},
|
|
48
56
|
|
|
49
57
|
/// Show current session snapshot (what would be handed off)
|
|
@@ -84,14 +92,33 @@ fn main() -> Result<()> {
|
|
|
84
92
|
});
|
|
85
93
|
|
|
86
94
|
match cli.command {
|
|
87
|
-
Commands::Handoff { to, deadline, dry_run } => {
|
|
95
|
+
Commands::Handoff { to, deadline, dry_run, turns, include } => {
|
|
88
96
|
eprintln!("{}", "⚡ Relay — capturing session state...".yellow().bold());
|
|
89
97
|
|
|
90
|
-
|
|
98
|
+
// Set conversation turn limit before capture
|
|
99
|
+
relay::capture::session::MAX_CONVERSATION_TURNS
|
|
100
|
+
.store(turns, std::sync::atomic::Ordering::Relaxed);
|
|
101
|
+
|
|
102
|
+
let mut snapshot = capture::capture_snapshot(
|
|
91
103
|
&project_dir,
|
|
92
104
|
deadline.as_deref(),
|
|
93
105
|
)?;
|
|
94
106
|
|
|
107
|
+
// Filter sections based on --include flag
|
|
108
|
+
let includes: Vec<&str> = include.split(',').map(|s| s.trim()).collect();
|
|
109
|
+
if !includes.contains(&"all") {
|
|
110
|
+
if !includes.contains(&"conversation") {
|
|
111
|
+
snapshot.conversation.clear();
|
|
112
|
+
}
|
|
113
|
+
if !includes.contains(&"git") {
|
|
114
|
+
snapshot.git_state = None;
|
|
115
|
+
snapshot.recent_files.clear();
|
|
116
|
+
}
|
|
117
|
+
if !includes.contains(&"todos") {
|
|
118
|
+
snapshot.todos.clear();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
95
122
|
let target = to.as_deref().unwrap_or("auto");
|
|
96
123
|
let handoff_text = handoff::build_handoff(
|
|
97
124
|
&snapshot,
|
|
@@ -206,6 +233,28 @@ fn main() -> Result<()> {
|
|
|
206
233
|
}
|
|
207
234
|
println!();
|
|
208
235
|
}
|
|
236
|
+
|
|
237
|
+
if !snapshot.conversation.is_empty() {
|
|
238
|
+
println!("{}", format!("── Conversation ({} turns) ──", snapshot.conversation.len()).cyan());
|
|
239
|
+
// Show last 15 turns
|
|
240
|
+
let start = snapshot.conversation.len().saturating_sub(15);
|
|
241
|
+
for turn in &snapshot.conversation[start..] {
|
|
242
|
+
let prefix = match turn.role.as_str() {
|
|
243
|
+
"user" => "👤 USER".to_string(),
|
|
244
|
+
"assistant" => "🤖 CLAUDE".to_string(),
|
|
245
|
+
"assistant_tool" => "🔧 TOOL".to_string(),
|
|
246
|
+
"tool_result" => "📤 RESULT".to_string(),
|
|
247
|
+
_ => turn.role.clone(),
|
|
248
|
+
};
|
|
249
|
+
let content = if turn.content.len() > 120 {
|
|
250
|
+
format!("{}...", &turn.content[..117])
|
|
251
|
+
} else {
|
|
252
|
+
turn.content.clone()
|
|
253
|
+
};
|
|
254
|
+
println!(" {}: {}", prefix, content);
|
|
255
|
+
}
|
|
256
|
+
println!();
|
|
257
|
+
}
|
|
209
258
|
}
|
|
210
259
|
|
|
211
260
|
Commands::Agents => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@masyv/relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|