Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
debc315b80 | ||
|
|
39605639e0 | ||
|
|
91d364b357 |
@@ -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))
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
+2265
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user