claude-recall 0.34.3 → 0.35.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/README.md CHANGED
@@ -176,6 +176,7 @@ Once installed, Claude Recall works in the background (CC = Claude Code):
176
176
  | **Sub-agent spawned** | Rules are injected into the sub-agent; its outcome is captured | ✓ | | |
177
177
  | **Session exit** | An auto-checkpoint (`{completed, remaining, blockers}`) is saved for next time | ✓ | ✓ | |
178
178
  | **End of session** | Failure patterns become candidate lessons; validated ones are promoted to rules | ✓ | ✓ | |
179
+ | **Once a day** | The memory janitor reviews stored rules with the runtime's LLM: demotes noise, merges duplicates, rewrites vague rules ([details](#memory-janitor)) | ✓ | | ✓ |
179
180
 
180
181
  Classification runs on each runtime's **own** LLM — Claude Code via headless `claude -p` on your subscription; Kiro via `kiro-cli chat --no-interactive` on Kiro credits — with regex as the fallback. **An exported `ANTHROPIC_API_KEY` is never touched** unless you explicitly opt in with `CLAUDE_RECALL_PREFER_API_KEY=1`. No API key is ever required; no configuration needed.
181
182
 
@@ -333,6 +334,24 @@ action → outcome event → episode → candidate lesson → promotion → acti
333
334
 
334
335
  Failures become candidate lessons (deduplicated by similarity); lessons seen 2+ times (or once, if severe) are promoted to active rules; every just-in-time injection (Claude Code, Pi) is recorded and resolved against the tool's outcome, building per-rule effectiveness data over time.
335
336
 
337
+ ### Memory janitor
338
+
339
+ Automatic capture inevitably stores some noise — a conversational fragment misfiled as a preference, the same lesson in five wordings, a rule too vague to act on. Counters can flag *unused* rules (`CLAUDE_RECALL_AUTO_DEMOTE`), but they can't tell a rarely-cited gem from junk. Once a day, the **memory janitor** has the runtime's own LLM (same backend policy as capture — your Claude subscription or Kiro credits, never an API key unless you opted in) review the stored rules and:
340
+
341
+ - **demote** noise — misfiled conversation, malformed junk, zero-content platitudes
342
+ - **merge** duplicates into the single best phrasing
343
+ - **rewrite** vague rules into precise trigger-plus-pattern form
344
+
345
+ Guardrails: nothing is ever deleted (demotions are reversible via `rules promote <id>`, and re-teaching a demoted rule revives it); memories younger than 24h are never reviewed; at most 10 actions per run; malformed LLM output does nothing.
346
+
347
+ ```bash
348
+ claude-recall janitor --dry-run # preview what it would do
349
+ claude-recall janitor # run it now
350
+ claude-recall janitor --status # see the last automatic run
351
+ ```
352
+
353
+ Disable with `CLAUDE_RECALL_JANITOR=off`.
354
+
336
355
  ---
337
356
 
338
357
  ## Upgrading
@@ -404,6 +423,9 @@ Defaults work out of the box; tune via environment variables as needed.
404
423
  | `CLAUDE_RECALL_REFRESH_INTERVAL` | `15` | **Kiro only.** Re-inject the active rules into context every N prompts, so marathon sessions can't silently lose them to context rollover (Kiro has no post-compaction event). `0` disables. |
405
424
  | `CLAUDE_RECALL_LOAD_BUDGET_TOKENS` | `2000` | Token budget for the `load_rules` payload. Rules are emitted in priority order (corrections → preferences by citation → devops by citation → failures) and dropped rules surface via `search_memory`. |
406
425
  | `CLAUDE_RECALL_AUTO_DEMOTE` | `false` | When `true`, auto-demote rules on MCP boot where `load_count >= CLAUDE_RECALL_DEMOTE_MIN_LOADS`, `cite_count = 0`, and age `> CLAUDE_RECALL_DEMOTE_MIN_AGE_DAYS`. Still reversible via `rules promote <id>`. |
