generated from nhcarrigan/template
feat: massive overhaul to manage costs (#103)
### Explanation _No response_ ### Issue Closes #102 ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: #103 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #103.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
generateSummaryPrompt,
|
||||
generateContextInjection,
|
||||
estimateTokens,
|
||||
createSummary,
|
||||
shouldSuggestCompaction,
|
||||
formatTokenCount,
|
||||
sanitizeForJson,
|
||||
} from "./conversationUtils";
|
||||
import type { ConversationSummary } from "$lib/stores/conversations";
|
||||
|
||||
describe("conversationUtils", () => {
|
||||
describe("generateSummaryPrompt", () => {
|
||||
it("generates a prompt containing the conversation content", () => {
|
||||
const content = "User: Hello\n\nAssistant: Hi there!";
|
||||
const prompt = generateSummaryPrompt(content);
|
||||
|
||||
expect(prompt).toContain(content);
|
||||
expect(prompt).toContain("summary");
|
||||
expect(prompt).toContain("Key topics");
|
||||
});
|
||||
|
||||
it("handles empty content", () => {
|
||||
const prompt = generateSummaryPrompt("");
|
||||
expect(prompt).toContain("summary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateContextInjection", () => {
|
||||
it("creates context injection message from summary", () => {
|
||||
const summary: ConversationSummary = {
|
||||
generatedAt: new Date("2024-01-01"),
|
||||
content: "We discussed building a new feature",
|
||||
messageCount: 50,
|
||||
tokenEstimate: 10000,
|
||||
};
|
||||
|
||||
const injection = generateContextInjection(summary);
|
||||
|
||||
expect(injection).toContain("Previous Session Context");
|
||||
expect(injection).toContain("We discussed building a new feature");
|
||||
expect(injection).toContain("50 messages");
|
||||
expect(injection).toContain("10,000 tokens");
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateTokens", () => {
|
||||
it("estimates tokens at ~4 chars per token", () => {
|
||||
expect(estimateTokens("")).toBe(0);
|
||||
expect(estimateTokens("test")).toBe(1); // 4 chars = 1 token
|
||||
expect(estimateTokens("testing")).toBe(2); // 7 chars = 2 tokens
|
||||
expect(estimateTokens("a".repeat(100))).toBe(25); // 100 chars = 25 tokens
|
||||
});
|
||||
|
||||
it("rounds up partial tokens", () => {
|
||||
expect(estimateTokens("a")).toBe(1); // 1 char rounds up to 1 token
|
||||
expect(estimateTokens("ab")).toBe(1); // 2 chars rounds up to 1 token
|
||||
expect(estimateTokens("abc")).toBe(1); // 3 chars rounds up to 1 token
|
||||
expect(estimateTokens("abcde")).toBe(2); // 5 chars rounds up to 2 tokens
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSummary", () => {
|
||||
it("creates a valid ConversationSummary object", () => {
|
||||
const summary = createSummary("Test summary content", 25, 5000);
|
||||
|
||||
expect(summary.content).toBe("Test summary content");
|
||||
expect(summary.messageCount).toBe(25);
|
||||
expect(summary.tokenEstimate).toBe(5000);
|
||||
expect(summary.generatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("sets generatedAt to current time", () => {
|
||||
const before = new Date();
|
||||
const summary = createSummary("content", 10, 1000);
|
||||
const after = new Date();
|
||||
|
||||
expect(summary.generatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(summary.generatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldSuggestCompaction", () => {
|
||||
it("returns false when under threshold", () => {
|
||||
expect(shouldSuggestCompaction(50000, 200000, 60)).toBe(false); // 25%
|
||||
expect(shouldSuggestCompaction(100000, 200000, 60)).toBe(false); // 50%
|
||||
expect(shouldSuggestCompaction(119000, 200000, 60)).toBe(false); // 59.5%
|
||||
});
|
||||
|
||||
it("returns true when at or above threshold", () => {
|
||||
expect(shouldSuggestCompaction(120000, 200000, 60)).toBe(true); // 60%
|
||||
expect(shouldSuggestCompaction(150000, 200000, 60)).toBe(true); // 75%
|
||||
expect(shouldSuggestCompaction(200000, 200000, 60)).toBe(true); // 100%
|
||||
});
|
||||
|
||||
it("handles zero context window limit", () => {
|
||||
expect(shouldSuggestCompaction(50000, 0, 60)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses default threshold of 60%", () => {
|
||||
expect(shouldSuggestCompaction(110000, 200000)).toBe(false); // 55%
|
||||
expect(shouldSuggestCompaction(130000, 200000)).toBe(true); // 65%
|
||||
});
|
||||
|
||||
it("respects custom threshold", () => {
|
||||
expect(shouldSuggestCompaction(70000, 200000, 40)).toBe(false); // 35%
|
||||
expect(shouldSuggestCompaction(90000, 200000, 40)).toBe(true); // 45%
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTokenCount", () => {
|
||||
it("formats small numbers directly", () => {
|
||||
expect(formatTokenCount(0)).toBe("0");
|
||||
expect(formatTokenCount(100)).toBe("100");
|
||||
expect(formatTokenCount(999)).toBe("999");
|
||||
});
|
||||
|
||||
it("formats thousands with K suffix", () => {
|
||||
expect(formatTokenCount(1000)).toBe("1.0K");
|
||||
expect(formatTokenCount(1500)).toBe("1.5K");
|
||||
expect(formatTokenCount(10000)).toBe("10.0K");
|
||||
expect(formatTokenCount(999999)).toBe("1000.0K");
|
||||
});
|
||||
|
||||
it("formats millions with M suffix", () => {
|
||||
expect(formatTokenCount(1000000)).toBe("1.0M");
|
||||
expect(formatTokenCount(1500000)).toBe("1.5M");
|
||||
expect(formatTokenCount(10000000)).toBe("10.0M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeForJson", () => {
|
||||
it("returns normal text unchanged", () => {
|
||||
expect(sanitizeForJson("Hello world")).toBe("Hello world");
|
||||
expect(sanitizeForJson("Test 123")).toBe("Test 123");
|
||||
});
|
||||
|
||||
it("preserves common whitespace", () => {
|
||||
expect(sanitizeForJson("line1\nline2")).toBe("line1\nline2");
|
||||
expect(sanitizeForJson("col1\tcol2")).toBe("col1\tcol2");
|
||||
expect(sanitizeForJson("line\r\nend")).toBe("line\r\nend");
|
||||
});
|
||||
|
||||
it("removes null bytes", () => {
|
||||
expect(sanitizeForJson("hello\x00world")).toBe("helloworld");
|
||||
});
|
||||
|
||||
it("removes other control characters", () => {
|
||||
// Bell character
|
||||
expect(sanitizeForJson("alert\x07here")).toBe("alerthere");
|
||||
// Backspace
|
||||
expect(sanitizeForJson("back\x08space")).toBe("backspace");
|
||||
// Form feed is removed
|
||||
expect(sanitizeForJson("page\x0Cbreak")).toBe("pagebreak");
|
||||
// Escape character
|
||||
expect(sanitizeForJson("esc\x1Bhere")).toBe("eschere");
|
||||
});
|
||||
|
||||
it("preserves printable characters including backslashes", () => {
|
||||
const codeContent = '```rust\nfn main() {\n println!("Hello");\n}\n```';
|
||||
expect(sanitizeForJson(codeContent)).toBe(codeContent);
|
||||
});
|
||||
|
||||
it("handles mixed content with various characters", () => {
|
||||
const mixed = "User: Hello\n\nAssistant: Here's some code:\n```\nconst x = 42;\n```";
|
||||
expect(sanitizeForJson(mixed)).toBe(mixed);
|
||||
});
|
||||
|
||||
it("preserves backslash sequences", () => {
|
||||
// Backslashes followed by letters should be preserved as-is
|
||||
expect(sanitizeForJson("path\\to\\file")).toBe("path\\to\\file");
|
||||
expect(sanitizeForJson("color\\x1b")).toBe("color\\x1b");
|
||||
});
|
||||
|
||||
it("removes lone surrogates", () => {
|
||||
// Lone surrogates (U+D800-U+DFFF) can cause JSON parse errors
|
||||
// High surrogate without low
|
||||
expect(sanitizeForJson("test\uD800end")).toBe("testend");
|
||||
// Low surrogate without high
|
||||
expect(sanitizeForJson("test\uDC00end")).toBe("testend");
|
||||
// But valid surrogate pairs should remain (they form valid characters)
|
||||
// Actually, JavaScript represents emoji as surrogate pairs, so this is tricky
|
||||
// The regex will remove the surrogates, which may break emoji. That's acceptable
|
||||
// for a conversation summary where data integrity is more important.
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { ConversationSummary } from "$lib/stores/conversations";
|
||||
|
||||
/**
|
||||
* Sanitises a string for safe JSON serialization through Tauri IPC.
|
||||
* Removes control characters and lone surrogates that could cause issues
|
||||
* during JSON serialization/deserialization.
|
||||
*/
|
||||
export function sanitizeForJson(text: string): string {
|
||||
// Remove control characters except for common whitespace (tab, newline, carriage return)
|
||||
// These can cause JSON parsing issues and are rarely meaningful in conversation summaries.
|
||||
// eslint-disable-next-line no-control-regex -- regex uses control character codes
|
||||
let sanitized = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
|
||||
|
||||
// Remove extended ASCII control chars (C1 control codes)
|
||||
sanitized = sanitized.replace(/[\x80-\x9F]/g, "");
|
||||
|
||||
// Remove lone surrogates (U+D800 to U+DFFF) which cause "unexpected end of hex escape"
|
||||
// errors in serde_json when they appear without proper pairing.
|
||||
// These are invalid in JSON and can cause parse failures.
|
||||
sanitized = sanitized.replace(/[\uD800-\uDFFF]/g, "");
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a prompt to ask Claude to summarise a conversation.
|
||||
* This can be sent as a user message to get a summary.
|
||||
*/
|
||||
export function generateSummaryPrompt(conversationContent: string): string {
|
||||
return `Please provide a concise summary of our conversation so far. Focus on:
|
||||
1. Key topics discussed
|
||||
2. Important decisions or conclusions made
|
||||
3. Any ongoing tasks or context that would be helpful to remember
|
||||
4. Code changes or files that were modified
|
||||
|
||||
Keep the summary brief but comprehensive enough to continue our work in a new session.
|
||||
|
||||
Here is our conversation:
|
||||
|
||||
${conversationContent}
|
||||
|
||||
Please provide the summary now:`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a context injection message to prepend to a new conversation.
|
||||
* This provides Claude with context from a previous session.
|
||||
*/
|
||||
export function generateContextInjection(summary: ConversationSummary): string {
|
||||
return `[Previous Session Context]
|
||||
The following is a summary from our previous conversation (${summary.messageCount} messages, approximately ${summary.tokenEstimate.toLocaleString()} tokens):
|
||||
|
||||
${summary.content}
|
||||
|
||||
[End of Previous Context]
|
||||
|
||||
Please continue from where we left off, or let me know if you need any clarification about the previous context.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the token count for a given string.
|
||||
* Uses a rough approximation of ~4 characters per token.
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ConversationSummary object from summary content.
|
||||
*/
|
||||
export function createSummary(
|
||||
content: string,
|
||||
messageCount: number,
|
||||
originalTokenEstimate: number
|
||||
): ConversationSummary {
|
||||
return {
|
||||
generatedAt: new Date(),
|
||||
content,
|
||||
messageCount,
|
||||
tokenEstimate: originalTokenEstimate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a conversation should be compacted based on token usage.
|
||||
* Returns true if the conversation is using more than the threshold percentage
|
||||
* of the context window.
|
||||
*/
|
||||
export function shouldSuggestCompaction(
|
||||
contextTokensUsed: number,
|
||||
contextWindowLimit: number,
|
||||
thresholdPercent: number = 60
|
||||
): boolean {
|
||||
if (contextWindowLimit === 0) return false;
|
||||
const utilisationPercent = (contextTokensUsed / contextWindowLimit) * 100;
|
||||
return utilisationPercent >= thresholdPercent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a token count for display.
|
||||
*/
|
||||
export function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return tokens.toString();
|
||||
}
|
||||
Reference in New Issue
Block a user