feat: add Chat SDK integration for persistent chat bot memory (#442)

Adds @vectorize-io/hindsight-chat, a wrapper for the Vercel Chat SDK
that gives any chat bot (Slack, Discord, Teams, etc.) long-term memory
via Hindsight. Includes withHindsightChat() handler wrapper with
auto-recall, auto-retain, and memoriesAsSystemPrompt() formatting.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ben 2026-02-26 11:07:47 -05:00 committed by GitHub
parent 8cd65b9896
commit fed987f931
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 4041 additions and 0 deletions

30
hindsight-integrations/chat/.gitignore vendored Normal file
View file

@ -0,0 +1,30 @@
# Dependencies
node_modules/
# Build output
dist/
# Test coverage
coverage/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment
.env
.env.local
.env.*.local

View file

@ -0,0 +1,161 @@
# @vectorize-io/hindsight-chat
Give your [Vercel Chat SDK](https://github.com/vercel/chat) bots persistent, per-user memory with a single handler wrapper. Works with Slack, Discord, Teams, Google Chat, GitHub, and Linear.
## Quick Start
```bash
npm install @vectorize-io/hindsight-chat
```
```typescript
import { Chat } from 'chat';
import { HindsightClient } from '@vectorize-io/hindsight-client';
import { withHindsightChat } from '@vectorize-io/hindsight-chat';
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
const chat = new Chat({ connectors: [/* your connectors */] });
const hindsight = new HindsightClient({ apiKey: process.env.HINDSIGHT_API_KEY });
chat.onNewMention(
withHindsightChat(
{
client: hindsight,
bankId: (msg) => msg.author.userId, // per-user memory
},
async (thread, message, ctx) => {
await thread.subscribe();
const result = await streamText({
model: openai('gpt-4o'),
system: ctx.memoriesAsSystemPrompt(),
messages: [{ role: 'user', content: message.text }],
});
// Stream the response
const chunks: string[] = [];
for await (const chunk of result.textStream) {
chunks.push(chunk);
}
const fullResponse = chunks.join('');
await thread.post(fullResponse);
// Store the conversation in memory
await ctx.retain(
`User: ${message.text}\nAssistant: ${fullResponse}`
);
}
)
);
```
## Configuration
### `withHindsightChat(options, handler)`
Returns a standard Chat SDK handler `(thread, message) => Promise<void>`.
#### Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `client` | `HindsightClient` | *required* | Hindsight client instance |
| `bankId` | `string \| (msg) => string` | *required* | Memory bank ID or resolver function |
| `recall.enabled` | `boolean` | `true` | Auto-recall memories before handler |
| `recall.budget` | `'low' \| 'mid' \| 'high'` | `'mid'` | Processing budget for recall |
| `recall.maxTokens` | `number` | API default | Max tokens for recall results |
| `recall.types` | `FactType[]` | all | Filter to specific fact types |
| `recall.includeEntities` | `boolean` | `true` | Include entity observations |
| `retain.enabled` | `boolean` | `false` | Auto-retain inbound messages |
| `retain.async` | `boolean` | `true` | Fire-and-forget retain |
| `retain.tags` | `string[]` | | Tags for retained memories |
| `retain.metadata` | `Record<string, string>` | | Metadata for retained memories |
### Context (`ctx`)
The third argument passed to your handler:
| Property/Method | Description |
|----------------|-------------|
| `ctx.bankId` | Resolved bank ID |
| `ctx.memories` | Array of recalled memories |
| `ctx.entities` | Entity observations (or null) |
| `ctx.memoriesAsSystemPrompt(options?)` | Format memories for LLM system prompt |
| `ctx.retain(content, options?)` | Store content in memory |
| `ctx.recall(query, options?)` | Search memories |
| `ctx.reflect(query, options?)` | Reason over memories |
## Examples
### Subscribed Message Handler
```typescript
chat.onSubscribedMessage(
withHindsightChat(
{
client: hindsight,
bankId: (msg) => msg.author.userId,
recall: { budget: 'high', maxTokens: 1000 },
},
async (thread, message, ctx) => {
const result = await generateText({
model: openai('gpt-4o'),
system: ctx.memoriesAsSystemPrompt(),
messages: [{ role: 'user', content: message.text }],
});
await thread.post(result.text);
}
)
);
```
### Auto-Retain Inbound Messages
```typescript
chat.onNewMention(
withHindsightChat(
{
client: hindsight,
bankId: (msg) => msg.author.userId,
retain: { enabled: true, tags: ['slack', 'inbound'] },
},
async (thread, message, ctx) => {
// Inbound message is already being retained automatically
const result = await generateText({
model: openai('gpt-4o'),
system: ctx.memoriesAsSystemPrompt(),
messages: [{ role: 'user', content: message.text }],
});
await thread.post(result.text);
// Retain the assistant response separately
await ctx.retain(`Assistant: ${result.text}`, {
tags: ['slack', 'outbound'],
});
}
)
);
```
### Static Bank ID (Shared Memory)
```typescript
// All users share the same memory bank
chat.onNewMention(
withHindsightChat(
{ client: hindsight, bankId: 'shared-team-memory' },
async (thread, message, ctx) => {
// ...
}
)
);
```
## Error Handling
Memory failures never break your bot. Auto-recall and auto-retain errors are logged as warnings and the handler continues with empty memories. Manual `ctx.retain()`, `ctx.recall()`, and `ctx.reflect()` calls propagate errors normally so you can handle them as needed.
## License
MIT

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,58 @@
{
"name": "@vectorize-io/hindsight-chat",
"version": "0.1.0",
"description": "Hindsight memory integration for Vercel Chat SDK - Give your chat bots persistent, per-user memory",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"keywords": [
"chat",
"chatbot",
"slack",
"discord",
"teams",
"memory",
"hindsight",
"agents",
"llm",
"long-term-memory"
],
"author": "Vectorize <support@vectorize.io>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/vectorize-io/hindsight.git",
"directory": "hindsight-integrations/chat"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run clean && npm run build"
},
"peerDependencies": {
"chat": "^4.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitest/ui": "^4.0.18",
"chat": "^4.0.0",
"typescript": "^5.7.0",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=22"
}
}

View file

@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import { formatMemoriesAsSystemPrompt } from './format.js';
import type { RecallResult, EntityState } from './types.js';
function makeMemory(overrides: Partial<RecallResult> = {}): RecallResult {
return {
id: 'mem-1',
text: 'User prefers dark mode',
type: 'experience',
...overrides,
};
}
function makeEntities(): Record<string, EntityState> {
return {
'ent-1': {
entity_id: 'ent-1',
canonical_name: 'Alice',
observations: [
{ text: 'Works at Acme Corp' },
{ text: 'Prefers TypeScript', mentioned_at: '2025-01-01T00:00:00Z' },
],
},
};
}
describe('formatMemoriesAsSystemPrompt', () => {
it('returns empty string when no memories and no entities', () => {
expect(formatMemoriesAsSystemPrompt([], null)).toBe('');
expect(formatMemoriesAsSystemPrompt([], {})).toBe('');
expect(formatMemoriesAsSystemPrompt([], undefined)).toBe('');
});
it('formats memories with default preamble', () => {
const result = formatMemoriesAsSystemPrompt(
[makeMemory(), makeMemory({ id: 'mem-2', text: 'Likes coffee', type: 'world' })],
null
);
expect(result).toContain(
'You have access to the following memories about this user'
);
expect(result).toContain('<memories>');
expect(result).toContain('- User prefers dark mode [experience]');
expect(result).toContain('- Likes coffee [world]');
expect(result).toContain('</memories>');
expect(result).not.toContain('<entity_observations>');
});
it('formats memories without type suffix when type is null', () => {
const result = formatMemoriesAsSystemPrompt(
[makeMemory({ type: null })],
null
);
expect(result).toContain('- User prefers dark mode\n');
expect(result).not.toContain('[');
});
it('includes entity observations', () => {
const result = formatMemoriesAsSystemPrompt(
[makeMemory()],
makeEntities()
);
expect(result).toContain('<memories>');
expect(result).toContain('<entity_observations>');
expect(result).toContain('## Alice');
expect(result).toContain('- Works at Acme Corp');
expect(result).toContain('- Prefers TypeScript');
expect(result).toContain('</entity_observations>');
});
it('shows only entities when no memories', () => {
const result = formatMemoriesAsSystemPrompt([], makeEntities());
expect(result).not.toContain('<memories>');
expect(result).toContain('<entity_observations>');
expect(result).toContain('## Alice');
});
it('uses custom preamble', () => {
const result = formatMemoriesAsSystemPrompt(
[makeMemory()],
null,
{ preamble: 'Here is what I know:' }
);
expect(result.startsWith('Here is what I know:')).toBe(true);
});
it('limits memories with maxMemories', () => {
const memories = [
makeMemory({ id: '1', text: 'First' }),
makeMemory({ id: '2', text: 'Second' }),
makeMemory({ id: '3', text: 'Third' }),
];
const result = formatMemoriesAsSystemPrompt(memories, null, {
maxMemories: 2,
});
expect(result).toContain('First');
expect(result).toContain('Second');
expect(result).not.toContain('Third');
});
it('filters by includeTypes', () => {
const memories = [
makeMemory({ id: '1', text: 'World fact', type: 'world' }),
makeMemory({ id: '2', text: 'Experience', type: 'experience' }),
makeMemory({ id: '3', text: 'Observation', type: 'observation' }),
];
const result = formatMemoriesAsSystemPrompt(memories, null, {
includeTypes: ['world', 'observation'],
});
expect(result).toContain('World fact');
expect(result).not.toContain('Experience');
expect(result).toContain('Observation');
});
it('excludes entities when includeEntities is false', () => {
const result = formatMemoriesAsSystemPrompt(
[makeMemory()],
makeEntities(),
{ includeEntities: false }
);
expect(result).toContain('<memories>');
expect(result).not.toContain('<entity_observations>');
});
});

View file

@ -0,0 +1,68 @@
import type { RecallResult, EntityState, MemoryPromptOptions } from './types.js';
const DEFAULT_PREAMBLE =
'You have access to the following memories about this user from previous interactions:';
/**
* Formats recalled memories and entity observations into a system prompt string.
*
* Returns an empty string when there are no memories or entities to include,
* so callers can safely concatenate or conditionally append.
*/
export function formatMemoriesAsSystemPrompt(
memories: RecallResult[],
entities: Record<string, EntityState> | null | undefined,
options?: MemoryPromptOptions
): string {
const {
preamble = DEFAULT_PREAMBLE,
maxMemories,
includeTypes,
includeEntities = true,
} = options ?? {};
let filtered = memories;
if (includeTypes && includeTypes.length > 0) {
filtered = filtered.filter(
(m) => m.type != null && includeTypes.includes(m.type as never)
);
}
if (maxMemories != null && maxMemories > 0) {
filtered = filtered.slice(0, maxMemories);
}
const hasMemories = filtered.length > 0;
const entityEntries = entities ? Object.values(entities) : [];
const hasEntities = includeEntities && entityEntries.length > 0;
if (!hasMemories && !hasEntities) {
return '';
}
const parts: string[] = [preamble, ''];
if (hasMemories) {
parts.push('<memories>');
for (const memory of filtered) {
const typeSuffix = memory.type ? ` [${memory.type}]` : '';
parts.push(`- ${memory.text}${typeSuffix}`);
}
parts.push('</memories>');
}
if (hasEntities) {
if (hasMemories) parts.push('');
parts.push('<entity_observations>');
for (const entity of entityEntries) {
parts.push(`## ${entity.canonical_name}`);
for (const obs of entity.observations) {
parts.push(`- ${obs.text}`);
}
}
parts.push('</entity_observations>');
}
return parts.join('\n');
}

View file

@ -0,0 +1,22 @@
export { withHindsightChat } from './wrapper.js';
export { formatMemoriesAsSystemPrompt } from './format.js';
export type {
Budget,
FactType,
RecallResult,
EntityState,
RecallResponse,
ReflectFact,
ReflectResponse,
RetainResponse,
HindsightClient,
BankIdResolver,
ChatMessage,
ChatThread,
MemoryPromptOptions,
RecallOptions,
RetainOptions,
HindsightChatOptions,
HindsightChatContext,
HindsightChatHandler,
} from './types.js';

View file

@ -0,0 +1,268 @@
/**
* Budget levels for recall/reflect operations.
*/
export type Budget = 'low' | 'mid' | 'high';
/**
* Fact types for filtering recall results.
*/
export type FactType = 'world' | 'experience' | 'observation';
/**
* Recall result item from Hindsight.
*/
export interface RecallResult {
id: string;
text: string;
type?: string | null;
entities?: string[] | null;
context?: string | null;
occurred_start?: string | null;
occurred_end?: string | null;
mentioned_at?: string | null;
document_id?: string | null;
metadata?: Record<string, string> | null;
chunk_id?: string | null;
}
/**
* Entity state with observations.
*/
export interface EntityState {
entity_id: string;
canonical_name: string;
observations: Array<{ text: string; mentioned_at?: string | null }>;
}
/**
* Recall response from Hindsight.
*/
export interface RecallResponse {
results: RecallResult[];
trace?: Record<string, unknown> | null;
entities?: Record<string, EntityState> | null;
}
/**
* Reflect fact.
*/
export interface ReflectFact {
id?: string | null;
text: string;
type?: string | null;
context?: string | null;
occurred_start?: string | null;
occurred_end?: string | null;
}
/**
* Reflect response from Hindsight.
*/
export interface ReflectResponse {
text: string;
based_on?: ReflectFact[];
}
/**
* Retain response from Hindsight.
*/
export interface RetainResponse {
success: boolean;
bank_id: string;
items_count: number;
async: boolean;
}
/**
* Hindsight client interface matches @vectorize-io/hindsight-client.
*/
export interface HindsightClient {
retain(
bankId: string,
content: string,
options?: {
timestamp?: Date | string;
context?: string;
metadata?: Record<string, string>;
documentId?: string;
tags?: string[];
async?: boolean;
}
): Promise<RetainResponse>;
recall(
bankId: string,
query: string,
options?: {
types?: FactType[];
maxTokens?: number;
budget?: Budget;
trace?: boolean;
queryTimestamp?: string;
includeEntities?: boolean;
maxEntityTokens?: number;
includeChunks?: boolean;
maxChunkTokens?: number;
}
): Promise<RecallResponse>;
reflect(
bankId: string,
query: string,
options?: {
context?: string;
budget?: Budget;
maxTokens?: number;
}
): Promise<ReflectResponse>;
}
/**
* Resolves a bank ID from a message. Can be a static string or a function
* that derives the bank ID from the message (e.g. per-user memory).
*/
export type BankIdResolver = string | ((message: ChatMessage) => string);
/**
* Minimal message shape expected from the Chat SDK.
* We declare our own interface so `chat` stays a peer dep only.
*/
export interface ChatMessage {
author: {
userId: string;
name?: string;
isMe?: boolean;
};
text: string;
threadId: string;
isMention?: boolean;
metadata?: {
timestamp?: Date;
};
}
/**
* Minimal thread shape expected from the Chat SDK.
*/
export interface ChatThread<TState = unknown> {
post(message: unknown): Promise<unknown>;
subscribe(): Promise<void>;
unsubscribe(): Promise<void>;
isSubscribed(): Promise<boolean>;
startTyping(status?: string): Promise<void>;
state: Promise<TState | null>;
setState(state: TState): Promise<void>;
}
/**
* Options for formatting memories as a system prompt.
*/
export interface MemoryPromptOptions {
/** Custom preamble text before the memories section. */
preamble?: string;
/** Maximum number of memories to include. */
maxMemories?: number;
/** Filter to specific fact types. */
includeTypes?: FactType[];
/** Include entity observations section. */
includeEntities?: boolean;
}
/**
* Options for the auto-recall step.
*/
export interface RecallOptions {
/** Enable auto-recall before the handler runs (default: true). */
enabled?: boolean;
/** Processing budget (default: 'mid'). */
budget?: Budget;
/** Maximum tokens for recall results. */
maxTokens?: number;
/** Filter to specific fact types. */
types?: FactType[];
/** Include entity observations (default: true). */
includeEntities?: boolean;
/** Tags to filter recall results. */
tags?: string[];
}
/**
* Options for the auto-retain step.
*/
export interface RetainOptions {
/** Enable auto-retain of inbound messages (default: false). */
enabled?: boolean;
/** Fire-and-forget retain (default: true when enabled). */
async?: boolean;
/** Tags to attach to retained memories. */
tags?: string[];
/** Metadata to attach to retained memories. */
metadata?: Record<string, string>;
}
/**
* Configuration for withHindsightChat.
*/
export interface HindsightChatOptions {
/** Hindsight client instance. */
client: HindsightClient;
/** Bank ID — static string or function deriving it from the message. */
bankId: BankIdResolver;
/** Auto-recall options (default: enabled). */
recall?: RecallOptions;
/** Auto-retain options for inbound messages (default: disabled). */
retain?: RetainOptions;
}
/**
* Context object passed to the wrapped handler, providing memory operations.
*/
export interface HindsightChatContext {
/** Resolved bank ID for this message. */
bankId: string;
/** Recalled memories (empty array if recall disabled or failed). */
memories: RecallResult[];
/** Recalled entity observations (null if entities not included). */
entities: Record<string, EntityState> | null;
/** Format memories as a system prompt string. */
memoriesAsSystemPrompt(options?: MemoryPromptOptions): string;
/** Store content in memory. */
retain(
content: string,
options?: {
timestamp?: Date | string;
context?: string;
metadata?: Record<string, string>;
tags?: string[];
async?: boolean;
}
): Promise<RetainResponse>;
/** Search memories. */
recall(
query: string,
options?: {
types?: FactType[];
maxTokens?: number;
budget?: Budget;
includeEntities?: boolean;
}
): Promise<RecallResponse>;
/** Reflect on memories to form insights. */
reflect(
query: string,
options?: {
context?: string;
budget?: Budget;
maxTokens?: number;
}
): Promise<ReflectResponse>;
}
/**
* The handler signature that withHindsightChat wraps.
*/
export type HindsightChatHandler<TState = unknown> = (
thread: ChatThread<TState>,
message: ChatMessage,
ctx: HindsightChatContext
) => void | Promise<void>;

View file

@ -0,0 +1,395 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { withHindsightChat } from './wrapper.js';
import type {
HindsightClient,
ChatThread,
ChatMessage,
HindsightChatContext,
} from './types.js';
// --- Mocks ---
function mockClient(overrides?: Partial<HindsightClient>): HindsightClient {
return {
retain: vi.fn().mockResolvedValue({
success: true,
bank_id: 'test-bank',
items_count: 1,
async: false,
}),
recall: vi.fn().mockResolvedValue({
results: [
{ id: 'mem-1', text: 'User likes TypeScript', type: 'experience' },
],
entities: {
'ent-1': {
entity_id: 'ent-1',
canonical_name: 'User',
observations: [{ text: 'Prefers dark mode' }],
},
},
}),
reflect: vi.fn().mockResolvedValue({
text: 'User is a TypeScript developer',
based_on: [],
}),
...overrides,
};
}
function mockThread(): ChatThread {
return {
post: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn().mockResolvedValue(undefined),
unsubscribe: vi.fn().mockResolvedValue(undefined),
isSubscribed: vi.fn().mockResolvedValue(false),
startTyping: vi.fn().mockResolvedValue(undefined),
state: Promise.resolve(null),
setState: vi.fn().mockResolvedValue(undefined),
};
}
function mockMessage(overrides?: Partial<ChatMessage>): ChatMessage {
return {
author: { userId: 'user-123', name: 'Alice', isMe: false },
text: 'What do you know about me?',
threadId: 'thread-1',
...overrides,
};
}
describe('withHindsightChat', () => {
let client: HindsightClient;
let thread: ChatThread;
let message: ChatMessage;
beforeEach(() => {
client = mockClient();
thread = mockThread();
message = mockMessage();
});
describe('bankId resolution', () => {
it('uses static bankId', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'static-bank' },
handler
);
await wrapped(thread, message);
expect(client.recall).toHaveBeenCalledWith(
'static-bank',
message.text,
expect.any(Object)
);
expect(handler).toHaveBeenCalledWith(
thread,
message,
expect.objectContaining({ bankId: 'static-bank' })
);
});
it('uses dynamic bankId from message', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: (msg) => `bank-${msg.author.userId}` },
handler
);
await wrapped(thread, message);
expect(client.recall).toHaveBeenCalledWith(
'bank-user-123',
message.text,
expect.any(Object)
);
expect(handler).toHaveBeenCalledWith(
thread,
message,
expect.objectContaining({ bankId: 'bank-user-123' })
);
});
});
describe('auto-recall', () => {
it('recalls by default', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
expect(client.recall).toHaveBeenCalledOnce();
expect(client.recall).toHaveBeenCalledWith('bank', message.text, {
budget: 'mid',
maxTokens: undefined,
types: undefined,
includeEntities: true,
});
const ctx: HindsightChatContext = handler.mock.calls[0][2];
expect(ctx.memories).toHaveLength(1);
expect(ctx.memories[0].text).toBe('User likes TypeScript');
expect(ctx.entities).not.toBeNull();
});
it('can be disabled', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', recall: { enabled: false } },
handler
);
await wrapped(thread, message);
expect(client.recall).not.toHaveBeenCalled();
const ctx: HindsightChatContext = handler.mock.calls[0][2];
expect(ctx.memories).toEqual([]);
expect(ctx.entities).toBeNull();
});
it('passes recall options through', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{
client,
bankId: 'bank',
recall: {
budget: 'high',
maxTokens: 500,
types: ['experience'],
includeEntities: false,
},
},
handler
);
await wrapped(thread, message);
expect(client.recall).toHaveBeenCalledWith('bank', message.text, {
budget: 'high',
maxTokens: 500,
types: ['experience'],
includeEntities: false,
});
});
it('skips recall for empty message text', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, mockMessage({ text: '' }));
expect(client.recall).not.toHaveBeenCalled();
});
it('handles recall errors gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
client = mockClient({
recall: vi.fn().mockRejectedValue(new Error('Network error')),
});
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
expect(warnSpy).toHaveBeenCalledWith(
'[hindsight-chat] Auto-recall failed:',
expect.any(Error)
);
// Handler still runs with empty memories
const ctx: HindsightChatContext = handler.mock.calls[0][2];
expect(ctx.memories).toEqual([]);
warnSpy.mockRestore();
});
});
describe('auto-retain', () => {
it('does not retain by default', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
expect(client.retain).not.toHaveBeenCalled();
});
it('retains when enabled', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', retain: { enabled: true } },
handler
);
await wrapped(thread, message);
expect(client.retain).toHaveBeenCalledWith('bank', message.text, {
tags: undefined,
metadata: undefined,
async: true,
});
});
it('passes retain options through', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{
client,
bankId: 'bank',
retain: {
enabled: true,
tags: ['slack'],
metadata: { source: 'chat' },
async: false,
},
},
handler
);
await wrapped(thread, message);
expect(client.retain).toHaveBeenCalledWith('bank', message.text, {
tags: ['slack'],
metadata: { source: 'chat' },
async: false,
});
});
it('skips retain for bot messages (isMe)', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', retain: { enabled: true } },
handler
);
await wrapped(
thread,
mockMessage({ author: { userId: 'bot', isMe: true } })
);
expect(client.retain).not.toHaveBeenCalled();
});
it('skips retain for empty text', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', retain: { enabled: true } },
handler
);
await wrapped(thread, mockMessage({ text: '' }));
expect(client.retain).not.toHaveBeenCalled();
});
it('handles retain errors gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
client = mockClient({
retain: vi.fn().mockRejectedValue(new Error('Retain failed')),
});
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', retain: { enabled: true } },
handler
);
// Should not throw
await wrapped(thread, message);
expect(warnSpy).toHaveBeenCalledWith(
'[hindsight-chat] Auto-retain failed:',
expect.any(Error)
);
// Handler still runs
expect(handler).toHaveBeenCalled();
warnSpy.mockRestore();
});
});
describe('context methods', () => {
it('memoriesAsSystemPrompt() formats recalled memories', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
const ctx: HindsightChatContext = handler.mock.calls[0][2];
const prompt = ctx.memoriesAsSystemPrompt();
expect(prompt).toContain('<memories>');
expect(prompt).toContain('User likes TypeScript');
expect(prompt).toContain('<entity_observations>');
});
it('ctx.retain() delegates to client', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
const ctx: HindsightChatContext = handler.mock.calls[0][2];
await ctx.retain('New memory content', { tags: ['test'] });
expect(client.retain).toHaveBeenCalledWith('bank', 'New memory content', {
tags: ['test'],
});
});
it('ctx.recall() delegates to client', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
const ctx: HindsightChatContext = handler.mock.calls[0][2];
await ctx.recall('search query', { budget: 'high' });
// Second call (first was auto-recall)
expect(client.recall).toHaveBeenCalledTimes(2);
expect(client.recall).toHaveBeenLastCalledWith('bank', 'search query', {
budget: 'high',
});
});
it('ctx.reflect() delegates to client', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
const ctx: HindsightChatContext = handler.mock.calls[0][2];
await ctx.reflect('What does the user prefer?');
expect(client.reflect).toHaveBeenCalledWith(
'bank',
'What does the user prefer?',
undefined
);
});
});
describe('handler invocation', () => {
it('passes thread and message through', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
expect(handler).toHaveBeenCalledWith(thread, message, expect.any(Object));
});
it('awaits async handlers', async () => {
let completed = false;
const handler = async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
completed = true;
};
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
expect(completed).toBe(true);
});
});
});