426
+ | `CLAUDE_RECALL_JANITOR` | `on` | Set to `off` to disable the daily memory-janitor pass (LLM review of stored rules — see [Memory janitor](#memory-janitor)). |
427
+ | `CLAUDE_RECALL_JANITOR_INTERVAL_HOURS` | `24` | Minimum hours between automatic janitor runs. |
428
+ | `CLAUDE_RECALL_JANITOR_GRACE_HOURS` | `24` | Memories younger than this are excluded from janitor review — a fresh learning can't be judged noise the day it was taught. |
407
429
  | `CLAUDE_RECALL_DEMOTE_MIN_LOADS` | `20` | Minimum load count before a rule qualifies for auto-demotion. |
408
430
  | `CLAUDE_RECALL_DEMOTE_MIN_AGE_DAYS` | `7` | Minimum rule age before auto-demotion can fire (avoids demoting brand-new rules). |
409
431
  | `CLAUDE_RECALL_AUTO_CLEANUP` | `false` | Auto-kill stale MCP processes on start (otherwise reports and exits). |
@@ -1932,12 +1932,58 @@ async function main() {
1932
1932
  });
1933
1933
  rulesCmd
1934
1934
  .command('promote <id>')
1935
- .description('Restore a previously auto-demoted or auto-deduped rule (safety valve)')
1935
+ .description('Restore a previously auto-demoted, auto-deduped, or janitor-demoted rule (safety valve)')
1936
1936
  .action((id) => {
1937
1937
  const cli = new ClaudeRecallCLI(program.opts());
1938
1938
  cli.promoteRule(parseInt(id));
1939
1939
  process.exit(0);
1940
1940
  });
1941
+ // Janitor: LLM-driven hygiene pass (the judgment layer above rules demote/dedup)
1942
+ program
1943
+ .command('janitor')
1944
+ .description('Review stored rules with the runtime LLM: demote noise, merge duplicates, rewrite vague rules')
1945
+ .option('--dry-run', 'Preview actions without mutating', false)
1946
+ .option('--runtime <cc|kiro>', 'Which LLM backend to use (claude -p or kiro-cli)', 'cc')
1947
+ .option('--status', 'Show the last janitor report instead of running', false)
1948
+ .action(async (options) => {
1949
+ const { handleMemoryJanitorWorker, readLastJanitorReport } = await Promise.resolve().then(() => __importStar(require('../hooks/memory-janitor')));
1950
+ if (options.status) {
1951
+ const last = readLastJanitorReport();
1952
+ if (!last) {
1953
+ console.log('No janitor run recorded yet.');
1954
+ }
1955
+ else {
1956
+ console.log(`Last run: ${new Date(last.timestamp).toLocaleString()} (runtime=${last.runtime}${last.dryRun ? ', dry-run' : ''})`);
1957
+ console.log(`Reviewed ${last.reviewed} rules, ${last.actions.length} action(s):`);
1958
+ for (const a of last.actions) {
1959
+ console.log(` ${a.applied ? '✓' : '✗'} ${a.action} [${a.ids.join(',')}]${a.replacement ? ` → "${a.replacement}"` : ''} — ${a.reason}`);
1960
+ }
1961
+ }
1962
+ process.exit(0);
1963
+ }
1964
+ if (options.runtime !== 'cc' && options.runtime !== 'kiro') {
1965
+ console.error(`Invalid --runtime "${options.runtime}" (expected cc or kiro)`);
1966
+ process.exit(1);
1967
+ }
1968
+ console.log(`Running janitor review (runtime=${options.runtime}${options.dryRun ? ', dry-run' : ''})...`);
1969
+ const report = await handleMemoryJanitorWorker({}, { dryRun: options.dryRun, runtime: options.runtime });
1970
+ if (!report) {
1971
+ console.log('Nothing to review (fewer than 2 rules past the grace period), or no LLM backend available.');
1972
+ console.log('Check ~/.claude-recall/hook-logs/memory-janitor.log for details.');
1973
+ process.exit(0);
1974
+ }
1975
+ console.log(`Reviewed ${report.reviewed} rules, ${report.actions.length} action(s)${report.dryRun ? ' (dry-run — nothing mutated)' : ''}:`);
1976
+ for (const a of report.actions) {
1977
+ console.log(` ${a.applied ? '✓' : '✗'} ${a.action} [${a.ids.join(',')}]${a.replacement ? ` → "${a.replacement}"` : ''} — ${a.reason}`);
1978
+ }
1979
+ if (report.actions.length === 0) {
1980
+ console.log(' (the LLM judged the corpus clean)');
1981
+ }
1982
+ else if (!report.dryRun) {
1983
+ console.log('\nDemotions are reversible: `claude-recall rules promote <id>`.');
1984
+ }
1985
+ process.exit(0);
1986
+ });
1941
1987
  rulesCmd
1942
1988
  .command('dedup')
1943
1989
  .description('Retroactively collapse near-duplicate rules (write-time dedup handles new writes)')
@@ -56,6 +56,7 @@ const AVAILABLE_HOOKS = [
56
56
  'kiro-tool-outcome',
57
57
  'kiro-capture',
58
58
  'kiro-capture-worker',
59
+ 'memory-janitor-worker',
59
60
  ];
60
61
  /**
61
62
  * Hook CLI Commands
@@ -177,6 +178,11 @@ class HookCommands {
177
178
  await handleKiroCaptureWorker(input);
178
179
  break;
179
180
  }
181
+ case 'memory-janitor-worker': {
182
+ const { handleMemoryJanitorWorker } = await Promise.resolve().then(() => __importStar(require('../../hooks/memory-janitor')));
183
+ await handleMemoryJanitorWorker(input);
184
+ break;
185
+ }
180
186
  default:
181
187
  console.error(`Unknown hook: ${name}`);
182
188
  console.error(`Available: ${AVAILABLE_HOOKS.join(', ')}`);
@@ -14,6 +14,39 @@
14
14
  * capture is silent and lands ~4s later. Verify via
15
15
  * ~/.claude-recall/hook-logs/cc-classifier.log or `claude-recall search`.
16
16
  */
17
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ var desc = Object.getOwnPropertyDescriptor(m, k);
20
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
21
+ desc = { enumerable: true, get: function() { return m[k]; } };
22
+ }
23
+ Object.defineProperty(o, k2, desc);
24
+ }) : (function(o, m, k, k2) {
25
+ if (k2 === undefined) k2 = k;
26
+ o[k2] = m[k];
27
+ }));
28
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
29
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
30
+ }) : function(o, v) {
31
+ o["default"] = v;
32
+ });
33
+ var __importStar = (this && this.__importStar) || (function () {
34
+ var ownKeys = function(o) {
35
+ ownKeys = Object.getOwnPropertyNames || function (o) {
36
+ var ar = [];
37
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
38
+ return ar;
39
+ };
40
+ return ownKeys(o);
41
+ };
42
+ return function (mod) {
43
+ if (mod && mod.__esModule) return mod;
44
+ var result = {};
45
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
46
+ __setModuleDefault(result, mod);
47
+ return result;
48
+ };
49
+ })();
17
50
  Object.defineProperty(exports, "__esModule", { value: true });
18
51
  exports.handleCcCapture = handleCcCapture;
19
52
  exports.handleCcCaptureWorker = handleCcCaptureWorker;
@@ -35,6 +68,16 @@ async function handleCcCapture(input) {
35
68
  || process.env.CLAUDE_RECALL_KIRO_CLASSIFIER) {
36
69
  return;
37
70
  }
71
+ // Janitor trigger rides on the first eligible prompt of the day — BEFORE
72
+ // the length pre-checks, since hygiene doesn't care whether this particular
73
+ // prompt is storable. Rate-limited internally (one run per 24h), detached.
74
+ try {
75
+ const { maybeSpawnJanitor } = await Promise.resolve().then(() => __importStar(require('./memory-janitor')));
76
+ maybeSpawnJanitor(input, 'cc');
77
+ }
78
+ catch (err) {
79
+ (0, shared_1.hookLog)(HOOK_NAME, `janitor spawn skipped: ${(0, shared_1.safeErrorMessage)(err)}`);
80
+ }
38
81
  const prompt = input?.prompt ?? '';
39
82
  if (prompt.length < 20 || prompt.length > 2000)
40
83
  return;
@@ -18,6 +18,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.CLASSIFIER_AGENT = void 0;
19
19
  exports.buildClassifyPrompt = buildClassifyPrompt;
20
20
  exports.extractClassification = extractClassification;
