@lotargo/memory_plugin 1.6.4 → 1.6.5
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 +11 -0
- package/mcp-server/memory.js +13 -3
- package/mcp-server/tools/core/memory_core.js +376 -356
- package/mcp-server/tools/identity_tools.js +4 -2
- package/mcp-server/tools/memory_tools.js +134 -121
- package/mcp-server/tools/rag_tools.js +207 -202
- package/opencode-plugin/index.js +366 -339
- package/package.json +5 -5
- package/skills/using-memory/SKILL.md +93 -89
|
@@ -1,81 +1,92 @@
|
|
|
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
|
-
|
|
59
|
-
return
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
function extractEffectiveDir(args = {}, ctx = {}) {
|
|
59
|
+
return (
|
|
60
|
+
args?.directory ||
|
|
61
|
+
args?.project ||
|
|
62
|
+
ctx?.directory ||
|
|
63
|
+
ctx?.worktree ||
|
|
64
|
+
null
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function resolveScopeKey(scope, args = {}, ctx = {}) {
|
|
69
|
+
const dir = extractEffectiveDir(args, ctx);
|
|
70
|
+
return await scopeKey(scope || "project", ctx?.worktree ?? null, dir);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function rememberFact(
|
|
74
|
+
{ fact, title, scope, directory, project, docId, startLine, endLine, relationType, ttl, keep, tags, supersedes },
|
|
75
|
+
ctx = {}
|
|
76
|
+
) {
|
|
77
|
+
const key = requireProjectKey(await resolveScopeKey(scope, { directory, project }, ctx));
|
|
78
|
+
const entries = await readMemory(key);
|
|
79
|
+
|
|
80
|
+
let { finalTitle, finalFact } = splitTitle(fact, title);
|
|
81
|
+
if (!finalTitle) finalTitle = autoGenerateTitle(finalFact);
|
|
82
|
+
|
|
83
|
+
const text = `**${finalTitle}** — ${finalFact}`;
|
|
84
|
+
const factBodyNormalized = finalFact.toLowerCase();
|
|
85
|
+
const duplicate = entries.some((e) => factBody(e).toLowerCase().trim() === factBodyNormalized);
|
|
86
|
+
|
|
87
|
+
let supersededInfo = "";
|
|
88
|
+
if (!duplicate) {
|
|
89
|
+
const [date, time] = today().split(" ");
|
|
79
90
|
const meta = { ttl, tags };
|
|
80
91
|
if (keep) meta.keep = "1";
|
|
81
92
|
if (supersedes) {
|
|
@@ -89,117 +100,121 @@ export async function rememberFact(
|
|
|
89
100
|
const newId = nextFactId(entries);
|
|
90
101
|
entries[targetIdx] = withMeta(entries[targetIdx], { id: targetId, supersededBy: newId });
|
|
91
102
|
meta.id = newId;
|
|
92
|
-
meta.supersedes = targetId;
|
|
93
|
-
supersededInfo = ` [superseded: "${factText(entries[targetIdx]).slice(0, 60)}"]`;
|
|
94
|
-
} else {
|
|
95
|
-
supersededInfo = " (note: supersedes target not found)";
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
if (!meta.id) meta.id = nextFactId(entries);
|
|
99
|
-
entries.push(formatFactEntry({ date, time, text, meta }));
|
|
100
|
-
await writeMemory(key, entries);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
let linkInfo = "";
|
|
104
|
-
if (docId) {
|
|
105
|
-
try {
|
|
106
|
-
const { linkFactToDocument } = await import("../../graph/knowledge_linker.js");
|
|
107
|
-
const linkRes = await linkFactToDocument({
|
|
108
|
-
factKey: key,
|
|
109
|
-
factText: finalFact,
|
|
110
|
-
docId,
|
|
111
|
-
startLine,
|
|
112
|
-
endLine,
|
|
113
|
-
relationType: relationType || "LINKS_TO",
|
|
114
|
-
});
|
|
115
|
-
const linesStr = startLine ? `:L${startLine}${endLine ? `-${endLine}` : ""}` : "";
|
|
116
|
-
linkInfo = ` [Linked to Doc: "${linkRes.docTitle}"${linesStr}]`;
|
|
117
|
-
} catch (err) {
|
|
118
|
-
linkInfo = ` (Note: Fact saved, but document link failed: ${err.message})`;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return `Memory updated${supersededInfo}${linkInfo}`;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async function resolveTargetKey(projectPath) {
|
|
126
|
-
if (!projectPath) return null;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
if (
|
|
179
|
-
badges.push(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
103
|
+
meta.supersedes = targetId;
|
|
104
|
+
supersededInfo = ` [superseded: "${factText(entries[targetIdx]).slice(0, 60)}"]`;
|
|
105
|
+
} else {
|
|
106
|
+
supersededInfo = " (note: supersedes target not found)";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!meta.id) meta.id = nextFactId(entries);
|
|
110
|
+
entries.push(formatFactEntry({ date, time, text, meta }));
|
|
111
|
+
await writeMemory(key, entries);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let linkInfo = "";
|
|
115
|
+
if (docId) {
|
|
116
|
+
try {
|
|
117
|
+
const { linkFactToDocument } = await import("../../graph/knowledge_linker.js");
|
|
118
|
+
const linkRes = await linkFactToDocument({
|
|
119
|
+
factKey: key,
|
|
120
|
+
factText: finalFact,
|
|
121
|
+
docId,
|
|
122
|
+
startLine,
|
|
123
|
+
endLine,
|
|
124
|
+
relationType: relationType || "LINKS_TO",
|
|
125
|
+
});
|
|
126
|
+
const linesStr = startLine ? `:L${startLine}${endLine ? `-${endLine}` : ""}` : "";
|
|
127
|
+
linkInfo = ` [Linked to Doc: "${linkRes.docTitle}"${linesStr}]`;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
linkInfo = ` (Note: Fact saved, but document link failed: ${err.message})`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return `Memory updated${supersededInfo}${linkInfo}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function resolveTargetKey(projectPath) {
|
|
137
|
+
if (!projectPath) return null;
|
|
138
|
+
if (typeof projectPath === "string" && (projectPath.startsWith("git:") || projectPath.startsWith("git_") || projectPath === GLOBAL_KEY)) {
|
|
139
|
+
return projectPath;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const { resolveProjectIdentity } = await import("../../identity.js");
|
|
143
|
+
const identity = await resolveProjectIdentity(projectPath);
|
|
144
|
+
if (identity) return identity.key;
|
|
145
|
+
} catch {}
|
|
146
|
+
return canonicalPath(projectPath);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function recallFacts(
|
|
150
|
+
{ scope, directory, project, query, tags, since, until, mode, offset, limit, includeSuperseded = false },
|
|
151
|
+
ctx = {}
|
|
152
|
+
) {
|
|
153
|
+
const results = [];
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const targetMode = mode || "full";
|
|
156
|
+
const targetOffset = offset !== undefined && offset !== null ? offset : 0;
|
|
157
|
+
const targetProjectInput = directory || project || ctx.directory || ctx.worktree || null;
|
|
158
|
+
|
|
159
|
+
if (scope === "list_projects") {
|
|
160
|
+
const stores = await listProjectStores();
|
|
161
|
+
if (!stores.length) return "No project memory stores found.";
|
|
162
|
+
const lines = stores.map(
|
|
163
|
+
(s, i) =>
|
|
164
|
+
`${i + 1}. ${s.basename} — ${s.count} fact(s) [${s.file}]${
|
|
165
|
+
s.path ? ` (bound to ${s.path})` : " (unbound legacy store)"
|
|
166
|
+
}`
|
|
167
|
+
);
|
|
168
|
+
return `Project Memory Stores:\n${lines.join(
|
|
169
|
+
"\n"
|
|
170
|
+
)}\n\nUse recall(scope: "project", directory: "<path>") to read a specific store.\n\nMemory dir: ${MEMORY_DIR}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let getLinksForFact = null;
|
|
174
|
+
try {
|
|
175
|
+
({ getLinksForFact } = await import("../../graph/knowledge_linker.js"));
|
|
176
|
+
} catch {}
|
|
177
|
+
|
|
178
|
+
const target =
|
|
179
|
+
(await resolveTargetKey(targetProjectInput)) ?? (await projectKey(ctx.worktree ?? null, targetProjectInput));
|
|
180
|
+
const label = targetProjectInput ? target : await projectName(ctx.worktree ?? null, targetProjectInput);
|
|
181
|
+
|
|
182
|
+
const formatFactWithLinks = async (factLine, index, key) => {
|
|
183
|
+
const p = parseFactEntry(factLine);
|
|
184
|
+
if (!p) return factLine;
|
|
185
|
+
|
|
186
|
+
const meta = p.meta;
|
|
187
|
+
const badges = [];
|
|
188
|
+
if (isExpiredLine(factLine, now)) badges.push("EXPIRED");
|
|
189
|
+
if (isKeepFact(factLine)) badges.push("KEEP");
|
|
190
|
+
if (isSuperseded(factLine)) badges.push("SUPERSEDED");
|
|
191
|
+
if (meta.inject === "1") badges.push("INJECT");
|
|
192
|
+
if (meta.id) badges.push(`id:${meta.id}`);
|
|
193
|
+
if (meta.tags) badges.push(`tags:${meta.tags}`);
|
|
194
|
+
badges.push(`${p.date} ${p.time}`);
|
|
195
|
+
const badgesStr = badges.length ? ` [${badges.join("] [")}]` : "";
|
|
196
|
+
|
|
197
|
+
let lineText =
|
|
198
|
+
targetMode === "headers" ? `**${factTitle(factLine)}**${badgesStr}` : `${p.text}${badgesStr}`;
|
|
199
|
+
|
|
200
|
+
if (getLinksForFact) {
|
|
201
|
+
try {
|
|
202
|
+
const links = await getLinksForFact(key, p.text);
|
|
203
|
+
if (links && links.length > 0) {
|
|
204
|
+
const docStr = links
|
|
205
|
+
.map((l) => {
|
|
206
|
+
const range = l.start_line ? `:L${l.start_line}${l.end_line ? `-${l.end_line}` : ""}` : "";
|
|
207
|
+
return `${l.doc_title || l.doc_path}${range}`;
|
|
208
|
+
})
|
|
209
|
+
.join(", ");
|
|
210
|
+
lineText += ` 🔗 [Linked Docs: ${docStr}]`;
|
|
211
|
+
}
|
|
212
|
+
} catch {}
|
|
213
|
+
}
|
|
214
|
+
return `${index}. ${lineText}`;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const collect = async (entries, key) => {
|
|
203
218
|
const matched = entries
|
|
204
219
|
.map((entry, storageIndex) => ({ entry, storageIndex }))
|
|
205
220
|
.filter(
|
|
@@ -209,88 +224,92 @@ export async function recallFacts(
|
|
|
209
224
|
matchesTags(entry, tags) &&
|
|
210
225
|
inDateRange(entry, since, until)
|
|
211
226
|
);
|
|
212
|
-
if (!matched.length) return;
|
|
213
|
-
if (results.length) results.push("");
|
|
214
|
-
results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
|
|
215
|
-
|
|
216
|
-
const hasLimit = limit !== undefined && limit !== null;
|
|
227
|
+
if (!matched.length) return;
|
|
228
|
+
if (results.length) results.push("");
|
|
229
|
+
results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
|
|
230
|
+
|
|
231
|
+
const hasLimit = limit !== undefined && limit !== null;
|
|
217
232
|
const targetLimit = hasLimit ? limit : matched.length;
|
|
218
233
|
const paginated = matched.slice(targetOffset, targetOffset + targetLimit);
|
|
219
234
|
for (let i = 0; i < paginated.length; i++) {
|
|
220
235
|
results.push(
|
|
221
236
|
await formatFactWithLinks(paginated[i].entry, paginated[i].storageIndex + 1, key)
|
|
222
237
|
);
|
|
223
|
-
}
|
|
224
|
-
if (hasLimit && matched.length > targetLimit) {
|
|
225
|
-
results.push(
|
|
226
|
-
`Showing entries ${targetOffset + 1}-${Math.min(targetOffset + targetLimit, matched.length)} of ${matched.length}`
|
|
227
|
-
);
|
|
228
|
-
}
|
|
229
|
-
results.push(`Store file: ${storeFilePath(key)}`);
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
if (scope !== "project") await collect(await readMemory(GLOBAL_KEY), GLOBAL_KEY);
|
|
233
|
-
if (scope !== "global") await collect(await readMemory(target), target);
|
|
234
|
-
|
|
235
|
-
const filtered = Boolean(query || tags || since || until);
|
|
236
|
-
if (!results.length) return filtered ? "No facts match the search." : "Memory is empty.";
|
|
237
|
-
return `${results.join("\n")}\n\nMemory dir: ${MEMORY_DIR}`;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export async function getFactById({ id, scope }, ctx = {}) {
|
|
241
|
-
const targetId = String(id || "").trim();
|
|
242
|
-
if (!targetId) throw new Error("ID parameter is required.");
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (scope !== "
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
if (!indices.length) {
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
if (!indices.length)
|
|
292
|
-
|
|
293
|
-
|
|
238
|
+
}
|
|
239
|
+
if (hasLimit && matched.length > targetLimit) {
|
|
240
|
+
results.push(
|
|
241
|
+
`Showing entries ${targetOffset + 1}-${Math.min(targetOffset + targetLimit, matched.length)} of ${matched.length}`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
results.push(`Store file: ${storeFilePath(key)}`);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
if (scope !== "project") await collect(await readMemory(GLOBAL_KEY), GLOBAL_KEY);
|
|
248
|
+
if (scope !== "global" && target) await collect(await readMemory(target), target);
|
|
249
|
+
|
|
250
|
+
const filtered = Boolean(query || tags || since || until);
|
|
251
|
+
if (!results.length) return filtered ? "No facts match the search." : "Memory is empty.";
|
|
252
|
+
return `${results.join("\n")}\n\nMemory dir: ${MEMORY_DIR}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function getFactById({ id, scope, directory, project }, ctx = {}) {
|
|
256
|
+
const targetId = String(id || "").trim();
|
|
257
|
+
if (!targetId) throw new Error("ID parameter is required.");
|
|
258
|
+
|
|
259
|
+
const targetDir = extractEffectiveDir({ directory, project }, ctx);
|
|
260
|
+
const results = [];
|
|
261
|
+
const check = async (key) => {
|
|
262
|
+
const entries = await readMemory(key);
|
|
263
|
+
const match = entries.find((e) => factMeta(e).id === targetId);
|
|
264
|
+
if (match) {
|
|
265
|
+
results.push({ key, title: factTitle(match), body: factBody(match), meta: factMeta(match), line: match });
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
if (scope !== "project") await check(GLOBAL_KEY);
|
|
270
|
+
if (scope !== "global") {
|
|
271
|
+
const projKey = await projectKey(ctx.worktree ?? null, targetDir);
|
|
272
|
+
if (projKey) await check(projKey);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!results.length) return `Fact with ID "${targetId}" not found.`;
|
|
276
|
+
|
|
277
|
+
return results
|
|
278
|
+
.map((r) => {
|
|
279
|
+
const metaStr = Object.entries(r.meta)
|
|
280
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
281
|
+
.join(", ");
|
|
282
|
+
return `[Store: ${r.key === GLOBAL_KEY ? "Global" : "Project"}]\nTitle: ${r.title}\nBody: ${r.body}\nMetadata: ${
|
|
283
|
+
metaStr ? `<!-- ${metaStr} -->` : "none"
|
|
284
|
+
}`;
|
|
285
|
+
})
|
|
286
|
+
.join("\n\n");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function forgetFacts({ query, scope, force, directory, project }, ctx = {}) {
|
|
290
|
+
const key = requireProjectKey(await resolveScopeKey(scope, { directory, project }, ctx));
|
|
291
|
+
const entries = await readMemory(key);
|
|
292
|
+
|
|
293
|
+
const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
|
|
294
|
+
let indices = [];
|
|
295
|
+
if (rangeMatch) {
|
|
296
|
+
const from = parseInt(rangeMatch[1], 10);
|
|
297
|
+
const to = parseInt(rangeMatch[2], 10);
|
|
298
|
+
if (from > 0 && to >= from && to <= entries.length) {
|
|
299
|
+
for (let i = from - 1; i < to; i++) indices.push(i);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (!indices.length && /^\s*\d+\s*$/.test(String(query))) {
|
|
303
|
+
const num = parseInt(query, 10);
|
|
304
|
+
if (num > 0 && num <= entries.length) indices.push(num - 1);
|
|
305
|
+
}
|
|
306
|
+
if (!indices.length) {
|
|
307
|
+
const q = String(query).toLowerCase();
|
|
308
|
+
indices = entries.reduce((acc, e, i) => (e.toLowerCase().includes(q) ? acc.concat(i) : acc), []);
|
|
309
|
+
}
|
|
310
|
+
if (!indices.length) return "Not found.";
|
|
311
|
+
|
|
312
|
+
const removable = indices.filter((i) => force || !isKeepFact(entries[i]));
|
|
294
313
|
const protectedCount = indices.length - removable.length;
|
|
295
314
|
if (removable.length) {
|
|
296
315
|
const removedBodies = removable.map((i) => factBody(entries[i]) || factText(entries[i]));
|
|
@@ -302,34 +321,34 @@ export async function forgetFacts({ query, scope, force }, ctx = {}) {
|
|
|
302
321
|
await deleteLinksForFacts(await getDatabase(), key, removedBodies);
|
|
303
322
|
} catch {}
|
|
304
323
|
}
|
|
305
|
-
let text = removable.length ? "Memory updated" : "Nothing removed.";
|
|
306
|
-
if (protectedCount) text += ` (${protectedCount} protected fact(s) skipped; use force=true to override)`;
|
|
307
|
-
return text;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export async function updateFactText({ id, newText, title, scope }, ctx = {}) {
|
|
311
|
-
const key = requireProjectKey(await resolveScopeKey(scope, ctx));
|
|
312
|
-
const entries = await readMemory(key);
|
|
313
|
-
const idx = resolveFactIndex(entries, id);
|
|
314
|
-
if (idx === -1) throw new Error(`Fact not found: ${id}`);
|
|
315
|
-
|
|
316
|
-
const p = parseFactEntry(entries[idx]);
|
|
317
|
-
const oldText = p ? p.text : entries[idx];
|
|
318
|
-
const oldBody = factBody(entries[idx]) || oldText;
|
|
319
|
-
|
|
320
|
-
let { finalTitle, finalFact } = splitTitle(newText, title);
|
|
321
|
-
if (!finalTitle) finalTitle = factTitle(entries[idx]) || autoGenerateTitle(finalFact);
|
|
322
|
-
|
|
323
|
-
entries[idx] = formatFactEntry({
|
|
324
|
-
date: p.date,
|
|
325
|
-
time: p.time,
|
|
326
|
-
text: `**${finalTitle}** — ${finalFact}`,
|
|
327
|
-
meta: p.meta,
|
|
328
|
-
});
|
|
329
|
-
await writeMemory(key, entries);
|
|
330
|
-
|
|
331
|
-
let linksUpdated = 0;
|
|
332
|
-
try {
|
|
324
|
+
let text = removable.length ? "Memory updated" : "Nothing removed.";
|
|
325
|
+
if (protectedCount) text += ` (${protectedCount} protected fact(s) skipped; use force=true to override)`;
|
|
326
|
+
return text;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function updateFactText({ id, newText, title, scope, directory, project }, ctx = {}) {
|
|
330
|
+
const key = requireProjectKey(await resolveScopeKey(scope, { directory, project }, ctx));
|
|
331
|
+
const entries = await readMemory(key);
|
|
332
|
+
const idx = resolveFactIndex(entries, id);
|
|
333
|
+
if (idx === -1) throw new Error(`Fact not found: ${id}`);
|
|
334
|
+
|
|
335
|
+
const p = parseFactEntry(entries[idx]);
|
|
336
|
+
const oldText = p ? p.text : entries[idx];
|
|
337
|
+
const oldBody = factBody(entries[idx]) || oldText;
|
|
338
|
+
|
|
339
|
+
let { finalTitle, finalFact } = splitTitle(newText, title);
|
|
340
|
+
if (!finalTitle) finalTitle = factTitle(entries[idx]) || autoGenerateTitle(finalFact);
|
|
341
|
+
|
|
342
|
+
entries[idx] = formatFactEntry({
|
|
343
|
+
date: p.date,
|
|
344
|
+
time: p.time,
|
|
345
|
+
text: `**${finalTitle}** — ${finalFact}`,
|
|
346
|
+
meta: p.meta,
|
|
347
|
+
});
|
|
348
|
+
await writeMemory(key, entries);
|
|
349
|
+
|
|
350
|
+
let linksUpdated = 0;
|
|
351
|
+
try {
|
|
333
352
|
const { getDatabase } = await import("../../db/database.js");
|
|
334
353
|
const db = await getDatabase();
|
|
335
354
|
const linkedRows = await db
|
|
@@ -368,78 +387,79 @@ export async function updateFactText({ id, newText, title, scope }, ctx = {}) {
|
|
|
368
387
|
for (const docId of docIds) await queueDocumentSyncIfNeeded(db, docId);
|
|
369
388
|
}
|
|
370
389
|
} catch {}
|
|
371
|
-
|
|
372
|
-
return `Fact updated${linksUpdated ? `, ${linksUpdated} doc link(s) updated` : ""}`;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
export async function memoryInfo(_args = {}, ctx = {}) {
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
const
|
|
390
|
+
|
|
391
|
+
return `Fact updated${linksUpdated ? `, ${linksUpdated} doc link(s) updated` : ""}`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export async function memoryInfo(_args = {}, ctx = {}) {
|
|
395
|
+
const effectiveDir = extractEffectiveDir(_args, ctx) || process.cwd();
|
|
396
|
+
const dbPath = join(MEMORY_DIR, "storage", "memory.sqlite");
|
|
397
|
+
const activeKey = await projectKey(ctx.worktree ?? null, effectiveDir);
|
|
398
|
+
const globalFile = storeFilePath(GLOBAL_KEY);
|
|
379
399
|
const projectFile = activeKey ? storeFilePath(activeKey) : null;
|
|
380
|
-
|
|
381
|
-
let version = "unknown";
|
|
382
|
-
try {
|
|
383
|
-
version = JSON.parse(await readFile(new URL("../../../package.json", import.meta.url), "utf-8")).version;
|
|
384
|
-
} catch {}
|
|
385
|
-
|
|
386
|
-
const rag = {};
|
|
387
|
-
try {
|
|
388
|
-
const { getDatabase } = await import("../../db/database.js");
|
|
389
|
-
const db = await getDatabase();
|
|
390
|
-
const count = async (table) => {
|
|
391
|
-
const row = await db.prepare(`SELECT COUNT(*) AS c FROM ${table}`).get();
|
|
392
|
-
return row ? row.c : 0;
|
|
393
|
-
};
|
|
394
|
-
rag.documents = await count("documents");
|
|
395
|
-
rag.sections = await count("sections");
|
|
396
|
-
rag.chunks = await count("micro_chunks");
|
|
397
|
-
rag.edges = await count("graph_edges");
|
|
398
|
-
rag.links = await count("knowledge_links");
|
|
399
|
-
} catch (e) {
|
|
400
|
-
rag.error = e.message;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
const stores = await listProjectStores();
|
|
404
|
-
|
|
405
|
-
const identityLines = [];
|
|
406
|
-
try {
|
|
407
|
-
const { getDatabase } = await import("../../db/database.js");
|
|
408
|
-
const { resolveProjectIdentity, listIdentities } = await import("../../identity.js");
|
|
409
|
-
const db = await getDatabase();
|
|
410
|
-
const identity = await resolveProjectIdentity(
|
|
400
|
+
|
|
401
|
+
let version = "unknown";
|
|
402
|
+
try {
|
|
403
|
+
version = JSON.parse(await readFile(new URL("../../../package.json", import.meta.url), "utf-8")).version;
|
|
404
|
+
} catch {}
|
|
405
|
+
|
|
406
|
+
const rag = {};
|
|
407
|
+
try {
|
|
408
|
+
const { getDatabase } = await import("../../db/database.js");
|
|
409
|
+
const db = await getDatabase();
|
|
410
|
+
const count = async (table) => {
|
|
411
|
+
const row = await db.prepare(`SELECT COUNT(*) AS c FROM ${table}`).get();
|
|
412
|
+
return row ? row.c : 0;
|
|
413
|
+
};
|
|
414
|
+
rag.documents = await count("documents");
|
|
415
|
+
rag.sections = await count("sections");
|
|
416
|
+
rag.chunks = await count("micro_chunks");
|
|
417
|
+
rag.edges = await count("graph_edges");
|
|
418
|
+
rag.links = await count("knowledge_links");
|
|
419
|
+
} catch (e) {
|
|
420
|
+
rag.error = e.message;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const stores = await listProjectStores();
|
|
424
|
+
|
|
425
|
+
const identityLines = [];
|
|
426
|
+
try {
|
|
427
|
+
const { getDatabase } = await import("../../db/database.js");
|
|
428
|
+
const { resolveProjectIdentity, listIdentities } = await import("../../identity.js");
|
|
429
|
+
const db = await getDatabase();
|
|
430
|
+
const identity = await resolveProjectIdentity(effectiveDir);
|
|
411
431
|
const all = await listIdentities(db);
|
|
412
432
|
const registered = identity ? all.find((item) => item.key === identity.key) : null;
|
|
413
433
|
identityLines.push(
|
|
414
434
|
`Identity: ${identity ? "git" : "no-git"}` +
|
|
415
|
-
(identity
|
|
416
|
-
? ` | key: ${identity.key} | name: ${identity.name}${
|
|
417
|
-
identity.primaryRemote ? ` | remote: ${identity.primaryRemote}` : ""
|
|
435
|
+
(identity
|
|
436
|
+
? ` | key: ${identity.key} | name: ${identity.name}${
|
|
437
|
+
identity.primaryRemote ? ` | remote: ${identity.primaryRemote}` : ""
|
|
418
438
|
}`
|
|
419
439
|
: ""),
|
|
420
440
|
`Registry: ${identity ? (registered ? "linked" : "unlinked") : "not-applicable"}` +
|
|
421
441
|
(registered ? ` | aliases: ${registered.aliases.length}` : ""),
|
|
422
442
|
`Known identities: ${all.length}`
|
|
423
443
|
);
|
|
424
|
-
} catch (e) {
|
|
425
|
-
identityLines.push(`Identity: unavailable (${e.message})`);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const lines = [
|
|
429
|
-
`Version: ${version}`,
|
|
430
|
-
`MEMORY_DIR: ${MEMORY_DIR}`,
|
|
431
|
-
`SQLite DB: ${dbPath}`,
|
|
432
|
-
`Global store: ${globalFile}`,
|
|
444
|
+
} catch (e) {
|
|
445
|
+
identityLines.push(`Identity: unavailable (${e.message})`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const lines = [
|
|
449
|
+
`Version: ${version}`,
|
|
450
|
+
`MEMORY_DIR: ${MEMORY_DIR}`,
|
|
451
|
+
`SQLite DB: ${dbPath}`,
|
|
452
|
+
`Global store: ${globalFile}`,
|
|
433
453
|
`Project store: ${projectFile || "not applicable (outside Git)"}`,
|
|
434
|
-
`Project stores: ${stores.length}`,
|
|
435
|
-
`Facts (global): ${(await readMemoryRaw(GLOBAL_KEY)).length}`,
|
|
436
|
-
`Facts (project): ${(await readMemoryRaw(activeKey)).length}`,
|
|
437
|
-
...identityLines,
|
|
438
|
-
];
|
|
439
|
-
if (rag.error) lines.push(`RAG: unavailable (${rag.error})`);
|
|
440
|
-
else
|
|
441
|
-
lines.push(
|
|
442
|
-
`RAG: ${rag.documents} doc(s), ${rag.sections} section(s), ${rag.chunks} chunk(s), ${rag.edges} edge(s), ${rag.links} memory link(s)`
|
|
443
|
-
);
|
|
444
|
-
return lines.join("\n");
|
|
445
|
-
}
|
|
454
|
+
`Project stores: ${stores.length}`,
|
|
455
|
+
`Facts (global): ${(await readMemoryRaw(GLOBAL_KEY)).length}`,
|
|
456
|
+
`Facts (project): ${(activeKey ? await readMemoryRaw(activeKey) : []).length}`,
|
|
457
|
+
...identityLines,
|
|
458
|
+
];
|
|
459
|
+
if (rag.error) lines.push(`RAG: unavailable (${rag.error})`);
|
|
460
|
+
else
|
|
461
|
+
lines.push(
|
|
462
|
+
`RAG: ${rag.documents} doc(s), ${rag.sections} section(s), ${rag.chunks} chunk(s), ${rag.edges} edge(s), ${rag.links} memory link(s)`
|
|
463
|
+
);
|
|
464
|
+
return lines.join("\n");
|
|
465
|
+
}
|