Compare commits

...
Author SHA1 Message Date
Ben 3b4c73cc24 chore(paperclip): apply prettier formatting and update skills changelog 2026-04-15 09:52:08 -04:00
Ben 4d5a559a3c Merge branch 'main' into feat/paperclip-plugin-replace 2026-04-15 08:52:01 -04:00
Ben 3960764522 feat(paperclip): replace library with Paperclip plugin (v0.2.0)
Replaces the @vectorize-io/hindsight-paperclip npm library with a proper
Paperclip plugin. Works with all adapter types (Claude, Codex, Cursor, HTTP,
Process) via the event system — no code changes required by operators.

- Auto-recalls on agent.run.started, auto-retains on agent.run.finished
- hindsight_recall and hindsight_retain agent tools for mid-run access
- onValidateConfig with live connectivity check
- 15 tests passing
2026-04-08 08:57:24 -04:00
20 changed files with 2104 additions and 2070 deletions
+55 -131
View File
@@ -1,163 +1,87 @@
---
sidebar_position: 11
title: "Paperclip Persistent Memory with Hindsight | Integration Guide"
description: "Add long-term memory to Paperclip agents with Hindsight. Retain, recall, and reflect memories across sessions using the Paperclip integration."
description: "Add long-term memory to all Paperclip agents with Hindsight. Install once as a plugin — every agent gets automatic recall before runs and retain after runs."
---
# 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.
Install the `@vectorize-io/hindsight-paperclip` plugin once. Every agent in your Paperclip instance automatically gets long-term memory that persists across runs, companies, and restarts — no code changes required.
## Quick Start
## Installation
```bash
npm install @vectorize-io/hindsight-paperclip
pnpm paperclipai plugin install @vectorize-io/hindsight-paperclip
```
```typescript
import { recall, retain, loadConfig } from '@vectorize-io/hindsight-paperclip'
Then configure in **Settings → Plugins → Hindsight Memory**.
const config = loadConfig() // reads HINDSIGHT_API_URL, HINDSIGHT_API_TOKEN
## Prerequisites
// Before the heartbeat — inject context from prior sessions
const memories = await recall({
companyId,
agentId,
query: `${task.title}\n${task.description}`,
}, config)
Either:
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)
```bash
# Self-hosted
pip install hindsight-all
export HINDSIGHT_API_LLM_API_KEY=your-openai-key
hindsight-api
```
Get an API key at [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup).
Or [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) — no self-hosting required.
## How It Works
```
Paperclip Heartbeat
recall() ← Query Hindsight for prior context
Agent executes ← Prompt enriched with memories
retain() ← Store output for future heartbeats
agent.run.started
└─ recall(issueTitle + description)
└─ cached in plugin state for this run
agent running…
├─ hindsight_recall(query) → returns cached context or live recall
└─ hindsight_retain(content) → stores immediately
agent.run.finished
└─ retain(output) → stored with runId as document_id
```
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' })
```
Memory is keyed to `companyId` + `agentId` — never to the run ID — so it accumulates across every run.
## 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 |
| Field | Default | Description |
|-------|---------|-------------|
| `hindsightApiUrl` | `http://localhost:8888` | Hindsight server URL |
| `hindsightApiKeyRef` | — | Paperclip secret name holding Hindsight Cloud API key |
| `bankGranularity` | `["company", "agent"]` | Memory isolation: per company+agent, per company, or per agent |
| `recallBudget` | `mid` | `low` = fastest, `mid` = balanced, `high` = most thorough |
| `autoRetain` | `true` | Automatically retain run output after every run |
## Skill File
## Bank ID Format
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.
```
paperclip::{companyId}::{agentId} ← default (company + agent granularity)
paperclip::{companyId} ← company granularity (shared across agents)
paperclip::{agentId} ← agent granularity (agent memory across companies)
```
## Requirements
## Agent Tools
- Node.js 20+ (uses native `fetch`, no external HTTP dependencies)
- Hindsight server (self-hosted or [Hindsight Cloud](https://hindsight.vectorize.io))
Agents can call these tools directly during a run:
**`hindsight_recall(query)`** — search memory for relevant context. Called automatically at run start; agents can also call it mid-run for targeted queries.
**`hindsight_retain(content)`** — store a fact or decision immediately, without waiting for run end.
## Adapter Compatibility
Works with all Paperclip adapter types via the event system:
| Adapter | Supported |
|---------|-----------|
| Claude | ✓ |
| Codex | ✓ |
| Cursor | ✓ |
| HTTP | ✓ |
| Process | ✓ |
@@ -10,6 +10,21 @@ For the source code, see [`hindsight-integrations/paperclip`](https://github.com
← [Back to main changelog](/changelog)
## [0.2.0](https://github.com/vectorize-io/hindsight/tree/integrations/paperclip/v0.2.0)
**Breaking Changes**
- Rewritten as a proper Paperclip plugin (installed via `pnpm paperclipai plugin install`). No code changes required — memory hooks run automatically via the event system.
- Works with all adapter types (Claude, Codex, Cursor, HTTP, Process). Previously required manual `recall()`/`retain()` calls and only supported HTTP adapter agents.
**Features**
- `agent.run.started` hook: auto-recalls context keyed to issue title + description
- `agent.run.finished` hook: auto-retains agent output with `runId` as document ID
- `hindsight_recall` and `hindsight_retain` agent tools for mid-run memory access
- `onValidateConfig`: live connectivity check when operator saves settings
- Configurable bank granularity (company+agent, company-only, agent-only)
## [0.1.2](https://github.com/vectorize-io/hindsight/tree/integrations/paperclip/v0.1.2)
**Improvements**
+64 -132
View File
@@ -1,159 +1,91 @@
# @vectorize-io/hindsight-paperclip
Persistent memory for [Paperclip AI](https://github.com/paperclipai/paperclip) agents using [Hindsight](https://hindsight.vectorize.io).
Persistent long-term memory for Paperclip agents via [Hindsight](https://github.com/vectorize-io/hindsight).
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.
Install once. Every agent in your Paperclip instance gets memory that persists across runs, companies, and restarts.
## How It Works
## What It Does
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.
- **Before each run** — recalls relevant memories from past runs and caches them for the agent
- **After each run** — retains the agent's output to Hindsight automatically
- **Agent tools** — `hindsight_recall` and `hindsight_retain` tools for agents to query and store memory mid-run
## Installation
```bash
npm install @vectorize-io/hindsight-paperclip
pnpm paperclipai plugin install @vectorize-io/hindsight-paperclip
```
Then configure in **Settings → Plugins → Hindsight Memory**.
## Prerequisites
Either:
```bash
# Self-hosted (runs locally)
pip install hindsight-all
export HINDSIGHT_API_LLM_API_KEY=your-openai-key
hindsight-api
```
Or [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) — no self-hosting required.
## Configuration
Set environment variables (or pass as options to `loadConfig()`):
| Field | Default | Description |
| -------------------- | ----------------------- | -------------------------------------------------------------- |
| `hindsightApiUrl` | `http://localhost:8888` | Hindsight server URL |
| `hindsightApiKeyRef` | — | Paperclip secret name holding Hindsight Cloud API key |
| `bankGranularity` | `["company", "agent"]` | Memory isolation: per company+agent, per company, or per agent |
| `recallBudget` | `mid` | `low` = fastest, `mid` = balanced, `high` = most thorough |
| `autoRetain` | `true` | Automatically retain run output after every run |
| 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:
## Bank ID Format
```
paperclip::{companyId}::{agentId}
paperclip::{companyId}::{agentId} ← default (company + agent granularity)
paperclip::{companyId} ← company granularity (shared across agents)
paperclip::{agentId} ← agent granularity (agent memory across companies)
```
You can change the isolation granularity:
## Agent Tools
```typescript
// Shared memory across all agents in a company
loadConfig({ bankGranularity: ["company"] });
// → "paperclip::{companyId}"
Agents can call these tools directly during a run:
// Agent's global memory across all companies
loadConfig({ bankGranularity: ["agent"] });
// → "paperclip::{agentId}"
**`hindsight_recall(query)`** — search memory for relevant context. Called automatically at run start; agents can also call it mid-run for targeted queries.
// Custom prefix
loadConfig({ bankIdPrefix: "myapp" });
// → "myapp::{companyId}::{agentId}"
**`hindsight_retain(content)`** — store a fact or decision immediately, without waiting for run end.
## How It Works
```
agent.run.started
└─ recall(issueTitle + description)
└─ store in plugin state for this run (instant lookup by tools)
agent running…
├─ hindsight_recall(query) → returns cached context or live recall
└─ hindsight_retain(content) → stores immediately
agent.run.finished
└─ retain(output) → stored in Hindsight with runId as document_id
```
## Configuration Reference
Memory is keyed to `companyId` + `agentId`, never to the Paperclip session or run ID — so it survives across any number of runs.
```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
}
## Development
```bash
npm install
npm run build
npm test
```
## Skill File
Local install into a running Paperclip instance:
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))
```bash
curl -X POST http://127.0.0.1:3100/api/plugins/install \
-H "Content-Type: application/json" \
-d '{"packageName":"/absolute/path/to/hindsight-integrations/paperclip","isLocalPath":true}'
```
@@ -0,0 +1,25 @@
import esbuild from "esbuild";
const watch = process.argv.includes("--watch");
const sharedConfig = {
bundle: true,
platform: "node",
target: "node20",
format: "esm",
external: ["@paperclipai/plugin-sdk"],
};
const builds = [
{ entryPoints: ["src/manifest.ts"], outfile: "dist/manifest.js" },
{ entryPoints: ["src/worker.ts"], outfile: "dist/worker.js" },
];
if (watch) {
const contexts = await Promise.all(builds.map((b) => esbuild.context({ ...sharedConfig, ...b })));
await Promise.all(contexts.map((ctx) => ctx.watch()));
console.log("Watching for changes…");
} else {
await Promise.all(builds.map((b) => esbuild.build({ ...sharedConfig, ...b })));
console.log("Build complete.");
}
File diff suppressed because it is too large Load Diff
+14 -19
View File
@@ -1,16 +1,19 @@
{
"name": "@vectorize-io/hindsight-paperclip",
"version": "0.1.2",
"description": "Persistent memory for Paperclip AI agents using Hindsight",
"version": "0.2.0",
"description": "Persistent long-term memory for Paperclip agents via Hindsight — recall before every heartbeat, retain after every run",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js"
},
"keywords": [
"paperclip",
"hindsight",
"memory",
"agents",
"ai"
"ai",
"plugin"
],
"author": "Vectorize <[email protected]>",
"license": "MIT",
@@ -24,32 +27,24 @@
"README.md"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"build": "node esbuild.config.mjs",
"dev": "node esbuild.config.mjs --watch",
"clean": "rm -rf dist",
"test": "vitest run tests",
"test:watch": "vitest tests",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run clean && npm run build"
},
"peerDependencies": {
"express": ">=4"
},
"peerDependenciesMeta": {
"express": {
"optional": true
}
"dependencies": {
"@paperclipai/plugin-sdk": "^2026.403.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^20.0.0",
"express": "^5.0.0",
"esbuild": "^0.25.0",
"typescript": "^5.3.0",
"vitest": "^4.0.0"
},
"engines": {
"node": ">=20"
},
"overrides": {
"vite": ">=8.0.5"
}
}
+13 -23
View File
@@ -1,40 +1,30 @@
/**
* Bank ID derivation for Paperclip agents.
* Bank ID derivation — maps Paperclip company/agent identity onto Hindsight bank IDs.
*
* Aligns Hindsight's memory bank model with Paperclip's company/agent isolation.
* Default format: "paperclip::{companyId}::{agentId}"
*
* bankGranularity: ['company'] → "paperclip::{companyId}"
* bankGranularity: ['agent'] → "paperclip::{agentId}"
* bankGranularity: ['company','agent'] → "paperclip::{companyId}::{agentId}"
*/
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[] = [];
export interface BankConfig {
bankGranularity?: Array<"company" | "agent">;
}
if (config.bankIdPrefix) {
parts.push(config.bankIdPrefix);
}
export function deriveBankId(context: BankContext, config: BankConfig): string {
const granularity = config.bankGranularity ?? ["company", "agent"];
const parts: string[] = ["paperclip"];
for (const field of config.bankGranularity ?? ["company", "agent"]) {
for (const field of granularity) {
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("::");
}
+25 -69
View File
@@ -1,73 +1,38 @@
/**
* HTTP client for the Hindsight REST API.
* Minimal Hindsight HTTP client for use inside the plugin worker.
*
* Uses native fetch (Node 20+). No external dependencies.
*/
import { readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import type { PaperclipMemoryConfig } from "./config.js";
function loadPackageVersion(): string {
try {
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
return pkg.version ?? "0.0.0";
} catch {
return "0.0.0";
}
}
// Sent on every request so self-hosted deployments behind Cloudflare (or any
// reverse proxy with UA-based bot filtering) accept the traffic.
const USER_AGENT = `hindsight-paperclip/${loadPackageVersion()}`;
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();
constructor(baseUrl: string, token?: string) {
const url = baseUrl.trim();
if (!url) throw new Error("hindsightApiUrl is required");
this.baseUrl = url.replace(/\/$/, "");
this.token = config.hindsightApiToken;
this.timeoutMs = config.timeoutMs ?? 15_000;
this.token = token;
}
private headers(): Record<string, string> {
const h: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
};
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> {
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs ?? this.timeoutMs);
const timer = setTimeout(() => controller.abort(), 15_000);
try {
const resp = await fetch(`${this.baseUrl}${path}`, {
@@ -88,43 +53,29 @@ export class HindsightClient {
}
}
async recall(
bankId: string,
query: string,
options?: { budget?: string; maxTokens?: number }
): Promise<RecallResponse> {
async recall(bankId: string, query: string, budget = "mid"): 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,
budget,
max_tokens: 1024,
});
}
async retain(
bankId: string,
content: string,
options?: {
documentId?: string;
context?: string;
metadata?: Record<string, string>;
tags?: string[];
}
): Promise<RetainResponse> {
documentId?: string,
metadata?: Record<string, string>
): Promise<void> {
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 });
const item: Record<string, unknown> = {
content,
context: "paperclip",
};
if (documentId) item["document_id"] = documentId;
if (metadata) item["metadata"] = metadata;
await this.request("POST", path, { items: [item], async: true });
}
async health(): Promise<boolean> {
@@ -139,3 +90,8 @@ export class HindsightClient {
}
}
}
export function formatMemories(memories: Memory[]): string {
if (memories.length === 0) return "";
return memories.map((m) => `- ${m.text}`).join("\n");
}
@@ -1,49 +0,0 @@
/**
* 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 {
const config: PaperclipMemoryConfig = {
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,
};
if (!config.hindsightApiUrl) {
throw new Error(
"hindsightApiUrl is required — set HINDSIGHT_API_URL or pass hindsightApiUrl to loadConfig()"
);
}
return config;
}
@@ -1,36 +0,0 @@
/**
* @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,100 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip-plugin-hindsight",
apiVersion: 1,
version: "0.2.0",
displayName: "Hindsight Memory",
author: "Vectorize <[email protected]>",
description:
"Persistent long-term memory for Paperclip agents. Automatically recalls relevant context before each run and retains agent output after — so every agent gets smarter over time.",
categories: ["automation"],
capabilities: [
"events.subscribe",
"agent.tools.register",
"plugin.state.read",
"plugin.state.write",
"http.outbound",
"secrets.read-ref",
"agents.read",
],
entrypoints: {
worker: "./dist/worker.js",
},
instanceConfigSchema: {
type: "object",
required: ["hindsightApiUrl"],
properties: {
hindsightApiUrl: {
type: "string",
title: "Hindsight API URL",
description:
"Base URL of your Hindsight instance. Use http://localhost:8888 for self-hosted.",
default: "http://localhost:8888",
},
hindsightApiKeyRef: {
type: "string",
title: "Hindsight API Key (secret ref)",
description:
"Name of the Paperclip secret holding your Hindsight Cloud API key. Leave empty for self-hosted.",
},
bankGranularity: {
type: "array",
title: "Bank Granularity",
description:
"Controls memory isolation. Default ['company', 'agent'] gives each agent its own bank per company.",
items: { type: "string", enum: ["company", "agent"] },
default: ["company", "agent"],
},
recallBudget: {
type: "string",
title: "Recall Budget",
description: "'low' is fastest, 'mid' balances speed and depth, 'high' is most thorough.",
enum: ["low", "mid", "high"],
default: "mid",
},
autoRetain: {
type: "boolean",
title: "Auto-retain on Run Finished",
description: "Automatically retain agent run output to Hindsight when a run completes.",
default: true,
},
},
},
tools: [
{
name: "hindsight_recall",
displayName: "Recall from Memory",
description:
"Search Hindsight long-term memory for context relevant to a query. Use this before starting a task to surface relevant past decisions, preferences, and knowledge.",
parametersSchema: {
type: "object",
required: ["query"],
properties: {
query: {
type: "string",
description: "What to search for in memory",
},
},
},
},
{
name: "hindsight_retain",
displayName: "Save to Memory",
description:
"Store important facts, decisions, or outcomes in Hindsight long-term memory for future runs.",
parametersSchema: {
type: "object",
required: ["content"],
properties: {
content: {
type: "string",
description: "The content to store in memory",
},
},
},
},
],
};
export default manifest;
@@ -1,96 +0,0 @@
/**
* 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(
(err) => {
console.warn("[hindsight-paperclip] retain failed:", (err as Error).message);
}
);
}
}
return originalJson(body);
};
next();
};
}
@@ -1,76 +0,0 @@
/**
* 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 (err) {
console.warn("[hindsight-paperclip] recall failed:", (err as Error).message);
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");
}
@@ -1,55 +0,0 @@
/**
* 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 (err) {
console.warn("[hindsight-paperclip] retain failed:", (err as Error).message);
}
}
@@ -0,0 +1,252 @@
/**
* paperclip-plugin-hindsight — worker entrypoint.
*
* Gives Paperclip agents persistent long-term memory via Hindsight.
*
* Lifecycle:
* agent.run.started → recall relevant memories, store in plugin state for the run
* agent.run.finished → retain agent output to Hindsight (if autoRetain is enabled)
*
* Agent tools (callable mid-run):
* hindsight_recall(query) → search memory, returns relevant context
* hindsight_retain(content) → store content in memory immediately
*/
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
import type { ToolRunContext } from "@paperclipai/plugin-sdk";
import { HindsightClient, formatMemories } from "./client.js";
import { deriveBankId } from "./bank.js";
interface PluginConfig {
hindsightApiUrl: string;
hindsightApiKeyRef?: string;
bankGranularity?: Array<"company" | "agent">;
recallBudget?: "low" | "mid" | "high";
autoRetain?: boolean;
}
interface RunStartedPayload {
agentId: string;
runId: string;
issueTitle?: string;
issueDescription?: string;
}
interface RunFinishedPayload {
agentId: string;
runId: string;
output?: string;
result?: string;
}
async function getConfig(ctx: {
config: { get(): Promise<Record<string, unknown>> };
}): Promise<PluginConfig> {
return (await ctx.config.get()) as unknown as PluginConfig;
}
async function resolveApiKey(
ctx: { secrets: { resolve(ref: string): Promise<string | null> } },
config: PluginConfig
): Promise<string | undefined> {
if (!config.hindsightApiKeyRef) return undefined;
const resolved = await ctx.secrets.resolve(config.hindsightApiKeyRef);
return resolved ?? undefined;
}
const plugin = definePlugin({
async setup(ctx) {
ctx.logger.info("Hindsight memory plugin starting");
// ---------------------------------------------------------------------------
// agent.run.started — recall memories and cache them for this run
// ---------------------------------------------------------------------------
ctx.events.on("agent.run.started", async (event) => {
const payload = event.payload as RunStartedPayload;
const config = await getConfig(ctx);
const { agentId, runId, issueTitle, issueDescription } = payload;
const companyId = event.companyId;
const query = [issueTitle, issueDescription].filter(Boolean).join("\n");
if (!query.trim()) return;
try {
const apiKey = await resolveApiKey(ctx, config);
const client = new HindsightClient(config.hindsightApiUrl, apiKey);
const bankId = deriveBankId({ companyId, agentId }, config);
const response = await client.recall(bankId, query, config.recallBudget ?? "mid");
const memories = formatMemories(response.results);
if (memories) {
await ctx.state.set(
{ scopeKind: "run", scopeId: runId, stateKey: "recalled-memories" },
memories
);
ctx.logger.info("Recalled memories for run", {
runId,
bankId,
count: response.results.length,
});
}
} catch (err) {
// Non-fatal: agent runs without memory context.
ctx.logger.warn("Failed to recall memories on run start", {
runId,
error: String(err),
});
}
});
// ---------------------------------------------------------------------------
// agent.run.finished — retain run output to Hindsight
// ---------------------------------------------------------------------------
ctx.events.on("agent.run.finished", async (event) => {
const payload = event.payload as RunFinishedPayload;
const config = await getConfig(ctx);
if (config.autoRetain === false) return;
const { agentId, runId, output, result } = payload;
const companyId = event.companyId;
const content = output ?? result;
if (!content?.trim()) return;
try {
const apiKey = await resolveApiKey(ctx, config);
const client = new HindsightClient(config.hindsightApiUrl, apiKey);
const bankId = deriveBankId({ companyId, agentId }, config);
await client.retain(bankId, content, runId, { agentId, companyId, runId });
ctx.logger.info("Retained run output to memory", { runId, bankId });
} catch (err) {
ctx.logger.warn("Failed to retain run output", {
runId,
error: String(err),
});
}
});
// ---------------------------------------------------------------------------
// Tool: hindsight_recall
// ---------------------------------------------------------------------------
ctx.tools.register(
"hindsight_recall",
{
displayName: "Recall from Memory",
description: "Search Hindsight long-term memory for context relevant to a query.",
parametersSchema: {
type: "object",
required: ["query"],
properties: {
query: { type: "string", description: "What to search for" },
},
},
},
async (params: unknown, runCtx: ToolRunContext) => {
const { query } = params as { query: string };
const config = await getConfig(ctx);
const bankId = deriveBankId(
{ companyId: runCtx.companyId, agentId: runCtx.agentId },
config
);
// Return cached memories from run start if available
const cached = await ctx.state.get({
scopeKind: "run",
scopeId: runCtx.runId,
stateKey: "recalled-memories",
});
if (cached && typeof cached === "string") {
return { content: cached };
}
// Live recall fallback
try {
const apiKey = await resolveApiKey(ctx, config);
const client = new HindsightClient(config.hindsightApiUrl, apiKey);
const response = await client.recall(bankId, query, config.recallBudget ?? "mid");
const memories = formatMemories(response.results);
return { content: memories || "No relevant memories found." };
} catch (err) {
return { content: `Memory recall failed: ${String(err)}` };
}
}
);
// ---------------------------------------------------------------------------
// Tool: hindsight_retain
// ---------------------------------------------------------------------------
ctx.tools.register(
"hindsight_retain",
{
displayName: "Save to Memory",
description:
"Store important facts, decisions, or outcomes in Hindsight long-term memory for future runs.",
parametersSchema: {
type: "object",
required: ["content"],
properties: {
content: {
type: "string",
description: "The content to store in memory",
},
},
},
},
async (params: unknown, runCtx: ToolRunContext) => {
const { content } = params as { content: string };
const config = await getConfig(ctx);
const bankId = deriveBankId(
{ companyId: runCtx.companyId, agentId: runCtx.agentId },
config
);
try {
const apiKey = await resolveApiKey(ctx, config);
const client = new HindsightClient(config.hindsightApiUrl, apiKey);
await client.retain(bankId, content, undefined, {
agentId: runCtx.agentId,
companyId: runCtx.companyId,
runId: runCtx.runId,
});
return { content: "Memory saved." };
} catch (err) {
return { content: `Failed to save memory: ${String(err)}` };
}
}
);
ctx.logger.info("Hindsight memory plugin ready");
},
async onHealth() {
return { status: "ok" };
},
async onValidateConfig(config) {
const c = config as Partial<PluginConfig>;
if (!c.hindsightApiUrl?.trim()) {
return { ok: false, errors: ["hindsightApiUrl is required"] };
}
try {
const client = new HindsightClient(c.hindsightApiUrl);
const healthy = await client.health();
if (!healthy) {
return {
ok: false,
errors: [`Cannot reach Hindsight at ${c.hindsightApiUrl}`],
};
}
} catch (err) {
return { ok: false, errors: [`Connection failed: ${String(err)}`] };
}
return { ok: true };
},
});
export default plugin;
runWorker(plugin, import.meta.url);
@@ -1,44 +0,0 @@
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" };
const baseUrl = "http://fake:9077";
it("default: paperclip::companyId::agentId", () => {
const config = loadConfig({ hindsightApiUrl: baseUrl });
expect(deriveBankId(ctx, config)).toBe("paperclip::co-123::ag-456");
});
it("company-only granularity", () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankGranularity: ["company"] });
expect(deriveBankId(ctx, config)).toBe("paperclip::co-123");
});
it("agent-only granularity", () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankGranularity: ["agent"] });
expect(deriveBankId(ctx, config)).toBe("paperclip::ag-456");
});
it("custom prefix", () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankIdPrefix: "myapp" });
expect(deriveBankId(ctx, config)).toBe("myapp::co-123::ag-456");
});
it("empty prefix with default granularity", () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankIdPrefix: "" });
expect(deriveBankId(ctx, config)).toBe("co-123::ag-456");
});
it("throws when bank ID would be empty", () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankIdPrefix: "", bankGranularity: [] });
expect(() => deriveBankId(ctx, config)).toThrow("Bank ID cannot be empty");
});
it("reversed granularity order", () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankGranularity: ["agent", "company"] });
expect(deriveBankId(ctx, config)).toBe("paperclip::ag-456::co-123");
});
});
@@ -0,0 +1,349 @@
/**
* Tests for paperclip-plugin-hindsight.
*
* Uses @paperclipai/plugin-sdk's createTestHarness to simulate the Paperclip
* host environment without requiring a running Paperclip instance.
*
* Hindsight API calls are intercepted via global fetch mocking.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestHarness } from "@paperclipai/plugin-sdk";
import manifest from "../src/manifest.js";
import plugin from "../src/worker.js";
// ---------------------------------------------------------------------------
// Fetch mock helpers
// ---------------------------------------------------------------------------
function mockFetch(responses: Array<{ url: string | RegExp; body: unknown; status?: number }>) {
return vi.fn(async (url: string) => {
const match = responses.find((r) =>
typeof r.url === "string" ? url.includes(r.url) : r.url.test(url)
);
if (!match) {
return new Response(JSON.stringify({ error: "unmatched url" }), { status: 404 });
}
return new Response(JSON.stringify(match.body), {
status: match.status ?? 200,
});
});
}
// ---------------------------------------------------------------------------
// Harness setup
// ---------------------------------------------------------------------------
const DEFAULT_CONFIG = {
hindsightApiUrl: "http://localhost:8888",
bankGranularity: ["company", "agent"],
recallBudget: "mid",
autoRetain: true,
};
function buildHarness(config: Record<string, unknown> = DEFAULT_CONFIG) {
return createTestHarness({ manifest, config, capabilities: manifest.capabilities });
}
async function setupPlugin(harness: ReturnType<typeof buildHarness>) {
await plugin.definition.setup(harness.ctx);
}
// ---------------------------------------------------------------------------
// Bank ID derivation
// ---------------------------------------------------------------------------
describe("bank ID derivation", () => {
it("default: company + agent", async () => {
const { deriveBankId } = await import("../src/bank.js");
expect(
deriveBankId(
{ companyId: "co-1", agentId: "ag-1" },
{ bankGranularity: ["company", "agent"] }
)
).toBe("paperclip::co-1::ag-1");
});
it("company only", async () => {
const { deriveBankId } = await import("../src/bank.js");
expect(
deriveBankId({ companyId: "co-1", agentId: "ag-1" }, { bankGranularity: ["company"] })
).toBe("paperclip::co-1");
});
it("agent only", async () => {
const { deriveBankId } = await import("../src/bank.js");
expect(
deriveBankId({ companyId: "co-1", agentId: "ag-1" }, { bankGranularity: ["agent"] })
).toBe("paperclip::ag-1");
});
});
// ---------------------------------------------------------------------------
// agent.run.started — recall
// ---------------------------------------------------------------------------
describe("agent.run.started", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = mockFetch([
{ url: /recall/, body: { results: [{ text: "User prefers TypeScript" }] } },
]);
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("calls recall and caches memories in plugin state", async () => {
const harness = buildHarness();
await setupPlugin(harness);
await harness.emit(
"agent.run.started",
{
agentId: "ag-1",
runId: "run-1",
issueTitle: "Refactor auth module",
issueDescription: "Migrate to JWT",
},
{ companyId: "co-1" }
);
const recallCall = fetchMock.mock.calls.find(([url]: [string]) => url.includes("recall"));
expect(recallCall).toBeDefined();
expect(recallCall?.[0]).toContain("paperclip%3A%3Aco-1%3A%3Aag-1");
const state = harness.getState({
scopeKind: "run",
scopeId: "run-1",
stateKey: "recalled-memories",
});
expect(state).toContain("TypeScript");
});
it("skips recall when no issue context provided", async () => {
const harness = buildHarness();
await setupPlugin(harness);
await harness.emit(
"agent.run.started",
{ agentId: "ag-1", runId: "run-2" },
{ companyId: "co-1" }
);
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not throw when Hindsight is unreachable", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("", { status: 503 }))
);
const harness = buildHarness();
await setupPlugin(harness);
await expect(
harness.emit(
"agent.run.started",
{ agentId: "ag-1", runId: "run-3", issueTitle: "Fix bug" },
{ companyId: "co-1" }
)
).resolves.not.toThrow();
});
});
// ---------------------------------------------------------------------------
// agent.run.finished — auto-retain
// ---------------------------------------------------------------------------
describe("agent.run.finished", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = mockFetch([{ url: /memories$/, body: { success: true } }]);
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("retains run output with runId as document ID", async () => {
const harness = buildHarness();
await setupPlugin(harness);
await harness.emit(
"agent.run.finished",
{
agentId: "ag-1",
runId: "run-1",
output: "Refactored auth. Migrated to JWT with 24h expiry.",
},
{ companyId: "co-1" }
);
const retainCall = fetchMock.mock.calls.find(([url]: [string]) => /memories$/.test(url));
expect(retainCall).toBeDefined();
const body = JSON.parse(retainCall?.[1]?.body as string) as {
items: Array<{ content: string; document_id?: string }>;
};
expect(body.items[0]?.content).toContain("JWT");
expect(body.items[0]?.document_id).toBe("run-1");
});
it("skips retain when output is empty", async () => {
const harness = buildHarness();
await setupPlugin(harness);
await harness.emit(
"agent.run.finished",
{ agentId: "ag-1", runId: "run-2", output: "" },
{ companyId: "co-1" }
);
expect(fetchMock).not.toHaveBeenCalled();
});
it("skips retain when autoRetain is false", async () => {
const harness = buildHarness({ ...DEFAULT_CONFIG, autoRetain: false });
await setupPlugin(harness);
await harness.emit(
"agent.run.finished",
{ agentId: "ag-1", runId: "run-3", output: "Some output" },
{ companyId: "co-1" }
);
expect(fetchMock).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// hindsight_recall tool
// ---------------------------------------------------------------------------
describe("hindsight_recall tool", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns cached memories from run start without additional API call", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("{}", { status: 200 }))
);
const harness = buildHarness();
await setupPlugin(harness);
// Emit agent.run.started so recall fires and caches state
vi.stubGlobal(
"fetch",
mockFetch([{ url: /recall/, body: { results: [{ text: "User prefers dark mode" }] } }])
);
await harness.emit(
"agent.run.started",
{ agentId: "ag-1", runId: "run-1", issueTitle: "Update UI" },
{ companyId: "co-1" }
);
// Now recall tool should return cached state, not hit the API again
const callsBefore = (vi.mocked(fetch) as ReturnType<typeof vi.fn>).mock.calls.length;
const result = await harness.executeTool(
"hindsight_recall",
{ query: "preferences" },
{ agentId: "ag-1", runId: "run-1", companyId: "co-1", projectId: "proj-1" }
);
expect((result as { content: string }).content).toContain("dark mode");
const callsAfter = (vi.mocked(fetch) as ReturnType<typeof vi.fn>).mock.calls.length;
// No new recall call — returned from cache
expect(callsAfter).toBe(callsBefore);
});
it("falls back to live recall when no cached state", async () => {
vi.stubGlobal(
"fetch",
mockFetch([{ url: /recall/, body: { results: [{ text: "Agent is a Python specialist" }] } }])
);
const harness = buildHarness();
await setupPlugin(harness);
const result = await harness.executeTool(
"hindsight_recall",
{ query: "specialization" },
{ agentId: "ag-1", runId: "run-2", companyId: "co-1", projectId: "proj-1" }
);
expect((result as { content: string }).content).toContain("Python specialist");
});
});
// ---------------------------------------------------------------------------
// hindsight_retain tool
// ---------------------------------------------------------------------------
describe("hindsight_retain tool", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("stores content via Hindsight retain endpoint", async () => {
const fetchMock = mockFetch([{ url: /memories$/, body: { success: true } }]);
vi.stubGlobal("fetch", fetchMock);
const harness = buildHarness();
await setupPlugin(harness);
const result = await harness.executeTool(
"hindsight_retain",
{ content: "Decision: use Postgres not MySQL" },
{ agentId: "ag-1", runId: "run-1", companyId: "co-1", projectId: "proj-1" }
);
expect((result as { content: string }).content).toBe("Memory saved.");
const call = fetchMock.mock.calls.find(([url]: [string]) => /memories$/.test(url));
const body = JSON.parse(call?.[1]?.body as string) as {
items: Array<{ content: string }>;
};
expect(body.items[0]?.content).toContain("Postgres");
});
});
// ---------------------------------------------------------------------------
// onValidateConfig
// ---------------------------------------------------------------------------
describe("onValidateConfig", () => {
it("fails when hindsightApiUrl is missing", async () => {
const result = await plugin.definition.onValidateConfig!({ hindsightApiUrl: "" });
expect(result.ok).toBe(false);
expect(result.errors?.some((e) => e.includes("hindsightApiUrl"))).toBe(true);
});
it("fails when Hindsight is unreachable", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("", { status: 503 }))
);
const result = await plugin.definition.onValidateConfig!({
hindsightApiUrl: "http://localhost:8888",
});
expect(result.ok).toBe(false);
vi.unstubAllGlobals();
});
it("passes with a reachable Hindsight instance", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("{}", { status: 200 }))
);
const result = await plugin.definition.onValidateConfig!({
hindsightApiUrl: "http://localhost:8888",
});
expect(result.ok).toBe(true);
vi.unstubAllGlobals();
});
});
@@ -1,122 +0,0 @@
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");
});
});
@@ -1,114 +0,0 @@
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);
});
});
@@ -10,6 +10,21 @@ For the source code, see [`hindsight-integrations/paperclip`](https://github.com
← [Back to main changelog](../index.md)
## [0.2.0](https://github.com/vectorize-io/hindsight/tree/integrations/paperclip/v0.2.0)
**Breaking Changes**
- Rewritten as a proper Paperclip plugin (installed via `pnpm paperclipai plugin install`). No code changes required — memory hooks run automatically via the event system.
- Works with all adapter types (Claude, Codex, Cursor, HTTP, Process). Previously required manual `recall()`/`retain()` calls and only supported HTTP adapter agents.
**Features**
- `agent.run.started` hook: auto-recalls context keyed to issue title + description
- `agent.run.finished` hook: auto-retains agent output with `runId` as document ID
- `hindsight_recall` and `hindsight_retain` agent tools for mid-run memory access
- `onValidateConfig`: live connectivity check when operator saves settings
- Configurable bank granularity (company+agent, company-only, agent-only)
## [0.1.2](https://github.com/vectorize-io/hindsight/tree/integrations/paperclip/v0.1.2)
**Improvements**