fleet-memory/hindsight-integrations/paperclip/src/client.ts
Ben 81441ee9af
feat(paperclip): add hindsight-paperclip TypeScript integration (#773)
* feat(paperclip): add hindsight-paperclip TypeScript integration

Adds long-term memory for Paperclip AI agents via a lightweight
TypeScript/Node.js npm package with no runtime dependencies.

- recall() / retain() functions for heartbeat lifecycle hooks
- createMemoryMiddleware() for Express HTTP adapter agents
- Bank ID strategy: paperclip::{companyId}::{agentId} (configurable)
- Skill file for agents to call Hindsight REST API directly
- 27 unit tests covering bank derivation, recall, and retain
- Docs page at sdks/integrations/paperclip

* Remove skills file from paperclip integration

* Rename package to @vectorize-io/hindsight-paperclip
2026-04-02 14:26:45 +02:00

121 lines
3.4 KiB
TypeScript

/**
* HTTP client for the Hindsight REST API.
*
* Uses native fetch (Node 20+). No external dependencies.
*/
import type { PaperclipMemoryConfig } from './config.js';
export interface Memory {
text: string;
type?: string;
mentionedAt?: string;
}
export interface RecallResponse {
results: Memory[];
}
export interface RetainResponse {
success: boolean;
bankId?: string;
}
export class HindsightClient {
private readonly baseUrl: string;
private readonly token: string | undefined;
private readonly timeoutMs: number;
constructor(config: PaperclipMemoryConfig) {
const url = config.hindsightApiUrl.trim();
if (!url) throw new Error('hindsightApiUrl is required');
this.baseUrl = url.replace(/\/$/, '');
this.token = config.hindsightApiToken;
this.timeoutMs = config.timeoutMs ?? 15_000;
}
private headers(): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.token) h['Authorization'] = `Bearer ${this.token}`;
return h;
}
private async request<T>(
method: string,
path: string,
body?: unknown,
timeoutMs?: number,
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs ?? this.timeoutMs);
try {
const resp = await fetch(`${this.baseUrl}${path}`, {
method,
headers: this.headers(),
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
throw new Error(`HTTP ${resp.status} from ${path}: ${text}`);
}
return (await resp.json()) as T;
} finally {
clearTimeout(timer);
}
}
async recall(
bankId: string,
query: string,
options?: { budget?: string; maxTokens?: number },
): Promise<RecallResponse> {
const path = `/v1/default/banks/${encodeURIComponent(bankId)}/memories/recall`;
return this.request<RecallResponse>('POST', path, {
query,
budget: options?.budget ?? 'mid',
max_tokens: options?.maxTokens ?? 1024,
}, 12_000);
}
async retain(
bankId: string,
content: string,
options?: {
documentId?: string;
context?: string;
metadata?: Record<string, string>;
tags?: string[];
},
): Promise<RetainResponse> {
const path = `/v1/default/banks/${encodeURIComponent(bankId)}/memories`;
const item: Record<string, unknown> = { content };
if (options?.documentId) item['document_id'] = options.documentId;
if (options?.context) item['context'] = options.context;
if (options?.metadata) item['metadata'] = options.metadata;
if (options?.tags) item['tags'] = options.tags;
return this.request<RetainResponse>('POST', path, { items: [item], async: true });
}
async setBankMission(bankId: string, mission: string, retainMission?: string): Promise<void> {
const path = `/v1/default/banks/${encodeURIComponent(bankId)}/config`;
const updates: Record<string, string> = { reflect_mission: mission };
if (retainMission) updates['retain_mission'] = retainMission;
await this.request('PATCH', path, { updates });
}
async health(): Promise<boolean> {
try {
const resp = await fetch(`${this.baseUrl}/health`, {
headers: this.headers(),
signal: AbortSignal.timeout(5_000),
});
return resp.ok;
} catch {
return false;
}
}
}