claude-mem-lite 2.96.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/cli/common.mjs +18 -0
- package/hook-llm.mjs +14 -4
- package/hook-update.mjs +16 -2
- package/lib/observation-write.mjs +18 -1
- package/lib/recall-core.mjs +43 -0
- package/mem-cli.mjs +28 -73
- package/package.json +2 -1
- package/scripts/pre-tool-recall.js +50 -2
- package/server.mjs +19 -48
- 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"
|
package/cli/common.mjs
CHANGED
|
@@ -98,6 +98,24 @@ export function fmtDateShort(iso) {
|
|
|
98
98
|
return iso.slice(0, 10);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// Integer epoch-ms time fields on the observations table that `get`/`mem_get`
|
|
102
|
+
// render. Shared by the CLI (mem-cli.mjs) and the MCP server (server.mjs) so the
|
|
103
|
+
// two `get` paths can't drift — pre-2.97 the MCP path printed bare ms
|
|
104
|
+
// (`last_accessed_at: 1781024049720`) while the CLI showed `<ms> (<relative>)`,
|
|
105
|
+
// because the formatter lived only in mem-cli.mjs.
|
|
106
|
+
export const OBS_TIME_FIELDS = ['superseded_at', 'last_accessed_at'];
|
|
107
|
+
|
|
108
|
+
// Pure formatter — null/undefined/non-time pass through; integer time fields
|
|
109
|
+
// render as `<raw> (<relative>)` so callers get both an audit value and a
|
|
110
|
+
// human/LLM-scannable hint, mirroring `recent`/`timeline`/`recall`.
|
|
111
|
+
export function formatObsFieldValue(field, val) {
|
|
112
|
+
if (val === null || val === undefined) return val;
|
|
113
|
+
if (OBS_TIME_FIELDS.includes(field) && typeof val === 'number') {
|
|
114
|
+
return `${val} (${relativeTime(val)})`;
|
|
115
|
+
}
|
|
116
|
+
return val;
|
|
117
|
+
}
|
|
118
|
+
|
|
101
119
|
// ─── ID Token Parsing ────────────────────────────────────────────────────────
|
|
102
120
|
// Re-exported from lib/id-routing.mjs so CLI and MCP (server.mjs) share a single
|
|
103
121
|
// parser — parity per #8050. Keep this re-export for back-compat with the
|
package/hook-llm.mjs
CHANGED
|
@@ -601,7 +601,12 @@ export async function handleLLMEpisode() {
|
|
|
601
601
|
await sleep(delayMs);
|
|
602
602
|
}
|
|
603
603
|
|
|
604
|
-
|
|
604
|
+
// `episode.files` is normally a [] from createEpisode, but a malformed or
|
|
605
|
+
// older-format tmp file can omit it — `.map()` on undefined would throw here,
|
|
606
|
+
// before any cleanup, leaking the tmp file (which is then retried and crashes
|
|
607
|
+
// forever). Guard defensively, mirroring buildImmediateObservation's `|| []`.
|
|
608
|
+
const episodeFiles = Array.isArray(episode.files) ? episode.files : [];
|
|
609
|
+
const fileList = episodeFiles.map(f => basename(f)).join(', ') || '(multiple)';
|
|
605
610
|
|
|
606
611
|
// Defense-in-depth (cso F#4): split static instructions (system) from
|
|
607
612
|
// per-call data (user). Episode descriptions and file paths come from tool
|
|
@@ -622,7 +627,7 @@ search_aliases: 2-6 alternative search terms someone might use to find this memo
|
|
|
622
627
|
JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"concise ≤80 char description","narrative":"what changed, why, and outcome (2-3 sentences)","concepts":["kw1","kw2"],"facts":["fact1","fact2"],"importance":1,"lesson_learned":"non-obvious insight a future session needs, or null","search_aliases":["alt query 1","alt query 2"]}
|
|
623
628
|
${SHARED_OBS_SCHEMA_TAIL}`;
|
|
624
629
|
const user = `Tool: ${e.tool}
|
|
625
|
-
File: ${
|
|
630
|
+
File: ${episodeFiles.join(', ') || 'unknown'}
|
|
626
631
|
Action: ${e.desc}
|
|
627
632
|
Error: ${e.isError ? 'yes' : 'no'}`;
|
|
628
633
|
prompt = { system, user };
|
|
@@ -743,7 +748,7 @@ ${actionList}`;
|
|
|
743
748
|
narrative: truncate(parsed.narrative || '', 500),
|
|
744
749
|
concepts: Array.isArray(parsed.concepts) ? parsed.concepts.slice(0, 10) : [],
|
|
745
750
|
facts: Array.isArray(parsed.facts) ? parsed.facts.slice(0, 10) : [],
|
|
746
|
-
files:
|
|
751
|
+
files: episodeFiles,
|
|
747
752
|
filesRead: episode.filesRead || [],
|
|
748
753
|
// v2.33.1: when lesson is low-signal, don't trust Haiku's importance
|
|
749
754
|
// inflation. v2.54.0: extended from {change, discovery} to all types
|
|
@@ -752,7 +757,12 @@ ${actionList}`;
|
|
|
752
757
|
// imp=2-3 even when lesson is null after retry. Keep `decision` exempt:
|
|
753
758
|
// it's rare (39 obs / 94.9% hit-rate) and the retry path already gave
|
|
754
759
|
// it a second chance; a no-lesson decision is still a worthwhile signal.
|
|
755
|
-
|
|
760
|
+
// `!retryRecovered`: when the P3 retry recovered a substantive lesson,
|
|
761
|
+
// the obs is no longer low-signal — capping it to 1 would negate the
|
|
762
|
+
// retry's entire purpose (a recovered bugfix lesson would silently drop
|
|
763
|
+
// out of --importance 2 searches and the working tier). Gate the cap on
|
|
764
|
+
// the *effective* low-signal state, not the pre-retry flag.
|
|
765
|
+
importance: isLessonLowSignal && !retryRecovered && parsed.type !== 'decision'
|
|
756
766
|
? Math.min(ruleImportance, 1)
|
|
757
767
|
: Math.max(ruleImportance, clampImportance(parsed.importance)),
|
|
758
768
|
lessonLearned,
|
package/hook-update.mjs
CHANGED
|
@@ -155,8 +155,22 @@ function isPluginMode() {
|
|
|
155
155
|
// ── Dev Mode Detection ─────────────────────────────────────
|
|
156
156
|
function isDevMode() {
|
|
157
157
|
try {
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
// A dev checkout always carries a .git dir. This catches a whole-directory
|
|
159
|
+
// symlink (~/.claude-mem-lite -> /repo): lstat on server.mjs there follows the
|
|
160
|
+
// intermediate symlink and sees a regular file, so the per-file probe below
|
|
161
|
+
// would return false and auto-update would clobber the working tree.
|
|
162
|
+
if (existsSync(join(INSTALL_DIR, '.git'))) return true;
|
|
163
|
+
// Standard `install --dev` symlinks individual source files; checking several
|
|
164
|
+
// core files (not just server.mjs) survives the drift case where one file was
|
|
165
|
+
// replaced by a plain copy while the install is still symlink-provisioned —
|
|
166
|
+
// mirrors lib/doctor-drift.mjs's "any symlink ⇒ dev" detection. A copy-based
|
|
167
|
+
// real install (install.mjs non-dev) has no symlinks and no .git, so this
|
|
168
|
+
// cannot false-positive into never auto-updating.
|
|
169
|
+
for (const f of ['server.mjs', 'hook.mjs', 'cli.mjs', 'mem-cli.mjs']) {
|
|
170
|
+
const p = join(INSTALL_DIR, f);
|
|
171
|
+
if (existsSync(p) && lstatSync(p).isSymbolicLink()) return true;
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
160
174
|
} catch { return false; }
|
|
161
175
|
}
|
|
162
176
|
|
|
@@ -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,14 +27,16 @@ 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
|
|
34
34
|
// router + remaining-command bodies during the incremental split. Future work:
|
|
35
35
|
// move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
|
|
36
|
-
import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags } from './cli/common.mjs';
|
|
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({
|
|
@@ -559,24 +538,12 @@ function cmdRecall(db, args) {
|
|
|
559
538
|
|
|
560
539
|
const OBS_FIELDS = ['id', 'type', 'title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases', 'files_read', 'files_modified', 'project', 'created_at', 'memory_session_id', 'prompt_number', 'importance', 'related_ids', 'access_count', 'branch', 'superseded_at', 'superseded_by', 'last_accessed_at'];
|
|
561
540
|
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
export
|
|
568
|
-
|
|
569
|
-
// Pure formatter — null/undefined/non-time pass through; time fields on
|
|
570
|
-
// integer values render as `<raw> (<relative>)` mirroring the convention
|
|
571
|
-
// already used by `recent` / `timeline` / `recall`. Pre-2.63.0 the get
|
|
572
|
-
// path printed bare ms (e.g. `last_accessed_at: 1778357330957`).
|
|
573
|
-
export function formatObsFieldValue(field, val) {
|
|
574
|
-
if (val === null || val === undefined) return val;
|
|
575
|
-
if (OBS_TIME_FIELDS.includes(field) && typeof val === 'number') {
|
|
576
|
-
return `${val} (${relativeTime(val)})`;
|
|
577
|
-
}
|
|
578
|
-
return val;
|
|
579
|
-
}
|
|
541
|
+
// Time-field formatting moved to cli/common.mjs so the CLI `get` and the MCP
|
|
542
|
+
// `mem_get` (server.mjs) share one source and can't drift (the drift bug:
|
|
543
|
+
// MCP printed bare ms while CLI showed `<ms> (<relative>)`). Imported at the
|
|
544
|
+
// top; re-exported here for back-compat with existing importers
|
|
545
|
+
// (tests/get-time-format.test.mjs).
|
|
546
|
+
export { OBS_TIME_FIELDS, formatObsFieldValue };
|
|
580
547
|
|
|
581
548
|
function renderObsRows(db, ids, requestedFields) {
|
|
582
549
|
const placeholders = ids.map(() => '?').join(',');
|
|
@@ -1720,28 +1687,11 @@ function cmdUpdate(db, args) {
|
|
|
1720
1687
|
|
|
1721
1688
|
params.push(id);
|
|
1722
1689
|
|
|
1723
|
-
// 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).
|
|
1724
1692
|
db.transaction(() => {
|
|
1725
1693
|
db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
1726
|
-
|
|
1727
|
-
// Rebuild FTS text field
|
|
1728
|
-
const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(id);
|
|
1729
|
-
const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
|
|
1730
|
-
const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
|
|
1731
|
-
const textField = bigrams ? base + ' ' + bigrams : base;
|
|
1732
|
-
db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, id);
|
|
1733
|
-
|
|
1734
|
-
// Re-vectorize (non-critical — catch to avoid rollback)
|
|
1735
|
-
try {
|
|
1736
|
-
const vocab = getVocabulary(db);
|
|
1737
|
-
if (vocab) {
|
|
1738
|
-
const vec = computeVector(textField, vocab);
|
|
1739
|
-
if (vec) {
|
|
1740
|
-
db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
|
|
1741
|
-
.run(id, Buffer.from(vec.buffer), vocab.version, Date.now());
|
|
1742
|
-
}
|
|
1743
|
-
}
|
|
1744
|
-
} catch { /* non-critical */ }
|
|
1694
|
+
rebuildObservationDerived(db, id);
|
|
1745
1695
|
})();
|
|
1746
1696
|
|
|
1747
1697
|
out(`[mem] Updated #${id}: ${updates.map(u => u.split(' =')[0]).join(', ')}`);
|
|
@@ -1797,12 +1747,15 @@ function cmdExport(db, args) {
|
|
|
1797
1747
|
|
|
1798
1748
|
// Full round-trippable column set so `restore` rebuilds observations faithfully —
|
|
1799
1749
|
// content + value-signals (access/cited/uncited/injection/decay) + branch + timing.
|
|
1800
|
-
//
|
|
1801
|
-
//
|
|
1802
|
-
//
|
|
1750
|
+
// `search_aliases` is an FTS5-indexed column (BM25 weight 5) — dropping it on
|
|
1751
|
+
// export silently lost the LLM-generated alternate query terms on restore, so a
|
|
1752
|
+
// restored memory became unfindable by its aliases. Additive vs the pre-v2.90
|
|
1753
|
+
// 13-col shape; existing `export | jq '.[].title'` consumers are unaffected.
|
|
1754
|
+
// id + memory_session_id are informational (restore remaps id and buckets under
|
|
1755
|
+
// a restore session).
|
|
1803
1756
|
const rows = db.prepare(`
|
|
1804
1757
|
SELECT id, memory_session_id, project, type, title, subtitle, narrative, concepts, facts,
|
|
1805
|
-
files_read, files_modified, lesson_learned, importance, branch,
|
|
1758
|
+
files_read, files_modified, lesson_learned, search_aliases, importance, branch,
|
|
1806
1759
|
access_count, cited_count, uncited_streak, injection_count, decay_seen_count,
|
|
1807
1760
|
last_accessed_at, created_at, created_at_epoch
|
|
1808
1761
|
FROM observations WHERE ${wheres.join(' AND ')}
|
|
@@ -1863,7 +1816,7 @@ function cmdRestore(db, argv) {
|
|
|
1863
1816
|
|
|
1864
1817
|
const dupCheck = db.prepare('SELECT id FROM observations WHERE project = ? AND title = ? AND created_at_epoch = ? LIMIT 1');
|
|
1865
1818
|
const signalUpdate = db.prepare(`UPDATE observations SET
|
|
1866
|
-
subtitle = ?, concepts = ?, facts = ?, files_read = ?, branch = COALESCE(?, branch),
|
|
1819
|
+
subtitle = ?, concepts = ?, facts = ?, search_aliases = ?, files_read = ?, branch = COALESCE(?, branch),
|
|
1867
1820
|
access_count = ?, cited_count = ?, uncited_streak = ?, injection_count = ?,
|
|
1868
1821
|
decay_seen_count = ?, last_accessed_at = ?
|
|
1869
1822
|
WHERE id = ?`);
|
|
@@ -1893,8 +1846,10 @@ function cmdRestore(db, argv) {
|
|
|
1893
1846
|
});
|
|
1894
1847
|
if (res.kind !== 'saved') { skipped++; continue; } // saveObservation Jaccard dedup
|
|
1895
1848
|
// Re-apply the fields saveObservation zeros/derives so the backup is faithful.
|
|
1849
|
+
// search_aliases is its own FTS5 column, so this UPDATE re-syncs the index
|
|
1850
|
+
// (via the observations FTS triggers) and restored aliases stay searchable.
|
|
1896
1851
|
signalUpdate.run(
|
|
1897
|
-
r.subtitle || '', r.concepts || '', r.facts || '', r.files_read || '[]', r.branch ?? null,
|
|
1852
|
+
r.subtitle || '', r.concepts || '', r.facts || '', r.search_aliases ?? null, r.files_read || '[]', r.branch ?? null,
|
|
1898
1853
|
num(r.access_count), num(r.cited_count), num(r.uncited_streak), num(r.injection_count),
|
|
1899
1854
|
num(r.decay_seen_count), r.last_accessed_at ?? null,
|
|
1900
1855
|
res.id,
|
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';
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from './lib/maintain-core.mjs';
|
|
21
21
|
import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
|
|
22
22
|
import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
23
|
+
import { formatObsFieldValue } from './cli/common.mjs';
|
|
23
24
|
import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
|
|
24
25
|
|
|
25
26
|
// Lookup helper: all user-facing tool descriptions live in tool-schemas.mjs
|
|
@@ -31,18 +32,20 @@ function descriptionOf(name) {
|
|
|
31
32
|
return d;
|
|
32
33
|
}
|
|
33
34
|
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
34
|
-
import {
|
|
35
|
+
import { join, sep } from 'path';
|
|
35
36
|
import { homedir } from 'os';
|
|
36
37
|
import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
37
38
|
import { searchResources } from './registry-retriever.mjs';
|
|
38
39
|
import { probeOtherSources as probeIdSources, parseIdToken, bucketIdTokens } from './lib/id-routing.mjs';
|
|
39
40
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
41
|
+
import { rebuildObservationDerived } from './lib/observation-write.mjs';
|
|
42
|
+
import { recallByFile } from './lib/recall-core.mjs';
|
|
40
43
|
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
41
44
|
import {
|
|
42
45
|
insertDeferred, listOpenWithOrdinal, dropDeferred,
|
|
43
46
|
resolveDeferredIds, closeDeferredItems,
|
|
44
47
|
} from './lib/deferred-work.mjs';
|
|
45
|
-
import {
|
|
48
|
+
import { _resetVocabCache } from './tfidf.mjs';
|
|
46
49
|
import { createRequire } from 'module';
|
|
47
50
|
|
|
48
51
|
const require = createRequire(import.meta.url);
|
|
@@ -801,8 +804,12 @@ server.registerTool(
|
|
|
801
804
|
const val = row[f];
|
|
802
805
|
if (val === null || val === undefined || val === '') continue;
|
|
803
806
|
if (f === 'text' && row.narrative && typeof val === 'string' && val.startsWith(row.narrative)) continue;
|
|
807
|
+
// Shared formatter (cli/common.mjs) renders epoch-ms time fields as
|
|
808
|
+
// `<ms> (<relative>)` — parity with the CLI `get` path so an LLM reader
|
|
809
|
+
// gets a scannable hint instead of a bare millisecond integer.
|
|
810
|
+
const display = formatObsFieldValue(f, val);
|
|
804
811
|
const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
|
|
805
|
-
lines.push(`${f}: ${typeof
|
|
812
|
+
lines.push(`${f}: ${typeof display === 'string' && display.length > maxLen ? display.slice(0, maxLen) + '…' : display}`);
|
|
806
813
|
}
|
|
807
814
|
sections.push(lines.join('\n'));
|
|
808
815
|
}
|
|
@@ -1814,28 +1821,11 @@ server.registerTool(
|
|
|
1814
1821
|
|
|
1815
1822
|
params.push(args.id);
|
|
1816
1823
|
|
|
1817
|
-
// 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).
|
|
1818
1826
|
db.transaction(() => {
|
|
1819
1827
|
db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
1820
|
-
|
|
1821
|
-
// Rebuild FTS text field (must include CJK bigrams + search_aliases to match mem_save/hook-llm)
|
|
1822
|
-
const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(args.id);
|
|
1823
|
-
const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
|
|
1824
|
-
const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
|
|
1825
|
-
const textField = bigrams ? base + ' ' + bigrams : base;
|
|
1826
|
-
db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, args.id);
|
|
1827
|
-
|
|
1828
|
-
// Re-vectorize (non-critical — catch to avoid rollback)
|
|
1829
|
-
try {
|
|
1830
|
-
const vocab = getVocabulary(db);
|
|
1831
|
-
if (vocab) {
|
|
1832
|
-
const vec = computeVector(textField, vocab);
|
|
1833
|
-
if (vec) {
|
|
1834
|
-
db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
|
|
1835
|
-
.run(args.id, Buffer.from(vec.buffer), vocab.version, Date.now());
|
|
1836
|
-
}
|
|
1837
|
-
}
|
|
1838
|
-
} catch (e) { debugCatch(e, 'mem_update-vector'); }
|
|
1828
|
+
rebuildObservationDerived(db, args.id);
|
|
1839
1829
|
})();
|
|
1840
1830
|
|
|
1841
1831
|
return { content: [{ type: 'text', text: `Updated observation #${args.id}: ${updates.map(u => u.split(' =')[0]).join(', ')}` }] };
|
|
@@ -1901,35 +1891,16 @@ server.registerTool(
|
|
|
1901
1891
|
inputSchema: memRecallSchema,
|
|
1902
1892
|
},
|
|
1903
1893
|
safeHandler(async (args) => {
|
|
1904
|
-
|
|
1905
|
-
const
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
const likePattern = `%${escaped}`;
|
|
1910
|
-
const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
|
|
1911
|
-
const rows = db.prepare(`
|
|
1912
|
-
SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.created_at, o.project
|
|
1913
|
-
FROM observations o
|
|
1914
|
-
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
1915
|
-
WHERE COALESCE(o.compressed_into, 0) = 0
|
|
1916
|
-
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
1917
|
-
${noiseClause}
|
|
1918
|
-
ORDER BY o.created_at_epoch DESC
|
|
1919
|
-
LIMIT ?
|
|
1920
|
-
`).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
|
+
});
|
|
1921
1899
|
|
|
1922
1900
|
if (rows.length === 0) {
|
|
1923
1901
|
return { content: [{ type: 'text', text: `No history for "${filename}". This file hasn't been observed yet.` }] };
|
|
1924
1902
|
}
|
|
1925
1903
|
|
|
1926
|
-
// Update access_count for recalled observations
|
|
1927
|
-
const recalledIds = rows.map(r => r.id);
|
|
1928
|
-
const ph = recalledIds.map(() => '?').join(',');
|
|
1929
|
-
try {
|
|
1930
|
-
db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...recalledIds);
|
|
1931
|
-
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
1932
|
-
|
|
1933
1904
|
const lines = [`History for ${filename} (${rows.length} observation${rows.length !== 1 ? 's' : ''}):\n`];
|
|
1934
1905
|
for (const r of rows) {
|
|
1935
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
|
|