Compare commits

...
Author SHA1 Message Date
Ben debc315b80 Rename package to @vectorize-io/hindsight-paperclip 2026-04-01 13:07:02 -04:00
Ben 39605639e0 Remove skills file from paperclip integration 2026-04-01 12:58:04 -04:00
Ben 91d364b357 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
2026-03-31 15:06:34 -04:00
16 changed files with 3400 additions and 0 deletions
@@ -0,0 +1,161 @@
---
sidebar_position: 11
---
# Paperclip
Persistent memory for [Paperclip AI](https://github.com/paperclipai/paperclip) agents using [Hindsight](https://hindsight.vectorize.io).
Paperclip agents start every heartbeat cold — no memory of prior sessions, decisions, or patterns. The `@vectorize-io/hindsight-paperclip` package gives them long-term memory that persists across heartbeats and sessions.
## Quick Start
```bash
npm install @vectorize-io/hindsight-paperclip
```
```typescript
import { recall, retain, loadConfig } from '@vectorize-io/hindsight-paperclip'
const config = loadConfig() // reads HINDSIGHT_API_URL, HINDSIGHT_API_TOKEN
// Before the heartbeat — inject context from prior sessions
const memories = await recall({
companyId,
agentId,
query: `${task.title}\n${task.description}`,
}, config)
if (memories) {
systemPrompt = `Past context:\n${memories}\n\n${systemPrompt}`
}
// After the heartbeat — store what the agent did
await retain({
companyId,
agentId,
content: agentOutput,
documentId: runId,
}, config)
```
Get an API key at [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup).
## How It Works
```
Paperclip Heartbeat
recall() ← Query Hindsight for prior context
Agent executes ← Prompt enriched with memories
retain() ← Store output for future heartbeats
```
Memory is isolated per company and agent by default (`paperclip::{companyId}::{agentId}`), matching Paperclip's multi-tenant model.
## HTTP Adapter Integration
For agents running as HTTP webhook servers, use the Express middleware:
```typescript
import express from 'express'
import { createMemoryMiddleware, loadConfig } from '@vectorize-io/hindsight-paperclip'
import type { HindsightRequest } from '@vectorize-io/hindsight-paperclip'
const app = express()
app.use(express.json())
app.use(createMemoryMiddleware(loadConfig()))
app.post('/heartbeat', async (req, res) => {
const { memories } = (req as HindsightRequest).hindsight
const { context } = req.body
const prompt = memories
? `Past context:\n${memories}\n\nCurrent task: ${context.taskDescription}`
: `Task: ${context.taskDescription}`
const output = await runYourAgent(prompt)
res.json({ output }) // output is auto-retained by middleware
})
```
The middleware reads `agentId`, `companyId`, `runId`, and `context.taskDescription` from Paperclip's HTTP adapter request body, then auto-retains the agent's `output` field after each response.
## Process Adapter Integration
For agents running as scripts via Paperclip's Process adapter:
```typescript
import { recall, retain, loadConfig } from '@vectorize-io/hindsight-paperclip'
const config = loadConfig()
const { PAPERCLIP_AGENT_ID, PAPERCLIP_COMPANY_ID, PAPERCLIP_RUN_ID } = process.env
const memories = await recall({
agentId: PAPERCLIP_AGENT_ID!,
companyId: PAPERCLIP_COMPANY_ID!,
query: process.env.TASK_DESCRIPTION ?? '',
}, config)
if (memories) {
console.log(`[Memory Context]\n${memories}`)
}
// ... agent executes ...
await retain({
agentId: PAPERCLIP_AGENT_ID!,
companyId: PAPERCLIP_COMPANY_ID!,
content: agentOutput,
documentId: PAPERCLIP_RUN_ID!,
}, config)
```
## Bank ID Isolation
By default, each company+agent pair gets its own memory bank:
| Setting | Bank ID format |
|---|---|
| Default | `paperclip::{companyId}::{agentId}` |
| Company-only | `paperclip::{companyId}` |
| Agent-only | `paperclip::{agentId}` |
| Custom prefix | `{prefix}::{companyId}::{agentId}` |
```typescript
// Shared memory across all agents in a company
loadConfig({ bankGranularity: ['company'] })
// Agent's global memory across all companies
loadConfig({ bankGranularity: ['agent'] })
// Custom prefix
loadConfig({ bankIdPrefix: 'myapp' })
```
## Configuration
| Option | Env Variable | Default | Description |
|---|---|---|---|
| `hindsightApiUrl` | `HINDSIGHT_API_URL` | Required | Hindsight server URL |
| `hindsightApiToken` | `HINDSIGHT_API_TOKEN` | — | API token for Hindsight Cloud |
| `bankGranularity` | — | `['company', 'agent']` | Which IDs to include in the bank ID |
| `bankIdPrefix` | — | `'paperclip'` | Prefix for bank IDs |
| `recallBudget` | — | `'mid'` | Search depth: `low`, `mid`, or `high` |
| `recallMaxTokens` | — | `1024` | Max tokens in recalled memory block |
| `retainContext` | — | `'paperclip'` | Provenance label stored with memories |
| `timeoutMs` | — | `15000` | Request timeout in milliseconds |
## Skill File
A markdown skill file is included at `src/skills/hindsight.md`. Inject it into your agent's system prompt to give the agent direct access to Hindsight's REST API via `curl` for mid-task recall and retention.
## Requirements
- Node.js 20+ (uses native `fetch`, no external HTTP dependencies)
- Hindsight server (self-hosted or [Hindsight Cloud](https://hindsight.vectorize.io))
+6
View File
@@ -250,6 +250,12 @@ const sidebars: SidebarsConfig = {
label: 'NemoClaw',
customProps: { icon: '/img/icons/nemoclaw.png' },
},
{
type: 'doc',
id: 'sdks/integrations/paperclip',
label: 'Paperclip',
customProps: { icon: '/img/icons/nodejs.png' },
},
{
type: 'doc',
id: 'sdks/integrations/strands',
+153
View File
@@ -0,0 +1,153 @@
# @vectorize-io/hindsight-paperclip
Persistent memory for [Paperclip AI](https://github.com/paperclipai/paperclip) agents using [Hindsight](https://hindsight.vectorize.io).
Paperclip agents start every heartbeat cold — no memory of prior sessions, decisions, or patterns. This package gives them long-term memory that persists across heartbeats and sessions.
## How It Works
1. **Before each heartbeat**: `recall()` queries Hindsight for context relevant to the current task and injects it into the agent's prompt
2. **After each heartbeat**: `retain()` stores the agent's output so future heartbeats can reference it
Memory is isolated per company and agent by default (`paperclip::{companyId}::{agentId}`), matching Paperclip's multi-tenant model.
## Installation
```bash
npm install @vectorize-io/hindsight-paperclip
```
## Configuration
Set environment variables (or pass as options to `loadConfig()`):
| Variable | Description | Default |
|---|---|---|
| `HINDSIGHT_API_URL` | Hindsight server URL | Required |
| `HINDSIGHT_API_TOKEN` | API token for Hindsight Cloud | — |
## Usage
### HTTP Adapter Agents (Express middleware)
```typescript
import express from 'express'
import { createMemoryMiddleware, loadConfig } from '@vectorize-io/hindsight-paperclip'
import type { HindsightRequest } from '@vectorize-io/hindsight-paperclip'
const app = express()
app.use(express.json())
app.use(createMemoryMiddleware(loadConfig()))
app.post('/heartbeat', async (req, res) => {
const { memories, runId } = (req as HindsightRequest).hindsight
const { context } = req.body
const prompt = memories
? `Past context:\n${memories}\n\nCurrent task: ${context.taskDescription}`
: `Task: ${context.taskDescription}`
const output = await runYourAgent(prompt)
res.json({ output }) // middleware auto-retains output
})
```
The middleware reads `agentId`, `companyId`, `runId`, and `context.taskDescription` from the Paperclip HTTP adapter request body automatically.
### Process Adapter Scripts
```typescript
import { recall, retain, loadConfig } from '@vectorize-io/hindsight-paperclip'
const config = loadConfig()
const { PAPERCLIP_AGENT_ID, PAPERCLIP_COMPANY_ID, PAPERCLIP_RUN_ID } = process.env
// Recall before executing
const memories = await recall({
agentId: PAPERCLIP_AGENT_ID!,
companyId: PAPERCLIP_COMPANY_ID!,
query: process.env.TASK_DESCRIPTION ?? '',
}, config)
if (memories) {
console.log(`[Memory Context]\n${memories}`)
}
// ... agent does its work ...
// Retain after
await retain({
agentId: PAPERCLIP_AGENT_ID!,
companyId: PAPERCLIP_COMPANY_ID!,
content: agentOutput,
documentId: PAPERCLIP_RUN_ID!,
}, config)
```
### Direct Function Usage
```typescript
import { recall, retain, loadConfig } from '@vectorize-io/hindsight-paperclip'
const config = loadConfig({
hindsightApiUrl: 'https://api.hindsight.vectorize.io',
hindsightApiToken: process.env.HINDSIGHT_API_TOKEN,
})
const memories = await recall(
{ companyId, agentId, query: `${task.title}\n${task.description}` },
config
)
if (memories) {
systemPrompt = `Past context:\n${memories}\n\n${systemPrompt}`
}
```
## Bank ID Isolation
By default, each company+agent pair gets its own memory bank:
```
paperclip::{companyId}::{agentId}
```
You can change the isolation granularity:
```typescript
// Shared memory across all agents in a company
loadConfig({ bankGranularity: ['company'] })
// → "paperclip::{companyId}"
// Agent's global memory across all companies
loadConfig({ bankGranularity: ['agent'] })
// → "paperclip::{agentId}"
// Custom prefix
loadConfig({ bankIdPrefix: 'myapp' })
// → "myapp::{companyId}::{agentId}"
```
## Configuration Reference
```typescript
interface PaperclipMemoryConfig {
hindsightApiUrl: string // HINDSIGHT_API_URL — required
hindsightApiToken?: string // HINDSIGHT_API_TOKEN
bankGranularity?: ('company' | 'agent')[] // default: ['company', 'agent']
bankIdPrefix?: string // default: 'paperclip'
recallBudget?: 'low' | 'mid' | 'high' // default: 'mid'
recallMaxTokens?: number // default: 1024
retainContext?: string // default: 'paperclip'
timeoutMs?: number // default: 15000
}
```
## Skill File
An agent-readable skill file is included at `src/skills/hindsight.md`. Inject it into your agent's system prompt or as a Paperclip skill to give the agent direct access to Hindsight's REST API via `curl`.
## Requirements
- Node.js 20+ (uses native `fetch`)
- Hindsight server (self-hosted or [Hindsight Cloud](https://hindsight.vectorize.io))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
{
"name": "@vectorize-io/hindsight-paperclip",
"version": "0.1.0",
"description": "Persistent memory for Paperclip AI agents using Hindsight",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"keywords": [
"paperclip",
"hindsight",
"memory",
"agents",
"ai"
],
"author": "Vectorize <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/vectorize-io/hindsight.git",
"directory": "hindsight-integrations/paperclip"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run tests",
"test:watch": "vitest tests",
"prepublishOnly": "npm run clean && npm run build"
},
"peerDependencies": {
"express": ">=4"
},
"peerDependenciesMeta": {
"express": {
"optional": true
}
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^20.0.0",
"express": "^5.0.0",
"typescript": "^5.3.0",
"vitest": "^4.0.0"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,40 @@
/**
* Bank ID derivation for Paperclip agents.
*
* Aligns Hindsight's memory bank model with Paperclip's company/agent isolation.
*/
import type { PaperclipMemoryConfig } from './config.js';
export interface BankContext {
companyId: string;
agentId: string;
}
/**
* Derive a Hindsight bank ID from Paperclip context.
*
* Default output: "paperclip::{companyId}::{agentId}"
*
* With bankGranularity: ['company'] → "paperclip::{companyId}"
* With bankGranularity: ['agent'] → "paperclip::{agentId}"
* With bankIdPrefix: '' → "{companyId}::{agentId}"
*/
export function deriveBankId(context: BankContext, config: PaperclipMemoryConfig): string {
const parts: string[] = [];
if (config.bankIdPrefix) {
parts.push(config.bankIdPrefix);
}
for (const field of config.bankGranularity ?? ['company', 'agent']) {
if (field === 'company') parts.push(context.companyId);
if (field === 'agent') parts.push(context.agentId);
}
if (parts.length === 0) {
throw new Error('Bank ID cannot be empty — bankGranularity or bankIdPrefix must be set');
}
return parts.join('::');
}
@@ -0,0 +1,121 @@
/**
* 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;
}
}
}
@@ -0,0 +1,43 @@
/**
* Configuration for @vectorize-io/hindsight-paperclip.
*
* Loaded from explicit options first, then environment variables.
*/
export type BankGranularity = 'company' | 'agent';
export interface PaperclipMemoryConfig {
/** Hindsight server URL. Required. env: HINDSIGHT_API_URL */
hindsightApiUrl: string;
/** API token for Hindsight Cloud. env: HINDSIGHT_API_TOKEN */
hindsightApiToken?: string;
/**
* Which dimensions to include in the bank ID.
* Default: ['company', 'agent'] → "paperclip::{companyId}::{agentId}"
*/
bankGranularity?: BankGranularity[];
/** Prefix prepended to all bank IDs. Default: "paperclip" */
bankIdPrefix?: string;
/** Recall search depth. Default: "mid" */
recallBudget?: 'low' | 'mid' | 'high';
/** Max tokens in the recalled memory block. Default: 1024 */
recallMaxTokens?: number;
/** Provenance label stored with each retained document. Default: "paperclip" */
retainContext?: string;
/** Request timeout in milliseconds. Default: 15000 */
timeoutMs?: number;
}
export function loadConfig(overrides?: Partial<PaperclipMemoryConfig>): PaperclipMemoryConfig {
return {
hindsightApiUrl: process.env['HINDSIGHT_API_URL'] ?? '',
hindsightApiToken: process.env['HINDSIGHT_API_TOKEN'],
bankGranularity: ['company', 'agent'],
bankIdPrefix: 'paperclip',
recallBudget: 'mid',
recallMaxTokens: 1024,
retainContext: 'paperclip',
timeoutMs: 15_000,
...overrides,
};
}
@@ -0,0 +1,36 @@
/**
* @vectorize-io/hindsight-paperclip
*
* Persistent memory for Paperclip AI agents using Hindsight.
*
* @example
* ```typescript
* import { recall, retain, loadConfig } from '@vectorize-io/hindsight-paperclip'
*
* const config = loadConfig()
*
* // Before heartbeat
* const memories = await recall({ companyId, agentId, query }, config)
*
* // After heartbeat
* await retain({ companyId, agentId, content: output, documentId: runId }, config)
* ```
*/
export { recall } from './recall.js';
export type { RecallInput } from './recall.js';
export { retain } from './retain.js';
export type { RetainInput } from './retain.js';
export { createMemoryMiddleware } from './middleware.js';
export type { HindsightRequest } from './middleware.js';
export { deriveBankId } from './bank.js';
export type { BankContext } from './bank.js';
export { loadConfig } from './config.js';
export type { PaperclipMemoryConfig, BankGranularity } from './config.js';
export { HindsightClient } from './client.js';
export type { Memory, RecallResponse, RetainResponse } from './client.js';
@@ -0,0 +1,97 @@
/**
* Express middleware for Paperclip HTTP adapter agents.
*
* Automatically injects recalled memories into each request and
* retains the agent's output after each response.
*
* Paperclip HTTP adapter request shape:
* {
* runId: string,
* agentId: string,
* companyId: string,
* context: { taskId: string, taskDescription?: string, ... }
* }
*/
import type { Request, Response, NextFunction } from 'express';
import type { PaperclipMemoryConfig } from './config.js';
import { recall } from './recall.js';
import { retain } from './retain.js';
/** Augmented request with Hindsight memory context. */
export interface HindsightRequest extends Request {
hindsight: {
memories: string;
companyId: string;
agentId: string;
runId: string;
};
}
/**
* Create Express middleware that auto-recalls before each heartbeat
* and auto-retains after each response.
*
* @example
* ```typescript
* import express from 'express'
* import { createMemoryMiddleware, loadConfig } from '@vectorize-io/hindsight-paperclip'
*
* const app = express()
* app.use(express.json())
* app.use(createMemoryMiddleware(loadConfig()))
*
* app.post('/heartbeat', (req, res) => {
* const { memories, runId } = (req as HindsightRequest).hindsight
* const { context } = req.body
*
* const prompt = memories
* ? `Past context:\n${memories}\n\nCurrent task: ${context.taskDescription}`
* : `Task: ${context.taskDescription}`
*
* // ... run agent ...
* res.json({ output: agentOutput }) // auto-retained by middleware
* })
* ```
*/
export function createMemoryMiddleware(config: PaperclipMemoryConfig) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const { runId, agentId, companyId, context } = req.body ?? {};
if (!agentId || !companyId) {
next();
return;
}
const query: string = context?.taskDescription ?? context?.taskTitle ?? '';
// Pre-recall: inject memories into request
const memories = await recall({ companyId, agentId, query }, config);
(req as HindsightRequest).hindsight = {
memories,
companyId,
agentId,
runId: runId ?? '',
};
// Post-retain: wrap res.json to capture agent output
const originalJson = res.json.bind(res) as (body: unknown) => Response;
(res as Response).json = function (body: unknown): Response {
// Fire-and-forget retain (don't block response)
if (body && typeof body === 'object' && 'output' in body && runId) {
const output = (body as { output: unknown }).output;
if (typeof output === 'string' && output.trim()) {
retain(
{ companyId, agentId, content: output, documentId: runId },
config,
).catch(() => {
// Graceful degradation
});
}
}
return originalJson(body);
};
next();
};
}
@@ -0,0 +1,79 @@
/**
* Recall memories for a Paperclip agent heartbeat.
*
* Call this before the agent processes a task to inject relevant context
* from prior heartbeats and sessions.
*/
import { HindsightClient } from './client.js';
import type { PaperclipMemoryConfig } from './config.js';
import { deriveBankId } from './bank.js';
export interface RecallInput {
/** Paperclip company ID — used to derive the bank ID. */
companyId: string;
/** Paperclip agent ID — used to derive the bank ID. */
agentId: string;
/**
* Query string for memory retrieval. Typically the task title + description.
* e.g. `${issue.title}\n${issue.description}`
*/
query: string;
}
/**
* Retrieve relevant memories for the current Paperclip task.
*
* Returns a formatted string of memories to inject into the agent's prompt,
* or an empty string if no relevant memories are found.
*
* @example
* ```typescript
* const memories = await recall(
* { companyId, agentId, query: `${task.title}\n${task.description}` },
* loadConfig()
* )
* if (memories) {
* systemPrompt = `Past context:\n${memories}\n\n${systemPrompt}`
* }
* ```
*/
export async function recall(
input: RecallInput,
config: PaperclipMemoryConfig,
): Promise<string> {
const { companyId, agentId, query } = input;
if (!query.trim()) return '';
const bankId = deriveBankId({ companyId, agentId }, config);
const client = new HindsightClient(config);
let results: Array<{ text: string; type?: string; mentionedAt?: string }>;
try {
const response = await client.recall(bankId, query, {
budget: config.recallBudget,
maxTokens: config.recallMaxTokens,
});
results = response.results;
} catch {
// Graceful degradation — memory is enhancement, not requirement
return '';
}
if (!results.length) return '';
return formatMemories(results);
}
function formatMemories(
results: Array<{ text: string; type?: string; mentionedAt?: string }>,
): string {
return results
.map((r) => {
const typeStr = r.type ? ` [${r.type}]` : '';
const dateStr = r.mentionedAt ? ` (${r.mentionedAt})` : '';
return `- ${r.text}${typeStr}${dateStr}`;
})
.join('\n\n');
}
@@ -0,0 +1,58 @@
/**
* Retain memories after a Paperclip agent heartbeat.
*
* Call this after the agent completes a task to store what it did
* so future heartbeats can recall the context.
*/
import { HindsightClient } from './client.js';
import type { PaperclipMemoryConfig } from './config.js';
import { deriveBankId } from './bank.js';
export interface RetainInput {
/** Paperclip company ID — used to derive the bank ID. */
companyId: string;
/** Paperclip agent ID — used to derive the bank ID. */
agentId: string;
/** The agent's output or summary of what it did during the heartbeat. */
content: string;
/** Paperclip run ID — used as document ID to prevent duplicate storage. */
documentId: string;
/** Additional metadata to store with the memory. */
metadata?: Record<string, string>;
}
/**
* Store the agent's output as a memory after a Paperclip task heartbeat.
*
* Fails silently — memory retention is an enhancement, not a requirement.
*
* @example
* ```typescript
* await retain(
* { companyId, agentId, content: agentOutput, documentId: runId },
* loadConfig()
* )
* ```
*/
export async function retain(
input: RetainInput,
config: PaperclipMemoryConfig,
): Promise<void> {
const { companyId, agentId, content, documentId, metadata } = input;
if (!content.trim()) return;
const bankId = deriveBankId({ companyId, agentId }, config);
const client = new HindsightClient(config);
try {
await client.retain(bankId, content, {
documentId,
context: config.retainContext,
metadata: { companyId, agentId, ...metadata },
});
} catch {
// Graceful degradation — memory is enhancement, not requirement
}
}
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { deriveBankId } from '../src/bank.js';
import { loadConfig } from '../src/config.js';
describe('deriveBankId', () => {
const ctx = { companyId: 'co-123', agentId: 'ag-456' };
it('default: paperclip::companyId::agentId', () => {
const config = loadConfig();
expect(deriveBankId(ctx, config)).toBe('paperclip::co-123::ag-456');
});
it('company-only granularity', () => {
const config = loadConfig({ bankGranularity: ['company'] });
expect(deriveBankId(ctx, config)).toBe('paperclip::co-123');
});
it('agent-only granularity', () => {
const config = loadConfig({ bankGranularity: ['agent'] });
expect(deriveBankId(ctx, config)).toBe('paperclip::ag-456');
});
it('custom prefix', () => {
const config = loadConfig({ bankIdPrefix: 'myapp' });
expect(deriveBankId(ctx, config)).toBe('myapp::co-123::ag-456');
});
it('empty prefix with default granularity', () => {
const config = loadConfig({ bankIdPrefix: '' });
expect(deriveBankId(ctx, config)).toBe('co-123::ag-456');
});
it('throws when bank ID would be empty', () => {
const config = loadConfig({ bankIdPrefix: '', bankGranularity: [] });
expect(() => deriveBankId(ctx, config)).toThrow('Bank ID cannot be empty');
});
it('reversed granularity order', () => {
const config = loadConfig({ bankGranularity: ['agent', 'company'] });
expect(deriveBankId(ctx, config)).toBe('paperclip::ag-456::co-123');
});
});
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { recall } from '../src/recall.js';
import { loadConfig } from '../src/config.js';
// Mock fetch globally
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
function makeRecallResponse(results: Array<{ text: string; type?: string; mentionedAt?: string }>) {
return {
ok: true,
status: 200,
json: async () => ({ results }),
text: async () => '',
} as unknown as Response;
}
function makeErrorResponse(status: number, body = '') {
return {
ok: false,
status,
json: async () => { throw new Error('not json'); },
text: async () => body,
} as unknown as Response;
}
beforeEach(() => {
mockFetch.mockReset();
});
const config = loadConfig({ hindsightApiUrl: 'http://fake:9077' });
const input = { companyId: 'co-1', agentId: 'ag-1', query: 'what did I work on?' };
describe('recall()', () => {
it('returns empty string for blank query', async () => {
const result = await recall({ ...input, query: ' ' }, config);
expect(result).toBe('');
expect(mockFetch).not.toHaveBeenCalled();
});
it('formats memories as bullet list', async () => {
mockFetch.mockResolvedValue(makeRecallResponse([
{ text: 'Fixed the login bug', type: 'experience' },
{ text: 'Prefers TypeScript', type: 'preference' },
]));
const result = await recall(input, config);
expect(result).toContain('- Fixed the login bug [experience]');
expect(result).toContain('- Prefers TypeScript [preference]');
});
it('includes mentionedAt date when present', async () => {
mockFetch.mockResolvedValue(makeRecallResponse([
{ text: 'Deployed to prod', mentionedAt: '2024-01-15' },
]));
const result = await recall(input, config);
expect(result).toContain('(2024-01-15)');
});
it('returns empty string when no results', async () => {
mockFetch.mockResolvedValue(makeRecallResponse([]));
const result = await recall(input, config);
expect(result).toBe('');
});
it('gracefully degrades on HTTP error', async () => {
mockFetch.mockResolvedValue(makeErrorResponse(500, 'Internal Server Error'));
const result = await recall(input, config);
expect(result).toBe('');
});
it('gracefully degrades on network error', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const result = await recall(input, config);
expect(result).toBe('');
});
it('calls the correct API path with bank ID', async () => {
mockFetch.mockResolvedValue(makeRecallResponse([]));
await recall(input, config);
const [url] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/v1/default/banks/paperclip%3A%3Aco-1%3A%3Aag-1/memories/recall');
});
it('sends query and budget in request body', async () => {
mockFetch.mockResolvedValue(makeRecallResponse([]));
await recall(input, config);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.query).toBe('what did I work on?');
expect(body.budget).toBe('mid');
expect(body.max_tokens).toBe(1024);
});
it('uses custom budget and max_tokens from config', async () => {
const customConfig = loadConfig({
hindsightApiUrl: 'http://fake:9077',
recallBudget: 'high',
recallMaxTokens: 2048,
});
mockFetch.mockResolvedValue(makeRecallResponse([]));
await recall(input, customConfig);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.budget).toBe('high');
expect(body.max_tokens).toBe(2048);
});
it('sends Authorization header when token is set', async () => {
const authConfig = loadConfig({
hindsightApiUrl: 'http://fake:9077',
hindsightApiToken: 'hsk_test123',
});
mockFetch.mockResolvedValue(makeRecallResponse([]));
await recall(input, authConfig);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer hsk_test123');
});
});
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { retain } from '../src/retain.js';
import { loadConfig } from '../src/config.js';
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
function makeRetainResponse() {
return {
ok: true,
status: 200,
json: async () => ({ success: true }),
text: async () => '',
} as unknown as Response;
}
function makeErrorResponse(status: number) {
return {
ok: false,
status,
json: async () => { throw new Error('not json'); },
text: async () => 'error',
} as unknown as Response;
}
beforeEach(() => {
mockFetch.mockReset();
});
const config = loadConfig({ hindsightApiUrl: 'http://fake:9077' });
const input = {
companyId: 'co-1',
agentId: 'ag-1',
content: 'Fixed the authentication bug in login.ts',
documentId: 'run-abc123',
};
describe('retain()', () => {
it('does nothing for blank content', async () => {
await retain({ ...input, content: ' ' }, config);
expect(mockFetch).not.toHaveBeenCalled();
});
it('calls the correct API path', async () => {
mockFetch.mockResolvedValue(makeRetainResponse());
await retain(input, config);
const [url] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/v1/default/banks/paperclip%3A%3Aco-1%3A%3Aag-1/memories');
});
it('sends content in request body items array', async () => {
mockFetch.mockResolvedValue(makeRetainResponse());
await retain(input, config);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.items).toHaveLength(1);
expect(body.items[0].content).toBe('Fixed the authentication bug in login.ts');
});
it('sends document_id to prevent duplicates', async () => {
mockFetch.mockResolvedValue(makeRetainResponse());
await retain(input, config);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.items[0].document_id).toBe('run-abc123');
});
it('includes companyId and agentId in metadata', async () => {
mockFetch.mockResolvedValue(makeRetainResponse());
await retain(input, config);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.items[0].metadata.companyId).toBe('co-1');
expect(body.items[0].metadata.agentId).toBe('ag-1');
});
it('merges custom metadata with default metadata', async () => {
mockFetch.mockResolvedValue(makeRetainResponse());
await retain({ ...input, metadata: { taskId: 'task-99' } }, config);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.items[0].metadata.taskId).toBe('task-99');
expect(body.items[0].metadata.companyId).toBe('co-1');
});
it('sets context to retainContext from config', async () => {
mockFetch.mockResolvedValue(makeRetainResponse());
await retain(input, config);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.items[0].context).toBe('paperclip');
});
it('gracefully degrades on HTTP error', async () => {
mockFetch.mockResolvedValue(makeErrorResponse(503));
// Should not throw
await expect(retain(input, config)).resolves.toBeUndefined();
});
it('gracefully degrades on network error', async () => {
mockFetch.mockRejectedValue(new Error('Network failure'));
await expect(retain(input, config)).resolves.toBeUndefined();
});
it('sends async flag in request body', async () => {
mockFetch.mockResolvedValue(makeRetainResponse());
await retain(input, config);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.async).toBe(true);
});
});
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}