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:
parent
8cd65b9896
commit
fed987f931
12 changed files with 4041 additions and 0 deletions
30
hindsight-integrations/chat/.gitignore
vendored
Normal file
30
hindsight-integrations/chat/.gitignore
vendored
Normal 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
|
||||||
161
hindsight-integrations/chat/README.md
Normal file
161
hindsight-integrations/chat/README.md
Normal 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
|
||||||
2766
hindsight-integrations/chat/package-lock.json
generated
Normal file
2766
hindsight-integrations/chat/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
58
hindsight-integrations/chat/package.json
Normal file
58
hindsight-integrations/chat/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
127
hindsight-integrations/chat/src/format.test.ts
Normal file
127
hindsight-integrations/chat/src/format.test.ts
Normal 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>');
|
||||||
|
});
|
||||||
|
});
|
||||||
68
hindsight-integrations/chat/src/format.ts
Normal file
68
hindsight-integrations/chat/src/format.ts
Normal 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');
|
||||||
|
}
|
||||||
22
hindsight-integrations/chat/src/index.ts
Normal file
22
hindsight-integrations/chat/src/index.ts
Normal 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';
|
||||||
268
hindsight-integrations/chat/src/types.ts
Normal file
268
hindsight-integrations/chat/src/types.ts
Normal 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>;
|
||||||
395
hindsight-integrations/chat/src/wrapper.test.ts
Normal file
395
hindsight-integrations/chat/src/wrapper.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
119
hindsight-integrations/chat/src/wrapper.ts
Normal file
119
hindsight-integrations/chat/src/wrapper.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
18
hindsight-integrations/chat/tsconfig.json
Normal file
18
hindsight-integrations/chat/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
9
hindsight-integrations/chat/vitest.config.ts
Normal file
9
hindsight-integrations/chat/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue