claude-recall 0.37.1 → 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/dist/cli/claude-recall-cli.js +9 -0
- 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/storage.js +15 -0
- package/dist/services/search-monitor.js +17 -0
- package/dist/services/session-link.js +92 -0
- package/package.json +1 -1
|
@@ -1096,6 +1096,15 @@ class ClaudeRecallCLI {
|
|
|
1096
1096
|
const stats = fs.statSync(dbPath);
|
|
1097
1097
|
console.log(` Path: ${dbPath}`);
|
|
1098
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
|
+
}
|
|
1099
1108
|
}
|
|
1100
1109
|
// Memory stats
|
|
1101
1110
|
const memStats = this.memoryService.getStats();
|
|
@@ -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/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/package.json
CHANGED