UNPKG

@stackmemoryai/stackmemory

Version:

Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, a

257 lines (256 loc) 7.93 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; import { join } from "path"; import { homedir } from "os"; import { logger } from "../../core/monitoring/logger.js"; const BATCH_JOBS_PATH = join(homedir(), ".stackmemory", "batch-jobs.json"); const DEFAULT_POLL_INTERVAL_MS = 3e4; const DEFAULT_TIMEOUT_MS = 2 * 60 * 60 * 1e3; class AnthropicBatchClient { apiKey; baseUrl; mockMode; constructor(config) { this.apiKey = config?.apiKey !== void 0 ? config.apiKey : process.env["ANTHROPIC_API_KEY"] || ""; this.baseUrl = config?.baseUrl || "https://api.anthropic.com"; this.mockMode = config?.mockMode ?? !this.apiKey; if (this.mockMode) { logger.warn("AnthropicBatchClient: no API key, using mock mode"); } } /** * Submit a batch of requests */ async submit(requests, description) { if (this.mockMode) { const batchId = `batch_mock_${Date.now()}`; this.persistJob({ batchId, status: "ended", createdAt: (/* @__PURE__ */ new Date()).toISOString(), endedAt: (/* @__PURE__ */ new Date()).toISOString(), requestCount: requests.length, description }); return batchId; } const response = await fetch(`${this.baseUrl}/v1/messages/batches`, { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": this.apiKey, "anthropic-version": "2023-06-01" }, body: JSON.stringify({ requests }) }); if (!response.ok) { const errText = await response.text(); throw new Error(`Batch submit failed: ${response.status} ${errText}`); } const job = await response.json(); this.persistJob({ batchId: job.id, status: job.processing_status, createdAt: job.created_at, requestCount: requests.length, description }); logger.info("Batch submitted", { batchId: job.id, count: requests.length }); return job.id; } /** * Poll batch job status */ async poll(batchId) { if (this.mockMode) { return this.mockBatchJob(batchId); } const response = await fetch( `${this.baseUrl}/v1/messages/batches/${batchId}`, { headers: { "x-api-key": this.apiKey, "anthropic-version": "2023-06-01" } } ); if (!response.ok) { const errText = await response.text(); throw new Error(`Batch poll failed: ${response.status} ${errText}`); } const job = await response.json(); this.updateJobStatus(batchId, job.processing_status, job.ended_at); return job; } /** * Retrieve batch results */ async retrieve(batchId) { if (this.mockMode) { return this.mockBatchResults(batchId); } const job = await this.poll(batchId); if (job.processing_status !== "ended") { throw new Error( `Batch ${batchId} not finished: ${job.processing_status}` ); } if (!job.results_url) { throw new Error(`Batch ${batchId} has no results URL`); } const response = await fetch(job.results_url, { headers: { "x-api-key": this.apiKey, "anthropic-version": "2023-06-01" } }); if (!response.ok) { throw new Error(`Batch retrieve failed: ${response.status}`); } const text = await response.text(); return text.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); } /** * Poll until batch completes or times out */ async waitForCompletion(batchId, timeoutMs = DEFAULT_TIMEOUT_MS, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const job = await this.poll(batchId); if (job.processing_status === "ended") { return job; } const remaining = deadline - Date.now(); const waitTime = Math.min(pollIntervalMs, remaining); if (waitTime <= 0) break; await new Promise((resolve) => { const timer = setTimeout(() => resolve(), waitTime); if (typeof timer === "object" && "unref" in timer) { timer.unref(); } }); } throw new Error(`Batch ${batchId} timed out after ${timeoutMs}ms`); } /** * Submit and wait for results (convenience) */ async submitAndWait(requests, timeoutMs = DEFAULT_TIMEOUT_MS, description) { const batchId = await this.submit(requests, description); await this.waitForCompletion(batchId, timeoutMs); return this.retrieve(batchId); } /** * Cancel a batch job */ async cancel(batchId) { if (this.mockMode) { return this.mockBatchJob(batchId, "canceling"); } const response = await fetch( `${this.baseUrl}/v1/messages/batches/${batchId}/cancel`, { method: "POST", headers: { "x-api-key": this.apiKey, "anthropic-version": "2023-06-01" } } ); if (!response.ok) { throw new Error(`Batch cancel failed: ${response.status}`); } const job = await response.json(); this.updateJobStatus(batchId, job.processing_status); return job; } /** * List stored batch jobs */ listJobs() { return this.loadStoredJobs(); } // ── Persistence ─────────────────────────────────────────────────────── persistJob(job) { const jobs = this.loadStoredJobs(); const existing = jobs.findIndex((j) => j.batchId === job.batchId); if (existing >= 0) { jobs[existing] = job; } else { jobs.push(job); } const trimmed = jobs.slice(-50); this.saveStoredJobs(trimmed); } updateJobStatus(batchId, status, endedAt) { const jobs = this.loadStoredJobs(); const job = jobs.find((j) => j.batchId === batchId); if (job) { job.status = status; if (endedAt) job.endedAt = endedAt; this.saveStoredJobs(jobs); } } loadStoredJobs() { try { if (existsSync(BATCH_JOBS_PATH)) { return JSON.parse(readFileSync(BATCH_JOBS_PATH, "utf8")); } } catch { } return []; } saveStoredJobs(jobs) { try { const dir = join(homedir(), ".stackmemory"); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(BATCH_JOBS_PATH, JSON.stringify(jobs, null, 2)); } catch { } } // ── Mock mode ───────────────────────────────────────────────────────── mockBatchJob(batchId, status = "ended") { return { id: batchId, type: "message_batch", processing_status: status, request_counts: { processing: 0, succeeded: 1, errored: 0, canceled: 0, expired: 0 }, created_at: (/* @__PURE__ */ new Date()).toISOString(), ended_at: status === "ended" ? (/* @__PURE__ */ new Date()).toISOString() : void 0 }; } mockBatchResults(batchId) { const jobs = this.loadStoredJobs(); const job = jobs.find((j) => j.batchId === batchId); const count = job?.requestCount || 1; return Array.from({ length: count }, (_, i) => ({ custom_id: `req_${i}`, result: { type: "succeeded", message: { id: `msg_mock_${i}`, content: [ { type: "text", text: `Mock batch response for request ${i}` } ], model: "claude-sonnet-4-5-20250929", stop_reason: "end_turn", usage: { input_tokens: 100, output_tokens: 50 } } } })); } } export { AnthropicBatchClient };