@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.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +29 -0
  2. package/.mcp.json +18 -0
  3. package/README.ja.md +400 -0
  4. package/README.md +410 -0
  5. package/bin/mneme.js +203 -0
  6. package/dist/lib/db.js +340 -0
  7. package/dist/lib/fuzzy-search.js +214 -0
  8. package/dist/lib/github.js +121 -0
  9. package/dist/lib/similarity.js +193 -0
  10. package/dist/lib/utils.js +62 -0
  11. package/dist/public/apple-touch-icon.png +0 -0
  12. package/dist/public/assets/index-BgqCALAg.css +1 -0
  13. package/dist/public/assets/index-EMvn4VEa.js +330 -0
  14. package/dist/public/assets/react-force-graph-2d-DWoBaKmT.js +46 -0
  15. package/dist/public/favicon-128-max.png +0 -0
  16. package/dist/public/favicon-256-max.png +0 -0
  17. package/dist/public/favicon-32-max.png +0 -0
  18. package/dist/public/favicon-512-max.png +0 -0
  19. package/dist/public/favicon-64-max.png +0 -0
  20. package/dist/public/index.html +15 -0
  21. package/dist/server.js +4791 -0
  22. package/dist/servers/db-server.js +30558 -0
  23. package/dist/servers/search-server.js +30366 -0
  24. package/hooks/default-tags.json +1055 -0
  25. package/hooks/hooks.json +61 -0
  26. package/hooks/post-tool-use.sh +96 -0
  27. package/hooks/pre-compact.sh +187 -0
  28. package/hooks/session-end.sh +567 -0
  29. package/hooks/session-start.sh +380 -0
  30. package/hooks/user-prompt-submit.sh +253 -0
  31. package/package.json +77 -0
  32. package/servers/db-server.ts +993 -0
  33. package/servers/search-server.ts +675 -0
  34. package/skills/AGENTS.override.md +5 -0
  35. package/skills/harvest/skill.md +295 -0
  36. package/skills/init-mneme/skill.md +101 -0
  37. package/skills/plan/skill.md +422 -0
  38. package/skills/report/skill.md +74 -0
  39. package/skills/resume/skill.md +278 -0
  40. package/skills/review/skill.md +419 -0
  41. package/skills/save/skill.md +482 -0
  42. package/skills/search/skill.md +175 -0
  43. package/skills/using-mneme/skill.md +185 -0
