claude-mem-lite 2.1.6 → 2.2.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/plugin.json +1 -1
- package/dispatch-feedback.mjs +1 -2
- package/dispatch-inject.mjs +1 -1
- package/dispatch.mjs +95 -33
- package/hook-context.mjs +1 -1
- package/hook-episode.mjs +20 -2
- package/hook-llm.mjs +74 -21
- package/hook-shared.mjs +14 -0
- package/hook.mjs +19 -41
- package/install.mjs +1 -1
- package/package.json +1 -1
- package/registry-indexer.mjs +3 -3
- package/registry-retriever.mjs +24 -19
- package/registry.mjs +25 -5
- package/schema.mjs +13 -3
- package/scripts/post-tool-use.sh +1 -1
- package/server.mjs +55 -1
- package/utils.mjs +4 -5
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-inject.mjs
CHANGED
|
@@ -24,7 +24,7 @@ const ALLOWED_BASES = [
|
|
|
24
24
|
|
|
25
25
|
function isAllowedPath(filePath) {
|
|
26
26
|
if (!filePath) return false;
|
|
27
|
-
return ALLOWED_BASES.some(base => filePath.startsWith(base));
|
|
27
|
+
return ALLOWED_BASES.some(base => filePath === base || filePath.startsWith(base + '/'));
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// ─── Template Detection ──────────────────────────────────────────────────────
|
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.
|
|
@@ -494,7 +495,7 @@ function inferTechFromPrompt(prompt) {
|
|
|
494
495
|
[/\b(typescript|ts)\b/i, 'typescript'],
|
|
495
496
|
[/\b(python|django|flask|fastapi)\b/i, 'python'],
|
|
496
497
|
[/\b(rust|cargo)\b/i, 'rust'],
|
|
497
|
-
[/\b(golang|go\s
|
|
498
|
+
[/\b(golang|go\s+(?:build|test|run|get|mod|install|fmt|vet|generate|clean|work|tool))\b/i, 'go'],
|
|
498
499
|
[/\b(java|spring|maven|gradle)\b/i, 'java'],
|
|
499
500
|
[/\b(ruby|rails)\b/i, 'ruby'],
|
|
500
501
|
[/\b(php|laravel|symfony)\b/i, 'php'],
|
|
@@ -645,7 +646,7 @@ export function isRecentlyRecommended(db, resourceId, sessionId) {
|
|
|
645
646
|
|
|
646
647
|
// Already recommended in this session (session dedup)
|
|
647
648
|
const sessionHit = db.prepare(
|
|
648
|
-
'SELECT 1 FROM invocations WHERE resource_id = ? AND session_id = ? LIMIT 1'
|
|
649
|
+
'SELECT 1 FROM invocations WHERE resource_id = ? AND session_id = ? AND recommended = 1 LIMIT 1'
|
|
649
650
|
).get(resourceId, sessionId);
|
|
650
651
|
if (sessionHit) return true;
|
|
651
652
|
}
|
|
@@ -691,7 +692,7 @@ 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
|
+
function applyAdoptionDecay(results, db) {
|
|
695
696
|
return results.map(r => {
|
|
696
697
|
const recs = r.recommend_count || 0;
|
|
697
698
|
const adopts = r.adopt_count || 0;
|
|
@@ -699,15 +700,24 @@ function applyAdoptionDecay(results) {
|
|
|
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
|
-
|
|
709
|
-
// To penalize: divide by multiplier to make less negative (worse rank).
|
|
710
|
-
return { ...r, relevance: r.relevance / multiplier, _decayed: true };
|
|
720
|
+
return { ...r, composite_score: (r.composite_score ?? r.relevance) * multiplier, _decayed: true };
|
|
711
721
|
}
|
|
712
722
|
return r;
|
|
713
723
|
}).filter(Boolean);
|
|
@@ -722,6 +732,15 @@ function applyAdoptionDecay(results) {
|
|
|
722
732
|
* @returns {object[]} Filtered results that pass the gate
|
|
723
733
|
*/
|
|
724
734
|
function passesConfidenceGate(results, signals) {
|
|
735
|
+
// BM25 absolute minimum: filter out weak text matches regardless of intent.
|
|
736
|
+
// Only apply when enough results exist (BM25 IDF is unreliable with < 3 matches).
|
|
737
|
+
if (results.length >= 3) {
|
|
738
|
+
results = results.filter(r => {
|
|
739
|
+
const score = Math.abs(r.composite_score ?? r.relevance);
|
|
740
|
+
return score >= BM25_MIN_THRESHOLD;
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
725
744
|
// signals.intent is a comma-separated string (e.g. "test,fix"), not an array
|
|
726
745
|
const intentTokens = typeof signals?.intent === 'string'
|
|
727
746
|
? signals.intent.split(',').filter(Boolean)
|
|
@@ -748,13 +767,19 @@ function passesConfidenceGate(results, signals) {
|
|
|
748
767
|
|
|
749
768
|
/**
|
|
750
769
|
* Dispatch on SessionStart: analyze user prompt, return best resource suggestion.
|
|
770
|
+
* Only dispatches when continuing from a previous session (handoff).
|
|
771
|
+
* Cold starts (no previous session) showed 0% adoption — skip dispatch entirely.
|
|
751
772
|
* @param {Database} db Registry database
|
|
752
773
|
* @param {string} userPrompt User's prompt text
|
|
753
774
|
* @param {string} [sessionId] Session identifier for dedup
|
|
775
|
+
* @param {Object} [options]
|
|
776
|
+
* @param {boolean} [options.hasHandoff=false] Whether a previous session handoff exists
|
|
754
777
|
* @returns {Promise<string|null>} Injection text or null
|
|
755
778
|
*/
|
|
756
|
-
export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
|
|
757
|
-
if (!
|
|
779
|
+
export async function dispatchOnSessionStart(db, userPrompt, sessionId, { hasHandoff = false } = {}) {
|
|
780
|
+
if (!db) return null;
|
|
781
|
+
if (!hasHandoff) return null; // Only dispatch when continuing from a previous session
|
|
782
|
+
if (!userPrompt) return null; // Prompt still required for FTS query
|
|
758
783
|
|
|
759
784
|
try {
|
|
760
785
|
const projectDomains = detectProjectDomains();
|
|
@@ -783,7 +808,7 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
|
|
|
783
808
|
}
|
|
784
809
|
|
|
785
810
|
results = reRankByKeywords(results, signals.rawKeywords);
|
|
786
|
-
results = applyAdoptionDecay(results);
|
|
811
|
+
results = applyAdoptionDecay(results, db);
|
|
787
812
|
results = passesConfidenceGate(results, signals);
|
|
788
813
|
results = results.slice(0, 3);
|
|
789
814
|
|
|
@@ -796,7 +821,7 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
|
|
|
796
821
|
if (haikuResult?.query) {
|
|
797
822
|
const haikuQuery = buildQueryFromText(haikuResult.query);
|
|
798
823
|
if (haikuQuery) {
|
|
799
|
-
|
|
824
|
+
let haikuResults = retrieveResources(db, haikuQuery, {
|
|
800
825
|
type: haikuResult.type === 'either' ? undefined : haikuResult.type,
|
|
801
826
|
limit: 3,
|
|
802
827
|
projectDomains,
|
|
@@ -804,7 +829,7 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
|
|
|
804
829
|
if (haikuResults.length > 0) {
|
|
805
830
|
// Apply same post-processing as Tier2 to prevent zombie/low-confidence bypass
|
|
806
831
|
haikuResults = reRankByKeywords(haikuResults, signals.rawKeywords);
|
|
807
|
-
haikuResults = applyAdoptionDecay(haikuResults);
|
|
832
|
+
haikuResults = applyAdoptionDecay(haikuResults, db);
|
|
808
833
|
haikuResults = passesConfidenceGate(haikuResults, signals);
|
|
809
834
|
if (haikuResults.length > 0) results = haikuResults;
|
|
810
835
|
}
|
|
@@ -848,14 +873,46 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
|
|
|
848
873
|
* @param {string} [sessionId] Session identifier for dedup
|
|
849
874
|
* @returns {Promise<string|null>} Injection text or null
|
|
850
875
|
*/
|
|
851
|
-
export async function dispatchOnUserPrompt(db, userPrompt, sessionId) {
|
|
876
|
+
export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionEvents } = {}) {
|
|
852
877
|
if (!userPrompt || !db) return null;
|
|
853
878
|
|
|
854
879
|
try {
|
|
880
|
+
// 1. Explicit request → highest priority, bypass all restrictions
|
|
881
|
+
const explicit = detectExplicitRequest(userPrompt);
|
|
882
|
+
if (explicit.isExplicit) {
|
|
883
|
+
const textQuery = buildQueryFromText(explicit.searchTerm);
|
|
884
|
+
if (textQuery) {
|
|
885
|
+
const explicitResults = retrieveResources(db, textQuery, { limit: 3, projectDomains: detectProjectDomains() });
|
|
886
|
+
if (explicitResults.length > 0) {
|
|
887
|
+
const best = explicitResults[0];
|
|
888
|
+
if (!sessionId || !isRecentlyRecommended(db, best.id, sessionId)) {
|
|
889
|
+
recordInvocation(db, { resource_id: best.id, session_id: sessionId, trigger: 'user_prompt', tier: 1, recommended: 1 });
|
|
890
|
+
updateResourceStats(db, best.id, 'recommend_count');
|
|
891
|
+
return renderInjection(best);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// 2. Suite auto-flow protection
|
|
898
|
+
const events = sessionEvents || peekToolEvents();
|
|
899
|
+
const activeSuite = detectActiveSuite(events);
|
|
900
|
+
|
|
855
901
|
const projectDomains = detectProjectDomains();
|
|
856
902
|
|
|
857
903
|
// Intent-aware enhanced query (column-targeted)
|
|
858
904
|
const signals = extractContextSignals({ tool_name: '_user_prompt' }, { userPrompt });
|
|
905
|
+
|
|
906
|
+
// Check if active suite covers the current stage
|
|
907
|
+
if (activeSuite) {
|
|
908
|
+
const currentStage = inferCurrentStage(signals.primaryIntent, activeSuite);
|
|
909
|
+
if (currentStage) {
|
|
910
|
+
const { shouldRecommend } = shouldRecommendForStage(activeSuite, currentStage);
|
|
911
|
+
if (!shouldRecommend) return null;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// 3. Normal FTS flow
|
|
859
916
|
const enhancedQuery = buildEnhancedQuery(signals);
|
|
860
917
|
|
|
861
918
|
// Fetch extra results when rawKeywords are present — the top-3 by BM25 may be
|
|
@@ -882,21 +939,14 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId) {
|
|
|
882
939
|
// match those keywords. "帮我做一下SEO审查" → rawKeywords=["seo"] → SEO audit
|
|
883
940
|
// resources should rank above generic code-review resources.
|
|
884
941
|
results = reRankByKeywords(results, signals.rawKeywords);
|
|
885
|
-
results = applyAdoptionDecay(results);
|
|
942
|
+
results = applyAdoptionDecay(results, db);
|
|
886
943
|
results = passesConfidenceGate(results, signals);
|
|
887
944
|
results = results.slice(0, 3);
|
|
888
945
|
|
|
889
946
|
if (results.length === 0) return null;
|
|
890
947
|
|
|
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
|
-
}
|
|
948
|
+
// Low confidence → skip (no Haiku in user_prompt path — stay fast)
|
|
949
|
+
if (needsHaikuDispatch(results)) return null;
|
|
900
950
|
|
|
901
951
|
// Filter by cooldown + session dedup (prevents double-recommend with SessionStart)
|
|
902
952
|
const viable = sessionId
|
|
@@ -939,6 +989,18 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
|
|
|
939
989
|
|
|
940
990
|
// Tier 1: Extract context signals
|
|
941
991
|
const signals = extractContextSignals(event, sessionCtx);
|
|
992
|
+
|
|
993
|
+
// Suite protection: if a suite auto-flow is active, suppress recommendations
|
|
994
|
+
// for stages the suite already covers
|
|
995
|
+
const events = peekToolEvents();
|
|
996
|
+
const activeSuite = detectActiveSuite(events);
|
|
997
|
+
if (activeSuite) {
|
|
998
|
+
const stage = inferCurrentStage(signals.primaryIntent, activeSuite);
|
|
999
|
+
if (stage) {
|
|
1000
|
+
const { shouldRecommend } = shouldRecommendForStage(activeSuite, stage);
|
|
1001
|
+
if (!shouldRecommend) return null;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
942
1004
|
const query = buildEnhancedQuery(signals);
|
|
943
1005
|
if (!query) return null;
|
|
944
1006
|
|
|
@@ -946,7 +1008,7 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
|
|
|
946
1008
|
|
|
947
1009
|
// Tier 2: FTS5 retrieval
|
|
948
1010
|
let results = retrieveResources(db, query, { limit: 3, projectDomains });
|
|
949
|
-
results = applyAdoptionDecay(results);
|
|
1011
|
+
results = applyAdoptionDecay(results, db);
|
|
950
1012
|
results = passesConfidenceGate(results, signals);
|
|
951
1013
|
if (results.length === 0) return null;
|
|
952
1014
|
|
package/hook-context.mjs
CHANGED
|
@@ -154,7 +154,7 @@ export function updateClaudeMd(contextBlock) {
|
|
|
154
154
|
const startIdx = content.indexOf(startTag);
|
|
155
155
|
const endIdx = content.indexOf(endTag);
|
|
156
156
|
|
|
157
|
-
if (startIdx !== -1 && endIdx !== -1) {
|
|
157
|
+
if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
|
|
158
158
|
// Replace existing section in-place — preserves surrounding content (including hint if present)
|
|
159
159
|
content = content.slice(0, startIdx) + newSection + content.slice(endIdx + endTag.length);
|
|
160
160
|
} else if (content.length > 0) {
|
package/hook-episode.mjs
CHANGED
|
@@ -210,13 +210,31 @@ export function mergePendingEntries(episode) {
|
|
|
210
210
|
|
|
211
211
|
/**
|
|
212
212
|
* Check if an episode has significant content worth processing with LLM.
|
|
213
|
-
* Significant = contains file edits or
|
|
213
|
+
* Significant = contains file edits, Bash errors, or a review/research pattern
|
|
214
|
+
* (5+ Read/Grep entries indicate investigation worth recording).
|
|
214
215
|
* @param {object} episode The episode to check
|
|
215
216
|
* @returns {boolean} true if the episode has significant content
|
|
216
217
|
*/
|
|
217
218
|
export function episodeHasSignificantContent(episode) {
|
|
218
|
-
|
|
219
|
+
// 1. File edits or Bash errors → always significant
|
|
220
|
+
const hasEditsOrErrors = episode.entries.some(e =>
|
|
219
221
|
EDIT_TOOLS.has(e.tool) ||
|
|
220
222
|
(e.tool === 'Bash' && e.isError)
|
|
221
223
|
);
|
|
224
|
+
if (hasEditsOrErrors) return true;
|
|
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
|
|
236
|
+
const readCount = episode.entries.filter(e =>
|
|
237
|
+
e.tool === 'Read' || e.tool === 'Grep'
|
|
238
|
+
).length;
|
|
239
|
+
return readCount >= 5;
|
|
222
240
|
}
|
package/hook-llm.mjs
CHANGED
|
@@ -183,6 +183,47 @@ export function buildDegradedTitle(episode) {
|
|
|
183
183
|
return desc.replace(/ → (?:ERROR: )?\{.*$/, hasError ? ' (error)' : '');
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Build a rule-based observation from episode metadata for immediate DB persistence.
|
|
188
|
+
* Used as pre-save (before LLM) and as fallback when LLM is unavailable.
|
|
189
|
+
* @param {object} episode Episode with entries, files, filesRead arrays
|
|
190
|
+
* @returns {object} Observation object ready for saveObservation()
|
|
191
|
+
*/
|
|
192
|
+
export function buildImmediateObservation(episode) {
|
|
193
|
+
const hasError = episode.entries.some(e => e.isError);
|
|
194
|
+
const hasEdit = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
|
|
195
|
+
const readCount = episode.entries.filter(e => e.tool === 'Read' || e.tool === 'Grep').length;
|
|
196
|
+
const isReviewPattern = !hasEdit && !hasError && readCount >= 5;
|
|
197
|
+
const inferredType = hasError ? 'bugfix' : hasEdit ? 'change' : 'discovery';
|
|
198
|
+
const fileList = (episode.files || []).map(f => basename(f)).join(', ') || '(multiple)';
|
|
199
|
+
|
|
200
|
+
// Review/research episodes: use a descriptive title with file count
|
|
201
|
+
let title;
|
|
202
|
+
if (isReviewPattern) {
|
|
203
|
+
const allFiles = [...new Set([
|
|
204
|
+
...(episode.files || []),
|
|
205
|
+
...(episode.filesRead || []),
|
|
206
|
+
])].map(f => basename(f));
|
|
207
|
+
const names = allFiles.slice(0, 4).join(', ');
|
|
208
|
+
const suffix = allFiles.length > 4 ? ` +${allFiles.length - 4} more` : '';
|
|
209
|
+
title = truncate(`Reviewed ${allFiles.length} files: ${names}${suffix}`, 120);
|
|
210
|
+
} else {
|
|
211
|
+
title = truncate(buildDegradedTitle(episode), 120);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
type: inferredType,
|
|
216
|
+
title,
|
|
217
|
+
subtitle: fileList,
|
|
218
|
+
narrative: episode.entries.map(e => e.desc).join('; '),
|
|
219
|
+
concepts: [],
|
|
220
|
+
facts: [],
|
|
221
|
+
files: episode.files,
|
|
222
|
+
filesRead: episode.filesRead || [],
|
|
223
|
+
importance: isReviewPattern ? Math.max(2, computeRuleImportance(episode)) : computeRuleImportance(episode),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
186
227
|
// ─── Background: LLM Episode Extraction (Tier 2 F) ──────────────────────────
|
|
187
228
|
|
|
188
229
|
export async function handleLLMEpisode() {
|
|
@@ -226,7 +267,7 @@ Error: ${e.isError ? 'yes' : 'no'}
|
|
|
226
267
|
|
|
227
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}
|
|
228
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"
|
|
229
|
-
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)`;
|
|
230
271
|
} else {
|
|
231
272
|
const actionList = episode.entries.map((e, i) =>
|
|
232
273
|
`${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`
|
|
@@ -241,7 +282,7 @@ ${actionList}
|
|
|
241
282
|
|
|
242
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}
|
|
243
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"
|
|
244
|
-
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)`;
|
|
245
286
|
}
|
|
246
287
|
|
|
247
288
|
const ruleImportance = computeRuleImportance(episode);
|
|
@@ -260,6 +301,21 @@ importance: 1=routine, 2=notable (error fix, arch decision, config change), 3=cr
|
|
|
260
301
|
}
|
|
261
302
|
|
|
262
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
|
+
|
|
263
319
|
obs = {
|
|
264
320
|
type: validTypes.has(parsed.type) ? parsed.type : 'change',
|
|
265
321
|
title: truncate(parsed.title, 120),
|
|
@@ -282,20 +338,7 @@ importance: 1=routine, 2=notable (error fix, arch decision, config change), 3=cr
|
|
|
282
338
|
try { unlinkSync(tmpFile); } catch {}
|
|
283
339
|
return;
|
|
284
340
|
}
|
|
285
|
-
|
|
286
|
-
const hasEdit = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
|
|
287
|
-
const inferredType = hasError ? 'bugfix' : hasEdit ? 'change' : 'discovery';
|
|
288
|
-
obs = {
|
|
289
|
-
type: inferredType,
|
|
290
|
-
title: truncate(buildDegradedTitle(episode), 120),
|
|
291
|
-
subtitle: fileList,
|
|
292
|
-
narrative: episode.entries.map(e => e.desc).join('; '),
|
|
293
|
-
concepts: [],
|
|
294
|
-
facts: [],
|
|
295
|
-
files: episode.files,
|
|
296
|
-
filesRead: episode.filesRead || [],
|
|
297
|
-
importance: ruleImportance,
|
|
298
|
-
};
|
|
341
|
+
obs = buildImmediateObservation(episode);
|
|
299
342
|
}
|
|
300
343
|
|
|
301
344
|
const db = openDb();
|
|
@@ -371,16 +414,25 @@ export async function handleLLMSummary() {
|
|
|
371
414
|
if (recentObs.length < 1) return;
|
|
372
415
|
|
|
373
416
|
const obsList = recentObs.map((o, i) =>
|
|
374
|
-
`${i + 1}. [${o.type}] ${o.title}${o.narrative ? ': ' + truncate(o.narrative,
|
|
417
|
+
`${i + 1}. [${o.type}] ${o.title}${o.narrative ? ': ' + truncate(o.narrative, 200) : ''}`
|
|
375
418
|
).join('\n');
|
|
376
419
|
|
|
420
|
+
// Include user prompts for richer context
|
|
421
|
+
const userPrompts = db.prepare(`
|
|
422
|
+
SELECT prompt_text FROM user_prompts
|
|
423
|
+
WHERE content_session_id = ? ORDER BY prompt_number ASC LIMIT 10
|
|
424
|
+
`).all(sessionId).map(p => truncate(p.prompt_text, 300));
|
|
425
|
+
const promptCtx = userPrompts.length > 0
|
|
426
|
+
? `\nUser requests: ${userPrompts.join(' → ')}\n`
|
|
427
|
+
: '';
|
|
428
|
+
|
|
377
429
|
const prompt = `Summarize this coding session. Return ONLY valid JSON, no markdown fences.
|
|
378
430
|
|
|
379
|
-
Project: ${project}
|
|
431
|
+
Project: ${project}${promptCtx}
|
|
380
432
|
Observations (${recentObs.length} total):
|
|
381
433
|
${obsList}
|
|
382
434
|
|
|
383
|
-
JSON: {"request":"what the user was working on","
|
|
435
|
+
JSON: {"request":"what the user was working on","completed":"specific items accomplished with file names","remaining_items":"specific unfinished items from the original request — compare investigation scope with actual changes to infer what was NOT yet done; be precise with file:issue format, or empty string if all done","next_steps":"suggested follow-up"}`;
|
|
384
436
|
|
|
385
437
|
if (!(await acquireLLMSlot())) {
|
|
386
438
|
debugLog('WARN', 'llm-summary', 'semaphore timeout, skipping summary');
|
|
@@ -398,12 +450,13 @@ JSON: {"request":"what the user was working on","investigated":"what was explore
|
|
|
398
450
|
if (llmParsed && llmParsed.request) {
|
|
399
451
|
const now = new Date();
|
|
400
452
|
db.prepare(`
|
|
401
|
-
INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, files_read, files_edited, notes, created_at, created_at_epoch)
|
|
402
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, '[]', '[]', '', ?, ?)
|
|
453
|
+
INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
|
|
454
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', '', ?, ?)
|
|
403
455
|
`).run(
|
|
404
456
|
sessionId, project,
|
|
405
457
|
llmParsed.request || '', llmParsed.investigated || '', llmParsed.learned || '',
|
|
406
458
|
llmParsed.completed || '', llmParsed.next_steps || '',
|
|
459
|
+
llmParsed.remaining_items || '',
|
|
407
460
|
now.toISOString(), now.getTime()
|
|
408
461
|
);
|
|
409
462
|
}
|
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
|
@@ -10,7 +10,7 @@ import { readFileSync, writeFileSync, unlinkSync, readdirSync, renameSync, statS
|
|
|
10
10
|
import {
|
|
11
11
|
truncate, typeIcon, inferProject, detectBashSignificance,
|
|
12
12
|
extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
|
|
13
|
-
makeEntryDesc, scrubSecrets,
|
|
13
|
+
makeEntryDesc, scrubSecrets, EDIT_TOOLS, debugCatch, debugLog, fmtTime,
|
|
14
14
|
} from './utils.mjs';
|
|
15
15
|
import {
|
|
16
16
|
readEpisodeRaw, episodeFile,
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
closeRegistryDb, spawnBackground, appendToolEvent, readAndClearToolEvents,
|
|
30
30
|
resetInjectionBudget, hasInjectionBudget, incrementInjection,
|
|
31
31
|
} from './hook-shared.mjs';
|
|
32
|
-
import { handleLLMEpisode, handleLLMSummary, saveObservation,
|
|
32
|
+
import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation } from './hook-llm.mjs';
|
|
33
33
|
import { searchRelevantMemories } from './hook-memory.mjs';
|
|
34
34
|
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection } from './hook-handoff.mjs';
|
|
35
35
|
|
|
@@ -89,21 +89,7 @@ function flushEpisode(episode) {
|
|
|
89
89
|
// LLM background worker will upgrade title/narrative/importance later.
|
|
90
90
|
if (isSignificant) {
|
|
91
91
|
try {
|
|
92
|
-
const
|
|
93
|
-
const hasEdit = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
|
|
94
|
-
const inferredType = hasError ? 'bugfix' : hasEdit ? 'change' : 'discovery';
|
|
95
|
-
const fileList = (episode.files || []).map(f => basename(f)).join(', ') || '(multiple)';
|
|
96
|
-
const obs = {
|
|
97
|
-
type: inferredType,
|
|
98
|
-
title: truncate(buildDegradedTitle(episode), 120),
|
|
99
|
-
subtitle: fileList,
|
|
100
|
-
narrative: episode.entries.map(e => e.desc).join('; '),
|
|
101
|
-
concepts: [],
|
|
102
|
-
facts: [],
|
|
103
|
-
files: episode.files,
|
|
104
|
-
filesRead: episode.filesRead || [],
|
|
105
|
-
importance: computeRuleImportance(episode),
|
|
106
|
-
};
|
|
92
|
+
const obs = buildImmediateObservation(episode);
|
|
107
93
|
const id = saveObservation(obs, episode.project, episode.sessionId);
|
|
108
94
|
if (id) episode.savedId = id;
|
|
109
95
|
} catch (e) { debugCatch(e, 'flushEpisode-immediateSave'); }
|
|
@@ -160,7 +146,7 @@ async function handlePostToolUse() {
|
|
|
160
146
|
|
|
161
147
|
// Skip noise
|
|
162
148
|
if (SKIP_TOOLS.has(tool_name)) return;
|
|
163
|
-
if (tool_name.startsWith('mem_') || tool_name.startsWith('mcp__mem__')) return;
|
|
149
|
+
if (tool_name.startsWith('mem_') || tool_name.startsWith('mcp__mem__') || tool_name.startsWith('mcp__plugin_claude-mem-lite')) return;
|
|
164
150
|
if (tool_name.startsWith('mcp__sequential') || tool_name.startsWith('mcp__plugin_context7')) return;
|
|
165
151
|
|
|
166
152
|
const resp = typeof tool_response === 'string' ? tool_response : JSON.stringify(tool_response || '');
|
|
@@ -347,21 +333,7 @@ async function handleStop() {
|
|
|
347
333
|
// Immediate save: persist rule-based observation to DB before spawning background worker.
|
|
348
334
|
// Without this, data is lost if the background worker fails.
|
|
349
335
|
try {
|
|
350
|
-
const
|
|
351
|
-
const hasEdit = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
|
|
352
|
-
const inferredType = hasError ? 'bugfix' : hasEdit ? 'change' : 'discovery';
|
|
353
|
-
const fileList = (episode.files || []).map(f => basename(f)).join(', ') || '(multiple)';
|
|
354
|
-
const obs = {
|
|
355
|
-
type: inferredType,
|
|
356
|
-
title: truncate(buildDegradedTitle(episode), 120),
|
|
357
|
-
subtitle: fileList,
|
|
358
|
-
narrative: episode.entries.map(e => e.desc).join('; '),
|
|
359
|
-
concepts: [],
|
|
360
|
-
facts: [],
|
|
361
|
-
files: episode.files,
|
|
362
|
-
filesRead: episode.filesRead || [],
|
|
363
|
-
importance: computeRuleImportance(episode),
|
|
364
|
-
};
|
|
336
|
+
const obs = buildImmediateObservation(episode);
|
|
365
337
|
const id = saveObservation(obs, episode.project, episode.sessionId);
|
|
366
338
|
if (id) episode.savedId = id;
|
|
367
339
|
} catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
|
|
@@ -397,9 +369,12 @@ async function handleStop() {
|
|
|
397
369
|
// Always clear event file to prevent stale events accumulating if registry DB is unavailable.
|
|
398
370
|
try {
|
|
399
371
|
const sessionEvents = readAndClearToolEvents();
|
|
400
|
-
|
|
401
|
-
if (
|
|
402
|
-
|
|
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
|
+
}
|
|
403
378
|
}
|
|
404
379
|
} catch (e) { debugCatch(e, 'handleStop-feedback'); }
|
|
405
380
|
|
|
@@ -456,7 +431,7 @@ async function handleSessionStart() {
|
|
|
456
431
|
|
|
457
432
|
// ── DB mutations in a transaction (crash-safe consistency) ──
|
|
458
433
|
const staleSessionCutoff = Date.now() - STALE_SESSION_MS;
|
|
459
|
-
const autoCompressAge = Date.now() -
|
|
434
|
+
const autoCompressAge = Date.now() - 30 * 86400000; // 30 days (accelerated from 90)
|
|
460
435
|
|
|
461
436
|
db.transaction(() => {
|
|
462
437
|
// Ensure session exists in DB (INSERT OR IGNORE avoids race condition)
|
|
@@ -479,7 +454,7 @@ async function handleSessionStart() {
|
|
|
479
454
|
WHERE status = 'active' AND started_at_epoch < ?
|
|
480
455
|
`).run(staleSessionCutoff);
|
|
481
456
|
|
|
482
|
-
// Auto-compress: mark old low-importance observations as compressed (
|
|
457
|
+
// Auto-compress: mark old low-importance observations as compressed (30+ days, importance=1)
|
|
483
458
|
// Lightweight: only marks rows, doesn't create summaries (full compression via mem_compress)
|
|
484
459
|
const compressed = db.prepare(`
|
|
485
460
|
UPDATE observations SET compressed_into = -1
|
|
@@ -703,8 +678,10 @@ async function handleSessionStart() {
|
|
|
703
678
|
try {
|
|
704
679
|
const rdb = getRegistryDb();
|
|
705
680
|
if (rdb && hasInjectionBudget()) {
|
|
706
|
-
|
|
707
|
-
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 });
|
|
708
685
|
if (dispatchResult) {
|
|
709
686
|
process.stdout.write(dispatchResult + '\n');
|
|
710
687
|
incrementInjection();
|
|
@@ -906,6 +883,7 @@ async function handleResourceScan() {
|
|
|
906
883
|
}
|
|
907
884
|
|
|
908
885
|
// Upsert changed resources with fallback metadata (no Haiku)
|
|
886
|
+
let firstErr = true;
|
|
909
887
|
for (const res of toIndex) {
|
|
910
888
|
try {
|
|
911
889
|
upsertResource(rdb, {
|
|
@@ -920,7 +898,7 @@ async function handleResourceScan() {
|
|
|
920
898
|
trigger_patterns: `when user needs ${res.name.replace(/-/g, ' ').replace(/\//g, ' ')}`,
|
|
921
899
|
capability_summary: `${res.type}: ${res.name.replace(/-/g, ' ')}`,
|
|
922
900
|
});
|
|
923
|
-
} catch {}
|
|
901
|
+
} catch (e) { if (firstErr) { debugCatch(e, 'handleResourceScan-upsert'); firstErr = false; } }
|
|
924
902
|
}
|
|
925
903
|
|
|
926
904
|
// Disable resources no longer on filesystem
|
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-indexer.mjs
CHANGED
|
@@ -54,13 +54,13 @@ function fallbackExtract(resource) {
|
|
|
54
54
|
infra: 'infrastructure,devops,cloud',
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
const intentTagSet = new Set();
|
|
58
58
|
for (const [key, tags] of Object.entries(intentMap)) {
|
|
59
59
|
if (name.includes(key) || content.includes(key)) {
|
|
60
|
-
|
|
61
|
-
break;
|
|
60
|
+
for (const t of tags.split(',')) intentTagSet.add(t);
|
|
62
61
|
}
|
|
63
62
|
}
|
|
63
|
+
const intentTags = [...intentTagSet].join(',');
|
|
64
64
|
|
|
65
65
|
// Infer domain tags from content
|
|
66
66
|
const domainPatterns = [
|
package/registry-retriever.mjs
CHANGED
|
@@ -197,8 +197,9 @@ export function buildEnhancedQuery(signals) {
|
|
|
197
197
|
// directly across name, intent_tags, capability_summary, trigger_patterns.
|
|
198
198
|
if (signals.rawKeywords?.length > 0) {
|
|
199
199
|
for (const kw of signals.rawKeywords) {
|
|
200
|
-
|
|
201
|
-
parts.push(
|
|
200
|
+
const safeKw = expandToken(kw);
|
|
201
|
+
parts.push(`intent_tags:${safeKw}`);
|
|
202
|
+
parts.push(safeKw);
|
|
202
203
|
}
|
|
203
204
|
}
|
|
204
205
|
|
|
@@ -376,27 +377,31 @@ const COMPOSITE_EXPR = `(
|
|
|
376
377
|
const COMPOSITE_ORDER = `ORDER BY ${COMPOSITE_EXPR} ASC`;
|
|
377
378
|
|
|
378
379
|
const SEARCH_SQL = `
|
|
379
|
-
SELECT
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
380
|
+
SELECT *, composite_score FROM (
|
|
381
|
+
SELECT r.*,
|
|
382
|
+
bm25(resources_fts, 5.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) AS relevance,
|
|
383
|
+
${COMPOSITE_EXPR} AS composite_score
|
|
384
|
+
FROM resources_fts
|
|
385
|
+
JOIN resources r ON r.id = resources_fts.rowid
|
|
386
|
+
WHERE resources_fts MATCH ?
|
|
387
|
+
AND r.status = 'active'
|
|
388
|
+
) sub
|
|
389
|
+
ORDER BY composite_score ASC
|
|
387
390
|
LIMIT ?
|
|
388
391
|
`;
|
|
389
392
|
|
|
390
393
|
const SEARCH_BY_TYPE_SQL = `
|
|
391
|
-
SELECT
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
394
|
+
SELECT *, composite_score FROM (
|
|
395
|
+
SELECT r.*,
|
|
396
|
+
bm25(resources_fts, 5.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) AS relevance,
|
|
397
|
+
${COMPOSITE_EXPR} AS composite_score
|
|
398
|
+
FROM resources_fts
|
|
399
|
+
JOIN resources r ON r.id = resources_fts.rowid
|
|
400
|
+
WHERE resources_fts MATCH ?
|
|
401
|
+
AND r.status = 'active'
|
|
402
|
+
AND r.type = ?
|
|
403
|
+
) sub
|
|
404
|
+
ORDER BY composite_score ASC
|
|
400
405
|
LIMIT ?
|
|
401
406
|
`;
|
|
402
407
|
|
package/registry.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import Database from 'better-sqlite3';
|
|
5
5
|
import { existsSync, mkdirSync } from 'fs';
|
|
6
6
|
import { dirname } from 'path';
|
|
7
|
-
|
|
7
|
+
import { debugCatch } from './utils.mjs';
|
|
8
8
|
|
|
9
9
|
// ─── Schema ──────────────────────────────────────────────────────────────────
|
|
10
10
|
|
|
@@ -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',NULL)),
|
|
108
108
|
score REAL,
|
|
109
109
|
created_at TEXT DEFAULT (datetime('now'))
|
|
110
110
|
);
|
|
@@ -178,6 +178,9 @@ export function ensureRegistryDb(dbPath) {
|
|
|
178
178
|
const schema = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='invocations'`).get();
|
|
179
179
|
if (schema?.sql && !schema.sql.includes('user_prompt')) {
|
|
180
180
|
db.transaction(() => {
|
|
181
|
+
// Clean up leftover from previous failed migration attempt
|
|
182
|
+
const hasOld = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='invocations_old'`).get();
|
|
183
|
+
if (hasOld) db.exec(`DROP TABLE invocations_old`);
|
|
181
184
|
db.exec(`ALTER TABLE invocations RENAME TO invocations_old`);
|
|
182
185
|
db.exec(INVOCATIONS_SCHEMA);
|
|
183
186
|
db.exec(`INSERT INTO invocations
|
|
@@ -187,7 +190,25 @@ export function ensureRegistryDb(dbPath) {
|
|
|
187
190
|
db.exec(`DROP TABLE invocations_old`);
|
|
188
191
|
})();
|
|
189
192
|
}
|
|
190
|
-
} catch {}
|
|
193
|
+
} catch (e) { debugCatch(e, 'ensureRegistryDb-migration'); }
|
|
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'); }
|
|
191
212
|
|
|
192
213
|
db.exec(PREINSTALLED_SCHEMA);
|
|
193
214
|
|
|
@@ -223,7 +244,7 @@ const UPSERT_SQL = `
|
|
|
223
244
|
*/
|
|
224
245
|
export function upsertResource(db, r) {
|
|
225
246
|
return db.transaction(() => {
|
|
226
|
-
|
|
247
|
+
db.prepare(UPSERT_SQL).run(
|
|
227
248
|
r.name, r.type, r.status || 'active', r.source || 'preinstalled',
|
|
228
249
|
r.repo_url || null, r.repo_stars || 0, r.local_path,
|
|
229
250
|
r.file_hash || null, r.invocation_name || '',
|
|
@@ -233,7 +254,6 @@ export function upsertResource(db, r) {
|
|
|
233
254
|
r.keywords || '', r.tech_stack || '', r.use_cases || '', r.complexity || 'intermediate',
|
|
234
255
|
r.indexed_at || null
|
|
235
256
|
);
|
|
236
|
-
if (result.changes > 0 && result.lastInsertRowid) return Number(result.lastInsertRowid);
|
|
237
257
|
const row = db.prepare('SELECT id FROM resources WHERE type = ? AND name = ?').get(r.type, r.name);
|
|
238
258
|
return row?.id || 0;
|
|
239
259
|
})();
|
package/schema.mjs
CHANGED
|
@@ -97,6 +97,7 @@ const MIGRATIONS = [
|
|
|
97
97
|
'ALTER TABLE observations ADD COLUMN minhash_sig TEXT',
|
|
98
98
|
'ALTER TABLE observations ADD COLUMN access_count INTEGER DEFAULT 0',
|
|
99
99
|
'ALTER TABLE observations ADD COLUMN compressed_into INTEGER DEFAULT NULL',
|
|
100
|
+
'ALTER TABLE session_summaries ADD COLUMN remaining_items TEXT',
|
|
100
101
|
];
|
|
101
102
|
|
|
102
103
|
/**
|
|
@@ -153,7 +154,7 @@ export function initSchema(db) {
|
|
|
153
154
|
|
|
154
155
|
// FTS5 full-text search tables + triggers (idempotent)
|
|
155
156
|
ensureFTS(db, 'observations_fts', 'observations', ['title', 'subtitle', 'narrative', 'text', 'facts', 'concepts']);
|
|
156
|
-
ensureFTS(db, 'session_summaries_fts', 'session_summaries', ['request', 'investigated', 'learned', 'completed', 'next_steps', 'notes']);
|
|
157
|
+
ensureFTS(db, 'session_summaries_fts', 'session_summaries', ['request', 'investigated', 'learned', 'completed', 'next_steps', 'notes', 'remaining_items']);
|
|
157
158
|
ensureFTS(db, 'user_prompts_fts', 'user_prompts', ['prompt_text']);
|
|
158
159
|
|
|
159
160
|
return db;
|
|
@@ -196,7 +197,12 @@ export function ensureDb() {
|
|
|
196
197
|
db.pragma('synchronous = NORMAL');
|
|
197
198
|
db.pragma('foreign_keys = OFF'); // Enabled after dedup migration
|
|
198
199
|
|
|
199
|
-
|
|
200
|
+
try {
|
|
201
|
+
return initSchema(db);
|
|
202
|
+
} catch (e) {
|
|
203
|
+
try { db.close(); } catch {}
|
|
204
|
+
throw e;
|
|
205
|
+
}
|
|
200
206
|
}
|
|
201
207
|
|
|
202
208
|
/**
|
|
@@ -211,10 +217,12 @@ export function ensureDb() {
|
|
|
211
217
|
*/
|
|
212
218
|
export function rebuildFTS(db) {
|
|
213
219
|
const FTS_TABLES = ['observations_fts', 'session_summaries_fts', 'user_prompts_fts'];
|
|
220
|
+
const idRe = /^[a-z][a-z0-9_]*$/;
|
|
214
221
|
const rebuilt = [];
|
|
215
222
|
const errors = [];
|
|
216
223
|
for (const fts of FTS_TABLES) {
|
|
217
224
|
try {
|
|
225
|
+
if (!idRe.test(fts)) { errors.push(`${fts}: invalid identifier`); continue; }
|
|
218
226
|
const exists = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(fts);
|
|
219
227
|
if (!exists) { errors.push(`${fts}: not found`); continue; }
|
|
220
228
|
db.exec(`INSERT INTO ${fts}(${fts}) VALUES('rebuild')`);
|
|
@@ -233,10 +241,12 @@ export function rebuildFTS(db) {
|
|
|
233
241
|
*/
|
|
234
242
|
export function checkFTSIntegrity(db) {
|
|
235
243
|
const FTS_TABLES = ['observations_fts', 'session_summaries_fts', 'user_prompts_fts'];
|
|
244
|
+
const idRe = /^[a-z][a-z0-9_]*$/;
|
|
236
245
|
const details = [];
|
|
237
246
|
let healthy = true;
|
|
238
247
|
for (const fts of FTS_TABLES) {
|
|
239
248
|
try {
|
|
249
|
+
if (!idRe.test(fts)) { details.push(`${fts}: invalid identifier`); healthy = false; continue; }
|
|
240
250
|
const exists = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(fts);
|
|
241
251
|
if (!exists) { details.push(`${fts}: missing`); healthy = false; continue; }
|
|
242
252
|
db.exec(`INSERT INTO ${fts}(${fts}) VALUES('integrity-check')`);
|
|
@@ -254,7 +264,7 @@ export function ensureFTS(db, ftsName, tableName, columns) {
|
|
|
254
264
|
if (exists) return;
|
|
255
265
|
|
|
256
266
|
// Validate identifiers to prevent SQL injection
|
|
257
|
-
const idRe = /^[a-
|
|
267
|
+
const idRe = /^[a-z][a-z0-9_]*$/;
|
|
258
268
|
if (!idRe.test(ftsName) || !idRe.test(tableName) || !columns.every(c => idRe.test(c))) {
|
|
259
269
|
throw new Error(`Invalid identifier in ensureFTS: ${ftsName}, ${tableName}`);
|
|
260
270
|
}
|
package/scripts/post-tool-use.sh
CHANGED
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,58 @@ 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
|
+
const deleted = db.prepare(`
|
|
1006
|
+
DELETE FROM observations
|
|
1007
|
+
WHERE importance <= 1 AND COALESCE(access_count, 0) = 0
|
|
1008
|
+
AND created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0
|
|
1009
|
+
`).run(thirtyDaysAgo);
|
|
1010
|
+
if (deleted.changes > 0) {
|
|
1011
|
+
debugLog('INFO', 'idle-cleanup', `Deleted ${deleted.changes} stale low-quality observations`);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Mark old importance=1 as compressed (30+ days)
|
|
1015
|
+
// NOTE: compressed_into = -1 is an established sentinel meaning "auto-compressed without merge target"
|
|
1016
|
+
// (same pattern used in hook.mjs:456 for time-based compression)
|
|
1017
|
+
const compressed = db.prepare(`
|
|
1018
|
+
UPDATE observations SET compressed_into = -1
|
|
1019
|
+
WHERE COALESCE(compressed_into, 0) = 0 AND importance = 1
|
|
1020
|
+
AND created_at_epoch < ?
|
|
1021
|
+
`).run(thirtyDaysAgo);
|
|
1022
|
+
if (compressed.changes > 0) {
|
|
1023
|
+
debugLog('INFO', 'idle-cleanup', `Compressed ${compressed.changes} old observations`);
|
|
1024
|
+
}
|
|
1025
|
+
})();
|
|
1026
|
+
|
|
1027
|
+
// FTS5 index optimization (outside transaction — WAL-friendly)
|
|
1028
|
+
db.exec("INSERT INTO observations_fts(observations_fts) VALUES('optimize')");
|
|
1029
|
+
debugLog('DEBUG', 'idle-cleanup', 'FTS5 optimize complete');
|
|
1030
|
+
} catch (e) {
|
|
1031
|
+
debugCatch(e, 'idle-cleanup');
|
|
1032
|
+
}
|
|
1033
|
+
}, 60000); // Check every minute
|
|
1034
|
+
idleTimer.unref();
|
|
1035
|
+
|
|
983
1036
|
// ─── Shutdown Cleanup ────────────────────────────────────────────────────────
|
|
984
1037
|
|
|
985
1038
|
function shutdown(exitCode = 0) {
|
|
986
1039
|
clearInterval(walTimer);
|
|
1040
|
+
clearInterval(idleTimer);
|
|
987
1041
|
try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch {}
|
|
988
1042
|
try { db.close(); } catch {}
|
|
989
1043
|
process.exit(exitCode);
|
package/utils.mjs
CHANGED
|
@@ -622,12 +622,11 @@ export function fmtTime(iso) {
|
|
|
622
622
|
*/
|
|
623
623
|
export function isoWeekKey(epochMs) {
|
|
624
624
|
const d = new Date(epochMs);
|
|
625
|
-
const tmp = new Date(d.
|
|
626
|
-
tmp.
|
|
627
|
-
|
|
628
|
-
const yearStart = new Date(tmp.getFullYear(), 0, 1);
|
|
625
|
+
const tmp = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
626
|
+
tmp.setUTCDate(tmp.getUTCDate() + 4 - (tmp.getUTCDay() || 7));
|
|
627
|
+
const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
|
|
629
628
|
const weekNum = Math.ceil(((tmp - yearStart) / 86400000 + 1) / 7);
|
|
630
|
-
const isoYear = tmp.
|
|
629
|
+
const isoYear = tmp.getUTCFullYear();
|
|
631
630
|
return `${isoYear}-W${String(weekNum).padStart(2, '0')}`;
|
|
632
631
|
}
|
|
633
632
|
|