claude-recall 0.26.0 → 0.27.0

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.
@@ -36,8 +36,13 @@ PASSTHROUGH_TOOLS = [
36
36
  'AskUserQuestion', # User interaction
37
37
  ]
38
38
 
39
- # Read-only bash commands that don't need memory search
40
- READ_ONLY_BASH = [
39
+ # Bash command prefixes exempt from the load-rules gate. NOT all read-only:
40
+ # alongside inspection commands, routine dev-workflow commands (git commit,
41
+ # npm test, ...) are deliberately exempt — the gate exists to get rules loaded
42
+ # before Claude starts shaping code, and blocking mid-workflow git/npm calls
43
+ # adds friction without a safety benefit. This is an advisory nudge, not a
44
+ # security boundary.
45
+ EXEMPT_BASH_PREFIXES = [
41
46
  'ls', 'cat', 'head', 'tail', 'less', 'more', 'file', 'stat', 'wc',
42
47
  'find', 'locate', 'which', 'whereis', 'type', 'pwd', 'whoami',
43
48
  'git status', 'git log', 'git diff', 'git show', 'git branch',
@@ -67,7 +72,7 @@ def load_state(session_id: str) -> dict:
67
72
  if state_file.exists():
68
73
  try:
69
74
  return json.load(open(state_file))
70
- except:
75
+ except Exception:
71
76
  pass
72
77
  return {'lastSearchAt': None, 'searchQuery': None}
73
78
 
@@ -76,7 +81,7 @@ def save_state(session_id: str, state: dict):
76
81
  STATE_DIR.mkdir(parents=True, exist_ok=True)
77
82
  try:
78
83
  json.dump(state, open(get_state_file(session_id), 'w'), indent=2)
79
- except:
84
+ except Exception:
80
85
  pass
81
86
 
82
87
 
@@ -84,18 +89,26 @@ def is_search_tool(tool_name: str) -> bool:
84
89
  return any(s in tool_name for s in SEARCH_TOOLS)
85
90
 
86
91
 
87
- def is_read_only_bash(command: str) -> bool:
92
+ def _has_command_prefix(cmd: str, prefix: str) -> bool:
93
+ """Word-boundary prefix match: 'cat foo' matches 'cat', but
94
+ 'catastrophic-script.sh' and 'envsubst' do not match 'cat'/'env'."""
95
+ if not cmd.startswith(prefix):
96
+ return False
97
+ rest = cmd[len(prefix):]
98
+ return rest == '' or rest[0] in ' \t;|&'
99
+
100
+
101
+ def is_exempt_bash(command: str) -> bool:
88
102
  if not command:
89
103
  return False
90
104
  cmd = command.strip().lower()
91
- # Check direct match or pipe starting with read-only
92
- for ro in READ_ONLY_BASH:
93
- if cmd.startswith(ro):
94
- return True
105
+ # Check direct match, or the first segment of a pipeline
106
+ candidates = [cmd]
95
107
  if '|' in cmd:
96
- first = cmd.split('|')[0].strip()
97
- for ro in READ_ONLY_BASH:
98
- if first.startswith(ro):
108
+ candidates.append(cmd.split('|')[0].strip())
109
+ for candidate in candidates:
110
+ for prefix in EXEMPT_BASH_PREFIXES:
111
+ if _has_command_prefix(candidate, prefix):
99
112
  return True
100
113
  return False
101
114
 
@@ -106,7 +119,7 @@ def main():
106
119
 
107
120
  try:
108
121
  data = json.load(sys.stdin)
109
- except:
122
+ except Exception:
110
123
  sys.exit(0)
111
124
 
112
125
  tool_name = data.get('tool_name', '')
@@ -139,14 +152,20 @@ def main():
139
152
  if not last_search:
140
153
  if tool_name not in ENFORCE_TOOLS:
141
154
  sys.exit(0)
142
- # Mutation tool on first call fall through to blocking logic below.
155
+ # Exempt bash passes even before rules are loaded previously this
156
+ # exemption only existed on the post-load branch, so the very first
157
+ # `git status`/`ls` of a session was hard-blocked up to MAX_BLOCKS
158
+ # times, contradicting the "read-only exploration passes freely" rule.
159
+ if tool_name == 'Bash' and is_exempt_bash(tool_input.get('command', '')):
160
+ sys.exit(0)
161
+ # Other mutation tool on first call — fall through to blocking logic below.
143
162
  else:
144
163
  # Rules loaded at least once — only enforce on mutation tools
145
164
  if tool_name not in ENFORCE_TOOLS:
146
165
  sys.exit(0)
147
166
 
148
- # Skip read-only bash
149
- if tool_name == 'Bash' and is_read_only_bash(tool_input.get('command', '')):
167
+ # Skip exempt bash
168
+ if tool_name == 'Bash' and is_exempt_bash(tool_input.get('command', '')):
150
169
  sys.exit(0)
151
170
 
152
171
  if last_search:
@@ -49,6 +49,11 @@ const mcp_commands_1 = require("./commands/mcp-commands");
49
49
  const project_commands_1 = require("./commands/project-commands");
50
50
  const hook_commands_1 = require("./commands/hook-commands");
51
51
  const repair_1 = require("./commands/repair");
52
+ // v14 = add PreToolUse rule-injector + Post resolver for JITRI.
53
+ // Bump when the hook block template changes — setup skips the settings
54
+ // rewrite when the installed hooksVersion already matches.
55
+ const HOOKS_VERSION = '14.0.0';
56
+ const parse_utils_1 = require("./parse-utils");
52
57
  const program = new commander_1.Command();
53
58
  class ClaudeRecallCLI {
54
59
  constructor(options) {
@@ -232,7 +237,7 @@ class ClaudeRecallCLI {
232
237
  // Sort by timestamp (newest first)
233
238
  failures.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
234
239
  // Limit results
235
- const limit = options.limit || 10;
240
+ const limit = (0, parse_utils_1.parsePositiveInt)(options.limit, 'limit', 10);
236
241
  const displayFailures = failures.slice(0, limit);
237
242
  console.log('\n❌ Failure Memories (Counterfactual Learning)\n');
238
243
  console.log(`Found ${failures.length} failures (showing ${displayFailures.length})\n`);
@@ -241,9 +246,20 @@ class ClaudeRecallCLI {
241
246
  return;
242
247
  }
243
248
  displayFailures.forEach((failure, index) => {
244
- const value = typeof failure.value === 'string'
245
- ? JSON.parse(failure.value)
246
- : failure.value;
249
+ // One malformed row must not crash the whole listing — fall back to the
250
+ // raw string as content
251
+ let value;
252
+ if (typeof failure.value === 'string') {
253
+ try {
254
+ value = JSON.parse(failure.value);
255
+ }
256
+ catch {
257
+ value = { content: failure.value };
258
+ }
259
+ }
260
+ else {
261
+ value = failure.value;
262
+ }
247
263
  const content = value.content || value;
248
264
  console.log(`${index + 1}. ${value.title || 'Untitled Failure'}`);
249
265
  console.log(` What Failed: ${content.what_failed || 'Unknown'}`);
@@ -394,10 +410,21 @@ class ClaudeRecallCLI {
394
410
  const needsInstall = current !== latest;
395
411
  if (needsInstall) {
396
412
  console.log(`\n📦 Upgrading ${current} → ${latest}...\n`);
397
- // Run npm install -g, streaming output so the user sees progress / errors live
413
+ // Run npm install -g, streaming output so the user sees progress / errors live.
414
+ // shell: true on Windows — npm is npm.cmd there and a bare spawnSync ENOENTs.
398
415
  const install = spawnSync('npm', ['install', '-g', 'claude-recall@latest'], {
399
416
  stdio: 'inherit',
417
+ shell: process.platform === 'win32',
400
418
  });
419
+ if (install.error && install.error.code === 'ENOENT') {
420
+ // spawn failed before npm ever ran — the EACCES advice below would be
421
+ // misleading here (previous behavior: status === null !== 0 fell
422
+ // through to the "sudo npm install" remediation for a missing npm)
423
+ console.error('\n❌ npm not found on PATH.');
424
+ console.error('\nInstall Node.js (which includes npm) ≥ 20.19, or fix your PATH, then re-run:');
425
+ console.error(' claude-recall upgrade');
426
+ process.exit(1);
427
+ }
401
428
  if (install.status !== 0) {
402
429
  // npm prints its own error — add the practical remediation on top
403
430
  console.error('\n❌ Install failed.');
@@ -534,7 +561,7 @@ class ClaudeRecallCLI {
534
561
  * Show outcome-aware learning status: episodes, outcome events, candidate lessons, memory stats
535
562
  */
536
563
  showOutcomes(options) {
537
- const limit = options.limit || 10;
564
+ const limit = (0, parse_utils_1.parsePositiveInt)(options.limit, 'limit', 10);
538
565
  const section = options.section;
539
566
  const db = this.memoryService.getDatabase();
540
567
  // Check if outcome tables exist
@@ -619,7 +646,7 @@ class ClaudeRecallCLI {
619
646
  * Search memories by query
620
647
  */
621
648
  search(query, options) {
622
- const limit = options.limit || 10;
649
+ const limit = (0, parse_utils_1.parsePositiveInt)(options.limit, 'limit', 10);
623
650
  // Determine search scope
624
651
  let results;
625
652
  if (options.global) {
@@ -709,7 +736,7 @@ class ClaudeRecallCLI {
709
736
  /**
710
737
  * Import memories from a file
711
738
  */
712
- async import(inputPath) {
739
+ async import(inputPath, options = {}) {
713
740
  try {
714
741
  if (!fs.existsSync(inputPath)) {
715
742
  console.error(`❌ File not found: ${inputPath}`);
@@ -724,11 +751,19 @@ class ClaudeRecallCLI {
724
751
  let imported = 0;
725
752
  for (const memory of data.memories) {
726
753
  try {
754
+ // Preserve each memory's original scoping. Exported rows carry
755
+ // project_id/scope as columns (not inside context) — the previous
756
+ // `context: memory.context || {}` dropped them, silently rescoping
757
+ // every imported memory to the CURRENT project. --project overrides.
727
758
  this.memoryService.store({
728
759
  key: memory.key || `imported_${Date.now()}_${Math.random()}`,
729
760
  value: memory.value,
730
761
  type: memory.type || 'imported',
731
- context: memory.context || {}
762
+ context: {
763
+ ...(memory.context || {}),
764
+ projectId: options.project ?? memory.project_id ?? memory.context?.projectId,
765
+ scope: memory.scope ?? memory.context?.scope ?? null,
766
+ }
732
767
  });
733
768
  imported++;
734
769
  }
@@ -736,8 +771,8 @@ class ClaudeRecallCLI {
736
771
  console.warn(`⚠️ Failed to import memory: ${error}`);
737
772
  }
738
773
  }
739
- console.log(`✅ Imported ${imported}/${data.memories.length} memories`);
740
- this.logger.info('CLI', 'Import completed', { imported, total: data.memories.length });
774
+ console.log(`✅ Imported ${imported}/${data.memories.length} memories${options.project ? ` (rescoped to project: ${options.project})` : ' (original project scoping preserved)'}`);
775
+ this.logger.info('CLI', 'Import completed', { imported, total: data.memories.length, project: options.project });
741
776
  }
742
777
  catch (error) {
743
778
  console.error('❌ Import failed:', error);
@@ -948,7 +983,7 @@ class ClaudeRecallCLI {
948
983
  async store(content, options) {
949
984
  try {
950
985
  const type = options.type || 'preference';
951
- const confidence = options.confidence || 0.8;
986
+ const confidence = (0, parse_utils_1.parseUnitFloat)(options.confidence, 'confidence', 0.8);
952
987
  // Parse metadata if provided
953
988
  let metadata = {};
954
989
  if (options.metadata) {
@@ -1034,8 +1069,7 @@ async function main() {
1034
1069
  }
1035
1070
  }
1036
1071
  // Install skills + minimal enforcement hook
1037
- // TODO: honor force currently `repair --reinstall-hooks` behaves identically to `setup --install`
1038
- function installSkillsAndHook(_force = false) {
1072
+ function installSkillsAndHook(force = false) {
1039
1073
  const cwd = process.cwd();
1040
1074
  const projectName = path.basename(cwd);
1041
1075
  console.log('\n📦 Claude Recall Setup\n');
@@ -1103,6 +1137,18 @@ async function main() {
1103
1137
  catch (e) {
1104
1138
  settings = {};
1105
1139
  }
1140
+ // Idempotent re-run: hook block already at the current version → leave
1141
+ // settings.json untouched (no rewrite, no backup churn) but still
1142
+ // refresh skills below. `force` (repair --reinstall-hooks) rewrites
1143
+ // unconditionally — the recovery path for a corrupted or hand-edited
1144
+ // block. Previously force was accepted and ignored, so the two
1145
+ // commands behaved identically.
1146
+ if (!force && settings.hooksVersion === HOOKS_VERSION) {
1147
+ console.log(`✅ Hooks already at v${HOOKS_VERSION} — settings.json untouched (use \`repair --reinstall-hooks\` to force a rewrite)`);
1148
+ installSkills(claudeDir, packageSkillsDir);
1149
+ console.log('\n✅ Setup complete!\n');
1150
+ return;
1151
+ }
1106
1152
  const existingHooks = settings.hooks;
1107
1153
  const hasExistingHooks = existingHooks
1108
1154
  && typeof existingHooks === 'object'
@@ -1116,11 +1162,18 @@ async function main() {
1116
1162
  console.log(' from other tools that need to coexist.');
1117
1163
  }
1118
1164
  }
1119
- // Resolve the CLI script path so hooks use the local install instead of npx.
1120
- // This avoids registry lookups on every hook invocation.
1165
+ // Prefer the portable `claude-recall` form when the binary is on PATH
1166
+ // absolute dist paths break on every nvm switch or prefix move, the exact
1167
+ // failure class `repair` exists to fix (repair already treats the PATH
1168
+ // form as canonical). Absolute path only as fallback.
1169
+ const onPath = (0, repair_1.resolveOnPath)('claude-recall');
1121
1170
  const cliScript = path.join(packageDir, 'dist', 'cli', 'claude-recall-cli.js');
1122
- const hookCmd = `node ${cliScript} hook run`;
1123
- settings.hooksVersion = '14.0.0'; // v14 = add PreToolUse rule-injector + Post resolver for JITRI
1171
+ const hookCmd = onPath ? 'claude-recall hook run' : `node ${cliScript} hook run`;
1172
+ if (!onPath) {
1173
+ console.log('⚠️ claude-recall not on PATH — hooks will use an absolute path (breaks if the install moves).');
1174
+ console.log(' `npm install -g claude-recall` gives move-proof hooks; `claude-recall repair` can convert later.');
1175
+ }
1176
+ settings.hooksVersion = HOOKS_VERSION;
1124
1177
  settings.hooks = {
1125
1178
  SubagentStart: [
1126
1179
  {
@@ -1259,7 +1312,13 @@ async function main() {
1259
1312
  }
1260
1313
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
1261
1314
  console.log('✅ Configured search enforcement hook');
1262
- // === INSTALL: Copy skills directory ===
1315
+ installSkills(claudeDir, packageSkillsDir);
1316
+ console.log('\n✅ Setup complete!\n');
1317
+ console.log('ℹ️ Uses Skills (guidance) + hooks (auto-capture with LLM classification).');
1318
+ console.log('Restart Claude Code to activate.\n');
1319
+ }
1320
+ // === INSTALL: Copy skills directory (idempotent overwrite) ===
1321
+ function installSkills(claudeDir, packageSkillsDir) {
1263
1322
  if (fs.existsSync(packageSkillsDir)) {
1264
1323
  const skillsDir = path.join(claudeDir, 'skills');
1265
1324
  copyDirRecursive(packageSkillsDir, skillsDir);
@@ -1268,9 +1327,6 @@ async function main() {
1268
1327
  else {
1269
1328
  console.log(`⚠️ Skills not found at: ${packageSkillsDir}`);
1270
1329
  }
1271
- console.log('\n✅ Setup complete!\n');
1272
- console.log('ℹ️ Uses Skills (guidance) + hooks (auto-capture with LLM classification).');
1273
- console.log('Restart Claude Code to activate.\n');
1274
1330
  }
1275
1331
  // Setup command - shows activation instructions or installs skills
1276
1332
  program
@@ -1761,7 +1817,7 @@ async function main() {
1761
1817
  .action((query, options) => {
1762
1818
  const cli = new ClaudeRecallCLI(program.opts());
1763
1819
  cli.search(query, {
1764
- limit: parseInt(options.limit),
1820
+ limit: (0, parse_utils_1.parsePositiveInt)(options.limit, 'limit', 10),
1765
1821
  json: options.json,
1766
1822
  project: options.project,
1767
1823
  global: options.global
@@ -1791,7 +1847,7 @@ async function main() {
1791
1847
  .action((options) => {
1792
1848
  const cli = new ClaudeRecallCLI(program.opts());
1793
1849
  cli.showFailures({
1794
- limit: parseInt(options.limit),
1850
+ limit: (0, parse_utils_1.parsePositiveInt)(options.limit, 'limit', 10),
1795
1851
  project: options.project
1796
1852
  });
1797
1853
  process.exit(0);
@@ -1805,7 +1861,7 @@ async function main() {
1805
1861
  .action((options) => {
1806
1862
  const cli = new ClaudeRecallCLI(program.opts());
1807
1863
  cli.showOutcomes({
1808
- limit: parseInt(options.limit),
1864
+ limit: (0, parse_utils_1.parsePositiveInt)(options.limit, 'limit', 10),
1809
1865
  section: options.section
1810
1866
  });
1811
1867
  process.exit(0);
@@ -1824,8 +1880,8 @@ async function main() {
1824
1880
  const cli = new ClaudeRecallCLI(program.opts());
1825
1881
  cli.demoteRules({
1826
1882
  dryRun: options.dryRun,
1827
- minLoads: parseInt(options.minLoads),
1828
- minAgeDays: parseInt(options.minAgeDays),
1883
+ minLoads: (0, parse_utils_1.parsePositiveInt)(options.minLoads, 'min-loads', 20),
1884
+ minAgeDays: (0, parse_utils_1.parsePositiveInt)(options.minAgeDays, 'min-age-days', 7),
1829
1885
  });
1830
1886
  process.exit(0);
1831
1887
  });
@@ -1846,7 +1902,7 @@ async function main() {
1846
1902
  const cli = new ClaudeRecallCLI(program.opts());
1847
1903
  cli.dedupSimilarRules({
1848
1904
  dryRun: options.dryRun,
1849
- threshold: parseFloat(options.threshold),
1905
+ threshold: (0, parse_utils_1.parseUnitFloat)(options.threshold, 'threshold', 0.65),
1850
1906
  });
1851
1907
  process.exit(0);
1852
1908
  });
@@ -1887,10 +1943,11 @@ async function main() {
1887
1943
  // Import command
1888
1944
  program
1889
1945
  .command('import <input>')
1890
- .description('Import memories from file')
1891
- .action(async (input) => {
1946
+ .description('Import memories from file (original project scoping preserved)')
1947
+ .option('--project <id>', 'Rescope all imported memories to this project ID')
1948
+ .action(async (input, options) => {
1892
1949
  const cli = new ClaudeRecallCLI(program.opts());
1893
- await cli.import(input);
1950
+ await cli.import(input, options);
1894
1951
  process.exit(0);
1895
1952
  });
1896
1953
  // Clear command
@@ -1993,7 +2050,7 @@ async function main() {
1993
2050
  const cli = new ClaudeRecallCLI(program.opts());
1994
2051
  await cli.store(content, {
1995
2052
  type: options.type,
1996
- confidence: parseFloat(options.confidence),
2053
+ confidence: (0, parse_utils_1.parseUnitFloat)(options.confidence, 'confidence', 0.8),
1997
2054
  metadata: options.metadata
1998
2055
  });
1999
2056
  process.exit(0);
@@ -173,19 +173,25 @@ class MCPCommands {
173
173
  return;
174
174
  }
175
175
  const signal = options.force ? 'SIGKILL' : 'SIGTERM';
176
- console.log(`Sending ${signal} to PID ${chalk_1.default.yellow(status.pid)}...`);
176
+ const pid = status.pid;
177
+ console.log(`Sending ${signal} to PID ${chalk_1.default.yellow(pid)}...`);
177
178
  try {
178
- this.processManager.killProcess(status.pid, options.force || false);
179
- this.processManager.removePidFile(projectId);
180
- // Give it a moment to shut down
181
- await new Promise(resolve => setTimeout(resolve, 1000));
182
- // Verify it stopped
183
- const newStatus = this.processManager.getServerStatus(projectId);
184
- if (!newStatus.isRunning) {
179
+ this.processManager.killProcess(pid, options.force || false);
180
+ // Verify death against the SAVED pid, polling up to 5s. The previous
181
+ // code removed the PID file first and then "verified" by re-reading it,
182
+ // so isRunning was always false and "stopped successfully" printed even
183
+ // if the process ignored SIGTERM.
184
+ const deadline = Date.now() + 5000;
185
+ while (this.processManager.isProcessRunning(pid) && Date.now() < deadline) {
186
+ await new Promise(resolve => setTimeout(resolve, 100));
187
+ }
188
+ if (!this.processManager.isProcessRunning(pid)) {
189
+ this.processManager.removePidFile(projectId);
185
190
  console.log(chalk_1.default.green('✓ Server stopped successfully'));
186
191
  }
187
192
  else {
188
- console.log(chalk_1.default.yellow('⚠ Server may still be running. Try --force flag.'));
193
+ // Keep the PID file the server is still alive and tracked
194
+ console.log(chalk_1.default.yellow(`⚠ PID ${pid} is still running after ${signal}. Try --force flag.`));
189
195
  }
190
196
  }
191
197
  catch (error) {
@@ -40,6 +40,7 @@ exports.ProjectCommands = void 0;
40
40
  const project_registry_1 = require("../../services/project-registry");
41
41
  const config_1 = require("../../services/config");
42
42
  const process_manager_1 = require("../../services/process-manager");
43
+ const parse_utils_1 = require("../parse-utils");
43
44
  const chalk_1 = __importDefault(require("chalk"));
44
45
  const path = __importStar(require("path"));
45
46
  const fs = __importStar(require("fs"));
@@ -207,6 +208,9 @@ class ProjectCommands {
207
208
  console.log(` Version: ${chalk_1.default.gray(entry.version)}`);
208
209
  console.log(` Last seen: ${chalk_1.default.gray(lastSeenTime)}`);
209
210
  console.log(` Status: ${isRunning ? chalk_1.default.green('✓ Active (MCP running)') : chalk_1.default.gray('✗ Inactive')}`);
211
+ if (entry.previousPaths && entry.previousPaths.length > 0) {
212
+ console.log(` ${chalk_1.default.yellow('⚠ Id collision:')} also seen at ${chalk_1.default.gray(entry.previousPaths.join(', '))} — these directories share one memory scope`);
213
+ }
210
214
  console.log();
211
215
  }
212
216
  console.log(chalk_1.default.gray(`Total: ${projects.length} project(s)`));
@@ -235,6 +239,13 @@ class ProjectCommands {
235
239
  console.log(` Version: ${chalk_1.default.gray(entry.version)}`);
236
240
  console.log(` Registered: ${chalk_1.default.gray(new Date(entry.registeredAt).toLocaleString())}`);
237
241
  console.log(` Last Seen: ${chalk_1.default.gray(new Date(entry.lastSeen).toLocaleString())} (${this.formatRelativeTime(entry.lastSeen)})`);
242
+ if (entry.previousPaths && entry.previousPaths.length > 0) {
243
+ console.log(` ${chalk_1.default.yellow('⚠ Id collision:')} this id was also registered at:`);
244
+ for (const p of entry.previousPaths) {
245
+ console.log(` ${chalk_1.default.gray(p)}`);
246
+ }
247
+ console.log(chalk_1.default.yellow(' Project ids are directory basenames — these directories share ONE memory scope. Rename a directory to isolate them.'));
248
+ }
238
249
  console.log();
239
250
  console.log(chalk_1.default.bold('MCP Server Status:'));
240
251
  console.log(` Running: ${status.isRunning ? chalk_1.default.green('✓ Yes') : chalk_1.default.gray('✗ No')}`);
@@ -248,7 +259,7 @@ class ProjectCommands {
248
259
  * Clean stale registry entries
249
260
  */
250
261
  async cleanRegistry(options) {
251
- const daysOld = parseInt(options.days || '30', 10);
262
+ const daysOld = (0, parse_utils_1.parsePositiveInt)(options.days, 'days', 30);
252
263
  console.log(chalk_1.default.cyan('\n🧹 Cleaning Registry\n'));
253
264
  if (options.dryRun) {
254
265
  console.log(chalk_1.default.yellow('DRY RUN MODE - No changes will be made\n'));
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ /**
3
+ * Numeric CLI option parsing with loud failures.
4
+ *
5
+ * Bare parseInt/parseFloat on user flags produced silent nonsense:
6
+ * `--limit abc` → NaN → `slice(0, NaN)` → zero results with no hint why;
7
+ * `--confidence abc` stored NaN into the database. Invalid input now exits 2
8
+ * with a message naming the flag.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.parsePositiveInt = parsePositiveInt;
12
+ exports.parseUnitFloat = parseUnitFloat;
13
+ function parsePositiveInt(value, flagName, fallback) {
14
+ if (value === undefined || value === null || value === '')
15
+ return fallback;
16
+ const n = Number(value);
17
+ if (!Number.isInteger(n) || n <= 0) {
18
+ console.error(`❌ Invalid --${flagName}: "${value}" (expected a positive integer)`);
19
+ process.exit(2);
20
+ }
21
+ return n;
22
+ }
23
+ function parseUnitFloat(value, flagName, fallback) {
24
+ if (value === undefined || value === null || value === '')
25
+ return fallback;
26
+ const n = Number(value);
27
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
28
+ console.error(`❌ Invalid --${flagName}: "${value}" (expected a number between 0 and 1)`);
29
+ process.exit(2);
30
+ }
31
+ return n;
32
+ }
@@ -72,17 +72,25 @@ class MemoryRetrieval {
72
72
  return sorted;
73
73
  }
74
74
  // Default: relevance sorting
75
+ // Batch-fetch usage stats and evidence counts for ALL candidates up
76
+ // front — the previous per-memory lookups ran 2 extra queries per
77
+ // candidate over an unbounded set before slicing to the top 5.
78
+ const statsMap = this.getMemoryStatsBatch(candidates.map(c => c.key));
79
+ const evidenceMap = this.getEvidenceCountBatch(candidates.map(c => c.key));
75
80
  // Score and prioritize by memory type
76
81
  const scored = candidates
77
82
  .map(memory => ({
78
83
  ...memory,
79
- score: this.calculateRelevance(memory, context)
84
+ score: this.calculateRelevance(memory, context, statsMap.get(memory.key) ?? null, evidenceMap.get(memory.key) ?? 0)
80
85
  }))
81
86
  .sort((a, b) => {
82
- // First priority: memory type (project-knowledge > preference > tool-use)
83
- const typeOrder = { 'project-knowledge': 3, 'preference': 2, 'tool-use': 1 };
84
- const aTypeScore = typeOrder[a.type] || 0;
85
- const bTypeScore = typeOrder[b.type] || 0;
87
+ // First priority: memory type. Every RULE type must outrank
88
+ // tool-use the previous map listed only project-knowledge and
89
+ // preference, so a perfectly keyword-matched correction or failure
90
+ // (default 0) sorted BELOW tool-use history noise and the top-5
91
+ // window could be entirely tool-use.
92
+ const aTypeScore = MemoryRetrieval.TYPE_PRIORITY[a.type] ?? 0;
93
+ const bTypeScore = MemoryRetrieval.TYPE_PRIORITY[b.type] ?? 0;
86
94
  if (aTypeScore !== bTypeScore) {
87
95
  return bTypeScore - aTypeScore;
88
96
  }
@@ -92,7 +100,7 @@ class MemoryRetrieval {
92
100
  // Return top 5 most relevant memories
93
101
  return scored.slice(0, 5);
94
102
  }
95
- calculateRelevance(memory, context) {
103
+ calculateRelevance(memory, context, stats, evidenceCount) {
96
104
  let score = memory.relevance_score || 1.0;
97
105
  // Boost for keyword matches in memory value
98
106
  if (context.keywords && context.keywords.length > 0) {
@@ -135,12 +143,10 @@ class MemoryRetrieval {
135
143
  const strength = MemoryRetrieval.computeStrength(memory);
136
144
  score *= 1 + strength * 2.0; // Up to 3x boost for max-strength memories
137
145
  // Evidence boost: promoted lessons seen multiple times
138
- const evidenceCount = this.getEvidenceCount(memory);
139
146
  if (evidenceCount > 1) {
140
147
  score *= 1 + Math.min(evidenceCount, 5) * 0.15; // up to 1.75x
141
148
  }
142
149
  // Helpfulness prior: retrieved + confirmed helpful
143
- const stats = this.getMemoryStats(memory.key);
144
150
  if (stats && stats.times_retrieved > 0) {
145
151
  const helpRatio = stats.times_helpful / stats.times_retrieved;
146
152
  score *= 0.8 + helpRatio * 0.4; // 0.8x to 1.2x
@@ -207,33 +213,67 @@ class MemoryRetrieval {
207
213
  const timeDecay = Math.pow(0.5, daysSinceTouch / classConfig.halfLife);
208
214
  return Math.min(signalScore * timeDecay, 1.0);
209
215
  }
210
- getMemoryStats(key) {
216
+ /** Batch lookup — one query per 500 keys instead of one per memory. */
217
+ getMemoryStatsBatch(keys) {
218
+ const map = new Map();
219
+ if (keys.length === 0)
220
+ return map;
211
221
  try {
212
- const row = this.storage.getDatabase().prepare('SELECT times_retrieved, times_helpful, last_confirmed_at FROM memory_stats WHERE memory_key = ?').get(key);
213
- return row || null;
222
+ // Chunk to stay under SQLite's bound-variable limit
223
+ for (let i = 0; i < keys.length; i += 500) {
224
+ const chunk = keys.slice(i, i + 500);
225
+ const placeholders = chunk.map(() => '?').join(',');
226
+ const rows = this.storage.getDatabase().prepare(`SELECT memory_key, times_retrieved, times_helpful, last_confirmed_at FROM memory_stats WHERE memory_key IN (${placeholders})`).all(...chunk);
227
+ for (const row of rows) {
228
+ map.set(row.memory_key, row);
229
+ }
230
+ }
214
231
  }
215
232
  catch {
216
- return null;
233
+ // Outcome tables may not exist yet — stats are an optional boost
217
234
  }
235
+ return map;
218
236
  }
219
- getEvidenceCount(memory) {
237
+ /** Batch lookup — one query per 500 keys instead of one per memory. */
238
+ getEvidenceCountBatch(keys) {
239
+ const map = new Map();
240
+ if (keys.length === 0)
241
+ return map;
220
242
  try {
221
- const row = this.storage.getDatabase().prepare("SELECT evidence_count FROM candidate_lessons WHERE promoted_memory_key = ? AND status = 'promoted' LIMIT 1").get(memory.key);
222
- return row?.evidence_count || 0;
243
+ for (let i = 0; i < keys.length; i += 500) {
244
+ const chunk = keys.slice(i, i + 500);
245
+ const placeholders = chunk.map(() => '?').join(',');
246
+ const rows = this.storage.getDatabase().prepare(`SELECT promoted_memory_key, evidence_count FROM candidate_lessons WHERE promoted_memory_key IN (${placeholders}) AND status = 'promoted'`).all(...chunk);
247
+ for (const row of rows) {
248
+ map.set(row.promoted_memory_key, row.evidence_count || 0);
249
+ }
250
+ }
223
251
  }
224
252
  catch {
225
- return 0;
253
+ // Outcome tables may not exist yet — evidence is an optional boost
226
254
  }
255
+ return map;
227
256
  }
228
257
  searchByKeyword(keyword) {
229
258
  const results = this.storage.search(keyword);
230
259
  const context = { timestamp: Date.now() };
260
+ const statsMap = this.getMemoryStatsBatch(results.map(r => r.key));
261
+ const evidenceMap = this.getEvidenceCountBatch(results.map(r => r.key));
231
262
  return results
232
263
  .map(memory => ({
233
264
  ...memory,
234
- score: this.calculateRelevance(memory, context)
265
+ score: this.calculateRelevance(memory, context, statsMap.get(memory.key) ?? null, evidenceMap.get(memory.key) ?? 0)
235
266
  }))
236
267
  .sort((a, b) => b.score - a.score);
237
268
  }
238
269
  }
239
270
  exports.MemoryRetrieval = MemoryRetrieval;
271
+ MemoryRetrieval.TYPE_PRIORITY = {
272
+ 'correction': 6,
273
+ 'project-knowledge': 5,
274
+ 'preference': 4,
275
+ 'devops': 3,
276
+ 'failure': 2,
277
+ 'tool-use': 1,
278
+ // unknown/imported types: 0
279
+ };
@@ -17,6 +17,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.detectTranscriptFailures = detectTranscriptFailures;
18
18
  const shared_1 = require("./shared");
19
19
  const MAX_FAILURES = 3;
20
+ // Tools that read but never mutate — transparent to cycle/loop detection.
21
+ const READ_ONLY_TOOLS = new Set([
22
+ 'Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'WebFetch', 'WebSearch', 'TodoWrite', 'TodoRead',
23
+ ]);
20
24
  // --- Detector 1: Non-zero exit codes ---
21
25
  function detectNonZeroExits(interactions, consumed) {
22
26
  const failures = [];
@@ -129,6 +133,12 @@ function detectEditTestCycles(interactions, consumed) {
129
133
  const file = ix.call.input?.file_path ?? ix.call.input?.path ?? '';
130
134
  seq.push({ type: 'edit', failed: false, file, idx: ix.call.entryIndex });
131
135
  }
136
+ else if (READ_ONLY_TOOLS.has(name)) {
137
+ // Transparent: reading/searching between an edit and a test run is how
138
+ // agents always work. Treating these as 'other' reset the cycle counter
139
+ // on every Read/Grep, so this detector could effectively never fire.
140
+ continue;
141
+ }
132
142
  else {
133
143
  seq.push({ type: 'other', failed: false, idx: ix.call.entryIndex });
134
144
  }
@@ -283,8 +293,18 @@ function detectRetryLoops(interactions, consumed) {
283
293
  const window = interactions.slice(start, start + WINDOW);
284
294
  const hashes = [];
285
295
  for (const ix of window) {
286
- // Exclude Read callsnormal to read files repeatedly
287
- if (ix.call.name === 'Read')
296
+ // Exclude read-only toolsreading/searching repeatedly is normal
297
+ if (READ_ONLY_TOOLS.has(ix.call.name))
298
+ continue;
299
+ // Exclude test commands — running `npm test` three times in a window is
300
+ // ordinary TDD, not a retry loop. A repeated call only signals a loop
301
+ // when the repeats FAIL, which the check below enforces for everything.
302
+ if (ix.call.name === 'Bash' && isTestCommand(ix.call.input?.command ?? ''))
303
+ continue;
304
+ // Only failed repeats indicate "retrying the same thing expecting a
305
+ // different result" — repeating a SUCCESSFUL idempotent command (git
306
+ // status, ls) is routine.
307
+ if (!ix.result?.isError)
288
308
  continue;
289
309
  if (consumed.has(ix.call.entryIndex))
290
310
  continue;
@@ -35,7 +35,7 @@ Rules:
35
35
  - "none" type should have confidence 0.0
36
36
  - extract should be a clean, imperative statement of the rule/fact (e.g. "Use tabs for indentation")
37
37
  - If the text is a question, greeting, or code block, classify as "none"`;
38
- const BATCH_SYSTEM_PROMPT = `You are a memory classifier for a developer tool. You will receive multiple texts separated by "---ITEM---" markers. Classify each into one of these types:
38
+ const BATCH_SYSTEM_PROMPT = `You are a memory classifier for a developer tool. You will receive a JSON array of texts. Classify each into one of these types:
39
39
 
40
40
  - correction: User correcting a mistake ("no, use X not Y", "wrong, it should be...")
41
41
  - preference: User stating a clear, reusable directive about how they want things done going forward ("we use tabs", "always use TypeScript", "I prefer X"). Must be a rule that applies beyond this conversation. NOT observations, complaints, questions, debugging statements, or one-off instructions like "fix this" or "tell me about X"
@@ -315,7 +315,11 @@ async function classifyBatchWithLLM(texts) {
315
315
  if (!client)
316
316
  return null;
317
317
  try {
318
- const joined = texts.join('\n---ITEM---\n');
318
+ // JSON array, not delimiter-joined text: a user message that happened to
319
+ // contain the old "---ITEM---" marker desynced item counts and silently
320
+ // dropped the entire batch to the regex fallback. JSON boundaries can't
321
+ // be forged by content.
322
+ const joined = JSON.stringify(texts);
319
323
  const response = await client.messages.create({
320
324
  model: MODEL,
321
325
  max_tokens: texts.length * 200,
@@ -194,8 +194,10 @@ async function handleMemoryStop(input) {
194
194
  scanForCitations(transcriptPath);
195
195
  // Scan transcript for failure signals (non-zero exits, test cycles, backtracking, etc.)
196
196
  const failures = detectAndStoreFailures(transcriptPath, episodeId);
197
- // Incorporate structured tool_failure events captured by PostToolUseFailure hook
198
- const toolFailures = getToolFailureEvents(outcomeStorage);
197
+ // Incorporate structured tool_failure events captured by PostToolUseFailure
198
+ // hook scoped to THIS session so concurrent sessions (or another
199
+ // project's session within the hour) don't contaminate this episode
200
+ const toolFailures = getToolFailureEvents(outcomeStorage, input?.session_id);
199
201
  const allFailures = [...failures, ...toolFailures];
200
202
  outcomeStorage.updateEpisode(episodeId, {
201
203
  outcome_type: allFailures.length > 0 ? 'failure' : 'success',
@@ -405,9 +407,11 @@ function detectAndStoreFailures(transcriptPath, _episodeId) {
405
407
  * Convert structured tool_failure outcome events into DetectedFailure format
406
408
  * so they feed into the candidate lessons pipeline.
407
409
  */
408
- function getToolFailureEvents(outcomeStorage) {
410
+ function getToolFailureEvents(outcomeStorage, sessionId) {
409
411
  try {
410
- const events = outcomeStorage.getEventsByType('tool_failure', 1); // last 1 hour
412
+ // Session-scoped when the Stop payload carries a session_id (it always
413
+ // does in practice); an undefined id falls back to the raw time window.
414
+ const events = outcomeStorage.getEventsByType('tool_failure', 1, sessionId); // last 1 hour
411
415
  return events.slice(0, 5).map(e => ({
412
416
  signal: 'tool_failure',
413
417
  confidence: 0.8,
@@ -60,11 +60,15 @@ const TEST_KEY_PATTERNS = [/^Test /i, /^Session test /i, /^test_/i];
60
60
  const SECRET_PATTERNS = [/api_key/i, /token/i, /password/i, /secret/i, /credential/i, /private_key/i];
61
61
  /**
62
62
  * Derive the auto-memory directory path from a cwd.
63
- * Matches Claude Code's convention: ~/.claude/projects/{cwd with / replaced by -}/memory/
63
+ * Matches Claude Code's convention: ~/.claude/projects/{sanitized cwd}/memory/
64
+ * where the sanitizer replaces EVERY non-alphanumeric with '-', not just '/'.
65
+ * Replacing only slashes wrote to ".../projects/-home-u-my_app.v2/memory"
66
+ * while Claude Code reads "-home-u-my-app-v2" — the entire sync output was
67
+ * silently invisible for any project path containing a dot or underscore.
64
68
  */
65
69
  function deriveAutoMemoryPath(cwd, homedir) {
66
70
  const home = homedir || os.homedir();
67
- const sanitized = cwd.replace(/\//g, '-');
71
+ const sanitized = cwd.replace(/[^a-zA-Z0-9]/g, '-');
68
72
  return path.join(home, '.claude', 'projects', sanitized, 'memory');
69
73
  }
70
74
  /**
@@ -18,7 +18,18 @@ const shared_1 = require("./shared");
18
18
  const event_processors_1 = require("../shared/event-processors");
19
19
  const config_1 = require("../services/config");
20
20
  const TRANSCRIPT_TAIL_SIZE = 30;
21
+ // Hard deadline for the whole worker. It's a DETACHED process nothing
22
+ // supervises — without this, a hung network connection left an orphan node
23
+ // process (holding a DB handle) alive for as long as the LLM call dangled,
24
+ // one per session exit. The in-process LLM timeout usually fires first; this
25
+ // is the backstop. unref() so the timer never keeps a finished worker alive.
26
+ const WORKER_DEADLINE_MS = 30000;
21
27
  async function handleSessionEndCheckpointWorker(input) {
28
+ const deadline = setTimeout(() => {
29
+ (0, shared_1.hookLog)('session-end-checkpoint-worker', `Deadline (${WORKER_DEADLINE_MS}ms) exceeded — exiting`);
30
+ process.exit(0);
31
+ }, WORKER_DEADLINE_MS);
32
+ deadline.unref();
22
33
  // Wire event-processor logs through hookLog so extractCheckpoint diagnostics
23
34
  // (LLM null, quality gate filter, save failure) end up in
24
35
  // ~/.claude-recall/hook-logs/session-end-checkpoint-worker.log instead of
@@ -48,7 +48,16 @@ async function handleSessionEndCheckpoint(input) {
48
48
  stdio: ['pipe', 'ignore', 'ignore'],
49
49
  // Inherit cwd so getProjectId() resolves correctly in the worker
50
50
  });
51
+ // Async spawn failures and EPIPE (worker exits before reading stdin)
52
+ // surface as 'error' events — without handlers they'd be UNCAUGHT
53
+ // exceptions in the hook process, the one thing a hook must never throw.
54
+ child.on('error', (err) => {
55
+ (0, shared_1.hookLog)('session-end-checkpoint', `Worker spawn error: ${err?.message ?? err}`);
56
+ });
51
57
  if (child.stdin) {
58
+ child.stdin.on('error', (err) => {
59
+ (0, shared_1.hookLog)('session-end-checkpoint', `Worker stdin error: ${err?.message ?? err}`);
60
+ });
52
61
  child.stdin.write(JSON.stringify(input));
53
62
  child.stdin.end();
54
63
  }
@@ -58,24 +58,32 @@ const os = __importStar(require("os"));
58
58
  const memory_1 = require("../services/memory");
59
59
  const config_1 = require("../services/config");
60
60
  const llm_classifier_1 = require("./llm-classifier");
61
+ // NOTE on confidence calibration: consumers (correction-detector, memory-stop,
62
+ // event-processors) gate corrections/preferences/devops at >= 0.75. Any
63
+ // pattern below that threshold can never store anything — don't add one.
64
+ // (The previous list carried eight 0.7-confidence patterns that were silently
65
+ // dead for exactly this reason; the weak ones — "actually", "I like",
66
+ // "I want", "I use" — were deleted rather than promoted because they match
67
+ // ordinary conversation far too often.)
61
68
  const CORRECTION_PATTERNS = [
62
69
  { regex: /^no[,.]?\s+(.+)/i, confidence: 0.8 },
63
70
  { regex: /^wrong[,.]?\s+(.+)/i, confidence: 0.8 },
64
- { regex: /^actually[,.]?\s+(.+)/i, confidence: 0.7 },
65
- { regex: /\bnever\s+(.+)/i, confidence: 0.7 },
71
+ { regex: /\bnever\s+(.+)/i, confidence: 0.75 },
66
72
  { regex: /\bdon'?t\s+ever\s+(.+)/i, confidence: 0.8 },
67
- { regex: /\bstop\s+(doing|using|adding)\s+(.+)/i, confidence: 0.7 },
73
+ { regex: /\bstop\s+(doing|using|adding)\s+(.+)/i, confidence: 0.75 },
68
74
  ];
69
75
  const PREFERENCE_PATTERNS = [
70
76
  { regex: /\bremember\s+(?:that|this|to)\s+(.+)/i, confidence: 0.8 },
71
77
  { regex: /\bfrom\s+now\s+on[,.]?\s+(.+)/i, confidence: 0.8 },
72
78
  { regex: /\bgoing\s+forward[,.]?\s+(.+)/i, confidence: 0.8 },
73
- { regex: /\balways\s+(.+)/i, confidence: 0.7 },
74
- { regex: /\bI\s+prefer\s+(.+)/i, confidence: 0.7 },
75
- { regex: /\bI\s+like\s+(.+)/i, confidence: 0.7 },
76
- { regex: /\bI\s+want\s+(.+)/i, confidence: 0.7 },
77
- { regex: /\bI\s+use\s+(.+)/i, confidence: 0.7 },
79
+ { regex: /\balways\s+(.+)/i, confidence: 0.75 },
80
+ { regex: /\bI\s+prefer\s+(.+)/i, confidence: 0.75 },
78
81
  ];
82
+ // Questions are never rules ("do you remember that config file we used?" must
83
+ // not become a stored preference), and "no ..." pleasantries are not
84
+ // corrections ("no worries, that looks good").
85
+ const INTERROGATIVE_START = /^(do|does|did|can|could|would|should|shall|is|are|was|were|will|have|has|what|why|how|when|where|who|which)\b/i;
86
+ const PLEASANTRY_NO = /^no\s+(worries|problem|problems|prob|thanks|thank|rush|need|biggie|sweat|pressure)\b/i;
79
87
  // Failure, devops, and project-knowledge patterns removed — single-keyword
80
88
  // matches ("error", "git", "build") are too broad for regex. These types
81
89
  // require context that only the LLM classifier can assess. When the LLM is
@@ -94,6 +102,11 @@ function readStdin() {
94
102
  * Returns the highest-confidence match, prioritizing corrections > preferences.
95
103
  */
96
104
  function classifyContentRegex(text) {
105
+ const trimmed = text.trim();
106
+ // Interrogatives and pleasantries are conversation, not rules
107
+ if (trimmed.endsWith('?') || INTERROGATIVE_START.test(trimmed) || PLEASANTRY_NO.test(trimmed)) {
108
+ return null;
109
+ }
97
110
  // Priority order: correction > preference > failure > devops > project-knowledge
98
111
  for (const p of CORRECTION_PATTERNS) {
99
112
  const m = text.match(p.regex);
@@ -61,26 +61,6 @@ const EXIT_CODE_REGEX = /Exit code (\d+)/;
61
61
  const MAX_PENDING = 5;
62
62
  const FIX_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
63
63
  const FIX_JACCARD_THRESHOLD = 0.3;
64
- // Error patterns for Edit/Write tools
65
- const WRITE_ERROR_PATTERNS = [
66
- /permission denied/i,
67
- /EACCES/i,
68
- /ENOENT/i,
69
- /file not found/i,
70
- /no such file/i,
71
- /is a directory/i,
72
- /read-?only file/i,
73
- /conflict/i,
74
- /old_string.*not found/i,
75
- /not unique in the file/i,
76
- ];
77
- // Error patterns for MCP tools
78
- const MCP_ERROR_PATTERNS = [
79
- /error/i,
80
- /failed/i,
81
- /exception/i,
82
- /timeout/i,
83
- ];
84
64
  // --- State management (Bash fix pairing only) ---
85
65
  function getStateDir() {
86
66
  return (0, shared_1.hookStateDir)();
@@ -331,35 +311,12 @@ async function handleWriteToolOutcome(input) {
331
311
  const output = input.tool_output ?? '';
332
312
  const toolName = input.tool_name;
333
313
  const filePath = input.tool_input?.file_path ?? '';
334
- // Check for error patterns
335
- const errorMatch = WRITE_ERROR_PATTERNS.find(p => p.test(output));
336
- if (errorMatch) {
337
- // Dedup check
338
- const summary = `${toolName} failed on ${filePath}: ${truncate(firstLine(output), 100)}`;
339
- const existing = (0, shared_1.searchExisting)(summary);
340
- if ((0, shared_1.isDuplicate)(summary, existing, 0.7)) {
341
- (0, shared_1.hookLog)(HOOK_NAME, `Skipped duplicate ${toolName} failure`);
342
- return;
343
- }
344
- const failureContent = {
345
- what_failed: `${toolName} failed on ${truncate(filePath, 80)}`,
346
- why_failed: truncate(firstLine(output), 200),
347
- what_should_do: `Verify file path exists and is writable before using ${toolName}`,
348
- context: `${toolName} tool error on ${filePath}`,
349
- preventative_checks: [
350
- 'Check file path exists',
351
- 'Check file permissions',
352
- 'Verify old_string is unique (for Edit)',
353
- ],
354
- };
355
- (0, shared_1.storeMemory)(JSON.stringify(failureContent), 'failure', undefined, 0.75);
356
- (0, shared_1.hookLog)(HOOK_NAME, `Stored ${toolName} failure: ${truncate(filePath, 60)}`);
357
- }
358
- // Always record outcome event
359
- const eventSummary = errorMatch
360
- ? `${toolName} error on ${filePath}: ${truncate(firstLine(output), 100)}`
361
- : `${toolName} success on ${truncate(filePath, 100)}`;
362
- recordOutcomeEvent(toolName, input.tool_input, eventSummary);
314
+ // No failure sniffing here: this handler runs on the PostToolUse SUCCESS
315
+ // path (real failures arrive via PostToolUseFailure → handleToolFailure
316
+ // with a structured error field). Pattern-matching successful output
317
+ // stored bogus permanent failures whenever the edited file merely
318
+ // MENTIONED strings like "ENOENT" or "permission denied".
319
+ recordOutcomeEvent(toolName, input.tool_input, `${toolName} success on ${truncate(filePath || firstLine(output), 100)}`);
363
320
  }
364
321
  // --- MCP tool handler ---
365
322
  async function handleMcpToolOutcome(input) {
@@ -369,21 +326,9 @@ async function handleMcpToolOutcome(input) {
369
326
  if (toolName.includes('claude-recall') || toolName.includes('claude_recall'))
370
327
  return;
371
328
  const outputStr = typeof output === 'string' ? output : JSON.stringify(output);
372
- const hasError = MCP_ERROR_PATTERNS.some(p => p.test(outputStr)) && outputStr.length < 500;
373
- if (hasError) {
374
- const summary = `${toolName}: ${truncate(firstLine(outputStr), 100)}`;
375
- const existing = (0, shared_1.searchExisting)(summary);
376
- if (!(0, shared_1.isDuplicate)(summary, existing, 0.7)) {
377
- (0, shared_1.storeMemory)(JSON.stringify({
378
- what_failed: `MCP tool ${toolName} returned error`,
379
- why_failed: truncate(firstLine(outputStr), 200),
380
- what_should_do: 'Check tool input parameters and server availability',
381
- context: `MCP tool error from ${toolName}`,
382
- }), 'failure', undefined, 0.7);
383
- (0, shared_1.hookLog)(HOOK_NAME, `Stored MCP failure: ${truncate(toolName, 40)}`);
384
- }
385
- }
386
- // Record outcome event for all MCP tool calls
329
+ // No failure sniffing here either /error/i against successful MCP output
330
+ // turned responses like "0 errors found" into stored failure memories.
331
+ // PostToolUseFailure carries the real signal.
387
332
  recordOutcomeEvent(toolName, input.tool_input, truncate(firstLine(outputStr), 200));
388
333
  }
389
334
  // --- Generic handler (all other tools) ---
@@ -448,12 +393,14 @@ async function handleToolFailure(input) {
448
393
  preventative_checks: ['Verify tool inputs are correct', 'Check preconditions'],
449
394
  };
450
395
  (0, shared_1.storeMemory)(JSON.stringify(failureContent), 'failure', undefined, 0.8);
451
- // Record structured outcome event
396
+ // Record structured outcome event, attributed to the emitting session so
397
+ // memory-stop only folds THIS session's failures into its episode
452
398
  try {
453
399
  const outcomeStorage = outcome_storage_1.OutcomeStorage.getInstance();
454
400
  outcomeStorage.createOutcomeEvent({
455
401
  event_type: 'tool_failure',
456
402
  actor: 'tool',
403
+ session_id: typeof input.session_id === 'string' ? input.session_id : undefined,
457
404
  action_summary: whatFailed,
458
405
  next_state_summary: truncate(error, 200),
459
406
  tags: extractToolTags(toolName, input.tool_input),
@@ -15,7 +15,7 @@ CREATE TABLE IF NOT EXISTS memories (
15
15
  superseded_at INTEGER,
16
16
  confidence_score REAL,
17
17
  sophistication_level INTEGER DEFAULT 1,
18
- scope TEXT CHECK(scope IN ('universal', 'project', NULL)),
18
+ scope TEXT CHECK(scope IS NULL OR scope IN ('universal', 'project')),
19
19
  content_hash TEXT
20
20
  );
21
21
 
@@ -102,7 +102,7 @@ class MemoryStorage {
102
102
  // Add scope if missing (v0.7.2+)
103
103
  if (!columnNames.includes('scope')) {
104
104
  console.error('📋 Migrating database schema: Adding scope column...');
105
- this.db.exec("ALTER TABLE memories ADD COLUMN scope TEXT CHECK(scope IN ('universal', 'project', NULL))");
105
+ this.db.exec("ALTER TABLE memories ADD COLUMN scope TEXT CHECK(scope IS NULL OR scope IN ('universal', 'project'))");
106
106
  this.db.exec('CREATE INDEX IF NOT EXISTS idx_memories_scope_project ON memories(scope, project_id)');
107
107
  console.error('✅ Added scope column');
108
108
  }
@@ -164,6 +164,7 @@ class MemoryStorage {
164
164
  this.db.exec(`CREATE TABLE outcome_events (
165
165
  id TEXT PRIMARY KEY,
166
166
  episode_id TEXT,
167
+ session_id TEXT,
167
168
  event_type TEXT NOT NULL,
168
169
  actor TEXT NOT NULL,
169
170
  action_summary TEXT,
@@ -175,6 +176,15 @@ class MemoryStorage {
175
176
  this.db.exec('CREATE INDEX idx_outcome_events_episode ON outcome_events(episode_id)');
176
177
  this.db.exec('CREATE INDEX idx_outcome_events_type ON outcome_events(event_type)');
177
178
  }
179
+ else {
180
+ // v0.26.2: session_id column so events can be attributed to the session
181
+ // that produced them — without it, one session's tool failures leak
182
+ // into another concurrent session's episode and candidate lessons.
183
+ const eventCols = this.db.prepare("PRAGMA table_info(outcome_events)").all();
184
+ if (!eventCols.some(c => c.name === 'session_id')) {
185
+ this.db.exec('ALTER TABLE outcome_events ADD COLUMN session_id TEXT');
186
+ }
187
+ }
178
188
  if (!existingOutcomeTables.has('candidate_lessons')) {
179
189
  this.db.exec(`CREATE TABLE candidate_lessons (
180
190
  id TEXT PRIMARY KEY,
@@ -695,7 +705,12 @@ class MemoryStorage {
695
705
  let query = 'SELECT * FROM memories WHERE preference_key = ? AND type = ?';
696
706
  const params = [preferenceKey, 'preference'];
697
707
  if (projectId) {
698
- query += ' AND project_id = ?';
708
+ // NULL-inclusive, matching getActiveByPreferenceKeyAnyType and what
709
+ // loadActiveRules returns. With a strict project_id filter, an override
710
+ // stored in a project failed to supersede the same preference_key
711
+ // stored unscoped/universal — leaving two active conflicting
712
+ // preferences both visible to loadActiveRules.
713
+ query += ' AND (project_id = ? OR project_id IS NULL)';
699
714
  params.push(projectId);
700
715
  }
701
716
  query += ' ORDER BY timestamp DESC';
@@ -48,7 +48,9 @@ class LoggingService {
48
48
  constructor() {
49
49
  this.config = config_1.ConfigService.getInstance();
50
50
  const configLevel = this.config.getConfig().logging.level;
51
- this.logLevel = LogLevel[configLevel.toUpperCase()] || LogLevel.INFO;
51
+ // ?? not ||: LogLevel.DEBUG === 0 is falsy, so || silently coerced
52
+ // CLAUDE_RECALL_LOG_LEVEL=debug into INFO and debug() never wrote
53
+ this.logLevel = LogLevel[configLevel.toUpperCase()] ?? LogLevel.INFO;
52
54
  }
53
55
  static getInstance() {
54
56
  if (!LoggingService.instance) {
@@ -65,6 +67,38 @@ class LoggingService {
65
67
  metadata
66
68
  };
67
69
  }
70
+ /**
71
+ * Size-based rotation honoring logging.maxSize / logging.maxFiles. These
72
+ * config fields existed since the beginning but nothing implemented them —
73
+ * with hooks logging on every tool call, info.log grew without bound.
74
+ * On overflow: log → log.1 → log.2 … up to maxFiles, oldest deleted.
75
+ */
76
+ rotateIfNeeded(logPath) {
77
+ try {
78
+ const loggingConfig = this.config.getConfig().logging;
79
+ const maxBytes = config_1.ConfigService.parseByteSize(loggingConfig.maxSize, 10 * 1024 * 1024);
80
+ const maxFiles = Math.max(1, loggingConfig.maxFiles || 5);
81
+ const stats = fs.statSync(logPath);
82
+ if (stats.size < maxBytes)
83
+ return;
84
+ // Shift older rotations up, dropping the oldest
85
+ for (let i = maxFiles - 1; i >= 1; i--) {
86
+ const from = `${logPath}.${i}`;
87
+ if (!fs.existsSync(from))
88
+ continue;
89
+ if (i === maxFiles - 1) {
90
+ fs.unlinkSync(from);
91
+ }
92
+ else {
93
+ fs.renameSync(from, `${logPath}.${i + 1}`);
94
+ }
95
+ }
96
+ fs.renameSync(logPath, `${logPath}.1`);
97
+ }
98
+ catch {
99
+ // Rotation is best-effort — never let it break logging or the app
100
+ }
101
+ }
68
102
  writeLog(logName, entry) {
69
103
  const logPath = this.config.getLogPath(logName);
70
104
  const logDir = path.dirname(logPath);
@@ -78,6 +112,7 @@ class LoggingService {
78
112
  return;
79
113
  }
80
114
  }
115
+ this.rotateIfNeeded(logPath);
81
116
  const logMessage = `[${entry.timestamp}] ${entry.level} [${entry.service}] ${entry.message}`;
82
117
  const fullMessage = entry.metadata
83
118
  ? `${logMessage}\n Metadata: ${JSON.stringify(entry.metadata)}\n`
@@ -681,6 +681,15 @@ class MemoryService {
681
681
  ? request.value
682
682
  : JSON.stringify(request.value);
683
683
  const lowerContent = content.toLowerCase();
684
+ // Project indicators FIRST: they are the more specific signal. Checking
685
+ // universal first meant "always use --no-verify in this project" matched
686
+ // "always use" and leaked the rule to every project.
687
+ if (lowerContent.includes('for this project') ||
688
+ lowerContent.includes('project-specific') ||
689
+ lowerContent.includes('only here') ||
690
+ lowerContent.includes('in this project')) {
691
+ return 'project';
692
+ }
684
693
  // Explicit user indicators for universal scope
685
694
  if (lowerContent.includes('remember everywhere') ||
686
695
  lowerContent.includes('for all projects') ||
@@ -688,13 +697,6 @@ class MemoryService {
688
697
  lowerContent.includes('always use')) {
689
698
  return 'universal';
690
699
  }
691
- // Explicit user indicators for project scope
692
- if (lowerContent.includes('for this project') ||
693
- lowerContent.includes('project-specific') ||
694
- lowerContent.includes('only here') ||
695
- lowerContent.includes('in this project')) {
696
- return 'project';
697
- }
698
700
  // Default: unscoped (null) for backward compatibility
699
701
  return null;
700
702
  }
@@ -111,10 +111,10 @@ class OutcomeStorage {
111
111
  createOutcomeEvent(data) {
112
112
  const id = this.generateId();
113
113
  this.db.prepare(`
114
- INSERT INTO outcome_events (id, episode_id, event_type, actor, action_summary,
114
+ INSERT INTO outcome_events (id, episode_id, session_id, event_type, actor, action_summary,
115
115
  next_state_summary, exit_code, tags_json, created_at)
116
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
117
- `).run(id, data.episode_id || null, data.event_type, data.actor, data.action_summary || null, data.next_state_summary, data.exit_code ?? null, data.tags ? JSON.stringify(data.tags) : null, this.now());
116
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
117
+ `).run(id, data.episode_id || null, data.session_id || null, data.event_type, data.actor, data.action_summary || null, data.next_state_summary, data.exit_code ?? null, data.tags ? JSON.stringify(data.tags) : null, this.now());
118
118
  return id;
119
119
  }
120
120
  // --- Candidate Lessons ---
@@ -165,8 +165,17 @@ class OutcomeStorage {
165
165
  * Get outcome events filtered by event_type, ordered by most recent.
166
166
  * Optionally filter by time window (defaults to last 24 hours).
167
167
  */
168
- getEventsByType(eventType, hoursBack = 24) {
168
+ /**
169
+ * @param sessionId When provided, only events recorded by that session are
170
+ * returned. Without it, a time-window query mixes events from concurrent
171
+ * sessions and other projects' sessions within the window — which is how
172
+ * session A's tool failures used to end up in session B's episode.
173
+ */
174
+ getEventsByType(eventType, hoursBack = 24, sessionId) {
169
175
  const cutoff = new Date(Date.now() - hoursBack * 60 * 60 * 1000).toISOString();
176
+ if (sessionId) {
177
+ return this.db.prepare('SELECT * FROM outcome_events WHERE event_type = ? AND created_at > ? AND session_id = ? ORDER BY created_at DESC').all(eventType, cutoff, sessionId);
178
+ }
170
179
  return this.db.prepare('SELECT * FROM outcome_events WHERE event_type = ? AND created_at > ? ORDER BY created_at DESC').all(eventType, cutoff);
171
180
  }
172
181
  // --- Memory Stats ---
@@ -124,6 +124,21 @@ class ProjectRegistry {
124
124
  registry.projects[projectId].lastSeen = now;
125
125
  this.logger.debug('ProjectRegistry', `Updated existing project: ${projectId}`);
126
126
  }
127
+ else if (existing) {
128
+ // Same basename, DIFFERENT path — an id collision, not a new project.
129
+ // Latest path wins (matches memory scoping, which is also keyed by
130
+ // basename), but keep the prior paths and say so instead of silently
131
+ // overwriting the other project's entry.
132
+ this.logger.warn('ProjectRegistry', `Project id collision: "${projectId}" already registered at ${existing.path}, now also at ${projectPath}. ` +
133
+ `Both directories share ONE memory scope (project ids are directory basenames) — rename one directory to isolate them.`);
134
+ registry.projects[projectId] = {
135
+ path: projectPath,
136
+ registeredAt: existing.registeredAt, // preserve original registration
137
+ version: version,
138
+ lastSeen: now,
139
+ previousPaths: [...(existing.previousPaths ?? []), existing.path].slice(-5),
140
+ };
141
+ }
127
142
  else {
128
143
  // New registration
129
144
  registry.projects[projectId] = {
@@ -114,25 +114,6 @@ function tryPairFix(toolName, toolInput, _output) {
114
114
  return matched;
115
115
  }
116
116
  // --- Tool Outcome Processing ---
117
- /** Error patterns for Edit/Write tools */
118
- const WRITE_ERROR_PATTERNS = [
119
- /permission denied/i,
120
- /EACCES/i,
121
- /ENOENT/i,
122
- /file not found/i,
123
- /no such file/i,
124
- /read-?only file/i,
125
- /conflict/i,
126
- /old_string.*not found/i,
127
- /not unique in the file/i,
128
- ];
129
- /** Error patterns for MCP/custom tools */
130
- const TOOL_ERROR_PATTERNS = [
131
- /error/i,
132
- /failed/i,
133
- /exception/i,
134
- /timeout/i,
135
- ];
136
117
  function truncate(s, maxLen) {
137
118
  return s.length <= maxLen ? s : s.substring(0, maxLen - 3) + '...';
138
119
  }
@@ -170,16 +151,14 @@ function processToolOutcome(toolName, toolInput, toolOutput, isError, sessionId)
170
151
  return result;
171
152
  }
172
153
  function isToolFailureOutput(toolName, output) {
154
+ // Bash embeds a structured exit-code marker in output — reliable signal.
173
155
  if (toolName === 'Bash' || toolName === 'bash') {
174
156
  return /Exit code (\d+)/.test(output) && !/Exit code 0/.test(output);
175
157
  }
176
- if (toolName === 'Edit' || toolName === 'Write' || toolName === 'edit' || toolName === 'write') {
177
- return WRITE_ERROR_PATTERNS.some(p => p.test(output));
178
- }
179
- // For other tools, only flag short error outputs (avoid false positives on long results)
180
- if (output.length < 500) {
181
- return TOOL_ERROR_PATTERNS.some(p => p.test(output));
182
- }
158
+ // Everything else: trust the caller's isError flag only. Content sniffing
159
+ // successful output stored bogus failures whenever a result merely
160
+ // MENTIONED words like "error" or "ENOENT" (e.g. "0 errors found", or an
161
+ // edit to a file containing error-handling code).
183
162
  return false;
184
163
  }
185
164
  function storeToolFailure(toolName, toolInput, output, _sessionId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-recall",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Persistent memory for Claude Code and Pi with native Skills integration, automatic capture, failure learning, and project scoping",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -77,7 +77,7 @@
77
77
  "registry": "https://registry.npmjs.org/"
78
78
  },
79
79
  "author": "Claude Recall Team",
80
- "license": "ISC",
80
+ "license": "MIT",
81
81
  "engines": {
82
82
  "node": ">=20.19.0"
83
83
  },
@@ -105,6 +105,6 @@
105
105
  "@anthropic-ai/sdk": "^0.110.0",
106
106
  "better-sqlite3": "^12.2.0",
107
107
  "chalk": "^5.5.0",
108
- "commander": "^12.1.0"
108
+ "commander": "^15.0.0"
109
109
  }
110
110
  }