@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,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
|