Skip to content

Commit 164c8cf

Browse files
committed
feat: initial commit — RAG memory system for AI agents
0 parents  commit 164c8cf

29 files changed

Lines changed: 1380 additions & 0 deletions

.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Storage backend: "chroma" (production) or "memory" (dev/testing)
2+
STORE_BACKEND=chroma
3+
CHROMA_URL=http://localhost:8000
4+
COLLECTION_NAME=engram
5+
6+
# API
7+
API_PORT=4000
8+
9+
# Maintenance
10+
PRUNE_INTERVAL_HOURS=24
11+
12+
# Logging
13+
LOG_LEVEL=info

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: oven-sh/setup-bun@v2
15+
with:
16+
bun-version: latest
17+
- run: bun install --frozen-lockfile
18+
- run: bun run lint
19+
- run: bun run test

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
node_modules/
2+
dist/
3+
.env
4+
.env.local
5+
logs/
6+
*.log
7+
chroma-data/
8+
.DS_Store
9+
Thumbs.db
10+
coverage/

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Changelog
2+
3+
## [1.0.0] — 2026-04-03
4+
5+
### Added
6+
- Two store backends: ChromaDB (production) + in-memory (dev/test)
7+
- Similarity-based deduplication — entries with cosine similarity > 0.92 are merged
8+
- TTL by category: pattern 90d · warning 60d · outcome 180d · context 30d
9+
- IngestionPipeline with chunking (1000 char chunks, 100 char overlap)
10+
- Hook system with `before:ingest` / `after:ingest` events
11+
- SearchEngine with recency-blended reranking (15% recency weight)
12+
- REST API: POST /memories · POST /search · DELETE /memories/:id · POST /prune · GET /stats
13+
- Zod validation on all API inputs
14+
- 12 unit tests — all run without Chroma using the in-memory backend

CLAUDE.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# CLAUDE.md
2+
3+
## What this is
4+
5+
Engram is a RAG memory layer. Agents POST memories to `/memories`, query them via `/search`, and get back semantically ranked results. ChromaDB stores vectors; Engram handles chunking, TTL, deduplication, hooks, and re-ranking on top.
6+
7+
## Layout
8+
9+
```
10+
index.ts ← entry point: init store, start API, schedule prune
11+
schemas/ ← Zod schemas for all types (Memory, SearchRequest, etc.)
12+
store/
13+
base.ts ← StoreBackend interface
14+
chroma.ts ← ChromaDB backend (production)
15+
memory.ts ← In-memory backend (dev/testing, no Chroma needed)
16+
retrieval/
17+
search.ts ← SearchEngine: query + filter
18+
ranker.ts ← rerank: blend similarity score with recency signal
19+
pipeline/
20+
chunker.ts ← split long text into overlapping chunks
21+
ingest.ts ← IngestionPipeline: hooks → chunk → store
22+
hooks/
23+
index.ts ← hook registry + built-in sanitize hook
24+
api/
25+
server.ts ← Bun HTTP server: /health /stats /search /memories /prune
26+
lib/
27+
config.ts ← Zod env validation
28+
logger.ts ← structured JSONL logger
29+
examples/
30+
basic.ts ← HTTP API usage example
31+
tests/
32+
```
33+
34+
## Dev commands
35+
36+
```bash
37+
bun run dev # hot reload
38+
bun run test # vitest (uses in-memory store — no Chroma needed)
39+
bun run example # run examples/basic.ts (needs server running)
40+
bun run lint # tsc type check
41+
```
42+
43+
## Switching backends
44+
45+
`STORE_BACKEND=memory` — no Chroma required, uses in-memory store.
46+
`STORE_BACKEND=chroma` — requires `docker-compose up chromadb -d` first.
47+
48+
## Adding a hook
49+
50+
```ts
51+
import { registerHook } from "./hooks/index.js";
52+
registerHook("before:ingest", (req) => ({ ...req, content: req.content.toUpperCase() }));
53+
```
54+
55+
Available events: `before:ingest`, `after:ingest`, `before:search`, `after:search`.

