claude-mem-lite 2.61.0 → 2.63.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/hook-shared.mjs +32 -1
- package/hook.mjs +12 -1
- package/install.mjs +82 -34
- package/mem-cli.mjs +34 -18
- package/package.json +2 -1
- package/search-engine.mjs +50 -0
- package/server.mjs +14 -18
package/hook-shared.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { execFileSync, spawn } from 'child_process';
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
6
|
import { join } from 'path';
|
|
7
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync, statSync, unlinkSync } from 'fs';
|
|
8
8
|
import { inferProject, debugCatch } from './utils.mjs';
|
|
9
9
|
import { ensureDb, DB_DIR } from './schema.mjs';
|
|
10
10
|
import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI } from './haiku-client.mjs';
|
|
@@ -62,6 +62,37 @@ export const HANDOFF_ANCHOR_MAX_AGE = 72 * 3600000; // 72h cap on gi
|
|
|
62
62
|
export const HANDOFF_MATCH_THRESHOLD = 3; // min weighted score
|
|
63
63
|
export const CONTINUE_KEYWORDS = /继续|接着|上次|之前的|前面的|刚才|\bcontinue\b|\bresume\b|\bwhere[\s-]+we[\s-]+left\b|\bpick[\s-]+up\b|\bcarry[\s-]+on\b/i;
|
|
64
64
|
|
|
65
|
+
// Orphan-sweep threshold for `ep-flush-*` / `pending-*` runtime artifacts.
|
|
66
|
+
// handleLLMEpisode's worst-case round-trip is ~60s (delay + LLM call + DB
|
|
67
|
+
// write); 1h leaves a wide safety margin against deleting an in-flight file.
|
|
68
|
+
// Older orphans are crashed workers or pre-shutdown buffers that no live
|
|
69
|
+
// caller will ever pick up, so sweeping them on SessionStart is safe.
|
|
70
|
+
export const ORPHAN_EPISODE_AGE_MS = 60 * 60 * 1000;
|
|
71
|
+
|
|
72
|
+
// Sweep stale `ep-flush-*` and `pending-*` files in `runtimeDir` whose mtime
|
|
73
|
+
// is older than `ageMs` (default 1h). Returns the number of files removed.
|
|
74
|
+
// fs-only — no DB / no network. Used by handleSessionStart auto-maintain to
|
|
75
|
+
// prevent the doctor "Stale temp files" warning from accumulating across
|
|
76
|
+
// crashes; equivalent to the manual path in `node install.mjs cleanup` but
|
|
77
|
+
// age-gated so concurrent in-flight workers are never raced.
|
|
78
|
+
export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE_MS, now = Date.now() } = {}) {
|
|
79
|
+
let entries;
|
|
80
|
+
try { entries = readdirSync(runtimeDir); } catch { return 0; }
|
|
81
|
+
const cutoff = now - ageMs;
|
|
82
|
+
let count = 0;
|
|
83
|
+
for (const f of entries) {
|
|
84
|
+
if (!(f.startsWith('ep-flush-') || f.startsWith('pending-'))) continue;
|
|
85
|
+
const full = join(runtimeDir, f);
|
|
86
|
+
try {
|
|
87
|
+
if (statSync(full).mtimeMs < cutoff) {
|
|
88
|
+
unlinkSync(full);
|
|
89
|
+
count++;
|
|
90
|
+
}
|
|
91
|
+
} catch { /* concurrent unlink / permission — ignore */ }
|
|
92
|
+
}
|
|
93
|
+
return count;
|
|
94
|
+
}
|
|
95
|
+
|
|
65
96
|
// Ensure runtime directory exists
|
|
66
97
|
try { if (!existsSync(RUNTIME_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true }); } catch {}
|
|
67
98
|
|
package/hook.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
RUNTIME_DIR, EPISODE_BUFFER_SIZE, EPISODE_TIME_GAP_MS,
|
|
41
41
|
SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS,
|
|
42
42
|
sessionFile, getSessionId, createSessionId, openDb,
|
|
43
|
-
spawnBackground,
|
|
43
|
+
spawnBackground, sweepOrphanEpisodeFiles,
|
|
44
44
|
} from './hook-shared.mjs';
|
|
45
45
|
import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation } from './hook-llm.mjs';
|
|
46
46
|
import { extractCitationsFromTranscript, bumpCitationAccess, computeCiteRecall } from './lib/citation-tracker.mjs';
|
|
@@ -885,6 +885,17 @@ async function handleSessionStart() {
|
|
|
885
885
|
}
|
|
886
886
|
}
|
|
887
887
|
|
|
888
|
+
// Orphan sweep: remove `ep-flush-*` / `pending-*` runtime files older
|
|
889
|
+
// than 1h. handleLLMEpisode normally unlinks its own tmpFile on every
|
|
890
|
+
// exit path, but a crashed worker (OOM, host reboot, kill -9) leaves
|
|
891
|
+
// the file behind, and the doctor "Stale temp files" warning then
|
|
892
|
+
// accumulates indefinitely. fs-only; runs inside the 24h gate so it
|
|
893
|
+
// shares cadence with the rest of auto-maintain.
|
|
894
|
+
try {
|
|
895
|
+
const swept = sweepOrphanEpisodeFiles(RUNTIME_DIR);
|
|
896
|
+
if (swept > 0) debugLog('DEBUG', 'auto-maintain', `swept ${swept} orphan ep-flush/pending file(s)`);
|
|
897
|
+
} catch (e) { debugCatch(e, 'auto-maintain-orphan-sweep'); }
|
|
898
|
+
|
|
888
899
|
// Mark maintenance as done (24h gate) — even though compression runs in background
|
|
889
900
|
writeFileSync(maintainFile, JSON.stringify({ epoch: Date.now() }));
|
|
890
901
|
// Weekly summary grouping runs in background to avoid blocking SessionStart
|
package/install.mjs
CHANGED
|
@@ -280,6 +280,38 @@ function ok(msg) { console.log(` ✓ ${msg}`); }
|
|
|
280
280
|
function warn(msg) { console.log(` ⚠ ${msg}`); }
|
|
281
281
|
function fail(msg) { console.log(` ✗ ${msg}`); }
|
|
282
282
|
|
|
283
|
+
// Pure JSON-version field bumper for the release pipeline. Reads `filePath`,
|
|
284
|
+
// walks `keyPath` (e.g. `['version']` or `['plugins', 0, 'version']`), and
|
|
285
|
+
// rewrites only when the new value differs. Returns `{ changed, prev }` so
|
|
286
|
+
// callers can log "X → Y" with the captured-before-mutation value — pre-2.63.0
|
|
287
|
+
// the plugin.json branch in syncVersions logged "Y → Y" because it read the
|
|
288
|
+
// field after assignment.
|
|
289
|
+
export function bumpJsonField(filePath, keyPath, newVal) {
|
|
290
|
+
const json = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
291
|
+
let parent = json;
|
|
292
|
+
for (let i = 0; i < keyPath.length - 1; i++) parent = parent?.[keyPath[i]];
|
|
293
|
+
if (!parent) return { changed: false, prev: undefined };
|
|
294
|
+
const lastKey = keyPath[keyPath.length - 1];
|
|
295
|
+
const prev = parent[lastKey];
|
|
296
|
+
if (prev === newVal) return { changed: false, prev };
|
|
297
|
+
parent[lastKey] = newVal;
|
|
298
|
+
writeFileSync(filePath, JSON.stringify(json, null, 2) + '\n');
|
|
299
|
+
return { changed: true, prev };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Doctor's final summary line. Pure function so the 4-way contract
|
|
303
|
+
// (clean / warnings-only / issues / mixed) is unit-testable without spinning
|
|
304
|
+
// up the full doctor pipeline. `issues` are ✗-level (action required);
|
|
305
|
+
// `warnings` are ⚠-level (informational, "All checks passed!" must NOT lie
|
|
306
|
+
// about them).
|
|
307
|
+
export function buildDoctorSummary(issues, warnings) {
|
|
308
|
+
const wPlural = warnings === 1 ? '' : 's';
|
|
309
|
+
if (issues === 0 && warnings === 0) return 'All checks passed!';
|
|
310
|
+
if (issues === 0) return `All critical checks passed (${warnings} warning${wPlural}).`;
|
|
311
|
+
const warnSuffix = warnings > 0 ? ` (+${warnings} warning${wPlural})` : '';
|
|
312
|
+
return `${issues} issue(s) found.${warnSuffix}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
283
315
|
// Dev installs symlink server.mjs → the project's source file. Used to suppress
|
|
284
316
|
// misleading "first run" messages since hook-update.mjs skips state-writes in
|
|
285
317
|
// this mode (see hook-update.mjs isDevMode).
|
|
@@ -1152,6 +1184,13 @@ async function status() {
|
|
|
1152
1184
|
async function doctor() {
|
|
1153
1185
|
console.log('\nclaude-mem-lite doctor\n');
|
|
1154
1186
|
let issues = 0;
|
|
1187
|
+
let warnings = 0;
|
|
1188
|
+
// Doctor-local ⚠ helper: visually identical to the file-level `warn`, but
|
|
1189
|
+
// bumps `warnings` so the summary line can distinguish "fully green" from
|
|
1190
|
+
// "warnings present". Used for informational ⚠ checks; the two ⚠ paths
|
|
1191
|
+
// that ALSO bump `issues` (stale procs, dev drift) keep using the file-level
|
|
1192
|
+
// `warn` directly to avoid double-counting.
|
|
1193
|
+
const dwarn = (msg) => { warnings++; console.log(` ⚠ ${msg}`); };
|
|
1155
1194
|
|
|
1156
1195
|
// Node version
|
|
1157
1196
|
const nodeVer = process.version;
|
|
@@ -1209,7 +1248,7 @@ async function doctor() {
|
|
|
1209
1248
|
} else if (hasHooks) {
|
|
1210
1249
|
ok('Plugin lifecycle: hooks active');
|
|
1211
1250
|
} else {
|
|
1212
|
-
|
|
1251
|
+
dwarn('Plugin lifecycle: hooks not configured');
|
|
1213
1252
|
}
|
|
1214
1253
|
|
|
1215
1254
|
// Database
|
|
@@ -1232,7 +1271,7 @@ async function doctor() {
|
|
|
1232
1271
|
if (healthy) {
|
|
1233
1272
|
ok('FTS5 integrity: all indexes healthy');
|
|
1234
1273
|
} else {
|
|
1235
|
-
|
|
1274
|
+
dwarn('FTS5 integrity issues detected:');
|
|
1236
1275
|
for (const d of details) log(` ${d}`);
|
|
1237
1276
|
log(' Attempting FTS5 rebuild...');
|
|
1238
1277
|
const { rebuilt, errors } = rebuildFTS(rwDb);
|
|
@@ -1243,17 +1282,17 @@ async function doctor() {
|
|
|
1243
1282
|
rwDb.close();
|
|
1244
1283
|
}
|
|
1245
1284
|
} catch (e) {
|
|
1246
|
-
|
|
1285
|
+
dwarn('FTS5 integrity check failed: ' + e.message);
|
|
1247
1286
|
}
|
|
1248
1287
|
} else {
|
|
1249
|
-
|
|
1288
|
+
dwarn('FTS5 index: missing (will be created on server start)');
|
|
1250
1289
|
}
|
|
1251
1290
|
} catch (e) {
|
|
1252
1291
|
fail('Database: ' + e.message);
|
|
1253
1292
|
issues++;
|
|
1254
1293
|
}
|
|
1255
1294
|
} else {
|
|
1256
|
-
|
|
1295
|
+
dwarn('Database: not found (will be created)');
|
|
1257
1296
|
}
|
|
1258
1297
|
|
|
1259
1298
|
// Check for stale processes
|
|
@@ -1287,10 +1326,10 @@ async function doctor() {
|
|
|
1287
1326
|
// short-circuits before writing state (see hook-update.mjs isDevMode).
|
|
1288
1327
|
ok('Update state: skipped (dev mode — symlinked install)');
|
|
1289
1328
|
} else {
|
|
1290
|
-
|
|
1329
|
+
dwarn('Update state: no state file (first run?)');
|
|
1291
1330
|
}
|
|
1292
1331
|
} catch {
|
|
1293
|
-
|
|
1332
|
+
dwarn('Update state: failed to read');
|
|
1294
1333
|
}
|
|
1295
1334
|
|
|
1296
1335
|
// Dev drift: in dev-mode installs, all SOURCE_FILES entries should be
|
|
@@ -1310,7 +1349,7 @@ async function doctor() {
|
|
|
1310
1349
|
}
|
|
1311
1350
|
// Prod (all plain) install: no message — dev-drift is a dev-only concern.
|
|
1312
1351
|
} catch (e) {
|
|
1313
|
-
|
|
1352
|
+
dwarn('Dev drift: check failed — ' + e.message);
|
|
1314
1353
|
}
|
|
1315
1354
|
|
|
1316
1355
|
// Stale temp files
|
|
@@ -1329,12 +1368,12 @@ async function doctor() {
|
|
|
1329
1368
|
}
|
|
1330
1369
|
}
|
|
1331
1370
|
if (staleCount > 0) {
|
|
1332
|
-
|
|
1371
|
+
dwarn(`Stale temp files: ${staleCount} found (run: node install.mjs cleanup)`);
|
|
1333
1372
|
} else {
|
|
1334
1373
|
ok('Stale temp files: none');
|
|
1335
1374
|
}
|
|
1336
1375
|
} catch {
|
|
1337
|
-
|
|
1376
|
+
dwarn('Stale temp files: check failed');
|
|
1338
1377
|
}
|
|
1339
1378
|
|
|
1340
1379
|
// DB stats
|
|
@@ -1350,7 +1389,7 @@ async function doctor() {
|
|
|
1350
1389
|
db.close();
|
|
1351
1390
|
ok(`DB stats: ${sizeMB}MB, ${obsCount} observations, ${sessCount} sessions`);
|
|
1352
1391
|
} catch (e) {
|
|
1353
|
-
|
|
1392
|
+
dwarn('DB stats: ' + e.message);
|
|
1354
1393
|
}
|
|
1355
1394
|
}
|
|
1356
1395
|
|
|
@@ -1364,14 +1403,14 @@ async function doctor() {
|
|
|
1364
1403
|
sizeStr = execFileSync('du', ['-sh', pluginCacheBase], { encoding: 'utf8', timeout: 5000 }).trim().split('\t')[0];
|
|
1365
1404
|
} catch { sizeStr = '?'; }
|
|
1366
1405
|
if (versions.length > 3) {
|
|
1367
|
-
|
|
1406
|
+
dwarn(`Plugin cache: ${versions.length} versions (${sizeStr}) — run setup.sh or update to auto-prune to 3`);
|
|
1368
1407
|
} else {
|
|
1369
1408
|
ok(`Plugin cache: ${versions.length} version(s) (${sizeStr})`);
|
|
1370
1409
|
}
|
|
1371
1410
|
} catch {}
|
|
1372
1411
|
}
|
|
1373
1412
|
|
|
1374
|
-
console.log(`\n ${issues
|
|
1413
|
+
console.log(`\n ${buildDoctorSummary(issues, warnings)}\n`);
|
|
1375
1414
|
}
|
|
1376
1415
|
|
|
1377
1416
|
// ─── Settings helpers ───────────────────────────────────────────────────────
|
|
@@ -1573,34 +1612,19 @@ function syncVersions() {
|
|
|
1573
1612
|
const version = pkg.version;
|
|
1574
1613
|
log(`package.json version: ${version}`);
|
|
1575
1614
|
|
|
1576
|
-
// Sync plugin.json
|
|
1577
1615
|
const pluginJsonPath = join(PROJECT_DIR, '.claude-plugin', 'plugin.json');
|
|
1578
1616
|
if (existsSync(pluginJsonPath)) {
|
|
1579
|
-
const
|
|
1580
|
-
|
|
1581
|
-
pluginJson.version = version;
|
|
1582
|
-
writeFileSync(pluginJsonPath, JSON.stringify(pluginJson, null, 2) + '\n');
|
|
1583
|
-
ok(`plugin.json: ${pluginJson.version} → ${version}`);
|
|
1584
|
-
} else {
|
|
1585
|
-
ok(`plugin.json: already ${version}`);
|
|
1586
|
-
}
|
|
1617
|
+
const r = bumpJsonField(pluginJsonPath, ['version'], version);
|
|
1618
|
+
ok(r.changed ? `plugin.json: ${r.prev} → ${version}` : `plugin.json: already ${version}`);
|
|
1587
1619
|
} else {
|
|
1588
1620
|
warn('plugin.json not found');
|
|
1589
1621
|
}
|
|
1590
1622
|
|
|
1591
|
-
// Sync marketplace.json
|
|
1592
1623
|
const marketJsonPath = join(PROJECT_DIR, '.claude-plugin', 'marketplace.json');
|
|
1593
1624
|
if (existsSync(marketJsonPath)) {
|
|
1594
|
-
const
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
const prev = plugin.version;
|
|
1598
|
-
plugin.version = version;
|
|
1599
|
-
writeFileSync(marketJsonPath, JSON.stringify(marketJson, null, 2) + '\n');
|
|
1600
|
-
ok(`marketplace.json: ${prev} → ${version}`);
|
|
1601
|
-
} else if (plugin) {
|
|
1602
|
-
ok(`marketplace.json: already ${version}`);
|
|
1603
|
-
}
|
|
1625
|
+
const r = bumpJsonField(marketJsonPath, ['plugins', 0, 'version'], version);
|
|
1626
|
+
if (r.prev === undefined) warn('marketplace.json: plugins[0] not found');
|
|
1627
|
+
else ok(r.changed ? `marketplace.json: ${r.prev} → ${version}` : `marketplace.json: already ${version}`);
|
|
1604
1628
|
} else {
|
|
1605
1629
|
warn('marketplace.json not found');
|
|
1606
1630
|
}
|
|
@@ -1629,6 +1653,29 @@ function syncVersions() {
|
|
|
1629
1653
|
console.log('');
|
|
1630
1654
|
}
|
|
1631
1655
|
|
|
1656
|
+
// Regenerate package-lock.json via npm@10.9.2 to guarantee CI parity. The
|
|
1657
|
+
// drift this prevents: `npm install --package-lock-only` on npm@11+ silently
|
|
1658
|
+
// strips top-level `@emnapi/core` + `@emnapi/runtime` entries when those are
|
|
1659
|
+
// transitive deps of platform-optional bindings (e.g. `@oxc-parser/binding-*`
|
|
1660
|
+
// from knip), and CI's bundled npm@10 (Node 22 default in GitHub Actions)
|
|
1661
|
+
// then refuses `npm ci` with EUSAGE. Same recipe bit twice (#8271 / 2.58.2 /
|
|
1662
|
+
// 2.62.1) before this guard. The packageManager field in package.json
|
|
1663
|
+
// declares the same version for corepack-aware tooling. Network cost: ~5-30s
|
|
1664
|
+
// per release; release cadence makes this acceptable.
|
|
1665
|
+
function regenerateLockfile() {
|
|
1666
|
+
console.log('\nclaude-mem-lite release — regenerate lockfile (npm@10.9.2)\n');
|
|
1667
|
+
try {
|
|
1668
|
+
execFileSync('npx', ['--yes', 'npm@10.9.2', 'install'], {
|
|
1669
|
+
stdio: 'inherit',
|
|
1670
|
+
cwd: PROJECT_DIR,
|
|
1671
|
+
});
|
|
1672
|
+
ok('lockfile regenerated');
|
|
1673
|
+
} catch (e) {
|
|
1674
|
+
fail('lockfile regen failed: ' + e.message);
|
|
1675
|
+
throw e;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1632
1679
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
1633
1680
|
|
|
1634
1681
|
export async function main(argv = process.argv.slice(2)) {
|
|
@@ -1660,6 +1707,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1660
1707
|
break;
|
|
1661
1708
|
case 'release':
|
|
1662
1709
|
syncVersions();
|
|
1710
|
+
if (!flags.has('--no-lock')) regenerateLockfile();
|
|
1663
1711
|
break;
|
|
1664
1712
|
default:
|
|
1665
1713
|
if (IS_NPX) {
|
|
@@ -1679,7 +1727,7 @@ Usage:
|
|
|
1679
1727
|
node install.mjs cleanup Remove stale temp/staging files
|
|
1680
1728
|
node install.mjs cleanup-hooks Remove only claude-mem-lite hooks from settings.json
|
|
1681
1729
|
node install.mjs self-update Check for and install updates
|
|
1682
|
-
node install.mjs release Sync
|
|
1730
|
+
node install.mjs release Sync versions (plugin/marketplace/CLAUDE.md) + regen lockfile via npm@10.9.2 (use --no-lock to skip lock regen)
|
|
1683
1731
|
|
|
1684
1732
|
npx claude-mem-lite Install via npx (one-liner)
|
|
1685
1733
|
`);
|
package/mem-cli.mjs
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import { homedir } from 'os';
|
|
6
6
|
import { ensureDb, DB_PATH, REGISTRY_DB_PATH } from './schema.mjs';
|
|
7
|
-
import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, jaccardSimilarity, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, isoWeekKey, COMPRESSED_PENDING_PURGE,
|
|
7
|
+
import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, jaccardSimilarity, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, isoWeekKey, COMPRESSED_PENDING_PURGE, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause } 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
12
|
import { getVocabulary, computeVector, rebuildVocabulary, _resetVocabCache } from './tfidf.mjs';
|
|
13
13
|
import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
|
|
14
|
-
import { searchObservationsHybrid } from './search-engine.mjs';
|
|
14
|
+
import { searchObservationsHybrid, findFtsAnchor } from './search-engine.mjs';
|
|
15
15
|
import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
16
16
|
import { searchResources } from './registry-retriever.mjs';
|
|
17
17
|
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
@@ -447,6 +447,25 @@ function cmdRecall(db, args) {
|
|
|
447
447
|
|
|
448
448
|
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'];
|
|
449
449
|
|
|
450
|
+
// Integer-typed time-epoch fields on the observations table that the `get`
|
|
451
|
+
// command renders. Callers expect raw ms (audit) AND a relative-time hint
|
|
452
|
+
// (human-scan), so formatObsFieldValue emits both. Other epoch fields like
|
|
453
|
+
// `created_at_epoch` / `optimized_at` / `last_injected_at` aren't in
|
|
454
|
+
// OBS_FIELDS so they don't surface via `get`.
|
|
455
|
+
export const OBS_TIME_FIELDS = ['superseded_at', 'last_accessed_at'];
|
|
456
|
+
|
|
457
|
+
// Pure formatter — null/undefined/non-time pass through; time fields on
|
|
458
|
+
// integer values render as `<raw> (<relative>)` mirroring the convention
|
|
459
|
+
// already used by `recent` / `timeline` / `recall`. Pre-2.63.0 the get
|
|
460
|
+
// path printed bare ms (e.g. `last_accessed_at: 1778357330957`).
|
|
461
|
+
export function formatObsFieldValue(field, val) {
|
|
462
|
+
if (val === null || val === undefined) return val;
|
|
463
|
+
if (OBS_TIME_FIELDS.includes(field) && typeof val === 'number') {
|
|
464
|
+
return `${val} (${relativeTime(val)})`;
|
|
465
|
+
}
|
|
466
|
+
return val;
|
|
467
|
+
}
|
|
468
|
+
|
|
450
469
|
function renderObsRows(db, ids, requestedFields) {
|
|
451
470
|
const placeholders = ids.map(() => '?').join(',');
|
|
452
471
|
try {
|
|
@@ -465,8 +484,9 @@ function renderObsRows(db, ids, requestedFields) {
|
|
|
465
484
|
const val = r[f];
|
|
466
485
|
if (val === null || val === undefined || val === '') continue;
|
|
467
486
|
if (f === 'text' && r.narrative && typeof val === 'string' && val.startsWith(r.narrative)) continue;
|
|
487
|
+
const formatted = formatObsFieldValue(f, val);
|
|
468
488
|
const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
|
|
469
|
-
const display = typeof
|
|
489
|
+
const display = typeof formatted === 'string' && formatted.length > maxLen ? formatted.slice(0, maxLen) + '…' : formatted;
|
|
470
490
|
lines.push(`${f}: ${display}`);
|
|
471
491
|
}
|
|
472
492
|
parts.push(lines.join('\n'));
|
|
@@ -663,24 +683,20 @@ function cmdTimeline(db, args) {
|
|
|
663
683
|
}
|
|
664
684
|
}
|
|
665
685
|
|
|
666
|
-
// Support query-based anchor: `timeline --query "search terms"` or positional
|
|
667
|
-
//
|
|
686
|
+
// Support query-based anchor: `timeline --query "search terms"` or positional.
|
|
687
|
+
// Routes through shared findFtsAnchor (paired-path with MCP mem_timeline)
|
|
688
|
+
// so AND→OR fallback semantics match `search` — without this, queries like
|
|
689
|
+
// "ep-flush leak" miss rows whose title is "ep-flush ... leaked" that
|
|
690
|
+
// search would otherwise find via OR relaxation.
|
|
668
691
|
const queryStr = flags.query || positional.join(' ');
|
|
669
692
|
if ((!anchorId || isNaN(anchorId)) && queryStr) {
|
|
670
693
|
const ftsQuery = sanitizeFtsQuery(queryStr);
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
AND (? IS NULL OR o.project = ?)
|
|
678
|
-
AND COALESCE(o.compressed_into, 0) = 0
|
|
679
|
-
ORDER BY ${OBS_BM25}
|
|
680
|
-
* (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${DEFAULT_DECAY_HALF_LIFE_MS}.0))
|
|
681
|
-
LIMIT 1
|
|
682
|
-
`).get(ftsQuery, project ?? null, project ?? null, nowT);
|
|
683
|
-
if (match) anchorId = match.id;
|
|
694
|
+
const found = findFtsAnchor(db, { ftsQuery, project: project ?? null });
|
|
695
|
+
if (found) {
|
|
696
|
+
anchorId = found.id;
|
|
697
|
+
if (found.relaxed && !anchorNote) {
|
|
698
|
+
anchorNote = `(query "${queryStr}" relaxed AND→OR — no row matched all terms)`;
|
|
699
|
+
}
|
|
684
700
|
}
|
|
685
701
|
}
|
|
686
702
|
|
package/package.json
CHANGED
package/search-engine.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE,
|
|
10
|
+
DEFAULT_DECAY_HALF_LIFE_MS,
|
|
10
11
|
notLowSignalTitleClause, LOW_SIGNAL_TITLE,
|
|
11
12
|
relaxFtsQueryToOr, debugLog, debugCatch,
|
|
12
13
|
} from './utils.mjs';
|
|
@@ -141,6 +142,55 @@ function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includ
|
|
|
141
142
|
* perSourceOffset, currentProject, limit, orFallbackFired }
|
|
142
143
|
* @returns {Array} list of result objects (mutated ctx may set orFallbackFired)
|
|
143
144
|
*/
|
|
145
|
+
/**
|
|
146
|
+
* Resolve `timeline --query "..."` / mem_timeline auto-anchor to a single
|
|
147
|
+
* observation id. Shared between mem-cli.mjs cmdTimeline and server.mjs
|
|
148
|
+
* mem_timeline so both surfaces use identical AND→OR fallback semantics
|
|
149
|
+
* (paired-path discipline per #8217).
|
|
150
|
+
*
|
|
151
|
+
* Pipeline:
|
|
152
|
+
* 1. FTS5 MATCH with the sanitized query (AND-by-default), recency-weighted
|
|
153
|
+
* 2. If AND returns 0 → relaxFtsQueryToOr fallback (mirrors searchObservationsHybrid)
|
|
154
|
+
*
|
|
155
|
+
* Always skips compressed rows.
|
|
156
|
+
*
|
|
157
|
+
* @param {Database} db
|
|
158
|
+
* @param {object} opts
|
|
159
|
+
* @param {string|null} opts.ftsQuery pre-sanitized FTS5 query
|
|
160
|
+
* @param {string|null} [opts.project] restrict to this project (boost-by-membership; null = no filter)
|
|
161
|
+
* @param {number} [opts.nowT] Date.now() override (for deterministic tests)
|
|
162
|
+
* @param {number} [opts.halfLifeMs] recency half-life (default DEFAULT_DECAY_HALF_LIFE_MS)
|
|
163
|
+
* @returns {{id:number, relaxed:boolean}|null} `relaxed:true` when AND returned 0 and OR rescued —
|
|
164
|
+
* callers should surface a "(relaxed AND→OR)" hint to mirror search transparency.
|
|
165
|
+
*/
|
|
166
|
+
export function findFtsAnchor(db, { ftsQuery, project = null, nowT = null, halfLifeMs = DEFAULT_DECAY_HALF_LIFE_MS } = {}) {
|
|
167
|
+
if (!ftsQuery) return null;
|
|
168
|
+
const now = nowT ?? Date.now();
|
|
169
|
+
const sql = `
|
|
170
|
+
SELECT o.id FROM observations_fts
|
|
171
|
+
JOIN observations o ON observations_fts.rowid = o.id
|
|
172
|
+
WHERE observations_fts MATCH ?
|
|
173
|
+
AND (? IS NULL OR o.project = ?)
|
|
174
|
+
AND COALESCE(o.compressed_into, 0) = 0
|
|
175
|
+
ORDER BY ${OBS_BM25}
|
|
176
|
+
* (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${halfLifeMs}.0))
|
|
177
|
+
LIMIT 1
|
|
178
|
+
`;
|
|
179
|
+
const stmt = db.prepare(sql);
|
|
180
|
+
try {
|
|
181
|
+
const m = stmt.get(ftsQuery, project, project, now);
|
|
182
|
+
if (m) return { id: m.id, relaxed: false };
|
|
183
|
+
} catch (e) { debugCatch(e, 'findFtsAnchor-and'); }
|
|
184
|
+
const orQuery = relaxFtsQueryToOr(ftsQuery);
|
|
185
|
+
if (orQuery && orQuery !== ftsQuery) {
|
|
186
|
+
try {
|
|
187
|
+
const m = stmt.get(orQuery, project, project, now);
|
|
188
|
+
if (m) return { id: m.id, relaxed: true };
|
|
189
|
+
} catch (e) { debugCatch(e, 'findFtsAnchor-or'); }
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
144
194
|
export function searchObservationsHybrid(db, ctx) {
|
|
145
195
|
const { ftsQuery, args, epochFrom, epochTo, perSourceLimit, perSourceOffset, currentProject, limit } = ctx;
|
|
146
196
|
const results = [];
|
package/server.mjs
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
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 { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_PENDING_PURGE,
|
|
8
|
+
import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_PENDING_PURGE, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined, notLowSignalTitleClause } 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, REGISTRY_DB_PATH } from './schema.mjs';
|
|
12
12
|
import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './server-internals.mjs';
|
|
13
|
-
import { searchObservationsHybrid } from './search-engine.mjs';
|
|
13
|
+
import { searchObservationsHybrid, findFtsAnchor } from './search-engine.mjs';
|
|
14
14
|
import { effectiveQuiet } from './hook-shared.mjs';
|
|
15
15
|
import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
16
16
|
import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
|
|
@@ -103,7 +103,7 @@ function resolveProject(name) { return _resolveProjectShared(db, name); }
|
|
|
103
103
|
// Importance: 0.5 + 0.5 × importance (range 0.5–2.0)
|
|
104
104
|
// Access bonus: 1 + 0.1 × ln(1 + access_count)
|
|
105
105
|
|
|
106
|
-
//
|
|
106
|
+
// SESS_BM25, TYPE_DECAY_CASE imported from utils.mjs
|
|
107
107
|
const RECENCY_HALF_LIFE_MS = DEFAULT_DECAY_HALF_LIFE_MS;
|
|
108
108
|
|
|
109
109
|
// ─── MCP Server ─────────────────────────────────────────────────────────────
|
|
@@ -612,23 +612,19 @@ server.registerTool(
|
|
|
612
612
|
}
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
-
// Auto-find anchor via FTS (with recency decay)
|
|
615
|
+
// Auto-find anchor via FTS (with recency decay). Routes through shared
|
|
616
|
+
// findFtsAnchor so CLI `timeline --query` and MCP mem_timeline use
|
|
617
|
+
// identical AND→OR fallback semantics (paired-path per #8217). When the
|
|
618
|
+
// OR fallback fired, surface a hint so the caller knows the match was
|
|
619
|
+
// not an exact AND coverage of the query — mirrors search transparency.
|
|
616
620
|
if (!anchorId && args.query) {
|
|
617
621
|
const ftsQuery = sanitizeFtsQuery(args.query);
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
WHERE observations_fts MATCH ?
|
|
625
|
-
AND (? IS NULL OR o.project = ?)
|
|
626
|
-
AND COALESCE(o.compressed_into, 0) = 0
|
|
627
|
-
ORDER BY ${OBS_BM25}
|
|
628
|
-
* (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${RECENCY_HALF_LIFE_MS}.0))
|
|
629
|
-
LIMIT 1
|
|
630
|
-
`).get(ftsQuery, args.project ?? null, args.project ?? null, nowT);
|
|
631
|
-
if (row) anchorId = row.id;
|
|
622
|
+
const found = findFtsAnchor(db, { ftsQuery, project: args.project ?? null });
|
|
623
|
+
if (found) {
|
|
624
|
+
anchorId = found.id;
|
|
625
|
+
if (found.relaxed && !anchorNote) {
|
|
626
|
+
anchorNote = `(query "${args.query}" relaxed AND→OR — no row matched all terms)`;
|
|
627
|
+
}
|
|
632
628
|
}
|
|
633
629
|
}
|
|
634
630
|
|