agent-memory-store 0.0.5 → 0.0.7

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.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Local embedding generation via @huggingface/transformers.
3
+ *
4
+ * Uses the all-MiniLM-L6-v2 model (384 dimensions) running locally via ONNX Runtime.
5
+ * Model is auto-downloaded (~23MB) on first use and cached in ~/.cache/huggingface/.
6
+ *
7
+ * Graceful degradation: if the model fails to load, all functions return null
8
+ * and the system falls back to BM25-only search.
9
+ */
10
+
11
+ let pipelineInstance = null;
12
+ let loadFailed = false;
13
+ let loadingPromise = null;
14
+
15
+ /**
16
+ * Lazily initializes the feature-extraction pipeline.
17
+ * Returns null if the model cannot be loaded.
18
+ * Ensures only one load attempt runs at a time.
19
+ */
20
+ async function getPipeline() {
21
+ if (pipelineInstance) return pipelineInstance;
22
+ if (loadFailed) return null;
23
+
24
+ // Deduplicate concurrent load attempts
25
+ if (loadingPromise) return loadingPromise;
26
+
27
+ loadingPromise = (async () => {
28
+ try {
29
+ process.stderr.write(
30
+ "[agent-memory-store] Loading embedding model (first run downloads ~23MB)...\n",
31
+ );
32
+ const { pipeline } = await import("@huggingface/transformers");
33
+ pipelineInstance = await pipeline(
34
+ "feature-extraction",
35
+ "Xenova/all-MiniLM-L6-v2",
36
+ { dtype: "fp32" },
37
+ );
38
+ process.stderr.write(
39
+ "[agent-memory-store] Embedding model loaded successfully.\n",
40
+ );
41
+ return pipelineInstance;
42
+ } catch (err) {
43
+ loadFailed = true;
44
+ process.stderr.write(
45
+ `[agent-memory-store] Embedding model failed to load: ${err.message}\n` +
46
+ `[agent-memory-store] Falling back to BM25-only search.\n`,
47
+ );
48
+ return null;
49
+ } finally {
50
+ loadingPromise = null;
51
+ }
52
+ })();
53
+
54
+ return loadingPromise;
55
+ }
56
+
57
+ /**
58
+ * Generates an embedding for a single text string.
59
+ *
60
+ * @param {string} text - Text to embed (topic + tags + content)
61
+ * @returns {Promise<Float32Array|null>} 384-dim embedding or null if unavailable
62
+ */
63
+ export async function embed(text) {
64
+ const extractor = await getPipeline();
65
+ if (!extractor) return null;
66
+
67
+ try {
68
+ const output = await extractor(text, {
69
+ pooling: "mean",
70
+ normalize: true,
71
+ });
72
+ return new Float32Array(output.data);
73
+ } catch (err) {
74
+ process.stderr.write(
75
+ `[agent-memory-store] Embedding error: ${err.message}\n`,
76
+ );
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Generates embeddings for multiple texts.
83
+ *
84
+ * @param {string[]} texts
85
+ * @returns {Promise<Array<Float32Array|null>>}
86
+ */
87
+ export async function embedBatch(texts) {
88
+ const results = [];
89
+ for (const text of texts) {
90
+ results.push(await embed(text));
91
+ }
92
+ return results;
93
+ }
94
+
95
+ /**
96
+ * Prepares searchable text from chunk fields for embedding.
97
+ *
98
+ * @param {object} chunk
99
+ * @param {string} chunk.topic
100
+ * @param {string[]|string} chunk.tags
101
+ * @param {string} chunk.content
102
+ * @returns {string}
103
+ */
104
+ export function prepareText({ topic, tags, content }) {
105
+ const tagStr = Array.isArray(tags) ? tags.join(" ") : tags || "";
106
+ // Truncate content to ~800 chars to stay within model token limit
107
+ const truncated = content.length > 800 ? content.slice(0, 800) : content;
108
+ return `${topic} ${tagStr} ${truncated}`.trim();
109
+ }
110
+
111
+ /**
112
+ * Returns whether the embedding model is available.
113
+ */
114
+ export function isEmbeddingAvailable() {
115
+ return pipelineInstance !== null && !loadFailed;
116
+ }
117
+
118
+ /**
119
+ * Pre-warms the embedding model (call during startup).
120
+ * Non-blocking — failures are silently handled.
121
+ */
122
+ export async function warmup() {
123
+ await getPipeline();
124
+ }
package/src/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * agent-store MCP server entry point.
3
+ * agent-memory-store MCP server entry point.
4
4
  *
5
5
  * Exposes 7 tools to any MCP-compatible client (Claude Code, opencode, etc.):
6
- * search_context — BM25 full-text search over stored chunks
6
+ * search_context — Hybrid search (BM25 + semantic) over stored chunks
7
7
  * write_context — persist a new memory chunk
8
8
  * read_context — retrieve a chunk by ID
9
9
  * list_context — list chunk metadata (no body)
@@ -12,8 +12,8 @@
12
12
  * set_state — write a session state variable
13
13
  *
14
14
  * Usage:
15
- * npx @agentops/context-store
16
- * CONTEXT_STORE_PATH=/your/project/.context npx @agentops/context-store
15
+ * npx agent-memory-store
16
+ * AGENT_STORE_PATH=/your/project/.agent-memory-store npx agent-memory-store
17
17
  */
18
18
 
19
19
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -27,6 +27,7 @@ import {
27
27
  listChunks,
28
28
  getState,
29
29
  setState,
30
+ initStore,
30
31
  } from "./store.js";
31
32
 
32
33
  const { version } = JSON.parse(
@@ -35,6 +36,9 @@ const { version } = JSON.parse(
35
36
  ),
36
37
  );
37
38
 
39
+ // Initialize database, run migration, warm up embeddings
40
+ await initStore();
41
+
38
42
  const server = new McpServer({
39
43
  name: "context-store",
40
44
  version,
@@ -45,9 +49,10 @@ const server = new McpServer({
45
49
  server.tool(
46
50
  "search_context",
47
51
  [
48
- "Search stored memory chunks by relevance using BM25 full-text ranking.",
52
+ "Search stored memory chunks using hybrid ranking (BM25 + semantic similarity).",
49
53
  "Call this at the start of any task to retrieve relevant prior knowledge,",
50
54
  "decisions, and outputs before generating a response.",
55
+ "Supports three modes: 'hybrid' (default, best quality), 'bm25' (keyword-only), 'semantic' (meaning-only).",
51
56
  ].join(" "),
52
57
  {
53
58
  query: z
@@ -75,16 +80,23 @@ server.tool(
75
80
  .min(0)
76
81
  .optional()
77
82
  .describe(
78
- "Minimum BM25 relevance score. Lower = more permissive (default: 0.1).",
83
+ "Minimum relevance score. Lower = more permissive (default: 0.1).",
84
+ ),
85
+ search_mode: z
86
+ .enum(["hybrid", "bm25", "semantic"])
87
+ .optional()
88
+ .describe(
89
+ "Search strategy: 'hybrid' (BM25 + semantic, default), 'bm25' (keyword only), 'semantic' (embedding similarity only).",
79
90
  ),
80
91
  },
81
- async ({ query, tags, agent, top_k, min_score }) => {
92
+ async ({ query, tags, agent, top_k, min_score, search_mode }) => {
82
93
  const results = await searchChunks({
83
94
  query,
84
95
  tags: tags ?? [],
85
96
  agent,
86
97
  topK: top_k ?? 6,
87
98
  minScore: min_score ?? 0.1,
99
+ mode: search_mode ?? "hybrid",
88
100
  });
89
101
 
90
102
  if (results.length === 0) {
@@ -111,9 +123,10 @@ server.tool(
111
123
  server.tool(
112
124
  "write_context",
113
125
  [
114
- "Persist a memory chunk to local storage.",
126
+ "Persist a memory chunk to the database.",
115
127
  "Call this after completing a subtask, making a key decision,",
116
128
  "or producing output that downstream agents will need.",
129
+ "Embeddings are computed automatically in the background for semantic search.",
117
130
  ].join(" "),
118
131
  {
119
132
  topic: z
package/src/migrate.js ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Migration: filesystem-based storage → SQLite database.
3
+ *
4
+ * Runs automatically on first startup if the legacy chunks/ directory exists
5
+ * but store.db does not. Migrates all chunks and state, then renames the
6
+ * legacy directories to *_backup/.
7
+ */
8
+
9
+ import fs from "fs/promises";
10
+ import path from "path";
11
+ import matter from "gray-matter";
12
+ import { insertChunk, setStateDb, STORE_PATH } from "./db.js";
13
+
14
+ const CHUNKS_DIR = path.join(STORE_PATH, "chunks");
15
+ const STATE_DIR = path.join(STORE_PATH, "state");
16
+ const DB_PATH = path.join(STORE_PATH, "store.db");
17
+
18
+ /**
19
+ * Checks if migration is needed and runs it.
20
+ * @returns {Promise<boolean>} true if migration was performed
21
+ */
22
+ export async function migrateIfNeeded() {
23
+ // Check if legacy chunks dir exists
24
+ const chunksExist = await fs
25
+ .stat(CHUNKS_DIR)
26
+ .then((s) => s.isDirectory())
27
+ .catch(() => false);
28
+
29
+ if (!chunksExist) return false;
30
+
31
+ // Check if DB already exists (already migrated)
32
+ const dbExists = await fs
33
+ .stat(DB_PATH)
34
+ .then((s) => s.isFile())
35
+ .catch(() => false);
36
+
37
+ if (dbExists) return false;
38
+
39
+ process.stderr.write(
40
+ "[agent-memory-store] Migrating filesystem storage to SQLite...\n",
41
+ );
42
+
43
+ let chunkCount = 0;
44
+ let stateCount = 0;
45
+
46
+ // Migrate chunks
47
+ try {
48
+ const files = await fs.readdir(CHUNKS_DIR);
49
+ for (const file of files) {
50
+ if (!file.endsWith(".md")) continue;
51
+ try {
52
+ const raw = await fs.readFile(path.join(CHUNKS_DIR, file), "utf8");
53
+ const { data: meta, content } = matter(raw);
54
+
55
+ // Skip expired chunks
56
+ if (meta.expires && new Date(meta.expires) < new Date()) continue;
57
+
58
+ const now = new Date().toISOString();
59
+ await insertChunk({
60
+ id: meta.id || file.replace(".md", ""),
61
+ topic: meta.topic || "Untitled",
62
+ agent: meta.agent || "global",
63
+ tags: meta.tags || [],
64
+ importance: meta.importance || "medium",
65
+ content: content.trim(),
66
+ embedding: null, // Will be computed in background
67
+ createdAt: meta.updated || now,
68
+ updatedAt: meta.updated || now,
69
+ expiresAt: meta.expires || null,
70
+ });
71
+ chunkCount++;
72
+ } catch {
73
+ // Skip unreadable files
74
+ }
75
+ }
76
+ } catch {
77
+ // chunks dir not readable
78
+ }
79
+
80
+ // Migrate state
81
+ try {
82
+ const files = await fs.readdir(STATE_DIR);
83
+ for (const file of files) {
84
+ if (!file.endsWith(".json")) continue;
85
+ try {
86
+ const raw = await fs.readFile(path.join(STATE_DIR, file), "utf8");
87
+ const { key, value } = JSON.parse(raw);
88
+ if (key) {
89
+ await setStateDb(key, value);
90
+ stateCount++;
91
+ }
92
+ } catch {
93
+ // Skip unreadable files
94
+ }
95
+ }
96
+ } catch {
97
+ // state dir not readable
98
+ }
99
+
100
+ // Rename legacy directories to backups
101
+ try {
102
+ await fs.rename(CHUNKS_DIR, CHUNKS_DIR + "_backup");
103
+ } catch {
104
+ // Rename failed — not critical
105
+ }
106
+
107
+ try {
108
+ const stateExists = await fs
109
+ .stat(STATE_DIR)
110
+ .then((s) => s.isDirectory())
111
+ .catch(() => false);
112
+ if (stateExists) {
113
+ await fs.rename(STATE_DIR, STATE_DIR + "_backup");
114
+ }
115
+ } catch {
116
+ // Rename failed — not critical
117
+ }
118
+
119
+ process.stderr.write(
120
+ `[agent-memory-store] Migration complete: ${chunkCount} chunks, ${stateCount} state entries.\n`,
121
+ );
122
+
123
+ return true;
124
+ }
package/src/search.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Hybrid search engine combining FTS5 BM25 (native SQLite) and vector similarity.
3
+ *
4
+ * Search modes:
5
+ * - "hybrid" — FTS5 BM25 + vector cosine similarity merged via Reciprocal Rank Fusion
6
+ * - "bm25" — FTS5 only (no embeddings needed)
7
+ * - "semantic" — Vector similarity only
8
+ *
9
+ * Falls back to BM25-only if embeddings are not available.
10
+ */
11
+
12
+ import { searchFTS, getAllEmbeddings, getChunk } from "./db.js";
13
+ import { embed, isEmbeddingAvailable } from "./embeddings.js";
14
+
15
+ // ─── Vector Search ──────────────────────────────────────────────────────────
16
+
17
+ /**
18
+ * Computes cosine similarity between two Float32Arrays.
19
+ * Assumes both vectors are already L2-normalized (dot product = cosine sim).
20
+ */
21
+ function cosineSimilarity(a, b) {
22
+ let dot = 0;
23
+ for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
24
+ return dot;
25
+ }
26
+
27
+ /**
28
+ * Brute-force vector search over all chunk embeddings.
29
+ */
30
+ function vectorSearch(queryEmbedding, { agent, tags = [], topK = 18 }) {
31
+ const embeddings = getAllEmbeddings({ agent, tags });
32
+ if (!embeddings.length) return [];
33
+
34
+ return embeddings
35
+ .map(({ id, embedding }) => ({
36
+ id,
37
+ score: cosineSimilarity(queryEmbedding, embedding),
38
+ }))
39
+ .filter((r) => r.score > 0)
40
+ .sort((a, b) => b.score - a.score)
41
+ .slice(0, topK);
42
+ }
43
+
44
+ // ─── Fusion ─────────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Reciprocal Rank Fusion — merges two ranked lists into one.
48
+ */
49
+ function reciprocalRankFusion(bm25Hits, vecHits, wBM25 = 0.4, wVec = 0.6) {
50
+ const K = 60;
51
+ const scores = new Map();
52
+
53
+ bm25Hits.forEach(({ id }, rank) => {
54
+ scores.set(id, (scores.get(id) || 0) + wBM25 / (K + rank + 1));
55
+ });
56
+
57
+ vecHits.forEach(({ id }, rank) => {
58
+ scores.set(id, (scores.get(id) || 0) + wVec / (K + rank + 1));
59
+ });
60
+
61
+ return [...scores.entries()]
62
+ .map(([id, score]) => ({ id, score }))
63
+ .sort((a, b) => b.score - a.score);
64
+ }
65
+
66
+ // ─── Main Search ────────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Main search function — performs hybrid, BM25, or semantic search.
70
+ *
71
+ * @param {object} opts
72
+ * @param {string} opts.query
73
+ * @param {string[]} [opts.tags]
74
+ * @param {string} [opts.agent]
75
+ * @param {number} [opts.topK]
76
+ * @param {number} [opts.minScore]
77
+ * @param {string} [opts.mode] - "hybrid" | "bm25" | "semantic"
78
+ * @returns {Promise<Array>}
79
+ */
80
+ export async function hybridSearch({
81
+ query,
82
+ tags = [],
83
+ agent,
84
+ topK = 6,
85
+ minScore = 0.1,
86
+ mode = "hybrid",
87
+ }) {
88
+ const candidateK = topK * 3;
89
+ const embeddingsReady = isEmbeddingAvailable();
90
+
91
+ // Determine effective mode
92
+ let effectiveMode = mode;
93
+ if ((mode === "hybrid" || mode === "semantic") && !embeddingsReady) {
94
+ effectiveMode = "bm25";
95
+ }
96
+
97
+ let fusedResults;
98
+
99
+ if (effectiveMode === "bm25") {
100
+ fusedResults = searchFTS({ query, agent, tags, topK: candidateK });
101
+ } else if (effectiveMode === "semantic") {
102
+ const queryEmbedding = await embed(query);
103
+ if (!queryEmbedding) {
104
+ fusedResults = searchFTS({ query, agent, tags, topK: candidateK });
105
+ } else {
106
+ fusedResults = vectorSearch(queryEmbedding, {
107
+ agent,
108
+ tags,
109
+ topK: candidateK,
110
+ });
111
+ }
112
+ } else {
113
+ // Hybrid: run FTS5 (sync) and embed query (async) in parallel
114
+ const queryEmbeddingPromise = embed(query);
115
+ const bm25Hits = searchFTS({ query, agent, tags, topK: candidateK });
116
+ const queryEmbedding = await queryEmbeddingPromise;
117
+
118
+ if (!queryEmbedding) {
119
+ fusedResults = bm25Hits;
120
+ } else {
121
+ const vecHits = vectorSearch(queryEmbedding, {
122
+ agent,
123
+ tags,
124
+ topK: candidateK,
125
+ });
126
+ fusedResults = reciprocalRankFusion(bm25Hits, vecHits);
127
+ }
128
+ }
129
+
130
+ // Take topK and enrich with full chunk data
131
+ const topResults = fusedResults.slice(0, topK);
132
+ const enriched = [];
133
+
134
+ for (const { id, score } of topResults) {
135
+ const chunk = getChunk(id);
136
+ if (!chunk) continue;
137
+
138
+ enriched.push({
139
+ id: chunk.id,
140
+ topic: chunk.topic,
141
+ agent: chunk.agent,
142
+ tags: chunk.tags,
143
+ importance: chunk.importance,
144
+ score: Math.round(score * 100) / 100,
145
+ content: chunk.content,
146
+ updated: chunk.updatedAt,
147
+ });
148
+ }
149
+
150
+ return enriched;
151
+ }