@lotargo/memory_plugin 1.2.7 → 1.2.9
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 +152 -63
- package/mcp-server/index.js +31 -8
- package/mcp-server/memory.js +188 -72
- package/mcp-server/prompt_manager.js +118 -11
- package/mcp-server/retrieval/retriever.js +7 -0
- package/mcp-server/setup.js +6 -0
- package/opencode-plugin/index.js +146 -18
- package/package.json +1 -1
package/mcp-server/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { getConfig, updateConfig, resetConfig } from "./config/config_manager.js
|
|
|
5
5
|
import { hybridQuery } from "./retrieval/retriever.js";
|
|
6
6
|
import { getDatabase } from "./db/database.js";
|
|
7
7
|
import { deleteDocument } from "./ingest/pipeline.js";
|
|
8
|
-
import { readMemoryRaw, readMemory, writeMemory, GLOBAL_KEY, projectName } from "./memory.js";
|
|
8
|
+
import { readMemoryRaw, readMemory, writeMemory, GLOBAL_KEY, projectName, projectKey, listProjectStores, migrateLegacyStore, memoryFileName } from "./memory.js";
|
|
9
9
|
import { getCorpusCacheSize, clearCorpusCache } from "./benchmarks/fetch_real_corpus.js";
|
|
10
10
|
import { SMOKE_DOC_IDS } from "./benchmarks/quality_evaluator.js";
|
|
11
11
|
import { getModelStorageInfo, deleteModelCache, listAllCachedModels } from "./ml/model_manager.js";
|
|
@@ -97,9 +97,9 @@ async function getQuickStats() {
|
|
|
97
97
|
|
|
98
98
|
let factCount = 0;
|
|
99
99
|
try {
|
|
100
|
-
const
|
|
100
|
+
const projKey = projectKey(null, null);
|
|
101
101
|
const globalF = await readMemoryRaw(GLOBAL_KEY);
|
|
102
|
-
const projF = await readMemoryRaw(
|
|
102
|
+
const projF = await readMemoryRaw(projKey);
|
|
103
103
|
factCount = (globalF ? globalF.length : 0) + (projF ? projF.length : 0);
|
|
104
104
|
} catch (e) {}
|
|
105
105
|
|
|
@@ -368,7 +368,7 @@ console.log(`\x1b[36m └──${"─".repeat(PANEL_WIDTH - 4)}┘\x1b[0m\n`);
|
|
|
368
368
|
} else if (key.name === "return") {
|
|
369
369
|
cleanup();
|
|
370
370
|
resolve({ action: "select", index: activeIndex, value: allItems[activeIndex].value });
|
|
371
|
-
} else if (key.name === "backspace" || key.name === "escape") {
|
|
371
|
+
} else if (key.name === "backspace" || key.name === "escape" || key.name === "delete") {
|
|
372
372
|
cleanup();
|
|
373
373
|
resolve({ action: "back" });
|
|
374
374
|
}
|
|
@@ -440,7 +440,7 @@ function selectSimpleMenu({ title, subtitle = "", items, initialIndex = 0 }) {
|
|
|
440
440
|
} else if (key.name === "return") {
|
|
441
441
|
cleanup();
|
|
442
442
|
resolve({ action: "select", index, value: items[index].value });
|
|
443
|
-
} else if (key.name === "backspace" || key.name === "escape") {
|
|
443
|
+
} else if (key.name === "backspace" || key.name === "escape" || key.name === "delete") {
|
|
444
444
|
cleanup();
|
|
445
445
|
resolve({ action: "back" });
|
|
446
446
|
}
|
|
@@ -517,7 +517,7 @@ function adjustAlphaMenu(initialAlpha) {
|
|
|
517
517
|
} else if (key.name === "return") {
|
|
518
518
|
cleanup();
|
|
519
519
|
resolve({ action: "save", value: alpha });
|
|
520
|
-
} else if (key.name === "backspace" || key.name === "escape") {
|
|
520
|
+
} else if (key.name === "backspace" || key.name === "escape" || key.name === "delete") {
|
|
521
521
|
cleanup();
|
|
522
522
|
resolve({ action: "cancel" });
|
|
523
523
|
}
|
|
@@ -566,7 +566,7 @@ function readTextInput(promptText, defaultValue = "") {
|
|
|
566
566
|
if (key.name === "return") {
|
|
567
567
|
cleanup();
|
|
568
568
|
resolve({ action: "submit", value: text.trim() });
|
|
569
|
-
} else if (key.name === "backspace") {
|
|
569
|
+
} else if (key.name === "backspace" || key.name === "delete") {
|
|
570
570
|
if (text.length > 0) {
|
|
571
571
|
text = text.slice(0, -1);
|
|
572
572
|
render();
|
|
@@ -610,7 +610,7 @@ function waitForEnter() {
|
|
|
610
610
|
cleanup();
|
|
611
611
|
process.exit(0);
|
|
612
612
|
}
|
|
613
|
-
if (key.name === "return" || key.name === "backspace" || key.name === "escape" || key.name === "space") {
|
|
613
|
+
if (key.name === "return" || key.name === "backspace" || key.name === "escape" || key.name === "delete" || key.name === "space") {
|
|
614
614
|
cleanup();
|
|
615
615
|
resolve();
|
|
616
616
|
}
|
|
@@ -1014,10 +1014,75 @@ export async function runCli() {
|
|
|
1014
1014
|
case "notebook": {
|
|
1015
1015
|
let nbRunning = true;
|
|
1016
1016
|
while (nbRunning) {
|
|
1017
|
-
const
|
|
1017
|
+
const projKey = projectKey(null, null);
|
|
1018
|
+
const projLabel = projectName(null, null);
|
|
1019
|
+
|
|
1020
|
+
async function browseFacts(key, title) {
|
|
1021
|
+
let factRunning = true;
|
|
1022
|
+
while (factRunning) {
|
|
1023
|
+
const rawEntries = await readMemory(key);
|
|
1024
|
+
const factList = await readMemoryRaw(key);
|
|
1025
|
+
|
|
1026
|
+
if (!factList || factList.length === 0) {
|
|
1027
|
+
console.clear();
|
|
1028
|
+
const line = "─".repeat(PANEL_WIDTH - 2);
|
|
1029
|
+
console.log(`\x1b[36m╭${line}╮\x1b[0m`);
|
|
1030
|
+
console.log(`\x1b[36m│\x1b[0m \x1b[1m\x1b[37mNOTEBOOK FACTS: STORE EMPTY\x1b[0m${" ".repeat(PANEL_WIDTH - 30)}\x1b[36m│\x1b[0m`);
|
|
1031
|
+
console.log(`\x1b[36m╰${line}╯\x1b[0m`);
|
|
1032
|
+
console.log(`\n [*] Notebook store [${key}] has no saved facts.\n`);
|
|
1033
|
+
await waitForEnter();
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const file = memoryFileName(key);
|
|
1038
|
+
const factItems = factList.map((fact, idx) => ({
|
|
1039
|
+
label: `${idx + 1}. ${fact}`,
|
|
1040
|
+
value: idx,
|
|
1041
|
+
info: `Select to delete this fact from ${file}`,
|
|
1042
|
+
}));
|
|
1043
|
+
factItems.push({ label: "< Back", value: "back" });
|
|
1044
|
+
|
|
1045
|
+
const factRes = await selectSimpleMenu({
|
|
1046
|
+
title: `NOTEBOOK FACTS [${title}]`,
|
|
1047
|
+
subtitle: `Total facts: ${factList.length}`,
|
|
1048
|
+
items: factItems,
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
if (factRes.action === "back" || factRes.value === "back") {
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const selectedIdx = factRes.value;
|
|
1056
|
+
const selectedFact = factList[selectedIdx];
|
|
1057
|
+
|
|
1058
|
+
const actionRes = await selectSimpleMenu({
|
|
1059
|
+
title: `FACT ACTION`,
|
|
1060
|
+
subtitle: `Fact: "${selectedFact}"`,
|
|
1061
|
+
items: [
|
|
1062
|
+
{ label: "[DELETE] Delete this fact from store", value: "delete", info: "Remove fact permanently" },
|
|
1063
|
+
{ label: "< Cancel / Back", value: "cancel" },
|
|
1064
|
+
],
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
if (actionRes.action === "back" || actionRes.value === "cancel") {
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (actionRes.action === "select" && actionRes.value === "delete") {
|
|
1072
|
+
const updated = [...rawEntries];
|
|
1073
|
+
updated.splice(selectedIdx, 1);
|
|
1074
|
+
await writeMemory(key, updated);
|
|
1075
|
+
console.clear();
|
|
1076
|
+
console.log("\n [OK] Fact deleted successfully.\n");
|
|
1077
|
+
await waitForEnter();
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1018
1082
|
const scopeItems = [
|
|
1019
1083
|
{ label: "Global Memory", value: "global", badge: "global.md", info: "User facts stored across all projects" },
|
|
1020
|
-
{ label: `Project Memory (${
|
|
1084
|
+
{ label: `Project Memory (${projLabel})`, value: "project", badge: memoryFileName(projKey), info: `Facts bound to ${projKey}` },
|
|
1085
|
+
{ label: "Project Stores (All Projects)", value: "projects", info: "List & browse every project memory store; bind legacy stores" },
|
|
1021
1086
|
{ label: "< Back to Main Menu", value: "back" },
|
|
1022
1087
|
];
|
|
1023
1088
|
const scopeRes = await selectSimpleMenu({
|
|
@@ -1031,63 +1096,87 @@ export async function runCli() {
|
|
|
1031
1096
|
break;
|
|
1032
1097
|
}
|
|
1033
1098
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
});
|
|
1064
|
-
|
|
1065
|
-
if (factRes.action === "back" || factRes.value === "back") {
|
|
1066
|
-
factRunning = false;
|
|
1067
|
-
break;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
const selectedIdx = factRes.value;
|
|
1071
|
-
const selectedFact = factList[selectedIdx];
|
|
1099
|
+
if (scopeRes.value === "projects") {
|
|
1100
|
+
let stores = await listProjectStores();
|
|
1101
|
+
let storeRunning = true;
|
|
1102
|
+
while (storeRunning) {
|
|
1103
|
+
if (!stores.length) {
|
|
1104
|
+
console.clear();
|
|
1105
|
+
const line = "─".repeat(PANEL_WIDTH - 2);
|
|
1106
|
+
console.log(`\x1b[36m╭${line}╮\x1b[0m`);
|
|
1107
|
+
console.log(`\x1b[36m│\x1b[0m \x1b[1m\x1b[37mPROJECT STORES: NONE FOUND\x1b[0m${" ".repeat(PANEL_WIDTH - 32)}\x1b[36m│\x1b[0m`);
|
|
1108
|
+
console.log(`\x1b[36m╰${line}╯\x1b[0m`);
|
|
1109
|
+
console.log("\n [*] No project memory stores found.\n");
|
|
1110
|
+
await waitForEnter();
|
|
1111
|
+
storeRunning = false;
|
|
1112
|
+
break;
|
|
1113
|
+
}
|
|
1114
|
+
const storeItems = stores.map((s) => ({
|
|
1115
|
+
label: `${s.basename} (${s.count})`,
|
|
1116
|
+
badge: s.file,
|
|
1117
|
+
hint: s.legacy ? "LEGACY" : "BOUND",
|
|
1118
|
+
info: s.path ? `Bound to: ${s.path}` : `Unbound legacy store. View facts or bind to current dir: ${projKey}`,
|
|
1119
|
+
value: s,
|
|
1120
|
+
}));
|
|
1121
|
+
storeItems.push({ label: "< Back", value: "back" });
|
|
1122
|
+
|
|
1123
|
+
const storeRes = await selectSimpleMenu({
|
|
1124
|
+
title: "PROJECT MEMORY STORES",
|
|
1125
|
+
subtitle: `Total stores: ${stores.length}`,
|
|
1126
|
+
items: storeItems,
|
|
1127
|
+
});
|
|
1072
1128
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
{ label: "[DELETE] Delete this fact from store", value: "delete", info: "Remove fact permanently" },
|
|
1078
|
-
{ label: "< Cancel / Back", value: "cancel" },
|
|
1079
|
-
],
|
|
1080
|
-
});
|
|
1129
|
+
if (storeRes.action === "back" || storeRes.value === "back") {
|
|
1130
|
+
storeRunning = false;
|
|
1131
|
+
break;
|
|
1132
|
+
}
|
|
1081
1133
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1134
|
+
const store = storeRes.value;
|
|
1135
|
+
let actionRunning = true;
|
|
1136
|
+
while (actionRunning) {
|
|
1137
|
+
const actionItems = [
|
|
1138
|
+
{ label: "View facts", value: "view", info: `Browse ${store.count} fact(s) in ${store.file}` },
|
|
1139
|
+
];
|
|
1140
|
+
if (store.legacy) {
|
|
1141
|
+
actionItems.push({
|
|
1142
|
+
label: "[MIGRATE] Bind to current directory",
|
|
1143
|
+
value: "migrate",
|
|
1144
|
+
info: `Rebind '${store.basename}' store from unbound legacy to ${projKey}`,
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
actionItems.push({ label: "< Cancel / Back", value: "cancel" });
|
|
1148
|
+
|
|
1149
|
+
const actRes = await selectSimpleMenu({
|
|
1150
|
+
title: `STORE: ${store.basename}`,
|
|
1151
|
+
subtitle: store.path || "Unbound legacy store",
|
|
1152
|
+
items: actionItems,
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
if (actRes.action === "back" || actRes.value === "cancel") {
|
|
1156
|
+
actionRunning = false;
|
|
1157
|
+
break;
|
|
1158
|
+
}
|
|
1159
|
+
if (actRes.value === "view") {
|
|
1160
|
+
await browseFacts(store.key, store.basename);
|
|
1161
|
+
} else if (actRes.value === "migrate") {
|
|
1162
|
+
const mig = await migrateLegacyStore(store.key, projKey);
|
|
1163
|
+
console.clear();
|
|
1164
|
+
if (mig.ok) {
|
|
1165
|
+
console.log(`\n [OK] Legacy store '${store.basename}' bound to ${mig.key} (${mig.facts} fact(s)) [${mig.file}]\n`);
|
|
1166
|
+
} else {
|
|
1167
|
+
console.log(`\n [*] Could not migrate: ${mig.reason}\n`);
|
|
1168
|
+
}
|
|
1169
|
+
await waitForEnter();
|
|
1170
|
+
stores = await listProjectStores();
|
|
1171
|
+
actionRunning = false;
|
|
1172
|
+
break;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1089
1175
|
}
|
|
1176
|
+
continue;
|
|
1090
1177
|
}
|
|
1178
|
+
|
|
1179
|
+
await browseFacts(scopeRes.value === "global" ? GLOBAL_KEY : projKey, scopeRes.value === "global" ? "GLOBAL" : projLabel);
|
|
1091
1180
|
}
|
|
1092
1181
|
break;
|
|
1093
1182
|
}
|
package/mcp-server/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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, projectName } from "./memory.js";
|
|
5
|
+
import { ensureDir, readMemory, readMemoryRaw, writeMemory, today, MEMORY_DIR, GLOBAL_KEY, scopeKey, projectKey, projectName, canonicalPath, listProjectStores } from "./memory.js";
|
|
6
6
|
|
|
7
7
|
const cliArgs = process.argv.slice(2);
|
|
8
8
|
|
|
@@ -92,13 +92,16 @@ server.registerTool(
|
|
|
92
92
|
server.registerTool(
|
|
93
93
|
"recall",
|
|
94
94
|
{
|
|
95
|
-
description:
|
|
95
|
+
description:
|
|
96
|
+
"Show saved facts with any Agent-linked Knowledge Base documents/lines. " +
|
|
97
|
+
"scope: 'project', 'global', 'all' (default), or 'list_projects'. " +
|
|
98
|
+
"Use project: '<directory path>' with scope 'project'/'all' to read facts of a specific project from any working directory.",
|
|
96
99
|
inputSchema: z.object({
|
|
97
|
-
scope: z.string().default("all").describe("'project', 'global', or '
|
|
100
|
+
scope: z.string().default("all").describe("'project', 'global', 'all', or 'list_projects'"),
|
|
101
|
+
project: z.string().optional().describe("Directory path of the project to read facts from (e.g. 'F:/projects/plugins/memory')"),
|
|
98
102
|
}),
|
|
99
103
|
},
|
|
100
|
-
async ({ scope }) => {
|
|
101
|
-
const project = projectName(null, null);
|
|
104
|
+
async ({ scope, project }) => {
|
|
102
105
|
const { getLinksForFact } = await import("./graph/knowledge_linker.js");
|
|
103
106
|
const results = [];
|
|
104
107
|
|
|
@@ -119,6 +122,26 @@ server.registerTool(
|
|
|
119
122
|
return line;
|
|
120
123
|
};
|
|
121
124
|
|
|
125
|
+
if (scope === "list_projects") {
|
|
126
|
+
const stores = await listProjectStores();
|
|
127
|
+
if (!stores.length) {
|
|
128
|
+
return { content: [{ type: "text", text: "No project memory stores found." }] };
|
|
129
|
+
}
|
|
130
|
+
const lines = stores.map(
|
|
131
|
+
(s, i) => `${i + 1}. ${s.basename} — ${s.count} fact(s) [${s.file}]${s.path ? ` (bound to ${s.path})` : " (unbound legacy store)"}`
|
|
132
|
+
);
|
|
133
|
+
return {
|
|
134
|
+
content: [
|
|
135
|
+
{
|
|
136
|
+
type: "text",
|
|
137
|
+
text: `Project Memory Stores:\n${lines.join("\n")}\n\nUse recall(scope: "project", project: "<path>") to read a specific store.`,
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const target = project ? canonicalPath(project) : projectKey(null, null);
|
|
144
|
+
const label = project ? target : projectName();
|
|
122
145
|
if (scope !== "project") {
|
|
123
146
|
const global = await readMemoryRaw(GLOBAL_KEY);
|
|
124
147
|
if (global.length) {
|
|
@@ -127,11 +150,11 @@ server.registerTool(
|
|
|
127
150
|
}
|
|
128
151
|
}
|
|
129
152
|
if (scope !== "global") {
|
|
130
|
-
const local = await readMemoryRaw(
|
|
153
|
+
const local = await readMemoryRaw(target);
|
|
131
154
|
if (local.length) {
|
|
132
155
|
if (results.length) results.push("");
|
|
133
|
-
results.push(`--- ${
|
|
134
|
-
local.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e,
|
|
156
|
+
results.push(`--- Project: ${label} ---`);
|
|
157
|
+
local.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, target)}`));
|
|
135
158
|
}
|
|
136
159
|
}
|
|
137
160
|
const text = results.length ? results.join("\n") : "Memory is empty.";
|
package/mcp-server/memory.js
CHANGED
|
@@ -1,72 +1,188 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
2
|
-
import { existsSync } from "fs";
|
|
3
|
-
import { join, basename } from "path";
|
|
4
|
-
import { homedir } from "os";
|
|
5
|
-
|
|
6
|
-
function resolveMemoryDir() {
|
|
7
|
-
if (process.env.MEMORY_DIR) return process.env.MEMORY_DIR;
|
|
8
|
-
if (process.env.OPENCODE_CONFIG_DIR) return join(process.env.OPENCODE_CONFIG_DIR, "memory");
|
|
9
|
-
|
|
10
|
-
const legacyDir = join(homedir(), ".config", "opencode", "memory");
|
|
11
|
-
if (existsSync(legacyDir)) {
|
|
12
|
-
return legacyDir;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
if (process.platform === "win32") {
|
|
16
|
-
const appData = process.env.LOCALAPPDATA || process.env.APPDATA || join(homedir(), "AppData", "Local");
|
|
17
|
-
return join(appData, "opencode", "memory");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
21
|
-
return join(configHome, "opencode", "memory");
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export const MEMORY_DIR = resolveMemoryDir();
|
|
25
|
-
export const GLOBAL_KEY = "global";
|
|
26
|
-
|
|
27
|
-
export async function ensureDir() {
|
|
28
|
-
if (!existsSync(MEMORY_DIR)) await mkdir(MEMORY_DIR, { recursive: true });
|
|
29
|
-
const storageDir = join(MEMORY_DIR, "storage");
|
|
30
|
-
const blobsDir = join(storageDir, "blobs");
|
|
31
|
-
const modelsDir = join(storageDir, "models");
|
|
32
|
-
const exportsDir = join(MEMORY_DIR, "exports");
|
|
33
|
-
if (!existsSync(blobsDir)) await mkdir(blobsDir, { recursive: true });
|
|
34
|
-
if (!existsSync(modelsDir)) await mkdir(modelsDir, { recursive: true });
|
|
35
|
-
if (!existsSync(exportsDir)) await mkdir(exportsDir, { recursive: true });
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
1
|
+
import { readFile, writeFile, mkdir, unlink, readdir } from "fs/promises";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join, basename, resolve } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
|
|
6
|
+
function resolveMemoryDir() {
|
|
7
|
+
if (process.env.MEMORY_DIR) return process.env.MEMORY_DIR;
|
|
8
|
+
if (process.env.OPENCODE_CONFIG_DIR) return join(process.env.OPENCODE_CONFIG_DIR, "memory");
|
|
9
|
+
|
|
10
|
+
const legacyDir = join(homedir(), ".config", "opencode", "memory");
|
|
11
|
+
if (existsSync(legacyDir)) {
|
|
12
|
+
return legacyDir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (process.platform === "win32") {
|
|
16
|
+
const appData = process.env.LOCALAPPDATA || process.env.APPDATA || join(homedir(), "AppData", "Local");
|
|
17
|
+
return join(appData, "opencode", "memory");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
21
|
+
return join(configHome, "opencode", "memory");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const MEMORY_DIR = resolveMemoryDir();
|
|
25
|
+
export const GLOBAL_KEY = "global";
|
|
26
|
+
|
|
27
|
+
export async function ensureDir() {
|
|
28
|
+
if (!existsSync(MEMORY_DIR)) await mkdir(MEMORY_DIR, { recursive: true });
|
|
29
|
+
const storageDir = join(MEMORY_DIR, "storage");
|
|
30
|
+
const blobsDir = join(storageDir, "blobs");
|
|
31
|
+
const modelsDir = join(storageDir, "models");
|
|
32
|
+
const exportsDir = join(MEMORY_DIR, "exports");
|
|
33
|
+
if (!existsSync(blobsDir)) await mkdir(blobsDir, { recursive: true });
|
|
34
|
+
if (!existsSync(modelsDir)) await mkdir(modelsDir, { recursive: true });
|
|
35
|
+
if (!existsSync(exportsDir)) await mkdir(exportsDir, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Canonical absolute path key: forward slashes, lowercase drive letter on win32.
|
|
39
|
+
export function canonicalPath(dir) {
|
|
40
|
+
let p = resolve(dir || process.cwd());
|
|
41
|
+
if (process.platform === "win32") {
|
|
42
|
+
p = p.replace(/\\/g, "/").replace(/^([a-zA-Z]):/, (_, d) => `${d.toLowerCase()}:`);
|
|
43
|
+
}
|
|
44
|
+
return p;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Project store key = full directory path. This removes basename collisions and
|
|
48
|
+
// binds each store to the real project location.
|
|
49
|
+
export function projectKey(worktree, directory) {
|
|
50
|
+
return canonicalPath(worktree || directory);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Display label for a project (basename of the resolved directory).
|
|
54
|
+
export function projectName(worktree, directory) {
|
|
55
|
+
const dir = worktree || directory || process.cwd();
|
|
56
|
+
return dir ? basename(resolve(dir)) : "default";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function scopeKey(scope, worktree, directory) {
|
|
60
|
+
return scope === "global" ? GLOBAL_KEY : projectKey(worktree, directory);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function slugify(key) {
|
|
64
|
+
return key.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function memoryPath(key) {
|
|
68
|
+
return join(MEMORY_DIR, `${slugify(key)}.md`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function memoryFileName(key) {
|
|
72
|
+
return basename(memoryPath(key));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseMeta(content) {
|
|
76
|
+
const m = content.match(/<!-- path: (.+?) -->/);
|
|
77
|
+
return { path: m ? m[1].trim() : null };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isSimpleKey(key) {
|
|
81
|
+
return /^[a-zA-Z0-9_-]+$/.test(key);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Lazy migration: when reading a project path store that doesn't exist yet but a
|
|
85
|
+
// legacy <basename>.md store (without path binding) does, claim it under the path.
|
|
86
|
+
async function maybeMigrateLegacy(key) {
|
|
87
|
+
if (key === GLOBAL_KEY || isSimpleKey(key)) return null;
|
|
88
|
+
const legacyBasename = basename(key);
|
|
89
|
+
if (!legacyBasename) return null;
|
|
90
|
+
const legacyFp = join(MEMORY_DIR, `${legacyBasename}.md`);
|
|
91
|
+
if (slugify(key) === legacyBasename || !existsSync(legacyFp)) return null;
|
|
92
|
+
const content = await readFile(legacyFp, "utf-8");
|
|
93
|
+
if (parseMeta(content).path) return null; // already bound to another project
|
|
94
|
+
// Collision guard: a different path with the same basename is already bound,
|
|
95
|
+
// so this legacy store is ambiguous and must not be silently claimed.
|
|
96
|
+
const files = await readdir(MEMORY_DIR).catch(() => []);
|
|
97
|
+
for (const f of files) {
|
|
98
|
+
if (!f.endsWith(".md") || f === `${legacyBasename}.md` || f === `${GLOBAL_KEY}.md`) continue;
|
|
99
|
+
try {
|
|
100
|
+
const other = parseMeta(await readFile(join(MEMORY_DIR, f), "utf-8")).path;
|
|
101
|
+
if (other && basename(other) === legacyBasename) return null;
|
|
102
|
+
} catch (e) {}
|
|
103
|
+
}
|
|
104
|
+
const facts = content.split("\n").filter((l) => l.startsWith("- ["));
|
|
105
|
+
await writeMemory(key, facts);
|
|
106
|
+
try {
|
|
107
|
+
await unlink(legacyFp);
|
|
108
|
+
} catch (e) {}
|
|
109
|
+
return facts;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function readMemory(key) {
|
|
113
|
+
const fp = memoryPath(key);
|
|
114
|
+
if (existsSync(fp)) {
|
|
115
|
+
const content = await readFile(fp, "utf-8");
|
|
116
|
+
return content.split("\n").filter((l) => l.startsWith("- ["));
|
|
117
|
+
}
|
|
118
|
+
const migrated = await maybeMigrateLegacy(key);
|
|
119
|
+
return migrated || [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function readMemoryRaw(key) {
|
|
123
|
+
return (await readMemory(key)).map((e) => e.slice(2));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function writeMemory(key, entries) {
|
|
127
|
+
const lines = [];
|
|
128
|
+
if (key === GLOBAL_KEY) {
|
|
129
|
+
lines.push("# Global Memory", "");
|
|
130
|
+
} else {
|
|
131
|
+
lines.push(`# Memory: ${basename(key) || key}`, "");
|
|
132
|
+
if (!isSimpleKey(key)) {
|
|
133
|
+
lines.push(`<!-- path: ${key} -->`, "");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const content = lines.join("\n") + "\n" + (entries.length ? entries.join("\n") + "\n" : "");
|
|
137
|
+
await writeFile(memoryPath(key), content);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function listProjectStores() {
|
|
141
|
+
const stores = [];
|
|
142
|
+
const files = await readdir(MEMORY_DIR).catch(() => []);
|
|
143
|
+
for (const f of files) {
|
|
144
|
+
if (!f.endsWith(".md") || f === `${GLOBAL_KEY}.md`) continue;
|
|
145
|
+
const fp = join(MEMORY_DIR, f);
|
|
146
|
+
let content = "";
|
|
147
|
+
try {
|
|
148
|
+
content = await readFile(fp, "utf-8");
|
|
149
|
+
} catch (e) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const facts = content.split("\n").filter((l) => l.startsWith("- ["));
|
|
153
|
+
const meta = parseMeta(content);
|
|
154
|
+
const key = meta.path || f.slice(0, -3);
|
|
155
|
+
stores.push({
|
|
156
|
+
key,
|
|
157
|
+
path: meta.path,
|
|
158
|
+
basename: basename(meta.path || key) || key,
|
|
159
|
+
file: f,
|
|
160
|
+
count: facts.length,
|
|
161
|
+
legacy: !meta.path,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
stores.sort((a, b) => a.basename.localeCompare(b.basename));
|
|
165
|
+
return stores;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Bind an unbound legacy store (e.g. "comfy-meta-viewer") to a directory path.
|
|
169
|
+
export async function migrateLegacyStore(legacyKey, targetDir) {
|
|
170
|
+
const legacyFp = join(MEMORY_DIR, `${legacyKey.replace(/[^a-zA-Z0-9_-]/g, "_")}.md`);
|
|
171
|
+
if (!existsSync(legacyFp)) return { ok: false, reason: "not_found", key: legacyKey };
|
|
172
|
+
const content = await readFile(legacyFp, "utf-8");
|
|
173
|
+
if (parseMeta(content).path) return { ok: false, reason: "already_bound", key: legacyKey };
|
|
174
|
+
const targetKey = projectKey(targetDir, null);
|
|
175
|
+
const facts = content.split("\n").filter((l) => l.startsWith("- ["));
|
|
176
|
+
await writeMemory(targetKey, facts);
|
|
177
|
+
try {
|
|
178
|
+
await unlink(legacyFp);
|
|
179
|
+
} catch (e) {}
|
|
180
|
+
return { ok: true, key: targetKey, file: memoryPath(targetKey), facts: facts.length };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function today() {
|
|
184
|
+
const d = new Date();
|
|
185
|
+
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
186
|
+
const time = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
187
|
+
return `${date} ${time}`;
|
|
188
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, unlink } from "fs/promises";
|
|
1
|
+
import { readFile, writeFile, mkdir, unlink, rename, copyFile } from "fs/promises";
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { homedir } from "os";
|
|
5
|
+
import { createHash } from "crypto";
|
|
5
6
|
|
|
6
7
|
const START_MARKER = "<!-- START MEMORY AGENT PROMPT -->";
|
|
7
8
|
const END_MARKER = "<!-- END MEMORY AGENT PROMPT -->";
|
|
@@ -13,24 +14,90 @@ export const PROMPT_BLOCK = `${START_MARKER}
|
|
|
13
14
|
3. SIGNAL FILTER: Save only high-signal facts (name, language, roles, constraints, tech stack preferences, architecture decisions, conventions). Translate facts into clear, concise English when saving. Do NOT save transient details or one-off conversation turns.
|
|
14
15
|
${END_MARKER}`;
|
|
15
16
|
|
|
17
|
+
// Plugin-owned files live here so we never destroy user-owned config content.
|
|
18
|
+
const AGENT_CONFIG_DIR = join(homedir(), ".config", "memory-agent");
|
|
19
|
+
export const PROMPT_FILE = join(AGENT_CONFIG_DIR, "prompt.md");
|
|
20
|
+
const BACKUP_DIR = join(AGENT_CONFIG_DIR, "backups");
|
|
21
|
+
const STATE_FILE = join(AGENT_CONFIG_DIR, "prompt-state.json");
|
|
22
|
+
|
|
23
|
+
function sha256(content) {
|
|
24
|
+
return createHash("sha256").update(content).digest("hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function atomicWrite(filePath, content) {
|
|
28
|
+
const tmp = `${filePath}.memory-tmp-${process.pid}`;
|
|
29
|
+
await writeFile(tmp, content, "utf-8");
|
|
30
|
+
await rename(tmp, filePath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function backupFile(filePath) {
|
|
34
|
+
await mkdir(BACKUP_DIR, { recursive: true });
|
|
35
|
+
const name = filePath.split(/[\\/]/).pop() || "config";
|
|
36
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
37
|
+
const dest = join(BACKUP_DIR, `${name}.${stamp}.bak`);
|
|
38
|
+
await copyFile(filePath, dest);
|
|
39
|
+
return dest;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function loadState() {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(await readFile(STATE_FILE, "utf-8"));
|
|
45
|
+
} catch {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function saveState(state) {
|
|
51
|
+
await mkdir(AGENT_CONFIG_DIR, { recursive: true });
|
|
52
|
+
await atomicWrite(STATE_FILE, JSON.stringify(state, null, 2) + "\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
16
55
|
export function getGlobalPromptTargets() {
|
|
17
56
|
const home = homedir();
|
|
18
57
|
return [
|
|
19
58
|
{
|
|
20
59
|
name: "Antigravity",
|
|
21
60
|
filePath: join(home, ".gemini", "config", "AGENTS.md"),
|
|
61
|
+
// Antigravity resolves `@` imports only in GEMINI.md, not reliably in AGENTS.md
|
|
62
|
+
includeSupported: false,
|
|
22
63
|
},
|
|
23
64
|
{
|
|
24
65
|
name: "Codex",
|
|
25
66
|
filePath: join(home, ".codex", "AGENTS.md"),
|
|
67
|
+
// AGENTS.md standard has no import syntax
|
|
68
|
+
includeSupported: false,
|
|
26
69
|
},
|
|
27
70
|
{
|
|
28
71
|
name: "Claude Code",
|
|
29
72
|
filePath: join(home, ".claude", "CLAUDE.md"),
|
|
73
|
+
// @path imports are supported and trusted in user-scope CLAUDE.md
|
|
74
|
+
includeSupported: true,
|
|
30
75
|
},
|
|
31
76
|
];
|
|
32
77
|
}
|
|
33
78
|
|
|
79
|
+
// Ensure the plugin-owned prompt file exists and is up to date with PROMPT_BLOCK.
|
|
80
|
+
// This file is referenced via `@` includes; updating it never touches user configs.
|
|
81
|
+
export async function syncPromptFile() {
|
|
82
|
+
await mkdir(AGENT_CONFIG_DIR, { recursive: true });
|
|
83
|
+
const expected = `${PROMPT_BLOCK}\n`;
|
|
84
|
+
const current = existsSync(PROMPT_FILE) ? await readFile(PROMPT_FILE, "utf-8") : "";
|
|
85
|
+
if (current !== expected) {
|
|
86
|
+
await atomicWrite(PROMPT_FILE, expected);
|
|
87
|
+
}
|
|
88
|
+
return PROMPT_FILE;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function toIncludePath(filePath) {
|
|
92
|
+
return filePath.replace(/\\/g, "/");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildIncludeBlock(promptFile) {
|
|
96
|
+
return `${START_MARKER}
|
|
97
|
+
@${toIncludePath(promptFile)}
|
|
98
|
+
${END_MARKER}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
34
101
|
function stripPromptBlock(content) {
|
|
35
102
|
const startIndex = content.indexOf(START_MARKER);
|
|
36
103
|
const endIndex = content.indexOf(END_MARKER);
|
|
@@ -44,7 +111,9 @@ function stripPromptBlock(content) {
|
|
|
44
111
|
}
|
|
45
112
|
|
|
46
113
|
export async function enableGlobalPrompt() {
|
|
114
|
+
const promptFile = await syncPromptFile();
|
|
47
115
|
const targets = getGlobalPromptTargets();
|
|
116
|
+
const state = await loadState();
|
|
48
117
|
const results = [];
|
|
49
118
|
|
|
50
119
|
for (const target of targets) {
|
|
@@ -54,26 +123,48 @@ export async function enableGlobalPrompt() {
|
|
|
54
123
|
await mkdir(parentDir, { recursive: true });
|
|
55
124
|
}
|
|
56
125
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
126
|
+
const existed = existsSync(target.filePath);
|
|
127
|
+
const existing = existed ? await readFile(target.filePath, "utf-8") : "";
|
|
128
|
+
const clean = stripPromptBlock(existing);
|
|
129
|
+
|
|
130
|
+
const block = target.includeSupported
|
|
131
|
+
? buildIncludeBlock(promptFile)
|
|
132
|
+
: PROMPT_BLOCK;
|
|
133
|
+
const updated = clean ? `${clean}\n\n${block}\n` : `${block}\n`;
|
|
134
|
+
|
|
135
|
+
const key = target.filePath;
|
|
136
|
+
const prev = state[key];
|
|
137
|
+
|
|
138
|
+
if (existed && existing === updated) {
|
|
139
|
+
results.push({ name: target.name, filePath: target.filePath, status: "up_to_date" });
|
|
140
|
+
continue;
|
|
60
141
|
}
|
|
61
142
|
|
|
62
|
-
|
|
63
|
-
|
|
143
|
+
// Hash guard: if the user modified the file since we last wrote it,
|
|
144
|
+
// back it up before overwriting their edits.
|
|
145
|
+
if (existed && prev && prev.hash && prev.hash !== sha256(existing)) {
|
|
146
|
+
await backupFile(target.filePath);
|
|
147
|
+
}
|
|
64
148
|
|
|
65
|
-
await
|
|
66
|
-
|
|
149
|
+
await atomicWrite(target.filePath, updated);
|
|
150
|
+
state[key] = { hash: sha256(updated), existedBefore: existed };
|
|
151
|
+
results.push({
|
|
152
|
+
name: target.name,
|
|
153
|
+
filePath: target.filePath,
|
|
154
|
+
status: existed ? "enabled" : "created_new_file",
|
|
155
|
+
});
|
|
67
156
|
} catch (err) {
|
|
68
157
|
results.push({ name: target.name, filePath: target.filePath, status: "failed", error: err.message });
|
|
69
158
|
}
|
|
70
159
|
}
|
|
71
160
|
|
|
161
|
+
await saveState(state);
|
|
72
162
|
return results;
|
|
73
163
|
}
|
|
74
164
|
|
|
75
165
|
export async function disableGlobalPrompt() {
|
|
76
166
|
const targets = getGlobalPromptTargets();
|
|
167
|
+
const state = await loadState();
|
|
77
168
|
const results = [];
|
|
78
169
|
|
|
79
170
|
for (const target of targets) {
|
|
@@ -84,20 +175,36 @@ export async function disableGlobalPrompt() {
|
|
|
84
175
|
}
|
|
85
176
|
|
|
86
177
|
const existing = await readFile(target.filePath, "utf-8");
|
|
178
|
+
if (!existing.includes(START_MARKER)) {
|
|
179
|
+
results.push({ name: target.name, filePath: target.filePath, status: "skipped" });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
87
183
|
const clean = stripPromptBlock(existing);
|
|
184
|
+
const key = target.filePath;
|
|
185
|
+
const prev = state[key];
|
|
88
186
|
|
|
89
187
|
if (clean.length === 0) {
|
|
90
|
-
|
|
91
|
-
|
|
188
|
+
// Only delete the file if we created it; otherwise leave the user's file in place.
|
|
189
|
+
if (prev && prev.existedBefore === false) {
|
|
190
|
+
await unlink(target.filePath);
|
|
191
|
+
results.push({ name: target.name, filePath: target.filePath, status: "removed_file" });
|
|
192
|
+
} else {
|
|
193
|
+
await atomicWrite(target.filePath, "");
|
|
194
|
+
results.push({ name: target.name, filePath: target.filePath, status: "disabled" });
|
|
195
|
+
}
|
|
92
196
|
} else {
|
|
93
|
-
await
|
|
197
|
+
await atomicWrite(target.filePath, clean + "\n");
|
|
94
198
|
results.push({ name: target.name, filePath: target.filePath, status: "disabled" });
|
|
95
199
|
}
|
|
200
|
+
|
|
201
|
+
delete state[key];
|
|
96
202
|
} catch (err) {
|
|
97
203
|
results.push({ name: target.name, filePath: target.filePath, status: "failed", error: err.message });
|
|
98
204
|
}
|
|
99
205
|
}
|
|
100
206
|
|
|
207
|
+
await saveState(state);
|
|
101
208
|
return results;
|
|
102
209
|
}
|
|
103
210
|
|
|
@@ -203,10 +203,17 @@ export async function hybridQuery({
|
|
|
203
203
|
rerankerModel = null,
|
|
204
204
|
rerankerEnabled = null,
|
|
205
205
|
instruction = null,
|
|
206
|
+
generateEmbeddings = true,
|
|
206
207
|
}) {
|
|
207
208
|
const db = customDb || getDatabase();
|
|
208
209
|
const activeConfig = getConfig();
|
|
209
210
|
|
|
211
|
+
// If embeddings are disabled (e.g. fast/offline test mode or model not cached),
|
|
212
|
+
// fall back to pure lexical search instead of attempting to load the model.
|
|
213
|
+
if (generateEmbeddings === false) {
|
|
214
|
+
fusionAlgorithm = "lexical_only";
|
|
215
|
+
}
|
|
216
|
+
|
|
210
217
|
const algo = fusionAlgorithm || activeConfig.fusionAlgorithm || "rsf";
|
|
211
218
|
const alphaWeight = alpha !== null && alpha !== undefined ? alpha : (activeConfig.alpha ?? 0.5);
|
|
212
219
|
const embModel = embeddingModel || activeConfig.embeddingModel || "Xenova/multilingual-e5-small";
|
package/mcp-server/setup.js
CHANGED
|
@@ -176,6 +176,12 @@ export async function runSetup() {
|
|
|
176
176
|
promptResults.forEach((r) => {
|
|
177
177
|
if (r.status === "enabled") {
|
|
178
178
|
console.log(` [OK] ${r.name}: enabled global prompt instruction in ${r.filePath}`);
|
|
179
|
+
} else if (r.status === "created_new_file") {
|
|
180
|
+
console.log(` [OK] ${r.name}: created ${r.filePath} with global prompt instruction`);
|
|
181
|
+
} else if (r.status === "up_to_date") {
|
|
182
|
+
console.log(` [OK] ${r.name}: global prompt already up to date (${r.filePath})`);
|
|
183
|
+
} else if (r.status === "failed") {
|
|
184
|
+
console.log(` [WARN] ${r.name}: failed to enable global prompt (${r.error})`);
|
|
179
185
|
}
|
|
180
186
|
});
|
|
181
187
|
} catch (err) {
|
package/opencode-plugin/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const { readFile, writeFile, mkdir, cp, readdir } = await import("fs/promises");
|
|
1
|
+
const { readFile, writeFile, mkdir, cp, readdir, unlink } = await import("fs/promises");
|
|
2
2
|
const { existsSync } = await import("fs");
|
|
3
|
-
const { join, basename, dirname } = await import("path");
|
|
3
|
+
const { join, basename, dirname, resolve } = await import("path");
|
|
4
4
|
const { homedir } = await import("os");
|
|
5
5
|
const { fileURLToPath } = await import("url");
|
|
6
6
|
|
|
@@ -28,24 +28,86 @@ async function ensureDir() {
|
|
|
28
28
|
} catch (e) {}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function canonicalPath(dir) {
|
|
32
|
+
let p = resolve(dir || process.cwd());
|
|
33
|
+
if (process.platform === "win32") {
|
|
34
|
+
p = p.replace(/\\/g, "/").replace(/^([a-zA-Z]):/, (_, d) => `${d.toLowerCase()}:`);
|
|
35
|
+
}
|
|
36
|
+
return p;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Display label for a project (basename of the resolved directory).
|
|
31
40
|
function projectName(worktree, directory) {
|
|
32
41
|
const dir = worktree || directory;
|
|
33
|
-
return dir ? basename(dir) : "default";
|
|
42
|
+
return dir ? basename(resolve(dir)) : "default";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Project store key = full directory path (removes basename collisions).
|
|
46
|
+
function projectKey(worktree, directory) {
|
|
47
|
+
return canonicalPath(worktree || directory);
|
|
34
48
|
}
|
|
35
49
|
|
|
36
50
|
function scopeKey(scope, worktree, directory) {
|
|
37
|
-
return scope === "global" ? GLOBAL_KEY :
|
|
51
|
+
return scope === "global" ? GLOBAL_KEY : projectKey(worktree, directory);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function slugify(key) {
|
|
55
|
+
return key.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
38
56
|
}
|
|
39
57
|
|
|
40
58
|
function memoryPath(key) {
|
|
41
|
-
return join(MEMORY_DIR, `${key
|
|
59
|
+
return join(MEMORY_DIR, `${slugify(key)}.md`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function memoryFileName(key) {
|
|
63
|
+
return basename(memoryPath(key));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseMeta(content) {
|
|
67
|
+
const m = content.match(/<!-- path: (.+?) -->/);
|
|
68
|
+
return { path: m ? m[1].trim() : null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isSimpleKey(key) {
|
|
72
|
+
return /^[a-zA-Z0-9_-]+$/.test(key);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Lazy migration: when reading a project path store that doesn't exist yet but a
|
|
76
|
+
// legacy <basename>.md store (without path binding) does, claim it under the path.
|
|
77
|
+
async function maybeMigrateLegacy(key) {
|
|
78
|
+
if (key === GLOBAL_KEY || isSimpleKey(key)) return null;
|
|
79
|
+
const legacyBasename = basename(key);
|
|
80
|
+
if (!legacyBasename) return null;
|
|
81
|
+
const legacyFp = join(MEMORY_DIR, `${legacyBasename}.md`);
|
|
82
|
+
if (slugify(key) === legacyBasename || !existsSync(legacyFp)) return null;
|
|
83
|
+
const content = await readFile(legacyFp, "utf-8");
|
|
84
|
+
if (parseMeta(content).path) return null; // already bound to another project
|
|
85
|
+
// Collision guard: a different path with the same basename is already bound,
|
|
86
|
+
// so this legacy store is ambiguous and must not be silently claimed.
|
|
87
|
+
const files = await readdir(MEMORY_DIR).catch(() => []);
|
|
88
|
+
for (const f of files) {
|
|
89
|
+
if (!f.endsWith(".md") || f === `${legacyBasename}.md` || f === `${GLOBAL_KEY}.md`) continue;
|
|
90
|
+
try {
|
|
91
|
+
const other = parseMeta(await readFile(join(MEMORY_DIR, f), "utf-8")).path;
|
|
92
|
+
if (other && basename(other) === legacyBasename) return null;
|
|
93
|
+
} catch (e) {}
|
|
94
|
+
}
|
|
95
|
+
const facts = content.split("\n").filter((l) => l.startsWith("- ["));
|
|
96
|
+
await writeMemory(key, facts);
|
|
97
|
+
try {
|
|
98
|
+
await unlink(legacyFp);
|
|
99
|
+
} catch (e) {}
|
|
100
|
+
return facts;
|
|
42
101
|
}
|
|
43
102
|
|
|
44
103
|
async function readMemory(key) {
|
|
45
104
|
const fp = memoryPath(key);
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
105
|
+
if (existsSync(fp)) {
|
|
106
|
+
const content = await readFile(fp, "utf-8");
|
|
107
|
+
return content.split("\n").filter((l) => l.startsWith("- ["));
|
|
108
|
+
}
|
|
109
|
+
const migrated = await maybeMigrateLegacy(key);
|
|
110
|
+
return migrated || [];
|
|
49
111
|
}
|
|
50
112
|
|
|
51
113
|
async function readMemoryRaw(key) {
|
|
@@ -53,8 +115,58 @@ async function readMemoryRaw(key) {
|
|
|
53
115
|
}
|
|
54
116
|
|
|
55
117
|
async function writeMemory(key, entries) {
|
|
56
|
-
const
|
|
57
|
-
|
|
118
|
+
const lines = [];
|
|
119
|
+
if (key === GLOBAL_KEY) {
|
|
120
|
+
lines.push("# Global Memory", "");
|
|
121
|
+
} else {
|
|
122
|
+
lines.push(`# Memory: ${basename(key) || key}`, "");
|
|
123
|
+
if (!isSimpleKey(key)) {
|
|
124
|
+
lines.push(`<!-- path: ${key} -->`, "");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const content = lines.join("\n") + "\n" + (entries.length ? entries.join("\n") + "\n" : "");
|
|
128
|
+
await writeFile(memoryPath(key), content);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function listProjectStores() {
|
|
132
|
+
const stores = [];
|
|
133
|
+
const files = await readdir(MEMORY_DIR).catch(() => []);
|
|
134
|
+
for (const f of files) {
|
|
135
|
+
if (!f.endsWith(".md") || f === `${GLOBAL_KEY}.md`) continue;
|
|
136
|
+
let content = "";
|
|
137
|
+
try {
|
|
138
|
+
content = await readFile(join(MEMORY_DIR, f), "utf-8");
|
|
139
|
+
} catch (e) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const facts = content.split("\n").filter((l) => l.startsWith("- ["));
|
|
143
|
+
const meta = parseMeta(content);
|
|
144
|
+
const key = meta.path || f.slice(0, -3);
|
|
145
|
+
stores.push({
|
|
146
|
+
key,
|
|
147
|
+
path: meta.path,
|
|
148
|
+
basename: basename(meta.path || key) || key,
|
|
149
|
+
file: f,
|
|
150
|
+
count: facts.length,
|
|
151
|
+
legacy: !meta.path,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
stores.sort((a, b) => a.basename.localeCompare(b.basename));
|
|
155
|
+
return stores;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function migrateLegacyStore(legacyKey, targetDir) {
|
|
159
|
+
const legacyFp = join(MEMORY_DIR, `${legacyKey.replace(/[^a-zA-Z0-9_-]/g, "_")}.md`);
|
|
160
|
+
if (!existsSync(legacyFp)) return { ok: false, reason: "not_found", key: legacyKey };
|
|
161
|
+
const content = await readFile(legacyFp, "utf-8");
|
|
162
|
+
if (parseMeta(content).path) return { ok: false, reason: "already_bound", key: legacyKey };
|
|
163
|
+
const targetKey = projectKey(targetDir, null);
|
|
164
|
+
const facts = content.split("\n").filter((l) => l.startsWith("- ["));
|
|
165
|
+
await writeMemory(targetKey, facts);
|
|
166
|
+
try {
|
|
167
|
+
await unlink(legacyFp);
|
|
168
|
+
} catch (e) {}
|
|
169
|
+
return { ok: true, key: targetKey, file: memoryPath(targetKey), facts: facts.length };
|
|
58
170
|
}
|
|
59
171
|
|
|
60
172
|
function today() {
|
|
@@ -128,7 +240,7 @@ const MCP_SERVERS = [
|
|
|
128
240
|
|
|
129
241
|
export const MemoryPlugin = async ({ directory, worktree, client }) => {
|
|
130
242
|
await ensureDir();
|
|
131
|
-
const projectKey =
|
|
243
|
+
const projectKey = scopeKey("project", worktree, directory);
|
|
132
244
|
|
|
133
245
|
return {
|
|
134
246
|
"experimental.chat.messages.transform": async (_input, output) => {
|
|
@@ -231,16 +343,19 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
|
|
|
231
343
|
},
|
|
232
344
|
},
|
|
233
345
|
"recall": {
|
|
234
|
-
description:
|
|
346
|
+
description:
|
|
347
|
+
"Show saved facts with any Agent-linked Knowledge Base documents/lines. " +
|
|
348
|
+
"scope: 'project', 'global', 'all' (default), or 'list_projects'. " +
|
|
349
|
+
"Use project: '<directory path>' to read facts of a specific project from any working directory.",
|
|
235
350
|
args: {
|
|
236
351
|
scope: {
|
|
237
352
|
type: "string",
|
|
238
|
-
description: "project, global
|
|
353
|
+
description: "project, global, all (по умолчанию) или list_projects",
|
|
239
354
|
default: "all",
|
|
240
355
|
},
|
|
356
|
+
project: { type: "string", description: "Directory path of the project to read facts from (e.g. 'F:/projects/plugins/memory')" },
|
|
241
357
|
},
|
|
242
|
-
async execute({ scope }, { worktree, directory }) {
|
|
243
|
-
const project = projectName(worktree, directory);
|
|
358
|
+
async execute({ scope, project }, { worktree, directory }) {
|
|
244
359
|
const results = [];
|
|
245
360
|
|
|
246
361
|
let getLinksForFact;
|
|
@@ -268,6 +383,19 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
|
|
|
268
383
|
return line;
|
|
269
384
|
};
|
|
270
385
|
|
|
386
|
+
if (scope === "list_projects") {
|
|
387
|
+
return listProjectStores().then((stores) => {
|
|
388
|
+
if (!stores.length) return "No project memory stores found.";
|
|
389
|
+
const lines = stores.map(
|
|
390
|
+
(s, i) => `${i + 1}. ${s.basename} — ${s.count} fact(s) [${s.file}]${s.path ? ` (bound to ${s.path})` : " (unbound legacy store)"}`
|
|
391
|
+
);
|
|
392
|
+
return `Project Memory Stores:\n${lines.join("\n")}\n\nUse recall(scope: "project", project: "<path>") to read a specific store.`;
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const target = project ? canonicalPath(project) : projectKey(worktree, directory);
|
|
397
|
+
const label = project ? target : projectName(worktree, directory);
|
|
398
|
+
|
|
271
399
|
if (scope !== "project") {
|
|
272
400
|
const global = await readMemoryRaw(GLOBAL_KEY);
|
|
273
401
|
if (global.length) {
|
|
@@ -276,11 +404,11 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
|
|
|
276
404
|
}
|
|
277
405
|
}
|
|
278
406
|
if (scope !== "global") {
|
|
279
|
-
const local = await readMemoryRaw(
|
|
407
|
+
const local = await readMemoryRaw(target);
|
|
280
408
|
if (local.length) {
|
|
281
409
|
if (results.length) results.push("");
|
|
282
|
-
results.push(`--- ${
|
|
283
|
-
local.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e,
|
|
410
|
+
results.push(`--- Project: ${label} ---`);
|
|
411
|
+
local.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, target)}`));
|
|
284
412
|
}
|
|
285
413
|
}
|
|
286
414
|
return results.length ? results.join("\n") : "Memory is empty.";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotargo/memory_plugin",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.9",
|
|
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",
|