claude-mem-lite 2.26.1 → 2.28.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +22 -5
- package/README.zh-CN.md +47 -63
- package/cli.mjs +14 -2
- package/commands/mem.md +1 -1
- package/commands/memory.md +1 -1
- package/commands/recall.md +1 -1
- package/commands/recent.md +1 -1
- package/commands/search.md +1 -1
- package/commands/timeline.md +1 -1
- package/commands/tools.md +9 -20
- package/commands/update.md +1 -1
- package/hook-episode.mjs +1 -1
- package/hook-llm.mjs +31 -4
- package/hook-memory.mjs +9 -3
- package/hook-semaphore.mjs +5 -4
- package/hook-update.mjs +1 -1
- package/hook.mjs +20 -2
- package/hooks/hooks.json +10 -0
- package/install.mjs +30 -13
- package/mem-cli.mjs +171 -30
- package/package.json +5 -1
- package/registry-enricher.mjs +101 -0
- package/registry-github.mjs +54 -0
- package/registry-importer.mjs +358 -0
- package/registry.mjs +39 -2
- package/schema.mjs +5 -5
- package/scoring-sql.mjs +3 -3
- package/scripts/post-tool-use.sh +1 -1
- package/scripts/pre-skill-bridge.js +83 -0
- package/scripts/prompt-search-utils.mjs +47 -2
- package/scripts/user-prompt-search.js +110 -6
- package/server.mjs +207 -31
- package/tfidf.mjs +2 -2
- package/tool-schemas.mjs +9 -2
- package/utils.mjs +22 -3
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// Runs as UserPromptSubmit hook — injects relevant memories before Claude sees the prompt
|
|
4
4
|
// Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
|
|
5
5
|
|
|
6
|
-
import { ensureDb, DB_DIR } from '../schema.mjs';
|
|
7
|
-
import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE } from '../utils.mjs';
|
|
8
|
-
import { writeFileSync, readFileSync } from 'fs';
|
|
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';
|
|
8
|
+
import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
|
-
import {
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
import Database from 'better-sqlite3';
|
|
12
|
+
import { shouldSkip, detectIntent, shouldSkipByDedup, extractFiles, DEDUP_STALE_MS, matchRegistrySkillName } from './prompt-search-utils.mjs';
|
|
11
13
|
|
|
12
14
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
13
15
|
|
|
@@ -152,6 +154,92 @@ function formatResults(rows) {
|
|
|
152
154
|
return lines.join('\n');
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
// ─── Registry Skill Auto-Load ──────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
const SKILL_COOLDOWN_FILE = join(DB_DIR, 'runtime', `.skill-cooldown-${inferProject()}`);
|
|
160
|
+
const SKILL_COOLDOWN_MS = 300_000; // 5 minutes
|
|
161
|
+
const SKILL_TOKEN_LIMIT = 16000; // ~4000 tokens
|
|
162
|
+
|
|
163
|
+
function loadManagedSkillNames() {
|
|
164
|
+
if (!existsSync(REGISTRY_DB_PATH)) return new Set();
|
|
165
|
+
try {
|
|
166
|
+
const rdb = new Database(REGISTRY_DB_PATH, { readonly: true });
|
|
167
|
+
rdb.pragma('busy_timeout = 500');
|
|
168
|
+
try {
|
|
169
|
+
const rows = rdb.prepare(`
|
|
170
|
+
SELECT name FROM resources
|
|
171
|
+
WHERE status = 'active' AND local_path LIKE '%/.claude-mem-lite/managed/%'
|
|
172
|
+
`).all();
|
|
173
|
+
return new Set(rows.map(r => r.name.toLowerCase()));
|
|
174
|
+
} finally { rdb.close(); }
|
|
175
|
+
} catch { return new Set(); }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function toPortablePath(absPath) {
|
|
179
|
+
const home = homedir();
|
|
180
|
+
return absPath.startsWith(home) ? '~' + absPath.slice(home.length) : absPath;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function loadSkillContent(skillName) {
|
|
184
|
+
if (!existsSync(REGISTRY_DB_PATH)) return null;
|
|
185
|
+
try {
|
|
186
|
+
const rdb = new Database(REGISTRY_DB_PATH, { readonly: true });
|
|
187
|
+
rdb.pragma('busy_timeout = 500');
|
|
188
|
+
try {
|
|
189
|
+
const row = rdb.prepare(`
|
|
190
|
+
SELECT name, type, local_path FROM resources
|
|
191
|
+
WHERE status = 'active'
|
|
192
|
+
AND (name = ? OR invocation_name = ?)
|
|
193
|
+
AND local_path LIKE '%/.claude-mem-lite/managed/%'
|
|
194
|
+
LIMIT 1
|
|
195
|
+
`).get(skillName, skillName);
|
|
196
|
+
|
|
197
|
+
if (!row || !row.local_path) return null;
|
|
198
|
+
|
|
199
|
+
let path = row.local_path;
|
|
200
|
+
if (!path.endsWith('.md')) {
|
|
201
|
+
const candidate = join(path, 'SKILL.md');
|
|
202
|
+
if (existsSync(candidate)) path = candidate;
|
|
203
|
+
}
|
|
204
|
+
// Path confinement check — prevent LIKE bypass via '../' in local_path
|
|
205
|
+
const managedBase = join(homedir(), '.claude-mem-lite');
|
|
206
|
+
if (!isPathConfined(path, managedBase)) return null;
|
|
207
|
+
if (!existsSync(path)) return null;
|
|
208
|
+
|
|
209
|
+
const portablePath = toPortablePath(path);
|
|
210
|
+
const sourceLabel = row.type === 'agent' ? 'managed-agent' : 'managed-skill';
|
|
211
|
+
const content = readFileSync(path, 'utf8');
|
|
212
|
+
if (content.length > SKILL_TOKEN_LIMIT) {
|
|
213
|
+
return `<skill-auto-loaded name="${row.name}" source="${sourceLabel}" path="${portablePath}" truncated="true">\n${content.slice(0, 800)}\n...\n</skill-auto-loaded>\nSkill truncated. Full content: Read("${portablePath}") or mem_use(name="${row.name}")`;
|
|
214
|
+
}
|
|
215
|
+
return `<skill-auto-loaded name="${row.name}" source="${sourceLabel}" path="${portablePath}">\n${content}\n</skill-auto-loaded>\nFollow the instructions above. Reload: Read("${portablePath}")`;
|
|
216
|
+
} finally { rdb.close(); }
|
|
217
|
+
} catch { return null; }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getSkillCooldown() {
|
|
221
|
+
try {
|
|
222
|
+
const raw = readFileSync(SKILL_COOLDOWN_FILE, 'utf8');
|
|
223
|
+
const data = JSON.parse(raw);
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
const cleaned = {};
|
|
226
|
+
for (const [k, v] of Object.entries(data)) {
|
|
227
|
+
if (now - v < SKILL_COOLDOWN_MS) cleaned[k] = v;
|
|
228
|
+
}
|
|
229
|
+
return cleaned;
|
|
230
|
+
} catch { return {}; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function setSkillCooldown(name) {
|
|
234
|
+
try {
|
|
235
|
+
const data = getSkillCooldown();
|
|
236
|
+
data[name] = Date.now();
|
|
237
|
+
const tmp = SKILL_COOLDOWN_FILE + `.tmp-${process.pid}`;
|
|
238
|
+
writeFileSync(tmp, JSON.stringify(data));
|
|
239
|
+
renameSync(tmp, SKILL_COOLDOWN_FILE);
|
|
240
|
+
} catch { /* silent */ }
|
|
241
|
+
}
|
|
242
|
+
|
|
155
243
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
156
244
|
|
|
157
245
|
async function main() {
|
|
@@ -209,9 +297,9 @@ async function main() {
|
|
|
209
297
|
}
|
|
210
298
|
|
|
211
299
|
const candidateIds = rows.map(r => r.id);
|
|
212
|
-
|
|
300
|
+
const dedupSkip = shouldSkipByDedup(candidateIds, INJECTED_IDS_FILE);
|
|
213
301
|
|
|
214
|
-
const output = formatResults(rows);
|
|
302
|
+
const output = !dedupSkip ? formatResults(rows) : null;
|
|
215
303
|
if (output) {
|
|
216
304
|
process.stdout.write(output + '\n');
|
|
217
305
|
// Write injected IDs for dedup with hook.mjs handleUserPrompt + self-dedup
|
|
@@ -228,6 +316,22 @@ async function main() {
|
|
|
228
316
|
}));
|
|
229
317
|
} catch {}
|
|
230
318
|
}
|
|
319
|
+
|
|
320
|
+
// ─── L1: Registry skill auto-load ───────────────────────────────────
|
|
321
|
+
try {
|
|
322
|
+
const skillNames = loadManagedSkillNames();
|
|
323
|
+
const matched = matchRegistrySkillName(promptText, skillNames);
|
|
324
|
+
if (matched) {
|
|
325
|
+
const cooldown = getSkillCooldown();
|
|
326
|
+
if (!cooldown[matched]) {
|
|
327
|
+
const skillContent = loadSkillContent(matched);
|
|
328
|
+
if (skillContent) {
|
|
329
|
+
process.stdout.write('\n' + skillContent + '\n');
|
|
330
|
+
setSkillCooldown(matched);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} catch { /* silent — never block on registry failure */ }
|
|
231
335
|
} catch {
|
|
232
336
|
// Hooks must never break Claude Code — swallow all errors
|
|
233
337
|
} finally {
|
package/server.mjs
CHANGED
|
@@ -4,13 +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 } 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 } from './utils.mjs';
|
|
8
8
|
import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
|
|
9
9
|
import { ensureDb, DB_PATH, REGISTRY_DB_PATH, checkFTSIntegrity, rebuildFTS } from './schema.mjs';
|
|
10
10
|
import { reRankWithContext, markSuperseded, extractPRFTerms, expandQueryByConcepts, autoBoostIfNeeded, runIdleCleanup } from './server-internals.mjs';
|
|
11
11
|
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 } from './tool-schemas.mjs';
|
|
13
|
-
import { basename } from 'path';
|
|
12
|
+
import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema } from './tool-schemas.mjs';
|
|
13
|
+
import { basename, join } from 'path';
|
|
14
|
+
import { homedir } from 'os';
|
|
14
15
|
import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
15
16
|
import { searchResources } from './registry-retriever.mjs';
|
|
16
17
|
import { getVocabulary, rebuildVocabulary, _resetVocabCache, computeVector, vectorSearch, rrfMerge } from './tfidf.mjs';
|
|
@@ -21,7 +22,7 @@ const { version: PKG_VERSION } = require('./package.json');
|
|
|
21
22
|
|
|
22
23
|
// ─── Database ───────────────────────────────────────────────────────────────
|
|
23
24
|
|
|
24
|
-
import { rmSync } from 'fs';
|
|
25
|
+
import { rmSync, existsSync, readFileSync } from 'fs';
|
|
25
26
|
|
|
26
27
|
let db;
|
|
27
28
|
try {
|
|
@@ -115,6 +116,15 @@ const server = new McpServer(
|
|
|
115
116
|
' • Starting work on a module → recall past decisions: claude-mem-lite search "module-name" --type decision',
|
|
116
117
|
' • After solving a non-obvious problem → save the lesson: mem_save with lesson_learned',
|
|
117
118
|
' • When hook-injected context mentions a relevant ID → get details: claude-mem-lite get ID',
|
|
119
|
+
'',
|
|
120
|
+
'Decision rules (use INSTEAD OF multi-step search):',
|
|
121
|
+
' • "what happened recently?" → mem_recent (NOT search with empty query)',
|
|
122
|
+
' • "what do we know about file.mjs?" → mem_recall (NOT grep + manual search)',
|
|
123
|
+
' • "show me around observation #42" → mem_timeline (NOT mem_get + manual navigation)',
|
|
124
|
+
' • "clean up old/duplicate memories" → mem_maintain (NOT manual mem_delete loop)',
|
|
125
|
+
' • "is the search index healthy?" → mem_fts_check (NOT manual COUNT queries)',
|
|
126
|
+
' • "overview of memory tiers" → mem_browse (NOT mem_search + manual grouping)',
|
|
127
|
+
' • "export for backup" → mem_export (NOT manual SELECT queries)',
|
|
118
128
|
].join('\n'),
|
|
119
129
|
},
|
|
120
130
|
);
|
|
@@ -514,7 +524,7 @@ function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, isCros
|
|
|
514
524
|
server.registerTool(
|
|
515
525
|
'mem_search',
|
|
516
526
|
{
|
|
517
|
-
description: '
|
|
527
|
+
description: 'Search project memory for past bugfixes, decisions, and discoveries. Use when: encountering a familiar error, investigating a module before changes, or looking for prior art on a problem. Returns compact index (use mem_get for full details).',
|
|
518
528
|
inputSchema: memSearchSchema,
|
|
519
529
|
},
|
|
520
530
|
safeHandler(async (args) => {
|
|
@@ -650,7 +660,7 @@ server.registerTool(
|
|
|
650
660
|
server.registerTool(
|
|
651
661
|
'mem_recent',
|
|
652
662
|
{
|
|
653
|
-
description: 'Show most recent observations
|
|
663
|
+
description: 'Show most recent observations. Use when: checking what happened recently in the project, reviewing progress after being away, or verifying that a recent change was captured.',
|
|
654
664
|
inputSchema: memRecentSchema,
|
|
655
665
|
},
|
|
656
666
|
safeHandler(async (args) => {
|
|
@@ -689,7 +699,7 @@ server.registerTool(
|
|
|
689
699
|
server.registerTool(
|
|
690
700
|
'mem_timeline',
|
|
691
701
|
{
|
|
692
|
-
description: 'Browse observations as a timeline around an anchor point. Use
|
|
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.',
|
|
693
703
|
inputSchema: memTimelineSchema,
|
|
694
704
|
},
|
|
695
705
|
safeHandler(async (args) => {
|
|
@@ -748,7 +758,9 @@ server.registerTool(
|
|
|
748
758
|
}
|
|
749
759
|
|
|
750
760
|
// Update access_count for anchor (aligned with CLI timeline)
|
|
751
|
-
|
|
761
|
+
try {
|
|
762
|
+
db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
|
|
763
|
+
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
752
764
|
|
|
753
765
|
const projectFilter = args.project ? 'AND project = ?' : '';
|
|
754
766
|
const baseParams = args.project ? [args.project] : [];
|
|
@@ -790,7 +802,7 @@ server.registerTool(
|
|
|
790
802
|
server.registerTool(
|
|
791
803
|
'mem_get',
|
|
792
804
|
{
|
|
793
|
-
description: 'Get full details for one or more records by ID. Use after mem_search to drill into specific
|
|
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.',
|
|
794
806
|
inputSchema: memGetSchema,
|
|
795
807
|
},
|
|
796
808
|
safeHandler(async (args) => {
|
|
@@ -808,11 +820,12 @@ server.registerTool(
|
|
|
808
820
|
prefix = 'P#';
|
|
809
821
|
} else {
|
|
810
822
|
// Increment access_count for retrieved observations (batch UPDATE)
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
823
|
+
try {
|
|
824
|
+
db.prepare(
|
|
825
|
+
`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`
|
|
826
|
+
).run(Date.now(), ...args.ids);
|
|
827
|
+
autoBoostIfNeeded(db, args.ids);
|
|
828
|
+
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
816
829
|
rows = db.prepare(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...args.ids);
|
|
817
830
|
allFields = ['id', 'type', 'title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases', 'files_read', 'files_modified', 'project', 'created_at', 'memory_session_id', 'prompt_number', 'importance', 'related_ids', 'access_count', 'branch', 'superseded_at', 'superseded_by', 'last_accessed_at'];
|
|
818
831
|
prefix = '#';
|
|
@@ -848,7 +861,7 @@ server.registerTool(
|
|
|
848
861
|
server.registerTool(
|
|
849
862
|
'mem_delete',
|
|
850
863
|
{
|
|
851
|
-
description: 'Delete observations by ID. Use confirm=false to preview, confirm=true to execute.
|
|
864
|
+
description: 'Delete observations by ID. Use when: cleaning up incorrect or duplicate observations, removing test data, or when the user asks to forget something. Use confirm=false to preview, confirm=true to execute.',
|
|
852
865
|
inputSchema: memDeleteSchema,
|
|
853
866
|
},
|
|
854
867
|
safeHandler(async (args) => {
|
|
@@ -912,7 +925,7 @@ server.registerTool(
|
|
|
912
925
|
server.registerTool(
|
|
913
926
|
'mem_save',
|
|
914
927
|
{
|
|
915
|
-
description: '
|
|
928
|
+
description: 'Save a memory/observation. Use when: solving a non-obvious bug (save the lesson), making an architecture decision, discovering something not obvious from code alone, or when the user asks to remember something.',
|
|
916
929
|
inputSchema: memSaveSchema,
|
|
917
930
|
},
|
|
918
931
|
safeHandler(async (args) => {
|
|
@@ -994,7 +1007,7 @@ server.registerTool(
|
|
|
994
1007
|
server.registerTool(
|
|
995
1008
|
'mem_stats',
|
|
996
1009
|
{
|
|
997
|
-
description: 'Get statistics
|
|
1010
|
+
description: 'Get memory statistics: counts, types, projects, daily activity, data health. Use when: assessing memory system health, checking how much project history exists, or diagnosing search quality issues.',
|
|
998
1011
|
inputSchema: memStatsSchema,
|
|
999
1012
|
},
|
|
1000
1013
|
safeHandler(async (args) => {
|
|
@@ -1104,7 +1117,7 @@ server.registerTool(
|
|
|
1104
1117
|
server.registerTool(
|
|
1105
1118
|
'mem_compress',
|
|
1106
1119
|
{
|
|
1107
|
-
description: 'Compress old low-value observations into weekly summaries. Use preview=true to see candidates first.',
|
|
1120
|
+
description: 'Compress old low-value observations into weekly summaries. Use when: memory database is growing large, observations are months old, or after a major project phase completes. Use preview=true to see candidates first.',
|
|
1108
1121
|
inputSchema: memCompressSchema,
|
|
1109
1122
|
},
|
|
1110
1123
|
safeHandler(async (args) => {
|
|
@@ -1217,7 +1230,7 @@ server.registerTool(
|
|
|
1217
1230
|
server.registerTool(
|
|
1218
1231
|
'mem_maintain',
|
|
1219
1232
|
{
|
|
1220
|
-
description: 'Memory maintenance: scan for duplicates/stale/broken items, then execute cleanup/decay/boost/dedup operations.',
|
|
1233
|
+
description: 'Memory maintenance: scan for duplicates/stale/broken items, then execute cleanup/decay/boost/dedup operations. Use when: search results seem noisy with duplicates, after bulk imports, or during periodic maintenance.',
|
|
1221
1234
|
inputSchema: memMaintainSchema,
|
|
1222
1235
|
},
|
|
1223
1236
|
safeHandler(async (args) => {
|
|
@@ -1478,7 +1491,7 @@ server.registerTool(
|
|
|
1478
1491
|
server.registerTool(
|
|
1479
1492
|
'mem_registry',
|
|
1480
1493
|
{
|
|
1481
|
-
description: 'Manage tool resource registry:
|
|
1494
|
+
description: 'Manage tool resource registry. Use when: looking for a skill or agent to solve a problem, importing tools from a repository, checking what resources are available, or managing installed tools.',
|
|
1482
1495
|
inputSchema: memRegistrySchema,
|
|
1483
1496
|
},
|
|
1484
1497
|
safeHandler(async (args) => {
|
|
@@ -1511,13 +1524,30 @@ server.registerTool(
|
|
|
1511
1524
|
if (results.length === 0) {
|
|
1512
1525
|
return { content: [{ type: 'text', text: `No matching resources for: "${args.query}"` }] };
|
|
1513
1526
|
}
|
|
1527
|
+
const home = homedir();
|
|
1528
|
+
const toPortable = (p) => p && p.startsWith(home) ? '~' + p.slice(home.length) : (p || '');
|
|
1514
1529
|
const lines = results.map(r => {
|
|
1515
1530
|
const qualityBadge = r.quality_tier === 'installed' ? '[✓]' : r.quality_tier === 'verified' ? '[★]' : '[○]';
|
|
1516
1531
|
const categoryLabel = r.category ? ` [${r.category}]` : '';
|
|
1517
|
-
const
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1532
|
+
const isManaged = r.local_path && r.local_path.includes('/.claude-mem-lite/managed/');
|
|
1533
|
+
const portablePath = isManaged ? toPortable(r.local_path) : '';
|
|
1534
|
+
let howToUse;
|
|
1535
|
+
if (isManaged) {
|
|
1536
|
+
// Managed: use Read(path) or mem_use — Skill() won't work for managed resources
|
|
1537
|
+
// Agents always have complete .md paths (e.g., agents/group/agents/name.md)
|
|
1538
|
+
// Only skills can be directory paths (9 cases) — resolve to /SKILL.md
|
|
1539
|
+
const resolvedPath = portablePath.endsWith('.md') ? portablePath : `${portablePath}/SKILL.md`;
|
|
1540
|
+
howToUse = `Read("${resolvedPath}") or mem_use(name="${r.name}"${r.type === 'agent' ? ', type="agent"' : ''})`;
|
|
1541
|
+
} else if (r.invocation_name) {
|
|
1542
|
+
// Native plugin/user skill: Skill() with full invocation name
|
|
1543
|
+
howToUse = r.type === 'skill'
|
|
1544
|
+
? `Skill("${r.invocation_name}")`
|
|
1545
|
+
: `Agent(subagent_type="${r.invocation_name}")`;
|
|
1546
|
+
} else {
|
|
1547
|
+
howToUse = `mem_use(name="${r.name}"${r.type === 'agent' ? ', type="agent"' : ''})`;
|
|
1548
|
+
}
|
|
1549
|
+
const pathLine = portablePath ? `\n Path: ${portablePath}` : '';
|
|
1550
|
+
return `${qualityBadge} ${r.type === 'skill' ? 'S' : 'A'} **${r.name}**${categoryLabel} — ${truncate(r.capability_summary || '', 80)}${pathLine}\n Use: ${howToUse}`;
|
|
1521
1551
|
});
|
|
1522
1552
|
return { content: [{ type: 'text', text: `Found ${results.length} resource(s) for "${args.query}":\n\n${lines.join('\n\n')}` }] };
|
|
1523
1553
|
}
|
|
@@ -1597,16 +1627,160 @@ server.registerTool(
|
|
|
1597
1627
|
return { content: [{ type: 'text', text: `FTS5 reindexed. ${count.c} active resources.` }] };
|
|
1598
1628
|
}
|
|
1599
1629
|
|
|
1600
|
-
|
|
1630
|
+
if (action === 'import_url') {
|
|
1631
|
+
if (!args.url) {
|
|
1632
|
+
return { content: [{ type: 'text', text: 'import_url requires a url parameter' }], isError: true };
|
|
1633
|
+
}
|
|
1634
|
+
const { importFromGitHub } = await import('./registry-importer.mjs');
|
|
1635
|
+
try {
|
|
1636
|
+
const results = await importFromGitHub(rdb, args.url);
|
|
1637
|
+
if (results.length === 0) {
|
|
1638
|
+
return { content: [{ type: 'text', text: `No skills/agents found in: ${args.url}` }] };
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
let enrichMsg = '';
|
|
1642
|
+
if (args.enrich) {
|
|
1643
|
+
const { enrichResource } = await import('./registry-enricher.mjs');
|
|
1644
|
+
let ok = 0;
|
|
1645
|
+
for (const r of results) {
|
|
1646
|
+
const row = rdb.prepare('SELECT local_path FROM resources WHERE id = ?').get(r.id);
|
|
1647
|
+
if (!row?.local_path) continue;
|
|
1648
|
+
try {
|
|
1649
|
+
const content = readFileSync(row.local_path, 'utf8');
|
|
1650
|
+
if (await enrichResource(rdb, r.name, r.type, content)) ok++;
|
|
1651
|
+
} catch {}
|
|
1652
|
+
}
|
|
1653
|
+
enrichMsg = `\nEnriched: ${ok}/${results.length}`;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
const lines = results.map(r => `${r.type === 'skill' ? 'S' : 'A'} ${r.name} (id=${r.id})`);
|
|
1657
|
+
return { content: [{ type: 'text', text: `Imported ${results.length} resource(s) from ${args.url}:\n${lines.join('\n')}${enrichMsg}` }] };
|
|
1658
|
+
} catch (e) {
|
|
1659
|
+
return { content: [{ type: 'text', text: `Import failed: ${e.message}` }], isError: true };
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
if (action === 'enrich') {
|
|
1664
|
+
if (!args.name) {
|
|
1665
|
+
return { content: [{ type: 'text', text: 'enrich requires a name parameter' }], isError: true };
|
|
1666
|
+
}
|
|
1667
|
+
const row = rdb.prepare("SELECT name, type, local_path FROM resources WHERE name = ? AND status = 'active'").get(args.name);
|
|
1668
|
+
if (!row) {
|
|
1669
|
+
return { content: [{ type: 'text', text: `Resource not found: ${args.name}` }], isError: true };
|
|
1670
|
+
}
|
|
1671
|
+
if (!row.local_path) {
|
|
1672
|
+
return { content: [{ type: 'text', text: `No local_path for ${args.name}` }], isError: true };
|
|
1673
|
+
}
|
|
1674
|
+
const enrichBase = join(homedir(), '.claude-mem-lite');
|
|
1675
|
+
if (!isPathConfined(row.local_path, enrichBase)) {
|
|
1676
|
+
return { content: [{ type: 'text', text: `Access denied: path outside managed directory` }], isError: true };
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
const { enrichResource } = await import('./registry-enricher.mjs');
|
|
1680
|
+
try {
|
|
1681
|
+
const content = readFileSync(row.local_path, 'utf8');
|
|
1682
|
+
const ok = await enrichResource(rdb, row.name, row.type, content);
|
|
1683
|
+
return { content: [{ type: 'text', text: ok ? `Enriched: ${args.name}` : `Enrichment failed for ${args.name}` }] };
|
|
1684
|
+
} catch (e) {
|
|
1685
|
+
return { content: [{ type: 'text', text: `Enrich error: ${e.message}` }], isError: true };
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
return { content: [{ type: 'text', text: `Unknown action: ${action}. Valid: search, list, stats, import, remove, reindex, import_url, enrich` }], isError: true };
|
|
1601
1690
|
})
|
|
1602
1691
|
);
|
|
1603
1692
|
|
|
1693
|
+
// ─── Tool: mem_use ──────────────────────────────────────────────────────────
|
|
1694
|
+
|
|
1695
|
+
server.registerTool(
|
|
1696
|
+
'mem_use',
|
|
1697
|
+
{
|
|
1698
|
+
description: 'Load and activate a skill or agent from the managed registry. '
|
|
1699
|
+
+ 'Returns the full skill/agent content for execution. '
|
|
1700
|
+
+ 'Use when: you found a skill via mem_registry search and want to use it, '
|
|
1701
|
+
+ 'or you know a managed skill/agent name and want to load its instructions.',
|
|
1702
|
+
inputSchema: memUseSchema,
|
|
1703
|
+
},
|
|
1704
|
+
safeHandler(async (args) => {
|
|
1705
|
+
const rdb = getRegistryDb();
|
|
1706
|
+
if (!rdb) {
|
|
1707
|
+
return { content: [{ type: 'text', text: 'Registry DB not available.' }], isError: true };
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
const name = args.name.trim();
|
|
1711
|
+
const type = args.type || 'skill';
|
|
1712
|
+
|
|
1713
|
+
// 1. Exact match by name or invocation_name
|
|
1714
|
+
let row = rdb.prepare(`
|
|
1715
|
+
SELECT id, name, type, local_path, invocation_name, capability_summary
|
|
1716
|
+
FROM resources
|
|
1717
|
+
WHERE status = 'active' AND type = ?
|
|
1718
|
+
AND (name = ? OR invocation_name = ?)
|
|
1719
|
+
LIMIT 1
|
|
1720
|
+
`).get(type, name, name);
|
|
1721
|
+
|
|
1722
|
+
// 2. Fuzzy fallback: FTS5 search, take top result
|
|
1723
|
+
if (!row) {
|
|
1724
|
+
const results = searchResources(rdb, name, { type, limit: 1 });
|
|
1725
|
+
if (results.length > 0) {
|
|
1726
|
+
row = rdb.prepare(`SELECT id, name, type, local_path, invocation_name, capability_summary FROM resources WHERE name = ? AND type = ? AND status = 'active'`).get(results[0].name, results[0].type);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
if (!row) {
|
|
1731
|
+
return { content: [{ type: 'text', text: `No ${type} found for "${name}". Try mem_registry(action="search", query="${name}") to browse.` }] };
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// 3. Resolve path: directory skills → SKILL.md (agents always have full .md paths)
|
|
1735
|
+
let skillPath = row.local_path || '';
|
|
1736
|
+
if (skillPath && !skillPath.endsWith('.md')) {
|
|
1737
|
+
for (const candidate of [
|
|
1738
|
+
join(skillPath, 'SKILL.md'),
|
|
1739
|
+
join(skillPath, `skills/${row.name}/SKILL.md`),
|
|
1740
|
+
]) {
|
|
1741
|
+
if (existsSync(candidate)) { skillPath = candidate; break; }
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// 4. Path confinement check — prevent reading arbitrary files via crafted local_path
|
|
1746
|
+
const managedBase = join(homedir(), '.claude-mem-lite');
|
|
1747
|
+
if (skillPath && !isPathConfined(skillPath, managedBase)) {
|
|
1748
|
+
return { content: [{ type: 'text', text: `Access denied: path "${skillPath}" is outside managed directory` }], isError: true };
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// 5. Read content
|
|
1752
|
+
let content;
|
|
1753
|
+
try {
|
|
1754
|
+
content = readFileSync(skillPath, 'utf8');
|
|
1755
|
+
} catch {
|
|
1756
|
+
const msg = skillPath.endsWith('.md')
|
|
1757
|
+
? `Found ${type} "${row.name}" but cannot read file: ${skillPath}`
|
|
1758
|
+
: `Found ${type} "${row.name}" but no .md file in: ${skillPath}`;
|
|
1759
|
+
return { content: [{ type: 'text', text: msg }], isError: true };
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// 5. Record invocation
|
|
1763
|
+
try {
|
|
1764
|
+
rdb.prepare(`
|
|
1765
|
+
INSERT INTO invocations (resource_id, session_id, trigger, adopted, outcome)
|
|
1766
|
+
VALUES (?, ?, 'user_explicit', 1, 'success')
|
|
1767
|
+
`).run(row.id, process.env.CLAUDE_SESSION_ID || 'unknown');
|
|
1768
|
+
} catch { /* non-critical */ }
|
|
1769
|
+
|
|
1770
|
+
const _home = homedir();
|
|
1771
|
+
const portablePath = skillPath && skillPath.startsWith(_home) ? '~' + skillPath.slice(_home.length) : (skillPath || '');
|
|
1772
|
+
const pathAttr = portablePath ? ` path="${portablePath}"` : '';
|
|
1773
|
+
const reloadHint = portablePath ? ` Reload: Read("${portablePath}")` : '';
|
|
1774
|
+
return { content: [{ type: 'text', text: `<skill-loaded name="${row.name}" type="${row.type}"${pathAttr}>\n${content}\n</skill-loaded>\n\nFollow the instructions above to execute this ${row.type}.${reloadHint}` }] };
|
|
1775
|
+
}),
|
|
1776
|
+
);
|
|
1777
|
+
|
|
1604
1778
|
// ─── Tool: mem_update ────────────────────────────────────────────────────────
|
|
1605
1779
|
|
|
1606
1780
|
server.registerTool(
|
|
1607
1781
|
'mem_update',
|
|
1608
1782
|
{
|
|
1609
|
-
description: 'Update an existing observation in-place. Preserves original ID and references.',
|
|
1783
|
+
description: 'Update an existing observation in-place. Use when: an observation needs correction, additional context was discovered later, or the user asks to update a specific memory. Preserves original ID and references.',
|
|
1610
1784
|
inputSchema: memUpdateSchema,
|
|
1611
1785
|
},
|
|
1612
1786
|
safeHandler(async (args) => {
|
|
@@ -1658,7 +1832,7 @@ server.registerTool(
|
|
|
1658
1832
|
server.registerTool(
|
|
1659
1833
|
'mem_export',
|
|
1660
1834
|
{
|
|
1661
|
-
description: 'Export observations as JSON or JSONL
|
|
1835
|
+
description: 'Export observations as JSON or JSONL. Use when: backing up memory before migration, sharing observations between machines, or creating a snapshot before major changes.',
|
|
1662
1836
|
inputSchema: memExportSchema,
|
|
1663
1837
|
},
|
|
1664
1838
|
safeHandler(async (args) => {
|
|
@@ -1698,7 +1872,7 @@ server.registerTool(
|
|
|
1698
1872
|
server.registerTool(
|
|
1699
1873
|
'mem_recall',
|
|
1700
1874
|
{
|
|
1701
|
-
description: 'Recall observations related to a file. Use
|
|
1875
|
+
description: 'Recall observations related to a file. Use when: about to edit a file, investigating a file with past issues, or before refactoring to recall past bugfixes, decisions, and context.',
|
|
1702
1876
|
inputSchema: memRecallSchema,
|
|
1703
1877
|
},
|
|
1704
1878
|
safeHandler(async (args) => {
|
|
@@ -1724,7 +1898,9 @@ server.registerTool(
|
|
|
1724
1898
|
// Update access_count for recalled observations
|
|
1725
1899
|
const recalledIds = rows.map(r => r.id);
|
|
1726
1900
|
const ph = recalledIds.map(() => '?').join(',');
|
|
1727
|
-
|
|
1901
|
+
try {
|
|
1902
|
+
db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...recalledIds);
|
|
1903
|
+
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
1728
1904
|
|
|
1729
1905
|
const lines = [`History for ${filename} (${rows.length} observation${rows.length !== 1 ? 's' : ''}):\n`];
|
|
1730
1906
|
for (const r of rows) {
|
|
@@ -1741,7 +1917,7 @@ server.registerTool(
|
|
|
1741
1917
|
server.registerTool(
|
|
1742
1918
|
'mem_fts_check',
|
|
1743
1919
|
{
|
|
1744
|
-
description: 'Check FTS5 index integrity or rebuild indexes. Use when search results seem wrong or after database recovery.',
|
|
1920
|
+
description: 'Check FTS5 index integrity or rebuild indexes. Use when: search results seem wrong or missing, after database recovery, or after manual DB edits.',
|
|
1745
1921
|
inputSchema: memFtsCheckSchema,
|
|
1746
1922
|
},
|
|
1747
1923
|
safeHandler(async (args) => {
|
|
@@ -1767,7 +1943,7 @@ server.registerTool(
|
|
|
1767
1943
|
server.registerTool(
|
|
1768
1944
|
'mem_browse',
|
|
1769
1945
|
{
|
|
1770
|
-
description: 'Tier-grouped memory dashboard.
|
|
1946
|
+
description: 'Tier-grouped memory dashboard. Use when: getting an overview of memory health, seeing how observations are distributed across tiers, or assessing what to compress or clean up.',
|
|
1771
1947
|
inputSchema: memBrowseSchema,
|
|
1772
1948
|
},
|
|
1773
1949
|
safeHandler(async (args) => {
|
package/tfidf.mjs
CHANGED
|
@@ -381,7 +381,7 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
|
|
|
381
381
|
|
|
382
382
|
// Fallback: if time-window yields too few, scan without time constraint
|
|
383
383
|
if (rows.length < VECTOR_MIN_RESULTS) {
|
|
384
|
-
params
|
|
384
|
+
const fallbackParams = [...params, limit];
|
|
385
385
|
rows = db.prepare(`
|
|
386
386
|
SELECT ov.observation_id, ov.vector
|
|
387
387
|
FROM observation_vectors ov
|
|
@@ -389,7 +389,7 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
|
|
|
389
389
|
WHERE ${wheres.join(' AND ')}
|
|
390
390
|
ORDER BY o.created_at_epoch DESC
|
|
391
391
|
LIMIT ?
|
|
392
|
-
`).all(...
|
|
392
|
+
`).all(...fallbackParams);
|
|
393
393
|
}
|
|
394
394
|
|
|
395
395
|
const results = [];
|
package/tool-schemas.mjs
CHANGED
|
@@ -130,12 +130,12 @@ export const memFtsCheckSchema = {
|
|
|
130
130
|
};
|
|
131
131
|
|
|
132
132
|
export const memRegistrySchema = {
|
|
133
|
-
action: z.enum(['list', 'stats', 'search', 'import', 'remove', 'reindex']).describe('Registry operation'),
|
|
133
|
+
action: z.enum(['list', 'stats', 'search', 'import', 'remove', 'reindex', 'import_url', 'enrich']).describe('Registry operation'),
|
|
134
134
|
query: z.string().optional().describe('Search query — keywords describing what you need (for search)'),
|
|
135
135
|
type: z.enum(['skill', 'agent']).optional().describe('Filter by resource type (for list/search)'),
|
|
136
136
|
name: z.string().optional().describe('Resource name (for import/remove)'),
|
|
137
137
|
resource_type: z.enum(['skill', 'agent']).optional().describe('Resource type (for import/remove)'),
|
|
138
|
-
source: z.enum(['preinstalled', 'user']).optional().describe('Source (for import, default: user)'),
|
|
138
|
+
source: z.enum(['preinstalled', 'user', 'github']).optional().describe('Source (for import, default: user)'),
|
|
139
139
|
repo_url: z.string().optional().describe('GitHub repository URL (for import)'),
|
|
140
140
|
local_path: z.string().optional().describe('Local file path (for import)'),
|
|
141
141
|
invocation_name: z.string().optional().describe('Invocation name like "plugin:skill" (for import)'),
|
|
@@ -146,10 +146,17 @@ export const memRegistrySchema = {
|
|
|
146
146
|
keywords: z.string().optional().describe('Search keywords (for import)'),
|
|
147
147
|
tech_stack: z.string().optional().describe('Technology stack tags (for import)'),
|
|
148
148
|
use_cases: z.string().optional().describe('Usage scenarios (for import)'),
|
|
149
|
+
url: z.string().optional().describe('GitHub repository URL (for import_url action)'),
|
|
150
|
+
enrich: coerceBool.optional().describe('Auto-enrich imported resources (for import_url action)'),
|
|
149
151
|
category: z.string().optional().describe("Filter by category (e.g., 'testing', 'code-quality', 'debugging')"),
|
|
150
152
|
quality: z.enum(['installed', 'verified', 'community']).optional().describe('Filter by quality tier (default: all)'),
|
|
151
153
|
};
|
|
152
154
|
|
|
155
|
+
export const memUseSchema = {
|
|
156
|
+
name: z.string().min(1).describe('Skill or agent name to load (exact name or search query)'),
|
|
157
|
+
type: z.enum(['skill', 'agent']).optional().describe('Resource type (default: skill)'),
|
|
158
|
+
};
|
|
159
|
+
|
|
153
160
|
export const memBrowseSchema = {
|
|
154
161
|
project: z.string().optional().describe('Filter by project (default: inferred from CWD)'),
|
|
155
162
|
tier: z.enum(['working', 'active', 'archive']).optional().describe('Show only this tier'),
|
package/utils.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Used by server.mjs, hook.mjs, and tests
|
|
3
3
|
|
|
4
4
|
|
|
5
|
-
import { basename, dirname } from 'path';
|
|
5
|
+
import { basename, dirname, resolve, sep } from 'path';
|
|
6
6
|
import { execSync } from 'child_process';
|
|
7
7
|
|
|
8
8
|
// ─── Re-exports from extracted modules ──────────────────────────────────────
|
|
@@ -27,6 +27,21 @@ export const COMPRESSED_AUTO = -1;
|
|
|
27
27
|
/** compressed_into sentinel: pending user-confirmed purge (marked by idle cleanup) */
|
|
28
28
|
export const COMPRESSED_PENDING_PURGE = -2;
|
|
29
29
|
|
|
30
|
+
// ─── Path Safety ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if a resolved path is confined within an allowed base directory.
|
|
34
|
+
* Prevents path traversal attacks via '../' sequences.
|
|
35
|
+
* @param {string} candidate Path to check
|
|
36
|
+
* @param {string} allowedBase Base directory the path must stay within
|
|
37
|
+
* @returns {boolean} true if safe
|
|
38
|
+
*/
|
|
39
|
+
export function isPathConfined(candidate, allowedBase) {
|
|
40
|
+
const resolved = resolve(candidate);
|
|
41
|
+
const base = resolve(allowedBase);
|
|
42
|
+
return resolved === base || resolved.startsWith(base + sep);
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
// ─── Token Estimation ─────────────────────────────────────────────────────
|
|
31
46
|
|
|
32
47
|
/**
|
|
@@ -157,9 +172,11 @@ export function isRelatedToEpisode(episode, newFiles) {
|
|
|
157
172
|
* @param {string} toolName Name of the tool (Edit, Write, Bash, etc.)
|
|
158
173
|
* @param {object} input Tool input parameters
|
|
159
174
|
* @param {string} resp Tool response text
|
|
175
|
+
* @param {object} [opts] Optional signals from detectBashSignificance
|
|
176
|
+
* @param {boolean} [opts.isError] If provided, overrides inline error regex detection
|
|
160
177
|
* @returns {string} Concise description of the action
|
|
161
178
|
*/
|
|
162
|
-
export function makeEntryDesc(toolName, input, resp) {
|
|
179
|
+
export function makeEntryDesc(toolName, input, resp, opts) {
|
|
163
180
|
switch (toolName) {
|
|
164
181
|
case 'Edit':
|
|
165
182
|
return `${basename(input.file_path || '')}: "${truncate(input.old_string || '', 40)}" → "${truncate(input.new_string || '', 40)}"`;
|
|
@@ -169,7 +186,9 @@ export function makeEntryDesc(toolName, input, resp) {
|
|
|
169
186
|
return `Notebook cell: ${truncate(input.new_source || '', 60)}`;
|
|
170
187
|
case 'Bash': {
|
|
171
188
|
const cmd = truncate(input.command || '', 50);
|
|
172
|
-
|
|
189
|
+
// Use caller-provided bashSig.isError (word-boundary aware) when available;
|
|
190
|
+
// fall back to inline regex only for standalone callers (tests, etc.)
|
|
191
|
+
const isErr = opts?.isError ?? (/\berror\b|\bfail(ed|ure)?\b|\bexception\b|\bpanic\b/i.test(resp) && resp.length > 30);
|
|
173
192
|
const snippet = truncate(resp, 60);
|
|
174
193
|
return isErr ? `${cmd} → ERROR: ${snippet}` : `${cmd} → ${snippet}`;
|
|
175
194
|
}
|