@lotargo/memory_plugin 1.2.902 → 1.3.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/mcp-server/cli.js +64 -13
- package/mcp-server/config/config_manager.js +3 -3
- package/mcp-server/fact_format.js +177 -0
- package/mcp-server/index.js +218 -41
- package/mcp-server/ingest/pipeline.js +8 -0
- package/mcp-server/memory.js +203 -188
- package/mcp-server/ml/model_manager.js +2 -2
- package/opencode-plugin/index.js +200 -47
- package/package.json +2 -1
- package/skills/using-memory/SKILL.md +71 -11
package/mcp-server/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { hybridQuery } from "./retrieval/retriever.js";
|
|
|
6
6
|
import { getDatabase } from "./db/database.js";
|
|
7
7
|
import { deleteDocument } from "./ingest/pipeline.js";
|
|
8
8
|
import { readMemoryRaw, readMemory, writeMemory, GLOBAL_KEY, projectName, projectKey, listProjectStores, migrateLegacyStore, memoryFileName } from "./memory.js";
|
|
9
|
+
import { parseFactEntry, factText, withMeta, displayFact, nextFactId, isKeepFact, isSuperseded, formatFactEntry, metaBadges } from "./fact_format.js";
|
|
9
10
|
import { getCorpusCacheSize, clearCorpusCache } from "./benchmarks/fetch_real_corpus.js";
|
|
10
11
|
import { SMOKE_DOC_IDS } from "./benchmarks/quality_evaluator.js";
|
|
11
12
|
import { getModelStorageInfo, deleteModelCache, listAllCachedModels } from "./ml/model_manager.js";
|
|
@@ -628,6 +629,16 @@ function waitForEnter() {
|
|
|
628
629
|
});
|
|
629
630
|
}
|
|
630
631
|
|
|
632
|
+
function promptText(question) {
|
|
633
|
+
return new Promise((resolve) => {
|
|
634
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
635
|
+
rl.question(`\n ${question}\n > `, (answer) => {
|
|
636
|
+
rl.close();
|
|
637
|
+
resolve(answer.trim());
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
631
642
|
export async function runCli() {
|
|
632
643
|
const cliArgs = process.argv.slice(2);
|
|
633
644
|
if (cliArgs.includes("--enable-prompt") || cliArgs.includes("enable-prompt")) {
|
|
@@ -1035,11 +1046,15 @@ export async function runCli() {
|
|
|
1035
1046
|
}
|
|
1036
1047
|
|
|
1037
1048
|
const file = memoryFileName(key);
|
|
1038
|
-
const factItems = factList.map((fact, idx) =>
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1049
|
+
const factItems = factList.map((fact, idx) => {
|
|
1050
|
+
const badges = metaBadges(fact);
|
|
1051
|
+
return {
|
|
1052
|
+
label: `${idx + 1}. ${factText(fact)}`,
|
|
1053
|
+
value: idx,
|
|
1054
|
+
badge: badges.length ? badges.join(" ") : undefined,
|
|
1055
|
+
info: `Select to manage this fact from ${file}`,
|
|
1056
|
+
};
|
|
1057
|
+
});
|
|
1043
1058
|
factItems.push({ label: "< Back", value: "back" });
|
|
1044
1059
|
|
|
1045
1060
|
const factRes = await selectSimpleMenu({
|
|
@@ -1053,22 +1068,58 @@ export async function runCli() {
|
|
|
1053
1068
|
}
|
|
1054
1069
|
|
|
1055
1070
|
const selectedIdx = factRes.value;
|
|
1056
|
-
const
|
|
1071
|
+
const selectedEntry = rawEntries[selectedIdx];
|
|
1072
|
+
const selDisplay = displayFact(selectedEntry);
|
|
1073
|
+
const selBadges = metaBadges(selectedEntry);
|
|
1074
|
+
|
|
1075
|
+
const actionItems = [
|
|
1076
|
+
{ label: "[UPDATE] Edit fact text", value: "update", info: "Rewrite the fact, keeping its date and metadata" },
|
|
1077
|
+
];
|
|
1078
|
+
if (isKeepFact(selectedEntry)) {
|
|
1079
|
+
actionItems.push({ label: "[UNPROTECT] Remove keep protection", value: "unprotect", info: "Allow forget to delete it without force" });
|
|
1080
|
+
} else {
|
|
1081
|
+
actionItems.push({ label: "[PROTECT] Mark as important (keep)", value: "protect", info: "forget will skip it unless force=true" });
|
|
1082
|
+
}
|
|
1083
|
+
actionItems.push({ label: "[DELETE] Delete this fact from store", value: "delete", info: "Remove fact permanently" });
|
|
1084
|
+
actionItems.push({ label: "< Cancel / Back", value: "cancel" });
|
|
1057
1085
|
|
|
1058
1086
|
const actionRes = await selectSimpleMenu({
|
|
1059
|
-
title:
|
|
1060
|
-
subtitle: `Fact: "${
|
|
1061
|
-
items:
|
|
1062
|
-
{ label: "[DELETE] Delete this fact from store", value: "delete", info: "Remove fact permanently" },
|
|
1063
|
-
{ label: "< Cancel / Back", value: "cancel" },
|
|
1064
|
-
],
|
|
1087
|
+
title: "FACT ACTION",
|
|
1088
|
+
subtitle: `Fact: "${selDisplay}"${selBadges.length ? " [" + selBadges.join("] [") + "]" : ""}`,
|
|
1089
|
+
items: actionItems,
|
|
1065
1090
|
});
|
|
1066
1091
|
|
|
1067
1092
|
if (actionRes.action === "back" || actionRes.value === "cancel") {
|
|
1068
1093
|
return;
|
|
1069
1094
|
}
|
|
1070
1095
|
|
|
1071
|
-
if (actionRes.action === "select" && actionRes.value === "
|
|
1096
|
+
if (actionRes.action === "select" && actionRes.value === "update") {
|
|
1097
|
+
const p = parseFactEntry(selectedEntry);
|
|
1098
|
+
const newText = await promptText(`New text for fact #${selectedIdx + 1}:`);
|
|
1099
|
+
if (!newText) continue;
|
|
1100
|
+
const newLine = formatFactEntry({ date: p.date, time: p.time, text: newText, meta: p.meta });
|
|
1101
|
+
const updated = [...rawEntries];
|
|
1102
|
+
updated[selectedIdx] = newLine;
|
|
1103
|
+
await writeMemory(key, updated);
|
|
1104
|
+
let links = 0;
|
|
1105
|
+
try {
|
|
1106
|
+
const db = getDatabase();
|
|
1107
|
+
links = db
|
|
1108
|
+
.prepare("UPDATE knowledge_links SET fact_text = ? WHERE fact_key = ? AND fact_text = ?")
|
|
1109
|
+
.run(newText, key, factText(selectedEntry)).changes;
|
|
1110
|
+
} catch (e) {}
|
|
1111
|
+
console.clear();
|
|
1112
|
+
console.log(`\n [OK] Fact updated successfully${links ? `, ${links} doc link(s) updated` : ""}.\n`);
|
|
1113
|
+
await waitForEnter();
|
|
1114
|
+
} else if (actionRes.action === "select" && (actionRes.value === "protect" || actionRes.value === "unprotect")) {
|
|
1115
|
+
const updated = [...rawEntries];
|
|
1116
|
+
updated[selectedIdx] =
|
|
1117
|
+
actionRes.value === "protect" ? withMeta(selectedEntry, { keep: "1" }) : withMeta(selectedEntry, { keep: null });
|
|
1118
|
+
await writeMemory(key, updated);
|
|
1119
|
+
console.clear();
|
|
1120
|
+
console.log(`\n [OK] Fact ${actionRes.value === "protect" ? "protected" : "unprotected"} successfully.\n`);
|
|
1121
|
+
await waitForEnter();
|
|
1122
|
+
} else if (actionRes.action === "select" && actionRes.value === "delete") {
|
|
1072
1123
|
const updated = [...rawEntries];
|
|
1073
1124
|
updated.splice(selectedIdx, 1);
|
|
1074
1125
|
await writeMemory(key, updated);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { MEMORY_DIR,
|
|
3
|
+
import { MEMORY_DIR, ensureDirSync } from "../memory.js";
|
|
4
4
|
|
|
5
5
|
const CONFIG_FILE = path.join(MEMORY_DIR, "config.json");
|
|
6
6
|
|
|
@@ -23,7 +23,7 @@ export function getConfig() {
|
|
|
23
23
|
return cachedConfig;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
ensureDirSync();
|
|
27
27
|
|
|
28
28
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
29
29
|
try {
|
|
@@ -42,7 +42,7 @@ export function getConfig() {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export function saveConfig(newConfig) {
|
|
45
|
-
|
|
45
|
+
ensureDirSync();
|
|
46
46
|
cachedConfig = Object.freeze({ ...DEFAULT_CONFIG, ...newConfig });
|
|
47
47
|
try {
|
|
48
48
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cachedConfig, null, 2), "utf-8");
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Shared fact-line format + metadata parsing for the Notebook Layer 1 store.
|
|
2
|
+
//
|
|
3
|
+
// A fact line looks like:
|
|
4
|
+
// - [2026-08-02 06:08] text here <!-- id:8f3a2c, ttl:90d, keep:1, supersedes:a1b2c3, tags:pref,arch -->
|
|
5
|
+
// The trailing HTML comment carries optional metadata. It is invisible in
|
|
6
|
+
// Markdown, so the store stays a single plain Markdown file. Older lines
|
|
7
|
+
// without a comment parse to empty metadata and stay fully compatible.
|
|
8
|
+
//
|
|
9
|
+
// Metadata keys:
|
|
10
|
+
// id short random id (e.g. "8f3a2c")
|
|
11
|
+
// ttl time-to-live, e.g. "90d", "2w", "24h", "12m" (m=month ~30d)
|
|
12
|
+
// keep "1" = protected fact: forget refuses to delete without force
|
|
13
|
+
// supersedes id (or number/text) this fact replaces
|
|
14
|
+
// supersededBy id of the fact that replaced this one
|
|
15
|
+
// tags comma-separated free-form tags for recall filtering
|
|
16
|
+
|
|
17
|
+
const META_KEYS = ["id", "ttl", "keep", "supersedes", "supersededBy", "tags"];
|
|
18
|
+
|
|
19
|
+
const ENTRY_RE = /^- \[(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]\s+(.*)$/;
|
|
20
|
+
|
|
21
|
+
// Parse a raw entry line into { line, date, time, text, meta }.
|
|
22
|
+
// Returns null if the line is not a fact entry.
|
|
23
|
+
export function parseFactEntry(line) {
|
|
24
|
+
const m = ENTRY_RE.exec(line);
|
|
25
|
+
if (!m) return null;
|
|
26
|
+
const [, date, time, rest] = m;
|
|
27
|
+
let text = rest;
|
|
28
|
+
const meta = {};
|
|
29
|
+
const cm = /^(.*?)\s*<!--\s*(.*?)\s*-->$/.exec(rest);
|
|
30
|
+
if (cm) {
|
|
31
|
+
// Values may themselves contain commas (e.g. "tags:pref,arch"), so scan
|
|
32
|
+
// for known "key:" tokens instead of splitting the comment on commas.
|
|
33
|
+
const raw = cm[2];
|
|
34
|
+
const keyRe = new RegExp(`(${META_KEYS.join("|")})\\s*:`, "g");
|
|
35
|
+
const found = [];
|
|
36
|
+
let fm;
|
|
37
|
+
while ((fm = keyRe.exec(raw))) {
|
|
38
|
+
found.push({ key: fm[1], valStart: fm.index + fm[0].length, tokenStart: fm.index });
|
|
39
|
+
}
|
|
40
|
+
for (let i = 0; i < found.length; i++) {
|
|
41
|
+
const valEnd = i + 1 < found.length ? found[i + 1].tokenStart : raw.length;
|
|
42
|
+
const v = raw.slice(found[i].valStart, valEnd).replace(/[,\s]+$/, "").trim();
|
|
43
|
+
if (v !== "") {
|
|
44
|
+
meta[found[i].key] = v;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (Object.keys(meta).length) text = cm[1];
|
|
48
|
+
}
|
|
49
|
+
return { line, date, time, text, meta };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Display text of a fact line (metadata comment stripped).
|
|
53
|
+
export function factText(line) {
|
|
54
|
+
const p = parseFactEntry(line);
|
|
55
|
+
return p ? p.text : line;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Metadata object of a fact line (empty if none / unparsable).
|
|
59
|
+
export function factMeta(line) {
|
|
60
|
+
const p = parseFactEntry(line);
|
|
61
|
+
return p ? p.meta : {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Format a fact line from parts. `meta` entries with falsy values are omitted.
|
|
65
|
+
export function formatFactEntry({ date, time, text, meta = {} }) {
|
|
66
|
+
let out = `- [${date} ${time}] ${text}`;
|
|
67
|
+
const pairs = [];
|
|
68
|
+
for (const k of META_KEYS) {
|
|
69
|
+
if (meta[k]) pairs.push(`${k}:${meta[k]}`);
|
|
70
|
+
}
|
|
71
|
+
if (pairs.length) out += ` <!-- ${pairs.join(", ")} -->`;
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Return a new line with `patch` applied to the metadata. Patch values that are
|
|
76
|
+
// null/undefined/"" remove the corresponding key. Non-fact lines are returned
|
|
77
|
+
// unchanged.
|
|
78
|
+
export function withMeta(line, patch) {
|
|
79
|
+
const p = parseFactEntry(line);
|
|
80
|
+
if (!p) return line;
|
|
81
|
+
const meta = { ...p.meta };
|
|
82
|
+
for (const k of Object.keys(patch)) {
|
|
83
|
+
const v = patch[k];
|
|
84
|
+
if (v === null || v === undefined || v === "") delete meta[k];
|
|
85
|
+
else meta[k] = String(v);
|
|
86
|
+
}
|
|
87
|
+
return formatFactEntry({ date: p.date, time: p.time, text: p.text, meta });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Generate a short random id that is unique within the given entries.
|
|
91
|
+
export function nextFactId(entries) {
|
|
92
|
+
let id;
|
|
93
|
+
do {
|
|
94
|
+
id = Math.random().toString(36).slice(2, 8);
|
|
95
|
+
} while (entries.some((e) => factMeta(e).id === id));
|
|
96
|
+
return id;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const TTL_UNITS = { h: 3600e3, d: 86400e3, w: 7 * 86400e3, m: 30 * 86400e3 };
|
|
100
|
+
|
|
101
|
+
// Parse "90d" | "2w" | "24h" | "12m" (m = ~30 days) into milliseconds. Null if invalid.
|
|
102
|
+
export function ttlMs(ttl) {
|
|
103
|
+
const m = /^(\d+)\s*([hdwm])?$/.exec(String(ttl || "").trim());
|
|
104
|
+
if (!m) return null;
|
|
105
|
+
const n = parseInt(m[1], 10);
|
|
106
|
+
const u = m[2] || "d";
|
|
107
|
+
return n * (TTL_UNITS[u] || TTL_UNITS.d);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// True if the fact line has a ttl and it has elapsed relative to `now` (ms).
|
|
111
|
+
export function isExpiredLine(line, now = Date.now()) {
|
|
112
|
+
const p = parseFactEntry(line);
|
|
113
|
+
if (!p || !p.meta.ttl) return false;
|
|
114
|
+
const ms = ttlMs(p.meta.ttl);
|
|
115
|
+
if (!ms) return false;
|
|
116
|
+
const ts = new Date(`${p.date}T${p.time}:00`).getTime();
|
|
117
|
+
if (Number.isNaN(ts)) return false;
|
|
118
|
+
return now > ts + ms;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// True if the fact is protected from deletion (keep:1).
|
|
122
|
+
export function isKeepFact(line) {
|
|
123
|
+
return factMeta(line).keep === "1";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// True if the fact has been superseded by another fact.
|
|
127
|
+
export function isSuperseded(line) {
|
|
128
|
+
return Boolean(factMeta(line).supersededBy);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Keyword match: space-separated terms, all must be present (case-insensitive).
|
|
132
|
+
// Also matches against the fact's id and tags.
|
|
133
|
+
export function matchesQuery(factLine, query) {
|
|
134
|
+
const q = String(query || "").trim();
|
|
135
|
+
if (!q) return true;
|
|
136
|
+
const p = parseFactEntry(factLine);
|
|
137
|
+
if (!p) return false;
|
|
138
|
+
const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
139
|
+
const haystack = `${p.text} ${p.meta.id || ""} ${p.meta.tags || ""} ${p.date}`.toLowerCase();
|
|
140
|
+
return terms.every((t) => haystack.includes(t));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Tag filter: comma-separated requested tags; match if ANY fact tag equals or
|
|
144
|
+
// contains a requested tag (case-insensitive).
|
|
145
|
+
export function matchesTags(factLine, tagsStr) {
|
|
146
|
+
const want = String(tagsStr || "").split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
147
|
+
if (!want.length) return true;
|
|
148
|
+
const have = (factMeta(factLine).tags || "").split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
149
|
+
if (!have.length) return false;
|
|
150
|
+
return want.some((w) => have.some((h) => h === w || h.includes(w) || w.includes(h)));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Date-range filter. `since`/`until` are "YYYY-MM-DD" (inclusive).
|
|
154
|
+
export function inDateRange(factLine, since, until) {
|
|
155
|
+
const p = parseFactEntry(factLine);
|
|
156
|
+
if (!p) return false;
|
|
157
|
+
if (since && p.date < since) return false;
|
|
158
|
+
if (until && p.date > until) return false;
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Human-readable badges for a fact line, e.g. ["EXPIRED", "KEEP", "SUPERSEDED"].
|
|
163
|
+
export function metaBadges(factLine, now = Date.now()) {
|
|
164
|
+
const badges = [];
|
|
165
|
+
if (isExpiredLine(factLine, now)) badges.push("EXPIRED");
|
|
166
|
+
if (isKeepFact(factLine)) badges.push("KEEP");
|
|
167
|
+
if (isSuperseded(factLine)) badges.push("SUPERSEDED");
|
|
168
|
+
return badges;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Display text of a fact line with badges appended, e.g.
|
|
172
|
+
// "user prefers TS [EXPIRED] [KEEP]"
|
|
173
|
+
export function displayFact(factLine, now = Date.now()) {
|
|
174
|
+
const text = factText(factLine);
|
|
175
|
+
const badges = metaBadges(factLine, now);
|
|
176
|
+
return badges.length ? `${text} [${badges.join("] [")}]` : text;
|
|
177
|
+
}
|