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
6 changes: 3 additions & 3 deletions packages/claude-sdk/src/private/AgentRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AnthropicBeta } from '../public/enums';
import type { AnyToolDefinition, ILogger, RunAgentQuery, SdkMessage } from '../public/types';
import { AgentChannel } from './AgentChannel';
import { ApprovalState } from './ApprovalState';
import type { ConversationHistory } from './ConversationHistory';
import type { ConversationStore } from './ConversationStore';
import { AGENT_SDK_PREFIX } from './consts';
import { MessageStream } from './MessageStream';
import { calculateCost, getContextWindow } from './pricing';
Expand All @@ -17,12 +17,12 @@ export class AgentRun {
readonly #client: Anthropic;
readonly #logger: ILogger | undefined;
readonly #options: RunAgentQuery;
readonly #history: ConversationHistory;
readonly #history: ConversationStore;
readonly #channel: AgentChannel;
readonly #approval: ApprovalState;
readonly #abortController: AbortController;

public constructor(client: Anthropic, logger: ILogger | undefined, options: RunAgentQuery, history: ConversationHistory) {
public constructor(client: Anthropic, logger: ILogger | undefined, options: RunAgentQuery, history: ConversationStore) {
this.#client = client;
this.#logger = logger;
this.#options = options;
Expand Down
6 changes: 3 additions & 3 deletions packages/claude-sdk/src/private/AnthropicAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import versionJson from '@shellicar/build-version/version';
import { IAnthropicAgent } from '../public/interfaces';
import type { AnthropicAgentOptions, ILogger, RunAgentQuery, RunAgentResult } from '../public/types';
import { AgentRun } from './AgentRun';
import { ConversationHistory } from './ConversationHistory';
import { ConversationStore } from './ConversationStore';
import { customFetch } from './http/customFetch';
import { TokenRefreshingAnthropic } from './http/TokenRefreshingAnthropic';

export class AnthropicAgent extends IAnthropicAgent {
readonly #client: TokenRefreshingAnthropic;
readonly #logger: ILogger | undefined;
readonly #history: ConversationHistory;
readonly #history: ConversationStore;

public constructor(options: AnthropicAgentOptions) {
super();
Expand All @@ -24,7 +24,7 @@ export class AnthropicAgent extends IAnthropicAgent {
logger: options.logger,
defaultHeaders,
});
this.#history = new ConversationHistory(options.historyFile);
this.#history = new ConversationStore(options.historyFile);
}

public runAgent(options: RunAgentQuery): RunAgentResult {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { readFileSync, renameSync, writeFileSync } from 'node:fs';
import type { Anthropic } from '@anthropic-ai/sdk';

type HistoryItem = {
export type HistoryItem = {
id?: string;
msg: Anthropic.Beta.Messages.BetaMessageParam;
};

function hasCompactionBlock(msg: Anthropic.Beta.Messages.BetaMessageParam): boolean {
export function hasCompactionBlock(msg: Anthropic.Beta.Messages.BetaMessageParam): boolean {
return Array.isArray(msg.content) && msg.content.some((b) => b.type === 'compaction');
}

function trimToLastCompaction(items: HistoryItem[]): HistoryItem[] {
export function trimToLastCompaction(items: HistoryItem[]): HistoryItem[] {
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
if (item && hasCompactionBlock(item.msg)) {
Expand All @@ -20,34 +19,31 @@ function trimToLastCompaction(items: HistoryItem[]): HistoryItem[] {
return items;
}

export class ConversationHistory {
/**
* Pure in-memory conversation state.
*
* Knows nothing about files or I/O. Enforces role-alternation merge and
* compaction-triggered clear. ConversationStore wraps this to add persistence.
*/
export class Conversation {
readonly #items: HistoryItem[] = [];
readonly #historyFile: string | undefined;

public constructor(historyFile?: string) {
this.#historyFile = historyFile;
if (historyFile) {
try {
const raw = readFileSync(historyFile, 'utf-8');
const msgs = raw
.split('\n')
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as Anthropic.Beta.Messages.BetaMessageParam);
this.#items.push(...trimToLastCompaction(msgs.map((msg) => ({ msg }))));
} catch {
// No history file yet
}
}
}

public get messages(): Anthropic.Beta.Messages.BetaMessageParam[] {
return this.#items.map((item) => item.msg);
}

/**
* Append a message to the conversation history.
* @param msg The message to append.
* @param opts Optional. `id` tags the message for later removal via `remove(id)`.
* Populate from pre-parsed items without applying merge or compaction logic.
* Only ConversationStore should call this, during construction from a persisted file.
*/
public load(items: HistoryItem[]): void {
this.#items.push(...items);
}

/**
* Append a message, enforcing role-alternation and compaction-clear semantics.
* @param msg The message to append.
* @param opts Optional. `id` tags the message for later removal via `remove(id)`.
*/
public push(msg: Anthropic.Beta.Messages.BetaMessageParam, opts?: { id?: string }): void {
if (hasCompactionBlock(msg)) {
Expand All @@ -64,11 +60,10 @@ export class ConversationHistory {
} else {
this.#items.push({ id: opts?.id, msg });
}
this.#save();
}

/**
* Remove a previously pushed message by its tag.
* Remove the last message tagged with `id`.
* Returns `true` if found and removed, `false` if no message with that id exists.
*/
public remove(id: string): boolean {
Expand All @@ -77,16 +72,6 @@ export class ConversationHistory {
return false;
}
this.#items.splice(idx, 1);
this.#save();
return true;
}

#save(): void {
if (!this.#historyFile) {
return;
}
const tmp = `${this.#historyFile}.tmp`;
writeFileSync(tmp, this.#items.map((item) => JSON.stringify(item.msg)).join('\n'));
renameSync(tmp, this.#historyFile);
}
}
56 changes: 56 additions & 0 deletions packages/claude-sdk/src/private/ConversationStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { readFileSync, renameSync, writeFileSync } from 'node:fs';
import type { Anthropic } from '@anthropic-ai/sdk';
import { Conversation, trimToLastCompaction } from './Conversation';

/**
* File-backed conversation store.
*
* Wraps Conversation (pure data) and persists to a JSONL file after every
* mutation. When no historyFile is provided it behaves as an in-memory store
* identical to bare Conversation.
*/
export class ConversationStore {
readonly #conversation: Conversation;
readonly #historyFile: string | undefined;

public constructor(historyFile?: string) {
this.#historyFile = historyFile;
this.#conversation = new Conversation();
if (historyFile) {
try {
const raw = readFileSync(historyFile, 'utf-8');
const msgs = raw
.split('\n')
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as Anthropic.Beta.Messages.BetaMessageParam);
this.#conversation.load(trimToLastCompaction(msgs.map((msg) => ({ msg }))));
} catch {
// No history file yet — start fresh.
}
}
}

public get messages(): Anthropic.Beta.Messages.BetaMessageParam[] {
return this.#conversation.messages;
}

public push(msg: Anthropic.Beta.Messages.BetaMessageParam, opts?: { id?: string }): void {
this.#conversation.push(msg, opts);
this.#save();
}

public remove(id: string): boolean {
const result = this.#conversation.remove(id);
this.#save();
return result;
}

#save(): void {
if (!this.#historyFile) {
return;
}
const tmp = `${this.#historyFile}.tmp`;
writeFileSync(tmp, this.#conversation.messages.map((msg) => JSON.stringify(msg)).join('\n'));
renameSync(tmp, this.#historyFile);
}
}
Loading
Loading