memorix 0.9.4 → 0.9.6

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 CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.9.6] — 2026-02-25
6
+
7
+ ### Fixed
8
+ - **Cross-IDE project identity fragmentation** — Data was stored in per-project subdirectories (`~/.memorix/data/<projectId>/`), but different IDEs often detected different projectIds for the same repo (e.g. `placeholder/repo` vs `local/repo` vs `local/Kiro`). This caused observations to silently split across directories, making cross-IDE relay unreliable. Now **all data is stored in a single flat directory** (`~/.memorix/data/`). projectId is metadata only, not used for directory partitioning. Existing per-project subdirectories are automatically merged on first startup (IDs remapped, graphs deduplicated, subdirs backed up to `.migrated-subdirs/`).
9
+ - **`scope: 'project'` parameter now works** — Previously accepted but ignored. Now properly filters search results by the current project's ID via Orama where-clause.
10
+
11
+ ## [0.9.5] — 2026-02-25
12
+
13
+ ### Fixed
14
+ - **Claude Code hooks `matcher` format** — `matcher` must be a **string** (tool name pattern like `"Bash"`, `"Edit|Write"`), not an object. For hooks that should fire on ALL events, `matcher` is now omitted entirely instead of using `{}`. Fixes `matcher: Expected string, but received object` validation error on Claude Code startup.
15
+
5
16
  ## [0.9.4] — 2026-02-25
6
17
 
7
18
  ### Fixed
