@sksoftofficial/mindroot 1.0.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/README.md +94 -0
- package/bin/mindroot.js +57 -0
- package/package.json +34 -0
- package/src/commands/cli-api.js +42 -0
- package/src/commands/human.js +79 -0
- package/src/commands/init.js +25 -0
- package/src/commands/start.js +162 -0
- package/src/core.js +176 -0
- package/src/dashboard/index.html +420 -0
- package/src/embedder.js +32 -0
- package/src/indexer.js +175 -0
- package/src/markdown.js +72 -0
- package/src/mcp.js +216 -0
- package/src/paths.js +38 -0
- package/src/search.js +196 -0
- package/src/server.js +192 -0
- package/src/store/db.js +107 -0
- package/test/markdown.test.js +32 -0
- package/test/mcp.test.js +74 -0
- package/test/search.test.js +87 -0
- package/test/store.test.js +34 -0
package/src/markdown.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export function parseMarkdown(raw) {
|
|
2
|
+
const lines = raw.split("\n");
|
|
3
|
+
let start = 0;
|
|
4
|
+
let frontmatter = null;
|
|
5
|
+
if ((lines[0] ?? "").trim() === "---") {
|
|
6
|
+
const end = lines.findIndex((l, j) => j > 0 && l.trim() === "---");
|
|
7
|
+
if (end !== -1) {
|
|
8
|
+
frontmatter = lines.slice(0, end + 1).join("\n");
|
|
9
|
+
start = end + 1;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const heads = [];
|
|
13
|
+
for (let j = start; j < lines.length; j++) {
|
|
14
|
+
const m = lines[j].match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
15
|
+
if (m) heads.push({ line: j, level: m[1].length, title: m[2].trim() });
|
|
16
|
+
}
|
|
17
|
+
const bounds = [...heads.map((h) => h.line), lines.length];
|
|
18
|
+
const sections = [];
|
|
19
|
+
if (!heads.length || heads[0].line > start) {
|
|
20
|
+
const endIdx = heads.length ? heads[0].line : lines.length;
|
|
21
|
+
const body = trimBlank(lines.slice(start, endIdx));
|
|
22
|
+
if (body.length) {
|
|
23
|
+
sections.push({
|
|
24
|
+
level: 0,
|
|
25
|
+
title: "",
|
|
26
|
+
path: "",
|
|
27
|
+
bodyStart: start,
|
|
28
|
+
bodyEnd: endIdx,
|
|
29
|
+
body: body.join("\n"),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const stack = [];
|
|
34
|
+
heads.forEach((h, k) => {
|
|
35
|
+
while (stack.length && stack[stack.length - 1].level >= h.level) stack.pop();
|
|
36
|
+
stack.push(h);
|
|
37
|
+
const bodyLines = trimBlank(lines.slice(h.line + 1, bounds[k + 1]));
|
|
38
|
+
sections.push({
|
|
39
|
+
level: h.level,
|
|
40
|
+
title: h.title,
|
|
41
|
+
path: stack.filter((s) => s.level > 1).map((s) => s.title).join("::"),
|
|
42
|
+
bodyStart: h.line + 1,
|
|
43
|
+
bodyEnd: bounds[k + 1],
|
|
44
|
+
body: bodyLines.join("\n"),
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
return { frontmatter, sections };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function trimBlank(arr) {
|
|
51
|
+
let a = 0;
|
|
52
|
+
let b = arr.length;
|
|
53
|
+
while (a < b && !arr[a].trim()) a++;
|
|
54
|
+
while (b > a && !arr[b - 1].trim()) b--;
|
|
55
|
+
return arr.slice(a, b);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function docTitle(parsed, fileName) {
|
|
59
|
+
const h1 = parsed.sections.find((s) => s.level === 1);
|
|
60
|
+
if (h1) return h1.title;
|
|
61
|
+
if (parsed.frontmatter) {
|
|
62
|
+
const m = parsed.frontmatter.match(/^title:\s*(.+)$/m);
|
|
63
|
+
if (m) return m[1].trim().replace(/^["']|["']$/g, "");
|
|
64
|
+
}
|
|
65
|
+
return fileName.replace(/\.md$/, "");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function replaceSectionBody(raw, sec, newBody) {
|
|
69
|
+
const lines = raw.split("\n");
|
|
70
|
+
const replacement = newBody.replace(/\n+$/, "").split("\n");
|
|
71
|
+
return [...lines.slice(0, sec.bodyStart), ...replacement, ...lines.slice(sec.bodyEnd)].join("\n");
|
|
72
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
const SERVER_INFO = { name: "mindroot", version: "0.2.0" };
|
|
2
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
3
|
+
|
|
4
|
+
export function tools() {
|
|
5
|
+
return [
|
|
6
|
+
{
|
|
7
|
+
name: "mindroot_list_projects",
|
|
8
|
+
description: "List all projects known to mindroot with their note and memory counts. Use to discover valid project slugs before calling project-scoped tools.",
|
|
9
|
+
inputSchema: { type: "object", properties: {} },
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: "mindroot_search_projects",
|
|
13
|
+
description:
|
|
14
|
+
"Find projects by fuzzy name. Matches project slugs semantically and by token overlap (e.g. 'mem-agent-mcp' finds 'proper-agent-memory'). Use to discover the right project slug before calling project-scoped tools.",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: { query: { type: "string", description: "Project name, full or partial/fuzzy" } },
|
|
18
|
+
required: ["query"],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "mindroot_search_memories",
|
|
23
|
+
description:
|
|
24
|
+
"Search a project's memories — short, retrieval-optimized notes about durable facts, conventions, decisions, and gotchas. Hybrid semantic + keyword ranking. Use before exploring a codebase or asking the user things that may already be remembered. Hits include ids usable with mindroot_delete_memory.",
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
project: { type: "string", description: "Project slug to search in" },
|
|
29
|
+
query: { type: "string", description: "Natural language search query" },
|
|
30
|
+
limit: { type: "number", description: "Max results (default 8)" },
|
|
31
|
+
},
|
|
32
|
+
required: ["project", "query"],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "mindroot_search_notes",
|
|
37
|
+
description:
|
|
38
|
+
"Search a project's note sections — detailed markdown documentation parsed from headings. Hybrid semantic ranking. Returns the note path and section each hit comes from; follow up with mindroot_read_note for full content.",
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
project: { type: "string", description: "Project slug to search in" },
|
|
43
|
+
query: { type: "string", description: "Natural language search query" },
|
|
44
|
+
limit: { type: "number", description: "Max results (default 8)" },
|
|
45
|
+
},
|
|
46
|
+
required: ["project", "query"],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "mindroot_save_memory",
|
|
51
|
+
description:
|
|
52
|
+
"Save a memory — a short, retrieval-optimized text about a durable fact, convention, decision, or gotcha for a project. Optionally link it to a note path ('notes/foo.md') or section ('notes/foo.md::Heading::Subheading'). Write memories so they answer 'when would someone need this'.",
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: {
|
|
56
|
+
project: { type: "string" },
|
|
57
|
+
text: { type: "string" },
|
|
58
|
+
target_path: { type: "string", description: "Optional link target: 'path.md' or 'path.md::Heading'" },
|
|
59
|
+
},
|
|
60
|
+
required: ["project", "text"],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "mindroot_delete_memory",
|
|
65
|
+
description: "Delete a memory by id. Use to dedupe or remove stale entries. Ids come from mindroot_search_memories hits.",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: { project: { type: "string" }, id: { type: "number" } },
|
|
69
|
+
required: ["project", "id"],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "mindroot_list_notes",
|
|
74
|
+
description: "List all memory notes for a project with their titles and section paths.",
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: { project: { type: "string" } },
|
|
78
|
+
required: ["project"],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "mindroot_read_note",
|
|
83
|
+
description:
|
|
84
|
+
"Read the full content of a memory note. Verifies file integrity first and re-indexes automatically if the file was edited externally.",
|
|
85
|
+
inputSchema: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: { project: { type: "string" }, path: { type: "string", description: "Note path relative to the project, e.g. 'architecture.md'" } },
|
|
88
|
+
required: ["project", "path"],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "mindroot_save_note",
|
|
93
|
+
description:
|
|
94
|
+
"Create or fully overwrite a markdown memory note. Sections are parsed from headings (# .. ######) and become individually searchable via mindroot_search_notes. After saving, consider saving 1-3 linked memories (mindroot_save_memory with target_path) so key facts surface in memory searches.",
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: "object",
|
|
97
|
+
properties: {
|
|
98
|
+
project: { type: "string" },
|
|
99
|
+
path: { type: "string", description: "Note path ending in .md" },
|
|
100
|
+
content: { type: "string", description: "Full markdown content" },
|
|
101
|
+
},
|
|
102
|
+
required: ["project", "path", "content"],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "mindroot_update_section",
|
|
107
|
+
description:
|
|
108
|
+
"Replace the body of one heading-section inside an existing note, leaving the rest of the file untouched.",
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: {
|
|
112
|
+
project: { type: "string" },
|
|
113
|
+
path: { type: "string" },
|
|
114
|
+
heading_path: { type: "string", description: "Section address like 'Architecture::Storage' (heading titles joined by ::)" },
|
|
115
|
+
content: { type: "string", description: "New body text for this section" },
|
|
116
|
+
},
|
|
117
|
+
required: ["project", "path", "heading_path", "content"],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "mindroot_delete_note",
|
|
122
|
+
description: "Delete a memory note and its sections index entries.",
|
|
123
|
+
inputSchema: {
|
|
124
|
+
type: "object",
|
|
125
|
+
properties: { project: { type: "string" }, path: { type: "string" } },
|
|
126
|
+
required: ["project", "path"],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function callTool(db, name, args) {
|
|
133
|
+
const core = await import("./core.js");
|
|
134
|
+
switch (name) {
|
|
135
|
+
case "mindroot_list_projects":
|
|
136
|
+
return core.listProjects(db);
|
|
137
|
+
case "mindroot_search_projects":
|
|
138
|
+
return core.searchProjects(db, args.query);
|
|
139
|
+
case "mindroot_search_memories":
|
|
140
|
+
return core.searchMemories(db, args.project, args.query, args.limit ?? 8);
|
|
141
|
+
case "mindroot_search_notes":
|
|
142
|
+
return core.searchNotes(db, args.project, args.query, args.limit ?? 8);
|
|
143
|
+
case "mindroot_save_memory":
|
|
144
|
+
return core.addMemory(db, args.project, args.text, args.target_path);
|
|
145
|
+
case "mindroot_delete_memory":
|
|
146
|
+
return core.deleteMemory(db, args.project, Number(args.id));
|
|
147
|
+
case "mindroot_list_notes":
|
|
148
|
+
return core.listDocs(db, args.project);
|
|
149
|
+
case "mindroot_read_note":
|
|
150
|
+
return core.readNote(db, args.project, args.path);
|
|
151
|
+
case "mindroot_save_note":
|
|
152
|
+
return core.saveNote(db, args.project, args.path, args.content);
|
|
153
|
+
case "mindroot_update_section":
|
|
154
|
+
return core.updateSection(db, args.project, args.path, args.heading_path, args.content);
|
|
155
|
+
case "mindroot_delete_note":
|
|
156
|
+
return core.deleteNote(db, args.project, args.path);
|
|
157
|
+
default:
|
|
158
|
+
throw new Error(`unknown tool: ${name}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function handleRpc(db, rpc) {
|
|
163
|
+
if (!rpc || typeof rpc !== "object") {
|
|
164
|
+
return { status: 400, body: jsonRpcError(null, -32600, "invalid request") };
|
|
165
|
+
}
|
|
166
|
+
if (rpc.id === undefined || rpc.id === null) {
|
|
167
|
+
return { status: 202, body: null };
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
let result;
|
|
171
|
+
switch (rpc.method) {
|
|
172
|
+
case "initialize": {
|
|
173
|
+
result = {
|
|
174
|
+
protocolVersion: rpc.params?.protocolVersion ?? PROTOCOL_VERSION,
|
|
175
|
+
capabilities: { tools: {} },
|
|
176
|
+
serverInfo: SERVER_INFO,
|
|
177
|
+
};
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case "ping": {
|
|
181
|
+
result = {};
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case "tools/list": {
|
|
185
|
+
result = { tools: tools() };
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case "tools/call": {
|
|
189
|
+
const { name, arguments: args } = rpc.params ?? {};
|
|
190
|
+
try {
|
|
191
|
+
const data = await callTool(db, name, args ?? {});
|
|
192
|
+
result = { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return {
|
|
195
|
+
status: 200,
|
|
196
|
+
body: {
|
|
197
|
+
jsonrpc: "2.0",
|
|
198
|
+
id: rpc.id,
|
|
199
|
+
result: { content: [{ type: "text", text: String(err.message) }], isError: true },
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
default:
|
|
206
|
+
return { status: 200, body: jsonRpcError(rpc.id, -32601, `method not found: ${rpc.method}`) };
|
|
207
|
+
}
|
|
208
|
+
return { status: 200, body: { jsonrpc: "2.0", id: rpc.id, result } };
|
|
209
|
+
} catch (err) {
|
|
210
|
+
return { status: 200, body: jsonRpcError(rpc.id, -32603, String(err.message)) };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function jsonRpcError(id, code, message) {
|
|
215
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
216
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export const MINDROOT_DIR = process.env.MINDROOT_DIR ?? path.join(os.homedir(), ".mindroot");
|
|
7
|
+
export const MODELS_DIR = path.join(MINDROOT_DIR, "models");
|
|
8
|
+
export const NOTES_DIR = path.join(MINDROOT_DIR, "notes");
|
|
9
|
+
export const DB_PATH = path.join(MINDROOT_DIR, "mindroot.db");
|
|
10
|
+
export const CONFIG_PATH = path.join(MINDROOT_DIR, "config.json");
|
|
11
|
+
|
|
12
|
+
export const ONNX_MODEL_ID = "onnx-community/embeddinggemma-300m-ONNX";
|
|
13
|
+
export const EMBED_DIM = 768;
|
|
14
|
+
export const DEFAULT_PORT = 7620;
|
|
15
|
+
|
|
16
|
+
export function loadConfig() {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function saveConfig(cfg) {
|
|
25
|
+
fs.mkdirSync(MINDROOT_DIR, { recursive: true });
|
|
26
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ensureConfig() {
|
|
30
|
+
const cfg = loadConfig();
|
|
31
|
+
const created = !cfg.apiKey;
|
|
32
|
+
if (created) {
|
|
33
|
+
cfg.apiKey = crypto.randomBytes(24).toString("hex");
|
|
34
|
+
}
|
|
35
|
+
cfg.port = cfg.port ?? DEFAULT_PORT;
|
|
36
|
+
if (created || !loadConfig().port) saveConfig(cfg);
|
|
37
|
+
return { cfg, created };
|
|
38
|
+
}
|
package/src/search.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { embedQuery, embedDoc } from "./embedder.js";
|
|
2
|
+
import { ONNX_MODEL_ID } from "./paths.js";
|
|
3
|
+
|
|
4
|
+
function toFloat32(buf) {
|
|
5
|
+
const ab = buf instanceof Uint8Array ? buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) : buf;
|
|
6
|
+
return new Float32Array(ab);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function cosine(a, b) {
|
|
10
|
+
let dot = 0;
|
|
11
|
+
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
|
|
12
|
+
return dot;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ftsQuery(query) {
|
|
16
|
+
const terms = query.trim().split(/\s+/).filter(Boolean).slice(0, 8);
|
|
17
|
+
if (!terms.length) return null;
|
|
18
|
+
return terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tokenize(s) {
|
|
22
|
+
return s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function slugBonus(slug, queryTokens) {
|
|
26
|
+
if (!queryTokens.length) return 0;
|
|
27
|
+
const slugTokens = tokenize(slug);
|
|
28
|
+
let matched = 0;
|
|
29
|
+
for (const qt of queryTokens) {
|
|
30
|
+
if (
|
|
31
|
+
slugTokens.some(
|
|
32
|
+
(st) =>
|
|
33
|
+
st === qt ||
|
|
34
|
+
(qt.length >= 3 && st.length >= 3 && (st.startsWith(qt) || qt.startsWith(st))),
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
matched++;
|
|
38
|
+
}
|
|
39
|
+
return matched / queryTokens.length;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function searchProjects(db, query) {
|
|
43
|
+
await backfillProjectVectors(db);
|
|
44
|
+
return rankProjects(db, await embedQuery(query), query);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function rankProjects(db, qvec, query) {
|
|
48
|
+
const queryTokens = tokenize(query);
|
|
49
|
+
|
|
50
|
+
const projects = db
|
|
51
|
+
.prepare(
|
|
52
|
+
`SELECT p.id, p.slug, pe.vec
|
|
53
|
+
FROM projects p
|
|
54
|
+
LEFT JOIN project_embeddings pe ON pe.project_id = p.id`,
|
|
55
|
+
)
|
|
56
|
+
.all();
|
|
57
|
+
|
|
58
|
+
const hits = [];
|
|
59
|
+
for (const p of projects) {
|
|
60
|
+
const cos = p.vec ? cosine(qvec, toFloat32(p.vec)) : 0;
|
|
61
|
+
const bonus = slugBonus(p.slug, queryTokens);
|
|
62
|
+
const score = bonus > 0 ? Math.max(cos, 0.55 + 0.4 * bonus) : cos;
|
|
63
|
+
if (bonus > 0 || score >= 0.5) {
|
|
64
|
+
hits.push({ kind: "project", id: p.id, project: p.slug, score });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
hits.sort((a, b) => b.score - a.score);
|
|
68
|
+
return hits;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function backfillProjectVectors(db) {
|
|
72
|
+
const projects = db
|
|
73
|
+
.prepare(
|
|
74
|
+
`SELECT p.id, p.slug FROM projects p
|
|
75
|
+
LEFT JOIN project_embeddings pe ON pe.project_id = p.id
|
|
76
|
+
WHERE pe.project_id IS NULL`,
|
|
77
|
+
)
|
|
78
|
+
.all();
|
|
79
|
+
for (const p of projects) {
|
|
80
|
+
const vec = await embedDoc(p.slug);
|
|
81
|
+
db.prepare(
|
|
82
|
+
"INSERT INTO project_embeddings (project_id, dim, model, vec) VALUES (?, ?, ?, ?)",
|
|
83
|
+
).run(p.id, vec.length, ONNX_MODEL_ID, Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function searchMemories(db, projectId, query, limit = 8) {
|
|
88
|
+
return rankMemories(db, projectId, await embedQuery(query), query, limit);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function rankMemories(db, projectId, qvec, query, limit = 8) {
|
|
92
|
+
const rows = db
|
|
93
|
+
.prepare(
|
|
94
|
+
`SELECT e.owner_id, e.vec, m.text AS mtext, m.target_path, m.updated_at
|
|
95
|
+
FROM embeddings e
|
|
96
|
+
JOIN memories m ON m.id = e.owner_id
|
|
97
|
+
WHERE e.owner_type = 'memory' AND m.project_id = $pid`,
|
|
98
|
+
)
|
|
99
|
+
.all({ pid: projectId });
|
|
100
|
+
|
|
101
|
+
const byId = new Map();
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
const vec = toFloat32(row.vec);
|
|
104
|
+
if (vec.length !== qvec.length) continue;
|
|
105
|
+
byId.set(row.owner_id, {
|
|
106
|
+
kind: "memory",
|
|
107
|
+
id: row.owner_id,
|
|
108
|
+
text: row.mtext,
|
|
109
|
+
target: row.target_path,
|
|
110
|
+
doc_title: null,
|
|
111
|
+
score: cosine(qvec, vec),
|
|
112
|
+
updated_at: row.updated_at,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
applyFts(db, projectId, query, byId);
|
|
117
|
+
|
|
118
|
+
return rank(byId, limit);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function applyFts(db, projectId, query, byId) {
|
|
122
|
+
const fq = ftsQuery(query);
|
|
123
|
+
if (!fq) return;
|
|
124
|
+
try {
|
|
125
|
+
const ftsRows = db
|
|
126
|
+
.prepare(
|
|
127
|
+
`SELECT rowid, bm25(memories_fts) AS rank FROM memories_fts WHERE memories_fts MATCH ? ORDER BY rank LIMIT 50`,
|
|
128
|
+
)
|
|
129
|
+
.all(fq);
|
|
130
|
+
const ranks = new Map();
|
|
131
|
+
let maxRank = 0;
|
|
132
|
+
for (const r of ftsRows) {
|
|
133
|
+
const norm = -r.rank;
|
|
134
|
+
if (norm > maxRank) maxRank = norm;
|
|
135
|
+
ranks.set(r.rowid, norm);
|
|
136
|
+
}
|
|
137
|
+
if (!ranks.size) return;
|
|
138
|
+
const allowed = new Set(
|
|
139
|
+
db
|
|
140
|
+
.prepare(`SELECT id FROM memories WHERE project_id = ? AND id IN (${[...ranks.keys()].map(() => "?").join(",")})`)
|
|
141
|
+
.all(projectId, ...ranks.keys())
|
|
142
|
+
.map((r) => r.id),
|
|
143
|
+
);
|
|
144
|
+
for (const [rid, rank] of ranks) {
|
|
145
|
+
if (!allowed.has(rid)) continue;
|
|
146
|
+
const hit = byId.get(rid);
|
|
147
|
+
if (hit) hit.fts = maxRank ? rank / maxRank : 1;
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// malformed query — keyword layer skipped, vector results still returned
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function searchNotes(db, projectId, query, limit = 8) {
|
|
155
|
+
return rankNotes(db, projectId, await embedQuery(query), limit);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function rankNotes(db, projectId, qvec, limit = 8) {
|
|
159
|
+
const rows = db
|
|
160
|
+
.prepare(
|
|
161
|
+
`SELECT e.owner_id, e.vec,
|
|
162
|
+
s.content AS scontent, s.heading_path, s.updated_at AS sut,
|
|
163
|
+
d.rel_path, d.title AS dtitle
|
|
164
|
+
FROM embeddings e
|
|
165
|
+
JOIN sections s ON s.id = e.owner_id
|
|
166
|
+
JOIN docs d ON d.id = s.doc_id
|
|
167
|
+
WHERE e.owner_type = 'section' AND d.project_id = $pid`,
|
|
168
|
+
)
|
|
169
|
+
.all({ pid: projectId });
|
|
170
|
+
|
|
171
|
+
const byId = new Map();
|
|
172
|
+
for (const row of rows) {
|
|
173
|
+
const vec = toFloat32(row.vec);
|
|
174
|
+
if (vec.length !== qvec.length) continue;
|
|
175
|
+
byId.set(row.owner_id, {
|
|
176
|
+
kind: "note_section",
|
|
177
|
+
id: row.owner_id,
|
|
178
|
+
text: row.scontent?.slice(0, 300),
|
|
179
|
+
target: `${row.rel_path}::${row.heading_path}`,
|
|
180
|
+
doc_title: row.dtitle,
|
|
181
|
+
score: cosine(qvec, vec),
|
|
182
|
+
updated_at: row.sut,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return rank(byId, limit);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function rank(byId, limit) {
|
|
190
|
+
const scored = [...byId.values()].map((h) => ({
|
|
191
|
+
...h,
|
|
192
|
+
score: h.score * 0.75 + (h.fts ?? 0) * 0.25,
|
|
193
|
+
}));
|
|
194
|
+
scored.sort((a, b) => b.score - a.score);
|
|
195
|
+
return scored.slice(0, limit);
|
|
196
|
+
}
|