Dockerfile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
FROM oven/bun:1.2-alpine AS builder
2+
WORKDIR /app
3+
COPY package.json bun.lockb* ./
4+
RUN bun install --frozen-lockfile
5+
COPY . .
6+
RUN bun run build
7+
8+
FROM node:22-alpine AS runtime
9+
WORKDIR /app
10+
RUN addgroup -g 1001 -S engram && adduser -u 1001 -S engram -G engram
11+
COPY --from=builder /app/dist ./dist
12+
COPY --from=builder /app/node_modules ./node_modules
13+
COPY --from=builder /app/package.json ./
14+
RUN mkdir -p /app/logs && chown -R engram:engram /app/logs
15+
USER engram
16+
EXPOSE 4000
17+
CMD ["node", "dist/index.js"]

Memory.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Memory
2+
3+
Design decisions and lessons from building Engram.
4+
5+
## Why two backends
6+
7+
The in-memory backend exists for one reason: tests should not require a running Chroma instance. All 12 unit tests use `MemoryStore` and run offline. The `ChromaStore` has integration tests that require `docker-compose up chromadb -d`.
8+
9+
## Deduplication threshold
10+
11+
0.92 cosine similarity was chosen empirically. At 0.85 too many distinct warnings were merged. At 0.95 too many true duplicates slipped through. Tune via `SIMILARITY_MERGE_THRESHOLD` in `store/chroma.ts` if your use case has denser memory clusters.
12+
13+
## Recency weight in reranker
14+
15+
15% recency weight. Higher values cause newer but less relevant memories to surface — bad for pattern retrieval where the most similar pattern matters more than the newest one. Lower values make the system forget to prioritize recent warnings.
16+
17+
## TTL policy rationale
18+
19+
- `pattern` 90d — recurring market behaviour takes time to establish and validate
20+
- `warning` 60d — pool-specific red flags expire faster since pool conditions change
21+
- `outcome` 180d — execution outcomes are the most durable training signal
22+
- `context` 30d — ephemeral shared state, expires quickly

