claude-recall 0.34.3 → 0.36.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 +39 -0
- package/dist/cli/claude-recall-cli.js +47 -1
- package/dist/cli/commands/hook-commands.js +6 -0
- package/dist/hooks/cc-capture.js +43 -0
- package/dist/hooks/correction-detector.js +9 -1
- package/dist/hooks/kiro-classifier.js +55 -29
- package/dist/hooks/kiro-hooks.js +10 -1
- package/dist/hooks/llm-classifier.js +6 -1
- package/dist/hooks/memory-janitor.js +397 -0
- package/dist/hooks/shared.js +5 -2
- package/dist/mcp/tools/memory-tools.js +15 -3
- package/dist/memory/storage.js +79 -12
- package/dist/services/memory.js +2 -2
- package/docs/2026-07-positioning-vs-steering-files.md +92 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,21 @@ $ claude-recall search "pnpm"
|
|
|
42
42
|
|
|
43
43
|
---
|
|
44
44
|
|
|
45
|
+
## Why not just steering files or CLAUDE.md?
|
|
46
|
+
|
|
47
|
+
Use them! They're the right tool for **team standards you already know** — written by hand, reviewed in PRs, shipped with the repo. Claude Recall covers what they structurally can't:
|
|
48
|
+
|
|
49
|
+
- **Nobody writes it down.** Steering files hold what you remembered to document. Recall captures rules *in the flow of work* — you correct the agent once, mid-session, and it's stored. The stuff that burns you repeatedly is exactly the stuff too small to feel worth documenting.
|
|
50
|
+
- **They can't learn from failure.** There's no steering-file mechanism for *tool failure → lesson → rule*. "Exit code 0 doesn't mean the tests passed" enters Recall because the agent got burned — not because someone wrote a postmortem.
|
|
51
|
+
- **One memory, every agent.** Steering is Kiro-only, CLAUDE.md is Claude-Code-only. Recall is one local DB behind Claude Code, Kiro, and Pi — correct one agent, all of them know.
|
|
52
|
+
- **They rot.** Nobody prunes a rules file. Recall tracks whether rules are actually used, demotes dead ones, and a daily LLM janitor merges duplicates and cleans noise.
|
|
53
|
+
- **Personal ≠ repo.** "GPG signing fails in *my* WSL" doesn't belong in a committed file. Recall is per-developer and never touches your repo.
|
|
54
|
+
- **They bloat context.** An always-included steering file ships its full text into every interaction, relevant or not, and only ever grows. Recall's injection is token-budgeted, surfaces rules relevant to the action at hand, and the corpus self-prunes — the context cost stays bounded as the memory grows.
|
|
55
|
+
|
|
56
|
+
In short: steering files are documentation — what your team decided. Claude Recall is memory — what your sessions taught.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
45
60
|
## Features
|
|
46
61
|
|
|
47
62
|
- **Automatic capture** — an LLM classifier detects preferences, corrections, and project facts in your normal prompts, running on **the agent's own LLM** (Claude subscription / Kiro credits — never a separate API key), with regex fallback when no LLM is available
|
|
@@ -176,9 +191,12 @@ Once installed, Claude Recall works in the background (CC = Claude Code):
|
|
|
176
191
|
| **Sub-agent spawned** | Rules are injected into the sub-agent; its outcome is captured | ✓ | | |
|
|
177
192
|
| **Session exit** | An auto-checkpoint (`{completed, remaining, blockers}`) is saved for next time | ✓ | ✓ | |
|
|
178
193
|
| **End of session** | Failure patterns become candidate lessons; validated ones are promoted to rules | ✓ | ✓ | |
|
|
194
|
+
| **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
195
|
|
|
180
196
|
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
197
|
|
|
198
|
+
Captured rules are kept **precise and current**: the classifier phrases each rule as *trigger + concrete pattern* where your message allows ("email files must start with email" → *"When creating an email text file, name it `email_*.txt`"*); a rule too vague to act on is stored but flagged, and the agent is nudged at injection time to ask you for a precise restatement. When you restate an existing rule in new words, the **new phrasing supersedes the old row** (counters carry over) instead of creating a duplicate or being swallowed by the old wording.
|
|
199
|
+
|
|
182
200
|
```bash
|
|
183
201
|
# Verify it's working
|
|
184
202
|
claude-recall stats
|
|
@@ -333,6 +351,24 @@ action → outcome event → episode → candidate lesson → promotion → acti
|
|
|
333
351
|
|
|
334
352
|
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
353
|
|
|
354
|
+
### Memory janitor
|
|
355
|
+
|
|
356
|
+
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:
|
|
357
|
+
|
|
358
|
+
- **demote** noise — misfiled conversation, malformed junk, zero-content platitudes
|
|
359
|
+
- **merge** duplicates into the single best phrasing
|
|
360
|
+
- **rewrite** vague rules into precise trigger-plus-pattern form
|
|
361
|
+
|
|
362
|
+
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.
|
|
363
|
+
|
|
364
|
+
```bash
|
|
365
|
+
claude-recall janitor --dry-run # preview what it would do
|
|
366
|
+
claude-recall janitor # run it now
|
|
367
|
+
claude-recall janitor --status # see the last automatic run
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Disable with `CLAUDE_RECALL_JANITOR=off`.
|
|
371
|
+
|
|
336
372
|
---
|
|
337
373
|
|
|
338
374
|
## Upgrading
|
|
@@ -404,6 +440,9 @@ Defaults work out of the box; tune via environment variables as needed.
|
|
|
404
440
|
| `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
441
|
| `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
442
|
| `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>`. |
|
|
443
|
+
| `CLAUDE_RECALL_JANITOR` | `on` | Set to `off` to disable the daily memory-janitor pass (LLM review of stored rules — see [Memory janitor](#memory-janitor)). |
|
|
444
|
+
| `CLAUDE_RECALL_JANITOR_INTERVAL_HOURS` | `24` | Minimum hours between automatic janitor runs. |
|
|
445
|
+
| `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
446
|
| `CLAUDE_RECALL_DEMOTE_MIN_LOADS` | `20` | Minimum load count before a rule qualifies for auto-demotion. |
|
|
408
447
|
| `CLAUDE_RECALL_DEMOTE_MIN_AGE_DAYS` | `7` | Minimum rule age before auto-demotion can fire (avoids demoting brand-new rules). |
|
|
409
448
|
| `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
|
|
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(', ')}`);
|
package/dist/hooks/cc-capture.js
CHANGED
|
@@ -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;
|
|
@@ -65,7 +65,15 @@ async function handleCorrectionDetector(input) {
|
|
|
65
65
|
(0, shared_1.hookLog)('correction-detector', `Skipped duplicate: ${result.extract.substring(0, 80)}`);
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
|
-
|
|
68
|
+
// fuzzyNewestWins: a user restating an existing rule in new words is a
|
|
69
|
+
// deliberate refinement — the new phrasing supersedes the old row instead
|
|
70
|
+
// of being absorbed into it. needsPrecision: vague-but-durable rules are
|
|
71
|
+
// stored (losing what the user said is worse) but flagged so injection
|
|
72
|
+
// nudges the agent to ask for a precise restatement.
|
|
73
|
+
(0, shared_1.storeMemory)(result.extract, result.type, undefined, result.confidence, {
|
|
74
|
+
needsPrecision: result.precision === 'vague',
|
|
75
|
+
fuzzyNewestWins: true,
|
|
76
|
+
});
|
|
69
77
|
const summary = result.extract.length > 60
|
|
70
78
|
? result.extract.substring(0, 60) + '...'
|
|
71
79
|
: result.extract;
|
|
@@ -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");
|
|
@@ -45,7 +46,7 @@ function buildClassifyPrompt(text) {
|
|
|
45
46
|
return ('You are a memory classifier for a developer tool. Classify the USER MESSAGE ' +
|
|
46
47
|
'into exactly one type and respond with ONLY minified JSON — no markdown, no ' +
|
|
47
48
|
'prose, no code fence:\n' +
|
|
48
|
-
'{"type":"correction|preference|failure|devops|project-knowledge|none","confidence":0.0-1.0,"extract":"<concise imperative rule to remember, or empty>"}\n\n' +
|
|
49
|
+
'{"type":"correction|preference|failure|devops|project-knowledge|none","confidence":0.0-1.0,"extract":"<concise imperative rule to remember, or empty>","precision":"precise|vague"}\n\n' +
|
|
49
50
|
'Types:\n' +
|
|
50
51
|
'- correction: user durably correcting how something should ALWAYS be done ("no, use X not Y"). Correcting a one-off action in the current task is NOT durable.\n' +
|
|
51
52
|
'- preference: a reusable directive about how the user wants things done ("I prefer X", "we use tabs", "my favourite color is green"). Must apply beyond this one message.\n' +
|
|
@@ -60,7 +61,14 @@ function buildClassifyPrompt(text) {
|
|
|
60
61
|
'- Reject meta-conversation about this tool or what to write/do next.\n\n' +
|
|
61
62
|
'Be conservative: when in doubt use "none" with confidence 0. Use confidence >= 0.75 for correction/preference/devops. ' +
|
|
62
63
|
'extract must be a clean standalone rule (e.g. "Favourite colour is green"), or empty when type is none.\n\n' +
|
|
63
|
-
'
|
|
64
|
+
'PRECISION — a rule is applied at a future moment of decision, so make the extract as precise as the message allows: ' +
|
|
65
|
+
'name the TRIGGER (when it applies) and the CONCRETE pattern (what exactly to do), e.g. ' +
|
|
66
|
+
'"email files must start with email" → "When creating an email text file, name it email_*.txt". ' +
|
|
67
|
+
'Never invent details the message does not contain. Add "precision":"vague" when the rule is durable but its ' +
|
|
68
|
+
'trigger or concrete pattern is missing and cannot be inferred (e.g. "name docs so they sort together" — sort by what?); ' +
|
|
69
|
+
'otherwise "precision":"precise".\n\n' +
|
|
70
|
+
'Examples: "we use pnpm, not npm" → {"type":"preference","confidence":0.9,"extract":"Use pnpm, not npm","precision":"precise"}; ' +
|
|
71
|
+
'"name docs so they sort together" → {"type":"preference","confidence":0.8,"extract":"Name documents so they sort together in the file explorer","precision":"vague"}; ' +
|
|
64
72
|
'"first fix the sentence" → {"type":"none","confidence":0,"extract":""}; ' +
|
|
65
73
|
'"then run claude-recall kiro setup" → {"type":"none","confidence":0,"extract":""}.\n\n' +
|
|
66
74
|
'USER MESSAGE: ' + text);
|
|
@@ -103,17 +111,23 @@ function extractClassification(raw) {
|
|
|
103
111
|
type: parsed.type,
|
|
104
112
|
confidence,
|
|
105
113
|
extract: parsed.extract.trim(),
|
|
114
|
+
...(parsed.precision === 'vague' || parsed.precision === 'precise'
|
|
115
|
+
? { precision: parsed.precision }
|
|
116
|
+
: {}),
|
|
106
117
|
};
|
|
107
118
|
}
|
|
108
119
|
/**
|
|
109
|
-
*
|
|
110
|
-
* failure (kiro-cli absent, timeout, non-zero exit
|
|
111
|
-
*
|
|
120
|
+
* Run one headless kiro-cli completion and return raw stdout, or null on any
|
|
121
|
+
* failure (kiro-cli absent, timeout, non-zero exit). Never throws. This is
|
|
122
|
+
* the generic primitive: capture classification wraps it below, and the
|
|
123
|
+
* memory janitor routes its review pass through it — the same role
|
|
124
|
+
* completeWithClaudeCli plays for the Claude Code runtime.
|
|
112
125
|
*/
|
|
113
|
-
function
|
|
126
|
+
function completeWithKiroCli(prompt, opts = {}) {
|
|
114
127
|
const model = process.env.CLAUDE_RECALL_KIRO_MODEL || DEFAULT_MODEL;
|
|
115
|
-
const
|
|
116
|
-
const timeout =
|
|
128
|
+
const envTimeout = parseInt(process.env.CLAUDE_RECALL_KIRO_LLM_TIMEOUT_MS || '', 10);
|
|
129
|
+
const timeout = opts.timeoutMs
|
|
130
|
+
?? (Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : DEFAULT_TIMEOUT_MS);
|
|
117
131
|
return new Promise((resolve) => {
|
|
118
132
|
let settled = false;
|
|
119
133
|
const done = (result) => {
|
|
@@ -124,14 +138,14 @@ function classifyWithKiro(text) {
|
|
|
124
138
|
};
|
|
125
139
|
let child;
|
|
126
140
|
try {
|
|
127
|
-
// args array (no shell) — the
|
|
141
|
+
// args array (no shell) — the prompt is passed as a single argv
|
|
128
142
|
// entry, so no shell escaping or injection is possible.
|
|
129
143
|
child = (0, child_process_1.spawn)('kiro-cli', [
|
|
130
144
|
'chat',
|
|
131
145
|
'--no-interactive',
|
|
132
146
|
'--agent', exports.CLASSIFIER_AGENT,
|
|
133
147
|
'--model', model,
|
|
134
|
-
|
|
148
|
+
prompt,
|
|
135
149
|
], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
136
150
|
}
|
|
137
151
|
catch (err) {
|
|
@@ -162,25 +176,37 @@ function classifyWithKiro(text) {
|
|
|
162
176
|
(0, shared_1.hookLog)('kiro-classifier', `kiro-cli exited ${code}`);
|
|
163
177
|
return done(null);
|
|
164
178
|
}
|
|
165
|
-
|
|
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);
|
|
179
|
+
done(stdout);
|
|
184
180
|
});
|
|
185
181
|
});
|
|
186
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Classify a prompt by invoking Kiro's headless LLM. Returns null on any
|
|
185
|
+
* failure (kiro-cli absent, timeout, non-zero exit, unparseable output) so the
|
|
186
|
+
* caller falls back to regex. Never throws.
|
|
187
|
+
*/
|
|
188
|
+
async function classifyWithKiro(text) {
|
|
189
|
+
const model = process.env.CLAUDE_RECALL_KIRO_MODEL || DEFAULT_MODEL;
|
|
190
|
+
const stdout = await completeWithKiroCli(buildClassifyPrompt(text));
|
|
191
|
+
if (stdout === null)
|
|
192
|
+
return null;
|
|
193
|
+
const result = extractClassification(stdout);
|
|
194
|
+
if (!result) {
|
|
195
|
+
// Distinguish a deliberate "none" verdict from unparseable output —
|
|
196
|
+
// "the model said not a rule" and "the call broke" are different
|
|
197
|
+
// diagnoses when reading the log.
|
|
198
|
+
(0, shared_1.hookLog)('kiro-classifier', /"type"\s*:\s*"none"/.test(stdout)
|
|
199
|
+
? 'classified as none (not a durable rule)'
|
|
200
|
+
: 'no parseable classification in kiro-cli output');
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
// Log success too, not just failures — otherwise a successful Kiro
|
|
204
|
+
// classification is silent and indistinguishable from "never ran",
|
|
205
|
+
// which makes "did the Kiro LLM handle this?" impossible to answer
|
|
206
|
+
// from the log. Spell out that this ran on Kiro credits (not the
|
|
207
|
+
// ANTHROPIC_API_KEY) using a dedicated classifier model — the `model`
|
|
208
|
+
// is CLAUDE_RECALL_KIRO_MODEL, independent of the interactive chat model.
|
|
209
|
+
(0, shared_1.hookLog)('kiro-classifier', `classified via kiro-cli (model=${model}, Kiro credits, no API key): ${result.type} — ${result.extract.slice(0, 60)}`);
|
|
210
|
+
}
|
|
211
|
+
return result;
|
|
212
|
+
}
|
package/dist/hooks/kiro-hooks.js
CHANGED
|
@@ -120,7 +120,7 @@ function formatRulesForContext() {
|
|
|
120
120
|
const section = (title, items) => {
|
|
121
121
|
if (items.length === 0)
|
|
122
122
|
return null;
|
|
123
|
-
return `## ${title}\n` + items.map(m => `- ${(0, memory_tools_1.formatRuleValue)(m.value)}`).join('\n');
|
|
123
|
+
return `## ${title}\n` + items.map(m => `- ${(0, memory_tools_1.formatRuleValue)(m.value)}${(0, memory_tools_1.precisionNudge)(m.value)}`).join('\n');
|
|
124
124
|
};
|
|
125
125
|
const sections = [
|
|
126
126
|
section('Preferences', rules.preferences),
|
|
@@ -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
|
|
@@ -37,7 +37,9 @@ const SYSTEM_PROMPT = `You are a memory classifier for a developer tool. Classif
|
|
|
37
37
|
- none: Casual conversation, questions, code snippets, one-off task instructions, meta-conversation, sentence fragments, or anything not worth remembering across sessions
|
|
38
38
|
|
|
39
39
|
Respond with ONLY valid JSON (no markdown fences). Format:
|
|
40
|
-
{"type":"<type>","confidence":<0.0-1.0>,"extract":"<the key fact to remember, concise>"}
|
|
40
|
+
{"type":"<type>","confidence":<0.0-1.0>,"extract":"<the key fact to remember, concise>","precision":"precise|vague"}
|
|
41
|
+
|
|
42
|
+
PRECISION — a rule is applied at a future moment of decision, so make the extract as precise as the text allows: name the TRIGGER (when it applies) and the CONCRETE pattern (what exactly to do), e.g. "email files must start with email" → "When creating an email text file, name it email_*.txt". Never invent details the text does not contain. Set "precision":"vague" when the rule is durable but its trigger or concrete pattern is missing and cannot be inferred; otherwise "precision":"precise".
|
|
41
43
|
|
|
42
44
|
THE STANDALONE TEST — apply this BEFORE classifying anything as correction/preference/devops:
|
|
43
45
|
A durable rule must make complete sense on its own, read cold in a future session with NO knowledge of this conversation. If the text needs the surrounding chat to be understood, it is "none".
|
|
@@ -219,6 +221,9 @@ async function classifyWithLLM(text) {
|
|
|
219
221
|
type: result.type,
|
|
220
222
|
confidence: result.confidence,
|
|
221
223
|
extract: result.extract,
|
|
224
|
+
...(result.precision === 'vague' || result.precision === 'precise'
|
|
225
|
+
? { precision: result.precision }
|
|
226
|
+
: {}),
|
|
222
227
|
};
|
|
223
228
|
}
|
|
224
229
|
catch {
|
|
@@ -0,0 +1,397 @@
|
|
|
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.dropCosmeticRewrites = dropCosmeticRewrites;
|
|
65
|
+
exports.buildJanitorPrompt = buildJanitorPrompt;
|
|
66
|
+
exports.parseJanitorActions = parseJanitorActions;
|
|
67
|
+
exports.applyJanitorActions = applyJanitorActions;
|
|
68
|
+
exports.handleMemoryJanitorWorker = handleMemoryJanitorWorker;
|
|
69
|
+
exports.readLastJanitorReport = readLastJanitorReport;
|
|
70
|
+
const child_process_1 = require("child_process");
|
|
71
|
+
const fs = __importStar(require("fs"));
|
|
72
|
+
const path = __importStar(require("path"));
|
|
73
|
+
const shared_1 = require("./shared");
|
|
74
|
+
const memory_1 = require("../services/memory");
|
|
75
|
+
const config_1 = require("../services/config");
|
|
76
|
+
const memory_tools_1 = require("../mcp/tools/memory-tools");
|
|
77
|
+
const HOOK_NAME = 'memory-janitor';
|
|
78
|
+
const STATE_FILE = 'janitor-state.json';
|
|
79
|
+
const REPORT_FILE = 'janitor-last-report.json';
|
|
80
|
+
const DEFAULT_INTERVAL_HOURS = 24;
|
|
81
|
+
const DEFAULT_GRACE_HOURS = 24;
|
|
82
|
+
const MAX_ACTIONS_PER_RUN = 10;
|
|
83
|
+
/** Review pass is one bigger completion than classification — allow more time. */
|
|
84
|
+
const JANITOR_TIMEOUT_MS = 60000;
|
|
85
|
+
const RULE_TYPES = new Set(['preference', 'correction', 'failure', 'devops', 'project-knowledge']);
|
|
86
|
+
function janitorDisabled() {
|
|
87
|
+
const v = (process.env.CLAUDE_RECALL_JANITOR || '').trim().toLowerCase();
|
|
88
|
+
return v === 'off' || v === 'false' || v === '0';
|
|
89
|
+
}
|
|
90
|
+
function envHours(name, fallback) {
|
|
91
|
+
const n = parseFloat(process.env[name] || '');
|
|
92
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Rate limiter with claim-on-check semantics: the caller that sees the
|
|
96
|
+
* interval elapsed WRITES the new timestamp before spawning, so several
|
|
97
|
+
* sessions starting in the same minute produce one janitor run, not one each.
|
|
98
|
+
*/
|
|
99
|
+
function claimJanitorRun(nowMs = Date.now()) {
|
|
100
|
+
const intervalMs = envHours('CLAUDE_RECALL_JANITOR_INTERVAL_HOURS', DEFAULT_INTERVAL_HOURS) * 3600000;
|
|
101
|
+
const statePath = path.join((0, shared_1.hookStateDir)(), STATE_FILE);
|
|
102
|
+
try {
|
|
103
|
+
const raw = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
104
|
+
if (typeof raw?.lastRun === 'number' && nowMs - raw.lastRun < intervalMs) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch { /* missing/corrupt state = never ran */ }
|
|
109
|
+
try {
|
|
110
|
+
fs.writeFileSync(statePath, JSON.stringify({ lastRun: nowMs }));
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
// If the claim can't be persisted, don't run — an unwritable state file
|
|
114
|
+
// would otherwise mean a janitor pass on EVERY session start.
|
|
115
|
+
(0, shared_1.hookLog)(HOOK_NAME, `state write failed, skipping run: ${(0, shared_1.safeErrorMessage)(err)}`);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The inline half — called from existing session hooks (kiro-agent-spawn,
|
|
122
|
+
* cc-capture). Decides eligibility and spawns the detached worker. Returns in
|
|
123
|
+
* milliseconds; never throws.
|
|
124
|
+
*/
|
|
125
|
+
function maybeSpawnJanitor(input, runtime) {
|
|
126
|
+
try {
|
|
127
|
+
if (janitorDisabled())
|
|
128
|
+
return;
|
|
129
|
+
// Recursion guard — a nested headless session must never run hygiene.
|
|
130
|
+
if (process.env.CLAUDE_RECALL_NESTED
|
|
131
|
+
|| process.env.CLAUDE_RECALL_CC_CLASSIFIER
|
|
132
|
+
|| process.env.CLAUDE_RECALL_KIRO_CLASSIFIER
|
|
133
|
+
|| process.env.CLAUDE_RECALL_JANITOR_WORKER) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!claimJanitorRun())
|
|
137
|
+
return;
|
|
138
|
+
const cliPath = process.argv[1];
|
|
139
|
+
const child = (0, child_process_1.spawn)(process.execPath, [cliPath, 'hook', 'run', 'memory-janitor-worker'], {
|
|
140
|
+
detached: true,
|
|
141
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
142
|
+
env: { ...process.env, CLAUDE_RECALL_JANITOR_RUNTIME: runtime },
|
|
143
|
+
});
|
|
144
|
+
child.on('error', (err) => {
|
|
145
|
+
(0, shared_1.hookLog)(HOOK_NAME, `worker spawn error: ${err?.message ?? err}`);
|
|
146
|
+
});
|
|
147
|
+
if (child.stdin) {
|
|
148
|
+
child.stdin.on('error', (err) => {
|
|
149
|
+
(0, shared_1.hookLog)(HOOK_NAME, `worker stdin error: ${err?.message ?? err}`);
|
|
150
|
+
});
|
|
151
|
+
child.stdin.write(JSON.stringify(input ?? {}));
|
|
152
|
+
child.stdin.end();
|
|
153
|
+
}
|
|
154
|
+
child.unref();
|
|
155
|
+
(0, shared_1.hookLog)(HOOK_NAME, `spawned detached janitor worker (pid=${child.pid}, runtime=${runtime})`);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
(0, shared_1.hookLog)(HOOK_NAME, `spawn failed: ${(0, shared_1.safeErrorMessage)(err)}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** Threshold above which a rewrite is judged cosmetic and dropped. */
|
|
162
|
+
const REWRITE_MIN_CHANGE_SIMILARITY = 0.8;
|
|
163
|
+
/** Extract a rule's display text from its stored value. */
|
|
164
|
+
function ruleText(rule) {
|
|
165
|
+
try {
|
|
166
|
+
return (0, memory_tools_1.formatRuleValue)(JSON.parse(rule.value));
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return String(rule.value);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Render one rule for the review prompt — id, type, counters, age, text. */
|
|
173
|
+
function renderRuleForReview(rule, nowMs) {
|
|
174
|
+
const ageDays = Math.max(0, Math.round((nowMs - rule.timestamp) / 86400000));
|
|
175
|
+
return JSON.stringify({
|
|
176
|
+
id: rule.id,
|
|
177
|
+
type: rule.type,
|
|
178
|
+
loads: rule.load_count,
|
|
179
|
+
cites: rule.cite_count,
|
|
180
|
+
age_days: ageDays,
|
|
181
|
+
text: ruleText(rule).slice(0, 400),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Deterministic backstop against cosmetic rewrites: whatever the LLM claims,
|
|
186
|
+
* a rewrite whose replacement is near-identical to the original changes
|
|
187
|
+
* nothing at decision time and just churns the row. Observed live on the
|
|
188
|
+
* first wild run ("already specific and actionable; minor clarification
|
|
189
|
+
* only" — and it rewrote anyway).
|
|
190
|
+
*/
|
|
191
|
+
function dropCosmeticRewrites(actions, textById) {
|
|
192
|
+
return actions.filter((a) => {
|
|
193
|
+
if (a.action !== 'rewrite')
|
|
194
|
+
return true;
|
|
195
|
+
const original = textById.get(a.ids[0]) ?? '';
|
|
196
|
+
const similarity = (0, shared_1.jaccardSimilarity)(original, a.replacement ?? '');
|
|
197
|
+
if (similarity >= REWRITE_MIN_CHANGE_SIMILARITY) {
|
|
198
|
+
(0, shared_1.hookLog)(HOOK_NAME, `dropped cosmetic rewrite [${a.ids[0]}] (similarity=${similarity.toFixed(2)})`);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function buildJanitorPrompt(ruleLines) {
|
|
205
|
+
return ('You are a memory hygiene reviewer for a developer tool. Below are rules a ' +
|
|
206
|
+
'coding agent stored automatically from past sessions. Some are valuable; ' +
|
|
207
|
+
'some are noise that pollutes every future session. Review them and respond ' +
|
|
208
|
+
'with ONLY minified JSON — no markdown, no prose, no code fence:\n' +
|
|
209
|
+
'{"actions":[{"action":"demote|merge|rewrite","ids":[<id>,...],"replacement":"<text or empty>","reason":"<short>"}]}\n\n' +
|
|
210
|
+
'Actions:\n' +
|
|
211
|
+
'- demote: the rule is NOISE. Conversational fragments or meta-discussion ' +
|
|
212
|
+
'misfiled as rules, statements about this tool or this chat, truncated/' +
|
|
213
|
+
'malformed junk, or generic platitudes with zero actionable content (e.g. ' +
|
|
214
|
+
'"check inputs before retrying" — advice so obvious no agent needs it stored).\n' +
|
|
215
|
+
'- merge: two or more rules say the SAME thing in different words. ids = all ' +
|
|
216
|
+
'of them; replacement = the single best phrasing (may combine details).\n' +
|
|
217
|
+
'- rewrite: the rule is real but too vague to act on at the moment of ' +
|
|
218
|
+
'decision. ids = [the one id]; replacement = a precise version naming the ' +
|
|
219
|
+
'TRIGGER and the CONCRETE pattern, e.g. "When creating a new file, match ' +
|
|
220
|
+
'the naming prefix of similar files — email files are email_*.txt".\n\n' +
|
|
221
|
+
'Be conservative:\n' +
|
|
222
|
+
'- When unsure, DO NOTHING with that rule. An empty actions array is a valid answer.\n' +
|
|
223
|
+
'- If a rule is ALREADY precise, do not rewrite it. A cosmetic rephrasing or ' +
|
|
224
|
+
'"minor clarification" is not an action — rewrite ONLY when the current wording ' +
|
|
225
|
+
'would fail to trigger at the moment of decision.\n' +
|
|
226
|
+
'- Never rewrite meaning — only clarity. Never merge rules that differ in substance.\n' +
|
|
227
|
+
'- Specific, actionable rules are valuable even if rarely used. Low usage counters alone are NOT grounds to demote.\n' +
|
|
228
|
+
'- Do not demote failure lessons that name a specific command, file, or error.\n\n' +
|
|
229
|
+
'Rules to review (JSON per line):\n' +
|
|
230
|
+
ruleLines.join('\n'));
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Parse and validate the LLM's response. Anything malformed is dropped;
|
|
234
|
+
* unknown ids are dropped; the total is capped. A parse failure returns [] —
|
|
235
|
+
* the janitor never guesses.
|
|
236
|
+
*/
|
|
237
|
+
function parseJanitorActions(raw, validIds) {
|
|
238
|
+
let parsed;
|
|
239
|
+
try {
|
|
240
|
+
const cleaned = raw.trim().replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '');
|
|
241
|
+
const start = cleaned.indexOf('{');
|
|
242
|
+
const end = cleaned.lastIndexOf('}');
|
|
243
|
+
if (start === -1 || end <= start)
|
|
244
|
+
return [];
|
|
245
|
+
parsed = JSON.parse(cleaned.slice(start, end + 1));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
if (!Array.isArray(parsed?.actions))
|
|
251
|
+
return [];
|
|
252
|
+
const out = [];
|
|
253
|
+
for (const a of parsed.actions) {
|
|
254
|
+
if (out.length >= MAX_ACTIONS_PER_RUN)
|
|
255
|
+
break;
|
|
256
|
+
if (!a || typeof a !== 'object')
|
|
257
|
+
continue;
|
|
258
|
+
if (a.action !== 'demote' && a.action !== 'merge' && a.action !== 'rewrite')
|
|
259
|
+
continue;
|
|
260
|
+
const ids = Array.isArray(a.ids)
|
|
261
|
+
? a.ids.filter((id) => Number.isInteger(id) && validIds.has(id))
|
|
262
|
+
: [];
|
|
263
|
+
if (ids.length === 0)
|
|
264
|
+
continue;
|
|
265
|
+
const replacement = typeof a.replacement === 'string' ? a.replacement.trim() : '';
|
|
266
|
+
if (a.action === 'merge') {
|
|
267
|
+
if (ids.length < 2)
|
|
268
|
+
continue;
|
|
269
|
+
if (replacement.length < 10 || replacement.length > 300)
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (a.action === 'rewrite') {
|
|
273
|
+
if (ids.length !== 1)
|
|
274
|
+
continue;
|
|
275
|
+
if (replacement.length < 10 || replacement.length > 300)
|
|
276
|
+
continue;
|
|
277
|
+
// A rewrite that doesn't change the text is a no-op dressed as work.
|
|
278
|
+
}
|
|
279
|
+
out.push({
|
|
280
|
+
action: a.action,
|
|
281
|
+
ids,
|
|
282
|
+
replacement: replacement || undefined,
|
|
283
|
+
type: typeof a.type === 'string' && RULE_TYPES.has(a.type) ? a.type : undefined,
|
|
284
|
+
reason: typeof a.reason === 'string' ? a.reason.slice(0, 200) : '',
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Apply validated actions. merge/rewrite store the replacement FIRST, then
|
|
291
|
+
* demote the sources — if the store throws, the originals survive untouched.
|
|
292
|
+
*/
|
|
293
|
+
function applyJanitorActions(actions, rulesById, opts = {}) {
|
|
294
|
+
const storage = memory_1.MemoryService.getInstance().getStorage();
|
|
295
|
+
const results = [];
|
|
296
|
+
for (const action of actions) {
|
|
297
|
+
let applied = false;
|
|
298
|
+
try {
|
|
299
|
+
if (!opts.dryRun) {
|
|
300
|
+
if (action.action === 'merge' || action.action === 'rewrite') {
|
|
301
|
+
const type = action.type ?? rulesById.get(action.ids[0])?.type ?? 'preference';
|
|
302
|
+
// fuzzyNewestWins: without it, a replacement similar to the rule it
|
|
303
|
+
// replaces gets absorbed into that still-active row by fuzzy dedup —
|
|
304
|
+
// then the demote below would destroy both versions.
|
|
305
|
+
(0, shared_1.storeMemory)(action.replacement, type, undefined, 0.9, { fuzzyNewestWins: true });
|
|
306
|
+
}
|
|
307
|
+
const demoted = storage.demoteRulesByIds(action.ids, 'janitor');
|
|
308
|
+
// For merge/rewrite the store above already succeeded (no throw), and
|
|
309
|
+
// newest-wins supersession may have retired the source row before the
|
|
310
|
+
// demote ran (changes=0) — the action still applied.
|
|
311
|
+
applied = action.action === 'demote' ? demoted > 0 : true;
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
applied = true; // would apply
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
(0, shared_1.hookLog)(HOOK_NAME, `apply ${action.action} [${action.ids.join(',')}] failed: ${(0, shared_1.safeErrorMessage)(err)}`);
|
|
319
|
+
}
|
|
320
|
+
results.push({ ...action, applied });
|
|
321
|
+
}
|
|
322
|
+
return results;
|
|
323
|
+
}
|
|
324
|
+
/** Route one completion through the runtime's own LLM. Null = no backend. */
|
|
325
|
+
async function completeForJanitor(prompt, runtime) {
|
|
326
|
+
if (runtime === 'kiro') {
|
|
327
|
+
const { completeWithKiroCli } = await Promise.resolve().then(() => __importStar(require('./kiro-classifier')));
|
|
328
|
+
return completeWithKiroCli(prompt, { timeoutMs: JANITOR_TIMEOUT_MS });
|
|
329
|
+
}
|
|
330
|
+
if (runtime === 'cc') {
|
|
331
|
+
const { completeWithClaudeCli } = await Promise.resolve().then(() => __importStar(require('./cc-classifier')));
|
|
332
|
+
return completeWithClaudeCli(prompt, { timeoutMs: JANITOR_TIMEOUT_MS });
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The worker half — runs detached (or inline via `claude-recall janitor`).
|
|
338
|
+
* Loads the corpus, asks the runtime's LLM for hygiene actions, applies them,
|
|
339
|
+
* and writes a report file the CLI can display.
|
|
340
|
+
*/
|
|
341
|
+
async function handleMemoryJanitorWorker(_input, opts = {}) {
|
|
342
|
+
// Mark this process so any hooks fired by the nested LLM call stay inert.
|
|
343
|
+
process.env.CLAUDE_RECALL_JANITOR_WORKER = '1';
|
|
344
|
+
const runtime = opts.runtime || process.env.CLAUDE_RECALL_JANITOR_RUNTIME || 'cc';
|
|
345
|
+
try {
|
|
346
|
+
const storage = memory_1.MemoryService.getInstance().getStorage();
|
|
347
|
+
const projectId = config_1.ConfigService.getInstance().getProjectId();
|
|
348
|
+
const now = Date.now();
|
|
349
|
+
const graceMs = envHours('CLAUDE_RECALL_JANITOR_GRACE_HOURS', DEFAULT_GRACE_HOURS) * 3600000;
|
|
350
|
+
const all = storage.getActiveRules(projectId);
|
|
351
|
+
const reviewable = all.filter(r => now - r.timestamp > graceMs);
|
|
352
|
+
if (reviewable.length < 2) {
|
|
353
|
+
(0, shared_1.hookLog)(HOOK_NAME, `nothing to review (${all.length} active, ${reviewable.length} past grace period)`);
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
const prompt = buildJanitorPrompt(reviewable.map(r => renderRuleForReview(r, now)));
|
|
357
|
+
const raw = await completeForJanitor(prompt, runtime);
|
|
358
|
+
if (raw === null) {
|
|
359
|
+
(0, shared_1.hookLog)(HOOK_NAME, `no LLM backend available (runtime=${runtime}) — skipping`);
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const validIds = new Set(reviewable.map(r => r.id));
|
|
363
|
+
const textById = new Map(reviewable.map(r => [r.id, ruleText(r)]));
|
|
364
|
+
const actions = dropCosmeticRewrites(parseJanitorActions(raw, validIds), textById);
|
|
365
|
+
(0, shared_1.hookLog)(HOOK_NAME, `reviewed ${reviewable.length} rules → ${actions.length} action(s)${opts.dryRun ? ' (dry-run)' : ''}`);
|
|
366
|
+
const rulesById = new Map(reviewable.map(r => [r.id, { id: r.id, type: r.type }]));
|
|
367
|
+
const results = applyJanitorActions(actions, rulesById, { dryRun: opts.dryRun });
|
|
368
|
+
const report = {
|
|
369
|
+
timestamp: now,
|
|
370
|
+
runtime,
|
|
371
|
+
reviewed: reviewable.length,
|
|
372
|
+
actions: results,
|
|
373
|
+
dryRun: opts.dryRun ?? false,
|
|
374
|
+
};
|
|
375
|
+
try {
|
|
376
|
+
fs.writeFileSync(path.join((0, shared_1.hookStateDir)(), REPORT_FILE), JSON.stringify(report, null, 2));
|
|
377
|
+
}
|
|
378
|
+
catch { /* report is best-effort */ }
|
|
379
|
+
for (const r of results) {
|
|
380
|
+
(0, shared_1.hookLog)(HOOK_NAME, `${r.applied ? 'applied' : 'FAILED'} ${r.action} [${r.ids.join(',')}]: ${r.reason}`);
|
|
381
|
+
}
|
|
382
|
+
return report;
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
(0, shared_1.hookLog)(HOOK_NAME, `worker error: ${(0, shared_1.safeErrorMessage)(err)}`);
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/** Read the last report for CLI display. Null if none exists. */
|
|
390
|
+
function readLastJanitorReport() {
|
|
391
|
+
try {
|
|
392
|
+
return JSON.parse(fs.readFileSync(path.join((0, shared_1.hookStateDir)(), REPORT_FILE), 'utf-8'));
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
}
|
package/dist/hooks/shared.js
CHANGED
|
@@ -275,7 +275,7 @@ function isDuplicate(content, existingMemories, threshold = 0.7) {
|
|
|
275
275
|
/**
|
|
276
276
|
* Store a memory via MemoryService (in-process, no subprocess).
|
|
277
277
|
*/
|
|
278
|
-
function storeMemory(content, type, projectId, confidence = 0.8) {
|
|
278
|
+
function storeMemory(content, type, projectId, confidence = 0.8, opts) {
|
|
279
279
|
const memoryService = memory_1.MemoryService.getInstance();
|
|
280
280
|
const key = `hook_${type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
281
281
|
memoryService.store({
|
|
@@ -285,6 +285,9 @@ function storeMemory(content, type, projectId, confidence = 0.8) {
|
|
|
285
285
|
confidence,
|
|
286
286
|
source: 'hook-auto-capture',
|
|
287
287
|
timestamp: Date.now(),
|
|
288
|
+
// Vague-but-durable rule: injection nudges the agent to ask the user
|
|
289
|
+
// for a precise restatement (trigger + concrete pattern).
|
|
290
|
+
...(opts?.needsPrecision ? { needs_precision: true } : {}),
|
|
288
291
|
},
|
|
289
292
|
type,
|
|
290
293
|
context: {
|
|
@@ -292,7 +295,7 @@ function storeMemory(content, type, projectId, confidence = 0.8) {
|
|
|
292
295
|
timestamp: Date.now(),
|
|
293
296
|
},
|
|
294
297
|
relevanceScore: confidence,
|
|
295
|
-
});
|
|
298
|
+
}, { fuzzyNewestWins: opts?.fuzzyNewestWins });
|
|
296
299
|
}
|
|
297
300
|
/**
|
|
298
301
|
* Search existing memories for dedup comparison.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.MemoryTools = void 0;
|
|
4
|
+
exports.precisionNudge = precisionNudge;
|
|
4
5
|
exports.formatRuleValue = formatRuleValue;
|
|
5
6
|
const config_1 = require("../../services/config");
|
|
6
7
|
const search_monitor_1 = require("../../services/search-monitor");
|
|
@@ -25,6 +26,17 @@ const directives_1 = require("../../shared/directives");
|
|
|
25
26
|
*
|
|
26
27
|
* Exported for direct unit testing in tests/unit/format-rule-value.test.ts.
|
|
27
28
|
*/
|
|
29
|
+
/**
|
|
30
|
+
* Injection-time nudge for rules the capture classifier graded as vague
|
|
31
|
+
* (needs_precision in the stored value). Hooks have no interactive channel to
|
|
32
|
+
* the user, but the AGENT does — the marker instructs it to ask for a precise
|
|
33
|
+
* restatement, whose capture then supersedes this row via fuzzy newest-wins.
|
|
34
|
+
*/
|
|
35
|
+
function precisionNudge(value) {
|
|
36
|
+
return value && typeof value === 'object' && value.needs_precision === true
|
|
37
|
+
? ' ⚠️ [vague — ask the user to restate this rule with its trigger and concrete pattern, e.g. "when X, do Y"]'
|
|
38
|
+
: '';
|
|
39
|
+
}
|
|
28
40
|
function formatRuleValue(value) {
|
|
29
41
|
if (value == null)
|
|
30
42
|
return '';
|
|
@@ -333,7 +345,7 @@ class MemoryTools {
|
|
|
333
345
|
const sections = [];
|
|
334
346
|
if (keptPreferences.length > 0) {
|
|
335
347
|
sections.push('## Preferences\n' + keptPreferences.map(m => {
|
|
336
|
-
const val = formatRuleValue(m.value);
|
|
348
|
+
const val = formatRuleValue(m.value) + precisionNudge(m.value);
|
|
337
349
|
const key = m.preference_key || m.key || '';
|
|
338
350
|
const isAutoKey = key.startsWith('memory_') || key.startsWith('auto_') || key.startsWith('pref_');
|
|
339
351
|
return isAutoKey ? `- ${val}` : `- ${key}: ${val}`;
|
|
@@ -341,7 +353,7 @@ class MemoryTools {
|
|
|
341
353
|
}
|
|
342
354
|
if (keptCorrections.length > 0) {
|
|
343
355
|
sections.push('## Corrections\n' + keptCorrections.map(m => {
|
|
344
|
-
const val = formatRuleValue(m.value);
|
|
356
|
+
const val = formatRuleValue(m.value) + precisionNudge(m.value);
|
|
345
357
|
const isPromoted = m.key.startsWith('promoted_') || m.value?.source === 'promotion-engine';
|
|
346
358
|
const evidence = isPromoted && m.value?.evidence_count ? ` (learned from ${m.value.evidence_count} observations)` : '';
|
|
347
359
|
return isPromoted ? `- [promoted lesson] ${val}${evidence}` : `- ${val}`;
|
|
@@ -366,7 +378,7 @@ class MemoryTools {
|
|
|
366
378
|
}
|
|
367
379
|
if (keptDevops.length > 0) {
|
|
368
380
|
sections.push('## DevOps Rules\n' + keptDevops.map(m => {
|
|
369
|
-
const val = formatRuleValue(m.value);
|
|
381
|
+
const val = formatRuleValue(m.value) + precisionNudge(m.value);
|
|
370
382
|
return `- ${val}`;
|
|
371
383
|
}).join('\n'));
|
|
372
384
|
}
|
package/dist/memory/storage.js
CHANGED
|
@@ -346,7 +346,7 @@ class MemoryStorage {
|
|
|
346
346
|
const union = wordsA.size + wordsB.size - intersection;
|
|
347
347
|
return union === 0 ? 0 : intersection / union;
|
|
348
348
|
}
|
|
349
|
-
save(memory) {
|
|
349
|
+
save(memory, opts) {
|
|
350
350
|
const contentHash = this.computeContentHash(memory.value, memory.type);
|
|
351
351
|
// Write-time dedup: identical content already stored under a different key.
|
|
352
352
|
// Scoped to the same project (or universal/unscoped) — without the project
|
|
@@ -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
|
|
371
|
-
// dedup hit would bump a dead row that loadActiveRules
|
|
372
|
-
// the rule would be unrecoverable through normal use.
|
|
373
|
-
//
|
|
374
|
-
|
|
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
|
|
@@ -386,11 +388,24 @@ class MemoryStorage {
|
|
|
386
388
|
const fuzzyMatch = MemoryStorage.RULE_TYPES.includes(memory.type)
|
|
387
389
|
? this.findFuzzyDuplicate(memory)
|
|
388
390
|
: null;
|
|
389
|
-
if (fuzzyMatch) {
|
|
391
|
+
if (fuzzyMatch && !opts?.fuzzyNewestWins) {
|
|
390
392
|
this.db.prepare('UPDATE memories SET timestamp = ?, access_count = access_count + 1 WHERE key = ?').run(Date.now(), fuzzyMatch);
|
|
391
393
|
this.db.pragma('wal_checkpoint(TRUNCATE)');
|
|
392
394
|
return;
|
|
393
395
|
}
|
|
396
|
+
// Newest-wins supersession (opt-in, user-prompt captures and janitor
|
|
397
|
+
// replacements): a user restating a rule in different words is a
|
|
398
|
+
// deliberate refinement — the NEW phrasing must win, not be absorbed into
|
|
399
|
+
// the old row (which is what the bump above does, and which silently
|
|
400
|
+
// discarded precise re-teaches of vague rules). The new row is inserted
|
|
401
|
+
// below; the old row is superseded BY KEY (not a hygiene sentinel), so it
|
|
402
|
+
// is not revivable — it was replaced, not judged noise. Compliance
|
|
403
|
+
// counters carry over so demotion/ranking history isn't reset by a
|
|
404
|
+
// rewording.
|
|
405
|
+
let fuzzySupersede = null;
|
|
406
|
+
if (fuzzyMatch && opts?.fuzzyNewestWins && fuzzyMatch !== memory.key) {
|
|
407
|
+
fuzzySupersede = { key: fuzzyMatch };
|
|
408
|
+
}
|
|
394
409
|
// Upsert instead of INSERT OR REPLACE: OR REPLACE deletes and reinserts,
|
|
395
410
|
// which resets columns absent from the insert list (load_count, cite_count,
|
|
396
411
|
// last_accessed) and churns the rowid. A same-key re-save must not destroy
|
|
@@ -418,6 +433,15 @@ class MemoryStorage {
|
|
|
418
433
|
content_hash = excluded.content_hash
|
|
419
434
|
`);
|
|
420
435
|
stmt.run(memory.key, JSON.stringify(memory.value), memory.type, memory.project_id || null, memory.file_path || null, memory.timestamp || Date.now(), memory.relevance_score || 1.0, memory.access_count || 0, memory.preference_key || null, memory.is_active !== undefined ? (memory.is_active ? 1 : 0) : 1, memory.superseded_by || null, memory.superseded_at || null, memory.confidence_score || null, memory.sophistication_level || 1, memory.scope || null, contentHash);
|
|
436
|
+
// Newest-wins: retire the fuzzy-matched old row in favor of the row just
|
|
437
|
+
// written, carrying its compliance counters over.
|
|
438
|
+
if (fuzzySupersede) {
|
|
439
|
+
const old = this.db.prepare('SELECT load_count, cite_count FROM memories WHERE key = ?').get(fuzzySupersede.key);
|
|
440
|
+
this.db.prepare(`UPDATE memories SET is_active = 0, superseded_by = ?, superseded_at = ? WHERE key = ?`).run(memory.key, Date.now(), fuzzySupersede.key);
|
|
441
|
+
if (old) {
|
|
442
|
+
this.db.prepare('UPDATE memories SET load_count = ?, cite_count = ? WHERE key = ?').run(old.load_count || 0, old.cite_count || 0, memory.key);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
421
445
|
// Force a WAL checkpoint to ensure the data is written to the main database file
|
|
422
446
|
// This ensures that other processes (like CLI) can see the changes immediately
|
|
423
447
|
this.db.pragma('wal_checkpoint(TRUNCATE)');
|
|
@@ -812,6 +836,41 @@ class MemoryStorage {
|
|
|
812
836
|
}
|
|
813
837
|
return candidates;
|
|
814
838
|
}
|
|
839
|
+
/**
|
|
840
|
+
* All active rule-type rows, for the memory janitor's review pass.
|
|
841
|
+
* Scoped like loadActiveRules: the given project, universal, or unscoped.
|
|
842
|
+
*/
|
|
843
|
+
getActiveRules(projectId) {
|
|
844
|
+
const typePlaceholders = MemoryStorage.RULE_TYPES.map(() => '?').join(',');
|
|
845
|
+
const params = [...MemoryStorage.RULE_TYPES];
|
|
846
|
+
let scopeClause = `AND (project_id IS NULL OR project_id = '' OR scope = 'universal')`;
|
|
847
|
+
if (projectId) {
|
|
848
|
+
scopeClause = `AND (project_id = ? OR project_id IS NULL OR project_id = '' OR scope = 'universal')`;
|
|
849
|
+
params.push(projectId);
|
|
850
|
+
}
|
|
851
|
+
return this.db.prepare(`
|
|
852
|
+
SELECT id, key, type, value, load_count, cite_count, timestamp
|
|
853
|
+
FROM memories
|
|
854
|
+
WHERE is_active = 1 AND type IN (${typePlaceholders}) ${scopeClause}
|
|
855
|
+
ORDER BY timestamp ASC
|
|
856
|
+
`).all(...params);
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Demote specific rows by id with a hygiene sentinel (default 'janitor').
|
|
860
|
+
* Reversible via promoteRule(); re-teaching identical content revives too.
|
|
861
|
+
* Returns the number of rows flipped.
|
|
862
|
+
*/
|
|
863
|
+
demoteRulesByIds(ids, sentinel = 'janitor') {
|
|
864
|
+
if (ids.length === 0)
|
|
865
|
+
return 0;
|
|
866
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
867
|
+
const result = this.db.prepare(`UPDATE memories SET is_active = 0, superseded_at = ?, superseded_by = ?
|
|
868
|
+
WHERE id IN (${placeholders}) AND is_active = 1`).run(Date.now(), sentinel, ...ids);
|
|
869
|
+
if (result.changes > 0) {
|
|
870
|
+
this.db.pragma('wal_checkpoint(TRUNCATE)');
|
|
871
|
+
}
|
|
872
|
+
return result.changes;
|
|
873
|
+
}
|
|
815
874
|
/**
|
|
816
875
|
* Delete rows whose stored value matches legacy test-fixture patterns.
|
|
817
876
|
* Matches against json_extract(value, '$.content') OR the raw value (covers both
|
|
@@ -935,14 +994,15 @@ class MemoryStorage {
|
|
|
935
994
|
return collapses;
|
|
936
995
|
}
|
|
937
996
|
/**
|
|
938
|
-
* Restore a previously auto-demoted
|
|
939
|
-
* superseded_by
|
|
940
|
-
* superseded by preference override logic (where superseded_by points at
|
|
941
|
-
* Returns true if a row was restored.
|
|
997
|
+
* Restore a previously auto-demoted, auto-deduped, or janitor-demoted rule.
|
|
998
|
+
* Only flips rows whose superseded_by is a hygiene sentinel — refuses to touch
|
|
999
|
+
* rules superseded by preference override logic (where superseded_by points at
|
|
1000
|
+
* another key). Returns true if a row was restored.
|
|
942
1001
|
*/
|
|
943
1002
|
promoteRule(id) {
|
|
1003
|
+
const placeholders = MemoryStorage.REVIVABLE_SENTINELS.map(() => '?').join(',');
|
|
944
1004
|
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 (
|
|
1005
|
+
WHERE id = ? AND is_active = 0 AND superseded_by IN (${placeholders})`).run(id, ...MemoryStorage.REVIVABLE_SENTINELS);
|
|
946
1006
|
if (result.changes > 0) {
|
|
947
1007
|
this.db.pragma('wal_checkpoint(TRUNCATE)');
|
|
948
1008
|
}
|
|
@@ -1001,6 +1061,13 @@ class MemoryStorage {
|
|
|
1001
1061
|
exports.MemoryStorage = MemoryStorage;
|
|
1002
1062
|
/** Rule-type memories: the only types subject to fuzzy dedup and retro-dedup. */
|
|
1003
1063
|
MemoryStorage.RULE_TYPES = ['preference', 'correction', 'failure', 'devops', 'project-knowledge'];
|
|
1064
|
+
/**
|
|
1065
|
+
* Supersession sentinels written by automatic hygiene passes (as opposed to
|
|
1066
|
+
* a USER preference override, where superseded_by names the winning key).
|
|
1067
|
+
* Rows carrying one of these are revivable: by content-hash re-teach in
|
|
1068
|
+
* save() and individually via promoteRule().
|
|
1069
|
+
*/
|
|
1070
|
+
MemoryStorage.REVIVABLE_SENTINELS = ['auto-demote', 'auto-dedup', 'janitor'];
|
|
1004
1071
|
// Bookkeeping fields the MCP store handler injects into every memory value.
|
|
1005
1072
|
// They carry no semantic meaning, but now that sessionId is stable per
|
|
1006
1073
|
// process (v0.26.0) a constant sessionId token would appear in every memory
|
package/dist/services/memory.js
CHANGED
|
@@ -31,7 +31,7 @@ class MemoryService {
|
|
|
31
31
|
/**
|
|
32
32
|
* Store a memory with proper context and logging
|
|
33
33
|
*/
|
|
34
|
-
store(request) {
|
|
34
|
+
store(request, opts) {
|
|
35
35
|
try {
|
|
36
36
|
// Write-time guard: silently drop values matching known test-fixture patterns
|
|
37
37
|
// (Test preference 177…, Session test preference …, Memory with complex metadata,
|
|
@@ -66,7 +66,7 @@ class MemoryService {
|
|
|
66
66
|
preference_key: request.preferenceKey,
|
|
67
67
|
is_active: true
|
|
68
68
|
};
|
|
69
|
-
this.storage.save(memory);
|
|
69
|
+
this.storage.save(memory, opts);
|
|
70
70
|
this.logger.logMemoryOperation('STORE', {
|
|
71
71
|
key: request.key,
|
|
72
72
|
type: request.type,
|
|
@@ -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