chore(lint): share ruff/prettier config across integrations (#1072)

* chore(lint): share ruff/prettier config across integrations

Adds root ruff.toml and .prettierrc.json so every integration package is
formatted with the same rules. lint.sh now also lints integration
packages — only those with modified files locally, all of them in CI
(when $CI is set, or via LINT_ALL_INTEGRATIONS=1).

* style(integrations): apply shared ruff/prettier formatting

Mechanical reformat — output of ruff format / prettier --write under the
new shared configs. No behavior changes.

* chore: regenerate docs skill
This commit is contained in:
Nicolò Boschi
2026-04-15 10:39:34 +02:00
committed by GitHub
parent dee581396b
commit 70a7411659
106 changed files with 6026 additions and 5609 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}
@@ -92,24 +92,12 @@ def create_hindsight_tools(
config = get_config()
effective_tags = tags if tags is not None else (config.tags if config else None)
effective_recall_tags = (
recall_tags
if recall_tags is not None
else (config.recall_tags if config else None)
)
effective_recall_tags = recall_tags if recall_tags is not None else (config.recall_tags if config else None)
effective_recall_tags_match = (
recall_tags_match
if recall_tags_match is not None
else (config.recall_tags_match if config else "any")
)
effective_budget = (
budget if budget is not None else (config.budget if config else "mid")
)
effective_max_tokens = (
max_tokens
if max_tokens is not None
else (config.max_tokens if config else 4096)
recall_tags_match if recall_tags_match is not None else (config.recall_tags_match if config else "any")
)
effective_budget = budget if budget is not None else (config.budget if config else "mid")
effective_max_tokens = max_tokens if max_tokens is not None else (config.max_tokens if config else 4096)
tools: list[Callable] = []
@@ -210,12 +198,8 @@ def create_hindsight_tools(
if reflect_response_schema:
reflect_kwargs["response_schema"] = reflect_response_schema
# Reflect tags: use reflect-specific or fall back to recall tags
effective_reflect_tags = (
reflect_tags if reflect_tags is not None else effective_recall_tags
)
effective_reflect_tags_match = (
reflect_tags_match or effective_recall_tags_match
)
effective_reflect_tags = reflect_tags if reflect_tags is not None else effective_recall_tags
effective_reflect_tags_match = reflect_tags_match or effective_recall_tags_match
if effective_reflect_tags:
reflect_kwargs["tags"] = effective_reflect_tags
reflect_kwargs["tags_match"] = effective_reflect_tags_match
@@ -55,8 +55,7 @@ def _resolve_client(
if url is None:
raise HindsightError(
"No Hindsight API URL configured. "
"Pass client= or hindsight_api_url=, or call configure() first."
"No Hindsight API URL configured. Pass client= or hindsight_api_url=, or call configure() first."
)
kwargs: dict[str, Any] = {"base_url": url, "timeout": 30.0, "user_agent": _USER_AGENT}
@@ -131,14 +130,8 @@ class HindsightTools(Toolkit):
self._budget = budget or (config.budget if config else "mid")
self._max_tokens = max_tokens or (config.max_tokens if config else 4096)
self._tags = tags if tags is not None else (config.tags if config else None)
self._recall_tags = (
recall_tags
if recall_tags is not None
else (config.recall_tags if config else None)
)
self._recall_tags_match = recall_tags_match or (
config.recall_tags_match if config else "any"
)
self._recall_tags = recall_tags if recall_tags is not None else (config.recall_tags if config else None)
self._recall_tags_match = recall_tags_match or (config.recall_tags_match if config else "any")
# Build list of tools to register based on enable flags
tools: list[Callable[..., Any]] = []
@@ -176,8 +169,7 @@ class HindsightTools(Toolkit):
return user_id
raise HindsightError(
"No bank_id available. Provide bank_id=, bank_resolver=, "
"or ensure run_context.user_id is set."
"No bank_id available. Provide bank_id=, bank_resolver=, or ensure run_context.user_id is set."
)
def _ensure_bank(self, bank_id: str) -> None:
+8 -7
View File
@@ -9,14 +9,14 @@ npm install @vectorize-io/hindsight-ai-sdk @vectorize-io/hindsight-client ai zod
```
```typescript
import { HindsightClient } from '@vectorize-io/hindsight-client';
import { createHindsightTools } from '@vectorize-io/hindsight-ai-sdk';
import { generateText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { HindsightClient } from "@vectorize-io/hindsight-client";
import { createHindsightTools } from "@vectorize-io/hindsight-ai-sdk";
import { generateText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
// 1. Initialize Hindsight client
const hindsightClient = new HindsightClient({
apiUrl: 'http://localhost:8000',
apiUrl: "http://localhost:8000",
});
// 2. Create memory tools
@@ -24,13 +24,13 @@ const tools = createHindsightTools({ client: hindsightClient });
// 3. Use with AI SDK
const result = await generateText({
model: anthropic('claude-sonnet-4-20250514'),
model: anthropic("claude-sonnet-4-20250514"),
tools,
system: `You have long-term memory. Use:
- 'recall' to search past conversations
- 'retain' to remember important information
- 'reflect' to synthesize insights from memories`,
prompt: 'Remember that Alice loves hiking and prefers spicy food',
prompt: "Remember that Alice loves hiking and prefers spicy food",
});
console.log(result.text);
@@ -49,6 +49,7 @@ console.log(result.text);
📖 **[Full Documentation](https://vectorize.io/hindsight/sdks/integrations/ai-sdk)**
The complete documentation includes:
- Detailed tool descriptions and parameters
- Advanced usage patterns (streaming, multi-user, ToolLoopAgent)
- HTTP client example (no dependencies)
+1 -1
View File
@@ -12,4 +12,4 @@ export {
type RetainResponse,
type EntityState,
type ChunkData,
} from './tools';
} from "./tools";
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createHindsightTools, type HindsightClient } from './index.js';
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createHindsightTools, type HindsightClient } from "./index.js";
describe('createHindsightTools', () => {
describe("createHindsightTools", () => {
let mockClient: HindsightClient;
beforeEach(() => {
@@ -12,56 +12,56 @@ describe('createHindsightTools', () => {
};
});
describe('tool creation', () => {
it('should create all tools', () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
describe("tool creation", () => {
it("should create all tools", () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
expect(tools).toHaveProperty('retain');
expect(tools).toHaveProperty('recall');
expect(tools).toHaveProperty('reflect');
expect(tools).toHaveProperty('getMentalModel');
expect(tools).toHaveProperty('getDocument');
expect(typeof tools.retain.execute).toBe('function');
expect(typeof tools.recall.execute).toBe('function');
expect(typeof tools.reflect.execute).toBe('function');
expect(tools).toHaveProperty("retain");
expect(tools).toHaveProperty("recall");
expect(tools).toHaveProperty("reflect");
expect(tools).toHaveProperty("getMentalModel");
expect(tools).toHaveProperty("getDocument");
expect(typeof tools.retain.execute).toBe("function");
expect(typeof tools.recall.execute).toBe("function");
expect(typeof tools.reflect.execute).toBe("function");
});
it('should use default descriptions when not provided', () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
it("should use default descriptions when not provided", () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
expect(tools.retain.description).toContain('Store information in long-term memory');
expect(tools.recall.description).toContain('Search memory for relevant information');
expect(tools.reflect.description).toContain('Analyze memories to form insights');
expect(tools.retain.description).toContain("Store information in long-term memory");
expect(tools.recall.description).toContain("Search memory for relevant information");
expect(tools.reflect.description).toContain("Analyze memories to form insights");
});
it('should use custom descriptions from nested options', () => {
it("should use custom descriptions from nested options", () => {
const tools = createHindsightTools({
client: mockClient,
bankId: 'test-bank',
retain: { description: 'Custom retain description' },
recall: { description: 'Custom recall description' },
reflect: { description: 'Custom reflect description' },
bankId: "test-bank",
retain: { description: "Custom retain description" },
recall: { description: "Custom recall description" },
reflect: { description: "Custom reflect description" },
});
expect(tools.retain.description).toBe('Custom retain description');
expect(tools.recall.description).toBe('Custom recall description');
expect(tools.reflect.description).toBe('Custom reflect description');
expect(tools.retain.description).toBe("Custom retain description");
expect(tools.recall.description).toBe("Custom recall description");
expect(tools.reflect.description).toBe("Custom reflect description");
});
});
describe('retain tool', () => {
it('should call client.retain with agent inputs and constructor defaults', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
describe("retain tool", () => {
it("should call client.retain with agent inputs and constructor defaults", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
vi.mocked(mockClient.retain).mockResolvedValue({
success: true,
bank_id: 'test-bank',
bank_id: "test-bank",
items_count: 5,
async: false,
});
const result = await tools.retain.execute({ content: 'Test content' });
const result = await tools.retain.execute({ content: "Test content" });
expect(mockClient.retain).toHaveBeenCalledWith('test-bank', 'Test content', {
expect(mockClient.retain).toHaveBeenCalledWith("test-bank", "Test content", {
documentId: undefined,
timestamp: undefined,
context: undefined,
@@ -72,299 +72,319 @@ describe('createHindsightTools', () => {
expect(result).toEqual({ success: true, itemsCount: 5 });
});
it('should pass agent-provided optional inputs', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
it("should pass agent-provided optional inputs", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
vi.mocked(mockClient.retain).mockResolvedValue({
success: true,
bank_id: 'test-bank',
bank_id: "test-bank",
items_count: 3,
async: false,
});
await tools.retain.execute({
content: 'Test content',
documentId: 'doc-123',
timestamp: '2024-01-01T00:00:00Z',
context: 'Test context',
content: "Test content",
documentId: "doc-123",
timestamp: "2024-01-01T00:00:00Z",
context: "Test context",
});
expect(mockClient.retain).toHaveBeenCalledWith('test-bank', 'Test content', {
documentId: 'doc-123',
timestamp: '2024-01-01T00:00:00Z',
context: 'Test context',
expect(mockClient.retain).toHaveBeenCalledWith("test-bank", "Test content", {
documentId: "doc-123",
timestamp: "2024-01-01T00:00:00Z",
context: "Test context",
tags: undefined,
metadata: undefined,
async: false,
});
});
it('should apply constructor-level retain options', async () => {
it("should apply constructor-level retain options", async () => {
const tools = createHindsightTools({
client: mockClient,
bankId: 'test-bank',
bankId: "test-bank",
retain: {
async: true,
tags: ['env:prod', 'app:support'],
metadata: { version: '1.0' },
tags: ["env:prod", "app:support"],
metadata: { version: "1.0" },
},
});
vi.mocked(mockClient.retain).mockResolvedValue({
success: true,
bank_id: 'test-bank',
bank_id: "test-bank",
items_count: 1,
async: true,
});
await tools.retain.execute({ content: 'Test content' });
await tools.retain.execute({ content: "Test content" });
expect(mockClient.retain).toHaveBeenCalledWith('test-bank', 'Test content', {
expect(mockClient.retain).toHaveBeenCalledWith("test-bank", "Test content", {
documentId: undefined,
timestamp: undefined,
context: undefined,
tags: ['env:prod', 'app:support'],
metadata: { version: '1.0' },
tags: ["env:prod", "app:support"],
metadata: { version: "1.0" },
async: true,
});
});
});
describe('recall tool', () => {
it('should call client.recall with agent inputs and constructor defaults', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
describe("recall tool", () => {
it("should call client.recall with agent inputs and constructor defaults", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
vi.mocked(mockClient.recall).mockResolvedValue({
results: [{ id: 'fact-1', text: 'Test fact', type: 'preference' }],
results: [{ id: "fact-1", text: "Test fact", type: "preference" }],
});
const result = await tools.recall.execute({ query: 'Test query' });
const result = await tools.recall.execute({ query: "Test query" });
expect(mockClient.recall).toHaveBeenCalledWith('test-bank', 'Test query', {
expect(mockClient.recall).toHaveBeenCalledWith("test-bank", "Test query", {
types: undefined,
maxTokens: undefined,
budget: 'mid',
budget: "mid",
queryTimestamp: undefined,
includeEntities: false,
includeChunks: false,
});
expect(result.results).toHaveLength(1);
expect(result.results[0].id).toBe('fact-1');
expect(result.results[0].id).toBe("fact-1");
});
it('should pass agent-provided queryTimestamp', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
it("should pass agent-provided queryTimestamp", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
vi.mocked(mockClient.recall).mockResolvedValue({ results: [] });
await tools.recall.execute({
query: 'Test query',
queryTimestamp: '2024-01-01T00:00:00Z',
query: "Test query",
queryTimestamp: "2024-01-01T00:00:00Z",
});
expect(mockClient.recall).toHaveBeenCalledWith('test-bank', 'Test query', {
expect(mockClient.recall).toHaveBeenCalledWith("test-bank", "Test query", {
types: undefined,
maxTokens: undefined,
budget: 'mid',
queryTimestamp: '2024-01-01T00:00:00Z',
budget: "mid",
queryTimestamp: "2024-01-01T00:00:00Z",
includeEntities: false,
includeChunks: false,
});
});
it('should apply constructor-level recall options', async () => {
it("should apply constructor-level recall options", async () => {
const tools = createHindsightTools({
client: mockClient,
bankId: 'test-bank',
bankId: "test-bank",
recall: {
types: ['preference', 'fact'],
types: ["preference", "fact"],
maxTokens: 1000,
budget: 'high',
budget: "high",
includeEntities: true,
includeChunks: true,
},
});
vi.mocked(mockClient.recall).mockResolvedValue({ results: [] });
await tools.recall.execute({ query: 'Test query' });
await tools.recall.execute({ query: "Test query" });
expect(mockClient.recall).toHaveBeenCalledWith('test-bank', 'Test query', {
types: ['preference', 'fact'],
expect(mockClient.recall).toHaveBeenCalledWith("test-bank", "Test query", {
types: ["preference", "fact"],
maxTokens: 1000,
budget: 'high',
budget: "high",
queryTimestamp: undefined,
includeEntities: true,
includeChunks: true,
});
});
it('should handle empty results', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
it("should handle empty results", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
vi.mocked(mockClient.recall).mockResolvedValue({ results: undefined as any });
const result = await tools.recall.execute({ query: 'Test query' });
const result = await tools.recall.execute({ query: "Test query" });
expect(result.results).toEqual([]);
});
it('should include entities when present', async () => {
it("should include entities when present", async () => {
const tools = createHindsightTools({
client: mockClient,
bankId: 'test-bank',
bankId: "test-bank",
recall: { includeEntities: true },
});
const entities = {
'entity-1': {
entity_id: 'entity-1',
canonical_name: 'Alice',
observations: [{ text: 'Alice loves hiking' }],
"entity-1": {
entity_id: "entity-1",
canonical_name: "Alice",
observations: [{ text: "Alice loves hiking" }],
},
};
vi.mocked(mockClient.recall).mockResolvedValue({ results: [], entities });
const result = await tools.recall.execute({ query: 'Test query' });
const result = await tools.recall.execute({ query: "Test query" });
expect(result.entities).toEqual(entities);
});
});
describe('reflect tool', () => {
it('should call client.reflect with agent inputs and constructor defaults', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
describe("reflect tool", () => {
it("should call client.reflect with agent inputs and constructor defaults", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
vi.mocked(mockClient.reflect).mockResolvedValue({
text: 'Reflection result',
based_on: [{ id: 'fact-1', text: 'Supporting fact' }],
text: "Reflection result",
based_on: [{ id: "fact-1", text: "Supporting fact" }],
});
const result = await tools.reflect.execute({ query: 'What are my preferences?' });
const result = await tools.reflect.execute({ query: "What are my preferences?" });
expect(mockClient.reflect).toHaveBeenCalledWith('test-bank', 'What are my preferences?', {
expect(mockClient.reflect).toHaveBeenCalledWith("test-bank", "What are my preferences?", {
context: undefined,
budget: 'mid',
budget: "mid",
});
expect(result.text).toBe('Reflection result');
expect(result.text).toBe("Reflection result");
expect(result.basedOn).toHaveLength(1);
});
it('should pass agent-provided context', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.reflect).mockResolvedValue({ text: 'Reflection result' });
it("should pass agent-provided context", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
vi.mocked(mockClient.reflect).mockResolvedValue({ text: "Reflection result" });
await tools.reflect.execute({
query: 'What are my preferences?',
context: 'User context',
query: "What are my preferences?",
context: "User context",
});
expect(mockClient.reflect).toHaveBeenCalledWith('test-bank', 'What are my preferences?', {
context: 'User context',
budget: 'mid',
expect(mockClient.reflect).toHaveBeenCalledWith("test-bank", "What are my preferences?", {
context: "User context",
budget: "mid",
});
});
it('should apply constructor-level reflect budget', async () => {
it("should apply constructor-level reflect budget", async () => {
const tools = createHindsightTools({
client: mockClient,
bankId: 'test-bank',
reflect: { budget: 'low' },
bankId: "test-bank",
reflect: { budget: "low" },
});
vi.mocked(mockClient.reflect).mockResolvedValue({ text: 'Reflection result' });
vi.mocked(mockClient.reflect).mockResolvedValue({ text: "Reflection result" });
await tools.reflect.execute({ query: 'Test query' });
await tools.reflect.execute({ query: "Test query" });
expect(mockClient.reflect).toHaveBeenCalledWith('test-bank', 'Test query', {
expect(mockClient.reflect).toHaveBeenCalledWith("test-bank", "Test query", {
context: undefined,
budget: 'low',
budget: "low",
});
});
it('should handle empty text response with fallback', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
it("should handle empty text response with fallback", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
vi.mocked(mockClient.reflect).mockResolvedValue({ text: undefined as any });
const result = await tools.reflect.execute({ query: 'Test query' });
const result = await tools.reflect.execute({ query: "Test query" });
expect(result.text).toBe('No insights available yet.');
expect(result.text).toBe("No insights available yet.");
});
it('should include basedOn facts when present', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
it("should include basedOn facts when present", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
const basedOn = [
{ id: 'fact-1', text: 'User prefers spicy food', type: 'preference' },
{ id: 'fact-2', text: 'User is allergic to nuts', type: 'health' },
{ id: "fact-1", text: "User prefers spicy food", type: "preference" },
{ id: "fact-2", text: "User is allergic to nuts", type: "health" },
];
vi.mocked(mockClient.reflect).mockResolvedValue({
text: 'Based on your history, you prefer spicy Asian cuisine',
text: "Based on your history, you prefer spicy Asian cuisine",
based_on: basedOn,
});
const result = await tools.reflect.execute({ query: 'What do I like?' });
const result = await tools.reflect.execute({ query: "What do I like?" });
expect(result.basedOn).toEqual(basedOn);
});
});
describe('error handling', () => {
it('should propagate errors from client.retain', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.retain).mockRejectedValue(new Error('Retain failed'));
describe("error handling", () => {
it("should propagate errors from client.retain", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
vi.mocked(mockClient.retain).mockRejectedValue(new Error("Retain failed"));
await expect(tools.retain.execute({ content: 'Test content' })).rejects.toThrow('Retain failed');
await expect(tools.retain.execute({ content: "Test content" })).rejects.toThrow(
"Retain failed"
);
});
it('should propagate errors from client.recall', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.recall).mockRejectedValue(new Error('Recall failed'));
it("should propagate errors from client.recall", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
vi.mocked(mockClient.recall).mockRejectedValue(new Error("Recall failed"));
await expect(tools.recall.execute({ query: 'Test query' })).rejects.toThrow('Recall failed');
await expect(tools.recall.execute({ query: "Test query" })).rejects.toThrow("Recall failed");
});
it('should propagate errors from client.reflect', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.reflect).mockRejectedValue(new Error('Reflect failed'));
it("should propagate errors from client.reflect", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
vi.mocked(mockClient.reflect).mockRejectedValue(new Error("Reflect failed"));
await expect(tools.reflect.execute({ query: 'Test query' })).rejects.toThrow('Reflect failed');
await expect(tools.reflect.execute({ query: "Test query" })).rejects.toThrow(
"Reflect failed"
);
});
});
describe('budget defaults', () => {
it('should default recall budget to mid', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
describe("budget defaults", () => {
it("should default recall budget to mid", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
vi.mocked(mockClient.recall).mockResolvedValue({ results: [] });
await tools.recall.execute({ query: 'Test' });
await tools.recall.execute({ query: "Test" });
expect(mockClient.recall).toHaveBeenCalledWith('test-bank', 'Test', expect.objectContaining({ budget: 'mid' }));
expect(mockClient.recall).toHaveBeenCalledWith(
"test-bank",
"Test",
expect.objectContaining({ budget: "mid" })
);
});
it('should default reflect budget to mid', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.reflect).mockResolvedValue({ text: 'ok' });
it("should default reflect budget to mid", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "test-bank" });
vi.mocked(mockClient.reflect).mockResolvedValue({ text: "ok" });
await tools.reflect.execute({ query: 'Test' });
await tools.reflect.execute({ query: "Test" });
expect(mockClient.reflect).toHaveBeenCalledWith('test-bank', 'Test', expect.objectContaining({ budget: 'mid' }));
expect(mockClient.reflect).toHaveBeenCalledWith(
"test-bank",
"Test",
expect.objectContaining({ budget: "mid" })
);
});
it('should accept low/mid/high budget values', async () => {
it("should accept low/mid/high budget values", async () => {
vi.mocked(mockClient.recall).mockResolvedValue({ results: [] });
for (const budget of ['low', 'mid', 'high'] as const) {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank', recall: { budget } });
await tools.recall.execute({ query: 'Test' });
expect(mockClient.recall).toHaveBeenCalledWith('test-bank', 'Test', expect.objectContaining({ budget }));
for (const budget of ["low", "mid", "high"] as const) {
const tools = createHindsightTools({
client: mockClient,
bankId: "test-bank",
recall: { budget },
});
await tools.recall.execute({ query: "Test" });
expect(mockClient.recall).toHaveBeenCalledWith(
"test-bank",
"Test",
expect.objectContaining({ budget })
);
}
});
});
describe('bankId enforcement', () => {
it('should always use the bankId from constructor options', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'forced-bank' });
describe("bankId enforcement", () => {
it("should always use the bankId from constructor options", async () => {
const tools = createHindsightTools({ client: mockClient, bankId: "forced-bank" });
vi.mocked(mockClient.retain).mockResolvedValue({
success: true,
bank_id: 'forced-bank',
bank_id: "forced-bank",
items_count: 1,
async: false,
});
await tools.retain.execute({ content: 'Test' });
await tools.retain.execute({ content: "Test" });
expect(mockClient.retain).toHaveBeenCalledWith('forced-bank', 'Test', expect.anything());
expect(mockClient.retain).toHaveBeenCalledWith("forced-bank", "Test", expect.anything());
});
});
});
@@ -1,16 +1,16 @@
import { tool } from 'ai';
import { z } from 'zod';
import { tool } from "ai";
import { z } from "zod";
/**
* Budget levels for recall/reflect operations.
*/
export const BudgetSchema = z.enum(['low', 'mid', 'high']);
export const BudgetSchema = z.enum(["low", "mid", "high"]);
export type Budget = z.infer<typeof BudgetSchema>;
/**
* Fact types for filtering recall results.
*/
export const FactTypeSchema = z.enum(['world', 'experience', 'observation']);
export const FactTypeSchema = z.enum(["world", "experience", "observation"]);
export type FactType = z.infer<typeof FactTypeSchema>;
/**
@@ -168,15 +168,9 @@ export interface HindsightClient {
}
): Promise<ReflectResponse>;
getMentalModel(
bankId: string,
mentalModelId: string
): Promise<MentalModelResponse>;
getMentalModel(bankId: string, mentalModelId: string): Promise<MentalModelResponse>;
getDocument(
bankId: string,
documentId: string
): Promise<DocumentResponse | null>;
getDocument(bankId: string, documentId: string): Promise<DocumentResponse | null>;
}
export interface HindsightToolsOptions {
@@ -270,30 +264,39 @@ export function createHindsightTools({
}: HindsightToolsOptions) {
// Agent-controlled params only: content, timestamp, documentId, context
const retainParams = z.object({
content: z.string().describe('Content to store in memory'),
documentId: z.string().optional().describe('Optional document ID for grouping/upserting content'),
timestamp: z.string().optional().describe('Optional ISO timestamp for when the memory occurred'),
context: z.string().optional().describe('Optional context about the memory'),
content: z.string().describe("Content to store in memory"),
documentId: z
.string()
.optional()
.describe("Optional document ID for grouping/upserting content"),
timestamp: z
.string()
.optional()
.describe("Optional ISO timestamp for when the memory occurred"),
context: z.string().optional().describe("Optional context about the memory"),
});
// Agent-controlled params only: query, queryTimestamp
const recallParams = z.object({
query: z.string().describe('What to search for in memory'),
queryTimestamp: z.string().optional().describe('Query from a specific point in time (ISO format)'),
query: z.string().describe("What to search for in memory"),
queryTimestamp: z
.string()
.optional()
.describe("Query from a specific point in time (ISO format)"),
});
// Agent-controlled params only: query, context
const reflectParams = z.object({
query: z.string().describe('Question to reflect on based on memories'),
context: z.string().optional().describe('Additional context for the reflection'),
query: z.string().describe("Question to reflect on based on memories"),
context: z.string().optional().describe("Additional context for the reflection"),
});
const getMentalModelParams = z.object({
mentalModelId: z.string().describe('ID of the mental model to retrieve'),
mentalModelId: z.string().describe("ID of the mental model to retrieve"),
});
const getDocumentParams = z.object({
documentId: z.string().describe('ID of the document to retrieve'),
documentId: z.string().describe("ID of the document to retrieve"),
});
type RetainInput = z.infer<typeof retainParams>;
@@ -309,7 +312,12 @@ export function createHindsightTools({
type GetMentalModelOutput = { content: string; name?: string; updatedAt: string };
type GetDocumentInput = z.infer<typeof getDocumentParams>;
type GetDocumentOutput = { originalText: string; id: string; createdAt: string; updatedAt: string } | null;
type GetDocumentOutput = {
originalText: string;
id: string;
createdAt: string;
updatedAt: string;
} | null;
return {
retain: tool<RetainInput, RetainOutput>({
@@ -318,7 +326,7 @@ export function createHindsightTools({
`Store information in long-term memory. Use this when information should be remembered for future interactions, such as user preferences, facts, experiences, or important context.`,
inputSchema: retainParams,
execute: async (input) => {
console.log('[AI SDK Tool] Retain input:', {
console.log("[AI SDK Tool] Retain input:", {
bankId,
documentId: input.documentId,
hasContent: !!input.content,
@@ -344,7 +352,7 @@ export function createHindsightTools({
const result = await client.recall(bankId, input.query, {
types: recallOpts.types,
maxTokens: recallOpts.maxTokens,
budget: recallOpts.budget ?? 'mid',
budget: recallOpts.budget ?? "mid",
queryTimestamp: input.queryTimestamp,
includeEntities: recallOpts.includeEntities ?? false,
includeChunks: recallOpts.includeChunks ?? false,
@@ -364,11 +372,11 @@ export function createHindsightTools({
execute: async (input) => {
const result = await client.reflect(bankId, input.query, {
context: input.context,
budget: reflectOpts.budget ?? 'mid',
budget: reflectOpts.budget ?? "mid",
maxTokens: reflectOpts.maxTokens,
});
return {
text: result.text ?? 'No insights available yet.',
text: result.text ?? "No insights available yet.",
basedOn: result.based_on,
};
},
@@ -382,7 +390,7 @@ export function createHindsightTools({
execute: async (input) => {
const result = await client.getMentalModel(bankId, input.mentalModelId);
return {
content: result.content ?? 'No content available yet.',
content: result.content ?? "No content available yet.",
name: result.name,
updatedAt: result.updated_at,
};
@@ -407,7 +415,6 @@ export function createHindsightTools({
};
},
}),
};
}
@@ -13,70 +13,91 @@ import { expect } from "jsr:@std/expect";
const MOCK_SYMBOL = Symbol.for("@MOCK");
type MockCall = {
args: unknown[];
returned?: unknown;
thrown?: unknown;
timestamp: number;
returns: boolean;
throws: boolean;
args: unknown[];
returned?: unknown;
thrown?: unknown;
timestamp: number;
returns: boolean;
throws: boolean;
};
function createMock(impl?: (...args: unknown[]) => unknown) {
let currentImpl = impl;
const calls: MockCall[] = [];
const mockInfo = { calls };
let currentImpl = impl;
const calls: MockCall[] = [];
const mockInfo = { calls };
const mockFn = function (this: unknown, ...args: unknown[]) {
const call: MockCall = {
args,
timestamp: Date.now(),
returns: false,
throws: false,
};
calls.push(call);
try {
const result = currentImpl ? currentImpl.apply(this, args) : undefined;
call.returned = result;
call.returns = true;
return result;
} catch (err) {
call.thrown = err;
call.throws = true;
throw err;
}
const mockFn = function (this: unknown, ...args: unknown[]) {
const call: MockCall = {
args,
timestamp: Date.now(),
returns: false,
throws: false,
};
calls.push(call);
try {
const result = currentImpl ? currentImpl.apply(this, args) : undefined;
call.returned = result;
call.returns = true;
return result;
} catch (err) {
call.thrown = err;
call.throws = true;
throw err;
}
};
// @std/expect mock interface — required for toHaveBeenCalledWith etc.
(mockFn as any)[MOCK_SYMBOL] = mockInfo;
// vitest-style chainable methods
(mockFn as any).mockReturnValue = (val: unknown) => { currentImpl = () => val; return mockFn; };
(mockFn as any).mockResolvedValue = (val: unknown) => { currentImpl = () => Promise.resolve(val); return mockFn; };
(mockFn as any).mockRejectedValue = (val: unknown) => { currentImpl = () => Promise.reject(val); return mockFn; };
(mockFn as any).mockImplementation = (fn: (...args: unknown[]) => unknown) => { currentImpl = fn; return mockFn; };
(mockFn as any).mockReset = () => { calls.length = 0; currentImpl = undefined; return mockFn; };
(mockFn as any).mockClear = () => { calls.length = 0; return mockFn; };
(mockFn as any).mockRestore = () => {};
// @std/expect mock interface — required for toHaveBeenCalledWith etc.
(mockFn as any)[MOCK_SYMBOL] = mockInfo;
// vitest-style chainable methods
(mockFn as any).mockReturnValue = (val: unknown) => {
currentImpl = () => val;
return mockFn;
};
(mockFn as any).mockResolvedValue = (val: unknown) => {
currentImpl = () => Promise.resolve(val);
return mockFn;
};
(mockFn as any).mockRejectedValue = (val: unknown) => {
currentImpl = () => Promise.reject(val);
return mockFn;
};
(mockFn as any).mockImplementation = (fn: (...args: unknown[]) => unknown) => {
currentImpl = fn;
return mockFn;
};
(mockFn as any).mockReset = () => {
calls.length = 0;
currentImpl = undefined;
return mockFn;
};
(mockFn as any).mockClear = () => {
calls.length = 0;
return mockFn;
};
(mockFn as any).mockRestore = () => {};
return mockFn;
}
export const vi = {
fn: (impl?: (...args: unknown[]) => unknown) => createMock(impl),
fn: (impl?: (...args: unknown[]) => unknown) => createMock(impl),
// vi.mocked() is a TypeScript type cast — just return the same value at runtime
mocked: <T>(fn: T): T => fn,
// vi.mocked() is a TypeScript type cast — just return the same value at runtime
mocked: <T>(fn: T): T => fn,
spyOn: <T extends Record<string, unknown>>(obj: T, method: keyof T) => {
const original = obj[method];
const mock = createMock(typeof original === "function" ? (original as (...args: unknown[]) => unknown) : undefined);
const restore = () => {
obj[method] = original;
};
(mock as any).mockRestore = restore;
obj[method] = mock as unknown as T[keyof T];
return mock;
},
spyOn: <T extends Record<string, unknown>>(obj: T, method: keyof T) => {
const original = obj[method];
const mock = createMock(
typeof original === "function" ? (original as (...args: unknown[]) => unknown) : undefined
);
const restore = () => {
obj[method] = original;
};
(mock as any).mockRestore = restore;
obj[method] = mock as unknown as T[keyof T];
return mock;
},
};
export { describe, it, it as test, beforeAll, beforeEach, afterAll, afterEach, expect };
+8 -8
View File
@@ -1,11 +1,11 @@
import { defineConfig } from 'tsup';
import { defineConfig } from "tsup";
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
outDir: 'dist',
clean: true,
sourcemap: true,
bundle: true,
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
outDir: "dist",
clean: true,
sourcemap: true,
bundle: true,
});
@@ -1,9 +1,9 @@
import { defineConfig } from 'vitest/config';
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
environment: "node",
include: ["src/**/*.test.ts"],
},
});
+43 -41
View File
@@ -9,13 +9,17 @@ 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';
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 chat = new Chat({
connectors: [
/* your connectors */
],
});
const hindsight = new HindsightClient({ apiKey: process.env.HINDSIGHT_API_KEY });
chat.onNewMention(
@@ -28,9 +32,9 @@ chat.onNewMention(
await thread.subscribe();
const result = await streamText({
model: openai('gpt-4o'),
model: openai("gpt-4o"),
system: ctx.memoriesAsSystemPrompt(),
messages: [{ role: 'user', content: message.text }],
messages: [{ role: "user", content: message.text }],
});
// Stream the response
@@ -38,13 +42,11 @@ chat.onNewMention(
for await (const chunk of result.textStream) {
chunks.push(chunk);
}
const fullResponse = chunks.join('');
const fullResponse = chunks.join("");
await thread.post(fullResponse);
// Store the conversation in memory
await ctx.retain(
`User: ${message.text}\nAssistant: ${fullResponse}`
);
await ctx.retain(`User: ${message.text}\nAssistant: ${fullResponse}`);
}
)
);
@@ -58,33 +60,33 @@ 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 |
| 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) |
| 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 |
| `ctx.retain(content, options?)` | Store content in memory |
| `ctx.recall(query, options?)` | Search memories |
| `ctx.reflect(query, options?)` | Reason over memories |
## Examples
@@ -96,13 +98,13 @@ chat.onSubscribedMessage(
{
client: hindsight,
bankId: (msg) => msg.author.userId,
recall: { budget: 'high', maxTokens: 1000 },
recall: { budget: "high", maxTokens: 1000 },
},
async (thread, message, ctx) => {
const result = await generateText({
model: openai('gpt-4o'),
model: openai("gpt-4o"),
system: ctx.memoriesAsSystemPrompt(),
messages: [{ role: 'user', content: message.text }],
messages: [{ role: "user", content: message.text }],
});
await thread.post(result.text);
}
@@ -118,20 +120,20 @@ chat.onNewMention(
{
client: hindsight,
bankId: (msg) => msg.author.userId,
retain: { enabled: true, tags: ['slack', 'inbound'] },
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'),
model: openai("gpt-4o"),
system: ctx.memoriesAsSystemPrompt(),
messages: [{ role: 'user', content: message.text }],
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'],
tags: ["slack", "outbound"],
});
}
)
@@ -144,7 +146,7 @@ chat.onNewMention(
// All users share the same memory bank
chat.onNewMention(
withHindsightChat(
{ client: hindsight, bankId: 'shared-team-memory' },
{ client: hindsight, bankId: "shared-team-memory" },
async (thread, message, ctx) => {
// ...
}
+66 -78
View File
@@ -1,127 +1,115 @@
import { describe, it, expect } from 'vitest';
import { formatMemoriesAsSystemPrompt } from './format.js';
import type { RecallResult, EntityState } from './types.js';
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',
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',
"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' },
{ 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('');
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', () => {
it("formats memories with default preamble", () => {
const result = formatMemoriesAsSystemPrompt(
[makeMemory(), makeMemory({ id: 'mem-2', text: 'Likes coffee', type: 'world' })],
[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>');
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("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()
);
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>');
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', () => {
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');
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("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', () => {
it("limits memories with maxMemories", () => {
const memories = [
makeMemory({ id: '1', text: 'First' }),
makeMemory({ id: '2', text: 'Second' }),
makeMemory({ id: '3', text: 'Third' }),
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');
expect(result).toContain("First");
expect(result).toContain("Second");
expect(result).not.toContain("Third");
});
it('filters by includeTypes', () => {
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' }),
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'],
includeTypes: ["world", "observation"],
});
expect(result).toContain('World fact');
expect(result).not.toContain('Experience');
expect(result).toContain('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>');
it("excludes entities when includeEntities is false", () => {
const result = formatMemoriesAsSystemPrompt([makeMemory()], makeEntities(), {
includeEntities: false,
});
expect(result).toContain("<memories>");
expect(result).not.toContain("<entity_observations>");
});
});
+12 -14
View File
@@ -1,7 +1,7 @@
import type { RecallResult, EntityState, MemoryPromptOptions } from './types.js';
import type { RecallResult, EntityState, MemoryPromptOptions } from "./types.js";
const DEFAULT_PREAMBLE =
'You have access to the following memories about this user from previous interactions:';
"You have access to the following memories about this user from previous interactions:";
/**
* Formats recalled memories and entity observations into a system prompt string.
@@ -24,9 +24,7 @@ export function formatMemoriesAsSystemPrompt(
let filtered = memories;
if (includeTypes && includeTypes.length > 0) {
filtered = filtered.filter(
(m) => m.type != null && includeTypes.includes(m.type as never)
);
filtered = filtered.filter((m) => m.type != null && includeTypes.includes(m.type as never));
}
if (maxMemories != null && maxMemories > 0) {
@@ -38,31 +36,31 @@ export function formatMemoriesAsSystemPrompt(
const hasEntities = includeEntities && entityEntries.length > 0;
if (!hasMemories && !hasEntities) {
return '';
return "";
}
const parts: string[] = [preamble, ''];
const parts: string[] = [preamble, ""];
if (hasMemories) {
parts.push('<memories>');
parts.push("<memories>");
for (const memory of filtered) {
const typeSuffix = memory.type ? ` [${memory.type}]` : '';
const typeSuffix = memory.type ? ` [${memory.type}]` : "";
parts.push(`- ${memory.text}${typeSuffix}`);
}
parts.push('</memories>');
parts.push("</memories>");
}
if (hasEntities) {
if (hasMemories) parts.push('');
parts.push('<entity_observations>');
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>');
parts.push("</entity_observations>");
}
return parts.join('\n');
return parts.join("\n");
}
+3 -3
View File
@@ -1,5 +1,5 @@
export { withHindsightChat } from './wrapper.js';
export { formatMemoriesAsSystemPrompt } from './format.js';
export { withHindsightChat } from "./wrapper.js";
export { formatMemoriesAsSystemPrompt } from "./format.js";
export type {
Budget,
FactType,
@@ -19,4 +19,4 @@ export type {
HindsightChatOptions,
HindsightChatContext,
HindsightChatHandler,
} from './types.js';
} from "./types.js";
+2 -2
View File
@@ -1,12 +1,12 @@
/**
* Budget levels for recall/reflect operations.
*/
export type Budget = 'low' | 'mid' | 'high';
export type Budget = "low" | "mid" | "high";
/**
* Fact types for filtering recall results.
*/
export type FactType = 'world' | 'experience' | 'observation';
export type FactType = "world" | "experience" | "observation";
/**
* Recall result item from Hindsight.
+94 -119
View File
@@ -1,11 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { withHindsightChat } from './wrapper.js';
import type {
HindsightClient,
ChatThread,
ChatMessage,
HindsightChatContext,
} from './types.js';
import { describe, it, expect, vi, beforeEach } from "vitest";
import { withHindsightChat } from "./wrapper.js";
import type { HindsightClient, ChatThread, ChatMessage, HindsightChatContext } from "./types.js";
// --- Mocks ---
@@ -13,24 +8,22 @@ function mockClient(overrides?: Partial<HindsightClient>): HindsightClient {
return {
retain: vi.fn().mockResolvedValue({
success: true,
bank_id: 'test-bank',
bank_id: "test-bank",
items_count: 1,
async: false,
}),
recall: vi.fn().mockResolvedValue({
results: [
{ id: 'mem-1', text: 'User likes TypeScript', type: 'experience' },
],
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' }],
"ent-1": {
entity_id: "ent-1",
canonical_name: "User",
observations: [{ text: "Prefers dark mode" }],
},
},
}),
reflect: vi.fn().mockResolvedValue({
text: 'User is a TypeScript developer',
text: "User is a TypeScript developer",
based_on: [],
}),
...overrides,
@@ -51,14 +44,14 @@ function mockThread(): ChatThread {
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',
author: { userId: "user-123", name: "Alice", isMe: false },
text: "What do you know about me?",
threadId: "thread-1",
...overrides,
};
}
describe('withHindsightChat', () => {
describe("withHindsightChat", () => {
let client: HindsightClient;
let thread: ChatThread;
let message: ChatMessage;
@@ -69,29 +62,22 @@ describe('withHindsightChat', () => {
message = mockMessage();
});
describe('bankId resolution', () => {
it('uses static bankId', async () => {
describe("bankId resolution", () => {
it("uses static bankId", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'static-bank' },
handler
);
const wrapped = withHindsightChat({ client, bankId: "static-bank" }, handler);
await wrapped(thread, message);
expect(client.recall).toHaveBeenCalledWith(
'static-bank',
message.text,
expect.any(Object)
);
expect(client.recall).toHaveBeenCalledWith("static-bank", message.text, expect.any(Object));
expect(handler).toHaveBeenCalledWith(
thread,
message,
expect.objectContaining({ bankId: 'static-bank' })
expect.objectContaining({ bankId: "static-bank" })
);
});
it('uses dynamic bankId from message', async () => {
it("uses dynamic bankId from message", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: (msg) => `bank-${msg.author.userId}` },
@@ -100,29 +86,25 @@ describe('withHindsightChat', () => {
await wrapped(thread, message);
expect(client.recall).toHaveBeenCalledWith(
'bank-user-123',
message.text,
expect.any(Object)
);
expect(client.recall).toHaveBeenCalledWith("bank-user-123", message.text, expect.any(Object));
expect(handler).toHaveBeenCalledWith(
thread,
message,
expect.objectContaining({ bankId: 'bank-user-123' })
expect.objectContaining({ bankId: "bank-user-123" })
);
});
});
describe('auto-recall', () => {
it('recalls by default', async () => {
describe("auto-recall", () => {
it("recalls by default", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
const wrapped = withHindsightChat({ client, bankId: "bank" }, handler);
await wrapped(thread, message);
expect(client.recall).toHaveBeenCalledOnce();
expect(client.recall).toHaveBeenCalledWith('bank', message.text, {
budget: 'mid',
expect(client.recall).toHaveBeenCalledWith("bank", message.text, {
budget: "mid",
maxTokens: undefined,
types: undefined,
includeEntities: true,
@@ -130,14 +112,14 @@ describe('withHindsightChat', () => {
const ctx: HindsightChatContext = handler.mock.calls[0][2];
expect(ctx.memories).toHaveLength(1);
expect(ctx.memories[0].text).toBe('User likes TypeScript');
expect(ctx.memories[0].text).toBe("User likes TypeScript");
expect(ctx.entities).not.toBeNull();
});
it('can be disabled', async () => {
it("can be disabled", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', recall: { enabled: false } },
{ client, bankId: "bank", recall: { enabled: false } },
handler
);
@@ -150,16 +132,16 @@ describe('withHindsightChat', () => {
expect(ctx.entities).toBeNull();
});
it('passes recall options through', async () => {
it("passes recall options through", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{
client,
bankId: 'bank',
bankId: "bank",
recall: {
budget: 'high',
budget: "high",
maxTokens: 500,
types: ['experience'],
types: ["experience"],
includeEntities: false,
},
},
@@ -168,35 +150,35 @@ describe('withHindsightChat', () => {
await wrapped(thread, message);
expect(client.recall).toHaveBeenCalledWith('bank', message.text, {
budget: 'high',
expect(client.recall).toHaveBeenCalledWith("bank", message.text, {
budget: "high",
maxTokens: 500,
types: ['experience'],
types: ["experience"],
includeEntities: false,
});
});
it('skips recall for empty message text', async () => {
it("skips recall for empty message text", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
const wrapped = withHindsightChat({ client, bankId: "bank" }, handler);
await wrapped(thread, mockMessage({ text: '' }));
await wrapped(thread, mockMessage({ text: "" }));
expect(client.recall).not.toHaveBeenCalled();
});
it('handles recall errors gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it("handles recall errors gracefully", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
client = mockClient({
recall: vi.fn().mockRejectedValue(new Error('Network error')),
recall: vi.fn().mockRejectedValue(new Error("Network error")),
});
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
const wrapped = withHindsightChat({ client, bankId: "bank" }, handler);
await wrapped(thread, message);
expect(warnSpy).toHaveBeenCalledWith(
'[hindsight-chat] Auto-recall failed:',
"[hindsight-chat] Auto-recall failed:",
expect.any(Error)
);
// Handler still runs with empty memories
@@ -206,42 +188,42 @@ describe('withHindsightChat', () => {
});
});
describe('auto-retain', () => {
it('does not retain by default', async () => {
describe("auto-retain", () => {
it("does not retain by default", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
const wrapped = withHindsightChat({ client, bankId: "bank" }, handler);
await wrapped(thread, message);
expect(client.retain).not.toHaveBeenCalled();
});
it('retains when enabled', async () => {
it("retains when enabled", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', retain: { enabled: true } },
{ client, bankId: "bank", retain: { enabled: true } },
handler
);
await wrapped(thread, message);
expect(client.retain).toHaveBeenCalledWith('bank', message.text, {
expect(client.retain).toHaveBeenCalledWith("bank", message.text, {
tags: undefined,
metadata: undefined,
async: true,
});
});
it('passes retain options through', async () => {
it("passes retain options through", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{
client,
bankId: 'bank',
bankId: "bank",
retain: {
enabled: true,
tags: ['slack'],
metadata: { source: 'chat' },
tags: ["slack"],
metadata: { source: "chat" },
async: false,
},
},
@@ -250,48 +232,45 @@ describe('withHindsightChat', () => {
await wrapped(thread, message);
expect(client.retain).toHaveBeenCalledWith('bank', message.text, {
tags: ['slack'],
metadata: { source: 'chat' },
expect(client.retain).toHaveBeenCalledWith("bank", message.text, {
tags: ["slack"],
metadata: { source: "chat" },
async: false,
});
});
it('skips retain for bot messages (isMe)', async () => {
it("skips retain for bot messages (isMe)", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', retain: { enabled: true } },
{ client, bankId: "bank", retain: { enabled: true } },
handler
);
await wrapped(
thread,
mockMessage({ author: { userId: 'bot', isMe: true } })
);
await wrapped(thread, mockMessage({ author: { userId: "bot", isMe: true } }));
expect(client.retain).not.toHaveBeenCalled();
});
it('skips retain for empty text', async () => {
it("skips retain for empty text", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', retain: { enabled: true } },
{ client, bankId: "bank", retain: { enabled: true } },
handler
);
await wrapped(thread, mockMessage({ text: '' }));
await wrapped(thread, mockMessage({ text: "" }));
expect(client.retain).not.toHaveBeenCalled();
});
it('handles retain errors gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it("handles retain errors gracefully", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
client = mockClient({
retain: vi.fn().mockRejectedValue(new Error('Retain failed')),
retain: vi.fn().mockRejectedValue(new Error("Retain failed")),
});
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', retain: { enabled: true } },
{ client, bankId: "bank", retain: { enabled: true } },
handler
);
@@ -299,7 +278,7 @@ describe('withHindsightChat', () => {
await wrapped(thread, message);
expect(warnSpy).toHaveBeenCalledWith(
'[hindsight-chat] Auto-retain failed:',
"[hindsight-chat] Auto-retain failed:",
expect.any(Error)
);
// Handler still runs
@@ -308,84 +287,80 @@ describe('withHindsightChat', () => {
});
});
describe('context methods', () => {
it('memoriesAsSystemPrompt() formats recalled memories', async () => {
describe("context methods", () => {
it("memoriesAsSystemPrompt() formats recalled memories", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
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>');
expect(prompt).toContain("<memories>");
expect(prompt).toContain("User likes TypeScript");
expect(prompt).toContain("<entity_observations>");
});
it('ctx.retain() delegates to client', async () => {
it("ctx.retain() delegates to client", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
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'] });
await ctx.retain("New memory content", { tags: ["test"] });
expect(client.retain).toHaveBeenCalledWith('bank', 'New memory content', {
tags: ['test'],
expect(client.retain).toHaveBeenCalledWith("bank", "New memory content", {
tags: ["test"],
});
});
it('ctx.recall() delegates to client', async () => {
it("ctx.recall() delegates to client", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
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' });
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',
expect(client.recall).toHaveBeenLastCalledWith("bank", "search query", {
budget: "high",
});
});
it('ctx.reflect() delegates to client', async () => {
it("ctx.reflect() delegates to client", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
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?');
await ctx.reflect("What does the user prefer?");
expect(client.reflect).toHaveBeenCalledWith(
'bank',
'What does the user prefer?',
undefined
);
expect(client.reflect).toHaveBeenCalledWith("bank", "What does the user prefer?", undefined);
});
});
describe('handler invocation', () => {
it('passes thread and message through', async () => {
describe("handler invocation", () => {
it("passes thread and message through", async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
const wrapped = withHindsightChat({ client, bankId: "bank" }, handler);
await wrapped(thread, message);
expect(handler).toHaveBeenCalledWith(thread, message, expect.any(Object));
});
it('awaits async handlers', async () => {
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);
const wrapped = withHindsightChat({ client, bankId: "bank" }, handler);
await wrapped(thread, message);
+12 -7
View File
@@ -1,4 +1,4 @@
import { formatMemoriesAsSystemPrompt } from './format.js';
import { formatMemoriesAsSystemPrompt } from "./format.js";
import type {
HindsightChatOptions,
HindsightChatContext,
@@ -8,7 +8,7 @@ import type {
RecallResult,
EntityState,
MemoryPromptOptions,
} from './types.js';
} from "./types.js";
/**
* Wraps a Chat SDK handler to automatically provide Hindsight memory context.
@@ -42,7 +42,12 @@ 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 {
client,
bankId: bankIdResolver,
recall: recallOpts = {},
retain: retainOpts = {},
} = options;
const recallEnabled = recallOpts.enabled !== false;
const retainEnabled = retainOpts.enabled === true;
@@ -51,7 +56,7 @@ export function withHindsightChat<TState = unknown>(
return async (thread: ChatThread<TState>, message: ChatMessage): Promise<void> => {
// 1. Resolve bank ID
const resolvedBankId =
typeof bankIdResolver === 'function' ? bankIdResolver(message) : bankIdResolver;
typeof bankIdResolver === "function" ? bankIdResolver(message) : bankIdResolver;
// 2. Auto-retain inbound message (fire-and-forget if async)
if (retainEnabled && message.text && !message.author.isMe) {
@@ -62,7 +67,7 @@ export function withHindsightChat<TState = unknown>(
async: retainAsync,
})
.catch((err) => {
console.warn('[hindsight-chat] Auto-retain failed:', err);
console.warn("[hindsight-chat] Auto-retain failed:", err);
});
// If not async, wait for retain to complete before proceeding
@@ -78,7 +83,7 @@ export function withHindsightChat<TState = unknown>(
if (recallEnabled && message.text) {
try {
const recallResponse = await client.recall(resolvedBankId, message.text, {
budget: recallOpts.budget ?? 'mid',
budget: recallOpts.budget ?? "mid",
maxTokens: recallOpts.maxTokens,
types: recallOpts.types,
includeEntities: recallOpts.includeEntities !== false,
@@ -86,7 +91,7 @@ export function withHindsightChat<TState = unknown>(
memories = recallResponse.results ?? [];
entities = recallResponse.entities ?? null;
} catch (err) {
console.warn('[hindsight-chat] Auto-recall failed:', err);
console.warn("[hindsight-chat] Auto-recall failed:", err);
}
}
+8 -8
View File
@@ -1,11 +1,11 @@
import { defineConfig } from 'tsup';
import { defineConfig } from "tsup";
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
outDir: 'dist',
clean: true,
sourcemap: true,
bundle: true,
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
outDir: "dist",
clean: true,
sourcemap: true,
bundle: true,
});
+3 -3
View File
@@ -1,9 +1,9 @@
import { defineConfig } from 'vitest/config';
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
environment: "node",
include: ["src/**/*.test.ts"],
},
});
@@ -79,12 +79,12 @@ On first connection, you'll be redirected to a login page. Enter the `SESSION_SE
## Secrets reference
| Secret | Purpose |
|--------|---------|
| `SESSION_SECRET` | Password shown on the login page to authorize a session |
| `PROXY_SECRET` | Value sent as `X-Proxy-Secret` header to the origin (for WAF validation) |
| `HINDSIGHT_API_TOKEN` | Bearer token for authenticating with the Hindsight API |
| `ALLOWED_EMAIL` | Your email address, used as the OAuth user identity |
| Secret | Purpose |
| --------------------- | ------------------------------------------------------------------------ |
| `SESSION_SECRET` | Password shown on the login page to authorize a session |
| `PROXY_SECRET` | Value sent as `X-Proxy-Secret` header to the origin (for WAF validation) |
| `HINDSIGHT_API_TOKEN` | Bearer token for authenticating with the Hindsight API |
| `ALLOWED_EMAIL` | Your email address, used as the OAuth user identity |
## Security notes
@@ -25,13 +25,11 @@ function createMockProvider(): OAuthHelpers & {
async (): Promise<OAuthReqInfo> => ({
clientId: "test-client",
scope: "mcp:full",
}),
),
completeAuthorization: vi.fn(
async () => ({
redirectTo: "https://claude.ai/oauth/callback?code=abc",
}),
})
),
completeAuthorization: vi.fn(async () => ({
redirectTo: "https://claude.ai/oauth/callback?code=abc",
})),
};
}
@@ -79,7 +77,7 @@ describe("handleDefaultRequest", () => {
it("returns { status: 'ok' }", async () => {
const response = await handleDefaultRequest(
new Request("https://hindsight.mydomain.com/health"),
env,
env
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ status: "ok" });
@@ -90,7 +88,7 @@ describe("handleDefaultRequest", () => {
it("returns the service identifier", async () => {
const response = await handleDefaultRequest(
new Request("https://hindsight.mydomain.com/"),
env,
env
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ service: "Hindsight MCP OAuth Proxy" });
@@ -101,7 +99,7 @@ describe("handleDefaultRequest", () => {
it("returns 404", async () => {
const response = await handleDefaultRequest(
new Request("https://hindsight.mydomain.com/nope"),
env,
env
);
expect(response.status).toBe(404);
});
@@ -111,7 +109,7 @@ describe("handleDefaultRequest", () => {
it("stores OAuth state in KV and renders the login page", async () => {
const response = await handleDefaultRequest(
new Request("https://hindsight.mydomain.com/authorize?client_id=x"),
env,
env
);
expect(response.status).toBe(200);
@@ -143,14 +141,14 @@ describe("handleDefaultRequest", () => {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
}),
e,
e
);
}
it("returns 401 and re-renders the login page on wrong password", async () => {
await env.OAUTH_KV.put(
"auth_state:state-1",
JSON.stringify({ clientId: "c", scope: "mcp:full" }),
JSON.stringify({ clientId: "c", scope: "mcp:full" })
);
const response = await postForm({ password: "wrong", stateKey: "state-1" });
expect(response.status).toBe(401);
@@ -185,20 +183,18 @@ describe("handleDefaultRequest", () => {
it("happy path: calls completeAuthorization and redirects", async () => {
await env.OAUTH_KV.put(
"auth_state:state-2",
JSON.stringify({ clientId: "test-client", scope: "mcp:full" }),
JSON.stringify({ clientId: "test-client", scope: "mcp:full" })
);
const response = await postForm({
password: "correct-horse-battery-staple",
stateKey: "state-2",
});
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe(
"https://claude.ai/oauth/callback?code=abc",
);
expect(response.headers.get("Location")).toBe("https://claude.ai/oauth/callback?code=abc");
expect(env.OAUTH_PROVIDER.completeAuthorization).toHaveBeenCalledTimes(1);
const call = (env.OAUTH_PROVIDER.completeAuthorization as ReturnType<typeof vi.fn>)
.mock.calls[0][0];
const call = (env.OAUTH_PROVIDER.completeAuthorization as ReturnType<typeof vi.fn>).mock
.calls[0][0];
expect(call.userId).toBe("[email protected]");
expect(call.scope).toBe("mcp:full");
expect(call.props.email).toBe("[email protected]");
@@ -209,34 +205,31 @@ describe("handleDefaultRequest", () => {
});
it("defaults scope to mcp:full when the OAuth request carries none", async () => {
await env.OAUTH_KV.put(
"auth_state:state-3",
JSON.stringify({ clientId: "test-client" }),
);
await env.OAUTH_KV.put("auth_state:state-3", JSON.stringify({ clientId: "test-client" }));
await postForm({
password: "correct-horse-battery-staple",
stateKey: "state-3",
});
const call = (env.OAUTH_PROVIDER.completeAuthorization as ReturnType<typeof vi.fn>)
.mock.calls[0][0];
const call = (env.OAUTH_PROVIDER.completeAuthorization as ReturnType<typeof vi.fn>).mock
.calls[0][0];
expect(call.scope).toBe("mcp:full");
});
it("returns 500 when completeAuthorization throws", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const broken = createEnv();
(broken.OAUTH_PROVIDER.completeAuthorization as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("library error"),
);
(
broken.OAUTH_PROVIDER.completeAuthorization as ReturnType<typeof vi.fn>
).mockRejectedValueOnce(new Error("library error"));
await broken.OAUTH_KV.put(
"auth_state:state-4",
JSON.stringify({ clientId: "test-client", scope: "mcp:full" }),
JSON.stringify({ clientId: "test-client", scope: "mcp:full" })
);
const response = await postForm(
{ password: "correct-horse-battery-staple", stateKey: "state-4" },
broken,
broken
);
expect(response.status).toBe(500);
errorSpy.mockRestore();
@@ -12,10 +12,7 @@ const AUTH_STATE_TTL_SECONDS = 300;
* Constant-time password comparison. Both inputs are hashed with SHA-256 first
* so the comparison length is fixed regardless of input length.
*/
export async function verifyPassword(
provided: string,
expected: string,
): Promise<boolean> {
export async function verifyPassword(provided: string, expected: string): Promise<boolean> {
const encoder = new TextEncoder();
const [a, b] = await Promise.all([
crypto.subtle.digest("SHA-256", encoder.encode(provided)),
@@ -47,11 +44,9 @@ function htmlResponse(body: string, init: ResponseInit = {}): Response {
async function handleAuthorizeGet(request: Request, env: Env): Promise<Response> {
const oauthReqInfo = await env.OAUTH_PROVIDER.parseAuthRequest(request);
const stateKey = crypto.randomUUID();
await env.OAUTH_KV.put(
AUTH_STATE_PREFIX + stateKey,
JSON.stringify(oauthReqInfo),
{ expirationTtl: AUTH_STATE_TTL_SECONDS },
);
await env.OAUTH_KV.put(AUTH_STATE_PREFIX + stateKey, JSON.stringify(oauthReqInfo), {
expirationTtl: AUTH_STATE_TTL_SECONDS,
});
return htmlResponse(loginPage(stateKey));
}
@@ -77,10 +72,9 @@ async function handleAuthorizePost(request: Request, env: Env): Promise<Response
await env.OAUTH_KV.delete(AUTH_STATE_PREFIX + stateKey);
if (!stored) {
return new Response(
"Authorization expired. Please try connecting again from Claude.",
{ status: 400 },
);
return new Response("Authorization expired. Please try connecting again from Claude.", {
status: 400,
});
}
const oauthReqInfo = JSON.parse(stored) as OAuthReqInfo;
@@ -103,10 +97,7 @@ async function handleAuthorizePost(request: Request, env: Env): Promise<Response
}
}
export async function handleDefaultRequest(
request: Request,
env: Env,
): Promise<Response> {
export async function handleDefaultRequest(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/health") {
@@ -8,7 +8,7 @@ describe("escapeHtml", () => {
it("escapes all reserved characters", () => {
expect(escapeHtml(`<script>alert("x&'y")</script>`)).toBe(
"&lt;script&gt;alert(&quot;x&amp;&#39;y&quot;)&lt;/script&gt;",
"&lt;script&gt;alert(&quot;x&amp;&#39;y&quot;)&lt;/script&gt;"
);
});
@@ -98,7 +98,7 @@ describe("proxyRequest", () => {
"X-Custom-Leak": "yes",
"Mcp-Session-Id": "session-123",
},
}),
})
);
const request = new Request("https://hindsight.mydomain.com/mcp", { method: "GET" });
@@ -75,7 +75,7 @@ export interface ProxyOptions {
export async function proxyRequest(
request: Request,
env: ProxyEnv,
options: ProxyOptions = {},
options: ProxyOptions = {}
): Promise<Response> {
const fetchImpl = options.fetchImpl ?? fetch;
@@ -30,7 +30,7 @@ describe("createWorker", () => {
headers: { Origin: "https://claude.ai" },
}),
fakeEnv(),
fakeCtx,
fakeCtx
);
expect(response.status).toBe(204);
@@ -47,7 +47,7 @@ describe("createWorker", () => {
headers: { Origin: "https://evil.example.com" },
}),
fakeEnv(),
fakeCtx,
fakeCtx
);
expect(response.status).toBe(403);
@@ -58,15 +58,16 @@ describe("createWorker", () => {
describe("/.well-known/oauth-authorization-server", () => {
it("forces code_challenge_methods_supported to ['S256']", async () => {
const provider: ProviderLike = {
fetch: vi.fn(async () =>
new Response(
JSON.stringify({
issuer: "https://proxy.example.com",
code_challenge_methods_supported: ["S256", "plain"],
grant_types_supported: ["authorization_code"],
}),
{ headers: { "Content-Type": "application/json" } },
),
fetch: vi.fn(
async () =>
new Response(
JSON.stringify({
issuer: "https://proxy.example.com",
code_challenge_methods_supported: ["S256", "plain"],
grant_types_supported: ["authorization_code"],
}),
{ headers: { "Content-Type": "application/json" } }
)
),
};
const worker = createWorker(provider);
@@ -76,10 +77,10 @@ describe("createWorker", () => {
headers: { Origin: "https://claude.ai" },
}),
fakeEnv(),
fakeCtx,
fakeCtx
);
const metadata = await response.json() as Record<string, unknown>;
const metadata = (await response.json()) as Record<string, unknown>;
expect(metadata.code_challenge_methods_supported).toEqual(["S256"]);
expect(metadata.grant_types_supported).toEqual(["authorization_code"]);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://claude.ai");
@@ -87,16 +88,14 @@ describe("createWorker", () => {
it("strips CORS headers when the request origin is not allowlisted", async () => {
const provider: ProviderLike = {
fetch: vi.fn(async () =>
new Response(
JSON.stringify({ code_challenge_methods_supported: ["plain"] }),
{
fetch: vi.fn(
async () =>
new Response(JSON.stringify({ code_challenge_methods_supported: ["plain"] }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
},
),
})
),
};
const worker = createWorker(provider);
@@ -104,7 +103,7 @@ describe("createWorker", () => {
const response = await worker.fetch(
new Request("https://proxy.example.com/.well-known/oauth-authorization-server"),
fakeEnv(),
fakeCtx,
fakeCtx
);
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
@@ -122,7 +121,7 @@ describe("createWorker", () => {
headers: { Origin: "https://claude.ai" },
}),
fakeEnv(),
fakeCtx,
fakeCtx
);
expect(providerFetch).toHaveBeenCalledTimes(1);
@@ -136,7 +135,7 @@ describe("createWorker", () => {
async () =>
new Response("ok", {
headers: { "Access-Control-Allow-Origin": "*" },
}),
})
),
};
const worker = createWorker(provider);
@@ -146,7 +145,7 @@ describe("createWorker", () => {
headers: { Origin: "https://evil.example.com" },
}),
fakeEnv(),
fakeCtx,
fakeCtx
);
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
@@ -84,20 +84,14 @@ class HindsightStorage(Storage):
# Resolve settings: constructor args override global config
config = get_config()
self._api_url = hindsight_api_url or (
config.hindsight_api_url if config else "http://localhost:8888"
)
self._api_url = hindsight_api_url or (config.hindsight_api_url if config else "http://localhost:8888")
self._api_key = api_key or (config.api_key if config else None)
self._budget = budget or (config.budget if config else "mid")
self._max_tokens = max_tokens or (config.max_tokens if config else 4096)
self._tags = tags or (config.tags if config else None)
self._recall_tags = recall_tags or (config.recall_tags if config else None)
self._recall_tags_match = recall_tags_match or (
config.recall_tags_match if config else "any"
)
self._verbose = (
verbose if verbose is not None else (config.verbose if config else False)
)
self._recall_tags_match = recall_tags_match or (config.recall_tags_match if config else "any")
self._verbose = verbose if verbose is not None else (config.verbose if config else False)
# Eagerly create the default bank if mission is provided
if mission:
@@ -202,9 +196,7 @@ class HindsightStorage(Storage):
try:
call_sync(_retain)
if self._verbose:
logger.info(
f"Stored memory to bank {bank_id} (agent={agent}, len={len(value)})"
)
logger.info(f"Stored memory to bank {bank_id} (agent={agent}, len={len(value)})")
except Exception as e:
logger.error(f"Failed to store memory: {e}")
raise HindsightError(f"Failed to store memory: {e}") from e
@@ -284,9 +276,7 @@ class HindsightStorage(Storage):
)
if self._verbose:
logger.info(
f"Recalled {len(results)} memories from bank {bank_id} for query: {query[:80]}"
)
logger.info(f"Recalled {len(results)} memories from bank {bank_id} for query: {query[:80]}")
return results
@@ -65,9 +65,7 @@ class HindsightReflectTool(BaseTool):
hindsight_api_url: str | None = Field(default=None, description="Override API URL")
api_key: str | None = Field(default=None, description="Override API key")
budget: str = Field(default="mid", description="Reflect budget (low/mid/high)")
reflect_context: str | None = Field(
default=None, description="Additional context for reflect reasoning"
)
reflect_context: str | None = Field(default=None, description="Additional context for reflect reasoning")
_local: Any = PrivateAttr(default_factory=threading.local)
@@ -78,9 +76,7 @@ class HindsightReflectTool(BaseTool):
from hindsight_client import Hindsight
config = get_config()
api_url = self.hindsight_api_url or (
config.hindsight_api_url if config else "http://localhost:8888"
)
api_url = self.hindsight_api_url or (config.hindsight_api_url if config else "http://localhost:8888")
api_key = self.api_key or (config.api_key if config else None)
client = Hindsight(
+4 -6
View File
@@ -9,9 +9,10 @@ Usage:
uv run python test_manual.py
"""
from hindsight_crewai import configure, HindsightStorage, HindsightReflectTool
from crewai.memory.external.external_memory import ExternalMemory
from crewai import Agent, Crew, Task
from crewai.memory.external.external_memory import ExternalMemory
from hindsight_crewai import HindsightReflectTool, HindsightStorage, configure
BANK_ID = "crewai-test"
HINDSIGHT_URL = "http://localhost:8888"
@@ -62,10 +63,7 @@ writer = Agent(
)
research_task = Task(
description=(
"Research the benefits of functional programming. "
"List at least 3 key benefits with examples."
),
description=("Research the benefits of functional programming. List at least 3 key benefits with examples."),
expected_output="A list of functional programming benefits with examples.",
agent=researcher,
)
@@ -58,9 +58,7 @@ def __getattr__(name: str):
raise ImportError(
f"'{name}' requires langgraph. Install with: pip install hindsight-langgraph[langgraph]"
) from None
return (
create_recall_node if name == "create_recall_node" else create_retain_node
)
return create_recall_node if name == "create_recall_node" else create_retain_node
if name == "HindsightStore":
try:
@@ -106,18 +106,14 @@ def create_recall_node(
"""
resolved_client = resolve_client(client, hindsight_api_url, api_key)
async def recall_node(
state: MessagesState, config: Optional[RunnableConfig] = None
) -> dict[str, Any]:
async def recall_node(state: MessagesState, config: Optional[RunnableConfig] = None) -> dict[str, Any]:
resolved_bank_id = bank_id
if resolved_bank_id is None and config:
configurable = config.get("configurable", {})
resolved_bank_id = configurable.get(bank_id_from_config)
if not resolved_bank_id:
logger.warning(
"No bank_id available for recall node, skipping memory injection."
)
logger.warning("No bank_id available for recall node, skipping memory injection.")
if output_key:
return {output_key: None}
return {"messages": []}
@@ -160,11 +156,7 @@ def create_recall_node(
if output_key:
return {output_key: memory_text}
return {
"messages": [
SystemMessage(content=memory_text, id="hindsight_memory_context")
]
}
return {"messages": [SystemMessage(content=memory_text, id="hindsight_memory_context")]}
except Exception as e:
logger.error(f"Recall node failed: {e}")
if output_key:
@@ -206,18 +198,14 @@ def create_retain_node(
"""
resolved_client = resolve_client(client, hindsight_api_url, api_key)
async def retain_node(
state: MessagesState, config: Optional[RunnableConfig] = None
) -> dict[str, Any]:
async def retain_node(state: MessagesState, config: Optional[RunnableConfig] = None) -> dict[str, Any]:
resolved_bank_id = bank_id
if resolved_bank_id is None and config:
configurable = config.get("configurable", {})
resolved_bank_id = configurable.get(bank_id_from_config)
if not resolved_bank_id:
logger.warning(
"No bank_id available for retain node, skipping memory storage."
)
logger.warning("No bank_id available for retain node, skipping memory storage.")
return {"messages": []}
# Only retain the latest human and/or AI message to avoid
@@ -131,14 +131,10 @@ class HindsightStore(BaseStore):
# Per-bank locks for concurrency-safe bank creation
self._bank_locks: dict[str, asyncio.Lock] = {}
def batch(
self, ops: Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp]
) -> list[Result]:
def batch(self, ops: Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp]) -> list[Result]:
raise NotImplementedError("Use abatch() for async operation.")
async def abatch(
self, ops: Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp]
) -> list[Result]:
async def abatch(self, ops: Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp]) -> list[Result]:
results: list[Result] = []
for op in ops:
if isinstance(op, GetOp):
@@ -199,11 +195,7 @@ class HindsightStore(BaseStore):
self._created_banks.add(bank_id)
except Exception as e:
error_str = str(e).lower()
if (
"already exists" in error_str
or "conflict" in error_str
or "409" in error_str
):
if "already exists" in error_str or "conflict" in error_str or "409" in error_str:
# Bank already exists — safe to cache
self._created_banks.add(bank_id)
else:
@@ -222,9 +214,7 @@ class HindsightStore(BaseStore):
try:
await self._ensure_bank(bank_id)
content = (
json.dumps(op.value) if isinstance(op.value, dict) else str(op.value)
)
content = json.dumps(op.value) if isinstance(op.value, dict) else str(op.value)
retain_kwargs: dict[str, Any] = {
"bank_id": bank_id,
"content": content,
@@ -258,25 +248,15 @@ class HindsightStore(BaseStore):
all_items = []
for i, result in enumerate(response.results):
value = _parse_value(result.text)
doc_id = getattr(result, "document_id", None) or _content_key(
result.text
)
score = max(
0.0, 1.0 - (i * 0.01)
) # Approximate score from rank position
doc_id = getattr(result, "document_id", None) or _content_key(result.text)
score = max(0.0, 1.0 - (i * 0.01)) # Approximate score from rank position
ts = getattr(result, "occurred_start", None)
all_items.append(
_make_search_item(
op.namespace_prefix, doc_id, value, score=score, created_at=ts
)
)
all_items.append(_make_search_item(op.namespace_prefix, doc_id, value, score=score, created_at=ts))
# Apply filters BEFORE pagination so offset/limit operate on
# the filtered set rather than discarding matching items.
if op.filter:
all_items = [
item for item in all_items if _matches_filter(item.value, op.filter)
]
all_items = [item for item in all_items if _matches_filter(item.value, op.filter)]
limit = op.limit or 10
offset = op.offset or 0
@@ -285,9 +265,7 @@ class HindsightStore(BaseStore):
logger.error(f"Store search failed for {op.namespace_prefix}: {e}")
return []
async def _handle_list_namespaces(
self, op: ListNamespacesOp
) -> list[tuple[str, ...]]:
async def _handle_list_namespaces(self, op: ListNamespacesOp) -> list[tuple[str, ...]]:
"""List known namespaces. Limited to namespaces seen via put() in this session."""
namespaces = list(self._known_namespaces)
@@ -83,24 +83,12 @@ def create_hindsight_tools(
config = get_config()
effective_tags = tags if tags is not None else (config.tags if config else None)
effective_recall_tags = (
recall_tags
if recall_tags is not None
else (config.recall_tags if config else None)
)
effective_recall_tags = recall_tags if recall_tags is not None else (config.recall_tags if config else None)
effective_recall_tags_match = (
recall_tags_match
if recall_tags_match is not None
else (config.recall_tags_match if config else "any")
)
effective_budget = (
budget if budget is not None else (config.budget if config else "mid")
)
effective_max_tokens = (
max_tokens
if max_tokens is not None
else (config.max_tokens if config else 4096)
recall_tags_match if recall_tags_match is not None else (config.recall_tags_match if config else "any")
)
effective_budget = budget if budget is not None else (config.budget if config else "mid")
effective_max_tokens = max_tokens if max_tokens is not None else (config.max_tokens if config else 4096)
tools: list = []
@@ -197,12 +185,8 @@ def create_hindsight_tools(
if reflect_response_schema:
reflect_kwargs["response_schema"] = reflect_response_schema
# Reflect tags: use reflect-specific or fall back to recall tags
effective_reflect_tags = (
reflect_tags if reflect_tags is not None else effective_recall_tags
)
effective_reflect_tags_match = (
reflect_tags_match or effective_recall_tags_match
)
effective_reflect_tags = reflect_tags if reflect_tags is not None else effective_recall_tags
effective_reflect_tags_match = reflect_tags_match or effective_recall_tags_match
if effective_reflect_tags:
reflect_kwargs["tags"] = effective_reflect_tags
reflect_kwargs["tags_match"] = effective_reflect_tags_match
@@ -99,56 +99,55 @@ Works with any LiteLLM-supported provider:
>>> hindsight_litellm.completion(model="groq/llama-3.1-70b-versatile", messages=[...])
"""
import logging
import threading
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Optional, List
import threading
import logging
from typing import List, Optional
import litellm
from .callbacks import (
HindsightCallback,
HindsightError,
cleanup_callback,
get_callback,
)
from .config import (
USER_AGENT,
HindsightConfig,
HindsightDefaults,
MemoryInjectionMode,
configure,
set_defaults,
get_config,
get_defaults,
is_configured,
reset_config,
HindsightConfig,
HindsightDefaults,
MemoryInjectionMode,
)
from .callbacks import (
HindsightCallback,
HindsightError,
get_callback,
cleanup_callback,
set_defaults,
)
from .wrappers import (
recall,
arecall,
RecallResult,
RecallResponse,
RecallDebugInfo,
reflect,
areflect,
ReflectResult,
ReflectDebugInfo,
retain,
aretain,
RetainResult,
RetainDebugInfo,
get_pending_retain_errors,
wrap_openai,
wrap_anthropic,
HindsightOpenAI,
HindsightAnthropic,
_get_client,
HindsightOpenAI,
RecallDebugInfo,
RecallResponse,
RecallResult,
ReflectDebugInfo,
ReflectResult,
RetainDebugInfo,
RetainResult,
_close_client,
_get_client,
arecall,
areflect,
aretain,
get_pending_retain_errors,
recall,
reflect,
retain,
wrap_anthropic,
wrap_openai,
)
__version__ = "0.1.0"
# Track whether we've registered with LiteLLM
@@ -184,9 +183,7 @@ class InjectionDebugInfo:
bank_id: str
memory_context: str # The formatted context that was injected
reflect_text: Optional[str] = None # Raw reflect response text
reflect_facts: Optional[List[dict]] = (
None # Facts used by reflect (when reflect_include_facts=True)
)
reflect_facts: Optional[List[dict]] = None # Facts used by reflect (when reflect_include_facts=True)
recall_results: Optional[List[dict]] = None # Raw recall results
results_count: int = 0
injected: bool = False
@@ -326,8 +323,8 @@ def _inject_memories(
# If reflect_include_facts is enabled, use the API directly to include facts
if defaults.reflect_include_facts:
from hindsight_client_api.models import (
reflect_request,
reflect_include_options,
reflect_request,
)
request_obj = reflect_request.ReflectRequest(
@@ -341,9 +338,7 @@ def _inject_memories(
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(
client._api.reflect(bank_id, request_obj)
)
result = loop.run_until_complete(client._api.reflect(bank_id, request_obj))
# Extract facts from based_on
if hasattr(result, "based_on") and result.based_on:
reflect_facts = [
@@ -422,9 +417,7 @@ def _inject_memories(
return messages
# Format memories (apply limit if set, otherwise use all)
results_to_use = (
results[: defaults.max_memories] if defaults.max_memories else results
)
results_to_use = results[: defaults.max_memories] if defaults.max_memories else results
memory_lines = []
for i, r in enumerate(results_to_use, 1):
text = r.text if hasattr(r, "text") else str(r)
@@ -449,8 +442,7 @@ def _inject_memories(
results_count = len(memory_lines)
memory_context = (
"# Relevant Memories\n"
"The following information from memory may be relevant:\n\n"
+ "\n".join(memory_lines)
"The following information from memory may be relevant:\n\n" + "\n".join(memory_lines)
)
# Inject into messages
@@ -507,9 +499,7 @@ def _inject_memories(
except Exception as e:
# Always set debug info on error when verbose mode is on
if config and config.verbose:
logging.getLogger("hindsight_litellm").warning(
f"Failed to inject memories: {e}"
)
logging.getLogger("hindsight_litellm").warning(f"Failed to inject memories: {e}")
_last_injection_debug = InjectionDebugInfo(
mode="reflect" if (defaults and defaults.use_reflect) else "recall",
query=user_query or "",
@@ -666,14 +656,10 @@ def enable() -> None:
defaults = get_defaults()
if not config:
raise RuntimeError(
"Hindsight not configured. Call configure() before enable()."
)
raise RuntimeError("Hindsight not configured. Call configure() before enable().")
if not defaults or not defaults.bank_id:
raise RuntimeError(
"Hindsight bank_id not set. Call set_defaults(bank_id=...) before enable()."
)
raise RuntimeError("Hindsight bank_id not set. Call set_defaults(bank_id=...) before enable().")
# Store original functions and monkeypatch for memory injection + storage
_original_completion = litellm.completion
@@ -785,9 +771,7 @@ def _format_conversation_for_storage(
tc_strs.append(f"{tc.function.name}({tc.function.arguments})")
elif isinstance(tc, dict) and "function" in tc:
func = tc["function"]
tc_strs.append(
f"{func.get('name', '')}({func.get('arguments', '')})"
)
tc_strs.append(f"{func.get('name', '')}({func.get('arguments', '')})")
if tc_strs:
items.append(f"ASSISTANT_TOOL_CALLS: {'; '.join(tc_strs)}")
if content:
@@ -815,9 +799,7 @@ def _format_conversation_for_storage(
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
for tc in choice.message.tool_calls:
if hasattr(tc, "function"):
assistant_tool_calls.append(
f"{tc.function.name}({tc.function.arguments})"
)
assistant_tool_calls.append(f"{tc.function.name}({tc.function.arguments})")
if assistant_content:
items.append(f"ASSISTANT: {assistant_content}")
@@ -834,9 +816,7 @@ _pending_storage_errors: List[Exception] = []
_storage_error_lock = threading.Lock()
def _get_existing_document_content(
bank_id: str, document_id: str, verbose: bool
) -> Optional[str]:
def _get_existing_document_content(bank_id: str, document_id: str, verbose: bool) -> Optional[str]:
"""Fetch existing document content for accumulation via low-level API.
Returns:
@@ -852,9 +832,7 @@ def _get_existing_document_content(
import hindsight_client_api
from hindsight_client_api.api import documents_api
api_config = hindsight_client_api.Configuration(
host=config.hindsight_api_url, access_token=config.api_key
)
api_config = hindsight_client_api.Configuration(host=config.hindsight_api_url, access_token=config.api_key)
api_client = hindsight_client_api.ApiClient(api_config)
api_client.user_agent = USER_AGENT
if config.api_key:
@@ -906,15 +884,11 @@ def _store_conversation_sync(
# If document_id is set, fetch existing content and append
content_to_store = conversation_text
if document_id:
existing_content = _get_existing_document_content(
bank_id, document_id, verbose
)
existing_content = _get_existing_document_content(bank_id, document_id, verbose)
if existing_content:
content_to_store = f"{existing_content}\n\n{conversation_text}"
if verbose:
_storage_logger.debug(
f"Appending to existing document: {document_id}"
)
_storage_logger.debug(f"Appending to existing document: {document_id}")
retain(
content=content_to_store,
@@ -930,9 +904,7 @@ def _store_conversation_sync(
_storage_logger.error(f"Failed to store conversation: {e}")
# Store error to raise on next completion call
with _storage_error_lock:
_pending_storage_errors.append(
HindsightError(f"Background storage failed: {e}")
)
_pending_storage_errors.append(HindsightError(f"Background storage failed: {e}"))
def _check_pending_storage_errors() -> None:
@@ -980,9 +952,7 @@ def _store_conversation(
return
if not defaults or not defaults.bank_id:
_storage_logger.warning(
"No bank_id configured for storage. Call set_defaults(bank_id=...)."
)
_storage_logger.warning("No bank_id configured for storage. Call set_defaults(bank_id=...).")
return
# Format conversation
@@ -1003,10 +973,7 @@ def _store_conversation(
if existing_content:
content_to_store = f"{existing_content}\n\n{conversation_text}"
if config.verbose:
_storage_logger.debug(
f"Appending to existing document: "
f"{defaults.effective_document_id}"
)
_storage_logger.debug(f"Appending to existing document: {defaults.effective_document_id}")
retain(
content=content_to_store,
@@ -7,25 +7,25 @@ Uses direct HTTP calls via requests/httpx to avoid async event loop conflicts
when the hindsight_client's async methods are called from LiteLLM callbacks.
"""
import logging
import asyncio
import concurrent.futures
import fnmatch
import hashlib
from typing import Any, Dict, List, Optional, Set
import asyncio
import logging
import threading
import concurrent.futures
from typing import Any, Dict, List, Optional, Set
from litellm.integrations.custom_logger import CustomLogger
from litellm.types.utils import ModelResponse
from .config import (
get_config,
get_defaults,
HindsightConfig,
HindsightCallSettings,
HindsightConfig,
HindsightDefaults, # Backward compatibility alias
MemoryInjectionMode,
_merge_call_settings,
get_config,
get_defaults,
)
# Use requests for sync HTTP calls to avoid async event loop issues
@@ -58,9 +58,7 @@ class HindsightError(Exception):
# Thread pool for running async operations in background
_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=4, thread_name_prefix="hindsight-"
)
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=4, thread_name_prefix="hindsight-")
class HindsightCallback(CustomLogger):
@@ -124,8 +122,7 @@ class HindsightCallback(CustomLogger):
self._http_session = httpx.Client(timeout=30.0)
else:
raise RuntimeError(
"Neither 'requests' nor 'httpx' is installed. "
"Please install one: pip install requests"
"Neither 'requests' nor 'httpx' is installed. Please install one: pip install requests"
)
return self._http_session
@@ -142,9 +139,7 @@ class HindsightCallback(CustomLogger):
try:
if HAS_REQUESTS:
response = session.post(
url, json=json_data, headers=headers, timeout=30
)
response = session.post(url, json=json_data, headers=headers, timeout=30)
response.raise_for_status()
return response.json()
elif HAS_HTTPX:
@@ -152,9 +147,7 @@ class HindsightCallback(CustomLogger):
response.raise_for_status()
return response.json()
else:
raise HindsightError(
"No HTTP client available (install requests or httpx)"
)
raise HindsightError("No HTTP client available (install requests or httpx)")
except HindsightError:
raise
except Exception as e:
@@ -190,9 +183,7 @@ class HindsightCallback(CustomLogger):
response.raise_for_status()
return response.json()
else:
raise HindsightError(
"No HTTP client available (install requests or httpx)"
)
raise HindsightError("No HTTP client available (install requests or httpx)")
except HindsightError:
raise
except Exception as e:
@@ -261,9 +252,7 @@ class HindsightCallback(CustomLogger):
return False
def _format_memories(
self, results: List[Any], settings: HindsightDefaults, config: HindsightConfig
) -> str:
def _format_memories(self, results: List[Any], settings: HindsightDefaults, config: HindsightConfig) -> str:
"""Format memory recall results into a context string.
Results can be RecallResult objects (with .text, .type attributes)
@@ -273,9 +262,7 @@ class HindsightCallback(CustomLogger):
return ""
# Apply limit if set, otherwise use all results
results_to_use = (
results[: settings.max_memories] if settings.max_memories else results
)
results_to_use = results[: settings.max_memories] if settings.max_memories else results
memory_lines = []
for i, result in enumerate(results_to_use, 1):
# Handle both RecallResult objects and dicts
@@ -299,10 +286,8 @@ class HindsightCallback(CustomLogger):
if not memory_lines:
return ""
return (
"# Relevant Memories\n"
"The following information from memory may be relevant:\n\n"
+ "\n".join(memory_lines)
return "# Relevant Memories\nThe following information from memory may be relevant:\n\n" + "\n".join(
memory_lines
)
def _inject_memories_into_messages(
@@ -407,15 +392,11 @@ class HindsightCallback(CustomLogger):
HindsightError: If inject_memories=True and recall fails.
"""
loop = asyncio.get_running_loop()
results = await loop.run_in_executor(
_executor, lambda: self._recall_memories_sync(query, settings, config)
)
results = await loop.run_in_executor(_executor, lambda: self._recall_memories_sync(query, settings, config))
return results if isinstance(results, list) else []
def _reflect_sync(
self, query: str, settings: HindsightDefaults, config: HindsightConfig
) -> Optional[str]:
def _reflect_sync(self, query: str, settings: HindsightDefaults, config: HindsightConfig) -> Optional[str]:
"""Generate a reflection response from Hindsight (sync) using direct HTTP.
Returns:
@@ -473,9 +454,7 @@ class HindsightCallback(CustomLogger):
logger.error(f"Failed to reflect: {e}")
raise HindsightError(f"Reflect failed: {e}") from e
async def _reflect_async(
self, query: str, settings: HindsightDefaults, config: HindsightConfig
) -> Optional[str]:
async def _reflect_async(self, query: str, settings: HindsightDefaults, config: HindsightConfig) -> Optional[str]:
"""Generate a reflection response from Hindsight (async).
Uses thread pool executor with sync HTTP to avoid event loop conflicts.
@@ -487,9 +466,7 @@ class HindsightCallback(CustomLogger):
HindsightError: If inject_memories=True and reflect fails.
"""
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
_executor, lambda: self._reflect_sync(query, settings, config)
)
result = await loop.run_in_executor(_executor, lambda: self._reflect_sync(query, settings, config))
return result
def _store_conversation_sync(
@@ -543,9 +520,7 @@ class HindsightCallback(CustomLogger):
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
for tc in choice.message.tool_calls:
if hasattr(tc, "function"):
assistant_tool_calls.append(
f"{tc.function.name}({tc.function.arguments})"
)
assistant_tool_calls.append(f"{tc.function.name}({tc.function.arguments})")
# Skip if no content AND no tool calls - nothing to store
if not assistant_output and not assistant_tool_calls:
@@ -580,9 +555,7 @@ class HindsightCallback(CustomLogger):
tc_strs.append(f"{tc.function.name}({tc.function.arguments})")
elif isinstance(tc, dict) and "function" in tc:
func = tc["function"]
tc_strs.append(
f"{func.get('name', '')}({func.get('arguments', '')})"
)
tc_strs.append(f"{func.get('name', '')}({func.get('arguments', '')})")
if tc_strs:
items.append(f"ASSISTANT_TOOL_CALLS: {'; '.join(tc_strs)}")
if content:
@@ -632,19 +605,13 @@ class HindsightCallback(CustomLogger):
if settings.effective_document_id:
try:
doc_url = (
f"{config.hindsight_api_url}/v1/default/banks/{bank_id}"
f"/documents/{settings.effective_document_id}"
f"{config.hindsight_api_url}/v1/default/banks/{bank_id}/documents/{settings.effective_document_id}"
)
existing_doc = self._http_get(doc_url, config)
if existing_doc and existing_doc.get("original_text"):
conversation_text = (
f"{existing_doc['original_text']}\n\n{new_conversation_text}"
)
conversation_text = f"{existing_doc['original_text']}\n\n{new_conversation_text}"
if config.verbose:
logger.debug(
f"Appending to existing document: "
f"{settings.effective_document_id}"
)
logger.debug(f"Appending to existing document: {settings.effective_document_id}")
except Exception as e:
if config.verbose:
logger.debug(f"No existing document found, creating new: {e}")
@@ -702,9 +669,7 @@ class HindsightCallback(CustomLogger):
loop = asyncio.get_running_loop()
await loop.run_in_executor(
_executor,
lambda: self._store_conversation_sync(
messages, response, model, settings, config
),
lambda: self._store_conversation_sync(messages, response, model, settings, config),
)
# ========== LiteLLM CustomLogger Interface ==========
@@ -758,9 +723,7 @@ class HindsightCallback(CustomLogger):
# Format and inject memories
memory_context = self._format_memories(memories, settings, config)
updated_messages = self._inject_memories_into_messages(
messages, memory_context, config
)
updated_messages = self._inject_memories_into_messages(messages, memory_context, config)
# Modify messages list IN-PLACE (don't just reassign kwargs)
messages.clear()
@@ -819,9 +782,7 @@ class HindsightCallback(CustomLogger):
# Format and inject memories
memory_context = self._format_memories(memories, settings, config)
updated_messages = self._inject_memories_into_messages(
messages, memory_context, config
)
updated_messages = self._inject_memories_into_messages(messages, memory_context, config)
# Modify messages list IN-PLACE (don't just reassign kwargs)
messages.clear()
@@ -893,9 +854,7 @@ class HindsightCallback(CustomLogger):
return
# Store the conversation
await self._store_conversation_async(
messages, response_obj, model, settings, config
)
await self._store_conversation_async(messages, response_obj, model, settings, config)
def log_failure_event(
self,
@@ -136,9 +136,7 @@ class HindsightCallSettings:
return self.session_id if self.session_id is not None else self.document_id
def _merge_call_settings(
defaults: HindsightCallSettings, kwargs: Dict[str, Any]
) -> HindsightCallSettings:
def _merge_call_settings(defaults: HindsightCallSettings, kwargs: Dict[str, Any]) -> HindsightCallSettings:
"""Merge per-call kwargs (hindsight_*) with defaults.
This automatically handles all fields in HindsightCallSettings.
@@ -193,9 +191,7 @@ class HindsightConfig:
api_key: Optional[str] = None
excluded_models: List[str] = field(default_factory=list)
sync_storage: bool = False
default_settings: HindsightCallSettings = field(
default_factory=HindsightCallSettings
)
default_settings: HindsightCallSettings = field(default_factory=HindsightCallSettings)
# Backward compatibility properties - delegate to default_settings
@property
@@ -446,34 +442,20 @@ def set_defaults(
bank_id=bank_id if bank_id is not None else current.bank_id,
document_id=document_id if document_id is not None else current.document_id,
session_id=session_id if session_id is not None else current.session_id,
store_conversations=store_conversations
if store_conversations is not None
else current.store_conversations,
inject_memories=inject_memories
if inject_memories is not None
else current.inject_memories,
injection_mode=injection_mode
if injection_mode is not None
else current.injection_mode,
store_conversations=store_conversations if store_conversations is not None else current.store_conversations,
inject_memories=inject_memories if inject_memories is not None else current.inject_memories,
injection_mode=injection_mode if injection_mode is not None else current.injection_mode,
budget=budget if budget is not None else current.budget,
fact_types=fact_types if fact_types is not None else current.fact_types,
max_memories=max_memories if max_memories is not None else current.max_memories,
max_memory_tokens=max_memory_tokens
if max_memory_tokens is not None
else current.max_memory_tokens,
include_entities=include_entities
if include_entities is not None
else current.include_entities,
max_memory_tokens=max_memory_tokens if max_memory_tokens is not None else current.max_memory_tokens,
include_entities=include_entities if include_entities is not None else current.include_entities,
trace=trace if trace is not None else current.trace,
tags=tags if tags is not None else current.tags,
recall_tags=recall_tags if recall_tags is not None else current.recall_tags,
recall_tags_match=recall_tags_match
if recall_tags_match is not None
else current.recall_tags_match,
recall_tags_match=recall_tags_match if recall_tags_match is not None else current.recall_tags_match,
use_reflect=use_reflect if use_reflect is not None else current.use_reflect,
reflect_context=reflect_context
if reflect_context is not None
else current.reflect_context,
reflect_context=reflect_context if reflect_context is not None else current.reflect_context,
reflect_response_schema=reflect_response_schema
if reflect_response_schema is not None
else current.reflect_response_schema,
@@ -524,24 +506,19 @@ def _create_or_update_bank(
if verbose:
import logging
logging.getLogger("hindsight_litellm").info(
f"Created/updated bank '{bank_id}' with mission"
)
logging.getLogger("hindsight_litellm").info(f"Created/updated bank '{bank_id}' with mission")
except ImportError:
if verbose:
import logging
logging.getLogger("hindsight_litellm").warning(
"hindsight_client not installed. Cannot create bank. "
"Install with: pip install hindsight-client"
"hindsight_client not installed. Cannot create bank. Install with: pip install hindsight-client"
)
except Exception as e:
if verbose:
import logging
logging.getLogger("hindsight_litellm").warning(
f"Failed to create/update bank: {e}"
)
logging.getLogger("hindsight_litellm").warning(f"Failed to create/update bank: {e}")
def get_config() -> Optional[HindsightConfig]:
@@ -579,5 +556,3 @@ def reset_config() -> None:
"""Reset all global configuration to None."""
global _global_config
_global_config = None
@@ -20,10 +20,12 @@ from .config import (
HINDSIGHT_API_KEY_ENV,
USER_AGENT,
HindsightCallSettings,
_merge_call_settings as _merge_settings,
get_config,
get_defaults,
)
from .config import (
_merge_call_settings as _merge_settings,
)
# Background thread support for async retain
_retain_errors: List[Exception] = []
@@ -151,9 +153,7 @@ def recall(
target_max_tokens = max_tokens or (defaults.max_memory_tokens if defaults else 4096)
if not api_url or not target_bank_id:
raise RuntimeError(
"Hindsight not configured. Call configure() or provide bank_id and hindsight_api_url."
)
raise RuntimeError("Hindsight not configured. Call configure() or provide bank_id and hindsight_api_url.")
client = None
try:
@@ -175,9 +175,7 @@ def recall(
for r in results:
if hasattr(r, "text"):
# Object with attributes
fact_type = getattr(r, "type", None) or getattr(
r, "fact_type", "unknown"
)
fact_type = getattr(r, "type", None) or getattr(r, "fact_type", "unknown")
recall_results.append(
RecallResult(
text=r.text,
@@ -323,9 +321,7 @@ def reflect(
target_budget = budget or (defaults.budget if defaults else "mid")
if not api_url or not target_bank_id:
raise RuntimeError(
"Hindsight not configured. Call configure() or provide bank_id and hindsight_api_url."
)
raise RuntimeError("Hindsight not configured. Call configure() or provide bank_id and hindsight_api_url.")
client = None
try:
@@ -608,9 +604,7 @@ def retain(
verbose = config.verbose if config else False
if not api_url or not target_bank_id:
raise RuntimeError(
"Hindsight not configured. Call configure() or provide bank_id and hindsight_api_url."
)
raise RuntimeError("Hindsight not configured. Call configure() or provide bank_id and hindsight_api_url.")
api_key = config.api_key if config else None
@@ -733,9 +727,7 @@ class _StreamWrapper:
assistant_output = "".join(self._collected_content)
if assistant_output:
try:
self._wrapper._store_conversation(
self._user_query, assistant_output, self._model, self._settings
)
self._wrapper._store_conversation(self._user_query, assistant_output, self._model, self._settings)
except Exception as e:
if self._settings.verbose:
logger.warning(f"Failed to store streamed conversation: {e}")
@@ -804,9 +796,7 @@ class _AnthropicStreamWrapper:
assistant_output = "".join(self._collected_content)
if assistant_output:
try:
self._wrapper._store_conversation(
self._user_query, assistant_output, self._model, self._settings
)
self._wrapper._store_conversation(self._user_query, assistant_output, self._model, self._settings)
except Exception as e:
if self._settings.verbose:
logger.warning(f"Failed to store streamed conversation: {e}")
@@ -881,9 +871,7 @@ class HindsightOpenAI:
else:
# Filter kwargs to only valid HindsightCallSettings fields
valid_fields = {f.name for f in fields(HindsightCallSettings)}
settings_kwargs = {
k: v for k, v in setting_kwargs.items() if k in valid_fields
}
settings_kwargs = {k: v for k, v in setting_kwargs.items() if k in valid_fields}
self._default_settings = HindsightCallSettings(**settings_kwargs)
# Create wrapped chat.completions interface
@@ -934,15 +922,11 @@ class HindsightOpenAI:
if not results:
return ""
results_to_use = (
results[: settings.max_memories] if settings.max_memories else results
)
results_to_use = results[: settings.max_memories] if settings.max_memories else results
memory_lines = []
for i, r in enumerate(results_to_use, 1):
text = r.text if hasattr(r, "text") else str(r)
fact_type = getattr(r, "type", None) or getattr(
r, "fact_type", "memory"
)
fact_type = getattr(r, "type", None) or getattr(r, "fact_type", "memory")
# Build memory line with available context
line_parts = [f"{i}. [{fact_type.upper()}]"]
@@ -982,8 +966,7 @@ class HindsightOpenAI:
return (
f"# Relevant Memories\n"
f"Current date/time: {current_time}\n"
f"The following information from memory may be relevant:\n\n"
+ "\n".join(memory_lines)
f"The following information from memory may be relevant:\n\n" + "\n".join(memory_lines)
)
except Exception as e:
@@ -1034,9 +1017,7 @@ class HindsightOpenAI:
logger.warning(f"Failed to reflect: {e}")
return ""
def _get_document_content(
self, bank_id: str, document_id: str
) -> Optional[str]:
def _get_document_content(self, bank_id: str, document_id: str) -> Optional[str]:
"""Fetch existing document content using the low-level API.
Returns:
@@ -1097,15 +1078,11 @@ class HindsightOpenAI:
# If document_id is set, fetch existing content and append
conversation_text = new_exchange
if settings.effective_document_id:
existing_content = self._get_document_content(
settings.bank_id, settings.effective_document_id
)
existing_content = self._get_document_content(settings.bank_id, settings.effective_document_id)
if existing_content:
conversation_text = f"{existing_content}\n\n{new_exchange}"
if settings.verbose:
logger.debug(
f"Appending to existing document: {settings.effective_document_id}"
)
logger.debug(f"Appending to existing document: {settings.effective_document_id}")
metadata = {
"source": "openai-wrapper",
@@ -1126,9 +1103,7 @@ class HindsightOpenAI:
client.retain(**retain_kwargs)
if settings.verbose:
logger.info(
f"Stored conversation to Hindsight bank: {settings.bank_id}"
)
logger.info(f"Stored conversation to Hindsight bank: {settings.bank_id}")
except Exception as e:
if settings.verbose:
@@ -1169,9 +1144,7 @@ class _WrappedCompletions:
settings = _merge_settings(self._wrapper._default_settings, kwargs)
# Remove hindsight_* kwargs before passing to OpenAI
openai_kwargs = {
k: v for k, v in kwargs.items() if not k.startswith("hindsight_")
}
openai_kwargs = {k: v for k, v in kwargs.items() if not k.startswith("hindsight_")}
messages = list(openai_kwargs.get("messages", []))
model = openai_kwargs.get("model", "gpt-4")
@@ -1245,9 +1218,7 @@ class _WrappedCompletions:
if response.choices and response.choices[0].message:
assistant_output = response.choices[0].message.content or ""
if assistant_output:
self._wrapper._store_conversation(
user_query, assistant_output, model, settings
)
self._wrapper._store_conversation(user_query, assistant_output, model, settings)
return response
@@ -1311,9 +1282,7 @@ class HindsightAnthropic:
else:
# Filter kwargs to only valid HindsightCallSettings fields
valid_fields = {f.name for f in fields(HindsightCallSettings)}
settings_kwargs = {
k: v for k, v in setting_kwargs.items() if k in valid_fields
}
settings_kwargs = {k: v for k, v in setting_kwargs.items() if k in valid_fields}
self._default_settings = HindsightCallSettings(**settings_kwargs)
# Create wrapped messages interface
@@ -1364,15 +1333,11 @@ class HindsightAnthropic:
if not results:
return ""
results_to_use = (
results[: settings.max_memories] if settings.max_memories else results
)
results_to_use = results[: settings.max_memories] if settings.max_memories else results
memory_lines = []
for i, r in enumerate(results_to_use, 1):
text = r.text if hasattr(r, "text") else str(r)
fact_type = getattr(r, "type", None) or getattr(
r, "fact_type", "memory"
)
fact_type = getattr(r, "type", None) or getattr(r, "fact_type", "memory")
# Build memory line with available context
line_parts = [f"{i}. [{fact_type.upper()}]"]
@@ -1412,8 +1377,7 @@ class HindsightAnthropic:
return (
f"# Relevant Memories\n"
f"Current date/time: {current_time}\n"
f"The following information from memory may be relevant:\n\n"
+ "\n".join(memory_lines)
f"The following information from memory may be relevant:\n\n" + "\n".join(memory_lines)
)
except Exception as e:
@@ -1464,9 +1428,7 @@ class HindsightAnthropic:
logger.warning(f"Failed to reflect: {e}")
return ""
def _get_document_content(
self, bank_id: str, document_id: str
) -> Optional[str]:
def _get_document_content(self, bank_id: str, document_id: str) -> Optional[str]:
"""Fetch existing document content using the low-level API.
Returns:
@@ -1527,15 +1489,11 @@ class HindsightAnthropic:
# If document_id is set, fetch existing content and append
conversation_text = new_exchange
if settings.effective_document_id:
existing_content = self._get_document_content(
settings.bank_id, settings.effective_document_id
)
existing_content = self._get_document_content(settings.bank_id, settings.effective_document_id)
if existing_content:
conversation_text = f"{existing_content}\n\n{new_exchange}"
if settings.verbose:
logger.debug(
f"Appending to existing document: {settings.effective_document_id}"
)
logger.debug(f"Appending to existing document: {settings.effective_document_id}")
metadata = {
"source": "anthropic-wrapper",
@@ -1556,9 +1514,7 @@ class HindsightAnthropic:
client.retain(**retain_kwargs)
if settings.verbose:
logger.info(
f"Stored conversation to Hindsight bank: {settings.bank_id}"
)
logger.info(f"Stored conversation to Hindsight bank: {settings.bank_id}")
except Exception as e:
if settings.verbose:
@@ -1591,9 +1547,7 @@ class _WrappedAnthropicMessages:
settings = _merge_settings(self._wrapper._default_settings, kwargs)
# Remove hindsight_* kwargs before passing to Anthropic
anthropic_kwargs = {
k: v for k, v in kwargs.items() if not k.startswith("hindsight_")
}
anthropic_kwargs = {k: v for k, v in kwargs.items() if not k.startswith("hindsight_")}
messages = list(anthropic_kwargs.get("messages", []))
model = anthropic_kwargs.get("model", "claude-sonnet-4-20250514")
@@ -1660,9 +1614,7 @@ class _WrappedAnthropicMessages:
if hasattr(block, "text"):
assistant_output += block.text
if assistant_output:
self._wrapper._store_conversation(
user_query, assistant_output, model, settings
)
self._wrapper._store_conversation(user_query, assistant_output, model, settings)
return response
@@ -25,9 +25,7 @@ DEFAULT_SYSTEM_PROMPT = (
)
# Patterns for detecting ReAct-style reasoning traces in assistant messages
_REACT_PATTERN = re.compile(
r"^(Thought|Action|Action Input|Observation)\s*:", re.MULTILINE
)
_REACT_PATTERN = re.compile(r"^(Thought|Action|Action Input|Observation)\s*:", re.MULTILINE)
_ANSWER_PATTERN = re.compile(r"^Answer\s*:\s*", re.MULTILINE)
@@ -77,16 +75,10 @@ class HindsightMemory(BaseMemory):
budget: str = Field(default="mid", description="Recall budget level")
max_tokens: int = Field(default=4096, description="Max tokens for recall")
tags: Optional[list[str]] = Field(default=None, description="Tags for retain")
recall_tags: Optional[list[str]] = Field(
default=None, description="Tags to filter recall"
)
recall_tags: Optional[list[str]] = Field(default=None, description="Tags to filter recall")
recall_tags_match: str = Field(default="any", description="Tag matching mode")
system_prompt: str = Field(
default=DEFAULT_SYSTEM_PROMPT, description="Memory system message template"
)
chat_history_limit: int = Field(
default=100, description="Max messages in local buffer"
)
system_prompt: str = Field(default=DEFAULT_SYSTEM_PROMPT, description="Memory system message template")
chat_history_limit: int = Field(default=100, description="Max messages in local buffer")
_client: Hindsight = PrivateAttr()
_chat_history: list[ChatMessage] = PrivateAttr(default_factory=list)
@@ -109,9 +101,7 @@ class HindsightMemory(BaseMemory):
@classmethod
def from_defaults(cls, **kwargs: Any) -> "HindsightMemory":
"""Create from defaults. Prefer ``from_client()`` instead."""
raise NotImplementedError(
"Use HindsightMemory.from_client() or HindsightMemory.from_url() instead."
)
raise NotImplementedError("Use HindsightMemory.from_client() or HindsightMemory.from_url() instead.")
@classmethod
def from_client(
@@ -232,7 +222,7 @@ class HindsightMemory(BaseMemory):
if answer_match:
# Use the last Answer: block (final answer after reasoning)
last_answer = answer_match[-1]
return content[last_answer.end():].strip()
return content[last_answer.end() :].strip()
# ReAct traces with no Answer: — skip retention
return ""
@@ -341,16 +331,12 @@ class HindsightMemory(BaseMemory):
memories_text = self._recall_memories(input)
if memories_text:
system_content = self.system_prompt.format(memories=memories_text)
messages.append(
ChatMessage(role=MessageRole.SYSTEM, content=system_content)
)
messages.append(ChatMessage(role=MessageRole.SYSTEM, content=system_content))
messages.extend(self._chat_history)
return messages
async def aget(
self, input: Optional[str] = None, **kwargs: Any
) -> list[ChatMessage]:
async def aget(self, input: Optional[str] = None, **kwargs: Any) -> list[ChatMessage]:
"""Async version of get()."""
messages: list[ChatMessage] = []
@@ -358,9 +344,7 @@ class HindsightMemory(BaseMemory):
memories_text = await self._arecall_memories(input)
if memories_text:
system_content = self.system_prompt.format(memories=memories_text)
messages.append(
ChatMessage(role=MessageRole.SYSTEM, content=system_content)
)
messages.append(ChatMessage(role=MessageRole.SYSTEM, content=system_content))
messages.extend(self._chat_history)
return messages
@@ -106,32 +106,18 @@ class HindsightToolSpec(BaseToolSpec):
# Resolve effective values using None-sentinel config fallback
config = get_config()
self._tags = tags if tags is not None else (config.tags if config else None)
self._recall_tags = (
recall_tags
if recall_tags is not None
else (config.recall_tags if config else None)
)
self._recall_tags = recall_tags if recall_tags is not None else (config.recall_tags if config else None)
self._recall_tags_match = (
recall_tags_match
if recall_tags_match is not None
else (config.recall_tags_match if config else "any")
)
self._budget = (
budget if budget is not None else (config.budget if config else "mid")
)
self._max_tokens = (
max_tokens
if max_tokens is not None
else (config.max_tokens if config else 4096)
recall_tags_match if recall_tags_match is not None else (config.recall_tags_match if config else "any")
)
self._budget = budget if budget is not None else (config.budget if config else "mid")
self._max_tokens = max_tokens if max_tokens is not None else (config.max_tokens if config else 4096)
# Retain-specific
self._retain_metadata = retain_metadata
self._retain_document_id = retain_document_id
self._retain_context = (
retain_context
if retain_context is not None
else (config.context if config else "llamaindex")
retain_context if retain_context is not None else (config.context if config else "llamaindex")
)
# Recall-specific
@@ -146,9 +132,7 @@ class HindsightToolSpec(BaseToolSpec):
self._reflect_tags_match = reflect_tags_match
# Bank management
self._mission = (
mission if mission is not None else (config.mission if config else None)
)
self._mission = mission if mission is not None else (config.mission if config else None)
def _ensure_bank(self) -> None:
"""Create/update the bank with mission if not already done."""
@@ -230,12 +214,8 @@ class HindsightToolSpec(BaseToolSpec):
kwargs["max_tokens"] = effective_reflect_max
if self._reflect_response_schema:
kwargs["response_schema"] = self._reflect_response_schema
effective_reflect_tags = (
self._reflect_tags if self._reflect_tags is not None else self._recall_tags
)
effective_reflect_tags_match = (
self._reflect_tags_match or self._recall_tags_match
)
effective_reflect_tags = self._reflect_tags if self._reflect_tags is not None else self._recall_tags
effective_reflect_tags_match = self._reflect_tags_match or self._recall_tags_match
if effective_reflect_tags:
kwargs["tags"] = effective_reflect_tags
kwargs["tags_match"] = effective_reflect_tags_match
+7 -6
View File
@@ -63,12 +63,12 @@ Add the plugin config to `~/.openclaw/openclaw.json` under `plugins.entries.hind
**Config notes:**
| Field | Value | Why |
|-------|-------|-----|
| `hindsightApiUrl` + `hindsightApiToken` | External API URL + key | Skips the local daemon; no `uvx`/`uv` required inside the sandbox |
| `llmProvider: "claude-code"` | `"claude-code"` | Satisfies LLM detection without a separate API key — Claude Code is available in the sandbox via the `claude_code` policy |
| `dynamicBankId: false` | `false` | All conversations write to one bank; easier to verify during testing |
| `bankIdPrefix` | e.g. `"my-sandbox"` | Results in bank ID `my-sandbox-openclaw` |
| Field | Value | Why |
| --------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `hindsightApiUrl` + `hindsightApiToken` | External API URL + key | Skips the local daemon; no `uvx`/`uv` required inside the sandbox |
| `llmProvider: "claude-code"` | `"claude-code"` | Satisfies LLM detection without a separate API key — Claude Code is available in the sandbox via the `claude_code` policy |
| `dynamicBankId: false` | `false` | All conversations write to one bank; easier to verify during testing |
| `bankIdPrefix` | e.g. `"my-sandbox"` | Results in bank ID `my-sandbox-openclaw` |
> **Note:** The gateway log will say `Dynamic bank IDs disabled - using static bank: openclaw` — this is a misleading log message. The actual bank ID used at runtime correctly applies the prefix (e.g. `my-sandbox-openclaw`). You can verify by watching for `[Hindsight] Default bank: my-sandbox-openclaw` in the logs after full initialization.
@@ -183,6 +183,7 @@ openclaw plugins install /path/to/hindsight-integrations/openclaw # no --link
**`[Hindsight] Failed to retain memory (HTTP 403)`**
The sandbox network policy is blocking the outbound call. Check that:
1. The `hindsight` network policy block is present in your policy YAML
2. The policy was applied and shows `Status: Loaded` (`openshell policy get <name>`)
3. The `binaries` list includes `/usr/local/bin/openclaw`
+19 -17
View File
@@ -1,5 +1,5 @@
import { runSetup } from './setup.js';
import type { CliArgs } from './types.js';
import { runSetup } from "./setup.js";
import type { CliArgs } from "./types.js";
function usage(): void {
process.stdout.write(`
@@ -32,12 +32,12 @@ Example:
function parseArgs(argv: string[]): CliArgs | null {
const args = argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
usage();
return null;
}
if (args[0] !== 'setup') {
if (args[0] !== "setup") {
process.stderr.write(`Unknown command: ${args[0]}\nRun with --help for usage.\n`);
process.exit(1);
}
@@ -48,19 +48,21 @@ function parseArgs(argv: string[]): CliArgs | null {
return args[idx + 1];
};
const sandbox = get('--sandbox');
const apiUrl = get('--api-url');
const apiToken = get('--api-token');
const bankPrefix = get('--bank-prefix');
const sandbox = get("--sandbox");
const apiUrl = get("--api-url");
const apiToken = get("--api-token");
const bankPrefix = get("--bank-prefix");
const missing: string[] = [];
if (!sandbox) missing.push('--sandbox');
if (!apiUrl) missing.push('--api-url');
if (!apiToken) missing.push('--api-token');
if (!bankPrefix) missing.push('--bank-prefix');
if (!sandbox) missing.push("--sandbox");
if (!apiUrl) missing.push("--api-url");
if (!apiToken) missing.push("--api-token");
if (!bankPrefix) missing.push("--bank-prefix");
if (missing.length > 0) {
process.stderr.write(`Missing required options: ${missing.join(', ')}\nRun with --help for usage.\n`);
process.stderr.write(
`Missing required options: ${missing.join(", ")}\nRun with --help for usage.\n`
);
process.exit(1);
}
@@ -69,15 +71,15 @@ function parseArgs(argv: string[]): CliArgs | null {
apiUrl: apiUrl!,
apiToken: apiToken!,
bankPrefix: bankPrefix!,
skipPolicy: args.includes('--skip-policy'),
skipPluginInstall: args.includes('--skip-plugin-install'),
dryRun: args.includes('--dry-run'),
skipPolicy: args.includes("--skip-policy"),
skipPluginInstall: args.includes("--skip-plugin-install"),
dryRun: args.includes("--dry-run"),
};
}
const args = parseArgs(process.argv);
if (args) {
runSetup(args).catch(err => {
runSetup(args).catch((err) => {
process.stderr.write(`\nError: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});
@@ -1,101 +1,101 @@
import { describe, it, expect } from 'vitest';
import { mergePluginConfig } from './openclaw-config.js';
import type { OpenClawConfig, HindsightPluginConfig } from './openclaw-config.js';
import { describe, it, expect } from "vitest";
import { mergePluginConfig } from "./openclaw-config.js";
import type { OpenClawConfig, HindsightPluginConfig } from "./openclaw-config.js";
const PLUGIN_CONFIG: HindsightPluginConfig = {
hindsightApiUrl: 'https://api.hindsight.vectorize.io',
hindsightApiToken: 'hsk_test123',
llmProvider: 'claude-code',
hindsightApiUrl: "https://api.hindsight.vectorize.io",
hindsightApiToken: "hsk_test123",
llmProvider: "claude-code",
dynamicBankId: false,
bankIdPrefix: 'my-sandbox',
bankIdPrefix: "my-sandbox",
};
const BASE_CONFIG: OpenClawConfig = {
meta: { lastTouchedVersion: '2026.3.2' },
gateway: { port: 18789, mode: 'local' },
meta: { lastTouchedVersion: "2026.3.2" },
gateway: { port: 18789, mode: "local" },
agents: {
defaults: { model: { primary: 'openai/gpt-5' } },
defaults: { model: { primary: "openai/gpt-5" } },
},
plugins: {
slots: { memory: 'memory-core' },
slots: { memory: "memory-core" },
entries: {
'memory-core': { enabled: false },
"memory-core": { enabled: false },
},
},
};
describe('mergePluginConfig', () => {
it('sets hindsight-openclaw as the memory slot', () => {
describe("mergePluginConfig", () => {
it("sets hindsight-openclaw as the memory slot", () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.plugins?.slots?.memory).toBe('hindsight-openclaw');
expect(result.plugins?.slots?.memory).toBe("hindsight-openclaw");
});
it('enables the hindsight-openclaw entry', () => {
it("enables the hindsight-openclaw entry", () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.plugins?.entries?.['hindsight-openclaw']?.enabled).toBe(true);
expect(result.plugins?.entries?.["hindsight-openclaw"]?.enabled).toBe(true);
});
it('writes the full plugin config', () => {
it("writes the full plugin config", () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
const config = result.plugins?.entries?.['hindsight-openclaw']?.config;
expect(config?.hindsightApiUrl).toBe('https://api.hindsight.vectorize.io');
expect(config?.hindsightApiToken).toBe('hsk_test123');
expect(config?.llmProvider).toBe('claude-code');
const config = result.plugins?.entries?.["hindsight-openclaw"]?.config;
expect(config?.hindsightApiUrl).toBe("https://api.hindsight.vectorize.io");
expect(config?.hindsightApiToken).toBe("hsk_test123");
expect(config?.llmProvider).toBe("claude-code");
expect(config?.dynamicBankId).toBe(false);
expect(config?.bankIdPrefix).toBe('my-sandbox');
expect(config?.bankIdPrefix).toBe("my-sandbox");
});
it('preserves existing top-level config fields', () => {
it("preserves existing top-level config fields", () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.gateway).toEqual({ port: 18789, mode: 'local' });
expect(result.gateway).toEqual({ port: 18789, mode: "local" });
expect(result.agents).toBeDefined();
});
it('preserves existing plugin entries', () => {
it("preserves existing plugin entries", () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.plugins?.entries?.['memory-core']?.enabled).toBe(false);
expect(result.plugins?.entries?.["memory-core"]?.enabled).toBe(false);
});
it('merges into existing hindsight-openclaw entry without overwriting other fields', () => {
it("merges into existing hindsight-openclaw entry without overwriting other fields", () => {
const configWithExisting: OpenClawConfig = {
...BASE_CONFIG,
plugins: {
...BASE_CONFIG.plugins,
entries: {
...BASE_CONFIG.plugins?.entries,
'hindsight-openclaw': {
"hindsight-openclaw": {
enabled: true,
config: { embedPackagePath: '/some/local/path' },
config: { embedPackagePath: "/some/local/path" },
},
},
},
};
const result = mergePluginConfig(configWithExisting, PLUGIN_CONFIG);
const config = result.plugins?.entries?.['hindsight-openclaw']?.config;
const config = result.plugins?.entries?.["hindsight-openclaw"]?.config;
// New fields written
expect(config?.hindsightApiUrl).toBe('https://api.hindsight.vectorize.io');
expect(config?.hindsightApiUrl).toBe("https://api.hindsight.vectorize.io");
// Existing custom field preserved
expect(config?.embedPackagePath).toBe('/some/local/path');
expect(config?.embedPackagePath).toBe("/some/local/path");
});
it('handles missing plugins section gracefully', () => {
it("handles missing plugins section gracefully", () => {
const minimal: OpenClawConfig = { gateway: { port: 18789 } };
const result = mergePluginConfig(minimal, PLUGIN_CONFIG);
expect(result.plugins?.slots?.memory).toBe('hindsight-openclaw');
expect(result.plugins?.entries?.['hindsight-openclaw']?.enabled).toBe(true);
expect(result.plugins?.slots?.memory).toBe("hindsight-openclaw");
expect(result.plugins?.entries?.["hindsight-openclaw"]?.enabled).toBe(true);
});
it('does not mutate the original config', () => {
it("does not mutate the original config", () => {
const original = JSON.parse(JSON.stringify(BASE_CONFIG)) as OpenClawConfig;
mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(JSON.stringify(BASE_CONFIG)).toBe(JSON.stringify(original));
});
it('records install metadata', () => {
it("records install metadata", () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
const install = result.plugins?.installs?.['hindsight-openclaw'] as Record<string, unknown>;
expect(install?.source).toBe('npm');
expect(install?.version).toBe('latest');
expect(typeof install?.installedAt).toBe('string');
const install = result.plugins?.installs?.["hindsight-openclaw"] as Record<string, unknown>;
expect(install?.source).toBe("npm");
expect(install?.version).toBe("latest");
expect(typeof install?.installedAt).toBe("string");
});
});
@@ -1,9 +1,9 @@
import { readFile, writeFile, rename } from 'fs/promises';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { randomBytes } from 'crypto';
import { readFile, writeFile, rename } from "fs/promises";
import { join, dirname } from "path";
import { homedir } from "os";
import { randomBytes } from "crypto";
const CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
const CONFIG_PATH = join(homedir(), ".openclaw", "openclaw.json");
export interface HindsightPluginConfig {
hindsightApiUrl: string;
@@ -26,13 +26,13 @@ export interface OpenClawConfig {
export async function readOpenClawConfig(configPath = CONFIG_PATH): Promise<OpenClawConfig> {
let raw: string;
try {
raw = await readFile(configPath, 'utf8');
raw = await readFile(configPath, "utf8");
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
if (code === "ENOENT") {
throw new Error(
`OpenClaw config not found at ${configPath}.\n` +
`Run \`openclaw\` once to initialize it, then re-run setup.`
`Run \`openclaw\` once to initialize it, then re-run setup.`
);
}
throw err;
@@ -46,7 +46,7 @@ export function mergePluginConfig(
): OpenClawConfig {
const plugins = config.plugins ?? {};
const entries = plugins.entries ?? {};
const existing = entries['hindsight-openclaw'] ?? { enabled: true };
const existing = entries["hindsight-openclaw"] ?? { enabled: true };
return {
...config,
@@ -54,11 +54,11 @@ export function mergePluginConfig(
...plugins,
slots: {
...(plugins.slots ?? {}),
memory: 'hindsight-openclaw',
memory: "hindsight-openclaw",
},
entries: {
...entries,
'hindsight-openclaw': {
"hindsight-openclaw": {
...existing,
enabled: true,
config: {
@@ -73,9 +73,9 @@ export function mergePluginConfig(
},
installs: {
...(plugins.installs ?? {}),
'hindsight-openclaw': {
source: 'npm',
version: 'latest',
"hindsight-openclaw": {
source: "npm",
version: "latest",
installedAt: new Date().toISOString(),
},
},
@@ -87,9 +87,9 @@ export async function writeOpenClawConfig(
config: OpenClawConfig,
configPath = CONFIG_PATH
): Promise<void> {
const contents = JSON.stringify(config, null, 2) + '\n';
const tmp = `${configPath}.${randomBytes(6).toString('hex')}.tmp`;
await writeFile(tmp, contents, 'utf8');
const contents = JSON.stringify(config, null, 2) + "\n";
const tmp = `${configPath}.${randomBytes(6).toString("hex")}.tmp`;
await writeFile(tmp, contents, "utf8");
await rename(tmp, configPath);
}
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { stripAnsi, extractPolicyYaml, parseSandboxPolicy } from './policy-reader.js';
import { serializePolicy } from './policy-writer.js';
import { describe, it, expect } from "vitest";
import { stripAnsi, extractPolicyYaml, parseSandboxPolicy } from "./policy-reader.js";
import { serializePolicy } from "./policy-writer.js";
// Fixture: actual output of `openshell sandbox get my-assistant`
// (ANSI codes represented as escape sequences)
@@ -36,63 +36,68 @@ const FIXTURE_RAW = `\x1b[1m\x1b[36mSandbox:\x1b[39m\x1b[0m
\x1b[2m- \x1b[0mpath: /usr/local/bin/claude
`;
describe('stripAnsi', () => {
it('removes ANSI escape codes', () => {
expect(stripAnsi('\x1b[1m\x1b[36mHello\x1b[39m\x1b[0m')).toBe('Hello');
describe("stripAnsi", () => {
it("removes ANSI escape codes", () => {
expect(stripAnsi("\x1b[1m\x1b[36mHello\x1b[39m\x1b[0m")).toBe("Hello");
});
it('leaves plain strings unchanged', () => {
expect(stripAnsi('version: 1')).toBe('version: 1');
it("leaves plain strings unchanged", () => {
expect(stripAnsi("version: 1")).toBe("version: 1");
});
it('handles strings with no ANSI codes', () => {
expect(stripAnsi(' - /usr')).toBe(' - /usr');
it("handles strings with no ANSI codes", () => {
expect(stripAnsi(" - /usr")).toBe(" - /usr");
});
});
describe('extractPolicyYaml', () => {
it('extracts the Policy: section and dedents by 2 spaces', () => {
describe("extractPolicyYaml", () => {
it("extracts the Policy: section and dedents by 2 spaces", () => {
const result = extractPolicyYaml(FIXTURE_RAW);
expect(result).toContain('version: 1');
expect(result).toContain('filesystem_policy:');
expect(result).toContain('network_policies:');
expect(result).toContain("version: 1");
expect(result).toContain("filesystem_policy:");
expect(result).toContain("network_policies:");
});
it('does not include the Sandbox: section', () => {
it("does not include the Sandbox: section", () => {
const result = extractPolicyYaml(FIXTURE_RAW);
expect(result).not.toContain('Sandbox:');
expect(result).not.toContain('my-assistant');
expect(result).not.toContain("Sandbox:");
expect(result).not.toContain("my-assistant");
});
it('throws if Policy: section is missing', () => {
expect(() => extractPolicyYaml('no policy here')).toThrow('Could not find "Policy:"');
it("throws if Policy: section is missing", () => {
expect(() => extractPolicyYaml("no policy here")).toThrow('Could not find "Policy:"');
});
});
describe('parseSandboxPolicy', () => {
it('parses version field', () => {
describe("parseSandboxPolicy", () => {
it("parses version field", () => {
const policy = parseSandboxPolicy(FIXTURE_RAW);
expect(policy.version).toBe(1);
});
it('parses filesystem_policy', () => {
it("parses filesystem_policy", () => {
const policy = parseSandboxPolicy(FIXTURE_RAW);
expect(policy.filesystem_policy?.include_workdir).toBe(true);
expect(policy.filesystem_policy?.read_only).toContain('/usr');
expect(policy.filesystem_policy?.read_only).toContain("/usr");
});
it('parses network_policies', () => {
it("parses network_policies", () => {
const policy = parseSandboxPolicy(FIXTURE_RAW);
expect(policy.network_policies).toBeDefined();
expect(policy.network_policies?.claude_code).toBeDefined();
expect(policy.network_policies?.claude_code?.name).toBe('claude_code');
expect(policy.network_policies?.claude_code?.name).toBe("claude_code");
});
it('is idempotent — parse → serialize → parse yields same structure', () => {
it("is idempotent — parse → serialize → parse yields same structure", () => {
const policy1 = parseSandboxPolicy(FIXTURE_RAW);
const yamlStr = serializePolicy(policy1);
// Re-wrap in a Policy: header to match the expected format
const wrapped = 'Policy:\n' + yamlStr.split('\n').map((l: string) => ` ${l}`).join('\n');
const wrapped =
"Policy:\n" +
yamlStr
.split("\n")
.map((l: string) => ` ${l}`)
.join("\n");
const policy2 = parseSandboxPolicy(wrapped);
expect(policy2.version).toBe(policy1.version);
expect(Object.keys(policy2.network_policies ?? {})).toEqual(
@@ -1,13 +1,13 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import yaml from 'js-yaml';
import type { SandboxPolicy } from './types.js';
import { execFile } from "child_process";
import { promisify } from "util";
import yaml from "js-yaml";
import type { SandboxPolicy } from "./types.js";
const execFileAsync = promisify(execFile);
/** Strip ANSI escape codes from a string */
export function stripAnsi(str: string): string {
return str.replace(/\x1B\[[0-9;]*m/g, '');
return str.replace(/\x1B\[[0-9;]*m/g, "");
}
/**
@@ -27,9 +27,9 @@ export function stripAnsi(str: string): string {
*/
export function extractPolicyYaml(raw: string): string {
const stripped = stripAnsi(raw);
const lines = stripped.split('\n');
const lines = stripped.split("\n");
const policyHeaderIdx = lines.findIndex(l => l.trimEnd() === 'Policy:');
const policyHeaderIdx = lines.findIndex((l) => l.trimEnd() === "Policy:");
if (policyHeaderIdx === -1) {
throw new Error('Could not find "Policy:" section in `openshell sandbox get` output');
}
@@ -37,17 +37,17 @@ export function extractPolicyYaml(raw: string): string {
const policyLines = lines.slice(policyHeaderIdx + 1);
// Dedent by 2 spaces (the policy block is indented under `Policy:`)
const dedented = policyLines.map(l => {
if (l.startsWith(' ')) return l.slice(2);
const dedented = policyLines.map((l) => {
if (l.startsWith(" ")) return l.slice(2);
return l;
});
// Drop trailing empty lines
while (dedented.length > 0 && dedented[dedented.length - 1].trim() === '') {
while (dedented.length > 0 && dedented[dedented.length - 1].trim() === "") {
dedented.pop();
}
return dedented.join('\n');
return dedented.join("\n");
}
/**
@@ -59,16 +59,16 @@ export function parseSandboxPolicy(rawOutput: string): SandboxPolicy {
try {
const parsed = yaml.load(policyYaml);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('Parsed policy is not an object');
if (typeof parsed !== "object" || parsed === null) {
throw new Error("Parsed policy is not an object");
}
return parsed as SandboxPolicy;
} catch (err) {
throw new Error(
`Failed to parse sandbox policy YAML.\n` +
`This may mean the openshell output format has changed.\n` +
`Apply the Hindsight policy manually using the instructions in NEMOCLAW.md.\n` +
`Parse error: ${err}`
`This may mean the openshell output format has changed.\n` +
`Apply the Hindsight policy manually using the instructions in NEMOCLAW.md.\n` +
`Parse error: ${err}`
);
}
}
@@ -77,7 +77,7 @@ export function parseSandboxPolicy(rawOutput: string): SandboxPolicy {
export async function readSandboxPolicy(sandboxName: string): Promise<SandboxPolicy> {
let stdout: string;
try {
const result = await execFileAsync('openshell', ['sandbox', 'get', sandboxName]);
const result = await execFileAsync("openshell", ["sandbox", "get", sandboxName]);
stdout = result.stdout;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
@@ -1,75 +1,75 @@
import { describe, it, expect } from 'vitest';
import { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } from './policy-writer.js';
import { parseSandboxPolicy } from './policy-reader.js';
import type { SandboxPolicy } from './types.js';
import { HINDSIGHT_HOST, OPENCLAW_BINARY } from './types.js';
import { describe, it, expect } from "vitest";
import { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } from "./policy-writer.js";
import { parseSandboxPolicy } from "./policy-reader.js";
import type { SandboxPolicy } from "./types.js";
import { HINDSIGHT_HOST, OPENCLAW_BINARY } from "./types.js";
const BASE_POLICY: SandboxPolicy = {
version: 1,
filesystem_policy: {
include_workdir: true,
read_only: ['/usr', '/lib'],
read_write: ['/sandbox', '/tmp'],
read_only: ["/usr", "/lib"],
read_write: ["/sandbox", "/tmp"],
},
network_policies: {
claude_code: {
name: 'claude_code',
name: "claude_code",
endpoints: [
{
host: 'api.anthropic.com',
host: "api.anthropic.com",
port: 443,
rules: [{ allow: { method: '*', path: '/**' } }],
rules: [{ allow: { method: "*", path: "/**" } }],
},
],
binaries: [{ path: '/usr/local/bin/claude' }],
binaries: [{ path: "/usr/local/bin/claude" }],
},
},
};
describe('hasHindsightPolicy', () => {
it('returns false when no hindsight policy exists', () => {
describe("hasHindsightPolicy", () => {
it("returns false when no hindsight policy exists", () => {
expect(hasHindsightPolicy(BASE_POLICY)).toBe(false);
});
it('returns false when network_policies is undefined', () => {
it("returns false when network_policies is undefined", () => {
expect(hasHindsightPolicy({ version: 1 })).toBe(false);
});
it('returns true when hindsight policy is present', () => {
it("returns true when hindsight policy is present", () => {
const withHindsight = mergeHindsightPolicy(BASE_POLICY);
expect(hasHindsightPolicy(withHindsight)).toBe(true);
});
});
describe('mergeHindsightPolicy', () => {
it('adds the hindsight network policy block', () => {
describe("mergeHindsightPolicy", () => {
it("adds the hindsight network policy block", () => {
const result = mergeHindsightPolicy(BASE_POLICY);
expect(result.network_policies?.hindsight).toBeDefined();
expect(result.network_policies?.hindsight?.endpoints[0].host).toBe(HINDSIGHT_HOST);
});
it('preserves all existing network policies', () => {
it("preserves all existing network policies", () => {
const result = mergeHindsightPolicy(BASE_POLICY);
expect(result.network_policies?.claude_code).toBeDefined();
expect(result.network_policies?.claude_code?.name).toBe('claude_code');
expect(result.network_policies?.claude_code?.name).toBe("claude_code");
});
it('sets the correct binary path', () => {
it("sets the correct binary path", () => {
const result = mergeHindsightPolicy(BASE_POLICY);
const binaries = result.network_policies?.hindsight?.binaries ?? [];
expect(binaries.some(b => b.path === OPENCLAW_BINARY)).toBe(true);
expect(binaries.some((b) => b.path === OPENCLAW_BINARY)).toBe(true);
});
it('includes GET, POST, and PUT rules', () => {
it("includes GET, POST, and PUT rules", () => {
const result = mergeHindsightPolicy(BASE_POLICY);
const rules = result.network_policies?.hindsight?.endpoints[0].rules ?? [];
const methods = rules.map(r => r.allow.method);
expect(methods).toContain('GET');
expect(methods).toContain('POST');
expect(methods).toContain('PUT');
const methods = rules.map((r) => r.allow.method);
expect(methods).toContain("GET");
expect(methods).toContain("POST");
expect(methods).toContain("PUT");
});
it('is idempotent — merging twice yields the same result', () => {
it("is idempotent — merging twice yields the same result", () => {
const once = mergeHindsightPolicy(BASE_POLICY);
const twice = mergeHindsightPolicy(once);
expect(JSON.stringify(twice.network_policies?.hindsight)).toBe(
@@ -77,7 +77,7 @@ describe('mergeHindsightPolicy', () => {
);
});
it('does not mutate the original policy', () => {
it("does not mutate the original policy", () => {
const original = JSON.parse(JSON.stringify(BASE_POLICY)) as SandboxPolicy;
mergeHindsightPolicy(BASE_POLICY);
expect(BASE_POLICY.network_policies?.hindsight).toBeUndefined();
@@ -85,23 +85,28 @@ describe('mergeHindsightPolicy', () => {
});
});
describe('serializePolicy', () => {
it('produces valid YAML that round-trips through parseSandboxPolicy', () => {
describe("serializePolicy", () => {
it("produces valid YAML that round-trips through parseSandboxPolicy", () => {
const merged = mergeHindsightPolicy(BASE_POLICY);
const yamlStr = serializePolicy(merged);
// Wrap in Policy: header as parseSandboxPolicy expects
const wrapped = 'Policy:\n' + yamlStr.split('\n').map(l => ` ${l}`).join('\n');
const wrapped =
"Policy:\n" +
yamlStr
.split("\n")
.map((l) => ` ${l}`)
.join("\n");
const reparsed = parseSandboxPolicy(wrapped);
expect(reparsed.version).toBe(merged.version);
expect(reparsed.network_policies?.hindsight?.endpoints[0].host).toBe(HINDSIGHT_HOST);
expect(reparsed.network_policies?.claude_code).toBeDefined();
});
it('includes all network policies in output', () => {
it("includes all network policies in output", () => {
const merged = mergeHindsightPolicy(BASE_POLICY);
const yaml = serializePolicy(merged);
expect(yaml).toContain('claude_code:');
expect(yaml).toContain('hindsight:');
expect(yaml).toContain("claude_code:");
expect(yaml).toContain("hindsight:");
expect(yaml).toContain(HINDSIGHT_HOST);
});
});
@@ -1,6 +1,6 @@
import yaml from 'js-yaml';
import type { SandboxPolicy } from './types.js';
import { HINDSIGHT_POLICY_NAME, HINDSIGHT_HOST, OPENCLAW_BINARY } from './types.js';
import yaml from "js-yaml";
import type { SandboxPolicy } from "./types.js";
import { HINDSIGHT_POLICY_NAME, HINDSIGHT_HOST, OPENCLAW_BINARY } from "./types.js";
const HINDSIGHT_NETWORK_POLICY = {
name: HINDSIGHT_POLICY_NAME,
@@ -8,13 +8,13 @@ const HINDSIGHT_NETWORK_POLICY = {
{
host: HINDSIGHT_HOST,
port: 443,
protocol: 'rest',
tls: 'terminate',
enforcement: 'enforce',
protocol: "rest",
tls: "terminate",
enforcement: "enforce",
rules: [
{ allow: { method: 'GET', path: '/**' } },
{ allow: { method: 'POST', path: '/**' } },
{ allow: { method: 'PUT', path: '/**' } },
{ allow: { method: "GET", path: "/**" } },
{ allow: { method: "POST", path: "/**" } },
{ allow: { method: "PUT", path: "/**" } },
],
},
],
@@ -27,7 +27,7 @@ const HINDSIGHT_NETWORK_POLICY = {
export function hasHindsightPolicy(policy: SandboxPolicy): boolean {
const np = policy.network_policies?.[HINDSIGHT_POLICY_NAME];
if (!np) return false;
return np.endpoints?.some(e => e.host === HINDSIGHT_HOST) ?? false;
return np.endpoints?.some((e) => e.host === HINDSIGHT_HOST) ?? false;
}
/**
@@ -1,173 +1,186 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { CliArgs } from './types.js';
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { CliArgs } from "./types.js";
// Mock all external I/O before importing setup
vi.mock('child_process', () => ({
vi.mock("child_process", () => ({
execFile: vi.fn(),
}));
vi.mock('./policy-reader.js', () => ({
vi.mock("./policy-reader.js", () => ({
readSandboxPolicy: vi.fn(),
}));
vi.mock('./policy-writer.js', () => ({
vi.mock("./policy-writer.js", () => ({
hasHindsightPolicy: vi.fn(),
mergeHindsightPolicy: vi.fn(),
serializePolicy: vi.fn(),
}));
vi.mock('./openclaw-config.js', () => ({
vi.mock("./openclaw-config.js", () => ({
applyPluginConfig: vi.fn(),
}));
vi.mock('fs/promises', () => ({
vi.mock("fs/promises", () => ({
writeFile: vi.fn(),
rm: vi.fn(),
}));
const BASE_ARGS: CliArgs = {
sandbox: 'my-assistant',
apiUrl: 'https://api.hindsight.vectorize.io',
apiToken: 'hsk_test123',
bankPrefix: 'my-sandbox',
sandbox: "my-assistant",
apiUrl: "https://api.hindsight.vectorize.io",
apiToken: "hsk_test123",
bankPrefix: "my-sandbox",
skipPolicy: false,
skipPluginInstall: false,
dryRun: false,
};
describe('runSetup', () => {
describe("runSetup", () => {
beforeEach(async () => {
vi.clearAllMocks();
const { execFile } = await import('child_process');
const { execFile } = await import("child_process");
const execFileMock = vi.mocked(execFile);
// Default: all shell commands succeed
execFileMock.mockImplementation((_cmd, _args, callback?: unknown) => {
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
if (typeof callback === "function") {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(null, {
stdout: "",
stderr: "",
});
}
return {} as ReturnType<typeof execFile>;
});
const { readSandboxPolicy } = await import('./policy-reader.js');
const { readSandboxPolicy } = await import("./policy-reader.js");
vi.mocked(readSandboxPolicy).mockResolvedValue({
version: 1,
network_policies: { claude_code: { name: 'claude_code', endpoints: [] } },
network_policies: { claude_code: { name: "claude_code", endpoints: [] } },
});
const { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } = await import('./policy-writer.js');
const { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } =
await import("./policy-writer.js");
vi.mocked(hasHindsightPolicy).mockReturnValue(false);
vi.mocked(mergeHindsightPolicy).mockImplementation(p => ({ ...p, network_policies: { ...p.network_policies, hindsight: { name: 'hindsight', endpoints: [] } } }));
vi.mocked(serializePolicy).mockReturnValue('version: 1\n');
vi.mocked(mergeHindsightPolicy).mockImplementation((p) => ({
...p,
network_policies: { ...p.network_policies, hindsight: { name: "hindsight", endpoints: [] } },
}));
vi.mocked(serializePolicy).mockReturnValue("version: 1\n");
const { applyPluginConfig } = await import('./openclaw-config.js');
const { applyPluginConfig } = await import("./openclaw-config.js");
vi.mocked(applyPluginConfig).mockResolvedValue(undefined);
});
it('runs all steps in order for a clean install', async () => {
const { execFile } = await import('child_process');
it("runs all steps in order for a clean install", async () => {
const { execFile } = await import("child_process");
const calls: string[] = [];
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
calls.push(`${cmd} ${(args as string[]).join(' ')}`);
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
calls.push(`${cmd} ${(args as string[]).join(" ")}`);
if (typeof callback === "function") {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(null, {
stdout: "",
stderr: "",
});
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
const { runSetup } = await import("./setup.js");
await runSetup(BASE_ARGS);
expect(calls.some(c => c.includes('which openshell'))).toBe(true);
expect(calls.some(c => c.includes('which openclaw'))).toBe(true);
expect(calls.some(c => c.includes('openclaw plugins install @vectorize-io/hindsight-openclaw'))).toBe(true);
expect(calls.some(c => c.includes('openshell policy set my-assistant'))).toBe(true);
expect(calls.some(c => c.includes('openclaw gateway restart'))).toBe(true);
expect(calls.some((c) => c.includes("which openshell"))).toBe(true);
expect(calls.some((c) => c.includes("which openclaw"))).toBe(true);
expect(
calls.some((c) => c.includes("openclaw plugins install @vectorize-io/hindsight-openclaw"))
).toBe(true);
expect(calls.some((c) => c.includes("openshell policy set my-assistant"))).toBe(true);
expect(calls.some((c) => c.includes("openclaw gateway restart"))).toBe(true);
});
it('skips plugin install when --skip-plugin-install is set', async () => {
const { execFile } = await import('child_process');
it("skips plugin install when --skip-plugin-install is set", async () => {
const { execFile } = await import("child_process");
const calls: string[] = [];
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
calls.push(`${cmd} ${(args as string[]).join(' ')}`);
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
calls.push(`${cmd} ${(args as string[]).join(" ")}`);
if (typeof callback === "function") {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(null, {
stdout: "",
stderr: "",
});
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
const { runSetup } = await import("./setup.js");
await runSetup({ ...BASE_ARGS, skipPluginInstall: true });
expect(calls.some(c => c.includes('plugins install'))).toBe(false);
expect(calls.some((c) => c.includes("plugins install"))).toBe(false);
});
it('skips policy update when --skip-policy is set', async () => {
const { runSetup } = await import('./setup.js');
const { readSandboxPolicy } = await import('./policy-reader.js');
it("skips policy update when --skip-policy is set", async () => {
const { runSetup } = await import("./setup.js");
const { readSandboxPolicy } = await import("./policy-reader.js");
await runSetup({ ...BASE_ARGS, skipPolicy: true });
expect(vi.mocked(readSandboxPolicy)).not.toHaveBeenCalled();
});
it('skips policy set when Hindsight policy already exists', async () => {
const { hasHindsightPolicy } = await import('./policy-writer.js');
it("skips policy set when Hindsight policy already exists", async () => {
const { hasHindsightPolicy } = await import("./policy-writer.js");
vi.mocked(hasHindsightPolicy).mockReturnValue(true);
const { execFile } = await import('child_process');
const { execFile } = await import("child_process");
const calls: string[] = [];
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
calls.push(`${cmd} ${(args as string[]).join(' ')}`);
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
calls.push(`${cmd} ${(args as string[]).join(" ")}`);
if (typeof callback === "function") {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(null, {
stdout: "",
stderr: "",
});
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
const { runSetup } = await import("./setup.js");
await runSetup(BASE_ARGS);
expect(calls.some(c => c.includes('openshell policy set'))).toBe(false);
expect(calls.some((c) => c.includes("openshell policy set"))).toBe(false);
});
it('does not execute any shell commands in dry-run mode', async () => {
const { execFile } = await import('child_process');
const { applyPluginConfig } = await import('./openclaw-config.js');
const { writeFile } = await import('fs/promises');
it("does not execute any shell commands in dry-run mode", async () => {
const { execFile } = await import("child_process");
const { applyPluginConfig } = await import("./openclaw-config.js");
const { writeFile } = await import("fs/promises");
const { runSetup } = await import('./setup.js');
const { runSetup } = await import("./setup.js");
await runSetup({ ...BASE_ARGS, dryRun: true });
// which checks still run (preflight), but no actual commands
const execCalls = vi.mocked(execFile).mock.calls.map(c => `${c[0]} ${(c[1] as string[]).join(' ')}`);
expect(execCalls.some(c => c.includes('plugins install'))).toBe(false);
expect(execCalls.some(c => c.includes('policy set'))).toBe(false);
expect(execCalls.some(c => c.includes('gateway restart'))).toBe(false);
const execCalls = vi
.mocked(execFile)
.mock.calls.map((c) => `${c[0]} ${(c[1] as string[]).join(" ")}`);
expect(execCalls.some((c) => c.includes("plugins install"))).toBe(false);
expect(execCalls.some((c) => c.includes("policy set"))).toBe(false);
expect(execCalls.some((c) => c.includes("gateway restart"))).toBe(false);
expect(vi.mocked(applyPluginConfig)).not.toHaveBeenCalled();
expect(vi.mocked(writeFile)).not.toHaveBeenCalled();
});
it('fails early if openshell is not on PATH', async () => {
const { execFile } = await import('child_process');
it("fails early if openshell is not on PATH", async () => {
const { execFile } = await import("child_process");
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
if (cmd === 'which' && (args as string[])[0] === 'openshell') {
if (typeof callback === 'function') {
(callback as (err: Error) => void)(new Error('not found'));
if (cmd === "which" && (args as string[])[0] === "openshell") {
if (typeof callback === "function") {
(callback as (err: Error) => void)(new Error("not found"));
}
} else {
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
if (typeof callback === "function") {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(null, {
stdout: "",
stderr: "",
});
}
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
await expect(runSetup(BASE_ARGS)).rejects.toThrow('openshell');
const { runSetup } = await import("./setup.js");
await expect(runSetup(BASE_ARGS)).rejects.toThrow("openshell");
});
});
+76 -64
View File
@@ -1,13 +1,13 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import { writeFile, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { randomBytes } from 'crypto';
import type { CliArgs } from './types.js';
import { readSandboxPolicy } from './policy-reader.js';
import { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } from './policy-writer.js';
import { applyPluginConfig } from './openclaw-config.js';
import { execFile } from "child_process";
import { promisify } from "util";
import { writeFile, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { randomBytes } from "crypto";
import type { CliArgs } from "./types.js";
import { readSandboxPolicy } from "./policy-reader.js";
import { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } from "./policy-writer.js";
import { applyPluginConfig } from "./openclaw-config.js";
const execFileAsync = promisify(execFile);
@@ -21,7 +21,7 @@ function step(n: number, msg: string) {
async function which(bin: string): Promise<boolean> {
try {
await execFileAsync('which', [bin]);
await execFileAsync("which", [bin]);
return true;
} catch {
return false;
@@ -29,48 +29,50 @@ async function which(bin: string): Promise<boolean> {
}
export async function runSetup(args: CliArgs): Promise<void> {
log('\nhindsight-nemoclaw setup');
log('─'.repeat(40));
log("\nhindsight-nemoclaw setup");
log("─".repeat(40));
// Step 0 — Preflight
step(0, 'Preflight checks...');
const [hasOpenshell, hasOpenclaw] = await Promise.all([which('openshell'), which('openclaw')]);
step(0, "Preflight checks...");
const [hasOpenshell, hasOpenclaw] = await Promise.all([which("openshell"), which("openclaw")]);
if (!hasOpenshell) {
throw new Error('`openshell` not found on PATH. Install it from https://openshell.ai');
throw new Error("`openshell` not found on PATH. Install it from https://openshell.ai");
}
if (!hasOpenclaw) {
throw new Error('`openclaw` not found on PATH. Install it from https://openclaw.ai');
throw new Error("`openclaw` not found on PATH. Install it from https://openclaw.ai");
}
log(' ✓ openshell found');
log(' ✓ openclaw found');
log(" ✓ openshell found");
log(" ✓ openclaw found");
// Step 1 — Install hindsight-openclaw plugin
if (!args.skipPluginInstall) {
step(1, 'Installing @vectorize-io/hindsight-openclaw plugin...');
step(1, "Installing @vectorize-io/hindsight-openclaw plugin...");
if (args.dryRun) {
log(' [dry-run] would run: openclaw plugins install @vectorize-io/hindsight-openclaw');
log(" [dry-run] would run: openclaw plugins install @vectorize-io/hindsight-openclaw");
} else {
const { stdout } = await execFileAsync('openclaw', [
'plugins', 'install', '@vectorize-io/hindsight-openclaw',
const { stdout } = await execFileAsync("openclaw", [
"plugins",
"install",
"@vectorize-io/hindsight-openclaw",
]);
log(stdout.trim() || ' ✓ Plugin installed');
log(stdout.trim() || " ✓ Plugin installed");
}
} else {
step(1, 'Skipping plugin install (--skip-plugin-install)');
step(1, "Skipping plugin install (--skip-plugin-install)");
}
// Step 2 — Configure ~/.openclaw/openclaw.json
step(2, 'Configuring plugin in ~/.openclaw/openclaw.json...');
step(2, "Configuring plugin in ~/.openclaw/openclaw.json...");
const pluginConfig = {
hindsightApiUrl: args.apiUrl,
hindsightApiToken: args.apiToken,
llmProvider: 'claude-code',
llmProvider: "claude-code",
dynamicBankId: false,
bankIdPrefix: args.bankPrefix,
};
if (args.dryRun) {
log(` [dry-run] would write plugin config to ~/.openclaw/openclaw.json`);
log(` config: ${JSON.stringify(pluginConfig, null, 4).split('\n').join('\n ')}`);
log(` config: ${JSON.stringify(pluginConfig, null, 4).split("\n").join("\n ")}`);
} else {
await applyPluginConfig(pluginConfig);
log(` ✓ Plugin config written (bank: ${args.bankPrefix}-openclaw)`);
@@ -83,20 +85,30 @@ export async function runSetup(args: CliArgs): Promise<void> {
const currentPolicy = await readSandboxPolicy(args.sandbox);
if (hasHindsightPolicy(currentPolicy)) {
log(' ✓ Hindsight policy already present — skipping');
log(" ✓ Hindsight policy already present — skipping");
} else {
const updatedPolicy = mergeHindsightPolicy(currentPolicy);
const policyYaml = serializePolicy(updatedPolicy);
if (args.dryRun) {
log(' [dry-run] would apply policy:');
log(policyYaml.split('\n').map(l => ` ${l}`).join('\n'));
log(" [dry-run] would apply policy:");
log(
policyYaml
.split("\n")
.map((l) => ` ${l}`)
.join("\n")
);
} else {
const tmpFile = join(tmpdir(), `hindsight-policy-${randomBytes(6).toString('hex')}.yaml`);
const tmpFile = join(tmpdir(), `hindsight-policy-${randomBytes(6).toString("hex")}.yaml`);
try {
await writeFile(tmpFile, policyYaml, 'utf8');
const { stdout } = await execFileAsync('openshell', [
'policy', 'set', args.sandbox, '--policy', tmpFile, '--wait',
await writeFile(tmpFile, policyYaml, "utf8");
const { stdout } = await execFileAsync("openshell", [
"policy",
"set",
args.sandbox,
"--policy",
tmpFile,
"--wait",
]);
log(stdout.trim() || ` ✓ Policy applied to sandbox "${args.sandbox}"`);
} finally {
@@ -105,44 +117,44 @@ export async function runSetup(args: CliArgs): Promise<void> {
}
}
} else {
step(3, 'Skipping policy update (--skip-policy)');
log(' Add the following block to your sandbox network_policies manually:');
log('');
log(' hindsight:');
log(' name: hindsight');
log(' endpoints:');
log(' - host: api.hindsight.vectorize.io');
log(' port: 443');
log(' protocol: rest');
log(' tls: terminate');
log(' enforcement: enforce');
log(' rules:');
log(' - allow: { method: GET, path: /** }');
log(' - allow: { method: POST, path: /** }');
log(' - allow: { method: PUT, path: /** }');
log(' binaries:');
log(' - path: /usr/local/bin/openclaw');
step(3, "Skipping policy update (--skip-policy)");
log(" Add the following block to your sandbox network_policies manually:");
log("");
log(" hindsight:");
log(" name: hindsight");
log(" endpoints:");
log(" - host: api.hindsight.vectorize.io");
log(" port: 443");
log(" protocol: rest");
log(" tls: terminate");
log(" enforcement: enforce");
log(" rules:");
log(" - allow: { method: GET, path: /** }");
log(" - allow: { method: POST, path: /** }");
log(" - allow: { method: PUT, path: /** }");
log(" binaries:");
log(" - path: /usr/local/bin/openclaw");
}
// Step 4 — Restart gateway
step(4, 'Restarting OpenClaw gateway...');
step(4, "Restarting OpenClaw gateway...");
if (args.dryRun) {
log(' [dry-run] would run: openclaw gateway restart');
log(" [dry-run] would run: openclaw gateway restart");
} else {
await execFileAsync('openclaw', ['gateway', 'restart']);
log(' ✓ Gateway restarted');
await execFileAsync("openclaw", ["gateway", "restart"]);
log(" ✓ Gateway restarted");
}
log('\n' + '─'.repeat(40));
log('✓ Setup complete!\n');
log("\n" + "─".repeat(40));
log("✓ Setup complete!\n");
log(` Bank ID: ${args.bankPrefix}-openclaw`);
log(` API URL: ${args.apiUrl}`);
log('');
log(' Watch gateway logs to confirm:');
log(' grep Hindsight ~/.openclaw/logs/gateway.log | tail -5');
log(' Expected: [Hindsight] ✓ Ready (external API mode)');
log('');
log(' Test memory retention:');
log("");
log(" Watch gateway logs to confirm:");
log(" grep Hindsight ~/.openclaw/logs/gateway.log | tail -5");
log(" Expected: [Hindsight] ✓ Ready (external API mode)");
log("");
log(" Test memory retention:");
log(` openclaw agent --agent main --session-id test-1 -m "My name is Ben."`);
log(` openclaw agent --agent main --session-id test-2 -m "What do you remember about me?"`);
}
+3 -3
View File
@@ -58,6 +58,6 @@ export interface SandboxPolicy {
network_policies?: Record<string, NetworkPolicy>;
}
export const HINDSIGHT_POLICY_NAME = 'hindsight';
export const HINDSIGHT_HOST = 'api.hindsight.vectorize.io';
export const OPENCLAW_BINARY = '/usr/local/bin/openclaw';
export const HINDSIGHT_POLICY_NAME = "hindsight";
export const HINDSIGHT_HOST = "api.hindsight.vectorize.io";
export const OPENCLAW_BINARY = "/usr/local/bin/openclaw";
@@ -1,9 +1,9 @@
import { defineConfig } from 'vitest/config';
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
environment: "node",
include: ["src/**/*.test.ts"],
},
});
+61 -58
View File
@@ -48,17 +48,17 @@ openclaw config set plugins.entries.hindsight-openclaw.config.hindsightApiToken
previously came from shell env vars must now go through OpenClaw's plugin config
(with SecretRef for credentials). Concrete mappings:
| Old (0.5.x) | New (0.6.0) |
|---|---|
| `OPENAI_API_KEY=…` (auto-detected) | `openclaw config set plugins.entries.hindsight-openclaw.config.llmProvider openai` <br> `openclaw config set plugins.entries.hindsight-openclaw.config.llmApiKey --ref-source env --ref-id OPENAI_API_KEY` |
| `HINDSIGHT_API_LLM_PROVIDER=…` | `openclaw config set plugins.entries.hindsight-openclaw.config.llmProvider …` |
| `HINDSIGHT_API_LLM_MODEL=…` | `openclaw config set plugins.entries.hindsight-openclaw.config.llmModel …` |
| `HINDSIGHT_API_LLM_API_KEY=…` | `openclaw config set plugins.entries.hindsight-openclaw.config.llmApiKey --ref-source env --ref-id …` |
| `HINDSIGHT_API_LLM_BASE_URL=…` | `openclaw config set plugins.entries.hindsight-openclaw.config.llmBaseUrl …` |
| `HINDSIGHT_EMBED_API_URL=…` | `openclaw config set plugins.entries.hindsight-openclaw.config.hindsightApiUrl …` |
| `HINDSIGHT_EMBED_API_TOKEN=…` | `openclaw config set plugins.entries.hindsight-openclaw.config.hindsightApiToken --ref-source env --ref-id …` |
| `HINDSIGHT_BANK_ID=…` | `openclaw config set plugins.entries.hindsight-openclaw.config.bankId …` |
| `llmApiKeyEnv: "MY_KEY"` (plugin config) | `llmApiKey` configured as a SecretRef with `--ref-id MY_KEY` |
| Old (0.5.x) | New (0.6.0) |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OPENAI_API_KEY=…` (auto-detected) | `openclaw config set plugins.entries.hindsight-openclaw.config.llmProvider openai` <br> `openclaw config set plugins.entries.hindsight-openclaw.config.llmApiKey --ref-source env --ref-id OPENAI_API_KEY` |
| `HINDSIGHT_API_LLM_PROVIDER=…` | `openclaw config set plugins.entries.hindsight-openclaw.config.llmProvider …` |
| `HINDSIGHT_API_LLM_MODEL=…` | `openclaw config set plugins.entries.hindsight-openclaw.config.llmModel …` |
| `HINDSIGHT_API_LLM_API_KEY=…` | `openclaw config set plugins.entries.hindsight-openclaw.config.llmApiKey --ref-source env --ref-id …` |
| `HINDSIGHT_API_LLM_BASE_URL=…` | `openclaw config set plugins.entries.hindsight-openclaw.config.llmBaseUrl …` |
| `HINDSIGHT_EMBED_API_URL=…` | `openclaw config set plugins.entries.hindsight-openclaw.config.hindsightApiUrl …` |
| `HINDSIGHT_EMBED_API_TOKEN=…` | `openclaw config set plugins.entries.hindsight-openclaw.config.hindsightApiToken --ref-source env --ref-id …` |
| `HINDSIGHT_BANK_ID=…` | `openclaw config set plugins.entries.hindsight-openclaw.config.bankId …` |
| `llmApiKeyEnv: "MY_KEY"` (plugin config) | `llmApiKey` configured as a SecretRef with `--ref-id MY_KEY` |
If your shell already exports `OPENAI_API_KEY`, the SecretRef config above resolves
to the same value at startup — no need to change your shell setup, just point the
@@ -76,66 +76,67 @@ to confirm the new shape parses cleanly.
Optional settings in `~/.openclaw/openclaw.json` under `plugins.entries.hindsight-openclaw.config`:
| Option | Default | Description |
|--------|---------|-------------|
| `apiPort` | `9077` | Port for the local Hindsight daemon |
| `daemonIdleTimeout` | `0` | Seconds before daemon shuts down from inactivity (0 = never) |
| `embedPort` | `0` | Port for `hindsight-embed` server (`0` = auto-assign) |
| `embedVersion` | `"latest"` | hindsight-embed version |
| `embedPackagePath` | — | Local path to `hindsight-embed` package for development |
| `bankMission` | — | Agent identity/purpose stored on the memory bank. Helps the engine understand context for better fact extraction. Set once per bank — not a recall prompt. |
| `llmProvider` | — | LLM provider for memory extraction (`openai`, `anthropic`, `gemini`, `groq`, `ollama`, `openai-codex`, `claude-code`). Required unless `hindsightApiUrl` is set. |
| `llmModel` | provider default | LLM model used with `llmProvider` |
| `llmApiKey` | — | API key for the LLM provider. **Sensitive** — set via `openclaw config set ... --ref-source env --ref-id OPENAI_API_KEY` to reference an env var (or `--ref-source file`/`exec` for mounted-secret/Vault sources). |
| `llmBaseUrl` | — | Optional base URL override for OpenAI-compatible providers (e.g. `https://openrouter.ai/api/v1`) |
| `dynamicBankId` | `true` | Enable per-context memory banks |
| `bankId` | — | Static bank ID used when `dynamicBankId` is `false`. |
| `bankIdPrefix` | — | Prefix for bank IDs (e.g. `"prod"`) |
| `retainTags` | `[]` | Tags applied to every retained document, useful for cross-agent/source labeling (e.g. `source_system:openclaw`, `agent:agentname`). Auto-retain also merges inline per-message tags from `<retain_tags>...</retain_tags>` or `<hindsight_retain_tags>...</hindsight_retain_tags>` blocks in user messages. |
| `retainSource` | `"openclaw"` | `source` value written into retained document metadata |
| `dynamicBankGranularity` | `["agent", "channel", "user"]` | Fields used to derive bank ID. Options: `agent`, `channel`, `user`, `provider` |
| `excludeProviders` | `["heartbeat"]` | Message providers to skip for recall/retain (e.g. `heartbeat`, `slack`, `telegram`, `discord`) |
| `autoRecall` | `true` | Auto-inject memories before each turn. Set to `false` when the agent has its own recall tool. |
| `autoRetain` | `true` | Auto-retain conversations after each turn |
| `retainRoles` | `["user", "assistant"]` | Which message roles to retain. Options: `user`, `assistant`, `system`, `tool` |
| `retainFormat` | `"json"` | Serialization format for retained conversation content. `"json"` emits a structured array of `{role, content}` messages (matches Claude Code). `"text"` emits legacy `[role: x] … [x:end]` markers. |
| `retainToolCalls` | `true` | With `retainFormat: "json"`, each message's content is an Anthropic-shaped block array (`text` / `tool_use` / `tool_result`). Tool results are truncated at 2000 chars. Hindsight's own MCP tools (recall/retain/search/…) are filtered to prevent feedback loops. Set `false` to retain text-only content. |
| `retainEveryNTurns` | `1` | Retain every Nth turn. `1` = every turn (default). Values > 1 enable chunked retention with a sliding window. |
| `retainOverlapTurns` | `0` | Extra prior turns included when chunked retention fires. Window = `retainEveryNTurns + retainOverlapTurns`. Only applies when `retainEveryNTurns > 1`. |
| `recallBudget` | `"mid"` | Recall effort: `low`, `mid`, or `high`. Higher budgets use more retrieval strategies. |
| `recallMaxTokens` | `1024` | Max tokens for recall response. Controls how much memory context is injected per turn. |
| `recallTypes` | `["world", "experience"]` | Memory types to recall. Options: `world`, `experience`, `observation`. Excludes verbose `observation` entries by default. |
| `recallRoles` | `["user", "assistant"]` | Roles included when building prior context for recall query composition. Options: `user`, `assistant`, `system`, `tool`. |
| `recallTopK` | — | Max number of memories to inject per turn. Applied after API response as a hard cap. |
| `recallContextTurns` | `1` | Number of user turns to include when composing recall query context. `1` keeps latest-message-only behavior. |
| `recallMaxQueryChars` | `800` | Maximum character length for the composed recall query before calling recall. |
| `recallPromptPreamble` | built-in string | Prompt text placed above recalled memories in the injected `<hindsight_memories>` system-context block. |
| `hindsightApiUrl` | — | External Hindsight API URL (skips local daemon) |
| `hindsightApiToken` | — | Auth token for external API. **Sensitive** — set via `openclaw config set ... --ref-source env --ref-id HINDSIGHT_API_TOKEN`. |
| `ignoreSessionPatterns` | `[]` | Session key glob patterns to skip entirely — no recall, no retain (e.g. `["agent:*:cron:**"]`) |
| `statelessSessionPatterns` | `[]` | Session key glob patterns for read-only sessions — retain is always skipped; recall is skipped when `skipStatelessSessions` is `true` (e.g. `["agent:*:subagent:**", "agent:*:heartbeat:**"]`) |
| `skipStatelessSessions` | `true` | When `true`, sessions matching `statelessSessionPatterns` also skip recall. Set to `false` to allow recall but still skip retain. |
| Option | Default | Description |
| -------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apiPort` | `9077` | Port for the local Hindsight daemon |
| `daemonIdleTimeout` | `0` | Seconds before daemon shuts down from inactivity (0 = never) |
| `embedPort` | `0` | Port for `hindsight-embed` server (`0` = auto-assign) |
| `embedVersion` | `"latest"` | hindsight-embed version |
| `embedPackagePath` | — | Local path to `hindsight-embed` package for development |
| `bankMission` | — | Agent identity/purpose stored on the memory bank. Helps the engine understand context for better fact extraction. Set once per bank — not a recall prompt. |
| `llmProvider` | — | LLM provider for memory extraction (`openai`, `anthropic`, `gemini`, `groq`, `ollama`, `openai-codex`, `claude-code`). Required unless `hindsightApiUrl` is set. |
| `llmModel` | provider default | LLM model used with `llmProvider` |
| `llmApiKey` | — | API key for the LLM provider. **Sensitive** — set via `openclaw config set ... --ref-source env --ref-id OPENAI_API_KEY` to reference an env var (or `--ref-source file`/`exec` for mounted-secret/Vault sources). |
| `llmBaseUrl` | — | Optional base URL override for OpenAI-compatible providers (e.g. `https://openrouter.ai/api/v1`) |
| `dynamicBankId` | `true` | Enable per-context memory banks |
| `bankId` | — | Static bank ID used when `dynamicBankId` is `false`. |
| `bankIdPrefix` | — | Prefix for bank IDs (e.g. `"prod"`) |
| `retainTags` | `[]` | Tags applied to every retained document, useful for cross-agent/source labeling (e.g. `source_system:openclaw`, `agent:agentname`). Auto-retain also merges inline per-message tags from `<retain_tags>...</retain_tags>` or `<hindsight_retain_tags>...</hindsight_retain_tags>` blocks in user messages. |
| `retainSource` | `"openclaw"` | `source` value written into retained document metadata |
| `dynamicBankGranularity` | `["agent", "channel", "user"]` | Fields used to derive bank ID. Options: `agent`, `channel`, `user`, `provider` |
| `excludeProviders` | `["heartbeat"]` | Message providers to skip for recall/retain (e.g. `heartbeat`, `slack`, `telegram`, `discord`) |
| `autoRecall` | `true` | Auto-inject memories before each turn. Set to `false` when the agent has its own recall tool. |
| `autoRetain` | `true` | Auto-retain conversations after each turn |
| `retainRoles` | `["user", "assistant"]` | Which message roles to retain. Options: `user`, `assistant`, `system`, `tool` |
| `retainFormat` | `"json"` | Serialization format for retained conversation content. `"json"` emits a structured array of `{role, content}` messages (matches Claude Code). `"text"` emits legacy `[role: x] … [x:end]` markers. |
| `retainToolCalls` | `true` | With `retainFormat: "json"`, each message's content is an Anthropic-shaped block array (`text` / `tool_use` / `tool_result`). Tool results are truncated at 2000 chars. Hindsight's own MCP tools (recall/retain/search/…) are filtered to prevent feedback loops. Set `false` to retain text-only content. |
| `retainEveryNTurns` | `1` | Retain every Nth turn. `1` = every turn (default). Values > 1 enable chunked retention with a sliding window. |
| `retainOverlapTurns` | `0` | Extra prior turns included when chunked retention fires. Window = `retainEveryNTurns + retainOverlapTurns`. Only applies when `retainEveryNTurns > 1`. |
| `recallBudget` | `"mid"` | Recall effort: `low`, `mid`, or `high`. Higher budgets use more retrieval strategies. |
| `recallMaxTokens` | `1024` | Max tokens for recall response. Controls how much memory context is injected per turn. |
| `recallTypes` | `["world", "experience"]` | Memory types to recall. Options: `world`, `experience`, `observation`. Excludes verbose `observation` entries by default. |
| `recallRoles` | `["user", "assistant"]` | Roles included when building prior context for recall query composition. Options: `user`, `assistant`, `system`, `tool`. |
| `recallTopK` | — | Max number of memories to inject per turn. Applied after API response as a hard cap. |
| `recallContextTurns` | `1` | Number of user turns to include when composing recall query context. `1` keeps latest-message-only behavior. |
| `recallMaxQueryChars` | `800` | Maximum character length for the composed recall query before calling recall. |
| `recallPromptPreamble` | built-in string | Prompt text placed above recalled memories in the injected `<hindsight_memories>` system-context block. |
| `hindsightApiUrl` | — | External Hindsight API URL (skips local daemon) |
| `hindsightApiToken` | — | Auth token for external API. **Sensitive** — set via `openclaw config set ... --ref-source env --ref-id HINDSIGHT_API_TOKEN`. |
| `ignoreSessionPatterns` | `[]` | Session key glob patterns to skip entirely — no recall, no retain (e.g. `["agent:*:cron:**"]`) |
| `statelessSessionPatterns` | `[]` | Session key glob patterns for read-only sessions — retain is always skipped; recall is skipped when `skipStatelessSessions` is `true` (e.g. `["agent:*:subagent:**", "agent:*:heartbeat:**"]`) |
| `skipStatelessSessions` | `true` | When `true`, sessions matching `statelessSessionPatterns` also skip recall. Set to `false` to allow recall but still skip retain. |
### Session pattern filtering
`ignoreSessionPatterns` and `statelessSessionPatterns` accept glob patterns matched against the session key (format: `agent:<agentId>:<type>:<uuid>`).
Glob syntax:
- `*` — matches any characters except `:` (single segment)
- `**` — matches anything including `:` (multiple segments)
| Pattern | Matches |
|---|---|
| `agent:*:cron:**` | All cron sessions for any agent |
| Pattern | Matches |
| --------------------- | ----------------------------------- |
| `agent:*:cron:**` | All cron sessions for any agent |
| `agent:*:subagent:**` | All subagent sessions for any agent |
| `agent:main:**` | All sessions under the `main` agent |
| `agent:main:**` | All sessions under the `main` agent |
**Difference between the two options:**
| | `ignoreSessionPatterns` | `statelessSessionPatterns` |
|---|---|---|
| Retain | Skipped | Always skipped |
| Recall | Skipped | Skipped only when `skipStatelessSessions: true` |
| | `ignoreSessionPatterns` | `statelessSessionPatterns` |
| ------ | ----------------------- | ----------------------------------------------- |
| Retain | Skipped | Always skipped |
| Recall | Skipped | Skipped only when `skipStatelessSessions: true` |
**Example config** — exclude cron jobs from memory entirely, allow subagents to read but not write memories:
@@ -162,6 +163,7 @@ For full documentation, configuration options, troubleshooting, and development
To test local changes to the Hindsight package before publishing:
1. Add `embedPackagePath` to your plugin config in `~/.openclaw/openclaw.json`:
```json
{
"plugins": {
@@ -180,6 +182,7 @@ To test local changes to the Hindsight package before publishing:
2. The plugin will use `uv run --directory <path> hindsight-embed` instead of `uvx hindsight-embed@latest`
3. To use a specific profile for testing:
```bash
# Check daemon status
uvx hindsight-embed@latest -p openclaw daemon status
@@ -36,15 +36,7 @@
"llmProvider": {
"type": "string",
"description": "LLM provider for Hindsight memory (e.g. 'openai', 'anthropic', 'gemini', 'groq', 'ollama', 'openai-codex', 'claude-code').",
"enum": [
"openai",
"anthropic",
"gemini",
"groq",
"ollama",
"openai-codex",
"claude-code"
]
"enum": ["openai", "anthropic", "gemini", "groq", "ollama", "openai-codex", "claude-code"]
},
"llmModel": {
"type": "string",
@@ -117,19 +109,10 @@
"type": "array",
"items": {
"type": "string",
"enum": [
"agent",
"channel",
"user",
"provider"
]
"enum": ["agent", "channel", "user", "provider"]
},
"description": "Fields used to derive bank ID. Controls memory isolation granularity. Default: ['agent', 'channel', 'user'].",
"default": [
"agent",
"channel",
"user"
]
"default": ["agent", "channel", "user"]
},
"autoRetain": {
"type": "boolean",
@@ -140,18 +123,10 @@
"type": "array",
"items": {
"type": "string",
"enum": [
"user",
"assistant",
"system",
"tool"
]
"enum": ["user", "assistant", "system", "tool"]
},
"description": "Message roles to include in retained transcript. Default: ['user', 'assistant'].",
"default": [
"user",
"assistant"
]
"default": ["user", "assistant"]
},
"retainFormat": {
"type": "string",
@@ -1,31 +1,37 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import {
buildBackfillPlan,
loadPluginConfigFromOpenClawRoot,
stableDocumentId,
} from './backfill-lib.js';
} from "./backfill-lib.js";
const tempDirs: string[] = [];
function makeTempRoot(): string {
const dir = mkdtempSync(join(tmpdir(), 'hindsight-openclaw-backfill-'));
const dir = mkdtempSync(join(tmpdir(), "hindsight-openclaw-backfill-"));
tempDirs.push(dir);
return dir;
}
function writeOpenClawConfig(root: string, config: Record<string, unknown>) {
writeFileSync(join(root, 'openclaw.json'), JSON.stringify(config, null, 2));
writeFileSync(join(root, "openclaw.json"), JSON.stringify(config, null, 2));
}
function writeSession(root: string, agentId: string, fileName: string, lines: unknown[], archive = false) {
function writeSession(
root: string,
agentId: string,
fileName: string,
lines: unknown[],
archive = false
) {
const dir = archive
? join(root, 'agents', agentId, 'sessions-archive-from-migration_backup')
: join(root, 'agents', agentId, 'sessions');
? join(root, "agents", agentId, "sessions-archive-from-migration_backup")
: join(root, "agents", agentId, "sessions");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, fileName), lines.map((line) => JSON.stringify(line)).join('\n') + '\n');
writeFileSync(join(dir, fileName), lines.map((line) => JSON.stringify(line)).join("\n") + "\n");
}
afterEach(() => {
@@ -34,89 +40,100 @@ afterEach(() => {
}
});
describe('backfill planning', () => {
it('mirrors plugin bank routing from config by default', () => {
describe("backfill planning", () => {
it("mirrors plugin bank routing from config by default", () => {
const root = makeTempRoot();
writeOpenClawConfig(root, {
plugins: {
entries: {
'hindsight-openclaw': {
"hindsight-openclaw": {
config: {
dynamicBankId: true,
dynamicBankGranularity: ['agent', 'provider', 'channel'],
dynamicBankGranularity: ["agent", "provider", "channel"],
},
},
},
},
});
writeSession(root, 'proj-run', 'one.jsonl', [
{ type: 'session', id: 'session-1', sessionKey: 'agent:proj-run:discord:channel:123' },
{ type: 'message', message: { role: 'user', content: 'hello' } },
{ type: 'message', message: { role: 'assistant', content: 'world' } },
writeSession(root, "proj-run", "one.jsonl", [
{ type: "session", id: "session-1", sessionKey: "agent:proj-run:discord:channel:123" },
{ type: "message", message: { role: "user", content: "hello" } },
{ type: "message", message: { role: "assistant", content: "world" } },
]);
const config = loadPluginConfigFromOpenClawRoot(root);
const result = buildBackfillPlan(config, {
openclawRoot: root,
includeArchive: true,
bankStrategy: 'mirror-config',
bankStrategy: "mirror-config",
});
expect(result.discoveredSessions).toBe(1);
expect(result.entries).toHaveLength(1);
expect(result.entries[0].bankId).toBe('proj-run::discord::channel%3A123');
expect(result.entries[0].documentId).toBe(stableDocumentId({
filePath: result.entries[0].filePath,
agentId: 'proj-run',
sessionId: 'session-1',
sessionKey: 'agent:proj-run:discord:channel:123',
messages: [],
}, result.entries[0].bankId));
expect(result.entries[0].bankId).toBe("proj-run::discord::channel%3A123");
expect(result.entries[0].documentId).toBe(
stableDocumentId(
{
filePath: result.entries[0].filePath,
agentId: "proj-run",
sessionId: "session-1",
sessionKey: "agent:proj-run:discord:channel:123",
messages: [],
},
result.entries[0].bankId
)
);
});
it('supports migration overrides for agent-only banks', () => {
it("supports migration overrides for agent-only banks", () => {
const root = makeTempRoot();
writeOpenClawConfig(root, { plugins: { entries: { 'hindsight-openclaw': { config: {} } } } });
writeSession(root, 'proj-debug', 'two.jsonl', [
{ type: 'session', id: 'session-2', sessionKey: 'agent:proj-debug:discord:group:abc' },
{ type: 'message', message: { role: 'user', content: 'hello' } },
{ type: 'message', message: { role: 'assistant', content: 'world' } },
writeOpenClawConfig(root, { plugins: { entries: { "hindsight-openclaw": { config: {} } } } });
writeSession(root, "proj-debug", "two.jsonl", [
{ type: "session", id: "session-2", sessionKey: "agent:proj-debug:discord:group:abc" },
{ type: "message", message: { role: "user", content: "hello" } },
{ type: "message", message: { role: "assistant", content: "world" } },
]);
const config = loadPluginConfigFromOpenClawRoot(root);
const result = buildBackfillPlan(config, {
openclawRoot: root,
includeArchive: true,
bankStrategy: 'agent',
bankStrategy: "agent",
});
expect(result.entries).toHaveLength(1);
expect(result.entries[0].bankId).toBe('proj-debug');
expect(result.entries[0].bankId).toBe("proj-debug");
});
it('can exclude archive sessions', () => {
it("can exclude archive sessions", () => {
const root = makeTempRoot();
writeOpenClawConfig(root, { plugins: { entries: { 'hindsight-openclaw': { config: {} } } } });
writeSession(root, 'main', 'live.jsonl', [
{ type: 'session', id: 'live' },
{ type: 'message', message: { role: 'user', content: 'live' } },
{ type: 'message', message: { role: 'assistant', content: 'reply' } },
writeOpenClawConfig(root, { plugins: { entries: { "hindsight-openclaw": { config: {} } } } });
writeSession(root, "main", "live.jsonl", [
{ type: "session", id: "live" },
{ type: "message", message: { role: "user", content: "live" } },
{ type: "message", message: { role: "assistant", content: "reply" } },
]);
writeSession(root, 'main', 'archive.jsonl', [
{ type: 'session', id: 'archive' },
{ type: 'message', message: { role: 'user', content: 'archived' } },
{ type: 'message', message: { role: 'assistant', content: 'reply' } },
], true);
writeSession(
root,
"main",
"archive.jsonl",
[
{ type: "session", id: "archive" },
{ type: "message", message: { role: "user", content: "archived" } },
{ type: "message", message: { role: "assistant", content: "reply" } },
],
true
);
const config = loadPluginConfigFromOpenClawRoot(root);
const result = buildBackfillPlan(config, {
openclawRoot: root,
includeArchive: false,
bankStrategy: 'mirror-config',
bankStrategy: "mirror-config",
});
expect(result.discoveredSessions).toBe(1);
expect(result.entries).toHaveLength(1);
expect(result.entries[0].sessionId).toBe('live');
expect(result.entries[0].sessionId).toBe("live");
});
});
@@ -1,20 +1,20 @@
import { homedir } from 'os';
import { dirname, join, resolve } from 'path';
import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync } from 'fs';
import { deriveBankId, prepareRetentionTranscript } from './index.js';
import type { PluginConfig, PluginHookAgentContext } from './types.js';
import { homedir } from "os";
import { dirname, join, resolve } from "path";
import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync } from "fs";
import { deriveBankId, prepareRetentionTranscript } from "./index.js";
import type { PluginConfig, PluginHookAgentContext } from "./types.js";
export interface BackfillCliOptions {
openclawRoot: string;
includeArchive: boolean;
selectedAgents?: Set<string>;
limit?: number;
bankStrategy: 'mirror-config' | 'agent' | 'fixed';
bankStrategy: "mirror-config" | "agent" | "fixed";
fixedBank?: string;
}
export interface SessionMessage {
role: 'user' | 'assistant' | 'system' | 'tool';
role: "user" | "assistant" | "system" | "tool";
content: string | Array<{ type?: string; text?: string }>;
}
@@ -39,7 +39,7 @@ export interface BackfillPlanEntry {
}
export interface BackfillCheckpointEntry {
status: 'enqueued' | 'completed' | 'failed';
status: "enqueued" | "completed" | "failed";
bankId: string;
filePath: string;
sessionId: string;
@@ -52,8 +52,8 @@ export interface BackfillCheckpoint {
entries: Record<string, BackfillCheckpointEntry>;
}
interface RawBackfillCheckpointEntry extends Omit<BackfillCheckpointEntry, 'status'> {
status: BackfillCheckpointEntry['status'] | 'queued';
interface RawBackfillCheckpointEntry extends Omit<BackfillCheckpointEntry, "status"> {
status: BackfillCheckpointEntry["status"] | "queued";
}
interface RawBackfillCheckpoint {
@@ -68,46 +68,48 @@ interface SessionDirectory {
const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
dynamicBankId: true,
retainRoles: ['user', 'assistant'],
retainRoles: ["user", "assistant"],
};
export function defaultOpenClawRoot(): string {
return resolve(join(homedir(), '.openclaw'));
return resolve(join(homedir(), ".openclaw"));
}
export function defaultCheckpointPath(openclawRoot: string): string {
return join(openclawRoot, 'data', 'hindsight-backfill-checkpoint.json');
return join(openclawRoot, "data", "hindsight-backfill-checkpoint.json");
}
export function loadPluginConfigFromOpenClawRoot(openclawRoot: string): PluginConfig {
const configPath = join(openclawRoot, 'openclaw.json');
const raw = JSON.parse(readFileSync(configPath, 'utf8')) as {
const configPath = join(openclawRoot, "openclaw.json");
const raw = JSON.parse(readFileSync(configPath, "utf8")) as {
plugins?: { entries?: Record<string, { config?: PluginConfig }> };
};
return {
...DEFAULT_PLUGIN_CONFIG,
...(raw.plugins?.entries?.['hindsight-openclaw']?.config || {}),
...(raw.plugins?.entries?.["hindsight-openclaw"]?.config || {}),
};
}
function extractTextContent(content: unknown): string {
if (typeof content === 'string') {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.filter((block): block is { type?: string; text?: string } => !!block && typeof block === 'object')
.filter((block) => block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text || '')
.join('\n');
.filter(
(block): block is { type?: string; text?: string } => !!block && typeof block === "object"
)
.filter((block) => block.type === "text" && typeof block.text === "string")
.map((block) => block.text || "")
.join("\n");
}
return '';
return "";
}
function readJsonLines(filePath: string): unknown[] {
const content = readFileSync(filePath, 'utf8');
const content = readFileSync(filePath, "utf8");
return content
.split('\n')
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line));
@@ -115,24 +117,28 @@ function readJsonLines(filePath: string): unknown[] {
export function parseSessionFile(filePath: string, agentId: string): ParsedSessionFile {
const records = readJsonLines(filePath) as Array<Record<string, any>>;
let sessionId = filePath.split('/').pop()?.replace(/\.jsonl$/, '') || 'session';
let sessionId =
filePath
.split("/")
.pop()
?.replace(/\.jsonl$/, "") || "session";
let sessionKey: string | undefined;
let startedAt: string | undefined;
const messages: SessionMessage[] = [];
for (const record of records) {
if (record.type === 'session') {
sessionId = typeof record.id === 'string' ? record.id : sessionId;
startedAt = typeof record.timestamp === 'string' ? record.timestamp : startedAt;
sessionKey = typeof record.sessionKey === 'string' ? record.sessionKey : sessionKey;
if (record.type === "session") {
sessionId = typeof record.id === "string" ? record.id : sessionId;
startedAt = typeof record.timestamp === "string" ? record.timestamp : startedAt;
sessionKey = typeof record.sessionKey === "string" ? record.sessionKey : sessionKey;
continue;
}
if (record.type !== 'message' || !record.message || typeof record.message !== 'object') {
if (record.type !== "message" || !record.message || typeof record.message !== "object") {
continue;
}
const message = record.message as Record<string, unknown>;
const role = message.role;
if (role !== 'user' && role !== 'assistant' && role !== 'system' && role !== 'tool') {
if (role !== "user" && role !== "assistant" && role !== "system" && role !== "tool") {
continue;
}
const text = extractTextContent(message.content);
@@ -141,9 +147,9 @@ export function parseSessionFile(filePath: string, agentId: string): ParsedSessi
}
messages.push({
role,
content: typeof message.content === 'string' ? message.content : [{ type: 'text', text }],
content: typeof message.content === "string" ? message.content : [{ type: "text", text }],
});
if (!sessionKey && typeof record.sessionKey === 'string') {
if (!sessionKey && typeof record.sessionKey === "string") {
sessionKey = record.sessionKey;
}
}
@@ -159,7 +165,7 @@ export function parseSessionFile(filePath: string, agentId: string): ParsedSessi
}
function sessionDirectories(openclawRoot: string, includeArchive: boolean): SessionDirectory[] {
const agentsRoot = join(openclawRoot, 'agents');
const agentsRoot = join(openclawRoot, "agents");
if (!existsSync(agentsRoot)) {
return [];
}
@@ -167,12 +173,12 @@ function sessionDirectories(openclawRoot: string, includeArchive: boolean): Sess
for (const entry of readdirSync(agentsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const agentId = entry.name;
const sessionsDir = join(agentsRoot, agentId, 'sessions');
const sessionsDir = join(agentsRoot, agentId, "sessions");
if (existsSync(sessionsDir)) {
result.push({ agentId, path: sessionsDir });
}
if (includeArchive) {
const archiveDir = join(agentsRoot, agentId, 'sessions-archive-from-migration_backup');
const archiveDir = join(agentsRoot, agentId, "sessions-archive-from-migration_backup");
if (existsSync(archiveDir)) {
result.push({ agentId, path: archiveDir });
}
@@ -181,18 +187,23 @@ function sessionDirectories(openclawRoot: string, includeArchive: boolean): Sess
return result.sort((a, b) => a.agentId.localeCompare(b.agentId) || a.path.localeCompare(b.path));
}
export function discoverSessionFiles(openclawRoot: string, includeArchive: boolean): Array<{ agentId: string; filePath: string }> {
export function discoverSessionFiles(
openclawRoot: string,
includeArchive: boolean
): Array<{ agentId: string; filePath: string }> {
const sessions: Array<{ agentId: string; filePath: string }> = [];
for (const dir of sessionDirectories(openclawRoot, includeArchive)) {
for (const entry of readdirSync(dir.path, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
sessions.push({
agentId: dir.agentId,
filePath: join(dir.path, entry.name),
});
}
}
return sessions.sort((a, b) => a.agentId.localeCompare(b.agentId) || a.filePath.localeCompare(b.filePath));
return sessions.sort(
(a, b) => a.agentId.localeCompare(b.agentId) || a.filePath.localeCompare(b.filePath)
);
}
function backfillContextForSession(session: ParsedSessionFile): PluginHookAgentContext {
@@ -205,15 +216,15 @@ function backfillContextForSession(session: ParsedSessionFile): PluginHookAgentC
function deriveTargetBank(
session: ParsedSessionFile,
pluginConfig: PluginConfig,
bankStrategy: BackfillCliOptions['bankStrategy'],
fixedBank?: string,
bankStrategy: BackfillCliOptions["bankStrategy"],
fixedBank?: string
): string {
if (bankStrategy === 'agent') {
if (bankStrategy === "agent") {
return session.agentId;
}
if (bankStrategy === 'fixed') {
if (bankStrategy === "fixed") {
if (!fixedBank) {
throw new Error('fixed bank strategy requires --fixed-bank');
throw new Error("fixed bank strategy requires --fixed-bank");
}
return fixedBank;
}
@@ -226,7 +237,7 @@ export function stableDocumentId(session: ParsedSessionFile, bankId: string): st
export function buildBackfillPlan(
pluginConfig: PluginConfig,
opts: BackfillCliOptions,
opts: BackfillCliOptions
): { entries: BackfillPlanEntry[]; discoveredSessions: number; skippedEmpty: number } {
const entries: BackfillPlanEntry[] = [];
let discoveredSessions = 0;
@@ -266,8 +277,8 @@ export function loadCheckpoint(checkpointPath: string): BackfillCheckpoint {
if (!existsSync(checkpointPath)) {
return { version: 1, entries: {} };
}
const raw = JSON.parse(readFileSync(checkpointPath, 'utf8')) as RawBackfillCheckpoint;
if (raw.version !== 1 || !raw.entries || typeof raw.entries !== 'object') {
const raw = JSON.parse(readFileSync(checkpointPath, "utf8")) as RawBackfillCheckpoint;
if (raw.version !== 1 || !raw.entries || typeof raw.entries !== "object") {
return { version: 1, entries: {} };
}
return {
@@ -277,18 +288,18 @@ export function loadCheckpoint(checkpointPath: string): BackfillCheckpoint {
key,
{
...entry,
status: entry.status === 'queued' ? 'enqueued' : entry.status,
status: entry.status === "queued" ? "enqueued" : entry.status,
},
]),
])
) as Record<string, BackfillCheckpointEntry>,
};
}
export function saveCheckpoint(checkpointPath: string, checkpoint: BackfillCheckpoint): void {
mkdirSync(dirname(checkpointPath), { recursive: true });
writeFileSync(checkpointPath, JSON.stringify(checkpoint, null, 2) + '\n', 'utf8');
writeFileSync(checkpointPath, JSON.stringify(checkpoint, null, 2) + "\n", "utf8");
}
export function checkpointKey(entry: Pick<BackfillPlanEntry, 'bankId' | 'documentId'>): string {
export function checkpointKey(entry: Pick<BackfillPlanEntry, "bankId" | "documentId">): string {
return `${entry.bankId}::${entry.documentId}`;
}
@@ -1,24 +1,28 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, symlinkSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { pathToFileURL } from 'url';
import type { BankStats, PluginConfig } from './types.js';
import type { BackfillCheckpoint, BackfillPlanEntry } from './backfill-lib.js';
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, symlinkSync, writeFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { pathToFileURL } from "url";
import type { BankStats, PluginConfig } from "./types.js";
import type { BackfillCheckpoint, BackfillPlanEntry } from "./backfill-lib.js";
const managerStart = vi.fn();
const managerStop = vi.fn();
const managerGetBaseUrl = vi.fn(() => 'http://127.0.0.1:9077');
const managerGetBaseUrl = vi.fn(() => "http://127.0.0.1:9077");
vi.mock('@vectorize-io/hindsight-all', async () => {
const actual = await vi.importActual<typeof import('@vectorize-io/hindsight-all')>('@vectorize-io/hindsight-all');
vi.mock("@vectorize-io/hindsight-all", async () => {
const actual = await vi.importActual<typeof import("@vectorize-io/hindsight-all")>(
"@vectorize-io/hindsight-all"
);
return {
...actual,
HindsightServer: vi.fn(class {
start = managerStart;
stop = managerStop;
getBaseUrl = managerGetBaseUrl;
}),
HindsightServer: vi.fn(
class {
start = managerStart;
stop = managerStop;
getBaseUrl = managerGetBaseUrl;
}
),
};
});
@@ -37,14 +41,14 @@ function makeEntry(bankId: string, sessionId: string): BackfillPlanEntry {
sessionId,
bankId,
documentId: `backfill::${bankId}::${sessionId}`,
transcript: '[role: user]\nhello\n[user:end]',
transcript: "[role: user]\nhello\n[user:end]",
messageCount: 1,
};
}
function makeStats(overrides: Partial<BankStats> = {}): BankStats {
return {
bank_id: 'bank',
bank_id: "bank",
total_nodes: 0,
total_links: 0,
total_documents: 0,
@@ -57,99 +61,148 @@ function makeStats(overrides: Partial<BankStats> = {}): BankStats {
};
}
describe('backfill helpers', () => {
it('resume skips only completed entries', async () => {
const { filterEntriesForResume, splitResumeEntries } = await import('./backfill.js');
const entries = [makeEntry('bank-a', '1'), makeEntry('bank-a', '2'), makeEntry('bank-a', '3')];
describe("backfill helpers", () => {
it("resume skips only completed entries", async () => {
const { filterEntriesForResume, splitResumeEntries } = await import("./backfill.js");
const entries = [makeEntry("bank-a", "1"), makeEntry("bank-a", "2"), makeEntry("bank-a", "3")];
const checkpoint: BackfillCheckpoint = {
version: 1,
entries: {
'bank-a::backfill::bank-a::1': { status: 'completed', bankId: 'bank-a', filePath: '/tmp/1', sessionId: '1', updatedAt: 'now' },
'bank-a::backfill::bank-a::2': { status: 'enqueued', bankId: 'bank-a', filePath: '/tmp/2', sessionId: '2', updatedAt: 'now' },
'bank-a::backfill::bank-a::3': { status: 'failed', bankId: 'bank-a', filePath: '/tmp/3', sessionId: '3', updatedAt: 'now' },
"bank-a::backfill::bank-a::1": {
status: "completed",
bankId: "bank-a",
filePath: "/tmp/1",
sessionId: "1",
updatedAt: "now",
},
"bank-a::backfill::bank-a::2": {
status: "enqueued",
bankId: "bank-a",
filePath: "/tmp/2",
sessionId: "2",
updatedAt: "now",
},
"bank-a::backfill::bank-a::3": {
status: "failed",
bankId: "bank-a",
filePath: "/tmp/3",
sessionId: "3",
updatedAt: "now",
},
},
};
const resumable = filterEntriesForResume(entries, checkpoint, true);
expect(resumable.map((entry) => entry.sessionId)).toEqual(['2', '3']);
expect(splitResumeEntries(resumable, checkpoint, false).entriesToEnqueue.map((entry) => entry.sessionId)).toEqual(['2', '3']);
expect(resumable.map((entry) => entry.sessionId)).toEqual(["2", "3"]);
expect(
splitResumeEntries(resumable, checkpoint, false).entriesToEnqueue.map(
(entry) => entry.sessionId
)
).toEqual(["2", "3"]);
expect(splitResumeEntries(resumable, checkpoint, true)).toEqual({
entriesToEnqueue: [entries[2]],
alreadyEnqueuedKeys: ['bank-a::backfill::bank-a::2'],
alreadyEnqueuedKeys: ["bank-a::backfill::bank-a::2"],
});
});
it('normalizes legacy queued checkpoint entries', async () => {
const { loadCheckpoint } = await import('./backfill-lib.js');
const dir = mkdtempSync(join(tmpdir(), 'hindsight-backfill-'));
const checkpointPath = join(dir, 'checkpoint.json');
writeFileSync(checkpointPath, JSON.stringify({
version: 1,
entries: {
legacy: { status: 'queued', bankId: 'bank-a', filePath: '/tmp/a', sessionId: 'a', updatedAt: 'now' },
},
}), 'utf8');
it("normalizes legacy queued checkpoint entries", async () => {
const { loadCheckpoint } = await import("./backfill-lib.js");
const dir = mkdtempSync(join(tmpdir(), "hindsight-backfill-"));
const checkpointPath = join(dir, "checkpoint.json");
writeFileSync(
checkpointPath,
JSON.stringify({
version: 1,
entries: {
legacy: {
status: "queued",
bankId: "bank-a",
filePath: "/tmp/a",
sessionId: "a",
updatedAt: "now",
},
},
}),
"utf8"
);
const checkpoint = loadCheckpoint(checkpointPath);
expect(checkpoint.entries.legacy.status).toBe('enqueued');
expect(checkpoint.entries.legacy.status).toBe("enqueued");
});
it('marks drained entries completed and leaves aggregate-failure banks enqueued', async () => {
const { applyDrainResults } = await import('./backfill.js');
it("marks drained entries completed and leaves aggregate-failure banks enqueued", async () => {
const { applyDrainResults } = await import("./backfill.js");
const checkpoint: BackfillCheckpoint = {
version: 1,
entries: {
a: { status: 'enqueued', bankId: 'bank-a', filePath: '/tmp/a', sessionId: 'a', updatedAt: 'now' },
b: { status: 'enqueued', bankId: 'bank-b', filePath: '/tmp/b', sessionId: 'b', updatedAt: 'now' },
a: {
status: "enqueued",
bankId: "bank-a",
filePath: "/tmp/a",
sessionId: "a",
updatedAt: "now",
},
b: {
status: "enqueued",
bankId: "bank-b",
filePath: "/tmp/b",
sessionId: "b",
updatedAt: "now",
},
},
};
const touchedEntriesByBank = new Map([
['bank-a', ['a']],
['bank-b', ['b']],
["bank-a", ["a"]],
["bank-b", ["b"]],
]);
const finalStatsByBank = new Map<string, BankStats>([
['bank-a', makeStats({ bank_id: 'bank-a', pending_operations: 0, failed_operations: 0 })],
['bank-b', makeStats({ bank_id: 'bank-b', pending_operations: 0, failed_operations: 2 })],
["bank-a", makeStats({ bank_id: "bank-a", pending_operations: 0, failed_operations: 0 })],
["bank-b", makeStats({ bank_id: "bank-b", pending_operations: 0, failed_operations: 2 })],
]);
const initialFailedByBank = new Map([
['bank-a', 0],
['bank-b', 0],
["bank-a", 0],
["bank-b", 0],
]);
const result = applyDrainResults(checkpoint, touchedEntriesByBank, finalStatsByBank, initialFailedByBank);
const result = applyDrainResults(
checkpoint,
touchedEntriesByBank,
finalStatsByBank,
initialFailedByBank
);
expect(result.completed).toBe(1);
expect(result.unresolved).toBe(1);
expect(result.warnings).toEqual([
'bank bank-b reported 2 new failed operations during drain; leaving 1 checkpoint entries enqueued',
"bank bank-b reported 2 new failed operations during drain; leaving 1 checkpoint entries enqueued",
]);
expect(checkpoint.entries.a.status).toBe('completed');
expect(checkpoint.entries.b.status).toBe('enqueued');
expect(checkpoint.entries.a.status).toBe("completed");
expect(checkpoint.entries.b.status).toBe("enqueued");
});
it('starts local daemon when no external API is configured and health check fails', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('offline'));
vi.stubGlobal('fetch', fetchMock);
const { createBackfillRuntime } = await import('./backfill.js');
it("starts local daemon when no external API is configured and health check fails", async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error("offline"));
vi.stubGlobal("fetch", fetchMock);
const { createBackfillRuntime } = await import("./backfill.js");
const pluginConfig: PluginConfig = {
apiPort: 9077,
llmProvider: 'openai-codex',
llmModel: 'gpt-5.4',
llmProvider: "openai-codex",
llmModel: "gpt-5.4",
};
const runtime = await createBackfillRuntime(pluginConfig);
expect(managerStart).toHaveBeenCalledTimes(1);
expect(runtime.apiUrl).toBe('http://127.0.0.1:9077');
expect(runtime.apiUrl).toBe("http://127.0.0.1:9077");
await runtime.stop();
expect(managerStop).toHaveBeenCalledTimes(1);
});
it('treats a symlinked bin path as direct execution', async () => {
const { isDirectExecution } = await import('./backfill.js');
const dir = mkdtempSync(join(tmpdir(), 'hindsight-backfill-bin-'));
const modulePath = join(process.cwd(), 'dist', 'backfill.js');
const symlinkPath = join(dir, 'hindsight-openclaw-backfill');
it("treats a symlinked bin path as direct execution", async () => {
const { isDirectExecution } = await import("./backfill.js");
const dir = mkdtempSync(join(tmpdir(), "hindsight-backfill-bin-"));
const modulePath = join(process.cwd(), "dist", "backfill.js");
const symlinkPath = join(dir, "hindsight-openclaw-backfill");
symlinkSync(modulePath, symlinkPath);
const moduleUrl = pathToFileURL(modulePath).href;
expect(isDirectExecution(symlinkPath, moduleUrl)).toBe(true);
expect(isDirectExecution(join(dir, 'other-entrypoint'), moduleUrl)).toBe(false);
expect(isDirectExecution(join(dir, "other-entrypoint"), moduleUrl)).toBe(false);
});
});
+176 -109
View File
@@ -1,23 +1,23 @@
#!/usr/bin/env node
import { existsSync, readFileSync, realpathSync } from 'fs';
import { dirname, join, resolve } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import { HindsightServer } from '@vectorize-io/hindsight-all';
import { HindsightClient } from '@vectorize-io/hindsight-client';
import { existsSync, readFileSync, realpathSync } from "fs";
import { dirname, join, resolve } from "path";
import { fileURLToPath, pathToFileURL } from "url";
import { HindsightServer } from "@vectorize-io/hindsight-all";
import { HindsightClient } from "@vectorize-io/hindsight-client";
function loadPackageVersion(): string {
try {
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string };
return pkg.version ?? '0.0.0';
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
return pkg.version ?? "0.0.0";
} catch {
return '0.0.0';
return "0.0.0";
}
}
const USER_AGENT = `hindsight-openclaw/${loadPackageVersion()}`;
import { detectExternalApi, detectLLMConfig } from './index.js';
import type { BankStats, PluginConfig } from './types.js';
import { detectExternalApi, detectLLMConfig } from "./index.js";
import type { BankStats, PluginConfig } from "./types.js";
import {
buildBackfillPlan,
checkpointKey,
@@ -29,7 +29,7 @@ import {
type BackfillCheckpoint,
type BackfillPlanEntry,
type BackfillCliOptions,
} from './backfill-lib.js';
} from "./backfill-lib.js";
interface ParsedArgs {
openclawRoot: string;
@@ -41,7 +41,7 @@ interface ParsedArgs {
json: boolean;
resume: boolean;
checkpointPath: string;
bankStrategy: 'mirror-config' | 'agent' | 'fixed';
bankStrategy: "mirror-config" | "agent" | "fixed";
fixedBank?: string;
apiUrl?: string;
apiToken?: string;
@@ -64,40 +64,40 @@ interface BankRuntime {
function usage(): string {
return [
'Usage: hindsight-openclaw-backfill [options]',
'',
'Options:',
' --openclaw-root <path> OpenClaw root directory (default: ~/.openclaw)',
' --profile <name> Logical profile name for reporting (default: openclaw)',
' --agent <id> Restrict import to a specific agent (repeatable)',
' --include-archive Include migration archives (default)',
' --exclude-archive Exclude migration archives',
' --limit <n> Stop after enqueueing N sessions',
' --dry-run Build and print the import plan without enqueueing',
' --json Print final summary as JSON',
' --resume Skip entries already marked completed in the checkpoint',
' --checkpoint <path> Path to checkpoint JSON',
' --bank-strategy <mode> mirror-config | agent | fixed',
' --fixed-bank <id> Required when bank strategy is fixed',
' --api-url <url> Hindsight API base URL override',
' --api-token <token> Hindsight API bearer token override',
' --max-pending-operations <n> Wait until target bank queue is <= n before enqueueing',
' --wait-until-drained Wait for touched banks to drain and finalize checkpoint state',
' -h, --help Show this help',
].join('\n');
"Usage: hindsight-openclaw-backfill [options]",
"",
"Options:",
" --openclaw-root <path> OpenClaw root directory (default: ~/.openclaw)",
" --profile <name> Logical profile name for reporting (default: openclaw)",
" --agent <id> Restrict import to a specific agent (repeatable)",
" --include-archive Include migration archives (default)",
" --exclude-archive Exclude migration archives",
" --limit <n> Stop after enqueueing N sessions",
" --dry-run Build and print the import plan without enqueueing",
" --json Print final summary as JSON",
" --resume Skip entries already marked completed in the checkpoint",
" --checkpoint <path> Path to checkpoint JSON",
" --bank-strategy <mode> mirror-config | agent | fixed",
" --fixed-bank <id> Required when bank strategy is fixed",
" --api-url <url> Hindsight API base URL override",
" --api-token <token> Hindsight API bearer token override",
" --max-pending-operations <n> Wait until target bank queue is <= n before enqueueing",
" --wait-until-drained Wait for touched banks to drain and finalize checkpoint state",
" -h, --help Show this help",
].join("\n");
}
function parseArgs(argv: string[]): ParsedArgs {
const args: ParsedArgs = {
openclawRoot: defaultOpenClawRoot(),
profile: 'openclaw',
profile: "openclaw",
agents: [],
includeArchive: true,
dryRun: false,
json: false,
resume: false,
checkpointPath: '',
bankStrategy: 'mirror-config',
checkpointPath: "",
bankStrategy: "mirror-config",
waitUntilDrained: false,
};
@@ -112,61 +112,61 @@ function parseArgs(argv: string[]): ParsedArgs {
};
switch (arg) {
case '--openclaw-root':
case "--openclaw-root":
args.openclawRoot = resolve(next());
break;
case '--profile':
case "--profile":
args.profile = next();
break;
case '--agent':
case "--agent":
args.agents.push(next());
break;
case '--include-archive':
case "--include-archive":
args.includeArchive = true;
break;
case '--exclude-archive':
case "--exclude-archive":
args.includeArchive = false;
break;
case '--limit':
case "--limit":
args.limit = Number(next());
break;
case '--dry-run':
case "--dry-run":
args.dryRun = true;
break;
case '--json':
case "--json":
args.json = true;
break;
case '--resume':
case "--resume":
args.resume = true;
break;
case '--checkpoint':
case "--checkpoint":
args.checkpointPath = resolve(next());
break;
case '--bank-strategy': {
case "--bank-strategy": {
const value = next();
if (value !== 'mirror-config' && value !== 'agent' && value !== 'fixed') {
if (value !== "mirror-config" && value !== "agent" && value !== "fixed") {
throw new Error(`invalid bank strategy: ${value}`);
}
args.bankStrategy = value;
break;
}
case '--fixed-bank':
case "--fixed-bank":
args.fixedBank = next();
break;
case '--api-url':
case "--api-url":
args.apiUrl = next();
break;
case '--api-token':
case "--api-token":
args.apiToken = next();
break;
case '--max-pending-operations':
case "--max-pending-operations":
args.maxPendingOperations = Number(next());
break;
case '--wait-until-drained':
case "--wait-until-drained":
args.waitUntilDrained = true;
break;
case '-h':
case '--help':
case "-h":
case "--help":
console.log(usage());
process.exit(0);
default:
@@ -177,26 +177,31 @@ function parseArgs(argv: string[]): ParsedArgs {
if (!args.checkpointPath) {
args.checkpointPath = defaultCheckpointPath(args.openclawRoot);
}
if (args.bankStrategy === 'fixed' && !args.fixedBank) {
throw new Error('--fixed-bank is required when --bank-strategy fixed is used');
if (args.bankStrategy === "fixed" && !args.fixedBank) {
throw new Error("--fixed-bank is required when --bank-strategy fixed is used");
}
return args;
}
function inferApiSettings(pluginConfig: PluginConfig, explicitApiUrl?: string, explicitApiToken?: string): { apiUrl: string; apiToken?: string } {
const apiUrl = explicitApiUrl
|| pluginConfig.hindsightApiUrl
|| `http://127.0.0.1:${pluginConfig.apiPort || 9077}`;
function inferApiSettings(
pluginConfig: PluginConfig,
explicitApiUrl?: string,
explicitApiToken?: string
): { apiUrl: string; apiToken?: string } {
const apiUrl =
explicitApiUrl ||
pluginConfig.hindsightApiUrl ||
`http://127.0.0.1:${pluginConfig.apiPort || 9077}`;
const apiToken = explicitApiToken || pluginConfig.hindsightApiToken;
return { apiUrl, apiToken: apiToken || undefined };
}
async function checkHealth(apiUrl: string, apiToken?: string): Promise<boolean> {
try {
const headers: Record<string, string> = { 'User-Agent': USER_AGENT };
const headers: Record<string, string> = { "User-Agent": USER_AGENT };
if (apiToken) headers.Authorization = `Bearer ${apiToken}`;
const response = await fetch(`${apiUrl.replace(/\/$/, '')}/health`, {
method: 'GET',
const response = await fetch(`${apiUrl.replace(/\/$/, "")}/health`, {
method: "GET",
headers,
signal: AbortSignal.timeout(5000),
});
@@ -206,23 +211,29 @@ async function checkHealth(apiUrl: string, apiToken?: string): Promise<boolean>
}
}
export function filterEntriesForResume(entries: BackfillPlanEntry[], checkpoint: BackfillCheckpoint, resume: boolean): BackfillPlanEntry[] {
export function filterEntriesForResume(
entries: BackfillPlanEntry[],
checkpoint: BackfillCheckpoint,
resume: boolean
): BackfillPlanEntry[] {
if (!resume) {
return entries;
}
return entries.filter((entry) => checkpoint.entries[checkpointKey(entry)]?.status !== 'completed');
return entries.filter(
(entry) => checkpoint.entries[checkpointKey(entry)]?.status !== "completed"
);
}
export function splitResumeEntries(
entries: BackfillPlanEntry[],
checkpoint: BackfillCheckpoint,
waitUntilDrained: boolean,
waitUntilDrained: boolean
): { entriesToEnqueue: BackfillPlanEntry[]; alreadyEnqueuedKeys: string[] } {
const entriesToEnqueue: BackfillPlanEntry[] = [];
const alreadyEnqueuedKeys: string[] = [];
for (const entry of entries) {
const status = checkpoint.entries[checkpointKey(entry)]?.status;
if (status === 'enqueued') {
if (status === "enqueued") {
if (waitUntilDrained) {
alreadyEnqueuedKeys.push(checkpointKey(entry));
} else {
@@ -239,7 +250,7 @@ export function applyDrainResults(
checkpoint: BackfillCheckpoint,
touchedEntriesByBank: Map<string, string[]>,
finalStatsByBank: Map<string, BankStats>,
initialFailedOperationsByBank: Map<string, number>,
initialFailedOperationsByBank: Map<string, number>
): { completed: number; unresolved: number; warnings: string[] } {
let completed = 0;
let unresolved = 0;
@@ -252,23 +263,23 @@ export function applyDrainResults(
if (hasNewFailures) {
warnings.push(
`bank ${bankId} reported ${stats!.failed_operations - initialFailed} new failed operations during drain; leaving ${entryKeys.length} checkpoint entries enqueued`,
`bank ${bankId} reported ${stats!.failed_operations - initialFailed} new failed operations during drain; leaving ${entryKeys.length} checkpoint entries enqueued`
);
} else if (!stats || stats.pending_operations > 0) {
warnings.push(
`bank ${bankId} did not finish draining cleanly; leaving ${entryKeys.length} checkpoint entries enqueued`,
`bank ${bankId} did not finish draining cleanly; leaving ${entryKeys.length} checkpoint entries enqueued`
);
}
for (const entryKey of entryKeys) {
const existing = checkpoint.entries[entryKey];
if (!existing || existing.status !== 'enqueued') {
if (!existing || existing.status !== "enqueued") {
continue;
}
if (!hasNewFailures && stats && stats.pending_operations === 0) {
checkpoint.entries[entryKey] = {
...existing,
status: 'completed',
status: "completed",
updatedAt: new Date().toISOString(),
error: undefined,
};
@@ -285,11 +296,16 @@ export function applyDrainResults(
export async function createBackfillRuntime(
pluginConfig: PluginConfig,
explicitApiUrl?: string,
explicitApiToken?: string,
explicitApiToken?: string
): Promise<BackfillRuntime> {
const explicit = inferApiSettings(pluginConfig, explicitApiUrl, explicitApiToken);
const externalApi = detectExternalApi(pluginConfig);
const useExternalApi = !!(explicitApiUrl || explicitApiToken || externalApi.apiUrl || pluginConfig.hindsightApiUrl);
const useExternalApi = !!(
explicitApiUrl ||
explicitApiToken ||
externalApi.apiUrl ||
pluginConfig.hindsightApiUrl
);
if (useExternalApi) {
return {
@@ -309,13 +325,13 @@ export async function createBackfillRuntime(
const llmConfig = detectLLMConfig(pluginConfig);
const manager = new HindsightServer({
profile: 'openclaw',
profile: "openclaw",
port: pluginConfig.apiPort || 9077,
embedVersion: pluginConfig.embedVersion,
embedPackagePath: pluginConfig.embedPackagePath,
env: {
HINDSIGHT_API_LLM_PROVIDER: llmConfig.provider || '',
HINDSIGHT_API_LLM_API_KEY: llmConfig.apiKey || '',
HINDSIGHT_API_LLM_PROVIDER: llmConfig.provider || "",
HINDSIGHT_API_LLM_API_KEY: llmConfig.apiKey || "",
HINDSIGHT_API_LLM_MODEL: llmConfig.model,
HINDSIGHT_API_LLM_BASE_URL: llmConfig.baseUrl,
HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT: String(pluginConfig.daemonIdleTimeout ?? 0),
@@ -336,12 +352,18 @@ export async function createBackfillRuntime(
* Fetch stats for a single bank over HTTP. The high-level `HindsightClient`
* doesn't yet wrap this endpoint, so we go direct — it's one call.
*/
async function fetchBankStats(baseUrl: string, apiToken: string | undefined, bankId: string): Promise<BankStats> {
const headers: Record<string, string> = { 'User-Agent': USER_AGENT };
async function fetchBankStats(
baseUrl: string,
apiToken: string | undefined,
bankId: string
): Promise<BankStats> {
const headers: Record<string, string> = { "User-Agent": USER_AGENT };
if (apiToken) headers.Authorization = `Bearer ${apiToken}`;
const res = await fetch(`${baseUrl}/v1/default/banks/${encodeURIComponent(bankId)}/stats`, { headers });
const res = await fetch(`${baseUrl}/v1/default/banks/${encodeURIComponent(bankId)}/stats`, {
headers,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
throw new Error(`HTTP ${res.status}: ${await res.text().catch(() => "")}`);
}
return res.json() as Promise<BankStats>;
}
@@ -350,7 +372,7 @@ async function waitForBankQueue(
apiUrl: string,
apiToken: string | undefined,
bankId: string,
maxPendingOperations: number,
maxPendingOperations: number
): Promise<void> {
for (;;) {
try {
@@ -359,7 +381,7 @@ async function waitForBankQueue(
return;
}
} catch (error) {
if (error instanceof Error && error.message.includes('HTTP 404')) {
if (error instanceof Error && error.message.includes("HTTP 404")) {
return;
}
throw error;
@@ -371,12 +393,12 @@ async function waitForBankQueue(
async function getInitialBankStats(
apiUrl: string,
apiToken: string | undefined,
bankId: string,
bankId: string
): Promise<BankStats | null> {
try {
return await fetchBankStats(apiUrl, apiToken, bankId);
} catch (error) {
if (error instanceof Error && error.message.includes('HTTP 404')) {
if (error instanceof Error && error.message.includes("HTTP 404")) {
return null;
}
throw error;
@@ -386,12 +408,12 @@ async function getInitialBankStats(
async function waitForBanksToDrain(
apiUrl: string,
apiToken: string | undefined,
bankIds: Iterable<string>,
bankIds: Iterable<string>
): Promise<Map<string, BankStats>> {
const ids = Array.from(bankIds);
for (;;) {
const stats = await Promise.all(
ids.map(async (bankId) => ({ bankId, stats: await fetchBankStats(apiUrl, apiToken, bankId) })),
ids.map(async (bankId) => ({ bankId, stats: await fetchBankStats(apiUrl, apiToken, bankId) }))
);
const statsByBank = new Map(stats.map(({ bankId, stats: bankStats }) => [bankId, bankStats]));
const pending = stats.filter(({ stats: bankStats }) => bankStats.pending_operations > 0);
@@ -400,8 +422,11 @@ async function waitForBanksToDrain(
}
console.log(
pending
.map(({ bankId, stats: bankStats }) => `${bankId}\tpending_operations=${bankStats.pending_operations}\tfailed_operations=${bankStats.failed_operations}\tpending_consolidation=${bankStats.pending_consolidation}`)
.join('\n'),
.map(
({ bankId, stats: bankStats }) =>
`${bankId}\tpending_operations=${bankStats.pending_operations}\tfailed_operations=${bankStats.failed_operations}\tpending_consolidation=${bankStats.pending_consolidation}`
)
.join("\n")
);
await new Promise((resolve) => setTimeout(resolve, 5000));
}
@@ -409,7 +434,7 @@ async function waitForBanksToDrain(
export async function runCli(argv: string[] = process.argv.slice(2)): Promise<void> {
const args = parseArgs(argv);
if (!existsSync(join(args.openclawRoot, 'openclaw.json'))) {
if (!existsSync(join(args.openclawRoot, "openclaw.json"))) {
throw new Error(`could not find openclaw.json under ${args.openclawRoot}`);
}
@@ -423,13 +448,22 @@ export async function runCli(argv: string[] = process.argv.slice(2)): Promise<vo
fixedBank: args.fixedBank,
};
const checkpoint = loadCheckpoint(args.checkpointPath);
const { entries, discoveredSessions, skippedEmpty } = buildBackfillPlan(pluginConfig, backfillOptions);
const { entries, discoveredSessions, skippedEmpty } = buildBackfillPlan(
pluginConfig,
backfillOptions
);
const plannedEntries = filterEntriesForResume(entries, checkpoint, args.resume);
const { entriesToEnqueue, alreadyEnqueuedKeys } = splitResumeEntries(plannedEntries, checkpoint, args.waitUntilDrained);
const { entriesToEnqueue, alreadyEnqueuedKeys } = splitResumeEntries(
plannedEntries,
checkpoint,
args.waitUntilDrained
);
if (args.dryRun) {
for (const entry of plannedEntries) {
console.log(`${entry.agentId}\t${entry.bankId}\t${entry.sessionId}\tmsgs=${entry.messageCount}\tchars=${entry.transcript.length}`);
console.log(
`${entry.agentId}\t${entry.bankId}\t${entry.sessionId}\tmsgs=${entry.messageCount}\tchars=${entry.transcript.length}`
);
}
const summary = {
profile: args.profile,
@@ -463,7 +497,8 @@ export async function runCli(argv: string[] = process.argv.slice(2)): Promise<vo
bankId: checkpointEntry.bankId,
touchedEntryKeys: [],
initialFailedOperations:
(await getInitialBankStats(runtime.apiUrl, runtime.apiToken, checkpointEntry.bankId))?.failed_operations ?? 0,
(await getInitialBankStats(runtime.apiUrl, runtime.apiToken, checkpointEntry.bankId))
?.failed_operations ?? 0,
missionApplied: false,
};
bankRuntimes.set(checkpointEntry.bankId, bankRuntime);
@@ -478,7 +513,8 @@ export async function runCli(argv: string[] = process.argv.slice(2)): Promise<vo
bankId: entry.bankId,
touchedEntryKeys: [],
initialFailedOperations:
(await getInitialBankStats(runtime.apiUrl, runtime.apiToken, entry.bankId))?.failed_operations ?? 0,
(await getInitialBankStats(runtime.apiUrl, runtime.apiToken, entry.bankId))
?.failed_operations ?? 0,
missionApplied: false,
};
bankRuntimes.set(entry.bankId, bankRuntime);
@@ -488,13 +524,18 @@ export async function runCli(argv: string[] = process.argv.slice(2)): Promise<vo
await client.createBank(entry.bankId, { reflectMission: pluginConfig.bankMission });
}
if (typeof args.maxPendingOperations === 'number' && args.maxPendingOperations >= 0) {
await waitForBankQueue(runtime.apiUrl, runtime.apiToken, entry.bankId, args.maxPendingOperations);
if (typeof args.maxPendingOperations === "number" && args.maxPendingOperations >= 0) {
await waitForBankQueue(
runtime.apiUrl,
runtime.apiToken,
entry.bankId,
args.maxPendingOperations
);
}
try {
const metadata: Record<string, string> = {
source: 'openclaw-backfill',
source: "openclaw-backfill",
file_path: entry.filePath,
agent_id: entry.agentId,
session_id: entry.sessionId,
@@ -509,7 +550,7 @@ export async function runCli(argv: string[] = process.argv.slice(2)): Promise<vo
async: true,
});
checkpoint.entries[checkpointKey(entry)] = {
status: 'enqueued',
status: "enqueued",
bankId: entry.bankId,
filePath: entry.filePath,
sessionId: entry.sessionId,
@@ -525,7 +566,7 @@ export async function runCli(argv: string[] = process.argv.slice(2)): Promise<vo
imported += 1;
} catch (error) {
checkpoint.entries[checkpointKey(entry)] = {
status: 'failed',
status: "failed",
bankId: entry.bankId,
filePath: entry.filePath,
sessionId: entry.sessionId,
@@ -534,15 +575,36 @@ export async function runCli(argv: string[] = process.argv.slice(2)): Promise<vo
};
saveCheckpoint(args.checkpointPath, checkpoint);
failed += 1;
console.error(`${entry.agentId}\t${entry.bankId}\t${entry.sessionId}\tfailed\t${error instanceof Error ? error.message : String(error)}`);
console.error(
`${entry.agentId}\t${entry.bankId}\t${entry.sessionId}\tfailed\t${error instanceof Error ? error.message : String(error)}`
);
}
}
if (args.waitUntilDrained && bankRuntimes.size > 0) {
const finalStatsByBank = await waitForBanksToDrain(runtime.apiUrl, runtime.apiToken, bankRuntimes.keys());
const touchedEntriesByBank = new Map(Array.from(bankRuntimes.entries()).map(([bankId, value]) => [bankId, value.touchedEntryKeys]));
const initialFailedByBank = new Map(Array.from(bankRuntimes.entries()).map(([bankId, value]) => [bankId, value.initialFailedOperations]));
const finalization = applyDrainResults(checkpoint, touchedEntriesByBank, finalStatsByBank, initialFailedByBank);
const finalStatsByBank = await waitForBanksToDrain(
runtime.apiUrl,
runtime.apiToken,
bankRuntimes.keys()
);
const touchedEntriesByBank = new Map(
Array.from(bankRuntimes.entries()).map(([bankId, value]) => [
bankId,
value.touchedEntryKeys,
])
);
const initialFailedByBank = new Map(
Array.from(bankRuntimes.entries()).map(([bankId, value]) => [
bankId,
value.initialFailedOperations,
])
);
const finalization = applyDrainResults(
checkpoint,
touchedEntriesByBank,
finalStatsByBank,
initialFailedByBank
);
finalized = finalization.completed;
for (const warning of finalization.warnings) {
console.warn(warning);
@@ -577,11 +639,16 @@ function canonicalizeExecutionPath(path: string): string {
}
}
export function isDirectExecution(entrypoint: string | undefined = process.argv[1], moduleUrl: string = import.meta.url): boolean {
export function isDirectExecution(
entrypoint: string | undefined = process.argv[1],
moduleUrl: string = import.meta.url
): boolean {
if (!entrypoint) {
return false;
}
return canonicalizeExecutionPath(entrypoint) === canonicalizeExecutionPath(fileURLToPath(moduleUrl));
return (
canonicalizeExecutionPath(entrypoint) === canonicalizeExecutionPath(fileURLToPath(moduleUrl))
);
}
if (isDirectExecution()) {
@@ -1,135 +1,138 @@
import { describe, it, expect } from 'vitest';
import { deriveBankId } from './index.js';
import type { PluginHookAgentContext, PluginConfig } from './types.js';
import { describe, it, expect } from "vitest";
import { deriveBankId } from "./index.js";
import type { PluginHookAgentContext, PluginConfig } from "./types.js";
describe('deriveBankId', () => {
describe("deriveBankId", () => {
const ctx: PluginHookAgentContext = {
agentId: 'agent-123',
channelId: 'channel-456',
senderId: 'user-789',
messageProvider: 'slack',
agentId: "agent-123",
channelId: "channel-456",
senderId: "user-789",
messageProvider: "slack",
};
const baseConfig: PluginConfig = {
dynamicBankId: true,
};
it('should use default isolation fields when not specified', () => {
it("should use default isolation fields when not specified", () => {
const bankId = deriveBankId(ctx, baseConfig);
expect(bankId).toBe('agent-123::channel-456::user-789');
expect(bankId).toBe("agent-123::channel-456::user-789");
});
it('should default to dynamic bank ID when dynamicBankId is not specified', () => {
it("should default to dynamic bank ID when dynamicBankId is not specified", () => {
const config: PluginConfig = {};
const bankId = deriveBankId(ctx, config);
expect(bankId).toBe('agent-123::channel-456::user-789');
expect(bankId).toBe("agent-123::channel-456::user-789");
});
it('should support ["agent", "user"] isolation', () => {
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['agent', 'user'] };
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ["agent", "user"] };
const bankId = deriveBankId(ctx, config);
expect(bankId).toBe('agent-123::user-789');
expect(bankId).toBe("agent-123::user-789");
});
it('should support ["user"] isolation', () => {
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['user'] };
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ["user"] };
const bankId = deriveBankId(ctx, config);
expect(bankId).toBe('user-789');
expect(bankId).toBe("user-789");
});
it('should support ["agent"] isolation', () => {
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['agent'] };
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ["agent"] };
const bankId = deriveBankId(ctx, config);
expect(bankId).toBe('agent-123');
expect(bankId).toBe("agent-123");
});
it('should support ["channel"] isolation', () => {
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['channel'] };
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ["channel"] };
const bankId = deriveBankId(ctx, config);
expect(bankId).toBe('channel-456');
expect(bankId).toBe("channel-456");
});
it('should support ["provider"] isolation', () => {
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['provider'] };
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ["provider"] };
const bankId = deriveBankId(ctx, config);
expect(bankId).toBe('slack');
expect(bankId).toBe("slack");
});
it('should support mixed fields including provider', () => {
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['provider', 'user'] };
it("should support mixed fields including provider", () => {
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ["provider", "user"] };
const bankId = deriveBankId(ctx, config);
expect(bankId).toBe('slack::user-789');
expect(bankId).toBe("slack::user-789");
});
it('should prepend bankIdPrefix if set', () => {
const config: PluginConfig = { ...baseConfig, bankIdPrefix: 'prod' };
it("should prepend bankIdPrefix if set", () => {
const config: PluginConfig = { ...baseConfig, bankIdPrefix: "prod" };
const bankId = deriveBankId(ctx, config);
expect(bankId).toBe('prod-agent-123::channel-456::user-789');
expect(bankId).toBe("prod-agent-123::channel-456::user-789");
});
it('should use fallback values for missing context fields', () => {
it("should use fallback values for missing context fields", () => {
const partialCtx: PluginHookAgentContext = {
agentId: 'agent-123',
agentId: "agent-123",
};
const bankId = deriveBankId(partialCtx, baseConfig);
expect(bankId).toBe('agent-123::unknown::anonymous');
expect(bankId).toBe("agent-123::unknown::anonymous");
});
it('should parse sessionKey as fallback for missing channel and provider', () => {
it("should parse sessionKey as fallback for missing channel and provider", () => {
const ctxWithSession: PluginHookAgentContext = {
agentId: 'my-agent',
sessionKey: 'agent:my-agent:telegram:group:-100123456:topic:7',
agentId: "my-agent",
sessionKey: "agent:my-agent:telegram:group:-100123456:topic:7",
};
const config: PluginConfig = {
...baseConfig,
dynamicBankGranularity: ["agent", "channel", "provider"],
};
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['agent', 'channel', 'provider'] };
const bankId = deriveBankId(ctxWithSession, config);
expect(bankId).toBe('my-agent::group%3A-100123456%3Atopic%3A7::telegram');
expect(bankId).toBe("my-agent::group%3A-100123456%3Atopic%3A7::telegram");
});
it('should return "openclaw" if dynamicBankId is false', () => {
const config: PluginConfig = { dynamicBankId: false };
const bankId = deriveBankId(ctx, config);
expect(bankId).toBe('openclaw');
expect(bankId).toBe("openclaw");
});
it('should return configured bankId when dynamicBankId is false', () => {
it("should return configured bankId when dynamicBankId is false", () => {
const config: PluginConfig = {
dynamicBankId: false,
bankId: 'shared-bank',
bankIdPrefix: 'prod',
dynamicBankGranularity: ['provider', 'user'],
bankId: "shared-bank",
bankIdPrefix: "prod",
dynamicBankGranularity: ["provider", "user"],
};
const bankId = deriveBankId(ctx, config);
expect(bankId).toBe('prod-shared-bank');
expect(bankId).toBe("prod-shared-bank");
});
it('should ignore ctx.channelId when it is a provider name and fall back to sessionKey (issue #854)', () => {
it("should ignore ctx.channelId when it is a provider name and fall back to sessionKey (issue #854)", () => {
const ctxDiscord: PluginHookAgentContext = {
agentId: 'main',
channelId: 'discord',
sessionKey: 'agent:main:discord:channel:1472750640760623226',
agentId: "main",
channelId: "discord",
sessionKey: "agent:main:discord:channel:1472750640760623226",
};
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['agent', 'channel'] };
const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ["agent", "channel"] };
const bankId = deriveBankId(ctxDiscord, config);
expect(bankId).toBe('main::channel%3A1472750640760623226');
expect(bankId).toBe("main::channel%3A1472750640760623226");
});
it('should encode segments to prevent separator collisions', () => {
it("should encode segments to prevent separator collisions", () => {
const ctxWithSeparator: PluginHookAgentContext = {
agentId: 'a::b',
channelId: 'c',
senderId: 'user-1',
agentId: "a::b",
channelId: "c",
senderId: "user-1",
};
const ctxWithoutSeparator: PluginHookAgentContext = {
agentId: 'a',
channelId: 'b::c',
senderId: 'user-1',
agentId: "a",
channelId: "b::c",
senderId: "user-1",
};
const bankId1 = deriveBankId(ctxWithSeparator, baseConfig);
const bankId2 = deriveBankId(ctxWithoutSeparator, baseConfig);
// These must NOT collide
expect(bankId1).not.toBe(bankId2);
// Segment delimiters are encoded, preserving unique values.
expect(bankId1).toBe('a%3A%3Ab::c::user-1');
expect(bankId2).toBe('a::b%3A%3Ac::user-1');
expect(bankId1).toBe("a%3A%3Ab::c::user-1");
expect(bankId2).toBe("a::b%3A%3Ac::user-1");
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22 -17
View File
@@ -9,7 +9,7 @@
* - Batched retain/recall summaries instead of per-event spam
*/
export type LogLevel = 'off' | 'error' | 'warning' | 'info' | 'debug';
export type LogLevel = "off" | "error" | "warning" | "info" | "debug";
export interface LoggerConfig {
/** Minimum severity to print. Default: 'info' */
@@ -19,7 +19,7 @@ export interface LoggerConfig {
}
// Muted blue (38;5;103 = slate/dusty blue from 256-color palette)
const PREFIX = '\x1b[38;5;103mhindsight:\x1b[0m';
const PREFIX = "\x1b[38;5;103mhindsight:\x1b[0m";
const LEVEL_RANK: Record<LogLevel, number> = {
off: 0,
@@ -45,16 +45,20 @@ const banksSeen = new Set<string>();
let lastSummaryTime = Date.now();
let summaryTimer: ReturnType<typeof setInterval> | null = null;
let currentLevel: LogLevel = 'info';
let currentLevel: LogLevel = "info";
let currentSummaryIntervalMs = 300_000; // 5 min
/** Bind to OpenClaw's api.logger for consistent output formatting */
export function setApiLogger(logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void }): void {
export function setApiLogger(logger: {
info(msg: string): void;
warn(msg: string): void;
error(msg: string): void;
}): void {
apiLogger = logger;
}
export function configureLogger(cfg: LoggerConfig): void {
currentLevel = cfg.logLevel ?? 'info';
currentLevel = cfg.logLevel ?? "info";
currentSummaryIntervalMs = cfg.logSummaryIntervalMs ?? 300_000;
// Restart summary timer
@@ -62,7 +66,7 @@ export function configureLogger(cfg: LoggerConfig): void {
clearInterval(summaryTimer);
summaryTimer = null;
}
if (currentSummaryIntervalMs > 0 && LEVEL_RANK[currentLevel] >= LEVEL_RANK['info']) {
if (currentSummaryIntervalMs > 0 && LEVEL_RANK[currentLevel] >= LEVEL_RANK["info"]) {
summaryTimer = setInterval(flushSummary, currentSummaryIntervalMs);
summaryTimer.unref?.(); // don't keep process alive
}
@@ -74,26 +78,26 @@ function allowed(level: LogLevel): boolean {
/** Info-level log (requires 'info' or higher) */
export function info(msg: string): void {
if (!allowed('info')) return;
if (!allowed("info")) return;
apiLogger.info(`${PREFIX} ${msg}`);
}
/** Debug log (requires 'debug') */
export function verbose(msg: string): void {
if (!allowed('debug')) return;
if (!allowed("debug")) return;
apiLogger.info(`${PREFIX} ${msg}`);
}
/** Warning (requires 'warning' or higher) */
export function warn(msg: string): void {
if (!allowed('warning')) return;
if (!allowed("warning")) return;
apiLogger.warn(`${PREFIX} ${msg}`);
}
/** Error (requires 'error' or higher) */
export function error(msg: string, err?: unknown): void {
if (!allowed('error')) return;
const detail = err instanceof Error ? err.message : (err ? String(err) : '');
if (!allowed("error")) return;
const detail = err instanceof Error ? err.message : err ? String(err) : "";
apiLogger.error(`${PREFIX} ${detail ? `${msg}: ${detail}` : msg}`);
}
@@ -102,7 +106,7 @@ export function trackRetain(bankId: string, messageCount: number): void {
retainCount++;
retainMsgTotal += messageCount;
banksSeen.add(bankId);
if (currentSummaryIntervalMs === 0 && allowed('info')) {
if (currentSummaryIntervalMs === 0 && allowed("info")) {
apiLogger.info(`${PREFIX} auto-retained ${messageCount} messages (bank: ${bankId})`);
}
}
@@ -117,17 +121,18 @@ export function trackRecall(bankId: string, memoriesFound: number): void {
/** Flush the batched summary to console */
export function flushSummary(): void {
if (!allowed('info')) return;
if (!allowed("info")) return;
if (retainCount === 0 && recallCount === 0) return;
const elapsed = Math.round((Date.now() - lastSummaryTime) / 1000);
const parts: string[] = [];
if (recallCount > 0) parts.push(`${recallCount} recalls (${recallMemoriesCount} memories injected)`);
if (recallCount > 0)
parts.push(`${recallCount} recalls (${recallMemoriesCount} memories injected)`);
if (retainCount > 0) parts.push(`${retainCount} retains (${retainMsgTotal} messages captured)`);
const bankList = [...banksSeen];
const bankLabel = bankList.length === 1 ? 'bank' : 'banks';
const banks = bankList.length > 0 ? ` (${bankLabel}: ${bankList.join(', ')})` : '';
apiLogger.info(`${PREFIX} ${parts.join(', ')} in ${elapsed}s${banks}`);
const bankLabel = bankList.length === 1 ? "bank" : "banks";
const banks = bankList.length > 0 ? ` (${bankLabel}: ${bankList.join(", ")})` : "";
apiLogger.info(`${PREFIX} ${parts.join(", ")} in ${elapsed}s${banks}`);
retainCount = 0;
retainMsgTotal = 0;
@@ -1,21 +1,21 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const manifestPath = resolve(__dirname, '..', 'openclaw.plugin.json');
const manifestPath = resolve(__dirname, "..", "openclaw.plugin.json");
describe('openclaw.plugin.json', () => {
it('is valid JSON', () => {
const raw = readFileSync(manifestPath, 'utf-8');
describe("openclaw.plugin.json", () => {
it("is valid JSON", () => {
const raw = readFileSync(manifestPath, "utf-8");
expect(() => JSON.parse(raw)).not.toThrow();
});
it('has required top-level fields', () => {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
expect(manifest.id).toBe('hindsight-openclaw');
expect(manifest.name).toBeTypeOf('string');
it("has required top-level fields", () => {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
expect(manifest.id).toBe("hindsight-openclaw");
expect(manifest.name).toBeTypeOf("string");
expect(manifest.configSchema).toBeDefined();
expect(manifest.configSchema.properties).toBeDefined();
});
@@ -1,9 +1,9 @@
// Type definitions for moltbot plugin SDK
// These are minimal types based on the documentation
declare module 'moltbot/plugin-sdk' {
declare module "moltbot/plugin-sdk" {
export interface HookEvent {
type: 'command' | 'session' | 'agent' | 'gateway' | 'tool_result_persist';
type: "command" | "session" | "agent" | "gateway" | "tool_result_persist";
action?: string;
sessionKey?: string;
timestamp?: string;
@@ -15,8 +15,8 @@ import {
existsSync,
renameSync,
unlinkSync,
} from 'fs';
import { randomBytes } from 'crypto';
} from "fs";
import { randomBytes } from "crypto";
/** The subset of a retain payload the queue needs to persist and replay. */
export interface QueuedRetainPayload {
@@ -57,15 +57,15 @@ export class RetainQueue {
/** Append a failed retain for later delivery. */
enqueue(bankId: string, request: QueuedRetainPayload, metadata?: Record<string, unknown>): void {
const item: QueuedRetain = {
id: `${Date.now()}-${randomBytes(4).toString('hex')}`,
id: `${Date.now()}-${randomBytes(4).toString("hex")}`,
bankId,
content: request.content,
documentId: request.documentId || 'conversation',
documentId: request.documentId || "conversation",
metadata: metadata || request.metadata || {},
tags: request.tags,
createdAt: new Date().toISOString(),
};
appendFileSync(this.filePath, JSON.stringify(item) + '\n', 'utf8');
appendFileSync(this.filePath, JSON.stringify(item) + "\n", "utf8");
this.cachedSize++;
}
@@ -112,10 +112,10 @@ export class RetainQueue {
private readAll(): QueuedRetain[] {
if (!existsSync(this.filePath)) return [];
const content = readFileSync(this.filePath, 'utf8').trim();
const content = readFileSync(this.filePath, "utf8").trim();
if (!content) return [];
const items: QueuedRetain[] = [];
for (const line of content.split('\n')) {
for (const line of content.split("\n")) {
try {
items.push(JSON.parse(line) as QueuedRetain);
} catch {
@@ -136,8 +136,8 @@ export class RetainQueue {
this.cachedSize = 0;
return;
}
const tmpPath = this.filePath + '.tmp';
writeFileSync(tmpPath, items.map((i) => JSON.stringify(i)).join('\n') + '\n', 'utf8');
const tmpPath = this.filePath + ".tmp";
writeFileSync(tmpPath, items.map((i) => JSON.stringify(i)).join("\n") + "\n", "utf8");
renameSync(tmpPath, this.filePath);
this.cachedSize = items.length;
}
@@ -1,53 +1,53 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect } from "vitest";
import {
compileSessionPattern,
compileSessionPatterns,
matchesSessionPattern,
} from './session-patterns.js';
} from "./session-patterns.js";
// ---------------------------------------------------------------------------
// compileSessionPattern
// ---------------------------------------------------------------------------
describe('compileSessionPattern', () => {
it('matches an exact key', () => {
const p = compileSessionPattern('agent:main:sess-123');
expect(p.test('agent:main:sess-123')).toBe(true);
expect(p.test('agent:main:sess-456')).toBe(false);
describe("compileSessionPattern", () => {
it("matches an exact key", () => {
const p = compileSessionPattern("agent:main:sess-123");
expect(p.test("agent:main:sess-123")).toBe(true);
expect(p.test("agent:main:sess-456")).toBe(false);
});
it('single * does not cross colon', () => {
const p = compileSessionPattern('agent:*:sess');
expect(p.test('agent:main:sess')).toBe(true);
expect(p.test('agent:subagent:sess')).toBe(true);
expect(p.test('agent:a:b:sess')).toBe(false);
it("single * does not cross colon", () => {
const p = compileSessionPattern("agent:*:sess");
expect(p.test("agent:main:sess")).toBe(true);
expect(p.test("agent:subagent:sess")).toBe(true);
expect(p.test("agent:a:b:sess")).toBe(false);
});
it('double ** crosses colons', () => {
const p = compileSessionPattern('agent:main:**');
expect(p.test('agent:main:sess-abc123')).toBe(true);
expect(p.test('agent:main:a:b:c')).toBe(true);
expect(p.test('agent:other:sess-abc123')).toBe(false);
it("double ** crosses colons", () => {
const p = compileSessionPattern("agent:main:**");
expect(p.test("agent:main:sess-abc123")).toBe(true);
expect(p.test("agent:main:a:b:c")).toBe(true);
expect(p.test("agent:other:sess-abc123")).toBe(false);
});
it('double ** at start matches any prefix', () => {
const p = compileSessionPattern('**:subagent:**');
expect(p.test('claude-code:subagent:sess-abc')).toBe(true);
expect(p.test('mybot:subagent:sess-xyz')).toBe(true);
expect(p.test('mybot:main:sess-xyz')).toBe(false);
it("double ** at start matches any prefix", () => {
const p = compileSessionPattern("**:subagent:**");
expect(p.test("claude-code:subagent:sess-abc")).toBe(true);
expect(p.test("mybot:subagent:sess-xyz")).toBe(true);
expect(p.test("mybot:main:sess-xyz")).toBe(false);
});
it('matches lossless-claw cron pattern', () => {
const p = compileSessionPattern('agent:*:cron:**');
expect(p.test('agent:mybot:cron:sess-123')).toBe(true);
expect(p.test('agent:mybot:subagent:sess-123')).toBe(false);
it("matches lossless-claw cron pattern", () => {
const p = compileSessionPattern("agent:*:cron:**");
expect(p.test("agent:mybot:cron:sess-123")).toBe(true);
expect(p.test("agent:mybot:subagent:sess-123")).toBe(false);
});
it('matches lossless-claw subagent pattern', () => {
const p = compileSessionPattern('agent:*:subagent:**');
expect(p.test('agent:main:subagent:sess-abc')).toBe(true);
expect(p.test('agent:x:subagent:sess-123')).toBe(true);
expect(p.test('agent:a:b:subagent:sess')).toBe(false);
it("matches lossless-claw subagent pattern", () => {
const p = compileSessionPattern("agent:*:subagent:**");
expect(p.test("agent:main:subagent:sess-abc")).toBe(true);
expect(p.test("agent:x:subagent:sess-123")).toBe(true);
expect(p.test("agent:a:b:subagent:sess")).toBe(false);
});
});
@@ -55,33 +55,33 @@ describe('compileSessionPattern', () => {
// matchesSessionPattern
// ---------------------------------------------------------------------------
describe('matchesSessionPattern', () => {
it('returns true when any pattern matches', () => {
const patterns = compileSessionPatterns(['agent:main:**', 'agent:*:cron:**']);
expect(matchesSessionPattern('agent:main:sess-abc', patterns)).toBe(true);
expect(matchesSessionPattern('agent:mybot:cron:sess-xyz', patterns)).toBe(true);
describe("matchesSessionPattern", () => {
it("returns true when any pattern matches", () => {
const patterns = compileSessionPatterns(["agent:main:**", "agent:*:cron:**"]);
expect(matchesSessionPattern("agent:main:sess-abc", patterns)).toBe(true);
expect(matchesSessionPattern("agent:mybot:cron:sess-xyz", patterns)).toBe(true);
});
it('returns false when no pattern matches', () => {
const patterns = compileSessionPatterns(['agent:main:**']);
expect(matchesSessionPattern('agent:subagent:sess-abc', patterns)).toBe(false);
it("returns false when no pattern matches", () => {
const patterns = compileSessionPatterns(["agent:main:**"]);
expect(matchesSessionPattern("agent:subagent:sess-abc", patterns)).toBe(false);
});
it('returns false for empty pattern list', () => {
expect(matchesSessionPattern('agent:main:sess', [])).toBe(false);
it("returns false for empty pattern list", () => {
expect(matchesSessionPattern("agent:main:sess", [])).toBe(false);
});
it('lossless-claw ignoreSessionPatterns example', () => {
const patterns = compileSessionPatterns(['agent:main:**', 'agent:*:cron:**']);
expect(matchesSessionPattern('agent:main:sess-abc123', patterns)).toBe(true);
expect(matchesSessionPattern('agent:mybot:cron:sess-123', patterns)).toBe(true);
expect(matchesSessionPattern('agent:mybot:subagent:sess-123', patterns)).toBe(false);
it("lossless-claw ignoreSessionPatterns example", () => {
const patterns = compileSessionPatterns(["agent:main:**", "agent:*:cron:**"]);
expect(matchesSessionPattern("agent:main:sess-abc123", patterns)).toBe(true);
expect(matchesSessionPattern("agent:mybot:cron:sess-123", patterns)).toBe(true);
expect(matchesSessionPattern("agent:mybot:subagent:sess-123", patterns)).toBe(false);
});
it('lossless-claw statelessSessionPatterns example', () => {
const patterns = compileSessionPatterns(['agent:*:subagent:**', 'agent:*:heartbeat:**']);
expect(matchesSessionPattern('agent:main:subagent:sess-abc', patterns)).toBe(true);
expect(matchesSessionPattern('agent:main:heartbeat:sess-abc', patterns)).toBe(true);
expect(matchesSessionPattern('agent:main:sess-abc', patterns)).toBe(false);
it("lossless-claw statelessSessionPatterns example", () => {
const patterns = compileSessionPatterns(["agent:*:subagent:**", "agent:*:heartbeat:**"]);
expect(matchesSessionPattern("agent:main:subagent:sess-abc", patterns)).toBe(true);
expect(matchesSessionPattern("agent:main:heartbeat:sess-abc", patterns)).toBe(true);
expect(matchesSessionPattern("agent:main:sess-abc", patterns)).toBe(false);
});
});
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, readFile, rm, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import {
HINDSIGHT_CLOUD_URL,
PLUGIN_ID,
@@ -18,109 +18,111 @@ import {
summarizeCloud,
summarizeEmbedded,
type OpenClawConfigShape,
} from './setup-lib.js';
} from "./setup-lib.js";
describe('isValidEnvVarName', () => {
it('accepts UPPER_SNAKE_CASE', () => {
expect(isValidEnvVarName('OPENAI_API_KEY')).toBe(true);
expect(isValidEnvVarName('HINDSIGHT_CLOUD_TOKEN')).toBe(true);
expect(isValidEnvVarName('A')).toBe(true);
describe("isValidEnvVarName", () => {
it("accepts UPPER_SNAKE_CASE", () => {
expect(isValidEnvVarName("OPENAI_API_KEY")).toBe(true);
expect(isValidEnvVarName("HINDSIGHT_CLOUD_TOKEN")).toBe(true);
expect(isValidEnvVarName("A")).toBe(true);
});
it('rejects lowercase, leading digits, empty, and undefined', () => {
expect(isValidEnvVarName('lowercase')).toBe(false);
expect(isValidEnvVarName('1LEADING_DIGIT')).toBe(false);
expect(isValidEnvVarName('')).toBe(false);
it("rejects lowercase, leading digits, empty, and undefined", () => {
expect(isValidEnvVarName("lowercase")).toBe(false);
expect(isValidEnvVarName("1LEADING_DIGIT")).toBe(false);
expect(isValidEnvVarName("")).toBe(false);
expect(isValidEnvVarName(undefined)).toBe(false);
expect(isValidEnvVarName('has-dash')).toBe(false);
expect(isValidEnvVarName("has-dash")).toBe(false);
});
});
describe('defaultApiKeyEnvVar', () => {
it('UPPERs and snake_cases the provider id', () => {
expect(defaultApiKeyEnvVar('openai')).toBe('OPENAI_API_KEY');
expect(defaultApiKeyEnvVar('claude-code')).toBe('CLAUDE_CODE_API_KEY');
describe("defaultApiKeyEnvVar", () => {
it("UPPERs and snake_cases the provider id", () => {
expect(defaultApiKeyEnvVar("openai")).toBe("OPENAI_API_KEY");
expect(defaultApiKeyEnvVar("claude-code")).toBe("CLAUDE_CODE_API_KEY");
});
});
describe('envSecretRef', () => {
it('builds a default-provider env SecretRef', () => {
expect(envSecretRef('OPENAI_API_KEY')).toEqual({
source: 'env',
provider: 'default',
id: 'OPENAI_API_KEY',
describe("envSecretRef", () => {
it("builds a default-provider env SecretRef", () => {
expect(envSecretRef("OPENAI_API_KEY")).toEqual({
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
});
});
});
describe('ensurePluginConfig', () => {
it('initializes the hindsight-openclaw entry on an empty config', () => {
describe("ensurePluginConfig", () => {
it("initializes the hindsight-openclaw entry on an empty config", () => {
const cfg: OpenClawConfigShape = {};
const pc = ensurePluginConfig(cfg);
expect(cfg.plugins?.entries?.[PLUGIN_ID]).toEqual({ enabled: true, config: {} });
expect(pc).toBe(cfg.plugins?.entries?.[PLUGIN_ID]?.config);
});
it('preserves existing config values and forces enabled=true', () => {
it("preserves existing config values and forces enabled=true", () => {
const cfg: OpenClawConfigShape = {
plugins: {
entries: {
[PLUGIN_ID]: {
enabled: false,
config: { llmProvider: 'openai' },
config: { llmProvider: "openai" },
},
},
},
};
const pc = ensurePluginConfig(cfg);
expect(cfg.plugins?.entries?.[PLUGIN_ID]?.enabled).toBe(true);
expect(pc.llmProvider).toBe('openai');
expect(pc.llmProvider).toBe("openai");
});
});
describe('applyCloudMode — direct token value', () => {
it('stores the token inline when a literal value is provided', () => {
describe("applyCloudMode — direct token value", () => {
it("stores the token inline when a literal value is provided", () => {
const pc: Record<string, unknown> = {
llmProvider: 'openai',
llmApiKey: { source: 'env', provider: 'default', id: 'OPENAI_API_KEY' },
llmProvider: "openai",
llmApiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
};
applyCloudMode(pc, { token: 'hsk_literal_value' });
applyCloudMode(pc, { token: "hsk_literal_value" });
expect(pc.hindsightApiUrl).toBe(HINDSIGHT_CLOUD_URL);
expect(pc.hindsightApiToken).toBe('hsk_literal_value');
expect(pc.hindsightApiToken).toBe("hsk_literal_value");
expect(pc.llmProvider).toBeUndefined();
expect(pc.llmApiKey).toBeUndefined();
});
it('trims whitespace around an inline token', () => {
it("trims whitespace around an inline token", () => {
const pc: Record<string, unknown> = {};
applyCloudMode(pc, { token: ' hsk_padded ' });
expect(pc.hindsightApiToken).toBe('hsk_padded');
applyCloudMode(pc, { token: " hsk_padded " });
expect(pc.hindsightApiToken).toBe("hsk_padded");
});
it('throws when neither token nor tokenEnvVar is provided', () => {
it("throws when neither token nor tokenEnvVar is provided", () => {
const pc: Record<string, unknown> = {};
expect(() => applyCloudMode(pc, {})).toThrow(/requires either/);
});
it('throws when both token and tokenEnvVar are provided', () => {
it("throws when both token and tokenEnvVar are provided", () => {
const pc: Record<string, unknown> = {};
expect(() => applyCloudMode(pc, { token: 'x', tokenEnvVar: 'Y' })).toThrow(/either a direct value or an env var name/);
expect(() => applyCloudMode(pc, { token: "x", tokenEnvVar: "Y" })).toThrow(
/either a direct value or an env var name/
);
});
});
describe('applyCloudMode', () => {
it('writes the default URL and a SecretRef, stripping local LLM state', () => {
describe("applyCloudMode", () => {
it("writes the default URL and a SecretRef, stripping local LLM state", () => {
const pc: Record<string, unknown> = {
llmProvider: 'openai',
llmApiKey: { source: 'env', provider: 'default', id: 'OPENAI_API_KEY' },
llmModel: 'gpt-4o-mini',
llmBaseUrl: 'https://openrouter.ai/api/v1',
llmProvider: "openai",
llmApiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
llmModel: "gpt-4o-mini",
llmBaseUrl: "https://openrouter.ai/api/v1",
};
applyCloudMode(pc, { tokenEnvVar: 'HINDSIGHT_CLOUD_TOKEN' });
applyCloudMode(pc, { tokenEnvVar: "HINDSIGHT_CLOUD_TOKEN" });
expect(pc.hindsightApiUrl).toBe(HINDSIGHT_CLOUD_URL);
expect(pc.hindsightApiToken).toEqual({
source: 'env',
provider: 'default',
id: 'HINDSIGHT_CLOUD_TOKEN',
source: "env",
provider: "default",
id: "HINDSIGHT_CLOUD_TOKEN",
});
expect(pc.llmProvider).toBeUndefined();
expect(pc.llmApiKey).toBeUndefined();
@@ -128,169 +130,173 @@ describe('applyCloudMode', () => {
expect(pc.llmBaseUrl).toBeUndefined();
});
it('honours an overridden apiUrl', () => {
it("honours an overridden apiUrl", () => {
const pc: Record<string, unknown> = {};
applyCloudMode(pc, {
apiUrl: 'https://cloud.example.com',
tokenEnvVar: 'CLOUD_TOKEN',
apiUrl: "https://cloud.example.com",
tokenEnvVar: "CLOUD_TOKEN",
});
expect(pc.hindsightApiUrl).toBe('https://cloud.example.com');
expect((pc.hindsightApiToken as { id: string }).id).toBe('CLOUD_TOKEN');
expect(pc.hindsightApiUrl).toBe("https://cloud.example.com");
expect((pc.hindsightApiToken as { id: string }).id).toBe("CLOUD_TOKEN");
});
});
describe('applyApiMode — direct token value', () => {
it('stores the token inline when a literal value is provided', () => {
describe("applyApiMode — direct token value", () => {
it("stores the token inline when a literal value is provided", () => {
const pc: Record<string, unknown> = {};
applyApiMode(pc, { apiUrl: 'https://mcp.example.com', token: 'api_literal' });
expect(pc.hindsightApiUrl).toBe('https://mcp.example.com');
expect(pc.hindsightApiToken).toBe('api_literal');
applyApiMode(pc, { apiUrl: "https://mcp.example.com", token: "api_literal" });
expect(pc.hindsightApiUrl).toBe("https://mcp.example.com");
expect(pc.hindsightApiToken).toBe("api_literal");
});
it('throws when both token and tokenEnvVar are provided', () => {
it("throws when both token and tokenEnvVar are provided", () => {
const pc: Record<string, unknown> = {};
expect(() =>
applyApiMode(pc, { apiUrl: 'https://mcp.example.com', token: 'x', tokenEnvVar: 'Y' }),
applyApiMode(pc, { apiUrl: "https://mcp.example.com", token: "x", tokenEnvVar: "Y" })
).toThrow(/either a direct value or an env var name/);
});
});
describe('applyApiMode', () => {
it('writes the URL without a token when none is provided', () => {
describe("applyApiMode", () => {
it("writes the URL without a token when none is provided", () => {
const pc: Record<string, unknown> = {
llmProvider: 'openai',
hindsightApiToken: { source: 'env', provider: 'default', id: 'STALE_TOKEN' },
llmProvider: "openai",
hindsightApiToken: { source: "env", provider: "default", id: "STALE_TOKEN" },
};
applyApiMode(pc, { apiUrl: 'https://mcp.example.com' });
expect(pc.hindsightApiUrl).toBe('https://mcp.example.com');
applyApiMode(pc, { apiUrl: "https://mcp.example.com" });
expect(pc.hindsightApiUrl).toBe("https://mcp.example.com");
expect(pc.hindsightApiToken).toBeUndefined();
expect(pc.llmProvider).toBeUndefined();
});
it('writes a SecretRef when a token env var is provided', () => {
it("writes a SecretRef when a token env var is provided", () => {
const pc: Record<string, unknown> = {};
applyApiMode(pc, { apiUrl: 'https://mcp.example.com', tokenEnvVar: 'MY_TOKEN' });
applyApiMode(pc, { apiUrl: "https://mcp.example.com", tokenEnvVar: "MY_TOKEN" });
expect(pc.hindsightApiToken).toEqual({
source: 'env',
provider: 'default',
id: 'MY_TOKEN',
source: "env",
provider: "default",
id: "MY_TOKEN",
});
});
it('treats an empty token env var as "no token"', () => {
const pc: Record<string, unknown> = {};
applyApiMode(pc, { apiUrl: 'https://mcp.example.com', tokenEnvVar: ' ' });
applyApiMode(pc, { apiUrl: "https://mcp.example.com", tokenEnvVar: " " });
expect(pc.hindsightApiToken).toBeUndefined();
});
});
describe('applyEmbeddedMode — direct API key value', () => {
it('stores the API key inline when a literal value is provided', () => {
describe("applyEmbeddedMode — direct API key value", () => {
it("stores the API key inline when a literal value is provided", () => {
const pc: Record<string, unknown> = {};
applyEmbeddedMode(pc, { llmProvider: 'openai', apiKey: 'sk-literal' });
expect(pc.llmProvider).toBe('openai');
expect(pc.llmApiKey).toBe('sk-literal');
applyEmbeddedMode(pc, { llmProvider: "openai", apiKey: "sk-literal" });
expect(pc.llmProvider).toBe("openai");
expect(pc.llmApiKey).toBe("sk-literal");
});
it('throws when both apiKey and apiKeyEnvVar are provided', () => {
it("throws when both apiKey and apiKeyEnvVar are provided", () => {
const pc: Record<string, unknown> = {};
expect(() =>
applyEmbeddedMode(pc, { llmProvider: 'openai', apiKey: 'sk-x', apiKeyEnvVar: 'OPENAI_API_KEY' }),
applyEmbeddedMode(pc, {
llmProvider: "openai",
apiKey: "sk-x",
apiKeyEnvVar: "OPENAI_API_KEY",
})
).toThrow(/either a direct value or an env var name/);
});
});
describe('applyEmbeddedMode', () => {
it('writes llmProvider + SecretRef for providers that require a key', () => {
describe("applyEmbeddedMode", () => {
it("writes llmProvider + SecretRef for providers that require a key", () => {
const pc: Record<string, unknown> = {
hindsightApiUrl: 'https://stale.example.com',
hindsightApiToken: { source: 'env', provider: 'default', id: 'STALE' },
hindsightApiUrl: "https://stale.example.com",
hindsightApiToken: { source: "env", provider: "default", id: "STALE" },
};
applyEmbeddedMode(pc, { llmProvider: 'openai', apiKeyEnvVar: 'OPENAI_API_KEY' });
expect(pc.llmProvider).toBe('openai');
applyEmbeddedMode(pc, { llmProvider: "openai", apiKeyEnvVar: "OPENAI_API_KEY" });
expect(pc.llmProvider).toBe("openai");
expect(pc.llmApiKey).toEqual({
source: 'env',
provider: 'default',
id: 'OPENAI_API_KEY',
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
});
expect(pc.hindsightApiUrl).toBeUndefined();
expect(pc.hindsightApiToken).toBeUndefined();
});
it('omits llmApiKey for no-key providers like claude-code', () => {
const pc: Record<string, unknown> = { llmApiKey: { source: 'env', provider: 'default', id: 'STALE' } };
applyEmbeddedMode(pc, { llmProvider: 'claude-code' });
expect(pc.llmProvider).toBe('claude-code');
it("omits llmApiKey for no-key providers like claude-code", () => {
const pc: Record<string, unknown> = {
llmApiKey: { source: "env", provider: "default", id: "STALE" },
};
applyEmbeddedMode(pc, { llmProvider: "claude-code" });
expect(pc.llmProvider).toBe("claude-code");
expect(pc.llmApiKey).toBeUndefined();
});
it('throws when a key-requiring provider is given without a key', () => {
it("throws when a key-requiring provider is given without a key", () => {
const pc: Record<string, unknown> = {};
expect(() => applyEmbeddedMode(pc, { llmProvider: 'openai' })).toThrow(
/requires either `apiKey` or `apiKeyEnvVar`/,
expect(() => applyEmbeddedMode(pc, { llmProvider: "openai" })).toThrow(
/requires either `apiKey` or `apiKeyEnvVar`/
);
});
it('persists llmModel when provided and clears it when absent', () => {
const pc: Record<string, unknown> = { llmModel: 'legacy-model' };
applyEmbeddedMode(pc, { llmProvider: 'ollama', llmModel: 'llama3' });
expect(pc.llmModel).toBe('llama3');
it("persists llmModel when provided and clears it when absent", () => {
const pc: Record<string, unknown> = { llmModel: "legacy-model" };
applyEmbeddedMode(pc, { llmProvider: "ollama", llmModel: "llama3" });
expect(pc.llmModel).toBe("llama3");
applyEmbeddedMode(pc, { llmProvider: 'ollama' });
applyEmbeddedMode(pc, { llmProvider: "ollama" });
expect(pc.llmModel).toBeUndefined();
});
});
describe('summarize*', () => {
it('produces human-readable mode summaries', () => {
expect(summarizeCloud({ tokenEnvVar: 'HINDSIGHT_CLOUD_TOKEN' })).toBe(
'Cloud → https://api.hindsight.vectorize.io (token from ${HINDSIGHT_CLOUD_TOKEN})',
describe("summarize*", () => {
it("produces human-readable mode summaries", () => {
expect(summarizeCloud({ tokenEnvVar: "HINDSIGHT_CLOUD_TOKEN" })).toBe(
"Cloud → https://api.hindsight.vectorize.io (token from ${HINDSIGHT_CLOUD_TOKEN})"
);
expect(summarizeApi({ apiUrl: 'https://api.example.com', tokenEnvVar: 'T' })).toBe(
'External API → https://api.example.com (token from ${T})',
expect(summarizeApi({ apiUrl: "https://api.example.com", tokenEnvVar: "T" })).toBe(
"External API → https://api.example.com (token from ${T})"
);
expect(summarizeApi({ apiUrl: 'https://api.example.com', token: 'literal' })).toBe(
'External API → https://api.example.com (token stored inline)',
expect(summarizeApi({ apiUrl: "https://api.example.com", token: "literal" })).toBe(
"External API → https://api.example.com (token stored inline)"
);
expect(summarizeApi({ apiUrl: 'https://api.example.com' })).toBe(
'External API → https://api.example.com (no auth)',
expect(summarizeApi({ apiUrl: "https://api.example.com" })).toBe(
"External API → https://api.example.com (no auth)"
);
expect(summarizeEmbedded({ llmProvider: 'openai', apiKeyEnvVar: 'X' })).toBe(
'Embedded daemon → openai (key from ${X})',
expect(summarizeEmbedded({ llmProvider: "openai", apiKeyEnvVar: "X" })).toBe(
"Embedded daemon → openai (key from ${X})"
);
expect(summarizeEmbedded({ llmProvider: 'openai', apiKey: 'sk-test' })).toBe(
'Embedded daemon → openai (key stored inline)',
);
expect(summarizeEmbedded({ llmProvider: 'claude-code' })).toBe(
'Embedded daemon → claude-code',
expect(summarizeEmbedded({ llmProvider: "openai", apiKey: "sk-test" })).toBe(
"Embedded daemon → openai (key stored inline)"
);
expect(summarizeEmbedded({ llmProvider: "claude-code" })).toBe("Embedded daemon → claude-code");
});
});
describe('loadConfig / saveConfig', () => {
describe("loadConfig / saveConfig", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'hindsight-openclaw-setup-'));
tmpDir = await mkdtemp(join(tmpdir(), "hindsight-openclaw-setup-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('returns an empty object when the config file does not exist', async () => {
const cfg = await loadConfig(join(tmpDir, 'missing.json'));
it("returns an empty object when the config file does not exist", async () => {
const cfg = await loadConfig(join(tmpDir, "missing.json"));
expect(cfg).toEqual({});
});
it('round-trips a config via atomic save and load', async () => {
const path = join(tmpDir, 'openclaw.json');
it("round-trips a config via atomic save and load", async () => {
const path = join(tmpDir, "openclaw.json");
const cfg: OpenClawConfigShape = {
plugins: {
entries: {
[PLUGIN_ID]: {
enabled: true,
config: { llmProvider: 'openai' },
config: { llmProvider: "openai" },
},
},
},
@@ -299,31 +305,29 @@ describe('loadConfig / saveConfig', () => {
const roundtrip = await loadConfig(path);
expect(roundtrip).toEqual(cfg);
// File should end in a newline (cosmetic — nice for diffs/editors).
const raw = await readFile(path, 'utf8');
expect(raw.endsWith('\n')).toBe(true);
const raw = await readFile(path, "utf8");
expect(raw.endsWith("\n")).toBe(true);
});
it('creates the parent directory if it does not exist', async () => {
const path = join(tmpDir, 'nested', 'subdir', 'openclaw.json');
await saveConfig(path, { hello: 'world' });
it("creates the parent directory if it does not exist", async () => {
const path = join(tmpDir, "nested", "subdir", "openclaw.json");
await saveConfig(path, { hello: "world" });
const roundtrip = await loadConfig(path);
expect(roundtrip).toEqual({ hello: 'world' });
expect(roundtrip).toEqual({ hello: "world" });
});
it('does not leave the .tmp file behind on success', async () => {
const path = join(tmpDir, 'openclaw.json');
it("does not leave the .tmp file behind on success", async () => {
const path = join(tmpDir, "openclaw.json");
await saveConfig(path, {});
const raw = await readFile(path, 'utf8');
expect(raw).toContain('{}');
const raw = await readFile(path, "utf8");
expect(raw).toContain("{}");
// Ensure the rename cleaned up the temp file.
await expect(
readFile(`${path}.tmp-1`, 'utf8').catch(() => 'missing'),
).resolves.toBe('missing');
await expect(readFile(`${path}.tmp-1`, "utf8").catch(() => "missing")).resolves.toBe("missing");
});
it('throws a useful error when the config file is invalid JSON', async () => {
const path = join(tmpDir, 'bad.json');
await writeFile(path, '{ not json', 'utf8');
it("throws a useful error when the config file is invalid JSON", async () => {
const path = join(tmpDir, "bad.json");
await writeFile(path, "{ not json", "utf8");
await expect(loadConfig(path)).rejects.toThrow(/Failed to read/);
});
});
@@ -7,22 +7,22 @@
* variable. All config writing is an atomic rename over the OpenClaw config JSON.
*/
import { readFile, writeFile, mkdir, rename } from 'fs/promises';
import { homedir } from 'os';
import { join, dirname } from 'path';
import { readFile, writeFile, mkdir, rename } from "fs/promises";
import { homedir } from "os";
import { join, dirname } from "path";
export const PLUGIN_ID = 'hindsight-openclaw';
export const PLUGIN_ID = "hindsight-openclaw";
/**
* Default Hindsight Cloud endpoint. Update this when the hosted service URL is
* finalized, or users can override it at the prompt.
*/
export const HINDSIGHT_CLOUD_URL = 'https://api.hindsight.vectorize.io';
export const HINDSIGHT_CLOUD_URL = "https://api.hindsight.vectorize.io";
export const DEFAULT_OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
export const DEFAULT_OPENCLAW_CONFIG_PATH = join(homedir(), ".openclaw", "openclaw.json");
export interface SecretRef {
source: 'env' | 'file' | 'exec';
source: "env" | "file" | "exec";
provider: string;
id: string;
}
@@ -40,23 +40,21 @@ export interface OpenClawConfigShape {
[key: string]: unknown;
}
export type SetupMode = 'cloud' | 'api' | 'embedded';
export type SetupMode = "cloud" | "api" | "embedded";
export const NO_KEY_PROVIDERS: ReadonlySet<string> = new Set([
'claude-code',
'openai-codex',
'ollama',
"claude-code",
"openai-codex",
"ollama",
]);
export async function loadConfig(path: string): Promise<OpenClawConfigShape> {
try {
const raw = await readFile(path, 'utf8');
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as OpenClawConfigShape;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return {};
throw new Error(
`Failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`,
);
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return {};
throw new Error(`Failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
}
}
@@ -64,7 +62,7 @@ export async function saveConfig(path: string, cfg: OpenClawConfigShape): Promis
await mkdir(dirname(path), { recursive: true });
const serialized = `${JSON.stringify(cfg, null, 2)}\n`;
const tmpPath = `${path}.tmp-${Date.now()}`;
await writeFile(tmpPath, serialized, 'utf8');
await writeFile(tmpPath, serialized, "utf8");
await rename(tmpPath, path);
}
@@ -82,7 +80,7 @@ export function ensurePluginConfig(cfg: OpenClawConfigShape): Record<string, unk
}
export function envSecretRef(id: string): SecretRef {
return { source: 'env', provider: 'default', id };
return { source: "env", provider: "default", id };
}
export function clearCloudFields(pluginConfig: Record<string, unknown>): void {
@@ -104,7 +102,7 @@ export function isValidEnvVarName(value: string | undefined): boolean {
}
export function defaultApiKeyEnvVar(provider: string): string {
return `${provider.toUpperCase().replace(/-/g, '_')}_API_KEY`;
return `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`;
}
/**
@@ -138,12 +136,12 @@ export interface EmbeddedSetupInput {
function pickCredential(
token: string | undefined,
tokenEnvVar: string | undefined,
tokenEnvVar: string | undefined
): string | SecretRef | undefined {
const hasToken = token && token.trim().length > 0;
const hasEnvVar = tokenEnvVar && tokenEnvVar.trim().length > 0;
if (hasToken && hasEnvVar) {
throw new Error('provide either a direct value or an env var name — not both');
throw new Error("provide either a direct value or an env var name — not both");
}
if (hasToken) return token!.trim();
if (hasEnvVar) return envSecretRef(tokenEnvVar!.trim());
@@ -158,11 +156,11 @@ function pickCredential(
*/
export function applyCloudMode(
pluginConfig: Record<string, unknown>,
input: CloudSetupInput,
input: CloudSetupInput
): void {
const token = pickCredential(input.token, input.tokenEnvVar);
if (token === undefined) {
throw new Error('Cloud mode requires either `token` or `tokenEnvVar`');
throw new Error("Cloud mode requires either `token` or `tokenEnvVar`");
}
clearLocalLlmFields(pluginConfig);
pluginConfig.hindsightApiUrl = (input.apiUrl ?? HINDSIGHT_CLOUD_URL).trim();
@@ -175,10 +173,7 @@ export function applyCloudMode(
* and strips any leftover local-LLM fields so mode switches don't carry
* stale state.
*/
export function applyApiMode(
pluginConfig: Record<string, unknown>,
input: ApiSetupInput,
): void {
export function applyApiMode(pluginConfig: Record<string, unknown>, input: ApiSetupInput): void {
const token = pickCredential(input.token, input.tokenEnvVar);
clearLocalLlmFields(pluginConfig);
pluginConfig.hindsightApiUrl = input.apiUrl.trim();
@@ -197,7 +192,7 @@ export function applyApiMode(
*/
export function applyEmbeddedMode(
pluginConfig: Record<string, unknown>,
input: EmbeddedSetupInput,
input: EmbeddedSetupInput
): void {
const key = pickCredential(input.apiKey, input.apiKeyEnvVar);
clearCloudFields(pluginConfig);
@@ -206,7 +201,9 @@ export function applyEmbeddedMode(
delete pluginConfig.llmApiKey;
} else {
if (key === undefined) {
throw new Error(`llmProvider "${input.llmProvider}" requires either \`apiKey\` or \`apiKeyEnvVar\``);
throw new Error(
`llmProvider "${input.llmProvider}" requires either \`apiKey\` or \`apiKeyEnvVar\``
);
}
pluginConfig.llmApiKey = key;
}
@@ -222,9 +219,9 @@ function credentialSuffix(token: string | undefined, tokenEnvVar: string | undef
return ` (token from \${${tokenEnvVar.trim()}})`;
}
if (token && token.trim().length > 0) {
return ' (token stored inline)';
return " (token stored inline)";
}
return ' (no auth)';
return " (no auth)";
}
export function summarizeCloud(input: CloudSetupInput): string {
@@ -240,6 +237,8 @@ export function summarizeEmbedded(input: EmbeddedSetupInput): string {
if (NO_KEY_PROVIDERS.has(input.llmProvider)) {
return `Embedded daemon → ${input.llmProvider}`;
}
const keyHint = input.apiKeyEnvVar ? ` (key from \${${input.apiKeyEnvVar.trim()}})` : ' (key stored inline)';
const keyHint = input.apiKeyEnvVar
? ` (key from \${${input.apiKeyEnvVar.trim()}})`
: " (key stored inline)";
return `Embedded daemon → ${input.llmProvider}${keyHint}`;
}
+181 -136
View File
@@ -1,113 +1,122 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, readFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { parseCliArgs, runNonInteractive } from './setup.js';
import { PLUGIN_ID, type OpenClawConfigShape } from './setup-lib.js';
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, readFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { parseCliArgs, runNonInteractive } from "./setup.js";
import { PLUGIN_ID, type OpenClawConfigShape } from "./setup-lib.js";
describe('parseCliArgs', () => {
it('returns defaults for no args', () => {
describe("parseCliArgs", () => {
it("returns defaults for no args", () => {
const args = parseCliArgs([]);
expect(args).toEqual({ help: false, noToken: false });
});
it('parses --help', () => {
expect(parseCliArgs(['--help']).help).toBe(true);
expect(parseCliArgs(['-h']).help).toBe(true);
it("parses --help", () => {
expect(parseCliArgs(["--help"]).help).toBe(true);
expect(parseCliArgs(["-h"]).help).toBe(true);
});
it('parses --config-path and positional config path', () => {
expect(parseCliArgs(['--config-path', '/tmp/a.json']).configPath).toBe('/tmp/a.json');
expect(parseCliArgs(['/tmp/b.json']).positional).toBe('/tmp/b.json');
it("parses --config-path and positional config path", () => {
expect(parseCliArgs(["--config-path", "/tmp/a.json"]).configPath).toBe("/tmp/a.json");
expect(parseCliArgs(["/tmp/b.json"]).positional).toBe("/tmp/b.json");
});
it('parses cloud-mode flags (direct token value)', () => {
it("parses cloud-mode flags (direct token value)", () => {
const args = parseCliArgs(["--mode", "cloud", "--token", "hsk_literal"]);
expect(args).toMatchObject({ mode: "cloud", token: "hsk_literal" });
});
it("parses cloud-mode flags (token env var)", () => {
const args = parseCliArgs([
'--mode', 'cloud',
'--token', 'hsk_literal',
]);
expect(args).toMatchObject({ mode: 'cloud', token: 'hsk_literal' });
});
it('parses cloud-mode flags (token env var)', () => {
const args = parseCliArgs([
'--mode', 'cloud',
'--api-url', 'https://cloud.example.com',
'--token-env', 'HINDSIGHT_CLOUD_TOKEN',
"--mode",
"cloud",
"--api-url",
"https://cloud.example.com",
"--token-env",
"HINDSIGHT_CLOUD_TOKEN",
]);
expect(args).toMatchObject({
mode: 'cloud',
apiUrl: 'https://cloud.example.com',
tokenEnv: 'HINDSIGHT_CLOUD_TOKEN',
mode: "cloud",
apiUrl: "https://cloud.example.com",
tokenEnv: "HINDSIGHT_CLOUD_TOKEN",
});
});
it('parses embedded-mode direct apiKey flag', () => {
it("parses embedded-mode direct apiKey flag", () => {
const args = parseCliArgs([
'--mode', 'embedded',
'--provider', 'openai',
'--api-key', 'sk-literal',
"--mode",
"embedded",
"--provider",
"openai",
"--api-key",
"sk-literal",
]);
expect(args).toMatchObject({
mode: 'embedded',
provider: 'openai',
apiKey: 'sk-literal',
mode: "embedded",
provider: "openai",
apiKey: "sk-literal",
});
});
it('parses api-mode flags with --no-token', () => {
it("parses api-mode flags with --no-token", () => {
const args = parseCliArgs([
'--mode', 'api',
'--api-url', 'https://mcp.example.com',
'--no-token',
"--mode",
"api",
"--api-url",
"https://mcp.example.com",
"--no-token",
]);
expect(args).toMatchObject({
mode: 'api',
apiUrl: 'https://mcp.example.com',
mode: "api",
apiUrl: "https://mcp.example.com",
noToken: true,
});
});
it('parses embedded-mode flags', () => {
it("parses embedded-mode flags", () => {
const args = parseCliArgs([
'--mode', 'embedded',
'--provider', 'openai',
'--api-key-env', 'OPENAI_API_KEY',
'--model', 'gpt-4o-mini',
"--mode",
"embedded",
"--provider",
"openai",
"--api-key-env",
"OPENAI_API_KEY",
"--model",
"gpt-4o-mini",
]);
expect(args).toMatchObject({
mode: 'embedded',
provider: 'openai',
apiKeyEnv: 'OPENAI_API_KEY',
model: 'gpt-4o-mini',
mode: "embedded",
provider: "openai",
apiKeyEnv: "OPENAI_API_KEY",
model: "gpt-4o-mini",
});
});
it('rejects invalid --mode', () => {
expect(() => parseCliArgs(['--mode', 'bogus'])).toThrow(/invalid --mode/);
it("rejects invalid --mode", () => {
expect(() => parseCliArgs(["--mode", "bogus"])).toThrow(/invalid --mode/);
});
it('rejects unknown flags', () => {
expect(() => parseCliArgs(['--what-is-this'])).toThrow(/unknown argument/);
it("rejects unknown flags", () => {
expect(() => parseCliArgs(["--what-is-this"])).toThrow(/unknown argument/);
});
it('rejects flags missing a value', () => {
expect(() => parseCliArgs(['--mode'])).toThrow(/missing value for --mode/);
expect(() => parseCliArgs(['--api-url'])).toThrow(/missing value for --api-url/);
it("rejects flags missing a value", () => {
expect(() => parseCliArgs(["--mode"])).toThrow(/missing value for --mode/);
expect(() => parseCliArgs(["--api-url"])).toThrow(/missing value for --api-url/);
});
it('rejects extra positional args', () => {
expect(() => parseCliArgs(['/tmp/a.json', '/tmp/b.json'])).toThrow(/extra positional/);
it("rejects extra positional args", () => {
expect(() => parseCliArgs(["/tmp/a.json", "/tmp/b.json"])).toThrow(/extra positional/);
});
});
describe('runNonInteractive', () => {
describe("runNonInteractive", () => {
let tmpDir: string;
let configPath: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'hindsight-openclaw-setup-cli-'));
configPath = join(tmpDir, 'openclaw.json');
tmpDir = await mkdtemp(join(tmpdir(), "hindsight-openclaw-setup-cli-"));
configPath = join(tmpDir, "openclaw.json");
});
afterEach(async () => {
@@ -115,178 +124,214 @@ describe('runNonInteractive', () => {
});
async function readBack(): Promise<OpenClawConfigShape> {
return JSON.parse(await readFile(configPath, 'utf8')) as OpenClawConfigShape;
return JSON.parse(await readFile(configPath, "utf8")) as OpenClawConfigShape;
}
it('writes a cloud-mode config with the default URL', async () => {
const args = parseCliArgs(['--mode', 'cloud', '--token-env', 'HINDSIGHT_CLOUD_TOKEN']);
it("writes a cloud-mode config with the default URL", async () => {
const args = parseCliArgs(["--mode", "cloud", "--token-env", "HINDSIGHT_CLOUD_TOKEN"]);
const result = await runNonInteractive(args, configPath);
expect(result.summary).toContain('Cloud');
expect(result.summary).toContain("Cloud");
const cfg = await readBack();
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
expect(pc.hindsightApiUrl).toBe('https://api.hindsight.vectorize.io');
expect(pc.hindsightApiUrl).toBe("https://api.hindsight.vectorize.io");
expect(pc.hindsightApiToken).toEqual({
source: 'env',
provider: 'default',
id: 'HINDSIGHT_CLOUD_TOKEN',
source: "env",
provider: "default",
id: "HINDSIGHT_CLOUD_TOKEN",
});
expect(cfg.plugins?.entries?.[PLUGIN_ID]?.enabled).toBe(true);
});
it('writes a cloud-mode config with a custom URL', async () => {
it("writes a cloud-mode config with a custom URL", async () => {
const args = parseCliArgs([
'--mode', 'cloud',
'--api-url', 'https://hindsight.custom.example.com',
'--token-env', 'MY_TOKEN',
"--mode",
"cloud",
"--api-url",
"https://hindsight.custom.example.com",
"--token-env",
"MY_TOKEN",
]);
await runNonInteractive(args, configPath);
const cfg = await readBack();
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
expect(pc.hindsightApiUrl).toBe('https://hindsight.custom.example.com');
expect((pc.hindsightApiToken as { id: string }).id).toBe('MY_TOKEN');
expect(pc.hindsightApiUrl).toBe("https://hindsight.custom.example.com");
expect((pc.hindsightApiToken as { id: string }).id).toBe("MY_TOKEN");
});
it('writes a cloud-mode config with an inline token (--token)', async () => {
const args = parseCliArgs(['--mode', 'cloud', '--token', 'hsk_direct_value']);
it("writes a cloud-mode config with an inline token (--token)", async () => {
const args = parseCliArgs(["--mode", "cloud", "--token", "hsk_direct_value"]);
await runNonInteractive(args, configPath);
const cfg = await readBack();
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
expect(pc.hindsightApiUrl).toBe('https://api.hindsight.vectorize.io');
expect(pc.hindsightApiToken).toBe('hsk_direct_value');
expect(pc.hindsightApiUrl).toBe("https://api.hindsight.vectorize.io");
expect(pc.hindsightApiToken).toBe("hsk_direct_value");
});
it('rejects cloud mode without --token or --token-env', async () => {
const args = parseCliArgs(['--mode', 'cloud']);
it("rejects cloud mode without --token or --token-env", async () => {
const args = parseCliArgs(["--mode", "cloud"]);
await expect(runNonInteractive(args, configPath)).rejects.toThrow(/--token .*--token-env/);
});
it('rejects cloud mode with both --token and --token-env', async () => {
it("rejects cloud mode with both --token and --token-env", async () => {
const args = parseCliArgs([
'--mode', 'cloud',
'--token', 'hsk_x',
'--token-env', 'HINDSIGHT_CLOUD_TOKEN',
"--mode",
"cloud",
"--token",
"hsk_x",
"--token-env",
"HINDSIGHT_CLOUD_TOKEN",
]);
await expect(runNonInteractive(args, configPath)).rejects.toThrow(/mutually exclusive/);
});
it('rejects cloud mode with a bad token env var name', async () => {
const args = parseCliArgs(['--mode', 'cloud', '--token-env', 'bad-name']);
it("rejects cloud mode with a bad token env var name", async () => {
const args = parseCliArgs(["--mode", "cloud", "--token-env", "bad-name"]);
await expect(runNonInteractive(args, configPath)).rejects.toThrow(/UPPER_SNAKE_CASE/);
});
it('writes an api-mode config without token', async () => {
it("writes an api-mode config without token", async () => {
const args = parseCliArgs([
'--mode', 'api',
'--api-url', 'https://mcp.example.com',
'--no-token',
"--mode",
"api",
"--api-url",
"https://mcp.example.com",
"--no-token",
]);
await runNonInteractive(args, configPath);
const cfg = await readBack();
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
expect(pc.hindsightApiUrl).toBe('https://mcp.example.com');
expect(pc.hindsightApiUrl).toBe("https://mcp.example.com");
expect(pc.hindsightApiToken).toBeUndefined();
});
it('writes an api-mode config with token', async () => {
it("writes an api-mode config with token", async () => {
const args = parseCliArgs([
'--mode', 'api',
'--api-url', 'https://mcp.example.com',
'--token-env', 'HINDSIGHT_API_TOKEN',
"--mode",
"api",
"--api-url",
"https://mcp.example.com",
"--token-env",
"HINDSIGHT_API_TOKEN",
]);
await runNonInteractive(args, configPath);
const cfg = await readBack();
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
expect((pc.hindsightApiToken as { id: string }).id).toBe('HINDSIGHT_API_TOKEN');
expect((pc.hindsightApiToken as { id: string }).id).toBe("HINDSIGHT_API_TOKEN");
});
it('rejects api mode without --api-url', async () => {
const args = parseCliArgs(['--mode', 'api']);
it("rejects api mode without --api-url", async () => {
const args = parseCliArgs(["--mode", "api"]);
await expect(runNonInteractive(args, configPath)).rejects.toThrow(/--api-url/);
});
it('rejects api mode with conflicting --token-env and --no-token', async () => {
it("rejects api mode with conflicting --token-env and --no-token", async () => {
const args = parseCliArgs([
'--mode', 'api',
'--api-url', 'https://mcp.example.com',
'--token-env', 'FOO',
'--no-token',
"--mode",
"api",
"--api-url",
"https://mcp.example.com",
"--token-env",
"FOO",
"--no-token",
]);
await expect(runNonInteractive(args, configPath)).rejects.toThrow(/mutually exclusive/);
});
it('writes an embedded-mode config for openai', async () => {
it("writes an embedded-mode config for openai", async () => {
const args = parseCliArgs([
'--mode', 'embedded',
'--provider', 'openai',
'--api-key-env', 'OPENAI_API_KEY',
'--model', 'gpt-4o-mini',
"--mode",
"embedded",
"--provider",
"openai",
"--api-key-env",
"OPENAI_API_KEY",
"--model",
"gpt-4o-mini",
]);
await runNonInteractive(args, configPath);
const cfg = await readBack();
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
expect(pc.llmProvider).toBe('openai');
expect((pc.llmApiKey as { id: string }).id).toBe('OPENAI_API_KEY');
expect(pc.llmModel).toBe('gpt-4o-mini');
expect(pc.llmProvider).toBe("openai");
expect((pc.llmApiKey as { id: string }).id).toBe("OPENAI_API_KEY");
expect(pc.llmModel).toBe("gpt-4o-mini");
expect(pc.hindsightApiUrl).toBeUndefined();
});
it('writes an embedded-mode config for a no-key provider', async () => {
const args = parseCliArgs(['--mode', 'embedded', '--provider', 'claude-code']);
it("writes an embedded-mode config for a no-key provider", async () => {
const args = parseCliArgs(["--mode", "embedded", "--provider", "claude-code"]);
await runNonInteractive(args, configPath);
const cfg = await readBack();
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
expect(pc.llmProvider).toBe('claude-code');
expect(pc.llmProvider).toBe("claude-code");
expect(pc.llmApiKey).toBeUndefined();
});
it('rejects embedded mode without --provider', async () => {
const args = parseCliArgs(['--mode', 'embedded']);
it("rejects embedded mode without --provider", async () => {
const args = parseCliArgs(["--mode", "embedded"]);
await expect(runNonInteractive(args, configPath)).rejects.toThrow(/--provider/);
});
it('rejects embedded mode with a key-requiring provider but no --api-key or --api-key-env', async () => {
const args = parseCliArgs(['--mode', 'embedded', '--provider', 'openai']);
it("rejects embedded mode with a key-requiring provider but no --api-key or --api-key-env", async () => {
const args = parseCliArgs(["--mode", "embedded", "--provider", "openai"]);
await expect(runNonInteractive(args, configPath)).rejects.toThrow(/--api-key .*--api-key-env/);
});
it('rejects embedded mode with both --api-key and --api-key-env', async () => {
it("rejects embedded mode with both --api-key and --api-key-env", async () => {
const args = parseCliArgs([
'--mode', 'embedded', '--provider', 'openai',
'--api-key', 'sk-x', '--api-key-env', 'OPENAI_API_KEY',
"--mode",
"embedded",
"--provider",
"openai",
"--api-key",
"sk-x",
"--api-key-env",
"OPENAI_API_KEY",
]);
await expect(runNonInteractive(args, configPath)).rejects.toThrow(/mutually exclusive/);
});
it('writes an embedded-mode config with an inline API key (--api-key)', async () => {
it("writes an embedded-mode config with an inline API key (--api-key)", async () => {
const args = parseCliArgs([
'--mode', 'embedded', '--provider', 'openai', '--api-key', 'sk-inline',
"--mode",
"embedded",
"--provider",
"openai",
"--api-key",
"sk-inline",
]);
await runNonInteractive(args, configPath);
const cfg = await readBack();
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
expect(pc.llmProvider).toBe('openai');
expect(pc.llmApiKey).toBe('sk-inline');
expect(typeof pc.llmApiKey).toBe('string');
expect(pc.llmProvider).toBe("openai");
expect(pc.llmApiKey).toBe("sk-inline");
expect(typeof pc.llmApiKey).toBe("string");
});
it('clears stale fields when switching between modes', async () => {
it("clears stale fields when switching between modes", async () => {
// First write an embedded-mode config
await runNonInteractive(
parseCliArgs(['--mode', 'embedded', '--provider', 'openai', '--api-key-env', 'OPENAI_API_KEY']),
configPath,
parseCliArgs([
"--mode",
"embedded",
"--provider",
"openai",
"--api-key-env",
"OPENAI_API_KEY",
]),
configPath
);
let cfg = await readBack();
expect(cfg.plugins?.entries?.[PLUGIN_ID]?.config?.llmProvider).toBe('openai');
expect(cfg.plugins?.entries?.[PLUGIN_ID]?.config?.llmProvider).toBe("openai");
// Now switch to cloud mode — local LLM fields should be gone
await runNonInteractive(
parseCliArgs(['--mode', 'cloud', '--token-env', 'HINDSIGHT_CLOUD_TOKEN']),
configPath,
parseCliArgs(["--mode", "cloud", "--token-env", "HINDSIGHT_CLOUD_TOKEN"]),
configPath
);
cfg = await readBack();
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
expect(pc.llmProvider).toBeUndefined();
expect(pc.llmApiKey).toBeUndefined();
expect(pc.hindsightApiUrl).toBe('https://api.hindsight.vectorize.io');
expect(pc.hindsightApiUrl).toBe("https://api.hindsight.vectorize.io");
});
});
+129 -124
View File
@@ -15,10 +15,10 @@
* variables directly. Pure config manipulation lives in setup-lib.ts.
*/
import * as p from '@clack/prompts';
import { realpathSync } from 'fs';
import { resolve } from 'path';
import { fileURLToPath } from 'url';
import * as p from "@clack/prompts";
import { realpathSync } from "fs";
import { resolve } from "path";
import { fileURLToPath } from "url";
import {
DEFAULT_OPENCLAW_CONFIG_PATH,
HINDSIGHT_CLOUD_URL,
@@ -38,7 +38,7 @@ import {
summarizeApi,
summarizeCloud,
summarizeEmbedded,
} from './setup-lib.js';
} from "./setup-lib.js";
// ---------------------------------------------------------------------------
// CLI parsing
@@ -61,47 +61,47 @@ export interface ParsedCliArgs {
function usage(): string {
return [
'Usage: hindsight-openclaw-setup [options] [config-path]',
'',
'Interactive mode (no flags): walks through a TUI picker for Cloud /',
'External API / Embedded daemon and writes the resulting plugin config',
"Usage: hindsight-openclaw-setup [options] [config-path]",
"",
"Interactive mode (no flags): walks through a TUI picker for Cloud /",
"External API / Embedded daemon and writes the resulting plugin config",
`to ${DEFAULT_OPENCLAW_CONFIG_PATH} (or the positional config-path arg).`,
'',
'Non-interactive mode: pass --mode and the relevant flags to skip the',
'TUI. Suitable for CI and scripted setups.',
'',
'Options:',
' --config-path <path> Path to openclaw.json (default: ~/.openclaw/openclaw.json)',
' --mode <mode> cloud | api | embedded (enables non-interactive mode)',
'',
'Cloud mode (must pass exactly one of --token or --token-env):',
"",
"Non-interactive mode: pass --mode and the relevant flags to skip the",
"TUI. Suitable for CI and scripted setups.",
"",
"Options:",
" --config-path <path> Path to openclaw.json (default: ~/.openclaw/openclaw.json)",
" --mode <mode> cloud | api | embedded (enables non-interactive mode)",
"",
"Cloud mode (must pass exactly one of --token or --token-env):",
` --api-url <url> Override the Hindsight Cloud URL (default: ${HINDSIGHT_CLOUD_URL})`,
' --token <value> Store the token inline in openclaw.json (simple)',
' --token-env <VAR> Reference an env var instead — resolved at startup',
' via SecretRef (keeps the secret off disk)',
'',
'External API mode (token is optional; pass at most one of --token / --token-env / --no-token):',
' --api-url <url> Hindsight API URL (required)',
' --token <value> Inline token value in openclaw.json',
' --token-env <VAR> Env var holding the token (SecretRef)',
' --no-token Explicitly disable token auth',
'',
'Embedded mode (for providers that need a key, pass exactly one of --api-key or --api-key-env):',
` --provider <id> LLM provider: ${['openai', 'anthropic', 'gemini', 'groq', ...NO_KEY_PROVIDERS].join(' | ')}`,
' --api-key <value> Store the LLM API key inline in openclaw.json',
' --api-key-env <VAR> Env var holding the LLM API key (SecretRef)',
' --model <id> Optional model override (otherwise uses the provider default)',
'',
' -h, --help Show this help',
'',
'Examples:',
' hindsight-openclaw-setup',
' hindsight-openclaw-setup --mode cloud --token hsk_...',
' hindsight-openclaw-setup --mode cloud --token-env HINDSIGHT_CLOUD_TOKEN',
' hindsight-openclaw-setup --mode api --api-url https://mcp.hindsight.example.com --no-token',
' hindsight-openclaw-setup --mode embedded --provider openai --api-key-env OPENAI_API_KEY',
' hindsight-openclaw-setup --mode embedded --provider claude-code',
].join('\n');
" --token <value> Store the token inline in openclaw.json (simple)",
" --token-env <VAR> Reference an env var instead — resolved at startup",
" via SecretRef (keeps the secret off disk)",
"",
"External API mode (token is optional; pass at most one of --token / --token-env / --no-token):",
" --api-url <url> Hindsight API URL (required)",
" --token <value> Inline token value in openclaw.json",
" --token-env <VAR> Env var holding the token (SecretRef)",
" --no-token Explicitly disable token auth",
"",
"Embedded mode (for providers that need a key, pass exactly one of --api-key or --api-key-env):",
` --provider <id> LLM provider: ${["openai", "anthropic", "gemini", "groq", ...NO_KEY_PROVIDERS].join(" | ")}`,
" --api-key <value> Store the LLM API key inline in openclaw.json",
" --api-key-env <VAR> Env var holding the LLM API key (SecretRef)",
" --model <id> Optional model override (otherwise uses the provider default)",
"",
" -h, --help Show this help",
"",
"Examples:",
" hindsight-openclaw-setup",
" hindsight-openclaw-setup --mode cloud --token hsk_...",
" hindsight-openclaw-setup --mode cloud --token-env HINDSIGHT_CLOUD_TOKEN",
" hindsight-openclaw-setup --mode api --api-url https://mcp.hindsight.example.com --no-token",
" hindsight-openclaw-setup --mode embedded --provider openai --api-key-env OPENAI_API_KEY",
" hindsight-openclaw-setup --mode embedded --provider claude-code",
].join("\n");
}
export function parseCliArgs(argv: string[]): ParsedCliArgs {
@@ -118,47 +118,47 @@ export function parseCliArgs(argv: string[]): ParsedCliArgs {
};
switch (arg) {
case '-h':
case '--help':
case "-h":
case "--help":
args.help = true;
break;
case '--config-path':
case "--config-path":
args.configPath = next();
break;
case '--mode': {
case "--mode": {
const value = next();
if (value !== 'cloud' && value !== 'api' && value !== 'embedded') {
if (value !== "cloud" && value !== "api" && value !== "embedded") {
throw new Error(`invalid --mode: ${value} (expected cloud | api | embedded)`);
}
args.mode = value;
break;
}
case '--api-url':
case "--api-url":
args.apiUrl = next();
break;
case '--token':
case "--token":
args.token = next();
break;
case '--token-env':
case "--token-env":
args.tokenEnv = next();
break;
case '--no-token':
case "--no-token":
args.noToken = true;
break;
case '--provider':
case "--provider":
args.provider = next();
break;
case '--api-key':
case "--api-key":
args.apiKey = next();
break;
case '--api-key-env':
case "--api-key-env":
args.apiKeyEnv = next();
break;
case '--model':
case "--model":
args.model = next();
break;
default:
if (arg.startsWith('-')) {
if (arg.startsWith("-")) {
throw new Error(`unknown argument: ${arg}`);
}
if (args.positional) {
@@ -177,10 +177,10 @@ export function parseCliArgs(argv: string[]): ParsedCliArgs {
function buildCloudInput(args: ParsedCliArgs): CloudSetupInput {
if (!args.token && !args.tokenEnv) {
throw new Error('--mode cloud requires --token <value> or --token-env <VAR>');
throw new Error("--mode cloud requires --token <value> or --token-env <VAR>");
}
if (args.token && args.tokenEnv) {
throw new Error('--token and --token-env are mutually exclusive — pick one');
throw new Error("--token and --token-env are mutually exclusive — pick one");
}
if (args.tokenEnv && !isValidEnvVarName(args.tokenEnv)) {
throw new Error(`--token-env must be an UPPER_SNAKE_CASE env var name, got: ${args.tokenEnv}`);
@@ -194,12 +194,13 @@ function buildCloudInput(args: ParsedCliArgs): CloudSetupInput {
function buildApiInput(args: ParsedCliArgs): ApiSetupInput {
if (!args.apiUrl) {
throw new Error('--mode api requires --api-url <url>');
throw new Error("--mode api requires --api-url <url>");
}
const credFlagCount =
(args.token ? 1 : 0) + (args.tokenEnv ? 1 : 0) + (args.noToken ? 1 : 0);
const credFlagCount = (args.token ? 1 : 0) + (args.tokenEnv ? 1 : 0) + (args.noToken ? 1 : 0);
if (credFlagCount > 1) {
throw new Error('--token, --token-env, and --no-token are mutually exclusive — pick at most one');
throw new Error(
"--token, --token-env, and --no-token are mutually exclusive — pick at most one"
);
}
if (args.tokenEnv && !isValidEnvVarName(args.tokenEnv)) {
throw new Error(`--token-env must be an UPPER_SNAKE_CASE env var name, got: ${args.tokenEnv}`);
@@ -213,20 +214,22 @@ function buildApiInput(args: ParsedCliArgs): ApiSetupInput {
function buildEmbeddedInput(args: ParsedCliArgs): EmbeddedSetupInput {
if (!args.provider) {
throw new Error('--mode embedded requires --provider <id>');
throw new Error("--mode embedded requires --provider <id>");
}
if (args.apiKey && args.apiKeyEnv) {
throw new Error('--api-key and --api-key-env are mutually exclusive — pick one');
throw new Error("--api-key and --api-key-env are mutually exclusive — pick one");
}
const needsKey = !NO_KEY_PROVIDERS.has(args.provider);
if (needsKey) {
if (!args.apiKey && !args.apiKeyEnv) {
throw new Error(
`--provider ${args.provider} requires --api-key <value> or --api-key-env <VAR> (providers that need no key: ${[...NO_KEY_PROVIDERS].join(', ')})`,
`--provider ${args.provider} requires --api-key <value> or --api-key-env <VAR> (providers that need no key: ${[...NO_KEY_PROVIDERS].join(", ")})`
);
}
if (args.apiKeyEnv && !isValidEnvVarName(args.apiKeyEnv)) {
throw new Error(`--api-key-env must be an UPPER_SNAKE_CASE env var name, got: ${args.apiKeyEnv}`);
throw new Error(
`--api-key-env must be an UPPER_SNAKE_CASE env var name, got: ${args.apiKeyEnv}`
);
}
}
return {
@@ -239,21 +242,21 @@ function buildEmbeddedInput(args: ParsedCliArgs): EmbeddedSetupInput {
export async function runNonInteractive(
args: ParsedCliArgs,
configPath: string,
configPath: string
): Promise<{ summary: string; configPath: string }> {
if (!args.mode) {
throw new Error('runNonInteractive called without --mode');
throw new Error("runNonInteractive called without --mode");
}
const cfg = await loadConfig(configPath);
const pluginConfig = ensurePluginConfig(cfg);
let summary: string;
if (args.mode === 'cloud') {
if (args.mode === "cloud") {
const input = buildCloudInput(args);
applyCloudMode(pluginConfig, input);
summary = summarizeCloud(input);
} else if (args.mode === 'api') {
} else if (args.mode === "api") {
const input = buildApiInput(args);
applyApiMode(pluginConfig, input);
summary = summarizeApi(input);
@@ -272,7 +275,7 @@ export async function runNonInteractive(
// ---------------------------------------------------------------------------
const validateEnvVar = (value: string | undefined): string | undefined =>
isValidEnvVarName(value) ? undefined : 'Must be an UPPER_SNAKE_CASE env var name';
isValidEnvVarName(value) ? undefined : "Must be an UPPER_SNAKE_CASE env var name";
const validateRequired =
(msg: string) =>
@@ -281,7 +284,7 @@ const validateRequired =
function assertNotCancelled<T>(value: T | symbol): asserts value is T {
if (p.isCancel(value)) {
p.cancel('Setup cancelled.');
p.cancel("Setup cancelled.");
process.exit(1);
}
}
@@ -296,17 +299,17 @@ async function promptCloud(pluginConfig: Record<string, unknown>): Promise<strin
let apiUrl: string | undefined;
if (!useDefaultUrl) {
const custom = await p.text({
message: 'Hindsight Cloud URL',
message: "Hindsight Cloud URL",
placeholder: HINDSIGHT_CLOUD_URL,
validate: validateRequired('URL is required'),
validate: validateRequired("URL is required"),
});
assertNotCancelled(custom);
apiUrl = custom;
}
const token = await p.password({
message: 'Hindsight Cloud API token (paste the value, it will be masked)',
validate: validateRequired('Token is required'),
message: "Hindsight Cloud API token (paste the value, it will be masked)",
validate: validateRequired("Token is required"),
});
assertNotCancelled(token);
@@ -317,14 +320,14 @@ async function promptCloud(pluginConfig: Record<string, unknown>): Promise<strin
async function promptApi(pluginConfig: Record<string, unknown>): Promise<string> {
const apiUrl = await p.text({
message: 'Hindsight API URL',
placeholder: 'https://mcp.hindsight.example.com',
validate: validateRequired('URL is required'),
message: "Hindsight API URL",
placeholder: "https://mcp.hindsight.example.com",
validate: validateRequired("URL is required"),
});
assertNotCancelled(apiUrl);
const needsToken = await p.confirm({
message: 'Does this API require an auth token?',
message: "Does this API require an auth token?",
initialValue: false,
});
assertNotCancelled(needsToken);
@@ -332,8 +335,8 @@ async function promptApi(pluginConfig: Record<string, unknown>): Promise<string>
let token: string | undefined;
if (needsToken) {
const value = await p.password({
message: 'API token (paste the value, it will be masked)',
validate: validateRequired('Token is required'),
message: "API token (paste the value, it will be masked)",
validate: validateRequired("Token is required"),
});
assertNotCancelled(value);
token = value;
@@ -346,23 +349,23 @@ async function promptApi(pluginConfig: Record<string, unknown>): Promise<string>
async function promptEmbedded(pluginConfig: Record<string, unknown>): Promise<string> {
const provider = await p.select({
message: 'LLM provider used by the Hindsight memory daemon',
message: "LLM provider used by the Hindsight memory daemon",
options: [
{ value: 'openai', label: 'OpenAI', hint: 'API key required' },
{ value: 'anthropic', label: 'Anthropic', hint: 'API key required' },
{ value: 'gemini', label: 'Gemini', hint: 'API key required' },
{ value: 'groq', label: 'Groq', hint: 'API key required' },
{ value: "openai", label: "OpenAI", hint: "API key required" },
{ value: "anthropic", label: "Anthropic", hint: "API key required" },
{ value: "gemini", label: "Gemini", hint: "API key required" },
{ value: "groq", label: "Groq", hint: "API key required" },
{
value: 'claude-code',
label: 'Claude Code',
hint: 'no API key needed (uses Claude Code CLI auth)',
value: "claude-code",
label: "Claude Code",
hint: "no API key needed (uses Claude Code CLI auth)",
},
{
value: 'openai-codex',
label: 'OpenAI Codex',
hint: 'no API key needed (uses codex auth login)',
value: "openai-codex",
label: "OpenAI Codex",
hint: "no API key needed (uses codex auth login)",
},
{ value: 'ollama', label: 'Ollama', hint: 'no API key needed (local models)' },
{ value: "ollama", label: "Ollama", hint: "no API key needed (local models)" },
],
});
assertNotCancelled(provider);
@@ -372,14 +375,14 @@ async function promptEmbedded(pluginConfig: Record<string, unknown>): Promise<st
if (!NO_KEY_PROVIDERS.has(llmProvider)) {
const value = await p.password({
message: `${llmProvider} API key (paste the value, it will be masked)`,
validate: validateRequired('API key is required'),
validate: validateRequired("API key is required"),
});
assertNotCancelled(value);
apiKey = value;
}
const overrideModel = await p.confirm({
message: 'Override the default model?',
message: "Override the default model?",
initialValue: false,
});
assertNotCancelled(overrideModel);
@@ -387,9 +390,9 @@ async function promptEmbedded(pluginConfig: Record<string, unknown>): Promise<st
let llmModel: string | undefined;
if (overrideModel) {
const value = await p.text({
message: 'Model id',
placeholder: 'gpt-4o-mini',
validate: validateRequired('Model id is required'),
message: "Model id",
placeholder: "gpt-4o-mini",
validate: validateRequired("Model id is required"),
});
assertNotCancelled(value);
llmModel = value;
@@ -400,57 +403,59 @@ async function promptEmbedded(pluginConfig: Record<string, unknown>): Promise<st
return summarizeEmbedded(input);
}
async function runInteractive(configPath: string): Promise<{ summary: string; configPath: string }> {
p.intro('🦞 Hindsight Memory setup for OpenClaw');
async function runInteractive(
configPath: string
): Promise<{ summary: string; configPath: string }> {
p.intro("🦞 Hindsight Memory setup for OpenClaw");
p.log.info(`Config file: ${configPath}`);
const cfg = await loadConfig(configPath);
const pluginConfig = ensurePluginConfig(cfg);
const mode = await p.select({
message: 'How do you want to run Hindsight?',
message: "How do you want to run Hindsight?",
options: [
{ value: 'cloud', label: 'Cloud', hint: 'managed Hindsight, no local setup' },
{ value: 'api', label: 'External API', hint: 'your own running Hindsight deployment' },
{ value: "cloud", label: "Cloud", hint: "managed Hindsight, no local setup" },
{ value: "api", label: "External API", hint: "your own running Hindsight deployment" },
{
value: 'embedded',
label: 'Embedded daemon',
hint: 'spawn a local hindsight daemon on this machine',
value: "embedded",
label: "Embedded daemon",
hint: "spawn a local hindsight daemon on this machine",
},
],
});
assertNotCancelled(mode);
let summary: string;
if ((mode as SetupMode) === 'cloud') {
if ((mode as SetupMode) === "cloud") {
summary = await promptCloud(pluginConfig);
} else if ((mode as SetupMode) === 'api') {
} else if ((mode as SetupMode) === "api") {
summary = await promptApi(pluginConfig);
} else {
summary = await promptEmbedded(pluginConfig);
}
const spin = p.spinner();
spin.start('Writing configuration');
spin.start("Writing configuration");
await saveConfig(configPath, cfg);
spin.stop(`Saved to ${configPath}`);
p.note(
[
summary,
'',
'Next steps:',
' 1. Restart the gateway: openclaw gateway restart',
' 2. Verify config: openclaw config validate',
'',
'Secrets were stored inline in openclaw.json. To reference an env var',
'instead (recommended for CI/production), use:',
' openclaw config set plugins.entries.hindsight-openclaw.config.hindsightApiToken \\',
' --ref-source env --ref-id HINDSIGHT_CLOUD_TOKEN',
].join('\n'),
'Hindsight Memory configured',
"",
"Next steps:",
" 1. Restart the gateway: openclaw gateway restart",
" 2. Verify config: openclaw config validate",
"",
"Secrets were stored inline in openclaw.json. To reference an env var",
"instead (recommended for CI/production), use:",
" openclaw config set plugins.entries.hindsight-openclaw.config.hindsightApiToken \\",
" --ref-source env --ref-id HINDSIGHT_CLOUD_TOKEN",
].join("\n"),
"Hindsight Memory configured"
);
p.outro('Done.');
p.outro("Done.");
return { summary, configPath };
}
+17 -10
View File
@@ -10,7 +10,10 @@ export interface MoltbotPluginAPI {
config: MoltbotConfig;
registerService(config: ServiceConfig): void;
// OpenClaw hook handler signature: (event, ctx?) where ctx contains channel/sender info
on(event: string, handler: (event: any, ctx?: any) => void | Promise<void | PluginPromptHookResult>): void;
on(
event: string,
handler: (event: any, ctx?: any) => void | Promise<void | PluginPromptHookResult>
): void;
// OpenClaw framework logger — handles coloring/formatting consistently across plugins
logger: {
info(msg: string): void;
@@ -69,15 +72,15 @@ export interface PluginConfig {
retainSource?: string; // Source written into retained document metadata (default: 'openclaw')
excludeProviders?: string[]; // Message providers to exclude from recall/retain (e.g. ['telegram', 'discord'])
autoRecall?: boolean; // Auto-recall memories on every prompt (default: true). Set to false when agent has its own recall tool.
dynamicBankGranularity?: Array<'agent' | 'provider' | 'channel' | 'user'>; // Fields for bank ID derivation. Default: ['agent', 'channel', 'user']
dynamicBankGranularity?: Array<"agent" | "provider" | "channel" | "user">; // Fields for bank ID derivation. Default: ['agent', 'channel', 'user']
autoRetain?: boolean; // Default: true
retainRoles?: Array<'user' | 'assistant' | 'system' | 'tool'>; // Roles to include in retained transcript. Default: ['user', 'assistant']
retainFormat?: 'json' | 'text'; // Serialization format for retained conversation content. Default: 'json' (structured array of {role, content}); 'text' emits legacy '[role: x] ... [x:end]' markers.
retainRoles?: Array<"user" | "assistant" | "system" | "tool">; // Roles to include in retained transcript. Default: ['user', 'assistant']
retainFormat?: "json" | "text"; // Serialization format for retained conversation content. Default: 'json' (structured array of {role, content}); 'text' emits legacy '[role: x] ... [x:end]' markers.
retainToolCalls?: boolean; // When true (default) and retainFormat='json', each message's content is an Anthropic-shaped array of typed blocks (text, tool_use, tool_result) including the agent's tool calls and their results. When false, content is a flat string with only text.
recallBudget?: 'low' | 'mid' | 'high'; // Recall effort. Default: 'mid'
recallBudget?: "low" | "mid" | "high"; // Recall effort. Default: 'mid'
recallMaxTokens?: number; // Max tokens for recall response. Default: 1024
recallTypes?: Array<'world' | 'experience' | 'observation'>; // Memory types to recall. Default: ['world', 'experience']
recallRoles?: Array<'user' | 'assistant' | 'system' | 'tool'>; // Roles to include when composing contextual recall query. Default: ['user', 'assistant']
recallTypes?: Array<"world" | "experience" | "observation">; // Memory types to recall. Default: ['world', 'experience']
recallRoles?: Array<"user" | "assistant" | "system" | "tool">; // Roles to include when composing contextual recall query. Default: ['user', 'assistant']
retainEveryNTurns?: number; // Retain every Nth turn (1 = every turn, default: 1). Values > 1 enable chunked retention.
retainOverlapTurns?: number; // Extra prior turns included when chunked retention fires (default: 0). Window = retainEveryNTurns + retainOverlapTurns.
recallTopK?: number; // Max number of memories to inject. Default: unlimited
@@ -85,12 +88,12 @@ export interface PluginConfig {
recallTimeoutMs?: number; // Timeout for auto-recall in milliseconds. Default: 10000
recallMaxQueryChars?: number; // Max chars for composed recall query. Default: 800
recallPromptPreamble?: string; // Prompt preamble placed above recalled memories. Default: built-in guidance text.
recallInjectionPosition?: 'prepend' | 'append' | 'user'; // Where to inject recalled memories. 'prepend' = start of system prompt (default), 'append' = end of system prompt (preserves prompt cache), 'user' = before user message.
recallInjectionPosition?: "prepend" | "append" | "user"; // Where to inject recalled memories. 'prepend' = start of system prompt (default), 'append' = end of system prompt (preserves prompt cache), 'user' = before user message.
ignoreSessionPatterns?: string[]; // Session key glob patterns to skip entirely (no recall, no retain). E.g. ["agent:main:**", "agent:*:cron:**"]
statelessSessionPatterns?: string[]; // Session key glob patterns for read-only sessions (recall allowed, retain skipped). E.g. ["agent:*:subagent:**"]
skipStatelessSessions?: boolean; // When true (default), stateless sessions also skip recall. When false, they recall but never retain.
debug?: boolean; // Enable debug logging (default: false)
logLevel?: 'off' | 'error' | 'warning' | 'info' | 'debug'; // Console log verbosity (default: 'info').
logLevel?: "off" | "error" | "warning" | "info" | "debug"; // Console log verbosity (default: 'info').
logSummaryIntervalMs?: number; // Batch retain/recall log summaries over this interval in ms. 0 = log every event. Default: 300000 (5 min).
retainQueuePath?: string; // Path to JSONL file for buffering failed retains. Default: ~/.openclaw/data/hindsight-retain-queue.jsonl
retainQueueMaxAgeMs?: number; // Max age in ms for queued items. -1 = keep forever (default: -1)
@@ -110,7 +113,11 @@ export interface ServiceConfig {
// MemoryResult / RecallResponse / ReflectResponse come from the generated
// hindsight-client SDK. We alias MemoryResult → RecallResult so existing code
// paths (formatMemories, etc.) keep the old name.
export type { RecallResult as MemoryResult, RecallResponse, ReflectResponse } from '@vectorize-io/hindsight-client';
export type {
RecallResult as MemoryResult,
RecallResponse,
ReflectResponse,
} from "@vectorize-io/hindsight-client";
/**
* Internal retain payload shape built by `buildRetainRequest`. Not a
@@ -13,11 +13,20 @@
* npm run test:integration
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi, type MockInstance } from 'vitest';
import type { RecallResponse, RetainResponse } from '@vectorize-io/hindsight-client';
import type { MoltbotPluginAPI, PluginConfig } from '../src/types.js';
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
vi,
type MockInstance,
} from "vitest";
import type { RecallResponse, RetainResponse } from "@vectorize-io/hindsight-client";
import type { MoltbotPluginAPI, PluginConfig } from "../src/types.js";
const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888';
const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || "http://localhost:8888";
// ---------------------------------------------------------------------------
// Helpers
@@ -53,7 +62,7 @@ function createMockApi(pluginConfig: Partial<PluginConfig> = {}): MockApiHandle
config: {
plugins: {
entries: {
'hindsight-openclaw': { enabled: true, config: pluginConfig as PluginConfig },
"hindsight-openclaw": { enabled: true, config: pluginConfig as PluginConfig },
},
},
},
@@ -89,7 +98,12 @@ function createMockApi(pluginConfig: Partial<PluginConfig> = {}): MockApiHandle
};
}
const EMPTY_RECALL: RecallResponse = { results: [], entities: null, trace: null, chunks: null } as RecallResponse;
const EMPTY_RECALL: RecallResponse = {
results: [],
entities: null,
trace: null,
chunks: null,
} as RecallResponse;
const OK_RETAIN = { operations: [], memory_units: [] } as unknown as RetainResponse;
interface MockMemoryResult {
@@ -111,9 +125,9 @@ function makeMemoryResult(text: string): MockMemoryResult {
return {
id: `mem-${Math.random().toString(36).slice(2)}`,
text,
type: 'fact',
type: "fact",
entities: [],
context: '',
context: "",
occurred_start: null,
occurred_end: null,
mentioned_at: null,
@@ -129,7 +143,7 @@ function makeMemoryResult(text: string): MockMemoryResult {
// ---------------------------------------------------------------------------
let apiReachable = false;
let triggerHook: MockApiHandle['trigger'];
let triggerHook: MockApiHandle["trigger"];
let stopServicesFn: () => Promise<void>;
// Typed loosely as MockInstance because vi.spyOn's generic form doesn't
// play nicely with the hindsight-client class shape (method overloads).
@@ -140,7 +154,7 @@ beforeAll(async () => {
apiReachable = await waitForApi(HINDSIGHT_API_URL, 8000);
if (!apiReachable) {
console.warn(
`[Hooks Integration] Hindsight API not reachable at ${HINDSIGHT_API_URL} skipping hook tests.`,
`[Hooks Integration] Hindsight API not reachable at ${HINDSIGHT_API_URL} skipping hook tests.`
);
return;
}
@@ -148,8 +162,8 @@ beforeAll(async () => {
// Reset module registry so we get a fresh module with clean state.
vi.resetModules();
const mod = await import('../src/index.js');
const { HindsightClient } = await import('@vectorize-io/hindsight-client');
const mod = await import("../src/index.js");
const { HindsightClient } = await import("@vectorize-io/hindsight-client");
const pluginFn = mod.default;
const getClient = mod.getClient;
@@ -158,11 +172,11 @@ beforeAll(async () => {
const handle = createMockApi({
hindsightApiUrl: HINDSIGHT_API_URL,
dynamicBankId: true,
excludeProviders: ['slack'],
excludeProviders: ["slack"],
retainEveryNTurns: 1, // retain every turn so individual tests aren't affected by chunking
recallContextTurns: 3,
recallMaxQueryChars: 180,
recallRoles: ['user'],
recallRoles: ["user"],
// No bankMission — keeps init lean
});
triggerHook = handle.trigger;
@@ -175,12 +189,13 @@ beforeAll(async () => {
await handle.startServices();
// After startServices the client must be ready.
if (!getClient()) throw new Error('[Hooks Integration] Client not initialized after service start');
if (!getClient())
throw new Error("[Hooks Integration] Client not initialized after service start");
// Spy on the HindsightClient prototype so all calls go through the spy.
// The plugin's scopeClient() wrapper calls through these prototype methods.
recallSpy = vi.spyOn(HindsightClient.prototype, 'recall');
retainSpy = vi.spyOn(HindsightClient.prototype, 'retain');
recallSpy = vi.spyOn(HindsightClient.prototype, "recall");
retainSpy = vi.spyOn(HindsightClient.prototype, "retain");
}, 30_000);
afterAll(async () => {
@@ -198,74 +213,74 @@ afterEach(() => {
// before_prompt_build hook
// ---------------------------------------------------------------------------
describe('before_prompt_build hook', () => {
it('skips recall for excluded providers and returns undefined', async () => {
describe("before_prompt_build hook", () => {
it("skips recall for excluded providers and returns undefined", async () => {
if (!apiReachable) return;
const result = await triggerHook(
'before_prompt_build',
{ rawMessage: 'What are my preferences?', prompt: 'What are my preferences?', messages: [] },
{ messageProvider: 'slack', senderId: 'U001' },
"before_prompt_build",
{ rawMessage: "What are my preferences?", prompt: "What are my preferences?", messages: [] },
{ messageProvider: "slack", senderId: "U001" }
);
expect(recallSpy).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('skips recall when rawMessage is too short and returns undefined', async () => {
it("skips recall when rawMessage is too short and returns undefined", async () => {
if (!apiReachable) return;
const result = await triggerHook(
'before_prompt_build',
{ rawMessage: 'Hi', prompt: 'Hi', messages: [] },
{ messageProvider: 'telegram', senderId: 'U001' },
"before_prompt_build",
{ rawMessage: "Hi", prompt: "Hi", messages: [] },
{ messageProvider: "telegram", senderId: "U001" }
);
expect(recallSpy).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('returns undefined when recall finds no results', async () => {
it("returns undefined when recall finds no results", async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
const result = await triggerHook(
'before_prompt_build',
{ rawMessage: 'What programming language do I like?', prompt: '', messages: [] },
{ messageProvider: 'telegram', senderId: 'U002' },
"before_prompt_build",
{ rawMessage: "What programming language do I like?", prompt: "", messages: [] },
{ messageProvider: "telegram", senderId: "U002" }
);
expect(recallSpy).toHaveBeenCalledOnce();
expect(result).toBeUndefined();
});
it('returns { prependSystemContext } with <hindsight_memories> when recall returns results', async () => {
it("returns { prependSystemContext } with <hindsight_memories> when recall returns results", async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue({
results: [makeMemoryResult('User likes Python')],
results: [makeMemoryResult("User likes Python")],
entities: null,
trace: null,
chunks: null,
} as RecallResponse);
const result = (await triggerHook(
'before_prompt_build',
{ rawMessage: 'What programming language do I prefer?', prompt: '', messages: [] },
{ messageProvider: 'telegram', senderId: 'U003' },
"before_prompt_build",
{ rawMessage: "What programming language do I prefer?", prompt: "", messages: [] },
{ messageProvider: "telegram", senderId: "U003" }
)) as { prependSystemContext: string; prependContext?: string };
expect(result).toBeDefined();
expect(result.prependContext).toBeUndefined();
expect(result.prependSystemContext).toContain('<hindsight_memories>');
expect(result.prependSystemContext).toContain('User likes Python');
expect(result.prependSystemContext).toContain('</hindsight_memories>');
expect(result.prependSystemContext).toContain("<hindsight_memories>");
expect(result.prependSystemContext).toContain("User likes Python");
expect(result.prependSystemContext).toContain("</hindsight_memories>");
});
it('injects all memory result fields in the prependSystemContext', async () => {
it("injects all memory result fields in the prependSystemContext", async () => {
if (!apiReachable) return;
const mem = makeMemoryResult('User prefers dark mode');
mem.tags = ['preference'];
mem.entities = ['dark_mode'];
const mem = makeMemoryResult("User prefers dark mode");
mem.tags = ["preference"];
mem.entities = ["dark_mode"];
recallSpy.mockResolvedValue({
results: [mem],
entities: null,
@@ -274,71 +289,71 @@ describe('before_prompt_build hook', () => {
} as RecallResponse);
const result = (await triggerHook(
'before_prompt_build',
{ rawMessage: 'Do I prefer dark or light mode?', prompt: '', messages: [] },
{ messageProvider: 'telegram', senderId: 'U004' },
"before_prompt_build",
{ rawMessage: "Do I prefer dark or light mode?", prompt: "", messages: [] },
{ messageProvider: "telegram", senderId: "U004" }
)) as { prependSystemContext: string; prependContext?: string };
// formatMemories returns a bullet list, not JSON
expect(result.prependContext).toBeUndefined();
expect(result.prependSystemContext).toContain('- User prefers dark mode');
expect(result.prependSystemContext).toContain('<hindsight_memories>');
expect(result.prependSystemContext).toContain('</hindsight_memories>');
expect(result.prependSystemContext).toContain("- User prefers dark mode");
expect(result.prependSystemContext).toContain("<hindsight_memories>");
expect(result.prependSystemContext).toContain("</hindsight_memories>");
});
it('extracts the inner query from an envelope-formatted prompt when rawMessage is absent', async () => {
it("extracts the inner query from an envelope-formatted prompt when rawMessage is absent", async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
const envelopePrompt = '[Telegram Chat]\nWhat is my favorite food?\n[from: Alice]';
const envelopePrompt = "[Telegram Chat]\nWhat is my favorite food?\n[from: Alice]";
await triggerHook(
'before_prompt_build',
{ rawMessage: '', prompt: envelopePrompt, messages: [] },
{ messageProvider: 'telegram', senderId: 'U005' },
"before_prompt_build",
{ rawMessage: "", prompt: envelopePrompt, messages: [] },
{ messageProvider: "telegram", senderId: "U005" }
);
expect(recallSpy).toHaveBeenCalledOnce();
// HindsightClient.recall signature: (bankId, query, options?)
const [, query] = recallSpy.mock.calls[0];
expect(query).not.toContain('[Telegram');
expect(query).not.toContain('[from: Alice]');
expect(query).toContain('What is my favorite food?');
expect(query).not.toContain("[Telegram");
expect(query).not.toContain("[from: Alice]");
expect(query).toContain("What is my favorite food?");
});
it('passes a latest-priority contextual recall query and respects max query chars', async () => {
it("passes a latest-priority contextual recall query and respects max query chars", async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
await triggerHook(
'before_prompt_build',
"before_prompt_build",
{
rawMessage: 'Do I still prefer dark mode?',
prompt: '',
rawMessage: "Do I still prefer dark mode?",
prompt: "",
messages: [
{ role: 'user', content: 'I prefer dark mode in IDEs.' },
{ role: 'assistant', content: 'Noted: dark mode preference.' },
{ role: 'user', content: 'Do I still prefer dark mode?' },
{ role: "user", content: "I prefer dark mode in IDEs." },
{ role: "assistant", content: "Noted: dark mode preference." },
{ role: "user", content: "Do I still prefer dark mode?" },
],
},
{ messageProvider: 'telegram', senderId: 'U006A' },
{ messageProvider: "telegram", senderId: "U006A" }
);
expect(recallSpy).toHaveBeenCalledOnce();
const [, query] = recallSpy.mock.calls[0];
expect(query).toContain('Do I still prefer dark mode?');
expect(query).toContain('user: I prefer dark mode in IDEs.');
expect(query).not.toContain('assistant: Noted: dark mode preference.');
expect(query).toContain("Do I still prefer dark mode?");
expect(query).toContain("user: I prefer dark mode in IDEs.");
expect(query).not.toContain("assistant: Noted: dark mode preference.");
expect(query.length).toBeLessThanOrEqual(180);
});
it('passes maxTokens to recall', async () => {
it("passes maxTokens to recall", async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
await triggerHook(
'before_prompt_build',
{ rawMessage: 'Tell me about my hobbies please.', prompt: '', messages: [] },
{ messageProvider: 'telegram', senderId: 'U006' },
"before_prompt_build",
{ rawMessage: "Tell me about my hobbies please.", prompt: "", messages: [] },
{ messageProvider: "telegram", senderId: "U006" }
);
expect(recallSpy).toHaveBeenCalledOnce();
@@ -346,67 +361,67 @@ describe('before_prompt_build hook', () => {
expect(options?.maxTokens).toBeGreaterThan(0);
});
it('includes recalled memories in the prependSystemContext block', async () => {
it("includes recalled memories in the prependSystemContext block", async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue({
results: [makeMemoryResult('User loves hiking')],
results: [makeMemoryResult("User loves hiking")],
entities: null,
trace: null,
chunks: null,
} as RecallResponse);
const result = (await triggerHook(
'before_prompt_build',
{ rawMessage: 'What outdoor activities do I enjoy?', prompt: '', messages: [] },
{ messageProvider: 'telegram', senderId: 'U007' },
"before_prompt_build",
{ rawMessage: "What outdoor activities do I enjoy?", prompt: "", messages: [] },
{ messageProvider: "telegram", senderId: "U007" }
)) as { prependSystemContext: string; prependContext?: string };
expect(result.prependContext).toBeUndefined();
expect(result.prependSystemContext).toContain('User loves hiking');
expect(result.prependSystemContext).toContain('<hindsight_memories>');
expect(result.prependSystemContext).toContain("User loves hiking");
expect(result.prependSystemContext).toContain("<hindsight_memories>");
});
it('uses identity cached in before_dispatch when later hooks lack sender metadata', async () => {
it("uses identity cached in before_dispatch when later hooks lack sender metadata", async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
await triggerHook(
'before_dispatch',
"before_dispatch",
{
sessionKey: 'agent:main:telegram:direct:U020',
channel: 'telegram',
senderId: 'U020',
sessionKey: "agent:main:telegram:direct:U020",
channel: "telegram",
senderId: "U020",
},
{ sessionKey: 'agent:main:telegram:direct:U020' },
{ sessionKey: "agent:main:telegram:direct:U020" }
);
await triggerHook(
'before_prompt_build',
{ rawMessage: 'What do I like?', prompt: '', messages: [] },
{ sessionKey: 'agent:main:telegram:direct:U020' },
"before_prompt_build",
{ rawMessage: "What do I like?", prompt: "", messages: [] },
{ sessionKey: "agent:main:telegram:direct:U020" }
);
expect(recallSpy).toHaveBeenCalledOnce();
});
it('skips recall when before_dispatch detects a provider mismatch for the session', async () => {
it("skips recall when before_dispatch detects a provider mismatch for the session", async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
await triggerHook(
'before_dispatch',
"before_dispatch",
{
sessionKey: 'agent:main:telegram:direct:U021',
channel: 'discord',
senderId: 'U021',
sessionKey: "agent:main:telegram:direct:U021",
channel: "discord",
senderId: "U021",
},
{ sessionKey: 'agent:main:telegram:direct:U021' },
{ sessionKey: "agent:main:telegram:direct:U021" }
);
const result = await triggerHook(
'before_prompt_build',
{ rawMessage: 'What do I like?', prompt: '', messages: [] },
{ sessionKey: 'agent:main:telegram:direct:U021' },
"before_prompt_build",
{ rawMessage: "What do I like?", prompt: "", messages: [] },
{ sessionKey: "agent:main:telegram:direct:U021" }
);
expect(recallSpy).not.toHaveBeenCalled();
@@ -418,60 +433,60 @@ describe('before_prompt_build hook', () => {
// agent_end hook
// ---------------------------------------------------------------------------
describe('agent_end hook', () => {
it('skips retain when success is false', async () => {
describe("agent_end hook", () => {
it("skips retain when success is false", async () => {
if (!apiReachable) return;
await triggerHook(
'agent_end',
{ success: false, messages: [{ role: 'user', content: 'Hello there world!' }] },
{ messageProvider: 'telegram', senderId: 'U010' },
"agent_end",
{ success: false, messages: [{ role: "user", content: "Hello there world!" }] },
{ messageProvider: "telegram", senderId: "U010" }
);
expect(retainSpy).not.toHaveBeenCalled();
});
it('skips retain when messages array is empty', async () => {
it("skips retain when messages array is empty", async () => {
if (!apiReachable) return;
await triggerHook(
'agent_end',
"agent_end",
{ success: true, messages: [] },
{ messageProvider: 'telegram', senderId: 'U011' },
{ messageProvider: "telegram", senderId: "U011" }
);
expect(retainSpy).not.toHaveBeenCalled();
});
it('skips retain for excluded providers', async () => {
it("skips retain for excluded providers", async () => {
if (!apiReachable) return;
await triggerHook(
'agent_end',
"agent_end",
{
success: true,
messages: [{ role: 'user', content: 'I work as a software engineer.' }],
messages: [{ role: "user", content: "I work as a software engineer." }],
},
{ messageProvider: 'slack', senderId: 'U012' },
{ messageProvider: "slack", senderId: "U012" }
);
expect(retainSpy).not.toHaveBeenCalled();
});
it('calls retain with correctly formatted transcript for string content', async () => {
it("calls retain with correctly formatted transcript for string content", async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
"agent_end",
{
success: true,
messages: [
{ role: 'user', content: 'I love TypeScript.' },
{ role: 'assistant', content: 'TypeScript is great!' },
{ role: "user", content: "I love TypeScript." },
{ role: "assistant", content: "TypeScript is great!" },
],
},
{ messageProvider: 'telegram', senderId: 'U013', sessionKey: 'sess-ts-test' },
{ messageProvider: "telegram", senderId: "U013", sessionKey: "sess-ts-test" }
);
expect(retainSpy).toHaveBeenCalledOnce();
@@ -480,108 +495,108 @@ describe('agent_end hook', () => {
const [, content] = retainSpy.mock.calls[0];
const parsed = JSON.parse(content);
expect(parsed).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'I love TypeScript.' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'TypeScript is great!' }] },
{ role: "user", content: [{ type: "text", text: "I love TypeScript." }] },
{ role: "assistant", content: [{ type: "text", text: "TypeScript is great!" }] },
]);
});
it('includes session key in documentId', async () => {
it("includes session key in documentId", async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
"agent_end",
{
success: true,
messages: [{ role: 'user', content: 'My favourite colour is blue.' }],
messages: [{ role: "user", content: "My favourite colour is blue." }],
},
{ messageProvider: 'telegram', senderId: 'U014', sessionKey: 'sess-colour' },
{ messageProvider: "telegram", senderId: "U014", sessionKey: "sess-colour" }
);
expect(retainSpy).toHaveBeenCalledOnce();
const [, , options] = retainSpy.mock.calls[0];
expect(options?.documentId).toContain('sess-colour');
expect(options?.documentId).toContain("sess-colour");
});
it('populates metadata with channel_type, channel_id, and sender_id', async () => {
it("populates metadata with channel_type, channel_id, and sender_id", async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
"agent_end",
{
success: true,
messages: [{ role: 'user', content: 'My cat is named Whiskers.' }],
messages: [{ role: "user", content: "My cat is named Whiskers." }],
},
{
messageProvider: 'telegram',
channelId: 'chat-999',
senderId: 'U015',
sessionKey: 'sess-cat',
},
messageProvider: "telegram",
channelId: "chat-999",
senderId: "U015",
sessionKey: "sess-cat",
}
);
expect(retainSpy).toHaveBeenCalledOnce();
const [, , options] = retainSpy.mock.calls[0];
expect(options?.metadata?.channel_type).toBe('telegram');
expect(options?.metadata?.channel_id).toBe('chat-999');
expect(options?.metadata?.sender_id).toBe('U015');
expect(options?.metadata?.channel_type).toBe("telegram");
expect(options?.metadata?.channel_id).toBe("chat-999");
expect(options?.metadata?.sender_id).toBe("U015");
expect(options?.metadata?.retained_at).toBeDefined();
expect(options?.metadata?.message_count).toBe('1');
expect(options?.metadata?.message_count).toBe("1");
});
it('uses identity cached in before_dispatch for retain metadata when agent_end ctx is sparse', async () => {
it("uses identity cached in before_dispatch for retain metadata when agent_end ctx is sparse", async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'before_dispatch',
"before_dispatch",
{
sessionKey: 'agent:main:telegram:direct:U015B',
channel: 'telegram',
senderId: 'U015B',
sessionKey: "agent:main:telegram:direct:U015B",
channel: "telegram",
senderId: "U015B",
},
{ sessionKey: 'agent:main:telegram:direct:U015B' },
{ sessionKey: "agent:main:telegram:direct:U015B" }
);
await triggerHook(
'agent_end',
"agent_end",
{
success: true,
messages: [{ role: 'user', content: 'I like midnight blue.' }],
messages: [{ role: "user", content: "I like midnight blue." }],
},
{ sessionKey: 'agent:main:telegram:direct:U015B' },
{ sessionKey: "agent:main:telegram:direct:U015B" }
);
expect(retainSpy).toHaveBeenCalledOnce();
const [, , options] = retainSpy.mock.calls[0];
expect(options?.metadata?.channel_type).toBe('telegram');
expect(options?.metadata?.channel_id).toBe('direct:U015B');
expect(options?.metadata?.sender_id).toBe('U015B');
expect(options?.metadata?.channel_type).toBe("telegram");
expect(options?.metadata?.channel_id).toBe("direct:U015B");
expect(options?.metadata?.sender_id).toBe("U015B");
});
it('keeps provider fallback without backfilling channel_type when only session parsing provides it', async () => {
it("keeps provider fallback without backfilling channel_type when only session parsing provides it", async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
"agent_end",
{
success: true,
messages: [{ role: 'user', content: 'I prefer espresso.' }],
messages: [{ role: "user", content: "I prefer espresso." }],
},
{ sessionKey: 'agent:main:telegram:direct:U015C' },
{ sessionKey: "agent:main:telegram:direct:U015C" }
);
expect(retainSpy).toHaveBeenCalledOnce();
const [, , options] = retainSpy.mock.calls[0];
expect(options?.metadata?.provider).toBe('telegram');
expect(options?.metadata?.provider).toBe("telegram");
expect(options?.metadata?.channel_type).toBeUndefined();
expect(options?.metadata?.channel_id).toBe('direct:U015C');
expect(options?.metadata?.sender_id).toBe('U015C');
expect(options?.metadata?.channel_id).toBe("direct:U015C");
expect(options?.metadata?.sender_id).toBe("U015C");
});
it('strips <hindsight_memories> tags from content before retaining', async () => {
it("strips <hindsight_memories> tags from content before retaining", async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
@@ -589,87 +604,87 @@ describe('agent_end hook', () => {
'<hindsight_memories>\nRelevant memories:\n[{"text":"old fact"}]\n</hindsight_memories>\nI enjoy reading science fiction.';
await triggerHook(
'agent_end',
"agent_end",
{
success: true,
messages: [{ role: 'user', content: contentWithMemories }],
messages: [{ role: "user", content: contentWithMemories }],
},
{ messageProvider: 'telegram', senderId: 'U016', sessionKey: 'sess-strip' },
{ messageProvider: "telegram", senderId: "U016", sessionKey: "sess-strip" }
);
expect(retainSpy).toHaveBeenCalledOnce();
const [, content] = retainSpy.mock.calls[0];
expect(content).not.toContain('<hindsight_memories>');
expect(content).not.toContain('</hindsight_memories>');
expect(content).not.toContain('old fact');
expect(content).toContain('I enjoy reading science fiction.');
expect(content).not.toContain("<hindsight_memories>");
expect(content).not.toContain("</hindsight_memories>");
expect(content).not.toContain("old fact");
expect(content).toContain("I enjoy reading science fiction.");
});
it('strips <relevant_memories> tags from content before retaining', async () => {
it("strips <relevant_memories> tags from content before retaining", async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
const contentWithLegacyTag =
'<relevant_memories>\nSome old memories\n</relevant_memories>\nI am learning Rust.';
"<relevant_memories>\nSome old memories\n</relevant_memories>\nI am learning Rust.";
await triggerHook(
'agent_end',
"agent_end",
{
success: true,
messages: [{ role: 'user', content: contentWithLegacyTag }],
messages: [{ role: "user", content: contentWithLegacyTag }],
},
{ messageProvider: 'telegram', senderId: 'U017', sessionKey: 'sess-legacy' },
{ messageProvider: "telegram", senderId: "U017", sessionKey: "sess-legacy" }
);
expect(retainSpy).toHaveBeenCalledOnce();
const [, content] = retainSpy.mock.calls[0];
expect(content).not.toContain('<relevant_memories>');
expect(content).toContain('I am learning Rust.');
expect(content).not.toContain("<relevant_memories>");
expect(content).toContain("I am learning Rust.");
});
it('handles array content blocks (structured message format)', async () => {
it("handles array content blocks (structured message format)", async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
"agent_end",
{
success: true,
messages: [
{
role: 'user',
role: "user",
content: [
{ type: 'text', text: 'I prefer dark mode in all my editors.' },
{ type: 'image', source: 'data:...' }, // non-text block — should be ignored
{ type: "text", text: "I prefer dark mode in all my editors." },
{ type: "image", source: "data:..." }, // non-text block — should be ignored
],
},
],
},
{ messageProvider: 'telegram', senderId: 'U018', sessionKey: 'sess-array' },
{ messageProvider: "telegram", senderId: "U018", sessionKey: "sess-array" }
);
expect(retainSpy).toHaveBeenCalledOnce();
const [, content] = retainSpy.mock.calls[0];
expect(content).toContain('I prefer dark mode in all my editors.');
expect(content).not.toContain('data:');
expect(content).toContain("I prefer dark mode in all my editors.");
expect(content).not.toContain("data:");
});
it('retains a multi-turn conversation in the correct transcript format', async () => {
it("retains a multi-turn conversation in the correct transcript format", async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
"agent_end",
{
success: true,
messages: [
{ role: 'user', content: 'My name is Carol.' },
{ role: 'assistant', content: 'Nice to meet you, Carol!' },
{ role: 'user', content: 'I work as a data scientist.' },
{ role: 'assistant', content: "That's a fascinating career!" },
{ role: "user", content: "My name is Carol." },
{ role: "assistant", content: "Nice to meet you, Carol!" },
{ role: "user", content: "I work as a data scientist." },
{ role: "assistant", content: "That's a fascinating career!" },
],
},
{ messageProvider: 'telegram', senderId: 'U019', sessionKey: 'sess-multi' },
{ messageProvider: "telegram", senderId: "U019", sessionKey: "sess-multi" }
);
expect(retainSpy).toHaveBeenCalledOnce();
@@ -679,10 +694,10 @@ describe('agent_end hook', () => {
// Default retainFormat is 'json' with Anthropic-shaped typed blocks.
const parsed = JSON.parse(content);
expect(parsed).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'I work as a data scientist.' }] },
{ role: 'assistant', content: [{ type: 'text', text: "That's a fascinating career!" }] },
{ role: "user", content: [{ type: "text", text: "I work as a data scientist." }] },
{ role: "assistant", content: [{ type: "text", text: "That's a fascinating career!" }] },
]);
expect(content).not.toContain('My name is Carol.');
expect(options?.metadata?.message_count).toBe('2');
expect(content).not.toContain("My name is Carol.");
expect(options?.metadata?.message_count).toBe("2");
});
});
@@ -14,11 +14,11 @@
* npm run test:integration
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { HindsightServer } from '@vectorize-io/hindsight-all';
import { HindsightClient } from '@vectorize-io/hindsight-client';
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import { HindsightServer } from "@vectorize-io/hindsight-all";
import { HindsightClient } from "@vectorize-io/hindsight-client";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -27,15 +27,14 @@ const __dirname = dirname(__filename);
// Test configuration (driven by environment variables)
// ---------------------------------------------------------------------------
const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888';
const LLM_PROVIDER = process.env.HINDSIGHT_API_LLM_PROVIDER || '';
const LLM_API_KEY = process.env.HINDSIGHT_API_LLM_API_KEY || '';
const LLM_MODEL = process.env.HINDSIGHT_API_LLM_MODEL || '';
const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || "http://localhost:8888";
const LLM_PROVIDER = process.env.HINDSIGHT_API_LLM_PROVIDER || "";
const LLM_API_KEY = process.env.HINDSIGHT_API_LLM_API_KEY || "";
const LLM_MODEL = process.env.HINDSIGHT_API_LLM_MODEL || "";
// Embed package path defaults to the sibling hindsight-embed directory in the repo
const EMBED_PACKAGE_PATH =
process.env.HINDSIGHT_EMBED_PACKAGE_PATH ||
join(__dirname, '..', '..', '..', 'hindsight-embed');
process.env.HINDSIGHT_EMBED_PACKAGE_PATH || join(__dirname, "..", "..", "..", "hindsight-embed");
// Port for the test embed daemon (different from production default 9077 to avoid conflicts)
const EMBED_TEST_PORT = 19077;
@@ -66,7 +65,7 @@ async function waitForApi(url: string, maxMs = 5000): Promise<boolean> {
// HTTP Mode Tests
// ---------------------------------------------------------------------------
describe('openclaw integration — HTTP mode', () => {
describe("openclaw integration — HTTP mode", () => {
let client: HindsightClient;
beforeAll(async () => {
@@ -74,68 +73,68 @@ describe('openclaw integration — HTTP mode', () => {
if (!reachable) {
throw new Error(
`Hindsight API not reachable at ${HINDSIGHT_API_URL}. ` +
'Start the server before running integration tests.',
"Start the server before running integration tests."
);
}
client = new HindsightClient({ baseUrl: HINDSIGHT_API_URL });
});
it('should retain a conversation', async () => {
it("should retain a conversation", async () => {
const bankId = randomBankId();
const response = await client.retain(
bankId,
'[role: user]\nMy name is Alice and I love hiking.\n[user:end]\n\n' +
'[role: assistant]\nNice to meet you, Alice!\n[assistant:end]',
"[role: user]\nMy name is Alice and I love hiking.\n[user:end]\n\n" +
"[role: assistant]\nNice to meet you, Alice!\n[assistant:end]",
{
documentId: 'http-retain-test-1',
metadata: { channel_type: 'slack', sender_id: 'U001' },
documentId: "http-retain-test-1",
metadata: { channel_type: "slack", sender_id: "U001" },
async: true,
},
}
);
expect(response).toBeDefined();
});
it('should retain with auto-generated document id', async () => {
it("should retain with auto-generated document id", async () => {
const bankId = randomBankId();
const response = await client.retain(
bankId,
'[role: user]\nI work at TechCorp as a software engineer.\n[user:end]',
{ async: true },
"[role: user]\nI work at TechCorp as a software engineer.\n[user:end]",
{ async: true }
);
expect(response).toBeDefined();
});
it('should recall from an empty bank without error', async () => {
it("should recall from an empty bank without error", async () => {
const bankId = randomBankId();
const response = await client.recall(bankId, 'What do I like?', { maxTokens: 512 });
const response = await client.recall(bankId, "What do I like?", { maxTokens: 512 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should set bank mission via createBank after retain creates the bank', async () => {
it("should set bank mission via createBank after retain creates the bank", async () => {
const bankId = randomBankId();
await client.retain(bankId, '[role: user]\nHello\n[user:end]', { async: true });
await client.retain(bankId, "[role: user]\nHello\n[user:end]", { async: true });
await expect(
client.createBank(bankId, { reflectMission: 'You are a helpful AI assistant.' }),
client.createBank(bankId, { reflectMission: "You are a helpful AI assistant." })
).resolves.toBeDefined();
});
it('should retain and then recall relevant memories', async () => {
it("should retain and then recall relevant memories", async () => {
const bankId = randomBankId();
await client.retain(
bankId,
'[role: user]\nMy favorite programming language is Python.\n[user:end]\n\n' +
'[role: assistant]\nPython is a great choice!\n[assistant:end]',
{ documentId: `session-${Date.now()}`, async: true },
"[role: user]\nMy favorite programming language is Python.\n[user:end]\n\n" +
"[role: assistant]\nPython is a great choice!\n[assistant:end]",
{ documentId: `session-${Date.now()}`, async: true }
);
const response = await client.recall(bankId, 'What programming language do I like?', {
const response = await client.recall(bankId, "What programming language do I like?", {
maxTokens: 1024,
});
@@ -143,28 +142,28 @@ describe('openclaw integration — HTTP mode', () => {
expect(Array.isArray(response.results)).toBe(true);
});
it('should use custom maxTokens in recall request', async () => {
it("should use custom maxTokens in recall request", async () => {
const bankId = randomBankId();
const response = await client.recall(bankId, 'anything', { maxTokens: 256 });
const response = await client.recall(bankId, "anything", { maxTokens: 256 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should map recall results to the RecallResult shape', async () => {
it("should map recall results to the RecallResult shape", async () => {
const bankId = randomBankId();
await client.retain(
bankId,
'[role: user]\nI enjoy reading science fiction books.\n[user:end]\n\n' +
'[role: assistant]\nSounds like a great hobby!\n[assistant:end]',
{ documentId: 'mapping-test', async: true },
"[role: user]\nI enjoy reading science fiction books.\n[user:end]\n\n" +
"[role: assistant]\nSounds like a great hobby!\n[assistant:end]",
{ documentId: "mapping-test", async: true }
);
const response = await client.recall(bankId, 'What are my hobbies?', { maxTokens: 1024 });
const response = await client.recall(bankId, "What are my hobbies?", { maxTokens: 1024 });
for (const result of response.results) {
expect(typeof result.id).toBe('string');
expect(typeof result.text).toBe('string');
expect(typeof result.id).toBe("string");
expect(typeof result.text).toBe("string");
}
});
});
@@ -173,7 +172,7 @@ describe('openclaw integration — HTTP mode', () => {
// Embed Mode Tests (local daemon spawned by HindsightServer)
// ---------------------------------------------------------------------------
describe('openclaw integration — embed mode', () => {
describe("openclaw integration — embed mode", () => {
let client: HindsightClient;
let server: HindsightServer;
@@ -182,22 +181,22 @@ describe('openclaw integration — embed mode', () => {
beforeAll(async () => {
if (!hasEmbedCredentials) {
console.warn(
'[Integration] Skipping embed mode tests: ' +
'HINDSIGHT_API_LLM_PROVIDER and HINDSIGHT_API_LLM_API_KEY must both be set.',
"[Integration] Skipping embed mode tests: " +
"HINDSIGHT_API_LLM_PROVIDER and HINDSIGHT_API_LLM_API_KEY must both be set."
);
return;
}
server = new HindsightServer({
profile: 'openclaw-test',
profile: "openclaw-test",
port: EMBED_TEST_PORT,
embedVersion: 'latest',
embedVersion: "latest",
embedPackagePath: EMBED_PACKAGE_PATH,
env: {
HINDSIGHT_API_LLM_PROVIDER: LLM_PROVIDER,
HINDSIGHT_API_LLM_API_KEY: LLM_API_KEY,
HINDSIGHT_API_LLM_MODEL: LLM_MODEL || undefined,
HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT: '0',
HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT: "0",
},
});
@@ -212,44 +211,44 @@ describe('openclaw integration — embed mode', () => {
}
}, 30_000);
it('should retain a conversation against the local daemon', async () => {
it("should retain a conversation against the local daemon", async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
const response = await client.retain(
bankId,
'[role: user]\nI love hiking in the mountains.\n[user:end]\n\n' +
'[role: assistant]\nSounds adventurous!\n[assistant:end]',
{ documentId: 'embed-retain-test-1', async: true },
"[role: user]\nI love hiking in the mountains.\n[user:end]\n\n" +
"[role: assistant]\nSounds adventurous!\n[assistant:end]",
{ documentId: "embed-retain-test-1", async: true }
);
expect(response).toBeDefined();
}, 60_000);
it('should recall from an empty bank against the local daemon', async () => {
it("should recall from an empty bank against the local daemon", async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
const response = await client.recall(bankId, 'What do I like?', { maxTokens: 512 });
const response = await client.recall(bankId, "What do I like?", { maxTokens: 512 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
}, 60_000);
it('should set bank mission against the local daemon', async () => {
it("should set bank mission against the local daemon", async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
// Create bank by retaining first, then set mission
await client.retain(bankId, '[role: user]\nHello\n[user:end]', { async: true });
await client.retain(bankId, "[role: user]\nHello\n[user:end]", { async: true });
await expect(
client.createBank(bankId, { reflectMission: 'Test mission for embed integration tests.' }),
client.createBank(bankId, { reflectMission: "Test mission for embed integration tests." })
).resolves.toBeDefined();
}, 60_000);
it('should retain and recall against the local daemon', async () => {
it("should retain and recall against the local daemon", async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
await client.retain(
bankId,
'[role: user]\nMy cat is named Whiskers and she is 3 years old.\n[user:end]\n\n' +
'[role: assistant]\nWhat a lovely name!\n[assistant:end]',
{ documentId: `embed-e2e-${Date.now()}`, async: true },
"[role: user]\nMy cat is named Whiskers and she is 3 years old.\n[user:end]\n\n" +
"[role: assistant]\nWhat a lovely name!\n[assistant:end]",
{ documentId: `embed-e2e-${Date.now()}`, async: true }
);
const response = await client.recall(bankId, "What is my cat's name?", { maxTokens: 1024 });
expect(response).toBeDefined();
@@ -1,9 +1,9 @@
import { defineConfig } from 'vitest/config';
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
environment: "node",
include: ["src/**/*.test.ts"],
},
});
@@ -1,10 +1,10 @@
import { defineConfig } from 'vitest/config';
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
include: ["tests/**/*.test.ts"],
testTimeout: 120_000,
hookTimeout: 120_000,
reporters: ['verbose'],
reporters: ["verbose"],
},
});
+31 -25
View File
@@ -49,10 +49,13 @@ Or configure inline in `opencode.json`:
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
["@vectorize-io/opencode-hindsight", {
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "your-api-key"
}]
[
"@vectorize-io/opencode-hindsight",
{
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "your-api-key"
}
]
]
}
```
@@ -66,13 +69,16 @@ Pass options directly in `opencode.json`:
```json
{
"plugin": [
["@vectorize-io/opencode-hindsight", {
"hindsightApiUrl": "http://localhost:8888",
"bankId": "my-project",
"autoRecall": true,
"autoRetain": true,
"recallBudget": "mid"
}]
[
"@vectorize-io/opencode-hindsight",
{
"hindsightApiUrl": "http://localhost:8888",
"bankId": "my-project",
"autoRecall": true,
"autoRetain": true,
"recallBudget": "mid"
}
]
]
}
```
@@ -93,20 +99,20 @@ Create `~/.hindsight/opencode.json` for persistent configuration:
### Environment Variables
| Variable | Description | Default |
|---|---|---|
| `HINDSIGHT_API_URL` | Hindsight API base URL | (required) |
| `HINDSIGHT_API_TOKEN` | API key for authentication | (none) |
| `HINDSIGHT_BANK_ID` | Static memory bank ID | `opencode` |
| `HINDSIGHT_AGENT_NAME` | Agent name for dynamic bank IDs | `opencode` |
| `HINDSIGHT_AUTO_RECALL` | Auto-recall on session start | `true` |
| `HINDSIGHT_AUTO_RETAIN` | Auto-retain on session idle | `true` |
| `HINDSIGHT_RETAIN_MODE` | `full-session` or `last-turn` | `full-session` |
| `HINDSIGHT_RECALL_BUDGET` | Recall budget: `low`, `mid`, `high` | `mid` |
| `HINDSIGHT_RECALL_MAX_TOKENS` | Max tokens for recall results | `1024` |
| `HINDSIGHT_DYNAMIC_BANK_ID` | Enable dynamic bank ID derivation | `false` |
| `HINDSIGHT_BANK_MISSION` | Bank mission/context | (none) |
| `HINDSIGHT_DEBUG` | Enable debug logging | `false` |
| Variable | Description | Default |
| ----------------------------- | ----------------------------------- | -------------- |
| `HINDSIGHT_API_URL` | Hindsight API base URL | (required) |
| `HINDSIGHT_API_TOKEN` | API key for authentication | (none) |
| `HINDSIGHT_BANK_ID` | Static memory bank ID | `opencode` |
| `HINDSIGHT_AGENT_NAME` | Agent name for dynamic bank IDs | `opencode` |
| `HINDSIGHT_AUTO_RECALL` | Auto-recall on session start | `true` |
| `HINDSIGHT_AUTO_RETAIN` | Auto-retain on session idle | `true` |
| `HINDSIGHT_RETAIN_MODE` | `full-session` or `last-turn` | `full-session` |
| `HINDSIGHT_RECALL_BUDGET` | Recall budget: `low`, `mid`, `high` | `mid` |
| `HINDSIGHT_RECALL_MAX_TOKENS` | Max tokens for recall results | `1024` |
| `HINDSIGHT_DYNAMIC_BANK_ID` | Enable dynamic bank ID derivation | `false` |
| `HINDSIGHT_BANK_MISSION` | Bank mission/context | (none) |
| `HINDSIGHT_DEBUG` | Enable debug logging | `false` |
### Configuration Priority
+108 -108
View File
@@ -1,139 +1,139 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { deriveBankId, ensureBankMission } from './bank.js';
import { makeConfig } from './test-helpers.js';
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { deriveBankId, ensureBankMission } from "./bank.js";
import { makeConfig } from "./test-helpers.js";
describe('deriveBankId', () => {
const originalEnv = { ...process.env };
describe("deriveBankId", () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
afterEach(() => {
process.env = { ...originalEnv };
});
it("returns default bank name in static mode", () => {
expect(deriveBankId(makeConfig(), "/home/user/project")).toBe("opencode");
});
it("returns configured bankId in static mode", () => {
const config = makeConfig({ bankId: "my-bank" });
expect(deriveBankId(config, "/home/user/project")).toBe("my-bank");
});
it("adds prefix in static mode", () => {
const config = makeConfig({ bankIdPrefix: "dev", bankId: "my-bank" });
expect(deriveBankId(config, "/home/user/project")).toBe("dev-my-bank");
});
it("composes from granularity fields in dynamic mode", () => {
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ["agent", "project"],
agentName: "opencode",
});
expect(deriveBankId(config, "/home/user/my-project")).toBe("opencode::my-project");
});
it('returns default bank name in static mode', () => {
expect(deriveBankId(makeConfig(), '/home/user/project')).toBe('opencode');
it("uses default granularity when not specified", () => {
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: [],
});
expect(deriveBankId(config, "/home/user/proj")).toBe("opencode::proj");
});
it('returns configured bankId in static mode', () => {
const config = makeConfig({ bankId: 'my-bank' });
expect(deriveBankId(config, '/home/user/project')).toBe('my-bank');
it("URL-encodes special characters", () => {
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ["project"],
});
expect(deriveBankId(config, "/home/user/my project")).toBe("my%20project");
});
it('adds prefix in static mode', () => {
const config = makeConfig({ bankIdPrefix: 'dev', bankId: 'my-bank' });
expect(deriveBankId(config, '/home/user/project')).toBe('dev-my-bank');
it("uses channel/user from env vars", () => {
process.env.HINDSIGHT_CHANNEL_ID = "slack-general";
process.env.HINDSIGHT_USER_ID = "user123";
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ["agent", "channel", "user"],
});
expect(deriveBankId(config, "/home/user/proj")).toBe("opencode::slack-general::user123");
});
it('composes from granularity fields in dynamic mode', () => {
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ['agent', 'project'],
agentName: 'opencode',
});
expect(deriveBankId(config, '/home/user/my-project')).toBe('opencode::my-project');
it("uses defaults for missing env vars", () => {
delete process.env.HINDSIGHT_CHANNEL_ID;
delete process.env.HINDSIGHT_USER_ID;
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ["channel", "user"],
});
expect(deriveBankId(config, "/home/user/proj")).toBe("default::anonymous");
});
it('uses default granularity when not specified', () => {
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: [],
});
expect(deriveBankId(config, '/home/user/proj')).toBe('opencode::proj');
});
it('URL-encodes special characters', () => {
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ['project'],
});
expect(deriveBankId(config, '/home/user/my project')).toBe('my%20project');
});
it('uses channel/user from env vars', () => {
process.env.HINDSIGHT_CHANNEL_ID = 'slack-general';
process.env.HINDSIGHT_USER_ID = 'user123';
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ['agent', 'channel', 'user'],
});
expect(deriveBankId(config, '/home/user/proj')).toBe('opencode::slack-general::user123');
});
it('uses defaults for missing env vars', () => {
delete process.env.HINDSIGHT_CHANNEL_ID;
delete process.env.HINDSIGHT_USER_ID;
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ['channel', 'user'],
});
expect(deriveBankId(config, '/home/user/proj')).toBe('default::anonymous');
});
it('adds prefix in dynamic mode', () => {
const config = makeConfig({
dynamicBankId: true,
bankIdPrefix: 'dev',
dynamicBankGranularity: ['agent'],
});
expect(deriveBankId(config, '/home/user/proj')).toBe('dev-opencode');
it("adds prefix in dynamic mode", () => {
const config = makeConfig({
dynamicBankId: true,
bankIdPrefix: "dev",
dynamicBankGranularity: ["agent"],
});
expect(deriveBankId(config, "/home/user/proj")).toBe("dev-opencode");
});
});
describe('ensureBankMission', () => {
it('calls createBank on first use', async () => {
const client = { createBank: vi.fn().mockResolvedValue({}) } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: 'Test mission' });
describe("ensureBankMission", () => {
it("calls createBank on first use", async () => {
const client = { createBank: vi.fn().mockResolvedValue({}) } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: "Test mission" });
await ensureBankMission(client, 'test-bank', config, missionsSet);
await ensureBankMission(client, "test-bank", config, missionsSet);
expect(client.createBank).toHaveBeenCalledWith('test-bank', {
reflectMission: 'Test mission',
retainMission: undefined,
});
expect(missionsSet.has('test-bank')).toBe(true);
expect(client.createBank).toHaveBeenCalledWith("test-bank", {
reflectMission: "Test mission",
retainMission: undefined,
});
expect(missionsSet.has("test-bank")).toBe(true);
});
it('skips if already set', async () => {
const client = { createBank: vi.fn() } as any;
const missionsSet = new Set(['test-bank']);
const config = makeConfig({ bankMission: 'Test mission' });
it("skips if already set", async () => {
const client = { createBank: vi.fn() } as any;
const missionsSet = new Set(["test-bank"]);
const config = makeConfig({ bankMission: "Test mission" });
await ensureBankMission(client, 'test-bank', config, missionsSet);
await ensureBankMission(client, "test-bank", config, missionsSet);
expect(client.createBank).not.toHaveBeenCalled();
});
expect(client.createBank).not.toHaveBeenCalled();
});
it('skips if no mission configured', async () => {
const client = { createBank: vi.fn() } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: '' });
it("skips if no mission configured", async () => {
const client = { createBank: vi.fn() } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: "" });
await ensureBankMission(client, 'test-bank', config, missionsSet);
await ensureBankMission(client, "test-bank", config, missionsSet);
expect(client.createBank).not.toHaveBeenCalled();
});
expect(client.createBank).not.toHaveBeenCalled();
});
it('does not throw on client error', async () => {
const client = { createBank: vi.fn().mockRejectedValue(new Error('Network error')) } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: 'Mission' });
it("does not throw on client error", async () => {
const client = { createBank: vi.fn().mockRejectedValue(new Error("Network error")) } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: "Mission" });
await expect(
ensureBankMission(client, 'test-bank', config, missionsSet),
).resolves.not.toThrow();
expect(missionsSet.has('test-bank')).toBe(false);
});
await expect(
ensureBankMission(client, "test-bank", config, missionsSet)
).resolves.not.toThrow();
expect(missionsSet.has("test-bank")).toBe(false);
});
it('passes retainMission when configured', async () => {
const client = { createBank: vi.fn().mockResolvedValue({}) } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: 'Reflect', retainMission: 'Extract carefully' });
it("passes retainMission when configured", async () => {
const client = { createBank: vi.fn().mockResolvedValue({}) } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: "Reflect", retainMission: "Extract carefully" });
await ensureBankMission(client, 'test-bank', config, missionsSet);
await ensureBankMission(client, "test-bank", config, missionsSet);
expect(client.createBank).toHaveBeenCalledWith('test-bank', {
reflectMission: 'Reflect',
retainMission: 'Extract carefully',
});
expect(client.createBank).toHaveBeenCalledWith("test-bank", {
reflectMission: "Reflect",
retainMission: "Extract carefully",
});
});
});
+58 -58
View File
@@ -8,13 +8,13 @@
* - project derived from working directory basename
*/
import { basename } from 'node:path';
import type { HindsightConfig } from './config.js';
import { debugLog } from './config.js';
import type { HindsightClient } from '@vectorize-io/hindsight-client';
import { basename } from "node:path";
import type { HindsightConfig } from "./config.js";
import { debugLog } from "./config.js";
import type { HindsightClient } from "@vectorize-io/hindsight-client";
const DEFAULT_BANK_NAME = 'opencode';
const VALID_FIELDS = new Set(['agent', 'project', 'channel', 'user']);
const DEFAULT_BANK_NAME = "opencode";
const VALID_FIELDS = new Set(["agent", "project", "channel", "user"]);
/**
* Derive a bank ID from context and config.
@@ -23,40 +23,40 @@ const VALID_FIELDS = new Set(['agent', 'project', 'channel', 'user']);
* Dynamic mode: composes from granularity fields joined by '::'.
*/
export function deriveBankId(config: HindsightConfig, directory: string): string {
const prefix = config.bankIdPrefix;
const prefix = config.bankIdPrefix;
if (!config.dynamicBankId) {
const base = config.bankId || DEFAULT_BANK_NAME;
return prefix ? `${prefix}-${base}` : base;
if (!config.dynamicBankId) {
const base = config.bankId || DEFAULT_BANK_NAME;
return prefix ? `${prefix}-${base}` : base;
}
const fields = config.dynamicBankGranularity?.length
? config.dynamicBankGranularity
: ["agent", "project"];
for (const f of fields) {
if (!VALID_FIELDS.has(f)) {
console.error(
`[Hindsight] Unknown dynamicBankGranularity field "${f}" — ` +
`valid: ${[...VALID_FIELDS].sort().join(", ")}`
);
}
}
const fields = config.dynamicBankGranularity?.length
? config.dynamicBankGranularity
: ['agent', 'project'];
const channelId = process.env.HINDSIGHT_CHANNEL_ID || "";
const userId = process.env.HINDSIGHT_USER_ID || "";
for (const f of fields) {
if (!VALID_FIELDS.has(f)) {
console.error(
`[Hindsight] Unknown dynamicBankGranularity field "${f}" — ` +
`valid: ${[...VALID_FIELDS].sort().join(', ')}`,
);
}
}
const fieldMap: Record<string, string> = {
agent: config.agentName || "opencode",
project: directory ? basename(directory) : "unknown",
channel: channelId || "default",
user: userId || "anonymous",
};
const channelId = process.env.HINDSIGHT_CHANNEL_ID || '';
const userId = process.env.HINDSIGHT_USER_ID || '';
const segments = fields.map((f) => encodeURIComponent(fieldMap[f] || "unknown"));
const baseBankId = segments.join("::");
const fieldMap: Record<string, string> = {
agent: config.agentName || 'opencode',
project: directory ? basename(directory) : 'unknown',
channel: channelId || 'default',
user: userId || 'anonymous',
};
const segments = fields.map((f) => encodeURIComponent(fieldMap[f] || 'unknown'));
const baseBankId = segments.join('::');
return prefix ? `${prefix}-${baseBankId}` : baseBankId;
return prefix ? `${prefix}-${baseBankId}` : baseBankId;
}
/**
@@ -64,31 +64,31 @@ export function deriveBankId(config: HindsightConfig, directory: string): string
* Uses an in-memory Set (plugin is long-lived, unlike Claude Code's ephemeral hooks).
*/
export async function ensureBankMission(
client: HindsightClient,
bankId: string,
config: HindsightConfig,
missionsSet: Set<string>,
client: HindsightClient,
bankId: string,
config: HindsightConfig,
missionsSet: Set<string>
): Promise<void> {
const mission = config.bankMission;
if (!mission?.trim()) return;
if (missionsSet.has(bankId)) return;
const mission = config.bankMission;
if (!mission?.trim()) return;
if (missionsSet.has(bankId)) return;
try {
await client.createBank(bankId, {
reflectMission: mission,
retainMission: config.retainMission || undefined,
});
missionsSet.add(bankId);
// Cap tracked banks
if (missionsSet.size > 10000) {
const keys = [...missionsSet].sort();
for (const k of keys.slice(0, keys.length >> 1)) {
missionsSet.delete(k);
}
}
debugLog(config, `Set mission for bank: ${bankId}`);
} catch (e) {
// Don't fail if mission set fails — bank may not exist yet
debugLog(config, `Could not set bank mission for ${bankId}: ${e}`);
try {
await client.createBank(bankId, {
reflectMission: mission,
retainMission: config.retainMission || undefined,
});
missionsSet.add(bankId);
// Cap tracked banks
if (missionsSet.size > 10000) {
const keys = [...missionsSet].sort();
for (const k of keys.slice(0, keys.length >> 1)) {
missionsSet.delete(k);
}
}
debugLog(config, `Set mission for bank: ${bankId}`);
} catch (e) {
// Don't fail if mission set fails — bank may not exist yet
debugLog(config, `Could not set bank mission for ${bankId}: ${e}`);
}
}
+111 -111
View File
@@ -1,127 +1,127 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { loadConfig, type HindsightConfig } from './config.js';
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { loadConfig, type HindsightConfig } from "./config.js";
describe('loadConfig', () => {
const originalEnv = { ...process.env };
describe("loadConfig", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
// Clear all HINDSIGHT_ env vars
for (const key of Object.keys(process.env)) {
if (key.startsWith('HINDSIGHT_')) {
delete process.env[key];
}
}
beforeEach(() => {
// Clear all HINDSIGHT_ env vars
for (const key of Object.keys(process.env)) {
if (key.startsWith("HINDSIGHT_")) {
delete process.env[key];
}
}
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("returns defaults when no config sources exist", () => {
const config = loadConfig();
expect(config.autoRecall).toBe(true);
expect(config.autoRetain).toBe(true);
expect(config.recallBudget).toBe("mid");
expect(config.recallMaxTokens).toBe(1024);
expect(config.retainContext).toBe("opencode");
expect(config.agentName).toBe("opencode");
expect(config.dynamicBankId).toBe(false);
expect(config.debug).toBe(false);
expect(config.hindsightApiUrl).toBeNull();
expect(config.hindsightApiToken).toBeNull();
expect(config.bankId).toBeNull();
});
it("env vars override defaults", () => {
process.env.HINDSIGHT_API_URL = "https://example.com";
process.env.HINDSIGHT_API_TOKEN = "secret-token";
process.env.HINDSIGHT_BANK_ID = "my-bank";
process.env.HINDSIGHT_AUTO_RECALL = "false";
process.env.HINDSIGHT_AUTO_RETAIN = "0";
process.env.HINDSIGHT_RECALL_MAX_TOKENS = "2048";
process.env.HINDSIGHT_DEBUG = "true";
const config = loadConfig();
expect(config.hindsightApiUrl).toBe("https://example.com");
expect(config.hindsightApiToken).toBe("secret-token");
expect(config.bankId).toBe("my-bank");
expect(config.autoRecall).toBe(false);
expect(config.autoRetain).toBe(false);
expect(config.recallMaxTokens).toBe(2048);
expect(config.debug).toBe(true);
});
it("plugin options override defaults", () => {
const config = loadConfig({
bankId: "plugin-bank",
autoRecall: false,
recallBudget: "high",
});
expect(config.bankId).toBe("plugin-bank");
expect(config.autoRecall).toBe(false);
expect(config.recallBudget).toBe("high");
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("env vars override plugin options", () => {
process.env.HINDSIGHT_BANK_ID = "env-bank";
const config = loadConfig({ bankId: "plugin-bank" });
expect(config.bankId).toBe("env-bank");
});
it('returns defaults when no config sources exist', () => {
const config = loadConfig();
expect(config.autoRecall).toBe(true);
expect(config.autoRetain).toBe(true);
expect(config.recallBudget).toBe('mid');
expect(config.recallMaxTokens).toBe(1024);
expect(config.retainContext).toBe('opencode');
expect(config.agentName).toBe('opencode');
expect(config.dynamicBankId).toBe(false);
expect(config.debug).toBe(false);
expect(config.hindsightApiUrl).toBeNull();
expect(config.hindsightApiToken).toBeNull();
expect(config.bankId).toBeNull();
});
it("boolean env var parsing", () => {
process.env.HINDSIGHT_AUTO_RECALL = "true";
expect(loadConfig().autoRecall).toBe(true);
it('env vars override defaults', () => {
process.env.HINDSIGHT_API_URL = 'https://example.com';
process.env.HINDSIGHT_API_TOKEN = 'secret-token';
process.env.HINDSIGHT_BANK_ID = 'my-bank';
process.env.HINDSIGHT_AUTO_RECALL = 'false';
process.env.HINDSIGHT_AUTO_RETAIN = '0';
process.env.HINDSIGHT_RECALL_MAX_TOKENS = '2048';
process.env.HINDSIGHT_DEBUG = 'true';
process.env.HINDSIGHT_AUTO_RECALL = "1";
expect(loadConfig().autoRecall).toBe(true);
const config = loadConfig();
expect(config.hindsightApiUrl).toBe('https://example.com');
expect(config.hindsightApiToken).toBe('secret-token');
expect(config.bankId).toBe('my-bank');
expect(config.autoRecall).toBe(false);
expect(config.autoRetain).toBe(false);
expect(config.recallMaxTokens).toBe(2048);
expect(config.debug).toBe(true);
});
process.env.HINDSIGHT_AUTO_RECALL = "yes";
expect(loadConfig().autoRecall).toBe(true);
it('plugin options override defaults', () => {
const config = loadConfig({
bankId: 'plugin-bank',
autoRecall: false,
recallBudget: 'high',
});
expect(config.bankId).toBe('plugin-bank');
expect(config.autoRecall).toBe(false);
expect(config.recallBudget).toBe('high');
});
process.env.HINDSIGHT_AUTO_RECALL = "false";
expect(loadConfig().autoRecall).toBe(false);
it('env vars override plugin options', () => {
process.env.HINDSIGHT_BANK_ID = 'env-bank';
const config = loadConfig({ bankId: 'plugin-bank' });
expect(config.bankId).toBe('env-bank');
});
process.env.HINDSIGHT_AUTO_RECALL = "no";
expect(loadConfig().autoRecall).toBe(false);
});
it('boolean env var parsing', () => {
process.env.HINDSIGHT_AUTO_RECALL = 'true';
expect(loadConfig().autoRecall).toBe(true);
it("integer env var parsing", () => {
process.env.HINDSIGHT_RECALL_MAX_TOKENS = "4096";
expect(loadConfig().recallMaxTokens).toBe(4096);
process.env.HINDSIGHT_AUTO_RECALL = '1';
expect(loadConfig().autoRecall).toBe(true);
// Invalid integer keeps default
process.env.HINDSIGHT_RECALL_MAX_TOKENS = "not-a-number";
expect(loadConfig().recallMaxTokens).toBe(1024);
});
process.env.HINDSIGHT_AUTO_RECALL = 'yes';
expect(loadConfig().autoRecall).toBe(true);
it("null plugin options are ignored", () => {
const config = loadConfig({ bankId: null, debug: undefined });
expect(config.bankId).toBeNull(); // stays default null
expect(config.debug).toBe(false); // stays default
});
process.env.HINDSIGHT_AUTO_RECALL = 'false';
expect(loadConfig().autoRecall).toBe(false);
it("invalid retainMode falls back to full-session with warning", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const config = loadConfig({ retainMode: "full_session" });
expect(config.retainMode).toBe("full-session");
expect(spy).toHaveBeenCalledWith(expect.stringContaining("Unknown retainMode"));
spy.mockRestore();
});
process.env.HINDSIGHT_AUTO_RECALL = 'no';
expect(loadConfig().autoRecall).toBe(false);
});
it("invalid recallBudget falls back to mid with warning", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const config = loadConfig({ recallBudget: "maximum" });
expect(config.recallBudget).toBe("mid");
expect(spy).toHaveBeenCalledWith(expect.stringContaining("Unknown recallBudget"));
spy.mockRestore();
});
it('integer env var parsing', () => {
process.env.HINDSIGHT_RECALL_MAX_TOKENS = '4096';
expect(loadConfig().recallMaxTokens).toBe(4096);
// Invalid integer keeps default
process.env.HINDSIGHT_RECALL_MAX_TOKENS = 'not-a-number';
expect(loadConfig().recallMaxTokens).toBe(1024);
});
it('null plugin options are ignored', () => {
const config = loadConfig({ bankId: null, debug: undefined });
expect(config.bankId).toBeNull(); // stays default null
expect(config.debug).toBe(false); // stays default
});
it('invalid retainMode falls back to full-session with warning', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const config = loadConfig({ retainMode: 'full_session' });
expect(config.retainMode).toBe('full-session');
expect(spy).toHaveBeenCalledWith(expect.stringContaining('Unknown retainMode'));
spy.mockRestore();
});
it('invalid recallBudget falls back to mid with warning', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const config = loadConfig({ recallBudget: 'maximum' });
expect(config.recallBudget).toBe('mid');
expect(spy).toHaveBeenCalledWith(expect.stringContaining('Unknown recallBudget'));
spy.mockRestore();
});
it('valid retainMode and recallBudget pass without warning', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const config = loadConfig({ retainMode: 'last-turn', recallBudget: 'high' });
expect(config.retainMode).toBe('last-turn');
expect(config.recallBudget).toBe('high');
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
it("valid retainMode and recallBudget pass without warning", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const config = loadConfig({ retainMode: "last-turn", recallBudget: "high" });
expect(config.retainMode).toBe("last-turn");
expect(config.recallBudget).toBe("high");
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
});
+163 -163
View File
@@ -8,206 +8,206 @@
* 4. Environment variable overrides
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
export interface HindsightConfig {
// Recall
autoRecall: boolean;
recallBudget: string;
recallMaxTokens: number;
recallTypes: string[];
recallContextTurns: number;
recallMaxQueryChars: number;
recallPromptPreamble: string;
recallTags: string[];
recallTagsMatch: 'any' | 'all' | 'any_strict' | 'all_strict';
// Recall
autoRecall: boolean;
recallBudget: string;
recallMaxTokens: number;
recallTypes: string[];
recallContextTurns: number;
recallMaxQueryChars: number;
recallPromptPreamble: string;
recallTags: string[];
recallTagsMatch: "any" | "all" | "any_strict" | "all_strict";
// Retain
autoRetain: boolean;
retainMode: string;
retainEveryNTurns: number;
retainOverlapTurns: number;
retainContext: string;
retainTags: string[];
retainMetadata: Record<string, string>;
// Retain
autoRetain: boolean;
retainMode: string;
retainEveryNTurns: number;
retainOverlapTurns: number;
retainContext: string;
retainTags: string[];
retainMetadata: Record<string, string>;
// Connection
hindsightApiUrl: string | null;
hindsightApiToken: string | null;
// Connection
hindsightApiUrl: string | null;
hindsightApiToken: string | null;
// Bank
bankId: string | null;
bankIdPrefix: string;
dynamicBankId: boolean;
dynamicBankGranularity: string[];
bankMission: string;
retainMission: string | null;
agentName: string;
// Bank
bankId: string | null;
bankIdPrefix: string;
dynamicBankId: boolean;
dynamicBankGranularity: string[];
bankMission: string;
retainMission: string | null;
agentName: string;
// Misc
debug: boolean;
// Misc
debug: boolean;
}
const DEFAULTS: HindsightConfig = {
// Recall
autoRecall: true,
recallBudget: 'mid',
recallMaxTokens: 1024,
recallTypes: ['world', 'experience'],
recallContextTurns: 1,
recallMaxQueryChars: 800,
recallTags: [],
recallTagsMatch: 'any',
recallPromptPreamble:
'Relevant memories from past conversations (prioritize recent when ' +
'conflicting). Only use memories that are directly useful to continue ' +
'this conversation; ignore the rest:',
// Recall
autoRecall: true,
recallBudget: "mid",
recallMaxTokens: 1024,
recallTypes: ["world", "experience"],
recallContextTurns: 1,
recallMaxQueryChars: 800,
recallTags: [],
recallTagsMatch: "any",
recallPromptPreamble:
"Relevant memories from past conversations (prioritize recent when " +
"conflicting). Only use memories that are directly useful to continue " +
"this conversation; ignore the rest:",
// Retain
autoRetain: true,
retainMode: 'full-session',
retainEveryNTurns: 10,
retainOverlapTurns: 2,
retainContext: 'opencode',
retainTags: [],
retainMetadata: {},
// Retain
autoRetain: true,
retainMode: "full-session",
retainEveryNTurns: 10,
retainOverlapTurns: 2,
retainContext: "opencode",
retainTags: [],
retainMetadata: {},
// Connection
hindsightApiUrl: null,
hindsightApiToken: null,
// Connection
hindsightApiUrl: null,
hindsightApiToken: null,
// Bank
bankId: null,
bankIdPrefix: '',
dynamicBankId: false,
dynamicBankGranularity: ['agent', 'project'],
bankMission: '',
retainMission: null,
agentName: 'opencode',
// Bank
bankId: null,
bankIdPrefix: "",
dynamicBankId: false,
dynamicBankGranularity: ["agent", "project"],
bankMission: "",
retainMission: null,
agentName: "opencode",
// Misc
debug: false,
// Misc
debug: false,
};
/** Env var → config key + type mapping */
const ENV_OVERRIDES: Record<string, [keyof HindsightConfig, 'string' | 'bool' | 'int']> = {
HINDSIGHT_API_URL: ['hindsightApiUrl', 'string'],
HINDSIGHT_API_TOKEN: ['hindsightApiToken', 'string'],
HINDSIGHT_BANK_ID: ['bankId', 'string'],
HINDSIGHT_AGENT_NAME: ['agentName', 'string'],
HINDSIGHT_AUTO_RECALL: ['autoRecall', 'bool'],
HINDSIGHT_AUTO_RETAIN: ['autoRetain', 'bool'],
HINDSIGHT_RETAIN_MODE: ['retainMode', 'string'],
HINDSIGHT_RECALL_BUDGET: ['recallBudget', 'string'],
HINDSIGHT_RECALL_MAX_TOKENS: ['recallMaxTokens', 'int'],
HINDSIGHT_RECALL_MAX_QUERY_CHARS: ['recallMaxQueryChars', 'int'],
HINDSIGHT_RECALL_CONTEXT_TURNS: ['recallContextTurns', 'int'],
HINDSIGHT_DYNAMIC_BANK_ID: ['dynamicBankId', 'bool'],
HINDSIGHT_BANK_MISSION: ['bankMission', 'string'],
HINDSIGHT_DEBUG: ['debug', 'bool'],
const ENV_OVERRIDES: Record<string, [keyof HindsightConfig, "string" | "bool" | "int"]> = {
HINDSIGHT_API_URL: ["hindsightApiUrl", "string"],
HINDSIGHT_API_TOKEN: ["hindsightApiToken", "string"],
HINDSIGHT_BANK_ID: ["bankId", "string"],
HINDSIGHT_AGENT_NAME: ["agentName", "string"],
HINDSIGHT_AUTO_RECALL: ["autoRecall", "bool"],
HINDSIGHT_AUTO_RETAIN: ["autoRetain", "bool"],
HINDSIGHT_RETAIN_MODE: ["retainMode", "string"],
HINDSIGHT_RECALL_BUDGET: ["recallBudget", "string"],
HINDSIGHT_RECALL_MAX_TOKENS: ["recallMaxTokens", "int"],
HINDSIGHT_RECALL_MAX_QUERY_CHARS: ["recallMaxQueryChars", "int"],
HINDSIGHT_RECALL_CONTEXT_TURNS: ["recallContextTurns", "int"],
HINDSIGHT_DYNAMIC_BANK_ID: ["dynamicBankId", "bool"],
HINDSIGHT_BANK_MISSION: ["bankMission", "string"],
HINDSIGHT_DEBUG: ["debug", "bool"],
};
function castEnv(value: string, typ: 'string' | 'bool' | 'int'): string | boolean | number | null {
if (typ === 'bool') return ['true', '1', 'yes'].includes(value.toLowerCase());
if (typ === 'int') {
const n = parseInt(value, 10);
return isNaN(n) ? null : n;
}
return value;
function castEnv(value: string, typ: "string" | "bool" | "int"): string | boolean | number | null {
if (typ === "bool") return ["true", "1", "yes"].includes(value.toLowerCase());
if (typ === "int") {
const n = parseInt(value, 10);
return isNaN(n) ? null : n;
}
return value;
}
function loadSettingsFile(path: string): Record<string, unknown> {
try {
const raw = readFileSync(path, 'utf-8');
return JSON.parse(raw);
} catch {
return {};
}
try {
const raw = readFileSync(path, "utf-8");
return JSON.parse(raw);
} catch {
return {};
}
}
export function loadConfig(pluginOptions?: Record<string, unknown>): HindsightConfig {
// 1. Start with defaults
const config: Record<string, unknown> = { ...DEFAULTS };
// 1. Start with defaults
const config: Record<string, unknown> = { ...DEFAULTS };
// 2. User config file (~/.hindsight/opencode.json)
const userConfigPath = join(homedir(), '.hindsight', 'opencode.json');
const fileConfig = loadSettingsFile(userConfigPath);
for (const [key, value] of Object.entries(fileConfig)) {
if (value !== null && value !== undefined) {
config[key] = value;
}
// 2. User config file (~/.hindsight/opencode.json)
const userConfigPath = join(homedir(), ".hindsight", "opencode.json");
const fileConfig = loadSettingsFile(userConfigPath);
for (const [key, value] of Object.entries(fileConfig)) {
if (value !== null && value !== undefined) {
config[key] = value;
}
}
// 3. Plugin options (from opencode.json: ["@vectorize-io/opencode-hindsight", { ... }])
if (pluginOptions) {
for (const [key, value] of Object.entries(pluginOptions)) {
if (value !== null && value !== undefined) {
config[key] = value;
}
}
// 3. Plugin options (from opencode.json: ["@vectorize-io/opencode-hindsight", { ... }])
if (pluginOptions) {
for (const [key, value] of Object.entries(pluginOptions)) {
if (value !== null && value !== undefined) {
config[key] = value;
}
}
}
// 4. Environment variable overrides (highest priority)
for (const [envName, [key, typ]] of Object.entries(ENV_OVERRIDES)) {
const val = process.env[envName];
if (val !== undefined) {
const castVal = castEnv(val, typ);
if (castVal !== null) {
config[key] = castVal;
}
}
// 4. Environment variable overrides (highest priority)
for (const [envName, [key, typ]] of Object.entries(ENV_OVERRIDES)) {
const val = process.env[envName];
if (val !== undefined) {
const castVal = castEnv(val, typ);
if (castVal !== null) {
config[key] = castVal;
}
}
}
// Array env vars (comma-separated)
const recallTagsEnv = process.env['HINDSIGHT_RECALL_TAGS'];
if (recallTagsEnv !== undefined) {
config['recallTags'] = recallTagsEnv
.split(',')
.map((t) => t.trim())
.filter(Boolean);
}
const recallTagsMatchEnv = process.env['HINDSIGHT_RECALL_TAGS_MATCH'];
if (recallTagsMatchEnv !== undefined) {
config['recallTagsMatch'] = recallTagsMatchEnv;
}
// Array env vars (comma-separated)
const recallTagsEnv = process.env["HINDSIGHT_RECALL_TAGS"];
if (recallTagsEnv !== undefined) {
config["recallTags"] = recallTagsEnv
.split(",")
.map((t) => t.trim())
.filter(Boolean);
}
const recallTagsMatchEnv = process.env["HINDSIGHT_RECALL_TAGS_MATCH"];
if (recallTagsMatchEnv !== undefined) {
config["recallTagsMatch"] = recallTagsMatchEnv;
}
const result = config as unknown as HindsightConfig;
const result = config as unknown as HindsightConfig;
// Validate enum-like fields to catch typos early
const VALID_RETAIN_MODES = ['full-session', 'last-turn'];
if (!VALID_RETAIN_MODES.includes(result.retainMode)) {
console.error(
`[Hindsight] Unknown retainMode "${result.retainMode}" — ` +
`valid: ${VALID_RETAIN_MODES.join(', ')}. Falling back to "full-session".`,
);
result.retainMode = 'full-session';
}
// Validate enum-like fields to catch typos early
const VALID_RETAIN_MODES = ["full-session", "last-turn"];
if (!VALID_RETAIN_MODES.includes(result.retainMode)) {
console.error(
`[Hindsight] Unknown retainMode "${result.retainMode}" — ` +
`valid: ${VALID_RETAIN_MODES.join(", ")}. Falling back to "full-session".`
);
result.retainMode = "full-session";
}
const VALID_TAGS_MATCH = ['any', 'all', 'any_strict', 'all_strict'];
if (!VALID_TAGS_MATCH.includes(result.recallTagsMatch)) {
console.error(
`[Hindsight] Unknown recallTagsMatch "${result.recallTagsMatch}" — ` +
`valid: ${VALID_TAGS_MATCH.join(', ')}. Falling back to "any".`,
);
result.recallTagsMatch = 'any';
}
const VALID_TAGS_MATCH = ["any", "all", "any_strict", "all_strict"];
if (!VALID_TAGS_MATCH.includes(result.recallTagsMatch)) {
console.error(
`[Hindsight] Unknown recallTagsMatch "${result.recallTagsMatch}" — ` +
`valid: ${VALID_TAGS_MATCH.join(", ")}. Falling back to "any".`
);
result.recallTagsMatch = "any";
}
const VALID_BUDGETS = ['low', 'mid', 'high'];
if (!VALID_BUDGETS.includes(result.recallBudget)) {
console.error(
`[Hindsight] Unknown recallBudget "${result.recallBudget}" — ` +
`valid: ${VALID_BUDGETS.join(', ')}. Falling back to "mid".`,
);
result.recallBudget = 'mid';
}
const VALID_BUDGETS = ["low", "mid", "high"];
if (!VALID_BUDGETS.includes(result.recallBudget)) {
console.error(
`[Hindsight] Unknown recallBudget "${result.recallBudget}" — ` +
`valid: ${VALID_BUDGETS.join(", ")}. Falling back to "mid".`
);
result.recallBudget = "mid";
}
return result;
return result;
}
export function debugLog(config: HindsightConfig, ...args: unknown[]): void {
if (config.debug) {
console.error('[Hindsight]', ...args);
}
if (config.debug) {
console.error("[Hindsight]", ...args);
}
}
@@ -1,194 +1,195 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect } from "vitest";
import {
stripMemoryTags,
formatMemories,
formatCurrentTime,
composeRecallQuery,
truncateRecallQuery,
sliceLastTurnsByUserBoundary,
prepareRetentionTranscript,
} from './content.js';
stripMemoryTags,
formatMemories,
formatCurrentTime,
composeRecallQuery,
truncateRecallQuery,
sliceLastTurnsByUserBoundary,
prepareRetentionTranscript,
} from "./content.js";
describe('stripMemoryTags', () => {
it('removes <hindsight_memories> blocks', () => {
const input = 'before <hindsight_memories>secret</hindsight_memories> after';
expect(stripMemoryTags(input)).toBe('before after');
});
describe("stripMemoryTags", () => {
it("removes <hindsight_memories> blocks", () => {
const input = "before <hindsight_memories>secret</hindsight_memories> after";
expect(stripMemoryTags(input)).toBe("before after");
});
it('removes <relevant_memories> blocks', () => {
const input = 'before <relevant_memories>\nmultiline\n</relevant_memories> after';
expect(stripMemoryTags(input)).toBe('before after');
});
it("removes <relevant_memories> blocks", () => {
const input = "before <relevant_memories>\nmultiline\n</relevant_memories> after";
expect(stripMemoryTags(input)).toBe("before after");
});
it('removes multiple blocks', () => {
const input = '<hindsight_memories>a</hindsight_memories> middle <relevant_memories>b</relevant_memories>';
expect(stripMemoryTags(input)).toBe(' middle ');
});
it("removes multiple blocks", () => {
const input =
"<hindsight_memories>a</hindsight_memories> middle <relevant_memories>b</relevant_memories>";
expect(stripMemoryTags(input)).toBe(" middle ");
});
it('returns unchanged if no tags', () => {
expect(stripMemoryTags('hello world')).toBe('hello world');
});
it("returns unchanged if no tags", () => {
expect(stripMemoryTags("hello world")).toBe("hello world");
});
});
describe('formatMemories', () => {
it('formats recall results with type and date', () => {
const results = [
{ text: 'User likes Python', type: 'world', mentioned_at: '2025-01-01' },
{ text: 'Met at conference', type: 'experience', mentioned_at: '2025-03-15' },
];
const formatted = formatMemories(results);
expect(formatted).toContain('- User likes Python [world] (2025-01-01)');
expect(formatted).toContain('- Met at conference [experience] (2025-03-15)');
});
it('handles missing type and date', () => {
const results = [{ text: 'Some fact' }];
expect(formatMemories(results)).toBe('- Some fact');
});
it('returns empty string for empty array', () => {
expect(formatMemories([])).toBe('');
});
it('separates entries with double newlines', () => {
const results = [{ text: 'A' }, { text: 'B' }];
expect(formatMemories(results)).toBe('- A\n\n- B');
});
});
describe('formatCurrentTime', () => {
it('returns UTC time in YYYY-MM-DD HH:MM format', () => {
const time = formatCurrentTime();
expect(time).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
});
});
describe('composeRecallQuery', () => {
const messages = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
{ role: 'user', content: 'What is my name?' },
describe("formatMemories", () => {
it("formats recall results with type and date", () => {
const results = [
{ text: "User likes Python", type: "world", mentioned_at: "2025-01-01" },
{ text: "Met at conference", type: "experience", mentioned_at: "2025-03-15" },
];
const formatted = formatMemories(results);
expect(formatted).toContain("- User likes Python [world] (2025-01-01)");
expect(formatted).toContain("- Met at conference [experience] (2025-03-15)");
});
it('returns latest query when contextTurns <= 1', () => {
expect(composeRecallQuery('What is my name?', messages, 1)).toBe('What is my name?');
});
it("handles missing type and date", () => {
const results = [{ text: "Some fact" }];
expect(formatMemories(results)).toBe("- Some fact");
});
it('returns latest query when messages empty', () => {
expect(composeRecallQuery('query', [], 3)).toBe('query');
});
it("returns empty string for empty array", () => {
expect(formatMemories([])).toBe("");
});
it('includes prior context when contextTurns > 1', () => {
const result = composeRecallQuery('What is my name?', messages, 3);
expect(result).toContain('Prior context:');
expect(result).toContain('user: Hello');
expect(result).toContain('assistant: Hi there');
expect(result).toContain('What is my name?');
});
it('does not duplicate latest query in context', () => {
const result = composeRecallQuery('What is my name?', messages, 3);
// "What is my name?" should appear once at the end, not also as "user: What is my name?"
const matches = result.match(/What is my name\?/g);
expect(matches?.length).toBe(1);
});
it("separates entries with double newlines", () => {
const results = [{ text: "A" }, { text: "B" }];
expect(formatMemories(results)).toBe("- A\n\n- B");
});
});
describe('truncateRecallQuery', () => {
it('returns query unchanged if within limit', () => {
expect(truncateRecallQuery('short', 'short', 100)).toBe('short');
});
it('truncates to latest when no prior context', () => {
const latest = 'my query';
expect(truncateRecallQuery(latest, latest, 5)).toBe('my qu');
});
it('drops oldest context lines first', () => {
const query = 'Prior context:\n\nuser: old\nassistant: older\nuser: recent\n\nlatest';
const result = truncateRecallQuery(query, 'latest', 50);
expect(result).toContain('latest');
// Should have dropped some old context
expect(result.length).toBeLessThanOrEqual(50);
});
describe("formatCurrentTime", () => {
it("returns UTC time in YYYY-MM-DD HH:MM format", () => {
const time = formatCurrentTime();
expect(time).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
});
});
describe('sliceLastTurnsByUserBoundary', () => {
const messages = [
{ role: 'user', content: 'A' },
{ role: 'assistant', content: 'B' },
{ role: 'user', content: 'C' },
{ role: 'assistant', content: 'D' },
{ role: 'user', content: 'E' },
describe("composeRecallQuery", () => {
const messages = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there" },
{ role: "user", content: "What is my name?" },
];
it("returns latest query when contextTurns <= 1", () => {
expect(composeRecallQuery("What is my name?", messages, 1)).toBe("What is my name?");
});
it("returns latest query when messages empty", () => {
expect(composeRecallQuery("query", [], 3)).toBe("query");
});
it("includes prior context when contextTurns > 1", () => {
const result = composeRecallQuery("What is my name?", messages, 3);
expect(result).toContain("Prior context:");
expect(result).toContain("user: Hello");
expect(result).toContain("assistant: Hi there");
expect(result).toContain("What is my name?");
});
it("does not duplicate latest query in context", () => {
const result = composeRecallQuery("What is my name?", messages, 3);
// "What is my name?" should appear once at the end, not also as "user: What is my name?"
const matches = result.match(/What is my name\?/g);
expect(matches?.length).toBe(1);
});
});
describe("truncateRecallQuery", () => {
it("returns query unchanged if within limit", () => {
expect(truncateRecallQuery("short", "short", 100)).toBe("short");
});
it("truncates to latest when no prior context", () => {
const latest = "my query";
expect(truncateRecallQuery(latest, latest, 5)).toBe("my qu");
});
it("drops oldest context lines first", () => {
const query = "Prior context:\n\nuser: old\nassistant: older\nuser: recent\n\nlatest";
const result = truncateRecallQuery(query, "latest", 50);
expect(result).toContain("latest");
// Should have dropped some old context
expect(result.length).toBeLessThanOrEqual(50);
});
});
describe("sliceLastTurnsByUserBoundary", () => {
const messages = [
{ role: "user", content: "A" },
{ role: "assistant", content: "B" },
{ role: "user", content: "C" },
{ role: "assistant", content: "D" },
{ role: "user", content: "E" },
];
it("returns last N turns", () => {
const result = sliceLastTurnsByUserBoundary(messages, 2);
expect(result.length).toBe(3); // user:C, assistant:D, user:E
expect(result[0].content).toBe("C");
});
it("returns all messages if turns > available", () => {
const result = sliceLastTurnsByUserBoundary(messages, 10);
expect(result.length).toBe(5);
});
it("returns empty for zero turns", () => {
expect(sliceLastTurnsByUserBoundary(messages, 0)).toEqual([]);
});
it("returns empty for empty messages", () => {
expect(sliceLastTurnsByUserBoundary([], 2)).toEqual([]);
});
});
describe("prepareRetentionTranscript", () => {
const messages = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there" },
{ role: "user", content: "How are you?" },
{ role: "assistant", content: "I am doing well" },
];
it("retains last turn by default", () => {
const { transcript, messageCount } = prepareRetentionTranscript(messages);
expect(messageCount).toBe(2);
expect(transcript).toContain("[role: user]");
expect(transcript).toContain("How are you?");
expect(transcript).toContain("I am doing well");
expect(transcript).not.toContain("Hello");
});
it("retains full window when requested", () => {
const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
expect(messageCount).toBe(4);
expect(transcript).toContain("Hello");
expect(transcript).toContain("How are you?");
});
it("returns null for empty messages", () => {
const { transcript, messageCount } = prepareRetentionTranscript([]);
expect(transcript).toBeNull();
expect(messageCount).toBe(0);
});
it("strips memory tags from content", () => {
const msgs = [
{ role: "user", content: "Query <hindsight_memories>data</hindsight_memories>" },
{ role: "assistant", content: "Response" },
];
const { transcript } = prepareRetentionTranscript(msgs);
expect(transcript).not.toContain("hindsight_memories");
expect(transcript).toContain("Query");
});
it('returns last N turns', () => {
const result = sliceLastTurnsByUserBoundary(messages, 2);
expect(result.length).toBe(3); // user:C, assistant:D, user:E
expect(result[0].content).toBe('C');
});
it('returns all messages if turns > available', () => {
const result = sliceLastTurnsByUserBoundary(messages, 10);
expect(result.length).toBe(5);
});
it('returns empty for zero turns', () => {
expect(sliceLastTurnsByUserBoundary(messages, 0)).toEqual([]);
});
it('returns empty for empty messages', () => {
expect(sliceLastTurnsByUserBoundary([], 2)).toEqual([]);
});
});
describe('prepareRetentionTranscript', () => {
const messages = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
{ role: 'user', content: 'How are you?' },
{ role: 'assistant', content: 'I am doing well' },
it("skips messages with empty content after stripping", () => {
const msgs = [
{ role: "user", content: "<hindsight_memories>only tags</hindsight_memories>" },
{ role: "assistant", content: "Response" },
];
it('retains last turn by default', () => {
const { transcript, messageCount } = prepareRetentionTranscript(messages);
expect(messageCount).toBe(2);
expect(transcript).toContain('[role: user]');
expect(transcript).toContain('How are you?');
expect(transcript).toContain('I am doing well');
expect(transcript).not.toContain('Hello');
});
it('retains full window when requested', () => {
const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
expect(messageCount).toBe(4);
expect(transcript).toContain('Hello');
expect(transcript).toContain('How are you?');
});
it('returns null for empty messages', () => {
const { transcript, messageCount } = prepareRetentionTranscript([]);
expect(transcript).toBeNull();
expect(messageCount).toBe(0);
});
it('strips memory tags from content', () => {
const msgs = [
{ role: 'user', content: 'Query <hindsight_memories>data</hindsight_memories>' },
{ role: 'assistant', content: 'Response' },
];
const { transcript } = prepareRetentionTranscript(msgs);
expect(transcript).not.toContain('hindsight_memories');
expect(transcript).toContain('Query');
});
it('skips messages with empty content after stripping', () => {
const msgs = [
{ role: 'user', content: '<hindsight_memories>only tags</hindsight_memories>' },
{ role: 'assistant', content: 'Response' },
];
const { transcript, messageCount } = prepareRetentionTranscript(msgs, true);
expect(messageCount).toBe(1); // only assistant message
expect(transcript).toContain('Response');
});
const { transcript, messageCount } = prepareRetentionTranscript(msgs, true);
expect(messageCount).toBe(1); // only assistant message
expect(transcript).toContain("Response");
});
});
+100 -100
View File
@@ -10,43 +10,43 @@
/** Strip <hindsight_memories> and <relevant_memories> blocks to prevent retain feedback loops. */
export function stripMemoryTags(content: string): string {
content = content.replace(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, '');
content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
return content;
content = content.replace(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, "");
content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, "");
return content;
}
export interface RecallResult {
text: string;
type?: string | null;
mentioned_at?: string | null;
text: string;
type?: string | null;
mentioned_at?: string | null;
}
/** Format recall results into human-readable text for context injection. */
export function formatMemories(results: RecallResult[]): string {
if (!results.length) return '';
return results
.map((r) => {
const typeStr = r.type ? ` [${r.type}]` : '';
const dateStr = r.mentioned_at ? ` (${r.mentioned_at})` : '';
return `- ${r.text}${typeStr}${dateStr}`;
})
.join('\n\n');
if (!results.length) return "";
return results
.map((r) => {
const typeStr = r.type ? ` [${r.type}]` : "";
const dateStr = r.mentioned_at ? ` (${r.mentioned_at})` : "";
return `- ${r.text}${typeStr}${dateStr}`;
})
.join("\n\n");
}
/** Format current UTC time for recall context. */
export function formatCurrentTime(): string {
const now = new Date();
const y = now.getUTCFullYear();
const m = String(now.getUTCMonth() + 1).padStart(2, '0');
const d = String(now.getUTCDate()).padStart(2, '0');
const h = String(now.getUTCHours()).padStart(2, '0');
const min = String(now.getUTCMinutes()).padStart(2, '0');
return `${y}-${m}-${d} ${h}:${min}`;
const now = new Date();
const y = now.getUTCFullYear();
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
const d = String(now.getUTCDate()).padStart(2, "0");
const h = String(now.getUTCHours()).padStart(2, "0");
const min = String(now.getUTCMinutes()).padStart(2, "0");
return `${y}-${m}-${d} ${h}:${min}`;
}
export interface Message {
role: string;
content: string;
role: string;
content: string;
}
/**
@@ -55,26 +55,26 @@ export interface Message {
* When recallContextTurns > 1, includes prior context above the latest query.
*/
export function composeRecallQuery(
latestQuery: string,
messages: Message[],
recallContextTurns: number,
latestQuery: string,
messages: Message[],
recallContextTurns: number
): string {
const latest = latestQuery.trim();
if (recallContextTurns <= 1 || !messages.length) return latest;
const latest = latestQuery.trim();
if (recallContextTurns <= 1 || !messages.length) return latest;
const contextual = sliceLastTurnsByUserBoundary(messages, recallContextTurns);
const contextLines: string[] = [];
const contextual = sliceLastTurnsByUserBoundary(messages, recallContextTurns);
const contextLines: string[] = [];
for (const msg of contextual) {
const content = stripMemoryTags(msg.content).trim();
if (!content) continue;
if (msg.role === 'user' && content === latest) continue;
contextLines.push(`${msg.role}: ${content}`);
}
for (const msg of contextual) {
const content = stripMemoryTags(msg.content).trim();
if (!content) continue;
if (msg.role === "user" && content === latest) continue;
contextLines.push(`${msg.role}: ${content}`);
}
if (!contextLines.length) return latest;
if (!contextLines.length) return latest;
return ['Prior context:', contextLines.join('\n'), latest].join('\n\n');
return ["Prior context:", contextLines.join("\n"), latest].join("\n\n");
}
/**
@@ -82,57 +82,57 @@ export function composeRecallQuery(
* Preserves the latest user message, drops oldest context lines first.
*/
export function truncateRecallQuery(query: string, latestQuery: string, maxChars: number): string {
if (maxChars <= 0 || query.length <= maxChars) return query;
if (maxChars <= 0 || query.length <= maxChars) return query;
const latest = latestQuery.trim();
const latestOnly = latest.length > maxChars ? latest.slice(0, maxChars) : latest;
const latest = latestQuery.trim();
const latestOnly = latest.length > maxChars ? latest.slice(0, maxChars) : latest;
if (!query.includes('Prior context:')) return latestOnly;
if (!query.includes("Prior context:")) return latestOnly;
const contextMarker = 'Prior context:\n\n';
const markerIndex = query.indexOf(contextMarker);
if (markerIndex === -1) return latestOnly;
const contextMarker = "Prior context:\n\n";
const markerIndex = query.indexOf(contextMarker);
if (markerIndex === -1) return latestOnly;
const suffix = '\n\n' + latest;
const suffixIndex = query.lastIndexOf(suffix);
if (suffixIndex === -1) return latestOnly;
if (suffix.length >= maxChars) return latestOnly;
const suffix = "\n\n" + latest;
const suffixIndex = query.lastIndexOf(suffix);
if (suffixIndex === -1) return latestOnly;
if (suffix.length >= maxChars) return latestOnly;
const contextBody = query.slice(markerIndex + contextMarker.length, suffixIndex);
const contextLines = contextBody.split('\n').filter(Boolean);
const contextBody = query.slice(markerIndex + contextMarker.length, suffixIndex);
const contextLines = contextBody.split("\n").filter(Boolean);
const kept: string[] = [];
for (let i = contextLines.length - 1; i >= 0; i--) {
kept.unshift(contextLines[i]);
const candidate = `${contextMarker}${kept.join('\n')}${suffix}`;
if (candidate.length > maxChars) {
kept.shift();
break;
}
const kept: string[] = [];
for (let i = contextLines.length - 1; i >= 0; i--) {
kept.unshift(contextLines[i]);
const candidate = `${contextMarker}${kept.join("\n")}${suffix}`;
if (candidate.length > maxChars) {
kept.shift();
break;
}
}
if (kept.length) return `${contextMarker}${kept.join('\n')}${suffix}`;
return latestOnly;
if (kept.length) return `${contextMarker}${kept.join("\n")}${suffix}`;
return latestOnly;
}
/** Slice messages to the last N turns, where a turn starts at a user message. */
export function sliceLastTurnsByUserBoundary(messages: Message[], turns: number): Message[] {
if (!messages.length || turns <= 0) return [];
if (!messages.length || turns <= 0) return [];
let userTurnsSeen = 0;
let startIndex = -1;
let userTurnsSeen = 0;
let startIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
userTurnsSeen++;
if (userTurnsSeen >= turns) {
startIndex = i;
break;
}
}
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") {
userTurnsSeen++;
if (userTurnsSeen >= turns) {
startIndex = i;
break;
}
}
}
return startIndex === -1 ? [...messages] : messages.slice(startIndex);
return startIndex === -1 ? [...messages] : messages.slice(startIndex);
}
/**
@@ -141,38 +141,38 @@ export function sliceLastTurnsByUserBoundary(messages: Message[], turns: number)
* Uses [role: ...]...[role:end] markers for structured retention.
*/
export function prepareRetentionTranscript(
messages: Message[],
retainFullWindow: boolean = false,
messages: Message[],
retainFullWindow: boolean = false
): { transcript: string | null; messageCount: number } {
if (!messages.length) return { transcript: null, messageCount: 0 };
if (!messages.length) return { transcript: null, messageCount: 0 };
let targetMessages: Message[];
if (retainFullWindow) {
targetMessages = messages;
} else {
// Default: retain only the last turn
let lastUserIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
lastUserIdx = i;
break;
}
}
if (lastUserIdx === -1) return { transcript: null, messageCount: 0 };
targetMessages = messages.slice(lastUserIdx);
let targetMessages: Message[];
if (retainFullWindow) {
targetMessages = messages;
} else {
// Default: retain only the last turn
let lastUserIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") {
lastUserIdx = i;
break;
}
}
if (lastUserIdx === -1) return { transcript: null, messageCount: 0 };
targetMessages = messages.slice(lastUserIdx);
}
const parts: string[] = [];
for (const msg of targetMessages) {
const content = stripMemoryTags(msg.content).trim();
if (!content) continue;
parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
}
const parts: string[] = [];
for (const msg of targetMessages) {
const content = stripMemoryTags(msg.content).trim();
if (!content) continue;
parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
}
if (!parts.length) return { transcript: null, messageCount: 0 };
if (!parts.length) return { transcript: null, messageCount: 0 };
const transcript = parts.join('\n\n');
if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
const transcript = parts.join("\n\n");
if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
return { transcript, messageCount: parts.length };
return { transcript, messageCount: parts.length };
}
+339 -314
View File
@@ -1,376 +1,401 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createHooks, type PluginState } from './hooks.js';
import { makeConfig } from './test-helpers.js';
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createHooks, type PluginState } from "./hooks.js";
import { makeConfig } from "./test-helpers.js";
function makeState(): PluginState {
return {
turnCount: 0,
missionsSet: new Set(),
recalledSessions: new Set(),
lastRetainedTurn: new Map(),
};
return {
turnCount: 0,
missionsSet: new Set(),
recalledSessions: new Set(),
lastRetainedTurn: new Map(),
};
}
function makeClient() {
return {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn().mockResolvedValue({ results: [] }),
reflect: vi.fn().mockResolvedValue({ text: '' }),
createBank: vi.fn().mockResolvedValue({}),
} as any;
return {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn().mockResolvedValue({ results: [] }),
reflect: vi.fn().mockResolvedValue({ text: "" }),
createBank: vi.fn().mockResolvedValue({}),
} as any;
}
function makeOpencodeClient(messages: Array<{ info: { role: string }; parts: Array<{ type: string; text?: string }> }> = []) {
return {
session: {
messages: vi.fn().mockResolvedValue({ data: messages }),
},
};
function makeOpencodeClient(
messages: Array<{ info: { role: string }; parts: Array<{ type: string; text?: string }> }> = []
) {
return {
session: {
messages: vi.fn().mockResolvedValue({ data: messages }),
},
};
}
describe('createHooks', () => {
it('returns all required hooks', () => {
const hooks = createHooks(makeClient(), 'bank', makeConfig(), makeState(), makeOpencodeClient());
expect(hooks.event).toBeDefined();
expect(hooks['experimental.session.compacting']).toBeDefined();
expect(hooks['experimental.chat.system.transform']).toBeDefined();
});
describe("createHooks", () => {
it("returns all required hooks", () => {
const hooks = createHooks(
makeClient(),
"bank",
makeConfig(),
makeState(),
makeOpencodeClient()
);
expect(hooks.event).toBeDefined();
expect(hooks["experimental.session.compacting"]).toBeDefined();
expect(hooks["experimental.chat.system.transform"]).toBeDefined();
});
});
describe('event hook — session.idle', () => {
it('auto-retains conversation on session.idle with document_id', async () => {
const client = makeClient();
const messages = [
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'Hello' }] },
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Hi there' }] },
];
const opencodeClient = makeOpencodeClient(messages);
const state = makeState();
const hooks = createHooks(client, 'bank', makeConfig({ retainEveryNTurns: 1 }), state, opencodeClient);
describe("event hook — session.idle", () => {
it("auto-retains conversation on session.idle with document_id", async () => {
const client = makeClient();
const messages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Hello" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Hi there" }] },
];
const opencodeClient = makeOpencodeClient(messages);
const state = makeState();
const hooks = createHooks(
client,
"bank",
makeConfig({ retainEveryNTurns: 1 }),
state,
opencodeClient
);
await hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
});
expect(client.retain).toHaveBeenCalledTimes(1);
expect(client.retain.mock.calls[0][0]).toBe('bank');
// Full-session mode uses session ID as document_id
const opts = client.retain.mock.calls[0][2];
expect(opts.documentId).toBe('sess-1');
expect(opts.metadata.session_id).toBe('sess-1');
await hooks.event({
event: { type: "session.idle", properties: { sessionID: "sess-1" } },
});
it('skips retain when autoRetain is false', async () => {
const client = makeClient();
const messages = [
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'Hello' }] },
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Hi' }] },
];
const hooks = createHooks(
client,
'bank',
makeConfig({ autoRetain: false }),
makeState(),
makeOpencodeClient(messages),
);
expect(client.retain).toHaveBeenCalledTimes(1);
expect(client.retain.mock.calls[0][0]).toBe("bank");
// Full-session mode uses session ID as document_id
const opts = client.retain.mock.calls[0][2];
expect(opts.documentId).toBe("sess-1");
expect(opts.metadata.session_id).toBe("sess-1");
});
await hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
});
it("skips retain when autoRetain is false", async () => {
const client = makeClient();
const messages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Hello" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Hi" }] },
];
const hooks = createHooks(
client,
"bank",
makeConfig({ autoRetain: false }),
makeState(),
makeOpencodeClient(messages)
);
expect(client.retain).not.toHaveBeenCalled();
await hooks.event({
event: { type: "session.idle", properties: { sessionID: "sess-1" } },
});
it('uses chunked document_id with overlap in last-turn mode', async () => {
const client = makeClient();
const messages = [
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'Turn 1' }] },
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Reply 1' }] },
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'Turn 2' }] },
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Reply 2' }] },
];
const config = makeConfig({ retainMode: 'last-turn', retainEveryNTurns: 1, retainOverlapTurns: 1 });
const state = makeState();
const hooks = createHooks(client, 'bank', config, state, makeOpencodeClient(messages));
expect(client.retain).not.toHaveBeenCalled();
});
await hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
});
it("uses chunked document_id with overlap in last-turn mode", async () => {
const client = makeClient();
const messages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Turn 1" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Reply 1" }] },
{ info: { role: "user" }, parts: [{ type: "text", text: "Turn 2" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Reply 2" }] },
];
const config = makeConfig({
retainMode: "last-turn",
retainEveryNTurns: 1,
retainOverlapTurns: 1,
});
const state = makeState();
const hooks = createHooks(client, "bank", config, state, makeOpencodeClient(messages));
expect(client.retain).toHaveBeenCalledTimes(1);
const opts = client.retain.mock.calls[0][2];
// Chunked mode uses session-timestamp format
expect(opts.documentId).toMatch(/^sess-1-\d+$/);
await hooks.event({
event: { type: "session.idle", properties: { sessionID: "sess-1" } },
});
it('respects retainEveryNTurns', async () => {
const client = makeClient();
const messages = [
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'Hello' }] },
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Hi' }] },
];
const config = makeConfig({ retainEveryNTurns: 5 });
const state = makeState();
const hooks = createHooks(client, 'bank', config, state, makeOpencodeClient(messages));
expect(client.retain).toHaveBeenCalledTimes(1);
const opts = client.retain.mock.calls[0][2];
// Chunked mode uses session-timestamp format
expect(opts.documentId).toMatch(/^sess-1-\d+$/);
});
await hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
});
it("respects retainEveryNTurns", async () => {
const client = makeClient();
const messages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Hello" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Hi" }] },
];
const config = makeConfig({ retainEveryNTurns: 5 });
const state = makeState();
const hooks = createHooks(client, "bank", config, state, makeOpencodeClient(messages));
// Only 1 user turn, needs 5 — should not retain
expect(client.retain).not.toHaveBeenCalled();
await hooks.event({
event: { type: "session.idle", properties: { sessionID: "sess-1" } },
});
it('does not throw on client error', async () => {
const client = makeClient();
client.retain.mockRejectedValue(new Error('Network error'));
const messages = [
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'Hello' }] },
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Hi' }] },
];
const hooks = createHooks(client, 'bank', makeConfig({ retainEveryNTurns: 1 }), makeState(), makeOpencodeClient(messages));
// Only 1 user turn, needs 5 — should not retain
expect(client.retain).not.toHaveBeenCalled();
});
await expect(
hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
}),
).resolves.not.toThrow();
});
it("does not throw on client error", async () => {
const client = makeClient();
client.retain.mockRejectedValue(new Error("Network error"));
const messages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Hello" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Hi" }] },
];
const hooks = createHooks(
client,
"bank",
makeConfig({ retainEveryNTurns: 1 }),
makeState(),
makeOpencodeClient(messages)
);
await expect(
hooks.event({
event: { type: "session.idle", properties: { sessionID: "sess-1" } },
})
).resolves.not.toThrow();
});
});
describe('event hook — session.created', () => {
it('tracks session for recall injection', async () => {
const state = makeState();
const hooks = createHooks(makeClient(), 'bank', makeConfig(), state, makeOpencodeClient());
describe("event hook — session.created", () => {
it("tracks session for recall injection", async () => {
const state = makeState();
const hooks = createHooks(makeClient(), "bank", makeConfig(), state, makeOpencodeClient());
await hooks.event({
event: {
type: 'session.created',
properties: { info: { id: 'sess-1', title: 'Test' } },
},
});
expect(state.recalledSessions.has('sess-1')).toBe(true);
await hooks.event({
event: {
type: "session.created",
properties: { info: { id: "sess-1", title: "Test" } },
},
});
it('does not track when autoRecall is false', async () => {
const state = makeState();
const hooks = createHooks(
makeClient(),
'bank',
makeConfig({ autoRecall: false }),
state,
makeOpencodeClient(),
);
expect(state.recalledSessions.has("sess-1")).toBe(true);
});
await hooks.event({
event: {
type: 'session.created',
properties: { info: { id: 'sess-1' } },
},
});
it("does not track when autoRecall is false", async () => {
const state = makeState();
const hooks = createHooks(
makeClient(),
"bank",
makeConfig({ autoRecall: false }),
state,
makeOpencodeClient()
);
expect(state.recalledSessions.has('sess-1')).toBe(false);
await hooks.event({
event: {
type: "session.created",
properties: { info: { id: "sess-1" } },
},
});
expect(state.recalledSessions.has("sess-1")).toBe(false);
});
});
describe('compacting hook', () => {
it('retains before compaction and recalls context', async () => {
const client = makeClient();
client.recall.mockResolvedValue({
results: [{ text: 'Important fact', type: 'world' }],
});
const messages = [
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'Build the feature' }] },
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working on it' }] },
];
const output = { context: [] as string[], prompt: undefined };
const hooks = createHooks(client, 'bank', makeConfig(), makeState(), makeOpencodeClient(messages));
await hooks['experimental.session.compacting']({ sessionID: 'sess-1' }, output);
// Should have retained and recalled
expect(client.retain).toHaveBeenCalled();
expect(client.recall).toHaveBeenCalled();
expect(output.context.length).toBeGreaterThan(0);
expect(output.context[0]).toContain('hindsight_memories');
expect(output.context[0]).toContain('Important fact');
describe("compacting hook", () => {
it("retains before compaction and recalls context", async () => {
const client = makeClient();
client.recall.mockResolvedValue({
results: [{ text: "Important fact", type: "world" }],
});
const messages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Build the feature" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Working on it" }] },
];
const output = { context: [] as string[], prompt: undefined };
const hooks = createHooks(
client,
"bank",
makeConfig(),
makeState(),
makeOpencodeClient(messages)
);
it('pre-compaction retain includes documentId and session metadata', async () => {
const client = makeClient();
client.recall.mockResolvedValue({ results: [] });
const messages = [
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'Hello' }] },
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Hi' }] },
];
const output = { context: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), makeState(), makeOpencodeClient(messages));
await hooks["experimental.session.compacting"]({ sessionID: "sess-1" }, output);
await hooks['experimental.session.compacting']({ sessionID: 'sess-1' }, output);
// Should have retained and recalled
expect(client.retain).toHaveBeenCalled();
expect(client.recall).toHaveBeenCalled();
expect(output.context.length).toBeGreaterThan(0);
expect(output.context[0]).toContain("hindsight_memories");
expect(output.context[0]).toContain("Important fact");
});
expect(client.retain).toHaveBeenCalledTimes(1);
const opts = client.retain.mock.calls[0][2];
expect(opts.documentId).toBe('sess-1');
expect(opts.metadata.session_id).toBe('sess-1');
});
it("pre-compaction retain includes documentId and session metadata", async () => {
const client = makeClient();
client.recall.mockResolvedValue({ results: [] });
const messages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Hello" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Hi" }] },
];
const output = { context: [] as string[] };
const hooks = createHooks(
client,
"bank",
makeConfig(),
makeState(),
makeOpencodeClient(messages)
);
it('pre-compaction retain uses chunked documentId in last-turn mode', async () => {
const client = makeClient();
client.recall.mockResolvedValue({ results: [] });
const messages = [
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'Hello' }] },
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Hi' }] },
];
const config = makeConfig({ retainMode: 'last-turn', retainEveryNTurns: 1 });
const output = { context: [] as string[] };
const hooks = createHooks(client, 'bank', config, makeState(), makeOpencodeClient(messages));
await hooks["experimental.session.compacting"]({ sessionID: "sess-1" }, output);
await hooks['experimental.session.compacting']({ sessionID: 'sess-1' }, output);
expect(client.retain).toHaveBeenCalledTimes(1);
const opts = client.retain.mock.calls[0][2];
expect(opts.documentId).toBe("sess-1");
expect(opts.metadata.session_id).toBe("sess-1");
});
const opts = client.retain.mock.calls[0][2];
expect(opts.documentId).toMatch(/^sess-1-\d+$/);
});
it("pre-compaction retain uses chunked documentId in last-turn mode", async () => {
const client = makeClient();
client.recall.mockResolvedValue({ results: [] });
const messages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Hello" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Hi" }] },
];
const config = makeConfig({ retainMode: "last-turn", retainEveryNTurns: 1 });
const output = { context: [] as string[] };
const hooks = createHooks(client, "bank", config, makeState(), makeOpencodeClient(messages));
it('resets lastRetainedTurn so idle-retain resumes after compaction', async () => {
const client = makeClient();
client.recall.mockResolvedValue({ results: [] });
const messages = [
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'Hello' }] },
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Hi' }] },
];
const state = makeState();
// Simulate prior retain at turn 10
state.lastRetainedTurn.set('sess-1', 10);
const output = { context: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient(messages));
await hooks["experimental.session.compacting"]({ sessionID: "sess-1" }, output);
await hooks['experimental.session.compacting']({ sessionID: 'sess-1' }, output);
const opts = client.retain.mock.calls[0][2];
expect(opts.documentId).toMatch(/^sess-1-\d+$/);
});
// After compaction, lastRetainedTurn should be cleared so idle-retain works again
expect(state.lastRetainedTurn.has('sess-1')).toBe(false);
});
it("resets lastRetainedTurn so idle-retain resumes after compaction", async () => {
const client = makeClient();
client.recall.mockResolvedValue({ results: [] });
const messages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Hello" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Hi" }] },
];
const state = makeState();
// Simulate prior retain at turn 10
state.lastRetainedTurn.set("sess-1", 10);
const output = { context: [] as string[] };
const hooks = createHooks(client, "bank", makeConfig(), state, makeOpencodeClient(messages));
it('does not throw on error', async () => {
const client = makeClient();
client.recall.mockRejectedValue(new Error('Failed'));
const messages = [
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'Test' }] },
];
const output = { context: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), makeState(), makeOpencodeClient(messages));
await hooks["experimental.session.compacting"]({ sessionID: "sess-1" }, output);
await expect(
hooks['experimental.session.compacting']({ sessionID: 's' }, output),
).resolves.not.toThrow();
});
// After compaction, lastRetainedTurn should be cleared so idle-retain works again
expect(state.lastRetainedTurn.has("sess-1")).toBe(false);
});
it("does not throw on error", async () => {
const client = makeClient();
client.recall.mockRejectedValue(new Error("Failed"));
const messages = [{ info: { role: "user" }, parts: [{ type: "text", text: "Test" }] }];
const output = { context: [] as string[] };
const hooks = createHooks(
client,
"bank",
makeConfig(),
makeState(),
makeOpencodeClient(messages)
);
await expect(
hooks["experimental.session.compacting"]({ sessionID: "s" }, output)
).resolves.not.toThrow();
});
});
describe('system transform hook', () => {
it('injects memories for tracked sessions', async () => {
const client = makeClient();
client.recall.mockResolvedValue({
results: [{ text: 'User is a developer', type: 'world' }],
});
const state = makeState();
state.recalledSessions.add('sess-1');
const output = { system: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient());
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output,
);
expect(output.system.length).toBeGreaterThan(0);
expect(output.system[0]).toContain('hindsight_memories');
// Session should be removed after first injection
expect(state.recalledSessions.has('sess-1')).toBe(false);
describe("system transform hook", () => {
it("injects memories for tracked sessions", async () => {
const client = makeClient();
client.recall.mockResolvedValue({
results: [{ text: "User is a developer", type: "world" }],
});
const state = makeState();
state.recalledSessions.add("sess-1");
const output = { system: [] as string[] };
const hooks = createHooks(client, "bank", makeConfig(), state, makeOpencodeClient());
it('skips untracked sessions', async () => {
const client = makeClient();
const state = makeState();
const output = { system: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient());
await hooks["experimental.chat.system.transform"]({ sessionID: "sess-1", model: {} }, output);
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-unknown', model: {} },
output,
);
expect(output.system.length).toBeGreaterThan(0);
expect(output.system[0]).toContain("hindsight_memories");
// Session should be removed after first injection
expect(state.recalledSessions.has("sess-1")).toBe(false);
});
expect(output.system.length).toBe(0);
expect(client.recall).not.toHaveBeenCalled();
it("skips untracked sessions", async () => {
const client = makeClient();
const state = makeState();
const output = { system: [] as string[] };
const hooks = createHooks(client, "bank", makeConfig(), state, makeOpencodeClient());
await hooks["experimental.chat.system.transform"](
{ sessionID: "sess-unknown", model: {} },
output
);
expect(output.system.length).toBe(0);
expect(client.recall).not.toHaveBeenCalled();
});
it("consumes session on empty recall (no repeated queries for empty banks)", async () => {
const client = makeClient();
// No results — empty bank
client.recall.mockResolvedValue({ results: [] });
const state = makeState();
state.recalledSessions.add("sess-1");
const output = { system: [] as string[] };
const hooks = createHooks(client, "bank", makeConfig(), state, makeOpencodeClient());
await hooks["experimental.chat.system.transform"]({ sessionID: "sess-1", model: {} }, output);
// No injection, but session consumed — won't re-query on next transform
expect(output.system.length).toBe(0);
expect(state.recalledSessions.has("sess-1")).toBe(false);
});
it("retries recall on next transform after transient API failure", async () => {
const client = makeClient();
// First call: API error (transient)
client.recall.mockRejectedValueOnce(new Error("Connection refused"));
// Second call: succeeds
client.recall.mockResolvedValueOnce({
results: [{ text: "Found it", type: "world" }],
});
const state = makeState();
state.recalledSessions.add("sess-1");
const hooks = createHooks(client, "bank", makeConfig(), state, makeOpencodeClient());
it('consumes session on empty recall (no repeated queries for empty banks)', async () => {
const client = makeClient();
// No results — empty bank
client.recall.mockResolvedValue({ results: [] });
const state = makeState();
state.recalledSessions.add('sess-1');
const output = { system: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient());
// First attempt — API error, session preserved for retry
const output1 = { system: [] as string[] };
await hooks["experimental.chat.system.transform"]({ sessionID: "sess-1", model: {} }, output1);
expect(output1.system.length).toBe(0);
expect(state.recalledSessions.has("sess-1")).toBe(true);
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output,
);
// Second attempt — succeeds, session consumed
const output2 = { system: [] as string[] };
await hooks["experimental.chat.system.transform"]({ sessionID: "sess-1", model: {} }, output2);
expect(output2.system.length).toBeGreaterThan(0);
expect(state.recalledSessions.has("sess-1")).toBe(false);
});
// No injection, but session consumed — won't re-query on next transform
expect(output.system.length).toBe(0);
expect(state.recalledSessions.has('sess-1')).toBe(false);
});
it("skips when autoRecall is false", async () => {
const client = makeClient();
const state = makeState();
state.recalledSessions.add("sess-1");
const output = { system: [] as string[] };
const hooks = createHooks(
client,
"bank",
makeConfig({ autoRecall: false }),
state,
makeOpencodeClient()
);
it('retries recall on next transform after transient API failure', async () => {
const client = makeClient();
// First call: API error (transient)
client.recall.mockRejectedValueOnce(new Error('Connection refused'));
// Second call: succeeds
client.recall.mockResolvedValueOnce({
results: [{ text: 'Found it', type: 'world' }],
});
const state = makeState();
state.recalledSessions.add('sess-1');
const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient());
await hooks["experimental.chat.system.transform"]({ sessionID: "sess-1", model: {} }, output);
// First attempt — API error, session preserved for retry
const output1 = { system: [] as string[] };
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output1,
);
expect(output1.system.length).toBe(0);
expect(state.recalledSessions.has('sess-1')).toBe(true);
// Second attempt — succeeds, session consumed
const output2 = { system: [] as string[] };
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output2,
);
expect(output2.system.length).toBeGreaterThan(0);
expect(state.recalledSessions.has('sess-1')).toBe(false);
});
it('skips when autoRecall is false', async () => {
const client = makeClient();
const state = makeState();
state.recalledSessions.add('sess-1');
const output = { system: [] as string[] };
const hooks = createHooks(
client,
'bank',
makeConfig({ autoRecall: false }),
state,
makeOpencodeClient(),
);
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output,
);
expect(output.system.length).toBe(0);
});
expect(output.system.length).toBe(0);
});
});
+273 -272
View File
@@ -7,323 +7,324 @@
* - experimental.session.compacting inject memories into compaction context
*/
import type { HindsightClient } from '@vectorize-io/hindsight-client';
import type { HindsightConfig } from './config.js';
import { debugLog } from './config.js';
import type { HindsightClient } from "@vectorize-io/hindsight-client";
import type { HindsightConfig } from "./config.js";
import { debugLog } from "./config.js";
import {
formatMemories,
formatCurrentTime,
stripMemoryTags,
composeRecallQuery,
truncateRecallQuery,
prepareRetentionTranscript,
sliceLastTurnsByUserBoundary,
type Message,
} from './content.js';
import { ensureBankMission } from './bank.js';
formatMemories,
formatCurrentTime,
stripMemoryTags,
composeRecallQuery,
truncateRecallQuery,
prepareRetentionTranscript,
sliceLastTurnsByUserBoundary,
type Message,
} from "./content.js";
import { ensureBankMission } from "./bank.js";
export interface PluginState {
turnCount: number;
missionsSet: Set<string>;
/** Track sessions we've already injected recall into */
recalledSessions: Set<string>;
/** Track last retained turn count per session to avoid duplicates */
lastRetainedTurn: Map<string, number>;
turnCount: number;
missionsSet: Set<string>;
/** Track sessions we've already injected recall into */
recalledSessions: Set<string>;
/** Track last retained turn count per session to avoid duplicates */
lastRetainedTurn: Map<string, number>;
}
interface EventInput {
event: {
type: string;
properties: Record<string, unknown>;
};
event: {
type: string;
properties: Record<string, unknown>;
};
}
interface CompactingInput {
sessionID: string;
sessionID: string;
}
interface CompactingOutput {
context: string[];
prompt?: string;
context: string[];
prompt?: string;
}
interface SystemTransformInput {
sessionID?: string;
model: unknown;
sessionID?: string;
model: unknown;
}
interface SystemTransformOutput {
system: string[];
system: string[];
}
type OpencodeClient = {
session: {
messages: (params: { path: { id: string } }) => Promise<{
data?: Array<{
info: { role: string };
parts: Array<{ type: string; text?: string }>;
}>;
error?: unknown;
request?: unknown;
response?: unknown;
}>;
};
session: {
messages: (params: { path: { id: string } }) => Promise<{
data?: Array<{
info: { role: string };
parts: Array<{ type: string; text?: string }>;
}>;
error?: unknown;
request?: unknown;
response?: unknown;
}>;
};
};
export interface HindsightHooks {
event: (input: EventInput) => Promise<void>;
'experimental.session.compacting': (
input: CompactingInput,
output: CompactingOutput,
) => Promise<void>;
'experimental.chat.system.transform': (
input: SystemTransformInput,
output: SystemTransformOutput,
) => Promise<void>;
event: (input: EventInput) => Promise<void>;
"experimental.session.compacting": (
input: CompactingInput,
output: CompactingOutput
) => Promise<void>;
"experimental.chat.system.transform": (
input: SystemTransformInput,
output: SystemTransformOutput
) => Promise<void>;
}
export function createHooks(
hindsightClient: HindsightClient,
bankId: string,
config: HindsightConfig,
state: PluginState,
opencodeClient: OpencodeClient,
hindsightClient: HindsightClient,
bankId: string,
config: HindsightConfig,
state: PluginState,
opencodeClient: OpencodeClient
): HindsightHooks {
interface RecallOutcome {
/** formatted context string, or null if no results */
context: string | null;
/** true if the API call succeeded (even with 0 results) */
ok: boolean;
interface RecallOutcome {
/** formatted context string, or null if no results */
context: string | null;
/** true if the API call succeeded (even with 0 results) */
ok: boolean;
}
/** Recall memories and format as context string */
async function recallForContext(query: string): Promise<RecallOutcome> {
try {
const response = await hindsightClient.recall(bankId, query, {
budget: config.recallBudget as "low" | "mid" | "high",
maxTokens: config.recallMaxTokens,
types: config.recallTypes,
tags: config.recallTags.length ? config.recallTags : undefined,
tagsMatch: config.recallTags.length ? config.recallTagsMatch : undefined,
});
const results = response.results || [];
if (!results.length) return { context: null, ok: true };
const formatted = formatMemories(results);
const context =
`<hindsight_memories>\n` +
`${config.recallPromptPreamble}\n` +
`Current time: ${formatCurrentTime()} UTC\n\n` +
`${formatted}\n` +
`</hindsight_memories>`;
return { context, ok: true };
} catch (e) {
debugLog(config, "Recall failed:", e);
return { context: null, ok: false };
}
}
/** Extract plain-text messages from an OpenCode session */
async function getSessionMessages(sessionId: string): Promise<Message[]> {
try {
debugLog(config, `getSessionMessages: fetching messages for session ${sessionId}`);
const response = await opencodeClient.session.messages({
path: { id: sessionId },
});
if (response.error) {
debugLog(
config,
`getSessionMessages: error=${JSON.stringify(response.error)?.substring(0, 500)}`
);
}
const rawMessages = response.data || [];
const messages: Message[] = [];
for (const msg of rawMessages) {
const role = msg.info.role;
if (role !== "user" && role !== "assistant") continue;
const textParts = msg.parts.filter((p) => p.type === "text" && p.text).map((p) => p.text!);
if (textParts.length) {
messages.push({ role, content: textParts.join("\n") });
}
}
debugLog(config, `getSessionMessages: raw=${rawMessages.length}, parsed=${messages.length}`);
return messages;
} catch (e) {
debugLog(config, "Failed to get session messages:", e);
return [];
}
}
/**
* Retain messages for a session, respecting retainMode and documentId semantics.
* Used by both idle-retain and pre-compaction retain.
*/
async function retainSession(sessionId: string, messages: Message[]): Promise<void> {
const retainFullWindow = config.retainMode === "full-session";
let targetMessages: Message[];
let documentId: string;
if (retainFullWindow) {
targetMessages = messages;
// Full-session upserts the same document each time
documentId = sessionId;
} else {
// Sliding window: retainEveryNTurns + overlap
const windowTurns = config.retainEveryNTurns + config.retainOverlapTurns;
targetMessages = sliceLastTurnsByUserBoundary(messages, windowTurns);
// Chunked mode: unique document per chunk
documentId = `${sessionId}-${Date.now()}`;
}
/** Recall memories and format as context string */
async function recallForContext(query: string): Promise<RecallOutcome> {
try {
const response = await hindsightClient.recall(bankId, query, {
budget: config.recallBudget as 'low' | 'mid' | 'high',
maxTokens: config.recallMaxTokens,
types: config.recallTypes,
tags: config.recallTags.length ? config.recallTags : undefined,
tagsMatch: config.recallTags.length ? config.recallTagsMatch : undefined,
});
const { transcript } = prepareRetentionTranscript(targetMessages, true);
if (!transcript) return;
const results = response.results || [];
if (!results.length) return { context: null, ok: true };
await ensureBankMission(hindsightClient, bankId, config, state.missionsSet);
await hindsightClient.retain(bankId, transcript, {
documentId,
context: config.retainContext,
tags: config.retainTags.length ? config.retainTags : undefined,
metadata: Object.keys(config.retainMetadata).length
? { ...config.retainMetadata, session_id: sessionId }
: { session_id: sessionId },
async: true,
});
}
const formatted = formatMemories(results);
const context =
`<hindsight_memories>\n` +
`${config.recallPromptPreamble}\n` +
`Current time: ${formatCurrentTime()} UTC\n\n` +
`${formatted}\n` +
`</hindsight_memories>`;
return { context, ok: true };
} catch (e) {
debugLog(config, 'Recall failed:', e);
return { context: null, ok: false };
}
/** Auto-retain conversation transcript */
async function handleSessionIdle(sessionId: string): Promise<void> {
debugLog(config, `handleSessionIdle called for session ${sessionId}`);
if (!config.autoRetain) return;
const messages = await getSessionMessages(sessionId);
if (!messages.length) return;
// Count user turns
const userTurns = messages.filter((m) => m.role === "user").length;
const lastRetained = state.lastRetainedTurn.get(sessionId) || 0;
debugLog(
config,
`handleSessionIdle: userTurns=${userTurns}, lastRetained=${lastRetained}, retainEveryNTurns=${config.retainEveryNTurns}`
);
// Only retain if enough new turns since last retain
if (userTurns - lastRetained < config.retainEveryNTurns) return;
try {
await retainSession(sessionId, messages);
state.lastRetainedTurn.set(sessionId, userTurns);
debugLog(config, `Auto-retained ${messages.length} messages for session ${sessionId}`);
} catch (e) {
debugLog(config, "Auto-retain failed:", e);
}
}
/** Extract plain-text messages from an OpenCode session */
async function getSessionMessages(sessionId: string): Promise<Message[]> {
try {
debugLog(config, `getSessionMessages: fetching messages for session ${sessionId}`);
const response = await opencodeClient.session.messages({
path: { id: sessionId },
});
if (response.error) {
debugLog(config, `getSessionMessages: error=${JSON.stringify(response.error)?.substring(0, 500)}`);
}
const rawMessages = response.data || [];
const messages: Message[] = [];
for (const msg of rawMessages) {
const role = msg.info.role;
if (role !== 'user' && role !== 'assistant') continue;
const textParts = msg.parts
.filter((p) => p.type === 'text' && p.text)
.map((p) => p.text!);
if (textParts.length) {
messages.push({ role, content: textParts.join('\n') });
}
}
debugLog(config, `getSessionMessages: raw=${rawMessages.length}, parsed=${messages.length}`);
return messages;
} catch (e) {
debugLog(config, 'Failed to get session messages:', e);
return [];
const event = async (input: EventInput): Promise<void> => {
try {
const { event: evt } = input;
debugLog(config, `event hook fired: type=${evt.type}`);
if (evt.type === "session.idle") {
const sessionId = (evt.properties as { sessionID?: string }).sessionID;
if (sessionId) {
await handleSessionIdle(sessionId);
}
}
if (evt.type === "session.created") {
const session = evt.properties.info as { id?: string; title?: string } | undefined;
const sessionId = session?.id;
if (sessionId && config.autoRecall && !state.recalledSessions.has(sessionId)) {
state.recalledSessions.add(sessionId);
// Cap tracked sessions
if (state.recalledSessions.size > 1000) {
const first = state.recalledSessions.values().next().value;
if (first) state.recalledSessions.delete(first);
}
}
}
} catch (e) {
debugLog(config, "Event hook error:", e);
}
};
/**
* Retain messages for a session, respecting retainMode and documentId semantics.
* Used by both idle-retain and pre-compaction retain.
*/
async function retainSession(sessionId: string, messages: Message[]): Promise<void> {
const retainFullWindow = config.retainMode === 'full-session';
let targetMessages: Message[];
let documentId: string;
if (retainFullWindow) {
targetMessages = messages;
// Full-session upserts the same document each time
documentId = sessionId;
} else {
// Sliding window: retainEveryNTurns + overlap
const windowTurns = config.retainEveryNTurns + config.retainOverlapTurns;
targetMessages = sliceLastTurnsByUserBoundary(messages, windowTurns);
// Chunked mode: unique document per chunk
documentId = `${sessionId}-${Date.now()}`;
const compacting = async (input: CompactingInput, output: CompactingOutput): Promise<void> => {
try {
// First, retain what we have before compaction (using shared retention logic)
const messages = await getSessionMessages(input.sessionID);
if (messages.length && config.autoRetain) {
try {
await retainSession(input.sessionID, messages);
// Reset turn tracking — after compaction the message list shrinks,
// so the old lastRetainedTurn value would block future idle retains.
state.lastRetainedTurn.delete(input.sessionID);
debugLog(config, "Pre-compaction retain completed");
} catch (e) {
debugLog(config, "Pre-compaction retain failed:", e);
}
}
const { transcript } = prepareRetentionTranscript(targetMessages, true);
if (!transcript) return;
await ensureBankMission(hindsightClient, bankId, config, state.missionsSet);
await hindsightClient.retain(bankId, transcript, {
documentId,
context: config.retainContext,
tags: config.retainTags.length ? config.retainTags : undefined,
metadata: Object.keys(config.retainMetadata).length
? { ...config.retainMetadata, session_id: sessionId }
: { session_id: sessionId },
async: true,
});
// Then recall relevant memories to inject into compaction context
if (messages.length) {
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
if (lastUserMsg) {
const query = composeRecallQuery(
lastUserMsg.content,
messages,
config.recallContextTurns
);
const truncated = truncateRecallQuery(
query,
lastUserMsg.content,
config.recallMaxQueryChars
);
const { context } = await recallForContext(truncated);
if (context) {
output.context.push(context);
}
}
}
} catch (e) {
debugLog(config, "Compaction hook error:", e);
}
};
/** Auto-retain conversation transcript */
async function handleSessionIdle(sessionId: string): Promise<void> {
debugLog(config, `handleSessionIdle called for session ${sessionId}`);
if (!config.autoRetain) return;
const systemTransform = async (
input: SystemTransformInput,
output: SystemTransformOutput
): Promise<void> => {
try {
if (!config.autoRecall) return;
const sessionId = input.sessionID;
if (!sessionId) return;
const messages = await getSessionMessages(sessionId);
if (!messages.length) return;
// Only inject on first message of a session (tracked by recalledSessions)
if (!state.recalledSessions.has(sessionId)) return;
// Count user turns
const userTurns = messages.filter((m) => m.role === 'user').length;
const lastRetained = state.lastRetainedTurn.get(sessionId) || 0;
debugLog(config, `handleSessionIdle: userTurns=${userTurns}, lastRetained=${lastRetained}, retainEveryNTurns=${config.retainEveryNTurns}`);
await ensureBankMission(hindsightClient, bankId, config, state.missionsSet);
// Only retain if enough new turns since last retain
if (userTurns - lastRetained < config.retainEveryNTurns) return;
// Use a generic project-context query for session start
const query = `project context and recent work`;
const { context, ok } = await recallForContext(query);
try {
await retainSession(sessionId, messages);
state.lastRetainedTurn.set(sessionId, userTurns);
debugLog(config, `Auto-retained ${messages.length} messages for session ${sessionId}`);
} catch (e) {
debugLog(config, 'Auto-retain failed:', e);
}
// Consume after a successful API round-trip (even with 0 results).
// Only preserve retry for transient API failures (ok=false).
if (ok) {
state.recalledSessions.delete(sessionId);
}
if (context) {
output.system.push(context);
debugLog(config, `Injected recall context for session ${sessionId}`);
}
} catch (e) {
debugLog(config, "System transform hook error:", e);
}
};
const event = async (input: EventInput): Promise<void> => {
try {
const { event: evt } = input;
debugLog(config, `event hook fired: type=${evt.type}`);
if (evt.type === 'session.idle') {
const sessionId = (evt.properties as { sessionID?: string }).sessionID;
if (sessionId) {
await handleSessionIdle(sessionId);
}
}
if (evt.type === 'session.created') {
const session = evt.properties.info as { id?: string; title?: string } | undefined;
const sessionId = session?.id;
if (sessionId && config.autoRecall && !state.recalledSessions.has(sessionId)) {
state.recalledSessions.add(sessionId);
// Cap tracked sessions
if (state.recalledSessions.size > 1000) {
const first = state.recalledSessions.values().next().value;
if (first) state.recalledSessions.delete(first);
}
}
}
} catch (e) {
debugLog(config, 'Event hook error:', e);
}
};
const compacting = async (
input: CompactingInput,
output: CompactingOutput,
): Promise<void> => {
try {
// First, retain what we have before compaction (using shared retention logic)
const messages = await getSessionMessages(input.sessionID);
if (messages.length && config.autoRetain) {
try {
await retainSession(input.sessionID, messages);
// Reset turn tracking — after compaction the message list shrinks,
// so the old lastRetainedTurn value would block future idle retains.
state.lastRetainedTurn.delete(input.sessionID);
debugLog(config, 'Pre-compaction retain completed');
} catch (e) {
debugLog(config, 'Pre-compaction retain failed:', e);
}
}
// Then recall relevant memories to inject into compaction context
if (messages.length) {
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
if (lastUserMsg) {
const query = composeRecallQuery(
lastUserMsg.content,
messages,
config.recallContextTurns,
);
const truncated = truncateRecallQuery(
query,
lastUserMsg.content,
config.recallMaxQueryChars,
);
const { context } = await recallForContext(truncated);
if (context) {
output.context.push(context);
}
}
}
} catch (e) {
debugLog(config, 'Compaction hook error:', e);
}
};
const systemTransform = async (
input: SystemTransformInput,
output: SystemTransformOutput,
): Promise<void> => {
try {
if (!config.autoRecall) return;
const sessionId = input.sessionID;
if (!sessionId) return;
// Only inject on first message of a session (tracked by recalledSessions)
if (!state.recalledSessions.has(sessionId)) return;
await ensureBankMission(hindsightClient, bankId, config, state.missionsSet);
// Use a generic project-context query for session start
const query = `project context and recent work`;
const { context, ok } = await recallForContext(query);
// Consume after a successful API round-trip (even with 0 results).
// Only preserve retry for transient API failures (ok=false).
if (ok) {
state.recalledSessions.delete(sessionId);
}
if (context) {
output.system.push(context);
debugLog(config, `Injected recall context for session ${sessionId}`);
}
} catch (e) {
debugLog(config, 'System transform hook error:', e);
}
};
return {
event,
'experimental.session.compacting': compacting,
'experimental.chat.system.transform': systemTransform,
};
return {
event,
"experimental.session.compacting": compacting,
"experimental.chat.system.transform": systemTransform,
};
}
+43 -37
View File
@@ -17,51 +17,57 @@
* ```
*/
import type { Plugin } from '@opencode-ai/plugin';
import { HindsightClient } from '@vectorize-io/hindsight-client';
import { loadConfig } from './config.js';
import { deriveBankId } from './bank.js';
import { createTools } from './tools.js';
import { createHooks, type PluginState } from './hooks.js';
import { debugLog } from './config.js';
import type { Plugin } from "@opencode-ai/plugin";
import { HindsightClient } from "@vectorize-io/hindsight-client";
import { loadConfig } from "./config.js";
import { deriveBankId } from "./bank.js";
import { createTools } from "./tools.js";
import { createHooks, type PluginState } from "./hooks.js";
import { debugLog } from "./config.js";
// Module-level state persists across sessions (plugin is instantiated per session,
// but the module is loaded once per OpenCode server process).
const state: PluginState = {
turnCount: 0,
missionsSet: new Set(),
recalledSessions: new Set(),
lastRetainedTurn: new Map(),
turnCount: 0,
missionsSet: new Set(),
recalledSessions: new Set(),
lastRetainedTurn: new Map(),
};
const HindsightPlugin: Plugin = async (input, options) => {
const config = loadConfig(options);
const config = loadConfig(options);
const apiUrl = config.hindsightApiUrl;
if (!apiUrl) {
console.error(
'[Hindsight] No API URL configured. Set HINDSIGHT_API_URL environment variable ' +
'or add hindsightApiUrl to ~/.hindsight/opencode.json',
);
// Return empty hooks — graceful degradation
return {};
}
const apiUrl = config.hindsightApiUrl;
if (!apiUrl) {
console.error(
"[Hindsight] No API URL configured. Set HINDSIGHT_API_URL environment variable " +
"or add hindsightApiUrl to ~/.hindsight/opencode.json"
);
// Return empty hooks — graceful degradation
return {};
}
const client = new HindsightClient({
baseUrl: apiUrl,
apiKey: config.hindsightApiToken || undefined,
});
const client = new HindsightClient({
baseUrl: apiUrl,
apiKey: config.hindsightApiToken || undefined,
});
const bankId = deriveBankId(config, input.directory);
debugLog(config, `Initialized with bank: ${bankId}, API: ${apiUrl}`);
const bankId = deriveBankId(config, input.directory);
debugLog(config, `Initialized with bank: ${bankId}, API: ${apiUrl}`);
const tools = createTools(client, bankId, config, state.missionsSet);
const hooks = createHooks(client, bankId, config, state, input.client as unknown as Parameters<typeof createHooks>[4]);
const tools = createTools(client, bankId, config, state.missionsSet);
const hooks = createHooks(
client,
bankId,
config,
state,
input.client as unknown as Parameters<typeof createHooks>[4]
);
return {
tool: tools,
...hooks,
};
return {
tool: tools,
...hooks,
};
};
// Named export for direct import
@@ -72,7 +78,7 @@ export { HindsightPlugin };
export default HindsightPlugin;
// Re-export types for consumers
export type { HindsightConfig } from './config.js';
export type { PluginState } from './hooks.js';
export { loadConfig } from './config.js';
export { deriveBankId } from './bank.js';
export type { HindsightConfig } from "./config.js";
export type { PluginState } from "./hooks.js";
export { loadConfig } from "./config.js";
export { deriveBankId } from "./bank.js";
+117 -117
View File
@@ -1,140 +1,140 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock the HindsightClient before importing the plugin
vi.mock('@vectorize-io/hindsight-client', () => {
const MockHindsightClient = vi.fn(function (this: any) {
this.retain = vi.fn().mockResolvedValue({});
this.recall = vi.fn().mockResolvedValue({ results: [] });
this.reflect = vi.fn().mockResolvedValue({ text: '' });
this.createBank = vi.fn().mockResolvedValue({});
});
return { HindsightClient: MockHindsightClient };
vi.mock("@vectorize-io/hindsight-client", () => {
const MockHindsightClient = vi.fn(function (this: any) {
this.retain = vi.fn().mockResolvedValue({});
this.recall = vi.fn().mockResolvedValue({ results: [] });
this.reflect = vi.fn().mockResolvedValue({ text: "" });
this.createBank = vi.fn().mockResolvedValue({});
});
return { HindsightClient: MockHindsightClient };
});
import { HindsightPlugin } from './index.js';
import { HindsightClient } from '@vectorize-io/hindsight-client';
import { HindsightPlugin } from "./index.js";
import { HindsightClient } from "@vectorize-io/hindsight-client";
const mockPluginInput = {
client: {
session: {
messages: vi.fn().mockResolvedValue({ data: [] }),
},
client: {
session: {
messages: vi.fn().mockResolvedValue({ data: [] }),
},
project: { id: 'test-project', worktree: '/tmp/test', vcs: 'git' },
directory: '/tmp/test-project',
worktree: '/tmp/test-project',
serverUrl: new URL('http://localhost:3000'),
$: {} as any,
},
project: { id: "test-project", worktree: "/tmp/test", vcs: "git" },
directory: "/tmp/test-project",
worktree: "/tmp/test-project",
serverUrl: new URL("http://localhost:3000"),
$: {} as any,
};
describe('HindsightPlugin', () => {
const originalEnv = { ...process.env };
describe("HindsightPlugin", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
for (const key of Object.keys(process.env)) {
if (key.startsWith('HINDSIGHT_')) delete process.env[key];
}
vi.clearAllMocks();
beforeEach(() => {
for (const key of Object.keys(process.env)) {
if (key.startsWith("HINDSIGHT_")) delete process.env[key];
}
vi.clearAllMocks();
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("returns empty hooks when no API URL configured", async () => {
const result = await HindsightPlugin(mockPluginInput as any);
expect(result).toEqual({});
expect(HindsightClient).not.toHaveBeenCalled();
});
it("returns tools and hooks when configured", async () => {
process.env.HINDSIGHT_API_URL = "http://localhost:8888";
const result = await HindsightPlugin(mockPluginInput as any);
expect(HindsightClient).toHaveBeenCalledWith({
baseUrl: "http://localhost:8888",
apiKey: undefined,
});
afterEach(() => {
process.env = { ...originalEnv };
expect(result.tool).toBeDefined();
expect(result.tool!.hindsight_retain).toBeDefined();
expect(result.tool!.hindsight_recall).toBeDefined();
expect(result.tool!.hindsight_reflect).toBeDefined();
expect(result.event).toBeDefined();
expect(result["experimental.session.compacting"]).toBeDefined();
expect(result["experimental.chat.system.transform"]).toBeDefined();
});
it("passes API key when configured", async () => {
process.env.HINDSIGHT_API_URL = "http://localhost:8888";
process.env.HINDSIGHT_API_TOKEN = "my-token";
await HindsightPlugin(mockPluginInput as any);
expect(HindsightClient).toHaveBeenCalledWith({
baseUrl: "http://localhost:8888",
apiKey: "my-token",
});
});
it("accepts plugin options", async () => {
const result = await HindsightPlugin(mockPluginInput as any, {
hindsightApiUrl: "http://example.com",
bankId: "custom-bank",
});
it('returns empty hooks when no API URL configured', async () => {
const result = await HindsightPlugin(mockPluginInput as any);
expect(result).toEqual({});
expect(HindsightClient).not.toHaveBeenCalled();
});
it('returns tools and hooks when configured', async () => {
process.env.HINDSIGHT_API_URL = 'http://localhost:8888';
const result = await HindsightPlugin(mockPluginInput as any);
expect(HindsightClient).toHaveBeenCalledWith({
baseUrl: 'http://localhost:8888',
apiKey: undefined,
});
expect(result.tool).toBeDefined();
expect(result.tool!.hindsight_retain).toBeDefined();
expect(result.tool!.hindsight_recall).toBeDefined();
expect(result.tool!.hindsight_reflect).toBeDefined();
expect(result.event).toBeDefined();
expect(result['experimental.session.compacting']).toBeDefined();
expect(result['experimental.chat.system.transform']).toBeDefined();
});
it('passes API key when configured', async () => {
process.env.HINDSIGHT_API_URL = 'http://localhost:8888';
process.env.HINDSIGHT_API_TOKEN = 'my-token';
await HindsightPlugin(mockPluginInput as any);
expect(HindsightClient).toHaveBeenCalledWith({
baseUrl: 'http://localhost:8888',
apiKey: 'my-token',
});
});
it('accepts plugin options', async () => {
const result = await HindsightPlugin(mockPluginInput as any, {
hindsightApiUrl: 'http://example.com',
bankId: 'custom-bank',
});
expect(result.tool).toBeDefined();
expect(HindsightClient).toHaveBeenCalledWith({
baseUrl: 'http://example.com',
apiKey: undefined,
});
expect(result.tool).toBeDefined();
expect(HindsightClient).toHaveBeenCalledWith({
baseUrl: "http://example.com",
apiKey: undefined,
});
});
});
describe('HindsightPlugin state sharing', () => {
beforeEach(() => {
for (const key of Object.keys(process.env)) {
if (key.startsWith('HINDSIGHT_')) delete process.env[key];
}
vi.clearAllMocks();
describe("HindsightPlugin state sharing", () => {
beforeEach(() => {
for (const key of Object.keys(process.env)) {
if (key.startsWith("HINDSIGHT_")) delete process.env[key];
}
vi.clearAllMocks();
});
it("shares state across multiple plugin instantiations (sessions)", async () => {
process.env.HINDSIGHT_API_URL = "http://localhost:8888";
// Simulate two sessions calling the plugin (OpenCode instantiates per session)
const result1 = await HindsightPlugin(mockPluginInput as any);
const result2 = await HindsightPlugin(mockPluginInput as any);
// Trigger session.created on session 1 — should track 'sess-A'
await result1.event!({
event: { type: "session.created", properties: { info: { id: "sess-A" } } },
});
it('shares state across multiple plugin instantiations (sessions)', async () => {
process.env.HINDSIGHT_API_URL = 'http://localhost:8888';
// Session 2's system transform should see 'sess-A' because state is shared
const output = { system: [] as string[] };
await result2["experimental.chat.system.transform"]!(
{ sessionID: "sess-A", model: {} },
output
);
// Simulate two sessions calling the plugin (OpenCode instantiates per session)
const result1 = await HindsightPlugin(mockPluginInput as any);
const result2 = await HindsightPlugin(mockPluginInput as any);
// Trigger session.created on session 1 — should track 'sess-A'
await result1.event!({
event: { type: 'session.created', properties: { info: { id: 'sess-A' } } },
});
// Session 2's system transform should see 'sess-A' because state is shared
const output = { system: [] as string[] };
await result2['experimental.chat.system.transform']!(
{ sessionID: 'sess-A', model: {} },
output,
);
// The recall was attempted (state was shared — sess-A was found in recalledSessions).
// If state were per-instance, result2 would have an empty recalledSessions and skip recall.
// result2 uses the second HindsightClient instance (index 1).
const clientInstance = (HindsightClient as any).mock.instances[1];
expect(clientInstance.recall).toHaveBeenCalled();
});
// The recall was attempted (state was shared — sess-A was found in recalledSessions).
// If state were per-instance, result2 would have an empty recalledSessions and skip recall.
// result2 uses the second HindsightClient instance (index 1).
const clientInstance = (HindsightClient as any).mock.instances[1];
expect(clientInstance.recall).toHaveBeenCalled();
});
});
describe('plugin default export', () => {
it('default-exports the Plugin function itself', async () => {
const mod = await import('./index.js');
expect(typeof mod.default).toBe('function');
// OpenCode iterates Object.entries(mod) and calls every export as a
// Plugin factory, deduping by reference. The default export must be
// the same reference as the named HindsightPlugin export to avoid
// running the factory twice.
expect(mod.default).toBe(mod.HindsightPlugin);
});
describe("plugin default export", () => {
it("default-exports the Plugin function itself", async () => {
const mod = await import("./index.js");
expect(typeof mod.default).toBe("function");
// OpenCode iterates Object.entries(mod) and calls every export as a
// Plugin factory, deduping by reference. The default export must be
// the same reference as the named HindsightPlugin export to avoid
// running the factory twice.
expect(mod.default).toBe(mod.HindsightPlugin);
});
});
@@ -1,33 +1,33 @@
import type { HindsightConfig } from './config.js';
import type { HindsightConfig } from "./config.js";
export function makeConfig(overrides: Partial<HindsightConfig> = {}): HindsightConfig {
return {
autoRecall: true,
recallBudget: 'mid',
recallMaxTokens: 1024,
recallTypes: ['world', 'experience'],
recallContextTurns: 1,
recallMaxQueryChars: 800,
recallPromptPreamble: '',
recallTags: [],
recallTagsMatch: 'any',
autoRetain: true,
retainMode: 'full-session',
retainEveryNTurns: 10,
retainOverlapTurns: 2,
retainContext: 'opencode',
retainTags: [],
retainMetadata: {},
hindsightApiUrl: null,
hindsightApiToken: null,
bankId: null,
bankIdPrefix: '',
dynamicBankId: false,
dynamicBankGranularity: ['agent', 'project'],
bankMission: '',
retainMission: null,
agentName: 'opencode',
debug: false,
...overrides,
};
return {
autoRecall: true,
recallBudget: "mid",
recallMaxTokens: 1024,
recallTypes: ["world", "experience"],
recallContextTurns: 1,
recallMaxQueryChars: 800,
recallPromptPreamble: "",
recallTags: [],
recallTagsMatch: "any",
autoRetain: true,
retainMode: "full-session",
retainEveryNTurns: 10,
retainOverlapTurns: 2,
retainContext: "opencode",
retainTags: [],
retainMetadata: {},
hindsightApiUrl: null,
hindsightApiToken: null,
bankId: null,
bankIdPrefix: "",
dynamicBankId: false,
dynamicBankGranularity: ["agent", "project"],
bankMission: "",
retainMission: null,
agentName: "opencode",
debug: false,
...overrides,
};
}
+281 -279
View File
@@ -1,318 +1,320 @@
import { describe, it, expect, vi } from 'vitest';
import { createTools } from './tools.js';
import { makeConfig } from './test-helpers.js';
import { describe, it, expect, vi } from "vitest";
import { createTools } from "./tools.js";
import { makeConfig } from "./test-helpers.js";
const mockContext = {
sessionID: 'sess-1',
messageID: 'msg-1',
agent: 'default',
directory: '/tmp',
worktree: '/tmp',
abort: new AbortController().signal,
metadata: vi.fn(),
ask: vi.fn(),
sessionID: "sess-1",
messageID: "msg-1",
agent: "default",
directory: "/tmp",
worktree: "/tmp",
abort: new AbortController().signal,
metadata: vi.fn(),
ask: vi.fn(),
};
describe('createTools', () => {
it('creates all three tools', () => {
const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn() } as any;
const tools = createTools(client, 'test-bank', makeConfig());
describe("createTools", () => {
it("creates all three tools", () => {
const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn() } as any;
const tools = createTools(client, "test-bank", makeConfig());
expect(tools.hindsight_retain).toBeDefined();
expect(tools.hindsight_recall).toBeDefined();
expect(tools.hindsight_reflect).toBeDefined();
expect(tools.hindsight_retain).toBeDefined();
expect(tools.hindsight_recall).toBeDefined();
expect(tools.hindsight_reflect).toBeDefined();
});
it("all tools have description and execute", () => {
const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn() } as any;
const tools = createTools(client, "test-bank", makeConfig());
for (const tool of Object.values(tools)) {
expect(tool.description).toBeTruthy();
expect(typeof tool.execute).toBe("function");
}
});
describe("hindsight_retain", () => {
it("calls client.retain with correct bank and content", async () => {
const client = {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn(),
reflect: vi.fn(),
} as any;
const tools = createTools(client, "test-bank", makeConfig());
const result = await tools.hindsight_retain.execute(
{ content: "User likes TypeScript" },
mockContext
);
expect(client.retain).toHaveBeenCalledWith("test-bank", "User likes TypeScript", {
context: "opencode",
tags: undefined,
metadata: undefined,
});
expect(result).toBe("Memory stored successfully.");
});
it('all tools have description and execute', () => {
const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn() } as any;
const tools = createTools(client, 'test-bank', makeConfig());
it("passes optional context", async () => {
const client = {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn(),
reflect: vi.fn(),
} as any;
const tools = createTools(client, "test-bank", makeConfig());
for (const tool of Object.values(tools)) {
expect(tool.description).toBeTruthy();
expect(typeof tool.execute).toBe('function');
}
await tools.hindsight_retain.execute(
{ content: "Fact", context: "from conversation" },
mockContext
);
expect(client.retain).toHaveBeenCalledWith("test-bank", "Fact", {
context: "from conversation",
tags: undefined,
metadata: undefined,
});
});
describe('hindsight_retain', () => {
it('calls client.retain with correct bank and content', async () => {
const client = {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn(),
reflect: vi.fn(),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
it("includes tags and metadata from config", async () => {
const client = {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn(),
reflect: vi.fn(),
} as any;
const config = makeConfig({
retainTags: ["coding"],
retainMetadata: { source: "opencode" },
});
const tools = createTools(client, "test-bank", config);
const result = await tools.hindsight_retain.execute(
{ content: 'User likes TypeScript' },
mockContext,
);
await tools.hindsight_retain.execute({ content: "Fact" }, mockContext);
expect(client.retain).toHaveBeenCalledWith('test-bank', 'User likes TypeScript', {
context: 'opencode',
tags: undefined,
metadata: undefined,
});
expect(result).toBe('Memory stored successfully.');
});
expect(client.retain).toHaveBeenCalledWith("test-bank", "Fact", {
context: "opencode",
tags: ["coding"],
metadata: { source: "opencode" },
});
});
});
it('passes optional context', async () => {
const client = { retain: vi.fn().mockResolvedValue({}), recall: vi.fn(), reflect: vi.fn() } as any;
const tools = createTools(client, 'test-bank', makeConfig());
describe("hindsight_recall", () => {
it("calls client.recall and formats results", async () => {
const client = {
retain: vi.fn(),
recall: vi.fn().mockResolvedValue({
results: [{ text: "User likes Python", type: "world", mentioned_at: "2025-01-01" }],
}),
reflect: vi.fn(),
} as any;
const tools = createTools(client, "test-bank", makeConfig());
await tools.hindsight_retain.execute(
{ content: 'Fact', context: 'from conversation' },
mockContext,
);
const result = await tools.hindsight_recall.execute(
{ query: "user preferences" },
mockContext
);
expect(client.retain).toHaveBeenCalledWith('test-bank', 'Fact', {
context: 'from conversation',
tags: undefined,
metadata: undefined,
});
});
it('includes tags and metadata from config', async () => {
const client = { retain: vi.fn().mockResolvedValue({}), recall: vi.fn(), reflect: vi.fn() } as any;
const config = makeConfig({
retainTags: ['coding'],
retainMetadata: { source: 'opencode' },
});
const tools = createTools(client, 'test-bank', config);
await tools.hindsight_retain.execute({ content: 'Fact' }, mockContext);
expect(client.retain).toHaveBeenCalledWith('test-bank', 'Fact', {
context: 'opencode',
tags: ['coding'],
metadata: { source: 'opencode' },
});
});
expect(client.recall).toHaveBeenCalledWith("test-bank", "user preferences", {
budget: "mid",
maxTokens: 1024,
types: ["world", "experience"],
});
expect(result).toContain("User likes Python");
expect(result).toContain("[world]");
});
describe('hindsight_recall', () => {
it('calls client.recall and formats results', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn().mockResolvedValue({
results: [
{ text: 'User likes Python', type: 'world', mentioned_at: '2025-01-01' },
],
}),
reflect: vi.fn(),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
it("returns no-results message when empty", async () => {
const client = {
retain: vi.fn(),
recall: vi.fn().mockResolvedValue({ results: [] }),
reflect: vi.fn(),
} as any;
const tools = createTools(client, "test-bank", makeConfig());
const result = await tools.hindsight_recall.execute(
{ query: 'user preferences' },
mockContext,
);
expect(client.recall).toHaveBeenCalledWith('test-bank', 'user preferences', {
budget: 'mid',
maxTokens: 1024,
types: ['world', 'experience'],
});
expect(result).toContain('User likes Python');
expect(result).toContain('[world]');
});
it('returns no-results message when empty', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn().mockResolvedValue({ results: [] }),
reflect: vi.fn(),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
const result = await tools.hindsight_recall.execute({ query: 'unknown' }, mockContext);
expect(result).toBe('No relevant memories found.');
});
it('uses config budget settings', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn().mockResolvedValue({ results: [] }),
reflect: vi.fn(),
} as any;
const config = makeConfig({ recallBudget: 'high', recallMaxTokens: 4096 });
const tools = createTools(client, 'test-bank', config);
await tools.hindsight_recall.execute({ query: 'test' }, mockContext);
expect(client.recall).toHaveBeenCalledWith('test-bank', 'test', {
budget: 'high',
maxTokens: 4096,
types: ['world', 'experience'],
});
});
const result = await tools.hindsight_recall.execute({ query: "unknown" }, mockContext);
expect(result).toBe("No relevant memories found.");
});
describe('hindsight_reflect', () => {
it('calls client.reflect and returns text', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockResolvedValue({ text: 'The user is a Python developer.' }),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
it("uses config budget settings", async () => {
const client = {
retain: vi.fn(),
recall: vi.fn().mockResolvedValue({ results: [] }),
reflect: vi.fn(),
} as any;
const config = makeConfig({ recallBudget: "high", recallMaxTokens: 4096 });
const tools = createTools(client, "test-bank", config);
const result = await tools.hindsight_reflect.execute(
{ query: 'What do I know about this user?' },
mockContext,
);
await tools.hindsight_recall.execute({ query: "test" }, mockContext);
expect(client.reflect).toHaveBeenCalledWith(
'test-bank',
'What do I know about this user?',
{ context: undefined, budget: 'mid' },
);
expect(result).toBe('The user is a Python developer.');
});
expect(client.recall).toHaveBeenCalledWith("test-bank", "test", {
budget: "high",
maxTokens: 4096,
types: ["world", "experience"],
});
});
});
it('returns fallback when no text', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockResolvedValue({ text: '' }),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
describe("hindsight_reflect", () => {
it("calls client.reflect and returns text", async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockResolvedValue({ text: "The user is a Python developer." }),
} as any;
const tools = createTools(client, "test-bank", makeConfig());
const result = await tools.hindsight_reflect.execute(
{ query: 'something' },
mockContext,
);
expect(result).toBe('No relevant information found to reflect on.');
});
const result = await tools.hindsight_reflect.execute(
{ query: "What do I know about this user?" },
mockContext
);
it('passes context to reflect', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockResolvedValue({ text: 'Answer' }),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
await tools.hindsight_reflect.execute(
{ query: 'Q', context: 'We are building an app' },
mockContext,
);
expect(client.reflect).toHaveBeenCalledWith('test-bank', 'Q', {
context: 'We are building an app',
budget: 'mid',
});
});
expect(client.reflect).toHaveBeenCalledWith("test-bank", "What do I know about this user?", {
context: undefined,
budget: "mid",
});
expect(result).toBe("The user is a Python developer.");
});
describe('error propagation', () => {
it('propagates retain errors', async () => {
const client = {
retain: vi.fn().mockRejectedValue(new Error('Network error')),
recall: vi.fn(),
reflect: vi.fn(),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
it("returns fallback when no text", async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockResolvedValue({ text: "" }),
} as any;
const tools = createTools(client, "test-bank", makeConfig());
await expect(
tools.hindsight_retain.execute({ content: 'test' }, mockContext),
).rejects.toThrow('Network error');
});
it('propagates recall errors', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn().mockRejectedValue(new Error('Timeout')),
reflect: vi.fn(),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
await expect(
tools.hindsight_recall.execute({ query: 'test' }, mockContext),
).rejects.toThrow('Timeout');
});
it('propagates reflect errors', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockRejectedValue(new Error('Server error')),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
await expect(
tools.hindsight_reflect.execute({ query: 'test' }, mockContext),
).rejects.toThrow('Server error');
});
const result = await tools.hindsight_reflect.execute({ query: "something" }, mockContext);
expect(result).toBe("No relevant information found to reflect on.");
});
describe('bank mission setup', () => {
it('calls ensureBankMission before retain when missionsSet provided', async () => {
const client = {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn(),
reflect: vi.fn(),
createBank: vi.fn().mockResolvedValue({}),
} as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: 'Extract technical decisions' });
const tools = createTools(client, 'test-bank', config, missionsSet);
it("passes context to reflect", async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockResolvedValue({ text: "Answer" }),
} as any;
const tools = createTools(client, "test-bank", makeConfig());
await tools.hindsight_retain.execute({ content: 'fact' }, mockContext);
await tools.hindsight_reflect.execute(
{ query: "Q", context: "We are building an app" },
mockContext
);
expect(client.createBank).toHaveBeenCalledWith('test-bank', {
reflectMission: 'Extract technical decisions',
retainMission: undefined,
});
expect(missionsSet.has('test-bank')).toBe(true);
expect(client.retain).toHaveBeenCalled();
});
expect(client.reflect).toHaveBeenCalledWith("test-bank", "Q", {
context: "We are building an app",
budget: "mid",
});
});
});
it('calls ensureBankMission before reflect when missionsSet provided', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockResolvedValue({ text: 'answer' }),
createBank: vi.fn().mockResolvedValue({}),
} as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: 'Synthesize project context' });
const tools = createTools(client, 'test-bank', config, missionsSet);
describe("error propagation", () => {
it("propagates retain errors", async () => {
const client = {
retain: vi.fn().mockRejectedValue(new Error("Network error")),
recall: vi.fn(),
reflect: vi.fn(),
} as any;
const tools = createTools(client, "test-bank", makeConfig());
await tools.hindsight_reflect.execute({ query: 'summary' }, mockContext);
expect(client.createBank).toHaveBeenCalled();
expect(client.reflect).toHaveBeenCalled();
});
it('skips mission setup when missionsSet not provided (backward compat)', async () => {
const client = {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn(),
reflect: vi.fn(),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
await tools.hindsight_retain.execute({ content: 'fact' }, mockContext);
expect(client.retain).toHaveBeenCalled();
// No createBank call since missionsSet wasn't passed
});
await expect(
tools.hindsight_retain.execute({ content: "test" }, mockContext)
).rejects.toThrow("Network error");
});
it('always uses constructor bankId', async () => {
const client = {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn().mockResolvedValue({ results: [] }),
reflect: vi.fn().mockResolvedValue({ text: 'ok' }),
} as any;
const tools = createTools(client, 'fixed-bank', makeConfig());
it("propagates recall errors", async () => {
const client = {
retain: vi.fn(),
recall: vi.fn().mockRejectedValue(new Error("Timeout")),
reflect: vi.fn(),
} as any;
const tools = createTools(client, "test-bank", makeConfig());
await tools.hindsight_retain.execute({ content: 'x' }, mockContext);
await tools.hindsight_recall.execute({ query: 'x' }, mockContext);
await tools.hindsight_reflect.execute({ query: 'x' }, mockContext);
expect(client.retain.mock.calls[0][0]).toBe('fixed-bank');
expect(client.recall.mock.calls[0][0]).toBe('fixed-bank');
expect(client.reflect.mock.calls[0][0]).toBe('fixed-bank');
await expect(tools.hindsight_recall.execute({ query: "test" }, mockContext)).rejects.toThrow(
"Timeout"
);
});
it("propagates reflect errors", async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockRejectedValue(new Error("Server error")),
} as any;
const tools = createTools(client, "test-bank", makeConfig());
await expect(tools.hindsight_reflect.execute({ query: "test" }, mockContext)).rejects.toThrow(
"Server error"
);
});
});
describe("bank mission setup", () => {
it("calls ensureBankMission before retain when missionsSet provided", async () => {
const client = {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn(),
reflect: vi.fn(),
createBank: vi.fn().mockResolvedValue({}),
} as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: "Extract technical decisions" });
const tools = createTools(client, "test-bank", config, missionsSet);
await tools.hindsight_retain.execute({ content: "fact" }, mockContext);
expect(client.createBank).toHaveBeenCalledWith("test-bank", {
reflectMission: "Extract technical decisions",
retainMission: undefined,
});
expect(missionsSet.has("test-bank")).toBe(true);
expect(client.retain).toHaveBeenCalled();
});
it("calls ensureBankMission before reflect when missionsSet provided", async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockResolvedValue({ text: "answer" }),
createBank: vi.fn().mockResolvedValue({}),
} as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: "Synthesize project context" });
const tools = createTools(client, "test-bank", config, missionsSet);
await tools.hindsight_reflect.execute({ query: "summary" }, mockContext);
expect(client.createBank).toHaveBeenCalled();
expect(client.reflect).toHaveBeenCalled();
});
it("skips mission setup when missionsSet not provided (backward compat)", async () => {
const client = {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn(),
reflect: vi.fn(),
} as any;
const tools = createTools(client, "test-bank", makeConfig());
await tools.hindsight_retain.execute({ content: "fact" }, mockContext);
expect(client.retain).toHaveBeenCalled();
// No createBank call since missionsSet wasn't passed
});
});
it("always uses constructor bankId", async () => {
const client = {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn().mockResolvedValue({ results: [] }),
reflect: vi.fn().mockResolvedValue({ text: "ok" }),
} as any;
const tools = createTools(client, "fixed-bank", makeConfig());
await tools.hindsight_retain.execute({ content: "x" }, mockContext);
await tools.hindsight_recall.execute({ query: "x" }, mockContext);
await tools.hindsight_reflect.execute({ query: "x" }, mockContext);
expect(client.retain.mock.calls[0][0]).toBe("fixed-bank");
expect(client.recall.mock.calls[0][0]).toBe("fixed-bank");
expect(client.reflect.mock.calls[0][0]).toBe("fixed-bank");
});
});
+87 -91
View File
@@ -5,107 +5,103 @@
* as tools the agent can call explicitly.
*/
import { tool } from '@opencode-ai/plugin/tool';
import type { ToolDefinition } from '@opencode-ai/plugin/tool';
import type { HindsightClient } from '@vectorize-io/hindsight-client';
import type { HindsightConfig } from './config.js';
import { formatMemories, formatCurrentTime } from './content.js';
import { ensureBankMission } from './bank.js';
import { tool } from "@opencode-ai/plugin/tool";
import type { ToolDefinition } from "@opencode-ai/plugin/tool";
import type { HindsightClient } from "@vectorize-io/hindsight-client";
import type { HindsightConfig } from "./config.js";
import { formatMemories, formatCurrentTime } from "./content.js";
import { ensureBankMission } from "./bank.js";
export interface HindsightTools {
hindsight_retain: ToolDefinition;
hindsight_recall: ToolDefinition;
hindsight_reflect: ToolDefinition;
hindsight_retain: ToolDefinition;
hindsight_recall: ToolDefinition;
hindsight_reflect: ToolDefinition;
}
export function createTools(
client: HindsightClient,
bankId: string,
config: HindsightConfig,
missionsSet?: Set<string>,
client: HindsightClient,
bankId: string,
config: HindsightConfig,
missionsSet?: Set<string>
): HindsightTools {
const hindsight_retain = tool({
description:
'Store information in long-term memory. Use this to remember important facts, ' +
'user preferences, project context, decisions, and anything worth recalling in future sessions. ' +
'Be specific — include who, what, when, and why.',
args: {
content: tool.schema.string().describe(
'The information to remember. Be specific and self-contained.',
),
context: tool.schema
.string()
.optional()
.describe('Optional context about where this information came from.'),
},
async execute(args) {
if (missionsSet) {
await ensureBankMission(client, bankId, config, missionsSet);
}
await client.retain(bankId, args.content, {
context: args.context || config.retainContext,
tags: config.retainTags.length ? config.retainTags : undefined,
metadata: Object.keys(config.retainMetadata).length
? config.retainMetadata
: undefined,
});
return 'Memory stored successfully.';
},
});
const hindsight_retain = tool({
description:
"Store information in long-term memory. Use this to remember important facts, " +
"user preferences, project context, decisions, and anything worth recalling in future sessions. " +
"Be specific — include who, what, when, and why.",
args: {
content: tool.schema
.string()
.describe("The information to remember. Be specific and self-contained."),
context: tool.schema
.string()
.optional()
.describe("Optional context about where this information came from."),
},
async execute(args) {
if (missionsSet) {
await ensureBankMission(client, bankId, config, missionsSet);
}
await client.retain(bankId, args.content, {
context: args.context || config.retainContext,
tags: config.retainTags.length ? config.retainTags : undefined,
metadata: Object.keys(config.retainMetadata).length ? config.retainMetadata : undefined,
});
return "Memory stored successfully.";
},
});
const hindsight_recall = tool({
description:
'Search long-term memory for relevant information. Use this proactively before ' +
'answering questions about past conversations, user preferences, project history, ' +
'or any topic where prior context would help. When in doubt, recall first.',
args: {
query: tool.schema.string().describe(
'Natural language search query. Be specific about what you need to know.',
),
},
async execute(args) {
const response = await client.recall(bankId, args.query, {
budget: config.recallBudget as 'low' | 'mid' | 'high',
maxTokens: config.recallMaxTokens,
types: config.recallTypes,
tags: config.recallTags.length ? config.recallTags : undefined,
tagsMatch: config.recallTags.length ? config.recallTagsMatch : undefined,
});
const hindsight_recall = tool({
description:
"Search long-term memory for relevant information. Use this proactively before " +
"answering questions about past conversations, user preferences, project history, " +
"or any topic where prior context would help. When in doubt, recall first.",
args: {
query: tool.schema
.string()
.describe("Natural language search query. Be specific about what you need to know."),
},
async execute(args) {
const response = await client.recall(bankId, args.query, {
budget: config.recallBudget as "low" | "mid" | "high",
maxTokens: config.recallMaxTokens,
types: config.recallTypes,
tags: config.recallTags.length ? config.recallTags : undefined,
tagsMatch: config.recallTags.length ? config.recallTagsMatch : undefined,
});
const results = response.results || [];
if (!results.length) return 'No relevant memories found.';
const results = response.results || [];
if (!results.length) return "No relevant memories found.";
const formatted = formatMemories(results);
return `Found ${results.length} relevant memories (as of ${formatCurrentTime()} UTC):\n\n${formatted}`;
},
});
const formatted = formatMemories(results);
return `Found ${results.length} relevant memories (as of ${formatCurrentTime()} UTC):\n\n${formatted}`;
},
});
const hindsight_reflect = tool({
description:
'Generate a thoughtful answer using long-term memory. Unlike recall (which returns ' +
'raw memories), reflect synthesizes memories into a coherent answer. Use for questions ' +
'like "What do you know about this user?" or "Summarize our project decisions."',
args: {
query: tool.schema.string().describe(
'The question to answer using long-term memory.',
),
context: tool.schema
.string()
.optional()
.describe('Optional additional context to guide the reflection.'),
},
async execute(args) {
if (missionsSet) {
await ensureBankMission(client, bankId, config, missionsSet);
}
const response = await client.reflect(bankId, args.query, {
context: args.context,
budget: config.recallBudget as 'low' | 'mid' | 'high',
});
const hindsight_reflect = tool({
description:
"Generate a thoughtful answer using long-term memory. Unlike recall (which returns " +
"raw memories), reflect synthesizes memories into a coherent answer. Use for questions " +
'like "What do you know about this user?" or "Summarize our project decisions."',
args: {
query: tool.schema.string().describe("The question to answer using long-term memory."),
context: tool.schema
.string()
.optional()
.describe("Optional additional context to guide the reflection."),
},
async execute(args) {
if (missionsSet) {
await ensureBankMission(client, bankId, config, missionsSet);
}
const response = await client.reflect(bankId, args.query, {
context: args.context,
budget: config.recallBudget as "low" | "mid" | "high",
});
return response.text || 'No relevant information found to reflect on.';
},
});
return response.text || "No relevant information found to reflect on.";
},
});
return { hindsight_retain, hindsight_recall, hindsight_reflect };
return { hindsight_retain, hindsight_recall, hindsight_reflect };
}
@@ -1,11 +1,11 @@
import { defineConfig } from 'tsup';
import { defineConfig } from "tsup";
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
outDir: 'dist',
clean: true,
sourcemap: true,
bundle: true,
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
outDir: "dist",
clean: true,
sourcemap: true,
bundle: true,
});
@@ -1,9 +1,9 @@
import { defineConfig } from 'vitest/config';
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
environment: "node",
include: ["src/**/*.test.ts"],
},
});
+54 -48
View File
@@ -21,35 +21,35 @@ npm install @vectorize-io/hindsight-paperclip
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 | — |
| 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'
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()))
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
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}`
: `Task: ${context.taskDescription}`;
const output = await runYourAgent(prompt)
res.json({ output }) // middleware auto-retains output
})
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.
@@ -57,50 +57,56 @@ The middleware reads `agentId`, `companyId`, `runId`, and `context.taskDescripti
### Process Adapter Scripts
```typescript
import { recall, retain, loadConfig } from '@vectorize-io/hindsight-paperclip'
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 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)
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}`)
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)
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'
import { recall, retain, loadConfig } from "@vectorize-io/hindsight-paperclip";
const config = loadConfig({
hindsightApiUrl: 'https://api.hindsight.vectorize.io',
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}`
systemPrompt = `Past context:\n${memories}\n\n${systemPrompt}`;
}
```
@@ -116,15 +122,15 @@ You can change the isolation granularity:
```typescript
// Shared memory across all agents in a company
loadConfig({ bankGranularity: ['company'] })
loadConfig({ bankGranularity: ["company"] });
// → "paperclip::{companyId}"
// Agent's global memory across all companies
loadConfig({ bankGranularity: ['agent'] })
loadConfig({ bankGranularity: ["agent"] });
// → "paperclip::{agentId}"
// Custom prefix
loadConfig({ bankIdPrefix: 'myapp' })
loadConfig({ bankIdPrefix: "myapp" });
// → "myapp::{companyId}::{agentId}"
```
@@ -132,14 +138,14 @@ loadConfig({ bankIdPrefix: 'myapp' })
```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
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
}
```
+6 -6
View File
@@ -4,7 +4,7 @@
* Aligns Hindsight's memory bank model with Paperclip's company/agent isolation.
*/
import type { PaperclipMemoryConfig } from './config.js';
import type { PaperclipMemoryConfig } from "./config.js";
export interface BankContext {
companyId: string;
@@ -27,14 +27,14 @@ export function deriveBankId(context: BankContext, config: PaperclipMemoryConfig
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);
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');
throw new Error("Bank ID cannot be empty — bankGranularity or bankIdPrefix must be set");
}
return parts.join('::');
return parts.join("::");
}
+26 -26
View File
@@ -4,18 +4,18 @@
* Uses native fetch (Node 20+). No external dependencies.
*/
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import type { PaperclipMemoryConfig } from './config.js';
import { readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import type { PaperclipMemoryConfig } from "./config.js";
function loadPackageVersion(): string {
try {
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string };
return pkg.version ?? '0.0.0';
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
return pkg.version ?? "0.0.0";
} catch {
return '0.0.0';
return "0.0.0";
}
}
@@ -45,18 +45,18 @@ export class HindsightClient {
constructor(config: PaperclipMemoryConfig) {
const url = config.hindsightApiUrl.trim();
if (!url) throw new Error('hindsightApiUrl is required');
this.baseUrl = url.replace(/\/$/, '');
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',
'User-Agent': USER_AGENT,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
};
if (this.token) h['Authorization'] = `Bearer ${this.token}`;
if (this.token) h["Authorization"] = `Bearer ${this.token}`;
return h;
}
@@ -64,7 +64,7 @@ export class HindsightClient {
method: string,
path: string,
body?: unknown,
timeoutMs?: number,
timeoutMs?: number
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs ?? this.timeoutMs);
@@ -78,7 +78,7 @@ export class HindsightClient {
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
const text = await resp.text().catch(() => "");
throw new Error(`HTTP ${resp.status} from ${path}: ${text}`);
}
@@ -91,12 +91,12 @@ export class HindsightClient {
async recall(
bankId: string,
query: string,
options?: { budget?: string; maxTokens?: number },
options?: { budget?: string; maxTokens?: number }
): Promise<RecallResponse> {
const path = `/v1/default/banks/${encodeURIComponent(bankId)}/memories/recall`;
return this.request<RecallResponse>('POST', path, {
return this.request<RecallResponse>("POST", path, {
query,
budget: options?.budget ?? 'mid',
budget: options?.budget ?? "mid",
max_tokens: options?.maxTokens ?? 1024,
});
}
@@ -109,22 +109,22 @@ export class HindsightClient {
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 });
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 });
if (retainMission) updates["retain_mission"] = retainMission;
await this.request("PATCH", path, { updates });
}
async health(): Promise<boolean> {
@@ -4,7 +4,7 @@
* Loaded from explicit options first, then environment variables.
*/
export type BankGranularity = 'company' | 'agent';
export type BankGranularity = "company" | "agent";
export interface PaperclipMemoryConfig {
/** Hindsight server URL. Required. env: HINDSIGHT_API_URL */
@@ -19,7 +19,7 @@ export interface PaperclipMemoryConfig {
/** Prefix prepended to all bank IDs. Default: "paperclip" */
bankIdPrefix?: string;
/** Recall search depth. Default: "mid" */
recallBudget?: 'low' | 'mid' | 'high';
recallBudget?: "low" | "mid" | "high";
/** Max tokens in the recalled memory block. Default: 1024 */
recallMaxTokens?: number;
/** Provenance label stored with each retained document. Default: "paperclip" */
@@ -30,19 +30,19 @@ export interface PaperclipMemoryConfig {
export function loadConfig(overrides?: Partial<PaperclipMemoryConfig>): PaperclipMemoryConfig {
const config: PaperclipMemoryConfig = {
hindsightApiUrl: process.env['HINDSIGHT_API_URL'] ?? '',
hindsightApiToken: process.env['HINDSIGHT_API_TOKEN'],
bankGranularity: ['company', 'agent'],
bankIdPrefix: 'paperclip',
recallBudget: 'mid',
hindsightApiUrl: process.env["HINDSIGHT_API_URL"] ?? "",
hindsightApiToken: process.env["HINDSIGHT_API_TOKEN"],
bankGranularity: ["company", "agent"],
bankIdPrefix: "paperclip",
recallBudget: "mid",
recallMaxTokens: 1024,
retainContext: 'paperclip',
retainContext: "paperclip",
timeoutMs: 15_000,
...overrides,
};
if (!config.hindsightApiUrl) {
throw new Error(
'hindsightApiUrl is required — set HINDSIGHT_API_URL or pass hindsightApiUrl to loadConfig()',
"hindsightApiUrl is required — set HINDSIGHT_API_URL or pass hindsightApiUrl to loadConfig()"
);
}
return config;
+12 -12
View File
@@ -17,20 +17,20 @@
* ```
*/
export { recall } from './recall.js';
export type { RecallInput } from './recall.js';
export { recall } from "./recall.js";
export type { RecallInput } from "./recall.js";
export { retain } from './retain.js';
export type { RetainInput } from './retain.js';
export { retain } from "./retain.js";
export type { RetainInput } from "./retain.js";
export { createMemoryMiddleware } from './middleware.js';
export type { HindsightRequest } from './middleware.js';
export { createMemoryMiddleware } from "./middleware.js";
export type { HindsightRequest } from "./middleware.js";
export { deriveBankId } from './bank.js';
export type { BankContext } from './bank.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 { loadConfig } from "./config.js";
export type { PaperclipMemoryConfig, BankGranularity } from "./config.js";
export { HindsightClient } from './client.js';
export type { Memory, RecallResponse, RetainResponse } from './client.js';
export { HindsightClient } from "./client.js";
export type { Memory, RecallResponse, RetainResponse } from "./client.js";
@@ -13,10 +13,10 @@
* }
*/
import type { Request, Response, NextFunction } from 'express';
import type { PaperclipMemoryConfig } from './config.js';
import { recall } from './recall.js';
import { retain } from './retain.js';
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 {
@@ -63,7 +63,7 @@ export function createMemoryMiddleware(config: PaperclipMemoryConfig) {
return;
}
const query: string = context?.taskDescription ?? context?.taskTitle ?? '';
const query: string = context?.taskDescription ?? context?.taskTitle ?? "";
// Pre-recall: inject memories into request
const memories = await recall({ companyId, agentId, query }, config);
@@ -71,22 +71,21 @@ export function createMemoryMiddleware(config: PaperclipMemoryConfig) {
memories,
companyId,
agentId,
runId: runId ?? '',
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) {
if (body && typeof body === "object" && "output" in body && runId) {
const output = (body as { output: unknown }).output;
if (typeof output === 'string' && output.trim()) {
retain(
{ companyId, agentId, content: output, documentId: runId },
config,
).catch((err) => {
console.warn('[hindsight-paperclip] retain failed:', (err as Error).message);
});
if (typeof output === "string" && output.trim()) {
retain({ companyId, agentId, content: output, documentId: runId }, config).catch(
(err) => {
console.warn("[hindsight-paperclip] retain failed:", (err as Error).message);
}
);
}
}
return originalJson(body);
+12 -15
View File
@@ -5,9 +5,9 @@
* from prior heartbeats and sessions.
*/
import { HindsightClient } from './client.js';
import type { PaperclipMemoryConfig } from './config.js';
import { deriveBankId } from './bank.js';
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. */
@@ -38,13 +38,10 @@ export interface RecallInput {
* }
* ```
*/
export async function recall(
input: RecallInput,
config: PaperclipMemoryConfig,
): Promise<string> {
export async function recall(input: RecallInput, config: PaperclipMemoryConfig): Promise<string> {
const { companyId, agentId, query } = input;
if (!query.trim()) return '';
if (!query.trim()) return "";
const bankId = deriveBankId({ companyId, agentId }, config);
const client = new HindsightClient(config);
@@ -57,23 +54,23 @@ export async function recall(
});
results = response.results;
} catch (err) {
console.warn('[hindsight-paperclip] recall failed:', (err as Error).message);
return '';
console.warn("[hindsight-paperclip] recall failed:", (err as Error).message);
return "";
}
if (!results.length) return '';
if (!results.length) return "";
return formatMemories(results);
}
function formatMemories(
results: Array<{ text: string; type?: string; mentionedAt?: string }>,
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})` : '';
const typeStr = r.type ? ` [${r.type}]` : "";
const dateStr = r.mentionedAt ? ` (${r.mentionedAt})` : "";
return `- ${r.text}${typeStr}${dateStr}`;
})
.join('\n\n');
.join("\n\n");
}
@@ -5,9 +5,9 @@
* so future heartbeats can recall the context.
*/
import { HindsightClient } from './client.js';
import type { PaperclipMemoryConfig } from './config.js';
import { deriveBankId } from './bank.js';
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. */
@@ -35,10 +35,7 @@ export interface RetainInput {
* )
* ```
*/
export async function retain(
input: RetainInput,
config: PaperclipMemoryConfig,
): Promise<void> {
export async function retain(input: RetainInput, config: PaperclipMemoryConfig): Promise<void> {
const { companyId, agentId, content, documentId, metadata } = input;
if (!content.trim()) return;
@@ -53,6 +50,6 @@ export async function retain(
metadata: { companyId, agentId, ...metadata },
});
} catch (err) {
console.warn('[hindsight-paperclip] retain failed:', (err as Error).message);
console.warn("[hindsight-paperclip] retain failed:", (err as Error).message);
}
}
@@ -1,44 +1,44 @@
import { describe, it, expect } from 'vitest';
import { deriveBankId } from '../src/bank.js';
import { loadConfig } from '../src/config.js';
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' };
describe("deriveBankId", () => {
const ctx = { companyId: "co-123", agentId: "ag-456" };
const baseUrl = 'http://fake:9077';
const baseUrl = "http://fake:9077";
it('default: paperclip::companyId::agentId', () => {
it("default: paperclip::companyId::agentId", () => {
const config = loadConfig({ hindsightApiUrl: baseUrl });
expect(deriveBankId(ctx, config)).toBe('paperclip::co-123::ag-456');
expect(deriveBankId(ctx, config)).toBe("paperclip::co-123::ag-456");
});
it('company-only granularity', () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankGranularity: ['company'] });
expect(deriveBankId(ctx, config)).toBe('paperclip::co-123');
it("company-only granularity", () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankGranularity: ["company"] });
expect(deriveBankId(ctx, config)).toBe("paperclip::co-123");
});
it('agent-only granularity', () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankGranularity: ['agent'] });
expect(deriveBankId(ctx, config)).toBe('paperclip::ag-456');
it("agent-only granularity", () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankGranularity: ["agent"] });
expect(deriveBankId(ctx, config)).toBe("paperclip::ag-456");
});
it('custom prefix', () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankIdPrefix: 'myapp' });
expect(deriveBankId(ctx, config)).toBe('myapp::co-123::ag-456');
it("custom prefix", () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankIdPrefix: "myapp" });
expect(deriveBankId(ctx, config)).toBe("myapp::co-123::ag-456");
});
it('empty prefix with default granularity', () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankIdPrefix: '' });
expect(deriveBankId(ctx, config)).toBe('co-123::ag-456');
it("empty prefix with default granularity", () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankIdPrefix: "" });
expect(deriveBankId(ctx, config)).toBe("co-123::ag-456");
});
it('throws when bank ID would be empty', () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankIdPrefix: '', bankGranularity: [] });
expect(() => deriveBankId(ctx, config)).toThrow('Bank ID cannot be empty');
it("throws when bank ID would be empty", () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankIdPrefix: "", bankGranularity: [] });
expect(() => deriveBankId(ctx, config)).toThrow("Bank ID cannot be empty");
});
it('reversed granularity order', () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankGranularity: ['agent', 'company'] });
expect(deriveBankId(ctx, config)).toBe('paperclip::ag-456::co-123');
it("reversed granularity order", () => {
const config = loadConfig({ hindsightApiUrl: baseUrl, bankGranularity: ["agent", "company"] });
expect(deriveBankId(ctx, config)).toBe("paperclip::ag-456::co-123");
});
});
@@ -1,25 +1,27 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { recall } from '../src/recall.js';
import { loadConfig } from '../src/config.js';
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);
vi.stubGlobal("fetch", mockFetch);
function makeRecallResponse(results: Array<{ text: string; type?: string; mentionedAt?: string }>) {
return {
ok: true,
status: 200,
json: async () => ({ results }),
text: async () => '',
text: async () => "",
} as unknown as Response;
}
function makeErrorResponse(status: number, body = '') {
function makeErrorResponse(status: number, body = "") {
return {
ok: false,
status,
json: async () => { throw new Error('not json'); },
json: async () => {
throw new Error("not json");
},
text: async () => body,
} as unknown as Response;
}
@@ -28,91 +30,93 @@ beforeEach(() => {
mockFetch.mockReset();
});
const config = loadConfig({ hindsightApiUrl: 'http://fake:9077' });
const input = { companyId: 'co-1', agentId: 'ag-1', query: 'what did I work on?' };
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('');
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' },
]));
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]');
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' },
]));
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)');
expect(result).toContain("(2024-01-15)");
});
it('returns empty string when no results', async () => {
it("returns empty string when no results", async () => {
mockFetch.mockResolvedValue(makeRecallResponse([]));
const result = await recall(input, config);
expect(result).toBe('');
expect(result).toBe("");
});
it('gracefully degrades on HTTP error', async () => {
mockFetch.mockResolvedValue(makeErrorResponse(500, 'Internal Server Error'));
it("gracefully degrades on HTTP error", async () => {
mockFetch.mockResolvedValue(makeErrorResponse(500, "Internal Server Error"));
const result = await recall(input, config);
expect(result).toBe('');
expect(result).toBe("");
});
it('gracefully degrades on network error', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
it("gracefully degrades on network error", async () => {
mockFetch.mockRejectedValue(new Error("ECONNREFUSED"));
const result = await recall(input, config);
expect(result).toBe('');
expect(result).toBe("");
});
it('calls the correct API path with bank ID', async () => {
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');
expect(url).toContain("/v1/default/banks/paperclip%3A%3Aco-1%3A%3Aag-1/memories/recall");
});
it('sends query and budget in request body', async () => {
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.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 () => {
it("uses custom budget and max_tokens from config", async () => {
const customConfig = loadConfig({
hindsightApiUrl: 'http://fake:9077',
recallBudget: 'high',
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.budget).toBe("high");
expect(body.max_tokens).toBe(2048);
});
it('sends Authorization header when token is set', async () => {
it("sends Authorization header when token is set", async () => {
const authConfig = loadConfig({
hindsightApiUrl: 'http://fake:9077',
hindsightApiToken: 'hsk_test123',
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');
expect((init.headers as Record<string, string>)["Authorization"]).toBe("Bearer hsk_test123");
});
});

Some files were not shown because too many files have changed in this diff Show More