package/dist/cli/index.js CHANGED
@@ -112,7 +112,7 @@ __export(persistence_exports, {
112
112
  loadIdCounter: () => loadIdCounter,
113
113
  loadObservationsJson: () => loadObservationsJson,
114
114
  loadSessionsJson: () => loadSessionsJson,
115
- migrateGlobalData: () => migrateGlobalData,
115
+ migrateSubdirsToFlat: () => migrateSubdirsToFlat,
116
116
  saveGraphJsonl: () => saveGraphJsonl,
117
117
  saveIdCounter: () => saveIdCounter,
118
118
  saveObservationsJson: () => saveObservationsJson,
@@ -121,18 +121,13 @@ __export(persistence_exports, {
121
121
  import { promises as fs2 } from "fs";
122
122
  import path3 from "path";
123
123
  import os from "os";
124
- function sanitizeProjectId(projectId) {
125
- return projectId.replace(/\//g, "--").replace(/[<>:"|?*\\]/g, "_");
126
- }
127
- async function getProjectDataDir(projectId, baseDir) {
128
- if (projectId === "__invalid__") {
124
+ async function getProjectDataDir(_projectId, baseDir) {
125
+ if (_projectId === "__invalid__") {
129
126
  throw new Error("Cannot create data directory for invalid project");
130
127
  }
131
128
  const base = baseDir ?? DEFAULT_DATA_DIR;
132
- const dirName = sanitizeProjectId(projectId);
133
- const dataDir = path3.join(base, dirName);
134
- await fs2.mkdir(dataDir, { recursive: true });
135
- return dataDir;
129
+ await fs2.mkdir(base, { recursive: true });
130
+ return base;
136
131
  }
137
132
  function getBaseDataDir(baseDir) {
138
133
  return baseDir ?? DEFAULT_DATA_DIR;
@@ -146,78 +141,141 @@ async function listProjectDirs(baseDir) {
146
141
  return [];
147
142
  }
148
143
  }
149
- async function migrateGlobalData(projectId, baseDir) {
144
+ async function migrateSubdirsToFlat(baseDir) {
150
145
  const base = baseDir ?? DEFAULT_DATA_DIR;
151
- const globalObsPath = path3.join(base, "observations.json");
152
- const migratedObsPath = path3.join(base, "observations.json.migrated");
153
- let sourceObsPath = null;
146
+ await fs2.mkdir(base, { recursive: true });
147
+ let entries;
154
148
  try {
155
- await fs2.access(globalObsPath);
156
- sourceObsPath = globalObsPath;
149
+ entries = await fs2.readdir(base, { withFileTypes: true });
157
150
  } catch {
151
+ return false;
152
+ }
153
+ const dataDirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => path3.join(base, e.name));
154
+ if (dataDirs.length === 0) return false;
155
+ const subdirData = [];
156
+ for (const dir of dataDirs) {
157
+ const obsPath = path3.join(dir, "observations.json");
158
158
  try {
159
- await fs2.access(migratedObsPath);
160
- sourceObsPath = migratedObsPath;
159
+ const data = await fs2.readFile(obsPath, "utf-8");
160
+ const obs = JSON.parse(data);
161
+ if (Array.isArray(obs) && obs.length > 0) {
162
+ let graph = { entities: [], relations: [] };
163
+ try {
164
+ const graphData = await fs2.readFile(path3.join(dir, "graph.jsonl"), "utf-8");
165
+ const lines = graphData.split("\n").filter((l) => l.trim());
166
+ for (const line of lines) {
167
+ const item = JSON.parse(line);
168
+ if (item.type === "entity") graph.entities.push(item);
169
+ if (item.type === "relation") graph.relations.push(item);
170
+ }
171
+ } catch {
172
+ }
173
+ subdirData.push({ dir, obs, graph });
174
+ }
161
175
  } catch {
162
- return false;
163
176
  }
164
177
  }
165
- let globalObs = [];
178
+ if (subdirData.length === 0) return false;
179
+ let baseObs = [];
166
180
  try {
167
- const data = await fs2.readFile(sourceObsPath, "utf-8");
168
- globalObs = JSON.parse(data);
169
- if (!Array.isArray(globalObs) || globalObs.length === 0) return false;
181
+ const data = await fs2.readFile(path3.join(base, "observations.json"), "utf-8");
182
+ baseObs = JSON.parse(data);
183
+ if (!Array.isArray(baseObs)) baseObs = [];
170
184
  } catch {
171
- return false;
172
185
  }
173
- const projectDir2 = await getProjectDataDir(projectId, baseDir);
174
- const projectObsPath = path3.join(projectDir2, "observations.json");
175
- let projectObs = [];
186
+ let baseGraph = { entities: [], relations: [] };
176
187
  try {
177
- const data = await fs2.readFile(projectObsPath, "utf-8");
178
- projectObs = JSON.parse(data);
179
- if (!Array.isArray(projectObs)) projectObs = [];
188
+ const graphData = await fs2.readFile(path3.join(base, "graph.jsonl"), "utf-8");
189
+ const lines = graphData.split("\n").filter((l) => l.trim());
190
+ for (const line of lines) {
191
+ const item = JSON.parse(line);
192
+ if (item.type === "entity") baseGraph.entities.push(item);
193
+ if (item.type === "relation") baseGraph.relations.push(item);
194
+ }
180
195
  } catch {
181
196
  }
182
- if (projectObs.length >= globalObs.length) {
183
- return false;
184
- }
185
- const existingIds = new Set(projectObs.map((o) => o.id));
186
- const merged = [...projectObs];
187
- for (const obs of globalObs) {
188
- if (!existingIds.has(obs.id)) {
189
- merged.push(obs);
197
+ const allObs = [...baseObs];
198
+ for (const { obs } of subdirData) {
199
+ for (const o of obs) {
200
+ const isDuplicate = allObs.some(
201
+ (existing) => existing.title === o.title && existing.createdAt === o.createdAt
202
+ );
203
+ if (!isDuplicate) {
204
+ allObs.push(o);
205
+ }
190
206
  }
191
207
  }
192
- merged.sort((a, b) => (a.id ?? 0) - (b.id ?? 0));
193
- for (const obs of merged) {
194
- obs.projectId = projectId;
208
+ allObs.sort((a, b) => (a.createdAt || "").localeCompare(b.createdAt || ""));
209
+ for (let i = 0; i < allObs.length; i++) {
210
+ allObs[i].id = i + 1;
195
211
  }
196
- await fs2.writeFile(projectObsPath, JSON.stringify(merged, null, 2), "utf-8");
197
- for (const file of ["graph.jsonl", "counter.json"]) {
198
- const src = path3.join(base, file);
199
- const srcMigrated = path3.join(base, file + ".migrated");
200
- const dst = path3.join(projectDir2, file);
201
- for (const source of [src, srcMigrated]) {
202
- try {
203
- await fs2.access(source);
204
- await fs2.copyFile(source, dst);
205
- break;
206
- } catch {
212
+ const entityMap = /* @__PURE__ */ new Map();
213
+ for (const e of baseGraph.entities) entityMap.set(e.name, e);
214
+ for (const { graph } of subdirData) {
215
+ for (const e of graph.entities) {
216
+ if (!entityMap.has(e.name)) {
217
+ entityMap.set(e.name, e);
218
+ } else {
219
+ const existing = entityMap.get(e.name);
220
+ const obsSet = /* @__PURE__ */ new Set([...existing.observations || [], ...e.observations || []]);
221
+ existing.observations = [...obsSet];
207
222
  }
208
223
  }
209
224
  }
210
- const maxId = merged.reduce((max, o) => Math.max(max, o.id ?? 0), 0);
225
+ const relationSet = /* @__PURE__ */ new Set();
226
+ const mergedRelations = [];
227
+ for (const rel of [...baseGraph.relations, ...subdirData.flatMap((d) => d.graph.relations)]) {
228
+ const key = `${rel.from}|${rel.to}|${rel.relationType}`;
229
+ if (!relationSet.has(key)) {
230
+ relationSet.add(key);
231
+ mergedRelations.push(rel);
232
+ }
233
+ }
234
+ await fs2.writeFile(path3.join(base, "observations.json"), JSON.stringify(allObs, null, 2), "utf-8");
211
235
  await fs2.writeFile(
212
- path3.join(projectDir2, "counter.json"),
213
- JSON.stringify({ nextId: maxId + 1 }),
236
+ path3.join(base, "counter.json"),
237
+ JSON.stringify({ nextId: allObs.length + 1 }),
214
238
  "utf-8"
215
239
  );
216
- for (const file of ["observations.json", "graph.jsonl", "counter.json"]) {
217
- const src = path3.join(base, file);
240
+ const graphLines = [
241
+ ...[...entityMap.values()].map((e) => JSON.stringify({ type: "entity", name: e.name, entityType: e.entityType, observations: e.observations })),
242
+ ...mergedRelations.map((r) => JSON.stringify({ type: "relation", from: r.from, to: r.to, relationType: r.relationType }))
243
+ ];
244
+ if (graphLines.length > 0) {
245
+ await fs2.writeFile(path3.join(base, "graph.jsonl"), graphLines.join("\n"), "utf-8");
246
+ }
247
+ let allSessions = [];
248
+ try {
249
+ const data = await fs2.readFile(path3.join(base, "sessions.json"), "utf-8");
250
+ allSessions = JSON.parse(data);
251
+ if (!Array.isArray(allSessions)) allSessions = [];
252
+ } catch {
253
+ }
254
+ for (const { dir } of subdirData) {
218
255
  try {
219
- await fs2.access(src);
220
- await fs2.rename(src, src + ".migrated");
256
+ const data = await fs2.readFile(path3.join(dir, "sessions.json"), "utf-8");
257
+ const sessions = JSON.parse(data);
258
+ if (Array.isArray(sessions)) allSessions.push(...sessions);
259
+ } catch {
260
+ }
261
+ }
262
+ if (allSessions.length > 0) {
263
+ await fs2.writeFile(path3.join(base, "sessions.json"), JSON.stringify(allSessions, null, 2), "utf-8");
264
+ }
265
+ const backupDir = path3.join(base, ".migrated-subdirs");
266
+ await fs2.mkdir(backupDir, { recursive: true });
267
+ for (const { dir } of subdirData) {
268
+ const dirName = path3.basename(dir);
269
+ try {
270
+ await fs2.rename(dir, path3.join(backupDir, dirName));
271
+ } catch {
272
+ }
273
+ }
274
+ for (const dir of dataDirs) {
275
+ const dirName = path3.basename(dir);
276
+ try {
277
+ await fs2.access(dir);
278
+ await fs2.rename(dir, path3.join(backupDir, dirName));
221
279
  } catch {
222
280
  }
223
281
  }
@@ -3700,23 +3758,18 @@ function resolveHookCommand() {
3700
3758
  }
3701
3759
  function generateClaudeConfig() {
3702
3760
  const cmd = `${resolveHookCommand()} hook`;
3703
- const hookGroup = {
3704
- matcher: {},
3705
- hooks: [
3706
- {
3707
- type: "command",
3708
- command: cmd,
3709
- timeout: 10
3710
- }
3711
- ]
3761
+ const hookEntry = {
3762
+ type: "command",
3763
+ command: cmd,
3764
+ timeout: 10
3712
3765
  };
3713
3766
  return {
3714
3767
  hooks: {
3715
- SessionStart: [hookGroup],
3716
- PostToolUse: [hookGroup],
3717
- UserPromptSubmit: [hookGroup],
3718
- PreCompact: [hookGroup],
3719
- Stop: [hookGroup]
3768
+ SessionStart: [{ hooks: [hookEntry] }],
3769
+ PostToolUse: [{ hooks: [hookEntry] }],
3770
+ UserPromptSubmit: [{ hooks: [hookEntry] }],
3771
+ PreCompact: [{ hooks: [hookEntry] }],
3772
+ Stop: [{ hooks: [hookEntry] }]
3720
3773
  }
3721
3774
  };
3722
3775
  }
@@ -5389,10 +5442,10 @@ async function createMemorixServer(cwd, existingServer) {
5389
5442
  );
5390
5443
  }
5391
5444
  try {
5392
- const { migrateGlobalData: migrateGlobalData2 } = await Promise.resolve().then(() => (init_persistence(), persistence_exports));
5393
- const migrated = await migrateGlobalData2(project.id);
5445
+ const { migrateSubdirsToFlat: migrateSubdirsToFlat2 } = await Promise.resolve().then(() => (init_persistence(), persistence_exports));
5446
+ const migrated = await migrateSubdirsToFlat2();
5394
5447
  if (migrated) {
5395
- console.error(`[memorix] Migrated legacy data to project directory: ${project.id}`);
5448
+ console.error(`[memorix] Migrated per-project subdirectories into flat storage`);
5396
5449
  }
5397
5450
  } catch {
5398
5451
  }
@@ -5615,10 +5668,10 @@ Use this as the \`topicKey\` parameter in \`memorix_store\` to enable upsert beh
5615
5668
  maxTokens: safeMaxTokens,
5616
5669
  since,
5617
5670
  until,
5618
- // Data isolation is handled at the directory level (each project has its own data dir).
5619
- // No projectId filter needed avoids cross-IDE search failures when different IDEs
5620
- // resolve different projectIds for the same directory.
5621
- projectId: void 0
5671
+ // All data is in a single flat directory. projectId is metadata only.
5672
+ // scope: 'project' filter by current projectId in Orama
5673
+ // scope: 'global' or omitted search all observations (default)
5674
+ projectId: scope === "project" ? project.id : void 0
5622
5675
  });
5623
5676
  let text = result.formatted;
5624
5677
  if (!syncAdvisoryShown && syncAdvisory) {