Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ grep-printer = { version = "0.3", default-features = false }
grep-regex = { version = "0.1", default-features = false }
grep-searcher = { version = "0.1", default-features = false }
htmd = { version = "0.5", default-features = false }
http = { version = "1", default-features = false }
httpmock = { git = "https://github.com/JeanMertz/httpmock", branch = "tweaks", default-features = false }
humantime = { version = "2", default-features = false }
ignore = { version = "0.4", default-features = false }
Expand Down
12 changes: 12 additions & 0 deletions crates/jp_cli/src/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,18 @@ impl From<crate::error::Error> for Error {
),
]
.into(),
TitleGeneration { model, reason } => [
("message", "Title generation failed".to_owned()),
("model", model),
("reason", reason),
(
"suggestion",
"Retry with a different model, e.g. `jp conversation title --model <model>`, \
or set the title directly with `jp conversation edit --title \"...\"`."
.to_owned(),
),
]
.into(),
CliConfig(error) => {
[("message", "CLI Config error".to_owned()), ("error", error)].into()
}
Expand Down
12 changes: 12 additions & 0 deletions crates/jp_cli/src/cmd/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mod print;
mod rm;
mod show;
pub(crate) mod summarize;
mod title;
mod unarchive;
mod use_;

Expand All @@ -32,6 +33,7 @@ impl Conversation {
Commands::Edit(args) => args.run(ctx, handles).await,
Commands::Fork(args) => args.run(ctx, &handles).await,
Commands::Compact(args) => args.run(ctx, handles).await,
Commands::Title(args) => args.run(ctx, handles).await,
Commands::Grep(args) => args.run(ctx, handles),
Commands::Print(args) => args.run(ctx, &handles),
Commands::Path(args) => args.run(ctx, handles),
Expand All @@ -49,6 +51,7 @@ impl Conversation {
Commands::Edit(args) => args.conversation_load_request(),
Commands::Fork(args) => args.conversation_load_request(),
Commands::Compact(args) => args.conversation_load_request(),
Commands::Title(args) => args.conversation_load_request(),
Commands::Grep(args) => args.conversation_load_request(),
Commands::Print(args) => args.conversation_load_request(),
Commands::Path(args) => args.conversation_load_request(),
Expand All @@ -69,6 +72,7 @@ impl IntoPartialAppConfig for Conversation {
) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
match &self.command {
Commands::Compact(args) => args.apply_cli_config(workspace, partial, merged_config),
Commands::Title(args) => args.apply_cli_config(workspace, partial, merged_config),
Commands::Show(_)
| Commands::Remove(_)
| Commands::Edit(_)
Expand Down Expand Up @@ -119,6 +123,14 @@ enum Commands {
#[command(name = "compact")]
Compact(compact::Compact),

/// Generate a conversation title with an LLM.
///
/// Offers the generated candidates as a picker, and applies the chosen one
/// as the conversation's title.
/// Use `edit --title "TITLE"` to set a title without involving a model.
#[command(name = "title", visible_alias = "t")]
Title(title::Title),

/// Search through conversation history.
#[command(name = "grep", alias = "rg", visible_alias = "g")]
Grep(grep::Grep),
Expand Down
151 changes: 16 additions & 135 deletions crates/jp_cli/src/cmd/conversation/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,15 @@ use camino::Utf8PathBuf;
use chrono::Utc;
use crossterm::style::Stylize as _;
use inquire::Confirm;
use jp_config::{
AppConfig, PartialAppConfig, ToPartial as _, model::id::PartialModelIdOrAliasConfig,
};
use jp_conversation::{
ConversationEvent, ConversationId, ConversationStream,
event::{ChatRequest, ChatResponse},
thread::ThreadBuilder,
};
use jp_conversation::ConversationId;
use jp_editor::{EditOutcome, EditRequest};
use jp_llm::{
event::Event,
event_builder::EventBuilder,
provider,
retry::{RetryConfig, collect_with_retry},
title,
};
use jp_printer::PrinterWriter;
use jp_storage::{
LoadError,
backend::{FsStorageBackend, LoadBackend, Projection},
};
use jp_workspace::ConversationHandle;

use super::path::resolve_paths;
use super::{path::resolve_paths, title};
use crate::{
cmd::{
ConversationLoadRequest, Output,
Expand Down Expand Up @@ -74,7 +59,11 @@ pub(crate) struct Edit {
#[arg(long = "no-tmp", group = "property")]
no_expires_at: bool,

/// Edit the title of the conversation.
/// Set the title of the conversation.
///
/// Without a value, generates one with an LLM and offers the candidates as
/// a picker — the same as `conversation title`, which additionally exposes
/// the model and candidate count.
#[arg(long, group = "property", conflicts_with = "no_title")]
title: Option<Option<String>>,

Expand Down Expand Up @@ -271,14 +260,15 @@ impl Edit {
});
}

if let Some(ref title) = self.title {
let events = conv.events().clone();
let title = match title {
Some(title) => title.clone(),
None => {
generate_titles(&ctx.config(), ctx.printer.out_writer(), events, vec![])
.await?
}
if let Some(ref new_title) = self.title {
// A bare `--title` asks for a generated one; `conversation
// title` owns that path and its flags.
let title = if let Some(title) = new_title {
title.clone()
} else {
let cfg = ctx.config();
let events = conv.events().clone();
title::select(ctx, &cfg, events, title::DEFAULT_COUNT, false).await?
};

conv.update_metadata(|m| m.title = Some(title));
Expand Down Expand Up @@ -308,115 +298,6 @@ impl Edit {
}
}

async fn generate_titles(
config: &AppConfig,
mut writer: PrinterWriter<'_>,
mut events: ConversationStream,
mut rejected: Vec<String>,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let count = 3;
let model = config
.conversation
.title
.generate
.model
.clone()
.unwrap_or_else(|| config.assistant.model.clone());

let model_id = model.id.resolved();

let mut partial = PartialAppConfig::empty();
partial.assistant.model.id = PartialModelIdOrAliasConfig::Id(model_id.to_partial());
events.add_config_delta(partial);

let provider = provider::get_provider(model_id.provider, &config.providers.llm)?;
let model_details = provider.model_details(&model_id.name).await?;

let sections = title::title_instructions(count, &rejected);
let schema = title::title_schema(count);

let thread = ThreadBuilder::default()
.with_events(events.clone())
.with_sections(sections)
.build()?;

let mut thread_events = thread.events.clone();
thread_events.start_turn(ChatRequest {
content: "Generate titles for this conversation.".into(),
schema: Some(schema),
author: None,
});

let query = jp_llm::query::ChatQuery {
thread: jp_conversation::thread::Thread {
events: thread_events,
..thread
},
tools: vec![],
tool_choice: jp_config::assistant::tool_choice::ToolChoice::default(),
};

let retry_config = RetryConfig::default();
let llm_events =
collect_with_retry(provider.as_ref(), &model_details, query, &retry_config).await?;

// Pipe raw streaming events through the EventBuilder so that structured
// JSON chunks are concatenated and parsed into a proper Value (rather than
// individual Value::String fragments).
let mut builder = EventBuilder::new();
let mut flushed = Vec::new();
for event in llm_events {
match event {
Event::Part {
index,
part,
metadata,
} => {
builder.handle_part(index, part, metadata);
}
Event::Flush { index, metadata } => {
flushed.extend(builder.handle_flush(index, metadata));
}
Event::Finished(_) => flushed.extend(builder.drain()),
Event::Patch(_) | Event::KeepAlive => {}
}
}

let structured_data = flushed
.into_iter()
.filter_map(ConversationEvent::into_chat_response)
.find_map(ChatResponse::into_structured_data);

let titles = structured_data
.as_ref()
.map(title::extract_titles)
.unwrap_or_default();

if titles.is_empty() {
return Err("No titles generated".into());
}

let mut choices = titles.clone();
choices.extend(rejected.clone());
choices.push("More...".to_owned());
choices.push("Manually enter a title".to_owned());

let result =
inquire::Select::new("Conversation Title", choices).prompt_with_writer(&mut writer)?;

match result.as_str() {
"More..." => {
rejected.extend(titles);
Box::pin(generate_titles(config, writer, events, rejected)).await
}
"Manually enter a title" => {
let title = inquire::Text::new("Title").prompt_with_writer(&mut writer)?;
Ok(title.trim().to_owned())
}
choice => Ok(choice.to_owned()),
}
}

/// Duration value for `--tmp`, supporting `now` as an alias for zero duration.
#[derive(Debug, Clone, Copy)]
struct ExpirationDuration(Duration);
Expand Down
47 changes: 47 additions & 0 deletions crates/jp_cli/src/cmd/conversation/summarize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use jp_llm::{
model::ModelDetails,
provider,
retry::{RetryConfig, collect_with_retry},
window,
};
use tracing::debug;

Expand Down Expand Up @@ -89,6 +90,25 @@ pub async fn generate_summary(
let provider = provider::get_provider(model_id.provider, &app_cfg.providers.llm)?;
let model_details = provider.model_details(&model_id.name).await?;

// The instructions ride in the system prompt and the request in its own
// turn; both share the window with the range.
let overhead =
window::estimate_overhead_chars(Some(instructions), &[], &[], &[]) + user_message.len();

if let Some(overflow) = window_overflow(&stream, model_details.context_window, overhead) {
return Err(Error::Summarize {
model: model_id.to_string(),
// Stored indices are 0-based; turn numbers shown to the user are
// 1-based.
reason: format!(
"turns {}..{} {overflow}; compact a smaller range (`--from`/`--to`) or summarize \
with a larger-window model",
range_from + 1,
range_to + 1,
),
});
}

summarize_stream(
provider.as_ref(),
&model_details,
Expand All @@ -100,6 +120,33 @@ pub async fn generate_summary(
.await
}

/// Describe why `stream` does not fit `context_window`, or `None` when it does.
///
/// A summary stands in for every turn it covers, so a range that doesn't fit is
/// rejected rather than shortened: summarizing only the tail would leave a
/// compaction that claims a range it never read, and the projected conversation
/// would quietly lose the rest.
///
/// `overhead_chars` is the size of everything else sharing the window (see
/// [`window::estimate_overhead_chars`]).
/// An unknown window always fits — there is no budget to measure against.
fn window_overflow(
stream: &ConversationStream,
context_window: Option<u32>,
overhead_chars: usize,
) -> Option<String> {
let context_window = context_window?;
let budget = window::budget_chars(context_window, overhead_chars);
let needed = window::estimate_chars(stream);

(needed > budget).then(|| {
format!(
"are roughly {needed} characters, which exceeds the ~{budget} that fit in the model's \
{context_window} token context window"
)
})
}

/// Request a summary of `stream`, honouring provider rebuild requests.
///
/// A provider that answers with [`FinishReason::Retry`] supplies patches that
Expand Down
50 changes: 50 additions & 0 deletions crates/jp_cli/src/cmd/conversation/summarize_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use jp_llm::{

use super::{
StreamOutcome, collect_range_events, failure_reason, summarize_events, summarize_stream,
window_overflow,
};

/// A stream that produced `text` and then stopped for `reason`.
Expand Down Expand Up @@ -85,6 +86,55 @@ fn chat_request_texts(events: &[jp_conversation::ConversationEvent]) -> Vec<Stri
.collect()
}

/// A range comfortably inside the window is summarized as-is.
#[test]
fn a_range_that_fits_reports_no_overflow() {
let stream = build_stream_with_turns(4);
assert_eq!(window_overflow(&stream, Some(100_000), 0), None);
}

/// The reported failure's shape, on the summarizer path: a large range against
/// a small-window model.
/// Unlike title generation this is rejected rather than shortened, so the
/// summary never covers less than the range it is stored for.
#[test]
fn a_range_past_the_window_overflows() {
let mut stream = ConversationStream::new_test();
for i in 0..200 {
stream.start_turn(format!("turn {i}: {}", "x".repeat(1000)));
}

let overflow = window_overflow(&stream, Some(1000), 0).expect("range must not fit");
assert_eq!(
overflow,
"are roughly 201890 characters, which exceeds the ~2700 that fit in the model's 1000 \
token context window"
);
}

/// Overhead is charged against the same window, so a range that fits on its own
/// can still overflow once the instructions are counted.
#[test]
fn overhead_can_push_a_fitting_range_over() {
let mut stream = ConversationStream::new_test();
stream.start_turn("x".repeat(2000));

assert_eq!(window_overflow(&stream, Some(1000), 0), None);
assert!(window_overflow(&stream, Some(1000), 1000).is_some());
}

/// Providers that don't report a window (local llama.cpp, Ollama) have no
/// budget to check against, so nothing is rejected.
#[test]
fn an_unknown_window_never_overflows() {
let mut stream = ConversationStream::new_test();
for i in 0..200 {
stream.start_turn(format!("turn {i}: {}", "x".repeat(1000)));
}

assert_eq!(window_overflow(&stream, None, 0), None);
}

#[test]
fn collects_full_range() {
let stream = build_stream_with_turns(4);
Expand Down
Loading
Loading