feat(memory-claudemem): implement HTTP client

Add ClaudeMemClient with methods:
- ping(): health check at /api/health
- observe(): fire-and-forget to /api/sessions/observations
- search(): query /api/search with index format
- getObservations(): batch fetch from /api/observations/batch

All methods use AbortSignal.timeout for request timeouts
and handle errors gracefully (worker may be offline).

Part of claude-mem integration (Phase 3/7).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alex Newman 2026-01-28 14:05:23 -05:00
parent 346cef2488
commit 967800c7e3

View File

@ -6,27 +6,138 @@ export class ClaudeMemClient {
private readonly timeout: number,
) {}
/**
* Check if the claude-mem worker is healthy.
* Returns true if the worker responds to health check, false otherwise.
*/
async ping(): Promise<boolean> {
// TODO: Phase 3
return false;
try {
const response = await fetch(`${this.baseUrl}/api/health`, {
method: "GET",
signal: AbortSignal.timeout(this.timeout),
});
return response.ok;
} catch {
return false;
}
}
/**
* Record an observation to the claude-mem worker.
* Fire-and-forget pattern: errors are logged but do not block.
*/
async observe(
sessionId: string,
claudeSessionId: string,
toolName: string,
toolInput: unknown,
toolResponse: unknown,
cwd?: string,
): Promise<void> {
// TODO: Phase 3
try {
const body = {
claudeSessionId,
tool_name: toolName,
tool_input: toolInput,
tool_response: toolResponse,
...(cwd && { cwd }),
};
await fetch(`${this.baseUrl}/api/sessions/observations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.timeout),
});
// Fire-and-forget: we don't check response status or throw on errors
} catch {
// Silently ignore errors - worker may be offline
// This is intentional: observations should never block the main flow
}
}
/**
* Search observations by query.
* Returns an array of search results with id, title, snippet, and score.
*/
async search(query: string, limit = 10): Promise<SearchResult[]> {
// TODO: Phase 3
return [];
try {
const searchParams = new URLSearchParams({
query,
type: "observations",
format: "index",
limit: String(limit),
});
const response = await fetch(
`${this.baseUrl}/api/search?${searchParams.toString()}`,
{
method: "GET",
signal: AbortSignal.timeout(this.timeout),
},
);
if (!response.ok) {
return [];
}
const data = await response.json();
// The API returns results in an array format
if (Array.isArray(data)) {
return data as SearchResult[];
}
// Handle wrapped response format { results: [...] }
if (data && Array.isArray(data.results)) {
return data.results as SearchResult[];
}
return [];
} catch {
return [];
}
}
/**
* Fetch full observation details by IDs.
* Returns observations with narrative, files_modified, tool_name, tool_input, tool_response, and created_at_epoch.
*/
async getObservations(ids: number[]): Promise<Observation[]> {
// TODO: Phase 3
return [];
if (ids.length === 0) {
return [];
}
try {
const response = await fetch(`${this.baseUrl}/api/observations/batch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ids }),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
return [];
}
const data = await response.json();
// Handle direct array response
if (Array.isArray(data)) {
return data as Observation[];
}
// Handle wrapped response format { observations: [...] }
if (data && Array.isArray(data.observations)) {
return data.observations as Observation[];
}
return [];
} catch {
return [];
}
}
}