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.
- package/.claude/hooks/search_enforcer.py +35 -16
- package/dist/cli/claude-recall-cli.js +89 -32
- package/dist/cli/commands/mcp-commands.js +15 -9
- package/dist/cli/commands/project-commands.js +12 -1
- package/dist/cli/parse-utils.js +32 -0
- package/dist/core/retrieval.js +57 -17
- package/dist/hooks/failure-detectors.js +22 -2
- package/dist/hooks/llm-classifier.js +6 -2
- package/dist/hooks/memory-stop-hook.js +8 -4
- package/dist/hooks/memory-sync-hook.js +6 -2
- package/dist/hooks/session-end-checkpoint-worker.js +11 -0
- package/dist/hooks/session-end-checkpoint.js +9 -0
- package/dist/hooks/shared.js +21 -8
- package/dist/hooks/tool-outcome-watcher.js +12 -65
- package/dist/memory/schema.sql +1 -1
- package/dist/memory/storage.js +17 -2
- package/dist/services/logging.js +36 -1
- package/dist/services/memory.js +9 -7
- package/dist/services/outcome-storage.js +13 -4
- package/dist/services/project-registry.js +15 -0
- package/dist/shared/event-processors.js +5 -26
- package/package.json +3 -3
|
@@ -36,8 +36,13 @@ PASSTHROUGH_TOOLS = [
|
|
|
36
36
|
'AskUserQuestion', # User interaction
|
|
37
37
|
]
|
|
38
38
|
|
|
39
|
-
#
|
|
40
|
-
|
|
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
|
|
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
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
#
|
|
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
|
|
149
|
-
if tool_name == 'Bash' and
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1120
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
1828
|
-
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:
|
|
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
|
-
.
|
|
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:
|
|
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
|
-
|
|
176
|
+
const pid = status.pid;
|
|
177
|
+
console.log(`Sending ${signal} to PID ${chalk_1.default.yellow(pid)}...`);
|
|
177
178
|
try {
|
|
178
|
-
this.processManager.killProcess(
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
const
|
|
184
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|
package/dist/core/retrieval.js
CHANGED
|
@@ -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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
233
|
+
// Outcome tables may not exist yet — stats are an optional boost
|
|
217
234
|
}
|
|
235
|
+
return map;
|
|
218
236
|
}
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
|
287
|
-
if (ix.call.name
|
|
296
|
+
// Exclude read-only tools — reading/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
|
|
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
|
-
|
|
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
|
|
198
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
}
|
package/dist/hooks/shared.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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.
|
|
74
|
-
{ regex: /\bI\s+prefer\s+(.+)/i, confidence: 0.
|
|
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
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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),
|
package/dist/memory/schema.sql
CHANGED
|
@@ -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'
|
|
18
|
+
scope TEXT CHECK(scope IS NULL OR scope IN ('universal', 'project')),
|
|
19
19
|
content_hash TEXT
|
|
20
20
|
);
|
|
21
21
|
|
package/dist/memory/storage.js
CHANGED
|
@@ -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'
|
|
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
|
-
|
|
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';
|
package/dist/services/logging.js
CHANGED
|
@@ -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
|
-
|
|
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`
|
package/dist/services/memory.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
//
|
|
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.
|
|
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": "
|
|
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": "^
|
|
108
|
+
"commander": "^15.0.0"
|
|
109
109
|
}
|
|
110
110
|
}
|