diff --git a/.github/ISSUE_TEMPLATE/benchmark_case.yml b/.github/ISSUE_TEMPLATE/benchmark_case.yml new file mode 100644 index 0000000..cba5b57 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/benchmark_case.yml @@ -0,0 +1,27 @@ +name: Benchmark Case +description: Propose a benchmark, regression case, or evaluation scenario. +labels: [benchmark] +body: + - type: textarea + id: scenario + attributes: + label: Scenario + description: What behavior should the benchmark measure? + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected signal + description: What would a good result demonstrate? + validations: + required: true + - type: textarea + id: data + attributes: + label: Dataset or fixture + description: Link or describe the data needed to reproduce the case. + - type: textarea + id: notes + attributes: + label: Notes diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..8ae4db8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,37 @@ +name: Bug Report +description: Report a reproducible bug in the AtomicMemory SDK. +labels: [bug] +body: + - type: textarea + id: description + attributes: + label: Description + description: What happened, and what did you expect instead? + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Include a minimal code sample or repository when possible. + placeholder: | + 1. Install package version ... + 2. Configure provider ... + 3. Run ... + validations: + required: true + - type: input + id: version + attributes: + label: Package version + placeholder: "@atomicmemory/atomicmemory-sdk@..." + - type: input + id: runtime + attributes: + label: Runtime + placeholder: "Node 22, browser, extension, edge runtime, etc." + - type: textarea + id: logs + attributes: + label: Relevant logs or errors + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a671807 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Documentation + url: https://docs.atomicmemory.ai + about: Read the AtomicMemory docs and quickstarts. + - name: Discussions + url: https://github.com/atomicstrata/atomicmemory-sdk/discussions + about: Ask questions, share ideas, and discuss roadmap items. diff --git a/.github/ISSUE_TEMPLATE/docs_issue.yml b/.github/ISSUE_TEMPLATE/docs_issue.yml new file mode 100644 index 0000000..23e5b33 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs_issue.yml @@ -0,0 +1,21 @@ +name: Docs Issue +description: Report missing, unclear, or incorrect documentation. +labels: [docs] +body: + - type: input + id: page + attributes: + label: Page or file + placeholder: "README.md, docs URL, API section, etc." + validations: + required: true + - type: textarea + id: issue + attributes: + label: What is wrong or missing? + validations: + required: true + - type: textarea + id: suggestion + attributes: + label: Suggested fix diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..2d8fba7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +name: Feature Request +description: Suggest an SDK capability or improvement. +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What user or developer problem should this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the API, behavior, or developer experience you want. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: dropdown + id: area + attributes: + label: Area + options: + - Memory client + - Provider contract + - Storage + - Embeddings + - Search/ranking + - Browser/runtime support + - Documentation + - Not sure diff --git a/.github/ISSUE_TEMPLATE/provider_request.yml b/.github/ISSUE_TEMPLATE/provider_request.yml new file mode 100644 index 0000000..45dcd32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/provider_request.yml @@ -0,0 +1,35 @@ +name: Provider Request +description: Request support for a memory, embedding, LLM, storage, or reranking provider. +labels: [provider] +body: + - type: input + id: provider + attributes: + label: Provider name + placeholder: "Provider or service name" + validations: + required: true + - type: dropdown + id: provider-type + attributes: + label: Provider type + options: + - Memory backend + - Embedding model + - LLM + - Reranker + - Storage + - Other + validations: + required: true + - type: textarea + id: use-case + attributes: + label: Use case + description: What workflow would this provider enable? + validations: + required: true + - type: textarea + id: references + attributes: + label: API docs or references diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4eb2a9c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ +## Summary + + + +## Validation + +- [ ] Tests were run or the reason they were not run is documented. +- [ ] Typecheck/build were run when code or package output changed. +- [ ] Docs were updated or no docs changes are needed. +- [ ] Benchmark or performance impact was considered. + +## Change Type + +- [ ] Bug fix +- [ ] Feature +- [ ] Provider change +- [ ] Refactoring (no behavior change) +- [ ] Documentation +- [ ] Tests / benchmarks +- [ ] Chore / maintenance + +## Breaking Changes + +- [ ] No breaking changes +- [ ] Breaking changes are documented below + + diff --git a/CHANGELOG.md b/CHANGELOG.md index dac256a..c132fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [1.0.1] - 2026-05-14 + +### Changed +- Version bump for public package publication after internal-to-public repository sync. + ## [1.0.0] Initial public release. diff --git a/README.md b/README.md index 3e41240..7b4bf1f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,15 @@ # @atomicmemory/sdk +[![CI](https://github.com/atomicstrata/atomicmemory-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/atomicstrata/atomicmemory-sdk/actions/workflows/ci.yml) +[![npm](https://img.shields.io/npm/v/%40atomicmemory%2Fsdk?label=npm)](https://www.npmjs.com/package/@atomicmemory/sdk) +[![Docs](https://img.shields.io/badge/docs-docs.atomicstrata.ai-blue)](https://docs.atomicstrata.ai) +[![License: Apache 2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) + Backend-agnostic memory-layer SDK — pluggable providers, local embeddings, storage adapters, semantic search. -[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +**Docs:** [docs.atomicstrata.ai/sdk](https://docs.atomicstrata.ai/sdk) + +AtomicMemory Core currently reaches cost-Pareto SOTA on BEAM-100K, BEAM-1M, and LoCoMo10, with BEAM-10M parity against the strongest published Mem0-new result. The SDK is the typed application surface for building on that memory layer. ## What this package provides @@ -34,6 +41,8 @@ Also works with `npm install` / `yarn add`. ## Quick start +Prerequisite: start `atomicmemory-core` first. The full SDK walkthrough is in the [SDK Quickstart](https://docs.atomicstrata.ai/sdk/quickstart). + ```ts import { AtomicMemoryClient } from '@atomicmemory/sdk'; @@ -69,9 +78,9 @@ const artifact = await client.storage.put({ console.log(artifact.artifactId); ``` -The flat `MemoryClient` export is still available for internal-tool -consumers but its JSDoc tags it `@internal`; new code should reach -the memory namespace through `AtomicMemoryClient.memory`. +Applications that only need memory operations can still use +`MemoryClient` directly. New integrations should prefer the +namespaced `AtomicMemoryClient.memory` surface. ## Providers diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..f88cc4b --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,72 @@ +# AtomicMemory SDK Roadmap + +This roadmap is directional. It describes the areas the maintainers are actively investing in, but it is not a promise of specific features or dates. + +The AtomicMemory SDK provides the TypeScript interface for applications, agents, and integrations that need to capture, store, retrieve, and package memory. The near-term focus is a stable developer surface that works across local, server, and agent-oriented runtimes. + +## Current Focus + +- Keep the SDK API stable and aligned with AtomicMemory Core. +- Make memory capture and retrieval flows easier to use from applications and agent integrations. +- Improve provider configuration for embeddings, storage, and retrieval behavior. +- Keep local-first and self-hosted use cases straightforward. +- Add examples that show realistic memory workflows rather than isolated snippets. +- Preserve a clear separation between SDK logic and application-specific UI or extension behavior. + +## Near-Term Work + +### API Stability + +- Clarify the public client surface for capture, search, retrieval, mutation, and context packaging. +- Tighten TypeScript types for request options, result metadata, and provider configuration. +- Document compatibility expectations across SDK and Core versions. +- Add migration notes when public behavior changes. + +### Retrieval And Context Packaging + +- Improve helpers for retrieving relevant memories and packaging them for model prompts. +- Expose enough metadata for applications to debug why a memory was selected. +- Add better support for correction-aware and time-sensitive memory workflows. +- Keep benchmark-driven behavior changes covered by reproducible examples or tests. + +### Providers And Runtime Support + +- Continue improving provider interfaces for embeddings, storage, and transport. +- Document recommended provider choices for common local and server deployments. +- Keep browser, Node.js, and server-side usage boundaries explicit. +- Avoid application-specific assumptions in the SDK layer. + +### Developer Experience + +- Expand quickstarts and examples for common application flows. +- Improve error messages for configuration and provider failures. +- Add focused tests around public API behavior and runtime compatibility. +- Keep package metadata, badges, and release notes easy to inspect. + +## Later Work + +- Higher-level workflows for memory lifecycle management where they remain runtime-agnostic. +- Additional provider adapters driven by contributor and application demand. +- More structured retrieval helpers over entities, events, and relationships. +- Deeper debugging utilities for ranking, token budgets, and prompt assembly. + +## Contribution Areas + +Good first areas for contributors include: + +- Type improvements and documentation for public APIs. +- Small examples that show real capture, retrieval, and context assembly flows. +- Provider adapter fixes and compatibility tests. +- Bug reports with minimal reproduction projects. +- Tests that protect behavior across supported runtimes. + +## Non-Goals + +- The SDK should not contain browser-extension UI logic. +- The SDK should not require a hosted AtomicMemory service. +- The SDK should not expose private roadmap, benchmark, or customer-specific planning details. +- The SDK should not hide provider behavior behind uninspectable defaults. + +## How We Prioritize + +We prioritize changes that make the SDK easier to adopt safely: stable APIs, clear examples, predictable runtime behavior, and testable retrieval improvements. diff --git a/package.json b/package.json index e52f1bb..da76d3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/sdk", - "version": "1.0.0", + "version": "1.0.1", "type": "module", "engines": { "node": ">=22" diff --git a/src/browser.ts b/src/browser.ts index d9b626c..8c31b9b 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -21,3 +21,4 @@ export * from './memory/pipeline'; export * from './memory/registration'; export * from './memory/atomicmemory-provider'; export * from './memory/mem0-provider'; +export * from './memory/hindsight-provider'; diff --git a/src/client/__tests__/memory-client.test.ts b/src/client/__tests__/memory-client.test.ts index 2d24db6..1395e09 100644 --- a/src/client/__tests__/memory-client.test.ts +++ b/src/client/__tests__/memory-client.test.ts @@ -60,4 +60,16 @@ describe('MemoryClient', () => { }); expect(client.atomicmemory).toBeUndefined(); }); + + it('initializes hindsight through the default provider registry', async () => { + const client = new MemoryClient({ + providers: { + hindsight: { apiUrl: 'https://api.hindsight.vectorize.io' }, + }, + }); + + await client.initialize(); + + expect(client.capabilities().extensions.reflect).toBe(true); + }); }); diff --git a/src/client/atomic-memory-client.ts b/src/client/atomic-memory-client.ts index da6fec5..6e060b3 100644 --- a/src/client/atomic-memory-client.ts +++ b/src/client/atomic-memory-client.ts @@ -21,9 +21,9 @@ * v1 SDK is server-side only. Browser bundles must proxy through a * trusted server (the webapp-sdk does this for `Atomicmem-webapp`). * - * The flat `MemoryClient` export remains available for internal-tool - * consumers but its JSDoc tags it `@internal` and steers readers - * toward `AtomicMemoryClient.memory`. + * Applications that only need memory operations can still use + * `MemoryClient` directly. New integrations should prefer + * `AtomicMemoryClient.memory`. */ import { MemoryClient, type MemoryClientConfig } from './memory-client'; @@ -55,9 +55,8 @@ export interface AtomicMemoryClientConfig { } /** - * Primary public client. Holds a `memory` namespace (existing - * `MemoryClient`) and a `storage` namespace (the Step-6 concrete - * `StorageClient`). + * Primary public client. Holds a `memory` namespace (`MemoryClient`) + * and a `storage` namespace (`StorageClient`). */ export class AtomicMemoryClient { readonly memory: MemoryClient; diff --git a/src/client/memory-client.ts b/src/client/memory-client.ts index b054a20..c6a9b31 100644 --- a/src/client/memory-client.ts +++ b/src/client/memory-client.ts @@ -12,6 +12,7 @@ import type { MemoryProvider } from '../memory/provider'; import type { AtomicMemoryProviderConfig } from '../memory/atomicmemory-provider/types'; import type { AtomicMemoryHandle } from '../memory/atomicmemory-provider/handle'; import type { Mem0ProviderConfig } from '../memory/mem0-provider/types'; +import type { HindsightProviderConfig } from '../memory/hindsight-provider/types'; import type { IngestInput, IngestResult, @@ -37,6 +38,7 @@ import { export interface MemoryProviderConfigs { atomicmemory?: AtomicMemoryProviderConfig; mem0?: Mem0ProviderConfig; + hindsight?: HindsightProviderConfig; [providerName: string]: unknown; } diff --git a/src/index.ts b/src/index.ts index 7a2ecbd..61b91f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,15 +18,13 @@ */ // Primary client — `AtomicMemoryClient` aggregates the memory and -// storage namespaces. README + JSDoc steer callers here; the flat -// `MemoryClient` export below stays available for internal-tool -// consumers but is `@internal` from the public-surface POV. +// storage namespaces. `MemoryClient` remains available for +// applications that only need memory operations. export { AtomicMemoryClient, type AtomicMemoryClientConfig, } from './client/atomic-memory-client'; -/** @internal — primary public surface is `AtomicMemoryClient.memory`. */ export { MemoryClient } from './client/memory-client'; export type { MemoryClientConfig, @@ -55,10 +53,8 @@ export { type RetryPolicy, } from './core/error-handling/'; -// KV cache (internal embedding / transformer cache; was `./storage` before the -// storage-sibling rev — see plan rev-6 §finding 1). Re-exported here for -// backwards compatibility with internal callers; new storage API lives -// under `./storage`. +// KV cache used by embeddings and local search. New artifact-storage +// integrations should use the `./storage` subpath. export { StorageManager } from './kv-cache/storage-manager'; export { MemoryStorageAdapter } from './kv-cache/memory-storage'; export { IndexedDBStorageAdapter } from './kv-cache/indexeddb-storage'; @@ -77,8 +73,7 @@ export { } from './kv-cache/validation'; export type { ValidationConfig, ValidationResult } from './kv-cache/validation'; -// Storage API (the new public `client.storage` namespace — types and -// interfaces only in this step; concrete runtime client lands in Step 6). +// Storage API for the public `client.storage` namespace. export * from './storage'; // Logging @@ -148,6 +143,7 @@ export * from './memory/pipeline'; export * from './memory/registration'; export * from './memory/atomicmemory-provider'; export * from './memory/mem0-provider'; +export * from './memory/hindsight-provider'; // Version information export const SDK_VERSION = '1.0.0'; diff --git a/src/memory/atomicmemory-provider/__tests__/ingest-metadata-forwarding.test.ts b/src/memory/atomicmemory-provider/__tests__/ingest-metadata-forwarding.test.ts index 03fc633..e9c65da 100644 --- a/src/memory/atomicmemory-provider/__tests__/ingest-metadata-forwarding.test.ts +++ b/src/memory/atomicmemory-provider/__tests__/ingest-metadata-forwarding.test.ts @@ -21,7 +21,7 @@ * `metadata` deep-equal to the input * - omission: metadata absent OR `{}` → no `metadata` key on the * wire (so non-metadata callers don't emit a stray empty object) - * - text / messages modes (codex round-1 medium): the field is + * - text / messages modes: the field is * inherited from `IngestBase` for type ergonomics, but core * rejects metadata with 400 on every non-verbatim branch. * Forwarding it on those modes would turn a previously-passing @@ -113,11 +113,10 @@ describe('AtomicMemoryProvider.doIngest — metadata forwarding', () => { describe('text mode (gate prevents core 400 regression)', () => { it('does NOT forward metadata even when caller supplies it', async () => { - // Codex round-1: forwarding metadata on text mode would turn - // a previously-passing call (silent drop) into a hard 400 from - // core. Until type-level narrowing moves `metadata` off - // IngestBase, the provider must runtime-gate the forward to - // verbatim only. + // Forwarding metadata on text mode would turn a previously + // accepted call into a hard 400 from core. Until type-level + // narrowing moves `metadata` off IngestBase, the provider must + // runtime-gate the forward to verbatim only. const provider = new AtomicMemoryProvider({ apiUrl: API_URL }); await provider.ingest({ mode: 'text', diff --git a/src/memory/atomicmemory-provider/__tests__/namespace-agents.test.ts b/src/memory/atomicmemory-provider/__tests__/namespace-agents.test.ts index 9df0a10..7d17c5e 100644 --- a/src/memory/atomicmemory-provider/__tests__/namespace-agents.test.ts +++ b/src/memory/atomicmemory-provider/__tests__/namespace-agents.test.ts @@ -1,5 +1,5 @@ /** - * @file AtomicMemory namespace agents HTTP wiring (Phase 7g) + * @file AtomicMemory namespace agents HTTP wiring * * Covers the 5 agents methods against a mocked fetch. Verifies: * - wire paths + methods (NB: under /v1/agents, NOT /v1/memories) diff --git a/src/memory/atomicmemory-provider/__tests__/namespace-audit.test.ts b/src/memory/atomicmemory-provider/__tests__/namespace-audit.test.ts index e1a8f36..65d6b2d 100644 --- a/src/memory/atomicmemory-provider/__tests__/namespace-audit.test.ts +++ b/src/memory/atomicmemory-provider/__tests__/namespace-audit.test.ts @@ -1,5 +1,5 @@ /** - * @file AtomicMemory namespace audit HTTP wiring (Phase 7d) + * @file AtomicMemory namespace audit HTTP wiring * * Covers the 3 audit methods: summary, recent, trail. Core's audit * routes are user-scoped (memories.ts:481/493/506). Tests verify diff --git a/src/memory/atomicmemory-provider/__tests__/namespace-base-routes.test.ts b/src/memory/atomicmemory-provider/__tests__/namespace-base-routes.test.ts index f4b0609..33d6941 100644 --- a/src/memory/atomicmemory-provider/__tests__/namespace-base-routes.test.ts +++ b/src/memory/atomicmemory-provider/__tests__/namespace-base-routes.test.ts @@ -1,5 +1,5 @@ /** - * @file AtomicMemory namespace base-route HTTP wiring (Phase 7b) + * @file AtomicMemory namespace base-route HTTP wiring * * Tests each of the 9 base-route methods on the AtomicMemoryHandle: * HTTP endpoint, request-body/query shape, response mapping, and scope diff --git a/src/memory/atomicmemory-provider/__tests__/namespace-config.test.ts b/src/memory/atomicmemory-provider/__tests__/namespace-config.test.ts index 131c24b..d67364c 100644 --- a/src/memory/atomicmemory-provider/__tests__/namespace-config.test.ts +++ b/src/memory/atomicmemory-provider/__tests__/namespace-config.test.ts @@ -1,5 +1,5 @@ /** - * @file AtomicMemory namespace config HTTP wiring (Phase 7f) + * @file AtomicMemory namespace config HTTP wiring * * Covers health() + updateConfig() against a mocked fetch. Verifies: * - wire paths + methods diff --git a/src/memory/atomicmemory-provider/__tests__/namespace-lessons.test.ts b/src/memory/atomicmemory-provider/__tests__/namespace-lessons.test.ts index 23450df..c2c02f9 100644 --- a/src/memory/atomicmemory-provider/__tests__/namespace-lessons.test.ts +++ b/src/memory/atomicmemory-provider/__tests__/namespace-lessons.test.ts @@ -1,5 +1,5 @@ /** - * @file AtomicMemory namespace lessons HTTP wiring (Phase 7e) + * @file AtomicMemory namespace lessons HTTP wiring * * Exercises each of the 4 lessons methods: list, stats, report, delete. * Core's lesson routes are user-scoped per memories.ts:352/362/372/385. diff --git a/src/memory/atomicmemory-provider/__tests__/namespace-lifecycle.test.ts b/src/memory/atomicmemory-provider/__tests__/namespace-lifecycle.test.ts index 0300e1a..d64f58e 100644 --- a/src/memory/atomicmemory-provider/__tests__/namespace-lifecycle.test.ts +++ b/src/memory/atomicmemory-provider/__tests__/namespace-lifecycle.test.ts @@ -1,5 +1,5 @@ /** - * @file AtomicMemory namespace lifecycle HTTP wiring (Phase 7c) + * @file AtomicMemory namespace lifecycle HTTP wiring * * Exercises each of the 7 lifecycle methods against a mocked fetch: * consolidate, decay, cap, stats, resetSource, reconcile, diff --git a/src/memory/atomicmemory-provider/__tests__/namespace-scaffolding.test.ts b/src/memory/atomicmemory-provider/__tests__/namespace-scaffolding.test.ts index 52ac680..8219553 100644 --- a/src/memory/atomicmemory-provider/__tests__/namespace-scaffolding.test.ts +++ b/src/memory/atomicmemory-provider/__tests__/namespace-scaffolding.test.ts @@ -1,5 +1,5 @@ /** - * @file AtomicMemory namespace scaffolding tests (Phase 7a) + * @file AtomicMemory namespace scaffolding tests * * Verifies the runtime plumbing of `provider.getExtension('atomicmemory.*')`, * `capabilities().customExtensions`, and the placeholder handle's fail-loud @@ -23,7 +23,7 @@ function createProvider(): AtomicMemoryProvider { return new AtomicMemoryProvider({ apiUrl: 'https://example.invalid' }); } -describe('AtomicMemory namespace scaffolding (Phase 7a)', () => { +describe('AtomicMemory namespace scaffolding', () => { it('declares all atomicmemory.* customExtensions in capabilities', () => { const provider = createProvider(); const caps = provider.capabilities(); @@ -92,10 +92,10 @@ describe('AtomicMemory namespace scaffolding (Phase 7a)', () => { // Every category now has a real HTTP-wired implementation — category-by- // category coverage lives in the dedicated test files: - // Phase 7b base routes → namespace-base-routes.test.ts - // Phase 7c lifecycle → namespace-lifecycle.test.ts - // Phase 7d audit → namespace-audit.test.ts - // Phase 7e lessons → namespace-lessons.test.ts - // Phase 7f config → namespace-config.test.ts - // Phase 7g agents → namespace-agents.test.ts + // base routes → namespace-base-routes.test.ts + // lifecycle → namespace-lifecycle.test.ts + // audit → namespace-audit.test.ts + // lessons → namespace-lessons.test.ts + // config → namespace-config.test.ts + // agents → namespace-agents.test.ts }); diff --git a/src/memory/atomicmemory-provider/atomicmemory-provider.ts b/src/memory/atomicmemory-provider/atomicmemory-provider.ts index 6199c43..7ebf1ab 100644 --- a/src/memory/atomicmemory-provider/atomicmemory-provider.ts +++ b/src/memory/atomicmemory-provider/atomicmemory-provider.ts @@ -268,9 +268,8 @@ export class AtomicMemoryProvider /** * Lazily construct a single AtomicMemoryHandle instance bound to this - * provider. The handle's methods will be implemented in Phase 7b-7g; - * for now, invoking them throws with an actionable error so consumers - * learn of the gap early rather than silently getting no-ops. + * provider. The handle exposes AtomicMemory-specific methods through + * named extensions instead of the backend-agnostic provider surface. */ private _atomicmemoryHandle?: AtomicMemoryHandle; private atomicmemoryHandle(): AtomicMemoryHandle { diff --git a/src/memory/atomicmemory-provider/handle-impl.ts b/src/memory/atomicmemory-provider/handle-impl.ts index 9f82fc0..0651b1b 100644 --- a/src/memory/atomicmemory-provider/handle-impl.ts +++ b/src/memory/atomicmemory-provider/handle-impl.ts @@ -1,13 +1,12 @@ /** - * @file AtomicMemoryHandle implementation for base routes (Phase 7b) + * @file AtomicMemoryHandle implementation for base routes * * Wires the 9 namespaced base-route methods directly to atomicmemory-core's * HTTP surface: * ingestFull, ingestQuick (incl. skipExtraction), search, searchFast, * expand, list, get, delete. * - * Category handles (lifecycle, audit, lessons, config, agents) remain the - * fail-loud placeholders from Phase 7a — replaced in Phases 7c–7g. + * Category handles cover lifecycle, audit, lessons, config, and agents. * * Namespace-specific mapping: returned memories carry the full * `MemoryScope` discriminated union (not V3's flat `Scope`), so workspace @@ -463,7 +462,7 @@ function mapSearchResponse( } // --------------------------------------------------------------------------- -// Lifecycle category (Phase 7c) +// Lifecycle category // --------------------------------------------------------------------------- /** @@ -709,7 +708,7 @@ function toReconciliationResult(raw: RawReconciliationResponse): ReconciliationR } // --------------------------------------------------------------------------- -// Audit category (Phase 7d) +// Audit category // --------------------------------------------------------------------------- /** @@ -880,7 +879,7 @@ function toAuditTrailEntry(raw: RawAuditTrailEntry): AuditTrailEntry { } // --------------------------------------------------------------------------- -// Lessons category (Phase 7e) +// Lessons category // --------------------------------------------------------------------------- /** @@ -992,7 +991,7 @@ function toLesson(raw: RawLessonRow): Lesson { } // --------------------------------------------------------------------------- -// Configuration category (Phase 7f) +// Configuration category // --------------------------------------------------------------------------- /** @@ -1098,7 +1097,7 @@ function toHealthConfig(raw: RawHealthConfig): HealthConfig { } // --------------------------------------------------------------------------- -// Agents category (Phase 7g) +// Agents category // --------------------------------------------------------------------------- /** diff --git a/src/memory/atomicmemory-provider/handle.ts b/src/memory/atomicmemory-provider/handle.ts index 1ca1c49..43a9f6d 100644 --- a/src/memory/atomicmemory-provider/handle.ts +++ b/src/memory/atomicmemory-provider/handle.ts @@ -791,7 +791,7 @@ export interface AtomicMemoryAgents { * for SDK configurations that do not include AtomicMemoryProvider. */ export interface AtomicMemoryHandle { - // Base routes (Phase 7b implementation) + // Base routes ingestFull( input: AtomicMemoryIngestInput, scope: MemoryScope, @@ -822,7 +822,7 @@ export interface AtomicMemoryHandle { get(id: string, scope: MemoryScope): Promise; delete(id: string, scope: MemoryScope): Promise; - // Category sub-accessors (Phase 7c-7g implementation) + // Category sub-accessors lifecycle: AtomicMemoryLifecycle; audit: AtomicMemoryAudit; lessons: AtomicMemoryLessons; diff --git a/src/memory/hindsight-provider/README.md b/src/memory/hindsight-provider/README.md new file mode 100644 index 0000000..626205d --- /dev/null +++ b/src/memory/hindsight-provider/README.md @@ -0,0 +1,111 @@ +# Hindsight provider + +The Hindsight provider lets the SDK use Hindsight Cloud or a self-hosted +Hindsight API as a memory backend. + +## Configuration + +Cloud example: + +```ts +const client = new MemoryClient({ + providers: { + hindsight: { + apiUrl: 'https://api.hindsight.vectorize.io', + apiKey: process.env.HINDSIGHT_API_KEY, + apiVersion: 'v1', + projectId: 'default', + }, + }, +}); +``` + +Self-hosted Docker example: + +```ts +const client = new MemoryClient({ + providers: { + hindsight: { + apiUrl: 'http://localhost:8888', + apiVersion: 'v1', + projectId: 'default', + }, + }, +}); +``` + +`defaultMaxTokens` controls the fallback Hindsight `max_tokens` value for +recall-backed search and packaging. `PackageRequest.tokenBudget` takes +precedence for `package()`. + +`defaultBudget` controls the fallback Hindsight recall budget. Request-level +typed budget overrides are not part of the first-pass provider contract. + +`SearchRequest.limit` is applied after Hindsight recall returns; it caps SDK +results and is not sent as Hindsight `max_tokens`. + +## Scope mapping + +The provider maps SDK scope to Hindsight banks and tags: + +- `scope.user` routes to `bank_id` and is required. +- `scope.agent` maps to tag `agent:`. +- `scope.namespace` maps to tag `namespace:`. +- `scope.thread` maps to tag `thread:`. + +`ingest`, `search`, `package`, and `reflect` apply the derived tags. +Recall-backed operations use `tags_match: 'all_strict'` so scoped reads exclude +untagged memories and require all supplied scope tags. + +`list`, `get`, and `delete` are bank-scoped by `user` and memory id because the +verified Hindsight list/get/delete contract does not expose the same tag filter +surface. + +## Contract notes + +Hindsight retain does not document stable created memory IDs. Successful +`ingest()` therefore returns: + +```ts +{ created: [], updated: [], unchanged: [] } +``` + +Callers that need the raw retain response can use: + +```ts +const retain = client.getExtension('hindsight.retain'); +const retained = await retain?.retain({ + mode: 'text', + content: 'Alice joined the platform team.', + scope: { user: 'alice' }, +}); +``` + +Callers that need async operation status can use: + +```ts +const operations = client.getExtension( + 'hindsight.operations', +); +const operation = retained?.operation_id + ? await operations?.get({ user: 'alice' }, retained.operation_id) + : null; +``` + +Hindsight recall does not document a stable numeric score field, so search +results use `score: 0` as the provider-score sentinel. Hindsight reflect does +not expose per-answer confidence, so `Insight.confidence` is also `0`. + +The provider throws when a returned memory lacks `id`, `text`, or a documented +timestamp field (`created_at`, `mentioned_at`, or `date`) instead of fabricating +SDK-visible memory data. + +## Live Integration Test + +After starting Hindsight locally, run the opt-in live test with: + +```bash +HINDSIGHT_TEST_API_URL=http://localhost:8890 pnpm exec vitest run src/memory/hindsight-provider/__tests__/hindsight-provider.integration.test.ts --reporter verbose +``` + +The test is skipped by default when `HINDSIGHT_TEST_API_URL` is not set. diff --git a/src/memory/hindsight-provider/__tests__/hindsight-provider-contract.test.ts b/src/memory/hindsight-provider/__tests__/hindsight-provider-contract.test.ts new file mode 100644 index 0000000..5e5b468 --- /dev/null +++ b/src/memory/hindsight-provider/__tests__/hindsight-provider-contract.test.ts @@ -0,0 +1,133 @@ +/** + * @file Hindsight Provider Contract Tests + * + * Covers provider behavior that depends on Hindsight's documented wire + * contract: health status, strict response mapping, direct operation lookup, + * and contextual retain failures. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HindsightProvider } from '../hindsight-provider'; +import type { HindsightOperationsHandle } from '../types'; +import type { Scope, SearchRequest } from '../../types'; +import { MemoryProviderError } from '../../errors'; +import { + errorResponse, + installFetchMock, + jsonResponse, +} from '../../__tests__/shared/http-mocks'; + +const API_URL = 'https://api.hindsight.vectorize.io'; +const VALID_SCOPE: Scope = { user: 'user-1' }; + +let mockFetch: ReturnType; + +beforeEach(() => { + mockFetch = installFetchMock(); +}); + +describe('health', () => { + it('returns ok true and maps version from a 200 health response', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce( + jsonResponse({ status: 'ok', version: '0.6.1' }), + ); + + const health = await provider.health(); + + expect(requestUrl()).toBe(`${API_URL}/health`); + expect(health.ok).toBe(true); + expect(health.version).toBe('0.6.1'); + }); + + it('returns ok false when health responds with an error status', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce(errorResponse(500)); + + const health = await provider.health(); + + expect(health.ok).toBe(false); + }); +}); + +describe('strict response mapping', () => { + it('throws when a memory response omits every documented timestamp field', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce( + jsonResponse({ results: [{ id: 'm1', text: 'No date' }] }), + ); + + await expect(provider.search(searchRequest())).rejects.toThrow( + /missing timestamp field/, + ); + }); + + it('throws when a recall response omits the documented results array', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce(jsonResponse({ items: [] })); + + await expect(provider.search(searchRequest())).rejects.toThrow( + /missing results array/, + ); + }); +}); + +describe('operation lookup', () => { + it('uses Hindsight direct operation status endpoint', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce( + jsonResponse({ operation_id: 'op-1', status: 'processing' }), + ); + const operations = provider.getExtension( + 'hindsight.operations', + ); + + const operation = await operations?.get(VALID_SCOPE, 'op-1'); + + expect(requestUrl()).toBe( + `${API_URL}/v1/default/banks/user-1/operations/op-1`, + ); + expect(operation?.status).toBe('processing'); + }); +}); + +describe('retain failure', () => { + it('throws a provider error with retain response context', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce( + jsonResponse({ + success: false, + operation_id: 'op-failed', + items_count: 2, + async: true, + }), + ); + + const ingest = provider.ingest(textInput()); + + await expect(ingest).rejects.toThrow( + /operation_id=op-failed, items_count=2, async=true/, + ); + await expect(ingest).rejects.toBeInstanceOf(MemoryProviderError); + }); +}); + +function createProvider(): HindsightProvider { + return new HindsightProvider({ apiUrl: API_URL }); +} + +function searchRequest(): SearchRequest { + return { query: 'python', scope: VALID_SCOPE }; +} + +function textInput() { + return { + mode: 'text' as const, + content: 'Alice likes Python', + scope: VALID_SCOPE, + }; +} + +function requestUrl(): string { + return String(mockFetch.mock.calls[0][0]); +} diff --git a/src/memory/hindsight-provider/__tests__/hindsight-provider.integration.test.ts b/src/memory/hindsight-provider/__tests__/hindsight-provider.integration.test.ts new file mode 100644 index 0000000..a016338 --- /dev/null +++ b/src/memory/hindsight-provider/__tests__/hindsight-provider.integration.test.ts @@ -0,0 +1,120 @@ +/** + * @file Hindsight Provider Live Integration Tests + * + * Opt-in tests for a running Hindsight API. Set `HINDSIGHT_TEST_API_URL`, for + * example `http://localhost:8890`, after starting the local Docker backend. + * These tests exercise the real provider HTTP path and are skipped by default + * so normal unit test runs do not require external services or LLM credentials. + */ + +import { describe, expect, it } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { HindsightProvider } from '../hindsight-provider'; +import type { Scope } from '../../types'; +import type { + HindsightOperationsHandle, + HindsightRetainHandle, + HindsightRetainResponse, +} from '../types'; + +const apiUrl = process.env.HINDSIGHT_TEST_API_URL; +const runLive = apiUrl ? describe : describe.skip; +const COMPLETED_OPERATION_STATUS = 'completed'; +const FAILED_OPERATION_STATUSES = new Set(['cancelled', 'failed']); +const OPERATION_STATUS_ATTEMPTS = 30; + +runLive('hindsight live integration', () => { + it('retains, searches, and reflects against a running Hindsight API', async () => { + const provider = new HindsightProvider({ + apiUrl: requireApiUrl(), + defaultMaxTokens: 4_096, + timeout: 120_000, + }); + const scope = uniqueScope(); + const retain = + provider.getExtension('hindsight.retain'); + + expect(retain).toBeDefined(); + if (!retain) throw new Error('hindsight.retain extension is missing'); + + const retained = await retain.retain({ + mode: 'text', + content: + 'Alice validates AtomicMemory Hindsight provider integration in Docker.', + scope, + }); + await waitForRetainOperations(provider, scope, retained); + + const search = await provider.search({ + query: 'Alice validates AtomicMemory Hindsight provider integration', + scope, + limit: 3, + }); + const insights = await provider.reflect( + 'What does Alice validate?', + scope, + ); + + expect(retained.success).toBe(true); + expect(search.results.length).toBeGreaterThan(0); + expect(search.results[0].memory.metadata?.tags).toContain('agent:sdk'); + expect(insights[0].content).toContain('AtomicMemory'); + }, 180_000); +}); + +async function waitForRetainOperations( + provider: HindsightProvider, + scope: Scope, + retained: HindsightRetainResponse, +): Promise { + const operationIds = retainOperationIds(retained); + if (operationIds.length === 0) return; + + const operations = provider.getExtension( + 'hindsight.operations', + ); + expect(operations).toBeDefined(); + if (!operations) throw new Error('hindsight.operations extension is missing'); + + for (const operationId of operationIds) { + await waitForOperation(operations, scope, operationId); + } +} + +async function waitForOperation( + operations: HindsightOperationsHandle, + scope: Scope, + operationId: string, +): Promise { + let lastStatus = 'missing'; + for (let attempt = 0; attempt < OPERATION_STATUS_ATTEMPTS; attempt += 1) { + const operation = await operations.get(scope, operationId); + lastStatus = operation?.status ?? 'missing'; + if (lastStatus === COMPLETED_OPERATION_STATUS) return; + if (FAILED_OPERATION_STATUSES.has(lastStatus)) { + throw new Error(`Hindsight operation ${operationId} ${lastStatus}`); + } + } + throw new Error( + `Hindsight operation ${operationId} did not complete: ${lastStatus}`, + ); +} + +function retainOperationIds(retained: HindsightRetainResponse): string[] { + return [ + retained.operation_id, + ...(retained.operation_ids ?? []), + ].filter((id): id is string => typeof id === 'string' && id.length > 0); +} + +function requireApiUrl(): string { + if (!apiUrl) throw new Error('HINDSIGHT_TEST_API_URL is required'); + return apiUrl; +} + +function uniqueScope(): Scope { + return { + user: `atomicmemory-live-${randomUUID()}`, + agent: 'sdk', + }; +} diff --git a/src/memory/hindsight-provider/__tests__/hindsight-provider.test.ts b/src/memory/hindsight-provider/__tests__/hindsight-provider.test.ts new file mode 100644 index 0000000..830f747 --- /dev/null +++ b/src/memory/hindsight-provider/__tests__/hindsight-provider.test.ts @@ -0,0 +1,382 @@ +/** + * @file Hindsight Provider Unit Tests + * + * Tests the HindsightProvider against a mocked globalThis.fetch, covering + * endpoint routing, scope-to-bank/tag mapping, response mappers, extension + * discovery, and provider capability declarations without requiring a live + * Hindsight service. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HindsightProvider } from '../hindsight-provider'; +import type { + HindsightOperationsHandle, + HindsightRetainHandle, +} from '../types'; +import { UnsupportedOperationError } from '../../errors'; +import type { + IngestInput, + ListRequest, + PackageRequest, + Scope, + SearchRequest, +} from '../../types'; +import { + errorResponse, + installFetchMock, + jsonResponse, +} from '../../__tests__/shared/http-mocks'; + +const API_URL = 'https://api.hindsight.vectorize.io'; +const VALID_SCOPE: Scope = { + user: 'user-1', + agent: 'agent-1', + namespace: 'ns-1', + thread: 'thread-1', +}; + +let mockFetch: ReturnType; + +beforeEach(() => { + mockFetch = installFetchMock(); +}); + +function createProvider( + overrides: Partial[0]> = {}, +): HindsightProvider { + return new HindsightProvider({ apiUrl: API_URL, ...overrides }); +} + +function requestBody(callIndex = 0): Record { + return JSON.parse(String(mockFetch.mock.calls[callIndex][1].body)); +} + +function requestUrl(callIndex = 0): string { + return String(mockFetch.mock.calls[callIndex][0]); +} + +describe('ingest', () => { + it('posts text retain requests to the default project route', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce(jsonResponse({ success: true })); + + const result = await provider.ingest(textInput()); + + expect(requestUrl()).toBe(`${API_URL}/v1/default/banks/user-1/memories`); + expect(requestBody().items).toEqual([ + expect.objectContaining({ content: 'Alice likes Python' }), + ]); + expect(result).toEqual({ created: [], updated: [], unchanged: [] }); + }); + + it('honors custom apiVersion and projectId route segments', async () => { + const provider = createProvider({ apiVersion: 'v2', projectId: 'proj' }); + mockFetch.mockResolvedValueOnce(jsonResponse({ success: true })); + + await provider.ingest(textInput()); + + expect(requestUrl()).toBe(`${API_URL}/v2/proj/banks/user-1/memories`); + }); + + it('joins messages into a role-prefixed transcript', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce(jsonResponse({ success: true })); + + await provider.ingest({ + mode: 'messages', + scope: VALID_SCOPE, + messages: [ + { role: 'user', content: 'Question' }, + { role: 'assistant', content: 'Answer' }, + ], + }); + + const items = requestBody().items as Array>; + expect(items[0].content).toBe('user: Question\nassistant: Answer'); + }); + + it('rejects verbatim ingest without calling fetch', async () => { + const provider = createProvider(); + const input: IngestInput = { + mode: 'verbatim', + content: 'Store exactly this.', + scope: VALID_SCOPE, + }; + + await expect(provider.ingest(input)).rejects.toBeInstanceOf( + UnsupportedOperationError, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('treats async retain responses as successful without invented ids', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce( + jsonResponse({ success: true, async: true, operation_id: 'op-1' }), + ); + + const result = await provider.ingest(textInput()); + + expect(result.created).toEqual([]); + expect(result.updated).toEqual([]); + expect(result.unchanged).toEqual([]); + }); + + it('adds strict scope tags for agent namespace and thread', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce(jsonResponse({ success: true })); + + await provider.ingest(textInput()); + + const items = requestBody().items as Array>; + expect(items[0].tags).toEqual([ + 'agent:agent-1', + 'namespace:ns-1', + 'thread:thread-1', + ]); + }); +}); + +describe('search', () => { + it('posts recall requests with bank routing and strict scope tags', async () => { + const provider = createProvider({ defaultMaxTokens: 123 }); + mockFetch.mockResolvedValueOnce(jsonResponse({ results: [] })); + + await provider.search(searchRequest()); + + expect(requestUrl()).toBe( + `${API_URL}/v1/default/banks/user-1/memories/recall`, + ); + expect(requestBody()).toMatchObject({ + query: 'python', + max_tokens: 123, + tags_match: 'all_strict', + }); + }); + + it('applies SearchRequest.limit as a result count after recall', async () => { + const provider = createProvider({ defaultMaxTokens: 123 }); + mockFetch.mockResolvedValueOnce( + jsonResponse({ + results: [rawMemory(), { ...rawMemory(), id: 'mem-2' }], + }), + ); + + const page = await provider.search({ ...searchRequest(), limit: 1 }); + + expect(requestBody().max_tokens).toBe(123); + expect(page.results).toHaveLength(1); + expect(page.results[0].memory.id).toBe('mem-1'); + }); + + it('maps documented recall result fields into SearchResult', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce(jsonResponse({ results: [rawMemory()] })); + + const page = await provider.search(searchRequest()); + + expect(page.results[0].memory.kind).toBe('fact'); + expect(page.results[0].memory.content).toBe('Alice likes Python'); + expect(page.results[0].score).toBe(0); + expect(page.results[0].memory.metadata?.hindsightType).toBe('world'); + }); + + it('preserves unknown memory types without guessing MemoryKind', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce( + jsonResponse({ results: [{ ...rawMemory(), type: 'opinion' }] }), + ); + + const page = await provider.search(searchRequest()); + + expect(page.results[0].memory.kind).toBeUndefined(); + expect(page.results[0].memory.metadata?.hindsightType).toBe('opinion'); + }); +}); + +describe('package extension', () => { + it('uses request tokenBudget before config defaultMaxTokens', async () => { + const provider = createProvider({ defaultMaxTokens: 99 }); + mockFetch.mockResolvedValueOnce(jsonResponse({ results: [rawMemory()] })); + + const result = await provider.package(packageRequest()); + + expect(requestBody().max_tokens).toBe(11); + expect(result.text).toContain('- [world] Alice likes Python'); + expect(result.budgetConstrained).toBe(false); + }); + + it('falls back to defaultMaxTokens when tokenBudget is absent', async () => { + const provider = createProvider({ defaultMaxTokens: 77 }); + mockFetch.mockResolvedValueOnce(jsonResponse({ results: [] })); + + await provider.package({ ...packageRequest(), tokenBudget: undefined }); + + expect(requestBody().max_tokens).toBe(77); + }); +}); + +describe('reflect extension', () => { + it('maps reflect answers into Insight with supporting memory ids', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce( + jsonResponse({ + text: 'Use Python.', + based_on: { memories: [{ id: 'm1' }] }, + }), + ); + + const insights = await provider.reflect('What language?', VALID_SCOPE); + + expect(requestBody()).toMatchObject({ + query: 'What language?', + tags_match: 'all_strict', + tags: ['agent:agent-1', 'namespace:ns-1', 'thread:thread-1'], + }); + expect(insights).toEqual([ + { content: 'Use Python.', confidence: 0, supportingMemoryIds: ['m1'] }, + ]); + }); + + it('uses zero confidence when Hindsight omits confidence', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce(jsonResponse({ text: 'No confidence.' })); + + const insights = await provider.reflect('q', VALID_SCOPE); + + expect(insights[0].confidence).toBe(0); + }); +}); + +describe('list get delete', () => { + it('lists memories with limit offset and cursor mapping', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce( + jsonResponse({ items: [rawMemory()], total: 3 }), + ); + + const page = await provider.list(listRequest()); + + expect(new URL(requestUrl()).searchParams.get('offset')).toBe('1'); + expect(page.cursor).toBe('2'); + expect(page.memories[0].id).toBe('mem-1'); + }); + + it('returns null for get 404 responses', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce(errorResponse(404)); + + const memory = await provider.get({ id: 'missing', scope: VALID_SCOPE }); + + expect(memory).toBeNull(); + }); + + it('ignores delete 404 responses', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce(errorResponse(404)); + + await expect( + provider.delete({ id: 'missing', scope: VALID_SCOPE }), + ).resolves.toBeUndefined(); + }); +}); + +describe('capabilities and extensions', () => { + it('declares Hindsight-supported capabilities and custom handles', () => { + const provider = createProvider(); + const caps = provider.capabilities(); + + expect(caps.ingestModes).toEqual(['text', 'messages']); + expect(caps.extensions.package).toBe(true); + expect(caps.extensions.reflect).toBe(true); + expect(caps.customExtensions).toHaveProperty('hindsight.retain'); + expect(caps.customExtensions).toHaveProperty('hindsight.operations'); + }); + + it('resolves retain and operations custom extension handles', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValue(errorResponse(404)); + + const retain = + provider.getExtension('hindsight.retain'); + const operations = provider.getExtension( + 'hindsight.operations', + ); + + expect(retain).toBeTruthy(); + expect(await operations?.get(VALID_SCOPE, 'missing')).toBeNull(); + }); + + it('rejects operations without required user scope', async () => { + const provider = createProvider(); + + await expect(provider.search({ query: 'q', scope: {} })).rejects.toThrow( + /requires scope fields: user/, + ); + }); +}); + +describe('http behavior', () => { + it('strips trailing slashes from apiUrl', async () => { + const provider = createProvider({ apiUrl: `${API_URL}///` }); + mockFetch.mockResolvedValueOnce(jsonResponse({ results: [] })); + + await provider.search(searchRequest()); + + expect(requestUrl()).toBe( + `${API_URL}/v1/default/banks/user-1/memories/recall`, + ); + }); + + it('adds Authorization header when apiKey is configured', async () => { + const provider = createProvider({ apiKey: 'secret' }); + mockFetch.mockResolvedValueOnce(jsonResponse({ results: [] })); + + await provider.search(searchRequest()); + + expect(mockFetch.mock.calls[0][1].headers.Authorization).toBe( + 'Bearer secret', + ); + }); + + it('omits Authorization header when apiKey is absent', async () => { + const provider = createProvider(); + mockFetch.mockResolvedValueOnce(jsonResponse({ results: [] })); + + await provider.search(searchRequest()); + + expect(mockFetch.mock.calls[0][1].headers.Authorization).toBeUndefined(); + }); +}); + +function textInput(): IngestInput { + return { + mode: 'text', + content: 'Alice likes Python', + scope: VALID_SCOPE, + provenance: { source: 'sdk-test' }, + }; +} + +function searchRequest(): SearchRequest { + return { query: 'python', scope: VALID_SCOPE, limit: 5 }; +} + +function packageRequest(): PackageRequest { + return { query: 'python', scope: VALID_SCOPE, tokenBudget: 11 }; +} + +function listRequest(): ListRequest { + return { scope: VALID_SCOPE, limit: 1, cursor: '1' }; +} + +function rawMemory(): Record { + return { + id: 'mem-1', + text: 'Alice likes Python', + type: 'world', + context: 'profile', + tags: ['agent:agent-1'], + created_at: '2026-05-13T00:00:00.000Z', + }; +} diff --git a/src/memory/hindsight-provider/hindsight-provider.ts b/src/memory/hindsight-provider/hindsight-provider.ts new file mode 100644 index 0000000..3401926 --- /dev/null +++ b/src/memory/hindsight-provider/hindsight-provider.ts @@ -0,0 +1,397 @@ +/** + * @file Hindsight Memory Provider + * + * HTTP-backed `MemoryProvider` implementation for Hindsight Cloud or a + * self-hosted Hindsight API. The adapter keeps Hindsight-specific request and + * response shapes inside this provider while exposing the SDK's backend-neutral + * memory contract plus standard package, reflect, and health extensions. + */ + +import { BaseMemoryProvider } from '../provider'; +import type { Health, Packager, Reflector } from '../provider'; +import { MemoryProviderError, UnsupportedOperationError } from '../errors'; +import type { + Capabilities, + ContextPackage, + HealthStatus, + IngestInput, + IngestResult, + Insight, + ListRequest, + ListResultPage, + Memory, + MemoryRef, + PackageRequest, + Scope, + SearchRequest, + SearchResultPage, +} from '../types'; +import { fetchJson, fetchJsonOrNull, deleteIgnore404 } from './http'; +import type { HttpOptions } from './http'; +import type { + HindsightOperation, + HindsightOperationsHandle, + HindsightOperationsPage, + HindsightProviderConfig, + HindsightRetainHandle, + HindsightRetainResponse, +} from './types'; +import type { + RawHealthResponse, + RawListResponse, + RawOperationsResponse, + RawOperationStatusResponse, + RawReflectResponse, +} from './wire-types'; +import { + HINDSIGHT_DEFAULT_API_VERSION, + HINDSIGHT_DEFAULT_PROJECT_ID, + HINDSIGHT_DEFAULT_TIMEOUT, + HINDSIGHT_SCOPE_TAGS_MATCH, +} from './types'; +import { + bankIdForScope, + buildRecallRequest, + buildRetainRequest, + estimateTokens, + tagsForScope, + toMemory, + toSearchResult, + unwrapResults, +} from './mappers'; + +export class HindsightProvider + extends BaseMemoryProvider + implements Packager, Reflector, Health +{ + readonly name = 'hindsight'; + private readonly http: HttpOptions; + private readonly config: HindsightProviderConfig; + private readonly apiVersion: string; + private readonly projectId: string; + private readonly retainHandle: HindsightRetainHandle; + private readonly operationsHandle: HindsightOperationsHandle; + + constructor(config: HindsightProviderConfig) { + super(); + this.config = config; + this.http = { + apiUrl: config.apiUrl.replace(/\/+$/, ''), + apiKey: config.apiKey, + timeout: config.timeout ?? HINDSIGHT_DEFAULT_TIMEOUT, + }; + this.apiVersion = normalizeSegment( + config.apiVersion ?? HINDSIGHT_DEFAULT_API_VERSION, + ); + this.projectId = normalizeSegment( + config.projectId ?? HINDSIGHT_DEFAULT_PROJECT_ID, + ); + this.retainHandle = this.createRetainHandle(); + this.operationsHandle = this.createOperationsHandle(); + } + + protected async doIngest(input: IngestInput): Promise { + if (input.mode === 'verbatim') { + throw new UnsupportedOperationError('hindsight', 'ingest(verbatim)'); + } + await this.retain(input); + return { created: [], updated: [], unchanged: [] }; + } + + protected async doSearch(request: SearchRequest): Promise { + const raw = await this.recallRaw(request); + const results = unwrapResults(raw).map((row) => + toSearchResult(row, request.scope), + ); + return { + results: + request.limit === undefined ? results : results.slice(0, request.limit), + }; + } + + protected async doGet(ref: MemoryRef): Promise { + const raw = await fetchJsonOrNull>( + this.http, + this.memoryPath(ref.scope, ref.id), + ); + return raw ? toMemory(raw, ref.scope) : null; + } + + protected async doDelete(ref: MemoryRef): Promise { + await deleteIgnore404(this.http, this.memoryPath(ref.scope, ref.id)); + } + + protected async doList(request: ListRequest): Promise { + const page = resolveListPage(request); + const raw = await fetchJson( + this.http, + `${this.bankPath(request.scope)}/memories/list?${page.query}`, + ); + return this.mapListPage(raw, request.scope, page.offset, page.limit); + } + + capabilities(): Capabilities { + return { + ingestModes: ['text', 'messages'], + requiredScope: { default: ['user'] }, + extensions: { + update: false, + package: true, + temporal: false, + graph: false, + forget: false, + profile: false, + reflect: true, + versioning: false, + batch: false, + health: true, + }, + customExtensions: { + 'hindsight.retain': { + version: '1.0.0', + description: 'Raw Hindsight retain response and operation metadata.', + }, + 'hindsight.operations': { + version: '1.0.0', + description: 'Hindsight async operation status helpers.', + }, + }, + }; + } + + override getExtension(name: string): T | undefined { + switch (name) { + case 'hindsight.retain': + return this.retainHandle as T; + case 'hindsight.operations': + return this.operationsHandle as T; + default: + return super.getExtension(name); + } + } + + async package(request: PackageRequest): Promise { + return this.runOperation('package', request.scope, async () => { + const raw = await this.recallRaw(request, request.tokenBudget); + const results = unwrapResults(raw).map((row) => + toSearchResult(row, request.scope), + ); + const text = formatPackageText(results.map((result) => result.memory)); + return { + text, + results, + tokens: estimateTokens(text), + budgetConstrained: false, + }; + }); + } + + async reflect(query: string, scope: Scope): Promise { + return this.runOperation('reflect', scope, async () => { + const raw = await fetchJson( + this.http, + `${this.bankPath(scope)}/reflect`, + { method: 'POST', body: JSON.stringify(buildReflectBody(query, scope)) }, + ); + return [toInsight(raw)]; + }); + } + + async health(): Promise { + const start = Date.now(); + try { + const raw = await fetchJson(this.http, '/health'); + return { + ok: isHealthy(raw), + latencyMs: Date.now() - start, + version: typeof raw.version === 'string' ? raw.version : undefined, + }; + } catch { + return { ok: false, latencyMs: Date.now() - start }; + } + } + + private async retain(input: IngestInput): Promise { + const raw = await fetchJson( + this.http, + `${this.bankPath(input.scope)}/memories`, + { method: 'POST', body: JSON.stringify(buildRetainRequest(input)) }, + ); + assertRetainSucceeded(raw); + return raw; + } + + private async recallRaw( + request: SearchRequest, + maxTokens?: number, + ): Promise { + const body = buildRecallRequest( + request.query, + request.scope, + this.config, + maxTokens, + ); + return fetchJson( + this.http, + `${this.bankPath(request.scope)}/memories/recall`, + { + method: 'POST', + body: JSON.stringify(body), + }, + ); + } + + private mapListPage( + raw: RawListResponse, + scope: Scope, + offset: number, + limit: number, + ): ListResultPage { + const rows = raw.items; + const nextOffset = offset + rows.length; + const total = raw.total; + const hasMore = + typeof total === 'number' ? nextOffset < total : rows.length === limit; + return { + memories: rows.map((row) => toMemory(row, scope)), + cursor: hasMore ? String(nextOffset) : undefined, + }; + } + + private createRetainHandle(): HindsightRetainHandle { + return { + retain: (input: IngestInput) => + this.runOperation('hindsight.retain', input.scope, () => + this.retain(input), + ), + }; + } + + private createOperationsHandle(): HindsightOperationsHandle { + return { + list: (scope: Scope) => + this.runOperation('hindsight.operations', scope, () => + this.listOperations(scope), + ), + get: (scope: Scope, operationId: string) => + this.runOperation('hindsight.operations', scope, () => + this.getOperation(scope, operationId), + ), + }; + } + + private async listOperations(scope: Scope): Promise { + const raw = await fetchJson( + this.http, + `${this.bankPath(scope)}/operations`, + ); + return { bank_id: raw.bank_id, operations: raw.operations }; + } + + private async getOperation( + scope: Scope, + operationId: string, + ): Promise { + const raw = await fetchJsonOrNull( + this.http, + `${this.bankPath(scope)}/operations/${encodeURIComponent(operationId)}`, + ); + return raw ? normalizeOperation(raw) : null; + } + + private bankPath(scope: Scope): string { + return this.route(`/banks/${encodeURIComponent(bankIdForScope(scope))}`); + } + + private memoryPath(scope: Scope, memoryId: string): string { + return `${this.bankPath(scope)}/memories/${encodeURIComponent(memoryId)}`; + } + + private route(path: string): string { + return `/${this.apiVersion}/${this.projectId}${path}`; + } +} + +function normalizeSegment(segment: string): string { + return segment.replace(/^\/+|\/+$/g, ''); +} + +function buildReflectBody(query: string, scope: Scope): Record { + const tags = tagsForScope(scope); + return { + query, + ...(tags.length > 0 + ? { tags, tags_match: HINDSIGHT_SCOPE_TAGS_MATCH } + : {}), + }; +} + +function resolveListPage( + request: ListRequest, +): { limit: number; offset: number; query: string } { + const limit = request.limit ?? 20; + const offset = request.cursor ? parseInt(request.cursor, 10) : 0; + const query = new URLSearchParams({ + limit: String(limit), + offset: String(offset), + }).toString(); + return { limit, offset, query }; +} + +function assertRetainSucceeded(raw: HindsightRetainResponse): void { + if (raw.success === false) { + throw new MemoryProviderError( + `Hindsight retain failed: ${retainFailureContext(raw)}`, + 'hindsight', + 'ingest', + ); + } +} + +function retainFailureContext(raw: HindsightRetainResponse): string { + const ids = raw.operation_ids?.join(',') ?? raw.operation_id ?? 'none'; + return `operation_id=${ids}, items_count=${raw.items_count ?? 'unknown'}, async=${raw.async ?? 'unknown'}`; +} + +function normalizeOperation(raw: RawOperationStatusResponse): HindsightOperation { + return { + id: raw.operation_id, + task_type: raw.operation_type ?? undefined, + created_at: raw.created_at ?? undefined, + status: raw.status, + error_message: raw.error_message ?? null, + retry_count: raw.retry_count ?? undefined, + next_retry_at: raw.next_retry_at ?? undefined, + }; +} + +function formatPackageText(memories: Memory[]): string { + if (memories.length === 0) return ''; + const lines = memories.map((memory) => { + const type = String( + memory.metadata?.hindsightType ?? memory.kind ?? 'memory', + ); + return `- [${type}] ${memory.content}`; + }); + return ['Relevant memories:', ...lines].join('\n'); +} + +function toInsight(raw: RawReflectResponse): Insight { + const ids = raw.based_on?.memories?.flatMap(supportingId) ?? []; + return { + content: raw.text, + // Hindsight does not expose per-answer confidence; 0 is a sentinel. + confidence: 0, + supportingMemoryIds: ids, + }; +} + +function supportingId(item: { id?: string | null }): string[] { + const id = item.id; + return id ? [id] : []; +} + +function isHealthy(raw: RawHealthResponse): boolean { + if (typeof raw.ok === 'boolean') return raw.ok; + return raw.status === undefined || ['ok', 'healthy'].includes(raw.status); +} diff --git a/src/memory/hindsight-provider/http.ts b/src/memory/hindsight-provider/http.ts new file mode 100644 index 0000000..4fc9091 --- /dev/null +++ b/src/memory/hindsight-provider/http.ts @@ -0,0 +1,16 @@ +/** + * @file HTTP Helper for Hindsight Provider + * + * Re-exports shared HTTP helpers bound to the `hindsight` provider name so + * errors, rate limits, auth headers, and timeouts are classified consistently + * with the SDK's other HTTP-backed memory providers. + */ + +import { createHttpClient } from '../shared/http-client'; +export type { HttpOptions } from '../shared/http-client'; + +const client = createHttpClient('hindsight'); + +export const fetchJson = client.fetchJson; +export const fetchJsonOrNull = client.fetchJsonOrNull; +export const deleteIgnore404 = client.deleteIgnore404; diff --git a/src/memory/hindsight-provider/index.ts b/src/memory/hindsight-provider/index.ts new file mode 100644 index 0000000..b0b90d8 --- /dev/null +++ b/src/memory/hindsight-provider/index.ts @@ -0,0 +1,28 @@ +/** + * @file Hindsight Provider Exports + * + * Browser-safe exports for the Hindsight memory provider. This entry point + * intentionally re-exports only provider code, types, and extension handles + * that depend on the shared fetch-based HTTP helper rather than Node-only APIs. + */ + +export { HindsightProvider } from './hindsight-provider'; +export type { + HindsightProviderConfig, + HindsightRecallBudget, + HindsightTagsMatch, + HindsightRetainItem, + HindsightRetainRequest, + HindsightRetainResponse, + HindsightOperation, + HindsightOperationsPage, + HindsightRetainHandle, + HindsightOperationsHandle, +} from './types'; +export { + HINDSIGHT_DEFAULT_TIMEOUT, + HINDSIGHT_DEFAULT_API_VERSION, + HINDSIGHT_DEFAULT_PROJECT_ID, + HINDSIGHT_DEFAULT_MAX_TOKENS, + HINDSIGHT_SCOPE_TAGS_MATCH, +} from './types'; diff --git a/src/memory/hindsight-provider/mappers.ts b/src/memory/hindsight-provider/mappers.ts new file mode 100644 index 0000000..2f0a6a4 --- /dev/null +++ b/src/memory/hindsight-provider/mappers.ts @@ -0,0 +1,200 @@ +/** + * @file Hindsight Provider Request and Response Mappers + * + * Converts SDK memory provider inputs into Hindsight retain/recall requests + * and maps Hindsight memory wire objects back into SDK `Memory` and + * `SearchResult` values. Unknown provider fields are preserved in metadata + * where they may help debugging without expanding the SDK's backend-agnostic + * public model. + */ + +import type { + IngestInput, + Message, + Memory, + MemoryKind, + Scope, + SearchResult, +} from '../types'; +import type { + HindsightProviderConfig, + HindsightRecallBudget, + HindsightRetainItem, + HindsightRetainRequest, +} from './types'; +import { + HINDSIGHT_DEFAULT_MAX_TOKENS, + HINDSIGHT_SCOPE_TAGS_MATCH, +} from './types'; + +export interface HindsightRecallRequestBody { + query: string; + max_tokens?: number; + budget?: HindsightRecallBudget; + tags?: string[]; + tags_match?: string; +} + +interface RawHindsightMemory { + id?: string; + text?: string; + type?: string; + context?: string | null; + metadata?: Record | null; + tags?: string[] | null; + entities?: string[] | null; + occurred_start?: string | null; + occurred_end?: string | null; + mentioned_at?: string | null; + created_at?: string | null; + date?: string | null; + updated_at?: string | null; +} + +export function bankIdForScope(scope: Scope): string { + return scope.user ?? ''; +} + +export function tagsForScope(scope: Scope): string[] { + return [ + scope.agent ? `agent:${scope.agent}` : undefined, + scope.namespace ? `namespace:${scope.namespace}` : undefined, + scope.thread ? `thread:${scope.thread}` : undefined, + ].filter((tag): tag is string => tag !== undefined); +} + +export function buildRetainRequest(input: IngestInput): HindsightRetainRequest { + return { + items: [buildRetainItem(input)], + async: false, + }; +} + +export function buildRecallRequest( + query: string, + scope: Scope, + config: HindsightProviderConfig, + maxTokens?: number, +): HindsightRecallRequestBody { + const tags = tagsForScope(scope); + return { + query, + max_tokens: + maxTokens ?? config.defaultMaxTokens ?? HINDSIGHT_DEFAULT_MAX_TOKENS, + budget: config.defaultBudget, + ...(tags.length > 0 + ? { tags, tags_match: HINDSIGHT_SCOPE_TAGS_MATCH } + : {}), + }; +} + +export function unwrapResults(raw: unknown): Record[] { + if (!raw || typeof raw !== 'object') { + throw new Error('Hindsight recall response missing results array'); + } + const results = (raw as Record).results; + if (Array.isArray(results)) return results as Record[]; + throw new Error('Hindsight recall response missing results array'); +} + +export function toMemory(raw: Record, scope: Scope): Memory { + const memory = raw as RawHindsightMemory; + const id = requireString(memory.id, 'id'); + return { + id, + content: requireString(memory.text, `text for memory ${id}`), + scope, + kind: mapMemoryKind(memory.type), + createdAt: parseMemoryDate(memory), + updatedAt: memory.updated_at ? new Date(memory.updated_at) : undefined, + metadata: buildMetadata(memory), + }; +} + +export function toSearchResult( + raw: Record, + scope: Scope, +): SearchResult { + return { + memory: toMemory(raw, scope), + score: 0, + }; +} + +export function messagesToTranscript(messages: Message[]): string { + return messages + .map((message) => `${message.role}: ${message.content}`) + .join('\n'); +} + +export function estimateTokens(text: string): number { + if (text.length === 0) return 0; + return Math.ceil(text.length / 4); +} + +function buildRetainItem(input: IngestInput): HindsightRetainItem { + const metadata = buildIngestMetadata(input); + return { + content: + input.mode === 'messages' + ? messagesToTranscript(input.messages) + : input.content, + context: input.provenance?.source, + ...(Object.keys(metadata).length > 0 ? { metadata } : {}), + tags: tagsForScope(input.scope), + }; +} + +function buildIngestMetadata(input: IngestInput): Record { + return { + ...(input.metadata ?? {}), + ...(input.provenance?.source ? { source: input.provenance.source } : {}), + ...(input.provenance?.sourceUrl + ? { sourceUrl: input.provenance.sourceUrl } + : {}), + ...(input.provenance?.sourceId + ? { sourceId: input.provenance.sourceId } + : {}), + }; +} + +function buildMetadata(memory: RawHindsightMemory): Memory['metadata'] { + const metadata: Record = { ...(memory.metadata ?? {}) }; + if (memory.type !== undefined) metadata.hindsightType = memory.type; + if (memory.context != null) metadata.context = memory.context; + if (memory.tags != null) metadata.tags = memory.tags; + if (memory.entities != null) metadata.entities = memory.entities; + if (memory.occurred_start != null) { + metadata.occurredStart = memory.occurred_start; + } + if (memory.occurred_end != null) metadata.occurredEnd = memory.occurred_end; + if (memory.mentioned_at != null) metadata.mentionedAt = memory.mentioned_at; + if (memory.date != null) metadata.hindsightDate = memory.date; + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + +function mapMemoryKind(type: string | undefined): MemoryKind | undefined { + switch (type) { + case 'world': + return 'fact'; + case 'experience': + return 'episode'; + case 'observation': + return 'summary'; + default: + return undefined; + } +} + +function parseMemoryDate(memory: RawHindsightMemory): Date { + const value = memory.created_at ?? memory.mentioned_at ?? memory.date; + if (value) return new Date(value); + throw new Error( + `Hindsight memory ${memory.id ?? ''} missing timestamp field`, + ); +} + +function requireString(value: unknown, field: string): string { + if (typeof value === 'string' && value.length > 0) return value; + throw new Error(`Hindsight response missing required ${field}`); +} diff --git a/src/memory/hindsight-provider/types.ts b/src/memory/hindsight-provider/types.ts new file mode 100644 index 0000000..f9cb975 --- /dev/null +++ b/src/memory/hindsight-provider/types.ts @@ -0,0 +1,86 @@ +/** + * @file Hindsight Provider Configuration and Wire Types + * + * Defines the public configuration surface and provider-specific extension + * handle types for the Hindsight memory backend. The core SDK provider API + * remains backend-agnostic; these types are exported so callers that opt into + * Hindsight-specific operation metadata can do so through named extensions. + */ + +import type { IngestInput, Scope } from '../types'; + +export type HindsightRecallBudget = 'low' | 'mid' | 'high'; +export type HindsightTagsMatch = 'any' | 'all' | 'any_strict' | 'all_strict'; + +export interface HindsightProviderConfig { + /** Hindsight API base URL, e.g. `https://api.hindsight.vectorize.io`. */ + apiUrl: string; + /** Optional bearer token for Hindsight Cloud or protected self-hosted APIs. */ + apiKey?: string; + /** Request timeout in milliseconds. Defaults to 30_000. */ + timeout?: number; + /** API version path segment. Defaults to `v1`. */ + apiVersion?: string; + /** Hindsight project path segment. Defaults to `default`. */ + projectId?: string; + /** Recall search depth fallback. Request-level typed override is deferred. */ + defaultBudget?: HindsightRecallBudget; + /** Fallback context token budget for package/recall requests. */ + defaultMaxTokens?: number; +} + +export interface HindsightRetainItem { + content: string; + context?: string; + timestamp?: string; + metadata?: Record; + tags?: string[]; +} + +export interface HindsightRetainRequest { + items: HindsightRetainItem[]; + async?: boolean; +} + +export interface HindsightRetainResponse { + success?: boolean; + bank_id?: string; + items_count?: number; + async?: boolean; + operation_id?: string; + operation_ids?: string[]; + usage?: Record; +} + +export interface HindsightOperation { + id: string; + task_type?: string; + items_count?: number; + document_id?: string | null; + created_at?: string; + status?: string; + error_message?: string | null; + retry_count?: number; + next_retry_at?: string; +} + +export interface HindsightOperationsPage { + bank_id?: string; + operations: HindsightOperation[]; +} + +export interface HindsightRetainHandle { + retain(input: IngestInput): Promise; +} + +export interface HindsightOperationsHandle { + list(scope: Scope): Promise; + get(scope: Scope, operationId: string): Promise; +} + +export const HINDSIGHT_DEFAULT_TIMEOUT = 30_000; +export const HINDSIGHT_DEFAULT_API_VERSION = 'v1'; +export const HINDSIGHT_DEFAULT_PROJECT_ID = 'default'; +/** Hindsight's documented default source-fact token budget for reflect/recall examples. */ +export const HINDSIGHT_DEFAULT_MAX_TOKENS = 4_096; +export const HINDSIGHT_SCOPE_TAGS_MATCH: HindsightTagsMatch = 'all_strict'; diff --git a/src/memory/hindsight-provider/wire-types.ts b/src/memory/hindsight-provider/wire-types.ts new file mode 100644 index 0000000..223a992 --- /dev/null +++ b/src/memory/hindsight-provider/wire-types.ts @@ -0,0 +1,41 @@ +/** + * @file Hindsight Provider Wire Types + * + * Contains the narrow response shapes consumed directly by the Hindsight + * provider implementation. These are intentionally provider-internal and map + * only documented Hindsight OpenAPI fields, keeping speculative aliases out of + * the backend-neutral SDK memory contract. + */ + +import type { HindsightOperation } from './types'; + +export interface RawListResponse { + items: Record[]; + total: number; +} + +export interface RawReflectResponse { + text: string; + based_on?: { memories?: Array<{ id?: string | null }> } | null; +} + +export interface RawHealthResponse { + status?: string; + ok?: boolean; + version?: string; +} + +export interface RawOperationsResponse { + bank_id?: string; + operations: HindsightOperation[]; +} + +export interface RawOperationStatusResponse { + operation_id: string; + operation_type?: string | null; + created_at?: string | null; + status?: string; + error_message?: string | null; + retry_count?: number | null; + next_retry_at?: string | null; +} diff --git a/src/memory/index.ts b/src/memory/index.ts index e55a3cf..c494f85 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -13,3 +13,4 @@ export * from './pipeline'; export * from './registration'; export * from './atomicmemory-provider'; export * from './mem0-provider'; +export * from './hindsight-provider'; diff --git a/src/memory/providers/registry.ts b/src/memory/providers/registry.ts index 0808fa3..891801d 100644 --- a/src/memory/providers/registry.ts +++ b/src/memory/providers/registry.ts @@ -10,6 +10,8 @@ import type { AtomicMemoryProviderConfig } from '../atomicmemory-provider/types' import { AtomicMemoryProvider } from '../atomicmemory-provider/atomicmemory-provider'; import type { Mem0ProviderConfig } from '../mem0-provider/types'; import { Mem0Provider } from '../mem0-provider/mem0-provider'; +import type { HindsightProviderConfig } from '../hindsight-provider/types'; +import { HindsightProvider } from '../hindsight-provider/hindsight-provider'; export type ProviderRegistry = Record< string, @@ -23,4 +25,7 @@ export const defaultRegistry: ProviderRegistry = { mem0: (config: Mem0ProviderConfig): MemoryProviderRegistration => ({ provider: new Mem0Provider(config), }), + hindsight: (config: HindsightProviderConfig): MemoryProviderRegistration => ({ + provider: new HindsightProvider(config), + }), }; diff --git a/src/storage/__tests__/types.test.ts b/src/storage/__tests__/types.test.ts index 69036f5..3379ca4 100644 --- a/src/storage/__tests__/types.test.ts +++ b/src/storage/__tests__/types.test.ts @@ -1,7 +1,6 @@ /** * @file Compile-time negative-contract tests for the storage artifact - * type surface. Mirrors the Phase 7b pattern from the Filecoin work: - * each `@ts-expect-error` directive proves that a specific shape is + * type surface. Each `@ts-expect-error` directive proves that a specific shape is * REJECTED by the type system today. A future regression that * loosens the types would invalidate the directive itself * ("unused directive"), failing `pnpm typecheck`. diff --git a/src/storage/types.ts b/src/storage/types.ts index 2bdf4e3..b7518bc 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -101,7 +101,7 @@ export interface StorageLifecycle { deleteSemantics?: StorageDeleteSemantics; } -/** Optional replication state — populated by Filecoin / FOC etc. */ +/** Optional replication state for content-addressed storage backends. */ export interface ReplicationState { desiredCopies?: number; confirmedCopies?: number; diff --git a/src/types/storage.ts b/src/types/storage.ts index 21b9c5c..b0b3ea6 100644 --- a/src/types/storage.ts +++ b/src/types/storage.ts @@ -2,9 +2,8 @@ * @file KV cache Type Definitions (placeholder) * * Type definitions for KV / cache storage interfaces. These types are - * available directly from '../kv-cache/storage-adapter'. The directory was - * renamed from `./storage` to `./kv-cache` in the storage-sibling rev so the - * new storage API can own `./storage` — see plan rev-6 finding 1. + * available directly from '../kv-cache/storage-adapter'. Artifact-storage + * integrations should use the public `./storage` subpath. */ export {};