@lotargo/memory_plugin 1.2.902 → 1.3.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/mcp-server/cli.js +64 -13
- package/mcp-server/fact_format.js +177 -0
- package/mcp-server/index.js +215 -39
- package/mcp-server/memory.js +4 -0
- package/opencode-plugin/index.js +200 -47
- package/package.json +2 -1
- package/skills/using-memory/SKILL.md +47 -5
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);
|
|
@@ -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
|
+
}
|
package/mcp-server/index.js
CHANGED
|
@@ -2,7 +2,34 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import * as z from "zod/v4";
|
|
5
|
-
import { ensureDir, readMemory, readMemoryRaw, writeMemory, today, MEMORY_DIR, GLOBAL_KEY, scopeKey, projectKey, projectName, canonicalPath, listProjectStores } from "./memory.js";
|
|
5
|
+
import { ensureDir, readMemory, readMemoryRaw, writeMemory, today, MEMORY_DIR, GLOBAL_KEY, scopeKey, projectKey, projectName, canonicalPath, listProjectStores, storeFilePath } from "./memory.js";
|
|
6
|
+
import {
|
|
7
|
+
parseFactEntry,
|
|
8
|
+
factText,
|
|
9
|
+
factMeta,
|
|
10
|
+
withMeta,
|
|
11
|
+
nextFactId,
|
|
12
|
+
isKeepFact,
|
|
13
|
+
displayFact,
|
|
14
|
+
formatFactEntry,
|
|
15
|
+
matchesQuery,
|
|
16
|
+
matchesTags,
|
|
17
|
+
inDateRange,
|
|
18
|
+
} from "./fact_format.js";
|
|
19
|
+
import { readFile } from "node:fs/promises";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
// Resolve a fact reference (1-based number, metadata id, or text) to an index.
|
|
23
|
+
function resolveFactIndex(entries, ref) {
|
|
24
|
+
const trimmed = String(ref || "").trim();
|
|
25
|
+
if (!trimmed) return -1;
|
|
26
|
+
const num = parseInt(trimmed, 10);
|
|
27
|
+
if (/^\d+$/.test(trimmed) && num >= 1 && num <= entries.length) return num - 1;
|
|
28
|
+
const idIdx = entries.findIndex((e) => factMeta(e).id === trimmed);
|
|
29
|
+
if (idIdx !== -1) return idIdx;
|
|
30
|
+
const textIdx = entries.findIndex((e) => factText(e).toLowerCase().includes(trimmed.toLowerCase()));
|
|
31
|
+
return textIdx;
|
|
32
|
+
}
|
|
6
33
|
|
|
7
34
|
const cliArgs = process.argv.slice(2);
|
|
8
35
|
|
|
@@ -56,6 +83,11 @@ server.registerTool(
|
|
|
56
83
|
"(name, goals, constraints, tech preferences, project conventions). " +
|
|
57
84
|
"docId/startLine/endLine/relationType are OPTIONAL and only used to link the fact to a " +
|
|
58
85
|
"Knowledge Base document or line range; omit them when no linking is needed. " +
|
|
86
|
+
"ttl is OPTIONAL (e.g. '90d', '2w', '24h') — expired facts are shown with [EXPIRED] but not auto-deleted. " +
|
|
87
|
+
"keep=true protects the fact from forget deletion unless force=true. " +
|
|
88
|
+
"tags is OPTIONAL comma-separated text for filtering. " +
|
|
89
|
+
"supersedes is OPTIONAL: a number (from recall), id, or text of a fact this one replaces; " +
|
|
90
|
+
"the target is then marked [SUPERSEDED]. " +
|
|
59
91
|
"Translate the fact into English and keep it concise. " +
|
|
60
92
|
"scope: 'project' (default) or 'global'",
|
|
61
93
|
inputSchema: z.object({
|
|
@@ -65,17 +97,42 @@ server.registerTool(
|
|
|
65
97
|
startLine: optNum().describe("Optional starting line number in target document"),
|
|
66
98
|
endLine: optNum().describe("Optional ending line number in target document"),
|
|
67
99
|
relationType: defStr("LINKS_TO").describe("Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'REFERENCES')"),
|
|
100
|
+
ttl: optStr().describe("Optional time-to-live, e.g. '90d', '2w', '24h', '12m'"),
|
|
101
|
+
keep: defBool(false).describe("Protect the fact from forget deletion unless force=true"),
|
|
102
|
+
tags: optStr().describe("Optional comma-separated tags, e.g. 'pref,arch'"),
|
|
103
|
+
supersedes: optStr().describe("Optional number, id, or text of the fact this one replaces"),
|
|
68
104
|
}),
|
|
69
105
|
},
|
|
70
|
-
async ({ fact, scope, docId, startLine, endLine, relationType }) => {
|
|
106
|
+
async ({ fact, scope, docId, startLine, endLine, relationType, ttl, keep, tags, supersedes }) => {
|
|
71
107
|
const key = scopeKey(scope, null, null);
|
|
72
108
|
const entries = await readMemory(key);
|
|
73
109
|
const factNormalized = fact.toLowerCase().trim();
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
110
|
+
let duplicate = false;
|
|
111
|
+
if (entries.some((e) => factText(e).toLowerCase().trim() === factNormalized)) {
|
|
112
|
+
duplicate = true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let supersededInfo = "";
|
|
116
|
+
if (!duplicate) {
|
|
117
|
+
const [date, time] = today().split(" ");
|
|
118
|
+
const meta = { ttl, tags };
|
|
119
|
+
if (keep) meta.keep = "1";
|
|
120
|
+
if (supersedes) {
|
|
121
|
+
const targetIdx = resolveFactIndex(entries, supersedes);
|
|
122
|
+
if (targetIdx !== -1) {
|
|
123
|
+
const newId = nextFactId(entries);
|
|
124
|
+
const targetMeta = factMeta(entries[targetIdx]);
|
|
125
|
+
const targetId = targetMeta.id || nextFactId(entries);
|
|
126
|
+
entries[targetIdx] = withMeta(entries[targetIdx], { id: targetId, supersededBy: newId });
|
|
127
|
+
meta.id = newId;
|
|
128
|
+
meta.supersedes = targetId;
|
|
129
|
+
supersededInfo = ` [superseded: "${factText(entries[targetIdx]).slice(0, 60)}"]`;
|
|
130
|
+
} else {
|
|
131
|
+
supersededInfo = " (note: supersedes target not found)";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!meta.id) meta.id = nextFactId(entries);
|
|
135
|
+
entries.push(formatFactEntry({ date, time, text: fact, meta }));
|
|
79
136
|
await writeMemory(key, entries);
|
|
80
137
|
}
|
|
81
138
|
|
|
@@ -98,7 +155,7 @@ server.registerTool(
|
|
|
98
155
|
}
|
|
99
156
|
}
|
|
100
157
|
|
|
101
|
-
return { content: [{ type: "text", text: `Memory updated${linkInfo}` }] };
|
|
158
|
+
return { content: [{ type: "text", text: `Memory updated${supersededInfo}${linkInfo}` }] };
|
|
102
159
|
}
|
|
103
160
|
);
|
|
104
161
|
|
|
@@ -108,20 +165,27 @@ server.registerTool(
|
|
|
108
165
|
description:
|
|
109
166
|
"Show saved facts with any Agent-linked Knowledge Base documents/lines. " +
|
|
110
167
|
"scope: 'project', 'global', 'all' (default), or 'list_projects'. " +
|
|
111
|
-
"Use project: '<directory path>' with scope 'project'/'all' to read facts of a specific project from any working directory."
|
|
168
|
+
"Use project: '<directory path>' with scope 'project'/'all' to read facts of a specific project from any working directory. " +
|
|
169
|
+
"query filters by keyword (all space-separated terms must match). " +
|
|
170
|
+
"tags filters by comma-separated tags. since/until filter by date (YYYY-MM-DD, inclusive). " +
|
|
171
|
+
"Expired facts are shown with [EXPIRED], protected ones with [KEEP]. The response includes the store file paths.",
|
|
112
172
|
inputSchema: z.object({
|
|
113
173
|
scope: defStr("all").describe("'project', 'global', 'all', or 'list_projects'"),
|
|
114
174
|
project: optStr().describe("Directory path of the project to read facts from (e.g. 'F:/projects/plugins/memory')"),
|
|
175
|
+
query: optStr().describe("Optional keyword filter; all space-separated terms must match"),
|
|
176
|
+
tags: optStr().describe("Optional comma-separated tag filter (any match)"),
|
|
177
|
+
since: optStr().describe("Optional start date filter, YYYY-MM-DD (inclusive)"),
|
|
178
|
+
until: optStr().describe("Optional end date filter, YYYY-MM-DD (inclusive)"),
|
|
115
179
|
}),
|
|
116
180
|
},
|
|
117
|
-
async ({ scope, project }) => {
|
|
181
|
+
async ({ scope, project, query, tags, since, until }) => {
|
|
118
182
|
const { getLinksForFact } = await import("./graph/knowledge_linker.js");
|
|
119
183
|
const results = [];
|
|
120
184
|
|
|
121
|
-
const formatFactWithLinks = (
|
|
122
|
-
let line =
|
|
185
|
+
const formatFactWithLinks = (factLine, key) => {
|
|
186
|
+
let line = displayFact(factLine);
|
|
123
187
|
try {
|
|
124
|
-
const links = getLinksForFact(key, factText);
|
|
188
|
+
const links = getLinksForFact(key, factText(factLine));
|
|
125
189
|
if (links && links.length > 0) {
|
|
126
190
|
const docStr = links
|
|
127
191
|
.map((l) => {
|
|
@@ -135,6 +199,17 @@ server.registerTool(
|
|
|
135
199
|
return line;
|
|
136
200
|
};
|
|
137
201
|
|
|
202
|
+
const collect = (entries, key) => {
|
|
203
|
+
const matched = entries.filter(
|
|
204
|
+
(e) => matchesQuery(e, query) && matchesTags(e, tags) && inDateRange(e, since, until)
|
|
205
|
+
);
|
|
206
|
+
if (!matched.length) return;
|
|
207
|
+
if (results.length) results.push("");
|
|
208
|
+
results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
|
|
209
|
+
matched.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, key)}`));
|
|
210
|
+
results.push(`Store file: ${storeFilePath(key)}`);
|
|
211
|
+
};
|
|
212
|
+
|
|
138
213
|
if (scope === "list_projects") {
|
|
139
214
|
const stores = await listProjectStores();
|
|
140
215
|
if (!stores.length) {
|
|
@@ -147,7 +222,7 @@ server.registerTool(
|
|
|
147
222
|
content: [
|
|
148
223
|
{
|
|
149
224
|
type: "text",
|
|
150
|
-
text: `Project Memory Stores:\n${lines.join("\n")}\n\nUse recall(scope: "project", project: "<path>") to read a specific store
|
|
225
|
+
text: `Project Memory Stores:\n${lines.join("\n")}\n\nUse recall(scope: "project", project: "<path>") to read a specific store.\n\nMemory dir: ${MEMORY_DIR}`,
|
|
151
226
|
},
|
|
152
227
|
],
|
|
153
228
|
};
|
|
@@ -156,21 +231,19 @@ server.registerTool(
|
|
|
156
231
|
const target = project ? canonicalPath(project) : projectKey(null, null);
|
|
157
232
|
const label = project ? target : projectName();
|
|
158
233
|
if (scope !== "project") {
|
|
159
|
-
const global = await
|
|
160
|
-
|
|
161
|
-
results.push("--- Global ---");
|
|
162
|
-
global.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, GLOBAL_KEY)}`));
|
|
163
|
-
}
|
|
234
|
+
const global = await readMemory(GLOBAL_KEY);
|
|
235
|
+
collect(global, GLOBAL_KEY);
|
|
164
236
|
}
|
|
165
237
|
if (scope !== "global") {
|
|
166
|
-
const local = await
|
|
167
|
-
|
|
168
|
-
if (results.length) results.push("");
|
|
169
|
-
results.push(`--- Project: ${label} ---`);
|
|
170
|
-
local.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, target)}`));
|
|
171
|
-
}
|
|
238
|
+
const local = await readMemory(target);
|
|
239
|
+
collect(local, target);
|
|
172
240
|
}
|
|
173
|
-
const
|
|
241
|
+
const filtered = Boolean(query || tags || since || until);
|
|
242
|
+
const text = results.length
|
|
243
|
+
? `${results.join("\n")}\n\nMemory dir: ${MEMORY_DIR}`
|
|
244
|
+
: filtered
|
|
245
|
+
? "No facts match the search."
|
|
246
|
+
: "Memory is empty.";
|
|
174
247
|
return { content: [{ type: "text", text }] };
|
|
175
248
|
}
|
|
176
249
|
);
|
|
@@ -179,40 +252,143 @@ server.registerTool(
|
|
|
179
252
|
"forget",
|
|
180
253
|
{
|
|
181
254
|
description:
|
|
182
|
-
"Delete a fact by number (from recall), by range (e.g. '3-30', inclusive), or by text search"
|
|
255
|
+
"Delete a fact by number (from recall), by range (e.g. '3-30', inclusive), or by text search. " +
|
|
256
|
+
"Protected facts (remember with keep=true) are skipped unless force=true.",
|
|
183
257
|
inputSchema: z.object({
|
|
184
258
|
query: z.string().describe("Number, range like '3-30', or text to search for"),
|
|
185
259
|
scope: defStr("project").describe("'project' (default) or 'global'"),
|
|
260
|
+
force: defBool(false).describe("Also delete protected (keep) facts"),
|
|
186
261
|
}),
|
|
187
262
|
},
|
|
188
|
-
async ({ query, scope }) => {
|
|
263
|
+
async ({ query, scope, force }) => {
|
|
189
264
|
const key = scopeKey(scope, null, null);
|
|
190
265
|
const entries = await readMemory(key);
|
|
191
266
|
const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
|
|
192
267
|
const num = parseInt(query, 10);
|
|
193
|
-
let
|
|
268
|
+
let indices = [];
|
|
194
269
|
if (rangeMatch) {
|
|
195
270
|
const from = parseInt(rangeMatch[1], 10);
|
|
196
271
|
const to = parseInt(rangeMatch[2], 10);
|
|
197
272
|
if (from > 0 && to >= from && to <= entries.length) {
|
|
198
|
-
|
|
273
|
+
for (let i = from - 1; i < to; i++) indices.push(i);
|
|
199
274
|
}
|
|
200
275
|
}
|
|
201
|
-
if (!
|
|
202
|
-
|
|
276
|
+
if (!indices.length && !isNaN(num) && num > 0 && num <= entries.length) {
|
|
277
|
+
indices.push(num - 1);
|
|
203
278
|
}
|
|
204
|
-
if (!
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
entries.length = 0;
|
|
208
|
-
entries.push(...filtered);
|
|
279
|
+
if (!indices.length) {
|
|
280
|
+
const q = query.toLowerCase();
|
|
281
|
+
indices = entries.reduce((acc, e, i) => (e.toLowerCase().includes(q) ? acc.concat(i) : acc), []);
|
|
209
282
|
}
|
|
210
|
-
|
|
211
|
-
|
|
283
|
+
if (!indices.length) {
|
|
284
|
+
return { content: [{ type: "text", text: "Not found." }] };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const removable = indices.filter((i) => force || !isKeepFact(entries[i]));
|
|
288
|
+
const protectedCount = indices.length - removable.length;
|
|
289
|
+
if (removable.length) {
|
|
290
|
+
for (const i of removable.sort((a, b) => b - a)) entries.splice(i, 1);
|
|
291
|
+
await writeMemory(key, entries);
|
|
292
|
+
}
|
|
293
|
+
let text = removable.length ? "Memory updated" : "Nothing removed.";
|
|
294
|
+
if (protectedCount) text += ` (${protectedCount} protected fact(s) skipped; use force=true to override)`;
|
|
212
295
|
return { content: [{ type: "text", text }] };
|
|
213
296
|
}
|
|
214
297
|
);
|
|
215
298
|
|
|
299
|
+
server.registerTool(
|
|
300
|
+
"update_fact",
|
|
301
|
+
{
|
|
302
|
+
description:
|
|
303
|
+
"Update the text of an existing fact by number (from recall), id, or text match, " +
|
|
304
|
+
"preserving its original date and metadata. Linked Knowledge Base documents are re-pointed to the new text.",
|
|
305
|
+
inputSchema: z.object({
|
|
306
|
+
id: z.string().describe("Number (from recall), metadata id, or text of the fact to update"),
|
|
307
|
+
newText: z.string().describe("New fact text"),
|
|
308
|
+
scope: defStr("project").describe("'project' (default) or 'global'"),
|
|
309
|
+
}),
|
|
310
|
+
},
|
|
311
|
+
async ({ id, newText, scope }) => {
|
|
312
|
+
const key = scopeKey(scope, null, null);
|
|
313
|
+
const entries = await readMemory(key);
|
|
314
|
+
const idx = resolveFactIndex(entries, id);
|
|
315
|
+
if (idx === -1) throw new Error(`Fact not found: ${id}`);
|
|
316
|
+
const p = parseFactEntry(entries[idx]);
|
|
317
|
+
const oldText = p ? p.text : entries[idx];
|
|
318
|
+
const newLine = formatFactEntry({ date: p.date, time: p.time, text: newText, meta: p.meta });
|
|
319
|
+
entries[idx] = newLine;
|
|
320
|
+
await writeMemory(key, entries);
|
|
321
|
+
|
|
322
|
+
let linksUpdated = 0;
|
|
323
|
+
try {
|
|
324
|
+
const { getDatabase } = await import("./db/database.js");
|
|
325
|
+
const db = getDatabase();
|
|
326
|
+
const res = db
|
|
327
|
+
.prepare(
|
|
328
|
+
"UPDATE knowledge_links SET fact_text = ? WHERE fact_key = ? AND fact_text = ?"
|
|
329
|
+
)
|
|
330
|
+
.run(newText, key, oldText);
|
|
331
|
+
linksUpdated = res.changes;
|
|
332
|
+
} catch (e) {}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
content: [
|
|
336
|
+
{ type: "text", text: `Fact updated${linksUpdated ? `, ${linksUpdated} doc link(s) updated` : ""}` },
|
|
337
|
+
],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
server.registerTool(
|
|
343
|
+
"memory_info",
|
|
344
|
+
{
|
|
345
|
+
description:
|
|
346
|
+
"Show memory storage paths (store file locations, MEMORY_DIR, SQLite DB), fact counts, " +
|
|
347
|
+
"Knowledge Base stats, and the installed package version.",
|
|
348
|
+
inputSchema: z.object({}),
|
|
349
|
+
},
|
|
350
|
+
async () => {
|
|
351
|
+
const dbPath = join(MEMORY_DIR, "storage", "memory.sqlite");
|
|
352
|
+
const globalFile = storeFilePath(GLOBAL_KEY);
|
|
353
|
+
const projectFile = storeFilePath(projectKey(null, null));
|
|
354
|
+
|
|
355
|
+
let version = "unknown";
|
|
356
|
+
try {
|
|
357
|
+
version = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf-8")).version;
|
|
358
|
+
} catch (e) {}
|
|
359
|
+
|
|
360
|
+
let rag = {};
|
|
361
|
+
try {
|
|
362
|
+
const { getDatabase } = await import("./db/database.js");
|
|
363
|
+
const db = getDatabase();
|
|
364
|
+
rag.documents = db.prepare("SELECT COUNT(*) AS c FROM documents").get().c;
|
|
365
|
+
rag.sections = db.prepare("SELECT COUNT(*) AS c FROM sections").get().c;
|
|
366
|
+
rag.chunks = db.prepare("SELECT COUNT(*) AS c FROM micro_chunks").get().c;
|
|
367
|
+
rag.edges = db.prepare("SELECT COUNT(*) AS c FROM graph_edges").get().c;
|
|
368
|
+
rag.links = db.prepare("SELECT COUNT(*) AS c FROM knowledge_links").get().c;
|
|
369
|
+
} catch (e) {
|
|
370
|
+
rag.error = e.message;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const stores = await listProjectStores();
|
|
374
|
+
const lines = [
|
|
375
|
+
`Version: ${version}`,
|
|
376
|
+
`MEMORY_DIR: ${MEMORY_DIR}`,
|
|
377
|
+
`SQLite DB: ${dbPath}`,
|
|
378
|
+
`Global store: ${globalFile}`,
|
|
379
|
+
`Project store: ${projectFile}`,
|
|
380
|
+
`Project stores: ${stores.length}`,
|
|
381
|
+
`Facts (global): ${(await readMemoryRaw(GLOBAL_KEY)).length}`,
|
|
382
|
+
`Facts (project): ${(await readMemoryRaw(projectKey(null, null))).length}`,
|
|
383
|
+
];
|
|
384
|
+
if (rag.error) lines.push(`RAG: unavailable (${rag.error})`);
|
|
385
|
+
else lines.push(
|
|
386
|
+
`RAG: ${rag.documents} doc(s), ${rag.sections} section(s), ${rag.chunks} chunk(s), ${rag.edges} edge(s), ${rag.links} memory link(s)`
|
|
387
|
+
);
|
|
388
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
389
|
+
}
|
|
390
|
+
);
|
|
391
|
+
|
|
216
392
|
server.registerTool(
|
|
217
393
|
"link_knowledge",
|
|
218
394
|
{
|
package/mcp-server/memory.js
CHANGED
|
@@ -72,6 +72,10 @@ export function memoryFileName(key) {
|
|
|
72
72
|
return basename(memoryPath(key));
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
export function storeFilePath(key) {
|
|
76
|
+
return memoryPath(key);
|
|
77
|
+
}
|
|
78
|
+
|
|
75
79
|
function parseMeta(content) {
|
|
76
80
|
const m = content.match(/<!-- path: (.+?) -->/);
|
|
77
81
|
return { path: m ? m[1].trim() : null };
|
package/opencode-plugin/index.js
CHANGED
|
@@ -3,6 +3,32 @@ const { existsSync } = await import("fs");
|
|
|
3
3
|
const { join, basename, dirname, resolve } = await import("path");
|
|
4
4
|
const { homedir } = await import("os");
|
|
5
5
|
const { fileURLToPath } = await import("url");
|
|
6
|
+
const {
|
|
7
|
+
parseFactEntry,
|
|
8
|
+
factText,
|
|
9
|
+
factMeta,
|
|
10
|
+
withMeta,
|
|
11
|
+
nextFactId,
|
|
12
|
+
isKeepFact,
|
|
13
|
+
isSuperseded,
|
|
14
|
+
displayFact,
|
|
15
|
+
formatFactEntry,
|
|
16
|
+
matchesQuery,
|
|
17
|
+
matchesTags,
|
|
18
|
+
inDateRange,
|
|
19
|
+
} = await import("../mcp-server/fact_format.js");
|
|
20
|
+
|
|
21
|
+
// Resolve a fact reference (1-based number, metadata id, or text) to an index.
|
|
22
|
+
function resolveFactIndex(entries, ref) {
|
|
23
|
+
const trimmed = String(ref || "").trim();
|
|
24
|
+
if (!trimmed) return -1;
|
|
25
|
+
const num = parseInt(trimmed, 10);
|
|
26
|
+
if (/^\d+$/.test(trimmed) && num >= 1 && num <= entries.length) return num - 1;
|
|
27
|
+
const idIdx = entries.findIndex((e) => factMeta(e).id === trimmed);
|
|
28
|
+
if (idIdx !== -1) return idIdx;
|
|
29
|
+
const textIdx = entries.findIndex((e) => factText(e).toLowerCase().includes(trimmed.toLowerCase()));
|
|
30
|
+
return textIdx;
|
|
31
|
+
}
|
|
6
32
|
|
|
7
33
|
const CONFIG_DIR = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode");
|
|
8
34
|
const MEMORY_DIR = join(CONFIG_DIR, "memory");
|
|
@@ -214,13 +240,18 @@ const MEMORY_INSTRUCTION =
|
|
|
214
240
|
"When saving, translate the fact into clear, concise English.\n" +
|
|
215
241
|
"Use `scope: \"global\"` for personal facts, `scope: \"project\"` for project-specific facts.";
|
|
216
242
|
|
|
217
|
-
function buildMemoryContext(globalFacts, projectFacts, projectKey) {
|
|
243
|
+
function buildMemoryContext(globalFacts, projectFacts, projectKey, now = Date.now()) {
|
|
218
244
|
const parts = [MEMORY_INSTRUCTION];
|
|
245
|
+
const fmt = (entries) =>
|
|
246
|
+
entries
|
|
247
|
+
.filter((e) => !isSuperseded(e))
|
|
248
|
+
.map((e, i) => `${i + 1}. ${displayFact(e, now)}`)
|
|
249
|
+
.join("\n");
|
|
219
250
|
if (globalFacts.length) {
|
|
220
|
-
parts.push("## Global\n" + globalFacts
|
|
251
|
+
parts.push("## Global\n" + fmt(globalFacts));
|
|
221
252
|
}
|
|
222
253
|
if (projectFacts.length) {
|
|
223
|
-
parts.push(`## Project: ${projectKey}\n` + projectFacts
|
|
254
|
+
parts.push(`## Project: ${projectKey}\n` + fmt(projectFacts));
|
|
224
255
|
}
|
|
225
256
|
return `<MEMORY>\n${parts.join("\n\n")}\n</MEMORY>`;
|
|
226
257
|
}
|
|
@@ -251,8 +282,8 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
|
|
|
251
282
|
if (firstUser.parts.some((p) => p.type === "text" && p.text.includes("<MEMORY>"))) return;
|
|
252
283
|
|
|
253
284
|
const [globalFacts, projectFacts] = await Promise.all([
|
|
254
|
-
|
|
255
|
-
|
|
285
|
+
readMemory(GLOBAL_KEY),
|
|
286
|
+
readMemory(activeProjectKey),
|
|
256
287
|
]);
|
|
257
288
|
|
|
258
289
|
const context = buildMemoryContext(globalFacts, projectFacts, activeProjectKey);
|
|
@@ -290,6 +321,10 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
|
|
|
290
321
|
"(name, goals, constraints, tech preferences, project conventions). " +
|
|
291
322
|
"docId/startLine/endLine/relationType are OPTIONAL and only used to link the fact to a " +
|
|
292
323
|
"Knowledge Base document or line range; omit them when no linking is needed. " +
|
|
324
|
+
"ttl is OPTIONAL (e.g. '90d', '2w', '24h') — expired facts are shown with [EXPIRED] but not auto-deleted. " +
|
|
325
|
+
"keep=true protects the fact from forget deletion unless force=true. " +
|
|
326
|
+
"tags is OPTIONAL comma-separated text for filtering. " +
|
|
327
|
+
"supersedes is OPTIONAL: a number, id, or text of a fact this one replaces. " +
|
|
293
328
|
"Translate the fact into English and keep it concise. " +
|
|
294
329
|
"scope: 'project' (default) or 'global'",
|
|
295
330
|
args: {
|
|
@@ -307,16 +342,38 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
|
|
|
307
342
|
description: "Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'REFERENCES')",
|
|
308
343
|
default: "LINKS_TO",
|
|
309
344
|
},
|
|
345
|
+
ttl: { type: "string", description: "Optional time-to-live, e.g. '90d', '2w', '24h', '12m'" },
|
|
346
|
+
keep: { type: "boolean", description: "Protect the fact from forget deletion unless force=true" },
|
|
347
|
+
tags: { type: "string", description: "Optional comma-separated tags, e.g. 'pref,arch'" },
|
|
348
|
+
supersedes: { type: "string", description: "Optional number, id, or text of the fact this one replaces" },
|
|
310
349
|
},
|
|
311
|
-
async execute({ fact, scope, docId, startLine, endLine, relationType }, { worktree, directory }) {
|
|
350
|
+
async execute({ fact, scope, docId, startLine, endLine, relationType, ttl, keep, tags, supersedes }, { worktree, directory }) {
|
|
312
351
|
const key = scopeKey(scope || "project", worktree, directory);
|
|
313
352
|
const entries = await readMemory(key);
|
|
314
353
|
const factNormalized = fact.toLowerCase().trim();
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
354
|
+
const duplicate = entries.some((e) => factText(e).toLowerCase().trim() === factNormalized);
|
|
355
|
+
|
|
356
|
+
let supersededInfo = "";
|
|
357
|
+
if (!duplicate) {
|
|
358
|
+
const [date, time] = today().split(" ");
|
|
359
|
+
const meta = { ttl, tags };
|
|
360
|
+
if (keep) meta.keep = "1";
|
|
361
|
+
if (supersedes) {
|
|
362
|
+
const targetIdx = resolveFactIndex(entries, supersedes);
|
|
363
|
+
if (targetIdx !== -1) {
|
|
364
|
+
const newId = nextFactId(entries);
|
|
365
|
+
const targetMeta = factMeta(entries[targetIdx]);
|
|
366
|
+
const targetId = targetMeta.id || nextFactId(entries);
|
|
367
|
+
entries[targetIdx] = withMeta(entries[targetIdx], { id: targetId, supersededBy: newId });
|
|
368
|
+
meta.id = newId;
|
|
369
|
+
meta.supersedes = targetId;
|
|
370
|
+
supersededInfo = ` [superseded: "${factText(entries[targetIdx]).slice(0, 60)}"]`;
|
|
371
|
+
} else {
|
|
372
|
+
supersededInfo = " (note: supersedes target not found)";
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (!meta.id) meta.id = nextFactId(entries);
|
|
376
|
+
entries.push(formatFactEntry({ date, time, text: fact, meta }));
|
|
320
377
|
await writeMemory(key, entries);
|
|
321
378
|
}
|
|
322
379
|
|
|
@@ -339,15 +396,18 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
|
|
|
339
396
|
}
|
|
340
397
|
}
|
|
341
398
|
|
|
342
|
-
|
|
343
|
-
|
|
399
|
+
const result = "Memory updated" + supersededInfo + linkInfo;
|
|
400
|
+
await notify(client, result);
|
|
401
|
+
return result;
|
|
344
402
|
},
|
|
345
403
|
},
|
|
346
404
|
"recall": {
|
|
347
405
|
description:
|
|
348
406
|
"Show saved facts with any Agent-linked Knowledge Base documents/lines. " +
|
|
349
407
|
"scope: 'project', 'global', 'all' (default), or 'list_projects'. " +
|
|
350
|
-
"Use project: '<directory path>' to read facts of a specific project from any working directory."
|
|
408
|
+
"Use project: '<directory path>' to read facts of a specific project from any working directory. " +
|
|
409
|
+
"query filters by keyword, tags by comma-separated tags, since/until by date (YYYY-MM-DD). " +
|
|
410
|
+
"The response includes the store file paths.",
|
|
351
411
|
args: {
|
|
352
412
|
scope: {
|
|
353
413
|
type: "string",
|
|
@@ -355,8 +415,12 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
|
|
|
355
415
|
default: "all",
|
|
356
416
|
},
|
|
357
417
|
project: { type: "string", description: "Directory path of the project to read facts from (e.g. 'F:/projects/plugins/memory')" },
|
|
418
|
+
query: { type: "string", description: "Optional keyword filter; all space-separated terms must match" },
|
|
419
|
+
tags: { type: "string", description: "Optional comma-separated tag filter (any match)" },
|
|
420
|
+
since: { type: "string", description: "Optional start date filter, YYYY-MM-DD (inclusive)" },
|
|
421
|
+
until: { type: "string", description: "Optional end date filter, YYYY-MM-DD (inclusive)" },
|
|
358
422
|
},
|
|
359
|
-
async execute({ scope, project }, { worktree, directory }) {
|
|
423
|
+
async execute({ scope, project, query, tags, since, until }, { worktree, directory }) {
|
|
360
424
|
const results = [];
|
|
361
425
|
|
|
362
426
|
let getLinksForFact;
|
|
@@ -365,11 +429,11 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
|
|
|
365
429
|
getLinksForFact = linker.getLinksForFact;
|
|
366
430
|
} catch (e) {}
|
|
367
431
|
|
|
368
|
-
const formatFactWithLinks = (
|
|
369
|
-
let line =
|
|
432
|
+
const formatFactWithLinks = (factLine, key) => {
|
|
433
|
+
let line = displayFact(factLine);
|
|
370
434
|
if (getLinksForFact) {
|
|
371
435
|
try {
|
|
372
|
-
const links = getLinksForFact(key, factText);
|
|
436
|
+
const links = getLinksForFact(key, factText(factLine));
|
|
373
437
|
if (links && links.length > 0) {
|
|
374
438
|
const docStr = links
|
|
375
439
|
.map((l) => {
|
|
@@ -384,39 +448,45 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
|
|
|
384
448
|
return line;
|
|
385
449
|
};
|
|
386
450
|
|
|
451
|
+
const target = project ? canonicalPath(project) : projectKey(worktree, directory);
|
|
452
|
+
const label = project ? target : projectName(worktree, directory);
|
|
453
|
+
|
|
454
|
+
const collect = (entries, key) => {
|
|
455
|
+
const matched = entries.filter(
|
|
456
|
+
(e) => matchesQuery(e, query) && matchesTags(e, tags) && inDateRange(e, since, until)
|
|
457
|
+
);
|
|
458
|
+
if (!matched.length) return;
|
|
459
|
+
if (results.length) results.push("");
|
|
460
|
+
results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
|
|
461
|
+
matched.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, key)}`));
|
|
462
|
+
results.push(`Store file: ${memoryPath(key)}`);
|
|
463
|
+
};
|
|
464
|
+
|
|
387
465
|
if (scope === "list_projects") {
|
|
388
466
|
return listProjectStores().then((stores) => {
|
|
389
467
|
if (!stores.length) return "No project memory stores found.";
|
|
390
468
|
const lines = stores.map(
|
|
391
469
|
(s, i) => `${i + 1}. ${s.basename} — ${s.count} fact(s) [${s.file}]${s.path ? ` (bound to ${s.path})` : " (unbound legacy store)"}`
|
|
392
470
|
);
|
|
393
|
-
return `Project Memory Stores:\n${lines.join("\n")}\n\nUse recall(scope: "project", project: "<path>") to read a specific store
|
|
471
|
+
return `Project Memory Stores:\n${lines.join("\n")}\n\nUse recall(scope: "project", project: "<path>") to read a specific store.\n\nMemory dir: ${MEMORY_DIR}`;
|
|
394
472
|
});
|
|
395
473
|
}
|
|
396
474
|
|
|
397
|
-
const target = project ? canonicalPath(project) : projectKey(worktree, directory);
|
|
398
|
-
const label = project ? target : projectName(worktree, directory);
|
|
399
|
-
|
|
400
475
|
if (scope !== "project") {
|
|
401
|
-
const global = await
|
|
402
|
-
|
|
403
|
-
results.push("--- Global ---");
|
|
404
|
-
global.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, GLOBAL_KEY)}`));
|
|
405
|
-
}
|
|
476
|
+
const global = await readMemory(GLOBAL_KEY);
|
|
477
|
+
collect(global, GLOBAL_KEY);
|
|
406
478
|
}
|
|
407
479
|
if (scope !== "global") {
|
|
408
|
-
const local = await
|
|
409
|
-
|
|
410
|
-
if (results.length) results.push("");
|
|
411
|
-
results.push(`--- Project: ${label} ---`);
|
|
412
|
-
local.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, target)}`));
|
|
413
|
-
}
|
|
480
|
+
const local = await readMemory(target);
|
|
481
|
+
collect(local, target);
|
|
414
482
|
}
|
|
415
|
-
|
|
483
|
+
const filtered = Boolean(query || tags || since || until);
|
|
484
|
+
if (!results.length) return filtered ? "No facts match the search." : "Memory is empty.";
|
|
485
|
+
return results.join("\n") + `\n\nMemory dir: ${MEMORY_DIR}`;
|
|
416
486
|
},
|
|
417
487
|
},
|
|
418
488
|
"forget": {
|
|
419
|
-
description: "Удалить факт по номеру (см. recall), по диапазону (например '3-30', включительно) или
|
|
489
|
+
description: "Удалить факт по номеру (см. recall), по диапазону (например '3-30', включительно) или тексту. Защищённые факты (remember с keep=true) пропускаются, если не передан force=true",
|
|
420
490
|
args: {
|
|
421
491
|
query: { type: "string", description: "Номер факта, диапазон вида '3-30' или текст для поиска" },
|
|
422
492
|
scope: {
|
|
@@ -424,35 +494,118 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
|
|
|
424
494
|
description: "project (по умолчанию) или global",
|
|
425
495
|
default: "project",
|
|
426
496
|
},
|
|
497
|
+
force: { type: "boolean", description: "Удалить также защищённые (keep) факты" },
|
|
427
498
|
},
|
|
428
|
-
async execute({ query, scope }, { worktree, directory }) {
|
|
499
|
+
async execute({ query, scope, force }, { worktree, directory }) {
|
|
429
500
|
const key = scopeKey(scope || "project", worktree, directory);
|
|
430
501
|
const entries = await readMemory(key);
|
|
431
502
|
const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
|
|
432
503
|
const num = parseInt(query, 10);
|
|
433
|
-
let
|
|
504
|
+
let indices = [];
|
|
434
505
|
if (rangeMatch) {
|
|
435
506
|
const from = parseInt(rangeMatch[1], 10);
|
|
436
507
|
const to = parseInt(rangeMatch[2], 10);
|
|
437
508
|
if (from > 0 && to >= from && to <= entries.length) {
|
|
438
|
-
|
|
509
|
+
for (let i = from - 1; i < to; i++) indices.push(i);
|
|
439
510
|
}
|
|
440
511
|
}
|
|
441
|
-
if (!
|
|
442
|
-
|
|
512
|
+
if (!indices.length && !isNaN(num) && num > 0 && num <= entries.length) {
|
|
513
|
+
indices.push(num - 1);
|
|
514
|
+
}
|
|
515
|
+
if (!indices.length) {
|
|
516
|
+
const q = query.toLowerCase();
|
|
517
|
+
indices = entries.reduce((acc, e, i) => (e.toLowerCase().includes(q) ? acc.concat(i) : acc), []);
|
|
443
518
|
}
|
|
444
|
-
if (!
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
519
|
+
if (!indices.length) return "Not found.";
|
|
520
|
+
|
|
521
|
+
const removable = indices.filter((i) => force || !isKeepFact(entries[i]));
|
|
522
|
+
const protectedCount = indices.length - removable.length;
|
|
523
|
+
if (removable.length) {
|
|
524
|
+
for (const i of removable.sort((a, b) => b - a)) entries.splice(i, 1);
|
|
525
|
+
await writeMemory(key, entries);
|
|
449
526
|
}
|
|
527
|
+
let result = removable.length ? "Memory updated" : "Nothing removed.";
|
|
528
|
+
if (protectedCount) result += ` (${protectedCount} protected fact(s) skipped; use force=true to override)`;
|
|
529
|
+
if (removable.length) await notify(client, result);
|
|
530
|
+
return result;
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
"update_fact": {
|
|
534
|
+
description:
|
|
535
|
+
"Update the text of an existing fact by number (from recall), id, or text match, " +
|
|
536
|
+
"preserving its original date and metadata. Linked Knowledge Base documents are re-pointed to the new text.",
|
|
537
|
+
args: {
|
|
538
|
+
id: { type: "string", description: "Number (from recall), metadata id, or text of the fact to update" },
|
|
539
|
+
newText: { type: "string", description: "New fact text" },
|
|
540
|
+
scope: { type: "string", description: "'project' (default) or 'global'", default: "project" },
|
|
541
|
+
},
|
|
542
|
+
async execute({ id, newText, scope }, { worktree, directory }) {
|
|
543
|
+
const key = scopeKey(scope || "project", worktree, directory);
|
|
544
|
+
const entries = await readMemory(key);
|
|
545
|
+
const idx = resolveFactIndex(entries, id);
|
|
546
|
+
if (idx === -1) throw new Error(`Fact not found: ${id}`);
|
|
547
|
+
const p = parseFactEntry(entries[idx]);
|
|
548
|
+
const oldText = p ? p.text : entries[idx];
|
|
549
|
+
const newLine = formatFactEntry({ date: p.date, time: p.time, text: newText, meta: p.meta });
|
|
550
|
+
entries[idx] = newLine;
|
|
450
551
|
await writeMemory(key, entries);
|
|
451
|
-
|
|
452
|
-
|
|
552
|
+
|
|
553
|
+
let linksUpdated = 0;
|
|
554
|
+
try {
|
|
555
|
+
const { getDatabase } = await import("../mcp-server/db/database.js");
|
|
556
|
+
const db = getDatabase();
|
|
557
|
+
const res = db
|
|
558
|
+
.prepare("UPDATE knowledge_links SET fact_text = ? WHERE fact_key = ? AND fact_text = ?")
|
|
559
|
+
.run(newText, key, oldText);
|
|
560
|
+
linksUpdated = res.changes;
|
|
561
|
+
} catch (e) {}
|
|
562
|
+
|
|
563
|
+
const result = `Fact updated${linksUpdated ? `, ${linksUpdated} doc link(s) updated` : ""}`;
|
|
564
|
+
await notify(client, result);
|
|
453
565
|
return result;
|
|
454
566
|
},
|
|
455
567
|
},
|
|
568
|
+
"memory_info": {
|
|
569
|
+
description: "Show memory storage paths (store files, MEMORY_DIR, SQLite DB), fact counts, and Knowledge Base stats.",
|
|
570
|
+
args: {},
|
|
571
|
+
async execute() {
|
|
572
|
+
const dbPath = join(MEMORY_DIR, "storage", "memory.sqlite");
|
|
573
|
+
let version = "unknown";
|
|
574
|
+
try {
|
|
575
|
+
const { readFile } = await import("fs/promises");
|
|
576
|
+
version = JSON.parse(
|
|
577
|
+
await readFile(new URL("../package.json", import.meta.url), "utf-8")
|
|
578
|
+
).version;
|
|
579
|
+
} catch (e) {}
|
|
580
|
+
|
|
581
|
+
let rag = {};
|
|
582
|
+
try {
|
|
583
|
+
const { getDatabase } = await import("../mcp-server/db/database.js");
|
|
584
|
+
const db = getDatabase();
|
|
585
|
+
rag.documents = db.prepare("SELECT COUNT(*) AS c FROM documents").get().c;
|
|
586
|
+
rag.sections = db.prepare("SELECT COUNT(*) AS c FROM sections").get().c;
|
|
587
|
+
rag.chunks = db.prepare("SELECT COUNT(*) AS c FROM micro_chunks").get().c;
|
|
588
|
+
rag.edges = db.prepare("SELECT COUNT(*) AS c FROM graph_edges").get().c;
|
|
589
|
+
rag.links = db.prepare("SELECT COUNT(*) AS c FROM knowledge_links").get().c;
|
|
590
|
+
} catch (e) {
|
|
591
|
+
rag.error = e.message;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const lines = [
|
|
595
|
+
`Version: ${version}`,
|
|
596
|
+
`MEMORY_DIR: ${MEMORY_DIR}`,
|
|
597
|
+
`SQLite DB: ${dbPath}`,
|
|
598
|
+
`Global store: ${memoryPath(GLOBAL_KEY)}`,
|
|
599
|
+
`Project store: ${memoryPath(activeProjectKey)}`,
|
|
600
|
+
];
|
|
601
|
+
if (rag.error) lines.push(`RAG: unavailable (${rag.error})`);
|
|
602
|
+
else
|
|
603
|
+
lines.push(
|
|
604
|
+
`RAG: ${rag.documents} doc(s), ${rag.sections} section(s), ${rag.chunks} chunk(s), ${rag.edges} edge(s), ${rag.links} memory link(s)`
|
|
605
|
+
);
|
|
606
|
+
return lines.join("\n");
|
|
607
|
+
},
|
|
608
|
+
},
|
|
456
609
|
"link_knowledge": {
|
|
457
610
|
description:
|
|
458
611
|
"Explicitly link a Notebook memory fact to a Knowledge Base document, section, or line range. " +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotargo/memory_plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Persistent memory agent for coding AI tools — remembers user preferences and project context across sessions. Works with Antigravity, OpenCode, Claude Code, and Codex.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "opencode-plugin/index.js",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"mcp-server/storage",
|
|
26
26
|
"mcp-server/cli.js",
|
|
27
27
|
"mcp-server/index.js",
|
|
28
|
+
"mcp-server/fact_format.js",
|
|
28
29
|
"mcp-server/memory.js",
|
|
29
30
|
"mcp-server/setup.js",
|
|
30
31
|
"mcp-server/preinstall.js",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: using-memory
|
|
3
|
-
description: Comprehensive guide for using the Memory & Hybrid RAG Knowledge Engine tools (remember, recall, forget, link_knowledge, ingest_document, query_knowledge_base, manage_knowledge_base). Trigger proactively whenever user preferences, project conventions, technology stack choices, or architecture decisions are introduced, or when querying ingested documentation, indexing files/repos, or managing persistent knowledge.
|
|
3
|
+
description: Comprehensive guide for using the Memory & Hybrid RAG Knowledge Engine tools (remember, recall, forget, update_fact, memory_info, link_knowledge, ingest_document, query_knowledge_base, manage_knowledge_base). Trigger proactively whenever user preferences, project conventions, technology stack choices, or architecture decisions are introduced, or when querying ingested documentation, indexing files/repos, or managing persistent knowledge.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Using Memory & Hybrid RAG Knowledge Engine
|
|
@@ -17,8 +17,13 @@ You have access to a persistent dual-layer memory engine supercharged with an **
|
|
|
17
17
|
| Scenario / Intent | Target Tool | Key Parameters |
|
|
18
18
|
|-------------------|-------------|----------------|
|
|
19
19
|
| User shares identity, tech stack preference, or workflow rule | `remember` | `fact` (English), `scope`, optional `docId`, `startLine`, `endLine` |
|
|
20
|
-
| User asks what you remember about them, the project, or linked docs | `recall` | `scope` ("all", "global", or "project") |
|
|
21
|
-
| User corrects/updates an old saved fact | `
|
|
20
|
+
| User asks what you remember about them, the project, or linked docs | `recall` | `scope` ("all", "global", or "project"), optional `query`, `tags`, `since`, `until`, `project` |
|
|
21
|
+
| User corrects/updates an old saved fact | `update_fact` | `id` (number/id/text), `newText`, `scope` |
|
|
22
|
+
| Replace a fact but keep a version trail | `remember` | `fact`, `supersedes` (number/id/text) |
|
|
23
|
+
| Protect a fact from accidental `forget` | `remember` | `keep: true` |
|
|
24
|
+
| Set a time-to-live on a fact | `remember` | `ttl` ("90d", "2w", "24h", "12m") |
|
|
25
|
+
| Filter facts by keyword / tags / date | `recall` | `query`, `tags`, `since`, `until` |
|
|
26
|
+
| Show storage paths, versions, fact & RAG stats | `memory_info` | — |
|
|
22
27
|
| Connect a Notebook fact to a document, section, or line range | `link_knowledge` | `factText`, `docId`, `startLine`, `endLine`, `relationType` |
|
|
23
28
|
| User asks to index a documentation URL, file, or repository | `ingest_document` | `content` or `source_path`, `title`, `metadata` |
|
|
24
29
|
| User asks a complex question about indexed docs or code | `query_knowledge_base` | `query`, `limit`, `generateEmbeddings` |
|
|
@@ -27,7 +32,7 @@ You have access to a persistent dual-layer memory engine supercharged with an **
|
|
|
27
32
|
|
|
28
33
|
---
|
|
29
34
|
|
|
30
|
-
## 2. Layer 1 & 3: Notebook Store & Agent-Driven Knowledge Graph (`remember`, `recall`, `link_knowledge`)
|
|
35
|
+
## 2. Layer 1 & 3: Notebook Store & Agent-Driven Knowledge Graph (`remember`, `recall`, `update_fact`, `forget`, `memory_info`, `link_knowledge`)
|
|
31
36
|
|
|
32
37
|
### Agent-Driven Knowledge Graph Architecture
|
|
33
38
|
Automatic regex/heuristic algorithms alone CANNOT infer high-level semantic intent or cross-document relationships. **You (the AI Agent) are the primary architect of the Knowledge Graph.**
|
|
@@ -50,6 +55,43 @@ When `recall` is invoked, the engine returns saved facts along with their Agent-
|
|
|
50
55
|
2. PostgreSQL 16 is primary database 🔗 [Linked Docs: database_guide.md:L20-35]
|
|
51
56
|
```
|
|
52
57
|
|
|
58
|
+
### Fact Line Format & Metadata
|
|
59
|
+
Each fact is stored as a single Markdown line with an optional invisible HTML comment carrying metadata:
|
|
60
|
+
```
|
|
61
|
+
- [2026-08-02 06:08] user prefers TypeScript <!-- id:8f3a2c, ttl:90d, keep:1, tags:pref,arch -->
|
|
62
|
+
```
|
|
63
|
+
Supported metadata keys (set via `remember`, rendered as badges by `recall`):
|
|
64
|
+
- `id` — auto-generated short id; stable reference for `update_fact` / `forget` / `supersedes`.
|
|
65
|
+
- `ttl` — time-to-live ("90d", "2w", "24h", "12m", bare number = days). Expired facts are marked `[EXPIRED]` but never auto-deleted.
|
|
66
|
+
- `keep` — protection flag; `forget` skips it unless `force: true`.
|
|
67
|
+
- `tags` — comma-separated free-form tags for filtering.
|
|
68
|
+
- `supersedes` / `supersededBy` — versioning: the old fact gets `[SUPERSEDED]` and is excluded from the injected memory block while staying in the store for history.
|
|
69
|
+
|
|
70
|
+
### Remember Options (`remember`)
|
|
71
|
+
- `ttl`: "90d", "2w", "24h", "12m" — mark the fact for expiry; it will show `[EXPIRED]` once past.
|
|
72
|
+
- `keep: true`: protect the fact from `forget` (unless `force: true`).
|
|
73
|
+
- `tags`: comma-separated tags for later filtering, e.g. `"pref,arch"`.
|
|
74
|
+
- `supersedes`: number (as listed by `recall`), metadata `id`, or text of the fact this one replaces.
|
|
75
|
+
|
|
76
|
+
### Filtering Facts (`recall`)
|
|
77
|
+
- `query`: all space-separated terms must match (case-insensitive); searches text, id, tags, and date.
|
|
78
|
+
- `tags`: comma-separated; returns facts with ANY matching tag.
|
|
79
|
+
- `since` / `until`: "YYYY-MM-DD" (inclusive) to filter by fact date.
|
|
80
|
+
- `project`: read a specific project's store from any working directory.
|
|
81
|
+
- Output shows `[EXPIRED]`, `[KEEP]`, `[SUPERSEDED]` badges and the `Store file:` path.
|
|
82
|
+
|
|
83
|
+
### Updating Facts (`update_fact`)
|
|
84
|
+
When the user corrects an old fact, prefer `update_fact` over `forget`+`remember` — it rewrites the text while preserving the original date and all metadata (`ttl`, `keep`, `tags`, `supersedes`), and re-points any linked Knowledge Base documents.
|
|
85
|
+
- `id`: recall index number, metadata `id`, or text of the fact.
|
|
86
|
+
- `newText`: replacement text.
|
|
87
|
+
- `scope`: "project" (default) or "global".
|
|
88
|
+
|
|
89
|
+
### Protecting Facts (`forget` with `keep`)
|
|
90
|
+
`forget` refuses to delete facts saved with `keep: true`; pass `force: true` to override. It still supports deleting by index number, range ("3-30"), or text.
|
|
91
|
+
|
|
92
|
+
### Storage Diagnostics (`memory_info`)
|
|
93
|
+
`memory_info` returns the package version, `MEMORY_DIR`, SQLite DB path, store-file locations, fact counts per store, and RAG stats (documents, sections, chunks, graph edges, links).
|
|
94
|
+
|
|
53
95
|
---
|
|
54
96
|
|
|
55
97
|
## 3. Layer 2: RAG Knowledge Base (`ingest_document`, `query_knowledge_base`, `manage_knowledge_base`)
|
|
@@ -121,4 +163,4 @@ In such cases, use the **Full Raw Document Reading** mechanism:
|
|
|
121
163
|
2. **Be Proactive**: When the user mentions a durable preference, personal fact, or constraint, save it immediately using `remember`. Do not wait for explicit user commands.
|
|
122
164
|
3. **Check Knowledge Base First**: If a user asks how a specific module, API, or project architecture works, call `query_knowledge_base` using concept-dense search phrases.
|
|
123
165
|
4. **Inspect Ambiguous Docs Directly**: If querying produces low relevance scores on abstractly-named documents, call `manage_knowledge_base(action: "read_document")` to inspect the full text directly.
|
|
124
|
-
5. **Keep Memory Clean**: If a preference changes, call `
|
|
166
|
+
5. **Keep Memory Clean**: If a preference changes, call `update_fact` to edit it in place, or `remember` with `supersedes` to keep a version trail. Use `keep: true` for facts that must survive an accidental `forget`, and give ephemeral facts a `ttl` so stale ones surface as `[EXPIRED]`.
|