diff --git a/README.md b/README.md index 712e700..66c752e 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ for the full command-to-token matrix, use the [`auth-matrix`](https://kagi.micr. | `kagi auth` | inspect, validate, and save credentials | | `kagi summarize` | use the paid public summarizer API or the subscriber summarizer with `--subscriber` | | `kagi news` | read Kagi News from public JSON endpoints | -| `kagi assistant` | prompt Kagi Assistant with a subscriber session token | +| `kagi assistant` | prompt Kagi Assistant, continue threads, and manage thread list/export/delete with a subscriber session token | | `kagi fastgpt` | query FastGPT through the paid API | | `kagi enrich` | query Kagi's web and news enrichment indexes | | `kagi smallweb` | fetch the Kagi Small Web feed | @@ -200,6 +200,13 @@ continue research with assistant: kagi assistant "plan a focused research session in the terminal" ``` +list or export Assistant threads: + +```bash +kagi assistant thread list +kagi assistant thread export +``` + use the subscriber summarizer: ```bash diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 21287e0..8aba6b1 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -12,6 +12,7 @@ - **Subscriber web Summarizer** - implemented on the session-token web-product path via `kagi summarize --subscriber ...` - **Kagi News public product endpoints** - implemented via `kagi news ...` - **Subscriber web Assistant prompt flow** - implemented on Kagi Assistant's authenticated tagged stream via `kagi assistant ...` +- **Subscriber web Assistant thread list/open/delete/export flows** - implemented on the authenticated Assistant thread endpoints via `kagi assistant thread ...` ## Source of truth @@ -25,6 +26,7 @@ According to Kagi's public API docs, the documented API families are: This CLI also implements non-public or product-only seams: - subscriber web Summarizer via Kagi session-token auth - subscriber web Assistant prompt flow via Kagi session-token auth +- subscriber web Assistant thread management via Kagi session-token auth - Kagi News product endpoints ## TODO / deferred @@ -41,5 +43,5 @@ This CLI also implements non-public or product-only seams: - The subscriber web Summarizer requires `KAGI_SESSION_TOKEN` and uses the authenticated `GET /mother/summary_labs?...` stream path instead of the public `/api/v0/summarize` endpoint. - Live verification on March 16, 2026 showed that `https://translate.kagi.com/api/auth` returns `null` even when the same `KAGI_SESSION_TOKEN` works on `kagi.com`. - Because the repo is marketed around Session Link auth, `translate` was removed from the CLI surface until that mismatch is solved. -- Assistant requires `KAGI_SESSION_TOKEN` and currently targets `/assistant/prompt` with the same tagged stream protocol used by the web app. +- Assistant requires `KAGI_SESSION_TOKEN` and currently targets `/assistant/prompt`, `/assistant/thread_list`, `/assistant/thread_open`, `/assistant/thread_delete`, and `/assistant//download`. - News uses `https://news.kagi.com/api/...` JSON endpoints and does not require auth. diff --git a/docs/commands/assistant.mdx b/docs/commands/assistant.mdx index 1fee252..a6651a3 100644 --- a/docs/commands/assistant.mdx +++ b/docs/commands/assistant.mdx @@ -1,11 +1,11 @@ --- title: "assistant" -description: "Complete reference for *kagi* assistant command - interact with Kagi AI Assistant programmatically." +description: "Complete reference for *kagi* assistant command - prompt Kagi Assistant and manage Assistant threads." --- # `kagi assistant` -Prompt Kagi Assistant and continue existing conversation threads from the terminal. +Prompt Kagi Assistant, continue an existing thread, and manage thread list, export, and deletion from the terminal. ![Assistant demo](/images/demos/assistant.gif) @@ -13,233 +13,216 @@ Prompt Kagi Assistant and continue existing conversation threads from the termin ```bash kagi assistant [OPTIONS] +kagi assistant thread list +kagi assistant thread get +kagi assistant thread delete +kagi assistant thread export [--format markdown|json] ``` -## Description - -The `kagi assistant` command provides programmatic access to Kagi's AI Assistant feature. Send prompts, receive responses, and continue multi-turn conversations via thread continuation. - -This command is ideal for: -- Quick questions without opening a browser -- Building conversational workflows -- Integrating Assistant replies into scripts -- Continuing previous conversations - ## Authentication **Required:** `KAGI_SESSION_TOKEN` -The Assistant feature requires a Kagi subscription and session token authentication. +The Assistant feature uses Kagi's authenticated web-product stream, not the paid public API token path. -## Arguments +## Prompt Mode -### `` (Required) +Use prompt mode to start a new thread or continue an existing one: -The prompt or question to send to the Assistant. - -**Example:** ```bash -kagi assistant "Explain recursion in Python" -kagi assistant "What are the latest features in Rust?" +kagi assistant "plan a focused research session in the terminal" +kagi assistant --thread-id "turn that into a 3-item checklist" ``` -## Options +### Prompt Arguments -### `--thread-id ` +#### `` -Continue an existing conversation thread. +The prompt to send to Kagi Assistant. -**Type:** String -**Source:** Previous Assistant response +#### `--thread-id ` -**Example:** -```bash -# Start conversation -kagi assistant "What is machine learning?" -# Response includes: "thread_id": "abc123..." +Continue an existing Assistant thread. -# Continue conversation -kagi assistant --thread-id abc123 "Give me an example" -``` +#### `--model ` -## Output Format +Override the model slug for a single prompt. -```json -{ - "meta": { - "version": "202603091651.stage.c128588", - "trace": "trace-123" - }, - "thread": { - "id": "thread-1", - "title": "Greeting", - "created_at": "2026-03-16T06:19:07Z" - }, - "message": { - "id": "msg-1", - "thread_id": "thread-1", - "created_at": "2026-03-16T06:19:07Z", - "state": "done", - "prompt": "Hello", - "markdown": "Hi" - } -} +Example: + +```bash +kagi assistant --model gpt-5-mini "reply with only the model name" ``` -### Fields +#### `--lens ` -| Field | Type | Description | -|-------|------|-------------| -| `meta` | object | Stream metadata such as version and trace id | -| `thread` | object | Thread metadata for continuation | -| `message` | object | The assistant reply payload | +Override the Assistant lens id for a single prompt. -## Examples +This is the Assistant profile `lens_id`, not the search-command lens index used by `kagi search --lens`. -### Basic Queries +#### `--web-access` +#### `--no-web-access` -```bash -# Simple question -kagi assistant "What is Docker?" +Force internet access on or off for a single prompt. -# Complex query -kagi assistant "Explain the difference between concurrency and parallelism with examples" +#### `--personalized` +#### `--no-personalized` -# Code help -kagi assistant "How do I read a file in Python?" -``` - -### Conversation Chains +Force Kagi personalizations on or off for a single prompt. -```bash -#!/bin/bash -# Start a conversation -THREAD_ID=$(kagi assistant "Explain quantum computing" | jq -r '.thread.id') +## Thread Subcommands -# Continue with follow-up -kagi assistant --thread-id "$THREAD_ID" "What are qubits?" +### `kagi assistant thread list` -# Another follow-up -kagi assistant --thread-id "$THREAD_ID" "How are they different from classical bits?" +List Assistant threads for the current account. -echo "Thread ID for later: $THREAD_ID" +```bash +kagi assistant thread list | jq -r '.threads[].id' ``` -### Processing Output +### `kagi assistant thread get ` -```bash -# Extract just the response -kagi assistant "Hello" | jq -r '.message.markdown' - -# Save conversation -kagi assistant "Explain Rust" > conversation.json +Fetch one thread with its messages. -# Extract thread ID for continuation -THREAD_ID=$(kagi assistant "Initial prompt" | jq -r '.thread.id') +```bash +kagi assistant thread get "$THREAD_ID" ``` -### Building Tools - -```bash -#!/bin/bash -# ai-helper.sh - Quick AI assistance +### `kagi assistant thread delete ` -QUESTION="$1" -if [ -z "$QUESTION" ]; then - echo "Usage: ai-helper.sh 'your question'" - exit 1 -fi +Delete one thread. -echo "🤔 Asking Kagi Assistant..." -kagi assistant "$QUESTION" | jq -r '.message.markdown' +```bash +kagi assistant thread delete "$THREAD_ID" ``` -## Exit Codes - -| Code | Meaning | -|------|---------| -| 0 | Success - response received | -| 1 | Error - see stderr | +### `kagi assistant thread export ` -**Common errors:** -- Missing session token -- Invalid thread ID -- Rate limiting -- Network error +Export one thread as markdown to stdout. -## Thread Management +```bash +kagi assistant thread export "$THREAD_ID" +``` -### Starting New Threads +### `kagi assistant thread export --format json` -Each `kagi assistant` call without `--thread-id` starts a new conversation: +Return the thread as structured JSON instead of markdown. This emits the same envelope shape as `thread get`. ```bash -# Thread 1 -kagi assistant "Topic A" - -# Thread 2 (separate conversation) -kagi assistant "Topic B" +kagi assistant thread export "$THREAD_ID" --format json ``` -### Continuing Threads - -Save the `thread.id` from responses to continue: +## Output Contract -```bash -# Get thread ID -RESPONSE=$(kagi assistant "Initial question") -THREAD_ID=$(echo "$RESPONSE" | jq -r '.thread.id') +Prompt mode returns: -# Continue same thread -kagi assistant --thread-id "$THREAD_ID" "Follow-up question" +```json +{ + "meta": { + "version": "202603171911.stage.707e740", + "trace": "trace-123" + }, + "thread": { + "id": "thread-1", + "title": "Greeting", + "ack": "2026-03-16T06:19:07Z", + "created_at": "2026-03-16T06:19:07Z", + "expires_at": "2026-03-16T07:19:07Z", + "saved": false, + "shared": false, + "branch_id": "00000000-0000-4000-0000-000000000000", + "tag_ids": [] + }, + "message": { + "id": "msg-1", + "thread_id": "thread-1", + "created_at": "2026-03-16T06:19:07Z", + "branch_list": [], + "state": "done", + "prompt": "Hello", + "reply_html": "

Hi

", + "markdown": "Hi", + "references_markdown": null, + "metadata_html": "
  • meta
  • ", + "documents": [], + "profile": { + "model": "ki_quick", + "model_name": "Quick" + }, + "trace_id": "trace-message-1" + } +} ``` -### Thread Persistence +`thread list` returns: -- Threads persist across CLI sessions -- Threads are associated with your Kagi account -- You can continue threads from the web interface -- Thread IDs are alphanumeric strings +```json +{ + "meta": { + "version": "202603171911.stage.707e740", + "trace": "trace-123" + }, + "tags": [], + "threads": [ + { + "id": "thread-1", + "title": "Greeting", + "url": "/assistant/thread-1", + "snippet": "Hello", + "saved": false, + "shared": false, + "tag_ids": [] + } + ], + "pagination": { + "next_cursor": null, + "has_more": false, + "count": 1, + "total_counts": { + "all": 1 + } + } +} +``` -## Best Practices +## Examples -### Clear Prompts +Start a thread, capture the id, continue it, then export it: ```bash -# Good - specific -kagi assistant "Explain Python list comprehensions with three examples" +RESPONSE=$(kagi assistant --model gpt-5-mini "plan a calm terminal research session in 3 bullets") +THREAD_ID=$(printf '%s' "$RESPONSE" | jq -r '.thread.id') -# Less effective - vague -kagi assistant "Python" +printf '%s\n' "$RESPONSE" | jq -r '.message.markdown' +kagi assistant --thread-id "$THREAD_ID" "turn that into a 3-item checklist" | jq -r '.message.markdown' +kagi assistant thread export "$THREAD_ID" ``` -### Context Management +List threads and inspect one: ```bash -# Provide context in thread -THREAD_ID=$(kagi assistant "I'm learning Rust" | jq -r '.thread.id') -kagi assistant --thread-id "$THREAD_ID" "What are ownership and borrowing?" +kagi assistant thread list | jq -r '.threads[] | "\(.id) \(.title)"' +kagi assistant thread get "$THREAD_ID" | jq '.messages | length' ``` -### Error Handling +Use prompt controls for a one-off request: ```bash -if RESPONSE=$(kagi assistant "Question" 2>/dev/null); then - echo "$RESPONSE" | jq -r '.message.markdown' -else - echo "Failed to get response" -fi +kagi assistant \ + --model gpt-5-mini \ + --web-access \ + --no-personalized \ + "What changed in Rust 1.86?" ``` -## Limitations +## Notes -- Requires active Kagi subscription -- Subject to rate limits -- Thread availability may vary -- Response length may be limited +- `kagi assistant` is JSON-first. There is no pretty renderer in this command. +- `thread export` defaults to markdown because that is the natural terminal transcript format. +- `--mode` is intentionally not exposed. The current web app resolves higher-level Assistant profiles client-side, and I did not find a stable request field that would make a trustworthy CLI contract. ## See Also -- [fastgpt](/commands/fastgpt) - Quick answers via API -- [Authentication](/guides/authentication) - Token setup -- [Workflows](/guides/workflows) - Conversation and automation patterns +- [fastgpt](/commands/fastgpt) - quick answers via the paid public API +- [auth-matrix](/reference/auth-matrix) - which commands require which token +- [output-contract](/reference/output-contract) - JSON shapes and jq examples diff --git a/docs/demo-assets/assistant.gif b/docs/demo-assets/assistant.gif index 3237b94..017a02f 100644 Binary files a/docs/demo-assets/assistant.gif and b/docs/demo-assets/assistant.gif differ diff --git a/docs/demos.md b/docs/demos.md index d02963e..4af039d 100644 --- a/docs/demos.md +++ b/docs/demos.md @@ -20,7 +20,7 @@ The current demo commands are: - `kagi search --format pretty "obsidian cli daily notes workflow"` - `kagi summarize --subscriber --url https://mullvad.net/en/browser | jq -M ...` - `kagi news --category tech --limit 1 | jq -M ...` -- `kagi assistant "plan a private obsidian workflow for cafe work. give me 3 setup tips and a short checklist." | jq -M ...` +- `RESPONSE=$(kagi assistant --model gpt-5-mini "..."); THREAD_ID=...; kagi assistant --thread-id "$THREAD_ID" "..."; kagi assistant thread export "$THREAD_ID"` ```bash chmod +x scripts/demo-search.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-assistant.sh diff --git a/docs/guides/quickstart.mdx b/docs/guides/quickstart.mdx index 18b8719..8303e7e 100644 --- a/docs/guides/quickstart.mdx +++ b/docs/guides/quickstart.mdx @@ -174,6 +174,9 @@ kagi search --lens 2 "developer documentation" # Test Assistant kagi assistant "What are the key features of Rust?" +# List Assistant threads +kagi assistant thread list + # Test subscriber Summarizer kagi summarize --subscriber --url https://www.rust-lang.org --summary-type keypoints --length digest ``` diff --git a/docs/index.mdx b/docs/index.mdx index 6fdb7c7..d675b9e 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -49,7 +49,7 @@ Unlock the full potential of your Kagi subscription: - **Lens-Aware Search**: Use your custom Kagi lenses directly from the command line. -- **Assistant Integration**: Prompt Kagi Assistant programmatically and continue conversations across sessions. +- **Assistant Integration**: Prompt Kagi Assistant programmatically, continue conversations, and manage threads across sessions. - **Subscriber Features**: Access subscriber-only capabilities like the web-based Summarizer with full control over output length and style. @@ -78,7 +78,7 @@ Two credential types serve different purposes: - **Summarization**: Both paid public API and subscriber web paths with multiple engines, styles, and output lengths - **FastGPT**: Quick answers powered by Kagi's FastGPT API - **Enrichment**: Query specialized web and news indexes -- **Assistant**: Full conversation support with thread continuation +- **Assistant**: Full conversation support with thread continuation and thread management ### Public Feeds diff --git a/docs/project/demos.mdx b/docs/project/demos.mdx index 2812cff..2e60804 100644 --- a/docs/project/demos.mdx +++ b/docs/project/demos.mdx @@ -41,7 +41,7 @@ The current demo commands are: - `kagi search --format pretty "obsidian cli daily notes workflow"` - `kagi summarize --subscriber --url https://mullvad.net/en/browser | jq -M ...` - `kagi news --category tech --limit 1 | jq -M ...` -- `kagi assistant "plan a private obsidian workflow for cafe work. give me 3 setup tips and a short checklist." | jq -M ...` +- `RESPONSE=$(kagi assistant --model gpt-5-mini "..."); THREAD_ID=...; kagi assistant --thread-id "$THREAD_ID" "..."; kagi assistant thread export "$THREAD_ID"` ```bash chmod +x scripts/demo-search.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-assistant.sh diff --git a/docs/reference/auth-matrix.mdx b/docs/reference/auth-matrix.mdx index 611f22a..0752d02 100644 --- a/docs/reference/auth-matrix.mdx +++ b/docs/reference/auth-matrix.mdx @@ -130,7 +130,7 @@ Both `enrich web` and `enrich news` require `KAGI_API_TOKEN`: Requires `KAGI_SESSION_TOKEN`: - ✅ Lens-aware search (`--lens`) -- ✅ Kagi Assistant (`assistant`) +- ✅ Kagi Assistant prompt and thread commands (`assistant`) - ✅ Subscriber Summarizer (`summarize --subscriber`) - ✅ Base search (fallback) diff --git a/docs/reference/coverage.mdx b/docs/reference/coverage.mdx index 7222877..27f1fb6 100644 --- a/docs/reference/coverage.mdx +++ b/docs/reference/coverage.mdx @@ -30,7 +30,7 @@ These features use the subscriber web product (Session Token): | Base Search | `kagi search` | ✅ Implemented (session path) | | Lens Search | `kagi search --lens` | ✅ Implemented | | Web Summarizer | `kagi summarize --subscriber` | ✅ Implemented | -| Assistant | `kagi assistant` | ✅ Implemented | +| Assistant prompt + thread management | `kagi assistant` | ✅ Implemented | ### Public Product Endpoints @@ -55,7 +55,7 @@ These require no authentication: | `summarize` | Public API summarizer | API | ✅ | | `summarize --subscriber` | Web summarizer | Session | ✅ | | `news` | News feed | None | ✅ | -| `assistant` | AI assistant | Session | ✅ | +| `assistant` | AI assistant with thread management | Session | ✅ | | `fastgpt` | Fast answers | API | ✅ | | `enrich web` | Web enrichment | API | ✅ | | `enrich news` | News enrichment | API | ✅ | @@ -81,6 +81,11 @@ These require no authentication: | `--web-search` | fastgpt | ✅ | | `--target-language` | summarize | ✅ | | `--thread-id` | assistant | ✅ | +| `--model` | assistant | ✅ | +| `--lens` | assistant | ✅ | +| `--web-access` / `--no-web-access` | assistant | ✅ | +| `--personalized` / `--no-personalized` | assistant | ✅ | +| `thread list/get/delete/export` | assistant | ✅ | ## Not Available @@ -191,7 +196,7 @@ All commands output JSON: |---------|-----|-----| | Search | ✅ | ✅ | | Lens Search | ✅ | ✅ | -| Assistant | ✅ (basic) | ✅ (full) | +| Assistant | ✅ (prompt + threads) | ✅ (full) | | Summarizer | ✅ | ✅ | | Translate | ❌ | ✅ | | Settings | ❌ | ✅ | diff --git a/docs/reference/output-contract.mdx b/docs/reference/output-contract.mdx index a2cef36..f1d795e 100644 --- a/docs/reference/output-contract.mdx +++ b/docs/reference/output-contract.mdx @@ -221,6 +221,12 @@ kagi fastgpt "What is Rust?" | jq -r '.data.output' # Assistant markdown reply kagi assistant "Hello" | jq -r '.message.markdown' +# Assistant thread ids +kagi assistant thread list | jq -r '.threads[].id' + +# Assistant thread export markdown +kagi assistant thread export "$THREAD_ID" + # Subscriber summary text kagi summarize --subscriber --url https://example.com | jq -r '.data.output' diff --git a/images/demos/assistant.gif b/images/demos/assistant.gif index 3618dce..017a02f 100644 Binary files a/images/demos/assistant.gif and b/images/demos/assistant.gif differ diff --git a/project/demos.mdx b/project/demos.mdx index 92069a8..7f489c5 100644 --- a/project/demos.mdx +++ b/project/demos.mdx @@ -41,7 +41,7 @@ The current demo commands are: - `kagi search --format pretty "obsidian cli daily notes workflow"` - `kagi summarize --subscriber --url https://mullvad.net/en/browser | jq -M ...` - `kagi news --category tech --limit 1 | jq -M ...` -- `kagi assistant "plan a private obsidian workflow for cafe work. give me 3 setup tips and a short checklist." | jq -M ...` +- `RESPONSE=$(kagi assistant --model gpt-5-mini "..."); THREAD_ID=...; kagi assistant --thread-id "$THREAD_ID" "..."; kagi assistant thread export "$THREAD_ID"` ```bash chmod +x scripts/demo-search.sh scripts/demo-summarize.sh scripts/demo-news.sh scripts/demo-assistant.sh diff --git a/scripts/demo-assistant.sh b/scripts/demo-assistant.sh index 028bcef..9c87c55 100755 --- a/scripts/demo-assistant.sh +++ b/scripts/demo-assistant.sh @@ -10,14 +10,40 @@ mkdir -p /tmp/kagi-demo-bin ln -sf "$PWD/target/debug/kagi" /tmp/kagi-demo-bin/kagi export PATH="/tmp/kagi-demo-bin:$PATH" +THREAD_ID="" +cleanup() { + if [[ -n "$THREAD_ID" ]]; then + kagi assistant thread delete "$THREAD_ID" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +PROMPT="plan a calm terminal research session in 3 bullets." +FOLLOWUP="turn that into a 3-item checklist." + printf '\033c' sleep 1.2 -printf '$ kagi assistant "plan a private obsidian workflow for cafe work. give me 3 setup tips and a short checklist." | jq -M ...\n' + +printf '$ RESPONSE=$(kagi assistant --model gpt-5-mini "%s")\n' "$PROMPT" +sleep 0.4 +RESPONSE=$(kagi assistant --model gpt-5-mini "$PROMPT") +THREAD_ID=$(printf '%s' "$RESPONSE" | jq -r '.thread.id') +printf '%s\n' "$RESPONSE" | jq -M '{ + thread_id: .thread.id, + reply: .message.markdown, + model: .message.profile.model_name +}' +sleep 1.8 + +printf '$ kagi assistant --thread-id "$THREAD_ID" "%s" | jq -M ...\n' "$FOLLOWUP" +sleep 0.4 +kagi assistant --thread-id "$THREAD_ID" "$FOLLOWUP" | jq -M '{ + thread_id: .thread.id, + reply: .message.markdown +}' +sleep 1.8 + +printf '$ kagi assistant thread export "$THREAD_ID" | sed -n '\''1,14p'\''\n' sleep 0.4 -kagi assistant "plan a private obsidian workflow for cafe work. give me 3 setup tips and a short checklist." \ - | jq -M '{ - thread_id: .thread.id, - reply: .message.markdown, - model: .message.profile.model_name - }' +kagi assistant thread export "$THREAD_ID" | sed -n '1,14p' sleep 2 diff --git a/src/api.rs b/src/api.rs index b6d2bd5..bfa2e3f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; -use reqwest::{Client, StatusCode, header}; +use reqwest::{Client, StatusCode, Url, header}; +use scraper::Html; use serde::Deserialize; #[cfg(test)] use serde::Serialize; @@ -9,13 +10,16 @@ use serde_json::Value; use serde_json::json; use crate::error::KagiError; +use crate::parser::parse_assistant_thread_list; #[cfg(test)] use crate::types::ApiMeta; use crate::types::{ AssistantMessage, AssistantMeta, AssistantPromptRequest, AssistantPromptResponse, - AssistantThread, EnrichResponse, FastGptRequest, FastGptResponse, NewsBatchCategories, - NewsBatchCategory, NewsCategoriesResponse, NewsCategoryMetadata, NewsCategoryMetadataList, - NewsChaos, NewsChaosResponse, NewsLatestBatch, NewsResolvedCategory, NewsStoriesPayload, + AssistantThread, AssistantThreadDeleteResponse, AssistantThreadExportResponse, + AssistantThreadListResponse, AssistantThreadOpenResponse, AssistantThreadPagination, + EnrichResponse, FastGptRequest, FastGptResponse, NewsBatchCategories, NewsBatchCategory, + NewsCategoriesResponse, NewsCategoryMetadata, NewsCategoryMetadataList, NewsChaos, + NewsChaosResponse, NewsLatestBatch, NewsResolvedCategory, NewsStoriesPayload, NewsStoriesResponse, SmallWebFeed, SubscriberSummarization, SubscriberSummarizeMeta, SubscriberSummarizeRequest, SubscriberSummarizeResponse, SummarizeRequest, SummarizeResponse, }; @@ -27,6 +31,9 @@ const KAGI_NEWS_LATEST_URL: &str = "https://news.kagi.com/api/batches/latest"; const KAGI_NEWS_CATEGORIES_METADATA_URL: &str = "https://news.kagi.com/api/categories/metadata"; const KAGI_NEWS_BATCH_CATEGORIES_URL: &str = "https://news.kagi.com/api/batches"; const KAGI_ASSISTANT_PROMPT_URL: &str = "https://kagi.com/assistant/prompt"; +const KAGI_ASSISTANT_THREAD_OPEN_URL: &str = "https://kagi.com/assistant/thread_open"; +const KAGI_ASSISTANT_THREAD_LIST_URL: &str = "https://kagi.com/assistant/thread_list"; +const KAGI_ASSISTANT_THREAD_DELETE_URL: &str = "https://kagi.com/assistant/thread_delete"; const KAGI_FASTGPT_URL: &str = "https://kagi.com/api/v0/fastgpt"; const KAGI_ENRICH_WEB_URL: &str = "https://kagi.com/api/v0/enrich/web"; const KAGI_ENRICH_NEWS_URL: &str = "https://kagi.com/api/v0/enrich/news"; @@ -292,59 +299,122 @@ pub async fn execute_assistant_prompt( request: &AssistantPromptRequest, token: &str, ) -> Result { - if token.trim().is_empty() { - return Err(KagiError::Auth( - "missing Kagi session token (expected KAGI_SESSION_TOKEN)".to_string(), - )); - } + let query = normalize_assistant_query(&request.query)?; + let thread_id = normalize_assistant_thread_id(request.thread_id.as_deref())?; + let profile = assistant_profile_payload(request); + let body = execute_assistant_stream( + KAGI_ASSISTANT_PROMPT_URL, + &json!({ + "focus": { + "thread_id": thread_id, + "branch_id": ASSISTANT_ZERO_BRANCH_UUID, + "prompt": query, + "message_id": Value::Null, + }, + "profile": profile, + }), + token, + "Assistant prompt", + ) + .await?; - let query = request.query.trim(); - if query.is_empty() { - return Err(KagiError::Config( - "assistant query cannot be empty".to_string(), - )); - } + parse_assistant_prompt_stream(&body) +} - let thread_id = request - .thread_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - if request.thread_id.is_some() && thread_id.is_none() { - return Err(KagiError::Config( - "assistant --thread-id cannot be empty".to_string(), - )); - } +pub async fn execute_assistant_thread_list( + token: &str, +) -> Result { + let body = execute_assistant_stream( + KAGI_ASSISTANT_THREAD_LIST_URL, + &json!({ "limit": 100 }), + token, + "Assistant thread list", + ) + .await?; - let client = build_client()?; - let response = client - .post(KAGI_ASSISTANT_PROMPT_URL) - .header(header::COOKIE, format!("kagi_session={token}")) - .header(header::CONTENT_TYPE, "application/json") - .header(header::ACCEPT, "application/vnd.kagi.stream") - .json(&json!({ + parse_assistant_thread_list_stream(&body) +} + +pub async fn execute_assistant_thread_get( + thread_id: &str, + token: &str, +) -> Result { + let thread_id = normalize_assistant_thread_id(Some(thread_id))? + .ok_or_else(|| KagiError::Config("assistant thread id cannot be empty".to_string()))?; + let body = execute_assistant_stream( + KAGI_ASSISTANT_THREAD_OPEN_URL, + &json!({ "focus": { "thread_id": thread_id, "branch_id": ASSISTANT_ZERO_BRANCH_UUID, - "prompt": query, - "message_id": Value::Null, } - })) + }), + token, + "Assistant thread open", + ) + .await?; + + parse_assistant_thread_open_stream(&body) +} + +pub async fn execute_assistant_thread_delete( + thread_id: &str, + token: &str, +) -> Result { + let thread = execute_assistant_thread_get(thread_id, token).await?.thread; + let body = execute_assistant_stream( + KAGI_ASSISTANT_THREAD_DELETE_URL, + &json!({ + "threads": [{ + "id": thread.id, + "title": thread.title, + "saved": thread.saved, + "shared": thread.shared, + "tag_ids": thread.tag_ids, + }] + }), + token, + "Assistant thread delete", + ) + .await?; + + parse_assistant_thread_delete_stream(&body, thread_id) +} + +pub async fn execute_assistant_thread_export( + thread_id: &str, + token: &str, +) -> Result { + let thread_id = normalize_assistant_thread_id(Some(thread_id))? + .ok_or_else(|| KagiError::Config("assistant thread id cannot be empty".to_string()))?; + let client = build_client()?; + let response = client + .get(format!("https://kagi.com/assistant/{thread_id}/download")) + .header(header::COOKIE, format!("kagi_session={token}")) .send() .await .map_err(map_transport_error)?; match response.status() { StatusCode::OK => { - let body = response.text().await.map_err(|error| { - KagiError::Network(format!("failed to read assistant response body: {error}")) + let filename = response + .headers() + .get(header::CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok()) + .and_then(parse_content_disposition_filename); + let markdown = response.text().await.map_err(|error| { + KagiError::Network(format!("failed to read Assistant export body: {error}")) })?; - if looks_like_html_document(&body) { + if looks_like_html_document(&markdown) { return Err(KagiError::Auth( "invalid or expired Kagi session token".to_string(), )); } - parse_assistant_prompt_stream(&body) + Ok(AssistantThreadExportResponse { + thread_id, + filename, + markdown, + }) } StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(KagiError::Auth( "invalid or expired Kagi session token".to_string(), @@ -352,15 +422,15 @@ pub async fn execute_assistant_prompt( status if status.is_client_error() => { let body = response.text().await.unwrap_or_else(|_| String::new()); Err(KagiError::Config(format!( - "Kagi Assistant request rejected: HTTP {status}{}", + "Kagi Assistant export request rejected: HTTP {status}{}", format_client_error_suffix(&body) ))) } status if status.is_server_error() => Err(KagiError::Network(format!( - "Kagi Assistant server error: HTTP {status}" + "Kagi Assistant export server error: HTTP {status}" ))), status => Err(KagiError::Network(format!( - "unexpected Kagi Assistant response status: HTTP {status}" + "unexpected Kagi Assistant export response status: HTTP {status}" ))), } } @@ -630,6 +700,136 @@ fn resolve_news_category( ))) } +fn normalize_assistant_query(raw: &str) -> Result { + let normalized = raw.trim(); + if normalized.is_empty() { + return Err(KagiError::Config( + "assistant query cannot be empty".to_string(), + )); + } + + Ok(normalized.to_string()) +} + +fn normalize_assistant_thread_id(raw: Option<&str>) -> Result, KagiError> { + match raw { + None => Ok(None), + Some(value) => { + let normalized = value.trim(); + if normalized.is_empty() { + return Err(KagiError::Config( + "assistant thread id cannot be empty".to_string(), + )); + } + Ok(Some(normalized.to_string())) + } + } +} + +fn assistant_profile_payload(request: &AssistantPromptRequest) -> Value { + let mut payload = serde_json::Map::new(); + + if let Some(model) = request + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + payload.insert("model".to_string(), Value::String(model.to_string())); + } + + if let Some(lens_id) = request.lens_id { + payload.insert("lens_id".to_string(), json!(lens_id)); + } + + if let Some(internet_access) = request.internet_access { + payload.insert("internet_access".to_string(), Value::Bool(internet_access)); + } + + if let Some(personalizations) = request.personalizations { + payload.insert( + "personalizations".to_string(), + Value::Bool(personalizations), + ); + } + + Value::Object(payload) +} + +async fn execute_assistant_stream( + url: &str, + payload: &Value, + token: &str, + surface: &str, +) -> Result { + if token.trim().is_empty() { + return Err(KagiError::Auth( + "missing Kagi session token (expected KAGI_SESSION_TOKEN)".to_string(), + )); + } + + let client = build_client()?; + let response = client + .post(url) + .header(header::COOKIE, format!("kagi_session={token}")) + .header(header::CONTENT_TYPE, "application/json") + .header(header::ACCEPT, "application/vnd.kagi.stream") + .json(payload) + .send() + .await + .map_err(map_transport_error)?; + + match response.status() { + StatusCode::OK => { + let body = response.text().await.map_err(|error| { + KagiError::Network(format!("failed to read {surface} response body: {error}")) + })?; + + if looks_like_html_document(&body) { + return Err(KagiError::Auth( + "invalid or expired Kagi session token".to_string(), + )); + } + + Ok(body) + } + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(KagiError::Auth( + "invalid or expired Kagi session token".to_string(), + )), + status if status.is_client_error() => { + let body = response.text().await.unwrap_or_else(|_| String::new()); + Err(KagiError::Config(format!( + "Kagi {surface} request rejected: HTTP {status}{}", + format_client_error_suffix(&body) + ))) + } + status if status.is_server_error() => Err(KagiError::Network(format!( + "Kagi {surface} server error: HTTP {status}{}", + { + let body = response.text().await.unwrap_or_else(|_| String::new()); + if body.trim().is_empty() { + String::new() + } else if looks_like_html_document(&body) { + let stripped = strip_html_to_text(&body); + let normalized_surface = surface.to_ascii_lowercase(); + if normalized_surface.contains("thread") { + "; the thread id may be invalid or no longer available".to_string() + } else if stripped.is_empty() { + String::new() + } else { + format!("; {stripped}") + } + } else { + format_client_error_suffix(&body) + } + } + ))), + status => Err(KagiError::Network(format!( + "unexpected Kagi {surface} response status: HTTP {status}" + ))), + } +} + fn parse_assistant_prompt_stream(body: &str) -> Result { let mut meta = AssistantMeta::default(); let mut thread = None; @@ -653,17 +853,7 @@ fn parse_assistant_prompt_stream(body: &str) -> Result { let payload: AssistantMessagePayload = @@ -672,18 +862,15 @@ fn parse_assistant_prompt_stream(body: &str) -> Result { + let detail = strip_html_to_text(payload); + return Err(KagiError::Config(if detail.is_empty() { + "Kagi Assistant rate limited this request".to_string() + } else { + detail + })); } "unauthorized" => { return Err(KagiError::Auth( @@ -719,6 +906,246 @@ fn parse_assistant_prompt_stream(body: &str) -> Result Result { + let mut meta = AssistantMeta::default(); + let mut tags = Vec::new(); + let mut thread = None; + let mut messages = None; + + for frame in body.split("\0\n").filter(|frame| !frame.trim().is_empty()) { + let Some((tag, payload)) = frame.split_once(':') else { + continue; + }; + + match tag { + "hi" => { + let hello: AssistantHello = serde_json::from_str(payload).map_err(|error| { + KagiError::Parse(format!("failed to parse assistant hello frame: {error}")) + })?; + meta.version = hello.v; + meta.trace = hello.trace; + } + "tags.json" => { + tags = serde_json::from_str(payload).map_err(|error| { + KagiError::Parse(format!("failed to parse assistant tags frame: {error}")) + })?; + } + "thread.json" => { + let payload: AssistantThreadPayload = + serde_json::from_str(payload).map_err(|error| { + KagiError::Parse(format!("failed to parse assistant thread frame: {error}")) + })?; + thread = Some(AssistantThread::from(payload)); + } + "messages.json" => { + let payloads: Vec = serde_json::from_str(payload) + .map_err(|error| { + KagiError::Parse(format!( + "failed to parse assistant messages frame: {error}" + )) + })?; + messages = Some( + payloads + .into_iter() + .map(assistant_message_from_payload) + .collect(), + ); + } + "limit_notice.html" => { + let detail = strip_html_to_text(payload); + return Err(KagiError::Config(if detail.is_empty() { + "Kagi Assistant rate limited this request".to_string() + } else { + detail + })); + } + "unauthorized" => { + return Err(KagiError::Auth( + "invalid or expired Kagi session token".to_string(), + )); + } + _ => {} + } + } + + Ok(AssistantThreadOpenResponse { + meta, + tags, + thread: thread.ok_or_else(|| { + KagiError::Parse( + "assistant thread open response did not include a thread.json frame".to_string(), + ) + })?, + messages: messages.ok_or_else(|| { + KagiError::Parse( + "assistant thread open response did not include a messages.json frame".to_string(), + ) + })?, + }) +} + +fn parse_assistant_thread_list_stream( + body: &str, +) -> Result { + let mut meta = AssistantMeta::default(); + let mut tags = Vec::new(); + let mut threads = Vec::new(); + let mut pagination = None; + + for frame in body.split("\0\n").filter(|frame| !frame.trim().is_empty()) { + let Some((tag, payload)) = frame.split_once(':') else { + continue; + }; + + match tag { + "hi" => { + let hello: AssistantHello = serde_json::from_str(payload).map_err(|error| { + KagiError::Parse(format!("failed to parse assistant hello frame: {error}")) + })?; + meta.version = hello.v; + meta.trace = hello.trace; + } + "tags.json" => { + tags = serde_json::from_str(payload).map_err(|error| { + KagiError::Parse(format!("failed to parse assistant tags frame: {error}")) + })?; + } + "thread_list.html" => { + let payload: AssistantThreadListPayload = + serde_json::from_str(payload).map_err(|error| { + KagiError::Parse(format!( + "failed to parse assistant thread list frame: {error}" + )) + })?; + threads = parse_assistant_thread_list(&payload.html)?; + pagination = Some(AssistantThreadPagination { + next_cursor: payload.next_cursor, + has_more: payload.has_more, + count: payload.count, + total_counts: payload.total_counts, + }); + } + "limit_notice.html" => { + let detail = strip_html_to_text(payload); + return Err(KagiError::Config(if detail.is_empty() { + "Kagi Assistant rate limited this request".to_string() + } else { + detail + })); + } + "unauthorized" => { + return Err(KagiError::Auth( + "invalid or expired Kagi session token".to_string(), + )); + } + _ => {} + } + } + + Ok(AssistantThreadListResponse { + meta, + tags, + threads, + pagination: pagination.ok_or_else(|| { + KagiError::Parse( + "assistant thread list response did not include a thread_list.html frame" + .to_string(), + ) + })?, + }) +} + +fn parse_assistant_thread_delete_stream( + body: &str, + thread_id: &str, +) -> Result { + for frame in body.split("\0\n").filter(|frame| !frame.trim().is_empty()) { + let Some((tag, payload)) = frame.split_once(':') else { + continue; + }; + + match tag { + "ok" => { + let value: Option = serde_json::from_str(payload).map_err(|error| { + KagiError::Parse(format!("failed to parse assistant delete frame: {error}")) + })?; + if value.is_none() { + return Ok(AssistantThreadDeleteResponse { + deleted_thread_ids: vec![thread_id.to_string()], + }); + } + } + "limit_notice.html" => { + let detail = strip_html_to_text(payload); + return Err(KagiError::Config(if detail.is_empty() { + "Kagi Assistant rate limited this request".to_string() + } else { + detail + })); + } + "unauthorized" => { + return Err(KagiError::Auth( + "invalid or expired Kagi session token".to_string(), + )); + } + _ => {} + } + } + + Err(KagiError::Parse( + "assistant thread delete response did not include an ok frame".to_string(), + )) +} + +fn assistant_message_from_payload(payload: AssistantMessagePayload) -> AssistantMessage { + AssistantMessage { + id: payload.id, + thread_id: payload.thread_id, + created_at: payload.created_at, + branch_list: payload.branch_list, + state: payload.state, + prompt: payload.prompt, + reply_html: payload.reply, + markdown: payload.md, + references_html: payload.references_html, + references_markdown: payload.references_md, + metadata_html: payload.metadata, + documents: payload.documents, + profile: payload.profile, + trace_id: payload.trace_id, + } +} + +fn strip_html_to_text(html: &str) -> String { + Html::parse_fragment(html) + .root_element() + .text() + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn parse_content_disposition_filename(header_value: &str) -> Option { + for segment in header_value.split(';').map(str::trim) { + if let Some(encoded) = segment.strip_prefix("filename*=utf-8''") { + let decoded = Url::parse(&format!("https://example.com/?filename={encoded}")) + .ok()? + .query_pairs() + .find_map(|(key, value)| (key == "filename").then(|| value.into_owned()))?; + return Some(decoded); + } + + if let Some(raw) = segment.strip_prefix("filename=") { + return Some(raw.trim_matches('"').to_string()); + } + } + + None +} + fn format_client_error_suffix(body: &str) -> String { let trimmed = body.trim(); if trimmed.is_empty() { @@ -786,11 +1213,29 @@ struct AssistantThreadPayload { tag_ids: Vec, } +impl From for AssistantThread { + fn from(payload: AssistantThreadPayload) -> Self { + Self { + id: payload.id, + title: payload.title, + ack: payload.ack, + created_at: payload.created_at, + expires_at: payload.expires_at, + saved: payload.saved, + shared: payload.shared, + branch_id: payload.branch_id, + tag_ids: payload.tag_ids, + } + } +} + #[derive(Debug, Deserialize)] struct AssistantMessagePayload { id: String, thread_id: String, created_at: String, + #[serde(default)] + branch_list: Vec, state: String, prompt: String, #[serde(default)] @@ -798,11 +1243,30 @@ struct AssistantMessagePayload { #[serde(default)] md: Option, #[serde(default)] + references_html: Option, + #[serde(default)] + references_md: Option, + #[serde(default)] metadata: Option, #[serde(default)] documents: Vec, #[serde(default)] profile: Option, + #[serde(default)] + trace_id: Option, +} + +#[derive(Debug, Deserialize)] +struct AssistantThreadListPayload { + html: String, + #[serde(default)] + next_cursor: Option, + #[serde(default)] + has_more: bool, + #[serde(default)] + count: u64, + #[serde(default)] + total_counts: HashMap, } async fn decode_kagi_json(response: reqwest::Response, surface: &str) -> Result @@ -901,14 +1365,24 @@ pub struct KagiEnvelope { #[cfg(test)] mod tests { use super::{ - ApiErrorBody, KagiEnvelope, normalize_subscriber_summary_input, - normalize_subscriber_summary_length, normalize_subscriber_summary_type, - parse_assistant_prompt_stream, parse_subscriber_summarize_stream, resolve_news_category, + ApiErrorBody, KagiEnvelope, normalize_assistant_query, normalize_assistant_thread_id, + normalize_subscriber_summary_input, normalize_subscriber_summary_length, + normalize_subscriber_summary_type, parse_assistant_prompt_stream, + parse_assistant_thread_delete_stream, parse_assistant_thread_list_stream, + parse_assistant_thread_open_stream, parse_content_disposition_filename, + parse_subscriber_summarize_stream, resolve_news_category, + }; + use crate::api::{ + execute_assistant_prompt, execute_assistant_thread_delete, execute_assistant_thread_export, + execute_assistant_thread_get, execute_assistant_thread_list, }; + use crate::auth::{SESSION_TOKEN_ENV, load_credential_inventory}; use crate::types::SubscriberSummarizeRequest; use crate::types::{ - FastGptAnswer, NewsBatchCategory, NewsCategoryMetadata, Reference, Summarization, + AssistantPromptRequest, FastGptAnswer, NewsBatchCategory, NewsCategoryMetadata, Reference, + Summarization, }; + use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn parses_summarize_envelope() { @@ -1113,12 +1587,177 @@ mod tests { let raw = concat!( "hi:{\"v\":\"202603091651.stage.c128588\",\"trace\":\"trace-123\"}\0\n", "thread.json:{\"id\":\"thread-1\",\"title\":\"Greeting\",\"ack\":\"2026-03-16T06:19:07Z\",\"created_at\":\"2026-03-16T06:19:07Z\",\"expires_at\":\"2026-03-16T07:19:07Z\",\"saved\":false,\"shared\":false,\"branch_id\":\"00000000-0000-4000-0000-000000000000\",\"tag_ids\":[]}\0\n", - "new_message.json:{\"id\":\"msg-1\",\"thread_id\":\"thread-1\",\"created_at\":\"2026-03-16T06:19:07Z\",\"state\":\"done\",\"prompt\":\"Hello\",\"reply\":\"

    Hi

    \",\"md\":\"Hi\",\"metadata\":\"
  • meta
  • \",\"documents\":[]}\0\n" + "new_message.json:{\"id\":\"msg-1\",\"thread_id\":\"thread-1\",\"created_at\":\"2026-03-16T06:19:07Z\",\"branch_list\":[\"00000000-0000-4000-0000-000000000000\"],\"state\":\"done\",\"prompt\":\"Hello\",\"reply\":\"

    Hi

    \",\"md\":\"Hi\",\"references_html\":\"
    1. Doc
    \",\"references_md\":\"1. [Doc](https://example.com)\",\"metadata\":\"
  • meta
  • \",\"documents\":[],\"trace_id\":\"trace-message-1\"}\0\n" ); let parsed = parse_assistant_prompt_stream(raw).expect("assistant stream parses"); assert_eq!(parsed.meta.trace.as_deref(), Some("trace-123")); assert_eq!(parsed.thread.id, "thread-1"); assert_eq!(parsed.message.markdown.as_deref(), Some("Hi")); + assert_eq!( + parsed.message.references_markdown.as_deref(), + Some("1. [Doc](https://example.com)") + ); + assert_eq!( + parsed.message.branch_list, + vec!["00000000-0000-4000-0000-000000000000".to_string()] + ); + assert_eq!(parsed.message.trace_id.as_deref(), Some("trace-message-1")); + } + + #[test] + fn normalizes_assistant_query_and_thread_id() { + assert_eq!( + normalize_assistant_query(" hello ").expect("query trims"), + "hello" + ); + assert_eq!( + normalize_assistant_thread_id(Some(" thread-1 ")).expect("thread id trims"), + Some("thread-1".to_string()) + ); + assert_eq!( + normalize_assistant_thread_id(None).expect("missing thread id stays none"), + None + ); + } + + #[test] + fn rejects_empty_assistant_query_and_thread_id() { + let query_error = normalize_assistant_query(" ").expect_err("blank query should fail"); + assert!( + query_error + .to_string() + .contains("assistant query cannot be empty") + ); + + let thread_error = + normalize_assistant_thread_id(Some(" ")).expect_err("blank thread id should fail"); + assert!( + thread_error + .to_string() + .contains("assistant thread id cannot be empty") + ); + } + + #[test] + fn parses_assistant_thread_open_stream() { + let raw = concat!( + "hi:{\"v\":\"202603171911.stage.707e740\",\"trace\":\"trace-open\"}\0\n", + "tags.json:[]\0\n", + "thread.json:{\"id\":\"thread-1\",\"title\":\"Greeting\",\"ack\":\"2026-03-16T06:19:07Z\",\"created_at\":\"2026-03-16T06:19:07Z\",\"expires_at\":\"2026-03-16T07:19:07Z\",\"saved\":false,\"shared\":false,\"branch_id\":\"00000000-0000-4000-0000-000000000000\",\"tag_ids\":[]}\0\n", + "messages.json:[{\"id\":\"msg-1\",\"thread_id\":\"thread-1\",\"created_at\":\"2026-03-16T06:19:07Z\",\"branch_list\":[],\"state\":\"done\",\"prompt\":\"Hello\",\"reply\":\"

    Hi

    \",\"md\":\"Hi\",\"metadata\":\"\",\"documents\":[],\"trace_id\":\"trace-msg\"}]\0\n" + ); + + let parsed = parse_assistant_thread_open_stream(raw).expect("thread open parses"); + assert_eq!(parsed.meta.trace.as_deref(), Some("trace-open")); + assert_eq!(parsed.thread.id, "thread-1"); + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.messages[0].trace_id.as_deref(), Some("trace-msg")); + } + + #[test] + fn parses_assistant_thread_list_stream() { + let raw = concat!( + "hi:{\"v\":\"202603171911.stage.707e740\",\"trace\":\"trace-list\"}\0\n", + "tags.json:[]\0\n", + "thread_list.html:{\"html\":\"\",\"next_cursor\":null,\"has_more\":false,\"count\":1,\"total_counts\":{\"all\":1}}\0\n" + ); + + let parsed = parse_assistant_thread_list_stream(raw).expect("thread list parses"); + assert_eq!(parsed.meta.trace.as_deref(), Some("trace-list")); + assert_eq!(parsed.threads.len(), 1); + assert_eq!(parsed.threads[0].id, "thread-1"); + assert_eq!(parsed.pagination.count, 1); + assert_eq!(parsed.pagination.total_counts.get("all"), Some(&1)); + } + + #[test] + fn parses_assistant_thread_delete_stream() { + let parsed = + parse_assistant_thread_delete_stream("hi:{\"v\":\"x\"}\0\nok:null\0\n", "thread-1") + .expect("delete stream parses"); + assert_eq!(parsed.deleted_thread_ids, vec!["thread-1".to_string()]); + } + + #[test] + fn parses_content_disposition_filename() { + assert_eq!( + parse_content_disposition_filename( + "attachment; filename*=utf-8''Say%20Hi%20In%20Five%20Words.md" + ), + Some("Say Hi In Five Words.md".to_string()) + ); + assert_eq!( + parse_content_disposition_filename("attachment; filename=\"thread.md\""), + Some("thread.md".to_string()) + ); + } + + fn live_session_token() -> Option { + load_credential_inventory() + .ok() + .and_then(|inventory| inventory.session_token.map(|credential| credential.value)) + } + + fn live_nonce() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos() + } + + #[tokio::test] + #[ignore] + async fn live_assistant_thread_roundtrip() { + let Some(token) = live_session_token() else { + eprintln!("skipping live assistant test because {SESSION_TOKEN_ENV} is not set"); + return; + }; + + let request = AssistantPromptRequest { + query: format!("Reply with exactly: assistant-v2-smoke-{}", live_nonce()), + thread_id: None, + model: Some("gpt-5-mini".to_string()), + lens_id: None, + internet_access: Some(true), + personalizations: Some(false), + }; + + let prompt = execute_assistant_prompt(&request, &token) + .await + .expect("assistant prompt should succeed"); + assert_eq!(prompt.message.state, "done"); + assert_eq!( + prompt + .message + .profile + .as_ref() + .and_then(|v| v.get("model")) + .and_then(|v| v.as_str()), + Some("gpt-5-mini") + ); + + let thread_id = prompt.thread.id.clone(); + + let fetched = execute_assistant_thread_get(&thread_id, &token) + .await + .expect("assistant thread get should succeed"); + assert_eq!(fetched.thread.id, thread_id); + assert!(!fetched.messages.is_empty()); + + let listed = execute_assistant_thread_list(&token) + .await + .expect("assistant thread list should succeed"); + assert!(listed.threads.iter().any(|thread| thread.id == thread_id)); + + let exported = execute_assistant_thread_export(&thread_id, &token) + .await + .expect("assistant thread export should succeed"); + assert!(exported.markdown.contains("assistant-v2-smoke-")); + + let deleted = execute_assistant_thread_delete(&thread_id, &token) + .await + .expect("assistant thread delete should succeed"); + assert_eq!(deleted.deleted_thread_ids, vec![thread_id]); } } diff --git a/src/auth.rs b/src/auth.rs index 6809cd6..2d982b0 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -183,11 +183,9 @@ pub fn load_credential_inventory() -> Result { source: CredentialSource::Env, value, }); - let env_session = read_env_credential(SESSION_TOKEN_ENV).map(|value| Credential { - kind: CredentialKind::SessionToken, - source: CredentialSource::Env, - value, - }); + let env_session = read_env_credential(SESSION_TOKEN_ENV) + .map(|value| build_session_credential(&value, CredentialSource::Env)) + .transpose()?; let config_api = config .auth @@ -207,11 +205,8 @@ pub fn load_credential_inventory() -> Result { .and_then(|auth| auth.session_token.as_ref()) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) - .map(|value| Credential { - kind: CredentialKind::SessionToken, - source: CredentialSource::Config, - value, - }); + .map(|value| build_session_credential(&value, CredentialSource::Config)) + .transpose()?; Ok(CredentialInventory { api_token: env_api.or(config_api), @@ -257,6 +252,17 @@ fn read_env_credential(key: &str) -> Option { .filter(|value| !value.is_empty()) } +fn build_session_credential( + raw_value: &str, + source: CredentialSource, +) -> Result { + Ok(Credential { + kind: CredentialKind::SessionToken, + source, + value: normalize_session_token_input(raw_value)?, + }) +} + pub fn save_credentials( api_token: Option<&str>, session_input: Option<&str>, @@ -590,4 +596,30 @@ mod tests { .expect_err("missing token param should fail"); assert!(error.to_string().contains("token=")); } + + #[test] + fn builds_env_session_credential_from_session_link() { + let credential = build_session_credential( + "https://kagi.com/search?token=abc123.def456", + CredentialSource::Env, + ) + .expect("session link should normalize"); + + assert_eq!(credential.kind, CredentialKind::SessionToken); + assert_eq!(credential.source, CredentialSource::Env); + assert_eq!(credential.value, "abc123.def456"); + } + + #[test] + fn builds_config_session_credential_from_session_link() { + let credential = build_session_credential( + "https://kagi.com/search?token=abc123.def456", + CredentialSource::Config, + ) + .expect("session link should normalize"); + + assert_eq!(credential.kind, CredentialKind::SessionToken); + assert_eq!(credential.source, CredentialSource::Config); + assert_eq!(credential.value, "abc123.def456"); + } } diff --git a/src/cli.rs b/src/cli.rs index 5b86e4f..dfeea7e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -9,6 +9,12 @@ pub enum CompletionShell { PowerShell, } +#[derive(Debug, Clone, ValueEnum)] +pub enum AssistantThreadExportFormat { + Markdown, + Json, +} + /// Output format options for search results #[derive(Debug, Clone, ValueEnum)] pub enum OutputFormat { @@ -79,7 +85,7 @@ pub enum Commands { Summarize(SummarizeArgs), /// Read Kagi News from the live public JSON endpoints News(NewsArgs), - /// Prompt Kagi Assistant with subscriber session-token auth + /// Prompt Kagi Assistant and manage Assistant threads Assistant(AssistantArgs), /// Answer a query with Kagi's FastGPT API Fastgpt(FastGptArgs), @@ -264,14 +270,84 @@ pub struct NewsArgs { } #[derive(Debug, Args)] +#[command(arg_required_else_help = true, args_conflicts_with_subcommands = true)] pub struct AssistantArgs { + #[command(subcommand)] + pub command: Option, + /// Prompt to send to Kagi Assistant #[arg(value_name = "QUERY")] - pub query: String, + pub query: Option, /// Continue an existing assistant thread by id #[arg(long, value_name = "THREAD_ID")] pub thread_id: Option, + + /// Override the Assistant model slug for this prompt + #[arg(long, value_name = "MODEL")] + pub model: Option, + + /// Override the Assistant lens id for this prompt + #[arg(long, value_name = "LENS_ID")] + pub lens: Option, + + /// Force web access on for this prompt + #[arg(long, conflicts_with = "no_web_access")] + pub web_access: bool, + + /// Force web access off for this prompt + #[arg(long, conflicts_with = "web_access")] + pub no_web_access: bool, + + /// Force personalizations on for this prompt + #[arg(long, conflicts_with = "no_personalized")] + pub personalized: bool, + + /// Force personalizations off for this prompt + #[arg(long, conflicts_with = "personalized")] + pub no_personalized: bool, +} + +#[derive(Debug, Subcommand)] +pub enum AssistantSubcommand { + /// Manage Assistant threads + Thread(AssistantThreadArgs), +} + +#[derive(Debug, Args)] +pub struct AssistantThreadArgs { + #[command(subcommand)] + pub command: AssistantThreadSubcommand, +} + +#[derive(Debug, Subcommand)] +pub enum AssistantThreadSubcommand { + /// List Assistant threads for the current account + List, + /// Fetch one Assistant thread with its messages + Get(AssistantThreadIdArgs), + /// Delete one Assistant thread + Delete(AssistantThreadIdArgs), + /// Export one Assistant thread + Export(AssistantThreadExportArgs), +} + +#[derive(Debug, Args)] +pub struct AssistantThreadIdArgs { + /// Assistant thread id + #[arg(value_name = "THREAD_ID")] + pub thread_id: String, +} + +#[derive(Debug, Args)] +pub struct AssistantThreadExportArgs { + /// Assistant thread id + #[arg(value_name = "THREAD_ID")] + pub thread_id: String, + + /// Export format + #[arg(long, value_name = "FORMAT", value_enum, default_value = "markdown")] + pub format: AssistantThreadExportFormat, } #[derive(Debug, Args)] diff --git a/src/main.rs b/src/main.rs index ce19788..a592c14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,15 +10,19 @@ use clap::{CommandFactory, Parser}; use clap_complete::{generate, shells}; use crate::api::{ - execute_assistant_prompt, execute_enrich_news, execute_enrich_web, execute_fastgpt, - execute_news, execute_news_categories, execute_news_chaos, execute_smallweb, - execute_subscriber_summarize, execute_summarize, + execute_assistant_prompt, execute_assistant_thread_delete, execute_assistant_thread_export, + execute_assistant_thread_get, execute_assistant_thread_list, execute_enrich_news, + execute_enrich_web, execute_fastgpt, execute_news, execute_news_categories, execute_news_chaos, + execute_smallweb, execute_subscriber_summarize, execute_summarize, }; use crate::auth::{ Credential, CredentialKind, SearchCredentials, format_status, load_credential_inventory, save_credentials, }; -use crate::cli::{AuthSetArgs, AuthSubcommand, Cli, Commands, CompletionShell, EnrichSubcommand}; +use crate::cli::{ + AssistantSubcommand, AssistantThreadExportFormat, AssistantThreadSubcommand, AuthSetArgs, + AuthSubcommand, Cli, Commands, CompletionShell, EnrichSubcommand, +}; use crate::error::KagiError; use crate::types::{ AssistantPromptRequest, FastGptRequest, SearchResponse, SubscriberSummarizeRequest, @@ -132,12 +136,62 @@ async fn run() -> Result<(), KagiError> { } Commands::Assistant(args) => { let token = resolve_session_token()?; - let request = AssistantPromptRequest { - query: args.query, - thread_id: args.thread_id, - }; - let response = execute_assistant_prompt(&request, &token).await?; - print_json(&response) + if let Some(AssistantSubcommand::Thread(thread_args)) = args.command { + match thread_args.command { + AssistantThreadSubcommand::List => { + let response = execute_assistant_thread_list(&token).await?; + print_json(&response) + } + AssistantThreadSubcommand::Get(thread) => { + let response = + execute_assistant_thread_get(&thread.thread_id, &token).await?; + print_json(&response) + } + AssistantThreadSubcommand::Delete(thread) => { + let response = + execute_assistant_thread_delete(&thread.thread_id, &token).await?; + print_json(&response) + } + AssistantThreadSubcommand::Export(export) => match export.format { + AssistantThreadExportFormat::Markdown => { + let response = + execute_assistant_thread_export(&export.thread_id, &token).await?; + println!("{}", response.markdown); + Ok(()) + } + AssistantThreadExportFormat::Json => { + let response = + execute_assistant_thread_get(&export.thread_id, &token).await?; + print_json(&response) + } + }, + } + } else { + let query = args.query.ok_or_else(|| { + KagiError::Config( + "assistant prompt mode requires a QUERY unless a thread subcommand is used" + .to_string(), + ) + })?; + let request = AssistantPromptRequest { + query, + thread_id: args.thread_id, + model: args.model, + lens_id: args.lens, + internet_access: match (args.web_access, args.no_web_access) { + (true, false) => Some(true), + (false, true) => Some(false), + _ => None, + }, + personalizations: match (args.personalized, args.no_personalized) { + (true, false) => Some(true), + (false, true) => Some(false), + _ => None, + }, + }; + let response = execute_assistant_prompt(&request, &token).await?; + print_json(&response) + } } Commands::Fastgpt(args) => { let request = FastGptRequest { diff --git a/src/parser.rs b/src/parser.rs index d5000f2..6bcb24f 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1,7 +1,7 @@ use scraper::{Html, Selector}; use crate::error::KagiError; -use crate::types::SearchResult; +use crate::types::{AssistantThreadSummary, SearchResult}; /// Parse Kagi search results from HTML. /// @@ -42,6 +42,85 @@ pub fn parse_search_results(html: &str) -> Result, KagiError> Ok(results) } +pub fn parse_assistant_thread_list(html: &str) -> Result, KagiError> { + let document = Html::parse_fragment(html); + let thread_selector = selector(".thread-list .thread")?; + let anchor_selector = selector("a")?; + let title_selector = selector(".title")?; + let snippet_selector = selector(".excerpt")?; + + let mut threads = Vec::new(); + + for element in document.select(&thread_selector) { + let id = element + .value() + .attr("data-code") + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + KagiError::Parse("assistant thread list item missing data-code".to_string()) + })?; + let saved = element + .value() + .attr("data-saved") + .map(|value| value == "true") + .unwrap_or(false); + let shared = element + .value() + .attr("data-public") + .map(|value| value == "true") + .unwrap_or(false); + let tag_ids = + serde_json::from_str::>(element.value().attr("data-tags").unwrap_or("[]")) + .map_err(|error| { + KagiError::Parse(format!("failed to parse assistant thread tag ids: {error}")) + })?; + let snippet = element + .value() + .attr("data-snippet") + .map(str::trim) + .unwrap_or_default() + .to_string(); + + let anchor = element.select(&anchor_selector).next().ok_or_else(|| { + KagiError::Parse("assistant thread list item missing anchor".to_string()) + })?; + let url = anchor + .value() + .attr("href") + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| KagiError::Parse("assistant thread list item missing href".to_string()))? + .to_string(); + let title = element + .select(&title_selector) + .next() + .map(|node| node.text().collect::().trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + KagiError::Parse("assistant thread list item missing title".to_string()) + })?; + let parsed_snippet = element + .select(&snippet_selector) + .next() + .map(|node| node.text().collect::().trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or(snippet); + + threads.push(AssistantThreadSummary { + id: id.to_string(), + title, + url, + snippet: parsed_snippet, + saved, + shared, + tag_ids, + }); + } + + Ok(threads) +} + fn extract_result( element: &scraper::element_ref::ElementRef<'_>, title_selector: &Selector, @@ -77,7 +156,7 @@ fn selector(value: &str) -> Result { #[cfg(test)] mod tests { - use super::parse_search_results; + use super::{parse_assistant_thread_list, parse_search_results}; #[test] fn parses_primary_and_grouped_results() { @@ -117,4 +196,36 @@ mod tests { let results = parse_search_results(html).expect("parser should succeed"); assert!(results.is_empty()); } + + #[test] + fn parses_assistant_thread_list_items() { + let html = r#" + + "#; + + let threads = parse_assistant_thread_list(html).expect("thread list should parse"); + + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].id, "thread-1"); + assert_eq!(threads[0].title, "First Thread"); + assert_eq!(threads[0].url, "/assistant/thread-1"); + assert_eq!(threads[0].snippet, "First snippet"); + assert!(threads[0].saved); + assert!(!threads[0].shared); + assert_eq!(threads[0].tag_ids, vec!["tag-1".to_string()]); + } } diff --git a/src/types.rs b/src/types.rs index c9b39e0..947e568 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -238,6 +240,14 @@ pub struct AssistantPromptRequest { pub query: String, #[serde(skip_serializing_if = "Option::is_none")] pub thread_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lens_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub internet_access: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub personalizations: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -259,6 +269,8 @@ pub struct AssistantMessage { pub id: String, pub thread_id: String, pub created_at: String, + #[serde(default)] + pub branch_list: Vec, pub state: String, pub prompt: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -266,11 +278,17 @@ pub struct AssistantMessage { #[serde(default, skip_serializing_if = "Option::is_none")] pub markdown: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub references_html: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub references_markdown: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub metadata_html: Option, #[serde(default)] pub documents: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trace_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -280,6 +298,60 @@ pub struct AssistantPromptResponse { pub message: AssistantMessage, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AssistantThreadSummary { + pub id: String, + pub title: String, + pub url: String, + pub snippet: String, + pub saved: bool, + pub shared: bool, + #[serde(default)] + pub tag_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AssistantThreadPagination { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub has_more: bool, + pub count: u64, + #[serde(default)] + pub total_counts: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AssistantThreadListResponse { + pub meta: AssistantMeta, + #[serde(default)] + pub tags: Vec, + pub threads: Vec, + pub pagination: AssistantThreadPagination, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AssistantThreadOpenResponse { + pub meta: AssistantMeta, + #[serde(default)] + pub tags: Vec, + pub thread: AssistantThread, + #[serde(default)] + pub messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AssistantThreadDeleteResponse { + pub deleted_thread_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AssistantThreadExportResponse { + pub thread_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filename: Option, + pub markdown: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FastGptRequest { pub query: String,