@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
JavaScript
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
};