claude-mem-lite 2.1.4 → 2.1.6
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/README.md +14 -4
- package/README.zh-CN.md +14 -4
- package/dispatch.mjs +23 -12
- package/hook-context.mjs +2 -3
- package/hook-memory.mjs +2 -2
- package/hook-shared.mjs +10 -3
- package/hook.mjs +51 -2
- package/install.mjs +62 -6
- package/package.json +1 -1
- package/registry-retriever.mjs +12 -7
- package/schema.mjs +14 -0
- package/server.mjs +7 -1
- package/utils.mjs +60 -0
package/README.md
CHANGED
|
@@ -95,6 +95,7 @@ The original sends **everything to the LLM and hopes it filters well**. claude-m
|
|
|
95
95
|
- **Exploration bonus** -- New resources in the registry get a fair chance in composite ranking; zombie resources (high recommend, zero adopt) are penalized
|
|
96
96
|
- **LLM concurrency control** -- File-based semaphore limits background workers to 2 concurrent LLM calls, preventing resource contention
|
|
97
97
|
- **stdin overflow protection** -- Hook input truncated at 256KB with regex-based action salvage for oversized tool outputs
|
|
98
|
+
- **Cross-session handoff** -- Captures session state (request, completed work, next steps, key files) on `/clear` or `/exit`, then injects context when the next session detects continuation intent via explicit keywords or FTS5 term overlap
|
|
98
99
|
|
|
99
100
|
## Platform Support
|
|
100
101
|
|
|
@@ -224,7 +225,7 @@ rm -rf ~/claude-mem-lite/ # pre-v0.5 unhidden (if not auto-moved)
|
|
|
224
225
|
|
|
225
226
|
## Database Schema
|
|
226
227
|
|
|
227
|
-
|
|
228
|
+
Five core tables with FTS5 virtual tables for search:
|
|
228
229
|
|
|
229
230
|
**observations** -- Individual coding observations (decisions, bugfixes, features, etc.)
|
|
230
231
|
```
|
|
@@ -250,6 +251,12 @@ started_at, completed_at, status, prompt_counter
|
|
|
250
251
|
id, content_session_id, prompt_text, prompt_number
|
|
251
252
|
```
|
|
252
253
|
|
|
254
|
+
**session_handoffs** -- Cross-session handoff snapshots (UPSERT, max 2 per project)
|
|
255
|
+
```
|
|
256
|
+
project, type, session_id, working_on, completed, unfinished,
|
|
257
|
+
key_files, key_decisions, match_keywords, created_at_epoch
|
|
258
|
+
```
|
|
259
|
+
|
|
253
260
|
FTS5 indexes: `observations_fts`, `session_summaries_fts`, `user_prompts_fts`
|
|
254
261
|
|
|
255
262
|
## How It Works
|
|
@@ -258,7 +265,7 @@ FTS5 indexes: `observations_fts`, `session_summaries_fts`, `user_prompts_fts`
|
|
|
258
265
|
|
|
259
266
|
```
|
|
260
267
|
SessionStart
|
|
261
|
-
-> Generate session ID
|
|
268
|
+
-> Generate session ID (or save handoff snapshot on /clear)
|
|
262
269
|
-> Mark stale sessions (>24h active) as abandoned
|
|
263
270
|
-> Clean orphaned/stale lock files
|
|
264
271
|
-> Query recent observations (24h)
|
|
@@ -281,11 +288,13 @@ PreToolUse (before tool execution)
|
|
|
281
288
|
UserPromptSubmit
|
|
282
289
|
-> Capture user prompt text to user_prompts table
|
|
283
290
|
-> Increment session prompt counter
|
|
291
|
+
-> Handoff: detect continuation intent → inject previous session context
|
|
284
292
|
-> Dispatch: recommend skill/agent based on user's actual prompt (Tier 0→1→2)
|
|
285
293
|
-> Primary dispatch point — user intent is clearest here
|
|
286
294
|
|
|
287
295
|
Stop
|
|
288
296
|
-> Flush final episode buffer
|
|
297
|
+
-> Save handoff snapshot (on /exit)
|
|
289
298
|
-> Collect dispatch feedback: adoption detection + outcome scoring
|
|
290
299
|
-> Mark session completed
|
|
291
300
|
-> Spawn LLM summary worker (poll-based wait)
|
|
@@ -419,6 +428,7 @@ claude-mem-lite/
|
|
|
419
428
|
hook.mjs # Claude Code hooks: episode capture, error recall, session management
|
|
420
429
|
hook-llm.mjs # Background LLM workers: episode extraction, session summaries
|
|
421
430
|
hook-shared.mjs # Shared hook infrastructure: session management, DB access, LLM calls
|
|
431
|
+
hook-handoff.mjs # Cross-session handoff: state extraction, intent detection, injection
|
|
422
432
|
hook-context.mjs # CLAUDE.md context injection and token budgeting
|
|
423
433
|
hook-episode.mjs # Episode buffer management: atomic writes, pending entry merging
|
|
424
434
|
hook-semaphore.mjs # LLM concurrency control: file-based semaphore for background workers
|
|
@@ -444,7 +454,7 @@ claude-mem-lite/
|
|
|
444
454
|
convert-commands.mjs # Converts command .md → SKILL.md in managed plugins
|
|
445
455
|
index-managed.mjs # Offline indexer for managed resources
|
|
446
456
|
# Test & benchmark (dev only)
|
|
447
|
-
tests/ # Unit, property, integration, contract, E2E, pipeline tests (
|
|
457
|
+
tests/ # Unit, property, integration, contract, E2E, pipeline tests (789 tests)
|
|
448
458
|
benchmark/ # BM25 search quality benchmarks + CI gate
|
|
449
459
|
```
|
|
450
460
|
|
|
@@ -466,7 +476,7 @@ The benchmark suite runs as a CI gate (`npm run benchmark:gate`) to prevent sear
|
|
|
466
476
|
|
|
467
477
|
```bash
|
|
468
478
|
npm run lint # ESLint static analysis
|
|
469
|
-
npm test # Run all
|
|
479
|
+
npm test # Run all 789 tests (vitest)
|
|
470
480
|
npm run test:smoke # Run 5 core smoke tests
|
|
471
481
|
npm run test:coverage # Run tests with V8 coverage (≥70% lines/functions, ≥60% branches)
|
|
472
482
|
npm run benchmark # Run full search quality benchmark
|
package/README.zh-CN.md
CHANGED
|
@@ -95,6 +95,7 @@
|
|
|
95
95
|
- **探索奖励** -- 注册表中的新资源在复合排名中获得公平机会;高推荐零采纳的"僵尸"资源被惩罚
|
|
96
96
|
- **LLM 并发控制** -- 基于文件的信号量将后台 worker 限制为 2 个并发 LLM 调用,防止资源争用
|
|
97
97
|
- **stdin 溢出保护** -- Hook 输入在 256KB 处截断,对超大工具输出使用正则挽救关键信息
|
|
98
|
+
- **跨会话交接** -- 在 `/clear` 或 `/exit` 时捕获会话状态(请求、已完成工作、后续步骤、关键文件),下次会话检测到继续意图时自动注入上下文(支持显式关键词和 FTS5 术语重叠匹配)
|
|
98
99
|
|
|
99
100
|
## 平台支持
|
|
100
101
|
|
|
@@ -224,7 +225,7 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
|
|
|
224
225
|
|
|
225
226
|
## 数据库结构
|
|
226
227
|
|
|
227
|
-
|
|
228
|
+
五张核心表 + FTS5 虚拟表用于搜索:
|
|
228
229
|
|
|
229
230
|
**observations** -- 单条编码观察(决策、bug修复、功能等)
|
|
230
231
|
```
|
|
@@ -250,6 +251,12 @@ started_at, completed_at, status, prompt_counter
|
|
|
250
251
|
id, content_session_id, prompt_text, prompt_number
|
|
251
252
|
```
|
|
252
253
|
|
|
254
|
+
**session_handoffs** -- 跨会话交接快照(UPSERT,每个项目最多 2 行)
|
|
255
|
+
```
|
|
256
|
+
project, type, session_id, working_on, completed, unfinished,
|
|
257
|
+
key_files, key_decisions, match_keywords, created_at_epoch
|
|
258
|
+
```
|
|
259
|
+
|
|
253
260
|
FTS5 索引:`observations_fts`、`session_summaries_fts`、`user_prompts_fts`
|
|
254
261
|
|
|
255
262
|
## 工作原理
|
|
@@ -258,7 +265,7 @@ FTS5 索引:`observations_fts`、`session_summaries_fts`、`user_prompts_fts`
|
|
|
258
265
|
|
|
259
266
|
```
|
|
260
267
|
SessionStart
|
|
261
|
-
-> 生成会话 ID
|
|
268
|
+
-> 生成会话 ID(/clear 时保存交接快照)
|
|
262
269
|
-> 标记过期会话(活跃 >24h)为 abandoned
|
|
263
270
|
-> 清理孤儿/过期锁文件
|
|
264
271
|
-> 查询最近观察(24 小时内)
|
|
@@ -281,11 +288,13 @@ PreToolUse(工具执行前)
|
|
|
281
288
|
UserPromptSubmit
|
|
282
289
|
-> 捕获用户提示文本到 user_prompts 表
|
|
283
290
|
-> 递增会话提示计数器
|
|
291
|
+
-> 交接:检测继续意图 → 注入上一次会话上下文
|
|
284
292
|
-> 调度:根据用户实际提示推荐 skill/agent(Tier 0→1→2)
|
|
285
293
|
-> 主要调度触发点 — 用户意图在此最为明确
|
|
286
294
|
|
|
287
295
|
Stop
|
|
288
296
|
-> 刷新最终 episode 缓冲区
|
|
297
|
+
-> 保存交接快照(/exit 时)
|
|
289
298
|
-> 收集调度反馈:采纳检测 + 结果评分
|
|
290
299
|
-> 标记会话为已完成
|
|
291
300
|
-> 启动 LLM 摘要 worker(轮询等待)
|
|
@@ -419,6 +428,7 @@ claude-mem-lite/
|
|
|
419
428
|
hook.mjs # Claude Code 钩子:episode 捕获、错误回忆、会话管理
|
|
420
429
|
hook-llm.mjs # 后台 LLM worker:episode 提取、会话摘要
|
|
421
430
|
hook-shared.mjs # 共享钩子基础设施:会话管理、数据库访问、LLM 调用
|
|
431
|
+
hook-handoff.mjs # 跨会话交接:状态提取、意图检测、上下文注入
|
|
422
432
|
hook-context.mjs # CLAUDE.md 上下文注入与 token 预算
|
|
423
433
|
hook-episode.mjs # Episode 缓冲区管理:原子写入、待处理条目合并
|
|
424
434
|
hook-semaphore.mjs # LLM 并发控制:基于文件的信号量
|
|
@@ -444,7 +454,7 @@ claude-mem-lite/
|
|
|
444
454
|
convert-commands.mjs # 将 command .md 转换为托管插件中的 SKILL.md
|
|
445
455
|
index-managed.mjs # 托管资源离线索引器
|
|
446
456
|
# 测试和基准(仅开发)
|
|
447
|
-
tests/ # 单元、属性、集成、契约、E2E、管线测试(
|
|
457
|
+
tests/ # 单元、属性、集成、契约、E2E、管线测试(789 个)
|
|
448
458
|
benchmark/ # BM25 搜索质量基准 + CI 门控
|
|
449
459
|
```
|
|
450
460
|
|
|
@@ -466,7 +476,7 @@ claude-mem-lite/
|
|
|
466
476
|
|
|
467
477
|
```bash
|
|
468
478
|
npm run lint # ESLint 静态分析
|
|
469
|
-
npm test # 运行全部
|
|
479
|
+
npm test # 运行全部 789 个测试(vitest)
|
|
470
480
|
npm run test:smoke # 运行 5 个核心冒烟测试
|
|
471
481
|
npm run test:coverage # 运行测试并生成 V8 覆盖率(≥70% 行/函数,≥60% 分支)
|
|
472
482
|
npm run benchmark # 运行完整搜索质量基准测试
|
package/dispatch.mjs
CHANGED
|
@@ -578,7 +578,10 @@ export function needsHaikuDispatch(results) {
|
|
|
578
578
|
|
|
579
579
|
if (results.length === 0) return true;
|
|
580
580
|
|
|
581
|
-
|
|
581
|
+
// Prefer composite_score (includes behavioral signals) over raw BM25 relevance.
|
|
582
|
+
// Both are negative (more negative = better). Use absolute values for comparison.
|
|
583
|
+
const scoreOf = r => Math.abs(r.composite_score ?? r.relevance);
|
|
584
|
+
const topScore = scoreOf(results[0]);
|
|
582
585
|
|
|
583
586
|
// Relative threshold: if only one result or few results, use absolute minimum
|
|
584
587
|
// For larger result sets, use mean-relative threshold
|
|
@@ -588,14 +591,14 @@ export function needsHaikuDispatch(results) {
|
|
|
588
591
|
}
|
|
589
592
|
|
|
590
593
|
// Compute mean relevance across results
|
|
591
|
-
const meanScore = results.reduce((sum, r) => sum +
|
|
594
|
+
const meanScore = results.reduce((sum, r) => sum + scoreOf(r), 0) / results.length;
|
|
592
595
|
|
|
593
596
|
// Top result should be significantly above mean (at least 1.5x)
|
|
594
597
|
if (topScore < meanScore * 1.5 && topScore < 3.0) return true;
|
|
595
598
|
|
|
596
599
|
// Top two results too close → ambiguous, need Haiku to disambiguate
|
|
597
600
|
if (results.length > 1) {
|
|
598
|
-
const gap = topScore -
|
|
601
|
+
const gap = topScore - scoreOf(results[1]);
|
|
599
602
|
// Gap should be at least 10% of top score, or at least 0.5 absolute
|
|
600
603
|
if (gap < Math.max(topScore * 0.1, 0.5)) return true;
|
|
601
604
|
}
|
|
@@ -633,19 +636,19 @@ JSON: {"query":"search keywords for finding the right skill or agent","type":"sk
|
|
|
633
636
|
// ─── Cooldown & Dedup (DB-persisted, survives process restarts) ─────────────
|
|
634
637
|
|
|
635
638
|
export function isRecentlyRecommended(db, resourceId, sessionId) {
|
|
636
|
-
// Check 1:
|
|
639
|
+
// Check 1 & 2: Session-scoped checks (cap + dedup) — only when sessionId is available
|
|
637
640
|
if (sessionId) {
|
|
638
641
|
const sessionCount = db.prepare(
|
|
639
642
|
'SELECT COUNT(*) as cnt FROM invocations WHERE session_id = ? AND recommended = 1'
|
|
640
643
|
).get(sessionId);
|
|
641
644
|
if (sessionCount.cnt >= SESSION_RECOMMEND_CAP) return true;
|
|
642
|
-
}
|
|
643
645
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
646
|
+
// Already recommended in this session (session dedup)
|
|
647
|
+
const sessionHit = db.prepare(
|
|
648
|
+
'SELECT 1 FROM invocations WHERE resource_id = ? AND session_id = ? LIMIT 1'
|
|
649
|
+
).get(resourceId, sessionId);
|
|
650
|
+
if (sessionHit) return true;
|
|
651
|
+
}
|
|
649
652
|
|
|
650
653
|
// Check 3: Recommended within cooldown window (cross-session cooldown)
|
|
651
654
|
const cooldownHit = db.prepare(
|
|
@@ -702,7 +705,9 @@ function applyAdoptionDecay(results) {
|
|
|
702
705
|
|
|
703
706
|
if (multiplier === 0) return null;
|
|
704
707
|
if (multiplier < 1) {
|
|
705
|
-
|
|
708
|
+
// BM25 scores are negative (more negative = more relevant).
|
|
709
|
+
// To penalize: divide by multiplier to make less negative (worse rank).
|
|
710
|
+
return { ...r, relevance: r.relevance / multiplier, _decayed: true };
|
|
706
711
|
}
|
|
707
712
|
return r;
|
|
708
713
|
}).filter(Boolean);
|
|
@@ -796,7 +801,13 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
|
|
|
796
801
|
limit: 3,
|
|
797
802
|
projectDomains,
|
|
798
803
|
});
|
|
799
|
-
if (haikuResults.length > 0)
|
|
804
|
+
if (haikuResults.length > 0) {
|
|
805
|
+
// Apply same post-processing as Tier2 to prevent zombie/low-confidence bypass
|
|
806
|
+
haikuResults = reRankByKeywords(haikuResults, signals.rawKeywords);
|
|
807
|
+
haikuResults = applyAdoptionDecay(haikuResults);
|
|
808
|
+
haikuResults = passesConfidenceGate(haikuResults, signals);
|
|
809
|
+
if (haikuResults.length > 0) results = haikuResults;
|
|
810
|
+
}
|
|
800
811
|
}
|
|
801
812
|
}
|
|
802
813
|
}
|
package/hook-context.mjs
CHANGED
|
@@ -78,14 +78,13 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
78
78
|
LIMIT 10
|
|
79
79
|
`).all(project, now_ms - windows.sessWindow);
|
|
80
80
|
|
|
81
|
-
const now = Date.now();
|
|
82
81
|
const selectedObs = [];
|
|
83
82
|
const selectedSess = [];
|
|
84
83
|
let totalTokens = 0;
|
|
85
84
|
|
|
86
85
|
// Score each candidate: value = recency * importance, cost = tokens
|
|
87
86
|
const scoredObs = obsPool.map(o => {
|
|
88
|
-
const ageDays = (
|
|
87
|
+
const ageDays = (now_ms - o.created_at_epoch) / 86400000;
|
|
89
88
|
const recency = 1 / (1 + ageDays);
|
|
90
89
|
const impBoost = 0.5 + 0.5 * (o.importance || 1);
|
|
91
90
|
const value = recency * impBoost;
|
|
@@ -94,7 +93,7 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
94
93
|
});
|
|
95
94
|
|
|
96
95
|
const scoredSess = sessPool.map(s => {
|
|
97
|
-
const ageDays = (
|
|
96
|
+
const ageDays = (now_ms - s.created_at_epoch) / 86400000;
|
|
98
97
|
const recency = 1 / (1 + ageDays);
|
|
99
98
|
const value = recency * 1.5; // Session summaries slightly boosted
|
|
100
99
|
const cost = estimateTokens((s.request || '') + (s.completed || '') + (s.next_steps || ''));
|
package/hook-memory.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
28
28
|
|
|
29
29
|
const selectStmt = db.prepare(`
|
|
30
30
|
SELECT o.id, o.type, o.title, o.importance,
|
|
31
|
-
bm25(observations_fts) as relevance
|
|
31
|
+
bm25(observations_fts, 10, 5, 5, 3, 3, 2) as relevance
|
|
32
32
|
FROM observations_fts
|
|
33
33
|
JOIN observations o ON o.id = observations_fts.rowid
|
|
34
34
|
WHERE observations_fts MATCH ?
|
|
@@ -36,7 +36,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
36
36
|
AND o.importance >= 2
|
|
37
37
|
AND o.created_at_epoch > ?
|
|
38
38
|
AND COALESCE(o.compressed_into, 0) = 0
|
|
39
|
-
ORDER BY bm25(observations_fts)
|
|
39
|
+
ORDER BY bm25(observations_fts, 10, 5, 5, 3, 3, 2)
|
|
40
40
|
LIMIT 10
|
|
41
41
|
`);
|
|
42
42
|
const rows = selectStmt.all(ftsQuery, project, cutoff);
|
package/hook-shared.mjs
CHANGED
|
@@ -26,6 +26,12 @@ export const RELATED_OBS_WINDOW_MS = 7 * 86400000; // 7 days
|
|
|
26
26
|
export const FALLBACK_OBS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
27
27
|
export const RESOURCE_RESCAN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
28
28
|
|
|
29
|
+
// Handoff system constants
|
|
30
|
+
export const HANDOFF_EXPIRY_CLEAR = 3600000; // 1 hour
|
|
31
|
+
export const HANDOFF_EXPIRY_EXIT = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
32
|
+
export const HANDOFF_MATCH_THRESHOLD = 3; // min weighted score
|
|
33
|
+
export const CONTINUE_KEYWORDS = /继续|接着|上次|之前的|前面的|刚才|\bcontinue\b|\bresume\b|\bwhere[\s\-]+we[\s\-]+left\b|\bpick[\s\-]+up\b|\bcarry[\s\-]+on\b/i;
|
|
34
|
+
|
|
29
35
|
// Ensure runtime directory exists
|
|
30
36
|
try { if (!existsSync(RUNTIME_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true }); } catch {}
|
|
31
37
|
|
|
@@ -121,9 +127,10 @@ export function spawnBackground(bgEvent, ...extraArgs) {
|
|
|
121
127
|
|
|
122
128
|
export function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
123
129
|
|
|
124
|
-
// ─── Injection Budget (per
|
|
125
|
-
// Limits
|
|
126
|
-
//
|
|
130
|
+
// ─── Injection Budget (per hook invocation, in-memory) ───────────────────────
|
|
131
|
+
// Limits context injections within a single hook process to prevent context bloat.
|
|
132
|
+
// Note: each hook event runs in a separate process, so this is per-invocation,
|
|
133
|
+
// not per-session. Session-level dedup is handled by cooldown/sessionId checks.
|
|
127
134
|
|
|
128
135
|
export const MAX_INJECTIONS_PER_SESSION = 3;
|
|
129
136
|
let _injectionCount = 0;
|
package/hook.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from './hook-shared.mjs';
|
|
32
32
|
import { handleLLMEpisode, handleLLMSummary, saveObservation, buildDegradedTitle } from './hook-llm.mjs';
|
|
33
33
|
import { searchRelevantMemories } from './hook-memory.mjs';
|
|
34
|
+
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection } from './hook-handoff.mjs';
|
|
34
35
|
|
|
35
36
|
// Prevent recursive hooks from background claude -p calls
|
|
36
37
|
// Background workers (llm-episode, llm-summary, resource-scan) are exempt — they're ours
|
|
@@ -318,6 +319,9 @@ async function handleStop() {
|
|
|
318
319
|
const sessionId = getSessionId();
|
|
319
320
|
const project = inferProject();
|
|
320
321
|
|
|
322
|
+
// Snapshot episode BEFORE flush for handoff extraction
|
|
323
|
+
const episodeSnapshot = readEpisodeRaw();
|
|
324
|
+
|
|
321
325
|
// Flush remaining episode buffer (locked to prevent race with handlePostToolUse)
|
|
322
326
|
if (acquireLock(1000)) {
|
|
323
327
|
try {
|
|
@@ -340,6 +344,27 @@ async function handleStop() {
|
|
|
340
344
|
if (episode && episode.entries && episode.entries.length > 0 && episodeHasSignificantContent(episode)) {
|
|
341
345
|
if (!episode.sessionId) episode.sessionId = sessionId;
|
|
342
346
|
if (!episode.project) episode.project = project;
|
|
347
|
+
// Immediate save: persist rule-based observation to DB before spawning background worker.
|
|
348
|
+
// Without this, data is lost if the background worker fails.
|
|
349
|
+
try {
|
|
350
|
+
const hasError = episode.entries.some(e => e.isError);
|
|
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
|
+
};
|
|
365
|
+
const id = saveObservation(obs, episode.project, episode.sessionId);
|
|
366
|
+
if (id) episode.savedId = id;
|
|
367
|
+
} catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
|
|
343
368
|
const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
|
|
344
369
|
writeFileSync(flushFile, JSON.stringify(episode));
|
|
345
370
|
spawnBackground('llm-episode', flushFile);
|
|
@@ -350,7 +375,7 @@ async function handleStop() {
|
|
|
350
375
|
} catch (e) { debugCatch(e, 'handleStop-fallback'); }
|
|
351
376
|
}
|
|
352
377
|
|
|
353
|
-
// Mark session completed (sync, instant)
|
|
378
|
+
// Mark session completed + save handoff (sync, instant)
|
|
354
379
|
const db = openDb();
|
|
355
380
|
if (db) {
|
|
356
381
|
try {
|
|
@@ -358,6 +383,9 @@ async function handleStop() {
|
|
|
358
383
|
UPDATE sdk_sessions SET status = 'completed', completed_at = ?, completed_at_epoch = ?
|
|
359
384
|
WHERE content_session_id = ? AND status = 'active'
|
|
360
385
|
`).run(new Date().toISOString(), Date.now(), sessionId);
|
|
386
|
+
// Save handoff snapshot for cross-session continuity
|
|
387
|
+
try { buildAndSaveHandoff(db, sessionId, project, 'exit', episodeSnapshot); }
|
|
388
|
+
catch (e) { debugCatch(e, 'handleStop-handoff'); }
|
|
361
389
|
} finally {
|
|
362
390
|
db.close();
|
|
363
391
|
}
|
|
@@ -366,10 +394,11 @@ async function handleStop() {
|
|
|
366
394
|
// Dispatch: collect feedback on recommendations using actual tool events
|
|
367
395
|
// PostToolUse tracks Skill/Task/Edit/Write/Bash events in a JSONL file.
|
|
368
396
|
// These events drive adoption detection (Skill/Task) and outcome detection (Edit/Bash errors).
|
|
397
|
+
// Always clear event file to prevent stale events accumulating if registry DB is unavailable.
|
|
369
398
|
try {
|
|
399
|
+
const sessionEvents = readAndClearToolEvents();
|
|
370
400
|
const rdb = getRegistryDb();
|
|
371
401
|
if (rdb) {
|
|
372
|
-
const sessionEvents = readAndClearToolEvents();
|
|
373
402
|
await collectFeedback(rdb, sessionId, sessionEvents);
|
|
374
403
|
}
|
|
375
404
|
} catch (e) { debugCatch(e, 'handleStop-feedback'); }
|
|
@@ -386,6 +415,9 @@ async function handleStop() {
|
|
|
386
415
|
async function handleSessionStart() {
|
|
387
416
|
resetInjectionBudget();
|
|
388
417
|
|
|
418
|
+
// Snapshot episode BEFORE flush for handoff extraction
|
|
419
|
+
const episodeSnapshot = readEpisodeRaw();
|
|
420
|
+
|
|
389
421
|
// Flush any leftover episode buffer from previous session (e.g. after /clear)
|
|
390
422
|
if (acquireLock()) {
|
|
391
423
|
try {
|
|
@@ -464,6 +496,10 @@ async function handleSessionStart() {
|
|
|
464
496
|
// ── Non-transactional operations (side effects, background work) ──
|
|
465
497
|
|
|
466
498
|
if (prevSessionId) {
|
|
499
|
+
// Save handoff for cross-session continuity (/clear or /compact)
|
|
500
|
+
try { buildAndSaveHandoff(db, prevSessionId, prevProject || project, 'clear', episodeSnapshot); }
|
|
501
|
+
catch (e) { debugCatch(e, 'session-start-handoff'); }
|
|
502
|
+
|
|
467
503
|
// Collect dispatch feedback for previous session
|
|
468
504
|
try {
|
|
469
505
|
const rdb = getRegistryDb();
|
|
@@ -774,6 +810,19 @@ async function handleUserPrompt() {
|
|
|
774
810
|
now.toISOString(), now.getTime()
|
|
775
811
|
);
|
|
776
812
|
|
|
813
|
+
// Cross-session handoff injection (first prompt only, before semantic memory)
|
|
814
|
+
if (counter?.prompt_counter === 1 && hasInjectionBudget()) {
|
|
815
|
+
try {
|
|
816
|
+
if (detectContinuationIntent(db, promptText, project)) {
|
|
817
|
+
const injection = renderHandoffInjection(db, project);
|
|
818
|
+
if (injection) {
|
|
819
|
+
process.stdout.write(injection + '\n');
|
|
820
|
+
incrementInjection();
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
} catch (e) { debugCatch(e, 'handleUserPrompt-handoff'); }
|
|
824
|
+
}
|
|
825
|
+
|
|
777
826
|
// Semantic memory injection: search past observations for the user's prompt
|
|
778
827
|
if (hasInjectionBudget()) {
|
|
779
828
|
try {
|
package/install.mjs
CHANGED
|
@@ -1213,7 +1213,7 @@ async function install() {
|
|
|
1213
1213
|
const SOURCE_FILES = [
|
|
1214
1214
|
'server.mjs', 'server-internals.mjs', 'tool-schemas.mjs',
|
|
1215
1215
|
'hook.mjs', 'hook-shared.mjs', 'hook-llm.mjs', 'hook-memory.mjs',
|
|
1216
|
-
'hook-semaphore.mjs', 'hook-episode.mjs', 'hook-context.mjs',
|
|
1216
|
+
'hook-semaphore.mjs', 'hook-episode.mjs', 'hook-context.mjs', 'hook-handoff.mjs',
|
|
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',
|
|
@@ -1581,7 +1581,7 @@ async function install() {
|
|
|
1581
1581
|
async function uninstall() {
|
|
1582
1582
|
console.log('\nclaude-mem-lite uninstaller\n');
|
|
1583
1583
|
|
|
1584
|
-
// 1. Remove MCP
|
|
1584
|
+
// 1. Remove MCP (legacy hook-based install)
|
|
1585
1585
|
try {
|
|
1586
1586
|
execFileSync('claude', ['mcp', 'remove', '-s', 'user', 'mem'], { stdio: 'pipe' });
|
|
1587
1587
|
ok('MCP server removed');
|
|
@@ -1589,7 +1589,7 @@ async function uninstall() {
|
|
|
1589
1589
|
warn('MCP server not found or already removed');
|
|
1590
1590
|
}
|
|
1591
1591
|
|
|
1592
|
-
// 2. Remove hooks (match both npx and git-clone install paths)
|
|
1592
|
+
// 2. Remove hooks from settings.json (match both npx and git-clone install paths)
|
|
1593
1593
|
const settings = readSettings();
|
|
1594
1594
|
if (settings.hooks) {
|
|
1595
1595
|
for (const [event, configs] of Object.entries(settings.hooks)) {
|
|
@@ -1598,11 +1598,67 @@ async function uninstall() {
|
|
|
1598
1598
|
if (settings.hooks[event].length === 0) delete settings.hooks[event];
|
|
1599
1599
|
}
|
|
1600
1600
|
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
1601
|
-
writeSettings(settings);
|
|
1602
|
-
ok('Hooks removed');
|
|
1603
1601
|
}
|
|
1604
1602
|
|
|
1605
|
-
// 3.
|
|
1603
|
+
// 3. Clean plugin system entries from settings.json
|
|
1604
|
+
const pluginKey = 'claude-mem-lite@sdsrss';
|
|
1605
|
+
const marketplaceKey = 'sdsrss';
|
|
1606
|
+
if (settings.enabledPlugins) {
|
|
1607
|
+
delete settings.enabledPlugins[pluginKey];
|
|
1608
|
+
}
|
|
1609
|
+
if (settings.extraKnownMarketplaces) {
|
|
1610
|
+
delete settings.extraKnownMarketplaces[marketplaceKey];
|
|
1611
|
+
}
|
|
1612
|
+
writeSettings(settings);
|
|
1613
|
+
ok('Hooks and plugin settings cleaned');
|
|
1614
|
+
|
|
1615
|
+
// 4. Clean plugin system registry files
|
|
1616
|
+
const pluginsDir = join(homedir(), '.claude', 'plugins');
|
|
1617
|
+
|
|
1618
|
+
// 4a. Remove marketplace directory
|
|
1619
|
+
const marketplaceDir = join(pluginsDir, 'marketplaces', marketplaceKey);
|
|
1620
|
+
if (existsSync(marketplaceDir)) {
|
|
1621
|
+
rmSync(marketplaceDir, { recursive: true, force: true });
|
|
1622
|
+
ok('Marketplace directory removed');
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// 4b. Remove cache directory
|
|
1626
|
+
const cacheDir = join(pluginsDir, 'cache', marketplaceKey);
|
|
1627
|
+
if (existsSync(cacheDir)) {
|
|
1628
|
+
rmSync(cacheDir, { recursive: true, force: true });
|
|
1629
|
+
ok('Plugin cache removed');
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// 4c. Clean known_marketplaces.json
|
|
1633
|
+
const knownPath = join(pluginsDir, 'known_marketplaces.json');
|
|
1634
|
+
try {
|
|
1635
|
+
const known = JSON.parse(readFileSync(knownPath, 'utf8'));
|
|
1636
|
+
if (marketplaceKey in known) {
|
|
1637
|
+
delete known[marketplaceKey];
|
|
1638
|
+
writeFileSync(knownPath, JSON.stringify(known, null, 2) + '\n');
|
|
1639
|
+
ok('Removed from known_marketplaces.json');
|
|
1640
|
+
}
|
|
1641
|
+
} catch { /* file may not exist */ }
|
|
1642
|
+
|
|
1643
|
+
// 4d. Clean installed_plugins.json
|
|
1644
|
+
const installedPath = join(pluginsDir, 'installed_plugins.json');
|
|
1645
|
+
try {
|
|
1646
|
+
const installed = JSON.parse(readFileSync(installedPath, 'utf8'));
|
|
1647
|
+
const plugins = installed.plugins || installed;
|
|
1648
|
+
let cleaned = false;
|
|
1649
|
+
for (const key of Object.keys(plugins)) {
|
|
1650
|
+
if (key.includes('claude-mem-lite') || key.includes('sdsrss')) {
|
|
1651
|
+
delete plugins[key];
|
|
1652
|
+
cleaned = true;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
if (cleaned) {
|
|
1656
|
+
writeFileSync(installedPath, JSON.stringify(installed, null, 2) + '\n');
|
|
1657
|
+
ok('Removed from installed_plugins.json');
|
|
1658
|
+
}
|
|
1659
|
+
} catch { /* file may not exist */ }
|
|
1660
|
+
|
|
1661
|
+
// 5. Purge data if requested
|
|
1606
1662
|
if (flags.has('--purge')) {
|
|
1607
1663
|
const expectedPurgePath = join(homedir(), '.claude-mem-lite');
|
|
1608
1664
|
if (existsSync(DATA_DIR) && DATA_DIR === expectedPurgePath) {
|
package/package.json
CHANGED
package/registry-retriever.mjs
CHANGED
|
@@ -338,8 +338,9 @@ export function filterByProjectDomain(results, projectDomains) {
|
|
|
338
338
|
// Sign convention: bm25() returns NEGATIVE (more negative = more relevant).
|
|
339
339
|
// We keep the negative direction and SUBTRACT positive behavioral signals to make
|
|
340
340
|
// better resources more negative. ORDER BY ... ASC puts most negative (best) first.
|
|
341
|
-
|
|
342
|
-
|
|
341
|
+
// Composite score expression (shared between SELECT and ORDER BY)
|
|
342
|
+
// Sign convention: more negative = better. BM25 is negative, behavioral signals are subtracted.
|
|
343
|
+
const COMPOSITE_EXPR = `(
|
|
343
344
|
bm25(resources_fts, 5.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) * 0.4
|
|
344
345
|
- COALESCE(r.repo_stars * 1.0 / (r.repo_stars + 100.0), 0) * 0.15
|
|
345
346
|
- (
|
|
@@ -369,23 +370,27 @@ const COMPOSITE_ORDER = `
|
|
|
369
370
|
AND (r.adopt_count + 1.0) / (r.recommend_count + 2.0) < 0.1
|
|
370
371
|
THEN 0.10
|
|
371
372
|
ELSE 0 END
|
|
372
|
-
)
|
|
373
|
-
|
|
373
|
+
)`;
|
|
374
|
+
|
|
375
|
+
// COMPOSITE_ORDER kept for SEARCH_BY_TYPE_SQL and other queries
|
|
376
|
+
const COMPOSITE_ORDER = `ORDER BY ${COMPOSITE_EXPR} ASC`;
|
|
374
377
|
|
|
375
378
|
const SEARCH_SQL = `
|
|
376
379
|
SELECT r.*,
|
|
377
|
-
bm25(resources_fts, 5.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) AS relevance
|
|
380
|
+
bm25(resources_fts, 5.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) AS relevance,
|
|
381
|
+
${COMPOSITE_EXPR} AS composite_score
|
|
378
382
|
FROM resources_fts
|
|
379
383
|
JOIN resources r ON r.id = resources_fts.rowid
|
|
380
384
|
WHERE resources_fts MATCH ?
|
|
381
385
|
AND r.status = 'active'
|
|
382
|
-
${
|
|
386
|
+
ORDER BY ${COMPOSITE_EXPR} ASC
|
|
383
387
|
LIMIT ?
|
|
384
388
|
`;
|
|
385
389
|
|
|
386
390
|
const SEARCH_BY_TYPE_SQL = `
|
|
387
391
|
SELECT r.*,
|
|
388
|
-
bm25(resources_fts, 5.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) AS relevance
|
|
392
|
+
bm25(resources_fts, 5.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) AS relevance,
|
|
393
|
+
${COMPOSITE_EXPR} AS composite_score
|
|
389
394
|
FROM resources_fts
|
|
390
395
|
JOIN resources r ON r.id = resources_fts.rowid
|
|
391
396
|
WHERE resources_fts MATCH ?
|
package/schema.mjs
CHANGED
|
@@ -74,6 +74,20 @@ const CORE_SCHEMA = `
|
|
|
74
74
|
created_at_epoch INTEGER NOT NULL,
|
|
75
75
|
FOREIGN KEY(content_session_id) REFERENCES sdk_sessions(content_session_id) ON DELETE CASCADE ON UPDATE CASCADE
|
|
76
76
|
);
|
|
77
|
+
|
|
78
|
+
CREATE TABLE IF NOT EXISTS session_handoffs (
|
|
79
|
+
project TEXT NOT NULL,
|
|
80
|
+
type TEXT NOT NULL,
|
|
81
|
+
session_id TEXT NOT NULL,
|
|
82
|
+
working_on TEXT,
|
|
83
|
+
completed TEXT,
|
|
84
|
+
unfinished TEXT,
|
|
85
|
+
key_files TEXT,
|
|
86
|
+
key_decisions TEXT,
|
|
87
|
+
match_keywords TEXT,
|
|
88
|
+
created_at_epoch INTEGER,
|
|
89
|
+
PRIMARY KEY (project, type)
|
|
90
|
+
);
|
|
77
91
|
`;
|
|
78
92
|
|
|
79
93
|
// Column migrations (idempotent — only swallow "duplicate column" errors)
|
package/server.mjs
CHANGED
|
@@ -939,6 +939,12 @@ server.registerTool(
|
|
|
939
939
|
const narrative = obs.map(o => `- ${o.title || '(untitled)'}`).join('\n');
|
|
940
940
|
const sessionId = obs[0].project ? `compress-${obs[0].project}` : 'compress-manual';
|
|
941
941
|
|
|
942
|
+
// Use median timestamp of compressed observations instead of now,
|
|
943
|
+
// so the summary appears at the correct position in timeline/recency scoring.
|
|
944
|
+
const sortedEpochs = obs.map(o => o.created_at_epoch).sort((a, b) => a - b);
|
|
945
|
+
const medianEpoch = sortedEpochs[Math.floor(sortedEpochs.length / 2)];
|
|
946
|
+
const medianDate = new Date(medianEpoch);
|
|
947
|
+
|
|
942
948
|
// Ensure session exists (INSERT OR IGNORE avoids race condition)
|
|
943
949
|
const now = new Date();
|
|
944
950
|
db.prepare(`
|
|
@@ -948,7 +954,7 @@ server.registerTool(
|
|
|
948
954
|
|
|
949
955
|
const summaryResult = insertSummary.run(
|
|
950
956
|
sessionId, proj, narrative, dominantType, title, narrative,
|
|
951
|
-
|
|
957
|
+
medianDate.toISOString(), medianEpoch
|
|
952
958
|
);
|
|
953
959
|
const summaryId = Number(summaryResult.lastInsertRowid);
|
|
954
960
|
|
package/utils.mjs
CHANGED
|
@@ -676,3 +676,63 @@ export function parseJsonFromLLM(text) {
|
|
|
676
676
|
if (obj) try { return JSON.parse(obj[0]); } catch {}
|
|
677
677
|
return null;
|
|
678
678
|
}
|
|
679
|
+
|
|
680
|
+
// ─── Handoff Utilities ──────────────────────────────────────────────────────
|
|
681
|
+
|
|
682
|
+
/** Stop words for handoff keyword extraction (broader than ERROR_STOP_WORDS). */
|
|
683
|
+
export const HANDOFF_STOP_WORDS = new Set([
|
|
684
|
+
'the', 'and', 'for', 'that', 'this', 'with', 'from', 'are', 'was', 'were',
|
|
685
|
+
'been', 'have', 'has', 'had', 'does', 'did', 'will', 'would', 'should', 'could',
|
|
686
|
+
'can', 'may', 'must', 'not', 'but', 'its', 'all', 'any', 'each', 'some',
|
|
687
|
+
'into', 'over', 'after', 'before', 'between', 'about', 'also', 'just', 'then',
|
|
688
|
+
'than', 'when', 'where', 'how', 'what', 'which', 'who', 'why', 'here', 'there',
|
|
689
|
+
'more', 'very', 'only', 'still', 'now', 'new', 'old', 'get', 'got', 'set',
|
|
690
|
+
'true', 'false', 'null', 'undefined', 'function', 'return', 'const', 'let', 'var',
|
|
691
|
+
'import', 'export', 'default', 'class', 'async', 'await', 'try', 'catch',
|
|
692
|
+
]);
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Tokenize text for handoff keyword matching.
|
|
696
|
+
* Splits on whitespace/punctuation, lowercases, filters short tokens.
|
|
697
|
+
* @param {string} text Input text
|
|
698
|
+
* @returns {string[]} Array of lowercase tokens (length >= 3)
|
|
699
|
+
*/
|
|
700
|
+
export function tokenizeHandoff(text) {
|
|
701
|
+
if (!text) return [];
|
|
702
|
+
return text
|
|
703
|
+
.split(/[\s,;:.()[\]{}'"`<>→|/\\#@!?=+*&^%$~]+/)
|
|
704
|
+
.map(w => w.toLowerCase().replace(/^[.\-]+|[.\-]+$/g, ''))
|
|
705
|
+
.filter(w => w.length >= 3);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Check if a token is a "specific" term (file name, identifier, etc.)
|
|
710
|
+
* that should get double weight in intent matching.
|
|
711
|
+
* @param {string} token Lowercase token
|
|
712
|
+
* @returns {boolean}
|
|
713
|
+
*/
|
|
714
|
+
export function isSpecificTerm(token) {
|
|
715
|
+
if (!token || token.length < 3) return false;
|
|
716
|
+
if (token.includes('_') || token.includes('-')) return true;
|
|
717
|
+
if (HANDOFF_STOP_WORDS.has(token)) return false;
|
|
718
|
+
return token.length >= 4 && !/^\d+$/.test(token);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Extract match keywords from text and file paths for handoff intent matching.
|
|
723
|
+
* @param {string} text Combined text from prompts, observations, etc.
|
|
724
|
+
* @param {string[]} files Array of file paths
|
|
725
|
+
* @returns {string} Space-separated keywords
|
|
726
|
+
*/
|
|
727
|
+
export function extractMatchKeywords(text, files) {
|
|
728
|
+
const terms = new Set();
|
|
729
|
+
for (const f of files) {
|
|
730
|
+
const base = basename(f).replace(/\.[^.]+$/, '');
|
|
731
|
+
if (base.length >= 3) terms.add(base.toLowerCase());
|
|
732
|
+
}
|
|
733
|
+
const words = tokenizeHandoff(text);
|
|
734
|
+
for (const w of words) {
|
|
735
|
+
if (!HANDOFF_STOP_WORDS.has(w)) terms.add(w);
|
|
736
|
+
}
|
|
737
|
+
return [...terms].join(' ');
|
|
738
|
+
}
|