@@ -0,0 +1,567 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # session-end.sh - SessionEnd hook for mneme plugin
4
+ #
5
+ # Auto-save session by extracting interactions from transcript using jq.
6
+ # Interactions are stored in project-local SQLite (.mneme/local.db).
7
+ # JSON file contains only metadata (no interactions).
8
+ #
9
+ # IMPORTANT: This script merges pre_compact_backups from SQLite with
10
+ # newly extracted interactions to preserve conversations from before auto-compact.
11
+ #
12
+ # Input (stdin): JSON with session_id, transcript_path, cwd
13
+ # Output (stderr): Log messages
14
+ # Exit codes: 0 = success (SessionEnd cannot be blocked)
15
+ #
16
+ # Dependencies: jq, sqlite3
17
+
18
+ set -euo pipefail
19
+
20
+ # Read input from stdin
21
+ input_json=$(cat)
22
+
23
+ # Extract fields
24
+ session_id=$(echo "$input_json" | jq -r '.session_id // empty' 2>/dev/null || echo "")
25
+ transcript_path=$(echo "$input_json" | jq -r '.transcript_path // empty' 2>/dev/null || echo "")
26
+ cwd=$(echo "$input_json" | jq -r '.cwd // empty' 2>/dev/null || echo "")
27
+
28
+ if [ -z "$session_id" ]; then
29
+ exit 0
30
+ fi
31
+
32
+ # Use cwd from input or fallback to PWD
33
+ if [ -z "$cwd" ]; then
34
+ cwd="${PWD}"
35
+ fi
36
+
37
+ # Resolve paths
38
+ cwd=$(cd "$cwd" 2>/dev/null && pwd || echo "$cwd")
39
+ mneme_dir="${cwd}/.mneme"
40
+ sessions_dir="${mneme_dir}/sessions"
41
+ session_links_dir="${mneme_dir}/session-links"
42
+
43
+ # Local database path (project-local)
44
+ db_path="${mneme_dir}/local.db"
45
+
46
+ # Find session file
47
+ session_short_id="${session_id:0:8}"
48
+ session_file=""
49
+ mneme_session_id="$session_short_id"
50
+
51
+ if [ -d "$sessions_dir" ]; then
52
+ session_file=$(find "$sessions_dir" -type f -name "${session_short_id}.json" 2>/dev/null | head -1)
53
+ fi
54
+
55
+ # If not found, check session-links for master session ID
56
+ if [ -z "$session_file" ] || [ ! -f "$session_file" ]; then
57
+ session_link_file="${session_links_dir}/${session_short_id}.json"
58
+ if [ -f "$session_link_file" ]; then
59
+ master_session_id=$(jq -r '.masterSessionId // empty' "$session_link_file" 2>/dev/null || echo "")
60
+ if [ -n "$master_session_id" ]; then
61
+ session_file=$(find "$sessions_dir" -type f -name "${master_session_id}.json" 2>/dev/null | head -1)
62
+ if [ -n "$session_file" ] && [ -f "$session_file" ]; then
63
+ mneme_session_id="$master_session_id"
64
+ echo "[mneme] Using master session via session-link: ${master_session_id}" >&2
65
+ fi
66
+ fi
67
+ fi
68
+ fi
69
+
70
+ # If still not found, exit (no auto-linking to avoid confusion)
71
+ if [ -z "$session_file" ] || [ ! -f "$session_file" ]; then
72
+ exit 0
73
+ fi
74
+
75
+ # Get git user for owner field
76
+ owner=$(git -C "$cwd" config user.name 2>/dev/null || whoami || echo "unknown")
77
+
78
+ # Get repository info
79
+ repository=""
80
+ repository_url=""
81
+ repository_root=""
82
+ if git -C "$cwd" rev-parse --git-dir &> /dev/null 2>&1; then
83
+ repository_root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || echo "")
84
+ repository_url=$(git -C "$cwd" remote get-url origin 2>/dev/null || echo "")
85
+ if [ -n "$repository_url" ]; then
86
+ # Extract owner/repo from URL
87
+ repository=$(echo "$repository_url" | sed -E 's|.*[:/]([^/]+/[^/]+)(\.git)?$|\1|' | sed 's/\.git$//')
88
+ fi
89
+ fi
90
+
91
+ # Escape for SQL
92
+ project_path_escaped="${cwd//\'/\'\'}"
93
+ repository_escaped="${repository//\'/\'\'}"
94
+ repository_url_escaped="${repository_url//\'/\'\'}"
95
+ repository_root_escaped="${repository_root//\'/\'\'}"
96
+
97
+ # Determine plugin root directory for schema
98
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
99
+ PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
100
+ schema_path="${PLUGIN_ROOT}/lib/schema.sql"
101
+
102
+ # Initialize local SQLite database if not exists
103
+ init_database() {
104
+ if [ ! -d "$mneme_dir" ]; then
105
+ mkdir -p "$mneme_dir"
106
+ fi
107
+ if [ ! -f "$db_path" ]; then
108
+ if [ -f "$schema_path" ]; then
109
+ sqlite3 "$db_path" < "$schema_path"
110
+ echo "[mneme] Local SQLite database initialized: ${db_path}" >&2
111
+ else
112
+ # Minimal schema if schema.sql not found
113
+ sqlite3 "$db_path" <<'SQLEOF'
114
+ CREATE TABLE IF NOT EXISTS interactions (
115
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
116
+ session_id TEXT NOT NULL,
117
+ project_path TEXT NOT NULL,
118
+ repository TEXT,
119
+ repository_url TEXT,
120
+ repository_root TEXT,
121
+ owner TEXT NOT NULL,
122
+ role TEXT NOT NULL,
123
+ content TEXT NOT NULL,
124
+ thinking TEXT,
125
+ tool_calls TEXT,
126
+ timestamp TEXT NOT NULL,
127
+ is_compact_summary INTEGER DEFAULT 0,
128
+ agent_id TEXT,
129
+ agent_type TEXT,
130
+ created_at TEXT DEFAULT (datetime('now'))
131
+ );
132
+ CREATE INDEX IF NOT EXISTS idx_interactions_session ON interactions(session_id);
133
+ CREATE INDEX IF NOT EXISTS idx_interactions_owner ON interactions(owner);
134
+ CREATE INDEX IF NOT EXISTS idx_interactions_project ON interactions(project_path);
135
+ CREATE INDEX IF NOT EXISTS idx_interactions_repository ON interactions(repository);
136
+ CREATE INDEX IF NOT EXISTS idx_interactions_agent ON interactions(agent_id);
137
+
138
+ CREATE TABLE IF NOT EXISTS pre_compact_backups (
139
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
140
+ session_id TEXT NOT NULL,
141
+ project_path TEXT NOT NULL,
142
+ owner TEXT NOT NULL,
143
+ interactions TEXT NOT NULL,
144
+ created_at TEXT DEFAULT (datetime('now'))
145
+ );
146
+ CREATE INDEX IF NOT EXISTS idx_backups_session ON pre_compact_backups(session_id);
147
+ CREATE INDEX IF NOT EXISTS idx_backups_project ON pre_compact_backups(project_path);
148
+
149
+ CREATE TABLE IF NOT EXISTS migrations (
150
+ project_path TEXT PRIMARY KEY,
151
+ migrated_at TEXT DEFAULT (datetime('now'))
152
+ );
153
+ SQLEOF
154
+ fi
155
+ else
156
+ # Migrate existing database: add agent_id and agent_type columns if not exist
157
+ has_agent_id=$(sqlite3 "$db_path" "SELECT COUNT(*) FROM pragma_table_info('interactions') WHERE name='agent_id';" 2>/dev/null || echo "0")
158
+ if [ "$has_agent_id" = "0" ]; then
159
+ sqlite3 "$db_path" "ALTER TABLE interactions ADD COLUMN agent_id TEXT;" 2>/dev/null || true
160
+ sqlite3 "$db_path" "ALTER TABLE interactions ADD COLUMN agent_type TEXT;" 2>/dev/null || true
161
+ sqlite3 "$db_path" "CREATE INDEX IF NOT EXISTS idx_interactions_agent ON interactions(agent_id);" 2>/dev/null || true
162
+ echo "[mneme] Database migrated: added agent_id, agent_type columns" >&2
163
+ fi
164
+ fi
165
+ # Configure pragmas
166
+ sqlite3 "$db_path" "PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;" 2>/dev/null || true
167
+ }
168
+
169
+ # Extract interactions from transcript if available
170
+ if [ -n "$transcript_path" ] && [ -f "$transcript_path" ]; then
171
+ # Initialize database
172
+ init_database
173
+
174
+ # Extract interactions using jq
175
+ interactions_json=$(cat "$transcript_path" | jq -s '
176
+ # User messages (text only, exclude tool results and local command outputs)
177
+ # Include isCompactSummary flag for auto-compact summaries
178
+ [.[] | select(
179
+ .type == "user" and
180
+ .message.role == "user" and
181
+ (.message.content | type) == "string" and
182
+ (.message.content | startswith("<local-command-stdout>") | not) and
183
+ (.message.content | startswith("<local-command-caveat>") | not)
184
+ ) | {
185
+ timestamp: .timestamp,
186
+ content: .message.content,
187
+ isCompactSummary: (.isCompactSummary // false)
188
+ }] as $user_messages |
189
+
190
+ # Get user message timestamps for grouping
191
+ ($user_messages | map(.timestamp)) as $user_timestamps |
192
+
193
+ # All assistant messages with thinking or text
194
+ [.[] | select(.type == "assistant") | . as $msg |
195
+ ($msg.message.content // []) |
196
+ {
197
+ timestamp: $msg.timestamp,
198
+ thinking: ([.[] | select(.type == "thinking") | .thinking] | join("\n")),
199
+ text: ([.[] | select(.type == "text") | .text] | join("\n"))
200
+ } | select(.thinking != "" or .text != "")
201
+ ] as $all_assistant |
202
+
203
+ # Detect plan mode: find EnterPlanMode and ExitPlanMode tool calls
204
+ [.[] | select(.type == "assistant") | . as $msg |
205
+ ($msg.message.content // []) | .[] |
206
+ select(.type == "tool_use" and (.name == "EnterPlanMode" or .name == "ExitPlanMode")) |
207
+ {timestamp: $msg.timestamp, tool: .name}
208
+ ] as $plan_events |
209
+
210
+ # Tool usage during plan mode (between EnterPlanMode and ExitPlanMode)
211
+ (if ($plan_events | length) >= 2 then
212
+ ($plan_events | map(select(.tool == "EnterPlanMode")) | .[0].timestamp // null) as $plan_start |
213
+ ($plan_events | map(select(.tool == "ExitPlanMode")) | .[0].timestamp // null) as $plan_end |
214
+ if $plan_start and $plan_end then
215
+ [.[] | select(.type == "assistant" and .timestamp > $plan_start and .timestamp < $plan_end) |
216
+ .message.content[]? | select(.type == "tool_use") |
217
+ {name: .name, input_keys: (.input | keys)}
218
+ ] | group_by(.name) | map({name: .[0].name, count: length}) | sort_by(-.count)
219
+ else [] end
220
+ else [] end) as $plan_tools |
221
+
222
+ # Tool usage summary
223
+ [.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | .name] |
224
+ group_by(.) | map({name: .[0], count: length}) | sort_by(-.count) as $tool_usage |
225
+
226
+ # Build interactions by grouping all assistant responses between user messages
227
+ [range(0; $user_messages | length) | . as $i |
228
+ $user_messages[$i] as $user |
229
+ # Get next user message timestamp (or far future if last)
230
+ (if $i + 1 < ($user_messages | length) then $user_messages[$i + 1].timestamp else "9999-12-31T23:59:59Z" end) as $next_user_ts |
231
+ # Collect all assistant responses between this user message and next
232
+ [$all_assistant[] | select(.timestamp > $user.timestamp and .timestamp < $next_user_ts)] as $turn_responses |
233
+
234
+ # Check if this turn includes plan mode
235
+ ([$plan_events[] | select(.timestamp > $user.timestamp and .timestamp < $next_user_ts)] | length > 0) as $has_plan |
236
+
237
+ # Collect tool details used in this turn
238
+ ([.[] | select(.type == "assistant" and .timestamp > $user.timestamp and .timestamp < $next_user_ts) |
239
+ .message.content[]? | select(.type == "tool_use") |
240
+ {
241
+ name: .name,
242
+ detail: (
243
+ if .name == "Bash" then (.input.command // null)
244
+ elif .name == "Read" then (.input.file_path // null)
245
+ elif .name == "Edit" then (.input.file_path // null)
246
+ elif .name == "Write" then (.input.file_path // null)
247
+ elif .name == "Glob" then (.input.pattern // null)
248
+ elif .name == "Grep" then (.input.pattern // null)
249
+ elif .name == "Task" then {type: (.input.subagent_type // null), prompt: ((.input.prompt // "")[:50])}
250
+ elif .name == "WebSearch" then (.input.query // null)
251
+ elif .name == "WebFetch" then (.input.url // null)
252
+ else null
253
+ end
254
+ )
255
+ }
256
+ ]) as $tool_details |
257
+
258
+ # Collect tool names used in this turn (for backwards compatibility)
259
+ ($tool_details | map(.name) | unique) as $tools_used |
260
+
261
+ if ($turn_responses | length) > 0 then (
262
+ {
263
+ id: ("int-" + (($i + 1) | tostring | if length < 3 then "00"[0:(3-length)] + . else . end)),
264
+ timestamp: $user.timestamp,
265
+ user: $user.content,
266
+ thinking: ([$turn_responses[].thinking | select(. != "")] | join("\n")),
267
+ assistant: ([$turn_responses[].text | select(. != "")] | join("\n"))
268
+ }
269
+ + (if $user.isCompactSummary then {isCompactSummary: true} else {} end)
270
+ + (if $has_plan then {hasPlanMode: true, planTools: $plan_tools} else {} end)
271
+ + (if ($tools_used | length) > 0 then {toolsUsed: $tools_used} else {} end)
272
+ + (if ($tool_details | length) > 0 then {toolDetails: $tool_details} else {} end)
273
+ ) else empty end
274
+ ] as $interactions |
275
+
276
+ # File changes from tool usage
277
+ [.[] | select(.type == "assistant") | .message.content[]? |
278
+ select(.type == "tool_use" and (.name == "Edit" or .name == "Write")) |
279
+ {
280
+ path: .input.file_path,
281
+ action: (if .name == "Write" then "create" else "edit" end)
282
+ }
283
+ ] | unique_by(.path) as $files |
284
+
285
+ {
286
+ interactions: $interactions,
287
+ toolUsage: $tool_usage,
288
+ files: $files,
289
+ metrics: {
290
+ userMessages: ($user_messages | length),
291
+ assistantResponses: ($all_assistant | length),
292
+ thinkingBlocks: ([$all_assistant[].thinking | select(. != "")] | length)
293
+ }
294
+ }
295
+ ' 2>/dev/null || echo '{"interactions":[],"toolUsage":[],"files":[],"metrics":{}}')
296
+
297
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
298
+
299
+ # Get latest backup from SQLite (if any) - check both session IDs and project path
300
+ backup_json=$(sqlite3 "$db_path" "SELECT interactions FROM pre_compact_backups WHERE session_id IN ('${mneme_session_id}', '${session_short_id}') AND project_path = '${project_path_escaped}' ORDER BY created_at DESC LIMIT 1;" 2>/dev/null || echo "[]")
301
+ if [ -z "$backup_json" ] || [ "$backup_json" = "" ]; then
302
+ backup_json="[]"
303
+ fi
304
+
305
+ # Also check existing interactions in SQLite - check both session IDs
306
+ existing_count=$(sqlite3 "$db_path" "SELECT COUNT(*) FROM interactions WHERE session_id IN ('${mneme_session_id}', '${session_short_id}') AND project_path = '${project_path_escaped}';" 2>/dev/null || echo "0")
307
+
308
+ # Merge backup with extracted interactions
309
+ merged_json=$(echo "$interactions_json" | jq --argjson backup "$backup_json" '
310
+ # Merge preCompactBackups with extracted interactions
311
+ ($backup | if type == "array" then . else [] end) as $backup_arr |
312
+ (.interactions // []) as $new_arr |
313
+
314
+ # Get the last timestamp from backup (or epoch if empty)
315
+ ($backup_arr | if length > 0 then .[-1].timestamp else "1970-01-01T00:00:00Z" end) as $last_backup_ts |
316
+
317
+ # Filter new interactions that are after backup
318
+ [$new_arr[] | select(.timestamp > $last_backup_ts)] as $truly_new |
319
+
320
+ # Merge: backup + truly new interactions
321
+ ($backup_arr + $truly_new) as $merged |
322
+
323
+ # Re-number IDs sequentially
324
+ [$merged | to_entries[] | .value + {id: ("int-" + ((.key + 1) | tostring | if length < 3 then "00"[0:(3-length)] + . else . end))}]
325
+ ')
326
+
327
+ # Count merged interactions
328
+ merged_count=$(echo "$merged_json" | jq 'length')
329
+ backup_count=$(echo "$backup_json" | jq 'if type == "array" then length else 0 end')
330
+
331
+ # Insert merged interactions into SQLite (using mneme_session_id for consistency)
332
+ # Only delete and replace if we have new data - prevents data loss when extraction fails
333
+ if [ "$merged_count" -gt 0 ]; then
334
+ # Clear existing interactions for this session (will be replaced)
335
+ sqlite3 "$db_path" "DELETE FROM interactions WHERE session_id IN ('${mneme_session_id}', '${session_short_id}') AND project_path = '${project_path_escaped}';" 2>/dev/null || true
336
+ echo "$merged_json" | jq -c '.[]' | while read -r interaction; do
337
+ timestamp=$(echo "$interaction" | jq -r '.timestamp // ""')
338
+ user_content=$(echo "$interaction" | jq -r '.user // ""')
339
+ thinking=$(echo "$interaction" | jq -r '.thinking // ""')
340
+ assistant_content=$(echo "$interaction" | jq -r '.assistant // ""')
341
+ is_compact=$(echo "$interaction" | jq -r 'if .isCompactSummary then 1 else 0 end')
342
+
343
+ # Create metadata JSON for tool_calls column (includes planMode info and tool details)
344
+ metadata_json=$(echo "$interaction" | jq -c '{hasPlanMode: (.hasPlanMode // false), planTools: (.planTools // []), toolsUsed: (.toolsUsed // []), toolDetails: (.toolDetails // [])}')
345
+ metadata_escaped="${metadata_json//\'/\'\'}"
346
+
347
+ # Escape single quotes for SQL
348
+ user_content_escaped="${user_content//\'/\'\'}"
349
+ thinking_escaped="${thinking//\'/\'\'}"
350
+ assistant_escaped="${assistant_content//\'/\'\'}"
351
+
352
+ # Insert user message with project/repository info and metadata
353
+ sqlite3 "$db_path" "INSERT INTO interactions (session_id, project_path, repository, repository_url, repository_root, owner, role, content, thinking, tool_calls, timestamp, is_compact_summary) VALUES ('${mneme_session_id}', '${project_path_escaped}', '${repository_escaped}', '${repository_url_escaped}', '${repository_root_escaped}', '${owner}', 'user', '${user_content_escaped}', NULL, '${metadata_escaped}', '${timestamp}', ${is_compact});" 2>/dev/null || true
354
+
355
+ # Insert assistant response with project/repository info
356
+ if [ -n "$assistant_content" ]; then
357
+ sqlite3 "$db_path" "INSERT INTO interactions (session_id, project_path, repository, repository_url, repository_root, owner, role, content, thinking, timestamp, is_compact_summary) VALUES ('${mneme_session_id}', '${project_path_escaped}', '${repository_escaped}', '${repository_url_escaped}', '${repository_root_escaped}', '${owner}', 'assistant', '${assistant_escaped}', '${thinking_escaped}', '${timestamp}', 0);" 2>/dev/null || true
358
+ fi
359
+ done
360
+ fi
361
+
362
+ # Clear pre_compact_backups for this session (merged into interactions) - delete both session IDs
363
+ sqlite3 "$db_path" "DELETE FROM pre_compact_backups WHERE session_id IN ('${mneme_session_id}', '${session_short_id}') AND project_path = '${project_path_escaped}';" 2>/dev/null || true
364
+
365
+ # Update JSON file (without interactions and preCompactBackups)
366
+ jq --argjson extracted "$interactions_json" \
367
+ --arg status "complete" \
368
+ --arg endedAt "$now" \
369
+ --arg updatedAt "$now" \
370
+ --argjson interactionCount "$merged_count" '
371
+ # Update files
372
+ .files = ((.files // []) + ($extracted.files // []) | unique_by(.path)) |
373
+ # Update metrics
374
+ .metrics = (.metrics // {}) + {
375
+ userMessages: $interactionCount,
376
+ assistantResponses: $interactionCount,
377
+ thinkingBlocks: ($extracted.metrics.thinkingBlocks // 0),
378
+ toolUsage: ($extracted.toolUsage // [])
379
+ } |
380
+ # Remove interactions and preCompactBackups (now in SQLite)
381
+ del(.interactions) |
382
+ del(.preCompactBackups) |
383
+ # Set status and timestamps
384
+ .status = $status |
385
+ .endedAt = $endedAt |
386
+ .updatedAt = $updatedAt
387
+ ' "$session_file" > "${session_file}.tmp" && mv "${session_file}.tmp" "$session_file"
388
+
389
+ echo "[mneme] Session auto-saved with ${merged_count} interactions to local DB (${backup_count} from backup): ${session_file}" >&2
390
+ else
391
+ # No transcript, just update status
392
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
393
+ jq --arg status "complete" --arg endedAt "$now" --arg updatedAt "$now" '
394
+ .status = $status | .endedAt = $endedAt | .updatedAt = $updatedAt |
395
+ del(.interactions) | del(.preCompactBackups)
396
+ ' "$session_file" > "${session_file}.tmp" && mv "${session_file}.tmp" "$session_file"
397
+
398
+ echo "[mneme] Session completed (no transcript): ${session_file}" >&2
399
+ fi
400
+
401
+ # ============================================
402
+ # Process subagent transcripts
403
+ # ============================================
404
+ if [ -n "$transcript_path" ] && [ -f "$transcript_path" ]; then
405
+ transcript_dir=$(dirname "$transcript_path")
406
+ subagents_dir="${transcript_dir}/subagents"
407
+
408
+ if [ -d "$subagents_dir" ]; then
409
+ subagent_count=0
410
+ for subagent_file in "$subagents_dir"/agent-*.jsonl; do
411
+ [ -f "$subagent_file" ] || continue
412
+
413
+ # Extract agent ID from filename (agent-a61bd5d.jsonl -> a61bd5d)
414
+ agent_filename=$(basename "$subagent_file")
415
+ agent_id="${agent_filename#agent-}"
416
+ agent_id="${agent_id%.jsonl}"
417
+
418
+ # Extract agent type from the first user message (Task prompt)
419
+ agent_type=$(jq -r '[.[] | select(.type == "user" and (.message.content | type) == "string")] | .[0].message.content // ""' "$subagent_file" 2>/dev/null | head -c 50)
420
+
421
+ # Determine agent type from the parent's Task tool call if possible
422
+ detected_type=$(jq -r --arg aid "$agent_id" '
423
+ [.[] | select(.type == "assistant") | .message.content[]? |
424
+ select(.type == "tool_use" and .name == "Task") |
425
+ select(.id | contains($aid) or (.input.description // "") | contains($aid)) |
426
+ .input.subagent_type // "unknown"
427
+ ] | .[0] // "unknown"
428
+ ' "$transcript_path" 2>/dev/null || echo "unknown")
429
+
430
+ if [ "$detected_type" = "unknown" ] || [ "$detected_type" = "null" ]; then
431
+ # Try to detect from subagent filename patterns
432
+ case "$agent_filename" in
433
+ *explore*|*Explore*) detected_type="Explore" ;;
434
+ *plan*|*Plan*) detected_type="Plan" ;;
435
+ *bash*|*Bash*) detected_type="Bash" ;;
436
+ *compact*) detected_type="compact" ;;
437
+ *) detected_type="general" ;;
438
+ esac
439
+ fi
440
+
441
+ # Extract interactions from subagent transcript
442
+ subagent_json=$(cat "$subagent_file" | jq -s '
443
+ # User messages (first one is the task prompt)
444
+ [.[] | select(
445
+ .type == "user" and
446
+ .message.role == "user" and
447
+ (.message.content | type) == "string"
448
+ ) | {
449
+ timestamp: .timestamp,
450
+ content: .message.content
451
+ }] as $user_messages |
452
+
453
+ # All assistant messages with thinking or text
454
+ [.[] | select(.type == "assistant") | . as $msg |
455
+ ($msg.message.content // []) |
456
+ {
457
+ timestamp: $msg.timestamp,
458
+ thinking: ([.[] | select(.type == "thinking") | .thinking] | join("\n")),
459
+ text: ([.[] | select(.type == "text") | .text] | join("\n"))
460
+ } | select(.thinking != "" or .text != "")
461
+ ] as $all_assistant |
462
+
463
+ # Build interactions
464
+ [range(0; $user_messages | length) | . as $i |
465
+ $user_messages[$i] as $user |
466
+ (if $i + 1 < ($user_messages | length) then $user_messages[$i + 1].timestamp else "9999-12-31T23:59:59Z" end) as $next_user_ts |
467
+ [$all_assistant[] | select(.timestamp > $user.timestamp and .timestamp < $next_user_ts)] as $turn_responses |
468
+
469
+ # Collect tool details
470
+ ([.[] | select(.type == "assistant" and .timestamp > $user.timestamp and .timestamp < $next_user_ts) |
471
+ .message.content[]? | select(.type == "tool_use") |
472
+ {
473
+ name: .name,
474
+ detail: (
475
+ if .name == "Bash" then (.input.command // null)
476
+ elif .name == "Read" then (.input.file_path // null)
477
+ elif .name == "Edit" then (.input.file_path // null)
478
+ elif .name == "Write" then (.input.file_path // null)
479
+ elif .name == "Glob" then (.input.pattern // null)
480
+ elif .name == "Grep" then (.input.pattern // null)
481
+ else null
482
+ end
483
+ )
484
+ }
485
+ ]) as $tool_details |
486
+
487
+ if ($turn_responses | length) > 0 then (
488
+ {
489
+ id: ("sub-" + (($i + 1) | tostring | if length < 3 then "00"[0:(3-length)] + . else . end)),
490
+ timestamp: $user.timestamp,
491
+ user: $user.content,
492
+ thinking: ([$turn_responses[].thinking | select(. != "")] | join("\n")),
493
+ assistant: ([$turn_responses[].text | select(. != "")] | join("\n")),
494
+ toolsUsed: ($tool_details | map(.name) | unique),
495
+ toolDetails: $tool_details
496
+ }
497
+ ) else empty end
498
+ ]
499
+ ' 2>/dev/null || echo '[]')
500
+
501
+ # Insert subagent interactions into SQLite
502
+ subagent_interaction_count=$(echo "$subagent_json" | jq 'length')
503
+ if [ "$subagent_interaction_count" -gt 0 ]; then
504
+ echo "$subagent_json" | jq -c '.[]' | while read -r interaction; do
505
+ timestamp=$(echo "$interaction" | jq -r '.timestamp // ""')
506
+ user_content=$(echo "$interaction" | jq -r '.user // ""')
507
+ thinking=$(echo "$interaction" | jq -r '.thinking // ""')
508
+ assistant_content=$(echo "$interaction" | jq -r '.assistant // ""')
509
+
510
+ # Create metadata JSON
511
+ metadata_json=$(echo "$interaction" | jq -c '{toolsUsed: (.toolsUsed // []), toolDetails: (.toolDetails // [])}')
512
+ metadata_escaped="${metadata_json//\'/\'\'}"
513
+
514
+ # Escape single quotes
515
+ user_content_escaped="${user_content//\'/\'\'}"
516
+ thinking_escaped="${thinking//\'/\'\'}"
517
+ assistant_escaped="${assistant_content//\'/\'\'}"
518
+ agent_id_escaped="${agent_id//\'/\'\'}"
519
+ detected_type_escaped="${detected_type//\'/\'\'}"
520
+
521
+ # Insert user message with agent info
522
+ sqlite3 "$db_path" "INSERT INTO interactions (session_id, project_path, repository, repository_url, repository_root, owner, role, content, thinking, tool_calls, timestamp, is_compact_summary, agent_id, agent_type) VALUES ('${mneme_session_id}', '${project_path_escaped}', '${repository_escaped}', '${repository_url_escaped}', '${repository_root_escaped}', '${owner}', 'user', '${user_content_escaped}', NULL, '${metadata_escaped}', '${timestamp}', 0, '${agent_id_escaped}', '${detected_type_escaped}');" 2>/dev/null || true
523
+
524
+ # Insert assistant response with agent info
525
+ if [ -n "$assistant_content" ]; then
526
+ sqlite3 "$db_path" "INSERT INTO interactions (session_id, project_path, repository, repository_url, repository_root, owner, role, content, thinking, timestamp, is_compact_summary, agent_id, agent_type) VALUES ('${mneme_session_id}', '${project_path_escaped}', '${repository_escaped}', '${repository_url_escaped}', '${repository_root_escaped}', '${owner}', 'assistant', '${assistant_escaped}', '${thinking_escaped}', '${timestamp}', 0, '${agent_id_escaped}', '${detected_type_escaped}');" 2>/dev/null || true
527
+ fi
528
+ done
529
+ subagent_count=$((subagent_count + 1))
530
+ fi
531
+ done
532
+
533
+ if [ "$subagent_count" -gt 0 ]; then
534
+ echo "[mneme] Processed ${subagent_count} subagent(s) for session: ${mneme_session_id}" >&2
535
+ fi
536
+ fi
537
+ fi
538
+
539
+ # ============================================
540
+ # Update master session workPeriods.endedAt (if linked)
541
+ # ============================================
542
+ session_link_file="${session_links_dir}/${session_short_id}.json"
543
+ if [ -f "$session_link_file" ]; then
544
+ master_session_id=$(jq -r '.masterSessionId // empty' "$session_link_file" 2>/dev/null || echo "")
545
+ if [ -n "$master_session_id" ]; then
546
+ master_session_path=$(find "$sessions_dir" -name "${master_session_id}.json" -type f 2>/dev/null | head -1)
547
+ if [ -n "$master_session_path" ] && [ -f "$master_session_path" ]; then
548
+ end_now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
549
+ claude_session_id="${session_id}"
550
+ # Update the workPeriod entry with matching claudeSessionId
551
+ jq --arg claudeSessionId "$claude_session_id" \
552
+ --arg endedAt "$end_now" '
553
+ .workPeriods = [.workPeriods[]? |
554
+ if .claudeSessionId == $claudeSessionId and .endedAt == null
555
+ then .endedAt = $endedAt
556
+ else .
557
+ end
558
+ ] |
559
+ .updatedAt = $endedAt
560
+ ' "$master_session_path" > "${master_session_path}.tmp" \
561
+ && mv "${master_session_path}.tmp" "$master_session_path"
562
+ echo "[mneme] Master session workPeriods.endedAt updated: ${master_session_path}" >&2
563
+ fi
564
+ fi
565
+ fi
566
+
567
+ exit 0