Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
- package-ecosystem: uv
directory: "/"
schedule:
interval: weekly
allow:
- dependency-name: hotdata-framework
21 changes: 21 additions & 0 deletions .github/workflows/check-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: check-release

on:
pull_request:
paths:
- pyproject.toml
- CHANGELOG.md

permissions:
contents: read

jobs:
changelog:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Check that a version change has a changelog section
run: python3 scripts/changelog.py check "origin/$GITHUB_BASE_REF"
84 changes: 84 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: publish

on:
push:
tags:
- "v[0-9]*"

concurrency:
group: publish-${{ github.ref_name }}
cancel-in-progress: false

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Check that the tagged commit is on main
run: |
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "The tag $GITHUB_REF_NAME points at $GITHUB_SHA, which is not on main" >&2
exit 1
fi
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v6
with:
python-version: "3.12"
- name: Check that the tag matches the project version
run: |
version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
if [[ "$GITHUB_REF_NAME" != "v$version" ]]; then
echo "The tag $GITHUB_REF_NAME does not match version $version in pyproject.toml" >&2
exit 1
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Add a check that the tagged commit is on main (not blocking).

Suggested step after the checkout, with fetch-depth: 0 on the checkout:

- name: Check that the tag is on main
  run: git merge-base --is-ancestor "$GITHUB_SHA" origin/main

The workflow publishes any v* tag whose version matches pyproject.toml. A tag pushed from a feature branch publishes unreviewed code to PyPI. RELEASING.md:4 says to tag only through release.sh, and the workflow does not enforce that rule. A tag rule on the pypi environment is an alternative fix.

