claude-mem-lite 6.19.0 → 6.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook-optimize.mjs +27 -2
- package/lib/activity.mjs +2 -0
- package/lib/deferred-work.mjs +24 -0
- package/lib/lesson-bridge.mjs +2 -1
- package/lib/provenance.mjs +3 -2
- package/lib/save-observation.mjs +4 -1
- package/mem-cli.mjs +3 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/secret-scrub.mjs +34 -16
- package/server.mjs +2 -2
- package/utils.mjs +50 -9
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.19.
|
|
12
|
+
"version": "6.19.1",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"homepage": "https://github.com/sdsrss/claude-mem-lite",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.19.
|
|
3
|
+
"version": "6.19.1",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/hook-optimize.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { normalizeScope, SCOPE_PROMPT_LEGEND, insertObservationRow } from './lib
|
|
|
32
32
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
33
33
|
import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
34
34
|
import { MEMORY_INPUT_GUARD } from './lib/memory-input-guard.mjs';
|
|
35
|
+
import { MANUAL_SESSION_ID_PREFIX } from './lib/provenance.mjs';
|
|
35
36
|
|
|
36
37
|
import { DAY_MS } from './lib/time-constants.mjs';
|
|
37
38
|
// P1-14: same resolver as hook-shared.mjs — this was the second module that had never
|
|
@@ -1108,7 +1109,7 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
|
|
|
1108
1109
|
-- keeper.search_aliases when it rebuilt the keeper's TF-IDF vector. Phase-2 removed that
|
|
1109
1110
|
-- rebuild, so the column had no reader left and went with it. Do NOT re-add it on the
|
|
1110
1111
|
-- strength of R10 P3-7 -- that finding is moot, not pending. executeMergeCluster reads
|
|
1111
|
-
-- keeper.{id,importance,narrative,concepts,facts} and o.{id,title,type,narrative,
|
|
1112
|
+
-- keeper.{id,project,importance,narrative,concepts,facts} and o.{id,title,type,narrative,
|
|
1112
1113
|
-- importance,access_count,lesson_learned}, and nothing else off these rows.
|
|
1113
1114
|
SELECT id, title, narrative, project, type, access_count, importance, created_at_epoch, minhash_sig, lesson_learned, concepts, facts
|
|
1114
1115
|
FROM observations
|
|
@@ -1311,10 +1312,33 @@ Return ONLY valid JSON:
|
|
|
1311
1312
|
SELECT ${snapColList}, ? FROM observations WHERE id = ?`,
|
|
1312
1313
|
).run(keeper.id, keeper.id);
|
|
1313
1314
|
|
|
1315
|
+
// The keeper now holds model text. Search reads authorship from memory_session_id, so an
|
|
1316
|
+
// explicit save's `manual-` id would mark it as one (D#138); it moves to the compression
|
|
1317
|
+
// writer's id. A machine-written keeper keeps its id, and so does the snapshot above, which
|
|
1318
|
+
// is the original save. The writer's session row is best-effort: sdk_sessions refuses an id
|
|
1319
|
+
// shaped like a uuid, which `compress-<project>` is for a 27-character project shaped
|
|
1320
|
+
// xxxx-xxxx-xxxx-xxxxxxxxxxxx, and that refusal must not fail the merge (v6.19.1 pre-tag F5a).
|
|
1321
|
+
let rewriteSessionId = null;
|
|
1322
|
+
const keeperSession = db
|
|
1323
|
+
.prepare('SELECT memory_session_id FROM observations WHERE id = ?')
|
|
1324
|
+
.get(keeper.id);
|
|
1325
|
+
if (keeperSession?.memory_session_id?.startsWith(MANUAL_SESSION_ID_PREFIX)) {
|
|
1326
|
+
const compressSessionId = `compress-${keeper.project}`;
|
|
1327
|
+
try {
|
|
1328
|
+
db.prepare(
|
|
1329
|
+
`INSERT OR IGNORE INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
|
|
1330
|
+
VALUES (?, ?, ?, ?, ?, 'active')`,
|
|
1331
|
+
).run(compressSessionId, compressSessionId, keeper.project, new Date().toISOString(), Date.now());
|
|
1332
|
+
rewriteSessionId = compressSessionId;
|
|
1333
|
+
} catch (e) {
|
|
1334
|
+
debugCatch(e, 'cluster-merge writer session');
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1314
1337
|
db.prepare(
|
|
1315
1338
|
`
|
|
1316
1339
|
UPDATE observations SET title=?, narrative=?, concepts=?, facts=?, text=?,
|
|
1317
|
-
importance=?, lesson_learned=?, minhash_sig=?, optimized_at
|
|
1340
|
+
importance=?, lesson_learned=?, minhash_sig=?, optimized_at=?,
|
|
1341
|
+
memory_session_id = COALESCE(?, memory_session_id)
|
|
1318
1342
|
WHERE id = ?
|
|
1319
1343
|
`,
|
|
1320
1344
|
).run(
|
|
@@ -1327,6 +1351,7 @@ Return ONLY valid JSON:
|
|
|
1327
1351
|
safe.lesson_learned,
|
|
1328
1352
|
minhashSig,
|
|
1329
1353
|
Date.now(),
|
|
1354
|
+
rewriteSessionId,
|
|
1330
1355
|
keeper.id,
|
|
1331
1356
|
);
|
|
1332
1357
|
|
package/lib/activity.mjs
CHANGED
|
@@ -199,6 +199,8 @@ export function promoteInsightEvents(
|
|
|
199
199
|
// manual-save contract) so it lands in the high-weight FTS field.
|
|
200
200
|
lesson_learned: ev.body.slice(0, 500),
|
|
201
201
|
now: new Date(ev.created_at_epoch),
|
|
202
|
+
// Event bodies are machine-written (episode summaries), so the row is not an explicit save.
|
|
203
|
+
sessionId: `promote-${ev.project}`,
|
|
202
204
|
});
|
|
203
205
|
mark.run(Date.now(), ev.id);
|
|
204
206
|
return r;
|
package/lib/deferred-work.mjs
CHANGED
|
@@ -86,6 +86,30 @@ export function listOpenWithOrdinal(db, project, limit = 10) {
|
|
|
86
86
|
.all(project, limit);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* One open row's ordinal, numbered as listOpenWithOrdinal numbers it; null when the row is not
|
|
91
|
+
* open. `defer add` / `mem_defer` read it from a 50-row page before, so an item added past 50
|
|
92
|
+
* open printed `(item ?)` (D#123).
|
|
93
|
+
* @param {Database} db
|
|
94
|
+
* @param {string} project
|
|
95
|
+
* @param {number} id
|
|
96
|
+
* @returns {number|null}
|
|
97
|
+
*/
|
|
98
|
+
export function openOrdinalOf(db, project, id) {
|
|
99
|
+
const row = db
|
|
100
|
+
.prepare(
|
|
101
|
+
`
|
|
102
|
+
SELECT ordinal FROM (
|
|
103
|
+
SELECT id, ROW_NUMBER() OVER (ORDER BY priority DESC, created_at_epoch ASC, id ASC) AS ordinal
|
|
104
|
+
FROM deferred_work
|
|
105
|
+
WHERE project = ? AND status = 'open'
|
|
106
|
+
) WHERE id = ?
|
|
107
|
+
`,
|
|
108
|
+
)
|
|
109
|
+
.get(project, id);
|
|
110
|
+
return row ? row.ordinal : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
/**
|
|
90
114
|
* How many open rows `defer list` / `mem_defer_list` left off their page, as a line to
|
|
91
115
|
* print — '' when the page held them all. Without it a page one short of the open set
|
package/lib/lesson-bridge.mjs
CHANGED
|
@@ -26,7 +26,8 @@ export function buildBridgePrompt(lesson, hunk) {
|
|
|
26
26
|
|
|
27
27
|
// { ok:true, check } when the bridge produced a usable, applicable check;
|
|
28
28
|
// { ok:false } on N/A / empty / error / timeout. NEVER throws — the caller
|
|
29
|
-
// falls back to the
|
|
29
|
+
// falls back to the arm's plain directive on { ok:false } (VERDICT_DIRECTIVE since 6.17.0,
|
|
30
|
+
// scripts/pre-tool-recall.js ACTIVE_DIRECTIVE).
|
|
30
31
|
export async function bridgeLesson({ lesson, hunk, timeoutMs = 2500, _callLLM = callLLM }) {
|
|
31
32
|
try {
|
|
32
33
|
const raw = await _callLLM(buildBridgePrompt(lesson, hunk), timeoutMs);
|
package/lib/provenance.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Which writer produced an observation, read back from its memory_session_id. saveObservation
|
|
2
2
|
// writes every explicit save under MANUAL_SESSION_ID_PREFIX. The hook capture (`hook-`),
|
|
3
|
-
// transcript import (`import-`), compression summaries (`compress-`)
|
|
4
|
-
// older stores (a bare session uuid) are
|
|
3
|
+
// transcript import (`import-`), compression summaries and cluster-merge keepers (`compress-`),
|
|
4
|
+
// promoted events (`promote-`) and rows imported from older stores (a bare session uuid) are
|
|
5
|
+
// machine-written.
|
|
5
6
|
//
|
|
6
7
|
// Search and get mark the machine-written side, because explicit saves are the large majority
|
|
7
8
|
// of a typical store and a mark on nearly every line would carry no information. An unknown id
|
package/lib/save-observation.mjs
CHANGED
|
@@ -144,6 +144,7 @@ export function formatSupersedeSkipped(skipped) {
|
|
|
144
144
|
* @param {string|null} [params.lesson_learned] Caller validates ≤500 chars.
|
|
145
145
|
* @param {boolean} [params.force=false] Skip the near-duplicate window (below).
|
|
146
146
|
* @param {Date} [params.now] Override for tests.
|
|
147
|
+
* @param {string} [params.sessionId] Writer id; default `manual-<project>` (an explicit save).
|
|
147
148
|
* Both result shapes carry `supersededIds` (observations actually tombstoned) and
|
|
148
149
|
* `supersedeSkipped` (requested but NOT tombstoned, each with a `reason`:
|
|
149
150
|
* `malformed-id` | `no-such-observation` | `no-such-event` | `other-project` |
|
|
@@ -198,7 +199,9 @@ export function saveObservation(db, params) {
|
|
|
198
199
|
const safeTitle = scrubSecrets(rawTitle);
|
|
199
200
|
const safeLesson = rawLesson ? scrubSecrets(rawLesson) : null;
|
|
200
201
|
|
|
201
|
-
|
|
202
|
+
// An explicit save by default; a caller storing machine-written text names its own writer
|
|
203
|
+
// (lib/provenance.mjs reads authorship from this id).
|
|
204
|
+
const sessionId = params.sessionId || `${MANUAL_SESSION_ID_PREFIX}${project}`;
|
|
202
205
|
|
|
203
206
|
// Ensure session exists (FK constraint). INSERT OR IGNORE makes this safe
|
|
204
207
|
// under concurrent calls.
|
package/mem-cli.mjs
CHANGED
|
@@ -163,6 +163,7 @@ import { aggregateMetrics, readMetrics } from './lib/metrics.mjs';
|
|
|
163
163
|
import {
|
|
164
164
|
insertDeferred,
|
|
165
165
|
listOpenWithOrdinal,
|
|
166
|
+
openOrdinalOf,
|
|
166
167
|
dropDeferred,
|
|
167
168
|
formatDropReasonHint,
|
|
168
169
|
resolveDeferredIds,
|
|
@@ -1448,9 +1449,8 @@ function cmdDeferAdd(db, args) {
|
|
|
1448
1449
|
return;
|
|
1449
1450
|
}
|
|
1450
1451
|
// Compute the freshly-inserted row's ordinal for an immediately-actionable
|
|
1451
|
-
// response ("ok, deferred this as item N")
|
|
1452
|
-
const
|
|
1453
|
-
const ord = open.find((o) => o.id === r.id)?.ordinal ?? '?';
|
|
1452
|
+
// response ("ok, deferred this as item N"), as mem_defer does.
|
|
1453
|
+
const ord = openOrdinalOf(db, project, r.id) ?? '?';
|
|
1454
1454
|
out(`[mem] Deferred as D#${r.id} (item ${ord}) in project "${project}".`);
|
|
1455
1455
|
}
|
|
1456
1456
|
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.19.
|
|
3
|
+
"version": "6.19.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "6.19.
|
|
9
|
+
"version": "6.19.1",
|
|
10
10
|
"os": [
|
|
11
11
|
"darwin",
|
|
12
12
|
"linux",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.19.
|
|
3
|
+
"version": "6.19.1",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
package/secret-scrub.mjs
CHANGED
|
@@ -184,32 +184,50 @@ export const SECRET_PATTERNS = [
|
|
|
184
184
|
[/\b(?:xox[bpasr]|xapp|xoxe)-[a-zA-Z0-9-]{10,}\b/g, '***'],
|
|
185
185
|
// Slack incoming-webhook URL — the path after /services/ is the shared secret.
|
|
186
186
|
[/(https:\/\/hooks\.slack\.com\/services\/)[A-Za-z0-9/]+/g, '$1***'],
|
|
187
|
-
// JWT tokens (eyJ...eyJ...)
|
|
188
|
-
//
|
|
189
|
-
// fresh start that
|
|
190
|
-
// (D#130)
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
// `-eyJ` (v6.19.0 pre-tag reviews P3-2, delta P3-1).
|
|
187
|
+
// JWT tokens (eyJ...eyJ...), from any `eyJ`: one glued to a prefix (`my-sess-eyJ…`,
|
|
188
|
+
// `session-<uuid>-eyJ…`, `tok_eyJ…`) is still a JWT. Every `eyJ` of one dotless run used to be a
|
|
189
|
+
// fresh start that rescanned the run to its end — quadratic, 9.6 s on 200k chars of `eyJ-`
|
|
190
|
+
// (D#130) — and v6.19.0's bounded lookbehind that fixed it missed a prefix over 40 characters
|
|
191
|
+
// (round-3 review P3-2). Here a failed start consumes its run instead (`|eyJ[\w-]*`, returned
|
|
192
|
+
// unchanged), so the scan resumes after it. Nothing is lost: the first segment cannot contain a
|
|
193
|
+
// `.`, so every later `eyJ` of the same run reaches the same run end and fails the same way.
|
|
195
194
|
[
|
|
196
|
-
/
|
|
197
|
-
'***',
|
|
195
|
+
/eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]+\b|eyJ[a-zA-Z0-9_-]*/g,
|
|
196
|
+
(m) => (m.includes('.') ? '***' : m),
|
|
198
197
|
],
|
|
199
198
|
// PEM private key blocks. `[A-Z0-9 ]*` covers every armor label — RSA/EC/DSA/
|
|
200
199
|
// OPENSSH plus ENCRYPTED and PGP (… PRIVATE KEY BLOCK) — that the fixed
|
|
201
200
|
// alternation missed; the block delimiters make FP impossible.
|
|
202
201
|
// The body stops at the next `-----BEGIN ` (D#130): with `[\s\S]*?` every header with no END
|
|
203
202
|
// scanned to the end of the text, on each of scrubSecrets' passes — quadratic, 8.3 s on 500k
|
|
204
|
-
// chars. A block whose END is missing
|
|
205
|
-
// key's body is scrubbed too (v6.19.0 pre-tag review P2-1: requiring the END there stored that
|
|
206
|
-
// body); any other BEGIN (a certificate) does not end it, so a bare header in prose does not
|
|
207
|
-
// erase the text up to one (delta review P3-2). A header with no END and no later key header
|
|
208
|
-
// is left as it was in v6.18.0.
|
|
203
|
+
// chars. A block whose END is missing is left to the next pattern.
|
|
209
204
|
[
|
|
210
|
-
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----(?:(?!-----BEGIN [A-Z0-9 ]*PRIVATE KEY)[\s\S])
|
|
205
|
+
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----(?:(?!-----BEGIN [A-Z0-9 ]*PRIVATE KEY)[\s\S])*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g,
|
|
211
206
|
'***PEM_KEY***',
|
|
212
207
|
],
|
|
208
|
+
// A cut-off key (`head id_rsa`, a tool output cut mid-key): the header and the WHOLE lines of
|
|
209
|
+
// base64 (or RFC 1421 headers) that follow it. Stored whole through v6.19.0 when no later key
|
|
210
|
+
// header followed. Whole lines only, so prose naming a header mid-sentence keeps its text
|
|
211
|
+
// (v6.19.0 round-3 P3-3: ending the block at the next key header erased the prose between two).
|
|
212
|
+
// A body line starts with 16+ base64 characters, and one shorter whole line may follow the last
|
|
213
|
+
// of them (a key's last line): a line of one word or number is prose (v6.19.1 pre-tag review F3),
|
|
214
|
+
// so the body needs a long line, and blank lines count only between long ones. A long line need
|
|
215
|
+
// not be whole, so a key cut mid-line loses the cut line too. A line break may be JSON-escaped
|
|
216
|
+
// (`\n` as two characters) and a quote may end the last line (v6.19.1 claims review F1). A header
|
|
217
|
+
// value may hold a backslash that does not start an escaped break (a Windows path), and it does
|
|
218
|
+
// not share its whitespace with a second quantifier, which was quadratic (delta review P1).
|
|
219
|
+
[
|
|
220
|
+
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----(?:[ \t]*(?:\r?\n|\\r\\n|\\n)(?=[ \t]*(?:\r?\n|\\r\\n|\\n)))*(?:[ \t]*(?:\r?\n|\\r\\n|\\n)[ \t]*(?:[A-Za-z0-9+/=]{16,}|(?:Proc-Type|DEK-Info|Version|Comment|Hash|Charset):(?:[^\r\n\\]|\\(?![rn]))*(?=\r?\n|\\[rn]|["']|$)))(?:(?:[ \t]*(?:\r?\n|\\r\\n|\\n)(?=[ \t]*(?:\r?\n|\\r\\n|\\n)))*(?:[ \t]*(?:\r?\n|\\r\\n|\\n)[ \t]*(?:[A-Za-z0-9+/=]{16,}|(?:Proc-Type|DEK-Info|Version|Comment|Hash|Charset):(?:[^\r\n\\]|\\(?![rn]))*(?=\r?\n|\\[rn]|["']|$))))*(?:[ \t]*(?:\r?\n|\\r\\n|\\n)[ \t]*[A-Za-z0-9+/=]{1,15}[ \t]*(?=\r?\n|\\[rn]|["']|$))?/g,
|
|
221
|
+
'***PEM_KEY***',
|
|
222
|
+
],
|
|
223
|
+
// The other end (`tail key.pem`): whole base64 lines ending in a private-key END, the same line
|
|
224
|
+
// rule as above but with two shorter lines allowed before the END (an armored PGP key's last data
|
|
225
|
+
// line and its `=XXXX` checksum; delta review P2). A line may start after a quote. A run of lines
|
|
226
|
+
// that does not end there is consumed and returned unchanged, so no line starts a second scan.
|
|
227
|
+
[
|
|
228
|
+
/(?:(?<![^\n])|(?<=\\n|["']))(?:[ \t]*[A-Za-z0-9+/=]{16,}[ \t]*(?:\r?\n|\\r\\n|\\n))+(?:[ \t]*[A-Za-z0-9+/=]{1,15}[ \t]*(?:\r?\n|\\r\\n|\\n)){0,2}(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----)?/g,
|
|
229
|
+
(m) => (m.endsWith('-----') ? '***PEM_KEY***' : m),
|
|
230
|
+
],
|
|
213
231
|
// Long hex strings in credential assignments (e.g. SECRET_KEY=abc123def456...).
|
|
214
232
|
// `hash` deliberately excluded: `hash: <40hex>` / `hash=<md5>` are git SHAs and
|
|
215
233
|
// checksums (real, preserved data in this hash-heavy repo), not credentials.
|
package/server.mjs
CHANGED
|
@@ -125,6 +125,7 @@ import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
|
125
125
|
import {
|
|
126
126
|
insertDeferred,
|
|
127
127
|
listOpenWithOrdinal,
|
|
128
|
+
openOrdinalOf,
|
|
128
129
|
dropDeferred,
|
|
129
130
|
formatDropReasonHint,
|
|
130
131
|
resolveDeferredIds,
|
|
@@ -1221,8 +1222,7 @@ server.registerTool(
|
|
|
1221
1222
|
});
|
|
1222
1223
|
// Compute the ordinal for the freshly-inserted row so the response is
|
|
1223
1224
|
// immediately actionable ("ok, I deferred this as item 1").
|
|
1224
|
-
const
|
|
1225
|
-
const ord = open.find((o) => o.id === r.id)?.ordinal ?? null;
|
|
1225
|
+
const ord = openOrdinalOf(db, project, r.id);
|
|
1226
1226
|
return {
|
|
1227
1227
|
content: [
|
|
1228
1228
|
{
|
package/utils.mjs
CHANGED
|
@@ -289,13 +289,34 @@ const DESC_SCRUB_WINDOW = 4096;
|
|
|
289
289
|
// before the first remaining `<private>` or private-key BEGIN, so no field shows a span whose end
|
|
290
290
|
// is out of view. Before v6.19.0 an unclosed opener in the window (a Grep line
|
|
291
291
|
// `notes.md:3:<private>bank pin 4412`) was shown as written (v6.19.0 pre-tag reviews P3-1, r3 P2-1).
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
292
|
+
// A `</private>` or private-key END that comes first is a span whose START is out of view (a
|
|
293
|
+
// nested span, `tail key.pem`), so everything before it may be its inside: the field shows nothing
|
|
294
|
+
// (round-3 P3-1). The PEM half is case-sensitive, like the scrubber's PEM pattern.
|
|
295
|
+
const PRIVATE_MARK_RE = /<\/?[Pp][Rr][Ii][Vv][Aa][Tt][Ee]>|-----(?:BEGIN|END) [A-Z0-9 ]*PRIVATE KEY/;
|
|
296
|
+
// A window edge that cuts a token leaves a fragment shorter than its pattern needs (`ghp_` and 12
|
|
297
|
+
// of its 36 characters), and whitespace collapsing can bring it into view: the cut token is
|
|
298
|
+
// dropped (defect review P3-6, round-3 P3-4/P3-6). A head window with no whitespace keeps it: its
|
|
299
|
+
// start is intact and it is the only part shown. A tail window with no whitespace is all one cut token.
|
|
300
|
+
const isWs = (c) => c === ' ' || c === '\n' || c === '\t' || c === '\r' || /\s/.test(c);
|
|
301
|
+
function dropCutTokenAtEnd(win, next) {
|
|
302
|
+
if (next === undefined || isWs(next)) return win;
|
|
303
|
+
let i = win.length;
|
|
304
|
+
while (i > 0 && !isWs(win[i - 1])) i--;
|
|
305
|
+
return i > 0 ? win.slice(0, i) : win;
|
|
306
|
+
}
|
|
307
|
+
function dropCutTokenAtStart(win, prev) {
|
|
308
|
+
if (prev === undefined || isWs(prev)) return win;
|
|
309
|
+
let i = 0;
|
|
310
|
+
while (i < win.length && !isWs(win[i])) i++;
|
|
311
|
+
return win.slice(i);
|
|
312
|
+
}
|
|
313
|
+
function scrubTruncate(str, max, window = DESC_SCRUB_WINDOW) {
|
|
295
314
|
if (typeof str !== 'string' || str === '') return truncate(str, max);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
315
|
+
const stripped = stripPrivate(str);
|
|
316
|
+
let win = stripped.slice(0, window);
|
|
317
|
+
const mark = PRIVATE_MARK_RE.exec(win);
|
|
318
|
+
if (mark) win = mark[0][1] === '/' || mark[0].startsWith('-----END') ? '' : win.slice(0, mark.index);
|
|
319
|
+
else win = dropCutTokenAtEnd(win, stripped[window]);
|
|
299
320
|
// The window edge can split a surrogate pair; truncate only guards a cut it makes itself.
|
|
300
321
|
if (/[\uD800-\uDBFF]$/.test(win)) win = win.slice(0, -1);
|
|
301
322
|
return truncate(_scrubSecrets(win), max);
|
|
@@ -314,6 +335,17 @@ function scrubTruncate(str, max) {
|
|
|
314
335
|
// key with no END more than 4096 characters back).
|
|
315
336
|
const PRIVATE_TAG_HINT_RE = /<\/?private>/i;
|
|
316
337
|
const HEAD_ONLY_MAX = 60;
|
|
338
|
+
// The tail window is scrubbed with TAIL_CONTEXT characters before it, which are then dropped: a
|
|
339
|
+
// label cut by the window's edge (`pass|word: <value>`) is seen whole, so its value is not left
|
|
340
|
+
// unlabelled at the window's start, where whitespace collapsing can bring it into view (v6.19.1
|
|
341
|
+
// pre-tag review F1). A replacement inside the context moves the cut by its length change; the
|
|
342
|
+
// token the cut lands in is dropped either way.
|
|
343
|
+
const TAIL_CONTEXT = 256;
|
|
344
|
+
function scrubTailWindow(str, window) {
|
|
345
|
+
const scrubbed = _scrubSecrets(str.slice(-(window + TAIL_CONTEXT)));
|
|
346
|
+
return dropCutTokenAtStart(scrubbed.slice(TAIL_CONTEXT), scrubbed[TAIL_CONTEXT - 1]);
|
|
347
|
+
}
|
|
348
|
+
const oneSpace = (s) => s.replace(/\s+/g, ' ');
|
|
317
349
|
// The early return needs the WHOLE output inside the head window: a long output whose first
|
|
318
350
|
// 4096 characters collapse to a few (whitespace) still has a tail to show (v6.19.0 pre-tag
|
|
319
351
|
// claims review F2).
|
|
@@ -321,11 +353,20 @@ function scrubTruncateEnds(str, max) {
|
|
|
321
353
|
if (typeof str === 'string' && (str.includes('PRIVATE KEY') || PRIVATE_TAG_HINT_RE.test(str))) {
|
|
322
354
|
return scrubTruncate(str, HEAD_ONLY_MAX);
|
|
323
355
|
}
|
|
324
|
-
|
|
325
|
-
|
|
356
|
+
// Up to two windows long, one window covers the whole output: two overlapping windows showed
|
|
357
|
+
// the same text twice (delta review P3-4).
|
|
358
|
+
const window =
|
|
359
|
+
typeof str === 'string' && str.length <= 2 * DESC_SCRUB_WINDOW
|
|
360
|
+
? 2 * DESC_SCRUB_WINDOW
|
|
361
|
+
: DESC_SCRUB_WINDOW;
|
|
362
|
+
// Whitespace runs are one space: a blank-line run no longer spends the budget (or, before the
|
|
363
|
+
// one-window rule, hid behind the window edge).
|
|
364
|
+
const flat = oneSpace(scrubTruncate(str, window, window));
|
|
365
|
+
const whole = typeof str !== 'string' || str.length <= window;
|
|
326
366
|
if (whole && flat.length <= max) return flat;
|
|
327
367
|
const tailLen = Math.floor(max / 2) - 1;
|
|
328
|
-
const tailSrc = whole ? flat : normalizeInline(
|
|
368
|
+
const tailSrc = whole ? flat : oneSpace(normalizeInline(scrubTailWindow(str, window)));
|
|
369
|
+
if (tailSrc === '') return truncate(flat, max);
|
|
329
370
|
// Drop a lone low surrogate the tail's cut may start on.
|
|
330
371
|
const tail = tailSrc.slice(-tailLen).replace(/^[\uDC00-\uDFFF]/, '');
|
|
331
372
|
// A head short enough to escape truncate's own "…" still gets one before the tail.
|