loreconvo 0.3.0__py3-none-any.whl
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.
- hooks/scripts/auto_load.py +307 -0
- hooks/scripts/auto_save.py +301 -0
- loreconvo-0.3.0.dist-info/LICENSE +93 -0
- loreconvo-0.3.0.dist-info/METADATA +365 -0
- loreconvo-0.3.0.dist-info/RECORD +33 -0
- loreconvo-0.3.0.dist-info/WHEEL +5 -0
- loreconvo-0.3.0.dist-info/entry_points.txt +3 -0
- loreconvo-0.3.0.dist-info/top_level.txt +4 -0
- scripts/onboard_verify.py +420 -0
- scripts/save_to_loreconvo.py +274 -0
- src/__init__.py +0 -0
- src/cli.py +212 -0
- src/core/__init__.py +8 -0
- src/core/config.py +38 -0
- src/core/database.py +594 -0
- src/core/license.py +214 -0
- src/core/models.py +61 -0
- src/server.py +362 -0
- tests/__init__.py +0 -0
- tests/test_auto_load.py +232 -0
- tests/test_auto_save.py +254 -0
- tests/test_cli.py +267 -0
- tests/test_database_new.py +204 -0
- tests/test_gitignore_safety.py +69 -0
- tests/test_license.py +296 -0
- tests/test_mcp_json_pro_defaults.py +76 -0
- tests/test_null_id_migration.py +305 -0
- tests/test_onboard.py +333 -0
- tests/test_plugin_json_structure.py +82 -0
- tests/test_plugin_skills_sync.py +135 -0
- tests/test_save_script.py +226 -0
- tests/test_session_limit.py +181 -0
- tests/test_vault_suggest.py +362 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""LoreConvo SessionStart auto-load hook.
|
|
2
|
+
|
|
3
|
+
Receives session metadata via stdin JSON from Claude Code's SessionStart hook.
|
|
4
|
+
Queries the LoreConvo SQLite database for recent sessions matching the current
|
|
5
|
+
working directory (project), then outputs a context summary to stdout.
|
|
6
|
+
|
|
7
|
+
Claude Code injects stdout content into the session as system context.
|
|
8
|
+
Designed to run within the hook timeout window.
|
|
9
|
+
|
|
10
|
+
Scoring logic (higher = shown first):
|
|
11
|
+
+3 session has open questions (most actionable context)
|
|
12
|
+
+2 session has >= 2 decisions
|
|
13
|
+
+1 session has artifacts
|
|
14
|
+
+2 started within last 24 hours
|
|
15
|
+
+1 started within last 3 days
|
|
16
|
+
-2 no summary, no decisions, no open questions, no artifacts (noise)
|
|
17
|
+
|
|
18
|
+
Sessions that score <= 0 and are not the only results are filtered out.
|
|
19
|
+
Total formatted context is capped at MAX_CONTEXT_CHARS to avoid bloat.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import sqlite3
|
|
26
|
+
from datetime import datetime, timedelta
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
MAX_CONTEXT_CHARS = 4000 # Soft cap on total output length
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_db_path():
|
|
34
|
+
"""Get database path, matching core/config.py logic."""
|
|
35
|
+
return os.environ.get("LORECONVO_DB", os.path.expanduser("~/.loreconvo/sessions.db"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def score_session(session, now):
|
|
39
|
+
"""Compute a signal quality score for a session dict.
|
|
40
|
+
|
|
41
|
+
Higher scores = more actionable context for the incoming session.
|
|
42
|
+
"""
|
|
43
|
+
score = 0
|
|
44
|
+
|
|
45
|
+
# Open questions are highest signal -- unanswered items the user may continue
|
|
46
|
+
open_q_raw = session.get("open_questions", "[]")
|
|
47
|
+
try:
|
|
48
|
+
open_questions = json.loads(open_q_raw) if isinstance(open_q_raw, str) else (open_q_raw or [])
|
|
49
|
+
except (json.JSONDecodeError, TypeError):
|
|
50
|
+
open_questions = []
|
|
51
|
+
if open_questions:
|
|
52
|
+
score += 3
|
|
53
|
+
|
|
54
|
+
# Decisions (>= 2 means a substantive work session)
|
|
55
|
+
decisions_raw = session.get("decisions", "[]")
|
|
56
|
+
try:
|
|
57
|
+
decisions = json.loads(decisions_raw) if isinstance(decisions_raw, str) else (decisions_raw or [])
|
|
58
|
+
except (json.JSONDecodeError, TypeError):
|
|
59
|
+
decisions = []
|
|
60
|
+
if len(decisions) >= 2:
|
|
61
|
+
score += 2
|
|
62
|
+
elif len(decisions) == 1:
|
|
63
|
+
score += 1
|
|
64
|
+
|
|
65
|
+
# Artifacts indicate something was produced
|
|
66
|
+
artifacts_raw = session.get("artifacts", "[]")
|
|
67
|
+
try:
|
|
68
|
+
artifacts = json.loads(artifacts_raw) if isinstance(artifacts_raw, str) else (artifacts_raw or [])
|
|
69
|
+
except (json.JSONDecodeError, TypeError):
|
|
70
|
+
artifacts = []
|
|
71
|
+
if artifacts:
|
|
72
|
+
score += 1
|
|
73
|
+
|
|
74
|
+
# Recency bonus
|
|
75
|
+
start_raw = session.get("start_date", "")
|
|
76
|
+
if start_raw:
|
|
77
|
+
try:
|
|
78
|
+
start_dt = datetime.fromisoformat(start_raw)
|
|
79
|
+
age = now - start_dt
|
|
80
|
+
if age <= timedelta(hours=24):
|
|
81
|
+
score += 2
|
|
82
|
+
elif age <= timedelta(days=3):
|
|
83
|
+
score += 1
|
|
84
|
+
except (ValueError, TypeError):
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
# Noise penalty: short/empty sessions add clutter
|
|
88
|
+
summary = session.get("summary", "") or ""
|
|
89
|
+
if not summary and not decisions and not open_questions and not artifacts:
|
|
90
|
+
score -= 2
|
|
91
|
+
|
|
92
|
+
return score
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def query_recent_sessions(db_path, cwd, days_back=14, limit=10):
|
|
96
|
+
"""Query LoreConvo for recent sessions, optionally filtered by project/cwd.
|
|
97
|
+
|
|
98
|
+
Fetches a wider window than the old hook (days_back=14, limit=10) so the
|
|
99
|
+
scoring pass has enough candidates to work with. The caller then scores,
|
|
100
|
+
filters, and caps before formatting.
|
|
101
|
+
|
|
102
|
+
Returns a list of session dicts.
|
|
103
|
+
"""
|
|
104
|
+
if not os.path.exists(db_path):
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
conn = sqlite3.connect(db_path)
|
|
108
|
+
conn.row_factory = sqlite3.Row
|
|
109
|
+
try:
|
|
110
|
+
cutoff = (datetime.now() - timedelta(days=days_back)).isoformat()
|
|
111
|
+
|
|
112
|
+
sessions = []
|
|
113
|
+
|
|
114
|
+
if cwd:
|
|
115
|
+
cursor = conn.execute(
|
|
116
|
+
"""SELECT id, title, summary, decisions, artifacts,
|
|
117
|
+
open_questions, tags, start_date, end_date
|
|
118
|
+
FROM sessions
|
|
119
|
+
WHERE project LIKE ?
|
|
120
|
+
AND start_date >= ?
|
|
121
|
+
ORDER BY start_date DESC
|
|
122
|
+
LIMIT ?""",
|
|
123
|
+
(f"%{cwd}%", cutoff, limit),
|
|
124
|
+
)
|
|
125
|
+
sessions = [dict(row) for row in cursor.fetchall()]
|
|
126
|
+
|
|
127
|
+
# Fall back to most recent across all projects if no project matches
|
|
128
|
+
if not sessions:
|
|
129
|
+
cursor = conn.execute(
|
|
130
|
+
"""SELECT id, title, summary, decisions, artifacts,
|
|
131
|
+
open_questions, tags, start_date, end_date
|
|
132
|
+
FROM sessions
|
|
133
|
+
ORDER BY start_date DESC
|
|
134
|
+
LIMIT ?""",
|
|
135
|
+
(limit,),
|
|
136
|
+
)
|
|
137
|
+
sessions = [dict(row) for row in cursor.fetchall()]
|
|
138
|
+
|
|
139
|
+
return sessions
|
|
140
|
+
|
|
141
|
+
except Exception as e:
|
|
142
|
+
sys.stderr.write(f"LoreConvo auto-load query error: {e}\n")
|
|
143
|
+
return []
|
|
144
|
+
finally:
|
|
145
|
+
conn.close()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def select_sessions(sessions, max_count=5):
|
|
149
|
+
"""Score, filter, and rank sessions. Returns up to max_count best ones."""
|
|
150
|
+
if not sessions:
|
|
151
|
+
return []
|
|
152
|
+
|
|
153
|
+
now = datetime.now()
|
|
154
|
+
scored = [(score_session(s, now), s) for s in sessions]
|
|
155
|
+
|
|
156
|
+
# Sort by score desc, then recency desc for ties
|
|
157
|
+
scored.sort(key=lambda x: (x[0], x[1].get("start_date", "")), reverse=True)
|
|
158
|
+
|
|
159
|
+
# Filter out noise sessions as long as we still have enough good ones
|
|
160
|
+
good = [(sc, s) for sc, s in scored if sc > 0]
|
|
161
|
+
if not good:
|
|
162
|
+
# All sessions scored <= 0 -- keep top few rather than returning nothing
|
|
163
|
+
good = scored[:max_count]
|
|
164
|
+
|
|
165
|
+
return [s for _, s in good[:max_count]]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def format_context(sessions, cwd):
|
|
169
|
+
"""Format session data into a concise context block for Claude.
|
|
170
|
+
|
|
171
|
+
Output is plain text that Claude Code injects into the session.
|
|
172
|
+
Includes open_questions (missing from old version) as they are highest-signal.
|
|
173
|
+
Enforces MAX_CONTEXT_CHARS soft cap to prevent system prompt bloat.
|
|
174
|
+
"""
|
|
175
|
+
if not sessions:
|
|
176
|
+
return ""
|
|
177
|
+
|
|
178
|
+
lines = []
|
|
179
|
+
lines.append("# LoreConvo: Recent Session Context")
|
|
180
|
+
lines.append("")
|
|
181
|
+
|
|
182
|
+
if cwd:
|
|
183
|
+
project_name = os.path.basename(cwd) if cwd else "unknown"
|
|
184
|
+
lines.append(f"Recent sessions for project: {project_name}")
|
|
185
|
+
else:
|
|
186
|
+
lines.append("Recent sessions (no project filter):")
|
|
187
|
+
lines.append("")
|
|
188
|
+
|
|
189
|
+
total_chars = sum(len(l) for l in lines)
|
|
190
|
+
|
|
191
|
+
for i, session in enumerate(sessions, 1):
|
|
192
|
+
block = []
|
|
193
|
+
title = session.get("title") or "Untitled"
|
|
194
|
+
start = session.get("start_date", "")
|
|
195
|
+
|
|
196
|
+
date_str = ""
|
|
197
|
+
if start:
|
|
198
|
+
try:
|
|
199
|
+
dt = datetime.fromisoformat(start)
|
|
200
|
+
date_str = dt.strftime("%b %d, %Y %I:%M %p")
|
|
201
|
+
except (ValueError, TypeError):
|
|
202
|
+
date_str = start[:19] if start else ""
|
|
203
|
+
|
|
204
|
+
block.append(f"## Session {i}: {title}")
|
|
205
|
+
if date_str:
|
|
206
|
+
block.append(f"Date: {date_str}")
|
|
207
|
+
|
|
208
|
+
# Summary (truncated)
|
|
209
|
+
summary = session.get("summary") or ""
|
|
210
|
+
if summary:
|
|
211
|
+
truncated = summary[:400]
|
|
212
|
+
if len(summary) > 400:
|
|
213
|
+
truncated += "..."
|
|
214
|
+
block.append(f"Summary: {truncated}")
|
|
215
|
+
|
|
216
|
+
# Open questions -- highest signal, always include when present
|
|
217
|
+
open_q_raw = session.get("open_questions", "[]")
|
|
218
|
+
try:
|
|
219
|
+
open_questions = json.loads(open_q_raw) if isinstance(open_q_raw, str) else (open_q_raw or [])
|
|
220
|
+
except (json.JSONDecodeError, TypeError):
|
|
221
|
+
open_questions = []
|
|
222
|
+
if open_questions:
|
|
223
|
+
block.append("Open questions:")
|
|
224
|
+
for q in open_questions[:4]:
|
|
225
|
+
block.append(f" ? {q}")
|
|
226
|
+
|
|
227
|
+
# Decisions
|
|
228
|
+
decisions_raw = session.get("decisions", "[]")
|
|
229
|
+
try:
|
|
230
|
+
decisions = json.loads(decisions_raw) if isinstance(decisions_raw, str) else (decisions_raw or [])
|
|
231
|
+
except (json.JSONDecodeError, TypeError):
|
|
232
|
+
decisions = []
|
|
233
|
+
if decisions:
|
|
234
|
+
block.append("Key decisions:")
|
|
235
|
+
for d in decisions[:4]:
|
|
236
|
+
block.append(f" - {d}")
|
|
237
|
+
|
|
238
|
+
# Artifacts
|
|
239
|
+
artifacts_raw = session.get("artifacts", "[]")
|
|
240
|
+
try:
|
|
241
|
+
artifacts = json.loads(artifacts_raw) if isinstance(artifacts_raw, str) else (artifacts_raw or [])
|
|
242
|
+
except (json.JSONDecodeError, TypeError):
|
|
243
|
+
artifacts = []
|
|
244
|
+
if artifacts:
|
|
245
|
+
block.append("Artifacts: " + ", ".join(artifacts[:5]))
|
|
246
|
+
|
|
247
|
+
block.append("")
|
|
248
|
+
|
|
249
|
+
block_chars = sum(len(l) for l in block)
|
|
250
|
+
if total_chars + block_chars > MAX_CONTEXT_CHARS and i > 1:
|
|
251
|
+
# Soft cap reached -- stop adding more sessions
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
lines.extend(block)
|
|
255
|
+
total_chars += block_chars
|
|
256
|
+
|
|
257
|
+
lines.append("---")
|
|
258
|
+
lines.append("Use this context to avoid re-asking questions or repeating work from prior sessions.")
|
|
259
|
+
lines.append("If a prior session is directly relevant, query LoreConvo MCP tools for full details.")
|
|
260
|
+
|
|
261
|
+
return "\n".join(lines)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def main():
|
|
265
|
+
"""Main entry point for SessionStart hook."""
|
|
266
|
+
try:
|
|
267
|
+
stdin_data = sys.stdin.read()
|
|
268
|
+
if not stdin_data:
|
|
269
|
+
sys.exit(0)
|
|
270
|
+
|
|
271
|
+
hook_input = json.loads(stdin_data)
|
|
272
|
+
session_id = hook_input.get("session_id", "unknown")
|
|
273
|
+
cwd = hook_input.get("cwd", "")
|
|
274
|
+
|
|
275
|
+
db_path = get_db_path()
|
|
276
|
+
|
|
277
|
+
days_back = int(os.environ.get("LORECONVO_DAYS_BACK", "14"))
|
|
278
|
+
limit = int(os.environ.get("LORECONVO_LIMIT", "10"))
|
|
279
|
+
max_count = int(os.environ.get("LORECONVO_MAX_SESSIONS", "5"))
|
|
280
|
+
|
|
281
|
+
raw_sessions = query_recent_sessions(db_path, cwd, days_back=days_back, limit=limit)
|
|
282
|
+
|
|
283
|
+
if not raw_sessions:
|
|
284
|
+
sys.stderr.write(
|
|
285
|
+
f"LoreConvo auto-load: No recent sessions found for {cwd or 'any project'}\n"
|
|
286
|
+
)
|
|
287
|
+
sys.exit(0)
|
|
288
|
+
|
|
289
|
+
sessions = select_sessions(raw_sessions, max_count=max_count)
|
|
290
|
+
|
|
291
|
+
context = format_context(sessions, cwd)
|
|
292
|
+
if context:
|
|
293
|
+
print(context)
|
|
294
|
+
sys.stderr.write(
|
|
295
|
+
f"LoreConvo auto-load: Injected context from {len(sessions)} session(s) "
|
|
296
|
+
f"(scored from {len(raw_sessions)} candidates) for session {session_id}\n"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
except json.JSONDecodeError:
|
|
300
|
+
sys.exit(0)
|
|
301
|
+
except Exception as e:
|
|
302
|
+
sys.stderr.write(f"LoreConvo auto-load error: {e}\n")
|
|
303
|
+
sys.exit(0)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == "__main__":
|
|
307
|
+
main()
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""LoreConvo SessionEnd auto-save hook.
|
|
2
|
+
|
|
3
|
+
Receives session metadata via stdin JSON from Claude Code's SessionEnd hook.
|
|
4
|
+
Parses the transcript JSONL to extract a summary, then saves directly to SQLite.
|
|
5
|
+
|
|
6
|
+
Designed to run within the 3-5 second timeout window.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import sqlite3
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_db_path():
|
|
19
|
+
"""Get database path, matching core/config.py logic."""
|
|
20
|
+
return os.environ.get("LORECONVO_DB", os.path.expanduser("~/.loreconvo/sessions.db"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_transcript(transcript_path):
|
|
24
|
+
"""Parse a Claude Code JSONL transcript into structured session data.
|
|
25
|
+
|
|
26
|
+
Extracts: title (from first user message), surface, summary of key exchanges,
|
|
27
|
+
decisions (lines starting with decision-like language), and artifacts.
|
|
28
|
+
"""
|
|
29
|
+
if not transcript_path or not os.path.exists(transcript_path):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
messages = []
|
|
33
|
+
try:
|
|
34
|
+
with open(transcript_path, "r") as f:
|
|
35
|
+
for line in f:
|
|
36
|
+
line = line.strip()
|
|
37
|
+
if not line:
|
|
38
|
+
continue
|
|
39
|
+
try:
|
|
40
|
+
entry = json.loads(line)
|
|
41
|
+
messages.append(entry)
|
|
42
|
+
except json.JSONDecodeError:
|
|
43
|
+
continue
|
|
44
|
+
except (IOError, PermissionError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
if not messages:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
# Extract user and assistant messages
|
|
51
|
+
user_messages = []
|
|
52
|
+
assistant_messages = []
|
|
53
|
+
tool_uses = []
|
|
54
|
+
|
|
55
|
+
for msg in messages:
|
|
56
|
+
# Real Claude Code transcripts wrap messages: {"type":"user", "message": {"role":..., "content":...}}
|
|
57
|
+
inner = msg.get("message", msg)
|
|
58
|
+
role = inner.get("role", "") if isinstance(inner, dict) else msg.get("role", "")
|
|
59
|
+
content = inner.get("content", "") if isinstance(inner, dict) else msg.get("content", "")
|
|
60
|
+
|
|
61
|
+
# Handle content that's a list of blocks
|
|
62
|
+
if isinstance(content, list):
|
|
63
|
+
text_parts = []
|
|
64
|
+
for block in content:
|
|
65
|
+
if isinstance(block, dict):
|
|
66
|
+
if block.get("type") == "text":
|
|
67
|
+
text_parts.append(block.get("text", ""))
|
|
68
|
+
elif block.get("type") == "tool_use":
|
|
69
|
+
tool_name = block.get("name", "unknown")
|
|
70
|
+
if tool_name == "Skill":
|
|
71
|
+
# Extract the actual skill name from the input parameter
|
|
72
|
+
# so skill-history shows e.g. "skill:langgraph-finance-workflow"
|
|
73
|
+
# instead of the raw string "Skill"
|
|
74
|
+
input_val = block.get("input") or {}
|
|
75
|
+
skill_name = input_val.get("skill") if isinstance(input_val, dict) else None
|
|
76
|
+
if skill_name:
|
|
77
|
+
tool_uses.append(f"skill:{skill_name}")
|
|
78
|
+
else:
|
|
79
|
+
tool_uses.append("Skill")
|
|
80
|
+
else:
|
|
81
|
+
tool_uses.append(tool_name)
|
|
82
|
+
elif isinstance(block, str):
|
|
83
|
+
text_parts.append(block)
|
|
84
|
+
content = " ".join(text_parts)
|
|
85
|
+
|
|
86
|
+
if role == "user" and content:
|
|
87
|
+
user_messages.append(content[:500]) # Truncate long messages
|
|
88
|
+
elif role == "assistant" and content:
|
|
89
|
+
assistant_messages.append(content[:500])
|
|
90
|
+
|
|
91
|
+
if not user_messages:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
# Title: first user message, truncated
|
|
95
|
+
first_msg = user_messages[0]
|
|
96
|
+
title = first_msg[:80].replace("\n", " ").strip()
|
|
97
|
+
if len(first_msg) > 80:
|
|
98
|
+
title += "..."
|
|
99
|
+
|
|
100
|
+
# Summary: combine first few exchanges
|
|
101
|
+
summary_parts = []
|
|
102
|
+
for i, msg in enumerate(user_messages[:3]):
|
|
103
|
+
summary_parts.append(f"User: {msg[:200]}")
|
|
104
|
+
if i < len(assistant_messages):
|
|
105
|
+
summary_parts.append(f"Assistant: {assistant_messages[i][:200]}")
|
|
106
|
+
summary = "\n".join(summary_parts)
|
|
107
|
+
|
|
108
|
+
# Truncate summary to reasonable length
|
|
109
|
+
if len(summary) > 2000:
|
|
110
|
+
summary = summary[:2000] + "..."
|
|
111
|
+
|
|
112
|
+
# Detect decisions (simple heuristic)
|
|
113
|
+
decisions = []
|
|
114
|
+
decision_keywords = ["decided", "agreed", "confirmed", "chose", "will use", "going with", "settled on"]
|
|
115
|
+
for msg in assistant_messages:
|
|
116
|
+
msg_lower = msg.lower()
|
|
117
|
+
for keyword in decision_keywords:
|
|
118
|
+
if keyword in msg_lower:
|
|
119
|
+
# Extract the sentence containing the keyword
|
|
120
|
+
for sentence in msg.split("."):
|
|
121
|
+
if keyword in sentence.lower():
|
|
122
|
+
clean = sentence.strip()
|
|
123
|
+
if clean and len(clean) > 10:
|
|
124
|
+
decisions.append(clean[:200])
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
# Detect artifacts (file paths, URLs)
|
|
128
|
+
artifacts = []
|
|
129
|
+
for msg in assistant_messages:
|
|
130
|
+
# Look for file paths
|
|
131
|
+
for word in msg.split():
|
|
132
|
+
if "/" in word and ("." in word.split("/")[-1]) and len(word) > 5:
|
|
133
|
+
clean = word.strip("(),\"'`")
|
|
134
|
+
if clean not in artifacts and len(artifacts) < 10:
|
|
135
|
+
artifacts.append(clean)
|
|
136
|
+
|
|
137
|
+
# Unique tools used
|
|
138
|
+
unique_tools = list(set(tool_uses))[:20]
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
"title": title,
|
|
142
|
+
"summary": summary,
|
|
143
|
+
"decisions": decisions[:10],
|
|
144
|
+
"artifacts": artifacts[:10],
|
|
145
|
+
"tools_used": unique_tools,
|
|
146
|
+
"message_count": len(user_messages) + len(assistant_messages),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ensure_tables(conn):
|
|
151
|
+
"""Create tables if they don't exist (matches core/database.py schema)."""
|
|
152
|
+
conn.executescript("""
|
|
153
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
154
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
155
|
+
title TEXT NOT NULL,
|
|
156
|
+
surface TEXT NOT NULL,
|
|
157
|
+
project TEXT,
|
|
158
|
+
summary TEXT,
|
|
159
|
+
decisions TEXT DEFAULT '[]',
|
|
160
|
+
artifacts TEXT DEFAULT '[]',
|
|
161
|
+
open_questions TEXT DEFAULT '[]',
|
|
162
|
+
tags TEXT DEFAULT '[]',
|
|
163
|
+
start_date TEXT,
|
|
164
|
+
end_date TEXT,
|
|
165
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
166
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
167
|
+
);
|
|
168
|
+
CREATE TABLE IF NOT EXISTS session_skills (
|
|
169
|
+
session_id TEXT NOT NULL,
|
|
170
|
+
skill_name TEXT NOT NULL,
|
|
171
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id),
|
|
172
|
+
UNIQUE(session_id, skill_name)
|
|
173
|
+
);
|
|
174
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
|
|
175
|
+
title, summary, decisions, content=sessions, content_rowid=rowid
|
|
176
|
+
);
|
|
177
|
+
""")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def save_to_db(db_path, session_id, parsed):
|
|
181
|
+
"""Save parsed session data directly to SQLite."""
|
|
182
|
+
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
183
|
+
|
|
184
|
+
conn = sqlite3.connect(db_path)
|
|
185
|
+
try:
|
|
186
|
+
ensure_tables(conn)
|
|
187
|
+
|
|
188
|
+
# Use Claude's session_id as our primary key so dedup actually works.
|
|
189
|
+
# Previous bug: generated a random UUID for id but checked WHERE id = session_id,
|
|
190
|
+
# so the duplicate guard never matched anything.
|
|
191
|
+
session_uuid = session_id
|
|
192
|
+
|
|
193
|
+
# Check if session already exists (e.g., resumed session or duplicate hook fire)
|
|
194
|
+
cursor = conn.execute("SELECT id FROM sessions WHERE id = ?", (session_uuid,))
|
|
195
|
+
if cursor.fetchone():
|
|
196
|
+
# Already saved -- update instead of duplicate
|
|
197
|
+
now = datetime.now().isoformat()
|
|
198
|
+
conn.execute(
|
|
199
|
+
"""UPDATE sessions SET summary = ?, decisions = ?, artifacts = ?,
|
|
200
|
+
tags = ?, end_date = ?, updated_at = ?
|
|
201
|
+
WHERE id = ?""",
|
|
202
|
+
(
|
|
203
|
+
parsed["summary"],
|
|
204
|
+
json.dumps(parsed["decisions"]),
|
|
205
|
+
json.dumps(parsed["artifacts"]),
|
|
206
|
+
json.dumps(["auto-saved"]),
|
|
207
|
+
now,
|
|
208
|
+
now,
|
|
209
|
+
session_uuid,
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
conn.commit()
|
|
213
|
+
return True # Updated existing record
|
|
214
|
+
|
|
215
|
+
now = datetime.now().isoformat()
|
|
216
|
+
|
|
217
|
+
conn.execute(
|
|
218
|
+
"""INSERT INTO sessions (id, title, surface, summary, decisions, artifacts,
|
|
219
|
+
open_questions, tags, start_date, end_date)
|
|
220
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
221
|
+
(
|
|
222
|
+
session_uuid,
|
|
223
|
+
parsed["title"],
|
|
224
|
+
"code",
|
|
225
|
+
parsed["summary"],
|
|
226
|
+
json.dumps(parsed["decisions"]),
|
|
227
|
+
json.dumps(parsed["artifacts"]),
|
|
228
|
+
json.dumps([]),
|
|
229
|
+
json.dumps(["auto-saved"]),
|
|
230
|
+
now,
|
|
231
|
+
now,
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Update FTS index
|
|
236
|
+
try:
|
|
237
|
+
conn.execute(
|
|
238
|
+
"""INSERT INTO sessions_fts(rowid, title, summary, decisions)
|
|
239
|
+
SELECT rowid, title, summary, decisions
|
|
240
|
+
FROM sessions WHERE id = ?""",
|
|
241
|
+
(session_uuid,),
|
|
242
|
+
)
|
|
243
|
+
except sqlite3.OperationalError:
|
|
244
|
+
pass # FTS table might not exist in older DBs
|
|
245
|
+
|
|
246
|
+
# Save skill/tool usage
|
|
247
|
+
for tool in parsed.get("tools_used", []):
|
|
248
|
+
try:
|
|
249
|
+
conn.execute(
|
|
250
|
+
"INSERT INTO session_skills (session_id, skill_name) VALUES (?, ?)",
|
|
251
|
+
(session_uuid, tool),
|
|
252
|
+
)
|
|
253
|
+
except sqlite3.IntegrityError:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
conn.commit()
|
|
257
|
+
return True
|
|
258
|
+
except Exception as e:
|
|
259
|
+
sys.stderr.write(f"LoreConvo auto-save DB error: {e}\n")
|
|
260
|
+
return False
|
|
261
|
+
finally:
|
|
262
|
+
conn.close()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def main():
|
|
266
|
+
"""Main entry point for SessionEnd hook."""
|
|
267
|
+
try:
|
|
268
|
+
# Read hook input from stdin
|
|
269
|
+
stdin_data = sys.stdin.read()
|
|
270
|
+
if not stdin_data:
|
|
271
|
+
sys.exit(0)
|
|
272
|
+
|
|
273
|
+
hook_input = json.loads(stdin_data)
|
|
274
|
+
session_id = hook_input.get("session_id", "unknown")
|
|
275
|
+
transcript_path = hook_input.get("transcript_path", "")
|
|
276
|
+
|
|
277
|
+
# Parse transcript
|
|
278
|
+
parsed = parse_transcript(transcript_path)
|
|
279
|
+
if not parsed:
|
|
280
|
+
sys.exit(0)
|
|
281
|
+
|
|
282
|
+
# Skip very short sessions (less than 2 messages)
|
|
283
|
+
if parsed["message_count"] < 2:
|
|
284
|
+
sys.exit(0)
|
|
285
|
+
|
|
286
|
+
# Save to database
|
|
287
|
+
db_path = get_db_path()
|
|
288
|
+
saved = save_to_db(db_path, session_id, parsed)
|
|
289
|
+
|
|
290
|
+
if saved:
|
|
291
|
+
sys.stderr.write(f"LoreConvo: Auto-saved session '{parsed['title']}'\n")
|
|
292
|
+
|
|
293
|
+
except json.JSONDecodeError:
|
|
294
|
+
sys.exit(0)
|
|
295
|
+
except Exception as e:
|
|
296
|
+
sys.stderr.write(f"LoreConvo auto-save error: {e}\n")
|
|
297
|
+
sys.exit(0)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__":
|
|
301
|
+
main()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
Parameters
|
|
4
|
+
|
|
5
|
+
Licensor: Labyrinth Analytics Consulting
|
|
6
|
+
Licensed Work: LoreConvo v0.3.0
|
|
7
|
+
The Licensed Work is (c) 2026 Labyrinth Analytics Consulting
|
|
8
|
+
Additional Use Grant: You may make personal, non-commercial use of the Licensed Work,
|
|
9
|
+
subject to the following limitation: you may store no more than
|
|
10
|
+
50 sessions in total across all databases on your machine. Any
|
|
11
|
+
use that exceeds this limit, or any commercial use, requires a
|
|
12
|
+
paid license from the Licensor.
|
|
13
|
+
Change Date: 2030-03-31
|
|
14
|
+
Change License: Apache License, Version 2.0
|
|
15
|
+
|
|
16
|
+
For information about alternative licensing arrangements for the Licensed Work,
|
|
17
|
+
please contact: info@labyrinthanalyticsconsulting.com
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
Business Source License 1.1
|
|
22
|
+
|
|
23
|
+
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
|
24
|
+
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
|
25
|
+
|
|
26
|
+
Terms
|
|
27
|
+
|
|
28
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
29
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
30
|
+
Licensor may make an Additional Use Grant, above, permitting limited production
|
|
31
|
+
use.
|
|
32
|
+
|
|
33
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
34
|
+
available distribution of a specific version of the Licensed Work under this
|
|
35
|
+
License, whichever comes first, the Licensor hereby grants you rights under
|
|
36
|
+
the terms of the Change License, and the rights granted in the paragraph
|
|
37
|
+
above terminate.
|
|
38
|
+
|
|
39
|
+
If your use of the Licensed Work does not comply with the requirements
|
|
40
|
+
currently in effect as described in this License, you must purchase a
|
|
41
|
+
commercial license from the Licensor, its affiliated entities, or authorized
|
|
42
|
+
resellers, or you must refrain from using the Licensed Work.
|
|
43
|
+
|
|
44
|
+
All copies of the original and modified Licensed Work, and derivative works
|
|
45
|
+
of the Licensed Work, are subject to this License. This License applies
|
|
46
|
+
separately for each version of the Licensed Work and the Change Date may vary
|
|
47
|
+
for each version of the Licensed Work released by Licensor.
|
|
48
|
+
|
|
49
|
+
You must conspicuously display this License on each original or modified copy
|
|
50
|
+
of the Licensed Work. If you receive a copy of the Licensed Work in
|
|
51
|
+
combination with other programs, as part of a larger work, or packaged by a
|
|
52
|
+
third party, and you receive the Licensed Work under a different license from
|
|
53
|
+
the one described in this License, you are required to comply with the license
|
|
54
|
+
applicable to you.
|
|
55
|
+
|
|
56
|
+
Any use of the Licensed Work in violation of this License will automatically
|
|
57
|
+
terminate your rights under this License for the current and all other
|
|
58
|
+
versions of the Licensed Work.
|
|
59
|
+
|
|
60
|
+
This License does not grant you any right in any trademark or logo of
|
|
61
|
+
Licensor or its affiliates (provided that you may use a trademark or logo of
|
|
62
|
+
Licensor as expressly required by this License).
|
|
63
|
+
|
|
64
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
|
65
|
+
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
|
66
|
+
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
|
67
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
|
68
|
+
TITLE.
|
|
69
|
+
|
|
70
|
+
MariaDB hereby grants you permission to use this License's text to license
|
|
71
|
+
your works, and to refer to it using the trademark "Business Source License",
|
|
72
|
+
as long as you comply with the Covenants of Licensor below.
|
|
73
|
+
|
|
74
|
+
Covenants of Licensor
|
|
75
|
+
|
|
76
|
+
In consideration of the right to use this License's text and the "Business
|
|
77
|
+
Source License" name and trademark, Licensor covenants to MariaDB, and to all
|
|
78
|
+
other recipients of the licensed work to be provided under this License:
|
|
79
|
+
|
|
80
|
+
1. To specify as the Change License the GPL Version 2.0 or any later version,
|
|
81
|
+
or a license that is compatible with GPL Version 2.0 or a later version,
|
|
82
|
+
where "compatible" means that software provided under the Change License can
|
|
83
|
+
be included in a program with software provided under GPL Version 2.0 or a
|
|
84
|
+
later version. Licensor may specify additional Change Licenses without
|
|
85
|
+
limitation.
|
|
86
|
+
|
|
87
|
+
2. To either: (a) specify an additional grant of rights to use that does not
|
|
88
|
+
impose any additional restriction on the right granted in this License, as
|
|
89
|
+
the Additional Use Grant; or (b) insert the text "None".
|
|
90
|
+
|
|
91
|
+
3. To specify a Change Date.
|
|
92
|
+
|
|
93
|
+
4. Not to modify this License in any other way.
|