claude-mem-lite 2.97.0 → 2.98.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/lib/observation-write.mjs +18 -1
- package/lib/recall-core.mjs +43 -0
- package/mem-cli.mjs +10 -48
- package/package.json +2 -1
- package/scripts/pre-tool-recall.js +50 -2
- package/server.mjs +13 -47
- package/source-files.mjs +1 -0
- package/tool-schemas.mjs +6 -2
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "2.
|
|
13
|
+
"version": "2.98.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": "2.
|
|
3
|
+
"version": "2.98.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"
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// + vector writes in one db.transaction so a failure can't leave a partial row).
|
|
10
10
|
|
|
11
11
|
import { getVocabulary, computeVector } from '../tfidf.mjs';
|
|
12
|
-
import { debugCatch } from '../utils.mjs';
|
|
12
|
+
import { debugCatch, cjkBigrams } from '../utils.mjs';
|
|
13
13
|
|
|
14
14
|
// Canonical column order — must mirror the observations schema (schema.mjs).
|
|
15
15
|
const OBS_COLUMNS = [
|
|
@@ -65,3 +65,20 @@ export function insertObservationVector(db, obsId, vecText) {
|
|
|
65
65
|
.run(obsId, Buffer.from(vec.buffer), vocab.version, Date.now());
|
|
66
66
|
} catch (e) { debugCatch(e, 'insertObservationVector'); }
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Rebuild an observation's derived columns after a field UPDATE: the FTS `text`
|
|
71
|
+
* column (incl. CJK bigrams + search_aliases, matching the ingest paths) and the
|
|
72
|
+
* TF-IDF vector. cmdUpdate (CLI) and mem_update (MCP) previously hand-copied this
|
|
73
|
+
* block — the same drift class #8614/#8639 closed for compress/maintain. Caller
|
|
74
|
+
* owns the transaction (vector write is internally non-critical).
|
|
75
|
+
*/
|
|
76
|
+
export function rebuildObservationDerived(db, obsId) {
|
|
77
|
+
const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(obsId);
|
|
78
|
+
if (!row) return;
|
|
79
|
+
const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
|
|
80
|
+
const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
|
|
81
|
+
const textField = bigrams ? base + ' ' + bigrams : base;
|
|
82
|
+
db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, obsId);
|
|
83
|
+
insertObservationVector(db, obsId, textField);
|
|
84
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Single source of truth for file-keyed recall: the observation_files junction
|
|
2
|
+
// query, LIKE-wildcard escaping, noise filtering, and the access-count bump.
|
|
3
|
+
// cmdRecall (mem-cli.mjs) and mem_recall (server.mjs) previously hand-copied all
|
|
4
|
+
// four — the drift class that produced the mem_get formatter drift (#8678) and
|
|
5
|
+
// the maintain hand-sync drift (#8614). Renderers stay per-surface; the data
|
|
6
|
+
// contract lives here.
|
|
7
|
+
|
|
8
|
+
import { basename } from 'path';
|
|
9
|
+
import { notLowSignalTitleClause } from '../utils.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Recall observations linked to a file (basename or full path). Returns
|
|
13
|
+
* { filename, rows } where rows carry the column superset both surfaces render.
|
|
14
|
+
* Side effect: bumps access_count / last_accessed_at on every returned row —
|
|
15
|
+
* recall IS engagement, and the tier/decay system feeds on these counters.
|
|
16
|
+
*/
|
|
17
|
+
export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}) {
|
|
18
|
+
const filename = basename(file);
|
|
19
|
+
const escaped = filename.replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
20
|
+
const likePattern = `%${escaped}`;
|
|
21
|
+
const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
|
|
22
|
+
const rows = db.prepare(`
|
|
23
|
+
SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.importance,
|
|
24
|
+
o.created_at, o.created_at_epoch, o.project
|
|
25
|
+
FROM observations o
|
|
26
|
+
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
27
|
+
WHERE COALESCE(o.compressed_into, 0) = 0
|
|
28
|
+
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
29
|
+
${noiseClause}
|
|
30
|
+
ORDER BY o.created_at_epoch DESC
|
|
31
|
+
LIMIT ?
|
|
32
|
+
`).all(filename, likePattern, limit);
|
|
33
|
+
|
|
34
|
+
if (rows.length > 0) {
|
|
35
|
+
const ph = rows.map(() => '?').join(',');
|
|
36
|
+
try {
|
|
37
|
+
db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`)
|
|
38
|
+
.run(Date.now(), ...rows.map(r => r.id));
|
|
39
|
+
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { filename, rows };
|
|
43
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import { homedir } from 'os';
|
|
6
6
|
import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
|
|
7
|
-
import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, scrubSecrets,
|
|
7
|
+
import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, scrubSecrets, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS } from './utils.mjs';
|
|
8
8
|
import { cjkPrecisionOk } from './nlp.mjs';
|
|
9
9
|
import { extractCjkLikePatterns } from './nlp.mjs';
|
|
10
10
|
import { resolveProject } from './project-utils.mjs';
|
|
11
11
|
import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
12
|
-
import {
|
|
12
|
+
import { _resetVocabCache } from './tfidf.mjs';
|
|
13
13
|
import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
|
|
14
14
|
import { searchObservationsHybrid, findFtsAnchor, countSearchTotal } from './search-engine.mjs';
|
|
15
15
|
import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
@@ -27,7 +27,7 @@ import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
|
|
|
27
27
|
import { parseIntFlag, isNumericToken } from './lib/cli-flags.mjs';
|
|
28
28
|
import { auditMemdir, memdirPath } from './memdir.mjs';
|
|
29
29
|
import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
|
|
30
|
-
import {
|
|
30
|
+
import { join, sep } from 'path';
|
|
31
31
|
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
32
32
|
|
|
33
33
|
// v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
|
|
@@ -35,6 +35,8 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
|
35
35
|
// move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
|
|
36
36
|
import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
|
|
37
37
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
38
|
+
import { rebuildObservationDerived } from './lib/observation-write.mjs';
|
|
39
|
+
import { recallByFile } from './lib/recall-core.mjs';
|
|
38
40
|
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
39
41
|
import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
|
|
40
42
|
import {
|
|
@@ -493,35 +495,12 @@ function cmdRecall(db, args) {
|
|
|
493
495
|
return;
|
|
494
496
|
}
|
|
495
497
|
|
|
496
|
-
const filename = basename(file);
|
|
497
498
|
const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 1000 });
|
|
498
499
|
const includeNoise = flags['include-noise'] === true || flags['include-noise'] === 'true';
|
|
499
500
|
const jsonOutput = flags.json === true || flags.json === 'true';
|
|
500
501
|
|
|
501
|
-
//
|
|
502
|
-
const
|
|
503
|
-
const likePattern = `%${escaped}`;
|
|
504
|
-
const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
|
|
505
|
-
const rows = db.prepare(`
|
|
506
|
-
SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.importance,
|
|
507
|
-
o.created_at, o.created_at_epoch, o.project
|
|
508
|
-
FROM observations o
|
|
509
|
-
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
510
|
-
WHERE COALESCE(o.compressed_into, 0) = 0
|
|
511
|
-
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
512
|
-
${noiseClause}
|
|
513
|
-
ORDER BY o.created_at_epoch DESC
|
|
514
|
-
LIMIT ?
|
|
515
|
-
`).all(filename, likePattern, limit);
|
|
516
|
-
|
|
517
|
-
if (rows.length > 0) {
|
|
518
|
-
// Update access_count for recalled observations (aligned with MCP mem_recall)
|
|
519
|
-
const recalledIds = rows.map(r => r.id);
|
|
520
|
-
const recallPh = recalledIds.map(() => '?').join(',');
|
|
521
|
-
try {
|
|
522
|
-
db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${recallPh})`).run(Date.now(), ...recalledIds);
|
|
523
|
-
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
524
|
-
}
|
|
502
|
+
// Shared core with MCP mem_recall: query + escaping + access bump (lib/recall-core.mjs)
|
|
503
|
+
const { filename, rows } = recallByFile(db, file, { limit, includeNoise });
|
|
525
504
|
|
|
526
505
|
if (jsonOutput) {
|
|
527
506
|
out(JSON.stringify({
|
|
@@ -1708,28 +1687,11 @@ function cmdUpdate(db, args) {
|
|
|
1708
1687
|
|
|
1709
1688
|
params.push(id);
|
|
1710
1689
|
|
|
1711
|
-
// Atomic: update fields + rebuild FTS text +
|
|
1690
|
+
// Atomic: update fields + rebuild derived columns (FTS text + vector) via the
|
|
1691
|
+
// shared core — single source with MCP mem_update (lib/observation-write.mjs).
|
|
1712
1692
|
db.transaction(() => {
|
|
1713
1693
|
db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
1714
|
-
|
|
1715
|
-
// Rebuild FTS text field
|
|
1716
|
-
const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(id);
|
|
1717
|
-
const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
|
|
1718
|
-
const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
|
|
1719
|
-
const textField = bigrams ? base + ' ' + bigrams : base;
|
|
1720
|
-
db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, id);
|
|
1721
|
-
|
|
1722
|
-
// Re-vectorize (non-critical — catch to avoid rollback)
|
|
1723
|
-
try {
|
|
1724
|
-
const vocab = getVocabulary(db);
|
|
1725
|
-
if (vocab) {
|
|
1726
|
-
const vec = computeVector(textField, vocab);
|
|
1727
|
-
if (vec) {
|
|
1728
|
-
db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
|
|
1729
|
-
.run(id, Buffer.from(vec.buffer), vocab.version, Date.now());
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
} catch { /* non-critical */ }
|
|
1694
|
+
rebuildObservationDerived(db, id);
|
|
1733
1695
|
})();
|
|
1734
1696
|
|
|
1735
1697
|
out(`[mem] Updated #${id}: ${updates.map(u => u.split(' =')[0]).join(', ')}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.98.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",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"lib/mem-override.mjs",
|
|
69
69
|
"lib/save-observation.mjs",
|
|
70
70
|
"lib/observation-write.mjs",
|
|
71
|
+
"lib/recall-core.mjs",
|
|
71
72
|
"lib/compress-core.mjs",
|
|
72
73
|
"lib/maintain-core.mjs",
|
|
73
74
|
"lib/dedup-constants.mjs",
|
|
@@ -29,6 +29,15 @@ const CROSS_HOOK_DEDUP_MS = 5 * 60 * 1000;
|
|
|
29
29
|
// legacy global path so env-less test harnesses still behave.
|
|
30
30
|
const LEGACY_COOLDOWN_PATH = join(RUNTIME_DIR, 'pre-recall-cooldown.json');
|
|
31
31
|
const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes (used only for legacy fallback)
|
|
32
|
+
// v2.98 salience forcing-function (#8651: verified injection only moved
|
|
33
|
+
// bug-reintroduction 100%→50% — the agent sees lessons and ignores them; the
|
|
34
|
+
// bottleneck is ACTING). Default ON: Edit/Write lesson blocks end with an ack
|
|
35
|
+
// directive, and Read→Edit re-surfaces the Read-time lesson IDs as a one-line
|
|
36
|
+
// ack nudge at the actual action point. CLAUDE_MEM_SALIENCE=legacy (or 0)
|
|
37
|
+
// restores the pre-v2.98 passive behavior.
|
|
38
|
+
const SALIENCE_LEGACY = process.env.CLAUDE_MEM_SALIENCE === 'legacy'
|
|
39
|
+
|| process.env.CLAUDE_MEM_SALIENCE === '0';
|
|
40
|
+
const ACK_DIRECTIVE = "apply each lesson to this edit or rule it out — state '#NN applied' or '#NN n/a — <reason>' in your next user-facing message.";
|
|
32
41
|
const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy file
|
|
33
42
|
// Stale-cooldown GC moved to hook.mjs::handleSessionStart — running it on every
|
|
34
43
|
// Edit cost 15-30 disk stats per call. SessionStart fires once at session boot,
|
|
@@ -183,7 +192,35 @@ try {
|
|
|
183
192
|
const cooldown = readCooldown(cooldownPath);
|
|
184
193
|
const now = Date.now();
|
|
185
194
|
if (isSessionScoped) {
|
|
186
|
-
|
|
195
|
+
const entry = cooldown[filePath];
|
|
196
|
+
if (entry) {
|
|
197
|
+
// v2.98 salience: the old full-dedup meant a Read-injected lesson left the
|
|
198
|
+
// actual Edit with ZERO context at the action point — the most likely spot
|
|
199
|
+
// for #8651's "saw it, ignored it". When this Edit/Write follows a
|
|
200
|
+
// Read-mode injection that surfaced lessons, emit a one-line ack nudge
|
|
201
|
+
// naming the IDs (no lesson bodies — token cost stays minimal), then mark
|
|
202
|
+
// the entry handled so the next Edit is silent again. Entries without a
|
|
203
|
+
// mode field (pre-v2.98) are treated as already handled.
|
|
204
|
+
const seenIds = (typeof entry === 'object' && Array.isArray(entry.lessonIds))
|
|
205
|
+
? entry.lessonIds : [];
|
|
206
|
+
const wasReadMode = typeof entry === 'object' && entry.mode === 'read';
|
|
207
|
+
if (!isRead && wasReadMode && seenIds.length > 0 && !SALIENCE_LEGACY) {
|
|
208
|
+
const idList = seenIds.map(id => `#${id}`).join(', ');
|
|
209
|
+
process.stdout.write(JSON.stringify({
|
|
210
|
+
suppressOutput: true,
|
|
211
|
+
hookSpecificOutput: {
|
|
212
|
+
hookEventName: 'PreToolUse',
|
|
213
|
+
additionalContext: [
|
|
214
|
+
'[mem] PreToolUse recall — system-injected context, continue your planned action:',
|
|
215
|
+
`[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACK_DIRECTIVE}`,
|
|
216
|
+
].join('\n'),
|
|
217
|
+
},
|
|
218
|
+
}));
|
|
219
|
+
cooldown[filePath] = { ...entry, mode: 'edit' };
|
|
220
|
+
writeCooldown(cooldownPath, cooldown, isSessionScoped);
|
|
221
|
+
}
|
|
222
|
+
process.exit(0); // already recalled this file in-session
|
|
223
|
+
}
|
|
187
224
|
} else {
|
|
188
225
|
const ts = entryTimestamp(cooldown[filePath]);
|
|
189
226
|
if (ts && (now - ts) < COOLDOWN_MS) process.exit(0);
|
|
@@ -329,6 +366,14 @@ try {
|
|
|
329
366
|
lines.push(` #${r.id} [${r.type}] ${title}`);
|
|
330
367
|
}
|
|
331
368
|
}
|
|
369
|
+
// v2.98 salience: Edit/Write is the action point — close the block with an
|
|
370
|
+
// explicit ack directive instead of leaving the lessons as passive FYI
|
|
371
|
+
// (#8651: passive framing was ignored ~half the time even when on-topic).
|
|
372
|
+
// Read keeps the quiet form; its forcing-function fires at the later Edit
|
|
373
|
+
// via the Read→Edit ack nudge above.
|
|
374
|
+
if (!isRead && !SALIENCE_LEGACY) {
|
|
375
|
+
lines.push(`[mem] ⚠ Before this edit: ${ACK_DIRECTIVE}`);
|
|
376
|
+
}
|
|
332
377
|
} else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
|
|
333
378
|
// R-4: Edit/Write empty → short backfill reminder. OPT-IN (default off) as
|
|
334
379
|
// of the cross-project audit: this "no prior lessons, remember to /lesson"
|
|
@@ -360,7 +405,10 @@ try {
|
|
|
360
405
|
// v2.81: record the emitted lesson IDs so flushEpisode (hook.mjs) can
|
|
361
406
|
// build the PostToolUse cite-back hint when the user actually edits the
|
|
362
407
|
// file. Empty array on no-lesson branches keeps the schema uniform.
|
|
363
|
-
|
|
408
|
+
// v2.98: mode records WHERE the injection happened so the Read→Edit ack
|
|
409
|
+
// nudge can distinguish "lessons seen passively at Read" from "already
|
|
410
|
+
// surfaced at an action point".
|
|
411
|
+
cooldown[filePath] = { ts: now, lessonIds: allRows.map(r => r.id), mode: isRead ? 'read' : 'edit' };
|
|
364
412
|
writeCooldown(cooldownPath, cooldown, isSessionScoped);
|
|
365
413
|
// A3 (v2.83): merge our newly-emitted IDs into the cross-hook injected
|
|
366
414
|
// file so the next UPS prompt skips them too. Always write, even on
|
package/server.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
7
|
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
-
import { truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, scrubSecrets,
|
|
8
|
+
import { truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, scrubSecrets, fmtDate, debugLog, debugCatch, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined } from './utils.mjs';
|
|
9
9
|
import { extractCjkLikePatterns, cjkPrecisionOk } from './nlp.mjs';
|
|
10
10
|
import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
|
|
11
11
|
import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
|
|
@@ -32,18 +32,20 @@ function descriptionOf(name) {
|
|
|
32
32
|
return d;
|
|
33
33
|
}
|
|
34
34
|
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
35
|
-
import {
|
|
35
|
+
import { join, sep } from 'path';
|
|
36
36
|
import { homedir } from 'os';
|
|
37
37
|
import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
38
38
|
import { searchResources } from './registry-retriever.mjs';
|
|
39
39
|
import { probeOtherSources as probeIdSources, parseIdToken, bucketIdTokens } from './lib/id-routing.mjs';
|
|
40
40
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
41
|
+
import { rebuildObservationDerived } from './lib/observation-write.mjs';
|
|
42
|
+
import { recallByFile } from './lib/recall-core.mjs';
|
|
41
43
|
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
42
44
|
import {
|
|
43
45
|
insertDeferred, listOpenWithOrdinal, dropDeferred,
|
|
44
46
|
resolveDeferredIds, closeDeferredItems,
|
|
45
47
|
} from './lib/deferred-work.mjs';
|
|
46
|
-
import {
|
|
48
|
+
import { _resetVocabCache } from './tfidf.mjs';
|
|
47
49
|
import { createRequire } from 'module';
|
|
48
50
|
|
|
49
51
|
const require = createRequire(import.meta.url);
|
|
@@ -1819,28 +1821,11 @@ server.registerTool(
|
|
|
1819
1821
|
|
|
1820
1822
|
params.push(args.id);
|
|
1821
1823
|
|
|
1822
|
-
// Atomic: update fields + rebuild FTS text +
|
|
1824
|
+
// Atomic: update fields + rebuild derived columns (FTS text + vector) via the
|
|
1825
|
+
// shared core — single source with CLI cmdUpdate (lib/observation-write.mjs).
|
|
1823
1826
|
db.transaction(() => {
|
|
1824
1827
|
db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
1825
|
-
|
|
1826
|
-
// Rebuild FTS text field (must include CJK bigrams + search_aliases to match mem_save/hook-llm)
|
|
1827
|
-
const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(args.id);
|
|
1828
|
-
const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
|
|
1829
|
-
const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
|
|
1830
|
-
const textField = bigrams ? base + ' ' + bigrams : base;
|
|
1831
|
-
db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, args.id);
|
|
1832
|
-
|
|
1833
|
-
// Re-vectorize (non-critical — catch to avoid rollback)
|
|
1834
|
-
try {
|
|
1835
|
-
const vocab = getVocabulary(db);
|
|
1836
|
-
if (vocab) {
|
|
1837
|
-
const vec = computeVector(textField, vocab);
|
|
1838
|
-
if (vec) {
|
|
1839
|
-
db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
|
|
1840
|
-
.run(args.id, Buffer.from(vec.buffer), vocab.version, Date.now());
|
|
1841
|
-
}
|
|
1842
|
-
}
|
|
1843
|
-
} catch (e) { debugCatch(e, 'mem_update-vector'); }
|
|
1828
|
+
rebuildObservationDerived(db, args.id);
|
|
1844
1829
|
})();
|
|
1845
1830
|
|
|
1846
1831
|
return { content: [{ type: 'text', text: `Updated observation #${args.id}: ${updates.map(u => u.split(' =')[0]).join(', ')}` }] };
|
|
@@ -1906,35 +1891,16 @@ server.registerTool(
|
|
|
1906
1891
|
inputSchema: memRecallSchema,
|
|
1907
1892
|
},
|
|
1908
1893
|
safeHandler(async (args) => {
|
|
1909
|
-
|
|
1910
|
-
const
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
const likePattern = `%${escaped}`;
|
|
1915
|
-
const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
|
|
1916
|
-
const rows = db.prepare(`
|
|
1917
|
-
SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.created_at, o.project
|
|
1918
|
-
FROM observations o
|
|
1919
|
-
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
1920
|
-
WHERE COALESCE(o.compressed_into, 0) = 0
|
|
1921
|
-
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
1922
|
-
${noiseClause}
|
|
1923
|
-
ORDER BY o.created_at_epoch DESC
|
|
1924
|
-
LIMIT ?
|
|
1925
|
-
`).all(filename, likePattern, limit);
|
|
1894
|
+
// Shared core with CLI cmdRecall: query + escaping + access bump (lib/recall-core.mjs)
|
|
1895
|
+
const { filename, rows } = recallByFile(db, args.file, {
|
|
1896
|
+
limit: args.limit ?? 10,
|
|
1897
|
+
includeNoise: args.include_noise === true,
|
|
1898
|
+
});
|
|
1926
1899
|
|
|
1927
1900
|
if (rows.length === 0) {
|
|
1928
1901
|
return { content: [{ type: 'text', text: `No history for "${filename}". This file hasn't been observed yet.` }] };
|
|
1929
1902
|
}
|
|
1930
1903
|
|
|
1931
|
-
// Update access_count for recalled observations
|
|
1932
|
-
const recalledIds = rows.map(r => r.id);
|
|
1933
|
-
const ph = recalledIds.map(() => '?').join(',');
|
|
1934
|
-
try {
|
|
1935
|
-
db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...recalledIds);
|
|
1936
|
-
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
1937
|
-
|
|
1938
1904
|
const lines = [`History for ${filename} (${rows.length} observation${rows.length !== 1 ? 's' : ''}):\n`];
|
|
1939
1905
|
for (const r of rows) {
|
|
1940
1906
|
const lesson = r.lesson_learned ? `\n Lesson: ${truncate(r.lesson_learned, 100)}` : '';
|
package/source-files.mjs
CHANGED
|
@@ -82,6 +82,7 @@ export const SOURCE_FILES = [
|
|
|
82
82
|
// entry-point-reachable); missing it from the manifest would break ALL saves on
|
|
83
83
|
// auto-update. Same single-source-of-truth pattern (see #8217).
|
|
84
84
|
'lib/observation-write.mjs',
|
|
85
|
+
'lib/recall-core.mjs',
|
|
85
86
|
// Shared "compress old low-value observations into weekly summaries" core.
|
|
86
87
|
// Statically imported by mem-cli.mjs (cmdCompress), server.mjs (mem_compress),
|
|
87
88
|
// and hook.mjs (handleAutoCompress) — same single-source-of-truth pattern as
|
package/tool-schemas.mjs
CHANGED
|
@@ -218,11 +218,15 @@ export const memMaintainSchema = {
|
|
|
218
218
|
|
|
219
219
|
export const memUpdateSchema = {
|
|
220
220
|
id: coerceInt.pipe(z.number().int().positive()).describe('Observation ID to update'),
|
|
221
|
-
|
|
221
|
+
// CLI parity (cmdUpdate): empty/whitespace title would render as `(untitled)`
|
|
222
|
+
// in every listing — reject here like the CLI does, instead of persisting it.
|
|
223
|
+
title: z.string().refine(s => s.trim() !== '', 'title cannot be empty').optional().describe('New title'),
|
|
222
224
|
narrative: z.string().optional().describe('New narrative/content'),
|
|
223
225
|
type: OBS_TYPE_ENUM.optional().describe('New observation type'),
|
|
224
226
|
importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('New importance (1-3)'),
|
|
225
|
-
|
|
227
|
+
// 500-char cap mirrors memSaveSchema + cmdUpdate — update was the one path
|
|
228
|
+
// that let overlong lessons leak into the DB via MCP.
|
|
229
|
+
lesson_learned: z.string().max(500).optional().describe('Add or update lesson learned'),
|
|
226
230
|
concepts: z.string().optional().describe('Space-separated concept tags'),
|
|
227
231
|
};
|
|
228
232
|
|