@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.
@@ -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
- /// Read the latest Claude session transcript and extract state.
14
- pub fn read_latest_session(project_dir: &Path) -> SessionInfo {
15
- // Claude stores transcripts in ~/.claude/projects/<project_hash>/<session>.jsonl
16
- // Try to find and parse the latest one
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 the project path: /Users/user/myproject -> -Users-user-myproject
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(&*dir_name) && entry.path().is_dir() {
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
- // Read last 200 lines of the JSONL for efficiency
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
- // Extract user messages for task context
101
- if val.get("type").and_then(|v| v.as_str()) == Some("human") {
102
- if let Some(msg) = val.get("message").and_then(|m| {
103
- m.get("content").and_then(|c| {
104
- if let Some(s) = c.as_str() {
105
- Some(s.to_string())
106
- } else if let Some(arr) = c.as_array() {
107
- arr.iter()
108
- .filter_map(|item| item.get("text").and_then(|t| t.as_str()))
109
- .last()
110
- .map(String::from)
111
- } else {
112
- None
113
- }
114
- })
115
- }) {
116
- // Keep the last substantive user message as current task
117
- if msg.len() > 10 && !msg.starts_with('/') {
118
- current_task = if msg.len() > 500 {
119
- format!("{}...", &msg[..500])
120
- } else {
121
- msg
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
- // Extract assistant tool use for last output
128
- if val.get("type").and_then(|v| v.as_str()) == Some("assistant") {
129
- if let Some(content) = val.get("message").and_then(|m| m.get("content")) {
130
- if let Some(arr) = content.as_array() {
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));
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
- // Look for text content with decisions
144
- if item.get("type").and_then(|t| t.as_str()) == Some("text") {
145
- if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
146
- // Extract lines that look like decisions
147
- for line in text.lines() {
148
- let trimmed = line.trim();
149
- if (trimmed.starts_with("I'll use") ||
150
- trimmed.starts_with("I chose") ||
151
- trimmed.starts_with("Decision:") ||
152
- trimmed.starts_with("Using ") ||
153
- trimmed.starts_with("Approach:"))
154
- && trimmed.len() > 15
155
- {
156
- decisions.push(truncate(trimmed, 200));
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 and limit decisions
229
+ // Deduplicate decisions
172
230
  decisions.dedup();
173
- decisions.truncate(10);
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
- } else {
206
- format!("{}...", &s[..max])
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
  }
@@ -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.1.1",
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",