feat: add response cache and compact output mode

Response cache (api-client.ts):
- In-memory TTL cache (5 min default) avoids duplicate API calls
  when LLMs repeat the same tool call within a conversation.
- Cache key = full URL with params; only 200 OK responses are cached.
- Exposed via client.cache for manual clear() if needed.
- Configurable TTL via TorrentClawClient constructor.

Compact mode (search_content tool):
- New compact boolean parameter (default: false).
- When true, magnet links use short form magnet:?xt=urn:btih:{hash}
  (~60 chars) instead of full magnets with trackers (~300 chars).
- Short magnets are still valid and clickable (DHT peer discovery).
- Also generates a magnet from info_hash even when API returns null.
- Saves ~10K chars per typical search (5 torrents x 10 results).

Tests: 100 total (15 new), all passing.
This commit is contained in:
Deivid Soto 2026-02-09 17:57:11 +01:00
parent e011c0f63e
commit bf459740fe
5 changed files with 349 additions and 9 deletions

143
tests/cache.test.ts Normal file
View file

@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ResponseCache, TorrentClawClient } from "../src/api-client.js";
describe("ResponseCache", () => {
it("returns undefined for missing keys", () => {
const cache = new ResponseCache();
expect(cache.get("missing")).toBeUndefined();
});
it("stores and retrieves values", () => {
const cache = new ResponseCache();
cache.set("key1", { data: "hello" });
expect(cache.get("key1")).toEqual({ data: "hello" });
});
it("expires entries after TTL", () => {
const cache = new ResponseCache(100); // 100ms TTL
cache.set("key1", "value");
// Advance time past TTL
vi.useFakeTimers();
vi.advanceTimersByTime(150);
expect(cache.get("key1")).toBeUndefined();
// Expired entry should be removed from store
expect(cache.size).toBe(0);
vi.useRealTimers();
});
it("returns value before TTL expires", () => {
vi.useFakeTimers();
const cache = new ResponseCache(1000); // 1s TTL
cache.set("key1", "value");
vi.advanceTimersByTime(500);
expect(cache.get("key1")).toBe("value");
vi.useRealTimers();
});
it("clears all entries", () => {
const cache = new ResponseCache();
cache.set("a", 1);
cache.set("b", 2);
expect(cache.size).toBe(2);
cache.clear();
expect(cache.size).toBe(0);
expect(cache.get("a")).toBeUndefined();
});
it("overwrites existing keys", () => {
const cache = new ResponseCache();
cache.set("key1", "old");
cache.set("key1", "new");
expect(cache.get("key1")).toBe("new");
expect(cache.size).toBe(1);
});
});
describe("TorrentClawClient cache integration", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = vi.fn();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
function mockFetch(body: unknown, status = 200) {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
);
}
it("caches search results on second call", async () => {
const responseData = { total: 1, page: 1, pageSize: 10, results: [] };
mockFetch(responseData);
const client = new TorrentClawClient();
const result1 = await client.search({ query: "inception" });
const result2 = await client.search({ query: "inception" });
// Only one fetch call — second was served from cache
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(result1).toEqual(responseData);
expect(result2).toEqual(responseData);
});
it("does not cache different queries", async () => {
const data1 = { total: 1, page: 1, pageSize: 10, results: [] };
const data2 = { total: 2, page: 1, pageSize: 10, results: [] };
mockFetch(data1);
mockFetch(data2);
const client = new TorrentClawClient();
await client.search({ query: "inception" });
await client.search({ query: "matrix" });
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
it("does not cache error responses", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(new Response("Server error", { status: 500 }))
.mockResolvedValueOnce(
new Response(JSON.stringify({ total: 0, page: 1, pageSize: 10, results: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const client = new TorrentClawClient();
// First call fails
await expect(client.search({ query: "test" })).rejects.toThrow();
// Second call should hit the API again (not cached)
const result = await client.search({ query: "test" });
expect(result.total).toBe(0);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
it("cache can be cleared manually", async () => {
const data = { total: 1, page: 1, pageSize: 10, results: [] };
mockFetch(data);
mockFetch(data);
const client = new TorrentClawClient();
await client.search({ query: "test" });
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
client.cache.clear();
await client.search({ query: "test" });
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
});

View file

@ -396,6 +396,128 @@ describe("formatSearchResults", () => {
expect(text).toContain("10.0 GB");
});
it("compact mode uses short magnet links", () => {
const hash = "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e";
const fullMagnet = `magnet:?xt=urn:btih:${hash}&dn=Inception&tr=udp://tracker.example.com:6969&tr=udp://tracker2.example.com:6969`;
const response: SearchResponse = {
total: 1,
page: 1,
pageSize: 10,
results: [
{
id: 42,
imdbId: null,
tmdbId: null,
contentType: "movie",
title: "Inception",
titleOriginal: null,
year: 2010,
overview: null,
posterUrl: null,
backdropUrl: null,
genres: null,
ratingImdb: null,
ratingTmdb: null,
hasTorrents: true,
torrents: [
{
infoHash: hash,
quality: "1080p",
codec: null,
sourceType: null,
sizeBytes: "2147483648",
seeders: 100,
leechers: 5,
magnetUrl: fullMagnet,
source: "yts",
qualityScore: 85,
uploadedAt: null,
languages: [],
audioCodec: null,
hdrType: null,
releaseGroup: null,
isProper: null,
isRepack: null,
isRemastered: null,
},
],
},
],
};
const compact = formatSearchResults(response, { compact: true });
const full = formatSearchResults(response);
// Compact: short magnet with just the hash
expect(compact).toContain(`magnet:?xt=urn:btih:${hash}`);
// Compact: no tracker URLs
expect(compact).not.toContain("tracker.example.com");
// Full: includes the full magnet URL with trackers
expect(full).toContain(fullMagnet);
// Compact output should be shorter
expect(compact.length).toBeLessThan(full.length);
// Both include the info hash
expect(compact).toContain(`Info hash: ${hash}`);
expect(full).toContain(`Info hash: ${hash}`);
});
it("compact mode generates magnet even when magnetUrl is null", () => {
const hash = "b".repeat(40);
const response: SearchResponse = {
total: 1,
page: 1,
pageSize: 10,
results: [
{
id: 1,
imdbId: null,
tmdbId: null,
contentType: "movie",
title: "No Magnet",
titleOriginal: null,
year: 2024,
overview: null,
posterUrl: null,
backdropUrl: null,
genres: null,
ratingImdb: null,
ratingTmdb: null,
hasTorrents: true,
torrents: [
{
infoHash: hash,
quality: "720p",
codec: null,
sourceType: null,
sizeBytes: "1073741824",
seeders: 10,
leechers: 0,
magnetUrl: null,
source: "test",
qualityScore: 50,
uploadedAt: null,
languages: [],
audioCodec: null,
hdrType: null,
releaseGroup: null,
isProper: null,
isRepack: null,
isRemastered: null,
},
],
},
],
};
const compact = formatSearchResults(response, { compact: true });
const full = formatSearchResults(response);
// Compact always generates a magnet from info_hash
expect(compact).toContain(`magnet:?xt=urn:btih:${hash}`);
// Full mode: no magnet when magnetUrl is null
expect(full).not.toContain("Magnet:");
});
it("shows streaming info when available", () => {
const response: SearchResponse = {
total: 1,