@hir4ta/mneme 0.17.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/plugin.json +29 -0
- package/.mcp.json +18 -0
- package/README.ja.md +400 -0
- package/README.md +410 -0
- package/bin/mneme.js +203 -0
- package/dist/lib/db.js +340 -0
- package/dist/lib/fuzzy-search.js +214 -0
- package/dist/lib/github.js +121 -0
- package/dist/lib/similarity.js +193 -0
- package/dist/lib/utils.js +62 -0
- package/dist/public/apple-touch-icon.png +0 -0
- package/dist/public/assets/index-BgqCALAg.css +1 -0
- package/dist/public/assets/index-EMvn4VEa.js +330 -0
- package/dist/public/assets/react-force-graph-2d-DWoBaKmT.js +46 -0
- package/dist/public/favicon-128-max.png +0 -0
- package/dist/public/favicon-256-max.png +0 -0
- package/dist/public/favicon-32-max.png +0 -0
- package/dist/public/favicon-512-max.png +0 -0
- package/dist/public/favicon-64-max.png +0 -0
- package/dist/public/index.html +15 -0
- package/dist/server.js +4791 -0
- package/dist/servers/db-server.js +30558 -0
- package/dist/servers/search-server.js +30366 -0
- package/hooks/default-tags.json +1055 -0
- package/hooks/hooks.json +61 -0
- package/hooks/post-tool-use.sh +96 -0
- package/hooks/pre-compact.sh +187 -0
- package/hooks/session-end.sh +567 -0
- package/hooks/session-start.sh +380 -0
- package/hooks/user-prompt-submit.sh +253 -0
- package/package.json +77 -0
- package/servers/db-server.ts +993 -0
- package/servers/search-server.ts +675 -0
- package/skills/AGENTS.override.md +5 -0
- package/skills/harvest/skill.md +295 -0
- package/skills/init-mneme/skill.md +101 -0
- package/skills/plan/skill.md +422 -0
- package/skills/report/skill.md +74 -0
- package/skills/resume/skill.md +278 -0
- package/skills/review/skill.md +419 -0
- package/skills/save/skill.md +482 -0
- package/skills/search/skill.md +175 -0
- package/skills/using-mneme/skill.md +185 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# session-start.sh - SessionStart hook for mneme plugin
|
|
4
|
+
#
|
|
5
|
+
# Purpose: Initialize session JSON and inject context via additionalContext
|
|
6
|
+
#
|
|
7
|
+
# Input (stdin): JSON with session_id, cwd, trigger (startup|resume|clear|compact)
|
|
8
|
+
# Output (stdout): JSON with hookSpecificOutput.additionalContext
|
|
9
|
+
# Exit codes: 0 = success (continue session)
|
|
10
|
+
#
|
|
11
|
+
# Dependencies: jq
|
|
12
|
+
#
|
|
13
|
+
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
# Read input from stdin
|
|
17
|
+
input_json=$(cat)
|
|
18
|
+
|
|
19
|
+
# Check for jq (required dependency)
|
|
20
|
+
if ! command -v jq &> /dev/null; then
|
|
21
|
+
echo "[mneme] Warning: jq not found. Install with: brew install jq" >&2
|
|
22
|
+
echo "[mneme] Session tracking disabled for this session." >&2
|
|
23
|
+
exit 0 # Non-blocking - allow session to continue without mneme
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
cwd=$(echo "$input_json" | jq -r '.cwd // empty' 2>/dev/null || echo "")
|
|
27
|
+
session_id=$(echo "$input_json" | jq -r '.session_id // empty' 2>/dev/null || echo "")
|
|
28
|
+
|
|
29
|
+
# If no cwd, use PWD
|
|
30
|
+
if [ -z "$cwd" ]; then
|
|
31
|
+
cwd="${PWD}"
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# Resolve cwd to absolute path
|
|
35
|
+
cwd=$(cd "$cwd" 2>/dev/null && pwd || echo "$cwd")
|
|
36
|
+
|
|
37
|
+
# Determine plugin root directory
|
|
38
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
|
39
|
+
|
|
40
|
+
# Find .mneme directory
|
|
41
|
+
mneme_dir="${cwd}/.mneme"
|
|
42
|
+
sessions_dir="${mneme_dir}/sessions"
|
|
43
|
+
rules_dir="${mneme_dir}/rules"
|
|
44
|
+
patterns_dir="${mneme_dir}/patterns"
|
|
45
|
+
session_links_dir="${mneme_dir}/session-links"
|
|
46
|
+
|
|
47
|
+
# Check if mneme is initialized
|
|
48
|
+
if [ ! -d "$mneme_dir" ]; then
|
|
49
|
+
echo "[mneme] Not initialized in this project. Run: npx @hir4ta/mneme --init" >&2
|
|
50
|
+
exit 0
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# Current timestamp and date parts
|
|
54
|
+
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
55
|
+
date_part=$(echo "$now" | cut -d'T' -f1)
|
|
56
|
+
year_part=$(echo "$date_part" | cut -d'-' -f1)
|
|
57
|
+
month_part=$(echo "$date_part" | cut -d'-' -f2)
|
|
58
|
+
|
|
59
|
+
# Generate session ID based on session_id (not date)
|
|
60
|
+
if [ -n "$session_id" ]; then
|
|
61
|
+
session_short_id="${session_id:0:8}"
|
|
62
|
+
else
|
|
63
|
+
session_short_id=$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' | cut -c1-8 || date +%s | md5sum | cut -c1-8)
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# file_id is now just the session_short_id (no date prefix)
|
|
67
|
+
file_id="${session_short_id}"
|
|
68
|
+
|
|
69
|
+
# Get git info
|
|
70
|
+
current_branch=""
|
|
71
|
+
git_user_name="unknown"
|
|
72
|
+
git_user_email=""
|
|
73
|
+
|
|
74
|
+
if git -C "$cwd" rev-parse --git-dir &> /dev/null 2>&1; then
|
|
75
|
+
current_branch=$(git -C "$cwd" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
|
76
|
+
git_user_name=$(git -C "$cwd" config user.name 2>/dev/null || echo "unknown")
|
|
77
|
+
git_user_email=$(git -C "$cwd" config user.email 2>/dev/null || echo "")
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
# Get project name from directory
|
|
81
|
+
project_name=$(basename "$cwd")
|
|
82
|
+
|
|
83
|
+
# Get repository name from git remote origin
|
|
84
|
+
repository=""
|
|
85
|
+
if git -C "$cwd" rev-parse --git-dir &> /dev/null 2>&1; then
|
|
86
|
+
git_remote_url=$(git -C "$cwd" remote get-url origin 2>/dev/null || echo "")
|
|
87
|
+
if [ -n "$git_remote_url" ]; then
|
|
88
|
+
# Extract user/repo from SSH or HTTPS URL
|
|
89
|
+
# git@github.com:user/repo.git → user/repo
|
|
90
|
+
# https://github.com/user/repo.git → user/repo
|
|
91
|
+
# Extract user/repo from SSH or HTTPS URL (BSD sed compatible)
|
|
92
|
+
repository=$(echo "$git_remote_url" | sed -E 's|.*[:/]([^/]+/[^/]+)(\.git)?$|\1|' | sed 's/\.git$//')
|
|
93
|
+
fi
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
# ============================================
|
|
97
|
+
# Check session-links for master session
|
|
98
|
+
# ============================================
|
|
99
|
+
master_session_id=""
|
|
100
|
+
master_session_path=""
|
|
101
|
+
session_link_file="${session_links_dir}/${file_id}.json"
|
|
102
|
+
|
|
103
|
+
if [ -f "$session_link_file" ]; then
|
|
104
|
+
master_session_id=$(jq -r '.masterSessionId // empty' "$session_link_file" 2>/dev/null || echo "")
|
|
105
|
+
if [ -n "$master_session_id" ]; then
|
|
106
|
+
# Find master session file
|
|
107
|
+
master_session_path=$(find "$sessions_dir" -name "${master_session_id}.json" -type f 2>/dev/null | head -1)
|
|
108
|
+
echo "[mneme] Session linked to master: ${master_session_id}" >&2
|
|
109
|
+
fi
|
|
110
|
+
fi
|
|
111
|
+
|
|
112
|
+
# ============================================
|
|
113
|
+
# Find existing session file or create new one
|
|
114
|
+
# ============================================
|
|
115
|
+
session_path=""
|
|
116
|
+
is_resumed=false
|
|
117
|
+
|
|
118
|
+
# Search for existing session file across all year/month directories
|
|
119
|
+
if [ -d "$sessions_dir" ]; then
|
|
120
|
+
existing_file=$(find "$sessions_dir" -name "${file_id}.json" -type f 2>/dev/null | head -1)
|
|
121
|
+
if [ -n "$existing_file" ] && [ -f "$existing_file" ]; then
|
|
122
|
+
session_path="$existing_file"
|
|
123
|
+
is_resumed=true
|
|
124
|
+
fi
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
# If no existing file, create in current year/month directory
|
|
128
|
+
if [ -z "$session_path" ]; then
|
|
129
|
+
session_year_month_dir="${sessions_dir}/${year_part}/${month_part}"
|
|
130
|
+
mkdir -p "$session_year_month_dir"
|
|
131
|
+
session_path="${session_year_month_dir}/${file_id}.json"
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# ============================================
|
|
135
|
+
# Find recent sessions for auto-suggestion (latest 3)
|
|
136
|
+
# ============================================
|
|
137
|
+
recent_sessions_info=""
|
|
138
|
+
if [ -d "$sessions_dir" ] && [ "$is_resumed" = false ]; then
|
|
139
|
+
# Get all sessions sorted by createdAt descending, excluding current
|
|
140
|
+
recent_sessions=$(find "$sessions_dir" -name "*.json" -type f 2>/dev/null | while read -r f; do
|
|
141
|
+
if [ -f "$f" ]; then
|
|
142
|
+
session_data=$(jq -r '[.id, .createdAt, .title // "", .context.branch // ""] | @tsv' "$f" 2>/dev/null || echo "")
|
|
143
|
+
if [ -n "$session_data" ]; then
|
|
144
|
+
echo "$session_data"
|
|
145
|
+
fi
|
|
146
|
+
fi
|
|
147
|
+
done | sort -t$'\t' -k2 -r | head -4) # Get 4 to filter out current
|
|
148
|
+
|
|
149
|
+
# Build recent sessions list (excluding current session)
|
|
150
|
+
count=0
|
|
151
|
+
while IFS=$'\t' read -r sid screated stitle sbranch; do
|
|
152
|
+
if [ -n "$sid" ] && [ "$sid" != "$file_id" ] && [ $count -lt 3 ]; then
|
|
153
|
+
count=$((count + 1))
|
|
154
|
+
# Format: [id] title (date, branch)
|
|
155
|
+
date_part_session=$(echo "$screated" | cut -d'T' -f1 2>/dev/null || echo "")
|
|
156
|
+
title_display="${stitle:-no title}"
|
|
157
|
+
branch_display="${sbranch:-no branch}"
|
|
158
|
+
recent_sessions_info="${recent_sessions_info} ${count}. [${sid}] ${title_display} (${date_part_session}, ${branch_display})\n"
|
|
159
|
+
fi
|
|
160
|
+
done <<< "$recent_sessions"
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
# ============================================
|
|
164
|
+
# Initialize or update session JSON
|
|
165
|
+
# ============================================
|
|
166
|
+
if [ "$is_resumed" = true ]; then
|
|
167
|
+
# Resume: reset status to null for re-processing at SessionEnd
|
|
168
|
+
jq --arg resumedAt "$now" '.status = null | .resumedAt = $resumedAt' "$session_path" > "${session_path}.tmp" \
|
|
169
|
+
&& mv "${session_path}.tmp" "$session_path"
|
|
170
|
+
echo "[mneme] Session resumed (status reset): ${session_path}" >&2
|
|
171
|
+
else
|
|
172
|
+
# New session: create initial JSON (log-focused schema)
|
|
173
|
+
# Note: summary, discussions, errors, handoff are set by /mneme:save
|
|
174
|
+
session_json=$(jq -n \
|
|
175
|
+
--arg id "$file_id" \
|
|
176
|
+
--arg sessionId "${session_id:-$session_short_id}" \
|
|
177
|
+
--arg createdAt "$now" \
|
|
178
|
+
--arg branch "$current_branch" \
|
|
179
|
+
--arg projectDir "$cwd" \
|
|
180
|
+
--arg projectName "$project_name" \
|
|
181
|
+
--arg repository "$repository" \
|
|
182
|
+
--arg userName "$git_user_name" \
|
|
183
|
+
--arg userEmail "$git_user_email" \
|
|
184
|
+
'{
|
|
185
|
+
id: $id,
|
|
186
|
+
sessionId: $sessionId,
|
|
187
|
+
createdAt: $createdAt,
|
|
188
|
+
title: "",
|
|
189
|
+
tags: [],
|
|
190
|
+
context: {
|
|
191
|
+
branch: (if $branch == "" then null else $branch end),
|
|
192
|
+
projectDir: $projectDir,
|
|
193
|
+
projectName: $projectName,
|
|
194
|
+
repository: (if $repository == "" then null else $repository end),
|
|
195
|
+
user: {
|
|
196
|
+
name: $userName,
|
|
197
|
+
email: (if $userEmail == "" then null else $userEmail end)
|
|
198
|
+
} | with_entries(select(.value != null))
|
|
199
|
+
} | with_entries(select(.value != null)),
|
|
200
|
+
metrics: {
|
|
201
|
+
userMessages: 0,
|
|
202
|
+
assistantResponses: 0,
|
|
203
|
+
thinkingBlocks: 0,
|
|
204
|
+
toolUsage: []
|
|
205
|
+
},
|
|
206
|
+
files: [],
|
|
207
|
+
status: null
|
|
208
|
+
}')
|
|
209
|
+
|
|
210
|
+
echo "$session_json" > "$session_path"
|
|
211
|
+
echo "[mneme] Session initialized: ${session_path}" >&2
|
|
212
|
+
fi
|
|
213
|
+
|
|
214
|
+
# ============================================
|
|
215
|
+
# Update master session workPeriods (if linked)
|
|
216
|
+
# ============================================
|
|
217
|
+
if [ -n "$master_session_id" ] && [ -n "$master_session_path" ] && [ -f "$master_session_path" ]; then
|
|
218
|
+
# Use full session_id for consistency with session-end.sh
|
|
219
|
+
claude_session_id="${session_id:-$session_short_id}"
|
|
220
|
+
|
|
221
|
+
# Check if workPeriod already exists for this claudeSessionId (prevent duplicates on clear/compact)
|
|
222
|
+
existing_period=$(jq --arg cid "$claude_session_id" '.workPeriods // [] | map(select(.claudeSessionId == $cid and .endedAt == null)) | length' "$master_session_path" 2>/dev/null || echo "0")
|
|
223
|
+
|
|
224
|
+
if [ "$existing_period" = "0" ]; then
|
|
225
|
+
# Add new workPeriod entry to master session
|
|
226
|
+
jq --arg claudeSessionId "$claude_session_id" \
|
|
227
|
+
--arg startedAt "$now" '
|
|
228
|
+
.workPeriods = ((.workPeriods // []) + [{
|
|
229
|
+
claudeSessionId: $claudeSessionId,
|
|
230
|
+
startedAt: $startedAt,
|
|
231
|
+
endedAt: null
|
|
232
|
+
}]) |
|
|
233
|
+
.updatedAt = $startedAt
|
|
234
|
+
' "$master_session_path" > "${master_session_path}.tmp" \
|
|
235
|
+
&& mv "${master_session_path}.tmp" "$master_session_path"
|
|
236
|
+
echo "[mneme] Master session workPeriods updated: ${master_session_path}" >&2
|
|
237
|
+
else
|
|
238
|
+
echo "[mneme] Master session workPeriod already exists for this Claude session" >&2
|
|
239
|
+
fi
|
|
240
|
+
fi
|
|
241
|
+
|
|
242
|
+
# Get relative path for additionalContext
|
|
243
|
+
# Extract year/month from session_path
|
|
244
|
+
session_relative_path="${session_path#$cwd/}"
|
|
245
|
+
|
|
246
|
+
# ============================================
|
|
247
|
+
# Initialize tags.json if not exists
|
|
248
|
+
# ============================================
|
|
249
|
+
tags_path="${mneme_dir}/tags.json"
|
|
250
|
+
default_tags_path="${SCRIPT_DIR}/default-tags.json"
|
|
251
|
+
|
|
252
|
+
if [ ! -f "$tags_path" ]; then
|
|
253
|
+
if [ -f "$default_tags_path" ]; then
|
|
254
|
+
cp "$default_tags_path" "$tags_path"
|
|
255
|
+
echo "[mneme] Tags master file created: ${tags_path}" >&2
|
|
256
|
+
fi
|
|
257
|
+
fi
|
|
258
|
+
|
|
259
|
+
# ============================================
|
|
260
|
+
# Initialize local database
|
|
261
|
+
# ============================================
|
|
262
|
+
local_db_path="${mneme_dir}/local.db"
|
|
263
|
+
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
264
|
+
schema_path="${PLUGIN_ROOT}/lib/schema.sql"
|
|
265
|
+
|
|
266
|
+
# Initialize local database if not exists
|
|
267
|
+
if [ ! -f "$local_db_path" ]; then
|
|
268
|
+
if [ -f "$schema_path" ]; then
|
|
269
|
+
sqlite3 "$local_db_path" < "$schema_path"
|
|
270
|
+
echo "[mneme] Local database initialized: ${local_db_path}" >&2
|
|
271
|
+
fi
|
|
272
|
+
fi
|
|
273
|
+
# Configure pragmas
|
|
274
|
+
sqlite3 "$local_db_path" "PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;" 2>/dev/null || true
|
|
275
|
+
|
|
276
|
+
# ============================================
|
|
277
|
+
# Ensure rules templates exist
|
|
278
|
+
# ============================================
|
|
279
|
+
rules_timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
280
|
+
|
|
281
|
+
init_rules_file() {
|
|
282
|
+
local path="$1"
|
|
283
|
+
if [ ! -f "$path" ]; then
|
|
284
|
+
cat <<RULEEOF > "$path"
|
|
285
|
+
{
|
|
286
|
+
"schemaVersion": 1,
|
|
287
|
+
"createdAt": "${rules_timestamp}",
|
|
288
|
+
"updatedAt": "${rules_timestamp}",
|
|
289
|
+
"items": []
|
|
290
|
+
}
|
|
291
|
+
RULEEOF
|
|
292
|
+
fi
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
init_rules_file "${rules_dir}/review-guidelines.json"
|
|
296
|
+
init_rules_file "${rules_dir}/dev-rules.json"
|
|
297
|
+
|
|
298
|
+
# ============================================
|
|
299
|
+
# Build additionalContext (superpowers style)
|
|
300
|
+
# ============================================
|
|
301
|
+
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
302
|
+
|
|
303
|
+
# Read using-mneme skill content
|
|
304
|
+
using_mneme_content=$(cat "${PLUGIN_ROOT}/skills/using-mneme/skill.md" 2>/dev/null || echo "")
|
|
305
|
+
|
|
306
|
+
# Escape for JSON using pure bash (superpowers style)
|
|
307
|
+
escape_for_json() {
|
|
308
|
+
local input="$1"
|
|
309
|
+
local output=""
|
|
310
|
+
local i char
|
|
311
|
+
for (( i=0; i<${#input}; i++ )); do
|
|
312
|
+
char="${input:$i:1}"
|
|
313
|
+
case "$char" in
|
|
314
|
+
$'\\') output+='\\' ;;
|
|
315
|
+
'"') output+='\"' ;;
|
|
316
|
+
$'\n') output+='\n' ;;
|
|
317
|
+
$'\r') output+='\r' ;;
|
|
318
|
+
$'\t') output+='\t' ;;
|
|
319
|
+
*) output+="$char" ;;
|
|
320
|
+
esac
|
|
321
|
+
done
|
|
322
|
+
printf '%s' "$output"
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
using_mneme_escaped=$(escape_for_json "$using_mneme_content")
|
|
326
|
+
|
|
327
|
+
resume_note=""
|
|
328
|
+
needs_summary=false
|
|
329
|
+
if [ "$is_resumed" = true ]; then
|
|
330
|
+
resume_note=" (Resumed)"
|
|
331
|
+
# Check if title is empty
|
|
332
|
+
session_title=$(jq -r '.title // ""' "$session_path" 2>/dev/null || echo "")
|
|
333
|
+
if [ -z "$session_title" ]; then
|
|
334
|
+
needs_summary=true
|
|
335
|
+
fi
|
|
336
|
+
fi
|
|
337
|
+
|
|
338
|
+
# Build the session info (no auto-save instruction)
|
|
339
|
+
session_info="**Session:** ${file_id}${resume_note}
|
|
340
|
+
**Path:** ${session_relative_path}
|
|
341
|
+
|
|
342
|
+
Sessions are saved:
|
|
343
|
+
- **Automatically** before Auto-Compact (context 95% full)
|
|
344
|
+
- **Manually** via \`/mneme:save\` or asking \"save the session\""
|
|
345
|
+
|
|
346
|
+
# Add recent sessions suggestion for new sessions
|
|
347
|
+
if [ "$is_resumed" = false ] && [ -n "$recent_sessions_info" ]; then
|
|
348
|
+
session_info="${session_info}
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
**Recent sessions:**
|
|
352
|
+
$(echo -e "$recent_sessions_info")
|
|
353
|
+
Continue from a previous session? Use \`/mneme:resume <id>\` or \`/mneme:resume\` to see more."
|
|
354
|
+
fi
|
|
355
|
+
|
|
356
|
+
# Add summary creation prompt if needed (for resumed sessions)
|
|
357
|
+
if [ "$needs_summary" = true ]; then
|
|
358
|
+
session_info="${session_info}
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
**Note:** This session was resumed but has no summary yet.
|
|
362
|
+
When you have enough context, consider creating a summary with \`/mneme:save\` to capture:
|
|
363
|
+
- What was accomplished in the previous session
|
|
364
|
+
- Key decisions made
|
|
365
|
+
- Any ongoing work or next steps"
|
|
366
|
+
fi
|
|
367
|
+
|
|
368
|
+
session_info_escaped=$(escape_for_json "$session_info")
|
|
369
|
+
|
|
370
|
+
# Output context injection as JSON (superpowers style)
|
|
371
|
+
cat <<EOF
|
|
372
|
+
{
|
|
373
|
+
"hookSpecificOutput": {
|
|
374
|
+
"hookEventName": "SessionStart",
|
|
375
|
+
"additionalContext": "${session_info_escaped}\n\n${using_mneme_escaped}"
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
EOF
|
|
379
|
+
|
|
380
|
+
exit 0
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# user-prompt-submit.sh - UserPromptSubmit hook for mneme plugin
|
|
4
|
+
#
|
|
5
|
+
# Purpose: Search mneme for relevant context and inject as additionalContext
|
|
6
|
+
#
|
|
7
|
+
# Input (stdin): JSON with prompt, cwd
|
|
8
|
+
# Output (stdout): JSON with hookSpecificOutput.additionalContext (if matches found)
|
|
9
|
+
# Exit codes: 0 = success (continue with optional context)
|
|
10
|
+
#
|
|
11
|
+
# Dependencies: jq
|
|
12
|
+
#
|
|
13
|
+
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
# Read input from stdin
|
|
17
|
+
input_json=$(cat)
|
|
18
|
+
|
|
19
|
+
# Check for jq (required dependency)
|
|
20
|
+
if ! command -v jq &> /dev/null; then
|
|
21
|
+
echo "[mneme] Warning: jq not found, memory search skipped." >&2
|
|
22
|
+
exit 0 # Non-blocking - continue without memory search
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
# Extract prompt and cwd
|
|
26
|
+
prompt=$(echo "$input_json" | jq -r '.prompt // empty' 2>/dev/null || echo "")
|
|
27
|
+
cwd=$(echo "$input_json" | jq -r '.cwd // empty' 2>/dev/null || echo "")
|
|
28
|
+
|
|
29
|
+
# Exit if no prompt or short prompt (less than 10 chars)
|
|
30
|
+
if [ -z "$prompt" ] || [ ${#prompt} -lt 10 ]; then
|
|
31
|
+
exit 0
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# Use PWD if cwd is empty
|
|
35
|
+
if [ -z "$cwd" ]; then
|
|
36
|
+
cwd="${PWD}"
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# Define mneme directory
|
|
40
|
+
mneme_dir="${cwd}/.mneme"
|
|
41
|
+
|
|
42
|
+
# Local database path
|
|
43
|
+
local_db_path="${mneme_dir}/local.db"
|
|
44
|
+
|
|
45
|
+
# Exit if no .mneme directory
|
|
46
|
+
if [ ! -d "$mneme_dir" ]; then
|
|
47
|
+
exit 0
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# Skip if prompt starts with /mneme (user is explicitly using mneme)
|
|
51
|
+
if [[ "$prompt" == /mneme* ]]; then
|
|
52
|
+
exit 0
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# Extract keywords from prompt (first 5 significant words, excluding common words)
|
|
56
|
+
# Simple extraction: take words longer than 3 chars, limit to 5
|
|
57
|
+
raw_keywords=$(echo "$prompt" | tr '[:upper:]' '[:lower:]' | \
|
|
58
|
+
tr -cs '[:alnum:]' '\n' | \
|
|
59
|
+
awk 'length > 3' | \
|
|
60
|
+
grep -vE '^(this|that|with|from|have|will|would|could|should|what|when|where|which|there|their|them|they|been|being|were|does|done|make|just|only|also|into|over|such|than|then|some|these|those|very|after|before|about|through)$' | \
|
|
61
|
+
head -5)
|
|
62
|
+
|
|
63
|
+
# Exit if no keywords extracted
|
|
64
|
+
if [ -z "$raw_keywords" ]; then
|
|
65
|
+
exit 0
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
# Expand keywords using tag aliases from tags.json (fuzzy-search style)
|
|
69
|
+
tags_path="${mneme_dir}/tags.json"
|
|
70
|
+
expanded_keywords="$raw_keywords"
|
|
71
|
+
|
|
72
|
+
if [ -f "$tags_path" ]; then
|
|
73
|
+
# For each keyword, check if it matches any tag id, label, or alias
|
|
74
|
+
# If matched, expand to include all related terms
|
|
75
|
+
while IFS= read -r kw; do
|
|
76
|
+
[ -z "$kw" ] && continue
|
|
77
|
+
# Search for matching tag and get all related terms
|
|
78
|
+
related_terms=$(jq -r --arg kw "$kw" '
|
|
79
|
+
.tags[]? |
|
|
80
|
+
select(
|
|
81
|
+
(.id | ascii_downcase) == $kw or
|
|
82
|
+
(.label | ascii_downcase) == $kw or
|
|
83
|
+
(.aliases[]? | ascii_downcase) == $kw
|
|
84
|
+
) |
|
|
85
|
+
[.id, .label] + .aliases | .[] | ascii_downcase
|
|
86
|
+
' "$tags_path" 2>/dev/null | sort -u)
|
|
87
|
+
|
|
88
|
+
if [ -n "$related_terms" ]; then
|
|
89
|
+
# Add related terms to expanded keywords
|
|
90
|
+
expanded_keywords="${expanded_keywords}
|
|
91
|
+
${related_terms}"
|
|
92
|
+
fi
|
|
93
|
+
done <<< "$raw_keywords"
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
# Remove duplicates and build grep pattern
|
|
97
|
+
keywords=$(echo "$expanded_keywords" | sort -u | tr '\n' '|' | sed 's/|$//')
|
|
98
|
+
|
|
99
|
+
# Exit if no keywords
|
|
100
|
+
if [ -z "$keywords" ]; then
|
|
101
|
+
exit 0
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
# Search local SQLite database for interactions
|
|
105
|
+
search_local_db() {
|
|
106
|
+
local pattern="$1"
|
|
107
|
+
local results=""
|
|
108
|
+
|
|
109
|
+
# Check if sqlite3 is available and local DB exists
|
|
110
|
+
if ! command -v sqlite3 &> /dev/null || [ ! -f "$local_db_path" ]; then
|
|
111
|
+
echo ""
|
|
112
|
+
return
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
# Convert pattern to SQLite FTS5 or LIKE-compatible format
|
|
116
|
+
# Replace | with OR for FTS5, escape special chars
|
|
117
|
+
local fts_pattern=$(echo "$pattern" | sed 's/|/ OR /g')
|
|
118
|
+
local like_pattern=$(echo "$pattern" | sed 's/|/%/g')
|
|
119
|
+
|
|
120
|
+
# Try FTS5 first, fallback to LIKE
|
|
121
|
+
local db_matches=""
|
|
122
|
+
|
|
123
|
+
# Search interactions (project-local database)
|
|
124
|
+
# Limit to 3 most recent matches
|
|
125
|
+
db_matches=$(sqlite3 -separator '|' "$local_db_path" "
|
|
126
|
+
SELECT DISTINCT session_id, substr(content, 1, 100) as snippet
|
|
127
|
+
FROM interactions
|
|
128
|
+
WHERE content LIKE '%${like_pattern}%' OR thinking LIKE '%${like_pattern}%'
|
|
129
|
+
ORDER BY timestamp DESC
|
|
130
|
+
LIMIT 3;
|
|
131
|
+
" 2>/dev/null || echo "")
|
|
132
|
+
|
|
133
|
+
if [ -n "$db_matches" ]; then
|
|
134
|
+
while IFS='|' read -r session_id snippet; do
|
|
135
|
+
[ -z "$session_id" ] && continue
|
|
136
|
+
# Truncate snippet and clean up
|
|
137
|
+
snippet=$(echo "$snippet" | tr '\n' ' ' | sed 's/ */ /g' | head -c 80)
|
|
138
|
+
results="${results}[interaction:${session_id:0:8}] ${snippet}...\n"
|
|
139
|
+
done <<< "$db_matches"
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
echo -e "$results"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# Search function - simple grep-based search
|
|
146
|
+
search_mneme() {
|
|
147
|
+
local pattern="$1"
|
|
148
|
+
local results=""
|
|
149
|
+
|
|
150
|
+
# Search sessions (limit to recent 10 files)
|
|
151
|
+
if [ -d "${mneme_dir}/sessions" ]; then
|
|
152
|
+
local session_matches=$(find "${mneme_dir}/sessions" -name "*.json" -type f 2>/dev/null | \
|
|
153
|
+
xargs -I{} sh -c "grep -l -i -E '$pattern' '{}' 2>/dev/null || true" | \
|
|
154
|
+
head -3)
|
|
155
|
+
|
|
156
|
+
for file in $session_matches; do
|
|
157
|
+
if [ -f "$file" ]; then
|
|
158
|
+
local title=$(jq -r '.title // .summary.title // ""' "$file" 2>/dev/null | head -1)
|
|
159
|
+
local id=$(jq -r '.id // ""' "$file" 2>/dev/null)
|
|
160
|
+
if [ -n "$title" ] && [ -n "$id" ]; then
|
|
161
|
+
results="${results}[session:${id}] ${title}\n"
|
|
162
|
+
fi
|
|
163
|
+
fi
|
|
164
|
+
done
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
# Search decisions
|
|
168
|
+
if [ -d "${mneme_dir}/decisions" ]; then
|
|
169
|
+
local decision_matches=$(find "${mneme_dir}/decisions" -name "*.json" -type f 2>/dev/null | \
|
|
170
|
+
xargs -I{} sh -c "grep -l -i -E '$pattern' '{}' 2>/dev/null || true" | \
|
|
171
|
+
head -3)
|
|
172
|
+
|
|
173
|
+
for file in $decision_matches; do
|
|
174
|
+
if [ -f "$file" ]; then
|
|
175
|
+
local title=$(jq -r '.title // ""' "$file" 2>/dev/null | head -1)
|
|
176
|
+
local decision=$(jq -r '.decision // ""' "$file" 2>/dev/null | head -1)
|
|
177
|
+
if [ -n "$title" ]; then
|
|
178
|
+
results="${results}[decision] ${title}: ${decision}\n"
|
|
179
|
+
fi
|
|
180
|
+
fi
|
|
181
|
+
done
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
# Search patterns
|
|
185
|
+
if [ -d "${mneme_dir}/patterns" ]; then
|
|
186
|
+
for file in "${mneme_dir}/patterns"/*.json; do
|
|
187
|
+
if [ -f "$file" ]; then
|
|
188
|
+
local pattern_matches=$(jq -r --arg p "$pattern" \
|
|
189
|
+
'.patterns[]? | select(.errorPattern | test($p; "i")) | "[pattern] \(.errorPattern | .[0:50])... → \(.solution | .[0:50])..."' \
|
|
190
|
+
"$file" 2>/dev/null | head -2)
|
|
191
|
+
if [ -n "$pattern_matches" ]; then
|
|
192
|
+
results="${results}${pattern_matches}\n"
|
|
193
|
+
fi
|
|
194
|
+
fi
|
|
195
|
+
done
|
|
196
|
+
fi
|
|
197
|
+
|
|
198
|
+
# Search local SQLite database
|
|
199
|
+
local db_results=$(search_local_db "$pattern")
|
|
200
|
+
if [ -n "$db_results" ]; then
|
|
201
|
+
results="${results}${db_results}"
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
echo -e "$results"
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# Perform search
|
|
208
|
+
search_results=$(search_mneme "$keywords")
|
|
209
|
+
|
|
210
|
+
# Exit if no results
|
|
211
|
+
if [ -z "$search_results" ] || [ "$search_results" = "\n" ]; then
|
|
212
|
+
exit 0
|
|
213
|
+
fi
|
|
214
|
+
|
|
215
|
+
# Escape for JSON
|
|
216
|
+
escape_for_json() {
|
|
217
|
+
local input="$1"
|
|
218
|
+
local output=""
|
|
219
|
+
local i char
|
|
220
|
+
for (( i=0; i<${#input}; i++ )); do
|
|
221
|
+
char="${input:$i:1}"
|
|
222
|
+
case "$char" in
|
|
223
|
+
$'\\') output+='\\' ;;
|
|
224
|
+
'"') output+='\"' ;;
|
|
225
|
+
$'\n') output+='\n' ;;
|
|
226
|
+
$'\r') output+='\r' ;;
|
|
227
|
+
$'\t') output+='\t' ;;
|
|
228
|
+
*) output+="$char" ;;
|
|
229
|
+
esac
|
|
230
|
+
done
|
|
231
|
+
printf '%s' "$output"
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
# Build context message
|
|
235
|
+
context_message="<mneme-context>
|
|
236
|
+
Related memories found:
|
|
237
|
+
$(echo -e "$search_results")
|
|
238
|
+
Use /mneme:search for more details.
|
|
239
|
+
</mneme-context>"
|
|
240
|
+
|
|
241
|
+
context_escaped=$(escape_for_json "$context_message")
|
|
242
|
+
|
|
243
|
+
# Output JSON with additionalContext
|
|
244
|
+
cat <<EOF
|
|
245
|
+
{
|
|
246
|
+
"hookSpecificOutput": {
|
|
247
|
+
"hookEventName": "UserPromptSubmit",
|
|
248
|
+
"additionalContext": "${context_escaped}"
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
EOF
|
|
252
|
+
|
|
253
|
+
exit 0
|