Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
### Fixes

- YouTube: detect obviously truncated caption-track transcripts on long videos and fall through to yt-dlp transcription instead of caching a broken partial result (#184, thanks @sportiz91).
- Cache: include the prompt `<context>` block in summary cache hashing and bump the cache format version so stale cross-page summary collisions cannot be reused (#171, thanks @mvance).
- Chrome extension chat: handle plain-string assistant replies in the side-panel agent loop instead of crashing on `.filter()` tool-call extraction (#186, thanks @Youpen-y).
- Homebrew: make the tap formula fail clearly on Linux instead of installing a macOS binary, and add generator/test coverage for the macOS-only guard (#147, thanks @steipete).
- Firecrawl: reject `--firecrawl always` for YouTube URLs with an explicit guidance error instead of silently skipping Firecrawl on the transcript-first path (#145, thanks @steipete).
Expand Down
19 changes: 16 additions & 3 deletions src/cache-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ export function normalizeContentForHash(content: string): string {
return content.replaceAll("\r\n", "\n").trim();
}

export function extractTaggedBlock(prompt: string, tag: "instructions" | "content"): string | null {
export function extractTaggedBlock(
prompt: string,
tag: "instructions" | "content" | "context",
): string | null {
const open = `<${tag}>`;
const close = `</${tag}>`;
const start = prompt.indexOf(open);
Expand All @@ -25,8 +28,18 @@ export function extractTaggedBlock(prompt: string, tag: "instructions" | "conten
}

export function buildPromptHash(prompt: string): string {
const instructions = extractTaggedBlock(prompt, "instructions") ?? prompt;
return hashString(instructions.trim());
const instructionsContent = extractTaggedBlock(prompt, "instructions");
const contextContent = extractTaggedBlock(prompt, "context");

// If at least one of the tags is present (even if empty), hash their contents.
if (instructionsContent !== null || contextContent !== null) {
const instructions = instructionsContent ?? "";
const context = contextContent ?? "";
return hashString(`${instructions}\n${context}`.trim());
}

// Fallback for prompts without any tags.
return hashString(prompt.trim());
}

export function buildPromptContentHash({
Expand Down
2 changes: 1 addition & 1 deletion src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export type CacheConfig = {
path?: string;
};

export const CACHE_FORMAT_VERSION = 1;
export const CACHE_FORMAT_VERSION = 2;
export const DEFAULT_CACHE_MAX_MB = 512;
export const DEFAULT_CACHE_TTL_DAYS = 30;

Expand Down
54 changes: 54 additions & 0 deletions tests/cache.keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,72 @@ import { describe, expect, it } from "vitest";
import {
buildExtractCacheKey,
buildPromptContentHash,
buildPromptHash,
buildSummaryCacheKey,
extractTaggedBlock,
hashString,
} from "../src/cache.js";

describe("cache keys and tags", () => {
it("extracts tagged blocks", () => {
const prompt = "<instructions>Do the thing.</instructions>\n<content>Body</content>";
expect(extractTaggedBlock(prompt, "instructions")).toBe("Do the thing.");
expect(extractTaggedBlock(prompt, "content")).toBe("Body");
expect(extractTaggedBlock(prompt, "context")).toBeNull();
expect(extractTaggedBlock("<context>Site</context>", "context")).toBe("Site");
expect(extractTaggedBlock("no tags here", "instructions")).toBeNull();
});

it("changes prompt hashes when context changes", () => {
const instructions = "Summarize it.";
const contextA = "URL: https://a.com";
const contextB = "URL: https://b.com";
const prompt1 = `<instructions>${instructions}</instructions>\n<context>${contextA}</context>\n<content></content>`;
const prompt2 = `<instructions>${instructions}</instructions>\n<context>${contextB}</context>\n<content></content>`;

const hash1 = buildPromptHash(prompt1);
const hash2 = buildPromptHash(prompt2);

expect(hash1).not.toBe(hash2);
});

it("hashes instructions-only prompt consistently", () => {
const promptWithEmptyContext =
"<instructions>Summarize.</instructions>\n<context></context>\n<content>Body</content>";
const promptWithNoContextTag =
"<instructions>Summarize.</instructions>\n<content>Body</content>";

const hash1 = buildPromptHash(promptWithEmptyContext);
const hash2 = buildPromptHash(promptWithNoContextTag);

// Both should hash just the instructions since context is empty/missing
expect(hash1).toBe(hash2);
});

it("treats multiple empty tags consistently", () => {
const p1 = "<instructions></instructions>";
const p2 = "<context></context>";
const p3 = "<instructions></instructions><context></context>";
const p4 = "<instructions> </instructions>";

const h1 = buildPromptHash(p1);
const h2 = buildPromptHash(p2);
const h3 = buildPromptHash(p3);
const h4 = buildPromptHash(p4);

expect(h1).toBe(h2);
expect(h2).toBe(h3);
expect(h3).toBe(h4);
// They should all hash to an empty string's hash (after trim)
expect(h1).toBe(hashString(""));
});

it("keeps the legacy whole-prompt fallback when no cache tags exist", () => {
const prompt = "legacy prompt without tags";

expect(buildPromptHash(prompt)).toBe(hashString(prompt));
});

it("changes summary keys when inputs change", () => {
const base = buildSummaryCacheKey({
contentHash: "content",
Expand Down