README.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Engram
2+
3+
![License](https://img.shields.io/badge/license-MIT-blue)
4+
![Runtime](https://img.shields.io/badge/runtime-Bun_1.2-black)
5+
![Backend](https://img.shields.io/badge/backend-ChromaDB-orange)
6+
7+
A RAG memory system for AI agents. Store observations, retrieve relevant context by semantic similarity, and let your agents learn from what they've seen before.
8+
9+
<br/>
10+
11+
![Engram semantic search interface](assets/preview.svg)
12+
13+
<br/>
14+
15+
---
16+
17+
## What it solves
18+
19+
Stateless agents repeat the same mistakes. They evaluate the same bad pool twice, miss a pattern they've seen forty times, and have no way to carry knowledge from one cycle to the next.
20+
21+
Engram gives agents a persistent memory layer with a simple contract: POST what you observe, GET what's relevant when you need to decide. Entries expire automatically, near-duplicates merge, and results are reranked by both semantic relevance and recency.
22+
23+
---
24+
25+
## API
26+
27+
```bash
28+
# Store a memory
29+
POST /memories
30+
{
31+
"category": "warning",
32+
"content": "Pool 7xKp showed 8x volume/TVL spike before 31% TVL drop",
33+
"poolAddress": "7xKp...",
34+
"tags": ["wash-trading", "volume-spike"]
35+
}
36+
37+
# Retrieve relevant memories
38+
POST /search
39+
{
40+
"query": "unusual volume before TVL drop",
41+
"topK": 4,
42+
"category": "warning"
43+
}
44+
45+
# Prune expired entries
46+
POST /prune
47+
48+
# Store statistics
49+
GET /stats
50+
```
51+
52+
---
53+
54+
## Memory categories & TTL
55+
56+
| Category | TTL | Use for |
57+
|----------|-----|---------|
58+
| `pattern` | 90 days | Recurring market behaviours |
59+
| `warning` | 60 days | Pool-specific red flags |
60+
| `outcome` | 180 days | Execution results with PnL |
61+
| `context` | 30 days | Ephemeral shared state |
62+
63+
Entries with cosine similarity > 0.92 are merged instead of duplicated.
64+
65+
---
66+
67+
## Quickstart
68+
69+
```bash
70+
git clone https://github.com/YOUR_USERNAME/engram
71+
cd engram
72+
bun install
73+
docker-compose up chromadb -d
74+
bun run dev
75+
```
76+
77+
No Chroma? Set `STORE_BACKEND=memory` in `.env` to use the in-memory backend — no Docker needed. Tests always use the in-memory backend.
78+
79+
```bash
80+
bun run example # run examples/basic.ts against the live server
81+
bun run test # vitest — no external dependencies required
82+
```
83+
84+
---
85+
86+
## Hooks
87+
88+
Register lifecycle hooks to transform data before/after storage:
89+
90+
```ts
91+
import { registerHook } from "./hooks/index.js";
92+
93+
// Enrich every memory with a source tag before storing
94+
registerHook("before:ingest", (req) => ({
95+
...req,
96+
tags: [...(req.tags ?? []), "auto-tagged"],
97+
}));
98+
```
99+
100+
Available events: `before:ingest` · `after:ingest` · `before:search` · `after:search`
101+
102+
---
103+
104+
## Reranking
105+
106+
Search results blend cosine similarity (85%) with a recency signal (15%). This prevents old high-similarity patterns from dominating over fresh warnings. The weights are tunable in `retrieval/ranker.ts`.
107+
108+
---
109+
110+
## Stack
111+
112+
- **Runtime**: Bun 1.2
113+
- **Vector store**: ChromaDB
114+
- **Schemas**: Zod — full validation on all inputs
115+
- **Deduplication**: cosine similarity merge at 0.92 threshold
116+
- **Chunking**: 1000-char chunks with 100-char overlap
117+
118+
---
119+
120+
## License
121+
122+
MIT

api/server.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { createLogger } from "../lib/logger.js";
2+
import { config } from "../lib/config.js";
3+
import type { StoreBackend } from "../store/base.js";
4+
import { SearchEngine } from "../retrieval/search.js";
5+
import { IngestionPipeline } from "../pipeline/ingest.js";
6+
import { SearchRequestSchema, UpsertRequestSchema } from "../schemas/index.js";
7+
8+
const log = createLogger("API");
9+
10+
export function startServer(store: StoreBackend): void {
11+
const search = new SearchEngine(store);
12+
const pipeline = new IngestionPipeline(store);
13+
14+
const server = Bun.serve({
15+
port: config.API_PORT,
16+
async fetch(req) {
17+
const url = new URL(req.url);
18+
const method = req.method;
19+
20+
// ── Health ──────────────────────────────────────────────────────────────
21+
if (url.pathname === "/health" && method === "GET") {
22+
return Response.json({ status: "ok", ts: Date.now() });
23+
}
24+
25+
// ── Stats ───────────────────────────────────────────────────────────────
26+
if (url.pathname === "/stats" && method === "GET") {
27+
const stats = await store.stats();
28+
return Response.json(stats);
29+
}
30+
31+
// ── Search ──────────────────────────────────────────────────────────────
32+
if (url.pathname === "/search" && method === "POST") {
33+
const body = await req.json();
34+
const parsed = SearchRequestSchema.safeParse(body);
35+
if (!parsed.success) {
36+
return Response.json({ error: parsed.error.flatten() }, { status: 400 });
37+
}
38+
const results = await search.query(parsed.data);
39+
return Response.json({ results, count: results.length });
40+
}
41+
42+
// ── Store ───────────────────────────────────────────────────────────────
43+
if (url.pathname === "/memories" && method === "POST") {
44+
const body = await req.json();
45+
const parsed = UpsertRequestSchema.safeParse(body);
46+
if (!parsed.success) {
47+
return Response.json({ error: parsed.error.flatten() }, { status: 400 });
48+
}
49+
const memories = await pipeline.ingest(parsed.data);
50+
return Response.json({ memories, count: memories.length }, { status: 201 });
51+
}
52+
53+
// ── Delete ──────────────────────────────────────────────────────────────
54+
if (url.pathname.startsWith("/memories/") && method === "DELETE") {
55+
const id = url.pathname.replace("/memories/", "");
56+
await store.delete(id);
57+
return new Response(null, { status: 204 });
58+
}
59+
60+
// ── Prune ───────────────────────────────────────────────────────────────
61+
if (url.pathname === "/prune" && method === "POST") {
62+
const pruned = await store.pruneExpired();
63+
return Response.json({ pruned });
64+
}
65+
66+
return Response.json({ error: "Not found" }, { status: 404 });
67+
},
68+
});
69+
70+
log.info(`API running at http://localhost:${config.API_PORT}`);
71+
}

0 commit comments

Comments
 (0)