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.
- package/README.MD +97 -88
- package/package.json +10 -4
- package/src/db.js +354 -0
- package/src/embeddings.js +124 -0
- package/src/index.js +21 -8
- package/src/migrate.js +124 -0
- package/src/search.js +151 -0
- package/src/store.js +112 -185
|
@@ -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
|
|
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
|
|
16
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|