memorix 0.9.5 → 0.9.7

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,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.9.7] — 2026-02-25
6
+
7
+ ### Fixed
8
+ - **Claude Code hooks never triggering auto-memory** — Claude Code sends `hook_event_name` (snake_case) but the normalizer expected `hookEventName` (camelCase). This caused **every event** (SessionStart, UserPromptSubmit, PostToolUse, PreCompact, Stop) to be misidentified as `post_tool`, breaking event routing, prompt extraction, memory injection, and session tracking. Also fixed `session_id` → `sessionId` and `tool_response` → `toolResult` field mappings.
9
+ - **Empty content extraction from Claude Code tool events** — `extractContent()` now unpacks `toolInput` fields (Bash commands, Write file content, etc.) when no other content is available. Previously tool events produced empty or near-empty content strings.
10
+ - **User prompts silently dropped** — `MIN_STORE_LENGTH=100` was too high for typical user prompts. Added `MIN_PROMPT_LENGTH=20` specifically for `user_prompt` events.
11
+ - **Post-tool events too aggressively filtered** — Tool events with substantial content (>200 chars) are now stored even without keyword pattern matches.
12
+
13
+ ## [0.9.6] — 2026-02-25
14
+
15
+ ### Fixed
16
+ - **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/`).
17
+ - **`scope: 'project'` parameter now works** — Previously accepted but ignored. Now properly filters search results by the current project's ID via Orama where-clause.
18
+
5
19
  ## [0.9.5] — 2026-02-25
6
20
 
7
21
  ### 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
  }
@@ -5384,10 +5442,10 @@ async function createMemorixServer(cwd, existingServer) {
5384
5442
  );
5385
5443
  }
5386
5444
  try {
5387
- const { migrateGlobalData: migrateGlobalData2 } = await Promise.resolve().then(() => (init_persistence(), persistence_exports));
5388
- const migrated = await migrateGlobalData2(project.id);
5445
+ const { migrateSubdirsToFlat: migrateSubdirsToFlat2 } = await Promise.resolve().then(() => (init_persistence(), persistence_exports));
5446
+ const migrated = await migrateSubdirsToFlat2();
5389
5447
  if (migrated) {
5390
- console.error(`[memorix] Migrated legacy data to project directory: ${project.id}`);
5448
+ console.error(`[memorix] Migrated per-project subdirectories into flat storage`);
5391
5449
  }
5392
5450
  } catch {
5393
5451
  }
@@ -5610,10 +5668,10 @@ Use this as the \`topicKey\` parameter in \`memorix_store\` to enable upsert beh
5610
5668
  maxTokens: safeMaxTokens,
5611
5669
  since,
5612
5670
  until,
5613
- // Data isolation is handled at the directory level (each project has its own data dir).
5614
- // No projectId filter needed avoids cross-IDE search failures when different IDEs
5615
- // resolve different projectIds for the same directory.
5616
- 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
5617
5675
  });
5618
5676
  let text = result.formatted;
5619
5677
  if (!syncAdvisoryShown && syncAdvisory) {
@@ -6836,9 +6894,8 @@ var init_sync = __esm({
6836
6894
  function detectAgent(payload) {
6837
6895
  if ("agent_action_name" in payload) return "windsurf";
6838
6896
  if ("hook_event_name" in payload && "conversation_id" in payload) return "cursor";
6839
- if ("hookEventName" in payload) {
6840
- return "copilot";
6841
- }
6897
+ if ("hook_event_name" in payload) return "claude";
6898
+ if ("hookEventName" in payload) return "copilot";
6842
6899
  if ("event_type" in payload) return "kiro";
6843
6900
  if ("hook_type" in payload) return "codex";
6844
6901
  return "claude";
@@ -6849,8 +6906,9 @@ function extractEventName(payload, agent) {
6849
6906
  return payload.agent_action_name ?? "";
6850
6907
  case "cursor":
6851
6908
  return payload.hook_event_name ?? "";
6852
- case "copilot":
6853
6909
  case "claude":
6910
+ return payload.hook_event_name ?? payload.hookEventName ?? "";
6911
+ case "copilot":
6854
6912
  return payload.hookEventName ?? "";
6855
6913
  case "kiro":
6856
6914
  return payload.event_type ?? "";
@@ -6862,7 +6920,7 @@ function extractEventName(payload, agent) {
6862
6920
  }
6863
6921
  function normalizeClaude(payload, event) {
6864
6922
  const result = {
6865
- sessionId: payload.sessionId ?? "",
6923
+ sessionId: payload.session_id ?? payload.sessionId ?? "",
6866
6924
  cwd: payload.cwd ?? "",
6867
6925
  transcriptPath: payload.transcript_path
6868
6926
  };
@@ -6870,10 +6928,18 @@ function normalizeClaude(payload, event) {
6870
6928
  if (toolName) {
6871
6929
  result.toolName = toolName;
6872
6930
  result.toolInput = payload.tool_input;
6873
- result.toolResult = payload.tool_result;
6874
- if (toolName === "write" || toolName === "edit" || toolName === "multi_edit") {
6875
- const input = payload.tool_input;
6876
- result.filePath = input?.file_path ?? input?.filePath;
6931
+ const toolResponse = payload.tool_response ?? payload.tool_result;
6932
+ if (typeof toolResponse === "string") {
6933
+ result.toolResult = toolResponse;
6934
+ } else if (toolResponse && typeof toolResponse === "object") {
6935
+ result.toolResult = JSON.stringify(toolResponse);
6936
+ }
6937
+ const toolInput = payload.tool_input;
6938
+ if (/^bash$/i.test(toolName) && toolInput?.command) {
6939
+ result.command = toolInput.command;
6940
+ }
6941
+ if (/^(write|edit|multi_edit|multiedittool)$/i.test(toolName)) {
6942
+ result.filePath = toolInput?.file_path ?? toolInput?.filePath;
6877
6943
  }
6878
6944
  }
6879
6945
  if (event === "user_prompt") {
@@ -7173,6 +7239,19 @@ function extractContent(input) {
7173
7239
  parts.push(`Edit: ${edit.oldString} \u2192 ${edit.newString}`);
7174
7240
  }
7175
7241
  }
7242
+ if (parts.length === 0 && input.toolInput && typeof input.toolInput === "object") {
7243
+ if (input.toolName) parts.push(`Tool: ${input.toolName}`);
7244
+ if (input.toolInput.command) parts.push(`Command: ${input.toolInput.command}`);
7245
+ if (input.toolInput.file_path) parts.push(`File: ${input.toolInput.file_path}`);
7246
+ if (input.toolInput.content) {
7247
+ const content = input.toolInput.content;
7248
+ parts.push(content.slice(0, 1e3));
7249
+ }
7250
+ if (parts.length <= 1) {
7251
+ const summary = JSON.stringify(input.toolInput).slice(0, 500);
7252
+ parts.push(summary);
7253
+ }
7254
+ }
7176
7255
  return parts.join("\n").slice(0, MAX_CONTENT_LENGTH);
7177
7256
  }
7178
7257
  function deriveEntityName(input) {
@@ -7363,7 +7442,7 @@ ${lines.join("\n")}`;
7363
7442
  return { observation: null, output: defaultOutput };
