@lotargo/memory_plugin 1.2.8 → 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 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 projName = projectName(null, null);
100
+ const projKey = projectKey(null, null);
101
101
  const globalF = await readMemoryRaw(GLOBAL_KEY);
102
- const projF = await readMemoryRaw(projName);
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 projName = projectName(null, null);
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 (${projName})`, value: "project", badge: `${projName}.md`, info: `Facts specific to project ${projName}` },
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
- const key = scopeRes.value === "global" ? GLOBAL_KEY : projName;
1035
- let factRunning = true;
1036
- while (factRunning) {
1037
- const rawEntries = await readMemory(key);
1038
- const factList = await readMemoryRaw(key);
1039
-
1040
- if (!factList || factList.length === 0) {
1041
- console.clear();
1042
- const line = "".repeat(PANEL_WIDTH - 2);
1043
- console.log(`\x1b[36m╭${line}╮\x1b[0m`);
1044
- console.log(`\x1b[36m│\x1b[0m \x1b[1m\x1b[37mNOTEBOOK FACTS: STORE EMPTY\x1b[0m${" ".repeat(PANEL_WIDTH - 30)}\x1b[36m│\x1b[0m`);
1045
- console.log(`\x1b[36m╰${line}╯\x1b[0m`);
1046
- console.log(`\n [*] Notebook store [${key}] has no saved facts.\n`);
1047
- await waitForEnter();
1048
- factRunning = false;
1049
- break;
1050
- }
1051
-
1052
- const factItems = factList.map((fact, idx) => ({
1053
- label: `${idx + 1}. ${fact}`,
1054
- value: idx,
1055
- info: `Select to delete this fact from ${key}.md`,
1056
- }));
1057
- factItems.push({ label: "< Back", value: "back" });
1058
-
1059
- const factRes = await selectSimpleMenu({
1060
- title: `NOTEBOOK FACTS [${key.toUpperCase()}]`,
1061
- subtitle: `Total facts: ${factList.length}`,
1062
- items: factItems,
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
- const actionRes = await selectSimpleMenu({
1074
- title: `FACT ACTION`,
1075
- subtitle: `Fact: "${selectedFact}"`,
1076
- items: [
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
- if (actionRes.action === "select" && actionRes.value === "delete") {
1083
- const updated = [...rawEntries];
1084
- updated.splice(selectedIdx, 1);
1085
- await writeMemory(key, updated);
1086
- console.clear();
1087
- console.log("\n [OK] Fact deleted successfully.\n");
1088
- await waitForEnter();
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
  }
@@ -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: "Show saved facts with any Agent-linked Knowledge Base documents/lines. scope: 'project', 'global', or 'all' (default)",
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 'all'"),
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(project);
153
+ const local = await readMemoryRaw(target);
131
154
  if (local.length) {
132
155
  if (results.length) results.push("");
133
- results.push(`--- ${project} ---`);
134
- local.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, project)}`));
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.";
@@ -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
- export function projectName(worktree, directory) {
39
- const dir = worktree || directory || process.cwd();
40
- return dir ? basename(dir) : "default";
41
- }
42
-
43
- export function scopeKey(scope, worktree, directory) {
44
- return scope === "global" ? GLOBAL_KEY : projectName(worktree, directory);
45
- }
46
-
47
- function memoryPath(key) {
48
- return join(MEMORY_DIR, `${key.replace(/[^a-zA-Z0-9_-]/g, "_")}.md`);
49
- }
50
-
51
- export async function readMemory(key) {
52
- const fp = memoryPath(key);
53
- if (!existsSync(fp)) return [];
54
- const content = await readFile(fp, "utf-8");
55
- return content.split("\n").filter((l) => l.startsWith("- ["));
56
- }
57
-
58
- export async function readMemoryRaw(key) {
59
- return (await readMemory(key)).map((e) => e.slice(2));
60
- }
61
-
62
- export async function writeMemory(key, entries) {
63
- const header = `# ${key === GLOBAL_KEY ? "Global Memory" : `Memory: ${key}`}\n\n`;
64
- await writeFile(memoryPath(key), header + entries.join("\n") + "\n");
65
- }
66
-
67
- export function today() {
68
- const d = new Date();
69
- const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
70
- const time = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
71
- return `${date} ${time}`;
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
+ }
@@ -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";
@@ -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 : projectName(worktree, directory);
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.replace(/[^a-zA-Z0-9_-]/g, "_")}.md`);
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 (!existsSync(fp)) return [];
47
- const content = await readFile(fp, "utf-8");
48
- return content.split("\n").filter((l) => l.startsWith("- ["));
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 header = `# ${key === GLOBAL_KEY ? "Global Memory" : `Memory: ${key}`}\n\n`;
57
- await writeFile(memoryPath(key), header + entries.join("\n") + "\n");
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 = projectName(worktree, directory);
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: "Show saved facts with any Agent-linked Knowledge Base documents/lines. scope: 'project', 'global', or 'all' (default)",
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 или all (по умолчанию)",
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(project);
407
+ const local = await readMemoryRaw(target);
280
408
  if (local.length) {
281
409
  if (results.length) results.push("");
282
- results.push(`--- ${project} ---`);
283
- local.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, project)}`));
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.8",
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",