21
+ exports.completeWithKiroCli = completeWithKiroCli;
21
22
  exports.classifyWithKiro = classifyWithKiro;
22
23
  const child_process_1 = require("child_process");
23
24
  const shared_1 = require("./shared");
@@ -106,14 +107,17 @@ function extractClassification(raw) {
106
107
  };
107
108
  }
108
109
  /**
109
- * Classify a prompt by invoking Kiro's headless LLM. Returns null on any
110
- * failure (kiro-cli absent, timeout, non-zero exit, unparseable output) so the
111
- * caller falls back to regex. Never throws.
110
+ * Run one headless kiro-cli completion and return raw stdout, or null on any
111
+ * failure (kiro-cli absent, timeout, non-zero exit). Never throws. This is
112
+ * the generic primitive: capture classification wraps it below, and the
113
+ * memory janitor routes its review pass through it — the same role
114
+ * completeWithClaudeCli plays for the Claude Code runtime.
112
115
  */
113
- function classifyWithKiro(text) {
116
+ function completeWithKiroCli(prompt, opts = {}) {
114
117
  const model = process.env.CLAUDE_RECALL_KIRO_MODEL || DEFAULT_MODEL;
115
- const timeoutMs = parseInt(process.env.CLAUDE_RECALL_KIRO_LLM_TIMEOUT_MS || '', 10);
116
- const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
118
+ const envTimeout = parseInt(process.env.CLAUDE_RECALL_KIRO_LLM_TIMEOUT_MS || '', 10);
119
+ const timeout = opts.timeoutMs
120
+ ?? (Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : DEFAULT_TIMEOUT_MS);
117
121
  return new Promise((resolve) => {
118
122
  let settled = false;
119
123
  const done = (result) => {
@@ -124,14 +128,14 @@ function classifyWithKiro(text) {
124
128
  };
125
129
  let child;
126
130
  try {
127
- // args array (no shell) — the user's text is passed as a single argv
131
+ // args array (no shell) — the prompt is passed as a single argv
128
132
  // entry, so no shell escaping or injection is possible.
129
133
  child = (0, child_process_1.spawn)('kiro-cli', [
130
134
  'chat',
131
135
  '--no-interactive',
132
136
  '--agent', exports.CLASSIFIER_AGENT,
133
137
  '--model', model,
134
- buildClassifyPrompt(text),
138
+ prompt,
135
139
  ], { stdio: ['ignore', 'pipe', 'ignore'] });
136
140
  }
137
141
  catch (err) {
@@ -162,25 +166,37 @@ function classifyWithKiro(text) {
162
166
  (0, shared_1.hookLog)('kiro-classifier', `kiro-cli exited ${code}`);
163
167
  return done(null);
164
168
  }
165
- const result = extractClassification(stdout);
166
- if (!result) {
167
- // Distinguish a deliberate "none" verdict from unparseable output —
168
- // "the model said not a rule" and "the call broke" are different
169
- // diagnoses when reading the log.
170
- (0, shared_1.hookLog)('kiro-classifier', /"type"\s*:\s*"none"/.test(stdout)
171
- ? 'classified as none (not a durable rule)'
172
- : 'no parseable classification in kiro-cli output');
173
- }
174
- else {
175
- // Log success too, not just failures — otherwise a successful Kiro
176
- // classification is silent and indistinguishable from "never ran",
177
- // which makes "did the Kiro LLM handle this?" impossible to answer
178
- // from the log. Spell out that this ran on Kiro credits (not the
179
- // ANTHROPIC_API_KEY) using a dedicated classifier model — the `model`
180
- // is CLAUDE_RECALL_KIRO_MODEL, independent of the interactive chat model.
181
- (0, shared_1.hookLog)('kiro-classifier', `classified via kiro-cli (model=${model}, Kiro credits, no API key): ${result.type} — ${result.extract.slice(0, 60)}`);
182
- }
183
- done(result);
169
+ done(stdout);
184
170
  });
185
171
  });
186
172
  }
173
+ /**
174
+ * Classify a prompt by invoking Kiro's headless LLM. Returns null on any
175
+ * failure (kiro-cli absent, timeout, non-zero exit, unparseable output) so the
176
+ * caller falls back to regex. Never throws.
177
+ */
178
+ async function classifyWithKiro(text) {
179
+ const model = process.env.CLAUDE_RECALL_KIRO_MODEL || DEFAULT_MODEL;
180
+ const stdout = await completeWithKiroCli(buildClassifyPrompt(text));
181
+ if (stdout === null)
182
+ return null;
183
+ const result = extractClassification(stdout);
184
+ if (!result) {
185
+ // Distinguish a deliberate "none" verdict from unparseable output —
186
+ // "the model said not a rule" and "the call broke" are different
187
+ // diagnoses when reading the log.
188
+ (0, shared_1.hookLog)('kiro-classifier', /"type"\s*:\s*"none"/.test(stdout)
189
+ ? 'classified as none (not a durable rule)'
190
+ : 'no parseable classification in kiro-cli output');
191
+ }
192
+ else {
193
+ // Log success too, not just failures — otherwise a successful Kiro
194
+ // classification is silent and indistinguishable from "never ran",
195
+ // which makes "did the Kiro LLM handle this?" impossible to answer
196
+ // from the log. Spell out that this ran on Kiro credits (not the
197
+ // ANTHROPIC_API_KEY) using a dedicated classifier model — the `model`
198
+ // is CLAUDE_RECALL_KIRO_MODEL, independent of the interactive chat model.
199
+ (0, shared_1.hookLog)('kiro-classifier', `classified via kiro-cli (model=${model}, Kiro credits, no API key): ${result.type} — ${result.extract.slice(0, 60)}`);
200
+ }
201
+ return result;
202
+ }
@@ -186,6 +186,15 @@ async function handleKiroAgentSpawn(_input) {
186
186
  catch { /* checkpoint hint is best-effort */ }
187
187
  process.stdout.write(parts.join('\n\n') + '\n');
188
188
  (0, shared_1.hookLog)(HOOK_NAME, `agentSpawn: injected memory directive + ${total} rule(s) into Kiro context`);
189
+ // Session start is also the janitor's trigger: at most one detached
190
+ // hygiene pass per 24h, never blocking startup (returns in ms).
191
+ try {
192
+ const { maybeSpawnJanitor } = await Promise.resolve().then(() => __importStar(require('./memory-janitor')));
193
+ maybeSpawnJanitor(_input, 'kiro');
194
+ }
195
+ catch (err) {
196
+ (0, shared_1.hookLog)(HOOK_NAME, `janitor spawn skipped: ${(0, shared_1.safeErrorMessage)(err)}`);
197
+ }
189
198
  }
190
199
  catch (err) {
191
200
  // Never block agent startup
@@ -0,0 +1,361 @@
1
+ "use strict";
2
+ /**
3
+ * Memory janitor — LLM-driven hygiene pass over the stored rule corpus.
4
+ *
5
+ * The deterministic hygiene passes judge rules by counters (auto-demote:
6
+ * loaded often + never cited; auto-dedup: textual similarity). Counters can
7
+ * say "unused" but not "this is noise", "these five platitudes are one rule",
8
+ * or "this preference is actually a conversational fragment the classifier
9
+ * misfiled". This pass gives that judgment to the runtime's own LLM — the
10
+ * same backend policy as capture classification (claude -p on the user's
11
+ * subscription under Claude Code, kiro-cli on Kiro credits under Kiro, and
12
+ * NEVER a personally-exported ANTHROPIC_API_KEY unless the user opted in).
13
+ *
14
+ * Design guardrails:
15
+ * - Never deletes. Demotes with superseded_by='janitor' — reversible via
16
+ * `rules promote <id>`, and re-teaching identical content revives the row.
17
+ * - Grace period: memories younger than 24h are not even shown to the LLM,
18
+ * so one session's janitor can't erase what the previous session just
19
+ * learned.
20
+ * - Capped at 10 actions per run; malformed LLM output is a no-op.
21
+ * - Rate-limited to one run per 24h (state file), spawned as a DETACHED
22
+ * worker from existing session hooks — never blocks a turn, and users get
23
+ * it on upgrade with no config change.
24
+ *
25
+ * Disable with CLAUDE_RECALL_JANITOR=off. Tune with
26
+ * CLAUDE_RECALL_JANITOR_INTERVAL_HOURS / CLAUDE_RECALL_JANITOR_GRACE_HOURS.
27
+ */
28
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
29
+ if (k2 === undefined) k2 = k;
30
+ var desc = Object.getOwnPropertyDescriptor(m, k);
31
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
32
+ desc = { enumerable: true, get: function() { return m[k]; } };
33
+ }
34
+ Object.defineProperty(o, k2, desc);
35
+ }) : (function(o, m, k, k2) {
36
+ if (k2 === undefined) k2 = k;
37
+ o[k2] = m[k];
38
+ }));
39
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
40
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
41
+ }) : function(o, v) {
42
+ o["default"] = v;
43
+ });
44
+ var __importStar = (this && this.__importStar) || (function () {
45
+ var ownKeys = function(o) {
46
+ ownKeys = Object.getOwnPropertyNames || function (o) {
47
+ var ar = [];
48
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
49
+ return ar;
50
+ };
51
+ return ownKeys(o);
52
+ };
53
+ return function (mod) {
54
+ if (mod && mod.__esModule) return mod;
55
+ var result = {};
56
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
57
+ __setModuleDefault(result, mod);
58
+ return result;
59
+ };
60
+ })();
61
+ Object.defineProperty(exports, "__esModule", { value: true });
62
+ exports.claimJanitorRun = claimJanitorRun;
63
+ exports.maybeSpawnJanitor = maybeSpawnJanitor;
64
+ exports.buildJanitorPrompt = buildJanitorPrompt;
65
+ exports.parseJanitorActions = parseJanitorActions;
66
+ exports.applyJanitorActions = applyJanitorActions;
67
+ exports.handleMemoryJanitorWorker = handleMemoryJanitorWorker;
68
+ exports.readLastJanitorReport = readLastJanitorReport;
69
+ const child_process_1 = require("child_process");
70
+ const fs = __importStar(require("fs"));
71
+ const path = __importStar(require("path"));
72
+ const shared_1 = require("./shared");
73
+ const memory_1 = require("../services/memory");
74
+ const config_1 = require("../services/config");
75
+ const memory_tools_1 = require("../mcp/tools/memory-tools");
76
+ const HOOK_NAME = 'memory-janitor';
77
+ const STATE_FILE = 'janitor-state.json';
78
+ const REPORT_FILE = 'janitor-last-report.json';
79
+ const DEFAULT_INTERVAL_HOURS = 24;
80
+ const DEFAULT_GRACE_HOURS = 24;
81
+ const MAX_ACTIONS_PER_RUN = 10;
82
+ /** Review pass is one bigger completion than classification — allow more time. */
83
+ const JANITOR_TIMEOUT_MS = 60000;
84
+ const RULE_TYPES = new Set(['preference', 'correction', 'failure', 'devops', 'project-knowledge']);
85
+ function janitorDisabled() {
86
+ const v = (process.env.CLAUDE_RECALL_JANITOR || '').trim().toLowerCase();
87
+ return v === 'off' || v === 'false' || v === '0';
88
+ }
89
+ function envHours(name, fallback) {
90
+ const n = parseFloat(process.env[name] || '');
91
+ return Number.isFinite(n) && n > 0 ? n : fallback;
92
+ }
93
+ /**
94
+ * Rate limiter with claim-on-check semantics: the caller that sees the
95
+ * interval elapsed WRITES the new timestamp before spawning, so several
96
+ * sessions starting in the same minute produce one janitor run, not one each.
97
+ */
98
+ function claimJanitorRun(nowMs = Date.now()) {
99
+ const intervalMs = envHours('CLAUDE_RECALL_JANITOR_INTERVAL_HOURS', DEFAULT_INTERVAL_HOURS) * 3600000;
100
+ const statePath = path.join((0, shared_1.hookStateDir)(), STATE_FILE);
101
+ try {
102
+ const raw = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
103
+ if (typeof raw?.lastRun === 'number' && nowMs - raw.lastRun < intervalMs) {
104
+ return false;
105
+ }
106
+ }
107
+ catch { /* missing/corrupt state = never ran */ }
108
+ try {
109
+ fs.writeFileSync(statePath, JSON.stringify({ lastRun: nowMs }));
110
+ }
111
+ catch (err) {
112
+ // If the claim can't be persisted, don't run — an unwritable state file
113
+ // would otherwise mean a janitor pass on EVERY session start.
114
+ (0, shared_1.hookLog)(HOOK_NAME, `state write failed, skipping run: ${(0, shared_1.safeErrorMessage)(err)}`);
115
+ return false;
116
+ }
117
+ return true;
118
+ }
119
+ /**
120
+ * The inline half — called from existing session hooks (kiro-agent-spawn,
121
+ * cc-capture). Decides eligibility and spawns the detached worker. Returns in
122
+ * milliseconds; never throws.
123
+ */
124
+ function maybeSpawnJanitor(input, runtime) {
125
+ try {
126
+ if (janitorDisabled())
127
+ return;
128
+ // Recursion guard — a nested headless session must never run hygiene.
129
+ if (process.env.CLAUDE_RECALL_NESTED
130
+ || process.env.CLAUDE_RECALL_CC_CLASSIFIER
131
+ || process.env.CLAUDE_RECALL_KIRO_CLASSIFIER
132
+ || process.env.CLAUDE_RECALL_JANITOR_WORKER) {
133
+ return;
134
+ }
135
+ if (!claimJanitorRun())
136
+ return;
137
+ const cliPath = process.argv[1];
138
+ const child = (0, child_process_1.spawn)(process.execPath, [cliPath, 'hook', 'run', 'memory-janitor-worker'], {
139
+ detached: true,
140
+ stdio: ['pipe', 'ignore', 'ignore'],
141
+ env: { ...process.env, CLAUDE_RECALL_JANITOR_RUNTIME: runtime },
142
+ });
143
+ child.on('error', (err) => {
144
+ (0, shared_1.hookLog)(HOOK_NAME, `worker spawn error: ${err?.message ?? err}`);
145
+ });
146
+ if (child.stdin) {
147
+ child.stdin.on('error', (err) => {
148
+ (0, shared_1.hookLog)(HOOK_NAME, `worker stdin error: ${err?.message ?? err}`);
149
+ });
150
+ child.stdin.write(JSON.stringify(input ?? {}));
151
+ child.stdin.end();
152
+ }
153
+ child.unref();
154
+ (0, shared_1.hookLog)(HOOK_NAME, `spawned detached janitor worker (pid=${child.pid}, runtime=${runtime})`);
155
+ }
156
+ catch (err) {
157
+ (0, shared_1.hookLog)(HOOK_NAME, `spawn failed: ${(0, shared_1.safeErrorMessage)(err)}`);
158
+ }
159
+ }
160
+ /** Render one rule for the review prompt — id, type, counters, age, text. */
161
+ function renderRuleForReview(rule, nowMs) {
162
+ let text;
163
+ try {
164
+ text = (0, memory_tools_1.formatRuleValue)(JSON.parse(rule.value));
165
+ }
166
+ catch {
167
+ text = String(rule.value);
168
+ }
169
+ const ageDays = Math.max(0, Math.round((nowMs - rule.timestamp) / 86400000));
170
+ return JSON.stringify({
171
+ id: rule.id,
172
+ type: rule.type,
173
+ loads: rule.load_count,
174
+ cites: rule.cite_count,
175
+ age_days: ageDays,
176
+ text: text.slice(0, 400),
177
+ });
178
+ }
179
+ function buildJanitorPrompt(ruleLines) {
180
+ return ('You are a memory hygiene reviewer for a developer tool. Below are rules a ' +
181
+ 'coding agent stored automatically from past sessions. Some are valuable; ' +
182
+ 'some are noise that pollutes every future session. Review them and respond ' +
183
+ 'with ONLY minified JSON — no markdown, no prose, no code fence:\n' +
184
+ '{"actions":[{"action":"demote|merge|rewrite","ids":[<id>,...],"replacement":"<text or empty>","reason":"<short>"}]}\n\n' +
185
+ 'Actions:\n' +
186
+ '- demote: the rule is NOISE. Conversational fragments or meta-discussion ' +
187
+ 'misfiled as rules, statements about this tool or this chat, truncated/' +
188
+ 'malformed junk, or generic platitudes with zero actionable content (e.g. ' +
189
+ '"check inputs before retrying" — advice so obvious no agent needs it stored).\n' +
190
+ '- merge: two or more rules say the SAME thing in different words. ids = all ' +
191
+ 'of them; replacement = the single best phrasing (may combine details).\n' +
192
+ '- rewrite: the rule is real but too vague to act on at the moment of ' +
193
+ 'decision. ids = [the one id]; replacement = a precise version naming the ' +
194
+ 'TRIGGER and the CONCRETE pattern, e.g. "When creating a new file, match ' +
195
+ 'the naming prefix of similar files — email files are email_*.txt".\n\n' +
196
+ 'Be conservative:\n' +
197
+ '- When unsure, DO NOTHING with that rule. An empty actions array is a valid answer.\n' +
198
+ '- Never rewrite meaning — only clarity. Never merge rules that differ in substance.\n' +
199
+ '- Specific, actionable rules are valuable even if rarely used. Low usage counters alone are NOT grounds to demote.\n' +
200
+ '- Do not demote failure lessons that name a specific command, file, or error.\n\n' +
201
+ 'Rules to review (JSON per line):\n' +
202
+ ruleLines.join('\n'));
203
+ }
204
+ /**
205
+ * Parse and validate the LLM's response. Anything malformed is dropped;
206
+ * unknown ids are dropped; the total is capped. A parse failure returns [] —
207
+ * the janitor never guesses.
208
+ */
209
+ function parseJanitorActions(raw, validIds) {
210
+ let parsed;
211
+ try {
212
+ const cleaned = raw.trim().replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '');
213
+ const start = cleaned.indexOf('{');
214
+ const end = cleaned.lastIndexOf('}');
215
+ if (start === -1 || end <= start)
216
+ return [];
217
+ parsed = JSON.parse(cleaned.slice(start, end + 1));
218
+ }
219
+ catch {
220
+ return [];
221
+ }
222
+ if (!Array.isArray(parsed?.actions))
223
+ return [];
224
+ const out = [];
225
+ for (const a of parsed.actions) {
226
+ if (out.length >= MAX_ACTIONS_PER_RUN)
227
+ break;
228
+ if (!a || typeof a !== 'object')
229
+ continue;
230
+ if (a.action !== 'demote' && a.action !== 'merge' && a.action !== 'rewrite')
231
+ continue;
232
+ const ids = Array.isArray(a.ids)
233
+ ? a.ids.filter((id) => Number.isInteger(id) && validIds.has(id))
234
+ : [];
235
+ if (ids.length === 0)
236
+ continue;
237
+ const replacement = typeof a.replacement === 'string' ? a.replacement.trim() : '';
238
+ if (a.action === 'merge') {
239
+ if (ids.length < 2)
240
+ continue;
241
+ if (replacement.length < 10 || replacement.length > 300)
242
+ continue;
243
+ }
244
+ if (a.action === 'rewrite') {
245
+ if (ids.length !== 1)
246
+ continue;
247
+ if (replacement.length < 10 || replacement.length > 300)
248
+ continue;
249
+ // A rewrite that doesn't change the text is a no-op dressed as work.
250
+ }
251
+ out.push({
252
+ action: a.action,
253
+ ids,
254
+ replacement: replacement || undefined,
255
+ type: typeof a.type === 'string' && RULE_TYPES.has(a.type) ? a.type : undefined,
256
+ reason: typeof a.reason === 'string' ? a.reason.slice(0, 200) : '',
257
+ });
258
+ }
259
+ return out;
260
+ }
261
+ /**
262
+ * Apply validated actions. merge/rewrite store the replacement FIRST, then
263
+ * demote the sources — if the store throws, the originals survive untouched.
264
+ */
265
+ function applyJanitorActions(actions, rulesById, opts = {}) {
266
+ const storage = memory_1.MemoryService.getInstance().getStorage();
267
+ const results = [];
268
+ for (const action of actions) {
269
+ let applied = false;
270
+ try {
271
+ if (!opts.dryRun) {
272
+ if (action.action === 'merge' || action.action === 'rewrite') {
273
+ const type = action.type ?? rulesById.get(action.ids[0])?.type ?? 'preference';
274
+ (0, shared_1.storeMemory)(action.replacement, type, undefined, 0.9);
275
+ }
276
+ applied = storage.demoteRulesByIds(action.ids, 'janitor') > 0;
277
+ }
278
+ else {
279
+ applied = true; // would apply
280
+ }
281
+ }
282
+ catch (err) {
283
+ (0, shared_1.hookLog)(HOOK_NAME, `apply ${action.action} [${action.ids.join(',')}] failed: ${(0, shared_1.safeErrorMessage)(err)}`);
284
+ }
285
+ results.push({ ...action, applied });
286
+ }
287
+ return results;
288
+ }
289
+ /** Route one completion through the runtime's own LLM. Null = no backend. */
290
+ async function completeForJanitor(prompt, runtime) {
291
+ if (runtime === 'kiro') {
292
+ const { completeWithKiroCli } = await Promise.resolve().then(() => __importStar(require('./kiro-classifier')));
293
+ return completeWithKiroCli(prompt, { timeoutMs: JANITOR_TIMEOUT_MS });
294
+ }
295
+ if (runtime === 'cc') {
296
+ const { completeWithClaudeCli } = await Promise.resolve().then(() => __importStar(require('./cc-classifier')));
297
+ return completeWithClaudeCli(prompt, { timeoutMs: JANITOR_TIMEOUT_MS });
298
+ }
299
+ return null;
300
+ }
301
+ /**
302
+ * The worker half — runs detached (or inline via `claude-recall janitor`).
303
+ * Loads the corpus, asks the runtime's LLM for hygiene actions, applies them,
304
+ * and writes a report file the CLI can display.
305
+ */
306
+ async function handleMemoryJanitorWorker(_input, opts = {}) {
307
+ // Mark this process so any hooks fired by the nested LLM call stay inert.
308
+ process.env.CLAUDE_RECALL_JANITOR_WORKER = '1';
309
+ const runtime = opts.runtime || process.env.CLAUDE_RECALL_JANITOR_RUNTIME || 'cc';
310
+ try {
311
+ const storage = memory_1.MemoryService.getInstance().getStorage();
312
+ const projectId = config_1.ConfigService.getInstance().getProjectId();
313
+ const now = Date.now();
314
+ const graceMs = envHours('CLAUDE_RECALL_JANITOR_GRACE_HOURS', DEFAULT_GRACE_HOURS) * 3600000;
315
+ const all = storage.getActiveRules(projectId);
316
+ const reviewable = all.filter(r => now - r.timestamp > graceMs);
317
+ if (reviewable.length < 2) {
318
+ (0, shared_1.hookLog)(HOOK_NAME, `nothing to review (${all.length} active, ${reviewable.length} past grace period)`);
319
+ return null;
320
+ }
321
+ const prompt = buildJanitorPrompt(reviewable.map(r => renderRuleForReview(r, now)));
322
+ const raw = await completeForJanitor(prompt, runtime);
323
+ if (raw === null) {
324
+ (0, shared_1.hookLog)(HOOK_NAME, `no LLM backend available (runtime=${runtime}) — skipping`);
325
+ return null;
326
+ }
327
+ const validIds = new Set(reviewable.map(r => r.id));
328
+ const actions = parseJanitorActions(raw, validIds);
329
+ (0, shared_1.hookLog)(HOOK_NAME, `reviewed ${reviewable.length} rules → ${actions.length} action(s)${opts.dryRun ? ' (dry-run)' : ''}`);
330
+ const rulesById = new Map(reviewable.map(r => [r.id, { id: r.id, type: r.type }]));
331
+ const results = applyJanitorActions(actions, rulesById, { dryRun: opts.dryRun });
332
+ const report = {
333
+ timestamp: now,
334
+ runtime,
335
+ reviewed: reviewable.length,
336
+ actions: results,
337
+ dryRun: opts.dryRun ?? false,
338
+ };
339
+ try {
340
+ fs.writeFileSync(path.join((0, shared_1.hookStateDir)(), REPORT_FILE), JSON.stringify(report, null, 2));
341
+ }
342
+ catch { /* report is best-effort */ }
343
+ for (const r of results) {
344
+ (0, shared_1.hookLog)(HOOK_NAME, `${r.applied ? 'applied' : 'FAILED'} ${r.action} [${r.ids.join(',')}]: ${r.reason}`);
345
+ }
346
+ return report;
347
+ }
348
+ catch (err) {
349
+ (0, shared_1.hookLog)(HOOK_NAME, `worker error: ${(0, shared_1.safeErrorMessage)(err)}`);
350
+ return null;
351
+ }
352
+ }
353
+ /** Read the last report for CLI display. Null if none exists. */
354
+ function readLastJanitorReport() {
355
+ try {
356
+ return JSON.parse(fs.readFileSync(path.join((0, shared_1.hookStateDir)(), REPORT_FILE), 'utf-8'));
357
+ }
358
+ catch {
359
+ return null;
360
+ }
361
+ }
@@ -367,11 +367,13 @@ class MemoryStorage {
367
367
  this.db.pragma('wal_checkpoint(TRUNCATE)');
368
368
  return;
369
369
  }