- name: Check that CHANGELOG.md has a section for the version
run: python3 scripts/changelog.py notes "${GITHUB_REF_NAME#v}" > /dev/null
- run: uv build
- run: uvx twine check --strict dist/*
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: dist
path: dist/

pypi:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: pypi
url: https://pypi.org/p/hotmemory
permissions:
id-token: write
steps:
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2

github-release:
needs: pypi
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
name: dist
path: dist/
- name: Create the GitHub Release with the changelog notes
env:
GH_TOKEN: ${{ github.token }}
run: |
python3 scripts/changelog.py notes "${GITHUB_REF_NAME#v}" > notes.md
gh release create "$GITHUB_REF_NAME" dist/* \
--title "hotmemory ${GITHUB_REF_NAME#v}" \
--notes-file notes.md \
--verify-tag
24 changes: 18 additions & 6 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,37 @@ jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v6
with:
python-version: "3.11"
python-version: ${{ matrix.python-version }}
- run: make verify

floor:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v6
- run: make floor

integration:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v6
with:
python-version: "3.11"
- uses: docker/login-action@v3
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,12 @@ build/
bench/data/
bench/cache/
bench/results/

# Local state of Claude Code. The rest of .claude/ (agents, skills, settings.json) is shared.
.claude/worktrees/
.claude/settings.local.json
.claude/scheduled_tasks.lock

# Notes of the owner, kept out of git: the plan, the roadmap, the design brief, the
# research, and the local stack.
docs/internal/
16 changes: 9 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ rules, and points to the files that hold everything else.
```sh
make verify # the full check; run it before each commit
make integration # the hotdata tests and the conformance suite; needs make local-up
make local-up # start the local RuntimeDB stack (Postgres, RustFS, engine)
make floor # the offline tests at the lowest allowed dependency versions
make local-up # start the local engine stack (Postgres, RustFS, engine)
make local-down # stop it and delete its data
cp .env.template .env # then fill in .env; it is ignored by git
uv run --env-file .env scripts/measure_cloud.py # M1 to M3
Expand All @@ -18,16 +19,17 @@ uv run --env-file .env scripts/measure_local.py --cloud # M4 to M6 in the clou

## Where things are

- [docs/internal/plan.md](docs/internal/plan.md): the open workstream, the tasks of each phase,
and its stop conditions.
- [docs/internal/roadmap.md](docs/internal/roadmap.md): every phase and its status.
- `docs/internal/`: the notes of the owner. Git ignores this folder, so it exists only in
the owner's checkout. `plan.md` holds the open workstream, the tasks of each phase, and
its stop conditions. `roadmap.md` holds every phase and its status. `brief.md` holds the
design and its reasons. `local.md` tells you how to run against the local stack.
- [docs/contracts.md](docs/contracts.md): the record and the operations.
- [docs/guarantees.md](docs/guarantees.md): what a consumer can rely on, and the proof.
- [docs/internal/architecture.md](docs/internal/architecture.md): how each operation runs on
the platform.
- [docs/internal/brief.md](docs/internal/brief.md): the design and its reasons.
- [docs/architecture.md](docs/architecture.md): how each operation runs on the platform.
- [CONTRIBUTING.md](CONTRIBUTING.md): the checks inside `make verify` and the test rules.
- [CHANGELOG.md](CHANGELOG.md): each change to a public surface.
- [RELEASING.md](RELEASING.md): how to publish a version to PyPI. Only the repository owner
publishes.
- `src/hotmemory/`: the library. `tests/test_conformance.py` is the conformance suite.
- [docs/benchmarks.md](docs/benchmarks.md): the benchmark numbers and their configuration.
- `bench/`: the LongMemEval-S runner, outside the package. [bench/README.md](bench/README.md)
Expand Down
124 changes: 43 additions & 81 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,92 +5,54 @@ names in `hotmemory.__all__`, the method set of `Store`, the fields and field ty
`Record` for each schema version, and the filter keys of `list` and `search`. A test guards
each one, and its failure message points to this file.

## Unreleased
New entries go under `## [Unreleased]`. A release moves them into a section named
`## [X.Y.Z] - YYYY-MM-DD`, and that section becomes the notes of the GitHub Release.
[RELEASING.md](RELEASING.md) gives the procedure.

## [Unreleased]

The first release. It needs Python 3.11 or later. The core package has no dependencies.

### Added

- `Record`, the frozen dataclass for schema version 1, and `normalize`, the form that
deduplication compares.
- `Store`, the protocol of the storage contract, with `put`, `get`, `history`, `list`,
`search`, `delete`, `list_namespaces`, and `writer`. `Writer` is the protocol of the
buffer that `writer` returns.
`search`, `delete`, `sweep`, `list_namespaces`, and `writer`. `Writer` is the protocol of
the buffer that `writer` returns.
- Records are immutable. A put under an existing key writes a new revision. A put whose
content duplicates the current revision but brings a new source writes a new revision
with the merged sources.
- `put` takes `close_previous`, which closes the validity span of the current revision
in the same write.
- A put that moves a key between `episode` and another kind raises `ValueError`.
- `sweep` deletes every revision of each key whose current revision is past
`forget_after`, and returns the deleted ids.
- `Filter` and `TimeRange`, the exact filters of `list` and `search`, and `Hit`, one search
result with its distance.
- `MemoryStore` and `MemoryWriter`, the in-process driver.
result with its distance. `Filter.kind` takes one kind or a tuple of kinds.
- `MemoryStore` and `MemoryWriter`, the in-process driver. `MemoryStore` takes `records`,
the revisions that it starts with, and `records()` returns every revision that it holds.
A writer flush in which one put raises writes nothing.
- `hotmemory.hotdata`, in the `hotdata` extra, with `HotdataStore`, the driver over one
Hotdata managed database, and `HotdataWriter`. `HotdataStore.provision` creates or opens
the database by name, and `HotdataStore.open` opens it by id. `LayoutError` reports a
database whose layout differs. `ranking="vector"` ranks a search by content distance
alone.
- `Memory`, the memory contract over one `Store`, with `remember`, `recall`,
`candidates`, `supersede`, `forget`, `profile`, and `capture`. `Fact` is the frozen
dataclass of one structured fact that `remember` takes.
- `MemoryStore` takes `records`, the revisions that it starts with, and `records()`
returns every revision that it holds.
- `Memory.load`, which cuts a Markdown document into chunks by headings and fences,
stores each chunk as an episode, and runs an extractor on each chunk. Each fact names
the episode of its chunk in `sources`.
- The skill file `skills/hotmemory/SKILL.md`, with one script in
`skills/hotmemory/scripts/` for each memory operation, including `load.py`.
- `hotmemory.openai`, in the `openai` extra, with `OpenAIEmbedder`, an `Embedder` over
the OpenAI embeddings API.

### Changed

- `Store` gains `sweep`, which deletes every revision of each key whose current
revision is past `forget_after`, and returns the deleted ids. `MemoryStore` and
`HotdataStore` implement it.
- A `put` that moves a key between `episode` and another kind raises `ValueError` in
every driver.
- `Store.put` and `Writer.put` take `close_previous`, which closes the validity span of
the current revision in the same write. `MemoryStore` and `HotdataStore` implement it.
- A put whose content duplicates the current revision but brings a new source writes a
new revision with the merged sources. Before, it wrote nothing.
- A `MemoryStore` writer flush in which one put raises writes nothing, as in
`HotdataStore`.
- The vector rankings of a fused `HotdataStore.search` filter first and rank by a scan, so
a search in a narrow scope returns the top k rows of that scope.
- `Filter.kind` takes one kind or a tuple of kinds, and matches a record of any kind named.
The filter key stays `kind`. In `HotdataStore`, a filter without `episode` reads only
`memory_v1`.
- `Memory.recall` and `Memory.profile` read the records of kind `fact`, `profile`, and
`procedure` only. They never return an episode, and the counts of `profile` leave
episodes out.
- `HotdataStore.provision` builds no vector index by default, so each write skips the cost
of the index. `provision` and `open` take `vector_index`, an experimental option: with
it, `provision` builds the three vector indexes, and the vector rankings search through
them. A filtered search through a vector index can miss rows loaded after its build.
- `HotdataStore.provision` builds no sorted index on `created_at` by default, because a
load into a table with it takes longer as the table grows. `provision` and `open` take
`sorted_index`: with it, `provision` builds the sorted index in each record table.
Databases provisioned before this change keep the index and open as before.
- `HotdataStore` writes a table by SQL `INSERT` when its rows in the write are all new and
number at most 5, as in a `put` of a new fact. The `INSERT` writes only the ids that the
table does not hold. Other writes stay keyed loads. If the engine refuses the `INSERT`,
the store writes by load from then on.
- `HotdataStore` reads the current revisions before a write with lists of at most 500
keys. Before, one condition for each key could overflow the stack of the engine on a
flush of about 2,000 rows.
- `HotdataStore.provision` reads every page of the database list. Before, it read the
first 100 databases, and in a larger workspace it could create a second database with
the name.
- `Memory.capture` takes `episode`, a key. With it, `capture` also stores the text as an
episode under that key, after the facts, and each fact names the episode in `sources`.
A capture of the same text under the same key writes nothing and calls no extractor.
- `Memory.recall` takes `episodes`, `"cited"` or `"search"`. With it, the block adds the
episodes that its facts cite, or the top episodes for the query, after the facts and
inside the budget. Without it, `recall` returns no episode, as before. Neither value
returns an episode past its `forget_after`.
- The block of `recall` and `profile` shows a source that is the id of a record in the
namespace of its line as `<key>@<revision>`. Before, it showed the full id. The stored
sources do not change.
- `HotdataStore` ranks by content vector alone by default, as `MemoryStore` does. Before,
the default was `ranking="fused"`, which stays as an option. On LongMemEval-S, the BM25
arm of the fused ranking lowered the retrieval score.
- `Memory.capture` takes `chunk_chars`, with `episode`. It cuts the text at blank lines
into chunks and stores chunk n as an episode under `<episode>-<n>`. The extractor still
reads the whole text, and each fact names every chunk in `sources`.
- The block of `recall` and `profile` shows a run of sources in the namespace of its line,
with keys `<prefix>-<n>`, n in four digits counting up by 1, and one revision, as
`<prefix>-<first>…<last>@<revision>`. The
stored sources do not change.
Hotdata managed database, and `HotdataWriter`. The extra needs `hotdata-framework` 0.14.1
and `pyarrow` 16.0 or later.
- `HotdataStore.provision` opens the database with the name, or creates it.
`HotdataStore.open` opens a database by id. `LayoutError` reports a database whose
layout differs.
- A search ranks by content vector by default, as in `MemoryStore`. `ranking="fused"`
ranks by reciprocal rank fusion of BM25 and the vector rankings.
- `provision` and `open` take `vector_index`, an experimental option that builds and
uses the vector indexes, and `sorted_index`, which builds a sorted index on
`created_at`. Both are off by default.
- `Memory`, the memory contract over one `Store`, with `remember`, `recall`, `candidates`,
`supersede`, `forget`, `profile`, `capture`, and `load`. `Fact` is the frozen dataclass
of one structured fact that `remember` takes.
- `recall` and `profile` read the records of kind `fact`, `profile`, and `procedure`.
`recall` takes `episodes`, `"cited"` or `"search"`, to add episodes after the facts
inside the budget.
- `capture` takes an extractor callable. With `episode`, it also stores the text as an
episode, and with `chunk_chars`, as chunks of that size.
- `load` cuts a Markdown document into chunks by headings and fences, stores each chunk
as an episode, and runs an extractor on each chunk.
- `hotmemory.openai`, in the `openai` extra, with `OpenAIEmbedder`, an `Embedder` over the
OpenAI embeddings API.
18 changes: 12 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

- [uv](https://docs.astral.sh/uv/) installs the development tools and runs the scripts.
- GNU Make runs the targets below.
- Docker Desktop runs the local RuntimeDB stack. You need it only for `make local-up` and
`make integration`.
- Docker Desktop runs the local engine stack. You need it only for `make local-up` and
`make integration`. The engine image is private, so only the Hotdata team can start the
stack.

## The one command

Expand Down Expand Up @@ -35,8 +36,9 @@ passes in CI. It has no tiers, because the full check takes less than five secon
| Target | What it does |
|---|---|
| `make verify` | Runs every check above. |
| `make floor` | Runs the offline tests on Python 3.11 with the lowest version of each dependency that `pyproject.toml` allows. It needs the network to install them. |
| `make integration` | Runs the tests marked `hotdata` against the local stack. Start the stack first with `make local-up`. To use another engine, set `HOTMEMORY_TEST_URL`. |
| `make local-up` | Starts the local RuntimeDB stack: Postgres, RustFS, and the engine. [docs/local.md](docs/local.md) tells you how to point the library at it. |
| `make local-up` | Starts the local engine stack: Postgres, RustFS, and the engine, on port 3000. |
| `make local-down` | Stops the stack and deletes its data. |
| `make local-pull` | Downloads newer images for the stack. |

Expand Down Expand Up @@ -76,8 +78,13 @@ model. The tests pass a fixed clock and a fake embedder to the store, from
offline. `make integration` sets it to the local stack. A run provisions one database
with a new name, empties its tables after each test, and deletes it at the end. They are
the only tests that use the network.
- CI runs two jobs on each pull request. `verify` runs `make verify`. `integration` starts
the local stack with `make local-up` and runs `make integration`.
- CI runs three jobs on each pull request. `verify` runs `make verify` on Python 3.11,
3.12, 3.13, and 3.14. `floor` runs `make floor`. `integration` starts the local stack
with `make local-up` and runs `make integration`. If a pull request changes
`pyproject.toml` or `CHANGELOG.md`, `check-release` also runs. [RELEASING.md](RELEASING.md)
describes it.
- Each workflow pins its actions by commit SHA. Dependabot opens a pull request each week
to update the pins and `hotdata-framework`.
- No test calls a model. The tests pass a fake embedder. `capture` and `load` take an
extractor callable, and the tests pass a fake extractor that returns fixed facts.
- There is no coverage gate, no mutation-testing gate, and no report generator. The output
Expand All @@ -91,4 +98,3 @@ same pull request. If you change a public surface, add an entry to
[CHANGELOG.md](CHANGELOG.md).

No file in this repository names a private repository, a customer, or a deployment detail.
This rule includes `docs/internal/`.
Loading
Loading