7364
7443
  }
7365
7444
  const toolPattern = detectBestPattern(toolContent);
7366
- if (!toolPattern) {
7445
+ if (!toolPattern && toolContent.length < 200) {
7367
7446
  return { observation: null, output: defaultOutput };
7368
7447
  }
7369
7448
  markTriggered(toolKey);
@@ -7379,7 +7458,8 @@ ${lines.join("\n")}`;
7379
7458
  return { observation: null, output: defaultOutput };
7380
7459
  }
7381
7460
  const content = extractContent(input);
7382
- if (content.length < MIN_STORE_LENGTH) {
7461
+ const minLen = input.event === "user_prompt" ? MIN_PROMPT_LENGTH : MIN_STORE_LENGTH;
7462
+ if (content.length < minLen) {
7383
7463
  return { observation: null, output: defaultOutput };
7384
7464
  }
7385
7465
  detectBestPattern(content);
@@ -7426,7 +7506,7 @@ async function runHook() {
7426
7506
  }
7427
7507
  process.stdout.write(JSON.stringify(output));
7428
7508
  }
7429
- var cooldowns, COOLDOWN_MS, MIN_STORE_LENGTH, MIN_EDIT_LENGTH, NOISE_COMMANDS, MAX_CONTENT_LENGTH;
7509
+ var cooldowns, COOLDOWN_MS, MIN_STORE_LENGTH, MIN_PROMPT_LENGTH, MIN_EDIT_LENGTH, NOISE_COMMANDS, MAX_CONTENT_LENGTH;
7430
7510
  var init_handler = __esm({
7431
7511
  "src/hooks/handler.ts"() {
7432
7512
  "use strict";
@@ -7436,6 +7516,7 @@ var init_handler = __esm({
7436
7516
  cooldowns = /* @__PURE__ */ new Map();
7437
7517
  COOLDOWN_MS = 3e4;
7438
7518
  MIN_STORE_LENGTH = 100;
7519
+ MIN_PROMPT_LENGTH = 20;
7439
7520
  MIN_EDIT_LENGTH = 30;
7440
7521
  NOISE_COMMANDS = [
7441
7522
  /^(ls|dir|cd|pwd|echo|cat|type|head|tail|wc|find|which|where|whoami)\b/i,