-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmemory_save.py
More file actions
276 lines (228 loc) · 8.78 KB
/
Copy pathmemory_save.py
File metadata and controls
276 lines (228 loc) · 8.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
#!/usr/bin/env python3
"""SessionEnd hook: transcript を解析してチャンク化し SQLite に保存する
Version: 20260323B
"""
import json
import os
import sqlite3
import sys
import time
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "memory.db")
MAX_USER_CHARS = 2000
MAX_ASSISTANT_CHARS = 4000
MAX_PAIRS = 500
def init_db(conn):
"""テーブルがなければ作成する"""
conn.executescript("""
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
cwd TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
chunk_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
seq INTEGER NOT NULL,
user_text TEXT NOT NULL,
assistant_text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
);
CREATE INDEX IF NOT EXISTS idx_chunks_session ON chunks(session_id);
CREATE INDEX IF NOT EXISTS idx_chunks_created ON chunks(created_at);
CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
user_text,
assistant_text,
content=chunks,
content_rowid=id,
tokenize="trigram"
);
""")
def extract_cwd_from_transcript(transcript_path):
"""JSONLファイルの先頭付近からcwdフィールドを抽出する"""
try:
with open(transcript_path, "r", encoding="utf-8") as f:
for i, line in enumerate(f):
if i >= 20:
break
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if obj.get("type") == "system":
cwd = obj.get("cwd", "")
if cwd:
return cwd
except (OSError, IOError):
pass
return None
def backfill_unsaved_sessions():
"""未保存のtranscriptを回収してDBに保存する"""
projects_dir = os.path.expanduser("~/.claude/projects/")
if not os.path.isdir(projects_dir):
return
conn = sqlite3.connect(DB_PATH)
try:
init_db(conn)
# 保存済みsession_idの一覧を取得
saved_ids = set()
for row in conn.execute("SELECT session_id FROM sessions"):
saved_ids.add(row[0])
finally:
conn.close()
now = time.time()
deadline = now + 5.0
# projects直下のディレクトリを走査
for dir_name in os.listdir(projects_dir):
if time.time() > deadline:
break
dir_path = os.path.join(projects_dir, dir_name)
if not os.path.isdir(dir_path):
continue
for file_name in os.listdir(dir_path):
if time.time() > deadline:
break
if not file_name.endswith(".jsonl"):
continue
file_path = os.path.join(dir_path, file_name)
if not os.path.isfile(file_path):
continue
session_id = file_name[:-6] # .jsonl を除去
if session_id in saved_ids:
continue
# アクティブセッション回避: mtime が直近5分以内ならスキップ
try:
mtime = os.path.getmtime(file_path)
if now - mtime < 300:
continue
except OSError:
continue
try:
cwd = extract_cwd_from_transcript(file_path)
if not cwd:
continue
pairs = parse_transcript(file_path)
if not pairs:
continue
save_to_db(session_id, cwd, pairs)
except Exception:
continue
def parse_transcript(transcript_path):
"""JSONL transcript をパースして (user_text, assistant_text) ペアのリストを返す"""
pairs = []
current_user_text = None
current_assistant_texts = []
with open(transcript_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
obj_type = obj.get("type", "")
if obj_type == "user":
content = obj.get("message", {}).get("content", "")
# string = ユーザー直接入力、list = tool_result(スキップ)
if isinstance(content, str) and content.strip():
# 前のペアをフラッシュ
if current_user_text and current_assistant_texts:
pairs.append((
current_user_text[:MAX_USER_CHARS],
"\n".join(current_assistant_texts)[:MAX_ASSISTANT_CHARS]
))
if len(pairs) >= MAX_PAIRS:
break
current_user_text = content.strip()
current_assistant_texts = []
elif obj_type == "assistant":
blocks = obj.get("message", {}).get("content", [])
for block in blocks:
if block.get("type") == "text":
text = block.get("text", "").strip()
if text:
current_assistant_texts.append(text)
# thinking, tool_use → スキップ
# progress, queue-operation, system, last-prompt → スキップ
# 最後のペアをフラッシュ
if current_user_text and current_assistant_texts and len(pairs) < MAX_PAIRS:
pairs.append((
current_user_text[:MAX_USER_CHARS],
"\n".join(current_assistant_texts)[:MAX_ASSISTANT_CHARS]
))
return pairs
def save_to_db(session_id, cwd, pairs):
"""チャンクをSQLiteに保存する"""
conn = sqlite3.connect(DB_PATH)
try:
init_db(conn)
# 冪等性: 既にこのsession_idが保存済みならスキップ
existing = conn.execute(
"SELECT 1 FROM sessions WHERE session_id = ?", (session_id,)
).fetchone()
if existing:
return
# H1修正: 明示的トランザクションで一貫性を保証
conn.execute("BEGIN IMMEDIATE")
try:
conn.execute(
"INSERT INTO sessions (session_id, cwd, chunk_count) VALUES (?, ?, ?)",
(session_id, cwd, len(pairs))
)
for seq, (user_text, assistant_text) in enumerate(pairs):
cursor = conn.execute(
"INSERT INTO chunks (session_id, seq, user_text, assistant_text) VALUES (?, ?, ?, ?)",
(session_id, seq, user_text, assistant_text)
)
# FTS5 に手動で同期
# NOTE: content=テーブル使用時、DELETE/UPDATEも手動同期が必要。
# 将来チャンク削除機能を追加する場合はFTSも同期すること。
conn.execute(
"INSERT INTO chunks_fts (rowid, user_text, assistant_text) VALUES (?, ?, ?)",
(cursor.lastrowid, user_text, assistant_text)
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
# DB パーミッション 600(初回のみ意味がある)
try:
os.chmod(DB_PATH, 0o600)
except OSError:
pass
def main():
# --backfill モード
if "--backfill" in sys.argv:
try:
backfill_unsaved_sessions()
except Exception:
pass
sys.exit(0)
try:
# stdin から JSON を読む
input_data = json.load(sys.stdin)
session_id = input_data.get("session_id", "")
transcript_path = input_data.get("transcript_path", "")
cwd = input_data.get("cwd", "")
if not session_id or not transcript_path:
sys.exit(0)
if not os.path.exists(transcript_path):
sys.exit(0)
pairs = parse_transcript(transcript_path)
if not pairs:
sys.exit(0)
save_to_db(session_id, cwd, pairs)
except Exception as e:
# M3修正: エラーをstderrにログ出力(デバッグ用)
print(f"memory_save error: {e}", file=sys.stderr)
sys.exit(0)
if __name__ == "__main__":
main()