@lotargo/memory_plugin 1.5.3 → 1.6.1
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/CHANGELOG.md +138 -0
- package/README.md +406 -352
- package/mcp-server/admin/auth.js +13 -4
- package/mcp-server/admin/snapshot.js +24 -7
- package/mcp-server/boot.js +43 -0
- package/mcp-server/cli/direct_commands.js +334 -313
- package/mcp-server/cli/handlers/engine_actions.js +41 -0
- package/mcp-server/cli/handlers/storage_actions.js +58 -0
- package/mcp-server/cli/secret_input.js +44 -0
- package/mcp-server/cli/ui.js +564 -565
- package/mcp-server/cli.js +356 -324
- package/mcp-server/cli_boot.js +37 -0
- package/mcp-server/config/auth_store.js +74 -16
- package/mcp-server/config/config_manager.js +4 -0
- package/mcp-server/db/database.js +33 -14
- package/mcp-server/db/sync_queue.js +9 -19
- package/mcp-server/index.js +112 -42
- package/mcp-server/ingest/normalizer.js +116 -29
- package/mcp-server/ingest/pipeline.js +94 -6
- package/mcp-server/logger.js +49 -0
- package/mcp-server/memory.js +6 -9
- package/mcp-server/ml/gpu_monitor.js +169 -166
- package/mcp-server/ml/model_manager.js +17 -4
- package/mcp-server/preinstall.js +23 -2
- package/mcp-server/retrieval/retriever.js +35 -15
- package/mcp-server/security/path_guard.js +67 -0
- package/mcp-server/setup.js +10 -2
- package/mcp-server/storage/blob_store.js +15 -2
- package/mcp-server/tools/core/memory_core.js +393 -0
- package/mcp-server/tools/helpers.js +59 -39
- package/mcp-server/tools/memory_tools.js +123 -516
- package/mcp-server/tools/rag_tools.js +49 -1
- package/opencode-plugin/index.js +94 -397
- package/package.json +13 -4
- package/skills/using-memory/SKILL.md +7 -2
package/mcp-server/preinstall.js
CHANGED
|
@@ -5,6 +5,27 @@ if (process.env.CI || process.env.CONTINUOUS_INTEGRATION || process.env.DEBIAN_F
|
|
|
5
5
|
process.exit(0);
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
// ── Node version warning ────────────────────────────────────────────────────
|
|
9
|
+
// engines.node >= 22.5.0 is set in package.json but npm only warns by default.
|
|
10
|
+
// Print a loud, actionable message so the user notices before the server crashes.
|
|
11
|
+
{
|
|
12
|
+
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
13
|
+
if (major < 22 || (major === 22 && minor < 5)) {
|
|
14
|
+
console.error(
|
|
15
|
+
`\n` +
|
|
16
|
+
` ⚠️ @lotargo/memory_plugin requires Node.js >= 22.5.0\n` +
|
|
17
|
+
` Detected: Node.js ${process.versions.node}\n` +
|
|
18
|
+
`\n` +
|
|
19
|
+
` The built-in node:sqlite module used by this plugin was\n` +
|
|
20
|
+
` introduced in Node.js 22.5.0. The server WILL NOT START\n` +
|
|
21
|
+
` on your current version.\n` +
|
|
22
|
+
`\n` +
|
|
23
|
+
` Please upgrade: nvm install 22 (or: brew install node@22)\n`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
8
29
|
// Only run graceful process termination during explicit global npm updates
|
|
9
30
|
if (process.env.npm_config_global === "true" || process.env.MEMORY_PREINSTALL_FORCE === "true") {
|
|
10
31
|
try {
|
|
@@ -13,7 +34,7 @@ if (process.env.npm_config_global === "true" || process.env.MEMORY_PREINSTALL_FO
|
|
|
13
34
|
|
|
14
35
|
if (process.platform === "win32") {
|
|
15
36
|
try {
|
|
16
|
-
const psCmd = `Get-CimInstance Win32_Process | Where-Object { ($_.CommandLine -like '*mcp-server/index.js*' -or $_.CommandLine -like '*mcp-server\\\\index.js*') -and $_.CommandLine -notlike '*install*' -and $_.ProcessId -ne ${currentPid} -and $_.ProcessId -ne ${ppid} } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`;
|
|
37
|
+
const psCmd = `Get-CimInstance Win32_Process | Where-Object { ($_.CommandLine -like '*mcp-server/boot.js*' -or $_.CommandLine -like '*mcp-server\\\\boot.js*' -or $_.CommandLine -like '*mcp-server/index.js*' -or $_.CommandLine -like '*mcp-server\\\\index.js*') -and $_.CommandLine -notlike '*install*' -and $_.ProcessId -ne ${currentPid} -and $_.ProcessId -ne ${ppid} } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`;
|
|
17
38
|
execSync(`powershell -NoProfile -NonInteractive -Command "${psCmd}"`, { stdio: "ignore" });
|
|
18
39
|
} catch {}
|
|
19
40
|
} else {
|
|
@@ -30,7 +51,7 @@ if (process.env.npm_config_global === "true" || process.env.MEMORY_PREINSTALL_FO
|
|
|
30
51
|
|
|
31
52
|
if (!pid || pid === currentPid || pid === ppid || parentPid === currentPid) continue;
|
|
32
53
|
|
|
33
|
-
const isServer = cmd.includes("mcp-server/
|
|
54
|
+
const isServer = cmd.includes("mcp-server/boot.js") || cmd.includes("mcp-server/index.js");
|
|
34
55
|
const isInstaller = /npm|npx|yarn|pnpm|preinstall|install/i.test(cmd);
|
|
35
56
|
|
|
36
57
|
if (isServer && !isInstaller) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { getDatabase } from "../db/database.js";
|
|
2
2
|
import { embedText, cosineSimilarity, rerankHits } from "../ml/model_manager.js";
|
|
3
|
-
import { getRelatedSymbols } from "../graph/graph_extractor.js";
|
|
4
3
|
import { getConfig } from "../config/config_manager.js";
|
|
5
4
|
|
|
6
5
|
export function sanitizeFtsQuery(query) {
|
|
@@ -37,6 +36,20 @@ export async function bm25Search(db, query, limit = 30) {
|
|
|
37
36
|
}
|
|
38
37
|
}
|
|
39
38
|
|
|
39
|
+
// Normalize whatever the active driver hands back for a BLOB column into a
|
|
40
|
+
// Uint8Array view. node:sqlite -> Uint8Array, Turso/libsql -> base64 string or
|
|
41
|
+
// a serialized {type:"Buffer",data:[...]} object, better-sqlite3 -> Buffer.
|
|
42
|
+
export function toVectorBytes(value) {
|
|
43
|
+
if (value === null || value === undefined) return null;
|
|
44
|
+
if (typeof value === "string") return new Uint8Array(Buffer.from(value, "base64"));
|
|
45
|
+
if (value instanceof Uint8Array) return value; // covers Buffer too
|
|
46
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
47
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
48
|
+
if (value.type === "Buffer" && Array.isArray(value.data)) return new Uint8Array(value.data);
|
|
49
|
+
if (Array.isArray(value)) return new Uint8Array(value);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
export async function vectorSearch(db, queryVector, limit = 30, minSim = 0.25) {
|
|
41
54
|
if (!queryVector || queryVector.length === 0) return [];
|
|
42
55
|
|
|
@@ -45,23 +58,30 @@ export async function vectorSearch(db, queryVector, limit = 30, minSim = 0.25) {
|
|
|
45
58
|
const tempView = new Uint8Array(tempBuf);
|
|
46
59
|
const tempVec = new Float32Array(tempBuf);
|
|
47
60
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
const scanLimit = Number(getConfig().vectorScanLimit) || 0;
|
|
62
|
+
const scanSql = scanLimit > 0
|
|
63
|
+
? `
|
|
64
|
+
SELECT m.id, m.section_id, m.doc_id, m.content, m.vector, s.breadcrumbs
|
|
65
|
+
FROM micro_chunks m
|
|
66
|
+
JOIN sections s ON m.section_id = s.id
|
|
67
|
+
LIMIT ?;
|
|
68
|
+
`
|
|
69
|
+
: `
|
|
70
|
+
SELECT m.id, m.section_id, m.doc_id, m.content, m.vector, s.breadcrumbs
|
|
71
|
+
FROM micro_chunks m
|
|
72
|
+
JOIN sections s ON m.section_id = s.id;
|
|
73
|
+
`;
|
|
53
74
|
|
|
75
|
+
const stmt = db.prepare(scanSql);
|
|
76
|
+
const rows = scanLimit > 0 ? await stmt.all(scanLimit) : await stmt.all();
|
|
54
77
|
const scored = [];
|
|
55
|
-
const rows = await stmt.all();
|
|
56
78
|
for (const r of rows) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
vecSub = Buffer.from(vecSub);
|
|
64
|
-
}
|
|
79
|
+
// node:sqlite returns BLOBs as plain Uint8Array (NOT Buffer), the Turso
|
|
80
|
+
// client may return base64 strings or {type:"Buffer",data:[...]}.
|
|
81
|
+
// A Buffer.isBuffer() gate here silently dropped every local row and made
|
|
82
|
+
// vector search return zero hits.
|
|
83
|
+
const vecSub = toVectorBytes(r.vector);
|
|
84
|
+
if (!vecSub || vecSub.byteLength !== vectorDim * 4) continue;
|
|
65
85
|
tempView.set(vecSub.subarray(0, vectorDim * 4));
|
|
66
86
|
|
|
67
87
|
const sim = cosineSimilarity(queryVector, tempVec);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { realpathSync, existsSync } from "node:fs";
|
|
2
|
+
import { resolve, dirname, sep } from "node:path";
|
|
3
|
+
import { getConfig } from "../config/config_manager.js";
|
|
4
|
+
import { MEMORY_DIR } from "../memory.js";
|
|
5
|
+
|
|
6
|
+
// Resolve a path to its real location, following symlinks/junctions.
|
|
7
|
+
// Falls back to the nearest existing ancestor when the target does not exist yet
|
|
8
|
+
// (needed for export targets that are about to be created).
|
|
9
|
+
export function realResolve(pathStr) {
|
|
10
|
+
const abs = resolve(String(pathStr || "").trim());
|
|
11
|
+
try {
|
|
12
|
+
return realpathSync(abs);
|
|
13
|
+
} catch {
|
|
14
|
+
let parent = dirname(abs);
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
while (parent && !seen.has(parent)) {
|
|
17
|
+
seen.add(parent);
|
|
18
|
+
if (existsSync(parent)) {
|
|
19
|
+
try {
|
|
20
|
+
return resolve(realpathSync(parent), abs.slice(parent.length + 1));
|
|
21
|
+
} catch {
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const next = dirname(parent);
|
|
26
|
+
if (next === parent) break;
|
|
27
|
+
parent = next;
|
|
28
|
+
}
|
|
29
|
+
return abs;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isWithin(resolvedPath, dir) {
|
|
34
|
+
const root = realResolve(dir);
|
|
35
|
+
return resolvedPath === root || resolvedPath.startsWith(root + sep);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Roots the ingest pipeline is allowed to read from.
|
|
39
|
+
// Defaults to the current working directory (the project the agent is working in)
|
|
40
|
+
// plus the plugin's own data directory. Extendable via config.ingestAllowedPaths.
|
|
41
|
+
export function getIngestAllowedRoots() {
|
|
42
|
+
const config = getConfig();
|
|
43
|
+
const roots = [process.cwd(), MEMORY_DIR];
|
|
44
|
+
if (Array.isArray(config.ingestAllowedPaths)) {
|
|
45
|
+
for (const p of config.ingestAllowedPaths) {
|
|
46
|
+
if (typeof p === "string" && p.trim()) roots.push(p.trim());
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return [...new Set(roots.map((r) => realResolve(r)))];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Guard against arbitrary file reads (~/.ssh/id_rsa, .env, /etc/passwd) reaching
|
|
53
|
+
// the RAG store — and, in hybrid-sync mode, the cloud.
|
|
54
|
+
export function assertIngestPathAllowed(pathStr) {
|
|
55
|
+
const resolved = realResolve(pathStr);
|
|
56
|
+
const config = getConfig();
|
|
57
|
+
if (config.ingestAllowAnyPath === true) return resolved;
|
|
58
|
+
|
|
59
|
+
const roots = getIngestAllowedRoots();
|
|
60
|
+
if (roots.some((root) => isWithin(resolved, root))) return resolved;
|
|
61
|
+
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Ingestion blocked: '${pathStr}' is outside the allowed directories ` +
|
|
64
|
+
`(${roots.join(", ")}). Add the directory to config.ingestAllowedPaths ` +
|
|
65
|
+
`or set config.ingestAllowAnyPath = true to override.`
|
|
66
|
+
);
|
|
67
|
+
}
|
package/mcp-server/setup.js
CHANGED
|
@@ -28,10 +28,18 @@ export async function runSetup() {
|
|
|
28
28
|
let configuredCount = 0;
|
|
29
29
|
|
|
30
30
|
// 0. Headless cloud authentication (Google Jules / CI / VPS)
|
|
31
|
-
if (apiKeyArg) {
|
|
31
|
+
if (apiKeyArg || process.env.TURSO_API_TOKEN) {
|
|
32
32
|
try {
|
|
33
|
+
const { resolveSecret } = await import("./cli/secret_input.js");
|
|
34
|
+
const apiKey = await resolveSecret({
|
|
35
|
+
argvValue: apiKeyArg,
|
|
36
|
+
envKeys: ["TURSO_API_TOKEN"],
|
|
37
|
+
promptLabel: "Turso API token",
|
|
38
|
+
interactive: false,
|
|
39
|
+
});
|
|
40
|
+
if (!apiKey) throw new Error("Missing API token. Set TURSO_API_TOKEN or pass --api-key <TOKEN>.");
|
|
33
41
|
const { loginWithApiToken } = await import("./admin/auth.js");
|
|
34
|
-
const secrets = await loginWithApiToken({ token:
|
|
42
|
+
const secrets = await loginWithApiToken({ token: apiKey });
|
|
35
43
|
if (modeArg && VALID_MODES.includes(modeArg)) {
|
|
36
44
|
const { updateConfig } = await import("./config/config_manager.js");
|
|
37
45
|
updateConfig({ mode: modeArg });
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { gzipSync, gunzipSync } from "node:zlib";
|
|
3
|
-
import { readFile, writeFile, mkdir, unlink
|
|
3
|
+
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { BLOBS_DIR } from "../db/database.js";
|
|
7
7
|
|
|
8
|
+
// Hard cap on gunzip output to prevent zip-bomb style memory exhaustion.
|
|
9
|
+
export const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export function safeGunzip(compressed, maxBytes = MAX_UNPACKED_BYTES) {
|
|
12
|
+
const decompressed = gunzipSync(compressed, { maxOutputLength: maxBytes });
|
|
13
|
+
if (decompressed.length > maxBytes) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`Decompressed payload of ${decompressed.length} bytes exceeds the ${maxBytes} byte limit.`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return decompressed;
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
export function hashContent(data) {
|
|
9
22
|
const hash = createHash("sha256");
|
|
10
23
|
hash.update(data);
|
|
@@ -48,7 +61,7 @@ export async function readBlob(hash, baseDir = BLOBS_DIR) {
|
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
const compressed = await readFile(blobPath);
|
|
51
|
-
const decompressed =
|
|
64
|
+
const decompressed = safeGunzip(compressed);
|
|
52
65
|
return decompressed.toString("utf-8");
|
|
53
66
|
}
|
|
54
67
|
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
readMemory,
|
|
5
|
+
readMemoryRaw,
|
|
6
|
+
writeMemory,
|
|
7
|
+
today,
|
|
8
|
+
MEMORY_DIR,
|
|
9
|
+
GLOBAL_KEY,
|
|
10
|
+
scopeKey,
|
|
11
|
+
projectKey,
|
|
12
|
+
projectName,
|
|
13
|
+
canonicalPath,
|
|
14
|
+
listProjectStores,
|
|
15
|
+
storeFilePath,
|
|
16
|
+
} from "../../memory.js";
|
|
17
|
+
import {
|
|
18
|
+
parseFactEntry,
|
|
19
|
+
factText,
|
|
20
|
+
factMeta,
|
|
21
|
+
withMeta,
|
|
22
|
+
nextFactId,
|
|
23
|
+
isKeepFact,
|
|
24
|
+
isExpiredLine,
|
|
25
|
+
isSuperseded,
|
|
26
|
+
formatFactEntry,
|
|
27
|
+
matchesQuery,
|
|
28
|
+
matchesTags,
|
|
29
|
+
inDateRange,
|
|
30
|
+
factTitle,
|
|
31
|
+
factBody,
|
|
32
|
+
autoGenerateTitle,
|
|
33
|
+
} from "../../fact_format.js";
|
|
34
|
+
import { requireProjectKey, resolveFactIndex } from "../helpers.js";
|
|
35
|
+
|
|
36
|
+
// Single implementation of the Notebook tools, shared by the MCP server
|
|
37
|
+
// (mcp-server/tools/memory_tools.js) and the OpenCode plugin
|
|
38
|
+
// (opencode-plugin/index.js). Both used to carry their own copy, so bug fixes
|
|
39
|
+
// in one never reached the other. Every function returns a plain string; the
|
|
40
|
+
// callers wrap it in whatever envelope their host expects.
|
|
41
|
+
//
|
|
42
|
+
// `ctx` carries the host's notion of the current location:
|
|
43
|
+
// { worktree, directory } — the MCP server passes nothing and falls back to cwd.
|
|
44
|
+
|
|
45
|
+
const TITLE_PATTERN = /^\*\*([^*]+)\*\*\s*(?:—|--|-|:)?\s*(.*)$/;
|
|
46
|
+
|
|
47
|
+
function splitTitle(rawText, explicitTitle) {
|
|
48
|
+
let finalTitle = explicitTitle ? explicitTitle.trim() : null;
|
|
49
|
+
let finalFact = String(rawText || "").trim();
|
|
50
|
+
const match = TITLE_PATTERN.exec(finalFact);
|
|
51
|
+
if (match) {
|
|
52
|
+
if (!finalTitle) finalTitle = match[1].trim();
|
|
53
|
+
finalFact = match[2].trim();
|
|
54
|
+
}
|
|
55
|
+
return { finalTitle, finalFact };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function resolveScopeKey(scope, ctx = {}) {
|
|
59
|
+
return await scopeKey(scope || "project", ctx.worktree ?? null, ctx.directory ?? null);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function rememberFact(
|
|
63
|
+
{ fact, title, scope, docId, startLine, endLine, relationType, ttl, keep, tags, supersedes },
|
|
64
|
+
ctx = {}
|
|
65
|
+
) {
|
|
66
|
+
const key = requireProjectKey(await resolveScopeKey(scope, ctx));
|
|
67
|
+
const entries = await readMemory(key);
|
|
68
|
+
|
|
69
|
+
let { finalTitle, finalFact } = splitTitle(fact, title);
|
|
70
|
+
if (!finalTitle) finalTitle = autoGenerateTitle(finalFact);
|
|
71
|
+
|
|
72
|
+
const text = `**${finalTitle}** — ${finalFact}`;
|
|
73
|
+
const factBodyNormalized = finalFact.toLowerCase();
|
|
74
|
+
const duplicate = entries.some((e) => factBody(e).toLowerCase().trim() === factBodyNormalized);
|
|
75
|
+
|
|
76
|
+
let supersededInfo = "";
|
|
77
|
+
if (!duplicate) {
|
|
78
|
+
const [date, time] = today().split(" ");
|
|
79
|
+
const meta = { ttl, tags };
|
|
80
|
+
if (keep) meta.keep = "1";
|
|
81
|
+
if (supersedes) {
|
|
82
|
+
const targetIdx = resolveFactIndex(entries, supersedes);
|
|
83
|
+
if (targetIdx !== -1) {
|
|
84
|
+
const newId = nextFactId(entries);
|
|
85
|
+
const targetId = factMeta(entries[targetIdx]).id || nextFactId(entries);
|
|
86
|
+
entries[targetIdx] = withMeta(entries[targetIdx], { id: targetId, supersededBy: newId });
|
|
87
|
+
meta.id = newId;
|
|
88
|
+
meta.supersedes = targetId;
|
|
89
|
+
supersededInfo = ` [superseded: "${factText(entries[targetIdx]).slice(0, 60)}"]`;
|
|
90
|
+
} else {
|
|
91
|
+
supersededInfo = " (note: supersedes target not found)";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!meta.id) meta.id = nextFactId(entries);
|
|
95
|
+
entries.push(formatFactEntry({ date, time, text, meta }));
|
|
96
|
+
await writeMemory(key, entries);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let linkInfo = "";
|
|
100
|
+
if (docId) {
|
|
101
|
+
try {
|
|
102
|
+
const { linkFactToDocument } = await import("../../graph/knowledge_linker.js");
|
|
103
|
+
const linkRes = await linkFactToDocument({
|
|
104
|
+
factKey: key,
|
|
105
|
+
factText: finalFact,
|
|
106
|
+
docId,
|
|
107
|
+
startLine,
|
|
108
|
+
endLine,
|
|
109
|
+
relationType: relationType || "LINKS_TO",
|
|
110
|
+
});
|
|
111
|
+
const linesStr = startLine ? `:L${startLine}${endLine ? `-${endLine}` : ""}` : "";
|
|
112
|
+
linkInfo = ` [Linked to Doc: "${linkRes.docTitle}"${linesStr}]`;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
linkInfo = ` (Note: Fact saved, but document link failed: ${err.message})`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return `Memory updated${supersededInfo}${linkInfo}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function resolveTargetKey(projectPath) {
|
|
122
|
+
if (!projectPath) return null;
|
|
123
|
+
try {
|
|
124
|
+
const { resolveProjectIdentity } = await import("../../identity.js");
|
|
125
|
+
const identity = await resolveProjectIdentity(projectPath);
|
|
126
|
+
if (identity) return identity.key;
|
|
127
|
+
} catch {}
|
|
128
|
+
return canonicalPath(projectPath);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function recallFacts(
|
|
132
|
+
{ scope, project, query, tags, since, until, mode, offset, limit },
|
|
133
|
+
ctx = {}
|
|
134
|
+
) {
|
|
135
|
+
const results = [];
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
const targetMode = mode || "full";
|
|
138
|
+
const targetOffset = offset !== undefined && offset !== null ? offset : 0;
|
|
139
|
+
|
|
140
|
+
if (scope === "list_projects") {
|
|
141
|
+
const stores = await listProjectStores();
|
|
142
|
+
if (!stores.length) return "No project memory stores found.";
|
|
143
|
+
const lines = stores.map(
|
|
144
|
+
(s, i) =>
|
|
145
|
+
`${i + 1}. ${s.basename} — ${s.count} fact(s) [${s.file}]${
|
|
146
|
+
s.path ? ` (bound to ${s.path})` : " (unbound legacy store)"
|
|
147
|
+
}`
|
|
148
|
+
);
|
|
149
|
+
return `Project Memory Stores:\n${lines.join(
|
|
150
|
+
"\n"
|
|
151
|
+
)}\n\nUse recall(scope: "project", project: "<path>") to read a specific store.\n\nMemory dir: ${MEMORY_DIR}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let getLinksForFact = null;
|
|
155
|
+
try {
|
|
156
|
+
({ getLinksForFact } = await import("../../graph/knowledge_linker.js"));
|
|
157
|
+
} catch {}
|
|
158
|
+
|
|
159
|
+
const target =
|
|
160
|
+
(await resolveTargetKey(project)) ?? (await projectKey(ctx.worktree ?? null, ctx.directory ?? null));
|
|
161
|
+
const label = project ? target : await projectName(ctx.worktree ?? null, ctx.directory ?? null);
|
|
162
|
+
|
|
163
|
+
const formatFactWithLinks = async (factLine, index, key) => {
|
|
164
|
+
const p = parseFactEntry(factLine);
|
|
165
|
+
if (!p) return factLine;
|
|
166
|
+
|
|
167
|
+
const meta = p.meta;
|
|
168
|
+
const badges = [];
|
|
169
|
+
if (isExpiredLine(factLine, now)) badges.push("EXPIRED");
|
|
170
|
+
if (isKeepFact(factLine)) badges.push("KEEP");
|
|
171
|
+
if (isSuperseded(factLine)) badges.push("SUPERSEDED");
|
|
172
|
+
if (meta.inject === "1") badges.push("INJECT");
|
|
173
|
+
if (meta.id) badges.push(`id:${meta.id}`);
|
|
174
|
+
if (meta.tags) badges.push(`tags:${meta.tags}`);
|
|
175
|
+
badges.push(`${p.date} ${p.time}`);
|
|
176
|
+
const badgesStr = badges.length ? ` [${badges.join("] [")}]` : "";
|
|
177
|
+
|
|
178
|
+
let lineText =
|
|
179
|
+
targetMode === "headers" ? `**${factTitle(factLine)}**${badgesStr}` : `${p.text}${badgesStr}`;
|
|
180
|
+
|
|
181
|
+
if (getLinksForFact) {
|
|
182
|
+
try {
|
|
183
|
+
const links = await getLinksForFact(key, p.text);
|
|
184
|
+
if (links && links.length > 0) {
|
|
185
|
+
const docStr = links
|
|
186
|
+
.map((l) => {
|
|
187
|
+
const range = l.start_line ? `:L${l.start_line}${l.end_line ? `-${l.end_line}` : ""}` : "";
|
|
188
|
+
return `${l.doc_title || l.doc_path}${range}`;
|
|
189
|
+
})
|
|
190
|
+
.join(", ");
|
|
191
|
+
lineText += ` 🔗 [Linked Docs: ${docStr}]`;
|
|
192
|
+
}
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
return `${index}. ${lineText}`;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const collect = async (entries, key) => {
|
|
199
|
+
const matched = entries.filter(
|
|
200
|
+
(e) => matchesQuery(e, query) && matchesTags(e, tags) && inDateRange(e, since, until)
|
|
201
|
+
);
|
|
202
|
+
if (!matched.length) return;
|
|
203
|
+
if (results.length) results.push("");
|
|
204
|
+
results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
|
|
205
|
+
|
|
206
|
+
const hasLimit = limit !== undefined && limit !== null;
|
|
207
|
+
const targetLimit = hasLimit ? limit : matched.length;
|
|
208
|
+
const paginated = matched.slice(targetOffset, targetOffset + targetLimit);
|
|
209
|
+
for (let i = 0; i < paginated.length; i++) {
|
|
210
|
+
results.push(await formatFactWithLinks(paginated[i], targetOffset + i + 1, key));
|
|
211
|
+
}
|
|
212
|
+
if (hasLimit && matched.length > targetLimit) {
|
|
213
|
+
results.push(
|
|
214
|
+
`Showing entries ${targetOffset + 1}-${Math.min(targetOffset + targetLimit, matched.length)} of ${matched.length}`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
results.push(`Store file: ${storeFilePath(key)}`);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (scope !== "project") await collect(await readMemory(GLOBAL_KEY), GLOBAL_KEY);
|
|
221
|
+
if (scope !== "global") await collect(await readMemory(target), target);
|
|
222
|
+
|
|
223
|
+
const filtered = Boolean(query || tags || since || until);
|
|
224
|
+
if (!results.length) return filtered ? "No facts match the search." : "Memory is empty.";
|
|
225
|
+
return `${results.join("\n")}\n\nMemory dir: ${MEMORY_DIR}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function getFactById({ id, scope }, ctx = {}) {
|
|
229
|
+
const targetId = String(id || "").trim();
|
|
230
|
+
if (!targetId) throw new Error("ID parameter is required.");
|
|
231
|
+
|
|
232
|
+
const results = [];
|
|
233
|
+
const check = async (key) => {
|
|
234
|
+
const entries = await readMemory(key);
|
|
235
|
+
const match = entries.find((e) => factMeta(e).id === targetId);
|
|
236
|
+
if (match) {
|
|
237
|
+
results.push({ key, title: factTitle(match), body: factBody(match), meta: factMeta(match), line: match });
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
if (scope !== "project") await check(GLOBAL_KEY);
|
|
242
|
+
if (scope !== "global") await check(await projectKey(ctx.worktree ?? null, ctx.directory ?? null));
|
|
243
|
+
|
|
244
|
+
if (!results.length) return `Fact with ID "${targetId}" not found.`;
|
|
245
|
+
|
|
246
|
+
return results
|
|
247
|
+
.map((r) => {
|
|
248
|
+
const metaStr = Object.entries(r.meta)
|
|
249
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
250
|
+
.join(", ");
|
|
251
|
+
return `[Store: ${r.key === GLOBAL_KEY ? "Global" : "Project"}]\nTitle: ${r.title}\nBody: ${r.body}\nMetadata: ${
|
|
252
|
+
metaStr ? `<!-- ${metaStr} -->` : "none"
|
|
253
|
+
}`;
|
|
254
|
+
})
|
|
255
|
+
.join("\n\n");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function forgetFacts({ query, scope, force }, ctx = {}) {
|
|
259
|
+
const key = requireProjectKey(await resolveScopeKey(scope, ctx));
|
|
260
|
+
const entries = await readMemory(key);
|
|
261
|
+
|
|
262
|
+
const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
|
|
263
|
+
let indices = [];
|
|
264
|
+
if (rangeMatch) {
|
|
265
|
+
const from = parseInt(rangeMatch[1], 10);
|
|
266
|
+
const to = parseInt(rangeMatch[2], 10);
|
|
267
|
+
if (from > 0 && to >= from && to <= entries.length) {
|
|
268
|
+
for (let i = from - 1; i < to; i++) indices.push(i);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (!indices.length && /^\s*\d+\s*$/.test(String(query))) {
|
|
272
|
+
const num = parseInt(query, 10);
|
|
273
|
+
if (num > 0 && num <= entries.length) indices.push(num - 1);
|
|
274
|
+
}
|
|
275
|
+
if (!indices.length) {
|
|
276
|
+
const q = String(query).toLowerCase();
|
|
277
|
+
indices = entries.reduce((acc, e, i) => (e.toLowerCase().includes(q) ? acc.concat(i) : acc), []);
|
|
278
|
+
}
|
|
279
|
+
if (!indices.length) return "Not found.";
|
|
280
|
+
|
|
281
|
+
const removable = indices.filter((i) => force || !isKeepFact(entries[i]));
|
|
282
|
+
const protectedCount = indices.length - removable.length;
|
|
283
|
+
if (removable.length) {
|
|
284
|
+
for (const i of removable.sort((a, b) => b - a)) entries.splice(i, 1);
|
|
285
|
+
await writeMemory(key, entries);
|
|
286
|
+
}
|
|
287
|
+
let text = removable.length ? "Memory updated" : "Nothing removed.";
|
|
288
|
+
if (protectedCount) text += ` (${protectedCount} protected fact(s) skipped; use force=true to override)`;
|
|
289
|
+
return text;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function updateFactText({ id, newText, title, scope }, ctx = {}) {
|
|
293
|
+
const key = requireProjectKey(await resolveScopeKey(scope, ctx));
|
|
294
|
+
const entries = await readMemory(key);
|
|
295
|
+
const idx = resolveFactIndex(entries, id);
|
|
296
|
+
if (idx === -1) throw new Error(`Fact not found: ${id}`);
|
|
297
|
+
|
|
298
|
+
const p = parseFactEntry(entries[idx]);
|
|
299
|
+
const oldText = p ? p.text : entries[idx];
|
|
300
|
+
const oldBody = factBody(entries[idx]) || oldText;
|
|
301
|
+
|
|
302
|
+
let { finalTitle, finalFact } = splitTitle(newText, title);
|
|
303
|
+
if (!finalTitle) finalTitle = factTitle(entries[idx]) || autoGenerateTitle(finalFact);
|
|
304
|
+
|
|
305
|
+
entries[idx] = formatFactEntry({
|
|
306
|
+
date: p.date,
|
|
307
|
+
time: p.time,
|
|
308
|
+
text: `**${finalTitle}** — ${finalFact}`,
|
|
309
|
+
meta: p.meta,
|
|
310
|
+
});
|
|
311
|
+
await writeMemory(key, entries);
|
|
312
|
+
|
|
313
|
+
let linksUpdated = 0;
|
|
314
|
+
try {
|
|
315
|
+
const { getDatabase } = await import("../../db/database.js");
|
|
316
|
+
const db = await getDatabase();
|
|
317
|
+
const res = await db
|
|
318
|
+
.prepare("UPDATE knowledge_links SET fact_text = ? WHERE fact_key = ? AND fact_text = ?")
|
|
319
|
+
.run(finalFact, key, oldBody);
|
|
320
|
+
linksUpdated = res.changes;
|
|
321
|
+
} catch {}
|
|
322
|
+
|
|
323
|
+
return `Fact updated${linksUpdated ? `, ${linksUpdated} doc link(s) updated` : ""}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function memoryInfo(_args = {}, ctx = {}) {
|
|
327
|
+
const dbPath = join(MEMORY_DIR, "storage", "memory.sqlite");
|
|
328
|
+
const activeKey = await projectKey(ctx.worktree ?? null, ctx.directory ?? null);
|
|
329
|
+
const globalFile = storeFilePath(GLOBAL_KEY);
|
|
330
|
+
const projectFile = storeFilePath(activeKey);
|
|
331
|
+
|
|
332
|
+
let version = "unknown";
|
|
333
|
+
try {
|
|
334
|
+
version = JSON.parse(await readFile(new URL("../../../package.json", import.meta.url), "utf-8")).version;
|
|
335
|
+
} catch {}
|
|
336
|
+
|
|
337
|
+
const rag = {};
|
|
338
|
+
try {
|
|
339
|
+
const { getDatabase } = await import("../../db/database.js");
|
|
340
|
+
const db = await getDatabase();
|
|
341
|
+
const count = async (table) => {
|
|
342
|
+
const row = await db.prepare(`SELECT COUNT(*) AS c FROM ${table}`).get();
|
|
343
|
+
return row ? row.c : 0;
|
|
344
|
+
};
|
|
345
|
+
rag.documents = await count("documents");
|
|
346
|
+
rag.sections = await count("sections");
|
|
347
|
+
rag.chunks = await count("micro_chunks");
|
|
348
|
+
rag.edges = await count("graph_edges");
|
|
349
|
+
rag.links = await count("knowledge_links");
|
|
350
|
+
} catch (e) {
|
|
351
|
+
rag.error = e.message;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const stores = await listProjectStores();
|
|
355
|
+
|
|
356
|
+
const identityLines = [];
|
|
357
|
+
try {
|
|
358
|
+
const { getDatabase } = await import("../../db/database.js");
|
|
359
|
+
const { resolveProjectIdentity, listIdentities } = await import("../../identity.js");
|
|
360
|
+
const db = await getDatabase();
|
|
361
|
+
const identity = await resolveProjectIdentity(ctx.worktree || ctx.directory || process.cwd());
|
|
362
|
+
const all = await listIdentities(db);
|
|
363
|
+
identityLines.push(
|
|
364
|
+
`Identity: ${identity ? "git" : "no-git"}` +
|
|
365
|
+
(identity
|
|
366
|
+
? ` | key: ${identity.key} | name: ${identity.name}${
|
|
367
|
+
identity.primaryRemote ? ` | remote: ${identity.primaryRemote}` : ""
|
|
368
|
+
}`
|
|
369
|
+
: ""),
|
|
370
|
+
`Known identities: ${all.length}`
|
|
371
|
+
);
|
|
372
|
+
} catch (e) {
|
|
373
|
+
identityLines.push(`Identity: unavailable (${e.message})`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const lines = [
|
|
377
|
+
`Version: ${version}`,
|
|
378
|
+
`MEMORY_DIR: ${MEMORY_DIR}`,
|
|
379
|
+
`SQLite DB: ${dbPath}`,
|
|
380
|
+
`Global store: ${globalFile}`,
|
|
381
|
+
`Project store: ${projectFile}`,
|
|
382
|
+
`Project stores: ${stores.length}`,
|
|
383
|
+
`Facts (global): ${(await readMemoryRaw(GLOBAL_KEY)).length}`,
|
|
384
|
+
`Facts (project): ${(await readMemoryRaw(activeKey)).length}`,
|
|
385
|
+
...identityLines,
|
|
386
|
+
];
|
|
387
|
+
if (rag.error) lines.push(`RAG: unavailable (${rag.error})`);
|
|
388
|
+
else
|
|
389
|
+
lines.push(
|
|
390
|
+
`RAG: ${rag.documents} doc(s), ${rag.sections} section(s), ${rag.chunks} chunk(s), ${rag.edges} edge(s), ${rag.links} memory link(s)`
|
|
391
|
+
);
|
|
392
|
+
return lines.join("\n");
|
|
393
|
+
}
|