claude-mem-lite 3.72.1 → 3.74.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.72.1",
13
+ "version": "3.74.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF 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)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.72.1",
3
+ "version": "3.74.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF 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/bash-utils.mjs CHANGED
@@ -117,35 +117,97 @@ const ERROR_STOP_WORDS = new Set([
117
117
  'node', 'require', 'stack', 'trace',
118
118
  ]);
119
119
 
120
+ const ERROR_LINE_RE = /error|fail|exception|cannot|not found|undefined|null/i;
121
+ const ERROR_RECALL_MAX_TERMS = 6;
122
+
120
123
  /**
121
- * Extract discriminative keywords from a failed command and its error output.
122
- * Filters out common stop words to produce useful FTS5 search terms.
123
- * @param {string} cmd The command that was executed
124
- * @param {string} response The error output text
125
- * @returns {string[]|null} Array of 1-6 keywords or null if none found
124
+ * Split a failed command + its output into command-derived and error-derived terms.
125
+ * Shared by extractErrorKeywords (merged view, unchanged contract) and
126
+ * planErrorRecall (which needs the two classes kept apart). Dedup is deliberately
127
+ * ACROSS both classes, command-first, so the merged view is byte-identical to the
128
+ * pre-split single-Set implementation.
129
+ * @returns {{cmdWords: string[], errWords: string[]}}
126
130
  */