370
- // Re-teaching an auto-demoted/auto-deduped rule revives it — otherwise the
371
- // dedup hit would bump a dead row that loadActiveRules never returns and
372
- // the rule would be unrecoverable through normal use. Rows superseded by a
373
- // USER override are deliberately not revived (mirrors promoteRule).
374
- const autoDemotedMatch = hashMatches.find(m => m.is_active !== 1 && (m.superseded_by === 'auto-demote' || m.superseded_by === 'auto-dedup'));
370
+ // Re-teaching an auto-demoted/auto-deduped/janitor-demoted rule revives it
371
+ // — otherwise the dedup hit would bump a dead row that loadActiveRules
372
+ // never returns and the rule would be unrecoverable through normal use.
373
+ // Deliberate re-teaching outranks the janitor's noise verdict. Rows
374
+ // superseded by a USER override are deliberately not revived (mirrors
375
+ // promoteRule).
376
+ const autoDemotedMatch = hashMatches.find(m => m.is_active !== 1 && MemoryStorage.REVIVABLE_SENTINELS.includes(m.superseded_by ?? ''));
375
377
  if (autoDemotedMatch) {
376
378
  this.db.prepare(`UPDATE memories SET timestamp = ?, access_count = access_count + 1,
377
379
  is_active = 1, superseded_by = NULL, superseded_at = NULL
@@ -812,6 +814,41 @@ class MemoryStorage {
812
814
  }
813
815
  return candidates;
814
816
  }
817
+ /**
818
+ * All active rule-type rows, for the memory janitor's review pass.
819
+ * Scoped like loadActiveRules: the given project, universal, or unscoped.
820
+ */
821
+ getActiveRules(projectId) {
822
+ const typePlaceholders = MemoryStorage.RULE_TYPES.map(() => '?').join(',');
823
+ const params = [...MemoryStorage.RULE_TYPES];
824
+ let scopeClause = `AND (project_id IS NULL OR project_id = '' OR scope = 'universal')`;
825
+ if (projectId) {
826
+ scopeClause = `AND (project_id = ? OR project_id IS NULL OR project_id = '' OR scope = 'universal')`;
827
+ params.push(projectId);
828
+ }
829
+ return this.db.prepare(`
830
+ SELECT id, key, type, value, load_count, cite_count, timestamp
831
+ FROM memories
832
+ WHERE is_active = 1 AND type IN (${typePlaceholders}) ${scopeClause}
833
+ ORDER BY timestamp ASC
834
+ `).all(...params);
835
+ }
836
+ /**
837
+ * Demote specific rows by id with a hygiene sentinel (default 'janitor').
838
+ * Reversible via promoteRule(); re-teaching identical content revives too.
839
+ * Returns the number of rows flipped.
840
+ */
841
+ demoteRulesByIds(ids, sentinel = 'janitor') {
842
+ if (ids.length === 0)
843
+ return 0;
844
+ const placeholders = ids.map(() => '?').join(',');
845
+ const result = this.db.prepare(`UPDATE memories SET is_active = 0, superseded_at = ?, superseded_by = ?
846
+ WHERE id IN (${placeholders}) AND is_active = 1`).run(Date.now(), sentinel, ...ids);
847
+ if (result.changes > 0) {
848
+ this.db.pragma('wal_checkpoint(TRUNCATE)');
849
+ }
850
+ return result.changes;
851
+ }
815
852
  /**
816
853
  * Delete rows whose stored value matches legacy test-fixture patterns.
817
854
  * Matches against json_extract(value, '$.content') OR the raw value (covers both
@@ -935,14 +972,15 @@ class MemoryStorage {
935
972
  return collapses;
936
973
  }
937
974
  /**
938
- * Restore a previously auto-demoted or auto-deduped rule. Only flips rows where
939
- * superseded_by IN ('auto-demote', 'auto-dedup') — refuses to touch rules
940
- * superseded by preference override logic (where superseded_by points at another key).
941
- * Returns true if a row was restored.
975
+ * Restore a previously auto-demoted, auto-deduped, or janitor-demoted rule.
976
+ * Only flips rows whose superseded_by is a hygiene sentinel — refuses to touch
977
+ * rules superseded by preference override logic (where superseded_by points at
978
+ * another key). Returns true if a row was restored.
942
979
  */
943
980
  promoteRule(id) {
981
+ const placeholders = MemoryStorage.REVIVABLE_SENTINELS.map(() => '?').join(',');
944
982
  const result = this.db.prepare(`UPDATE memories SET is_active = 1, superseded_at = NULL, superseded_by = NULL
945
- WHERE id = ? AND is_active = 0 AND superseded_by IN ('auto-demote', 'auto-dedup')`).run(id);
983
+ WHERE id = ? AND is_active = 0 AND superseded_by IN (${placeholders})`).run(id, ...MemoryStorage.REVIVABLE_SENTINELS);
946
984
  if (result.changes > 0) {
947
985
  this.db.pragma('wal_checkpoint(TRUNCATE)');
948
986
  }
@@ -1001,6 +1039,13 @@ class MemoryStorage {
1001
1039
  exports.MemoryStorage = MemoryStorage;
1002
1040
  /** Rule-type memories: the only types subject to fuzzy dedup and retro-dedup. */
1003
1041
  MemoryStorage.RULE_TYPES = ['preference', 'correction', 'failure', 'devops', 'project-knowledge'];
1042
+ /**
1043
+ * Supersession sentinels written by automatic hygiene passes (as opposed to
1044
+ * a USER preference override, where superseded_by names the winning key).
1045
+ * Rows carrying one of these are revivable: by content-hash re-teach in
1046
+ * save() and individually via promoteRule().
1047
+ */
1048
+ MemoryStorage.REVIVABLE_SENTINELS = ['auto-demote', 'auto-dedup', 'janitor'];
1004
1049
  // Bookkeeping fields the MCP store handler injects into every memory value.
1005
1050
  // They carry no semantic meaning, but now that sessionId is stable per
1006
1051
  // process (v0.26.0) a constant sessionId token would appear in every memory
@@ -0,0 +1,92 @@
1
+ # Positioning: claude-recall vs. steering files (and CLAUDE.md)
2
+
3
+ *Reference note, 2026-07-15. Prompted by the question: "why would I use claude-recall
4
+ if I can just use steering files?" The same argument covers CLAUDE.md and any
5
+ hand-written per-tool rules file.*
6
+
7
+ ## What steering files are
8
+
9
+ Kiro's persistent-context mechanism: markdown files the agent loads into context.
10
+
11
+ - **Workspace scope**: `.kiro/steering/` (per project, checked into the repo)
12
+ - **Global scope**: `~/.kiro/steering/` (all workspaces; MDM-distributable for teams)
13
+ - **Inclusion modes**: always (default) · conditional (file-pattern match) ·
14
+ manual (`#steering-file-name`) · auto (description match)
15
+ - Explicitly documented as "part of your codebase" — no secrets, team-visible.
16
+
17
+ Claude Code's CLAUDE.md is the same idea with fewer inclusion modes; Pi has no
18
+ equivalent.
19
+
20
+ ## The one-sentence positioning
21
+
22
+ > **Steering files are documentation; claude-recall is learning.**
23
+ > Use steering files for what your *team* has decided; use claude-recall for
24
+ > what your *sessions* have taught.
25
+
26
+ ## Where steering files win (concede cleanly)
27
+
28
+ 1. **Deliberate, curated, team-shared standards.** Tech stack, architecture
29
+ conventions, "we use pnpm." Reviewed in PRs, versioned with the code,
30
+ distributed to every teammate. Recall's DB is local and personal — it
31
+ cannot do this and shouldn't try.
32
+ 2. **Reliable inclusion under Kiro.** Always-mode steering is loaded into every
33
+ interaction by the platform itself — sturdier than any hook channel.
34
+
35
+ ## Why steering files don't replace claude-recall
36
+
37
+ 1. **Somebody has to write them.** Steering captures what you already knew and
38
+ remembered to document. Recall captures what surfaces *in the flow of work*:
39
+ correct the agent once mid-session and it's stored. Nobody stops coding to
40
+ open `.kiro/steering/conventions.md` and write "email files are
41
+ `email_*.txt`" — the stuff that burns you repeatedly is precisely the stuff
42
+ too small to feel worth documenting.
43
+
44
+ 2. **Steering files can't learn from failure.** There is no steering-file
45
+ equivalent of the outcome pipeline: tool failure → episode → candidate
46
+ lesson → promoted rule. "Exit code 0 doesn't mean the tests passed" entered
47
+ the DB because the agent got burned, not because someone wrote a postmortem.
48
+ The self-improvement rationale (agent gets better at coding/building/
49
+ reasoning from its own experience) has no steering-file mechanism at all.
50
+
51
+ 3. **One memory, every agent.** Steering is Kiro-only; CLAUDE.md is
52
+ Claude-Code-only; Pi has neither. One SQLite DB sits behind all three
53
+ runtimes: correct the agent once in Claude Code and Kiro knows it tomorrow.
54
+ With steering files you hand-maintain N parallel rule files per project per
55
+ tool.
56
+
57
+ 4. **Steering files rot; the recall corpus self-maintains.** No one prunes a
58
+ steering file — stale rules burn context forever, and the platform never
59
+ reports that a rule was ignored 44 times. Recall tracks loads/citations,
60
+ auto-demotes dead rules (`CLAUDE_RECALL_AUTO_DEMOTE`), and runs the daily
61
+ memory janitor (v0.35.0): LLM review that demotes noise, merges duplicates,
62
+ and rewrites vague rules into trigger+pattern form.
63
+
64
+ 5. **Personal ≠ repo.** Steering files are code — committed, team-visible,
65
+ unsuitable for anything user-specific ("GPG signing fails in *my* WSL",
66
+ "*I* prefer X"). Recall is per-developer and never touches the repo.
67
+
68
+ ## Roadmap idea: compose, don't compete
69
+
70
+ Recall already crystallizes memories into skill files (`.claude/skills/auto-*`).
71
+ The same mechanism could emit a **steering-file draft**:
72
+
73
+ > "Your sessions taught this project 6 durable rules — commit them to
74
+ > `.kiro/steering/` for the team?"
75
+
76
+ That turns the personal-learning layer into a feeder for the team-documentation
77
+ layer: recall discovers the rules from experience, steering distributes the
78
+ vetted ones to humans and agents alike. It converts "why not just steering
79
+ files?" from an objection into the integration story — the strongest possible
80
+ answer, because it stops being either/or.
81
+
82
+ ## Cheat sheet
83
+
84
+ | | Steering files / CLAUDE.md | claude-recall |
85
+ |---|---|---|
86
+ | Authored by | Humans, deliberately | Hooks + LLM, automatically |
87
+ | Captures | What you already know | What sessions teach (incl. failures) |
88
+ | Scope | Per tool, per repo (team-shared) | One DB across CC/Kiro/Pi (personal) |
89
+ | Learns from outcomes | No | Yes (episodes → lessons → rules) |
90
+ | Hygiene | Manual, rots | Citation tracking, auto-demote, daily janitor |
91
+ | Sensitive/personal rules | No (committed code) | Yes (local DB) |
92
+ | Team distribution | Yes (repo/MDM) | No (by design) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-recall",
3
- "version": "0.34.3",
3
+ "version": "0.35.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": {