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