View file

@ -0,0 +1,119 @@
import { formatMemoriesAsSystemPrompt } from './format.js';
import type {
HindsightChatOptions,
HindsightChatContext,
HindsightChatHandler,
ChatThread,
ChatMessage,
RecallResult,
EntityState,
MemoryPromptOptions,
} from './types.js';
/**
* Wraps a Chat SDK handler to automatically provide Hindsight memory context.
*
* Before the handler runs:
* 1. Resolves the bank ID from the message
* 2. Optionally auto-retains the inbound message (off by default)
* 3. Auto-recalls relevant memories (on by default)
* 4. Builds a HindsightChatContext and passes it to the handler
*
* Works with `onNewMention`, `onSubscribedMessage`, and `onNewMessage`.
*
* @example
* ```ts
* chat.onNewMention(
* withHindsightChat(
* { client, bankId: (msg) => msg.author.userId },
* async (thread, message, ctx) => {
* const result = await streamText({
* system: ctx.memoriesAsSystemPrompt(),
* messages: [{ role: 'user', content: message.text }],
* });
* await thread.post(result.textStream);
* await ctx.retain(`User: ${message.text}\nAssistant: ${fullResponse}`);
* }
* )
* );
* ```
*/
export function withHindsightChat<TState = unknown>(
options: HindsightChatOptions,
handler: HindsightChatHandler<TState>
): (thread: ChatThread<TState>, message: ChatMessage) => Promise<void> {
const { client, bankId: bankIdResolver, recall: recallOpts = {}, retain: retainOpts = {} } = options;
const recallEnabled = recallOpts.enabled !== false;
const retainEnabled = retainOpts.enabled === true;
const retainAsync = retainOpts.async !== false; // default true when retain is enabled
return async (thread: ChatThread<TState>, message: ChatMessage): Promise<void> => {
// 1. Resolve bank ID
const resolvedBankId =
typeof bankIdResolver === 'function' ? bankIdResolver(message) : bankIdResolver;
// 2. Auto-retain inbound message (fire-and-forget if async)
if (retainEnabled && message.text && !message.author.isMe) {
const retainPromise = client
.retain(resolvedBankId, message.text, {
tags: retainOpts.tags,
metadata: retainOpts.metadata,
async: retainAsync,
})
.catch((err) => {
console.warn('[hindsight-chat] Auto-retain failed:', err);
});
// If not async, wait for retain to complete before proceeding
if (!retainAsync) {
await retainPromise;
}
}
// 3. Auto-recall memories
let memories: RecallResult[] = [];
let entities: Record<string, EntityState> | null = null;
if (recallEnabled && message.text) {
try {
const recallResponse = await client.recall(resolvedBankId, message.text, {
budget: recallOpts.budget ?? 'mid',
maxTokens: recallOpts.maxTokens,
types: recallOpts.types,
includeEntities: recallOpts.includeEntities !== false,
});
memories = recallResponse.results ?? [];
entities = recallResponse.entities ?? null;
} catch (err) {
console.warn('[hindsight-chat] Auto-recall failed:', err);
}
}
// 4. Build context
const ctx: HindsightChatContext = {
bankId: resolvedBankId,
memories,
entities,
memoriesAsSystemPrompt(promptOptions?: MemoryPromptOptions): string {
return formatMemoriesAsSystemPrompt(memories, entities, promptOptions);
},
retain(content, retainCallOpts) {
return client.retain(resolvedBankId, content, retainCallOpts);
},
recall(query, recallCallOpts) {
return client.recall(resolvedBankId, query, recallCallOpts);
},
reflect(query, reflectCallOpts) {
return client.reflect(resolvedBankId, query, reflectCallOpts);
},
};
// 5. Call the user's handler
await handler(thread, message, ctx);
};
}

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});