claude-recall 0.37.0 → 0.37.2
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 +29 -2
- package/README.md +1 -1
- package/dist/cli/claude-recall-cli.js +16 -2
- package/dist/hooks/memory-sync-hook.js +101 -8
- package/dist/hooks/precompact-preserve.js +24 -16
- package/dist/hooks/rule-injector.js +95 -5
- package/dist/memory/schema.sql +0 -0
- package/dist/memory/storage.js +15 -0
- package/dist/services/search-monitor.js +17 -0
- package/dist/services/session-link.js +92 -0
- package/docs/hooks.md +1 -1
- package/package.json +1 -1
- package/scripts/uninstall.js +0 -0
- package/dist/cli/commands/live-test.js +0 -245
- package/dist/cli/commands/migrate.js +0 -317
- package/dist/core/patterns.js +0 -56
- package/dist/hooks/bash-failure-watcher.js +0 -253
- package/dist/mcp/memory-capture-middleware.js +0 -349
- package/dist/mcp/queue-tools.js +0 -532
- package/dist/mcp/tools/live-testing-tools.js +0 -231
- package/dist/mcp/tools/test-tools.js +0 -320
- package/dist/memory/database-adapter.js +0 -256
- package/dist/memory/pattern-store.js +0 -68
- package/dist/services/claude-json-watcher.js +0 -243
- package/dist/services/context-enhancer.js +0 -215
- package/dist/services/conversation-context-manager.js +0 -254
- package/dist/services/embedding-service.js +0 -183
- package/dist/services/memory-enhancer.js +0 -148
- package/dist/services/memory-evolution.js +0 -249
- package/dist/services/memory-usage-tracker.js +0 -227
- package/dist/services/preference-analyzer.js +0 -242
- package/dist/services/queue-api.js +0 -560
- package/dist/services/queue-integration.js +0 -409
- package/dist/services/queue-migration.js +0 -415
- package/dist/services/queue-system.js +0 -1092
- package/dist/services/restart-continuity.js +0 -361
- package/dist/services/semantic-preference-extractor.js +0 -432
- package/dist/testing/auto-correction-engine.js +0 -338
- package/dist/testing/live-testing-manager.js +0 -402
- package/dist/testing/mock-claude.js +0 -249
- package/dist/testing/observable-database.js +0 -178
- package/dist/testing/scenario-runner.js +0 -354
- package/dist/testing/test-orchestrator.js +0 -328
- package/docs/2026-07-follow-up-article-draft.md +0 -68
- package/docs/2026-07-follow-up-article-linkedin-tight.md +0 -46
- package/docs/2026-07-positioning-vs-steering-files.md +0 -92
- package/docs/design-hybrid-retrieval-fts5.md +0 -185
- package/docs/using-claude-code-subscription-instead-of-api-key.md +0 -69
|
@@ -16,7 +16,29 @@ from datetime import datetime
|
|
|
16
16
|
|
|
17
17
|
STATE_DIR = Path.home() / '.claude-recall' / 'hook-state'
|
|
18
18
|
SEARCH_TTL_MS = int(os.environ.get('CLAUDE_RECALL_SEARCH_TTL', 60 * 1000)) # 1 min default (once per task)
|
|
19
|
-
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _resolve_enforce_mode() -> str:
|
|
22
|
+
"""Enforcement mode: env var wins, then ~/.claude-recall/config.json
|
|
23
|
+
("enforceMode"), else default 'warn'. A file-based switch is reachable
|
|
24
|
+
from inside a running session (an agent can edit config.json to escape a
|
|
25
|
+
stuck gate); an env var set before launch is not. Default is 'warn' — this
|
|
26
|
+
gate is an advisory nudge (see module docstring), so it should never
|
|
27
|
+
hard-block a session by default."""
|
|
28
|
+
env = os.environ.get('CLAUDE_RECALL_ENFORCE_MODE')
|
|
29
|
+
if env:
|
|
30
|
+
return env.strip().lower()
|
|
31
|
+
try:
|
|
32
|
+
cfg = json.load(open(Path.home() / '.claude-recall' / 'config.json'))
|
|
33
|
+
mode = cfg.get('enforceMode')
|
|
34
|
+
if mode:
|
|
35
|
+
return str(mode).strip().lower()
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
return 'warn'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
ENFORCE_MODE = _resolve_enforce_mode() # block, warn, off
|
|
20
42
|
MAX_BLOCKS = int(os.environ.get('CLAUDE_RECALL_MAX_BLOCKS', 3)) # degrade to warn after N blocks
|
|
21
43
|
|
|
22
44
|
# Tools that count as "search performed"
|
|
@@ -182,6 +204,7 @@ STALE RULES — consider reloading before {tool_name}
|
|
|
182
204
|
|
|
183
205
|
Rules were loaded earlier but TTL expired.
|
|
184
206
|
Run: mcp__claude-recall__load_rules({{}})
|
|
207
|
+
(If not directly callable, ToolSearch it first — see below.)
|
|
185
208
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
186
209
|
"""
|
|
187
210
|
print(msg.strip(), file=sys.stderr)
|
|
@@ -214,10 +237,14 @@ LOAD RULES REQUIRED before {tool_name} (attempt {block_count}/{MAX_BLOCKS})
|
|
|
214
237
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
215
238
|
|
|
216
239
|
Run: mcp__claude-recall__load_rules({{}})
|
|
240
|
+
(If that tool is not directly callable, first run
|
|
241
|
+
ToolSearch({{query:"select:mcp__claude-recall__load_rules"}}) —
|
|
242
|
+
some harnesses defer MCP tool schemas until discovered.)
|
|
217
243
|
|
|
218
244
|
This ensures you apply user preferences and avoid past mistakes.
|
|
219
245
|
|
|
220
|
-
To disable:
|
|
246
|
+
To disable this gate: set "enforceMode":"off" in ~/.claude-recall/config.json
|
|
247
|
+
(or export CLAUDE_RECALL_ENFORCE_MODE=off before launching Claude Code).
|
|
221
248
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
222
249
|
"""
|
|
223
250
|
print(msg.strip(), file=sys.stderr)
|
package/README.md
CHANGED
|
@@ -460,7 +460,7 @@ Defaults work out of the box; tune via environment variables as needed.
|
|
|
460
460
|
| `CLAUDE_RECALL_AUTO_CLEANUP` | `false` | Auto-kill stale MCP processes on start (otherwise reports and exits). |
|
|
461
461
|
| `CLAUDE_RECALL_COMPACT_THRESHOLD` | `10MB` | DB size at which automatic compaction kicks in. |
|
|
462
462
|
| `CLAUDE_RECALL_MAX_MEMORIES` | `10000` | Memory-row soft cap. |
|
|
463
|
-
| `CLAUDE_RECALL_ENFORCE_MODE` | `
|
|
463
|
+
| `CLAUDE_RECALL_ENFORCE_MODE` | `warn` | `block` / `warn` / `off` for the search-enforcer hook. Env wins, else `~/.claude-recall/config.json` `"enforceMode"`, else `warn`. |
|
|
464
464
|
| `CLAUDE_RECALL_LLM_TIMEOUT_MS` | `5000` | Timeout for hook-context LLM calls (classification, hindsight hints). Hooks fall back to regex when it fires. |
|
|
465
465
|
| `CLAUDE_RECALL_STOP_DEBOUNCE_MS` | `300000` | Debounce for the heavy Stop-hook pipeline (episodes, session extraction, promotion). `0` disables. |
|
|
466
466
|
| `CLAUDE_RECALL_PROJECT_ID` | *(cwd)* | Pin the project scope to a fixed id, overriding working-directory detection. |
|
|
@@ -51,9 +51,10 @@ const hook_commands_1 = require("./commands/hook-commands");
|
|
|
51
51
|
const kiro_commands_1 = require("./commands/kiro-commands");
|
|
52
52
|
const repair_1 = require("./commands/repair");
|
|
53
53
|
// v14 = add PreToolUse rule-injector + Post resolver for JITRI.
|
|
54
|
+
// v15 = bound the search_enforcer PreToolUse entry with timeout: 5.
|
|
54
55
|
// Bump when the hook block template changes — setup skips the settings
|
|
55
56
|
// rewrite when the installed hooksVersion already matches.
|
|
56
|
-
const HOOKS_VERSION = '
|
|
57
|
+
const HOOKS_VERSION = '15.0.0';
|
|
57
58
|
const parse_utils_1 = require("./parse-utils");
|
|
58
59
|
const program = new commander_1.Command();
|
|
59
60
|
class ClaudeRecallCLI {
|
|
@@ -1095,6 +1096,15 @@ class ClaudeRecallCLI {
|
|
|
1095
1096
|
const stats = fs.statSync(dbPath);
|
|
1096
1097
|
console.log(` Path: ${dbPath}`);
|
|
1097
1098
|
console.log(` Size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
|
|
1099
|
+
// Surface the write-ahead log size. A WAL that has grown to a large
|
|
1100
|
+
// fraction of the DB usually means a long-lived connection isn't
|
|
1101
|
+
// checkpointing; it's truncated on the next clean shutdown (close()).
|
|
1102
|
+
const walPath = `${dbPath}-wal`;
|
|
1103
|
+
if (fs.existsSync(walPath)) {
|
|
1104
|
+
const walMb = fs.statSync(walPath).size / 1024 / 1024;
|
|
1105
|
+
const warn = walMb > 16 ? ' ⚠️ large — will truncate on next clean shutdown' : '';
|
|
1106
|
+
console.log(` WAL: ${walMb.toFixed(2)} MB${warn}`);
|
|
1107
|
+
}
|
|
1098
1108
|
}
|
|
1099
1109
|
// Memory stats
|
|
1100
1110
|
const memStats = this.memoryService.getStats();
|
|
@@ -1375,7 +1385,11 @@ async function main() {
|
|
|
1375
1385
|
hooks: [
|
|
1376
1386
|
{
|
|
1377
1387
|
type: "command",
|
|
1378
|
-
command: `python3 ${hookDest}
|
|
1388
|
+
command: `python3 ${hookDest}`,
|
|
1389
|
+
// Runs before EVERY tool call (matcher .*). Bound the python3
|
|
1390
|
+
// cold start so it can't become a per-call latency tax or an
|
|
1391
|
+
// unbounded failure mode.
|
|
1392
|
+
timeout: 5
|
|
1379
1393
|
},
|
|
1380
1394
|
{
|
|
1381
1395
|
type: "command",
|
|
@@ -75,12 +75,94 @@ function deriveAutoMemoryPath(cwd, homedir) {
|
|
|
75
75
|
* Extract display value from a memory record.
|
|
76
76
|
*/
|
|
77
77
|
function extractValue(value) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
// Always resolve to readable text. Handles every historical shape:
|
|
79
|
+
// - clean structured `{ title, description, content }` → prefer the title
|
|
80
|
+
// - failure content objects → "what_failed → what_should_do"
|
|
81
|
+
// - nested `{ content: { ... } }` / `{ content: "{...json...}" }` wrappers
|
|
82
|
+
// - stringified-JSON stored as a plain string
|
|
83
|
+
// Without this, a failure stored as `JSON.stringify(content)` rendered its
|
|
84
|
+
// raw JSON as the file title/slug (e.g. `[{"what_failed":"Bash command...`),
|
|
85
|
+
// and clean object-content memories stringified to "[object Object]".
|
|
86
|
+
let v = value;
|
|
87
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
88
|
+
if (typeof v === 'string') {
|
|
89
|
+
const t = v.trim();
|
|
90
|
+
if (t.startsWith('{') || t.startsWith('[')) {
|
|
91
|
+
try {
|
|
92
|
+
v = JSON.parse(t);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return v;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return v;
|
|
100
|
+
}
|
|
101
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
102
|
+
if (typeof v.title === 'string' && v.title.trim())
|
|
103
|
+
return v.title.trim();
|
|
104
|
+
if (typeof v.what_failed === 'string') {
|
|
105
|
+
return v.what_should_do ? `${v.what_failed} → ${v.what_should_do}` : v.what_failed;
|
|
106
|
+
}
|
|
107
|
+
const next = v.content ?? v.value ?? v.text;
|
|
108
|
+
if (next === undefined)
|
|
109
|
+
return JSON.stringify(v);
|
|
110
|
+
v = next;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
82
114
|
}
|
|
83
|
-
return
|
|
115
|
+
return typeof v === 'string' ? v : JSON.stringify(v ?? '');
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Generic default "lessons" the failure detectors emit when no fix has been
|
|
119
|
+
* paired yet. A promoted memory whose only takeaway is one of these teaches
|
|
120
|
+
* nothing — it just costs context. We skip syncing those to the file-based
|
|
121
|
+
* memory; the specific failure still lives in the DB, so fix-pairing and
|
|
122
|
+
* evidence counting are unaffected, and once a fix enriches what_should_do
|
|
123
|
+
* (e.g. "Fix: <command>") the memory syncs normally.
|
|
124
|
+
*/
|
|
125
|
+
const BOILERPLATE_LESSONS = new Set([
|
|
126
|
+
'check inputs and prerequisites before retrying',
|
|
127
|
+
'check command syntax, file paths, and prerequisites before running',
|
|
128
|
+
'review error details and adjust approach',
|
|
129
|
+
]);
|
|
130
|
+
/** Unwrap a memory value to the object that carries the failure fields, or null. */
|
|
131
|
+
function unwrapToFailureObject(value) {
|
|
132
|
+
let v = value;
|
|
133
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
134
|
+
if (typeof v === 'string') {
|
|
135
|
+
const t = v.trim();
|
|
136
|
+
if (t.startsWith('{')) {
|
|
137
|
+
try {
|
|
138
|
+
v = JSON.parse(t);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
148
|
+
if (typeof v.what_should_do === 'string' || typeof v.what_failed === 'string')
|
|
149
|
+
return v;
|
|
150
|
+
const next = v.content ?? v.value;
|
|
151
|
+
if (next === undefined)
|
|
152
|
+
return null;
|
|
153
|
+
v = next;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
function isBoilerplateFailure(rule) {
|
|
161
|
+
if (rule.crType !== 'failure')
|
|
162
|
+
return false;
|
|
163
|
+
const obj = unwrapToFailureObject(rule.value);
|
|
164
|
+
const wsd = obj && typeof obj.what_should_do === 'string' ? obj.what_should_do.trim().toLowerCase() : '';
|
|
165
|
+
return BOILERPLATE_LESSONS.has(wsd);
|
|
84
166
|
}
|
|
85
167
|
/**
|
|
86
168
|
* Check if a memory key matches test data patterns.
|
|
@@ -231,12 +313,23 @@ async function handleMemorySync(input) {
|
|
|
231
313
|
const memoryService = memory_1.MemoryService.getInstance();
|
|
232
314
|
// Get top rules ranked for sync
|
|
233
315
|
const rules = memoryService.getTopRulesForSync(projectId, MAX_SYNC_FILES);
|
|
234
|
-
// Filter out test data and
|
|
316
|
+
// Filter out test data, secrets, and boilerplate-only failure lessons.
|
|
235
317
|
const filtered = rules.filter(r => {
|
|
236
318
|
if (isTestData(r.key))
|
|
237
319
|
return false;
|
|
238
|
-
|
|
239
|
-
|
|
320
|
+
// Scan the FULL raw value for secrets, not just the display gist —
|
|
321
|
+
// extractValue now returns a summary that could omit a secret buried in
|
|
322
|
+
// a non-title field.
|
|
323
|
+
let raw;
|
|
324
|
+
try {
|
|
325
|
+
raw = typeof r.value === 'string' ? r.value : JSON.stringify(r.value);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
raw = String(r.value ?? '');
|
|
329
|
+
}
|
|
330
|
+
if (containsSecret(raw))
|
|
331
|
+
return false;
|
|
332
|
+
if (isBoilerplateFailure(r))
|
|
240
333
|
return false;
|
|
241
334
|
return true;
|
|
242
335
|
});
|
|
@@ -97,29 +97,37 @@ async function handlePrecompactPreserve(input) {
|
|
|
97
97
|
console.log(`💾 Recall: preserved ${stored} memories before context compression`);
|
|
98
98
|
}
|
|
99
99
|
(0, shared_1.hookLog)('precompact', `PreCompact sweep: stored ${stored} memories from ${entries.length} entries`);
|
|
100
|
-
// Reset
|
|
101
|
-
// after context compression. Without this, the enforcer thinks rules
|
|
102
|
-
// still loaded
|
|
103
|
-
|
|
100
|
+
// Reset per-session hook-state so Claude is forced to re-load AND re-inject
|
|
101
|
+
// rules after context compression. Without this, the enforcer thinks rules
|
|
102
|
+
// are still loaded and the rule-injector thinks it already injected them —
|
|
103
|
+
// but compaction may have dropped both from context.
|
|
104
|
+
resetSessionHookState(input?.session_id);
|
|
104
105
|
}
|
|
105
106
|
/**
|
|
106
|
-
* Delete
|
|
107
|
-
*
|
|
107
|
+
* Delete this session's per-session hook-state files, forcing a fresh
|
|
108
|
+
* load_rules gate (search enforcer) and re-injection (rule-injector) on the
|
|
109
|
+
* next tool call after compaction.
|
|
108
110
|
*/
|
|
109
|
-
function
|
|
111
|
+
function resetSessionHookState(sessionId) {
|
|
110
112
|
if (!sessionId) {
|
|
111
|
-
(0, shared_1.hookLog)('precompact', 'No session_id — cannot reset
|
|
113
|
+
(0, shared_1.hookLog)('precompact', 'No session_id — cannot reset session hook-state');
|
|
112
114
|
return;
|
|
113
115
|
}
|
|
114
116
|
const safeId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '_') || 'default';
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
const stateDir = path.join(os.homedir(), '.claude-recall', 'hook-state');
|
|
118
|
+
const stateFiles = [
|
|
119
|
+
path.join(stateDir, `${safeId}.json`), // search-enforcer gate state
|
|
120
|
+
path.join(stateDir, `rule-injector-${safeId}.json`), // rule-injector dedup set
|
|
121
|
+
];
|
|
122
|
+
for (const stateFile of stateFiles) {
|
|
123
|
+
try {
|
|
124
|
+
if (fs.existsSync(stateFile)) {
|
|
125
|
+
fs.unlinkSync(stateFile);
|
|
126
|
+
(0, shared_1.hookLog)('precompact', `Reset hook-state ${path.basename(stateFile)} for session ${safeId}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
(0, shared_1.hookLog)('precompact', `Failed to reset ${path.basename(stateFile)}: ${err.message}`);
|
|
120
131
|
}
|
|
121
|
-
}
|
|
122
|
-
catch (err) {
|
|
123
|
-
(0, shared_1.hookLog)('precompact', `Failed to reset enforcer state: ${err.message}`);
|
|
124
132
|
}
|
|
125
133
|
}
|
|
@@ -24,15 +24,52 @@
|
|
|
24
24
|
* effectiveness directly. This is the meter that replaces the broken
|
|
25
25
|
* citation-detection regex.
|
|
26
26
|
*/
|
|
27
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
28
|
+
if (k2 === undefined) k2 = k;
|
|
29
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
30
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
31
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
32
|
+
}
|
|
33
|
+
Object.defineProperty(o, k2, desc);
|
|
34
|
+
}) : (function(o, m, k, k2) {
|
|
35
|
+
if (k2 === undefined) k2 = k;
|
|
36
|
+
o[k2] = m[k];
|
|
37
|
+
}));
|
|
38
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
39
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
40
|
+
}) : function(o, v) {
|
|
41
|
+
o["default"] = v;
|
|
42
|
+
});
|
|
43
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
44
|
+
var ownKeys = function(o) {
|
|
45
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
46
|
+
var ar = [];
|
|
47
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
48
|
+
return ar;
|
|
49
|
+
};
|
|
50
|
+
return ownKeys(o);
|
|
51
|
+
};
|
|
52
|
+
return function (mod) {
|
|
53
|
+
if (mod && mod.__esModule) return mod;
|
|
54
|
+
var result = {};
|
|
55
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
56
|
+
__setModuleDefault(result, mod);
|
|
57
|
+
return result;
|
|
58
|
+
};
|
|
59
|
+
})();
|
|
27
60
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
61
|
exports.computeInjection = computeInjection;
|
|
29
62
|
exports.handleRuleInjector = handleRuleInjector;
|
|
63
|
+
const crypto_1 = require("crypto");
|
|
64
|
+
const fs = __importStar(require("fs"));
|
|
65
|
+
const path = __importStar(require("path"));
|
|
30
66
|
const shared_1 = require("./shared");
|
|
31
67
|
const memory_1 = require("../services/memory");
|
|
32
68
|
const config_1 = require("../services/config");
|
|
33
69
|
const outcome_storage_1 = require("../services/outcome-storage");
|
|
34
70
|
const rule_retrieval_1 = require("../services/rule-retrieval");
|
|
35
71
|
const memory_tools_1 = require("../mcp/tools/memory-tools");
|
|
72
|
+
const session_link_1 = require("../services/session-link");
|
|
36
73
|
const TYPE_LABELS = {
|
|
37
74
|
correction: 'correction',
|
|
38
75
|
devops: 'devops',
|
|
@@ -81,6 +118,44 @@ function formatInjection(matches, toolName) {
|
|
|
81
118
|
`but defer to safety and correctness if any conflict.\n${lines.join('\n')}\n` +
|
|
82
119
|
`</recalled-memory>`);
|
|
83
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Per-session injection dedup. The hook fires on EVERY tool call, so without
|
|
123
|
+
* this the same rule is re-injected verbatim before every Read/Bash/Edit —
|
|
124
|
+
* pure per-call token overhead, and identical repetition trains the model to
|
|
125
|
+
* tune the block out. We remember which rules were already injected this
|
|
126
|
+
* session (keyed by rule identity + a content hash, so a rule re-injects only
|
|
127
|
+
* if its content actually changed) and skip them next time.
|
|
128
|
+
*/
|
|
129
|
+
function injectionStateFile(sessionId) {
|
|
130
|
+
const safe = (sessionId || 'default').replace(/[^a-zA-Z0-9_-]/g, '_') || 'default';
|
|
131
|
+
return path.join((0, shared_1.hookStateDir)(), `rule-injector-${safe}.json`);
|
|
132
|
+
}
|
|
133
|
+
function loadInjectedSet(sessionId) {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(fs.readFileSync(injectionStateFile(sessionId), 'utf-8'));
|
|
136
|
+
if (Array.isArray(parsed?.injected))
|
|
137
|
+
return new Set(parsed.injected);
|
|
138
|
+
}
|
|
139
|
+
catch { /* no state yet — first injection this session */ }
|
|
140
|
+
return new Set();
|
|
141
|
+
}
|
|
142
|
+
function saveInjectedSet(sessionId, injected) {
|
|
143
|
+
try {
|
|
144
|
+
fs.writeFileSync(injectionStateFile(sessionId), JSON.stringify({ injected: [...injected] }), 'utf-8');
|
|
145
|
+
}
|
|
146
|
+
catch { /* best-effort — dedup is an optimization, never block the call */ }
|
|
147
|
+
}
|
|
148
|
+
function ruleInjectionId(rule) {
|
|
149
|
+
let payload;
|
|
150
|
+
try {
|
|
151
|
+
payload = JSON.stringify(rule.value);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
payload = String(rule.value);
|
|
155
|
+
}
|
|
156
|
+
const hash = (0, crypto_1.createHash)('sha1').update(payload).digest('hex').slice(0, 8);
|
|
157
|
+
return `${rule.type}:${rule.key}:${hash}`;
|
|
158
|
+
}
|
|
84
159
|
/**
|
|
85
160
|
* Runtime-agnostic core: rank active rules against this tool call, record
|
|
86
161
|
* the injections for outcome resolution, and return the formatted context
|
|
@@ -88,7 +163,7 @@ function formatInjection(matches, toolName) {
|
|
|
88
163
|
* runtime (Claude Code wants a hookSpecificOutput JSON envelope; Kiro adds
|
|
89
164
|
* raw stdout to context).
|
|
90
165
|
*/
|
|
91
|
-
async function computeInjection(toolName, toolInput, toolUseId) {
|
|
166
|
+
async function computeInjection(toolName, toolInput, toolUseId, sessionId = 'default') {
|
|
92
167
|
if (!toolName)
|
|
93
168
|
return null;
|
|
94
169
|
// Skip the hook for our own tools so we don't recursively inject rules
|
|
@@ -99,6 +174,10 @@ async function computeInjection(toolName, toolInput, toolUseId) {
|
|
|
99
174
|
}
|
|
100
175
|
const projectId = config_1.ConfigService.getInstance().getProjectId();
|
|
101
176
|
const memoryService = memory_1.MemoryService.getInstance();
|
|
177
|
+
// Record the harness session id for this project so the MCP server can
|
|
178
|
+
// correlate its own logs with hook-side state (#6). This hook runs on every
|
|
179
|
+
// tool call with the harness session_id, so the link stays fresh.
|
|
180
|
+
(0, session_link_1.writeHarnessSessionLink)(sessionId, projectId);
|
|
102
181
|
// Fetch all active rules for this project. We pass them all to the ranker
|
|
103
182
|
// because the ranking function is fast and we want sticky rules to surface
|
|
104
183
|
// even when token overlap is low.
|
|
@@ -126,10 +205,17 @@ async function computeInjection(toolName, toolInput, toolUseId) {
|
|
|
126
205
|
(0, shared_1.hookLog)('rule-injector', `No relevant rules for ${toolName} (scanned ${allRules.length})`);
|
|
127
206
|
return null;
|
|
128
207
|
}
|
|
208
|
+
// Drop rules already injected this session (unless their content changed).
|
|
209
|
+
const injected = loadInjectedSet(sessionId);
|
|
210
|
+
const freshMatches = matches.filter(m => !injected.has(ruleInjectionId(m.rule)));
|
|
211
|
+
if (freshMatches.length === 0) {
|
|
212
|
+
(0, shared_1.hookLog)('rule-injector', `All ${matches.length} matched rule(s) already injected this session (${sessionId}) — skipping`);
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
129
215
|
// Record each injection so PostToolUse can resolve it with the outcome
|
|
130
216
|
try {
|
|
131
217
|
const outcomeStorage = outcome_storage_1.OutcomeStorage.getInstance();
|
|
132
|
-
for (const m of
|
|
218
|
+
for (const m of freshMatches) {
|
|
133
219
|
outcomeStorage.recordRuleInjection({
|
|
134
220
|
rule_key: m.rule.key,
|
|
135
221
|
tool_name: toolName,
|
|
@@ -144,12 +230,16 @@ async function computeInjection(toolName, toolInput, toolUseId) {
|
|
|
144
230
|
// Non-critical — failure to record shouldn't block the injection itself
|
|
145
231
|
(0, shared_1.hookLog)('rule-injector', `Failed to record injections: ${err.message}`);
|
|
146
232
|
}
|
|
147
|
-
|
|
148
|
-
|
|
233
|
+
// Mark them injected so they don't repeat on the next tool call.
|
|
234
|
+
for (const m of freshMatches)
|
|
235
|
+
injected.add(ruleInjectionId(m.rule));
|
|
236
|
+
saveInjectedSet(sessionId, injected);
|
|
237
|
+
(0, shared_1.hookLog)('rule-injector', `Injected ${freshMatches.length} rule(s) for ${toolName} (top score=${freshMatches[0].score.toFixed(3)})`);
|
|
238
|
+
return formatInjection(freshMatches, toolName);
|
|
149
239
|
}
|
|
150
240
|
async function handleRuleInjector(input) {
|
|
151
241
|
try {
|
|
152
|
-
const additionalContext = await computeInjection(input?.tool_name ?? '', input?.tool_input ?? {}, input?.tool_use_id ?? '');
|
|
242
|
+
const additionalContext = await computeInjection(input?.tool_name ?? '', input?.tool_input ?? {}, input?.tool_use_id ?? '', input?.session_id ?? 'default');
|
|
153
243
|
if (!additionalContext) {
|
|
154
244
|
// Nothing to inject — print empty JSON so CC parses it cleanly
|
|
155
245
|
process.stdout.write('{}\n');
|
package/dist/memory/schema.sql
CHANGED
|
File without changes
|
package/dist/memory/storage.js
CHANGED
|
@@ -49,6 +49,11 @@ class MemoryStorage {
|
|
|
49
49
|
this.db.pragma('journal_mode = WAL');
|
|
50
50
|
// Ensure changes are synced to disk
|
|
51
51
|
this.db.pragma('synchronous = NORMAL');
|
|
52
|
+
// Cap WAL growth: after each checkpoint SQLite truncates the -wal file
|
|
53
|
+
// back to this ceiling, so it can't balloon (a stuck reader once left it
|
|
54
|
+
// at 34MB) and linger across restarts. Paired with a TRUNCATE checkpoint
|
|
55
|
+
// in close().
|
|
56
|
+
this.db.pragma('journal_size_limit = 8388608'); // 8 MB
|
|
52
57
|
this.initialize();
|
|
53
58
|
}
|
|
54
59
|
initialize() {
|
|
@@ -1055,6 +1060,16 @@ class MemoryStorage {
|
|
|
1055
1060
|
return this.db;
|
|
1056
1061
|
}
|
|
1057
1062
|
close() {
|
|
1063
|
+
// Flush the WAL into the main DB and truncate the -wal/-shm files so they
|
|
1064
|
+
// don't linger at tens of MB after the process exits. Best-effort: a
|
|
1065
|
+
// checkpoint can fail if another connection still holds a read lock, in
|
|
1066
|
+
// which case db.close() below still runs an implicit passive checkpoint.
|
|
1067
|
+
try {
|
|
1068
|
+
this.db.pragma('wal_checkpoint(TRUNCATE)');
|
|
1069
|
+
}
|
|
1070
|
+
catch {
|
|
1071
|
+
// ignore — proceed to close regardless
|
|
1072
|
+
}
|
|
1058
1073
|
this.db.close();
|
|
1059
1074
|
}
|
|
1060
1075
|
}
|
|
@@ -35,6 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.SearchMonitor = void 0;
|
|
37
37
|
const logging_1 = require("./logging");
|
|
38
|
+
const config_1 = require("./config");
|
|
39
|
+
const session_link_1 = require("./session-link");
|
|
38
40
|
const fs = __importStar(require("fs"));
|
|
39
41
|
const path = __importStar(require("path"));
|
|
40
42
|
const os = __importStar(require("os"));
|
|
@@ -58,6 +60,20 @@ class SearchMonitor {
|
|
|
58
60
|
fs.mkdirSync(dir, { recursive: true });
|
|
59
61
|
}
|
|
60
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Best-effort lookup of the harness session_id for this process's project.
|
|
65
|
+
* A hook records it (see session-link.ts); returns undefined if none has run
|
|
66
|
+
* yet or on any error — this must never disrupt a search.
|
|
67
|
+
*/
|
|
68
|
+
resolveHarnessSessionId() {
|
|
69
|
+
try {
|
|
70
|
+
const projectId = config_1.ConfigService.getInstance().getProjectId();
|
|
71
|
+
return (0, session_link_1.readHarnessSessionLink)(projectId) ?? undefined;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
61
77
|
recordSearch(query, resultCount, sessionId, source, context) {
|
|
62
78
|
if (!this.monitoringEnabled)
|
|
63
79
|
return;
|
|
@@ -66,6 +82,7 @@ class SearchMonitor {
|
|
|
66
82
|
query,
|
|
67
83
|
resultCount,
|
|
68
84
|
sessionId,
|
|
85
|
+
harnessSessionId: this.resolveHarnessSessionId(),
|
|
69
86
|
source,
|
|
70
87
|
context
|
|
71
88
|
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.harnessSessionLinkPath = harnessSessionLinkPath;
|
|
37
|
+
exports.writeHarnessSessionLink = writeHarnessSessionLink;
|
|
38
|
+
exports.readHarnessSessionLink = readHarnessSessionLink;
|
|
39
|
+
/**
|
|
40
|
+
* Session-identity bridge (#6).
|
|
41
|
+
*
|
|
42
|
+
* There are two unrelated session ids in the system:
|
|
43
|
+
* - Claude Code's harness `session_id`, known only to the hook processes
|
|
44
|
+
* (they receive it on stdin) and used to name hook-state files.
|
|
45
|
+
* - The long-lived MCP server's own per-process id (`session_<ts>_<rand>`),
|
|
46
|
+
* which is what ends up in search-monitor.log.
|
|
47
|
+
*
|
|
48
|
+
* Because they never met, hook-side state couldn't be correlated with
|
|
49
|
+
* MCP-side activity when debugging. This module is the meeting point: a hook
|
|
50
|
+
* that has the harness id writes it here (keyed by project); the MCP server
|
|
51
|
+
* reads it so it can stamp the harness id onto its own logs.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately dependency-free (fs/path/os only) so both the hooks layer and
|
|
54
|
+
* the services layer can import it without creating a cycle. The directory
|
|
55
|
+
* mirrors the hook-state dir used elsewhere (CLAUDE_RECALL_DB_PATH override or
|
|
56
|
+
* ~/.claude-recall).
|
|
57
|
+
*/
|
|
58
|
+
const fs = __importStar(require("fs"));
|
|
59
|
+
const path = __importStar(require("path"));
|
|
60
|
+
const os = __importStar(require("os"));
|
|
61
|
+
function linkDir() {
|
|
62
|
+
const base = process.env.CLAUDE_RECALL_DB_PATH || path.join(os.homedir(), '.claude-recall');
|
|
63
|
+
return path.join(base, 'hook-state');
|
|
64
|
+
}
|
|
65
|
+
function safeId(id) {
|
|
66
|
+
return (id || 'default').replace(/[^a-zA-Z0-9_-]/g, '_') || 'default';
|
|
67
|
+
}
|
|
68
|
+
function harnessSessionLinkPath(projectId) {
|
|
69
|
+
return path.join(linkDir(), `harness-session-${safeId(projectId)}.json`);
|
|
70
|
+
}
|
|
71
|
+
/** Persist the harness session id for a project. No-op for empty/'default'. */
|
|
72
|
+
function writeHarnessSessionLink(sessionId, projectId) {
|
|
73
|
+
if (!sessionId || sessionId === 'default')
|
|
74
|
+
return;
|
|
75
|
+
try {
|
|
76
|
+
fs.mkdirSync(linkDir(), { recursive: true });
|
|
77
|
+
fs.writeFileSync(harnessSessionLinkPath(projectId), JSON.stringify({ harnessSessionId: sessionId, projectId, updatedAt: Date.now() }), 'utf8');
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// best-effort — correlation is a debugging aid, never block the caller
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Read the most recently recorded harness session id for a project, or null. */
|
|
84
|
+
function readHarnessSessionLink(projectId) {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(fs.readFileSync(harnessSessionLinkPath(projectId), 'utf8'));
|
|
87
|
+
return typeof parsed?.harnessSessionId === 'string' ? parsed.harnessSessionId : null;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
package/docs/hooks.md
CHANGED
|
@@ -58,7 +58,7 @@ Common read-only commands are exempt from enforcement:
|
|
|
58
58
|
| Environment Variable | Default | Description |
|
|
59
59
|
|---|---|---|
|
|
60
60
|
| `CLAUDE_RECALL_SEARCH_TTL` | `300000` (5 min) | Milliseconds a search remains valid |
|
|
61
|
-
| `CLAUDE_RECALL_ENFORCE_MODE` | `
|
|
61
|
+
| `CLAUDE_RECALL_ENFORCE_MODE` | `warn` | `block` (exit 2), `warn` (exit 0 + stderr message), or `off` (disabled). Env wins; otherwise read from `~/.claude-recall/config.json` → `"enforceMode"`; else `warn`. The file switch is reachable from inside a running session — an env var set before launch is not. |
|
|
62
62
|
|
|
63
63
|
### Exit Codes
|
|
64
64
|
|
package/package.json
CHANGED
package/scripts/uninstall.js
CHANGED
|
File without changes
|