claude-mem-lite 6.26.1 → 6.27.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -1
- package/hook-context.mjs +14 -3
- package/hook.mjs +11 -1
- package/lib/file-intel.mjs +11 -0
- package/lib/recall-shown.mjs +116 -0
- package/lib/recent-table.mjs +49 -0
- package/lib/startup-dashboard.mjs +22 -13
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -1
- package/scripts/pre-tool-recall.js +71 -42
- package/scripts/prompt-search-utils.mjs +23 -0
- package/scripts/user-prompt-search.js +22 -4
- package/source-files.mjs +2 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
12
|
+
"version": "6.27.0",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"homepage": "https://github.com/sdsrss/claude-mem-lite",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.27.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/README.md
CHANGED
|
@@ -1141,6 +1141,8 @@ claude-mem-lite.
|
|
|
1141
1141
|
| `CLAUDE_MEM_DEBUG` | Enable debug logging (`1` to enable). | _(disabled)_ |
|
|
1142
1142
|
| `MEM_QUIET_HOOKS` | Low-noise hooks. `1` drops the `File Lessons` / `Key Context` sections from SessionStart injection, the lesson suffix from `[mem] Related memories`, and the `WHEN TO USE` / `Decision rules` blocks from MCP server instructions. IDs and the `Recent` table still surface so `mem_get(ids=[…])` remains reachable. Intended for users running the invited-memory adopt path or who otherwise want minimal auto-injection. **Since v2.82.0 this env no longer gates auto-adopt — use `MEM_NO_AUTO_ADOPT=1` for that.** | _(disabled)_ |
|
|
1143
1143
|
| `CLAUDE_MEM_SESSION_EVENTS` | `1`/`on` restores the SessionStart `### Key Events` section (recent high-importance rows from the `events` table). **Off by default since v6.13.0**: an audit of 30 events read 2 accurate and 16 wrong. The UserPromptSubmit events block and PreToolUse recall are query-matched and stay on; `mem_search` still reaches every event. | _(off)_ |
|
|
1144
|
+
| `CLAUDE_MEM_STARTUP_RECENT_ROWS` | Rows in the SessionStart `### Recent` table: of the rows the token budget picks, the project's own first and then other projects' (each newest first). `0` hides the table. Was the whole pick before the injection-budget change (7–13 rows per SessionStart table on the corpus it was measured on, mean 9.4). | `5` |
|
|
1145
|
+
| `CLAUDE_MEM_DASHBOARD_EVENTS` | `1` puts the `💡 mem events: N entries` count back on the SessionStart dashboard (dropped in the injection-budget change: the model never ran `activity` from it on the corpus measured). | _(off)_ |
|
|
1144
1146
|
| `CLAUDE_MEM_SUMMARY_TAIL` | `0`/`off` stops Stop from using the head of the final reply (first 120 characters, code blocks and line-leading markers removed) as a session's Completed line when that reply has no Done / Not done / Failed / Uncertain section; Completed then falls back to observation titles, as before v6.18.0; a line already written gives way to the titles at the session's next turn. A later reply under 400 characters does not replace an earlier one. | _(on)_ |
|
|
1145
1147
|
| `CLAUDE_MEM_EPISODE_INPUT_FILTER` | What the episode summarizer may learn from. A subagent's tool calls stay out of the episode buffer unless they edit a file inside the project, and mutation probes (mutate → RED run → restore) and the agent's own failing inline scripts (a patch's `anchor not found`) are dropped before a window is saved or summarized — replayed over the 30 audited events, this removes 3 of the 16 that were wrong outright; with `CLAUDE_MEM_LESSON_GROUNDING` none of the 16 lessons is injected. `off` restores the unfiltered input. | _(on)_ |
|
|
1146
1148
|
| `CLAUDE_MEM_BASH_RECALL` | File recall before a Bash command that views (`cat`, `sed -n`, `head`…) or writes (`sed -i`, `cat > f`, a python patch…) a file, like the Read / Edit recall. A bash prefilter keeps Node from starting for other commands. `off` disables this leg only. | _(on)_ |
|
|
@@ -1154,7 +1156,7 @@ claude-mem-lite.
|
|
|
1154
1156
|
| Variable | Description | Default |
|
|
1155
1157
|
|----------|-------------|---------|
|
|
1156
1158
|
| `CLAUDE_MEM_ALL_TOOLS` | `1` exposes every registered MCP tool in `tools/list` instead of only the core ones (pre-v2.34.0 behavior). The 9 hidden tools stay callable by exact name either way; `mem_search_feedback` is registered only when search telemetry is enabled. | _(9 core)_ |
|
|
1157
|
-
| `CLAUDE_MEM_FILE_INTEL` | `
|
|
1159
|
+
| `CLAUDE_MEM_FILE_INTEL` | `1` turns on the file-intel line on the first read of a large file — a `Read`, or a Bash `cat` / `sed -n` / `head` — (its approximate token size and a one-line summary). Off by default since the injection-budget change: the line reaches the model next to the read's result, so it cannot shape that read. | _(off)_ |
|
|
1158
1160
|
| `CLAUDE_MEM_FILE_INTEL_MIN_TOKENS` | Files smaller than this stay silent — file-intel only pays for itself on large files. | `800` |
|
|
1159
1161
|
| `CLAUDE_MEM_REREAD_GUARD` | `0` disables the warning when the same file is read twice in a session. Never fires on `offset`/`limit` paging. | _(on)_ |
|
|
1160
1162
|
| `CLAUDE_MEM_REREAD_MIN_TOKENS` | Token floor below which the re-read guard stays silent. | `600` |
|
|
@@ -1194,6 +1196,7 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
|
|
|
1194
1196
|
| `CLAUDE_MEM_REACH_DISCLOSURE` | `off` suppresses the one-line note that fires when a search's reported `total` exceeds what its pagination can hand back. The candidate pool is sized from `limit` alone and deliberately does not grow with `offset` (D#30 — an offset-scaled pool re-ranks its own prefix under RRF, so pages overlapped and gapped), while `total` is the full match count. Measured on a 128-row corpus: at the default limit of 20 the last non-empty offset is 59, so 60 of 128 rows are unreachable at any offset. The note reports that; it does not change retrieval, ranking, or which rows are returned. It stays **silent** when a filter you asked for (`tier`, or the CJK precision gate on prompts) removed rows after the count was taken — that gap is your filter, not the pool, and raising the limit would not recover it. | _(on)_ |
|
|
1195
1197
|
| `CLAUDE_MEM_NORMALIZE_CROSS_PROJECT` | `1` restores the pre-fix behaviour where the daily unattended `normalize` runs ONCE over every project's concepts at the same time. That is how one project's stored content could steer synonym groups applied to another project's rows, so the default is now one scoped pass per project (bounded to 8 per run). The cost of the default is that `k8s` in one project and `kubernetes` in another are no longer unified automatically. Note that EVERY unscoped run fans out, including an explicit `optimize --run --task normalize` with no `--project` — this variable is the only route back to the single cross-project pass. A foreground `optimize` run prints a warning when it is set; the daily unattended pass cannot (its worker is spawned with stderr closed), so `claude-mem-lite doctor` reports it as a ⚠ instead. | _(off)_ |
|
|
1196
1198
|
| `CLAUDE_MEM_AUTO_DEEP_CLI` | `0` disables the same auto-escalation on the CLI path only. | _(auto)_ |
|
|
1199
|
+
| `CLAUDE_MEM_UPS_PROMPT_ECHO` | `0` lets the "Past similar questions" fallback show a stored question identical (or nearly) to the prompt just typed, from an earlier session. The current session's own prompts stay excluded either way: showing the prompt just typed was a bug. | _(on)_ |
|
|
1197
1200
|
| `CLAUDE_MEM_SCOPE_FILTER` | `1` stops environment-scoped observations from firing on file-triggered recall. They stay reachable via search. **Leave it off**: on the face it gates, `environment` is not the low-relevance class its premise assumes — it cites at least as well as `project` (47.5% vs 44.3%, intervals overlapping), and an earlier measurement left 173 recall groups empty with it on. | _(off)_ |
|
|
1198
1201
|
| `CLAUDE_MEM_READS_CARRY` | An episode flush collects `reads-<project>.txt` only when it will actually save an observation, so a flush that records nothing no longer discards the Read paths it swept up (42.2% of the paths a flush consumed, measured over 1122 transcripts). `0` restores the pre-v3.83.0 behaviour. | _(on)_ |
|
|
1199
1202
|
|
|
@@ -1252,6 +1255,8 @@ and names can change between releases.
|
|
|
1252
1255
|
| `CLAUDE_MEM_TASK_IMPERATIVE` | `on`/`1` injects the single most relevant lesson at prompt position under an imperative template. | _(off)_ |
|
|
1253
1256
|
| `CLAUDE_MEM_SUBAGENT_INJECT` | Dispatch-time memory injection for subagents. | _(off)_ |
|
|
1254
1257
|
| `CLAUDE_MEM_RECALL_FRAMING` | First line of a PreToolUse / PostToolUse recall block. Unset or `legacy` = "system-injected context, continue your planned action"; `factual` = a plain statement of where the notes come from; `ab` = each session gets one of the two, for `benchmark/citation-live-replay.mjs --by-framing`. Any other value is `legacy`. | `legacy` |
|
|
1258
|
+
| `CLAUDE_MEM_RECALL_HEADER_ONCE` | `0` puts the framing line on every PreToolUse recall block. By default a thread (the main session, or one subagent) gets it on its first block only, and again after `/compact`. | _(on)_ |
|
|
1259
|
+
| `CLAUDE_MEM_DIRECTIVE_ONCE` | `0` repeats the full default pre-edit directive on every block. By default a thread gets it in full once (again after `/compact`) and a short form that keeps the `#NN` tag rule after that. The `verdict` / `bind` / `bridge` arms always get their full text. | _(on)_ |
|
|
1255
1260
|
| `CLAUDE_MEM_SALIENCE` | Selects how the pre-edit lesson line asks for a response: unset = name a lesson's `#NN` only where it changed the edit; `verdict` = the pre-6.17 per-lesson `applied` / `n/a` verdict (the adoption row in CLAUDE.md and the detail doc keep the new wording, and since 6.17.1 the directive says it overrides them); `bind` / `bridge` = comprehension-bridge arms (`bridge` keeps the pre-6.17 verdict wording as its fallback; neither says it overrides the adopted text, so their measured wording stays fixed); `legacy` = no directive. | _(unset)_ |
|
|
1256
1261
|
| `CLAUDE_MEM_EDGE_DECAY` | Enables decay of file↔observation edges. | _(off)_ |
|
|
1257
1262
|
| `CLAUDE_MEM_EDGE_DECAY_K` | Edge-decay threshold when the flag above is on (clamped to ≥1). | `3` |
|
package/hook-context.mjs
CHANGED
|
@@ -46,6 +46,8 @@ import { inferProjectDir } from './project-utils.mjs';
|
|
|
46
46
|
import { TYPE_QUALITY, TYPE_QUALITY_DEFAULT } from './scoring-sql.mjs';
|
|
47
47
|
|
|
48
48
|
import { DAY_MS } from './lib/time-constants.mjs';
|
|
49
|
+
import { capRecentRows, recentTableRowCap } from './lib/recent-table.mjs';
|
|
50
|
+
|
|
49
51
|
/**
|
|
50
52
|
* Compute adaptive recall time windows based on project activity velocity.
|
|
51
53
|
* High activity -> shorter windows (recent data more relevant).
|
|
@@ -532,14 +534,16 @@ const MIN_LOCAL_OBS = 3;
|
|
|
532
534
|
* MIN_LOCAL_OBS, so the table gains at most two rows.
|
|
533
535
|
*
|
|
534
536
|
* The COUNT never shrinks; the composition can, and that is a ranking decision rather than
|
|
535
|
-
* an accident. At the
|
|
537
|
+
* an accident. At the table site, before its 5-row cap, this only added. At the `.slice(0, MIN_LOCAL_OBS)`
|
|
536
538
|
* site the selected rows are PREPENDED, so with 1 local row and 3+ fallback rows the third
|
|
537
539
|
* fallback title is evicted — still three rows, one of them now local. Exhaustive
|
|
538
540
|
* enumeration over 42,436 ordered input pairs: 0 cases render fewer rows, 3,000 evict a
|
|
539
541
|
* fallback row that way, max gain 2. Preferring the project's own rows over cross-project
|
|
540
542
|
* ones inside a fixed window is the intended order, and saying so is the point: a new
|
|
541
543
|
* population entering a limit-bounded window without a stated ranking is this repo's
|
|
542
|
-
* recorded failure shape.
|
|
544
|
+
* recorded failure shape. The table site has been capped since the injection-budget change
|
|
545
|
+
* (5 rows), and its cut keeps that order too — capRecentRows ranks own rows first; a newest-5
|
|
546
|
+
* cut over the union evicted them (pre-ship defect review P2-1).
|
|
543
547
|
*/
|
|
544
548
|
function withFallbackTopUp(observations, fallbackObs) {
|
|
545
549
|
if (observations.length >= MIN_LOCAL_OBS) return observations;
|
|
@@ -914,8 +918,15 @@ export function buildSessionContextLines(
|
|
|
914
918
|
// already chosen, so the token budget and the row set are untouched (pinned by a case in
|
|
915
919
|
// tests/hook-context.test.mjs). Tiebroken on id for the same reason D#9 gives — an
|
|
916
920
|
// untiebroken tie flips direction, and two saves in one millisecond are common.
|
|
921
|
+
//
|
|
922
|
+
// Injection budget C4: the display is then cut to `recentTableRowCap()` rows (5), the project's
|
|
923
|
+
// own rows ranked before the cross-project top-up (lib/recent-table.mjs). The knapsack still
|
|
924
|
+
// picks as before.
|
|
917
925
|
const obsLines = [];
|
|
918
|
-
const obsToShow =
|
|
926
|
+
const obsToShow = capRecentRows(
|
|
927
|
+
withFallbackTopUp(observations, fallbackObs),
|
|
928
|
+
project,
|
|
929
|
+
recentTableRowCap(),
|
|
919
930
|
(a, b) => Date.parse(b.created_at) - Date.parse(a.created_at) || b.id - a.id,
|
|
920
931
|
);
|
|
921
932
|
if (obsToShow.length > 0) {
|
package/hook.mjs
CHANGED
|
@@ -175,6 +175,7 @@ import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
|
|
|
175
175
|
import { detectMemOverride } from './lib/mem-override.mjs';
|
|
176
176
|
import { injectedIdsFileName, keyContextIdsFileName, readInjectedMarker } from './lib/injected-ids.mjs';
|
|
177
177
|
import { recordKeyContextInjection, touchKeyContextMarker } from './lib/keyctx-marker.mjs';
|
|
178
|
+
import { RECALL_SHOWN_PREFIX, clearRecallShown } from './lib/recall-shown.mjs';
|
|
178
179
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
179
180
|
import { selectErrorRecall } from './lib/error-recall-core.mjs';
|
|
180
181
|
import { errorRecallSuppression } from './lib/error-recall-gate.mjs';
|
|
@@ -1871,7 +1872,8 @@ function gcStalePreRecallCooldowns() {
|
|
|
1871
1872
|
const isInjectedMarker =
|
|
1872
1873
|
name.startsWith('.claude-mem-injected-') ||
|
|
1873
1874
|
name.startsWith('.claude-mem-keyctx-') || // D#123 Key Context marker — same per-session growth, same 24h GC
|
|
1874
|
-
name.startsWith(BUGFIX_NUDGE_MARKER_PREFIX)
|
|
1875
|
+
name.startsWith(BUGFIX_NUDGE_MARKER_PREFIX) || // §9-B once-per-session nudge claim — same shape
|
|
1876
|
+
name.startsWith(RECALL_SHOWN_PREFIX); // injection budget C1/C3 per-thread marker — same shape
|
|
1875
1877
|
if (!isCooldown && !isInjectedMarker) continue;
|
|
1876
1878
|
try {
|
|
1877
1879
|
const p = join(RUNTIME_DIR, name);
|
|
@@ -3102,6 +3104,14 @@ async function handleSessionStart() {
|
|
|
3102
3104
|
} catch {
|
|
3103
3105
|
/* stdin unavailable — legacy behavior */
|
|
3104
3106
|
}
|
|
3107
|
+
// Injection budget C1/C3 (lib/recall-shown.mjs): the recall header and full directive are
|
|
3108
|
+
// shown once per thread because the thread still holds the first copy. Compaction is when it
|
|
3109
|
+
// stops holding it, so the main thread starts over; so does any fresh context (startup, or a
|
|
3110
|
+
// /clear, which also rotates the session id). `resume` keeps the marker, on the ASSUMPTION that
|
|
3111
|
+
// a resumed transcript still carries the earlier copies — not verified (pre-ship delta review);
|
|
3112
|
+
// hooks/hooks.json does not even match `resume`, so this branch only sees it from a direct
|
|
3113
|
+
// call. Clearing errs safe: the worst case is one more header.
|
|
3114
|
+
if (ccSessionId && startSource !== 'resume') clearRecallShown(RUNTIME_DIR, ccSessionId);
|
|
3105
3115
|
|
|
3106
3116
|
// Snapshot episode BEFORE flush for handoff extraction
|
|
3107
3117
|
const episodeSnapshot = readEpisodeRaw();
|
package/lib/file-intel.mjs
CHANGED
|
@@ -17,6 +17,17 @@ const SUMMARY_MAX = 80;
|
|
|
17
17
|
const DEFAULT_MIN_TOKENS = 800;
|
|
18
18
|
const DEFAULT_MAX_READ_BYTES = 24 * 1024;
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Whether the hook injects the line at all. Opt-in since the injection-budget change
|
|
22
|
+
* (docs/audits/20261016-injection-budget-baseline.md): CLAUDE_MEM_FILE_INTEL=1/on/true/yes.
|
|
23
|
+
* Exported so benchmark/injection-budget.mjs replays the shipped predicate, not a copy.
|
|
24
|
+
* @param {Record<string, string|undefined>} [env]
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function fileIntelEnabled(env = process.env) {
|
|
28
|
+
return ['1', 'on', 'true', 'yes'].includes(String(env.CLAUDE_MEM_FILE_INTEL || '').toLowerCase());
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
// Mirror of utils.estimateTokens (ASCII ~4 chars/token, CJK ~1.5). Kept local so
|
|
21
32
|
// the standalone hook stays lean — see file header + the mirror test.
|
|
22
33
|
export function estimateContentTokens(text) {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// lib/recall-shown.mjs — what one THREAD has already been shown once: the recall framing header
|
|
2
|
+
// and the full cite directive (injection budget C1 / C3, docs/audits/20261016-injection-budget-baseline.md).
|
|
3
|
+
//
|
|
4
|
+
// Every PreToolUse recall block opened with the framing line — the legacy wording, or under the
|
|
5
|
+
// `factual` arm (lib/recall-framing.mjs) a line naming the file — and every directive line carried
|
|
6
|
+
// the same ACK text. On the frozen 52-session corpus the header was 2.78 KB/session over 1,546
|
|
7
|
+
// blocks (220 distinct strings: 1,033 legacy at 82 B, 513 factual naming a file) and the
|
|
8
|
+
// Read→Edit nudge carrying the full directive 2.03 KB/session over 340 — while a thread needs each
|
|
9
|
+
// once to know what the `[mem]` blocks are and how to tag a lesson. (PostToolUse recall carries a
|
|
10
|
+
// framing line only under the `bind` arm, and this module does not govern it.) So the first block of a
|
|
11
|
+
// thread carries both in full, later blocks drop the header and carry a short directive that
|
|
12
|
+
// keeps the tag rule. Lesson rows are never touched here: every `#NN` that was injected still
|
|
13
|
+
// is, so the (session, id) pairs the cite-rate counts do not move.
|
|
14
|
+
//
|
|
15
|
+
// The marker is keyed like the cooldown (lib/cooldown-path.mjs threadKey): a subagent thread
|
|
16
|
+
// shares no context with its parent, so it gets its own header. SessionStart clears the main
|
|
17
|
+
// thread's marker on every source but `resume` — compaction, /clear and a fresh start are when the
|
|
18
|
+
// earlier copy leaves (or was never in) the context. A subagent's marker goes only with the 24 h GC.
|
|
19
|
+
//
|
|
20
|
+
// Leaf on purpose: scripts/pre-tool-recall.js is a hot standalone hook and imports this.
|
|
21
|
+
//
|
|
22
|
+
// Switches (each restores the old behaviour on its own):
|
|
23
|
+
// CLAUDE_MEM_RECALL_HEADER_ONCE=0 header on every block
|
|
24
|
+
// CLAUDE_MEM_DIRECTIVE_ONCE=0 full directive on every block
|
|
25
|
+
|
|
26
|
+
import { readFileSync, unlinkSync } from 'fs';
|
|
27
|
+
import { join } from 'path';
|
|
28
|
+
import { atomicWriteFileSync } from './atomic-write.mjs';
|
|
29
|
+
import { cooldownSessionKey } from './cooldown-path.mjs';
|
|
30
|
+
|
|
31
|
+
/** Filename prefix, exported so the SessionStart GC can match the family. */
|
|
32
|
+
export const RECALL_SHOWN_PREFIX = 'recall-shown-';
|
|
33
|
+
|
|
34
|
+
// The default salience arm's directive (D#98). Moved here from scripts/pre-tool-recall.js, byte
|
|
35
|
+
// for byte, so benchmark/injection-budget.mjs can recognise it in recorded blocks.
|
|
36
|
+
export const ACK_DIRECTIVE =
|
|
37
|
+
'apply each lesson to this edit or rule it out. If one changes the edit, add its bare tag (#NN) once at the end of the sentence describing that change; a lesson that did not apply needs no mention, and memory ids get no other mention in your reply.';
|
|
38
|
+
// What later blocks of the same thread carry. It keeps the one rule a cite depends on (tag only
|
|
39
|
+
// a lesson that changed the edit) and points back at the full text, which this thread has
|
|
40
|
+
// already been shown — the marker is cleared whenever the context starts over (see above) precisely
|
|
41
|
+
// so that stays true.
|
|
42
|
+
export const ACK_DIRECTIVE_SHORT =
|
|
43
|
+
'apply each lesson to this edit — tag rule as before: (#NN) only where one changes it.';
|
|
44
|
+
|
|
45
|
+
const OFF = ['0', 'off', 'false', 'no'];
|
|
46
|
+
const isOn = (v) => !OFF.includes(String(v ?? '').toLowerCase());
|
|
47
|
+
|
|
48
|
+
const EMPTY = Object.freeze({ header: false, directive: false });
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decide what one recall block shows, given what the thread was already shown.
|
|
52
|
+
*
|
|
53
|
+
* @param {{header: boolean, directive: boolean}} shown
|
|
54
|
+
* @param {{header: boolean, directive: boolean}} wants what the block would carry today
|
|
55
|
+
* @param {Record<string, string|undefined>} [env]
|
|
56
|
+
* @returns {{header: boolean, directive: 'full'|'short'|null, next: {header: boolean, directive: boolean}}}
|
|
57
|
+
*/
|
|
58
|
+
export function planRecallBlock(shown, wants, env = process.env) {
|
|
59
|
+
const s = shown || EMPTY;
|
|
60
|
+
const header = Boolean(wants?.header) && (!isOn(env.CLAUDE_MEM_RECALL_HEADER_ONCE) || !s.header);
|
|
61
|
+
let directive = null;
|
|
62
|
+
if (wants?.directive) directive = isOn(env.CLAUDE_MEM_DIRECTIVE_ONCE) && s.directive ? 'short' : 'full';
|
|
63
|
+
return {
|
|
64
|
+
header,
|
|
65
|
+
directive,
|
|
66
|
+
next: { header: Boolean(s.header || header), directive: Boolean(s.directive || directive === 'full') },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} runtimeDir
|
|
72
|
+
* @param {string|null|undefined} thread threadKey(sessionId, agentId); null without a session
|
|
73
|
+
* @returns {string|null}
|
|
74
|
+
*/
|
|
75
|
+
export function recallShownPath(runtimeDir, thread) {
|
|
76
|
+
if (!thread) return null;
|
|
77
|
+
return join(runtimeDir, `${RECALL_SHOWN_PREFIX}${cooldownSessionKey(thread)}.json`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @returns {{header: boolean, directive: boolean}} empty when absent, unreadable or no path */
|
|
81
|
+
export function readRecallShown(path) {
|
|
82
|
+
if (!path) return { ...EMPTY };
|
|
83
|
+
try {
|
|
84
|
+
const v = JSON.parse(readFileSync(path, 'utf8'));
|
|
85
|
+
return { header: v?.header === true, directive: v?.directive === true };
|
|
86
|
+
} catch {
|
|
87
|
+
return { ...EMPTY };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Best-effort write; a lost write only means the next block shows the line again. */
|
|
92
|
+
export function markRecallShown(path, shown) {
|
|
93
|
+
if (!path) return;
|
|
94
|
+
try {
|
|
95
|
+
atomicWriteFileSync(
|
|
96
|
+
path,
|
|
97
|
+
JSON.stringify({ header: Boolean(shown?.header), directive: Boolean(shown?.directive) }),
|
|
98
|
+
);
|
|
99
|
+
} catch {
|
|
100
|
+
/* silent — never block a tool call over a marker */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* SessionStart (startup / clear / compact — every source but resume): the main thread starts
|
|
106
|
+
* from a context without the earlier copies.
|
|
107
|
+
*/
|
|
108
|
+
export function clearRecallShown(runtimeDir, sessionId) {
|
|
109
|
+
const p = recallShownPath(runtimeDir, sessionId);
|
|
110
|
+
if (!p) return;
|
|
111
|
+
try {
|
|
112
|
+
unlinkSync(p);
|
|
113
|
+
} catch {
|
|
114
|
+
/* absent is fine */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// lib/recent-table.mjs — how many rows the SessionStart `### Recent` table shows, and which
|
|
2
|
+
// (injection budget C4, docs/audits/20261016-injection-budget-baseline.md).
|
|
3
|
+
//
|
|
4
|
+
// The table used to render the whole token-budget pick: 7–13 rows per table (60 SessionStart
|
|
5
|
+
// tables) on the frozen 52-session corpus, 1.02 KB/session, ids cited 2 times in 498 (session, id) pairs. It now shows
|
|
6
|
+
// `recentTableRowCap()` rows (5).
|
|
7
|
+
//
|
|
8
|
+
// WHICH five matters on a thin project: below MIN_LOCAL_OBS own rows, hook-context tops the pick up
|
|
9
|
+
// with other projects' rows, which are often newer. A newest-5 cut over that union evicted the
|
|
10
|
+
// project's OWN rows (pre-ship defect review P2-1: 5 of the 60 corpus tables, 10 own rows), so
|
|
11
|
+
// the cut ranks own rows first and only then fills with foreign ones.
|
|
12
|
+
//
|
|
13
|
+
// Leaf (env-number only): benchmark/injection-budget.mjs replays these two functions, and
|
|
14
|
+
// importing hook-context.mjs would create the data dir's runtime directory as a side effect.
|
|
15
|
+
|
|
16
|
+
import { envNumber } from './env-number.mjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Rows the table shows. Read per call so the CLI `context` command and tests see the current env.
|
|
20
|
+
* 0 hides the table.
|
|
21
|
+
* @param {Record<string, string|undefined>} [env]
|
|
22
|
+
* @returns {number}
|
|
23
|
+
*/
|
|
24
|
+
export function recentTableRowCap(env = process.env) {
|
|
25
|
+
return envNumber(env.CLAUDE_MEM_STARTUP_RECENT_ROWS, {
|
|
26
|
+
name: 'CLAUDE_MEM_STARTUP_RECENT_ROWS',
|
|
27
|
+
defaultValue: 5,
|
|
28
|
+
min: 0,
|
|
29
|
+
integer: true,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The rows the table keeps, in display order: the project's own rows first (a row with no
|
|
35
|
+
* `project`, or this project), then other projects' rows, each newest first by `newer`; cut to
|
|
36
|
+
* `cap`; then sorted by `newer` for display. Copies — the caller's array is not reordered.
|
|
37
|
+
*
|
|
38
|
+
* @template {{project?: string|null}} T
|
|
39
|
+
* @param {T[]} rows
|
|
40
|
+
* @param {string} project
|
|
41
|
+
* @param {number} cap
|
|
42
|
+
* @param {(a: T, b: T) => number} newer the display comparator (newest first)
|
|
43
|
+
* @returns {T[]}
|
|
44
|
+
*/
|
|
45
|
+
export function capRecentRows(rows, project, cap, newer) {
|
|
46
|
+
const own = (o) => !o.project || o.project === project;
|
|
47
|
+
const ranked = [...rows.filter(own).sort(newer), ...rows.filter((o) => !own(o)).sort(newer)];
|
|
48
|
+
return ranked.slice(0, Math.max(0, cap)).sort(newer);
|
|
49
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// lib/startup-dashboard.mjs — aggregates git/tasks/plans/handoff
|
|
2
|
-
// into a single SessionStart injection line (T10c v2.31
|
|
1
|
+
// lib/startup-dashboard.mjs — aggregates git/tasks/plans/handoff state
|
|
2
|
+
// into a single SessionStart injection line (T10c v2.31; the events count left in the
|
|
3
|
+
// injection-budget change).
|
|
3
4
|
//
|
|
4
5
|
// Pure function (with injectable stubs for test determinism). Never throws:
|
|
5
6
|
// every external reader is try/catch'd so a broken git repo, missing tasks dir,
|
|
6
|
-
// or absent
|
|
7
|
+
// or absent table cannot break SessionStart.
|
|
7
8
|
|
|
8
9
|
import { readGitState } from './git-state.mjs';
|
|
9
10
|
import { readProjectTasks } from './task-reader.mjs';
|
|
@@ -47,7 +48,7 @@ function readRecentHandoff(db, project) {
|
|
|
47
48
|
*
|
|
48
49
|
* @param {object} options
|
|
49
50
|
* @param {object} options.db - better-sqlite3 handle. May be null.
|
|
50
|
-
* @param {string} options.project - project identifier used for handoff
|
|
51
|
+
* @param {string} options.project - project identifier used for the handoff (and opt-in events) query.
|
|
51
52
|
* @param {string} [options.projectPath=process.cwd()] - filesystem root for git + tasks.
|
|
52
53
|
* @param {object} [options.stubs] - per-field test injectors:
|
|
53
54
|
* {git?, tasks?, plans?, handoff?}. handoff: pass explicit null to skip;
|
|
@@ -61,13 +62,6 @@ export function buildDashboard({ db, project, projectPath = process.cwd(), stubs
|
|
|
61
62
|
// stubs.handoff === null means "test asserted no handoff"; undefined means "query DB".
|
|
62
63
|
const handoff = stubs && 'handoff' in stubs ? stubs.handoff : readRecentHandoff(db, project);
|
|
63
64
|
|
|
64
|
-
let eventCount = 0;
|
|
65
|
-
try {
|
|
66
|
-
eventCount = db?.prepare(`SELECT COUNT(*) c FROM events WHERE project = ?`).get(project)?.c ?? 0;
|
|
67
|
-
} catch {
|
|
68
|
-
/* events table may not exist on very old DBs */
|
|
69
|
-
}
|
|
70
|
-
|
|
71
65
|
const parts = [];
|
|
72
66
|
|
|
73
67
|
if ((git?.changed?.length ?? 0) > 0 || (git?.stashes?.length ?? 0) > 0) {
|
|
@@ -110,8 +104,23 @@ export function buildDashboard({ db, project, projectPath = process.cwd(), stubs
|
|
|
110
104
|
);
|
|
111
105
|
}
|
|
112
106
|
|
|
113
|
-
|
|
114
|
-
|
|
107
|
+
// Injection budget C6: a `💡 mem events: N entries (claude-mem-lite activity recent)` line
|
|
108
|
+
// used to follow. It sat in all 60 SessionStart blocks of the frozen 52-session corpus and
|
|
109
|
+
// `activity` was run in none of the 52 sessions (docs/audits/20261016-injection-budget-
|
|
110
|
+
// baseline.md; a first count said 56 and 4, corrected in the pre-ship claims review); the
|
|
111
|
+
// count tells the model nothing it acts on. `claude-mem-lite help` still lists `activity recent`.
|
|
112
|
+
// CLAUDE_MEM_DASHBOARD_EVENTS=1 brings the line back (pre-ship defect review P2-2).
|
|
113
|
+
if (
|
|
114
|
+
['1', 'on', 'true', 'yes'].includes(String(process.env.CLAUDE_MEM_DASHBOARD_EVENTS || '').toLowerCase())
|
|
115
|
+
) {
|
|
116
|
+
let eventCount = 0;
|
|
117
|
+
try {
|
|
118
|
+
eventCount = db?.prepare(`SELECT COUNT(*) c FROM events WHERE project = ?`).get(project)?.c ?? 0;
|
|
119
|
+
} catch {
|
|
120
|
+
/* events table may not exist on very old DBs */
|
|
121
|
+
}
|
|
122
|
+
if (eventCount > 0)
|
|
123
|
+
parts.push(`💡 mem events: ${eventCount} entries (\`claude-mem-lite activity recent\`)`);
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
// Invited-memory hint (Phase F): persists every SessionStart until the user
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.27.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "6.
|
|
9
|
+
"version": "6.27.0",
|
|
10
10
|
"os": [
|
|
11
11
|
"darwin",
|
|
12
12
|
"linux",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.27.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
@@ -123,6 +123,8 @@
|
|
|
123
123
|
"lib/hook-stdout.mjs",
|
|
124
124
|
"lib/hook-text-cap.mjs",
|
|
125
125
|
"lib/recall-framing.mjs",
|
|
126
|
+
"lib/recall-shown.mjs",
|
|
127
|
+
"lib/recent-table.mjs",
|
|
126
128
|
"lib/proc-lock.mjs",
|
|
127
129
|
"lib/atomic-write.mjs",
|
|
128
130
|
"lib/platform-gate.mjs",
|
|
@@ -29,12 +29,21 @@ import {
|
|
|
29
29
|
toolEditPath,
|
|
30
30
|
} from '../lib/file-edge-match.mjs';
|
|
31
31
|
import { scrubFilePath } from '../lib/scrub-record.mjs';
|
|
32
|
-
import { fileIntelFor } from '../lib/file-intel.mjs';
|
|
32
|
+
import { fileIntelFor, fileIntelEnabled } from '../lib/file-intel.mjs';
|
|
33
33
|
import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
|
|
34
34
|
import { recordMetric } from '../lib/metrics.mjs';
|
|
35
35
|
import { presentIdents } from '../lib/lesson-idents.mjs';
|
|
36
36
|
import { neutralizeContextDelimiters } from '../format-utils.mjs';
|
|
37
37
|
import { recallFramingLine } from '../lib/recall-framing.mjs';
|
|
38
|
+
// Leaf (fs + path): the header and the full ACK directive once per thread (injection budget C1/C3).
|
|
39
|
+
import {
|
|
40
|
+
ACK_DIRECTIVE,
|
|
41
|
+
ACK_DIRECTIVE_SHORT,
|
|
42
|
+
markRecallShown,
|
|
43
|
+
planRecallBlock,
|
|
44
|
+
readRecallShown,
|
|
45
|
+
recallShownPath,
|
|
46
|
+
} from '../lib/recall-shown.mjs';
|
|
38
47
|
// D#154: the one stdout writer. This script has THREE emit sites (Read→Edit ack,
|
|
39
48
|
// repeated-read guard, lesson block) and they stay one document because each branch
|
|
40
49
|
// process.exit()s before reaching the next.
|
|
@@ -158,8 +167,9 @@ const SALIENCE_BRIDGE = process.env.CLAUDE_MEM_SALIENCE === 'bridge';
|
|
|
158
167
|
// would make readings before and after this change incomparable (pre-ship review P2-2).
|
|
159
168
|
const VERDICT_DIRECTIVE =
|
|
160
169
|
"apply each lesson to this edit or rule it out — state '#NN applied' or '#NN n/a — <reason>' in your next user-facing message.";
|
|
161
|
-
|
|
162
|
-
|
|
170
|
+
// ACK_DIRECTIVE (the default) lives in lib/recall-shown.mjs with its short form: a thread gets the
|
|
171
|
+
// full text once and the short one after (injection budget C3). Only the default arm shortens —
|
|
172
|
+
// the other arms' text must stay the one their readings were taken with (see below).
|
|
163
173
|
// v-bind salience forcing-function (#8771 audit: ack ≠ act). Instead of a cheap
|
|
164
174
|
// '#NN applied / n/a' verdict, demand the model bind the lesson to the concrete
|
|
165
175
|
// line it's editing and quote the satisfying edit line. Selected by
|
|
@@ -182,13 +192,20 @@ const ACTIVE_DIRECTIVE = SALIENCE_BIND
|
|
|
182
192
|
: ACK_DIRECTIVE;
|
|
183
193
|
const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy file
|
|
184
194
|
// Feature ① (file intelligence): on the first Read of a file each session, inject
|
|
185
|
-
// its approximate token size + a one-line summary
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
195
|
+
// its approximate token size + a one-line summary. Read-only (Edit/Write already commit
|
|
196
|
+
// to the file). Files below the token floor stay silent. Env names mirror schema.mjs
|
|
197
|
+
// CLAUDE_MEM_* convention (#8447).
|
|
198
|
+
//
|
|
199
|
+
// OPT-IN since the injection-budget change (docs/audits/20261016-injection-budget-baseline.md).
|
|
200
|
+
// It was meant to let the agent "decide to read fully, slice, or grep", but PreToolUse
|
|
201
|
+
// additionalContext is delivered next to the tool RESULT (Claude Code hooks reference, "Add
|
|
202
|
+
// context for Claude"), so it can never change the call it rides on. On the frozen 52-session
|
|
203
|
+
// corpus 89.9% of its 1,102 lines rode a call that was already a partial read (`sed -n`, grep,
|
|
204
|
+
// Read with offset/limit), after a full Read the same file was sliced later in the turn LESS
|
|
205
|
+
// often (34.9%, 22/63) than after a partial Bash read (69.1%), and no reply named a size (0/1,102) —
|
|
206
|
+
// at 1.57 KB/session, plus the framing header of the 426 blocks it was the only content of.
|
|
207
|
+
// CLAUDE_MEM_FILE_INTEL=1 turns it back on.
|
|
208
|
+
const FILE_INTEL_ON = fileIntelEnabled();
|
|
192
209
|
// P2 (D#78): edge-level decay ENFORCEMENT — opt-in (default OFF, shadow-first).
|
|
193
210
|
// When on, a (obs,file) edge whose miss_streak reached K consecutive uncited
|
|
194
211
|
// injections stops firing on this surface; the lesson stays reachable via
|
|
@@ -541,6 +558,33 @@ try {
|
|
|
541
558
|
const isSessionScoped = Boolean(sessionId);
|
|
542
559
|
const cooldown = readCooldown(cooldownPath);
|
|
543
560
|
const now = Date.now();
|
|
561
|
+
// C1/C3 (lib/recall-shown.mjs): the framing header and the full ACK directive once per thread.
|
|
562
|
+
// Same thread key as the cooldown; null without a session, which shows both every time.
|
|
563
|
+
const shownPath = recallShownPath(RUNTIME_DIR, dedupKey);
|
|
564
|
+
// Prepends the header when this thread has not had one, fills the directive slot, and records
|
|
565
|
+
// what was shown. `body` is everything but the header; `directiveAt(text)` builds the line
|
|
566
|
+
// that carries the directive, or is null when the block has none.
|
|
567
|
+
// `name` is the file the factual framing arm names; the lesson block passes its scrubbed `fname`.
|
|
568
|
+
const emitRecallBlock = (body, directiveAt = null, name = basename(filePath)) => {
|
|
569
|
+
const shown = readRecallShown(shownPath);
|
|
570
|
+
const plan = planRecallBlock(shown, { header: true, directive: Boolean(directiveAt) });
|
|
571
|
+
const lines = [...body];
|
|
572
|
+
if (directiveAt) {
|
|
573
|
+
lines.push(
|
|
574
|
+
directiveAt(
|
|
575
|
+
plan.directive === 'short' && ACTIVE_DIRECTIVE === ACK_DIRECTIVE
|
|
576
|
+
? ACK_DIRECTIVE_SHORT
|
|
577
|
+
: ACTIVE_DIRECTIVE,
|
|
578
|
+
),
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
if (plan.header) lines.unshift(recallFramingLine('PreToolUse', { sessionId, fname: name }));
|
|
582
|
+
queueHookContext('PreToolUse', lines.join('\n'));
|
|
583
|
+
flushHookStdout();
|
|
584
|
+
if (plan.next.header !== shown.header || plan.next.directive !== shown.directive) {
|
|
585
|
+
markRecallShown(shownPath, plan.next);
|
|
586
|
+
}
|
|
587
|
+
};
|
|
544
588
|
if (isSessionScoped) {
|
|
545
589
|
const entry = cooldown[filePath];
|
|
546
590
|
if (entry) {
|
|
@@ -556,14 +600,11 @@ try {
|
|
|
556
600
|
if (!isRead && wasReadMode && seenIds.length > 0 && !SALIENCE_LEGACY) {
|
|
557
601
|
// Namespaced per table: a bare `#N` for an event id names a different memory.
|
|
558
602
|
const idList = lessonIdTokens(seenIds, entry.obsIds).join(', ');
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
`[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACTIVE_DIRECTIVE}`,
|
|
564
|
-
].join('\n'),
|
|
603
|
+
emitRecallBlock(
|
|
604
|
+
[],
|
|
605
|
+
(directive) =>
|
|
606
|
+
`[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${directive}`,
|
|
565
607
|
);
|
|
566
|
-
flushHookStdout();
|
|
567
608
|
cooldown[filePath] = { ...entry, mode: 'edit' };
|
|
568
609
|
writeCooldown(cooldownPath, cooldown, isSessionScoped);
|
|
569
610
|
} else if (isRead && !REREAD_GUARD_OFF && typeof entry === 'object' && entry.reread) {
|
|
@@ -571,14 +612,7 @@ try {
|
|
|
571
612
|
// nudge to reuse what's already in context. Read-only; never throws.
|
|
572
613
|
const meta = readFileMeta(filePath);
|
|
573
614
|
if (shouldWarnReread(entry.reread, meta ? meta.mtimeMs : null, isFullRead, REREAD_MIN_TOKENS)) {
|
|
574
|
-
|
|
575
|
-
'PreToolUse',
|
|
576
|
-
[
|
|
577
|
-
recallFramingLine('PreToolUse', { sessionId, fname: basename(filePath) }),
|
|
578
|
-
buildRereadWarning(basename(filePath), entry.reread.tokens),
|
|
579
|
-
].join('\n'),
|
|
580
|
-
);
|
|
581
|
-
flushHookStdout();
|
|
615
|
+
emitRecallBlock([buildRereadWarning(basename(filePath), entry.reread.tokens)]);
|
|
582
616
|
recordMetric(DATA_DIR, { event: 'reread_warn' }); // tier-1 firing counter (②)
|
|
583
617
|
}
|
|
584
618
|
}
|
|
@@ -824,10 +858,10 @@ try {
|
|
|
824
858
|
// reliably renders across CC variants (sdscc drops plain-text stdout from PreToolUse).
|
|
825
859
|
// suppressOutput:true hides it from transcript mode per CC hook docs.
|
|
826
860
|
// Feature ①: file intelligence (size + summary) for the first Read of this
|
|
827
|
-
// file this session. Read-only; opt
|
|
861
|
+
// file this session. Read-only; opt-in via CLAUDE_MEM_FILE_INTEL=1. Never
|
|
828
862
|
// throws — fileIntelFor returns null on unreadable/below-threshold files.
|
|
829
863
|
let fileIntelLine = null;
|
|
830
|
-
if (isRead &&
|
|
864
|
+
if (isRead && FILE_INTEL_ON) {
|
|
831
865
|
try {
|
|
832
866
|
fileIntelLine = fileIntelFor(filePath, { minTokens: FILE_INTEL_MIN_TOKENS });
|
|
833
867
|
} catch {}
|
|
@@ -860,15 +894,14 @@ try {
|
|
|
860
894
|
via: viaBash ? 'bash' : 'tool',
|
|
861
895
|
});
|
|
862
896
|
}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
}
|
|
897
|
+
// Framing line mirrors #7758 handoff-injection fix: without an explicit
|
|
898
|
+
// "this is context, the call continues" line, observed turn-end after Edit+reminder
|
|
899
|
+
// when the model misreads passive lesson context as a closing note. Wording:
|
|
900
|
+
// lib/recall-framing.mjs. emitRecallBlock prepends it to the FIRST block of a thread
|
|
901
|
+
// only (C1, lib/recall-shown.mjs): every later block still opens with its own `[mem]`
|
|
902
|
+
// line, and the thread has already been told what those are.
|
|
903
|
+
// The directive slot, filled by emitRecallBlock (full text once per thread, short after).
|
|
904
|
+
let directiveAt = null;
|
|
872
905
|
// MED-1 (full audit 2026-07-16): defang the injection-block delimiters in
|
|
873
906
|
// all DB/file-derived text before it enters additionalContext (which CC wraps
|
|
874
907
|
// in <system-reminder>). Stored text is raw — a lesson/title/summary carrying
|
|
@@ -925,7 +958,7 @@ try {
|
|
|
925
958
|
lines.push(
|
|
926
959
|
`[mem] ⚠ #${bridged.id} → this edit must: ${neutralizeContextDelimiters(bridged.check)}. Confirm your new code satisfies it.`,
|
|
927
960
|
);
|
|
928
|
-
else
|
|
961
|
+
else directiveAt = (directive) => `[mem] ⚠ Before this edit: ${directive}`;
|
|
929
962
|
}
|
|
930
963
|
} else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
|
|
931
964
|
// R-4: Edit/Write empty → short backfill reminder. OPT-IN (default off) as
|
|
@@ -939,16 +972,12 @@ try {
|
|
|
939
972
|
//
|
|
940
973
|
// Read never emitted this (passive). The cooldown write below still runs on
|
|
941
974
|
// every branch, so Read→Edit dedup + cite-back lessonId tracking are intact.
|
|
942
|
-
// (Framing line already pushed above via showFraming.)
|
|
943
975
|
lines.push(
|
|
944
976
|
`[mem] No prior lessons for ${fname} — if you solve a non-obvious bug here, run: /lesson --file ${fname} "<root cause + fix>"`,
|
|
945
977
|
);
|
|
946
978
|
}
|
|
947
979
|
|
|
948
|
-
if (lines.length > 0)
|
|
949
|
-
queueHookContext('PreToolUse', lines.join('\n'));
|
|
950
|
-
flushHookStdout();
|
|
951
|
-
}
|
|
980
|
+
if (lines.length > 0) emitRecallBlock(lines, directiveAt, fname);
|
|
952
981
|
// Cooldown applies on ALL branches (including silent-Read) so subsequent
|
|
953
982
|
// calls on the same file in the same session don't re-query — preserving
|
|
954
983
|
// the per-filePath invariant that underpins Read→Edit dedup.
|
|
@@ -275,3 +275,26 @@ export function extractDeferredRefs(text) {
|
|
|
275
275
|
}
|
|
276
276
|
return out;
|
|
277
277
|
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Whether a stored prompt is an echo of the current one: the same text after whitespace and
|
|
281
|
+
* case are folded, or one a prefix of the other covering at least 80% of the longer (a re-paste
|
|
282
|
+
* with a line added). Injection budget C5: the past-question fallback handed the model its own
|
|
283
|
+
* prompt back in 8 of 52 rows on the frozen corpus (docs/audits/20261016-injection-budget-baseline.md).
|
|
284
|
+
* @param {string} stored
|
|
285
|
+
* @param {string} current
|
|
286
|
+
* @returns {boolean}
|
|
287
|
+
*/
|
|
288
|
+
export function isPromptEcho(stored, current) {
|
|
289
|
+
const norm = (s) =>
|
|
290
|
+
String(s ?? '')
|
|
291
|
+
.replace(/\s+/g, ' ')
|
|
292
|
+
.trim()
|
|
293
|
+
.toLowerCase();
|
|
294
|
+
const a = norm(stored);
|
|
295
|
+
const b = norm(current);
|
|
296
|
+
if (!a || !b) return false;
|
|
297
|
+
if (a === b) return true;
|
|
298
|
+
const [short, long] = a.length <= b.length ? [a, b] : [b, a];
|
|
299
|
+
return short.length >= 0.8 * long.length && long.startsWith(short);
|
|
300
|
+
}
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
extractDeferredRefs,
|
|
41
41
|
DEDUP_STALE_MS,
|
|
42
42
|
detectMemOverride,
|
|
43
|
+
isPromptEcho,
|
|
43
44
|
} from './prompt-search-utils.mjs';
|
|
44
45
|
import { injectedIdsFileName, mergeInjectedMarker } from '../lib/injected-ids.mjs';
|
|
45
46
|
import { getDeferredByIds } from '../lib/deferred-work.mjs';
|
|
@@ -566,7 +567,13 @@ function searchByFile(db, files, project, limit) {
|
|
|
566
567
|
// ranking with no scoring multipliers and no top-|rel| gate (prompts are
|
|
567
568
|
// sparser and more surface-form than observations; the gate would rarely
|
|
568
569
|
// fire and mostly kill real hits).
|
|
569
|
-
|
|
570
|
+
//
|
|
571
|
+
// Injection budget C5: a prompt from THIS Claude Code session is already in the context, and the
|
|
572
|
+
// current prompt itself can be in the table (hook.mjs user-prompt writes it in parallel with this
|
|
573
|
+
// search) — so rows of the current session are excluded in SQL, and a row that is an echo of the
|
|
574
|
+
// current prompt (isPromptEcho) in JS. Rows written before cc_session_id existed (NULL) stay
|
|
575
|
+
// eligible.
|
|
576
|
+
function searchByUserPrompts(db, queryText, project, limit, ccSessionId = null) {
|
|
570
577
|
const ftsQuery = upsFtsQuery(queryText);
|
|
571
578
|
if (!ftsQuery) return [];
|
|
572
579
|
|
|
@@ -584,6 +591,7 @@ function searchByUserPrompts(db, queryText, project, limit) {
|
|
|
584
591
|
AND s.project = ?
|
|
585
592
|
AND up.created_at_epoch > ?
|
|
586
593
|
AND up.prompt_text NOT LIKE '<task-notification>%'
|
|
594
|
+
AND (? IS NULL OR up.cc_session_id IS NULL OR up.cc_session_id != ?)
|
|
587
595
|
ORDER BY relevance
|
|
588
596
|
LIMIT ?
|
|
589
597
|
`;
|
|
@@ -596,13 +604,23 @@ function searchByUserPrompts(db, queryText, project, limit) {
|
|
|
596
604
|
// filter, then take `limit` — same ORDER BY, so the old result is a prefix of this
|
|
597
605
|
// one and a row can only be added, never displaced by something worse.
|
|
598
606
|
const poolLimit = Math.min(limit * PROMPT_FALLBACK_POOL_FACTOR, PROMPT_FALLBACK_POOL_MAX);
|
|
599
|
-
|
|
607
|
+
const session = typeof ccSessionId === 'string' && ccSessionId ? ccSessionId : null;
|
|
608
|
+
// The echo filter runs BEFORE the OR relaxation decides: the current prompt matches every one
|
|
609
|
+
// of its own AND terms, so an echo is often the AND query's only row — filtering it after
|
|
610
|
+
// would leave nothing and never try the OR pool (the LIMIT-upstream-of-a-JS-filter shape).
|
|
611
|
+
// CLAUDE_MEM_UPS_PROMPT_ECHO=0 keeps echoes from EARLIER sessions (the pre-change behaviour);
|
|
612
|
+
// the current session's rows stay excluded in SQL either way — that half is the bug fix.
|
|
613
|
+
const echoFilterOn = !['0', 'off', 'false', 'no'].includes(
|
|
614
|
+
String(process.env.CLAUDE_MEM_UPS_PROMPT_ECHO || '').toLowerCase(),
|
|
615
|
+
);
|
|
616
|
+
const notEcho = (rs) => (echoFilterOn ? rs.filter((r) => !isPromptEcho(r.prompt_text, queryText)) : rs);
|
|
617
|
+
let rows = notEcho(db.prepare(sql).all(ftsQuery, project, cutoff, session, session, poolLimit));
|
|
600
618
|
|
|
601
619
|
if (rows.length === 0) {
|
|
602
620
|
const orQuery = relaxFtsQueryToOr(ftsQuery);
|
|
603
621
|
if (orQuery) {
|
|
604
622
|
try {
|
|
605
|
-
rows = db.prepare(sql).all(orQuery, project, cutoff, poolLimit);
|
|
623
|
+
rows = notEcho(db.prepare(sql).all(orQuery, project, cutoff, session, session, poolLimit));
|
|
606
624
|
} catch {}
|
|
607
625
|
}
|
|
608
626
|
}
|
|
@@ -1092,7 +1110,7 @@ async function main() {
|
|
|
1092
1110
|
// single highest-noise UPS path. Restored when REQUIRE_SIGNAL=0.
|
|
1093
1111
|
let promptRows = [];
|
|
1094
1112
|
if (rows.length === 0 && (!REQUIRE_EXPLICIT_SIGNAL || signalPresent)) {
|
|
1095
|
-
promptRows = searchByUserPrompts(db, promptText, project, PROMPT_FALLBACK_LIMIT);
|
|
1113
|
+
promptRows = searchByUserPrompts(db, promptText, project, PROMPT_FALLBACK_LIMIT, hookData.session_id);
|
|
1096
1114
|
}
|
|
1097
1115
|
|
|
1098
1116
|
const candidateIds = rows.length > 0 ? rows.map((r) => r.id) : promptRows.map((r) => `P${r.id}`);
|
package/source-files.mjs
CHANGED
|
@@ -157,6 +157,8 @@ export const SOURCE_FILES = [
|
|
|
157
157
|
'lib/hook-stdout.mjs',
|
|
158
158
|
'lib/hook-text-cap.mjs',
|
|
159
159
|
'lib/recall-framing.mjs',
|
|
160
|
+
'lib/recall-shown.mjs',
|
|
161
|
+
'lib/recent-table.mjs',
|
|
160
162
|
// audit P0/P1: inter-process install lock + atomic config writes — imported by
|
|
161
163
|
// install.mjs (settings.json + install lock) and hook-update.mjs (.claude.json
|
|
162
164
|
// + auto-update lock). Must ship or a partial install/update skips them.
|