claude-mem-lite 2.69.0 → 2.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -3
- package/cli.mjs +1 -1
- package/hook-context.mjs +20 -16
- package/hook-handoff.mjs +25 -7
- package/hook-llm.mjs +95 -19
- package/hook-optimize.mjs +45 -7
- package/hook-precompact.mjs +44 -0
- package/hook.mjs +79 -19
- package/hooks/hooks.json +12 -0
- package/lib/deferred-work.mjs +171 -0
- package/lib/git-state.mjs +15 -0
- package/lib/import-jsonl.mjs +225 -0
- package/lib/scrub-record.mjs +63 -0
- package/lib/upgrade-banner.mjs +31 -0
- package/mem-cli.mjs +235 -12
- package/package.json +6 -1
- package/schema.mjs +33 -1
- package/server.mjs +132 -21
- package/source-files.mjs +17 -1
- package/tool-schemas.mjs +107 -4
package/README.md
CHANGED
|
@@ -209,14 +209,14 @@ rm -rf ~/claude-mem-lite/ # pre-v0.5 unhidden (if not auto-moved)
|
|
|
209
209
|
|
|
210
210
|
### MCP Tools (used automatically by Claude)
|
|
211
211
|
|
|
212
|
-
As of v2.
|
|
212
|
+
As of v2.70.0, the server registers 20 tools in total but only the 9 **core**
|
|
213
213
|
tools appear in `tools/list`. The 11 **hidden** tools remain callable at the
|
|
214
214
|
protocol layer (`tools/call` by exact name still routes normally); they're
|
|
215
215
|
omitted from the list response so Claude Code sessions don't load 11 extra
|
|
216
216
|
tool schemas at startup. Hidden tools are the maintenance / admin / browser
|
|
217
217
|
surface — reach them through the CLI column in the second table.
|
|
218
218
|
|
|
219
|
-
**Core (
|
|
219
|
+
**Core (9, exposed to Claude Code)**
|
|
220
220
|
|
|
221
221
|
| Tool | Description |
|
|
222
222
|
|------|-------------|
|
|
@@ -225,7 +225,10 @@ surface — reach them through the CLI column in the second table.
|
|
|
225
225
|
| `mem_recall` | Recall observations related to a file. Use before editing to surface past bugfixes and context. |
|
|
226
226
|
| `mem_timeline` | Browse observations chronologically around an anchor point. |
|
|
227
227
|
| `mem_get` | Retrieve full details for specific observation IDs (includes importance and related_ids). |
|
|
228
|
-
| `mem_save` | Manually save a memory/observation. |
|
|
228
|
+
| `mem_save` | Manually save a memory/observation. Accepts `closes_deferred` array for transactional closure of deferred work. |
|
|
229
|
+
| `mem_defer` | Mark work for a future session (v2.70+). First-class carry-forward signal, surfaced in SessionStart `### Deferred Work` block. |
|
|
230
|
+
| `mem_defer_list` | List open deferred items for the current project. |
|
|
231
|
+
| `mem_defer_drop` | Drop a deferred item without fixing it; requires a `reason` for the audit trail. |
|
|
229
232
|
|
|
230
233
|
**Hidden-but-callable (11, CLI-routed)**
|
|
231
234
|
|
package/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'help']);
|
|
2
|
+
const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'defer', 'help']);
|
|
3
3
|
const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'cleanup', 'cleanup-hooks', 'self-update', 'release']);
|
|
4
4
|
|
|
5
5
|
const cmd = process.argv[2];
|
package/hook-context.mjs
CHANGED
|
@@ -407,26 +407,30 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
|
|
|
407
407
|
handoffLines.push('');
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
-
// 5b. Deferred Work —
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
ORDER BY
|
|
410
|
+
// 5b. Deferred Work — backed by deferred_work table (v2.70.0).
|
|
411
|
+
// Replaces the prior importance≥3 obs proxy. Items shown by per-project
|
|
412
|
+
// ordinal so user can refer to "处理1" / "handle item 1" naturally; D#<id>
|
|
413
|
+
// is the stable handle for tool-layer references (closes_deferred=[N]).
|
|
414
|
+
// Quiet-hooks does NOT suppress: cross-session continuity is the whole point.
|
|
415
|
+
const deferredItems = db.prepare(`
|
|
416
|
+
SELECT id, title, priority,
|
|
417
|
+
ROW_NUMBER() OVER (
|
|
418
|
+
ORDER BY priority DESC, created_at_epoch ASC
|
|
419
|
+
) AS ordinal
|
|
420
|
+
FROM deferred_work
|
|
421
|
+
WHERE project = ? AND status = 'open'
|
|
422
|
+
ORDER BY priority DESC, created_at_epoch ASC
|
|
423
|
+
LIMIT 5
|
|
423
424
|
`).all(project);
|
|
424
425
|
|
|
425
426
|
const deferredLines = [];
|
|
426
|
-
if (
|
|
427
|
+
if (deferredItems.length > 0) {
|
|
427
428
|
deferredLines.push('### Deferred Work');
|
|
428
|
-
for (const
|
|
429
|
-
|
|
429
|
+
for (const d of deferredItems) {
|
|
430
|
+
const pTag = d.priority === 3 ? '🔴' : d.priority === 1 ? '⚪' : '🟡';
|
|
431
|
+
deferredLines.push(
|
|
432
|
+
`${d.ordinal}. ${pTag} [P${d.priority}] ${truncate(d.title, 120)} (D#${d.id})`
|
|
433
|
+
);
|
|
430
434
|
}
|
|
431
435
|
deferredLines.push('');
|
|
432
436
|
}
|
package/hook-handoff.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Extracted for testability — hook.mjs has module-level side effects
|
|
3
3
|
|
|
4
4
|
import { basename } from 'path';
|
|
5
|
-
import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, LOW_SIGNAL_TITLE, EDIT_TOOLS, isMetaTriggerPrompt, notLowSignalTitleClause } from './utils.mjs';
|
|
5
|
+
import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, scrubSecrets, LOW_SIGNAL_TITLE, EDIT_TOOLS, isMetaTriggerPrompt, notLowSignalTitleClause } from './utils.mjs';
|
|
6
|
+
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
6
7
|
import {
|
|
7
8
|
HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT, HANDOFF_ANCHOR_MAX_AGE,
|
|
8
9
|
HANDOFF_MATCH_THRESHOLD, CONTINUE_KEYWORDS,
|
|
@@ -161,6 +162,23 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
161
162
|
// `scopeSessionId` (CC UUID) tags the row for parallel scoping; falls back to
|
|
162
163
|
// the mem-internal `sessionId` when the caller didn't supply one (tests + legacy).
|
|
163
164
|
const storedSessionId = scopeSessionId || sessionId;
|
|
165
|
+
// Defense-in-depth: aggregates are built from already-stored rows + raw
|
|
166
|
+
// session memory; scrub at the persistence boundary regardless of source.
|
|
167
|
+
// Order matters: scrub raw values BEFORE truncation, so a secret straddling
|
|
168
|
+
// the truncation boundary doesn't fall below scrubSecrets's regex length
|
|
169
|
+
// floors. JSON-stringified fields (key_files) are pre-scrubbed at the
|
|
170
|
+
// element level before stringify — letting scrubSecrets rewrite the JSON
|
|
171
|
+
// string would risk breaking downstream JSON.parse.
|
|
172
|
+
const safe = scrubRecord('session_handoffs', {
|
|
173
|
+
working_on: workingOn,
|
|
174
|
+
completed: completed.map(c => `[${c.type}] ${c.title}`).join('\n'),
|
|
175
|
+
unfinished,
|
|
176
|
+
key_decisions: decisions.map(d => d.title).join('\n'),
|
|
177
|
+
match_keywords: keywords,
|
|
178
|
+
});
|
|
179
|
+
const safeKeyFiles = JSON.stringify(
|
|
180
|
+
[...fileSet].slice(0, 20).map(f => scrubSecrets(String(f)))
|
|
181
|
+
);
|
|
164
182
|
db.prepare(`
|
|
165
183
|
INSERT INTO session_handoffs (project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch, git_sha_at_handoff)
|
|
166
184
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
@@ -175,12 +193,12 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
175
193
|
git_sha_at_handoff = excluded.git_sha_at_handoff
|
|
176
194
|
`).run(
|
|
177
195
|
project, type, storedSessionId,
|
|
178
|
-
truncate(
|
|
179
|
-
completed
|
|
180
|
-
unfinished.length > 3000 ? unfinished.slice(0, 2999) + '…' : unfinished,
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
196
|
+
truncate(safe.working_on, 1000),
|
|
197
|
+
safe.completed,
|
|
198
|
+
safe.unfinished.length > 3000 ? safe.unfinished.slice(0, 2999) + '…' : safe.unfinished,
|
|
199
|
+
safeKeyFiles,
|
|
200
|
+
safe.key_decisions,
|
|
201
|
+
safe.match_keywords,
|
|
184
202
|
Date.now(),
|
|
185
203
|
gitShaAtHandoff,
|
|
186
204
|
);
|
package/hook-llm.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getCurrentBranch, notLowSignalTitleClause,
|
|
11
11
|
} from './utils.mjs';
|
|
12
12
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
13
|
+
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
13
14
|
import { getVocabulary, computeVector } from './tfidf.mjs';
|
|
14
15
|
import {
|
|
15
16
|
RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
|
|
@@ -194,6 +195,19 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
194
195
|
|
|
195
196
|
const { conceptsText, factsText, textField } = buildFtsTextField(obs);
|
|
196
197
|
|
|
198
|
+
// Defense-in-depth: scrub text fields before INSERT. Source is LLM output
|
|
199
|
+
// (Haiku occasionally regurgitates input verbatim — error logs, hashes).
|
|
200
|
+
const safe = scrubRecord('observations', {
|
|
201
|
+
text: textField,
|
|
202
|
+
title: obs.title || '',
|
|
203
|
+
subtitle: obs.subtitle || '',
|
|
204
|
+
narrative: obs.narrative || '',
|
|
205
|
+
concepts: conceptsText,
|
|
206
|
+
facts: factsText,
|
|
207
|
+
lesson_learned: obs.lessonLearned || null,
|
|
208
|
+
search_aliases: obs.searchAliases || null,
|
|
209
|
+
});
|
|
210
|
+
|
|
197
211
|
// Atomic: observation INSERT + observation_files + vector in one transaction
|
|
198
212
|
const savedId = db.transaction(() => {
|
|
199
213
|
const result = db.prepare(`
|
|
@@ -201,16 +215,16 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
201
215
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
202
216
|
`).run(
|
|
203
217
|
sessionId, project,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
218
|
+
safe.text, obs.type, safe.title, safe.subtitle,
|
|
219
|
+
safe.narrative,
|
|
220
|
+
safe.concepts,
|
|
221
|
+
safe.facts,
|
|
208
222
|
JSON.stringify(obs.filesRead || []),
|
|
209
223
|
JSON.stringify(obs.files || []),
|
|
210
224
|
obs.importance ?? 1,
|
|
211
225
|
minhashSig,
|
|
212
|
-
|
|
213
|
-
|
|
226
|
+
safe.lesson_learned,
|
|
227
|
+
safe.search_aliases,
|
|
214
228
|
getCurrentBranch(),
|
|
215
229
|
now.toISOString(), now.getTime()
|
|
216
230
|
);
|
|
@@ -823,19 +837,32 @@ ${actionList}`;
|
|
|
823
837
|
// so the enriched FTS text field + minhash + vector are refreshed atomically.
|
|
824
838
|
const { conceptsText, factsText, textField } = buildFtsTextField(obs);
|
|
825
839
|
const minhashSig = computeMinHash((obs.title || '') + ' ' + (obs.narrative || ''));
|
|
840
|
+
// Scrub LLM-output text fields at the UPDATE boundary, mirroring the
|
|
841
|
+
// INSERT path. type is an enum, importance is numeric, files_read is a
|
|
842
|
+
// JSON array (already scrubbed upstream), minhash_sig is hash bytes.
|
|
843
|
+
const safe = scrubRecord('observations', {
|
|
844
|
+
title: truncate(obs.title, 120),
|
|
845
|
+
subtitle: obs.subtitle || '',
|
|
846
|
+
narrative: truncate(obs.narrative || '', 500),
|
|
847
|
+
concepts: conceptsText,
|
|
848
|
+
facts: factsText,
|
|
849
|
+
text: textField,
|
|
850
|
+
lesson_learned: obs.lessonLearned || null,
|
|
851
|
+
search_aliases: obs.searchAliases || null,
|
|
852
|
+
});
|
|
826
853
|
db.prepare(`
|
|
827
854
|
UPDATE observations SET type=?, title=?, subtitle=?, narrative=?, concepts=?, facts=?,
|
|
828
855
|
text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?
|
|
829
856
|
WHERE id = ?
|
|
830
857
|
`).run(
|
|
831
|
-
obs.type,
|
|
832
|
-
|
|
833
|
-
|
|
858
|
+
obs.type, safe.title, safe.subtitle,
|
|
859
|
+
safe.narrative,
|
|
860
|
+
safe.concepts, safe.facts, safe.text,
|
|
834
861
|
obs.importance,
|
|
835
862
|
JSON.stringify(obs.filesRead || []),
|
|
836
863
|
minhashSig,
|
|
837
|
-
|
|
838
|
-
|
|
864
|
+
safe.lesson_learned,
|
|
865
|
+
safe.search_aliases,
|
|
839
866
|
episode.savedId
|
|
840
867
|
);
|
|
841
868
|
savedId = episode.savedId;
|
|
@@ -973,6 +1000,23 @@ ${obsList}`;
|
|
|
973
1000
|
// empty for that field. Without COALESCE, a degraded Haiku pass would erase
|
|
974
1001
|
// the deterministic floor — the exact regression that made 72% of prod
|
|
975
1002
|
// session_summaries ship with empty remaining_items.
|
|
1003
|
+
//
|
|
1004
|
+
// Scrub LLM-output text fields at the UPDATE boundary. lessons /
|
|
1005
|
+
// key_decisions are JSON.stringify(array<string>); we scrub the JSON
|
|
1006
|
+
// string here to match the sibling INSERT path. scrubSecrets uses
|
|
1007
|
+
// opaque placeholders that preserve JSON structure; element-level
|
|
1008
|
+
// pre-scrub remains safer in principle but would diverge from the
|
|
1009
|
+
// merged INSERT contract.
|
|
1010
|
+
const safe = scrubRecord('session_summaries', {
|
|
1011
|
+
request: llmParsed.request || '',
|
|
1012
|
+
investigated: llmParsed.investigated || '',
|
|
1013
|
+
learned: llmParsed.learned || '',
|
|
1014
|
+
completed: llmParsed.completed || '',
|
|
1015
|
+
next_steps: llmParsed.next_steps || '',
|
|
1016
|
+
remaining_items: llmParsed.remaining_items || '',
|
|
1017
|
+
lessons: lessonsJson,
|
|
1018
|
+
key_decisions: decisionsJson,
|
|
1019
|
+
});
|
|
976
1020
|
db.prepare(`
|
|
977
1021
|
UPDATE session_summaries
|
|
978
1022
|
SET request = COALESCE(NULLIF(?, ''), request),
|
|
@@ -988,23 +1032,33 @@ ${obsList}`;
|
|
|
988
1032
|
created_at_epoch = ?
|
|
989
1033
|
WHERE id = ?
|
|
990
1034
|
`).run(
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1035
|
+
safe.request, safe.investigated, safe.learned,
|
|
1036
|
+
safe.completed, safe.next_steps,
|
|
1037
|
+
safe.remaining_items,
|
|
1038
|
+
safe.lessons, safe.key_decisions,
|
|
995
1039
|
now.toISOString(), now.getTime(),
|
|
996
1040
|
existingFast.id
|
|
997
1041
|
);
|
|
998
1042
|
} else {
|
|
1043
|
+
const safe = scrubRecord('session_summaries', {
|
|
1044
|
+
request: llmParsed.request || '',
|
|
1045
|
+
investigated: llmParsed.investigated || '',
|
|
1046
|
+
learned: llmParsed.learned || '',
|
|
1047
|
+
completed: llmParsed.completed || '',
|
|
1048
|
+
next_steps: llmParsed.next_steps || '',
|
|
1049
|
+
remaining_items: llmParsed.remaining_items || '',
|
|
1050
|
+
lessons: lessonsJson,
|
|
1051
|
+
key_decisions: decisionsJson,
|
|
1052
|
+
});
|
|
999
1053
|
db.prepare(`
|
|
1000
1054
|
INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, lessons, key_decisions, created_at, created_at_epoch)
|
|
1001
1055
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', '', ?, ?, ?, ?)
|
|
1002
1056
|
`).run(
|
|
1003
1057
|
sessionId, project,
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1058
|
+
safe.request, safe.investigated, safe.learned,
|
|
1059
|
+
safe.completed, safe.next_steps,
|
|
1060
|
+
safe.remaining_items,
|
|
1061
|
+
safe.lessons, safe.key_decisions,
|
|
1008
1062
|
now.toISOString(), now.getTime()
|
|
1009
1063
|
);
|
|
1010
1064
|
}
|
|
@@ -1013,3 +1067,25 @@ ${obsList}`;
|
|
|
1013
1067
|
db.close();
|
|
1014
1068
|
}
|
|
1015
1069
|
}
|
|
1070
|
+
|
|
1071
|
+
// Test-only — DO NOT import outside tests/. Underscore prefix is a
|
|
1072
|
+
// convention; the plugin has no `main`/`exports` field so external imports
|
|
1073
|
+
// are blocked at the package level, but a misguided sibling import inside
|
|
1074
|
+
// this repo could drag this into prod by accident. If that ever needs
|
|
1075
|
+
// enforcing, move the helper to a tests/_helpers/ module that takes a
|
|
1076
|
+
// db-insert callback.
|
|
1077
|
+
//
|
|
1078
|
+
// Exercises the same scrubRecord path used by saveObservation without
|
|
1079
|
+
// spinning up the full LLM dispatcher. Lets the e2e leak test verify that
|
|
1080
|
+
// the observations INSERT path scrubs all configured text fields.
|
|
1081
|
+
export const __insertObservationForTest = (db, obs) => {
|
|
1082
|
+
const safe = scrubRecord('observations', obs);
|
|
1083
|
+
db.prepare(`INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, lesson_learned, search_aliases, branch, created_at, created_at_epoch)
|
|
1084
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
1085
|
+
obs.session_id, obs.project, safe.text, 'change',
|
|
1086
|
+
safe.title, safe.subtitle, safe.narrative,
|
|
1087
|
+
safe.concepts, safe.facts, obs.files_read, obs.files_modified,
|
|
1088
|
+
obs.importance, obs.minhash_sig, safe.lesson_learned, safe.search_aliases,
|
|
1089
|
+
obs.branch, new Date().toISOString(), Date.now(),
|
|
1090
|
+
);
|
|
1091
|
+
};
|
package/hook-optimize.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from './utils.mjs';
|
|
12
12
|
import { callModelJSON } from './haiku-client.mjs';
|
|
13
13
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
14
|
+
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
14
15
|
import { getVocabulary, computeVector, cosineSimilarity } from './tfidf.mjs';
|
|
15
16
|
import { DB_DIR } from './schema.mjs';
|
|
16
17
|
|
|
@@ -159,12 +160,22 @@ search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
|
|
|
159
160
|
const textField = [conceptsText, factsText, searchAliases || '', bigramText].filter(Boolean).join(' ');
|
|
160
161
|
const minhashSig = computeMinHash((title || '') + ' ' + (narrative || ''));
|
|
161
162
|
|
|
163
|
+
// Scrub LLM-output text fields at the UPDATE boundary. type is an
|
|
164
|
+
// enum, importance is numeric, minhash_sig is hash bytes.
|
|
165
|
+
const safe = scrubRecord('observations', {
|
|
166
|
+
title, narrative,
|
|
167
|
+
concepts: conceptsText,
|
|
168
|
+
facts: factsText,
|
|
169
|
+
text: textField,
|
|
170
|
+
lesson_learned: lessonLearned,
|
|
171
|
+
search_aliases: searchAliases,
|
|
172
|
+
});
|
|
162
173
|
db.prepare(`
|
|
163
174
|
UPDATE observations SET type=?, title=?, narrative=?, concepts=?, facts=?,
|
|
164
175
|
text=?, importance=?, lesson_learned=?, search_aliases=?, minhash_sig=?, optimized_at=?
|
|
165
176
|
WHERE id = ?
|
|
166
|
-
`).run(type, title, narrative,
|
|
167
|
-
importance,
|
|
177
|
+
`).run(type, safe.title, safe.narrative, safe.concepts, safe.facts, safe.text,
|
|
178
|
+
importance, safe.lesson_learned, safe.search_aliases, minhashSig, Date.now(), cand.id);
|
|
168
179
|
|
|
169
180
|
rebuildVector(db, cand.id, [title, narrative, conceptsText]);
|
|
170
181
|
|
|
@@ -277,7 +288,14 @@ export function applyNormalization(db, groups) {
|
|
|
277
288
|
const existingAliases = row.search_aliases || '';
|
|
278
289
|
const originalTerms = terms.filter(t => aliasMap.has(t.toLowerCase()) && aliasMap.get(t.toLowerCase()) !== t);
|
|
279
290
|
const newAliases = [existingAliases, ...originalTerms].filter(Boolean).join(' ');
|
|
280
|
-
|
|
291
|
+
// Defense-in-depth scrub. Canonical concept names come from LLM output
|
|
292
|
+
// (identifySynonymGroups via Sonnet); existing values are already
|
|
293
|
+
// scrubbed but free LLM tokens can re-introduce secret-shaped strings.
|
|
294
|
+
const safe = scrubRecord('observations', {
|
|
295
|
+
concepts: uniqueConcepts,
|
|
296
|
+
search_aliases: newAliases,
|
|
297
|
+
});
|
|
298
|
+
updateStmt.run(safe.concepts, safe.search_aliases, Date.now(), row.id);
|
|
281
299
|
updated++;
|
|
282
300
|
}
|
|
283
301
|
}
|
|
@@ -397,13 +415,22 @@ Return ONLY valid JSON:
|
|
|
397
415
|
const minhashSig = computeMinHash((title || '') + ' ' + (narrative || ''));
|
|
398
416
|
const importance = clampImportance(parsed.importance || 2);
|
|
399
417
|
|
|
418
|
+
// Scrub LLM-output cluster-merge text fields at the UPDATE boundary.
|
|
419
|
+
// importance is numeric; minhash_sig is hash bytes.
|
|
420
|
+
const safe = scrubRecord('observations', {
|
|
421
|
+
title, narrative,
|
|
422
|
+
concepts: conceptsText,
|
|
423
|
+
facts: factsText,
|
|
424
|
+
text: textField,
|
|
425
|
+
lesson_learned: lessonLearned,
|
|
426
|
+
});
|
|
400
427
|
db.transaction(() => {
|
|
401
428
|
db.prepare(`
|
|
402
429
|
UPDATE observations SET title=?, narrative=?, concepts=?, facts=?, text=?,
|
|
403
430
|
importance=?, lesson_learned=?, minhash_sig=?, optimized_at=?
|
|
404
431
|
WHERE id = ?
|
|
405
|
-
`).run(title, narrative,
|
|
406
|
-
importance,
|
|
432
|
+
`).run(safe.title, safe.narrative, safe.concepts, safe.facts, safe.text,
|
|
433
|
+
importance, safe.lesson_learned, minhashSig, Date.now(), keeper.id);
|
|
407
434
|
|
|
408
435
|
const otherIds = others.map(o => o.id);
|
|
409
436
|
const ph = otherIds.map(() => '?').join(',');
|
|
@@ -573,13 +600,24 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
|
|
|
573
600
|
VALUES (?,?,?,?,?,'active')`
|
|
574
601
|
).run(sessionId, sessionId, project, now.toISOString(), now.getTime());
|
|
575
602
|
|
|
603
|
+
// Defense-in-depth: title/narrative/etc. are LLM-generated compression
|
|
604
|
+
// output; scrub at the persistence boundary regardless of upstream trust.
|
|
605
|
+
const safe = scrubRecord('observations', {
|
|
606
|
+
text: textField,
|
|
607
|
+
title,
|
|
608
|
+
narrative,
|
|
609
|
+
concepts: conceptsText,
|
|
610
|
+
facts: factsText,
|
|
611
|
+
lesson_learned: lessonLearned,
|
|
612
|
+
search_aliases: searchAliases,
|
|
613
|
+
});
|
|
576
614
|
const result = db.prepare(`INSERT INTO observations
|
|
577
615
|
(memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts,
|
|
578
616
|
files_read, files_modified, importance, lesson_learned, search_aliases, optimized_at,
|
|
579
617
|
created_at, created_at_epoch)
|
|
580
618
|
VALUES (?,?,?,?,?,'',?,?,?,'[]','[]',2,?,?,?,?,?)`
|
|
581
|
-
).run(sessionId, project,
|
|
582
|
-
|
|
619
|
+
).run(sessionId, project, safe.text, 'discovery', safe.title, safe.narrative,
|
|
620
|
+
safe.concepts, safe.facts, safe.lesson_learned, safe.search_aliases, Date.now(),
|
|
583
621
|
new Date(medianEpoch).toISOString(), medianEpoch);
|
|
584
622
|
|
|
585
623
|
const sId = Number(result.lastInsertRowid);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// claude-mem-lite: PreCompact hook handler.
|
|
2
|
+
// Fires immediately before Claude Code auto-compaction begins. Emits a
|
|
3
|
+
// fresh <claude-mem-context> block on stdout so the summarizer that
|
|
4
|
+
// produces the compacted context has the most relevant memory in scope.
|
|
5
|
+
// Differs from SessionStart-on-compact (which fires AFTER compaction):
|
|
6
|
+
// PreCompact ensures memory survives the compaction step itself.
|
|
7
|
+
|
|
8
|
+
import { buildSessionContextLines } from './hook-context.mjs';
|
|
9
|
+
import { inferProject, debugCatch, debugLog } from './utils.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build + emit the memory context block on stdout. Pure read; no DB writes.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} ctx
|
|
15
|
+
* @param {import('better-sqlite3').Database} ctx.db
|
|
16
|
+
* @param {string} ctx.project
|
|
17
|
+
* @param {string} [ctx.sessionId]
|
|
18
|
+
* @returns {void}
|
|
19
|
+
*/
|
|
20
|
+
export function handlePreCompact({ db, project, sessionId }) {
|
|
21
|
+
try {
|
|
22
|
+
const body = buildSessionContextLines(db, project, new Date(), sessionId || null);
|
|
23
|
+
if (!body || String(body).trim() === '') return;
|
|
24
|
+
process.stdout.write(`<claude-mem-context>\n${body}\n</claude-mem-context>\n`);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
debugCatch(e, 'handlePreCompact');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Default-export entry for hook.mjs dispatcher. Caller passes an opened DB
|
|
32
|
+
* and the parsed stdin payload — no I/O performed inside this function
|
|
33
|
+
* beyond what handlePreCompact does.
|
|
34
|
+
*
|
|
35
|
+
* @param {import('better-sqlite3').Database} db
|
|
36
|
+
* @param {object} hookData Parsed JSON from hook stdin
|
|
37
|
+
* @returns {Promise<void>}
|
|
38
|
+
*/
|
|
39
|
+
export async function entry(db, hookData) {
|
|
40
|
+
const project = inferProject();
|
|
41
|
+
const sessionId = hookData?.session_id;
|
|
42
|
+
debugLog('DEBUG', 'pre-compact', `project=${project} sessionId=${sessionId || 'none'}`);
|
|
43
|
+
handlePreCompact({ db, project, sessionId });
|
|
44
|
+
}
|