claude-mem-lite 2.2.0 → 2.2.3
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/dispatch-feedback.mjs +1 -2
- package/dispatch.mjs +116 -43
- package/hook-episode.mjs +11 -1
- package/hook-llm.mjs +17 -2
- package/hook-shared.mjs +14 -0
- package/hook.mjs +12 -7
- package/install.mjs +1 -1
- package/package.json +1 -1
- package/registry-retriever.mjs +2 -1
- package/registry.mjs +24 -2
- package/server.mjs +57 -1
package/dispatch-feedback.mjs
CHANGED
|
@@ -183,13 +183,12 @@ export async function collectFeedback(db, sessionId, sessionEvents = []) {
|
|
|
183
183
|
const invocations = getSessionInvocations(db, sessionId);
|
|
184
184
|
if (invocations.length === 0) return;
|
|
185
185
|
|
|
186
|
-
const outcome = detectOutcome(sessionEvents);
|
|
187
|
-
|
|
188
186
|
for (const inv of invocations) {
|
|
189
187
|
// Skip if already collected (prevents double-collection from stop + session-start)
|
|
190
188
|
if (inv.outcome) continue;
|
|
191
189
|
|
|
192
190
|
const adopted = detectAdoption(inv, sessionEvents);
|
|
191
|
+
const outcome = adopted ? detectOutcome(sessionEvents) : 'ignored';
|
|
193
192
|
const score = adopted ? (outcome === 'success' ? 1.0 : outcome === 'partial' ? 0.5 : 0.2) : 0;
|
|
194
193
|
|
|
195
194
|
// Update invocation record
|
package/dispatch.mjs
CHANGED
|
@@ -5,17 +5,14 @@
|
|
|
5
5
|
// Tier 3: Haiku semantic dispatch (~500ms, only when needed)
|
|
6
6
|
|
|
7
7
|
import { basename, join } from 'path';
|
|
8
|
-
import { existsSync, readFileSync, writeFileSync
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
9
9
|
import { retrieveResources, buildEnhancedQuery, buildQueryFromText, DISPATCH_SYNONYMS } from './registry-retriever.mjs';
|
|
10
10
|
import { renderInjection } from './dispatch-inject.mjs';
|
|
11
11
|
import { updateResourceStats, recordInvocation } from './registry.mjs';
|
|
12
12
|
import { callHaikuJSON } from './haiku-client.mjs';
|
|
13
13
|
import { debugCatch, truncate } from './utils.mjs';
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
// Duplicated from hook-shared.mjs to avoid circular import (dispatch ← hook ← hook-shared)
|
|
17
|
-
const RUNTIME_DIR = join(DB_DIR, 'runtime');
|
|
18
|
-
try { if (!existsSync(RUNTIME_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true }); } catch {}
|
|
14
|
+
import { peekToolEvents, RUNTIME_DIR } from './hook-shared.mjs';
|
|
15
|
+
import { detectActiveSuite, shouldRecommendForStage, detectExplicitRequest, inferCurrentStage } from './dispatch-workflow.mjs';
|
|
19
16
|
|
|
20
17
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
21
18
|
|
|
@@ -28,6 +25,10 @@ const READ_ONLY_TOOLS = new Set([
|
|
|
28
25
|
export const COOLDOWN_MINUTES = 60;
|
|
29
26
|
export const SESSION_RECOMMEND_CAP = 3;
|
|
30
27
|
|
|
28
|
+
// Minimum absolute BM25 composite score to recommend. Typical good matches score 5-50;
|
|
29
|
+
// this filters only near-zero noise matches from incidental text overlap.
|
|
30
|
+
export const BM25_MIN_THRESHOLD = 1.5;
|
|
31
|
+
|
|
31
32
|
// ─── Haiku Circuit Breaker ──────────────────────────────────────────────────
|
|
32
33
|
// Prevents cascading latency when Haiku API is down or slow.
|
|
33
34
|
// After BREAKER_THRESHOLD consecutive failures, disable for BREAKER_RESET_MS.
|
|
@@ -691,26 +692,41 @@ function reRankByKeywords(results, rawKeywords) {
|
|
|
691
692
|
* @param {object[]} results FTS5 results with recommend_count/adopt_count
|
|
692
693
|
* @returns {object[]} Filtered results with decayed scores
|
|
693
694
|
*/
|
|
694
|
-
function applyAdoptionDecay(results) {
|
|
695
|
-
|
|
695
|
+
function applyAdoptionDecay(results, db) {
|
|
696
|
+
const decayed = results.map(r => {
|
|
696
697
|
const recs = r.recommend_count || 0;
|
|
697
698
|
const adopts = r.adopt_count || 0;
|
|
698
699
|
if (recs < 10) return r; // Cold start protection
|
|
699
700
|
|
|
700
701
|
const rate = (adopts + 1) / (recs + 2); // Laplace smoothing
|
|
701
702
|
let multiplier = 1.0;
|
|
702
|
-
if (recs > 100 && rate < 0.01) multiplier = 0;
|
|
703
|
-
else if (recs > 50 && rate < 0.02) multiplier = 0.
|
|
704
|
-
else if (recs > 20 && rate < 0.05) multiplier = 0.
|
|
703
|
+
if (recs > 100 && rate < 0.01) multiplier = 0.05; // Near-block but not permanent
|
|
704
|
+
else if (recs > 50 && rate < 0.02) multiplier = 0.15;
|
|
705
|
+
else if (recs > 20 && rate < 0.05) multiplier = 0.4;
|
|
706
|
+
|
|
707
|
+
// Recent rejection boost: extra penalty for resources rejected many times recently
|
|
708
|
+
if (db && multiplier < 1) {
|
|
709
|
+
try {
|
|
710
|
+
const recentRejects = db.prepare(
|
|
711
|
+
`SELECT COUNT(*) as cnt FROM invocations WHERE resource_id = ? AND adopted = 0 AND created_at > datetime('now', '-7 days')`
|
|
712
|
+
).get(r.id)?.cnt || 0;
|
|
713
|
+
if (recentRejects >= 10) multiplier *= 0.3;
|
|
714
|
+
else if (recentRejects >= 5) multiplier *= 0.5;
|
|
715
|
+
} catch (e) { debugCatch(e, 'applyAdoptionDecay-recentRejects'); }
|
|
716
|
+
}
|
|
705
717
|
|
|
706
|
-
if (multiplier
|
|
718
|
+
if (multiplier < 0.01) return null;
|
|
707
719
|
if (multiplier < 1) {
|
|
708
|
-
// Composite scores are negative (more negative = more relevant).
|
|
709
|
-
// To penalize: multiply by multiplier (<1) to make less negative (worse rank).
|
|
710
720
|
return { ...r, composite_score: (r.composite_score ?? r.relevance) * multiplier, _decayed: true };
|
|
711
721
|
}
|
|
712
722
|
return r;
|
|
713
723
|
}).filter(Boolean);
|
|
724
|
+
// Re-sort after decay: decayed zombies must drop in ranking.
|
|
725
|
+
// BM25 scores are negative (more negative = better match), sort ascending.
|
|
726
|
+
if (decayed.some(r => r._decayed)) {
|
|
727
|
+
decayed.sort((a, b) => (a.composite_score ?? a.relevance) - (b.composite_score ?? b.relevance));
|
|
728
|
+
}
|
|
729
|
+
return decayed;
|
|
714
730
|
}
|
|
715
731
|
|
|
716
732
|
/**
|
|
@@ -722,6 +738,15 @@ function applyAdoptionDecay(results) {
|
|
|
722
738
|
* @returns {object[]} Filtered results that pass the gate
|
|
723
739
|
*/
|
|
724
740
|
function passesConfidenceGate(results, signals) {
|
|
741
|
+
// BM25 absolute minimum: filter out weak text matches regardless of intent.
|
|
742
|
+
// Only apply when enough results exist (BM25 IDF is unreliable with < 3 matches).
|
|
743
|
+
if (results.length >= 3) {
|
|
744
|
+
results = results.filter(r => {
|
|
745
|
+
const score = Math.abs(r.composite_score ?? r.relevance);
|
|
746
|
+
return score >= BM25_MIN_THRESHOLD;
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
|
|
725
750
|
// signals.intent is a comma-separated string (e.g. "test,fix"), not an array
|
|
726
751
|
const intentTokens = typeof signals?.intent === 'string'
|
|
727
752
|
? signals.intent.split(',').filter(Boolean)
|
|
@@ -744,17 +769,41 @@ function passesConfidenceGate(results, signals) {
|
|
|
744
769
|
});
|
|
745
770
|
}
|
|
746
771
|
|
|
772
|
+
// ─── Shared Post-Processing Pipeline ────────────────────────────────────────
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Standard post-processing pipeline for dispatch results.
|
|
776
|
+
* Applies keyword re-ranking, adoption decay, confidence gating, and limit.
|
|
777
|
+
* @param {object[]} results FTS5 results
|
|
778
|
+
* @param {object} signals Context signals
|
|
779
|
+
* @param {object} db Registry database
|
|
780
|
+
* @param {number} [limit=3] Maximum results to return
|
|
781
|
+
* @returns {object[]} Post-processed results
|
|
782
|
+
*/
|
|
783
|
+
function postProcessResults(results, signals, db, limit = 3) {
|
|
784
|
+
results = reRankByKeywords(results, signals.rawKeywords);
|
|
785
|
+
results = applyAdoptionDecay(results, db);
|
|
786
|
+
results = passesConfidenceGate(results, signals);
|
|
787
|
+
return results.slice(0, limit);
|
|
788
|
+
}
|
|
789
|
+
|
|
747
790
|
// ─── Main Dispatch Functions ─────────────────────────────────────────────────
|
|
748
791
|
|
|
749
792
|
/**
|
|
750
793
|
* Dispatch on SessionStart: analyze user prompt, return best resource suggestion.
|
|
794
|
+
* Only dispatches when continuing from a previous session (handoff).
|
|
795
|
+
* Cold starts (no previous session) showed 0% adoption — skip dispatch entirely.
|
|
751
796
|
* @param {Database} db Registry database
|
|
752
797
|
* @param {string} userPrompt User's prompt text
|
|
753
798
|
* @param {string} [sessionId] Session identifier for dedup
|
|
799
|
+
* @param {Object} [options]
|
|
800
|
+
* @param {boolean} [options.hasHandoff=false] Whether a previous session handoff exists
|
|
754
801
|
* @returns {Promise<string|null>} Injection text or null
|
|
755
802
|
*/
|
|
756
|
-
export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
|
|
757
|
-
if (!
|
|
803
|
+
export async function dispatchOnSessionStart(db, userPrompt, sessionId, { hasHandoff = false } = {}) {
|
|
804
|
+
if (!db) return null;
|
|
805
|
+
if (!hasHandoff) return null; // Only dispatch when continuing from a previous session
|
|
806
|
+
if (!userPrompt) return null; // Prompt still required for FTS query
|
|
758
807
|
|
|
759
808
|
try {
|
|
760
809
|
const projectDomains = detectProjectDomains();
|
|
@@ -782,10 +831,7 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
|
|
|
782
831
|
}
|
|
783
832
|
}
|
|
784
833
|
|
|
785
|
-
results =
|
|
786
|
-
results = applyAdoptionDecay(results);
|
|
787
|
-
results = passesConfidenceGate(results, signals);
|
|
788
|
-
results = results.slice(0, 3);
|
|
834
|
+
results = postProcessResults(results, signals, db);
|
|
789
835
|
|
|
790
836
|
let tier = 2;
|
|
791
837
|
|
|
@@ -802,10 +848,7 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
|
|
|
802
848
|
projectDomains,
|
|
803
849
|
});
|
|
804
850
|
if (haikuResults.length > 0) {
|
|
805
|
-
|
|
806
|
-
haikuResults = reRankByKeywords(haikuResults, signals.rawKeywords);
|
|
807
|
-
haikuResults = applyAdoptionDecay(haikuResults);
|
|
808
|
-
haikuResults = passesConfidenceGate(haikuResults, signals);
|
|
851
|
+
haikuResults = postProcessResults(haikuResults, signals, db);
|
|
809
852
|
if (haikuResults.length > 0) results = haikuResults;
|
|
810
853
|
}
|
|
811
854
|
}
|
|
@@ -848,14 +891,46 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
|
|
|
848
891
|
* @param {string} [sessionId] Session identifier for dedup
|
|
849
892
|
* @returns {Promise<string|null>} Injection text or null
|
|
850
893
|
*/
|
|
851
|
-
export async function dispatchOnUserPrompt(db, userPrompt, sessionId) {
|
|
894
|
+
export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionEvents } = {}) {
|
|
852
895
|
if (!userPrompt || !db) return null;
|
|
853
896
|
|
|
854
897
|
try {
|
|
898
|
+
// 1. Explicit request → highest priority, bypass all restrictions
|
|
899
|
+
const explicit = detectExplicitRequest(userPrompt);
|
|
900
|
+
if (explicit.isExplicit) {
|
|
901
|
+
const textQuery = buildQueryFromText(explicit.searchTerm);
|
|
902
|
+
if (textQuery) {
|
|
903
|
+
const explicitResults = retrieveResources(db, textQuery, { limit: 3, projectDomains: detectProjectDomains() });
|
|
904
|
+
if (explicitResults.length > 0) {
|
|
905
|
+
const best = explicitResults[0];
|
|
906
|
+
if (!sessionId || !isRecentlyRecommended(db, best.id, sessionId)) {
|
|
907
|
+
recordInvocation(db, { resource_id: best.id, session_id: sessionId, trigger: 'user_prompt', tier: 1, recommended: 1 });
|
|
908
|
+
updateResourceStats(db, best.id, 'recommend_count');
|
|
909
|
+
return renderInjection(best);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// 2. Suite auto-flow protection
|
|
916
|
+
const events = sessionEvents || peekToolEvents();
|
|
917
|
+
const activeSuite = detectActiveSuite(events);
|
|
918
|
+
|
|
855
919
|
const projectDomains = detectProjectDomains();
|
|
856
920
|
|
|
857
921
|
// Intent-aware enhanced query (column-targeted)
|
|
858
922
|
const signals = extractContextSignals({ tool_name: '_user_prompt' }, { userPrompt });
|
|
923
|
+
|
|
924
|
+
// Check if active suite covers the current stage
|
|
925
|
+
if (activeSuite) {
|
|
926
|
+
const currentStage = inferCurrentStage(signals.primaryIntent, activeSuite);
|
|
927
|
+
if (currentStage) {
|
|
928
|
+
const { shouldRecommend } = shouldRecommendForStage(activeSuite, currentStage);
|
|
929
|
+
if (!shouldRecommend) return null;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// 3. Normal FTS flow
|
|
859
934
|
const enhancedQuery = buildEnhancedQuery(signals);
|
|
860
935
|
|
|
861
936
|
// Fetch extra results when rawKeywords are present — the top-3 by BM25 may be
|
|
@@ -878,25 +953,12 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId) {
|
|
|
878
953
|
}
|
|
879
954
|
}
|
|
880
955
|
|
|
881
|
-
|
|
882
|
-
// match those keywords. "帮我做一下SEO审查" → rawKeywords=["seo"] → SEO audit
|
|
883
|
-
// resources should rank above generic code-review resources.
|
|
884
|
-
results = reRankByKeywords(results, signals.rawKeywords);
|
|
885
|
-
results = applyAdoptionDecay(results);
|
|
886
|
-
results = passesConfidenceGate(results, signals);
|
|
887
|
-
results = results.slice(0, 3);
|
|
956
|
+
results = postProcessResults(results, signals, db);
|
|
888
957
|
|
|
889
958
|
if (results.length === 0) return null;
|
|
890
959
|
|
|
891
|
-
//
|
|
892
|
-
|
|
893
|
-
// close BM25 scores indicate "multiple equally good options in the right domain"
|
|
894
|
-
// rather than "ambiguous/wrong match". Trust the domain match.
|
|
895
|
-
if (needsHaikuDispatch(results)) {
|
|
896
|
-
const hasKeywordMatch = signals.rawKeywords?.length > 0 && results.some(r =>
|
|
897
|
-
signals.rawKeywords.some(kw => (r.intent_tags || '').toLowerCase().includes(kw)));
|
|
898
|
-
if (!hasKeywordMatch) return null;
|
|
899
|
-
}
|
|
960
|
+
// Low confidence → skip (no Haiku in user_prompt path — stay fast)
|
|
961
|
+
if (needsHaikuDispatch(results)) return null;
|
|
900
962
|
|
|
901
963
|
// Filter by cooldown + session dedup (prevents double-recommend with SessionStart)
|
|
902
964
|
const viable = sessionId
|
|
@@ -939,6 +1001,18 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
|
|
|
939
1001
|
|
|
940
1002
|
// Tier 1: Extract context signals
|
|
941
1003
|
const signals = extractContextSignals(event, sessionCtx);
|
|
1004
|
+
|
|
1005
|
+
// Suite protection: if a suite auto-flow is active, suppress recommendations
|
|
1006
|
+
// for stages the suite already covers
|
|
1007
|
+
const events = peekToolEvents();
|
|
1008
|
+
const activeSuite = detectActiveSuite(events);
|
|
1009
|
+
if (activeSuite) {
|
|
1010
|
+
const stage = inferCurrentStage(signals.primaryIntent, activeSuite);
|
|
1011
|
+
if (stage) {
|
|
1012
|
+
const { shouldRecommend } = shouldRecommendForStage(activeSuite, stage);
|
|
1013
|
+
if (!shouldRecommend) return null;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
942
1016
|
const query = buildEnhancedQuery(signals);
|
|
943
1017
|
if (!query) return null;
|
|
944
1018
|
|
|
@@ -946,8 +1020,7 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
|
|
|
946
1020
|
|
|
947
1021
|
// Tier 2: FTS5 retrieval
|
|
948
1022
|
let results = retrieveResources(db, query, { limit: 3, projectDomains });
|
|
949
|
-
results =
|
|
950
|
-
results = passesConfidenceGate(results, signals);
|
|
1023
|
+
results = postProcessResults(results, signals, db);
|
|
951
1024
|
if (results.length === 0) return null;
|
|
952
1025
|
|
|
953
1026
|
const tier = 2; // Tier 3 disabled for PreToolUse — 2s hook timeout insufficient
|
package/hook-episode.mjs
CHANGED
|
@@ -216,13 +216,23 @@ export function mergePendingEntries(episode) {
|
|
|
216
216
|
* @returns {boolean} true if the episode has significant content
|
|
217
217
|
*/
|
|
218
218
|
export function episodeHasSignificantContent(episode) {
|
|
219
|
+
// 1. File edits or Bash errors → always significant
|
|
219
220
|
const hasEditsOrErrors = episode.entries.some(e =>
|
|
220
221
|
EDIT_TOOLS.has(e.tool) ||
|
|
221
222
|
(e.tool === 'Bash' && e.isError)
|
|
222
223
|
);
|
|
223
224
|
if (hasEditsOrErrors) return true;
|
|
224
225
|
|
|
225
|
-
//
|
|
226
|
+
// 2. Important files touched (config, schema, security, migration)
|
|
227
|
+
// Checks episode.files (all touched files, including reads) — catches important-file investigation
|
|
228
|
+
const allFiles = episode.files || [];
|
|
229
|
+
const hasImportantFile = allFiles.some(f =>
|
|
230
|
+
/\.(env|yml|yaml|toml|lock|sql|prisma|proto)$/.test(f) ||
|
|
231
|
+
/(config|schema|migration|auth|security)/i.test(f)
|
|
232
|
+
);
|
|
233
|
+
if (hasImportantFile) return true;
|
|
234
|
+
|
|
235
|
+
// 3. Research pattern: reading many files indicates investigation
|
|
226
236
|
const readCount = episode.entries.filter(e =>
|
|
227
237
|
e.tool === 'Read' || e.tool === 'Grep'
|
|
228
238
|
).length;
|
package/hook-llm.mjs
CHANGED
|
@@ -267,7 +267,7 @@ Error: ${e.isError ? 'yes' : 'no'}
|
|
|
267
267
|
|
|
268
268
|
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}
|
|
269
269
|
Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
|
|
270
|
-
importance: 1=routine, 2=notable (error fix, arch decision, config change), 3=critical (breaking change, security fix, data migration)`;
|
|
270
|
+
importance: 0=not worth saving (pure browsing, trivial query, no learning value), 1=routine, 2=notable (error fix, arch decision, config change), 3=critical (breaking change, security fix, data migration)`;
|
|
271
271
|
} else {
|
|
272
272
|
const actionList = episode.entries.map((e, i) =>
|
|
273
273
|
`${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`
|
|
@@ -282,7 +282,7 @@ ${actionList}
|
|
|
282
282
|
|
|
283
283
|
JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"coherent ≤80 char summary","narrative":"what was done, why, and outcome (3-5 sentences)","concepts":["keyword1","keyword2"],"facts":["specific fact 1","specific fact 2"],"importance":1}
|
|
284
284
|
Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
|
|
285
|
-
importance: 1=routine, 2=notable (error fix, arch decision, config change), 3=critical (breaking change, security fix, data migration)`;
|
|
285
|
+
importance: 0=not worth saving (pure browsing, trivial query, no learning value), 1=routine, 2=notable (error fix, arch decision, config change), 3=critical (breaking change, security fix, data migration)`;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
const ruleImportance = computeRuleImportance(episode);
|
|
@@ -301,6 +301,21 @@ importance: 1=routine, 2=notable (error fix, arch decision, config change), 3=cr
|
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
if (parsed && parsed.title) {
|
|
304
|
+
// Discard if LLM judges observation has no learning value
|
|
305
|
+
if (parsed.importance === 0 || parsed.importance === '0') {
|
|
306
|
+
debugLog('DEBUG', 'llm-episode', `Discarded low-value observation: ${parsed.title}`);
|
|
307
|
+
// If pre-saved, delete it too
|
|
308
|
+
if (episode.savedId) {
|
|
309
|
+
const ddb = openDb();
|
|
310
|
+
if (ddb) {
|
|
311
|
+
try { ddb.prepare('DELETE FROM observations WHERE id = ?').run(episode.savedId); }
|
|
312
|
+
finally { ddb.close(); }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
try { unlinkSync(tmpFile); } catch {}
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
304
319
|
obs = {
|
|
305
320
|
type: validTypes.has(parsed.type) ? parsed.type : 'change',
|
|
306
321
|
title: truncate(parsed.title, 120),
|
package/hook-shared.mjs
CHANGED
|
@@ -179,6 +179,20 @@ export function readAndClearToolEvents() {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Read tracked tool events WITHOUT clearing the file.
|
|
184
|
+
* Used by dispatch to detect active suites mid-session.
|
|
185
|
+
* @returns {object[]} Array of tool event objects
|
|
186
|
+
*/
|
|
187
|
+
export function peekToolEvents() {
|
|
188
|
+
try {
|
|
189
|
+
const raw = readFileSync(toolEventsFile(), 'utf8');
|
|
190
|
+
return raw.trim().split('\n').filter(Boolean).map(line => {
|
|
191
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
192
|
+
}).filter(Boolean);
|
|
193
|
+
} catch { return []; }
|
|
194
|
+
}
|
|
195
|
+
|
|
182
196
|
/**
|
|
183
197
|
* Extract partial response from CLI error output (timeout/error recovery).
|
|
184
198
|
* @param {Error} error The caught error from execFileSync
|
package/hook.mjs
CHANGED
|
@@ -369,9 +369,12 @@ async function handleStop() {
|
|
|
369
369
|
// Always clear event file to prevent stale events accumulating if registry DB is unavailable.
|
|
370
370
|
try {
|
|
371
371
|
const sessionEvents = readAndClearToolEvents();
|
|
372
|
-
|
|
373
|
-
if (
|
|
374
|
-
|
|
372
|
+
// Skip feedback for zero-interaction sessions (no tool events = no meaningful signal)
|
|
373
|
+
if (sessionEvents.length > 0) {
|
|
374
|
+
const rdb = getRegistryDb();
|
|
375
|
+
if (rdb) {
|
|
376
|
+
await collectFeedback(rdb, sessionId, sessionEvents);
|
|
377
|
+
}
|
|
375
378
|
}
|
|
376
379
|
} catch (e) { debugCatch(e, 'handleStop-feedback'); }
|
|
377
380
|
|
|
@@ -428,7 +431,7 @@ async function handleSessionStart() {
|
|
|
428
431
|
|
|
429
432
|
// ── DB mutations in a transaction (crash-safe consistency) ──
|
|
430
433
|
const staleSessionCutoff = Date.now() - STALE_SESSION_MS;
|
|
431
|
-
const autoCompressAge = Date.now() -
|
|
434
|
+
const autoCompressAge = Date.now() - 30 * 86400000; // 30 days (accelerated from 90)
|
|
432
435
|
|
|
433
436
|
db.transaction(() => {
|
|
434
437
|
// Ensure session exists in DB (INSERT OR IGNORE avoids race condition)
|
|
@@ -451,7 +454,7 @@ async function handleSessionStart() {
|
|
|
451
454
|
WHERE status = 'active' AND started_at_epoch < ?
|
|
452
455
|
`).run(staleSessionCutoff);
|
|
453
456
|
|
|
454
|
-
// Auto-compress: mark old low-importance observations as compressed (
|
|
457
|
+
// Auto-compress: mark old low-importance observations as compressed (30+ days, importance=1)
|
|
455
458
|
// Lightweight: only marks rows, doesn't create summaries (full compression via mem_compress)
|
|
456
459
|
const compressed = db.prepare(`
|
|
457
460
|
UPDATE observations SET compressed_into = -1
|
|
@@ -675,8 +678,10 @@ async function handleSessionStart() {
|
|
|
675
678
|
try {
|
|
676
679
|
const rdb = getRegistryDb();
|
|
677
680
|
if (rdb && hasInjectionBudget()) {
|
|
678
|
-
|
|
679
|
-
const
|
|
681
|
+
// Build prompt context with fallbacks: next_steps → request → completed (empty = no dispatch)
|
|
682
|
+
const promptCtx = latestSummary?.next_steps || latestSummary?.request || latestSummary?.completed || '';
|
|
683
|
+
const hasHandoff = !!prevSessionId; // prevSessionId set when /clear or rapid restart detected
|
|
684
|
+
const dispatchResult = await dispatchOnSessionStart(rdb, promptCtx, sessionId, { hasHandoff });
|
|
680
685
|
if (dispatchResult) {
|
|
681
686
|
process.stdout.write(dispatchResult + '\n');
|
|
682
687
|
incrementInjection();
|
package/install.mjs
CHANGED
|
@@ -1217,7 +1217,7 @@ async function install() {
|
|
|
1217
1217
|
'haiku-client.mjs', 'utils.mjs', 'schema.mjs', 'package.json', 'skill.md',
|
|
1218
1218
|
'registry.mjs', 'registry-scanner.mjs', 'registry-indexer.mjs',
|
|
1219
1219
|
'registry-retriever.mjs', 'resource-discovery.mjs',
|
|
1220
|
-
'dispatch.mjs', 'dispatch-inject.mjs', 'dispatch-feedback.mjs',
|
|
1220
|
+
'dispatch.mjs', 'dispatch-inject.mjs', 'dispatch-feedback.mjs', 'dispatch-workflow.mjs',
|
|
1221
1221
|
];
|
|
1222
1222
|
|
|
1223
1223
|
if (IS_DEV) {
|
package/package.json
CHANGED
package/registry-retriever.mjs
CHANGED
|
@@ -196,8 +196,9 @@ export function buildEnhancedQuery(signals) {
|
|
|
196
196
|
// would dilute BM25 precision. Literal matching is sufficient — "seo" matches "seo"
|
|
197
197
|
// directly across name, intent_tags, capability_summary, trigger_patterns.
|
|
198
198
|
if (signals.rawKeywords?.length > 0) {
|
|
199
|
+
const isSafe = t => /^[a-zA-Z0-9]+$/.test(t) || /[\u4e00-\u9fff\u3400-\u4dbf]/.test(t);
|
|
199
200
|
for (const kw of signals.rawKeywords) {
|
|
200
|
-
const safeKw =
|
|
201
|
+
const safeKw = isSafe(kw) ? kw : `"${kw.replace(/"/g, '""')}"`;
|
|
201
202
|
parts.push(`intent_tags:${safeKw}`);
|
|
202
203
|
parts.push(safeKw);
|
|
203
204
|
}
|
package/registry.mjs
CHANGED
|
@@ -104,7 +104,7 @@ const INVOCATIONS_SCHEMA = `
|
|
|
104
104
|
tier INTEGER CHECK(tier IN (1,2,3)),
|
|
105
105
|
recommended INTEGER DEFAULT 1,
|
|
106
106
|
adopted INTEGER DEFAULT 0,
|
|
107
|
-
outcome TEXT CHECK(outcome IN ('success','partial','failure','skipped',NULL)
|
|
107
|
+
outcome TEXT CHECK(outcome IN ('success','partial','failure','skipped','ignored') OR outcome IS NULL),
|
|
108
108
|
score REAL,
|
|
109
109
|
created_at TEXT DEFAULT (datetime('now'))
|
|
110
110
|
);
|
|
@@ -161,7 +161,7 @@ export function ensureRegistryDb(dbPath) {
|
|
|
161
161
|
if (!cols.some(c => c.name === 'invocation_name')) {
|
|
162
162
|
db.exec("ALTER TABLE resources ADD COLUMN invocation_name TEXT DEFAULT ''");
|
|
163
163
|
}
|
|
164
|
-
} catch {}
|
|
164
|
+
} catch (e) { debugCatch(e, 'invocation_name-migration'); }
|
|
165
165
|
|
|
166
166
|
// FTS5 + triggers: only create if not exists
|
|
167
167
|
const hasFts = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='resources_fts'`).get();
|
|
@@ -192,11 +192,33 @@ export function ensureRegistryDb(dbPath) {
|
|
|
192
192
|
}
|
|
193
193
|
} catch (e) { debugCatch(e, 'ensureRegistryDb-migration'); }
|
|
194
194
|
|
|
195
|
+
// Migrate invocations CHECK constraint: add 'ignored' outcome value
|
|
196
|
+
try {
|
|
197
|
+
const schema = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='invocations'`).get();
|
|
198
|
+
if (schema?.sql && !schema.sql.includes("'ignored'")) {
|
|
199
|
+
db.transaction(() => {
|
|
200
|
+
const hasOld = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='invocations_old'`).get();
|
|
201
|
+
if (hasOld) db.exec(`DROP TABLE invocations_old`);
|
|
202
|
+
db.exec(`ALTER TABLE invocations RENAME TO invocations_old`);
|
|
203
|
+
db.exec(INVOCATIONS_SCHEMA);
|
|
204
|
+
db.exec(`INSERT INTO invocations
|
|
205
|
+
(id, resource_id, session_id, trigger, tier, recommended, adopted, outcome, score, created_at)
|
|
206
|
+
SELECT id, resource_id, session_id, trigger, tier, recommended, adopted, outcome, score, created_at
|
|
207
|
+
FROM invocations_old`);
|
|
208
|
+
db.exec(`DROP TABLE invocations_old`);
|
|
209
|
+
})();
|
|
210
|
+
}
|
|
211
|
+
} catch (e) { debugCatch(e, 'ensureRegistryDb-ignored-migration'); }
|
|
212
|
+
|
|
195
213
|
db.exec(PREINSTALLED_SCHEMA);
|
|
196
214
|
|
|
197
215
|
return db;
|
|
198
216
|
}
|
|
199
217
|
|
|
218
|
+
// ─── Exported Schema (for test-helpers.mjs) ─────────────────────────────────
|
|
219
|
+
|
|
220
|
+
export { RESOURCES_SCHEMA, FTS5_SCHEMA, TRIGGERS_SCHEMA, INVOCATIONS_SCHEMA, PREINSTALLED_SCHEMA };
|
|
221
|
+
|
|
200
222
|
// ─── Resource CRUD ───────────────────────────────────────────────────────────
|
|
201
223
|
|
|
202
224
|
const UPSERT_SQL = `
|
package/server.mjs
CHANGED
|
@@ -61,7 +61,7 @@ const RECENCY_HALF_LIFE_MS = 1209600000; // 14 days in milliseconds
|
|
|
61
61
|
// ─── MCP Server ─────────────────────────────────────────────────────────────
|
|
62
62
|
|
|
63
63
|
const server = new McpServer(
|
|
64
|
-
{ name: 'claude-mem-lite', version: '2.
|
|
64
|
+
{ name: 'claude-mem-lite', version: '2.2.2' },
|
|
65
65
|
{
|
|
66
66
|
instructions: [
|
|
67
67
|
'Proactively search memory to leverage past experience. This is your long-term memory across sessions.',
|
|
@@ -83,9 +83,15 @@ const server = new McpServer(
|
|
|
83
83
|
},
|
|
84
84
|
);
|
|
85
85
|
|
|
86
|
+
// Track MCP request activity for idle-time cleanup (see idle timer below)
|
|
87
|
+
let lastMcpRequestTime = Date.now();
|
|
88
|
+
let idleCleanupRan = false;
|
|
89
|
+
|
|
86
90
|
function safeHandler(fn) {
|
|
87
91
|
return async (args, extra) => {
|
|
88
92
|
try {
|
|
93
|
+
lastMcpRequestTime = Date.now();
|
|
94
|
+
idleCleanupRan = false;
|
|
89
95
|
return await fn(args, extra);
|
|
90
96
|
} catch (err) {
|
|
91
97
|
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
@@ -980,10 +986,60 @@ const walTimer = setInterval(() => {
|
|
|
980
986
|
}, WAL_CHECKPOINT_INTERVAL);
|
|
981
987
|
walTimer.unref(); // Don't keep process alive just for checkpoints
|
|
982
988
|
|
|
989
|
+
// ─── Idle-Time Memory Optimization ──────────────────────────────────────────
|
|
990
|
+
// When no MCP requests for 5 minutes, run lightweight DB maintenance.
|
|
991
|
+
// lastMcpRequestTime and idleCleanupRan are declared near safeHandler (which updates them).
|
|
992
|
+
|
|
993
|
+
const IDLE_THRESHOLD_MS = 5 * 60 * 1000;
|
|
994
|
+
|
|
995
|
+
const idleTimer = setInterval(() => {
|
|
996
|
+
if (idleCleanupRan) return;
|
|
997
|
+
if (Date.now() - lastMcpRequestTime < IDLE_THRESHOLD_MS) return;
|
|
998
|
+
idleCleanupRan = true;
|
|
999
|
+
|
|
1000
|
+
try {
|
|
1001
|
+
const thirtyDaysAgo = Date.now() - 30 * 86400000;
|
|
1002
|
+
|
|
1003
|
+
db.transaction(() => {
|
|
1004
|
+
// Delete old low-quality observations (importance<=1, never accessed, 30+ days).
|
|
1005
|
+
// NOTE: no project filter — MCP server is global, operates across all projects.
|
|
1006
|
+
// This is intentionally broader than hook.mjs auto-compress (which scopes to current project).
|
|
1007
|
+
const deleted = db.prepare(`
|
|
1008
|
+
DELETE FROM observations
|
|
1009
|
+
WHERE importance <= 1 AND COALESCE(access_count, 0) = 0
|
|
1010
|
+
AND created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0
|
|
1011
|
+
`).run(thirtyDaysAgo);
|
|
1012
|
+
if (deleted.changes > 0) {
|
|
1013
|
+
debugLog('INFO', 'idle-cleanup', `Deleted ${deleted.changes} stale low-quality observations`);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Mark old importance=1 as compressed (30+ days)
|
|
1017
|
+
// NOTE: compressed_into = -1 is an established sentinel meaning "auto-compressed without merge target"
|
|
1018
|
+
// (same pattern used in hook.mjs:456 for time-based compression)
|
|
1019
|
+
const compressed = db.prepare(`
|
|
1020
|
+
UPDATE observations SET compressed_into = -1
|
|
1021
|
+
WHERE COALESCE(compressed_into, 0) = 0 AND importance = 1
|
|
1022
|
+
AND created_at_epoch < ?
|
|
1023
|
+
`).run(thirtyDaysAgo);
|
|
1024
|
+
if (compressed.changes > 0) {
|
|
1025
|
+
debugLog('INFO', 'idle-cleanup', `Compressed ${compressed.changes} old observations`);
|
|
1026
|
+
}
|
|
1027
|
+
})();
|
|
1028
|
+
|
|
1029
|
+
// FTS5 index optimization (outside transaction — WAL-friendly)
|
|
1030
|
+
db.exec("INSERT INTO observations_fts(observations_fts) VALUES('optimize')");
|
|
1031
|
+
debugLog('DEBUG', 'idle-cleanup', 'FTS5 optimize complete');
|
|
1032
|
+
} catch (e) {
|
|
1033
|
+
debugCatch(e, 'idle-cleanup');
|
|
1034
|
+
}
|
|
1035
|
+
}, 60000); // Check every minute
|
|
1036
|
+
idleTimer.unref();
|
|
1037
|
+
|
|
983
1038
|
// ─── Shutdown Cleanup ────────────────────────────────────────────────────────
|
|
984
1039
|
|
|
985
1040
|
function shutdown(exitCode = 0) {
|
|
986
1041
|
clearInterval(walTimer);
|
|
1042
|
+
clearInterval(idleTimer);
|
|
987
1043
|
try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch {}
|
|
988
1044
|
try { db.close(); } catch {}
|
|
989
1045
|
process.exit(exitCode);
|