claude-recall 0.35.0 → 0.36.1
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 +19 -0
- package/dist/cli/claude-recall-cli.js +101 -0
- package/dist/hooks/correction-detector.js +9 -1
- package/dist/hooks/kiro-classifier.js +12 -2
- package/dist/hooks/kiro-hooks.js +1 -1
- package/dist/hooks/llm-classifier.js +6 -1
- package/dist/hooks/memory-janitor.js +45 -9
- package/dist/hooks/shared.js +5 -2
- package/dist/mcp/tools/memory-tools.js +15 -3
- package/dist/memory/storage.js +24 -2
- package/dist/services/memory.js +2 -2
- package/docs/using-claude-code-subscription-instead-of-api-key.md +69 -0
- package/package.json +2 -2
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
|
|
@@ -180,6 +195,8 @@ Once installed, Claude Recall works in the background (CC = Claude Code):
|
|
|
180
195
|
|
|
181
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.
|
|
182
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
|
+
|
|
183
200
|
```bash
|
|
184
201
|
# Verify it's working
|
|
185
202
|
claude-recall stats
|
|
@@ -194,6 +211,7 @@ claude-recall search "preference"
|
|
|
194
211
|
claude-recall status # Installation health: hooks, MCP, DB path, project ID
|
|
195
212
|
claude-recall stats # What's in the DB for this project (--global for all)
|
|
196
213
|
|
|
214
|
+
claude-recall list # List all memories, newest first (-t <type>, --all, --json, --global)
|
|
197
215
|
claude-recall search "query" # Search this project's memories (--global, --json, --project <id>)
|
|
198
216
|
claude-recall failures # What broke and what fixed it
|
|
199
217
|
claude-recall outcomes # Outcome-aware learning status
|
|
@@ -256,6 +274,7 @@ claude-recall hooks test-enforcement # Test if search enforcer hook works
|
|
|
256
274
|
|
|
257
275
|
# ── Memory ───────────────────────────────────────────────────────────
|
|
258
276
|
claude-recall stats # Memory statistics (--global for all projects)
|
|
277
|
+
claude-recall list # List memories, newest first (-t <type>, --all, --json, --global)
|
|
259
278
|
claude-recall search "query" # Search memories (--global, --json, --project <id>)
|
|
260
279
|
claude-recall store "content" # Store memory directly
|
|
261
280
|
claude-recall store "content" -t <type> # Type: preference, correction, failure, devops, project-knowledge
|
|
@@ -733,6 +733,85 @@ class ClaudeRecallCLI {
|
|
|
733
733
|
});
|
|
734
734
|
this.logger.info('CLI', 'Search completed', { query, resultCount: results.length });
|
|
735
735
|
}
|
|
736
|
+
/**
|
|
737
|
+
* List (enumerate) stored memories — no query, no ranking, no relevance cap.
|
|
738
|
+
*
|
|
739
|
+
* Fills the gap between `search` (needs a query, returns ranked top-N) and
|
|
740
|
+
* `stats` (counts only, no content). Backed by getAllByProject/getAllMemories
|
|
741
|
+
* so it returns EVERYTHING in scope, newest first. Shows the numeric id and
|
|
742
|
+
* key so rows can be fed to `delete <key>` / `rules promote <id>`.
|
|
743
|
+
*/
|
|
744
|
+
listMemories(options) {
|
|
745
|
+
// Scope resolution mirrors search(): current project (+ universal/unscoped)
|
|
746
|
+
// by default, a named project, or every project with --global.
|
|
747
|
+
let memories;
|
|
748
|
+
let scopeLabel;
|
|
749
|
+
if (options.global) {
|
|
750
|
+
memories = this.memoryService.getAllMemories();
|
|
751
|
+
scopeLabel = 'all projects';
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
const projectId = options.project || config_1.ConfigService.getInstance().getProjectId();
|
|
755
|
+
memories = this.memoryService.getAllByProject(projectId);
|
|
756
|
+
scopeLabel = `project: ${projectId}`;
|
|
757
|
+
}
|
|
758
|
+
if (options.type) {
|
|
759
|
+
memories = memories.filter(m => m.type === options.type);
|
|
760
|
+
}
|
|
761
|
+
// Newest first — most recently learned memory at the top.
|
|
762
|
+
memories.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
|
763
|
+
const total = memories.length;
|
|
764
|
+
const limit = options.all ? total : (0, parse_utils_1.parsePositiveInt)(options.limit, 'limit', 20);
|
|
765
|
+
const shown = memories.slice(0, limit);
|
|
766
|
+
if (options.json) {
|
|
767
|
+
console.log(JSON.stringify(shown.map(m => ({
|
|
768
|
+
id: m.id,
|
|
769
|
+
key: m.key,
|
|
770
|
+
type: m.type,
|
|
771
|
+
value: m.value,
|
|
772
|
+
project_id: m.project_id,
|
|
773
|
+
scope: m.scope,
|
|
774
|
+
is_active: m.is_active,
|
|
775
|
+
timestamp: m.timestamp,
|
|
776
|
+
})), null, 2));
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const typeFilter = options.type ? ` of type "${options.type}"` : '';
|
|
780
|
+
console.log(`\n🧠 Memories (${scopeLabel})\n`);
|
|
781
|
+
console.log(`${total} memor${total === 1 ? 'y' : 'ies'}${typeFilter}${total > shown.length ? ` — showing ${shown.length} (use --all or --limit)` : ''}\n`);
|
|
782
|
+
if (shown.length === 0) {
|
|
783
|
+
console.log('No memories found.\n');
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
for (const m of shown) {
|
|
787
|
+
const inactive = m.is_active === false ? ' 💤 inactive' : '';
|
|
788
|
+
const when = m.timestamp ? new Date(m.timestamp).toLocaleString() : 'unknown time';
|
|
789
|
+
console.log(`[${m.id ?? '?'}] ${m.type}${inactive} · ${when}`);
|
|
790
|
+
console.log(` ${this.truncateContent(this.extractContent(m.value))}`);
|
|
791
|
+
console.log(` key: ${m.key}`);
|
|
792
|
+
console.log('');
|
|
793
|
+
}
|
|
794
|
+
this.logger.info('CLI', 'List completed', { scope: scopeLabel, total, shown: shown.length });
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Pull the human-readable text out of a memory value, whether it's a raw
|
|
798
|
+
* string, a JSON string, or a structured object with a content/value field.
|
|
799
|
+
*/
|
|
800
|
+
extractContent(value) {
|
|
801
|
+
let v = value;
|
|
802
|
+
if (typeof v === 'string') {
|
|
803
|
+
try {
|
|
804
|
+
v = JSON.parse(v);
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
return value;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (v && typeof v === 'object') {
|
|
811
|
+
return String(v.content ?? v.value ?? v.text ?? JSON.stringify(v));
|
|
812
|
+
}
|
|
813
|
+
return String(v);
|
|
814
|
+
}
|
|
736
815
|
/**
|
|
737
816
|
* Export memories to a file
|
|
738
817
|
*/
|
|
@@ -1869,6 +1948,28 @@ async function main() {
|
|
|
1869
1948
|
});
|
|
1870
1949
|
process.exit(0);
|
|
1871
1950
|
});
|
|
1951
|
+
// List command — enumerate memories (no query, unlike `search`)
|
|
1952
|
+
program
|
|
1953
|
+
.command('list')
|
|
1954
|
+
.description('List stored memories (no query needed; newest first)')
|
|
1955
|
+
.option('-t, --type <type>', 'Filter by memory type (preference, failure, devops, ...)')
|
|
1956
|
+
.option('-l, --limit <number>', 'Maximum memories to show', '20')
|
|
1957
|
+
.option('--all', 'Show every memory in scope (ignores --limit)')
|
|
1958
|
+
.option('--project <id>', 'List a specific project (includes universal memories)')
|
|
1959
|
+
.option('--global', 'List memories across all projects')
|
|
1960
|
+
.option('--json', 'Output as JSON')
|
|
1961
|
+
.action((options) => {
|
|
1962
|
+
const cli = new ClaudeRecallCLI(program.opts());
|
|
1963
|
+
cli.listMemories({
|
|
1964
|
+
type: options.type,
|
|
1965
|
+
limit: (0, parse_utils_1.parsePositiveInt)(options.limit, 'limit', 20),
|
|
1966
|
+
all: options.all,
|
|
1967
|
+
project: options.project,
|
|
1968
|
+
global: options.global,
|
|
1969
|
+
json: options.json,
|
|
1970
|
+
});
|
|
1971
|
+
process.exit(0);
|
|
1972
|
+
});
|
|
1872
1973
|
// Stats command
|
|
1873
1974
|
program
|
|
1874
1975
|
.command('stats')
|
|
@@ -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;
|
|
@@ -46,7 +46,7 @@ function buildClassifyPrompt(text) {
|
|
|
46
46
|
return ('You are a memory classifier for a developer tool. Classify the USER MESSAGE ' +
|
|
47
47
|
'into exactly one type and respond with ONLY minified JSON — no markdown, no ' +
|
|
48
48
|
'prose, no code fence:\n' +
|
|
49
|
-
'{"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' +
|
|
50
50
|
'Types:\n' +
|
|
51
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' +
|
|
52
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' +
|
|
@@ -61,7 +61,14 @@ function buildClassifyPrompt(text) {
|
|
|
61
61
|
'- Reject meta-conversation about this tool or what to write/do next.\n\n' +
|
|
62
62
|
'Be conservative: when in doubt use "none" with confidence 0. Use confidence >= 0.75 for correction/preference/devops. ' +
|
|
63
63
|
'extract must be a clean standalone rule (e.g. "Favourite colour is green"), or empty when type is none.\n\n' +
|
|
64
|
-
'
|
|
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"}; ' +
|
|
65
72
|
'"first fix the sentence" → {"type":"none","confidence":0,"extract":""}; ' +
|
|
66
73
|
'"then run claude-recall kiro setup" → {"type":"none","confidence":0,"extract":""}.\n\n' +
|
|
67
74
|
'USER MESSAGE: ' + text);
|
|
@@ -104,6 +111,9 @@ function extractClassification(raw) {
|
|
|
104
111
|
type: parsed.type,
|
|
105
112
|
confidence,
|
|
106
113
|
extract: parsed.extract.trim(),
|
|
114
|
+
...(parsed.precision === 'vague' || parsed.precision === 'precise'
|
|
115
|
+
? { precision: parsed.precision }
|
|
116
|
+
: {}),
|
|
107
117
|
};
|
|
108
118
|
}
|
|
109
119
|
/**
|
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),
|
|
@@ -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 {
|
|
@@ -61,6 +61,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
61
61
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
62
62
|
exports.claimJanitorRun = claimJanitorRun;
|
|
63
63
|
exports.maybeSpawnJanitor = maybeSpawnJanitor;
|
|
64
|
+
exports.dropCosmeticRewrites = dropCosmeticRewrites;
|
|
64
65
|
exports.buildJanitorPrompt = buildJanitorPrompt;
|
|
65
66
|
exports.parseJanitorActions = parseJanitorActions;
|
|
66
67
|
exports.applyJanitorActions = applyJanitorActions;
|
|
@@ -157,15 +158,19 @@ function maybeSpawnJanitor(input, runtime) {
|
|
|
157
158
|
(0, shared_1.hookLog)(HOOK_NAME, `spawn failed: ${(0, shared_1.safeErrorMessage)(err)}`);
|
|
158
159
|
}
|
|
159
160
|
}
|
|
160
|
-
/**
|
|
161
|
-
|
|
162
|
-
|
|
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) {
|
|
163
165
|
try {
|
|
164
|
-
|
|
166
|
+
return (0, memory_tools_1.formatRuleValue)(JSON.parse(rule.value));
|
|
165
167
|
}
|
|
166
168
|
catch {
|
|
167
|
-
|
|
169
|
+
return String(rule.value);
|
|
168
170
|
}
|
|
171
|
+
}
|
|
172
|
+
/** Render one rule for the review prompt — id, type, counters, age, text. */
|
|
173
|
+
function renderRuleForReview(rule, nowMs) {
|
|
169
174
|
const ageDays = Math.max(0, Math.round((nowMs - rule.timestamp) / 86400000));
|
|
170
175
|
return JSON.stringify({
|
|
171
176
|
id: rule.id,
|
|
@@ -173,7 +178,27 @@ function renderRuleForReview(rule, nowMs) {
|
|
|
173
178
|
loads: rule.load_count,
|
|
174
179
|
cites: rule.cite_count,
|
|
175
180
|
age_days: ageDays,
|
|
176
|
-
text:
|
|
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;
|
|
177
202
|
});
|
|
178
203
|
}
|
|
179
204
|
function buildJanitorPrompt(ruleLines) {
|
|
@@ -195,6 +220,9 @@ function buildJanitorPrompt(ruleLines) {
|
|
|
195
220
|
'the naming prefix of similar files — email files are email_*.txt".\n\n' +
|
|
196
221
|
'Be conservative:\n' +
|
|
197
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' +
|
|
198
226
|
'- Never rewrite meaning — only clarity. Never merge rules that differ in substance.\n' +
|
|
199
227
|
'- Specific, actionable rules are valuable even if rarely used. Low usage counters alone are NOT grounds to demote.\n' +
|
|
200
228
|
'- Do not demote failure lessons that name a specific command, file, or error.\n\n' +
|
|
@@ -271,9 +299,16 @@ function applyJanitorActions(actions, rulesById, opts = {}) {
|
|
|
271
299
|
if (!opts.dryRun) {
|
|
272
300
|
if (action.action === 'merge' || action.action === 'rewrite') {
|
|
273
301
|
const type = action.type ?? rulesById.get(action.ids[0])?.type ?? 'preference';
|
|
274
|
-
|
|
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 });
|
|
275
306
|
}
|
|
276
|
-
|
|
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;
|
|
277
312
|
}
|
|
278
313
|
else {
|
|
279
314
|
applied = true; // would apply
|
|
@@ -325,7 +360,8 @@ async function handleMemoryJanitorWorker(_input, opts = {}) {
|
|
|
325
360
|
return null;
|
|
326
361
|
}
|
|
327
362
|
const validIds = new Set(reviewable.map(r => r.id));
|
|
328
|
-
const
|
|
363
|
+
const textById = new Map(reviewable.map(r => [r.id, ruleText(r)]));
|
|
364
|
+
const actions = dropCosmeticRewrites(parseJanitorActions(raw, validIds), textById);
|
|
329
365
|
(0, shared_1.hookLog)(HOOK_NAME, `reviewed ${reviewable.length} rules → ${actions.length} action(s)${opts.dryRun ? ' (dry-run)' : ''}`);
|
|
330
366
|
const rulesById = new Map(reviewable.map(r => [r.id, { id: r.id, type: r.type }]));
|
|
331
367
|
const results = applyJanitorActions(actions, rulesById, { dryRun: opts.dryRun });
|
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
|
|
@@ -388,11 +388,24 @@ class MemoryStorage {
|
|
|
388
388
|
const fuzzyMatch = MemoryStorage.RULE_TYPES.includes(memory.type)
|
|
389
389
|
? this.findFuzzyDuplicate(memory)
|
|
390
390
|
: null;
|
|
391
|
-
if (fuzzyMatch) {
|
|
391
|
+
if (fuzzyMatch && !opts?.fuzzyNewestWins) {
|
|
392
392
|
this.db.prepare('UPDATE memories SET timestamp = ?, access_count = access_count + 1 WHERE key = ?').run(Date.now(), fuzzyMatch);
|
|
393
393
|
this.db.pragma('wal_checkpoint(TRUNCATE)');
|
|
394
394
|
return;
|
|
395
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
|
+
}
|
|
396
409
|
// Upsert instead of INSERT OR REPLACE: OR REPLACE deletes and reinserts,
|
|
397
410
|
// which resets columns absent from the insert list (load_count, cite_count,
|
|
398
411
|
// last_accessed) and churns the rowid. A same-key re-save must not destroy
|
|
@@ -420,6 +433,15 @@ class MemoryStorage {
|
|
|
420
433
|
content_hash = excluded.content_hash
|
|
421
434
|
`);
|
|
422
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
|
+
}
|
|
423
445
|
// Force a WAL checkpoint to ensure the data is written to the main database file
|
|
424
446
|
// This ensures that other processes (like CLI) can see the changes immediately
|
|
425
447
|
this.db.pragma('wal_checkpoint(TRUNCATE)');
|
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,69 @@
|
|
|
1
|
+
# Wiring a project to the Claude Code subscription (no API key)
|
|
2
|
+
|
|
3
|
+
The mechanism: don't call the Anthropic API at all — **shell out to the `claude`
|
|
4
|
+
CLI**, which is already authenticated by the user's Claude Code subscription
|
|
5
|
+
login. No key, no billing setup; it borrows the session the user logged into.
|
|
6
|
+
|
|
7
|
+
> Applies to any project. Replace the placeholder name (`MY_APP_NESTED`) with
|
|
8
|
+
> your own convention.
|
|
9
|
+
|
|
10
|
+
## The core idea
|
|
11
|
+
|
|
12
|
+
`claude -p "<prompt>"` runs a one-shot, non-interactive completion using the
|
|
13
|
+
subscription auth. Your project spawns it as a subprocess and reads stdout.
|
|
14
|
+
That's the whole trick.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
claude -p "Summarize this in one line: ..." # prints completion to stdout, exits
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Wiring it into any project
|
|
21
|
+
|
|
22
|
+
Spawn it non-interactively (never through a shell string — pass args to avoid
|
|
23
|
+
injection):
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { spawn } from 'node:child_process';
|
|
27
|
+
|
|
28
|
+
function complete(prompt: string, timeoutMs = 60_000): Promise<string> {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const p = spawn('claude', ['-p', prompt], {
|
|
31
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
32
|
+
timeout: timeoutMs,
|
|
33
|
+
env: { ...process.env, MY_APP_NESTED: '1' }, // recursion guard
|
|
34
|
+
});
|
|
35
|
+
let out = '', err = '';
|
|
36
|
+
p.stdout.on('data', d => (out += d));
|
|
37
|
+
p.stderr.on('data', d => (err += d));
|
|
38
|
+
p.on('close', code =>
|
|
39
|
+
code === 0 ? resolve(out.trim()) : reject(new Error(err || `exit ${code}`)));
|
|
40
|
+
p.on('error', reject);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## The things that bite people
|
|
46
|
+
|
|
47
|
+
- **Recursion.** If your code runs *inside* a Claude Code session (a hook, an
|
|
48
|
+
MCP tool) and then spawns `claude -p`, that child can trigger the same hook
|
|
49
|
+
and fork-bomb. Set a sentinel env var on the child and bail at the top of your
|
|
50
|
+
entry point if it's present.
|
|
51
|
+
- **Availability.** Require `claude` to be installed and logged in. If it's not
|
|
52
|
+
on `PATH` (`ENOENT`), surface a clear error telling the user to install the
|
|
53
|
+
CLI and run `claude login` — don't crash silently.
|
|
54
|
+
- **Timeouts.** The CLI is a full agent process, slower and heavier than a raw
|
|
55
|
+
API call. Always set a timeout and handle the null result; never block your
|
|
56
|
+
main flow on it.
|
|
57
|
+
- **Structured output.** `-p` returns prose. If you need JSON, say so in the
|
|
58
|
+
prompt and parse defensively (treat malformed output as a no-op). Add
|
|
59
|
+
`--output-format json` if you want the CLI's own envelope instead.
|
|
60
|
+
|
|
61
|
+
## Setup, once
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
claude login # authenticate the subscription — no key involved
|
|
65
|
+
claude -p "ping" # verify it responds
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
That's all a fresh clone needs: an installed, logged-in `claude` CLI. No
|
|
69
|
+
secrets, no `ANTHROPIC_API_KEY`, no billing configuration.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-recall",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.1",
|
|
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": {
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"typescript-eslint": "^8.63.0"
|
|
103
103
|
},
|
|
104
104
|
"dependencies": {
|
|
105
|
-
"@anthropic-ai/sdk": "^0.
|
|
105
|
+
"@anthropic-ai/sdk": "^0.111.0",
|
|
106
106
|
"better-sqlite3": "^12.2.0",
|
|
107
107
|
"chalk": "^5.5.0",
|
|
108
108
|
"commander": "^14.0.3"
|