@crewhaus/memory-store 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +41 -0
- package/src/index.test.ts +132 -0
- package/src/index.ts +216 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/memory-store",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "M4.2 — persistent cross-session memory store. File-backed JSONL with simple BM25-style text search. Per-spec scoped.",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/errors": "0.0.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Max Meier",
|
|
20
|
+
"email": "max@studiomax.io",
|
|
21
|
+
"url": "https://studiomax.io"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
26
|
+
"directory": "packages/memory-store"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/memory-store#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the persistent memory store. Covers basic remember/recall,
|
|
3
|
+
* BM25 ranking sanity, file persistence across instances, and graceful
|
|
4
|
+
* handling of malformed lines.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { appendFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { MemoryStoreError, createMemoryStore } from "./index";
|
|
11
|
+
|
|
12
|
+
let tmp: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmp = mkdtempSync(join(tmpdir(), "memory-store-"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("createMemoryStore", () => {
|
|
23
|
+
test("rejects empty specName", () => {
|
|
24
|
+
expect(() => createMemoryStore({ specName: "", rootDir: tmp })).toThrow(MemoryStoreError);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rejects specName with bad characters (path traversal etc.)", () => {
|
|
28
|
+
expect(() => createMemoryStore({ specName: "../etc/passwd", rootDir: tmp })).toThrow(
|
|
29
|
+
MemoryStoreError,
|
|
30
|
+
);
|
|
31
|
+
expect(() => createMemoryStore({ specName: "foo bar", rootDir: tmp })).toThrow(
|
|
32
|
+
MemoryStoreError,
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("path() returns the expected file location", () => {
|
|
37
|
+
const store = createMemoryStore({ specName: "my-spec", rootDir: tmp });
|
|
38
|
+
expect(store.path()).toBe(join(tmp, "my-spec.jsonl"));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("remember + recall", () => {
|
|
43
|
+
test("size() returns 0 before any remember", async () => {
|
|
44
|
+
const store = createMemoryStore({ specName: "s", rootDir: tmp });
|
|
45
|
+
expect(await store.size()).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("remember() persists and recall() finds it", async () => {
|
|
49
|
+
const store = createMemoryStore({ specName: "s", rootDir: tmp });
|
|
50
|
+
await store.remember("the user prefers TypeScript over JavaScript");
|
|
51
|
+
const results = await store.recall("TypeScript");
|
|
52
|
+
expect(results.length).toBe(1);
|
|
53
|
+
expect(results[0]?.entry.text).toBe("the user prefers TypeScript over JavaScript");
|
|
54
|
+
expect(results[0]?.score).toBeGreaterThan(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("remember assigns ids with the mem_ prefix", async () => {
|
|
58
|
+
const store = createMemoryStore({ specName: "s", rootDir: tmp });
|
|
59
|
+
const e1 = await store.remember("a");
|
|
60
|
+
const e2 = await store.remember("b");
|
|
61
|
+
expect(e1.id).toMatch(/^mem_[0-9a-f]{16}$/);
|
|
62
|
+
expect(e2.id).toMatch(/^mem_[0-9a-f]{16}$/);
|
|
63
|
+
expect(e1.id).not.toBe(e2.id);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("recall() ranks closer matches higher", async () => {
|
|
67
|
+
const store = createMemoryStore({ specName: "s", rootDir: tmp });
|
|
68
|
+
await store.remember("the user prefers TypeScript");
|
|
69
|
+
await store.remember("we discussed Python yesterday");
|
|
70
|
+
await store.remember("TypeScript is a typed superset of JavaScript");
|
|
71
|
+
const results = await store.recall("TypeScript", 3);
|
|
72
|
+
expect(results.length).toBe(2);
|
|
73
|
+
// Both TypeScript-mentioning entries should outrank the Python one,
|
|
74
|
+
// which is excluded entirely (zero score).
|
|
75
|
+
expect(results[0]?.score).toBeGreaterThanOrEqual(results[1]?.score);
|
|
76
|
+
expect(results.find((r) => r.entry.text.includes("Python"))).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("tags are indexed for recall", async () => {
|
|
80
|
+
const store = createMemoryStore({ specName: "s", rootDir: tmp });
|
|
81
|
+
await store.remember("personal note", ["family", "anniversary"]);
|
|
82
|
+
const results = await store.recall("anniversary");
|
|
83
|
+
expect(results.length).toBe(1);
|
|
84
|
+
expect(results[0]?.entry.tags).toEqual(["family", "anniversary"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("recall returns empty array when nothing matches", async () => {
|
|
88
|
+
const store = createMemoryStore({ specName: "s", rootDir: tmp });
|
|
89
|
+
await store.remember("apples and oranges");
|
|
90
|
+
const results = await store.recall("kumquat");
|
|
91
|
+
expect(results).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("k parameter caps the result count", async () => {
|
|
95
|
+
const store = createMemoryStore({ specName: "s", rootDir: tmp });
|
|
96
|
+
for (let i = 0; i < 5; i++) {
|
|
97
|
+
await store.remember(`memory entry number ${i} about banana`);
|
|
98
|
+
}
|
|
99
|
+
const results = await store.recall("banana", 2);
|
|
100
|
+
expect(results.length).toBe(2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("persists across store instances (file-backed)", async () => {
|
|
104
|
+
const store1 = createMemoryStore({ specName: "persist", rootDir: tmp });
|
|
105
|
+
await store1.remember("durable claim");
|
|
106
|
+
const store2 = createMemoryStore({ specName: "persist", rootDir: tmp });
|
|
107
|
+
expect(await store2.size()).toBe(1);
|
|
108
|
+
const results = await store2.recall("durable");
|
|
109
|
+
expect(results.length).toBe(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("malformed lines in the JSONL are skipped (not raised)", async () => {
|
|
113
|
+
const store = createMemoryStore({ specName: "broken", rootDir: tmp });
|
|
114
|
+
await store.remember("good entry");
|
|
115
|
+
// Corrupt the file by appending a broken line.
|
|
116
|
+
appendFileSync(store.path(), "{not json\n");
|
|
117
|
+
await store.remember("another good entry");
|
|
118
|
+
expect(await store.size()).toBe(2);
|
|
119
|
+
const results = await store.recall("entry");
|
|
120
|
+
expect(results.length).toBe(2);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("recall rejects empty queries", async () => {
|
|
124
|
+
const store = createMemoryStore({ specName: "s", rootDir: tmp });
|
|
125
|
+
await expect(store.recall("")).rejects.toThrow(MemoryStoreError);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("remember rejects empty text", async () => {
|
|
129
|
+
const store = createMemoryStore({ specName: "s", rootDir: tmp });
|
|
130
|
+
await expect(store.remember("")).rejects.toThrow(MemoryStoreError);
|
|
131
|
+
});
|
|
132
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog R7 sibling — `memory-store` (M4.2 of the heavy-hitter plan).
|
|
3
|
+
*
|
|
4
|
+
* Persistent cross-session memory: a file-backed JSONL store of facts
|
|
5
|
+
* the user wants the agent to remember across sessions. Each entry
|
|
6
|
+
* carries text + optional tags + a created-at timestamp. Recall does a
|
|
7
|
+
* BM25-style ranking over the tokenized text.
|
|
8
|
+
*
|
|
9
|
+
* Why JSONL: matches event-log and session-store; trivial to inspect
|
|
10
|
+
* with `tail`/`jq`; append-only writes are crash-safe; small enough
|
|
11
|
+
* working sets (< 10k entries per spec) that an in-memory load on each
|
|
12
|
+
* `recall()` call is fine.
|
|
13
|
+
*
|
|
14
|
+
* Why simple BM25 instead of embeddings: zero deps, deterministic for
|
|
15
|
+
* tests, and the working set is small. When a user grows into a much
|
|
16
|
+
* larger memory bank, swap the search backend behind the `MemoryStore`
|
|
17
|
+
* interface — `recall()` is the only consumer-visible signature.
|
|
18
|
+
*
|
|
19
|
+
* File path: `<rootDir>/<specName>.jsonl` where rootDir defaults to
|
|
20
|
+
* `.crewhaus/memories/`. One file per spec keeps memories scoped.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
23
|
+
import { appendFile, readFile } from "node:fs/promises";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_ROOT_DIR = ".crewhaus/memories";
|
|
28
|
+
|
|
29
|
+
export type MemoryEntry = {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly text: string;
|
|
32
|
+
readonly tags: readonly string[];
|
|
33
|
+
readonly createdAt: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type MemoryRecallResult = {
|
|
37
|
+
readonly entry: MemoryEntry;
|
|
38
|
+
readonly score: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface MemoryStoreOptions {
|
|
42
|
+
readonly rootDir?: string;
|
|
43
|
+
readonly specName: string;
|
|
44
|
+
readonly now?: () => Date;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MemoryStore {
|
|
48
|
+
/** Append a new memory. Returns the assigned entry. */
|
|
49
|
+
remember(text: string, tags?: readonly string[]): Promise<MemoryEntry>;
|
|
50
|
+
/** Top-k matches for the query, ranked by BM25-style score. */
|
|
51
|
+
recall(query: string, k?: number): Promise<readonly MemoryRecallResult[]>;
|
|
52
|
+
/** Diagnostic: how many entries are stored. */
|
|
53
|
+
size(): Promise<number>;
|
|
54
|
+
/** Diagnostic: where on disk this store writes. */
|
|
55
|
+
path(): string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class MemoryStoreError extends CrewhausError {
|
|
59
|
+
override readonly name = "MemoryStoreError";
|
|
60
|
+
constructor(message: string, cause?: unknown) {
|
|
61
|
+
super("config", message, cause);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DEFAULT_K = 5;
|
|
66
|
+
const ID_PREFIX = "mem_";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Construct a memory store for a given spec. The store is lazy — the
|
|
70
|
+
* underlying file is created on the first `remember()` call.
|
|
71
|
+
*/
|
|
72
|
+
export function createMemoryStore(opts: MemoryStoreOptions): MemoryStore {
|
|
73
|
+
if (!opts.specName) {
|
|
74
|
+
throw new MemoryStoreError("specName is required");
|
|
75
|
+
}
|
|
76
|
+
if (!/^[a-zA-Z0-9_\-.]+$/.test(opts.specName)) {
|
|
77
|
+
throw new MemoryStoreError(
|
|
78
|
+
`invalid specName "${opts.specName}" — must match [a-zA-Z0-9_\\-.]+`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
|
|
82
|
+
const now = opts.now ?? (() => new Date());
|
|
83
|
+
const filePath = join(rootDir, `${opts.specName}.jsonl`);
|
|
84
|
+
|
|
85
|
+
async function ensureRootDir(): Promise<void> {
|
|
86
|
+
if (!existsSync(rootDir)) {
|
|
87
|
+
mkdirSync(rootDir, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function loadAll(): Promise<MemoryEntry[]> {
|
|
92
|
+
if (!existsSync(filePath)) return [];
|
|
93
|
+
let raw: string;
|
|
94
|
+
try {
|
|
95
|
+
raw = await readFile(filePath, "utf-8");
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
const entries: MemoryEntry[] = [];
|
|
100
|
+
for (const line of raw.split("\n")) {
|
|
101
|
+
if (!line.trim()) continue;
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(line) as unknown;
|
|
104
|
+
if (isMemoryEntry(parsed)) entries.push(parsed);
|
|
105
|
+
} catch {
|
|
106
|
+
// Malformed line — skip. Append-only writes mean partial-write
|
|
107
|
+
// corruption is the most likely failure mode; one bad line
|
|
108
|
+
// shouldn't block recall on the others.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return entries;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function mkId(): string {
|
|
115
|
+
// 8-byte random hex; collision probability with < 10k entries per
|
|
116
|
+
// spec is negligible. crypto.randomUUID().replace would also work
|
|
117
|
+
// but we keep a fixed-width id for grep-friendliness in the JSONL.
|
|
118
|
+
let hex = "";
|
|
119
|
+
for (let i = 0; i < 16; i++) {
|
|
120
|
+
hex += Math.floor(Math.random() * 16).toString(16);
|
|
121
|
+
}
|
|
122
|
+
return `${ID_PREFIX}${hex}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
async remember(text: string, tags: readonly string[] = []): Promise<MemoryEntry> {
|
|
127
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
128
|
+
throw new MemoryStoreError("remember(): text must be a non-empty string");
|
|
129
|
+
}
|
|
130
|
+
const entry: MemoryEntry = {
|
|
131
|
+
id: mkId(),
|
|
132
|
+
text,
|
|
133
|
+
tags: [...tags],
|
|
134
|
+
createdAt: now().toISOString(),
|
|
135
|
+
};
|
|
136
|
+
await ensureRootDir();
|
|
137
|
+
await appendFile(filePath, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
138
|
+
return entry;
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async recall(query: string, k: number = DEFAULT_K): Promise<readonly MemoryRecallResult[]> {
|
|
142
|
+
if (typeof query !== "string" || query.length === 0) {
|
|
143
|
+
throw new MemoryStoreError("recall(): query must be a non-empty string");
|
|
144
|
+
}
|
|
145
|
+
const all = await loadAll();
|
|
146
|
+
if (all.length === 0) return [];
|
|
147
|
+
const queryTerms = tokenize(query);
|
|
148
|
+
if (queryTerms.length === 0) return [];
|
|
149
|
+
|
|
150
|
+
// BM25-style scoring:
|
|
151
|
+
// - tf = term frequency in this document
|
|
152
|
+
// - idf = log((N - df + 0.5) / (df + 0.5))
|
|
153
|
+
// - score = sum over terms of idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (dl / avgdl)))
|
|
154
|
+
const k1 = 1.5;
|
|
155
|
+
const b = 0.75;
|
|
156
|
+
const docs = all.map((entry) => ({
|
|
157
|
+
entry,
|
|
158
|
+
terms: tokenize(`${entry.text} ${entry.tags.join(" ")}`),
|
|
159
|
+
}));
|
|
160
|
+
const N = docs.length;
|
|
161
|
+
const avgdl = docs.reduce((sum, d) => sum + d.terms.length, 0) / Math.max(1, N);
|
|
162
|
+
|
|
163
|
+
// Document frequency for each query term.
|
|
164
|
+
const df = new Map<string, number>();
|
|
165
|
+
for (const t of new Set(queryTerms)) {
|
|
166
|
+
df.set(t, docs.filter((d) => d.terms.includes(t)).length);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const results: MemoryRecallResult[] = [];
|
|
170
|
+
for (const d of docs) {
|
|
171
|
+
let score = 0;
|
|
172
|
+
for (const t of queryTerms) {
|
|
173
|
+
const tf = d.terms.filter((x) => x === t).length;
|
|
174
|
+
if (tf === 0) continue;
|
|
175
|
+
const dfi = df.get(t) ?? 0;
|
|
176
|
+
const idf = Math.log((N - dfi + 0.5) / (dfi + 0.5) + 1);
|
|
177
|
+
const dl = d.terms.length;
|
|
178
|
+
const norm = tf * (k1 + 1);
|
|
179
|
+
const denom = tf + k1 * (1 - b + (b * dl) / Math.max(1, avgdl));
|
|
180
|
+
score += idf * (norm / denom);
|
|
181
|
+
}
|
|
182
|
+
if (score > 0) results.push({ entry: d.entry, score });
|
|
183
|
+
}
|
|
184
|
+
results.sort((a, b) => b.score - a.score);
|
|
185
|
+
return results.slice(0, Math.max(0, k));
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
async size(): Promise<number> {
|
|
189
|
+
const all = await loadAll();
|
|
190
|
+
return all.length;
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
path(): string {
|
|
194
|
+
return filePath;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isMemoryEntry(value: unknown): value is MemoryEntry {
|
|
200
|
+
if (typeof value !== "object" || value === null) return false;
|
|
201
|
+
const v = value as Record<string, unknown>;
|
|
202
|
+
return (
|
|
203
|
+
typeof v["id"] === "string" &&
|
|
204
|
+
typeof v["text"] === "string" &&
|
|
205
|
+
Array.isArray(v["tags"]) &&
|
|
206
|
+
(v["tags"] as unknown[]).every((t) => typeof t === "string") &&
|
|
207
|
+
typeof v["createdAt"] === "string"
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function tokenize(text: string): string[] {
|
|
212
|
+
return text
|
|
213
|
+
.toLowerCase()
|
|
214
|
+
.split(/[^a-z0-9]+/)
|
|
215
|
+
.filter((t) => t.length > 0);
|
|
216
|
+
}
|