claude-mem-lite 2.34.0 → 2.34.2
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.mjs +42 -28
- package/mem-cli.mjs +115 -26
- package/package.json +1 -1
- package/scripts/pre-skill-bridge.js +14 -3
- package/scripts/pre-tool-recall.js +8 -4
- package/server.mjs +124 -32
- package/tool-schemas.mjs +7 -3
package/hook.mjs
CHANGED
|
@@ -417,27 +417,35 @@ async function handleStop() {
|
|
|
417
417
|
try { buildAndSaveHandoff(db, sessionId, project, 'exit', episodeSnapshot, ccSessionId || sessionId); }
|
|
418
418
|
catch (e) { debugCatch(e, 'handleStop-handoff'); }
|
|
419
419
|
|
|
420
|
-
// Fast summary baseline — ensures summary exists even if background LLM fails
|
|
420
|
+
// Fast summary baseline — ensures summary exists even if background LLM fails.
|
|
421
|
+
// T4-P2-B: guard against Stop firing twice for the same session (rare but possible;
|
|
422
|
+
// mirrors handleSessionStart line 795 hasSummary guard). Uses mem-internal sessionId
|
|
423
|
+
// as the WHERE key per the top-of-file dual-id invariant (#7789).
|
|
421
424
|
try {
|
|
422
|
-
const
|
|
423
|
-
SELECT
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
425
|
+
const existingSummary = db.prepare(
|
|
426
|
+
'SELECT 1 FROM session_summaries WHERE memory_session_id = ? LIMIT 1'
|
|
427
|
+
).get(sessionId);
|
|
428
|
+
if (!existingSummary) {
|
|
429
|
+
const firstPrompt = db.prepare(`
|
|
430
|
+
SELECT prompt_text FROM user_prompts
|
|
431
|
+
WHERE content_session_id = ?
|
|
432
|
+
ORDER BY prompt_number ASC LIMIT 1
|
|
433
|
+
`).get(sessionId);
|
|
434
|
+
const recentObs = db.prepare(`
|
|
435
|
+
SELECT title FROM observations
|
|
436
|
+
WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0
|
|
437
|
+
ORDER BY created_at_epoch DESC LIMIT 5
|
|
438
|
+
`).all(sessionId);
|
|
439
|
+
const fastRequest = truncate(firstPrompt?.prompt_text || '', 200);
|
|
440
|
+
const fastCompleted = recentObs.map(o => o.title).filter(Boolean).join('; ');
|
|
441
|
+
if (fastRequest || fastCompleted) {
|
|
442
|
+
const now = new Date();
|
|
443
|
+
db.prepare(`
|
|
444
|
+
INSERT INTO session_summaries
|
|
445
|
+
(memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
|
|
446
|
+
VALUES (?, ?, ?, '', '', ?, '', '', '[]', '[]', 'fast', ?, ?)
|
|
447
|
+
`).run(sessionId, project, fastRequest, truncate(fastCompleted, 300), now.toISOString(), now.getTime());
|
|
448
|
+
}
|
|
441
449
|
}
|
|
442
450
|
} catch (e) { debugCatch(e, 'handleStop-fast-summary'); }
|
|
443
451
|
} finally {
|
|
@@ -603,12 +611,14 @@ async function handleSessionStart() {
|
|
|
603
611
|
const STALE_AGE = Date.now() - 30 * 86400000;
|
|
604
612
|
const OP_CAP = 500;
|
|
605
613
|
|
|
606
|
-
// Purge FIRST: delete
|
|
607
|
-
//
|
|
614
|
+
// Purge FIRST: delete pending-purge entries. Schema has no marked_at_epoch, so we
|
|
615
|
+
// anchor retention on created_at_epoch instead: 30d marking gate + 7d grace = 37d.
|
|
616
|
+
// Older cutoffs (e.g. 7d) were always redundant with the 30d marking filter and
|
|
617
|
+
// made purge effectively immediate on the next maintenance cycle — fix for T4-P1-A.
|
|
608
618
|
const purged = db.prepare(`
|
|
609
619
|
DELETE FROM observations WHERE compressed_into = ${COMPRESSED_PENDING_PURGE}
|
|
610
620
|
AND created_at_epoch < ?
|
|
611
|
-
`).run(Date.now() -
|
|
621
|
+
`).run(Date.now() - 37 * 86400000);
|
|
612
622
|
if (purged.changes > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged.changes} stale observations`);
|
|
613
623
|
|
|
614
624
|
// Cleanup: remove broken observations (no title AND no narrative)
|
|
@@ -906,9 +916,13 @@ async function handleUserPrompt() {
|
|
|
906
916
|
VALUES (?, ?, ?, ?, ?, 'active')
|
|
907
917
|
`).run(sessionId, sessionId, project, now.toISOString(), now.getTime());
|
|
908
918
|
|
|
909
|
-
//
|
|
910
|
-
|
|
911
|
-
|
|
919
|
+
// T4-P2-D: atomic increment+read via UPDATE ... RETURNING (SQLite 3.35+).
|
|
920
|
+
// Previously UPDATE + SELECT as two statements; parallel prompts could read a stale
|
|
921
|
+
// counter and emit duplicate prompt_number values. better-sqlite3 ships a modern SQLite.
|
|
922
|
+
const bumped = db.prepare(
|
|
923
|
+
'UPDATE sdk_sessions SET prompt_counter = COALESCE(prompt_counter, 0) + 1 WHERE content_session_id = ? RETURNING prompt_counter'
|
|
924
|
+
).get(sessionId);
|
|
925
|
+
const promptNumber = bumped?.prompt_counter || 1;
|
|
912
926
|
|
|
913
927
|
db.prepare(`
|
|
914
928
|
INSERT INTO user_prompts (content_session_id, prompt_text, prompt_number, created_at, created_at_epoch)
|
|
@@ -916,7 +930,7 @@ async function handleUserPrompt() {
|
|
|
916
930
|
`).run(
|
|
917
931
|
sessionId,
|
|
918
932
|
scrubSecrets(promptText.slice(0, 10000)),
|
|
919
|
-
|
|
933
|
+
promptNumber,
|
|
920
934
|
now.toISOString(), now.getTime()
|
|
921
935
|
);
|
|
922
936
|
|
|
@@ -928,7 +942,7 @@ async function handleUserPrompt() {
|
|
|
928
942
|
const ccSessionId = typeof hookData.session_id === 'string' && hookData.session_id.length > 0
|
|
929
943
|
? hookData.session_id
|
|
930
944
|
: null;
|
|
931
|
-
if (
|
|
945
|
+
if (promptNumber <= 3) {
|
|
932
946
|
try {
|
|
933
947
|
if (detectContinuationIntent(db, promptText, project, ccSessionId)) {
|
|
934
948
|
const injection = renderHandoffInjection(db, project, ccSessionId);
|
package/mem-cli.mjs
CHANGED
|
@@ -742,8 +742,11 @@ function cmdTimeline(db, args) {
|
|
|
742
742
|
return;
|
|
743
743
|
}
|
|
744
744
|
|
|
745
|
-
|
|
746
|
-
|
|
745
|
+
// Auto-scope to anchor's project when --project not explicitly given: users asking
|
|
746
|
+
// "what happened around #N" expect same-project context, not cross-project time-bleed.
|
|
747
|
+
const effectiveProject = project || anchorRow.project;
|
|
748
|
+
const projectFilter = effectiveProject ? 'AND project = ?' : '';
|
|
749
|
+
const baseParams = effectiveProject ? [effectiveProject] : [];
|
|
747
750
|
|
|
748
751
|
// Before anchor
|
|
749
752
|
const beforeRows = db.prepare(`
|
|
@@ -783,7 +786,7 @@ function cmdSave(db, args) {
|
|
|
783
786
|
const { positional, flags } = parseArgs(args);
|
|
784
787
|
const text = positional.join(' ');
|
|
785
788
|
if (!text) {
|
|
786
|
-
fail('[mem] Usage: mem save "<text>" [--type T] [--title T] [--importance N] [--project P] [--files f1,f2]');
|
|
789
|
+
fail('[mem] Usage: mem save "<text>" [--type T] [--title T] [--importance N] [--project P] [--files f1,f2] [--lesson T]');
|
|
787
790
|
return;
|
|
788
791
|
}
|
|
789
792
|
|
|
@@ -805,9 +808,21 @@ function cmdSave(db, args) {
|
|
|
805
808
|
const project = flags.project ? resolveProject(db, flags.project) : inferProject();
|
|
806
809
|
const saveFiles = flags.files ? flags.files.split(',').map(f => f.trim()).filter(Boolean) : [];
|
|
807
810
|
|
|
811
|
+
// Optional lesson_learned — accepts --lesson or --lesson-learned (alias)
|
|
812
|
+
// Mirrors MCP memSaveSchema.lesson_learned (≤500 chars) and cmdUpdate's flag handling.
|
|
813
|
+
const rawLesson = flags.lesson !== undefined ? flags.lesson
|
|
814
|
+
: flags['lesson-learned'] !== undefined ? flags['lesson-learned']
|
|
815
|
+
: null;
|
|
816
|
+
if (rawLesson !== null && typeof rawLesson === 'string' && rawLesson.length > 500) {
|
|
817
|
+
fail(`[mem] --lesson too long (${rawLesson.length} chars, max 500).`);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
808
821
|
// Secret scrubbing (aligned with MCP mem_save)
|
|
809
822
|
const safeContent = scrubSecrets(text);
|
|
810
823
|
const safeTitle = scrubSecrets(rawTitle);
|
|
824
|
+
const safeLesson = (rawLesson !== null && typeof rawLesson === 'string' && rawLesson.length > 0)
|
|
825
|
+
? scrubSecrets(rawLesson) : null;
|
|
811
826
|
|
|
812
827
|
// Dedup: skip if similar title/content saved in last 5 minutes (aligned with MCP mem_save)
|
|
813
828
|
const fiveMinAgo = Date.now() - 5 * 60 * 1000;
|
|
@@ -827,8 +842,11 @@ function cmdSave(db, args) {
|
|
|
827
842
|
}
|
|
828
843
|
|
|
829
844
|
// MinHash + CJK bigrams (aligned with MCP mem_save)
|
|
845
|
+
// Include lesson in the FTS-indexed text so the +0.3 lesson-boost actually surfaces
|
|
846
|
+
// lesson-bearing rows (mirrors MCP mem_save which builds the same indexText).
|
|
830
847
|
const minhashSig = computeMinHash(safeTitle + ' ' + safeContent);
|
|
831
|
-
const
|
|
848
|
+
const indexText = [safeTitle, safeContent, safeLesson].filter(Boolean).join(' ');
|
|
849
|
+
const bigramText = cjkBigrams(indexText);
|
|
832
850
|
const textField = bigramText ? safeContent + ' ' + bigramText : safeContent;
|
|
833
851
|
|
|
834
852
|
const now = new Date();
|
|
@@ -843,9 +861,9 @@ function cmdSave(db, args) {
|
|
|
843
861
|
// Atomic: insert observation + observation_files + TF-IDF vector (aligned with MCP mem_save)
|
|
844
862
|
const saveTx = db.transaction(() => {
|
|
845
863
|
const result = db.prepare(`
|
|
846
|
-
INSERT INTO observations (memory_session_id, project, text, type, title, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, branch, created_at, created_at_epoch)
|
|
847
|
-
VALUES (?, ?, ?, ?, ?, ?, '', '', '[]', ?, ?, ?, ?, ?, ?)
|
|
848
|
-
`).run(sessionId, project, textField, type, safeTitle, safeContent, JSON.stringify(saveFiles), importance, minhashSig, getCurrentBranch(), now.toISOString(), now.getTime());
|
|
864
|
+
INSERT INTO observations (memory_session_id, project, text, type, title, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, lesson_learned, branch, created_at, created_at_epoch)
|
|
865
|
+
VALUES (?, ?, ?, ?, ?, ?, '', '', '[]', ?, ?, ?, ?, ?, ?, ?)
|
|
866
|
+
`).run(sessionId, project, textField, type, safeTitle, safeContent, JSON.stringify(saveFiles), importance, minhashSig, safeLesson, getCurrentBranch(), now.toISOString(), now.getTime());
|
|
849
867
|
const savedId = Number(result.lastInsertRowid);
|
|
850
868
|
|
|
851
869
|
// Populate observation_files junction table (aligned with MCP mem_save)
|
|
@@ -870,7 +888,8 @@ function cmdSave(db, args) {
|
|
|
870
888
|
});
|
|
871
889
|
const result = saveTx();
|
|
872
890
|
|
|
873
|
-
|
|
891
|
+
const lessonNote = safeLesson ? ' 💡lesson captured' : '';
|
|
892
|
+
out(`[mem] Saved #${result.lastInsertRowid} [${type}] "${truncate(safeTitle, 80)}" (project: ${project})${lessonNote}`);
|
|
874
893
|
}
|
|
875
894
|
|
|
876
895
|
// N-1: Quality-focused stats for R-2 A/B baseline.
|
|
@@ -1645,6 +1664,9 @@ function cmdMaintain(db, args) {
|
|
|
1645
1664
|
const OP_CAP = 1000;
|
|
1646
1665
|
const results = [];
|
|
1647
1666
|
|
|
1667
|
+
// T2-P1-B: surface the OP_CAP hit so users know to re-run, matching MCP mem_maintain.
|
|
1668
|
+
const capHint = (changes) => (changes >= OP_CAP ? ' (cap reached, re-run for more)' : '');
|
|
1669
|
+
|
|
1648
1670
|
db.transaction(() => {
|
|
1649
1671
|
if (ops.includes('cleanup')) {
|
|
1650
1672
|
const deleted = db.prepare(`
|
|
@@ -1655,7 +1677,7 @@ function cmdMaintain(db, args) {
|
|
|
1655
1677
|
${projectFilter} LIMIT ${OP_CAP}
|
|
1656
1678
|
)
|
|
1657
1679
|
`).run(...baseParams);
|
|
1658
|
-
results.push(`Cleaned up ${deleted.changes} broken observations`);
|
|
1680
|
+
results.push(`Cleaned up ${deleted.changes} broken observations${capHint(deleted.changes)}`);
|
|
1659
1681
|
}
|
|
1660
1682
|
|
|
1661
1683
|
if (ops.includes('decay')) {
|
|
@@ -1683,7 +1705,8 @@ function cmdMaintain(db, args) {
|
|
|
1683
1705
|
${projectFilter} LIMIT ${OP_CAP}
|
|
1684
1706
|
)
|
|
1685
1707
|
`).run(staleAge, ...baseParams);
|
|
1686
|
-
|
|
1708
|
+
const decayCap = (decayed.changes >= OP_CAP || idleMarked.changes >= OP_CAP) ? ' (cap reached, re-run for more)' : '';
|
|
1709
|
+
results.push(`Decayed ${decayed.changes} stale observations, marked ${idleMarked.changes} idle as pending-purge${decayCap}`);
|
|
1687
1710
|
}
|
|
1688
1711
|
|
|
1689
1712
|
if (ops.includes('boost')) {
|
|
@@ -1697,35 +1720,68 @@ function cmdMaintain(db, args) {
|
|
|
1697
1720
|
${projectFilter} LIMIT ${OP_CAP}
|
|
1698
1721
|
)
|
|
1699
1722
|
`).run(...baseParams);
|
|
1700
|
-
results.push(`Boosted ${boosted.changes} frequently-accessed observations`);
|
|
1723
|
+
results.push(`Boosted ${boosted.changes} frequently-accessed observations${capHint(boosted.changes)}`);
|
|
1701
1724
|
}
|
|
1702
1725
|
|
|
1703
1726
|
if (ops.includes('dedup') && flags['merge-ids']) {
|
|
1704
|
-
// Parse merge-ids: "keepId:removeId1:removeId2,keepId2:removeId3" format
|
|
1727
|
+
// Parse merge-ids: "keepId:removeId1:removeId2,keepId2:removeId3" format.
|
|
1728
|
+
// Surface malformed segments (non-numeric tokens, single-element pairs) instead of
|
|
1729
|
+
// silently dropping them, so typos like "abc:def" don't hide behind "Merged 0".
|
|
1705
1730
|
let totalMerged = 0;
|
|
1731
|
+
const invalidSegments = [];
|
|
1706
1732
|
const mergeStmt = db.prepare('UPDATE observations SET compressed_into = ? WHERE id = ? AND COALESCE(compressed_into, 0) = 0');
|
|
1707
|
-
const
|
|
1708
|
-
for (const
|
|
1709
|
-
|
|
1710
|
-
const
|
|
1733
|
+
const rawSegments = flags['merge-ids'].split(',').map(s => s.trim()).filter(Boolean);
|
|
1734
|
+
for (const seg of rawSegments) {
|
|
1735
|
+
const parts = seg.split(':').map(s => s.trim());
|
|
1736
|
+
const nums = parts.map(p => Number(p));
|
|
1737
|
+
const badToken = parts.length < 2 || nums.some(n => !Number.isFinite(n) || n <= 0);
|
|
1738
|
+
if (badToken) { invalidSegments.push(seg); continue; }
|
|
1739
|
+
const [keepId, ...removeIds] = nums;
|
|
1711
1740
|
for (const removeId of removeIds) {
|
|
1712
1741
|
totalMerged += mergeStmt.run(keepId, removeId).changes;
|
|
1713
1742
|
}
|
|
1714
1743
|
}
|
|
1744
|
+
if (invalidSegments.length) {
|
|
1745
|
+
results.push(`Warning: ignored ${invalidSegments.length} malformed --merge-ids segment(s): ${invalidSegments.join(', ')} (expected keepId:removeId[:removeId...] with positive integers)`);
|
|
1746
|
+
}
|
|
1715
1747
|
results.push(`Merged ${totalMerged} duplicate observations`);
|
|
1716
1748
|
}
|
|
1717
1749
|
|
|
1750
|
+
// T2-P1-B parity with MCP: warn when merge-ids is provided but dedup wasn't requested.
|
|
1751
|
+
if (!ops.includes('dedup') && flags['merge-ids']) {
|
|
1752
|
+
results.push('Warning: --merge-ids provided but "dedup" not in operations — merge-ids ignored');
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1718
1755
|
if (ops.includes('purge_stale')) {
|
|
1719
1756
|
const retainDays = parseInt(flags['retain-days'], 10) || 30;
|
|
1720
1757
|
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1758
|
+
// T2-P0-A (CLI parity): purge_stale is the only DELETE in this code path — require
|
|
1759
|
+
// --confirm so a mis-typed `maintain execute --ops purge_stale` can't wipe rows silently.
|
|
1760
|
+
const confirmed = flags.confirm === true || flags.confirm === 'true';
|
|
1761
|
+
if (!confirmed) {
|
|
1762
|
+
const previewRow = db.prepare(`
|
|
1763
|
+
SELECT COUNT(*) AS candidates, MIN(created_at_epoch) AS oldest, MAX(created_at_epoch) AS newest
|
|
1764
|
+
FROM observations
|
|
1765
|
+
WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ? ${projectFilter}
|
|
1766
|
+
`).get(retainCutoff, ...baseParams);
|
|
1767
|
+
const pushLines = [`purge_stale preview (no --confirm):`,
|
|
1768
|
+
` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`];
|
|
1769
|
+
if (previewRow.candidates > 0) {
|
|
1770
|
+
pushLines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
|
|
1771
|
+
pushLines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
|
|
1772
|
+
}
|
|
1773
|
+
pushLines.push(` To delete, re-run with --confirm.`);
|
|
1774
|
+
results.push(pushLines.join('\n'));
|
|
1775
|
+
} else {
|
|
1776
|
+
const purged = db.prepare(`
|
|
1777
|
+
DELETE FROM observations WHERE id IN (
|
|
1778
|
+
SELECT id FROM observations
|
|
1779
|
+
WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ?
|
|
1780
|
+
${projectFilter} LIMIT ${OP_CAP}
|
|
1781
|
+
)
|
|
1782
|
+
`).run(retainCutoff, ...baseParams);
|
|
1783
|
+
results.push(`Purged ${purged.changes} stale observations (retained last ${retainDays} days)${capHint(purged.changes)}`);
|
|
1784
|
+
}
|
|
1729
1785
|
}
|
|
1730
1786
|
})();
|
|
1731
1787
|
|
|
@@ -1993,6 +2049,7 @@ Commands:
|
|
|
1993
2049
|
--importance N 1-3 (default: 2)
|
|
1994
2050
|
--project P Project name
|
|
1995
2051
|
--files f1,f2 Comma-separated file paths
|
|
2052
|
+
--lesson T Lesson learned (≤500 chars; alias: --lesson-learned)
|
|
1996
2053
|
|
|
1997
2054
|
delete <id1,id2,...> Delete observations by ID
|
|
1998
2055
|
--confirm Execute deletion (preview by default)
|
|
@@ -2025,6 +2082,16 @@ Commands:
|
|
|
2025
2082
|
--project P Filter by project
|
|
2026
2083
|
--retain-days N For purge_stale: keep last N days (default 30)
|
|
2027
2084
|
|
|
2085
|
+
optimize LLM-powered memory optimization (preview by default)
|
|
2086
|
+
--run Execute (default: preview gates)
|
|
2087
|
+
--run-all Execute bypassing gates
|
|
2088
|
+
--task T Comma-separated: re-enrich,normalize,cluster-merge,smart-compress
|
|
2089
|
+
--max N Max items per task (1-100, default 15)
|
|
2090
|
+
--scope S re-enrich scope: narrow (default) or wide
|
|
2091
|
+
|
|
2092
|
+
doctor Environment diagnostics and benchmarks
|
|
2093
|
+
--benchmark Run perf benchmark and emit JSON
|
|
2094
|
+
|
|
2028
2095
|
fts-check <check|rebuild> FTS5 index check or rebuild
|
|
2029
2096
|
|
|
2030
2097
|
stats Show memory statistics
|
|
@@ -2180,10 +2247,32 @@ async function cmdEnrich(argv) {
|
|
|
2180
2247
|
async function cmdOptimize(db, args) {
|
|
2181
2248
|
const run = args.includes('--run');
|
|
2182
2249
|
const runAll = args.includes('--run-all');
|
|
2250
|
+
// T2-P1-D: --task accepts a single task or a comma-separated list, parity with MCP memOptimizeSchema.tasks.
|
|
2251
|
+
const VALID_TASKS = ['re-enrich', 'normalize', 'cluster-merge', 'smart-compress'];
|
|
2183
2252
|
const taskIdx = args.indexOf('--task');
|
|
2184
|
-
|
|
2253
|
+
let tasks;
|
|
2254
|
+
if (taskIdx >= 0 && args[taskIdx + 1]) {
|
|
2255
|
+
const parsed = args[taskIdx + 1].split(',').map(s => s.trim()).filter(Boolean);
|
|
2256
|
+
const invalid = parsed.filter(t => !VALID_TASKS.includes(t));
|
|
2257
|
+
if (invalid.length > 0) {
|
|
2258
|
+
fail(`[mem] Unknown task(s): ${invalid.join(', ')}. Valid: ${VALID_TASKS.join(', ')}`);
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
tasks = parsed;
|
|
2262
|
+
}
|
|
2263
|
+
// T2-P1-C: reject --max 0 / --max <non-positive> / --max <non-number> explicitly — the old
|
|
2264
|
+
// `|| 15` fallback silently turned these into the default (15), burning LLM tokens.
|
|
2185
2265
|
const maxIdx = args.indexOf('--max');
|
|
2186
|
-
|
|
2266
|
+
let maxItems = 15;
|
|
2267
|
+
if (maxIdx >= 0) {
|
|
2268
|
+
const raw = args[maxIdx + 1];
|
|
2269
|
+
const parsed = parseInt(raw, 10);
|
|
2270
|
+
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 100) {
|
|
2271
|
+
fail(`[mem] Invalid --max "${raw}". Must be an integer between 1 and 100.`);
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
maxItems = parsed;
|
|
2275
|
+
}
|
|
2187
2276
|
// R-7 micro: --scope wide targets bugfix/refactor/feature/decision with narrative but no
|
|
2188
2277
|
// lesson_learned (the "Haiku judged 'none'" cases). Default 'narrow' preserves old behavior.
|
|
2189
2278
|
const scopeIdx = args.indexOf('--scope');
|
package/package.json
CHANGED
|
@@ -66,13 +66,24 @@ try {
|
|
|
66
66
|
|
|
67
67
|
// Read and output
|
|
68
68
|
const content = readFileSync(skillPath, 'utf8');
|
|
69
|
-
//
|
|
69
|
+
// T4-P1-B: JSON hookSpecificOutput parity with pre-tool-recall.js. Some CC variants
|
|
70
|
+
// (notably sdscc) silently drop plain-text stdout from PreToolUse — the previous
|
|
71
|
+
// console.log() form would render on stock CC but no-op on those variants.
|
|
72
|
+
// Token budget: ~4 chars per token, 4000 token limit = 16000 chars.
|
|
73
|
+
let additionalContext;
|
|
70
74
|
if (content.length > 16000) {
|
|
71
75
|
const summary = content.slice(0, 800);
|
|
72
|
-
|
|
76
|
+
additionalContext = `<skill-bridge name="${row.name}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated. Use mem_use(name="${row.name}") to load full content.`;
|
|
73
77
|
} else {
|
|
74
|
-
|
|
78
|
+
additionalContext = `<skill-bridge name="${row.name}" source="managed">\n${content}\n</skill-bridge>\n\nThis skill was loaded from the managed registry. Follow the instructions above.`;
|
|
75
79
|
}
|
|
80
|
+
process.stdout.write(JSON.stringify({
|
|
81
|
+
suppressOutput: true,
|
|
82
|
+
hookSpecificOutput: {
|
|
83
|
+
hookEventName: 'PreToolUse',
|
|
84
|
+
additionalContext,
|
|
85
|
+
},
|
|
86
|
+
}));
|
|
76
87
|
} catch {
|
|
77
88
|
// Silent failure — never block Skill tool
|
|
78
89
|
} finally {
|
|
@@ -188,15 +188,19 @@ try {
|
|
|
188
188
|
const lines = [];
|
|
189
189
|
if (allRows.length > 0) {
|
|
190
190
|
lines.push(`[mem] Lessons for ${fname}:`);
|
|
191
|
+
// R3-UX: raised from 120 → 240 after measuring 97% of lessons exceed 120 chars
|
|
192
|
+
// (p50=218, avg=247). Previous limit truncated the actionable "Fix:" tail in 80%
|
|
193
|
+
// of lessons containing it. 3 × 240 ≈ 180 tokens/Edit — negligible context cost.
|
|
194
|
+
const LESSON_MAX = 240;
|
|
191
195
|
for (const r of allRows) {
|
|
192
196
|
if (r.lesson_learned) {
|
|
193
|
-
const lesson = r.lesson_learned.length >
|
|
194
|
-
? r.lesson_learned.slice(0,
|
|
197
|
+
const lesson = r.lesson_learned.length > LESSON_MAX
|
|
198
|
+
? r.lesson_learned.slice(0, LESSON_MAX - 3) + '...'
|
|
195
199
|
: r.lesson_learned;
|
|
196
200
|
lines.push(` #${r.id} [${r.type}] ${lesson}`);
|
|
197
201
|
} else {
|
|
198
|
-
const title = (r.title || '').length >
|
|
199
|
-
? r.title.slice(0,
|
|
202
|
+
const title = (r.title || '').length > LESSON_MAX
|
|
203
|
+
? r.title.slice(0, LESSON_MAX - 3) + '...'
|
|
200
204
|
: (r.title || '');
|
|
201
205
|
lines.push(` #${r.id} [${r.type}] ${title}`);
|
|
202
206
|
}
|
package/server.mjs
CHANGED
|
@@ -159,7 +159,7 @@ function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset, includ
|
|
|
159
159
|
const mult = multiplier ? ` * ${multiplier}` : '';
|
|
160
160
|
const lowSignalClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
|
|
161
161
|
return `
|
|
162
|
-
SELECT o.id, o.type, o.title, o.subtitle, o.project, o.created_at, o.importance,
|
|
162
|
+
SELECT o.id, o.type, o.title, o.subtitle, o.project, o.created_at, o.created_at_epoch, o.importance,
|
|
163
163
|
o.files_modified,
|
|
164
164
|
${withSnippet ? "snippet(observations_fts, 2, '»', '«', '…', 10) as match_snippet," : ''}
|
|
165
165
|
${scoreExpr}${mult} as score
|
|
@@ -201,7 +201,8 @@ function buildObsFtsParams({ now, projectBoost, ftsQuery, args, epochFrom, epoch
|
|
|
201
201
|
function ftsRowToResult(r, { scoreMultiplier, snippet } = {}) {
|
|
202
202
|
return {
|
|
203
203
|
source: 'obs', id: r.id, type: r.type, title: r.title, subtitle: r.subtitle,
|
|
204
|
-
project: r.project, date: r.created_at,
|
|
204
|
+
project: r.project, date: r.created_at, created_at_epoch: r.created_at_epoch,
|
|
205
|
+
score: scoreMultiplier ? r.score * scoreMultiplier : r.score,
|
|
205
206
|
files_modified: r.files_modified, importance: r.importance, snippet: snippet ? (r.match_snippet || '') : '',
|
|
206
207
|
};
|
|
207
208
|
}
|
|
@@ -312,7 +313,7 @@ function searchObservations(ctx) {
|
|
|
312
313
|
LIMIT ? OFFSET ?
|
|
313
314
|
`).all(...params);
|
|
314
315
|
for (const r of rows) {
|
|
315
|
-
results.push({ source: 'obs', id: r.id, type: r.type, title: r.title, subtitle: r.subtitle, project: r.project, date: r.created_at,
|
|
316
|
+
results.push({ source: 'obs', id: r.id, type: r.type, title: r.title, subtitle: r.subtitle, project: r.project, date: r.created_at, created_at_epoch: r.created_at_epoch, files_modified: r.files_modified, importance: r.importance });
|
|
316
317
|
}
|
|
317
318
|
}
|
|
318
319
|
|
|
@@ -371,7 +372,7 @@ function searchSessions(ctx) {
|
|
|
371
372
|
const now = Date.now();
|
|
372
373
|
const sessionProjectBoost = args.project ? null : currentProject;
|
|
373
374
|
const rows = db.prepare(`
|
|
374
|
-
SELECT s.id, s.request, s.completed, s.project, s.created_at,
|
|
375
|
+
SELECT s.id, s.request, s.completed, s.project, s.created_at, s.created_at_epoch,
|
|
375
376
|
${SESS_BM25}
|
|
376
377
|
* (1.0 + EXP(-0.693 * (? - s.created_at_epoch) / ${RECENCY_HALF_LIFE_MS}.0))
|
|
377
378
|
* (CASE WHEN ? IS NOT NULL AND s.project = ? THEN 2.0 ELSE 1.0 END) as score
|
|
@@ -393,7 +394,7 @@ function searchSessions(ctx) {
|
|
|
393
394
|
perSourceLimit, perSourceOffset
|
|
394
395
|
);
|
|
395
396
|
for (const r of rows) {
|
|
396
|
-
results.push({ source: 'session', id: r.id, request: r.request, completed: r.completed, project: r.project, date: r.created_at, score: r.score });
|
|
397
|
+
results.push({ source: 'session', id: r.id, request: r.request, completed: r.completed, project: r.project, date: r.created_at, created_at_epoch: r.created_at_epoch, score: r.score });
|
|
397
398
|
}
|
|
398
399
|
} else if (!searchType) {
|
|
399
400
|
// Skip sessions in unfiltered no-query mode (too noisy)
|
|
@@ -412,7 +413,7 @@ function searchSessions(ctx) {
|
|
|
412
413
|
LIMIT ? OFFSET ?
|
|
413
414
|
`).all(...params);
|
|
414
415
|
for (const r of rows) {
|
|
415
|
-
results.push({ source: 'session', id: r.id, request: r.request, completed: r.completed, project: r.project, date: r.created_at,
|
|
416
|
+
results.push({ source: 'session', id: r.id, request: r.request, completed: r.completed, project: r.project, date: r.created_at, created_at_epoch: r.created_at_epoch });
|
|
416
417
|
}
|
|
417
418
|
}
|
|
418
419
|
|
|
@@ -425,7 +426,7 @@ function searchPrompts(ctx) {
|
|
|
425
426
|
|
|
426
427
|
if (ftsQuery) {
|
|
427
428
|
const rows = db.prepare(`
|
|
428
|
-
SELECT p.id, p.prompt_text, p.content_session_id, p.created_at,
|
|
429
|
+
SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch,
|
|
429
430
|
bm25(user_prompts_fts, 1) as score
|
|
430
431
|
FROM user_prompts_fts
|
|
431
432
|
JOIN user_prompts p ON user_prompts_fts.rowid = p.id
|
|
@@ -445,7 +446,7 @@ function searchPrompts(ctx) {
|
|
|
445
446
|
perSourceLimit, perSourceOffset
|
|
446
447
|
);
|
|
447
448
|
for (const r of rows) {
|
|
448
|
-
results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, score: r.score });
|
|
449
|
+
results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, created_at_epoch: r.created_at_epoch, score: r.score });
|
|
449
450
|
}
|
|
450
451
|
// CJK LIKE fallback: FTS5 unicode61 can't tokenize CJK substrings in prompts
|
|
451
452
|
if (rows.length === 0 && args.query) {
|
|
@@ -454,7 +455,7 @@ function searchPrompts(ctx) {
|
|
|
454
455
|
const likeConds = cjkPatterns.map(() => 'p.prompt_text LIKE ?');
|
|
455
456
|
const likeParams = cjkPatterns.map(p => `%${p}%`);
|
|
456
457
|
const fallbackRows = db.prepare(`
|
|
457
|
-
SELECT p.id, p.prompt_text, p.content_session_id, p.created_at
|
|
458
|
+
SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch
|
|
458
459
|
FROM user_prompts p
|
|
459
460
|
JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
|
|
460
461
|
WHERE (${likeConds.join(' OR ')})
|
|
@@ -472,7 +473,7 @@ function searchPrompts(ctx) {
|
|
|
472
473
|
perSourceLimit, perSourceOffset
|
|
473
474
|
);
|
|
474
475
|
for (const r of fallbackRows) {
|
|
475
|
-
results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, score: 0 });
|
|
476
|
+
results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, created_at_epoch: r.created_at_epoch, score: 0 });
|
|
476
477
|
}
|
|
477
478
|
}
|
|
478
479
|
}
|
|
@@ -493,7 +494,7 @@ function searchPrompts(ctx) {
|
|
|
493
494
|
LIMIT ? OFFSET ?
|
|
494
495
|
`).all(...params);
|
|
495
496
|
for (const r of rows) {
|
|
496
|
-
results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at,
|
|
497
|
+
results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, created_at_epoch: r.created_at_epoch });
|
|
497
498
|
}
|
|
498
499
|
}
|
|
499
500
|
|
|
@@ -522,7 +523,10 @@ function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, isCros
|
|
|
522
523
|
? `${paginatedResults.length} of ${totalCount}`
|
|
523
524
|
: `${paginatedResults.length}`;
|
|
524
525
|
const hasMixed = paginatedResults.some(r => r.source === 'session' || r.source === 'prompt');
|
|
525
|
-
|
|
526
|
+
// P2-6: empty/omitted query falls through to a "listing recent" path — label it explicitly
|
|
527
|
+
// so callers don't mistake BM25-less results for relevance-ranked ones.
|
|
528
|
+
const qLabel = args.query ? ` for "${args.query}"` : ' (no query — listing recent)';
|
|
529
|
+
lines.push(`Found ${countLabel} result(s)${qLabel}:${hasMixed ? ' (# observation, S# session, P# prompt)' : ''}\n`);
|
|
526
530
|
|
|
527
531
|
for (const r of paginatedResults) {
|
|
528
532
|
if (r.source === 'obs') {
|
|
@@ -627,7 +631,7 @@ server.registerTool(
|
|
|
627
631
|
if (ftsQuery) {
|
|
628
632
|
results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
629
633
|
} else {
|
|
630
|
-
results.sort((a, b) => (b.
|
|
634
|
+
results.sort((a, b) => (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
|
|
631
635
|
}
|
|
632
636
|
}
|
|
633
637
|
|
|
@@ -785,8 +789,11 @@ server.registerTool(
|
|
|
785
789
|
db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
|
|
786
790
|
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
787
791
|
|
|
788
|
-
|
|
789
|
-
|
|
792
|
+
// Auto-scope to anchor's project when caller didn't pass one: "timeline around #N"
|
|
793
|
+
// means same-project context by default; cross-project bleed breaks user mental model.
|
|
794
|
+
const effectiveProject = args.project || anchorRow.project;
|
|
795
|
+
const projectFilter = effectiveProject ? 'AND project = ?' : '';
|
|
796
|
+
const baseParams = effectiveProject ? [effectiveProject] : [];
|
|
790
797
|
|
|
791
798
|
// Before anchor
|
|
792
799
|
const beforeRows = db.prepare(`
|
|
@@ -832,15 +839,17 @@ server.registerTool(
|
|
|
832
839
|
const source = args.source || 'obs';
|
|
833
840
|
const placeholders = args.ids.map(() => '?').join(',');
|
|
834
841
|
|
|
835
|
-
let rows, allFields, prefix;
|
|
842
|
+
let rows, allFields, prefix, sourceLabel;
|
|
836
843
|
if (source === 'session') {
|
|
837
844
|
rows = db.prepare(`SELECT * FROM session_summaries WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...args.ids);
|
|
838
845
|
allFields = ['id', 'request', 'investigated', 'learned', 'completed', 'next_steps', 'files_read', 'files_edited', 'notes', 'project', 'created_at', 'memory_session_id', 'prompt_number'];
|
|
839
846
|
prefix = 'S#';
|
|
847
|
+
sourceLabel = 'sessions';
|
|
840
848
|
} else if (source === 'prompt') {
|
|
841
849
|
rows = db.prepare(`SELECT * FROM user_prompts WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...args.ids);
|
|
842
850
|
allFields = ['id', 'prompt_text', 'content_session_id', 'prompt_number', 'created_at'];
|
|
843
851
|
prefix = 'P#';
|
|
852
|
+
sourceLabel = 'prompts';
|
|
844
853
|
} else {
|
|
845
854
|
// Increment access_count for retrieved observations (batch UPDATE)
|
|
846
855
|
try {
|
|
@@ -852,15 +861,43 @@ server.registerTool(
|
|
|
852
861
|
rows = db.prepare(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...args.ids);
|
|
853
862
|
allFields = ['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'];
|
|
854
863
|
prefix = '#';
|
|
864
|
+
sourceLabel = 'observations';
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// P1-3: validate requested fields — throw on all-invalid so callers don't silently get an
|
|
868
|
+
// empty record (header only). Partial-invalid is tolerated but surfaced as a note.
|
|
869
|
+
let fieldsNote = '';
|
|
870
|
+
if (args.fields?.length) {
|
|
871
|
+
const invalid = args.fields.filter(f => !allFields.includes(f));
|
|
872
|
+
const valid = args.fields.filter(f => allFields.includes(f));
|
|
873
|
+
if (valid.length === 0) {
|
|
874
|
+
throw new Error(`No valid fields. Unknown field(s): ${invalid.join(', ')}. Valid: ${allFields.join(', ')}`);
|
|
875
|
+
}
|
|
876
|
+
if (invalid.length > 0) {
|
|
877
|
+
fieldsNote = `Note: unknown field(s) dropped: ${invalid.join(', ')}. Valid: ${allFields.join(', ')}`;
|
|
878
|
+
}
|
|
855
879
|
}
|
|
856
880
|
|
|
857
881
|
if (rows.length === 0) {
|
|
858
|
-
|
|
882
|
+
// P2-7: for source=session/prompt, check whether the IDs exist as observations so the
|
|
883
|
+
// caller can switch source instead of chasing a phantom miss.
|
|
884
|
+
let hint = '';
|
|
885
|
+
if (source === 'session' || source === 'prompt') {
|
|
886
|
+
try {
|
|
887
|
+
const obsHits = db.prepare(`SELECT id FROM observations WHERE id IN (${placeholders})`).all(...args.ids);
|
|
888
|
+
if (obsHits.length > 0) {
|
|
889
|
+
hint = ` These ID(s) exist as observations: ${obsHits.map(r => r.id).join(', ')}. Try source='obs'.`;
|
|
890
|
+
}
|
|
891
|
+
} catch { /* best-effort hint */ }
|
|
892
|
+
}
|
|
893
|
+
const msg = `No ${sourceLabel} found for given IDs.${hint}`;
|
|
894
|
+
return { content: [{ type: 'text', text: fieldsNote ? `${msg}\n\n${fieldsNote}` : msg }] };
|
|
859
895
|
}
|
|
860
896
|
|
|
861
897
|
const fields = args.fields?.length ? args.fields.filter(f => allFields.includes(f)) : allFields;
|
|
862
898
|
|
|
863
899
|
const parts = [];
|
|
900
|
+
if (fieldsNote) parts.push(fieldsNote);
|
|
864
901
|
for (const row of rows) {
|
|
865
902
|
const lines = [`── ${prefix}${row.id} ──`];
|
|
866
903
|
for (const f of fields) {
|
|
@@ -875,6 +912,13 @@ server.registerTool(
|
|
|
875
912
|
parts.push(lines.join('\n'));
|
|
876
913
|
}
|
|
877
914
|
|
|
915
|
+
// P1-4: surface IDs that weren't found (mirrors mem_delete's missing-ID note).
|
|
916
|
+
const foundIds = new Set(rows.map(r => r.id));
|
|
917
|
+
const missing = args.ids.filter(id => !foundIds.has(id));
|
|
918
|
+
if (missing.length > 0) {
|
|
919
|
+
parts.push(`Note: ID(s) ${missing.join(', ')} not found.`);
|
|
920
|
+
}
|
|
921
|
+
|
|
878
922
|
return { content: [{ type: 'text', text: parts.join('\n\n') }] };
|
|
879
923
|
})
|
|
880
924
|
);
|
|
@@ -1366,11 +1410,43 @@ server.registerTool(
|
|
|
1366
1410
|
}
|
|
1367
1411
|
|
|
1368
1412
|
if (action === 'execute') {
|
|
1369
|
-
const ops = args.operations
|
|
1413
|
+
const ops = args.operations && args.operations.length > 0
|
|
1414
|
+
? args.operations
|
|
1415
|
+
: ['cleanup', 'decay', 'boost'];
|
|
1416
|
+
// T2-P1-A: reject explicit empty array (vs. omitted → defaults above). Empty-array
|
|
1417
|
+
// callers are almost always mistakes; silently running only FTS5 optimize hides the error.
|
|
1418
|
+
if (args.operations && args.operations.length === 0) {
|
|
1419
|
+
return { content: [{ type: 'text', text: 'operations array is empty. Pass a non-empty list (e.g. ["cleanup","decay","boost"]) or omit operations to use the default set.' }], isError: true };
|
|
1420
|
+
}
|
|
1370
1421
|
const results = [];
|
|
1371
1422
|
const staleAge = Date.now() - STALE_AGE_MS;
|
|
1372
1423
|
const OP_ROW_CAP = 1000; // safety cap per operation
|
|
1373
1424
|
|
|
1425
|
+
// T2-P0-A: purge_stale is the only DELETE in this handler. Require confirm=true;
|
|
1426
|
+
// a first call without confirm returns a dry-run preview so callers know the blast radius.
|
|
1427
|
+
const purgeRequested = ops.includes('purge_stale');
|
|
1428
|
+
if (purgeRequested && args.confirm !== true) {
|
|
1429
|
+
const retainDays = args.retain_days ?? 30;
|
|
1430
|
+
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1431
|
+
const previewRow = db.prepare(`
|
|
1432
|
+
SELECT COUNT(*) AS candidates, MIN(created_at_epoch) AS oldest, MAX(created_at_epoch) AS newest
|
|
1433
|
+
FROM observations
|
|
1434
|
+
WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ? ${projectFilter}
|
|
1435
|
+
`).get(retainCutoff, ...baseParams);
|
|
1436
|
+
const lines = [
|
|
1437
|
+
'purge_stale preview (confirm=false):',
|
|
1438
|
+
` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`,
|
|
1439
|
+
];
|
|
1440
|
+
if (previewRow.candidates > 0) {
|
|
1441
|
+
lines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
|
|
1442
|
+
lines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
|
|
1443
|
+
}
|
|
1444
|
+
lines.push('');
|
|
1445
|
+
lines.push('Nothing was deleted. To execute, re-run with confirm=true:');
|
|
1446
|
+
lines.push(` mem_maintain(action="execute", operations=${JSON.stringify(ops)}, confirm=true${args.retain_days ? `, retain_days=${args.retain_days}` : ''}${args.project ? `, project="${args.project}"` : ''})`);
|
|
1447
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1374
1450
|
db.transaction(() => {
|
|
1375
1451
|
if (ops.includes('cleanup')) {
|
|
1376
1452
|
const deleted = db.prepare(`
|
|
@@ -1541,6 +1617,9 @@ server.registerTool(
|
|
|
1541
1617
|
tasks: args.tasks,
|
|
1542
1618
|
maxItems: args.max_items || 15,
|
|
1543
1619
|
force,
|
|
1620
|
+
// T2-P0-B: scope parity with CLI (--scope wide). When omitted, optimizeRun defaults
|
|
1621
|
+
// to narrow via its own code; passing through keeps that fallback intact.
|
|
1622
|
+
reenrichScope: args.scope,
|
|
1544
1623
|
});
|
|
1545
1624
|
|
|
1546
1625
|
const lines = ['🔧 LLM Optimization Results:'];
|
|
@@ -1626,15 +1705,18 @@ server.registerTool(
|
|
|
1626
1705
|
const typeFilter = args.type;
|
|
1627
1706
|
const where = typeFilter ? 'WHERE type = ? AND status = ?' : 'WHERE status = ?';
|
|
1628
1707
|
const params = typeFilter ? [typeFilter, 'active'] : ['active'];
|
|
1708
|
+
// T3-P2-A: order by adoption then recommendation (CLI parity), and coalesce NULL counts
|
|
1709
|
+
// so the output shows "adopt:0" rather than the jarring "adopt:null".
|
|
1629
1710
|
const resources = rdb.prepare(`
|
|
1630
1711
|
SELECT name, type, invocation_name, recommend_count, adopt_count, capability_summary
|
|
1631
|
-
FROM resources ${where}
|
|
1712
|
+
FROM resources ${where}
|
|
1713
|
+
ORDER BY COALESCE(adopt_count, 0) DESC, COALESCE(recommend_count, 0) DESC, type, name
|
|
1632
1714
|
`).all(...params);
|
|
1633
1715
|
|
|
1634
1716
|
if (resources.length === 0) return { content: [{ type: 'text', text: 'No resources found.' }] };
|
|
1635
1717
|
|
|
1636
1718
|
const lines = resources.map(r =>
|
|
1637
|
-
`${r.type === 'skill' ? 'S' : 'A'} ${r.name}${r.invocation_name ? ` (${r.invocation_name})` : ''} — rec:${r.recommend_count} adopt:${r.adopt_count} — ${truncate(r.capability_summary || '', 80)}`
|
|
1719
|
+
`${r.type === 'skill' ? 'S' : 'A'} ${r.name}${r.invocation_name ? ` (${r.invocation_name})` : ''} — rec:${r.recommend_count ?? 0} adopt:${r.adopt_count ?? 0} — ${truncate(r.capability_summary || '', 80)}`
|
|
1638
1720
|
);
|
|
1639
1721
|
return { content: [{ type: 'text', text: `Resources (${resources.length}):\n${lines.join('\n')}` }] };
|
|
1640
1722
|
}
|
|
@@ -1909,19 +1991,29 @@ server.registerTool(
|
|
|
1909
1991
|
wheres.push('superseded_at IS NULL');
|
|
1910
1992
|
if (args.project) { wheres.push('project = ?'); params.push(resolveProject(args.project)); }
|
|
1911
1993
|
if (args.type) { wheres.push('type = ?'); params.push(args.type); }
|
|
1994
|
+
// T3-P1-A: surface invalid dates instead of silently dropping the filter — mirrors
|
|
1995
|
+
// mem_search, which threw. A dropped filter can quietly expand the export blast radius.
|
|
1912
1996
|
if (args.date_from) {
|
|
1913
1997
|
const epoch = new Date(args.date_from).getTime();
|
|
1914
|
-
if (
|
|
1998
|
+
if (isNaN(epoch)) throw new Error(`Invalid date_from: "${args.date_from}" (use ISO 8601 or YYYY-MM-DD)`);
|
|
1999
|
+
wheres.push('created_at_epoch >= ?');
|
|
2000
|
+
params.push(epoch);
|
|
1915
2001
|
}
|
|
1916
2002
|
if (args.date_to) {
|
|
1917
2003
|
const d = args.date_to.length === 10 ? args.date_to + 'T23:59:59.999Z' : args.date_to;
|
|
1918
2004
|
const epoch = new Date(d).getTime();
|
|
1919
|
-
if (
|
|
2005
|
+
if (isNaN(epoch)) throw new Error(`Invalid date_to: "${args.date_to}" (use ISO 8601 or YYYY-MM-DD)`);
|
|
2006
|
+
wheres.push('created_at_epoch <= ?');
|
|
2007
|
+
params.push(epoch);
|
|
1920
2008
|
}
|
|
1921
2009
|
|
|
1922
2010
|
const where = wheres.length > 0 ? 'WHERE ' + wheres.join(' AND ') : '';
|
|
1923
2011
|
const exportLimit = Math.min(args.limit ?? 200, 1000);
|
|
1924
|
-
|
|
2012
|
+
// T3-P2-B: probe limit+1 so we can tell "user hit their own limit with more waiting" from
|
|
2013
|
+
// "user got exactly what existed". Trim to exportLimit before rendering.
|
|
2014
|
+
const probed = db.prepare(`SELECT id, project, type, title, subtitle, narrative, concepts, facts, lesson_learned, importance, files_modified, branch, access_count, memory_session_id, created_at, created_at_epoch FROM observations ${where} ORDER BY created_at_epoch DESC LIMIT ?`).all(...params, exportLimit + 1);
|
|
2015
|
+
const rows = probed.slice(0, exportLimit);
|
|
2016
|
+
const moreAvailable = probed.length > exportLimit;
|
|
1925
2017
|
|
|
1926
2018
|
if (rows.length === 0) return { content: [{ type: 'text', text: 'No observations found matching the criteria.' }] };
|
|
1927
2019
|
|
|
@@ -1929,7 +2021,7 @@ server.registerTool(
|
|
|
1929
2021
|
? rows.map(r => JSON.stringify(r)).join('\n')
|
|
1930
2022
|
: JSON.stringify(rows, null, 2);
|
|
1931
2023
|
|
|
1932
|
-
const cap =
|
|
2024
|
+
const cap = moreAvailable ? `\nNote: Results capped at ${exportLimit}. Use date_from/date_to or increase limit (max 1000) to export more.` : '';
|
|
1933
2025
|
return { content: [{ type: 'text', text: `Exported ${rows.length} observations:${cap}\n${output}` }] };
|
|
1934
2026
|
})
|
|
1935
2027
|
);
|
|
@@ -1988,20 +2080,20 @@ server.registerTool(
|
|
|
1988
2080
|
inputSchema: memFtsCheckSchema,
|
|
1989
2081
|
},
|
|
1990
2082
|
safeHandler(async (args) => {
|
|
2083
|
+
// T3-P2-C: Zod `action: z.enum(['check','rebuild'])` filters any other value before we
|
|
2084
|
+
// reach this handler, so there's no "Unknown action" fallback to write.
|
|
1991
2085
|
if (args.action === 'check') {
|
|
1992
2086
|
const result = checkFTSIntegrity(db);
|
|
1993
2087
|
return { content: [{ type: 'text', text: result.healthy
|
|
1994
2088
|
? 'FTS5 indexes are healthy — all integrity checks passed.'
|
|
1995
2089
|
: `FTS5 issues found:\n${result.details.join('\n')}` }] };
|
|
1996
2090
|
}
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
}
|
|
2004
|
-
return { content: [{ type: 'text', text: `Unknown action: ${args.action}` }], isError: true };
|
|
2091
|
+
// args.action === 'rebuild'
|
|
2092
|
+
const result = rebuildFTS(db);
|
|
2093
|
+
const summary = result.errors.length > 0
|
|
2094
|
+
? `Rebuilt: ${result.rebuilt.join(', ')}. Errors: ${result.errors.join(', ')}`
|
|
2095
|
+
: `Successfully rebuilt: ${result.rebuilt.join(', ')}`;
|
|
2096
|
+
return { content: [{ type: 'text', text: summary }] };
|
|
2005
2097
|
})
|
|
2006
2098
|
);
|
|
2007
2099
|
|
package/tool-schemas.mjs
CHANGED
|
@@ -50,8 +50,8 @@ export const memRecentSchema = {
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
export const memTimelineSchema = {
|
|
53
|
-
anchor: coerceInt.pipe(z.number().int()).optional().describe('Observation ID as center point'),
|
|
54
|
-
query: z.string().optional().describe('FTS5 query to auto-find anchor'),
|
|
53
|
+
anchor: coerceInt.pipe(z.number().int()).optional().describe('Observation ID as center point. Takes precedence over query when both are provided.'),
|
|
54
|
+
query: z.string().optional().describe('FTS5 query to auto-find anchor. Ignored when anchor is also given; use one or the other.'),
|
|
55
55
|
before: coerceInt.pipe(z.number().int().min(0).max(50)).optional().describe('Items before anchor (default 5)'),
|
|
56
56
|
after: coerceInt.pipe(z.number().int().min(0).max(50)).optional().describe('Items after anchor (default 5)'),
|
|
57
57
|
project: z.string().optional().describe('Filter by project'),
|
|
@@ -96,18 +96,22 @@ export const memOptimizeSchema = {
|
|
|
96
96
|
.describe('Which optimization tasks to run (default: all)'),
|
|
97
97
|
max_items: coerceInt.pipe(z.number().int().min(1).max(100)).optional().default(15)
|
|
98
98
|
.describe('Maximum LLM calls across all tasks (default: 15)'),
|
|
99
|
+
scope: z.enum(['narrow', 'wide']).optional().default('narrow')
|
|
100
|
+
.describe("Re-enrich scope: narrow=narrative-only candidates (default); wide=R-7 backfill (bugfix/refactor/feature/decision with narrative but lesson_learned='none'). CLI parity: --scope wide."),
|
|
99
101
|
};
|
|
100
102
|
|
|
101
103
|
export const memMaintainSchema = {
|
|
102
104
|
action: z.enum(['scan', 'execute']).describe('scan=analyze candidates, execute=apply changes'),
|
|
103
105
|
operations: z.array(z.enum(['dedup', 'decay', 'cleanup', 'boost', 'purge_stale', 'rebuild_vectors'])).optional()
|
|
104
|
-
.describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, purge_stale=
|
|
106
|
+
.describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, purge_stale=DELETE pending-purge obs older than retain_days (requires confirm=true; first call previews), rebuild_vectors=rebuild TF-IDF vocabulary and all observation vectors'),
|
|
105
107
|
merge_ids: z.preprocess(
|
|
106
108
|
(v) => Array.isArray(v) ? v.map(g => Array.isArray(g) ? g.map(x => typeof x === 'string' ? parseInt(x, 10) : x) : g) : v,
|
|
107
109
|
z.array(z.array(z.number().int()).min(2))
|
|
108
110
|
).optional().describe('For dedup: [[keepId, removeId1, removeId2], ...] — first ID in each group is kept'),
|
|
109
111
|
retain_days: coerceInt.pipe(z.number().int().min(7).max(365)).optional()
|
|
110
112
|
.describe('For purge_stale: keep observations newer than N days (default 30)'),
|
|
113
|
+
confirm: coerceBool.optional()
|
|
114
|
+
.describe('Required for destructive ops in `execute` mode (currently: purge_stale). Omit/false → dry-run preview; true → actually delete.'),
|
|
111
115
|
project: z.string().optional().describe('Filter by project'),
|
|
112
116
|
};
|
|
113
117
|
|