claude-mem-lite 2.28.2 → 2.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/cli.mjs +1 -1
- package/commands/mem.md +2 -1
- package/commands/memory.md +2 -1
- package/commands/tools.md +2 -1
- package/commands/update.md +2 -1
- package/haiku-client.mjs +103 -0
- package/hook-context.mjs +213 -32
- package/hook-memory.mjs +40 -17
- package/hook.mjs +36 -134
- package/install.mjs +1 -1
- package/mem-cli.mjs +248 -34
- package/nlp.mjs +26 -0
- package/package.json +1 -5
- package/project-utils.mjs +14 -1
- package/schema.mjs +2 -1
- package/scoring-sql.mjs +46 -6
- package/scripts/pre-tool-recall.js +35 -12
- package/scripts/prompt-search-utils.mjs +39 -14
- package/scripts/user-prompt-search.js +10 -1
- package/server.mjs +123 -30
- package/skill.md +13 -26
- package/synonyms.mjs +79 -1
- package/tool-schemas.mjs +11 -0
- package/utils.mjs +9 -3
- package/commands/recall.md +0 -9
- package/commands/recent.md +0 -7
- package/commands/search.md +0 -9
- package/commands/timeline.md +0 -7
|
@@ -7,8 +7,10 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
|
7
7
|
import { basename, join } from 'path';
|
|
8
8
|
import { homedir } from 'os';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR env overrides allow tests and debug tools to
|
|
11
|
+
// point the hook at an isolated DB + cooldown dir without touching the user's real state.
|
|
12
|
+
const DB_PATH = process.env.CLAUDE_MEM_DB_PATH || join(homedir(), '.claude-mem-lite', 'claude-mem-lite.db');
|
|
13
|
+
const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(homedir(), '.claude-mem-lite', 'runtime');
|
|
12
14
|
const COOLDOWN_PATH = join(RUNTIME_DIR, 'pre-recall-cooldown.json');
|
|
13
15
|
const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
|
|
14
16
|
const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold
|
|
@@ -88,34 +90,55 @@ try {
|
|
|
88
90
|
// 60-day lookback to avoid surfacing ancient observations
|
|
89
91
|
const cutoff = Date.now() - 60 * 86400000;
|
|
90
92
|
|
|
93
|
+
// Surface actionable lessons first, then high-importance bugfix/decision observations.
|
|
94
|
+
// Priority: 1) observations with lesson_learned (most actionable for preventing repeat bugs)
|
|
95
|
+
// 2) bugfix/decision types with importance>=2 (contextual history)
|
|
96
|
+
// Skip pure change/discovery without lessons — they add noise without actionable value.
|
|
91
97
|
const rows = db.prepare(`
|
|
92
98
|
SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned
|
|
93
99
|
FROM observations o
|
|
94
100
|
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
95
101
|
WHERE o.project = ?
|
|
96
102
|
AND o.importance >= 2
|
|
97
|
-
AND o.lesson_learned IS NOT NULL
|
|
98
|
-
AND o.lesson_learned != ''
|
|
99
103
|
AND COALESCE(o.compressed_into, 0) = 0
|
|
100
104
|
AND o.superseded_at IS NULL
|
|
101
105
|
AND o.created_at_epoch > ?
|
|
102
106
|
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
103
|
-
|
|
107
|
+
AND (
|
|
108
|
+
(o.lesson_learned IS NOT NULL AND o.lesson_learned != '')
|
|
109
|
+
OR o.type IN ('bugfix', 'decision')
|
|
110
|
+
)
|
|
111
|
+
ORDER BY
|
|
112
|
+
CASE WHEN o.lesson_learned IS NOT NULL AND o.lesson_learned != '' THEN 0 ELSE 1 END,
|
|
113
|
+
o.created_at_epoch DESC
|
|
104
114
|
LIMIT 2
|
|
105
115
|
`).all(project, cutoff, filePath, likePattern);
|
|
106
116
|
|
|
107
117
|
if (rows.length > 0) {
|
|
108
118
|
console.log(`[mem] Lessons for ${fname}:`);
|
|
109
119
|
for (const r of rows) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
120
|
+
if (r.lesson_learned) {
|
|
121
|
+
const lesson = r.lesson_learned.length > 120
|
|
122
|
+
? r.lesson_learned.slice(0, 117) + '...'
|
|
123
|
+
: r.lesson_learned;
|
|
124
|
+
console.log(` #${r.id} [${r.type}] ${lesson}`);
|
|
125
|
+
} else {
|
|
126
|
+
const title = (r.title || '').length > 120
|
|
127
|
+
? r.title.slice(0, 117) + '...'
|
|
128
|
+
: (r.title || '');
|
|
129
|
+
console.log(` #${r.id} [${r.type}] ${title}`);
|
|
130
|
+
}
|
|
114
131
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
132
|
+
} else {
|
|
133
|
+
// R-4: emit a short backfill reminder instead of staying silent.
|
|
134
|
+
// Two goals: (1) Claude sees that the system actually ran, (2) Claude is
|
|
135
|
+
// nudged to mem_save a lesson when solving a non-obvious bug. The reminder
|
|
136
|
+
// is one line to minimize per-Edit context cost.
|
|
137
|
+
console.log(`[mem] No prior lessons for ${fname} — if you solve a non-obvious bug here, run: claude-mem-lite save --type bugfix --lesson "<one-line root cause + fix>"`);
|
|
118
138
|
}
|
|
139
|
+
// Cooldown applies to BOTH branches so the reminder doesn't spam every Edit.
|
|
140
|
+
cooldown[filePath] = now;
|
|
141
|
+
writeCooldown(cooldown);
|
|
119
142
|
} catch {
|
|
120
143
|
// Silent failure — never block editing
|
|
121
144
|
} finally {
|
|
@@ -25,12 +25,32 @@ export function shouldSkip(text) {
|
|
|
25
25
|
// ─── Intent Detection ───────────────────────────────────────────────────────
|
|
26
26
|
|
|
27
27
|
export const INTENTS = [
|
|
28
|
-
// Error/debug intent
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
// Error/debug intent — highest priority, most actionable
|
|
29
|
+
// CJK: 不工作/有问题/挂了 from real prompts; 异常/失败/排查/定位/诊断 from dev vocabulary
|
|
30
|
+
{ pattern: /error|bug|crash|broken|fail(?:ed|ing|ure)?|fix(?:ed|ing)?|debug|调试|报错|出错|错误|崩溃|修复|故障|不工作|有问题|出了问题|挂了|异常|失败|解决|排查|定位|诊断/i, type: 'bugfix', limit: 3 },
|
|
31
|
+
// Test intent — test failures surface bugfix memories
|
|
32
|
+
// CJK: 跑测试/写测试/测试用例/覆盖率 from real prompts
|
|
33
|
+
{ pattern: /\btest(?:s|ing)?\b|spec\b|assert|单元测试|测试失败|test fail|测试|跑测试|写测试|测试用例|覆盖率/i, type: 'bugfix', limit: 3 },
|
|
34
|
+
// Review/audit intent — from real data: 审查(6x), 检查(9x), 审核, 代码审核
|
|
35
|
+
{ pattern: /\breview\b|audit|inspect|审查|审核|检查|代码审核|审阅|code.?review/i, type: 'discovery', limit: 3 },
|
|
36
|
+
// Refactor intent — surface past refactor decisions and patterns
|
|
37
|
+
// CJK: 拆分/提取/简化/解耦/清理 from real prompts; 优化代码 = refactor (not perf)
|
|
38
|
+
{ pattern: /refactor|restructur|cleanup|clean up|重构|整理|代码质量|拆分|提取|简化|解耦|清理/i, type: 'refactor', limit: 3 },
|
|
39
|
+
// Performance intent — before decision (so "slow" doesn't get classified as decision)
|
|
40
|
+
// CJK: 卡顿/超时/内存泄漏/优化 from real prompts; 加速/提速 from dev vocabulary
|
|
41
|
+
{ pattern: /performance|perf\b|slow|latency|bottleneck|optimiz|性能|慢|延迟|耗时|效率低|卡顿|超时|内存泄漏|优化|加速|提速/i, type: 'discovery', limit: 3 },
|
|
42
|
+
// Decision/architecture intent
|
|
43
|
+
// CJK: 方案/原因/考虑/权衡/思路 from real prompts
|
|
44
|
+
{ pattern: /why\b|decided|architecture|design\b|为什么|决定|架构|设计|方案|原因|考虑|权衡|思路/i, type: 'decision', limit: 3 },
|
|
45
|
+
// Database/schema intent — surface migration decisions
|
|
46
|
+
// CJK: 索引/查询/建表/改表 from dev vocabulary
|
|
47
|
+
{ pattern: /schema|migration|数据库|迁移|database\b|表结构|字段|索引|查询|建表|改表/i, type: 'decision', limit: 3 },
|
|
48
|
+
// Implementation intent — surface related feature history (no type filter for broader recall)
|
|
49
|
+
// CJK: 开发/编写/创建/构建/做一个/写一个 from real prompts
|
|
50
|
+
{ pattern: /implement|feature\b|add\s+(?:a\s+)?new|实现|添加|新功能|新增|开发|编写|创建|构建|做一个|加一个|写一个/i, type: null, limit: 3 },
|
|
32
51
|
// Recall/history intent (catch-all temporal, lowest priority)
|
|
33
|
-
|
|
52
|
+
// CJK: 刚才/历史/回顾 from real prompts
|
|
53
|
+
{ pattern: /before|previously|last time|remember|之前|上次|以前|记得|刚才|历史|回顾/i, type: null, limit: 5, useRecent: true },
|
|
34
54
|
];
|
|
35
55
|
|
|
36
56
|
export function detectIntent(text) {
|
|
@@ -42,15 +62,15 @@ export function detectIntent(text) {
|
|
|
42
62
|
if (matches.length === 0) return null;
|
|
43
63
|
if (matches.length === 1) return matches[0];
|
|
44
64
|
|
|
45
|
-
// Disambiguation:
|
|
46
|
-
// position-based resolution — the pattern appearing earlier in text wins.
|
|
65
|
+
// Disambiguation: when recall intent overlaps with an actionable intent,
|
|
66
|
+
// use position-based resolution — the pattern appearing earlier in text wins.
|
|
47
67
|
// "I remember we fixed..." → recall leads. "fix the bug from before" → bugfix leads.
|
|
48
68
|
const first = matches[0];
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
const
|
|
52
|
-
const recallPos = text.search(
|
|
53
|
-
if (recallPos <
|
|
69
|
+
const recallMatch = matches.find(m => m.useRecent);
|
|
70
|
+
if (recallMatch && first !== recallMatch) {
|
|
71
|
+
const actionPos = text.search(first.pattern);
|
|
72
|
+
const recallPos = text.search(recallMatch.pattern);
|
|
73
|
+
if (recallPos < actionPos) return recallMatch;
|
|
54
74
|
}
|
|
55
75
|
return first;
|
|
56
76
|
}
|
|
@@ -112,8 +132,13 @@ export function matchRegistrySkillName(text, skillNames) {
|
|
|
112
132
|
|
|
113
133
|
// ─── File Path Detection ─────────────────────────────────────────────────────
|
|
114
134
|
|
|
115
|
-
/** Detect file paths in text */
|
|
135
|
+
/** Detect file paths in text — excludes URLs and pure version numbers */
|
|
116
136
|
export function extractFiles(text) {
|
|
117
137
|
const matches = text.match(/[\w./-]+\.\w{1,10}/g) || [];
|
|
118
|
-
return matches.filter(m =>
|
|
138
|
+
return matches.filter(m =>
|
|
139
|
+
m.includes('.') &&
|
|
140
|
+
!m.startsWith('http') &&
|
|
141
|
+
!m.includes('//') &&
|
|
142
|
+
!/^\d+\.\d+$/.test(m) // Exclude pure version numbers like "3.14" (not paths like "1.0/config.json")
|
|
143
|
+
);
|
|
119
144
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
|
|
5
5
|
|
|
6
6
|
import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
|
|
7
|
-
import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, isPathConfined } from '../utils.mjs';
|
|
7
|
+
import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, isPathConfined, notLowSignalTitleClause } from '../utils.mjs';
|
|
8
8
|
import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { homedir } from 'os';
|
|
@@ -27,6 +27,8 @@ function searchByFts(db, queryText, project, limit, typeFilter) {
|
|
|
27
27
|
|
|
28
28
|
const typeClause = typeFilter ? 'AND o.type = ?' : '';
|
|
29
29
|
const now = Date.now();
|
|
30
|
+
// R1: notLowSignalTitleClause() excludes hook-llm degraded titles
|
|
31
|
+
// ("Modified X", "Worked on X", "Reviewed N files:", raw error logs).
|
|
30
32
|
const sql = `
|
|
31
33
|
SELECT o.id, o.type, o.title, o.lesson_learned,
|
|
32
34
|
${OBS_BM25}
|
|
@@ -40,6 +42,7 @@ function searchByFts(db, queryText, project, limit, typeFilter) {
|
|
|
40
42
|
AND o.importance >= 1
|
|
41
43
|
AND o.created_at_epoch > ?
|
|
42
44
|
AND COALESCE(o.compressed_into, 0) = 0
|
|
45
|
+
AND ${notLowSignalTitleClause('o')}
|
|
43
46
|
${typeClause}
|
|
44
47
|
ORDER BY relevance
|
|
45
48
|
LIMIT ?
|
|
@@ -75,6 +78,7 @@ function searchByFile(db, files, project, limit) {
|
|
|
75
78
|
const escaped = basename.replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
76
79
|
const likePattern = `%${escaped}`;
|
|
77
80
|
|
|
81
|
+
// R1: exclude LOW_SIGNAL degraded titles from file-level recall.
|
|
78
82
|
const rows = db.prepare(`
|
|
79
83
|
SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned
|
|
80
84
|
FROM observations o
|
|
@@ -84,6 +88,7 @@ function searchByFile(db, files, project, limit) {
|
|
|
84
88
|
AND COALESCE(o.compressed_into, 0) = 0
|
|
85
89
|
AND o.created_at_epoch > ?
|
|
86
90
|
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
91
|
+
AND ${notLowSignalTitleClause('o')}
|
|
87
92
|
ORDER BY o.created_at_epoch DESC
|
|
88
93
|
LIMIT ?
|
|
89
94
|
`).all(project, cutoff, file, likePattern, limit);
|
|
@@ -102,6 +107,9 @@ function searchByFile(db, files, project, limit) {
|
|
|
102
107
|
|
|
103
108
|
function searchRecent(db, project, limit) {
|
|
104
109
|
const cutoff = Date.now() - LOOKBACK_MS;
|
|
110
|
+
// R1: exclude LOW_SIGNAL degraded titles from "recent" recall intent
|
|
111
|
+
// (e.g. when user asks "what did I do earlier"). Unqualified alias because
|
|
112
|
+
// this query selects directly from observations with no join.
|
|
105
113
|
return db.prepare(`
|
|
106
114
|
SELECT id, type, title, lesson_learned
|
|
107
115
|
FROM observations
|
|
@@ -109,6 +117,7 @@ function searchRecent(db, project, limit) {
|
|
|
109
117
|
AND importance >= 1
|
|
110
118
|
AND COALESCE(compressed_into, 0) = 0
|
|
111
119
|
AND created_at_epoch > ?
|
|
120
|
+
AND ${notLowSignalTitleClause('')}
|
|
112
121
|
ORDER BY created_at_epoch DESC
|
|
113
122
|
LIMIT ?
|
|
114
123
|
`).all(project, cutoff, limit);
|
package/server.mjs
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
-
import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, getCurrentBranch, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined } from './utils.mjs';
|
|
7
|
+
import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, getCurrentBranch, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined, notLowSignalTitleClause, LOW_SIGNAL_TITLE } from './utils.mjs';
|
|
8
|
+
import { extractCjkLikePatterns } from './nlp.mjs';
|
|
8
9
|
import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
|
|
9
10
|
import { ensureDb, DB_PATH, REGISTRY_DB_PATH, checkFTSIntegrity, rebuildFTS } from './schema.mjs';
|
|
10
11
|
import { reRankWithContext, markSuperseded, extractPRFTerms, expandQueryByConcepts, autoBoostIfNeeded, runIdleCleanup } from './server-internals.mjs';
|
|
11
12
|
import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
12
|
-
import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema } from './tool-schemas.mjs';
|
|
13
|
+
import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema } from './tool-schemas.mjs';
|
|
14
|
+
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
13
15
|
import { basename, join } from 'path';
|
|
14
16
|
import { homedir } from 'os';
|
|
15
17
|
import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
@@ -110,12 +112,14 @@ const server = new McpServer(
|
|
|
110
112
|
'mem_save: Save non-obvious insights (bugfix lessons, architecture decisions).',
|
|
111
113
|
'Search tips: short keywords (2-3 words), filter with obs_type when relevant.',
|
|
112
114
|
'',
|
|
113
|
-
'WHEN TO USE (proactive triggers):',
|
|
114
|
-
' •
|
|
115
|
-
' •
|
|
116
|
-
' •
|
|
117
|
-
' •
|
|
118
|
-
' •
|
|
115
|
+
'WHEN TO USE (proactive triggers during coding):',
|
|
116
|
+
' • About to Edit/Write a file → mem_recall(file="path") FIRST — past bugfixes & lessons',
|
|
117
|
+
' • Test failure or error → mem_search(query="error keywords", obs_type="bugfix")',
|
|
118
|
+
' • Before refactoring → mem_search(query="module-name", obs_type="refactor") for past decisions',
|
|
119
|
+
' • Starting new feature → mem_search(query="feature area") for prior art & patterns',
|
|
120
|
+
' • After fixing a tricky bug → mem_save(type="bugfix", lesson_learned="root cause & fix")',
|
|
121
|
+
' • After architecture decision → mem_save(type="decision", lesson_learned="rationale")',
|
|
122
|
+
' • Hook-injected context mentions #ID → mem_get(ids=[ID]) for full details',
|
|
119
123
|
'',
|
|
120
124
|
'Decision rules (use INSTEAD OF multi-step search):',
|
|
121
125
|
' • "what happened recently?" → mem_recent (NOT search with empty query)',
|
|
@@ -151,26 +155,32 @@ function safeHandler(fn) {
|
|
|
151
155
|
|
|
152
156
|
// Score expression variants for FTS5 queries (see Scoring Model Constants above)
|
|
153
157
|
// TYPE_QUALITY_CASE demotes bugfix (×0.6) and promotes decision/discovery (×1.5/1.3)
|
|
158
|
+
// R-3: lesson_learned presence adds ×1.3 boost — empirical +6.3pp hit-rate lift on bugfix.
|
|
154
159
|
const FULL_SCORE = `${OBS_BM25}
|
|
155
160
|
* (1.0 + EXP(-0.693 * (? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
|
|
156
161
|
* ${TYPE_QUALITY_CASE}
|
|
157
162
|
* (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
|
|
158
163
|
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
159
|
-
* (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))
|
|
164
|
+
* (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))
|
|
165
|
+
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))`;
|
|
160
166
|
|
|
161
167
|
const SIMPLE_SCORE = `${OBS_BM25}
|
|
162
168
|
* (1.0 + EXP(-0.693 * (? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
|
|
163
169
|
* ${TYPE_QUALITY_CASE}
|
|
164
|
-
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
170
|
+
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
171
|
+
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))`;
|
|
165
172
|
|
|
166
173
|
/**
|
|
167
174
|
* Build an FTS5 observation search query.
|
|
168
175
|
* @param {'full'|'simple'} scoring - full includes project boost + access bonus
|
|
169
|
-
* @param {object} opts - { multiplier, withSnippet, withOffset }
|
|
176
|
+
* @param {object} opts - { multiplier, withSnippet, withOffset, includeNoise }
|
|
177
|
+
* includeNoise=true keeps hook-llm fallback titles ("Modified X", "Worked on X", etc.);
|
|
178
|
+
* default false mirrors the filter already applied in hook-memory.mjs / user-prompt-search.js.
|
|
170
179
|
*/
|
|
171
|
-
function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset } = {}) {
|
|
180
|
+
function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset, includeNoise } = {}) {
|
|
172
181
|
const scoreExpr = scoring === 'full' ? FULL_SCORE : SIMPLE_SCORE;
|
|
173
182
|
const mult = multiplier ? ` * ${multiplier}` : '';
|
|
183
|
+
const lowSignalClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
|
|
174
184
|
return `
|
|
175
185
|
SELECT o.id, o.type, o.title, o.subtitle, o.project, o.created_at, o.importance,
|
|
176
186
|
o.files_modified,
|
|
@@ -187,6 +197,7 @@ function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset } = {})
|
|
|
187
197
|
AND (? IS NULL OR o.created_at_epoch <= ?)
|
|
188
198
|
AND (? IS NULL OR COALESCE(o.importance, 1) >= ?)
|
|
189
199
|
AND (? IS NULL OR o.branch = ?)
|
|
200
|
+
${lowSignalClause}
|
|
190
201
|
ORDER BY score
|
|
191
202
|
LIMIT ?${withOffset ? ' OFFSET ?' : ''}`;
|
|
192
203
|
}
|
|
@@ -221,12 +232,14 @@ function ftsRowToResult(r, { scoreMultiplier, snippet } = {}) {
|
|
|
221
232
|
function searchObservations(ctx) {
|
|
222
233
|
const { ftsQuery, args, epochFrom, epochTo, perSourceLimit, perSourceOffset, currentProject, limit } = ctx;
|
|
223
234
|
const results = [];
|
|
235
|
+
// R-1: hide hook-llm fallback titles unless caller explicitly opts in via include_noise=true.
|
|
236
|
+
const includeNoise = args.include_noise === true;
|
|
224
237
|
|
|
225
238
|
if (ftsQuery) {
|
|
226
239
|
const now = Date.now();
|
|
227
240
|
const projectBoost = args.project ? null : currentProject;
|
|
228
241
|
|
|
229
|
-
const rows = db.prepare(buildObsFtsQuery('full', { withSnippet: true, withOffset: true }))
|
|
242
|
+
const rows = db.prepare(buildObsFtsQuery('full', { withSnippet: true, withOffset: true, includeNoise }))
|
|
230
243
|
.all(...buildObsFtsParams({ now, projectBoost, ftsQuery, args, epochFrom, epochTo, limit: perSourceLimit, offset: perSourceOffset }));
|
|
231
244
|
for (const r of rows) results.push(ftsRowToResult(r, { snippet: true }));
|
|
232
245
|
|
|
@@ -235,7 +248,7 @@ function searchObservations(ctx) {
|
|
|
235
248
|
const orQuery = relaxFtsQueryToOr(ftsQuery);
|
|
236
249
|
if (orQuery) {
|
|
237
250
|
try {
|
|
238
|
-
const orRows = db.prepare(buildObsFtsQuery('full', { multiplier: 0.5, withSnippet: true, withOffset: true }))
|
|
251
|
+
const orRows = db.prepare(buildObsFtsQuery('full', { multiplier: 0.5, withSnippet: true, withOffset: true, includeNoise }))
|
|
239
252
|
.all(...buildObsFtsParams({ now, projectBoost, ftsQuery: orQuery, args, epochFrom, epochTo, limit: perSourceLimit, offset: perSourceOffset }));
|
|
240
253
|
for (const r of orRows) results.push(ftsRowToResult(r, { snippet: true }));
|
|
241
254
|
} catch (e) { debugCatch(e, 'searchObservations-or-fallback'); }
|
|
@@ -245,8 +258,8 @@ function searchObservations(ctx) {
|
|
|
245
258
|
// Two-phase query expansion for sparse results (only when well below limit)
|
|
246
259
|
if (rows.length > 0 && results.length < Math.ceil(limit / 2)) {
|
|
247
260
|
const existingIds = new Set(results.map(r => r.id));
|
|
248
|
-
expandObsByConceptCo(ctx, now, existingIds, results);
|
|
249
|
-
expandObsByPRF(ctx, now, rows.length, existingIds, results);
|
|
261
|
+
expandObsByConceptCo(ctx, now, existingIds, results, includeNoise);
|
|
262
|
+
expandObsByPRF(ctx, now, rows.length, existingIds, results, includeNoise);
|
|
250
263
|
}
|
|
251
264
|
|
|
252
265
|
// Vector search + RRF hybrid merge
|
|
@@ -275,6 +288,9 @@ function searchObservations(ctx) {
|
|
|
275
288
|
if (epochTo !== null && obs.created_at_epoch > epochTo) continue;
|
|
276
289
|
if (args.importance && (obs.importance ?? 1) < args.importance) continue;
|
|
277
290
|
if (args.branch && obs.branch !== args.branch) continue;
|
|
291
|
+
// R-1: parity with FTS5 WHERE — vector path must also reject LOW_SIGNAL titles
|
|
292
|
+
// so RRF cannot re-admit what the SQL clause excluded.
|
|
293
|
+
if (!includeNoise && obs.title && LOW_SIGNAL_TITLE.test(obs.title)) continue;
|
|
278
294
|
resultMap.set(vr.id, { source: 'obs', id: obs.id, type: obs.type, title: obs.title, subtitle: obs.subtitle, project: obs.project, date: obs.created_at, importance: obs.importance, files_modified: obs.files_modified, snippet: '' });
|
|
279
295
|
}
|
|
280
296
|
}
|
|
@@ -294,6 +310,7 @@ function searchObservations(ctx) {
|
|
|
294
310
|
if (epochTo !== null && obs.created_at_epoch > epochTo) continue;
|
|
295
311
|
if (args.importance && (obs.importance ?? 1) < args.importance) continue;
|
|
296
312
|
if (args.branch && obs.branch !== args.branch) continue;
|
|
313
|
+
if (!includeNoise && obs.title && LOW_SIGNAL_TITLE.test(obs.title)) continue;
|
|
297
314
|
results.push({ source: 'obs', id: obs.id, type: obs.type, title: obs.title, subtitle: obs.subtitle, project: obs.project, date: obs.created_at, importance: obs.importance, files_modified: obs.files_modified, score: -vr.similarity, snippet: '' });
|
|
298
315
|
}
|
|
299
316
|
}
|
|
@@ -325,14 +342,14 @@ function searchObservations(ctx) {
|
|
|
325
342
|
return results;
|
|
326
343
|
}
|
|
327
344
|
|
|
328
|
-
function expandObsByConceptCo(ctx, now, existingIds, results) {
|
|
345
|
+
function expandObsByConceptCo(ctx, now, existingIds, results, includeNoise = false) {
|
|
329
346
|
const { ftsQuery, args, epochFrom, epochTo, limit } = ctx;
|
|
330
347
|
if (results.length >= Math.ceil(limit / 2)) return;
|
|
331
348
|
const expanded = expandQueryByConcepts(db, ftsQuery, args.project);
|
|
332
349
|
if (expanded.length === 0) return;
|
|
333
350
|
const expansionFts = expanded.map(c => `"${c.replace(/"/g, '""')}"`).join(' OR ');
|
|
334
351
|
try {
|
|
335
|
-
const expRows = db.prepare(buildObsFtsQuery('simple'))
|
|
352
|
+
const expRows = db.prepare(buildObsFtsQuery('simple', { includeNoise }))
|
|
336
353
|
.all(...buildObsFtsParams({ now, ftsQuery: expansionFts, args, epochFrom, epochTo, limit }));
|
|
337
354
|
for (const r of expRows) {
|
|
338
355
|
if (!existingIds.has(r.id)) {
|
|
@@ -343,7 +360,7 @@ function expandObsByConceptCo(ctx, now, existingIds, results) {
|
|
|
343
360
|
} catch (e) { debugLog('WARN', 'mem_search', `concept expansion error: ${e.message}`); }
|
|
344
361
|
}
|
|
345
362
|
|
|
346
|
-
function expandObsByPRF(ctx, now, primaryCount, existingIds, results) {
|
|
363
|
+
function expandObsByPRF(ctx, now, primaryCount, existingIds, results, includeNoise = false) {
|
|
347
364
|
const { ftsQuery, args, epochFrom, epochTo, limit } = ctx;
|
|
348
365
|
if (primaryCount < 3) return;
|
|
349
366
|
const topResults = db.prepare(`
|
|
@@ -358,7 +375,7 @@ function expandObsByPRF(ctx, now, primaryCount, existingIds, results) {
|
|
|
358
375
|
if (prfTerms.length === 0) return;
|
|
359
376
|
const prfFts = prfTerms.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
|
|
360
377
|
try {
|
|
361
|
-
const prfRows = db.prepare(buildObsFtsQuery('simple'))
|
|
378
|
+
const prfRows = db.prepare(buildObsFtsQuery('simple', { includeNoise }))
|
|
362
379
|
.all(...buildObsFtsParams({ now, ftsQuery: prfFts, args, epochFrom, epochTo, limit }));
|
|
363
380
|
for (const r of prfRows) {
|
|
364
381
|
if (!existingIds.has(r.id)) {
|
|
@@ -453,6 +470,35 @@ function searchPrompts(ctx) {
|
|
|
453
470
|
for (const r of rows) {
|
|
454
471
|
results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, score: r.score });
|
|
455
472
|
}
|
|
473
|
+
// CJK LIKE fallback: FTS5 unicode61 can't tokenize CJK substrings in prompts
|
|
474
|
+
if (rows.length === 0 && args.query) {
|
|
475
|
+
const cjkPatterns = extractCjkLikePatterns(args.query);
|
|
476
|
+
if (cjkPatterns.length > 0) {
|
|
477
|
+
const likeConds = cjkPatterns.map(() => 'p.prompt_text LIKE ?');
|
|
478
|
+
const likeParams = cjkPatterns.map(p => `%${p}%`);
|
|
479
|
+
const fallbackRows = db.prepare(`
|
|
480
|
+
SELECT p.id, p.prompt_text, p.content_session_id, p.created_at
|
|
481
|
+
FROM user_prompts p
|
|
482
|
+
JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
|
|
483
|
+
WHERE (${likeConds.join(' OR ')})
|
|
484
|
+
AND p.prompt_text NOT LIKE '<task-notification>%'
|
|
485
|
+
AND (? IS NULL OR s.project = ?)
|
|
486
|
+
AND (? IS NULL OR p.created_at_epoch >= ?)
|
|
487
|
+
AND (? IS NULL OR p.created_at_epoch <= ?)
|
|
488
|
+
ORDER BY p.created_at_epoch DESC
|
|
489
|
+
LIMIT ? OFFSET ?
|
|
490
|
+
`).all(
|
|
491
|
+
...likeParams,
|
|
492
|
+
args.project ?? null, args.project ?? null,
|
|
493
|
+
epochFrom, epochFrom,
|
|
494
|
+
epochTo, epochTo,
|
|
495
|
+
perSourceLimit, perSourceOffset
|
|
496
|
+
);
|
|
497
|
+
for (const r of fallbackRows) {
|
|
498
|
+
results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, score: 0 });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
456
502
|
} else if (searchType === 'prompts') {
|
|
457
503
|
const params = [];
|
|
458
504
|
const wheres = [];
|
|
@@ -524,7 +570,7 @@ function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, isCros
|
|
|
524
570
|
server.registerTool(
|
|
525
571
|
'mem_search',
|
|
526
572
|
{
|
|
527
|
-
description: 'Search project memory for past bugfixes, decisions, and discoveries. Use when: encountering
|
|
573
|
+
description: 'Search project memory for past bugfixes, decisions, and discoveries. Use proactively when: encountering an error (search with obs_type="bugfix"), investigating a module before changes, or looking for prior art. Returns compact index (use mem_get for full details).',
|
|
528
574
|
inputSchema: memSearchSchema,
|
|
529
575
|
},
|
|
530
576
|
safeHandler(async (args) => {
|
|
@@ -699,7 +745,7 @@ server.registerTool(
|
|
|
699
745
|
server.registerTool(
|
|
700
746
|
'mem_timeline',
|
|
701
747
|
{
|
|
702
|
-
description: 'Browse observations as a timeline around an anchor point. Use when: exploring what happened before/after a specific observation, understanding the sequence of changes that led to a bug, or reviewing a session chronologically.',
|
|
748
|
+
description: 'Browse observations as a timeline around an anchor point. Accepts anchor ID or a query string to auto-find the anchor via FTS. Use when: exploring what happened before/after a specific observation, understanding the sequence of changes that led to a bug, or reviewing a session chronologically. Example: mem_timeline(query="FTS5 search bug") or mem_timeline(anchor=42).',
|
|
703
749
|
inputSchema: memTimelineSchema,
|
|
704
750
|
},
|
|
705
751
|
safeHandler(async (args) => {
|
|
@@ -802,7 +848,7 @@ server.registerTool(
|
|
|
802
848
|
server.registerTool(
|
|
803
849
|
'mem_get',
|
|
804
850
|
{
|
|
805
|
-
description: 'Get full details for one or more records by ID. Use when: hook-injected context mentions a relevant observation ID, or after mem_search to drill into specific results for narrative, lesson_learned, and file details.',
|
|
851
|
+
description: 'Get full details for one or more records by ID. Use when: hook-injected context mentions a relevant observation ID, or after mem_search to drill into specific results for narrative, lesson_learned, and file details. For session results (S#15), pass source="session". For prompt results (P#22), pass source="prompt".',
|
|
806
852
|
inputSchema: memGetSchema,
|
|
807
853
|
},
|
|
808
854
|
safeHandler(async (args) => {
|
|
@@ -925,7 +971,7 @@ server.registerTool(
|
|
|
925
971
|
server.registerTool(
|
|
926
972
|
'mem_save',
|
|
927
973
|
{
|
|
928
|
-
description: 'Save a memory/observation. Use
|
|
974
|
+
description: 'Save a memory/observation with optional lesson_learned. Use after: solving a non-obvious bug (pass lesson_learned="root cause & fix"), making an architecture decision (pass lesson_learned="rationale"), or discovering something not obvious from code. Also when user asks to remember something.',
|
|
929
975
|
inputSchema: memSaveSchema,
|
|
930
976
|
},
|
|
931
977
|
safeHandler(async (args) => {
|
|
@@ -960,18 +1006,20 @@ server.registerTool(
|
|
|
960
1006
|
|
|
961
1007
|
const safeContent = scrubSecrets(args.content);
|
|
962
1008
|
const safeTitle = scrubSecrets(title);
|
|
1009
|
+
const safeLesson = args.lesson_learned ? scrubSecrets(args.lesson_learned) : null;
|
|
963
1010
|
const minhashSig = computeMinHash(safeTitle + ' ' + safeContent);
|
|
964
1011
|
// Append CJK bigrams to text field for FTS5 indexing of Chinese content
|
|
965
|
-
const
|
|
1012
|
+
const indexText = [safeTitle, safeContent, safeLesson].filter(Boolean).join(' ');
|
|
1013
|
+
const bigramText = cjkBigrams(indexText);
|
|
966
1014
|
const textField = bigramText ? safeContent + ' ' + bigramText : safeContent;
|
|
967
1015
|
|
|
968
1016
|
// Atomic: insert observation + observation_files + TF-IDF vector in one transaction
|
|
969
1017
|
const saveFiles = args.files || [];
|
|
970
1018
|
const saveTx = db.transaction(() => {
|
|
971
1019
|
const result = db.prepare(`
|
|
972
|
-
INSERT INTO observations (memory_session_id, project, text, type, title, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, branch, created_at, created_at_epoch)
|
|
973
|
-
VALUES (?, ?, ?, ?, ?, ?, '', '', '[]', ?, ?, ?, ?, ?, ?)
|
|
974
|
-
`).run(sessionId, project, textField, type, safeTitle, safeContent, JSON.stringify(saveFiles), args.importance ?? 2, minhashSig, getCurrentBranch(), now.toISOString(), now.getTime());
|
|
1020
|
+
INSERT INTO observations (memory_session_id, project, text, type, title, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, lesson_learned, branch, created_at, created_at_epoch)
|
|
1021
|
+
VALUES (?, ?, ?, ?, ?, ?, '', '', '[]', ?, ?, ?, ?, ?, ?, ?)
|
|
1022
|
+
`).run(sessionId, project, textField, type, safeTitle, safeContent, JSON.stringify(saveFiles), args.importance ?? 2, minhashSig, safeLesson, getCurrentBranch(), now.toISOString(), now.getTime());
|
|
975
1023
|
const savedId = Number(result.lastInsertRowid);
|
|
976
1024
|
|
|
977
1025
|
// Populate observation_files junction table
|
|
@@ -998,7 +1046,8 @@ server.registerTool(
|
|
|
998
1046
|
});
|
|
999
1047
|
const result = saveTx();
|
|
1000
1048
|
|
|
1001
|
-
|
|
1049
|
+
const lessonNote = safeLesson ? ` 💡lesson captured` : '';
|
|
1050
|
+
return { content: [{ type: 'text', text: `Saved as observation #${result.lastInsertRowid} [${type}] in project "${project}".${lessonNote}` }] };
|
|
1002
1051
|
})
|
|
1003
1052
|
);
|
|
1004
1053
|
|
|
@@ -1486,6 +1535,50 @@ server.registerTool(
|
|
|
1486
1535
|
})
|
|
1487
1536
|
);
|
|
1488
1537
|
|
|
1538
|
+
// ─── Tool: mem_optimize ────────────────────────────────────────────────────
|
|
1539
|
+
|
|
1540
|
+
server.registerTool(
|
|
1541
|
+
'mem_optimize',
|
|
1542
|
+
{
|
|
1543
|
+
description: 'LLM-powered database optimization: re-enrich degraded records, normalize concepts, merge related observations, smart-compress old data. Use when: database quality seems low, search results are noisy, or for periodic deep maintenance.',
|
|
1544
|
+
inputSchema: memOptimizeSchema,
|
|
1545
|
+
},
|
|
1546
|
+
safeHandler(async (args) => {
|
|
1547
|
+
const action = args.action || 'preview';
|
|
1548
|
+
|
|
1549
|
+
if (action === 'preview') {
|
|
1550
|
+
const preview = optimizePreview(db);
|
|
1551
|
+
const lines = [
|
|
1552
|
+
`🔍 LLM Optimization Preview:`,
|
|
1553
|
+
` Re-enrich candidates: ${preview.reenrich}`,
|
|
1554
|
+
` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`,
|
|
1555
|
+
` Cluster-merge candidates: ${preview.clusterMerge} clusters`,
|
|
1556
|
+
` Smart-compress candidates: ${preview.smartCompress} clusters`,
|
|
1557
|
+
` Total: ${preview.total} items`,
|
|
1558
|
+
];
|
|
1559
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
const force = action === 'run_all';
|
|
1563
|
+
const results = await optimizeRun(db, {
|
|
1564
|
+
tasks: args.tasks,
|
|
1565
|
+
maxItems: args.max_items || 15,
|
|
1566
|
+
force,
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
const lines = ['🔧 LLM Optimization Results:'];
|
|
1570
|
+
if (results.reenrich) lines.push(` Re-enrich: ${results.reenrich.processed || 0} processed, ${results.reenrich.skipped || 0} skipped`);
|
|
1571
|
+
if (results.normalize) {
|
|
1572
|
+
if (results.normalize.skipped) lines.push(` Normalize: skipped (${results.normalize.reason})`);
|
|
1573
|
+
else lines.push(` Normalize: ${results.normalize.processed || 0} updated, ${results.normalize.groups || 0} synonym groups`);
|
|
1574
|
+
}
|
|
1575
|
+
if (results.clusterMerge) lines.push(` Cluster-merge: ${results.clusterMerge.merged || 0} merged of ${results.clusterMerge.processed || 0} clusters`);
|
|
1576
|
+
if (results.smartCompress) lines.push(` Smart-compress: ${results.smartCompress.compressed || 0} compressed of ${results.smartCompress.processed || 0} clusters`);
|
|
1577
|
+
|
|
1578
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1579
|
+
})
|
|
1580
|
+
);
|
|
1581
|
+
|
|
1489
1582
|
// ─── Tool: mem_registry ─────────────────────────────────────────────────────
|
|
1490
1583
|
|
|
1491
1584
|
server.registerTool(
|
|
@@ -1872,7 +1965,7 @@ server.registerTool(
|
|
|
1872
1965
|
server.registerTool(
|
|
1873
1966
|
'mem_recall',
|
|
1874
1967
|
{
|
|
1875
|
-
description: 'Recall observations related to a file.
|
|
1968
|
+
description: 'Recall observations related to a file. ALWAYS use before editing a file with known issues. Also use when: investigating a file, or before refactoring to recall past bugfixes, decisions, and context.',
|
|
1876
1969
|
inputSchema: memRecallSchema,
|
|
1877
1970
|
},
|
|
1878
1971
|
safeHandler(async (args) => {
|
package/skill.md
CHANGED
|
@@ -1,35 +1,22 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mem
|
|
3
|
-
description:
|
|
3
|
+
description: "Use when: querying past work, managing memories, checking project history, or saving session findings"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Memory
|
|
7
|
-
|
|
8
|
-
Search and browse your project memory efficiently.
|
|
6
|
+
# Memory
|
|
9
7
|
|
|
10
8
|
## Commands
|
|
11
9
|
|
|
12
|
-
- `/mem search <query>` — FTS5 full-text search
|
|
13
|
-
- `/mem recent [n]` —
|
|
14
|
-
- `/mem
|
|
15
|
-
- `/mem
|
|
16
|
-
- `/mem
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
1. **Search** → `mem_search(query="...")` → get compact ID index
|
|
21
|
-
2. **Browse** → `mem_timeline(anchor=ID)` → see surrounding context
|
|
22
|
-
3. **Detail** → `mem_get(ids=[...])` → get full content for specific IDs
|
|
23
|
-
|
|
24
|
-
## Instructions
|
|
25
|
-
|
|
26
|
-
When the user invokes `/mem`, parse their intent:
|
|
10
|
+
- `/mem search <query>` — FTS5 full-text search
|
|
11
|
+
- `/mem recent [n]` — Recent observations (default 5)
|
|
12
|
+
- `/mem recall <file>` — File history before editing
|
|
13
|
+
- `/mem timeline <id>` — Browse around an observation
|
|
14
|
+
- `/mem save <text>` — Save a note
|
|
15
|
+
- `/mem stats` — Memory statistics
|
|
16
|
+
- `/mem cleanup [Nd]` — Purge stale data
|
|
27
17
|
|
|
28
|
-
|
|
29
|
-
- `/mem recent` or `/mem recent 20` → call `mem_search` with no query, limit=N
|
|
30
|
-
- `/mem save <text>` → call `mem_save` with the text as content
|
|
31
|
-
- `/mem stats` → call `mem_stats`
|
|
32
|
-
- `/mem timeline <query>` → call `mem_timeline` with the query
|
|
33
|
-
- `/mem <query>` (no subcommand) → treat as search, call `mem_search`
|
|
18
|
+
## Efficient Workflow (saves 10x tokens)
|
|
34
19
|
|
|
35
|
-
|
|
20
|
+
1. `mem_search(query)` → compact ID index
|
|
21
|
+
2. `mem_timeline(anchor=ID)` → surrounding context
|
|
22
|
+
3. `mem_get(ids=[...])` → full content only when needed
|