anveesa 0.3.1 → 0.3.2
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/Cargo.lock +1 -1
- package/Cargo.toml +2 -2
- package/package.json +1 -1
- package/src/cli.rs +20 -0
- package/src/lib.rs +527 -93
- package/src/tools.rs +79 -22
- package/src/tools_scenarios.rs +1693 -0
package/src/lib.rs
CHANGED
|
@@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
|
|
|
18
18
|
use tokio::sync::mpsc;
|
|
19
19
|
|
|
20
20
|
use crate::{
|
|
21
|
-
cli::{AskOptions, Cli, Command, ConfigCommand},
|
|
21
|
+
cli::{AskOptions, Cli, Command, ConfigCommand, SessionsCommand},
|
|
22
22
|
config::{
|
|
23
23
|
AppConfig, ProviderConfig, config_path, init_config, print_path, set_default_model,
|
|
24
24
|
set_default_provider,
|
|
@@ -42,6 +42,8 @@ struct InteractiveSession {
|
|
|
42
42
|
model: Option<String>,
|
|
43
43
|
system: Option<String>,
|
|
44
44
|
messages: Vec<ChatMessage>,
|
|
45
|
+
#[serde(default)]
|
|
46
|
+
saved_at: u64,
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
pub async fn run_anveesa() -> Result<()> {
|
|
@@ -53,6 +55,7 @@ async fn run_cli(cli: Cli) -> Result<()> {
|
|
|
53
55
|
Some(Command::Ask(args)) => run_ask(args.options, args.prompt).await,
|
|
54
56
|
Some(Command::Providers) => list_providers(),
|
|
55
57
|
Some(Command::Config(args)) => run_config(args.command),
|
|
58
|
+
Some(Command::Sessions(args)) => run_sessions(args.command),
|
|
56
59
|
None if cli.prompt.is_empty() && cli.ask_options.stdin => {
|
|
57
60
|
run_ask(cli.ask_options, cli.prompt).await
|
|
58
61
|
}
|
|
@@ -77,7 +80,7 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
77
80
|
.providers
|
|
78
81
|
.get(&provider_name)
|
|
79
82
|
.with_context(|| format!("unknown provider '{provider_name}'"))?;
|
|
80
|
-
let
|
|
83
|
+
let _tools_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
|
|
81
84
|
let mut images_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
|
|
82
85
|
let model = options
|
|
83
86
|
.model
|
|
@@ -101,20 +104,32 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
101
104
|
|
|
102
105
|
let mut accumulated_usage = Usage::default();
|
|
103
106
|
|
|
104
|
-
|
|
105
|
-
|
|
107
|
+
purge_stale_sessions();
|
|
108
|
+
|
|
109
|
+
let session_path = repl_session_path(&cwd);
|
|
110
|
+
let loaded_session = session_path
|
|
106
111
|
.as_deref()
|
|
107
|
-
.and_then(|path| load_interactive_session(path, &cwd
|
|
108
|
-
.
|
|
112
|
+
.and_then(|path| load_interactive_session(path, &cwd))
|
|
113
|
+
.or_else(|| {
|
|
114
|
+
// Migrate from the legacy single session.json if it matches our cwd.
|
|
115
|
+
let legacy = legacy_session_path()?;
|
|
116
|
+
let session = load_interactive_session(&legacy, &cwd)?;
|
|
117
|
+
let _ = fs::remove_file(&legacy);
|
|
118
|
+
Some(session)
|
|
119
|
+
});
|
|
120
|
+
let mut history = loaded_session.as_ref().map(|s| s.messages.clone()).unwrap_or_default();
|
|
121
|
+
// saved_at at load time — used only for the startup header so it shows when the previous
|
|
122
|
+
// run ended, not the current run's save time.
|
|
123
|
+
let session_saved_at = loaded_session.as_ref().filter(|s| s.saved_at > 0).map(|s| s.saved_at);
|
|
124
|
+
// tracks the most recent successful save this run — kept fresh for /session display
|
|
125
|
+
let mut last_saved_at: u64 = session_saved_at.unwrap_or(0);
|
|
109
126
|
let history_path = repl_history_path();
|
|
110
127
|
print_session_header(
|
|
111
128
|
&provider_name,
|
|
112
129
|
session_options.model.as_deref().unwrap_or("-"),
|
|
113
130
|
history.len() / 2,
|
|
114
|
-
workspace_context.is_some(),
|
|
115
|
-
tools_available,
|
|
116
|
-
policy,
|
|
117
131
|
!history.is_empty(),
|
|
132
|
+
session_saved_at,
|
|
118
133
|
);
|
|
119
134
|
|
|
120
135
|
let is_tty = io::stdout().is_terminal();
|
|
@@ -166,6 +181,15 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
166
181
|
print_help_inline(is_tty);
|
|
167
182
|
continue;
|
|
168
183
|
}
|
|
184
|
+
"/session" => {
|
|
185
|
+
print_session_info(
|
|
186
|
+
is_tty,
|
|
187
|
+
session_path.as_deref(),
|
|
188
|
+
history.len() / 2,
|
|
189
|
+
Some(last_saved_at).filter(|&t| t > 0),
|
|
190
|
+
);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
169
193
|
"/status" => {
|
|
170
194
|
print_status_inline(
|
|
171
195
|
is_tty,
|
|
@@ -284,19 +308,22 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
284
308
|
eprintln!("\x1b[2m Screenshot from clipboard attached.\x1b[0m");
|
|
285
309
|
}
|
|
286
310
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
311
|
+
let ask_result = tokio::select! {
|
|
312
|
+
r = ask_streaming(
|
|
313
|
+
&config,
|
|
314
|
+
&session_options,
|
|
315
|
+
prompt.clone(),
|
|
316
|
+
&history,
|
|
317
|
+
workspace_context.as_deref(),
|
|
318
|
+
policy,
|
|
319
|
+
image,
|
|
320
|
+
RenderMode::Interactive,
|
|
321
|
+
) => Some(r),
|
|
322
|
+
_ = tokio::signal::ctrl_c() => None,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
match ask_result {
|
|
326
|
+
Some(Ok(result)) => {
|
|
300
327
|
println!();
|
|
301
328
|
if let Some(u) = result.usage {
|
|
302
329
|
accumulated_usage.prompt_tokens += u.prompt_tokens;
|
|
@@ -308,16 +335,12 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
308
335
|
history.push(ChatMessage::user(prompt));
|
|
309
336
|
history.push(ChatMessage::assistant(result.text));
|
|
310
337
|
if let Some(path) = &session_path {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
&provider_name,
|
|
315
|
-
&session_options,
|
|
316
|
-
&history,
|
|
317
|
-
);
|
|
338
|
+
if save_interactive_session(path, &cwd, &provider_name, &session_options, &history).is_ok() {
|
|
339
|
+
last_saved_at = unix_now();
|
|
340
|
+
}
|
|
318
341
|
}
|
|
319
342
|
}
|
|
320
|
-
Err(error) => {
|
|
343
|
+
Some(Err(error)) => {
|
|
321
344
|
if is_tty {
|
|
322
345
|
eprintln!("\x1b[1;31m✗\x1b[0m {error:#}");
|
|
323
346
|
} else {
|
|
@@ -329,15 +352,24 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
329
352
|
"The previous turn failed inside Anveesa before a final answer was produced: {error:#}"
|
|
330
353
|
)));
|
|
331
354
|
if let Some(path) = &session_path {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
&provider_name,
|
|
336
|
-
&session_options,
|
|
337
|
-
&history,
|
|
338
|
-
);
|
|
355
|
+
if save_interactive_session(path, &cwd, &provider_name, &session_options, &history).is_ok() {
|
|
356
|
+
last_saved_at = unix_now();
|
|
357
|
+
}
|
|
339
358
|
}
|
|
340
359
|
}
|
|
360
|
+
None => {
|
|
361
|
+
// Ctrl+C during streaming — save current history and exit cleanly.
|
|
362
|
+
println!();
|
|
363
|
+
if is_tty {
|
|
364
|
+
eprintln!("\x1b[2m ^C Session saved.\x1b[0m");
|
|
365
|
+
} else {
|
|
366
|
+
eprintln!("interrupted");
|
|
367
|
+
}
|
|
368
|
+
if let Some(path) = &session_path {
|
|
369
|
+
let _ = save_interactive_session(path, &cwd, &provider_name, &session_options, &history);
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
341
373
|
}
|
|
342
374
|
}
|
|
343
375
|
|
|
@@ -347,6 +379,117 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
347
379
|
Ok(())
|
|
348
380
|
}
|
|
349
381
|
|
|
382
|
+
fn run_sessions(command: SessionsCommand) -> Result<()> {
|
|
383
|
+
match command {
|
|
384
|
+
SessionsCommand::List => list_sessions(),
|
|
385
|
+
SessionsCommand::Clear { all } => clear_sessions(all),
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
fn sessions_dir() -> Option<PathBuf> {
|
|
390
|
+
let config_dir = config_path().ok()?.parent()?.to_path_buf();
|
|
391
|
+
Some(config_dir.join("sessions"))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
fn list_sessions() -> Result<()> {
|
|
395
|
+
let Some(dir) = sessions_dir() else {
|
|
396
|
+
println!("No sessions directory found.");
|
|
397
|
+
return Ok(());
|
|
398
|
+
};
|
|
399
|
+
let Ok(entries) = fs::read_dir(&dir) else {
|
|
400
|
+
println!("No sessions found.");
|
|
401
|
+
return Ok(());
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
let mut sessions: Vec<(String, usize, u64)> = Vec::new();
|
|
405
|
+
for entry in entries.flatten() {
|
|
406
|
+
let path = entry.path();
|
|
407
|
+
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if let Ok(content) = fs::read_to_string(&path) {
|
|
411
|
+
if let Ok(session) = serde_json::from_str::<InteractiveSession>(&content) {
|
|
412
|
+
sessions.push((session.cwd, session.messages.len() / 2, session.saved_at));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
sessions.sort_by(|a, b| b.2.cmp(&a.2));
|
|
417
|
+
|
|
418
|
+
let is_tty = io::stdout().is_terminal();
|
|
419
|
+
if sessions.is_empty() {
|
|
420
|
+
if is_tty {
|
|
421
|
+
eprintln!("\x1b[2m No saved sessions.\x1b[0m");
|
|
422
|
+
} else {
|
|
423
|
+
println!("no sessions");
|
|
424
|
+
}
|
|
425
|
+
return Ok(());
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if !is_tty {
|
|
429
|
+
for (cwd, turns, saved_at) in &sessions {
|
|
430
|
+
println!("{cwd}\t{turns}\t{saved_at}");
|
|
431
|
+
}
|
|
432
|
+
return Ok(());
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
println!();
|
|
436
|
+
println!("\x1b[90m ──────────────────────────────────────────────────────\x1b[0m");
|
|
437
|
+
for (cwd, turns, saved_at) in &sessions {
|
|
438
|
+
let age = format_session_age(Some(*saved_at));
|
|
439
|
+
let turn_str = if *turns == 1 { "1 turn ".to_string() } else { format!("{turns} turns") };
|
|
440
|
+
let short_cwd = std::env::var("HOME")
|
|
441
|
+
.map(|h| cwd.replacen(&h, "~", 1))
|
|
442
|
+
.unwrap_or_else(|_| cwd.clone());
|
|
443
|
+
println!(" \x1b[2m{age:>10}\x1b[0m {turn_str:>7} {short_cwd}");
|
|
444
|
+
}
|
|
445
|
+
println!("\x1b[90m ──────────────────────────────────────────────────────\x1b[0m");
|
|
446
|
+
println!();
|
|
447
|
+
Ok(())
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
fn clear_sessions(all: bool) -> Result<()> {
|
|
451
|
+
let is_tty = io::stdout().is_terminal();
|
|
452
|
+
if all {
|
|
453
|
+
let mut count = 0usize;
|
|
454
|
+
if let Some(dir) = sessions_dir() {
|
|
455
|
+
if let Ok(entries) = fs::read_dir(&dir) {
|
|
456
|
+
for entry in entries.flatten() {
|
|
457
|
+
let path = entry.path();
|
|
458
|
+
if path.extension().and_then(|e| e.to_str()) == Some("json") {
|
|
459
|
+
if fs::remove_file(&path).is_ok() {
|
|
460
|
+
count += 1;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if is_tty {
|
|
467
|
+
eprintln!("\x1b[2m {count} session(s) deleted.\x1b[0m");
|
|
468
|
+
} else {
|
|
469
|
+
println!("{count} sessions deleted");
|
|
470
|
+
}
|
|
471
|
+
} else {
|
|
472
|
+
let cwd = std::env::current_dir().context("failed to resolve current directory")?;
|
|
473
|
+
let path = repl_session_path(&cwd).context("could not determine session path")?;
|
|
474
|
+
if path.exists() {
|
|
475
|
+
fs::remove_file(&path)
|
|
476
|
+
.with_context(|| format!("failed to delete {}", path.display()))?;
|
|
477
|
+
if is_tty {
|
|
478
|
+
eprintln!("\x1b[2m Session for {} cleared.\x1b[0m", cwd.display());
|
|
479
|
+
} else {
|
|
480
|
+
println!("session cleared");
|
|
481
|
+
}
|
|
482
|
+
} else {
|
|
483
|
+
if is_tty {
|
|
484
|
+
eprintln!("\x1b[2m No session for {}.\x1b[0m", cwd.display());
|
|
485
|
+
} else {
|
|
486
|
+
println!("no session");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
Ok(())
|
|
491
|
+
}
|
|
492
|
+
|
|
350
493
|
async fn run_ask(options: AskOptions, prompt_parts: Vec<String>) -> Result<()> {
|
|
351
494
|
let config = AppConfig::load()?;
|
|
352
495
|
let provider_name = config
|
|
@@ -957,7 +1100,7 @@ fn print_status_inline(
|
|
|
957
1100
|
|
|
958
1101
|
fn print_help_inline(is_tty: bool) {
|
|
959
1102
|
if !is_tty {
|
|
960
|
-
println!("commands: /clear, /attach [path], /exit, /quit, /help");
|
|
1103
|
+
println!("commands: /clear, /session, /attach [path], /exit, /quit, /help");
|
|
961
1104
|
println!("images: copy an image (Cmd+C), then send a message to auto-attach it");
|
|
962
1105
|
return;
|
|
963
1106
|
}
|
|
@@ -965,9 +1108,10 @@ fn print_help_inline(is_tty: bool) {
|
|
|
965
1108
|
println!("\x1b[2m Commands\x1b[0m");
|
|
966
1109
|
println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
|
|
967
1110
|
println!(" \x1b[1;32m/status\x1b[0m provider, model, turns, token usage");
|
|
1111
|
+
println!(" \x1b[1;32m/session\x1b[0m show session file, age, and turn count");
|
|
968
1112
|
println!(" \x1b[1;32m/model\x1b[0m \x1b[2m[name]\x1b[0m switch or show current model");
|
|
969
1113
|
println!(" \x1b[1;32m/provider\x1b[0m \x1b[2m[name]\x1b[0m switch or show current provider");
|
|
970
|
-
println!(" \x1b[1;32m/clear\x1b[0m reset conversation");
|
|
1114
|
+
println!(" \x1b[1;32m/clear\x1b[0m reset conversation and delete saved session");
|
|
971
1115
|
println!(" \x1b[1;32m/attach\x1b[0m \x1b[2m[path]\x1b[0m attach image from file or clipboard");
|
|
972
1116
|
println!(" \x1b[1;32m/exit\x1b[0m, \x1b[1;32m/quit\x1b[0m leave the session");
|
|
973
1117
|
println!(" \x1b[1;32m/help\x1b[0m show this message");
|
|
@@ -980,6 +1124,42 @@ fn print_help_inline(is_tty: bool) {
|
|
|
980
1124
|
println!();
|
|
981
1125
|
}
|
|
982
1126
|
|
|
1127
|
+
fn print_session_info(is_tty: bool, path: Option<&Path>, turns: usize, saved_at: Option<u64>) {
|
|
1128
|
+
let Some(path) = path else {
|
|
1129
|
+
if is_tty {
|
|
1130
|
+
eprintln!("\x1b[2m no session path available\x1b[0m");
|
|
1131
|
+
} else {
|
|
1132
|
+
println!("no session path available");
|
|
1133
|
+
}
|
|
1134
|
+
return;
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
let short_path = std::env::var("HOME")
|
|
1138
|
+
.map(|h| path.display().to_string().replacen(&h, "~", 1))
|
|
1139
|
+
.unwrap_or_else(|_| path.display().to_string());
|
|
1140
|
+
|
|
1141
|
+
if !is_tty {
|
|
1142
|
+
println!("session: {short_path}");
|
|
1143
|
+
println!("turns: {turns}");
|
|
1144
|
+
if let Some(ts) = saved_at {
|
|
1145
|
+
println!("saved: {}", format_session_age(Some(ts)));
|
|
1146
|
+
}
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
println!();
|
|
1151
|
+
println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
|
|
1152
|
+
println!(" \x1b[2mfile \x1b[0m \x1b[2m{short_path}\x1b[0m");
|
|
1153
|
+
println!(" \x1b[2mturns \x1b[0m {turns}");
|
|
1154
|
+
if let Some(ts) = saved_at {
|
|
1155
|
+
println!(" \x1b[2msaved \x1b[0m {}", format_session_age(Some(ts)));
|
|
1156
|
+
} else {
|
|
1157
|
+
println!(" \x1b[2msaved \x1b[0m not yet");
|
|
1158
|
+
}
|
|
1159
|
+
println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
|
|
1160
|
+
println!();
|
|
1161
|
+
}
|
|
1162
|
+
|
|
983
1163
|
fn list_providers() -> Result<()> {
|
|
984
1164
|
let config = AppConfig::load()?;
|
|
985
1165
|
let is_tty = io::stdout().is_terminal();
|
|
@@ -1073,17 +1253,19 @@ fn build_prompt(prompt_parts: Vec<String>, force_stdin: bool) -> Result<String>
|
|
|
1073
1253
|
fn print_session_header(
|
|
1074
1254
|
provider: &str,
|
|
1075
1255
|
model: &str,
|
|
1076
|
-
|
|
1077
|
-
_has_workspace_context: bool,
|
|
1078
|
-
_tools_available: bool,
|
|
1079
|
-
_policy: ApprovalPolicy,
|
|
1256
|
+
turns: usize,
|
|
1080
1257
|
resumed: bool,
|
|
1258
|
+
saved_at: Option<u64>,
|
|
1081
1259
|
) {
|
|
1082
1260
|
let is_tty = io::stdout().is_terminal();
|
|
1083
1261
|
let version = env!("CARGO_PKG_VERSION");
|
|
1084
1262
|
|
|
1085
1263
|
if !is_tty {
|
|
1086
|
-
let tag = if resumed {
|
|
1264
|
+
let tag = if resumed {
|
|
1265
|
+
format!(" (resumed · {turns} turns · {})", format_session_age(saved_at))
|
|
1266
|
+
} else {
|
|
1267
|
+
String::new()
|
|
1268
|
+
};
|
|
1087
1269
|
println!("anveesa v{version}{tag} | {provider} · {model}");
|
|
1088
1270
|
return;
|
|
1089
1271
|
}
|
|
@@ -1110,8 +1292,11 @@ fn print_session_header(
|
|
|
1110
1292
|
r
|
|
1111
1293
|
}
|
|
1112
1294
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1295
|
+
let greeting = if resumed {
|
|
1296
|
+
format!(" · Resumed ({turns} turns · {})", format_session_age(saved_at))
|
|
1297
|
+
} else {
|
|
1298
|
+
String::new()
|
|
1299
|
+
};
|
|
1115
1300
|
let title = format!(" anveesa v{version}{greeting} ");
|
|
1116
1301
|
let title_len = title.chars().count();
|
|
1117
1302
|
let right_dashes = width.saturating_sub(2 + title_len);
|
|
@@ -1120,11 +1305,9 @@ fn print_session_header(
|
|
|
1120
1305
|
"─".repeat(right_dashes)
|
|
1121
1306
|
);
|
|
1122
1307
|
|
|
1123
|
-
// provider · model · ~/cwd
|
|
1124
1308
|
let info = trunc_to(&format!(" {provider} · {model} · {cwd}"), width);
|
|
1125
1309
|
println!("\x1b[2m{info}\x1b[0m");
|
|
1126
1310
|
|
|
1127
|
-
// /help for commands
|
|
1128
1311
|
println!("\x1b[2m /help for commands\x1b[0m");
|
|
1129
1312
|
println!();
|
|
1130
1313
|
}
|
|
@@ -1755,6 +1938,40 @@ fn repl_history_path() -> Option<PathBuf> {
|
|
|
1755
1938
|
Some(dir.join("history"))
|
|
1756
1939
|
}
|
|
1757
1940
|
|
|
1941
|
+
fn unix_now() -> u64 {
|
|
1942
|
+
std::time::SystemTime::now()
|
|
1943
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
1944
|
+
.unwrap_or_default()
|
|
1945
|
+
.as_secs()
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
fn format_session_age(saved_at: Option<u64>) -> String {
|
|
1949
|
+
let Some(ts) = saved_at else {
|
|
1950
|
+
return "unknown age".to_string();
|
|
1951
|
+
};
|
|
1952
|
+
let secs = unix_now().saturating_sub(ts);
|
|
1953
|
+
if secs < 60 {
|
|
1954
|
+
"just now".to_string()
|
|
1955
|
+
} else if secs < 3600 {
|
|
1956
|
+
format!("{}m ago", secs / 60)
|
|
1957
|
+
} else if secs < 86400 {
|
|
1958
|
+
format!("{}h ago", secs / 3600)
|
|
1959
|
+
} else {
|
|
1960
|
+
format!("{}d ago", secs / 86400)
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/// FNV-1a 64-bit hash of the cwd path — used as a stable per-directory session filename.
|
|
1965
|
+
fn cwd_session_hash(cwd: &Path) -> String {
|
|
1966
|
+
let s = cwd.to_string_lossy();
|
|
1967
|
+
let mut h: u64 = 14695981039346656037;
|
|
1968
|
+
for b in s.bytes() {
|
|
1969
|
+
h ^= b as u64;
|
|
1970
|
+
h = h.wrapping_mul(1099511628211);
|
|
1971
|
+
}
|
|
1972
|
+
format!("{h:016x}")
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1758
1975
|
fn append_repl_history(path: &Path, prompt: &str) -> io::Result<()> {
|
|
1759
1976
|
if let Some(dir) = path.parent() {
|
|
1760
1977
|
fs::create_dir_all(dir)?;
|
|
@@ -1767,25 +1984,55 @@ fn append_repl_history(path: &Path, prompt: &str) -> io::Result<()> {
|
|
|
1767
1984
|
writeln!(file, "{prompt}")
|
|
1768
1985
|
}
|
|
1769
1986
|
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
let
|
|
1774
|
-
|
|
1987
|
+
/// Delete all session files whose saved_at is older than 30 days. Called once at
|
|
1988
|
+
/// startup so orphaned sessions (from deleted/moved projects) eventually disappear.
|
|
1989
|
+
fn purge_stale_sessions() {
|
|
1990
|
+
let Some(dir) = sessions_dir() else { return };
|
|
1991
|
+
let Ok(entries) = fs::read_dir(&dir) else { return };
|
|
1992
|
+
let cutoff = unix_now().saturating_sub(30 * 24 * 3600);
|
|
1993
|
+
for entry in entries.flatten() {
|
|
1994
|
+
let path = entry.path();
|
|
1995
|
+
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
|
1996
|
+
continue;
|
|
1997
|
+
}
|
|
1998
|
+
let stale = fs::read_to_string(&path)
|
|
1999
|
+
.ok()
|
|
2000
|
+
.and_then(|c| serde_json::from_str::<InteractiveSession>(&c).ok())
|
|
2001
|
+
.map(|s| s.saved_at > 0 && s.saved_at < cutoff)
|
|
2002
|
+
.unwrap_or(true); // unparseable → delete
|
|
2003
|
+
if stale {
|
|
2004
|
+
let _ = fs::remove_file(&path);
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
1775
2007
|
}
|
|
1776
2008
|
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
)
|
|
2009
|
+
/// Per-directory session file: ~/.config/anveesa/sessions/{cwd-hash}.json
|
|
2010
|
+
fn repl_session_path(cwd: &Path) -> Option<PathBuf> {
|
|
2011
|
+
let config_dir = config_path().ok()?.parent()?.to_path_buf();
|
|
2012
|
+
let sessions_dir = config_dir.join("sessions");
|
|
2013
|
+
let _ = fs::create_dir_all(&sessions_dir);
|
|
2014
|
+
Some(sessions_dir.join(format!("{}.json", cwd_session_hash(cwd))))
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
/// Legacy path for backward-compat migration.
|
|
2018
|
+
fn legacy_session_path() -> Option<PathBuf> {
|
|
2019
|
+
let config_dir = config_path().ok()?.parent()?.to_path_buf();
|
|
2020
|
+
let path = config_dir.join("session.json");
|
|
2021
|
+
if path.exists() { Some(path) } else { None }
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
fn load_interactive_session(path: &Path, cwd: &Path) -> Option<InteractiveSession> {
|
|
1783
2025
|
let content = fs::read_to_string(path).ok()?;
|
|
1784
2026
|
let session: InteractiveSession = serde_json::from_str(&content).ok()?;
|
|
1785
|
-
if
|
|
2027
|
+
if session.cwd != cwd.display().to_string() {
|
|
2028
|
+
return None;
|
|
2029
|
+
}
|
|
2030
|
+
// Auto-expire sessions older than 30 days.
|
|
2031
|
+
if session.saved_at > 0 && unix_now().saturating_sub(session.saved_at) > 30 * 24 * 3600 {
|
|
2032
|
+
let _ = fs::remove_file(path);
|
|
1786
2033
|
return None;
|
|
1787
2034
|
}
|
|
1788
|
-
Some(session
|
|
2035
|
+
Some(session)
|
|
1789
2036
|
}
|
|
1790
2037
|
|
|
1791
2038
|
fn save_interactive_session(
|
|
@@ -1801,24 +2048,13 @@ fn save_interactive_session(
|
|
|
1801
2048
|
model: options.model.clone(),
|
|
1802
2049
|
system: options.system.clone(),
|
|
1803
2050
|
messages: history.to_vec(),
|
|
2051
|
+
saved_at: unix_now(),
|
|
1804
2052
|
};
|
|
1805
2053
|
let content = serde_json::to_string_pretty(&session)
|
|
1806
2054
|
.context("failed to serialize interactive session")?;
|
|
1807
2055
|
fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
|
|
1808
2056
|
}
|
|
1809
2057
|
|
|
1810
|
-
fn session_matches(
|
|
1811
|
-
session: &InteractiveSession,
|
|
1812
|
-
cwd: &Path,
|
|
1813
|
-
provider: &str,
|
|
1814
|
-
options: &AskOptions,
|
|
1815
|
-
) -> bool {
|
|
1816
|
-
session.cwd == cwd.display().to_string()
|
|
1817
|
-
&& session.provider == provider
|
|
1818
|
-
&& session.model == options.model
|
|
1819
|
-
&& session.system == options.system
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
2058
|
fn workspace_context() -> Result<String> {
|
|
1823
2059
|
let cwd = std::env::current_dir().context("failed to resolve current directory")?;
|
|
1824
2060
|
workspace_context_for(&cwd)
|
|
@@ -1987,14 +2223,7 @@ mod tests {
|
|
|
1987
2223
|
}
|
|
1988
2224
|
|
|
1989
2225
|
#[test]
|
|
1990
|
-
fn
|
|
1991
|
-
let options = AskOptions {
|
|
1992
|
-
provider: Some("provider-a".into()),
|
|
1993
|
-
model: Some("model-a".into()),
|
|
1994
|
-
system: None,
|
|
1995
|
-
stdin: false,
|
|
1996
|
-
yes: false,
|
|
1997
|
-
};
|
|
2226
|
+
fn interactive_session_matches_cwd_only() {
|
|
1998
2227
|
let cwd = Path::new("/tmp/anveesa-session");
|
|
1999
2228
|
let session = InteractiveSession {
|
|
2000
2229
|
cwd: cwd.display().to_string(),
|
|
@@ -2002,16 +2231,14 @@ mod tests {
|
|
|
2002
2231
|
model: Some("model-a".into()),
|
|
2003
2232
|
system: None,
|
|
2004
2233
|
messages: vec![],
|
|
2234
|
+
saved_at: 0,
|
|
2005
2235
|
};
|
|
2006
2236
|
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
&options
|
|
2013
|
-
));
|
|
2014
|
-
assert!(!session_matches(&session, cwd, "provider-b", &options));
|
|
2237
|
+
// Matches when cwd is the same.
|
|
2238
|
+
assert_eq!(session.cwd, cwd.display().to_string());
|
|
2239
|
+
// A different cwd should not match.
|
|
2240
|
+
assert_ne!(session.cwd, Path::new("/tmp/other").display().to_string());
|
|
2241
|
+
// Provider/model differences no longer prevent a session from loading.
|
|
2015
2242
|
}
|
|
2016
2243
|
|
|
2017
2244
|
#[test]
|
|
@@ -2034,11 +2261,218 @@ mod tests {
|
|
|
2034
2261
|
|
|
2035
2262
|
save_interactive_session(&path, &dir, "provider-a", &options, &history).unwrap();
|
|
2036
2263
|
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
);
|
|
2264
|
+
let loaded = load_interactive_session(&path, &dir).unwrap();
|
|
2265
|
+
assert_eq!(loaded.messages, history);
|
|
2266
|
+
// saved_at should be set.
|
|
2267
|
+
assert!(loaded.saved_at > 0);
|
|
2041
2268
|
|
|
2042
2269
|
let _ = fs::remove_dir_all(&dir);
|
|
2043
2270
|
}
|
|
2271
|
+
|
|
2272
|
+
// ── cwd_session_hash ──────────────────────────────────────────────────────
|
|
2273
|
+
|
|
2274
|
+
#[test]
|
|
2275
|
+
fn cwd_hash_is_deterministic() {
|
|
2276
|
+
let p = Path::new("/home/user/my-project");
|
|
2277
|
+
assert_eq!(cwd_session_hash(p), cwd_session_hash(p));
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
#[test]
|
|
2281
|
+
fn cwd_hash_differs_for_different_paths() {
|
|
2282
|
+
let a = cwd_session_hash(Path::new("/home/user/project-a"));
|
|
2283
|
+
let b = cwd_session_hash(Path::new("/home/user/project-b"));
|
|
2284
|
+
let c = cwd_session_hash(Path::new("/home/user/project-a/sub"));
|
|
2285
|
+
assert_ne!(a, b);
|
|
2286
|
+
assert_ne!(a, c);
|
|
2287
|
+
assert_ne!(b, c);
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
#[test]
|
|
2291
|
+
fn cwd_hash_is_16_hex_chars() {
|
|
2292
|
+
let h = cwd_session_hash(Path::new("/any/path"));
|
|
2293
|
+
assert_eq!(h.len(), 16);
|
|
2294
|
+
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// ── format_session_age ────────────────────────────────────────────────────
|
|
2298
|
+
|
|
2299
|
+
#[test]
|
|
2300
|
+
fn format_age_none_returns_unknown() {
|
|
2301
|
+
assert_eq!(format_session_age(None), "unknown age");
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
#[test]
|
|
2305
|
+
fn format_age_just_now() {
|
|
2306
|
+
let ts = unix_now();
|
|
2307
|
+
assert_eq!(format_session_age(Some(ts)), "just now");
|
|
2308
|
+
assert_eq!(format_session_age(Some(ts - 59)), "just now");
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
#[test]
|
|
2312
|
+
fn format_age_minutes() {
|
|
2313
|
+
let ts = unix_now() - 60;
|
|
2314
|
+
assert_eq!(format_session_age(Some(ts)), "1m ago");
|
|
2315
|
+
let ts2 = unix_now() - 3599;
|
|
2316
|
+
assert_eq!(format_session_age(Some(ts2)), "59m ago");
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
#[test]
|
|
2320
|
+
fn format_age_hours() {
|
|
2321
|
+
let ts = unix_now() - 3600;
|
|
2322
|
+
assert_eq!(format_session_age(Some(ts)), "1h ago");
|
|
2323
|
+
let ts2 = unix_now() - 86399;
|
|
2324
|
+
assert_eq!(format_session_age(Some(ts2)), "23h ago");
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
#[test]
|
|
2328
|
+
fn format_age_days() {
|
|
2329
|
+
let ts = unix_now() - 86400;
|
|
2330
|
+
assert_eq!(format_session_age(Some(ts)), "1d ago");
|
|
2331
|
+
let ts2 = unix_now() - 7 * 86400;
|
|
2332
|
+
assert_eq!(format_session_age(Some(ts2)), "7d ago");
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
// ── 30-day expiry ─────────────────────────────────────────────────────────
|
|
2336
|
+
|
|
2337
|
+
#[test]
|
|
2338
|
+
fn expired_session_is_deleted_on_load() {
|
|
2339
|
+
let dir = std::env::temp_dir().join(format!("anveesa_expiry_{}", std::process::id()));
|
|
2340
|
+
let _ = fs::remove_dir_all(&dir);
|
|
2341
|
+
fs::create_dir_all(&dir).unwrap();
|
|
2342
|
+
let path = dir.join("old_session.json");
|
|
2343
|
+
let options = AskOptions { provider: Some("p".into()), model: None, system: None, stdin: false, yes: false };
|
|
2344
|
+
save_interactive_session(&path, &dir, "p", &options, &[]).unwrap();
|
|
2345
|
+
|
|
2346
|
+
// Backdate saved_at by 31 days.
|
|
2347
|
+
let content = fs::read_to_string(&path).unwrap();
|
|
2348
|
+
let mut session: InteractiveSession = serde_json::from_str(&content).unwrap();
|
|
2349
|
+
session.saved_at = unix_now() - 31 * 24 * 3600;
|
|
2350
|
+
fs::write(&path, serde_json::to_string_pretty(&session).unwrap()).unwrap();
|
|
2351
|
+
|
|
2352
|
+
let result = load_interactive_session(&path, &dir);
|
|
2353
|
+
assert!(result.is_none(), "expired session must not load");
|
|
2354
|
+
assert!(!path.exists(), "expired session file must be deleted");
|
|
2355
|
+
|
|
2356
|
+
let _ = fs::remove_dir_all(&dir);
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
#[test]
|
|
2360
|
+
fn non_expired_session_loads_normally() {
|
|
2361
|
+
let dir = std::env::temp_dir().join(format!("anveesa_noexpiry_{}", std::process::id()));
|
|
2362
|
+
let _ = fs::remove_dir_all(&dir);
|
|
2363
|
+
fs::create_dir_all(&dir).unwrap();
|
|
2364
|
+
let path = dir.join("session.json");
|
|
2365
|
+
let options = AskOptions { provider: Some("p".into()), model: None, system: None, stdin: false, yes: false };
|
|
2366
|
+
let history = vec![ChatMessage::user("hi".into()), ChatMessage::assistant("hello".into())];
|
|
2367
|
+
save_interactive_session(&path, &dir, "p", &options, &history).unwrap();
|
|
2368
|
+
|
|
2369
|
+
let loaded = load_interactive_session(&path, &dir).unwrap();
|
|
2370
|
+
assert_eq!(loaded.messages, history);
|
|
2371
|
+
assert!(path.exists());
|
|
2372
|
+
|
|
2373
|
+
let _ = fs::remove_dir_all(&dir);
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// ── legacy migration ──────────────────────────────────────────────────────
|
|
2377
|
+
|
|
2378
|
+
#[test]
|
|
2379
|
+
fn mismatched_cwd_returns_none() {
|
|
2380
|
+
let dir_a = std::env::temp_dir().join(format!("anveesa_cwd_a_{}", std::process::id()));
|
|
2381
|
+
let dir_b = std::env::temp_dir().join(format!("anveesa_cwd_b_{}", std::process::id()));
|
|
2382
|
+
let _ = fs::remove_dir_all(&dir_a);
|
|
2383
|
+
let _ = fs::remove_dir_all(&dir_b);
|
|
2384
|
+
fs::create_dir_all(&dir_a).unwrap();
|
|
2385
|
+
let path = dir_a.join("session.json");
|
|
2386
|
+
let options = AskOptions { provider: None, model: None, system: None, stdin: false, yes: false };
|
|
2387
|
+
save_interactive_session(&path, &dir_a, "p", &options, &[]).unwrap();
|
|
2388
|
+
|
|
2389
|
+
// Loading with a different cwd must return None.
|
|
2390
|
+
assert!(load_interactive_session(&path, &dir_b).is_none());
|
|
2391
|
+
// Loading with the correct cwd must succeed.
|
|
2392
|
+
assert!(load_interactive_session(&path, &dir_a).is_some());
|
|
2393
|
+
|
|
2394
|
+
let _ = fs::remove_dir_all(&dir_a);
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// ── purge_stale_sessions ──────────────────────────────────────────────────
|
|
2398
|
+
|
|
2399
|
+
#[test]
|
|
2400
|
+
fn purge_removes_old_files_but_keeps_recent_ones() {
|
|
2401
|
+
let sessions_base = std::env::temp_dir()
|
|
2402
|
+
.join(format!("anveesa_purge_{}", std::process::id()));
|
|
2403
|
+
let _ = fs::remove_dir_all(&sessions_base);
|
|
2404
|
+
fs::create_dir_all(&sessions_base).unwrap();
|
|
2405
|
+
|
|
2406
|
+
let options = AskOptions { provider: None, model: None, system: None, stdin: false, yes: false };
|
|
2407
|
+
|
|
2408
|
+
// Create two fresh sessions and one stale session.
|
|
2409
|
+
let fresh_dir_1 = sessions_base.join("project1");
|
|
2410
|
+
let fresh_dir_2 = sessions_base.join("project2");
|
|
2411
|
+
let stale_dir = sessions_base.join("old_project");
|
|
2412
|
+
fs::create_dir_all(&fresh_dir_1).unwrap();
|
|
2413
|
+
fs::create_dir_all(&fresh_dir_2).unwrap();
|
|
2414
|
+
fs::create_dir_all(&stale_dir).unwrap();
|
|
2415
|
+
|
|
2416
|
+
let fresh1_path = sessions_base.join("fresh1.json");
|
|
2417
|
+
let fresh2_path = sessions_base.join("fresh2.json");
|
|
2418
|
+
let stale_path = sessions_base.join("stale.json");
|
|
2419
|
+
|
|
2420
|
+
save_interactive_session(&fresh1_path, &fresh_dir_1, "p", &options, &[]).unwrap();
|
|
2421
|
+
save_interactive_session(&fresh2_path, &fresh_dir_2, "p", &options, &[]).unwrap();
|
|
2422
|
+
save_interactive_session(&stale_path, &stale_dir, "p", &options, &[]).unwrap();
|
|
2423
|
+
|
|
2424
|
+
// Backdate the stale session.
|
|
2425
|
+
let content = fs::read_to_string(&stale_path).unwrap();
|
|
2426
|
+
let mut session: InteractiveSession = serde_json::from_str(&content).unwrap();
|
|
2427
|
+
session.saved_at = unix_now() - 31 * 24 * 3600;
|
|
2428
|
+
fs::write(&stale_path, serde_json::to_string_pretty(&session).unwrap()).unwrap();
|
|
2429
|
+
|
|
2430
|
+
// Manually run purge logic over our temp dir (can't call purge_stale_sessions
|
|
2431
|
+
// directly since it targets the real config dir, so we replicate its logic).
|
|
2432
|
+
let cutoff = unix_now().saturating_sub(30 * 24 * 3600);
|
|
2433
|
+
for entry in fs::read_dir(&sessions_base).unwrap().flatten() {
|
|
2434
|
+
let path = entry.path();
|
|
2435
|
+
if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; }
|
|
2436
|
+
let stale = fs::read_to_string(&path)
|
|
2437
|
+
.ok()
|
|
2438
|
+
.and_then(|c| serde_json::from_str::<InteractiveSession>(&c).ok())
|
|
2439
|
+
.map(|s| s.saved_at > 0 && s.saved_at < cutoff)
|
|
2440
|
+
.unwrap_or(true);
|
|
2441
|
+
if stale { let _ = fs::remove_file(&path); }
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
assert!(fresh1_path.exists(), "fresh session 1 must not be purged");
|
|
2445
|
+
assert!(fresh2_path.exists(), "fresh session 2 must not be purged");
|
|
2446
|
+
assert!(!stale_path.exists(), "stale session must be purged");
|
|
2447
|
+
|
|
2448
|
+
let _ = fs::remove_dir_all(&sessions_base);
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
#[test]
|
|
2452
|
+
fn purge_removes_unparseable_json_files() {
|
|
2453
|
+
let sessions_base = std::env::temp_dir()
|
|
2454
|
+
.join(format!("anveesa_purge_bad_{}", std::process::id()));
|
|
2455
|
+
let _ = fs::remove_dir_all(&sessions_base);
|
|
2456
|
+
fs::create_dir_all(&sessions_base).unwrap();
|
|
2457
|
+
|
|
2458
|
+
let bad_path = sessions_base.join("corrupt.json");
|
|
2459
|
+
fs::write(&bad_path, b"not valid json at all {{{").unwrap();
|
|
2460
|
+
|
|
2461
|
+
// Replicate purge logic.
|
|
2462
|
+
let cutoff = unix_now().saturating_sub(30 * 24 * 3600);
|
|
2463
|
+
for entry in fs::read_dir(&sessions_base).unwrap().flatten() {
|
|
2464
|
+
let path = entry.path();
|
|
2465
|
+
if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; }
|
|
2466
|
+
let stale = fs::read_to_string(&path)
|
|
2467
|
+
.ok()
|
|
2468
|
+
.and_then(|c| serde_json::from_str::<InteractiveSession>(&c).ok())
|
|
2469
|
+
.map(|s| s.saved_at > 0 && s.saved_at < cutoff)
|
|
2470
|
+
.unwrap_or(true);
|
|
2471
|
+
if stale { let _ = fs::remove_file(&path); }
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
assert!(!bad_path.exists(), "corrupt session file must be purged");
|
|
2475
|
+
|
|
2476
|
+
let _ = fs::remove_dir_all(&sessions_base);
|
|
2477
|
+
}
|
|
2044
2478
|
}
|