127
- export function extractErrorKeywords(cmd, response) {
128
- const words = new Set();
129
- const cmdParts = cmd.split(/[\s/\\|&;]+/).filter(w => w.length > 2 && !/^-/.test(w));
131
+ function collectErrorTerms(cmd, response) {
132
+ const seen = new Set();
133
+ const cmdWords = [];
134
+ const cmdParts = String(cmd || '').split(/[\s/\\|&;]+/).filter(w => w.length > 2 && !/^-/.test(w));
130
135
  for (const w of cmdParts.slice(0, 3)) {
131
136
  const lw = w.toLowerCase();
132
- if (!ERROR_STOP_WORDS.has(lw)) words.add(lw);
137
+ if (!ERROR_STOP_WORDS.has(lw) && !seen.has(lw)) { seen.add(lw); cmdWords.push(lw); }
133
138
  }
134
- const errLines = response.split('\n').filter(l =>
135
- /error|fail|exception|cannot|not found|undefined|null/i.test(l)
136
- ).slice(0, 3);
139
+ const errWords = [];
140
+ const errLines = String(response || '').split('\n').filter(l => ERROR_LINE_RE.test(l)).slice(0, 3);
137
141
  for (const line of errLines) {
138
142
  const tokens = line.replace(/[^a-zA-Z0-9_.-]/g, ' ').split(/\s+/)
139
143
  .filter(w => w.length > 3 && !/^\d+$/.test(w));
140
144
  for (const t of tokens.slice(0, 5)) {
141
145
  const lt = t.toLowerCase();
142
- if (!ERROR_STOP_WORDS.has(lt)) words.add(lt);
146
+ if (!ERROR_STOP_WORDS.has(lt) && !seen.has(lt)) { seen.add(lt); errWords.push(lt); }
143
147
  }
144
148
  }
145
- const result = [...words].slice(0, 6);
149
+ return { cmdWords, errWords };
150
+ }
151
+
152
+ /**
153
+ * Extract discriminative keywords from a failed command and its error output.
154
+ * Filters out common stop words to produce useful FTS5 search terms.
155
+ * @param {string} cmd The command that was executed
156
+ * @param {string} response The error output text
157
+ * @returns {string[]|null} Array of 1-6 keywords or null if none found
158
+ */
159
+ export function extractErrorKeywords(cmd, response) {
160
+ const { cmdWords, errWords } = collectErrorTerms(cmd, response);
161
+ const result = [...cmdWords, ...errWords].slice(0, ERROR_RECALL_MAX_TERMS);
146
162
  return result.length >= 1 ? result : null;
147
163
  }
148
164
 
165
+ /**
166
+ * Decide whether the error-recall surface should fire, and with which terms (D#136).
167
+ *
168
+ * Two defects this closes, both measured against the live DB on 2026-08-22 (obs
169
+ * #10730 carries the readings):
170
+ *
171
+ * 1. NO ERROR SIGNAL ⇒ NO INJECTION. This surface fires on detectBashSignificance's
172
+ * isHardError, and that gate's HARD_ERROR_RE is NOT in sync with ERROR_LINE_RE
173
+ * here: HARD_ERROR_RE accepts `ERR!`, `enoent` and `traceback`, while ERROR_LINE_RE
174
+ * only matches the whole word `error`. So npm's own failure output clears the
175
+ * trigger and then yields ZERO error lines — `npm ERR! code ENOENT / npm ERR!
176
+ * enoent ENOENT: no such file or directory` contains no `error`, no `fail`, and no
177
+ * `not found` (it says "no such file"). The keyword set then degraded to pure
178
+ * command words — literally ['npm','run','build'] — and the surface searched the
179
+ * COMMAND'S TOPIC instead of the failure. Same for a Python traceback whose head
180
+ * lines carry `Traceback (most recent call last):` and a bare `File "x.py"`.
181
+ * Both are among the most common failures a session produces.
182
+ * With no error term there is nothing to recall ON, so the honest answer is
183
+ * silence rather than a topic match. Widening ERROR_LINE_RE is NOT the fix —
184
+ * enumeration always misses one more shape, and this gate is correct for every
185
+ * shape it misses. (Verified: a `grep` killed by seccomp does NOT reach here at
186
+ * all — isHardError is false for it — so that shape is not evidence for this gate.)
187
+ *
188
+ * 2. COMMAND WORDS STAY IN THE QUERY — a demotion was TRIED AND REJECTED on data.
189
+ * The obvious follow-up is to drop `npm` / `run` / `grep` from the query, since
190
+ * they demonstrably let BM25 return release records for a missing-module failure.
191
+ * Replaying five real failures against the live DB (2026-08-22) says the trade is
192
+ * not one-way: error-terms-only did fix `npm run build` (it surfaced #8721
193
+ * ERR_MODULE_NOT_FOUND and #8185 SOURCE_FILES, the rows that actually explain it),
194
+ * but it REGRESSED two others — dropping `database` lost #8673 (plugin-mode
195
+ * data-dir skew) for a failed DB open, and dropping `vitest` lost #8725 (test
196
+ * fails locally) for a test failure. Command words are carrying domain anchoring,
197
+ * not just noise. A demote-to-fallback variant measured byte-identical to
198
+ * error-terms-only (12 rows either way): the primary query always filled its
199
+ * LIMIT 3, so the fallback never ran. Net: gate only, selection unchanged.
200
+ *
201
+ * @param {string} cmd The command that was executed
202
+ * @param {string} response The error output text
203
+ * @returns {{terms: string[]}|null} null ⇒ do not inject
204
+ */
205
+ export function planErrorRecall(cmd, response) {
206
+ const { cmdWords, errWords } = collectErrorTerms(cmd, response);
207
+ if (errWords.length === 0) return null;
208
+ return { terms: [...cmdWords, ...errWords].slice(0, ERROR_RECALL_MAX_TERMS) };
209
+ }
210
+
149
211
  // ─── File Paths ──────────────────────────────────────────────────────────────
150
212
 
151
213
  /**
package/haiku-client.mjs CHANGED
@@ -6,83 +6,12 @@
6
6
  // overridable via OPENROUTER_MODEL
7
7
 
8
8
  import { execFileSync, spawn } from 'child_process';
9
- import http from 'node:http';
10
- import https from 'node:https';
11
- import tls from 'node:tls';
12
9
  import { readFileSync } from 'fs';
13
10
  import { join } from 'path';
14
11
  import { randomUUID } from 'crypto';
15
12
  import { debugLog, debugCatch, parseJsonFromLLM } from './utils.mjs';
16
13
  import { DB_DIR } from './schema.mjs';
17
-
18
- // ─── Proxy support (native fetch ignores HTTP(S)_PROXY) ──────────────────────
19
- //
20
- // Node's global fetch (undici) does NOT honour HTTP(S)_PROXY env vars, and
21
- // undici's ProxyAgent isn't importable without adding a dependency. In an env
22
- // that requires a local proxy to reach external APIs (e.g.
23
- // HTTPS_PROXY=http://127.0.0.1:PORT), a direct fetch to openrouter.ai
24
- // hangs/times out. We tunnel HTTPS through the HTTP CONNECT proxy using built-ins
25
- // only. No proxy var (or a NO_PROXY host) → null → callers keep native fetch,
26
- // unchanged (zero behaviour change when no proxy is configured).
27
- function httpConnectProxyFor(targetUrl) {
28
- const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
29
- if (!proxy || !/^https?:\/\//.test(proxy)) return null; // socks5 ALL_PROXY not supported here
30
- try {
31
- const host = new URL(targetUrl).hostname;
32
- const noProxy = (process.env.NO_PROXY || process.env.no_proxy || '').split(',').map((s) => s.trim()).filter(Boolean);
33
- if (noProxy.some((n) => n === host || (n.startsWith('.') && host.endsWith(n.slice(1))))) return null;
34
- return proxy;
35
- } catch {
36
- return null;
37
- }
38
- }
39
-
40
- // fetch-compatible (subset) POST over an HTTP CONNECT tunnel: returns
41
- // { ok, status, json(), text() }. Rejects on connect/timeout/socket error so the
42
- // caller's try/catch degrades to the CLI exactly as a failed fetch would.
43
- function postViaConnectProxy(proxy, url, { headers = {}, body = '', timeout = 20000 }) {
44
- return new Promise((resolve, reject) => {
45
- const p = new URL(proxy);
46
- const t = new URL(url);
47
- const port = Number(t.port) || 443;
48
- let settled = false;
49
- const finish = (fn, arg) => { if (!settled) { settled = true; fn(arg); } };
50
- const connReq = http.request({
51
- host: p.hostname,
52
- port: Number(p.port) || 80,
53
- method: 'CONNECT',
54
- path: `${t.hostname}:${port}`,
55
- headers: { Host: `${t.hostname}:${port}` },
56
- });
57
- connReq.setTimeout(timeout, () => connReq.destroy(new Error('proxy CONNECT timeout')));
58
- connReq.on('error', (e) => finish(reject, e));
59
- connReq.on('connect', (res, socket) => {
60
- if (res.statusCode !== 200) {
61
- socket.destroy();
62
- return finish(reject, new Error(`proxy CONNECT ${res.statusCode}`));
63
- }
64
- const req = https.request(
65
- url,
66
- { method: 'POST', headers, createConnection: () => tls.connect({ socket, servername: t.hostname }) },
67
- (resp) => {
68
- let data = '';
69
- resp.setEncoding('utf8');
70
- resp.on('data', (c) => (data += c));
71
- resp.on('end', () => finish(resolve, {
72
- ok: resp.statusCode >= 200 && resp.statusCode < 300,
73
- status: resp.statusCode,
74
- json: () => JSON.parse(data),
75
- text: () => data,
76
- }));
77
- }
78
- );
79
- req.setTimeout(timeout, () => req.destroy(new Error('proxy request timeout')));
80
- req.on('error', (e) => finish(reject, e));
81
- req.end(body);
82
- });
83
- connReq.end();
84
- });
85
- }
14
+ import { httpConnectProxyFor, postViaConnectProxy } from './lib/proxy-fetch.mjs';
86
15
 
87
16
  // ─── Model Resolution ────────────────────────────────────────────────────────
88
17
 
@@ -465,16 +394,26 @@ async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = D
465
394
  body.system = [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }];
466
395
  }
467
396
 
468
- const res = await fetch('https://api.anthropic.com/v1/messages', {
469
- method: 'POST',
470
- headers: {
471
- 'Content-Type': 'application/json',
472
- 'x-api-key': apiKey,
473
- 'anthropic-version': '2023-06-01',
474
- },
475
- body: JSON.stringify(body),
476
- signal: controller.signal,
477
- });
397
+ // Proxy-aware, same as the OpenRouter site below. Missing it here meant the
398
+ // ANTHROPIC_API_KEY paths were the one keyed provider still doing a bare
399
+ // fetch — a silent outage behind a proxy, and one the new doctor check would
400
+ // have certified as healthy because it probes the hop this code was ASSUMED
401
+ // to use. (pre-tag review SHOULD-FIX 3)
402
+ const apiUrl = 'https://api.anthropic.com/v1/messages';
403
+ const apiHeaders = {
404
+ 'Content-Type': 'application/json',
405
+ 'x-api-key': apiKey,
406
+ 'anthropic-version': '2023-06-01',
407
+ };
408
+ const apiProxy = httpConnectProxyFor(apiUrl);
409
+ const res = apiProxy
410
+ ? await postViaConnectProxy(apiProxy, apiUrl, { headers: apiHeaders, body: JSON.stringify(body), timeout })
411
+ : await fetch(apiUrl, {
412
+ method: 'POST',
413
+ headers: apiHeaders,
414
+ body: JSON.stringify(body),
415
+ signal: controller.signal,
416
+ });
478
417
 
479
418
  if (!res.ok) {
480
419
  debugLog('WARN', `${model}-api`, `HTTP ${res.status}`);
@@ -767,16 +706,26 @@ async function callHaikuAPI(prompt, { timeout, maxTokens, temperature = DEFAULT_
767
706
  body.system = [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }];
768
707
  }
769
708
 
770
- const res = await fetch('https://api.anthropic.com/v1/messages', {
771
- method: 'POST',
772
- headers: {
773
- 'Content-Type': 'application/json',
774
- 'x-api-key': apiKey,
775
- 'anthropic-version': '2023-06-01',
776
- },
777
- body: JSON.stringify(body),
778
- signal: controller.signal,
779
- });
709
+ // Proxy-aware, same as the OpenRouter site below. Missing it here meant the
710
+ // ANTHROPIC_API_KEY paths were the one keyed provider still doing a bare
711
+ // fetch — a silent outage behind a proxy, and one the new doctor check would
712
+ // have certified as healthy because it probes the hop this code was ASSUMED
713
+ // to use. (pre-tag review SHOULD-FIX 3)
714
+ const apiUrl = 'https://api.anthropic.com/v1/messages';
715
+ const apiHeaders = {
716
+ 'Content-Type': 'application/json',
717
+ 'x-api-key': apiKey,
718
+ 'anthropic-version': '2023-06-01',
719
+ };
720
+ const apiProxy = httpConnectProxyFor(apiUrl);
721
+ const res = apiProxy
722
+ ? await postViaConnectProxy(apiProxy, apiUrl, { headers: apiHeaders, body: JSON.stringify(body), timeout })
723
+ : await fetch(apiUrl, {
724
+ method: 'POST',
725
+ headers: apiHeaders,
726
+ body: JSON.stringify(body),
727
+ signal: controller.signal,
728
+ });
780
729
 
781
730
  if (!res.ok) {
782
731
  debugLog('WARN', 'haiku-api', `HTTP ${res.status}`);
package/hook-llm.mjs CHANGED
@@ -13,7 +13,7 @@ import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
13
13
  import { BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
14
14
  import { scrubRecord } from './lib/scrub-record.mjs';
15
15
  import { getVocabulary, computeVector, vecTextForRow } from './tfidf.mjs';
16
- import { insertObservationRow, insertObservationFiles, insertObservationVector, normalizeScope } from './lib/observation-write.mjs';
16
+ import { insertObservationRow, insertObservationFiles, insertObservationVector, normalizeScope, SCOPE_PROMPT_LEGEND } from './lib/observation-write.mjs';
17
17
  import { DEDUP_JACCARD_THRESHOLD, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
18
18
  import {
19
19
  RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
@@ -740,7 +740,7 @@ type: pick by strongest signal. decision = explicit tradeoff / "chose X over Y b
740
740
  Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
741
741
  importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
742
742
  lesson_learned: The non-obvious insight a future session would benefit from. Examples: "FTS5 porter stemmer doesn't tokenize CJK — need bigram workaround", "vitest --reporter=verbose hangs on large test suites, use default reporter". Look hard before giving up — most coding episodes contain at least one micro-lesson (an undocumented flag, a surprising default, a debugging shortcut, an unexpected interaction). If literally no insight worth teaching (e.g. version bump, whitespace fix, file rename), output JSON null. Do NOT invent a lesson, do NOT write the strings "none"/"n/a"/"todo"/"tbd"/"-" — those will be discarded as noise.
743
- scope: where does the lesson APPLY (not where it was learned)? file = specific to the touched file(s)' own code. module = a directory/subsystem of this project. project = a project-wide convention, architecture, or workflow. environment = a tooling/OS/CI/network/registry/service quirk (proxy, npm, git, GitHub, shell, runner, editor) that would hold in ANY project — even though some project files were touched when it surfaced. When lesson_learned is null, still classify the episode's dominant subject.
743
+ scope: ${SCOPE_PROMPT_LEGEND}
744
744
  search_aliases: 2-6 alternative search terms someone might use to find this memory later (include CJK if project uses Chinese)`;
745
745
 
746
746
  let prompt;
package/hook-optimize.mjs CHANGED
@@ -20,6 +20,7 @@ import { getVocabulary, computeVector, cosineSimilarity, vecTextForRow } from '.
20
20
  import { MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
21
21
  import { DB_DIR } from './schema.mjs';
22
22
  import { OBS_TYPE_SET } from './lib/obs-types.mjs';
23
+ import { normalizeScope, SCOPE_PROMPT_LEGEND } from './lib/observation-write.mjs';
23
24
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
24
25
 
25
26
  import { DAY_MS } from './lib/time-constants.mjs';
@@ -92,10 +93,38 @@ export function rebuildVector(db, obsId, textPartsOrRow) {
92
93
  *
93
94
  * @param {object} db better-sqlite3 database handle
94
95
  * @param {number} limit max candidates to return
95
- * @param {{ scope?: 'narrow' | 'wide', project?: string }} [opts] Optional project filter (e.g. inferProject()-resolved name) narrows candidates to a single project — opt-in to preserve prior cross-project default.
96
+ * @param {{ scope?: 'narrow' | 'wide' | 'aliases' | 'scopes', project?: string }} [opts] Optional project filter (e.g. inferProject()-resolved name) narrows candidates to a single project — opt-in to preserve prior cross-project default.
96
97
  */
97
98
  export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', project } = {}) {
98
99
  const projectClause = project ? 'AND project = ?' : '';
100
+ if (scope === 'scopes') {
101
+ // D#135 P3 scope backfill: substantive rows with observations.scope still
102
+ // NULL, REGARDLESS of lesson or aliases. narrow/wide need lesson IS NULL and
103
+ // aliases needs search_aliases IS NULL, so a legacy lesson-bearing row with
104
+ // aliases is reachable by NONE of them — that shape was 1955 of the 2041
105
+ // scope-less rows on 2026-08-19, i.e. the pool is ~97% invisible to the
106
+ // existing passes. Idempotent via scope becoming non-null; deliberately NOT
107
+ // gated on optimized_at (the alias branch's precedent — an optimized row can
108
+ // still be unclassified) and it never SETS optimized_at, so the wide pass
109
+ // keeps its own candidates.
110
+ // Lesson-bearing first: CLAUDE_MEM_SCOPE_FILTER gates pre-tool recall, which
111
+ // injects lesson-bearing rows — classifying those first is what makes the
112
+ // lever usable before the backlog is fully drained.
113
+ const stmt = db.prepare(`
114
+ SELECT id, title, narrative, type, lesson_learned, importance, project
115
+ FROM observations
116
+ WHERE ${liveObsFilterSql('')}
117
+ AND scope IS NULL
118
+ AND LENGTH(COALESCE(narrative, '')) > 100
119
+ AND ${notLowSignalTitleClause('')}
120
+ ${projectClause}
121
+ ORDER BY
122
+ CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 0 ELSE 1 END,
123
+ created_at_epoch DESC
124
+ LIMIT ?
125
+ `);
126
+ return project ? stmt.all(project, limit) : stmt.all(limit);
127
+ }
99
128
  if (scope === 'aliases') {
100
129
  // P1 alias backfill: substantive rows missing search_aliases, REGARDLESS of
101
130
  // lesson. Targets lesson-bearing manual saves (mem_save writes no aliases →
@@ -150,6 +179,29 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
150
179
  return project ? stmt.all(project, limit) : stmt.all(limit);
151
180
  }
152
181
 
182
+ /**
183
+ * Row count for a re-enrich pool, without materialising it. Only the 'scopes'
184
+ * pool is served: it is the one large enough for the difference to matter
185
+ * (2041 rows at introduction, against ~22 alias candidates), and keeping the
186
+ * predicate here rather than duplicating it would drift — so this shares the
187
+ * finder's WHERE by construction, via a SELECT COUNT over the same clauses.
188
+ * @returns {number}
189
+ */
190
+ export function countReenrichCandidates(db, scope = 'scopes', project) {
191
+ if (scope !== 'scopes') return findReenrichCandidates(db, 5000, { scope, project }).length;
192
+ const projectClause = project ? 'AND project = ?' : '';
193
+ const stmt = db.prepare(`
194
+ SELECT COUNT(*) c
195
+ FROM observations
196
+ WHERE ${liveObsFilterSql('')}
197
+ AND scope IS NULL
198
+ AND LENGTH(COALESCE(narrative, '')) > 100
199
+ AND ${notLowSignalTitleClause('')}
200
+ ${projectClause}
201
+ `);
202
+ return (project ? stmt.get(project) : stmt.get()).c;
203
+ }
204
+
153
205
  export async function executeReenrich(db, limit = 10, { scope = 'narrow', project } = {}) {
154
206
  const candidates = findReenrichCandidates(db, limit, { scope, project });
155
207
  if (candidates.length === 0) return { processed: 0, skipped: 0 };
@@ -162,6 +214,34 @@ export async function executeReenrich(db, limit = 10, { scope = 'narrow', projec
162
214
  if (!gotSlot) { skipped++; continue; }
163
215
 
164
216
  try {
217
+ if (scope === 'scopes') {
218
+ // Classification-only pass (D#135 P3). One cheap Haiku call per row, and
219
+ // the UPDATE touches exactly ONE column — this pool is full of curated
220
+ // lesson-bearing rows, so borrowing the general re-enrich (which rewrites
221
+ // title/narrative/lesson and stamps optimized_at) would risk permanent
222
+ // content loss to buy a single enum value.
223
+ const scopePrompt = `Classify where this coding memory APPLIES. Return ONLY valid JSON, no markdown fences.
224
+
225
+ Title: ${truncate(cand.title || '(untitled)', 200)}
226
+ Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
227
+ Lesson: ${truncate(cand.lesson_learned || '(none)', 300)}
228
+
229
+ JSON: {"scope":"file|module|project|environment"}
230
+ scope: ${SCOPE_PROMPT_LEGEND}`;
231
+ const parsed = await callModelJSONAsync(scopePrompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 60 });
232
+ const scopeValue = normalizeScope(parsed && parsed.scope);
233
+ if (!scopeValue) { skipped++; continue; }
234
+ // `AND scope IS NULL` is the fill-only-empty guard: a save-enrich worker or
235
+ // an episode upgrade can land between candidate selection and this write,
236
+ // and a classifier round-trip is long enough for that to be real.
237
+ const res = db.prepare('UPDATE observations SET scope = ? WHERE id = ? AND scope IS NULL')
238
+ .run(scopeValue, cand.id);
239
+ if (res.changes === 0) { skipped++; continue; }
240
+ // No rebuildVector: scope is a filter column, absent from the FTS text
241
+ // field and from vecTextForRow — a rebuild here would be a no-op write.
242
+ processed++;
243
+ continue;
244
+ }
165
245
  if (scope === 'aliases') {
166
246
  // Alias-only backfill: generate search_aliases and APPEND them (plus any
167
247
  // CJK bigrams) to the EXISTING FTS text. Never rebuild text from
@@ -173,8 +253,9 @@ export async function executeReenrich(db, limit = 10, { scope = 'narrow', projec
173
253
  Title: ${truncate(cand.title || '(untitled)', 200)}
174
254
  Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
175
255
 
176
- JSON: {"search_aliases":["alt phrasing","synonym","spelled-out jargon","CJK term if the domain word has one"]}
177
- Give 3-6 aliases: words a user might search for the SAME concept but that are NOT already in the title (synonyms, the spelled-out form of an acronym, the jargon term for a described symptom, a CJK translation of a key domain term).`;
256
+ JSON: {"search_aliases":["alt phrasing","synonym","spelled-out jargon","CJK term if the domain word has one"],"scope":"file|module|project|environment"}
257
+ Give 3-6 aliases: words a user might search for the SAME concept but that are NOT already in the title (synonyms, the spelled-out form of an acronym, the jargon term for a described symptom, a CJK translation of a key domain term).
258
+ scope: ${SCOPE_PROMPT_LEGEND}`;
178
259
  const parsed = await callModelJSONAsync(aliasPrompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 300 });
179
260
  const aliasArr = parsed && Array.isArray(parsed.search_aliases)
180
261
  ? parsed.search_aliases.filter((a) => typeof a === 'string' && a.trim().length > 0)
@@ -184,8 +265,11 @@ Give 3-6 aliases: words a user might search for the SAME concept but that are NO
184
265
  const aliasBigrams = cjkBigrams(searchAliases);
185
266
  const appendedText = [cand.text || '', searchAliases, aliasBigrams].filter(Boolean).join(' ');
186
267
  const safe = scrubRecord('observations', { text: appendedText, search_aliases: searchAliases });
187
- db.prepare(`UPDATE observations SET search_aliases = ?, text = ? WHERE id = ?`)
188
- .run(safe.search_aliases, safe.text, cand.id);
268
+ // scope rides this call for free (D#135 P3). COALESCE, not a plain set:
269
+ // an omitted or off-enum value normalizes to null and must not erase a
270
+ // classification an earlier face already wrote.
271
+ db.prepare(`UPDATE observations SET search_aliases = ?, text = ?, scope = COALESCE(?, scope) WHERE id = ?`)
272
+ .run(safe.search_aliases, safe.text, normalizeScope(parsed.scope), cand.id);
189
273
  // Refresh the TF-IDF vector from the just-updated FTS text so the new
190
274
  // aliases reach the vector arm too — the narrow/wide branch rebuilds, this
191
275
  // one must as well. No-ops when the vector arm is off / vocab unbuilt.
@@ -199,10 +283,11 @@ Title: ${truncate(cand.title || '(untitled)', 200)}
199
283
  Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
200
284
  Type: ${cand.type || 'change'}
201
285
 
202
- JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"improved ≤120 char title","narrative":"improved 2-3 sentence narrative","concepts":["kw1","kw2"],"facts":["specific fact 1","specific fact 2"],"importance":1,"lesson_learned":"non-obvious insight or 'none' if routine","search_aliases":["alt query 1","alt query 2"]}
286
+ JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"improved ≤120 char title","narrative":"improved 2-3 sentence narrative","concepts":["kw1","kw2"],"facts":["specific fact 1","specific fact 2"],"importance":1,"lesson_learned":"non-obvious insight or 'none' if routine","search_aliases":["alt query 1","alt query 2"],"scope":"file|module|project|environment"}
203
287
  importance: 0=no value, 1=routine, 2=notable non-obvious insight, 3=critical. Default 1.
204
288
  lesson_learned: State what was learned. If routine, write "none".
205
- search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
289
+ search_aliases: 2-6 alternative search terms (include CJK if applicable).
290
+ scope: ${SCOPE_PROMPT_LEGEND}`;
206
291
 
207
292
  const parsed = await callModelJSONAsync(prompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 500 });
208
293
  if (!parsed || !parsed.title) { skipped++; continue; }
@@ -266,10 +351,15 @@ search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
266
351
  });
267
352
  db.prepare(`
268
353
  UPDATE observations SET type=?, title=?, narrative=?, concepts=?, facts=?,
269
- text=?, importance=?, lesson_learned=?, search_aliases=?, minhash_sig=?, optimized_at=?
354
+ text=?, importance=?, lesson_learned=?, search_aliases=?, minhash_sig=?, optimized_at=?,
355
+ scope=COALESCE(?, scope)
270
356
  WHERE id = ?
271
357
  `).run(type, safe.title, safe.narrative, safe.concepts, safe.facts, safe.text,
272
- importance, safe.lesson_learned, safe.search_aliases, minhashSig, Date.now(), cand.id);
358
+ importance, safe.lesson_learned, safe.search_aliases, minhashSig, Date.now(),
359
+ // COALESCE (mirrors the hook-llm upgrade path): a re-enrich that omits
360
+ // scope, or emits an off-enum value, must never blank an existing label —
361
+ // and THIS update stamps optimized_at, so the loss would be permanent.
362
+ normalizeScope(parsed.scope), cand.id);
273
363
 
274
364
  rebuildVector(db, cand.id, { title, narrative, concepts: conceptsText, lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases });
275
365
 
@@ -869,6 +959,14 @@ export function optimizePreview(db, { project, detail = false } = {}) {
869
959
  // P1: alias-backfill eligibility — substantive rows missing search_aliases
870
960
  // (incl. lesson-bearing manual saves) that narrow+wide both skip.
871
961
  const reenrichAliases = findReenrichCandidates(db, 5000, { scope: 'aliases', project }).length;
962
+ // D#135 P3: the scope-backfill backlog. Reported so the one-shot drain
963
+ // (`optimize --run --task re-enrich --scope scopes --max N`) can be sized —
964
+ // the daily pass alone would take months on a multi-thousand-row pool.
965
+ // COUNT, not `findReenrichCandidates(5000).length`: this pool started at 2041
966
+ // rows against the aliases pool's ~22, and the finder selects narrative +
967
+ // lesson_learned per row, so counting by materialising was the one place this
968
+ // round pulled megabytes to print an integer. (pre-tag review NOTE 11)
969
+ const reenrichScopes = countReenrichCandidates(db, 'scopes', project);
872
970
 
873
971
  const concepts = extractUniqueConcepts(db, 500, { project });
874
972
  const normalizeReady = shouldRunNormalize(project) && concepts.length >= 5;
@@ -884,6 +982,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
884
982
  reenrich,
885
983
  reenrichWide,
886
984
  reenrichAliases,
985
+ reenrichScopes,
887
986
  normalize: normalizeReady ? concepts.length : 0,
888
987
  normalizeGateOpen: shouldRunNormalize(project),
889
988
  clusterMerge,
@@ -911,6 +1010,9 @@ export function optimizePreview(db, { project, detail = false } = {}) {
911
1010
  * slice from distributeBudget() — otherwise explicit `--max N --task re-enrich`
912
1011
  * would silently waste 60% of the requested budget.
913
1012
  * @param {number} [opts.maxItems=15] Total item budget across all selected tasks.
1013
+ * Exception: the 'scopes' side-pass the default re-enrich run performs (D#135 P3)
1014
+ * is budgeted separately, up to the re-enrich slice again — see the rationale at
1015
+ * the call site. Its calls are enum-classification only (maxTokens 60).
914
1016
  * @param {boolean} [opts.force=false] Bypass time-based gates (e.g. normalize interval).
915
1017
  * @param {'narrow'|'wide'|'aliases'} [opts.reenrichScope='narrow'] Scope for the re-enrich task.
916
1018
  * 'wide' targets bugfix/refactor/feature/decision with narrative but no lesson (R-7).
@@ -949,16 +1051,37 @@ export async function optimizeRun(db, { tasks, maxItems = 15, force = false, ree
949
1051
  // (reachable only via manual `optimize --max ≤4`; the daily path runs reenrich=6),
950
1052
  // and the starved scope self-corrects next cycle.
951
1053
  // An explicit --scope aliases still runs exactly that one scope (below).
1054
+ //
1055
+ // D#135 P3 adds a THIRD claimant, 'scopes' (observations.scope backfill),
1056
+ // for the same cadence reason — but it is budgeted SEPARATELY, not carved
1057
+ // out of budget.reenrich like aliases. Two measured reasons:
1058
+ // • Its candidate pool is a near-superset of the others' (any live
1059
+ // substantive row with scope NULL: 2041 rows on 2026-08-19 vs 36 wide
1060
+ // and 22 alias candidates), so an adaptive half-share would not be
1061
+ // occasional — it would permanently halve the lesson-enrichment
1062
+ // cadence that the main scope exists to provide.
1063
+ // • It is a fundamentally cheaper call: one enum token (maxTokens 60)
1064
+ // against a full re-enrich's 500, so charging it one full item slot
1065
+ // mis-prices it by an order of magnitude.
1066
+ // Cap is budget.reenrich, so the daily pass adds at most that many cheap
1067
+ // classification calls and an empty pool still costs nothing.
952
1068
  const half = Math.max(1, Math.floor(budget.reenrich / 2));
953
1069
  const aliasBudget = Math.min(half, findReenrichCandidates(db, half, { scope: 'aliases', project }).length);
1070
+ const scopesBudget = Math.min(
1071
+ budget.reenrich,
1072
+ findReenrichCandidates(db, budget.reenrich, { scope: 'scopes', project }).length,
1073
+ );
954
1074
  const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget, { scope: reenrichScope, project });
955
1075
  const aliasRes = aliasBudget > 0
956
1076
  ? await executeReenrich(db, aliasBudget, { scope: 'aliases', project })
957
1077
  : { processed: 0, skipped: 0 };
1078
+ const scopesRes = scopesBudget > 0
1079
+ ? await executeReenrich(db, scopesBudget, { scope: 'scopes', project })
1080
+ : { processed: 0, skipped: 0 };
958
1081
  results.reenrich = {
959
- processed: (mainRes.processed || 0) + (aliasRes.processed || 0),
960
- skipped: (mainRes.skipped || 0) + (aliasRes.skipped || 0),
961
- byScope: { [reenrichScope]: mainRes, aliases: aliasRes },
1082
+ processed: (mainRes.processed || 0) + (aliasRes.processed || 0) + (scopesRes.processed || 0),
1083
+ skipped: (mainRes.skipped || 0) + (aliasRes.skipped || 0) + (scopesRes.skipped || 0),
1084
+ byScope: { [reenrichScope]: mainRes, aliases: aliasRes, scopes: scopesRes },
962
1085
  };
963
1086
  } else {
964
1087
  results.reenrich = await executeReenrich(db, budget.reenrich, { scope: reenrichScope, project });
package/hook-update.mjs CHANGED
@@ -13,6 +13,11 @@ import { debugCatch, debugLog } from './utils.mjs';
13
13
  // extracted tarball's own source-files.mjs inside installExtractedRelease.
14
14
  // See loadReleaseManifest below.
15
15
  import { SOURCE_FILES as LOCAL_SOURCE_FILES, HOOK_SCRIPT_FILES as LOCAL_HOOK_SCRIPT_FILES } from './source-files.mjs';
16
+ // Native fetch ignores HTTP(S)_PROXY. Without this the whole update path — the
17
+ // version check AND the release download — dies instantly behind a proxy, and
18
+ // because checkForUpdate is silent on network failure the plugin then reports
19
+ // itself permanently up to date. Same tunnel the OpenRouter call site uses.
20
+ import { httpConnectProxyFor, getViaConnectProxy } from './lib/proxy-fetch.mjs';
16
21
  import { acquireLock } from './lib/proc-lock.mjs';
17
22
  import { atomicWriteFileSync } from './lib/atomic-write.mjs';
18
23
  import { verifyReleaseFiles, verifyManifestSignature } from './lib/release-digest.mjs';
@@ -234,7 +239,17 @@ async function fetchWithTimeout(url, headers) {
234
239
  const controller = new AbortController();
235
240
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
236
241
  try {
237
- const res = await fetch(url, { signal: controller.signal, headers });
242
+ // Proxy configured CONNECT tunnel; otherwise native fetch, byte-for-byte
243
+ // the previous behaviour. Both shapes expose { status, ok, json() }, and the
244
+ // tunnel REJECTS on transport failure exactly as a failed fetch does, so the
245
+ // catch below still returns null and the caller still stays silent.
246
+ // The AbortController above governs only the fetch branch; the tunnel takes
247
+ // the same budget as an explicit argument and bounds the whole call with it
248
+ // (redirect chain included). (pre-tag review NOTE 7)
249
+ const proxy = httpConnectProxyFor(url);
250
+ const res = proxy
251
+ ? await getViaConnectProxy(proxy, url, { headers, timeout: FETCH_TIMEOUT_MS })
252
+ : await fetch(url, { signal: controller.signal, headers });
238
253
  if (res.status === 403 || res.status === 429) {
239
254
  // 429 = GitHub secondary rate limit (403 = primary). Both must route to the 6h
240
255
  // rate-limit backoff, not the 24h transient-failure path — else a 429 defers the
@@ -452,16 +467,24 @@ export function verifyDownloadedRelease(extractedDir, manifestBytes, signatureB6
452
467
 
453
468
  // Fetch a GitHub Release asset as a Buffer. Host-locked to github.com (the asset
454
469
  // browser_download_url); GitHub's own 302 to its CDN is followed by fetch.
455
- async function fetchAssetBuffer(url) {
470
+ export async function fetchAssetBuffer(url) {
471
+ // Host lock is checked BEFORE transport selection, so having a proxy
472
+ // configured can never route around this supply-chain guard.
456
473
  if (!/^https:\/\/github\.com\/[\w./%~-]+$/.test(url || '')) {
457
474
  throw new Error(`rejected asset url: ${url}`);
458
475
  }
459
476
  const controller = new AbortController();
460
477
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
461
478
  try {
462
- const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
479
+ // getViaConnectProxy follows redirects itself native fetch does that for
480
+ // free, and this URL always 302s from github.com to the asset CDN, so a
481
+ // tunnel without redirect handling would hand back an empty 302 body.
482
+ const proxy = httpConnectProxyFor(url);
483
+ const res = proxy
484
+ ? await getViaConnectProxy(proxy, url, { timeout: FETCH_TIMEOUT_MS })
485
+ : await fetch(url, { signal: controller.signal, redirect: 'follow' });
463
486
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
464
- return Buffer.from(await res.arrayBuffer());
487
+ return proxy ? res.buffer() : Buffer.from(await res.arrayBuffer());
465
488
  } finally {
466
489
  clearTimeout(timeout);
467
490
  }
package/hook.mjs CHANGED
@@ -24,7 +24,7 @@ import { readFileSync, writeFileSync, unlinkSync, readdirSync, renameSync, statS
24
24
  import { homedir } from 'os';
25
25
  import {
26
26
  truncate, inferProject, detectBashSignificance,
27
- extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
27
+ planErrorRecall, extractFilePaths, isRelatedToEpisode,
28
28
  makeEntryDesc, scrubSecrets, stripPrivate, EDIT_TOOLS, debugCatch, debugLog,
29
29
  COMPRESSED_AUTO, OBS_BM25, notLowSignalTitleClause, formatErrorRecallHints,
30
30
  MAX_HOOK_STDIN_BYTES,
@@ -473,13 +473,20 @@ function triggerErrorRecall(db, toolInput, response) {
473
473
  try {
474
474
  const project = inferProject();
475
475
 
476
- // Extract error keywords
476
+ // Extract error keywords. planErrorRecall (D#136) returns null when the output
477
+ // carried NO error-signal token. isHardError above and that check do NOT use the
478
+ // same pattern list: HARD_ERROR_RE accepts `ERR!`/`enoent`/`traceback`, while the
479
+ // line filter wants the whole word `error`. npm's own failure text clears the
480
+ // former and yields zero lines to the latter, so `npm run build` failing on
481
+ // ENOENT used to query ['npm','run','build'] — the command's topic, not the
482
+ // failure. Silence is the honest answer there.
483
+ // The term list itself is unchanged when the gate passes (rationale at the seam).
477
484
  const cmd = toolInput.command || '';
478
- const keywords = extractErrorKeywords(cmd, response);
479
- if (!keywords || keywords.length === 0) return;
485
+ const plan = planErrorRecall(cmd, response);
486
+ if (!plan) return;
480
487
 
481
488
  // FTS5 OR query for broader recall
482
- const ftsQuery = keywords.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
489
+ const ftsQuery = plan.terms.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
483
490
  if (!ftsQuery) return;
484
491
 
485
492
  const nowR = Date.now();
package/install.mjs CHANGED
@@ -1737,8 +1737,17 @@ async function doctor() {
1737
1737
  try { currentVersion = JSON.parse(readFileSync(join(PROJECT_DIR, 'package.json'), 'utf8')).version; } catch { /* fall through with empty version */ }
1738
1738
  const stale = lines.filter(l => isStaleMemProcess(l, currentVersion));
1739
1739
  if (stale.length > 0) {
1740
+ // ⚠-level ONLY, deliberately not `issues++`. buildDoctorSummary's contract is
1741
+ // "issues are ✗-level (action required); warnings are ⚠-level (informational)",
1742
+ // and an old process is the one finding here the user cannot act on from a
1743
+ // doctor run: auto-update bumps installed_plugins.json but cannot kill the MCP
1744
+ // process an active session already spawned, so a correct, healthy install
1745
+ // reports this for as long as that session lives. Counting it made `doctor`
1746
+ // exit 1 while every line on screen was ✓ or ⚠ — it failed the v3.70.0 release
1747
+ // `validate` job (where the "old processes" were vitest's own workers) and it
1748
+ // reddens doctor-install-shape-e2e's "instead of going red forever" case on any
1749
+ // dev box with a previous-version session still open.
1740
1750
  warn(`Old processes running${currentVersion ? ` (current: v${currentVersion})` : ''}:\n ` + stale.join('\n '));
1741
- issues++;
1742
1751
  } else {
1743
1752
  ok('No stale processes');
1744
1753
  }
@@ -1770,6 +1779,20 @@ async function doctor() {
1770
1779
  dwarn('Update state: failed to read');
1771
1780
  }
1772
1781
 
1782
+ // LLM provider reachability. Doctor had no provider check at all, which is how
1783
+ // a configured OPENROUTER_API_KEY could sit unusable for weeks behind an
1784
+ // all-green report while every background call silently paid the CLI fallback.
1785
+ // Transport only, and only when a key is set — no key means no probe and no
1786
+ // network touched.
1787
+ try {
1788
+ const { llmProviderStatus } = await import('./lib/llm-provider-probe.mjs');
1789
+ const st = await llmProviderStatus();
1790
+ if (st.level === 'ok') ok(st.message);
1791
+ else dwarn(st.message);
1792
+ } catch {
1793
+ dwarn('LLM provider: check failed');
1794
+ }
1795
+
1773
1796
  // Dev drift: in dev-mode installs, all SOURCE_FILES entries should be
1774
1797
  // symlinks. A plain file means an earlier install (or manual cp) copied it
1775
1798
  // (edits in the repo won't propagate). A missing entry (neither symlink nor
@@ -0,0 +1,103 @@
1
+ // lib/llm-provider-probe.mjs — "is the configured LLM provider actually usable?"
2
+ //
3
+ // Every keyed-provider dispatcher in haiku-client.mjs degrades to `claude -p`
4
+ // when the API call fails, and says so with one debugLog('WARN') that no surface
5
+ // reads. That is the right RUNTIME behaviour — a memory hook must never block on
6
+ // a provider outage — but it means a permanently broken provider is invisible.
7
+ //
8
+ // Observed 2026-08-19 on the dev box: OPENROUTER_API_KEY set, every call failing
9
+ // at the socket (a local firewall denied the node binary's egress), doctor
10
+ // reporting 21/21 green with no mention of the provider. Cost of the silence:
11
+ // 13.5s per background LLM call instead of 1.4s, for weeks.
12
+ //
13
+ // Scope: TRANSPORT only. A rejected key answers HTTP 401 — loud, and it costs a
14
+ // real request plus shipping the key to learn. Unreachability is the silent
15
+ // class, and one socket open/close answers it.
16
+
17
+ import net from 'node:net';
18
+ import { httpConnectProxyFor, connectProbeViaProxy, redactProxyUrl } from './proxy-fetch.mjs';
19
+
20
+ const PROVIDER_HOST = { api: 'api.anthropic.com', openrouter: 'openrouter.ai' };
21
+
22
+ /**
23
+ * Open and immediately close a TCP connection. No TLS, no request, no key.
24
+ * @param {string} host
25
+ * @param {{port?: number, timeout?: number}} [opts]
26
+ * @returns {Promise<{reachable: boolean, error?: string}>} never rejects
27
+ */
28
+ export function tcpReachable(host, { port = 443, timeout = 4000 } = {}) {
29
+ return new Promise((resolve) => {
30
+ let settled = false;
31
+ const done = (v) => { if (!settled) { settled = true; resolve(v); } };
32
+ const socket = net.connect({ host, port });
33
+ socket.setTimeout(timeout, () => { socket.destroy(); done({ reachable: false, error: 'timeout' }); });
34
+ socket.on('connect', () => { socket.destroy(); done({ reachable: true }); });
35
+ socket.on('error', (e) => { socket.destroy(); done({ reachable: false, error: e.code || e.message }); });
36
+ });
37
+ }
38
+
39
+ /**
40
+ * One doctor line about the LLM provider.
41
+ *
42
+ * Mode detection is duplicated from haiku-client's detectMode ON PURPOSE: that
43
+ * one memoizes into a module-level `_mode` for the life of the process, which is
44
+ * correct for a worker and wrong for a diagnostic. The precedence order is the
45
+ * shared contract and must not drift — ANTHROPIC_API_KEY, then OPENROUTER_API_KEY,
46
+ * then the CLI.
47
+ *
48
+ * Two seams, not one: the proxied and direct paths ask different questions of
49
+ * different endpoints, so a single injected probe would have to switch on its
50
+ * own arguments — the shape that hides which path a test actually exercised.
51
+ *
52
+ * @param {{_probe?: Function, _proxyProbe?: Function}} [seams]
53
+ * @returns {Promise<{mode: string, level: 'ok'|'warn', message: string}>}
54
+ */
55
+ export async function llmProviderStatus({ _probe = tcpReachable, _proxyProbe = connectProbeViaProxy } = {}) {
56
+ const mode = process.env.ANTHROPIC_API_KEY ? 'api'
57
+ : process.env.OPENROUTER_API_KEY ? 'openrouter' : 'cli';
58
+
59
+ if (mode === 'cli') {
60
+ return {
61
+ mode,
62
+ level: 'ok',
63
+ message: 'LLM provider: claude CLI (no ANTHROPIC_API_KEY / OPENROUTER_API_KEY set)',
64
+ };
65
+ }
66
+
67
+ const host = PROVIDER_HOST[mode];
68
+ const proxy = httpConnectProxyFor(`https://${host}/`);
69
+ // Report the hop actually exercised. When a proxy is configured the request
70
+ // path is node → proxy → host, so probing the host directly would answer a
71
+ // question the product never asks — and on a machine where only the proxy is
72
+ // permitted, it would answer it wrong.
73
+ // Redacted: HTTP(S)_PROXY legitimately carries user:pass@ and this string is
74
+ // printed and serialized into `doctor --json`. (pre-tag review)
75
+ const via = proxy ? `via proxy ${redactProxyUrl(proxy)}` : 'direct';
76
+
77
+ let result;
78
+ try {
79
+ // Through a proxy the question is "does a tunnel open", not "is the port
80
+ // occupied": a plain TCP connect passes against a SOCKS-only listener or a
81
+ // proxy that forbids CONNECT, and doctor would then certify a dead
82
+ // provider. (pre-tag review)
83
+ result = proxy
84
+ ? await _proxyProbe(proxy, host, { timeout: 4000 })
85
+ : await _probe(host, { port: 443 });
86
+ } catch (e) {
87
+ result = { reachable: false, error: e?.message || String(e) };
88
+ }
89
+
90
+ if (result?.reachable) {
91
+ return { mode, level: 'ok', message: `LLM provider: ${mode} key set, ${host} reachable (${via})` };
92
+ }
93
+ return {
94
+ mode,
95
+ level: 'warn',
96
+ // Name the consequence, not just the probe: "unreachable" alone reads as
97
+ // cosmetic, and the actual cost (every background call silently falling back
98
+ // to a ~10x slower path) is the reason anyone should care.
99
+ message: `LLM provider: ${mode} key set but unreachable ${via} (${result?.error || 'unknown'}) — `
100
+ + 'every background LLM call fails and silently falls back to the claude CLI (~10x slower); '
101
+ + 'check egress/proxy for this node binary',
102
+ };
103
+ }
@@ -28,6 +28,15 @@ const VALID_SCOPES = new Set(['file', 'module', 'project', 'environment']);
28
28
  export function normalizeScope(value) {
29
29
  return typeof value === 'string' && VALID_SCOPES.has(value) ? value : null;
30
30
  }
31
+
32
+ // Single source for the scope-classification legend (D#135 P3). THREE write
33
+ // faces classify scope — the episode summarizer (hook-llm), save-time enrichment
34
+ // (lib/save-enrich) and the re-enrich passes (hook-optimize). Hand-copied
35
+ // definitions would drift, and a column whose `environment` means something
36
+ // different per face makes the CLAUDE_MEM_SCOPE_FILTER read lever incoherent —
37
+ // the same OBS_TYPE_ENUM hard-copy problem the 2026-07-17 audit flagged.
38
+ // Rendered as `scope: ${SCOPE_PROMPT_LEGEND}` in every prompt.
39
+ export const SCOPE_PROMPT_LEGEND = "where does the lesson APPLY (not where it was learned)? file = specific to the touched file(s)' own code. module = a directory/subsystem of this project. project = a project-wide convention, architecture, or workflow. environment = a tooling/OS/CI/network/registry/service quirk (proxy, npm, git, GitHub, shell, runner, editor) that would hold in ANY project — even though some project files were touched when it surfaced. When lesson_learned is null, still classify the episode's dominant subject.";
31
40
  // Defaults for columns a caller omits. NULL-default columns (subtitle,
32
41
  // search_aliases) match the schema DEFAULT, so omitting == the old short INSERT.
33
42
  // concepts/facts/files_read default to the empty literals the manual path used.
@@ -0,0 +1,245 @@
1
+ // lib/proxy-fetch.mjs — HTTPS over an HTTP CONNECT tunnel, using node: built-ins only.
2
+ //
3
+ // Node's global fetch (undici) does NOT honour HTTP(S)_PROXY, and undici's
4
+ // ProxyAgent is not importable without adding a dependency (this package ships
5
+ // three: @modelcontextprotocol/sdk, better-sqlite3, zod — a proxy shim is not
6
+ // worth a fourth). In an environment where external hosts are only reachable
7
+ // through a local proxy, a direct fetch hangs or dies instantly, so every
8
+ // network feature silently stops working.
9
+ //
10
+ // This lived as two private functions inside haiku-client.mjs (added for the
11
+ // OpenRouter path, memory #8757). It is a lib module now because it was needed
12
+ // in a SECOND place and nobody could see that: hook-update.mjs kept calling bare
13
+ // `fetch`, so on a proxy-bound machine the auto-update version check and the
14
+ // release download both failed silently and the plugin reported itself
15
+ // permanently up to date. Measured on the dev box 2026-08-19: direct egress to
16
+ // api.github.com / openrouter.ai = HTTP 000, the same requests through the proxy
17
+ // = HTTP 200.
18
+ //
19
+ // Zero behaviour change when no proxy is configured: httpConnectProxyFor returns
20
+ // null and callers keep native fetch.
21
+
22
+ import http from 'node:http';
23
+ import https from 'node:https';
24
+ import tls from 'node:tls';
25
+
26
+ /**
27
+ * The HTTP proxy that should carry `targetUrl`, or null to use native fetch.
28
+ * Deliberately ignores socks5:// (ALL_PROXY on Clash/v2ray setups): an HTTP
29
+ * CONNECT sent into a SOCKS listener does not fail fast, it hangs.
30
+ * @param {string} targetUrl
31
+ * @returns {string|null}
32
+ */
33
+ export function httpConnectProxyFor(targetUrl) {
34
+ const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
35
+ if (!proxy || !/^https?:\/\//.test(proxy)) return null; // socks5 ALL_PROXY not supported here
36
+ try {
37
+ const host = new URL(targetUrl).hostname;
38
+ const noProxy = (process.env.NO_PROXY || process.env.no_proxy || '').split(',').map((s) => s.trim()).filter(Boolean);
39
+ if (noProxy.some((n) => n === host || (n.startsWith('.') && host.endsWith(n.slice(1))))) return null;
40
+ return proxy;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * A proxy URL safe to print. HTTP(S)_PROXY legitimately carries `user:pass@`,
48
+ * and this string reaches doctor's stdout and `doctor --json` — the text users
49
+ * paste into bug reports. Host and port survive so the line stays diagnosable.
50
+ * Never returns the input on the error path: echoing an unparseable value back
51
+ * is how the leak would return through the defensive branch. (pre-tag review)
52
+ * @param {string|null|undefined} proxy
53
+ * @returns {string}
54
+ */
55
+ export function redactProxyUrl(proxy) {
56
+ if (!proxy || typeof proxy !== 'string') return '(unset)';
57
+ try {
58
+ const u = new URL(proxy);
59
+ return `${u.protocol}//${u.host}`;
60
+ } catch {
61
+ return '(unparseable proxy url)';
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Does this proxy actually establish a tunnel to `host`? Opens a CONNECT,
67
+ * checks for 200, closes. No TLS, no request, no credentials.
68
+ *
69
+ * A plain TCP connect to the proxy port is NOT this question: anything
70
+ * listening there answers it yes — a SOCKS-only listener, a proxy that forbids
71
+ * CONNECT, or whatever took the port after the proxy died. That gap made
72
+ * doctor's provider check report a healthy provider against a socket that
73
+ * could not carry one request. (pre-tag review)
74
+ *
75
+ * @param {string} proxy
76
+ * @param {string} host target hostname
77
+ * @param {{timeout?: number, port?: number}} [opts]
78
+ * @returns {Promise<{reachable: boolean, error?: string}>} never rejects
79
+ */
80
+ export function connectProbeViaProxy(proxy, host, { timeout = 4000, port = 443 } = {}) {
81
+ return new Promise((resolve) => {
82
+ let p;
83
+ try { p = new URL(proxy); } catch { return resolve({ reachable: false, error: 'unparseable proxy url' }); }
84
+ let settled = false;
85
+ const done = (v) => { if (!settled) { settled = true; resolve(v); } };
86
+ const req = http.request({
87
+ host: p.hostname,
88
+ port: Number(p.port) || 80,
89
+ method: 'CONNECT',
90
+ path: `${host}:${port}`,
91
+ headers: { Host: `${host}:${port}` },
92
+ });
93
+ req.setTimeout(timeout, () => { req.destroy(); done({ reachable: false, error: 'timeout' }); });
94
+ req.on('connect', (res, socket) => {
95
+ socket.destroy();
96
+ done(res.statusCode === 200
97
+ ? { reachable: true }
98
+ : { reachable: false, error: `proxy CONNECT ${res.statusCode}` });
99
+ });
100
+ // A listener that accepts TCP then closes (not a proxy) lands here, as does
101
+ // a refused connection. Both are "no tunnel", which is the whole question.
102
+ req.on('error', (e) => done({ reachable: false, error: e.code || e.message }));
103
+ req.on('close', () => done({ reachable: false, error: 'closed without CONNECT response' }));
104
+ req.end();
105
+ });
106
+ }
107
+
108
+ /**
109
+ * ONE request over a freshly-opened CONNECT tunnel — no redirect handling.
110
+ * fetch-compatible subset: resolves { ok, status, headers, text(), json(),
111
+ * buffer() }; REJECTS on connect/timeout/socket error so a caller's try/catch
112
+ * degrades exactly as it would for a failed fetch.
113
+ *
114
+ * The body is collected as Buffers (not a utf8 string) so the same primitive
115
+ * serves the JSON call sites and the release manifest/signature asset download.
116
+ *
117
+ * @param {string} proxy proxy origin, e.g. http://127.0.0.1:10808
118
+ * @param {string} url absolute https:// target
119
+ * @param {{method?:string, headers?:object, body?:string|Buffer, timeout?:number}} [opts]
120
+ */
121
+ export function onceViaConnectProxy(proxy, url, { method = 'GET', headers = {}, body = '', timeout = 20000 } = {}) {
122
+ return new Promise((resolve, reject) => {
123
+ let p, t;
124
+ try { p = new URL(proxy); t = new URL(url); } catch (e) { return reject(e); }
125
+ const port = Number(t.port) || 443;
126
+ let settled = false;
127
+ const finish = (fn, arg) => {
128
+ if (settled) return;
129
+ settled = true;
130
+ clearTimeout(overall);
131
+ fn(arg);
132
+ };
133
+ // `timeout` bounds the WHOLE call, not each phase. Two per-phase
134
+ // socket timers made a 1000ms budget reject at 2008ms — every caller's
135
+ // network deadline was silently double what it asked for. Recomputing each
136
+ // phase's timer from a deadline did NOT fix it: a TLS socket wrapping an
137
+ // already-connected socket never completes its handshake here, and
138
+ // ClientRequest.setTimeout arms against socket events that then never come.
139
+ // So the bound is an explicit timer rather than a claim about when Node
140
+ // arms its own. unref'd: a background worker must not be held open by it.
141
+ // (pre-tag review)
142
+ const deadline = Date.now() + timeout;
143
+ const remaining = () => Math.max(1, deadline - Date.now());
144
+ const sockets = [];
145
+ const overall = setTimeout(() => {
146
+ for (const s of sockets) { try { s.destroy(); } catch { /* already gone */ } }
147
+ finish(reject, new Error('proxy request timeout'));
148
+ }, timeout);
149
+ if (typeof overall.unref === 'function') overall.unref();
150
+ const connReq = http.request({
151
+ host: p.hostname,
152
+ port: Number(p.port) || 80,
153
+ method: 'CONNECT',
154
+ path: `${t.hostname}:${port}`,
155
+ headers: { Host: `${t.hostname}:${port}` },
156
+ });
157
+ connReq.setTimeout(remaining(), () => connReq.destroy(new Error('proxy CONNECT timeout')));
158
+ connReq.on('error', (e) => finish(reject, e));
159
+ connReq.on('socket', (s) => sockets.push(s));
160
+ connReq.on('connect', (res, socket) => {
161
+ sockets.push(socket);
162
+ if (res.statusCode !== 200) {
163
+ socket.destroy();
164
+ return finish(reject, new Error(`proxy CONNECT ${res.statusCode}`));
165
+ }
166
+ const req = https.request(
167
+ url,
168
+ { method, headers, createConnection: () => tls.connect({ socket, servername: t.hostname }) },
169
+ (resp) => {
170
+ const chunks = [];
171
+ resp.on('data', (c) => chunks.push(Buffer.from(c)));
172
+ resp.on('end', () => {
173
+ const buf = Buffer.concat(chunks);
174
+ finish(resolve, {
175
+ ok: resp.statusCode >= 200 && resp.statusCode < 300,
176
+ status: resp.statusCode,
177
+ headers: resp.headers,
178
+ buffer: () => buf,
179
+ text: () => buf.toString('utf8'),
180
+ json: () => JSON.parse(buf.toString('utf8')),
181
+ });
182
+ });
183
+ }
184
+ );
185
+ req.setTimeout(remaining(), () => req.destroy(new Error('proxy request timeout')));
186
+ req.on('error', (e) => finish(reject, e));
187
+ req.end(body);
188
+ });
189
+ connReq.end();
190
+ });
191
+ }
192
+
193
+ /**
194
+ * onceViaConnectProxy + redirect following. Native fetch follows redirects by
195
+ * default, so a call site swapped from fetch to the tunnel needs this or the
196
+ * GitHub release asset (302 github.com → objects.githubusercontent.com) comes
197
+ * back as an unusable 302 body.
198
+ *
199
+ * Two safety rules the loop enforces, both of which fetch also applies:
200
+ * • Authorization is dropped when the hop crosses to a different host — a
201
+ * redirect must not be able to walk a credential to another origin.
202
+ * • The body is not replayed; a redirected request continues as a bodiless
203
+ * GET (fetch's behaviour for 301/302/303).
204
+ *
205
+ * @param {string} proxy
206
+ * @param {string} url
207
+ * @param {{method?:string, headers?:object, body?:string|Buffer, timeout?:number, maxRedirects?:number}} [opts]
208
+ * @param {{_once?: Function}} [seams] injectable transport (tests)
209
+ */
210
+ export async function requestViaConnectProxy(proxy, url, opts = {}, { _once = onceViaConnectProxy } = {}) {
211
+ const { maxRedirects = 5, ...rest } = opts;
212
+ let current = url;
213
+ let next = { method: 'GET', headers: {}, body: '', timeout: 20000, ...rest };
214
+ // `timeout` is the budget for the CHAIN, not per hop. Native fetch aborts the
215
+ // whole redirect sequence on one signal; giving each hop a fresh full budget
216
+ // made a 3s caller worst-case ~18s across 6 hops. (pre-tag review NOTE 7)
217
+ const chainDeadline = Date.now() + (next.timeout || 20000);
218
+ for (let hop = 0; ; hop++) {
219
+ next = { ...next, timeout: Math.max(1, chainDeadline - Date.now()) };
220
+ const res = await _once(proxy, current, next);
221
+ const location = res.status >= 300 && res.status < 400 && res.headers && res.headers.location;
222
+ if (!location || hop >= maxRedirects) return res;
223
+ const target = new URL(location, current);
224
+ const sameHost = target.hostname === new URL(current).hostname;
225
+ const headers = { ...next.headers };
226
+ if (!sameHost) { delete headers.Authorization; delete headers.authorization; }
227
+ next = { ...next, method: 'GET', body: '', headers };
228
+ current = target.toString();
229
+ }
230
+ }
231
+
232
+ /** GET convenience wrapper — the shape hook-update's two call sites need. */
233
+ export function getViaConnectProxy(proxy, url, { headers = {}, timeout = 20000, maxRedirects = 5 } = {}) {
234
+ return requestViaConnectProxy(proxy, url, { method: 'GET', headers, timeout, maxRedirects });
235
+ }
236
+
237
+ /**
238
+ * POST convenience wrapper. Kept for the OpenRouter call site, whose contract
239
+ * predates this module: single request, NO redirect following (the API does not
240
+ * redirect, and silently re-issuing a prompt POST as a GET would be worse than
241
+ * surfacing the status).
242
+ */
243
+ export function postViaConnectProxy(proxy, url, { headers = {}, body = '', timeout = 20000 } = {}) {
244
+ return onceViaConnectProxy(proxy, url, { method: 'POST', headers, body, timeout });
245
+ }
@@ -9,10 +9,17 @@
9
9
  // lesson_learned (obligated types only) + search_aliases (every manual save),
10
10
  // executed by a detached worker so the save path itself stays zero-latency.
11
11
  //
12
+ // D#135 P3: the same call also classifies `scope` (where the lesson APPLIES).
13
+ // v44 shipped the column plus the CLAUDE_MEM_SCOPE_FILTER read lever, but only
14
+ // hook-llm's episode summarizer ever wrote it — manual saves left it NULL, so on
15
+ // 2026-08-19 every bugfix/decision/discovery row was unclassified (0/41 of the
16
+ // 08-16..08-17 cohort) and the lever was inert on exactly the rows pre-tool
17
+ // recall injects. Riding this Haiku call costs no extra round trip.
18
+ //
12
19
  // Contract (empty-overwrite is the historical audit main-class — R1/R4):
13
- // • FILL-ONLY-EMPTY: never replaces a caller-written lesson or aliases filled
14
- // by a concurrent optimize pass (re-checked inside a BEGIN IMMEDIATE txn).
15
- // • Touches ONLY lesson_learned / search_aliases / text (alias append) —
20
+ // • FILL-ONLY-EMPTY: never replaces a caller-written lesson, aliases, or scope
21
+ // filled by a concurrent optimize pass (re-checked inside a BEGIN IMMEDIATE txn).
22
+ // • Touches ONLY lesson_learned / search_aliases / scope / text (alias append) —
16
23
  // title/narrative/type/importance/concepts/facts stay byte-identical.
17
24
  // • Never sets optimized_at: the daily wide re-enrich stays the safety net.
18
25
  // • Silent degradation: no Haiku, bad JSON, ineligible row → row unchanged.
@@ -26,6 +33,7 @@ import { join, dirname } from 'path';
26
33
  import { fileURLToPath } from 'url';
27
34
  import { scrubSecrets, cjkBigrams, truncate, debugCatch } from '../utils.mjs';
28
35
  import { scrubRecord } from './scrub-record.mjs';
36
+ import { normalizeScope, SCOPE_PROMPT_LEGEND } from './observation-write.mjs';
29
37
 
30
38
  export const ENRICH_OBLIGATED_TYPES = new Set(['bugfix', 'decision']);
31
39
 
@@ -72,7 +80,7 @@ export function queueSaveEnrich(id) {
72
80
  */
73
81
  export async function executeSaveEnrich(db, id, { callJson } = {}) {
74
82
  const row = db.prepare(`
75
- SELECT id, type, title, narrative, text, lesson_learned, search_aliases,
83
+ SELECT id, type, title, narrative, text, lesson_learned, search_aliases, scope,
76
84
  superseded_at, compressed_into
77
85
  FROM observations WHERE id = ?
78
86
  `).get(id);
@@ -82,7 +90,8 @@ export async function executeSaveEnrich(db, id, { callJson } = {}) {
82
90
  const wantLesson = ENRICH_OBLIGATED_TYPES.has(row.type)
83
91
  && !(row.lesson_learned && row.lesson_learned.trim());
84
92
  const wantAliases = !(row.search_aliases && row.search_aliases.trim());
85
- if (!wantLesson && !wantAliases) return { enriched: false, reason: 'complete' };
93
+ const wantScope = !row.scope;
94
+ if (!wantLesson && !wantAliases && !wantScope) return { enriched: false, reason: 'complete' };
86
95
 
87
96
  const { callModelJSON, BG_LLM_TIMEOUT_MS } = await import('../haiku-client.mjs');
88
97
  const call = callJson || callModelJSON;
@@ -92,8 +101,9 @@ Title: ${truncate(row.title || '(untitled)', 200)}
92
101
  Context: ${truncate(row.narrative || row.text || '(none)', 500)}
93
102
  Type: ${row.type || 'change'}
94
103
 
95
- JSON: {"lesson_learned":"the transferable insight (root cause + fix, or constraint + tradeoff) in 1-2 sentences — or 'none' if routine","search_aliases":["alt phrasing","synonym","spelled-out jargon","CJK term if a key domain word has one"]}
96
- search_aliases: 3-6 terms a user might search for the SAME memory that are NOT already in the title.`;
104
+ JSON: {"lesson_learned":"the transferable insight (root cause + fix, or constraint + tradeoff) in 1-2 sentences — or 'none' if routine","search_aliases":["alt phrasing","synonym","spelled-out jargon","CJK term if a key domain word has one"],"scope":"file|module|project|environment"}
105
+ search_aliases: 3-6 terms a user might search for the SAME memory that are NOT already in the title.
106
+ scope: ${SCOPE_PROMPT_LEGEND}`;
97
107
 
98
108
  let parsed = null;
99
109
  try {
@@ -109,7 +119,10 @@ search_aliases: 3-6 terms a user might search for the SAME memory that are NOT a
109
119
  const aliasArr = wantAliases && Array.isArray(parsed.search_aliases)
110
120
  ? parsed.search_aliases.filter((a) => typeof a === 'string' && a.trim().length > 0).slice(0, 6)
111
121
  : [];
112
- if (!lesson && aliasArr.length === 0) return { enriched: false, reason: 'nothing-usable' };
122
+ // Whitelist-validated: Haiku output is untrusted, anything off-enum becomes null
123
+ // (= "unclassified", which every scope-aware read path treats as do-not-filter).
124
+ const scope = wantScope ? normalizeScope(parsed.scope) : null;
125
+ if (!lesson && aliasArr.length === 0 && !scope) return { enriched: false, reason: 'nothing-usable' };
113
126
 
114
127
  // Fill-only-empty under BEGIN IMMEDIATE: a concurrent daily-optimize pass (or
115
128
  // a caller's mem_update) may have filled either field between spawn and now —
@@ -117,7 +130,7 @@ search_aliases: 3-6 terms a user might search for the SAME memory that are NOT a
117
130
  let enriched = false;
118
131
  const txn = db.transaction(() => {
119
132
  const fresh = db.prepare(`
120
- SELECT lesson_learned, search_aliases, text, superseded_at, compressed_into
133
+ SELECT lesson_learned, search_aliases, scope, text, superseded_at, compressed_into
121
134
  FROM observations WHERE id = ?
122
135
  `).get(id);
123
136
  if (!fresh || fresh.superseded_at || fresh.compressed_into) return;
@@ -139,6 +152,12 @@ search_aliases: 3-6 terms a user might search for the SAME memory that are NOT a
139
152
  sets.push('search_aliases = ?', 'text = ?');
140
153
  vals.push(safe.search_aliases, safe.text);
141
154
  }
155
+ // Enum value, not free text — no scrubRecord pass needed (and none possible:
156
+ // a secret can't survive normalizeScope's four-value whitelist).
157
+ if (scope && !fresh.scope) {
158
+ sets.push('scope = ?');
159
+ vals.push(scope);
160
+ }
142
161
  if (sets.length === 0) return;
143
162
  db.prepare(`UPDATE observations SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
144
163
  enriched = true;
package/mem-cli.mjs CHANGED
@@ -2790,10 +2790,13 @@ Commands:
2790
2790
  --run-all Execute bypassing gates
2791
2791
  --task T Comma-separated: re-enrich,normalize,cluster-merge,smart-compress
2792
2792
  --max N Max items per task (1-100, default 15)
2793
- --scope S re-enrich scope: narrow (default) | wide | aliases
2793
+ --scope S re-enrich scope: narrow (default) | wide | aliases | scopes
2794
2794
  (aliases: backfill search_aliases on substantive rows that
2795
2795
  lack them — incl. lesson-bearing manual saves — adds ONLY
2796
2796
  aliases, never rewrites title/narrative/lesson)
2797
+ (scopes: backfill the applicability label observations.scope
2798
+ on rows that lack it — writes ONLY that column, never stamps
2799
+ optimized_at; feeds CLAUDE_MEM_SCOPE_FILTER)
2797
2800
  --project P Limit to a single project (.|current = the current project)
2798
2801
  --verbose / -v Preview also dumps cluster contents + re-enrich samples
2799
2802
 
@@ -3133,8 +3136,8 @@ async function cmdOptimize(db, args) {
3133
3136
  let reenrichScope = 'narrow';
3134
3137
  if (scopeIdx >= 0 && args[scopeIdx + 1] !== undefined) {
3135
3138
  const raw = args[scopeIdx + 1];
3136
- if (raw !== 'narrow' && raw !== 'wide' && raw !== 'aliases') {
3137
- fail(`[mem] Invalid --scope "${raw}". Use: narrow, wide, aliases`);
3139
+ if (raw !== 'narrow' && raw !== 'wide' && raw !== 'aliases' && raw !== 'scopes') {
3140
+ fail(`[mem] Invalid --scope "${raw}". Use: narrow, wide, aliases, scopes`);
3138
3141
  return;
3139
3142
  }
3140
3143
  reenrichScope = raw;
@@ -3153,7 +3156,7 @@ async function cmdOptimize(db, args) {
3153
3156
  const preview = optimizePreview(db, { project, detail: verbose });
3154
3157
  out('[mem] 🔍 LLM Optimization Preview:');
3155
3158
  if (project) out(` Project filter: ${project}`);
3156
- out(` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}${preview.reenrichAliases ? ` (aliases scope: ${preview.reenrichAliases})` : ''}`);
3159
+ out(` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}${preview.reenrichAliases ? ` (aliases scope: ${preview.reenrichAliases})` : ''}${preview.reenrichScopes ? ` (scopes scope: ${preview.reenrichScopes})` : ''}`);
3157
3160
  out(` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`);
3158
3161
  // "candidates" matches the MCP wording (server.mjs mem_optimize preview) AND the
3159
3162
  // Re-enrich line just above, which already read that way on both surfaces. The two
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.72.1",
3
+ "version": "3.74.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.72.1",
9
+ "version": "3.74.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.72.1",
3
+ "version": "3.74.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF 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",
@@ -83,6 +83,8 @@
83
83
  "lib/hook-stdout.mjs",
84
84
  "lib/proc-lock.mjs",
85
85
  "lib/atomic-write.mjs",
86
+ "lib/proxy-fetch.mjs",
87
+ "lib/llm-provider-probe.mjs",
86
88
  "lib/release-digest.mjs",
87
89
  "lib/mem-override.mjs",
88
90
  "lib/injected-ids.mjs",
package/source-files.mjs CHANGED
@@ -111,6 +111,8 @@ export const SOURCE_FILES = [
111
111
  // + auto-update lock). Must ship or a partial install/update skips them.
112
112
  'lib/proc-lock.mjs',
113
113
  'lib/atomic-write.mjs',
114
+ 'lib/proxy-fetch.mjs',
115
+ 'lib/llm-provider-probe.mjs',
114
116
  // P1 supply-chain: shared release-signing core (sha256 manifest + Ed25519
115
117
  // verify). Imported by hook-update.mjs (verify) + scripts/sign-release.mjs (CI
116
118
  // sign). Must ship or auto-update can't verify release signatures.
package/utils.mjs CHANGED
@@ -16,7 +16,7 @@ export { scrubSecrets, SECRET_PATTERNS } from './secret-scrub.mjs';
16
16
  export { stripPrivate } from './lib/private-strip.mjs';
17
17
  export { truncate, typeIcon, fmtDate, fmtTime, isoWeekKey, formatErrorRecallHints, neutralizeContextDelimiters } from './format-utils.mjs';
18
18
  export { computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity } from './hash-utils.mjs';
19
- export { detectBashSignificance, extractErrorKeywords, extractFilePaths, stripTestSuffix } from './bash-utils.mjs';
19
+ export { detectBashSignificance, extractErrorKeywords, planErrorRecall, extractFilePaths, stripTestSuffix } from './bash-utils.mjs';
20
20
 
21
21
  // Internal imports for functions that remain in this module
22
22
  import { truncate } from './format-utils.mjs';