fix(memory)!: rename LocalMemoryStore to TestMemoryStore (#3123)

This commit is contained in:
opieter-aws
2026-07-09 11:04:16 -04:00
committed by GitHub
parent 802acf705c
commit 6c58f97ca6
11 changed files with 144 additions and 121 deletions
@@ -9,7 +9,7 @@ from typing import Any
__all__ = [
"BedrockKnowledgeBaseStore",
"LocalMemoryStore",
"TestMemoryStore",
]
@@ -22,8 +22,8 @@ def __getattr__(name: str) -> Any:
from .bedrock_knowledge_base import BedrockKnowledgeBaseStore
return BedrockKnowledgeBaseStore
if name == "LocalMemoryStore":
from .local import LocalMemoryStore
if name == "TestMemoryStore":
from .test_memory_store import TestMemoryStore
return LocalMemoryStore
return TestMemoryStore
raise AttributeError(f"cannot import name '{name}' from '{__name__}' ({__file__})")
@@ -5,17 +5,17 @@ resources required. Persists to disk by default so an agent remembers across res
Example:
```python
from strands.vended_memory_stores.local import LocalMemoryStore
from strands.vended_memory_stores.test_memory_store import TestMemoryStore
store = LocalMemoryStore(name="notes")
store = TestMemoryStore(name="notes")
```
"""
from .store import LocalMemoryStore
from .types import LocalMemoryAddResult, LocalMemoryStoreConfig
from .store import TestMemoryStore
from .types import TestMemoryAddResult, TestMemoryStoreConfig
__all__ = [
"LocalMemoryAddResult",
"LocalMemoryStore",
"LocalMemoryStoreConfig",
"TestMemoryAddResult",
"TestMemoryStore",
"TestMemoryStoreConfig",
]
@@ -17,7 +17,7 @@ from typing import Any
from typing_extensions import Unpack
from ...memory.types import MemoryEntry, MemoryStore, Metadata, SearchOptions
from .types import LocalMemoryAddResult, LocalMemoryStoreConfig
from .types import TestMemoryAddResult, TestMemoryStoreConfig
DEFAULT_MAX_SEARCH_RESULTS = 10
@@ -68,11 +68,11 @@ def _token_overlap_score(query_tokens: set[str], content: str) -> int:
return len(query_tokens & _tokenize(content))
class LocalMemoryStore(MemoryStore):
class TestMemoryStore(MemoryStore):
"""A :class:`~strands.memory.types.MemoryStore` backed by an in-memory list and a local JSON file.
A zero-infrastructure store for prototyping and testing. It persists to disk by default so memories persist
accross sessions. Set ``persist=False`` for an ephemeral, single-session store.
across sessions. Set ``persist=False`` for an ephemeral, single-session store.
Recall is lexical: results are ranked by how many query tokens overlap an entry's content, with
the most recent entry winning ties. This is keyword matching, not the semantic search a managed
@@ -83,27 +83,30 @@ class LocalMemoryStore(MemoryStore):
of entries), not production workloads — use a managed store like ``BedrockKnowledgeBaseStore`` for
that. Writes within a process are serialized; concurrent writers across processes are not.
The on-disk format is shared with the TypeScript SDK's ``LocalMemoryStore``: records use the same
The on-disk format is shared with the TypeScript SDK's ``TestMemoryStore``: records use the same
camelCase keys (``id``, ``content``, ``metadata``, ``createdAt``) and the same timestamp shape, so
a backing file written by either SDK can be read by the other.
Example:
```python
from strands.vended_memory_stores.local import LocalMemoryStore
from strands.vended_memory_stores.test_memory_store import TestMemoryStore
# Persists to ~/.strands/memory/notes.json by default.
store = LocalMemoryStore(name="notes")
store = TestMemoryStore(name="notes")
result = await store.add("User prefers dark mode")
results = await store.search("what theme does the user like?")
```
"""
def __init__(self, **store_config: Unpack[LocalMemoryStoreConfig]) -> None:
# Tell pytest not to collect this class as a test suite despite its ``Test`` prefix.
__test__ = False
def __init__(self, **store_config: Unpack[TestMemoryStoreConfig]) -> None:
"""Initialize the store.
Args:
**store_config: See :class:`LocalMemoryStoreConfig`.
**store_config: See :class:`TestMemoryStoreConfig`.
Raises:
ValueError: If ``name`` or ``path`` is empty/whitespace, or ``max_search_results`` is
@@ -111,11 +114,11 @@ class LocalMemoryStore(MemoryStore):
"""
self.name = store_config["name"]
if not self.name.strip():
raise ValueError("LocalMemoryStore: name must not be empty.")
raise ValueError("TestMemoryStore: name must not be empty.")
self.description = store_config.get("description")
max_search_results = store_config.get("max_search_results")
if max_search_results is not None and max_search_results < 1:
raise ValueError("LocalMemoryStore: max_search_results must be at least 1.")
raise ValueError("TestMemoryStore: max_search_results must be at least 1.")
self.max_search_results = max_search_results
# A local store is writable by default: the point is a zero-setup store you can write to.
self.writable = store_config.get("writable", True)
@@ -124,7 +127,7 @@ class LocalMemoryStore(MemoryStore):
self._persist = store_config.get("persist", True)
path = store_config.get("path")
if path is not None and not path.strip():
raise ValueError("LocalMemoryStore: path must not be empty.")
raise ValueError("TestMemoryStore: path must not be empty.")
if not self._persist:
self._path: Path | None = None
elif path is not None:
@@ -155,7 +158,7 @@ class LocalMemoryStore(MemoryStore):
"""
caller_max = options.get("max_search_results") if options is not None else None
if caller_max is not None and caller_max < 1:
raise ValueError("LocalMemoryStore: max_search_results must be at least 1.")
raise ValueError("TestMemoryStore: max_search_results must be at least 1.")
limit = caller_max or self.max_search_results or DEFAULT_MAX_SEARCH_RESULTS
query_tokens = _tokenize(query)
@@ -178,7 +181,7 @@ class LocalMemoryStore(MemoryStore):
entries.append(MemoryEntry(content=record["content"], metadata=metadata))
return entries
async def add(self, content: str, metadata: Metadata | None = None) -> LocalMemoryAddResult:
async def add(self, content: str, metadata: Metadata | None = None) -> TestMemoryAddResult:
"""Add ``content`` (with optional ``metadata``) to the store.
Identical content is deduplicated: a repeat write returns the existing record's id without
@@ -200,9 +203,9 @@ class LocalMemoryStore(MemoryStore):
writable), with the target path in the message.
"""
if not self.writable:
raise ValueError("LocalMemoryStore: store is not writable. Set writable=True in config to enable add().")
raise ValueError("TestMemoryStore: store is not writable. Set writable=True in config to enable add().")
if not content.strip():
raise ValueError("LocalMemoryStore: content must not be empty.")
raise ValueError("TestMemoryStore: content must not be empty.")
# The lock serializes the whole load-modify-flush cycle so concurrent adds don't each load
# the same snapshot and clobber one another (last-write-wins). Within a single event loop the
@@ -214,7 +217,7 @@ class LocalMemoryStore(MemoryStore):
normalized_content = content.strip()
for record in records:
if record["content"].strip() == normalized_content:
return LocalMemoryAddResult(id=record["id"])
return TestMemoryAddResult(id=record["id"])
new_record: dict[str, Any] = {"id": _new_id(), "content": content, "createdAt": _now()}
if metadata is not None:
@@ -225,7 +228,7 @@ class LocalMemoryStore(MemoryStore):
next_records = [*records, new_record]
self._flush(next_records)
self._records = next_records
return LocalMemoryAddResult(id=new_record["id"])
return TestMemoryAddResult(id=new_record["id"])
def _load(self) -> list[dict[str, Any]]:
"""Load records from disk on first use; ephemeral stores (and a missing file) start empty."""
@@ -243,12 +246,12 @@ class LocalMemoryStore(MemoryStore):
self._records = []
return self._records
except json.JSONDecodeError as error:
raise ValueError(f"LocalMemoryStore: invalid JSON in {self._path}: {error}") from error
raise ValueError(f"TestMemoryStore: invalid JSON in {self._path}: {error}") from error
except OSError as error:
raise OSError(f"LocalMemoryStore: failed to read {self._path}: {error}") from error
raise OSError(f"TestMemoryStore: failed to read {self._path}: {error}") from error
if not isinstance(parsed_file, list):
raise ValueError(f"LocalMemoryStore: invalid backing file {self._path}: expected a JSON array of records")
raise ValueError(f"TestMemoryStore: invalid backing file {self._path}: expected a JSON array of records")
for record in parsed_file:
if (
not isinstance(record, dict)
@@ -257,7 +260,7 @@ class LocalMemoryStore(MemoryStore):
or not isinstance(record.get("createdAt"), str)
):
raise ValueError(
f"LocalMemoryStore: invalid backing file {self._path}: "
f"TestMemoryStore: invalid backing file {self._path}: "
"each record must have string 'id', 'content', and 'createdAt' fields"
)
self._records = parsed_file
@@ -282,4 +285,4 @@ class LocalMemoryStore(MemoryStore):
json.dump(records, file, indent=2, ensure_ascii=False)
tmp_path.replace(self._path)
except OSError as error:
raise OSError(f"LocalMemoryStore: failed to write {self._path}: {error}") from error
raise OSError(f"TestMemoryStore: failed to write {self._path}: {error}") from error
@@ -1,4 +1,4 @@
"""Configuration and result types for the local memory store."""
"""Configuration and result types for the JSON-file memory store."""
from __future__ import annotations
@@ -7,8 +7,8 @@ from dataclasses import dataclass
from ...memory.types import MemoryStoreConfig
class LocalMemoryStoreConfig(MemoryStoreConfig, total=False):
"""Full configuration for a :class:`LocalMemoryStore`, passed as its constructor kwargs.
class TestMemoryStoreConfig(MemoryStoreConfig, total=False):
"""Full configuration for a :class:`TestMemoryStore`, passed as its constructor kwargs.
The store persists to disk by default so memories persist across sessions.
Set ``persist`` to ``False`` for an ephemeral, single-session store.
@@ -25,12 +25,20 @@ class LocalMemoryStoreConfig(MemoryStoreConfig, total=False):
path: str
# Tell pytest not to collect this class as a test suite despite its ``Test`` prefix. A TypedDict
# rejects a ``__test__`` entry in its body, so it is assigned after the class instead.
TestMemoryStoreConfig.__test__ = False # type: ignore[attr-defined]
@dataclass
class LocalMemoryAddResult:
"""Result returned by :meth:`LocalMemoryStore.add`.
class TestMemoryAddResult:
"""Result returned by :meth:`TestMemoryStore.add`.
Attributes:
id: The generated id of the stored (or already-present, on dedup) record.
"""
# Tell pytest not to collect this class as a test suite despite its ``Test`` prefix.
__test__ = False
id: str
@@ -1,4 +1,4 @@
"""Tests for ``LocalMemoryStore``.
"""Tests for ``TestMemoryStore``.
Test scaffolding:
- ``tmp_path`` backs every persistent store, so tests never touch the real ``~/.strands/memory``.
@@ -18,13 +18,17 @@ from unittest.mock import MagicMock
import pytest
import strands.vended_memory_stores.local.store as store_module
import strands.vended_memory_stores.test_memory_store.store as store_module
from strands.hooks.events import AfterInvocationEvent, MessageAddedEvent
from strands.hooks.registry import HookOrder
from strands.memory.extraction.triggers import InvocationTrigger
from strands.memory.extraction.types import ExtractionConfig, ExtractionResult
from strands.memory.memory_manager import MemoryManager
from strands.vended_memory_stores.local import LocalMemoryStore
from strands.vended_memory_stores.test_memory_store import (
TestMemoryAddResult,
TestMemoryStore,
TestMemoryStoreConfig,
)
@pytest.fixture
@@ -37,10 +41,10 @@ def store_path(tmp_path: Path) -> str:
def make_store(store_path: str):
"""Factory building a persistent store at ``store_path`` with overridable config."""
def _make(**overrides: Any) -> LocalMemoryStore:
def _make(**overrides: Any) -> TestMemoryStore:
config: dict[str, Any] = {"name": "notes", "path": store_path}
config.update(overrides)
return LocalMemoryStore(**config)
return TestMemoryStore(**config)
return _make
@@ -50,7 +54,7 @@ class TestPackageExport:
# The store is documented as importable from the parent package via its lazy __getattr__.
from strands.vended_memory_stores import __getattr__
assert __getattr__("LocalMemoryStore") is LocalMemoryStore
assert __getattr__("TestMemoryStore") is TestMemoryStore
def test_unknown_attribute_raises(self):
from strands.vended_memory_stores import __getattr__
@@ -58,6 +62,14 @@ class TestPackageExport:
with pytest.raises(AttributeError):
__getattr__("NoSuchStore")
def test_test_prefixed_classes_are_not_collected_by_pytest(self):
# The ``Test`` prefix would otherwise make pytest try to collect these classes as test suites
# (emitting a PytestCollectionWarning). ``__test__ = False`` opts them out; assert it stays set
# so a future edit that drops a guard fails here instead of silently re-enabling collection.
assert TestMemoryStore.__test__ is False
assert TestMemoryStoreConfig.__test__ is False
assert TestMemoryAddResult.__test__ is False
class TestConstructor:
def test_is_writable_by_default(self, make_store):
@@ -82,7 +94,7 @@ class TestConstructor:
def test_raises_when_explicit_path_is_empty(self):
with pytest.raises(ValueError, match="path must not be empty"):
LocalMemoryStore(name="notes", path=" ")
TestMemoryStore(name="notes", path=" ")
def test_does_no_filesystem_io_on_construction(self, make_store, store_path):
make_store()
@@ -94,7 +106,7 @@ class TestConstructor:
# redirected to tmp_path so the test never touches the real home dir, and the unsafe name
# exercises sanitization.
monkeypatch.setattr(store_module.Path, "home", classmethod(lambda cls: tmp_path))
store = LocalMemoryStore(name="../weird/name")
store = TestMemoryStore(name="../weird/name")
await store.add("a fact worth keeping")
expected = tmp_path / ".strands" / "memory" / "__weird_name.json"
assert expected.is_file()
@@ -274,7 +286,7 @@ class TestPersistence:
# wrapped "failed to read/write" error naming the path rather than a bare OSError.
blocker = tmp_path / "blocker"
blocker.write_text("not a directory", encoding="utf-8")
store = LocalMemoryStore(name="notes", path=str(blocker / "notes.json"))
store = TestMemoryStore(name="notes", path=str(blocker / "notes.json"))
with pytest.raises(OSError, match="failed to"):
await store.add("user prefers dark mode")
@@ -313,7 +325,7 @@ class TestPackageExports:
def test_lazily_exported_from_vended_memory_stores(self):
import strands.vended_memory_stores as vended_memory_stores
assert vended_memory_stores.LocalMemoryStore is LocalMemoryStore
assert vended_memory_stores.TestMemoryStore is TestMemoryStore
def test_unknown_attribute_raises_attribute_error(self):
import strands.vended_memory_stores as vended_memory_stores
+3 -3
View File
@@ -80,9 +80,9 @@
"types": "./dist/src/vended-memory-stores/bedrock-knowledge-base/index.d.ts",
"default": "./dist/src/vended-memory-stores/bedrock-knowledge-base/index.js"
},
"./vended-memory-stores/local": {
"types": "./dist/src/vended-memory-stores/local/index.d.ts",
"default": "./dist/src/vended-memory-stores/local/index.js"
"./vended-memory-stores/test-memory-store": {
"types": "./dist/src/vended-memory-stores/test-memory-store/index.d.ts",
"default": "./dist/src/vended-memory-stores/test-memory-store/index.js"
},
"./telemetry": {
"types": "./dist/src/telemetry/index.d.ts",
@@ -1 +0,0 @@
export { LocalMemoryStore, type LocalMemoryStoreConfig, type LocalMemoryAddResult } from './store.js'
@@ -3,7 +3,7 @@ import { promises as fs } from 'node:fs'
import * as os from 'node:os'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { LocalMemoryStore } from '../store.js'
import { TestMemoryStore } from '../store.js'
import { MemoryManager } from '../../../memory/index.js'
import { InvocationTrigger } from '../../../memory/extraction/triggers.js'
import type { Extractor } from '../../../memory/extraction/types.js'
@@ -17,7 +17,7 @@ let dir: string
let counter = 0
beforeEach(async () => {
dir = join(tmpdir(), `strands-local-memory-${process.pid}-${counter++}`)
dir = join(tmpdir(), `strands-test-memory-${process.pid}-${counter++}`)
await fs.mkdir(dir, { recursive: true })
})
@@ -29,62 +29,62 @@ function filePath(name = 'notes'): string {
return join(dir, `${name}.json`)
}
describe('LocalMemoryStore', () => {
describe('TestMemoryStore', () => {
describe('constructor', () => {
it('is writable by default', () => {
expect(new LocalMemoryStore({ name: 'notes' }).writable).toBe(true)
expect(new TestMemoryStore({ name: 'notes' }).writable).toBe(true)
})
it('honors an explicit writable: false', () => {
expect(new LocalMemoryStore({ name: 'notes', writable: false }).writable).toBe(false)
expect(new TestMemoryStore({ name: 'notes', writable: false }).writable).toBe(false)
})
it('exposes name, description, and maxSearchResults', () => {
const store = new LocalMemoryStore({ name: 'notes', description: 'my notes', maxSearchResults: 7 })
const store = new TestMemoryStore({ name: 'notes', description: 'my notes', maxSearchResults: 7 })
expect(store.name).toBe('notes')
expect(store.description).toBe('my notes')
expect(store.maxSearchResults).toBe(7)
})
it('throws when maxSearchResults is less than 1', () => {
expect(() => new LocalMemoryStore({ name: 'notes', maxSearchResults: 0 })).toThrow(
expect(() => new TestMemoryStore({ name: 'notes', maxSearchResults: 0 })).toThrow(
'maxSearchResults must be at least 1'
)
})
it('throws when name is empty', () => {
expect(() => new LocalMemoryStore({ name: ' ' })).toThrow('name must not be empty')
expect(() => new TestMemoryStore({ name: ' ' })).toThrow('name must not be empty')
})
it('throws when an explicit path is empty', () => {
expect(() => new LocalMemoryStore({ name: 'notes', path: ' ' })).toThrow('path must not be empty')
expect(() => new TestMemoryStore({ name: 'notes', path: ' ' })).toThrow('path must not be empty')
})
it('does no filesystem I/O on construction', async () => {
new LocalMemoryStore({ name: 'notes', path: filePath() })
new TestMemoryStore({ name: 'notes', path: filePath() })
await expect(fs.access(filePath())).rejects.toThrow()
})
})
describe('add', () => {
it('throws when the store is not writable', async () => {
const store = new LocalMemoryStore({ name: 'notes', writable: false, path: filePath() })
const store = new TestMemoryStore({ name: 'notes', writable: false, path: filePath() })
await expect(store.add('fact')).rejects.toThrow('store is not writable')
})
it('throws on empty or whitespace content', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await expect(store.add(' ')).rejects.toThrow('content must not be empty')
})
it('returns an id for stored content', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
const { id } = await store.add('user prefers dark mode')
expect(id).toBeTruthy()
})
it('deduplicates identical content, returning the existing id', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
const first = await store.add('user prefers dark mode')
const second = await store.add('user prefers dark mode')
expect(second.id).toBe(first.id)
@@ -93,7 +93,7 @@ describe('LocalMemoryStore', () => {
})
it('persists a human-readable JSON array to disk', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await store.add('user prefers dark mode', { source: 'user' })
const raw = await fs.readFile(filePath(), 'utf8')
expect(raw).toContain('\n ') // pretty-printed
@@ -107,19 +107,19 @@ describe('LocalMemoryStore', () => {
describe('search', () => {
it('throws when maxSearchResults is less than 1', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await expect(store.search('q', { maxSearchResults: 0 })).rejects.toThrow('maxSearchResults must be at least 1')
})
it('returns no results for an empty or token-less query', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await store.add('user prefers dark mode')
expect(await store.search('')).toEqual([])
expect(await store.search(' ... ')).toEqual([])
})
it('ranks higher token overlap first and exposes _relevanceScore', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await store.add('the cat sat on the mat')
await store.add('the cat chased the dog in the park')
@@ -131,7 +131,7 @@ describe('LocalMemoryStore', () => {
})
it('excludes records with no token overlap', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await store.add('the cat sat on the mat')
await store.add('a completely unrelated note')
@@ -141,7 +141,7 @@ describe('LocalMemoryStore', () => {
})
it('breaks ties by recency (most recent first)', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
// Stamp deterministic, increasing timestamps so the tie-break is testable.
const isoSpy = vi
.spyOn(Date.prototype, 'toISOString')
@@ -157,7 +157,7 @@ describe('LocalMemoryStore', () => {
})
it('caps results to maxSearchResults', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await store.add('alpha match')
await store.add('beta match')
await store.add('gamma match')
@@ -166,7 +166,7 @@ describe('LocalMemoryStore', () => {
})
it('tokenizes non-ASCII content as whole words (Unicode-aware, matching Python)', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await store.add('the café in 日本 is naïve')
expect(await store.search('café')).toHaveLength(1)
expect(await store.search('日本')).toHaveLength(1)
@@ -175,26 +175,26 @@ describe('LocalMemoryStore', () => {
describe('persistence', () => {
it('recalls entries from a fresh instance pointed at the same file (survives restart)', async () => {
const first = new LocalMemoryStore({ name: 'notes', path: filePath() })
const first = new TestMemoryStore({ name: 'notes', path: filePath() })
await first.add('user lives in Berlin')
const second = new LocalMemoryStore({ name: 'notes', path: filePath() })
const second = new TestMemoryStore({ name: 'notes', path: filePath() })
const results = await second.search('where does the user live')
expect(results).toHaveLength(1)
expect(results[0]?.content).toBe('user lives in Berlin')
})
it('is ephemeral when persist is false: no file, a fresh instance forgets', async () => {
const first = new LocalMemoryStore({ name: 'notes', persist: false, path: filePath() })
const first = new TestMemoryStore({ name: 'notes', persist: false, path: filePath() })
await first.add('ephemeral fact')
await expect(fs.access(filePath())).rejects.toThrow()
const second = new LocalMemoryStore({ name: 'notes', persist: false, path: filePath() })
const second = new TestMemoryStore({ name: 'notes', persist: false, path: filePath() })
expect(await second.search('ephemeral fact')).toEqual([])
})
it('starts empty when the backing file is missing', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
expect(await store.search('anything')).toEqual([])
})
@@ -206,7 +206,7 @@ describe('LocalMemoryStore', () => {
process.env.HOME = dir
process.env.USERPROFILE = dir
try {
const store = new LocalMemoryStore({ name: '../weird/name' })
const store = new TestMemoryStore({ name: '../weird/name' })
await store.add('a fact worth keeping')
const expected = join(os.homedir(), '.strands', 'memory', '__weird_name.json')
await expect(fs.access(expected)).resolves.toBeUndefined()
@@ -218,19 +218,19 @@ describe('LocalMemoryStore', () => {
it('throws a clear error on a corrupt backing file', async () => {
await fs.writeFile(filePath(), 'not json{', 'utf8')
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await expect(store.search('anything')).rejects.toThrow('invalid JSON')
})
it('throws a clear error on a valid-but-wrong-shape backing file', async () => {
await fs.writeFile(filePath(), '{}', 'utf8')
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await expect(store.search('anything')).rejects.toThrow('expected a JSON array')
})
it('throws a clear error on a malformed record', async () => {
await fs.writeFile(filePath(), JSON.stringify([{ foo: 'bar' }]), 'utf8')
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await expect(store.search('anything')).rejects.toThrow('each record must have string')
})
@@ -239,12 +239,12 @@ describe('LocalMemoryStore', () => {
// wrapped "failed to read/write" error naming the path rather than a bare OS error.
const blocker = join(dir, 'blocker')
await fs.writeFile(blocker, 'not a directory', 'utf8')
const store = new LocalMemoryStore({ name: 'notes', path: join(blocker, 'notes.json') })
const store = new TestMemoryStore({ name: 'notes', path: join(blocker, 'notes.json') })
await expect(store.add('user prefers dark mode')).rejects.toThrow('failed to')
})
it('keeps all entries when many writes happen concurrently', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await Promise.all(Array.from({ length: 10 }, (_, index) => store.add(`fact number ${index}`)))
const raw = await fs.readFile(filePath(), 'utf8')
expect(JSON.parse(raw)).toHaveLength(10)
@@ -254,7 +254,7 @@ describe('LocalMemoryStore', () => {
// On a cold store, search() and add() both trigger the lazy load; the memoized load must keep
// the added record visible to subsequent searches rather than overwriting it with a pre-write
// snapshot.
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await Promise.all([store.add('zebra giraffe'), store.search('apple')])
const results = await store.search('zebra')
expect(results).toHaveLength(1)
@@ -276,7 +276,7 @@ describe('LocalMemoryStore', () => {
]
await fs.writeFile(filePath(), JSON.stringify(pyWritten), 'utf8')
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
const results = await store.search('dark mode preference')
expect(results).toHaveLength(1)
expect(results[0]?.content).toBe('the user prefers dark mode')
@@ -285,7 +285,7 @@ describe('LocalMemoryStore', () => {
})
it('writes the shared camelCase format with a Z-suffixed millisecond timestamp', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await store.add('hello world')
const record = JSON.parse(await fs.readFile(filePath(), 'utf8'))[0]
expect(Object.keys(record).sort()).toEqual(['content', 'createdAt', 'id'])
@@ -295,7 +295,7 @@ describe('LocalMemoryStore', () => {
describe('MemoryManager integration', () => {
it('stamps storeName on results returned through the manager', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
await store.add('user prefers dark mode')
const mm = new MemoryManager({ stores: [store] })
@@ -305,7 +305,7 @@ describe('LocalMemoryStore', () => {
})
it('writes through the manager add API', async () => {
const store = new LocalMemoryStore({ name: 'notes', path: filePath() })
const store = new TestMemoryStore({ name: 'notes', path: filePath() })
const mm = new MemoryManager({ stores: [store], addToolConfig: true })
await mm.add('user likes coffee')
expect(JSON.parse(await fs.readFile(filePath(), 'utf8'))).toHaveLength(1)
@@ -315,7 +315,7 @@ describe('LocalMemoryStore', () => {
const extractor: Extractor = {
extract: vi.fn().mockResolvedValue([{ content: 'user prefers dark mode' }]),
}
const store = new LocalMemoryStore({
const store = new TestMemoryStore({
name: 'notes',
path: filePath(),
extraction: { trigger: new InvocationTrigger(), extractor },
@@ -0,0 +1 @@
export { TestMemoryStore, type TestMemoryStoreConfig, type TestMemoryAddResult } from './store.js'
@@ -14,7 +14,7 @@ const RELEVANCE_SCORE_KEY = '_relevanceScore'
/**
* A stored memory, as it is persisted on disk.
*/
interface LocalMemoryRecord {
interface TestMemoryRecord {
id: string
content: string
metadata?: Record<string, JSONValue>
@@ -22,12 +22,12 @@ interface LocalMemoryRecord {
}
/**
* Configuration for {@link LocalMemoryStore}.
* Configuration for {@link TestMemoryStore}.
*
* The store persists to disk by default so the memory records persist across restarts. Set
* {@link persist} to `false` for an ephemeral, single session store (useful for e.g. testing).
*/
export interface LocalMemoryStoreConfig extends MemoryStoreConfig {
export interface TestMemoryStoreConfig extends MemoryStoreConfig {
/**
* Whether to persist entries to disk so they survive across sessions.
* - `true` (default): writes are flushed to {@link path} (or the default location).
@@ -43,8 +43,8 @@ export interface LocalMemoryStoreConfig extends MemoryStoreConfig {
path?: string
}
/** Result returned by {@link LocalMemoryStore.add}. */
export interface LocalMemoryAddResult {
/** Result returned by {@link TestMemoryStore.add}. */
export interface TestMemoryAddResult {
/** The id of the stored record. */
id: string
}
@@ -99,22 +99,22 @@ function tokenOverlapScore(queryTokens: Set<string>, content: string): number {
* Each {@link add} rewrites the whole file, so this fits modest volumes, not fit for high volume
* production workloads. Use a managed store like {@link BedrockKnowledgeBaseStore} for that.
*
* The on-disk format is shared with the Python SDK's `LocalMemoryStore`: records use the same
* The on-disk format is shared with the Python SDK's `TestMemoryStore`: records use the same
* camelCase keys (`id`, `content`, `metadata`, `createdAt`) and the same timestamp shape, so a
* backing file written by either SDK can be read by the other.
*
* @example
* ```typescript
* import { LocalMemoryStore } from '@strands-agents/sdk/vended-memory-stores/local'
* import { TestMemoryStore } from '@strands-agents/sdk/vended-memory-stores/test-memory-store'
*
* // Persists to ~/.strands/memory/notes.json by default.
* const store = new LocalMemoryStore({ name: 'notes' })
* const store = new TestMemoryStore({ name: 'notes' })
*
* const { id } = await store.add('User prefers dark mode')
* const results = await store.search('what theme does the user like?')
* ```
*/
export class LocalMemoryStore implements MemoryStore {
export class TestMemoryStore implements MemoryStore {
readonly name: string
readonly description?: string
readonly maxSearchResults?: number
@@ -126,27 +126,27 @@ export class LocalMemoryStore implements MemoryStore {
private readonly _explicitPath: string | undefined
private _resolvedPath: string | undefined
/** The loaded records once {@link _load} resolves; the working in-memory copy thereafter. */
private _records: LocalMemoryRecord[] | undefined
private _records: TestMemoryRecord[] | undefined
/**
* Memoizes the first (async) load so concurrent `search`/`add` callers share a single file read
* instead of each racing their own — without it, a search interleaved with a first-use add could
* overwrite the cache with a pre-write snapshot and drop the just-added record.
*/
private _loadPromise: Promise<LocalMemoryRecord[]> | undefined
private _loadPromise: Promise<TestMemoryRecord[]> | undefined
/** Serializes writes so concurrent `add`s never interleave the load-modify-flush cycle. */
private _writeChain: Promise<unknown> = Promise.resolve()
constructor(options: LocalMemoryStoreConfig) {
constructor(options: TestMemoryStoreConfig) {
const { name, description, maxSearchResults, writable, extraction, persist, path } = options
if (!name.trim()) {
throw new Error('LocalMemoryStore: name must not be empty.')
throw new Error('TestMemoryStore: name must not be empty.')
}
this.name = name
if (description !== undefined) this.description = description
if (maxSearchResults !== undefined) {
if (maxSearchResults < 1) {
throw new Error('LocalMemoryStore: maxSearchResults must be at least 1.')
throw new Error('TestMemoryStore: maxSearchResults must be at least 1.')
}
this.maxSearchResults = maxSearchResults
}
@@ -155,7 +155,7 @@ export class LocalMemoryStore implements MemoryStore {
if (extraction !== undefined) this.extraction = extraction
if (path !== undefined && !path.trim()) {
throw new Error('LocalMemoryStore: path must not be empty.')
throw new Error('TestMemoryStore: path must not be empty.')
}
this._persist = persist ?? true
this._explicitPath = path
@@ -173,7 +173,7 @@ export class LocalMemoryStore implements MemoryStore {
*/
async search(query: string, options?: SearchOptions): Promise<MemoryEntry[]> {
if (options?.maxSearchResults !== undefined && options.maxSearchResults < 1) {
throw new Error('LocalMemoryStore: maxSearchResults must be at least 1.')
throw new Error('TestMemoryStore: maxSearchResults must be at least 1.')
}
const limit = options?.maxSearchResults || this.maxSearchResults || DEFAULT_MAX_SEARCH_RESULTS
@@ -182,7 +182,7 @@ export class LocalMemoryStore implements MemoryStore {
const records = await this._load()
const scored: Array<{ record: LocalMemoryRecord; score: number }> = []
const scored: Array<{ record: TestMemoryRecord; score: number }> = []
for (const record of records) {
const score = tokenOverlapScore(queryTokens, record.content)
if (score > 0) scored.push({ record, score })
@@ -209,12 +209,12 @@ export class LocalMemoryStore implements MemoryStore {
* overwritten in search output.
* @returns The id of the stored (or already-present) record
*/
async add(content: string, metadata?: Record<string, JSONValue>): Promise<LocalMemoryAddResult> {
async add(content: string, metadata?: Record<string, JSONValue>): Promise<TestMemoryAddResult> {
if (!this.writable) {
throw new Error('LocalMemoryStore: store is not writable. Set writable: true in config to enable add().')
throw new Error('TestMemoryStore: store is not writable. Set writable: true in config to enable add().')
}
if (!content.trim()) {
throw new Error('LocalMemoryStore: content must not be empty.')
throw new Error('TestMemoryStore: content must not be empty.')
}
// Serialize the whole load-modify-flush cycle behind any in-flight write so concurrent `add`s
@@ -226,7 +226,7 @@ export class LocalMemoryStore implements MemoryStore {
const existing = records.find((record) => record.content.trim() === normalizedContent)
if (existing) return { id: existing.id }
const record: LocalMemoryRecord = { id: uuidv7(), content, createdAt: new Date().toISOString() }
const record: TestMemoryRecord = { id: uuidv7(), content, createdAt: new Date().toISOString() }
if (metadata !== undefined) record.metadata = metadata
// Flush the candidate list first and only commit it to the in-memory cache once the write
@@ -267,7 +267,7 @@ export class LocalMemoryStore implements MemoryStore {
* first call's promise is memoized in {@link _loadPromise} so concurrent callers await one shared
* read rather than each loading independently and racing to assign the cache.
*/
private async _load(): Promise<LocalMemoryRecord[]> {
private async _load(): Promise<TestMemoryRecord[]> {
if (this._records !== undefined) return this._records
if (this._loadPromise !== undefined) return this._loadPromise
@@ -281,7 +281,7 @@ export class LocalMemoryStore implements MemoryStore {
}
/** Reads and parses the backing file (or returns an empty list when ephemeral / missing). */
private async _readFromDisk(): Promise<LocalMemoryRecord[]> {
private async _readFromDisk(): Promise<TestMemoryRecord[]> {
const filePath = await this._getPath()
if (filePath === undefined) return []
@@ -291,17 +291,17 @@ export class LocalMemoryStore implements MemoryStore {
rawContent = await readFile(filePath, 'utf8')
} catch (error: unknown) {
if ((error as { code?: string }).code === 'ENOENT') return []
throw new Error(`LocalMemoryStore: failed to read ${filePath}`, { cause: error })
throw new Error(`TestMemoryStore: failed to read ${filePath}`, { cause: error })
}
let parsedFile: unknown
try {
parsedFile = JSON.parse(rawContent)
} catch (error: unknown) {
throw new Error(`LocalMemoryStore: invalid JSON in ${filePath}`, { cause: error })
throw new Error(`TestMemoryStore: invalid JSON in ${filePath}`, { cause: error })
}
if (!Array.isArray(parsedFile)) {
throw new Error(`LocalMemoryStore: invalid backing file ${filePath}: expected a JSON array of records`)
throw new Error(`TestMemoryStore: invalid backing file ${filePath}: expected a JSON array of records`)
}
for (const record of parsedFile) {
if (
@@ -312,12 +312,12 @@ export class LocalMemoryStore implements MemoryStore {
typeof record.createdAt !== 'string'
) {
throw new Error(
`LocalMemoryStore: invalid backing file ${filePath}: ` +
`TestMemoryStore: invalid backing file ${filePath}: ` +
"each record must have string 'id', 'content', and 'createdAt' fields"
)
}
}
return parsedFile as LocalMemoryRecord[]
return parsedFile as TestMemoryRecord[]
}
/**
@@ -326,7 +326,7 @@ export class LocalMemoryStore implements MemoryStore {
* serialize invocations via {@link _writeChain}. Throws with the target path (and the OS error as
* `cause`) when the path is unreachable or not writable.
*/
private async _flush(records: LocalMemoryRecord[]): Promise<void> {
private async _flush(records: TestMemoryRecord[]): Promise<void> {
const filePath = await this._getPath()
if (filePath === undefined) return
@@ -338,7 +338,7 @@ export class LocalMemoryStore implements MemoryStore {
await writeFile(tmpPath, JSON.stringify(records, null, 2), 'utf8')
await rename(tmpPath, filePath)
} catch (error: unknown) {
throw new Error(`LocalMemoryStore: failed to write ${filePath}`, { cause: error })
throw new Error(`TestMemoryStore: failed to write ${filePath}`, { cause: error })
}
}
}