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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
// ...
|
||||
}
|
||||
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
"<script>alert("x&'y")</script>",
|
||||
"<script>alert("x&'y")</script>"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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?"`);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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("::");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user