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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/bash-utils.mjs +76 -14
- package/haiku-client.mjs +41 -92
- package/hook-llm.mjs +2 -2
- package/hook-optimize.mjs +135 -12
- package/hook-update.mjs +27 -4
- package/hook.mjs +12 -5
- package/install.mjs +24 -1
- package/lib/llm-provider-probe.mjs +103 -0
- package/lib/observation-write.mjs +9 -0
- package/lib/proxy-fetch.mjs +245 -0
- package/lib/save-enrich.mjs +28 -9
- package/mem-cli.mjs +7 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -1
- package/source-files.mjs +2 -0
- package/utils.mjs +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
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.
|
|
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
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
|
|
128
|
-
const
|
|
129
|
-
const
|
|
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))
|
|
137
|
+
if (!ERROR_STOP_WORDS.has(lw) && !seen.has(lw)) { seen.add(lw); cmdWords.push(lw); }
|
|
133
138
|
}
|
|
134
|
-
const
|
|
135
|
-
|
|
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))
|
|
146
|
+
if (!ERROR_STOP_WORDS.has(lt) && !seen.has(lt)) { seen.add(lt); errWords.push(lt); }
|
|
143
147
|
}
|
|
144
148
|
}
|
|
145
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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:
|
|
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
|
-
|
|
188
|
-
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
479
|
-
if (!
|
|
485
|
+
const plan = planErrorRecall(cmd, response);
|
|
486
|
+
if (!plan) return;
|
|
480
487
|
|
|
481
488
|
// FTS5 OR query for broader recall
|
|
482
|
-
const ftsQuery =
|
|
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
|
+
}
|
package/lib/save-enrich.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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.
|
|
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';
|