Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b4c73cc24 | ||
|
|
4d5a559a3c | ||
|
|
3960764522 |
@@ -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**
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
+1177
-1104
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("::");
|
||||
}
|
||||
|
||||
@@ -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**
|
||||
|
||||
Reference in New Issue
Block a user