codeatrium 0.1.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.
- codeatrium/__init__.py +3 -0
- codeatrium/__main__.py +5 -0
- codeatrium/cli/__init__.py +295 -0
- codeatrium/cli/distill_cmd.py +76 -0
- codeatrium/cli/hook_cmd.py +24 -0
- codeatrium/cli/index_cmd.py +62 -0
- codeatrium/cli/prime_cmd.py +90 -0
- codeatrium/cli/search_cmd.py +128 -0
- codeatrium/cli/server_cmd.py +122 -0
- codeatrium/cli/show_cmd.py +151 -0
- codeatrium/cli/status_cmd.py +59 -0
- codeatrium/config.py +96 -0
- codeatrium/db.py +135 -0
- codeatrium/distiller.py +290 -0
- codeatrium/embedder.py +168 -0
- codeatrium/embedder_server.py +172 -0
- codeatrium/hooks.py +156 -0
- codeatrium/indexer.py +237 -0
- codeatrium/llm.py +148 -0
- codeatrium/models.py +53 -0
- codeatrium/paths.py +74 -0
- codeatrium/py.typed +0 -0
- codeatrium/resolver.py +301 -0
- codeatrium/search.py +273 -0
- codeatrium-0.1.0.dist-info/METADATA +180 -0
- codeatrium-0.1.0.dist-info/RECORD +29 -0
- codeatrium-0.1.0.dist-info/WHEEL +4 -0
- codeatrium-0.1.0.dist-info/entry_points.txt +2 -0
- codeatrium-0.1.0.dist-info/licenses/LICENSE +21 -0
codeatrium/hooks.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Claude Code hook 設定の JSON 操作ロジック"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shlex
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from codeatrium.config import DEFAULT_DISTILL_BATCH_LIMIT
|
|
11
|
+
from codeatrium.paths import loci_bin
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def install_hooks(batch_limit: int = DEFAULT_DISTILL_BATCH_LIMIT) -> tuple[bool, str]:
|
|
15
|
+
"""Claude Code の Stop / SessionStart フックに loci を登録する。
|
|
16
|
+
|
|
17
|
+
Returns: (changed, message) — 変更の有無と結果メッセージ
|
|
18
|
+
"""
|
|
19
|
+
settings_path = Path.home() / ".claude" / "settings.json"
|
|
20
|
+
|
|
21
|
+
if settings_path.exists():
|
|
22
|
+
with settings_path.open() as f:
|
|
23
|
+
settings: dict[str, Any] = json.load(f)
|
|
24
|
+
else:
|
|
25
|
+
settings = {}
|
|
26
|
+
|
|
27
|
+
hooks = settings.setdefault("hooks", {})
|
|
28
|
+
loci = shlex.quote(loci_bin())
|
|
29
|
+
index_cmd = f"{loci} index"
|
|
30
|
+
distill_cmd = f"nohup {loci} distill --limit {int(batch_limit)} > /dev/null 2>&1 &"
|
|
31
|
+
server_cmd = f"nohup {loci} server start > /dev/null 2>&1 &"
|
|
32
|
+
prime_cmd = f"{loci} prime"
|
|
33
|
+
changed = False
|
|
34
|
+
|
|
35
|
+
# --- Stop hook: loci index (async: true) ---
|
|
36
|
+
stop_hooks: list[dict[str, Any]] = hooks.setdefault("Stop", [])
|
|
37
|
+
stop_installed = False
|
|
38
|
+
for entry in stop_hooks:
|
|
39
|
+
for h in entry.get("hooks", []):
|
|
40
|
+
if "loci" in h.get("command", "") and "index" in h.get("command", ""):
|
|
41
|
+
stop_installed = True
|
|
42
|
+
if h.get("command") != index_cmd or not h.get("async"):
|
|
43
|
+
h["command"] = index_cmd
|
|
44
|
+
h["async"] = True
|
|
45
|
+
h.pop("nohup", None)
|
|
46
|
+
changed = True
|
|
47
|
+
if not stop_installed:
|
|
48
|
+
stop_hooks.append(
|
|
49
|
+
{"hooks": [{"type": "command", "command": index_cmd, "async": True}]}
|
|
50
|
+
)
|
|
51
|
+
changed = True
|
|
52
|
+
|
|
53
|
+
# --- SessionStart hook: loci server start + loci distill (nohup detach) ---
|
|
54
|
+
session_start_hooks: list[dict[str, Any]] = hooks.setdefault("SessionStart", [])
|
|
55
|
+
|
|
56
|
+
server_start_installed = False
|
|
57
|
+
for entry in session_start_hooks:
|
|
58
|
+
if entry.get("matcher") != "startup|clear|resume|compact":
|
|
59
|
+
continue
|
|
60
|
+
for h in entry.get("hooks", []):
|
|
61
|
+
if "loci" in h.get("command", "") and "server" in h.get("command", ""):
|
|
62
|
+
server_start_installed = True
|
|
63
|
+
if h.get("command") != server_cmd:
|
|
64
|
+
h["command"] = server_cmd
|
|
65
|
+
changed = True
|
|
66
|
+
|
|
67
|
+
session_start_installed = False
|
|
68
|
+
for entry in session_start_hooks:
|
|
69
|
+
if entry.get("matcher") != "startup|clear|resume|compact":
|
|
70
|
+
continue
|
|
71
|
+
for h in entry.get("hooks", []):
|
|
72
|
+
if "loci" in h.get("command", "") and "distill" in h.get("command", ""):
|
|
73
|
+
session_start_installed = True
|
|
74
|
+
if h.get("command") != distill_cmd:
|
|
75
|
+
h["command"] = distill_cmd
|
|
76
|
+
changed = True
|
|
77
|
+
|
|
78
|
+
if not server_start_installed or not session_start_installed:
|
|
79
|
+
target_entry = next(
|
|
80
|
+
(
|
|
81
|
+
e
|
|
82
|
+
for e in session_start_hooks
|
|
83
|
+
if e.get("matcher") == "startup|clear|resume|compact"
|
|
84
|
+
),
|
|
85
|
+
None,
|
|
86
|
+
)
|
|
87
|
+
if target_entry is None:
|
|
88
|
+
target_entry = {"matcher": "startup|clear|resume|compact", "hooks": []}
|
|
89
|
+
session_start_hooks.append(target_entry)
|
|
90
|
+
hooks_list = cast(list[dict[str, Any]], target_entry["hooks"])
|
|
91
|
+
if not server_start_installed:
|
|
92
|
+
hooks_list.append({"type": "command", "command": server_cmd})
|
|
93
|
+
changed = True
|
|
94
|
+
if not session_start_installed:
|
|
95
|
+
hooks_list.append({"type": "command", "command": distill_cmd})
|
|
96
|
+
changed = True
|
|
97
|
+
|
|
98
|
+
# --- SessionStart hook: loci prime (blocking, stdout をコンテキストに注入) ---
|
|
99
|
+
prime_installed = False
|
|
100
|
+
for entry in session_start_hooks:
|
|
101
|
+
if entry.get("matcher") != "startup|clear|resume|compact":
|
|
102
|
+
continue
|
|
103
|
+
for h in entry.get("hooks", []):
|
|
104
|
+
if "loci" in h.get("command", "") and "prime" in h.get("command", ""):
|
|
105
|
+
prime_installed = True
|
|
106
|
+
if h.get("command") != prime_cmd:
|
|
107
|
+
h["command"] = prime_cmd
|
|
108
|
+
changed = True
|
|
109
|
+
|
|
110
|
+
if not prime_installed:
|
|
111
|
+
target_entry = next(
|
|
112
|
+
(
|
|
113
|
+
e
|
|
114
|
+
for e in session_start_hooks
|
|
115
|
+
if e.get("matcher") == "startup|clear|resume|compact"
|
|
116
|
+
),
|
|
117
|
+
None,
|
|
118
|
+
)
|
|
119
|
+
if target_entry is None:
|
|
120
|
+
target_entry = {"matcher": "startup|clear|resume|compact", "hooks": []}
|
|
121
|
+
session_start_hooks.append(target_entry)
|
|
122
|
+
cast(list[dict[str, Any]], target_entry["hooks"]).append(
|
|
123
|
+
{"type": "command", "command": prime_cmd}
|
|
124
|
+
)
|
|
125
|
+
changed = True
|
|
126
|
+
|
|
127
|
+
# 古い SessionEnd の loci distill エントリがあれば削除
|
|
128
|
+
if "SessionEnd" in hooks:
|
|
129
|
+
hooks["SessionEnd"] = [
|
|
130
|
+
entry
|
|
131
|
+
for entry in hooks["SessionEnd"]
|
|
132
|
+
if not any(
|
|
133
|
+
"loci" in h.get("command", "") and "distill" in h.get("command", "")
|
|
134
|
+
for h in entry.get("hooks", [])
|
|
135
|
+
)
|
|
136
|
+
]
|
|
137
|
+
if not hooks["SessionEnd"]:
|
|
138
|
+
del hooks["SessionEnd"]
|
|
139
|
+
changed = True
|
|
140
|
+
|
|
141
|
+
if not changed:
|
|
142
|
+
return False, "Hooks already up to date."
|
|
143
|
+
|
|
144
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
with settings_path.open("w") as f:
|
|
146
|
+
json.dump(settings, f, ensure_ascii=False, indent=2)
|
|
147
|
+
|
|
148
|
+
lines = [
|
|
149
|
+
f"Hooks installed: {settings_path}",
|
|
150
|
+
f" Stop (async): {index_cmd}",
|
|
151
|
+
f" SessionStart: {server_cmd}",
|
|
152
|
+
f" SessionStart: {distill_cmd}",
|
|
153
|
+
f" SessionStart: {prime_cmd}",
|
|
154
|
+
" (matcher: startup|clear|resume|compact)",
|
|
155
|
+
]
|
|
156
|
+
return True, "\n".join(lines)
|
codeatrium/indexer.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""
|
|
2
|
+
.jsonl パース・exchange 分割・DB 保存
|
|
3
|
+
|
|
4
|
+
exchange 境界定義:
|
|
5
|
+
role="user" かつ isMeta!=true かつ実質的なテキスト発話を持つエントリから
|
|
6
|
+
次の同様エントリの直前まで。ツール呼び出し・中間応答は同一 exchange に含める。
|
|
7
|
+
|
|
8
|
+
フィルタルール(SPEC Section 6 / 論文 Section 3.1 準拠):
|
|
9
|
+
- 50文字未満の exchange は trivial として除外
|
|
10
|
+
- isMeta=True の user エントリは exchange 境界としない
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Exchange:
|
|
25
|
+
"""exchange 単位の verbatim テキスト"""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
conversation_id: str
|
|
29
|
+
ply_start: int
|
|
30
|
+
ply_end: int
|
|
31
|
+
user_content: str
|
|
32
|
+
agent_content: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---- 内部ヘルパー ----
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _sha256(text: str) -> str:
|
|
39
|
+
return hashlib.sha256(text.encode()).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _extract_text(content: Any) -> str:
|
|
43
|
+
"""message.content から平文テキストを抽出する"""
|
|
44
|
+
if isinstance(content, str):
|
|
45
|
+
return content
|
|
46
|
+
if isinstance(content, list):
|
|
47
|
+
parts: list[str] = []
|
|
48
|
+
for block in content:
|
|
49
|
+
if isinstance(block, dict):
|
|
50
|
+
if block.get("type") == "text":
|
|
51
|
+
parts.append(block.get("text", ""))
|
|
52
|
+
elif block.get("type") == "thinking":
|
|
53
|
+
pass # thinking block は含めない
|
|
54
|
+
return "\n".join(p for p in parts if p)
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# コンパクション要約の先頭パターン(CC が自動生成するセッション引き継ぎテキスト)
|
|
59
|
+
_COMPACT_PREFIXES = (
|
|
60
|
+
"This session is being continued from a previous conversation",
|
|
61
|
+
"前のセッションからの引き継ぎです",
|
|
62
|
+
"このセッションは、以前の会話から引き継がれています",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# loci distill が claude --print に渡す蒸留プロンプトの先頭パターン
|
|
66
|
+
_DISTILL_PROMPT_PREFIX = "この対話のやり取りをJSONに蒸留してください"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_compaction_summary(text: str) -> bool:
|
|
70
|
+
"""CC のコンパクション要約エントリか判定する"""
|
|
71
|
+
t = text.strip()
|
|
72
|
+
return any(t.startswith(prefix) for prefix in _COMPACT_PREFIXES)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_real_user_entry(entry: dict) -> bool:
|
|
76
|
+
"""実質的なユーザー発話を持つ user エントリか判定する"""
|
|
77
|
+
if entry.get("type") != "user":
|
|
78
|
+
return False
|
|
79
|
+
if entry.get("isMeta", False):
|
|
80
|
+
return False
|
|
81
|
+
msg = entry.get("message", {})
|
|
82
|
+
if not isinstance(msg, dict):
|
|
83
|
+
return False
|
|
84
|
+
if msg.get("role") != "user":
|
|
85
|
+
return False
|
|
86
|
+
content = msg.get("content", "")
|
|
87
|
+
text = _extract_text(content)
|
|
88
|
+
# tool_result のみの場合は実質発話なし
|
|
89
|
+
if isinstance(content, list) and all(
|
|
90
|
+
isinstance(b, dict) and b.get("type") == "tool_result"
|
|
91
|
+
for b in content
|
|
92
|
+
if isinstance(b, dict)
|
|
93
|
+
):
|
|
94
|
+
return False
|
|
95
|
+
# コンパクション要約は exchange 境界としない
|
|
96
|
+
if _is_compaction_summary(text):
|
|
97
|
+
return False
|
|
98
|
+
# loci distill の蒸留プロンプトは除外
|
|
99
|
+
if text.strip().startswith(_DISTILL_PROMPT_PREFIX):
|
|
100
|
+
return False
|
|
101
|
+
return bool(text.strip())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---- 公開API ----
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_exchanges(jsonl_path: Path, min_chars: int = 50) -> list[Exchange]:
|
|
108
|
+
"""
|
|
109
|
+
.jsonl ファイルを読んで exchange リストを返す。
|
|
110
|
+
trivial(min_chars 文字未満)は除外する。
|
|
111
|
+
"""
|
|
112
|
+
entries: list[dict] = []
|
|
113
|
+
with jsonl_path.open(encoding="utf-8") as f:
|
|
114
|
+
for line in f:
|
|
115
|
+
line = line.strip()
|
|
116
|
+
if not line:
|
|
117
|
+
continue
|
|
118
|
+
try:
|
|
119
|
+
entries.append(json.loads(line))
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
conversation_id = _sha256(str(jsonl_path))
|
|
124
|
+
|
|
125
|
+
# exchange の境界インデックスを収集
|
|
126
|
+
boundaries: list[int] = [i for i, e in enumerate(entries) if _is_real_user_entry(e)]
|
|
127
|
+
|
|
128
|
+
exchanges: list[Exchange] = []
|
|
129
|
+
for b_idx, start in enumerate(boundaries):
|
|
130
|
+
end = (
|
|
131
|
+
boundaries[b_idx + 1] - 1
|
|
132
|
+
if b_idx + 1 < len(boundaries)
|
|
133
|
+
else len(entries) - 1
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
user_entry = entries[start]
|
|
137
|
+
user_text = _extract_text(user_entry["message"]["content"])
|
|
138
|
+
|
|
139
|
+
# assistant の発話を連結(コンパクション要約ゾーンは除外)
|
|
140
|
+
agent_parts: list[str] = []
|
|
141
|
+
in_compaction_zone = False
|
|
142
|
+
for e in entries[start + 1 : end + 1]:
|
|
143
|
+
if e.get("type") == "user":
|
|
144
|
+
msg = e.get("message", {})
|
|
145
|
+
if isinstance(msg, dict):
|
|
146
|
+
text = _extract_text(msg.get("content", ""))
|
|
147
|
+
in_compaction_zone = _is_compaction_summary(text)
|
|
148
|
+
continue
|
|
149
|
+
if e.get("type") == "assistant" and not in_compaction_zone:
|
|
150
|
+
msg = e.get("message", {})
|
|
151
|
+
if isinstance(msg, dict):
|
|
152
|
+
text = _extract_text(msg.get("content", ""))
|
|
153
|
+
if text:
|
|
154
|
+
agent_parts.append(text)
|
|
155
|
+
|
|
156
|
+
agent_text = "\n".join(agent_parts)
|
|
157
|
+
combined = user_text + agent_text
|
|
158
|
+
|
|
159
|
+
# trivial フィルタ
|
|
160
|
+
if len(combined) < min_chars:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
user_uuid = user_entry.get("uuid", f"{start}")
|
|
164
|
+
exchange_id = _sha256(f"{conversation_id}:{user_uuid}")
|
|
165
|
+
|
|
166
|
+
exchanges.append(
|
|
167
|
+
Exchange(
|
|
168
|
+
id=exchange_id,
|
|
169
|
+
conversation_id=conversation_id,
|
|
170
|
+
ply_start=start,
|
|
171
|
+
ply_end=end,
|
|
172
|
+
user_content=user_text,
|
|
173
|
+
agent_content=agent_text,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return exchanges
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def index_file(jsonl_path: Path, db_path: Path, min_chars: int = 50) -> int:
|
|
181
|
+
"""
|
|
182
|
+
.jsonl ファイルを DB に登録する。
|
|
183
|
+
既存 conversation の場合は last_ply_end 以降の新規 exchange のみ追加する。
|
|
184
|
+
Returns: 新規登録した exchange 数
|
|
185
|
+
"""
|
|
186
|
+
from codeatrium.db import get_connection
|
|
187
|
+
|
|
188
|
+
conversation_id = _sha256(str(jsonl_path))
|
|
189
|
+
con = get_connection(db_path)
|
|
190
|
+
|
|
191
|
+
# 既存 conversation の last_ply_end を取得
|
|
192
|
+
row = con.execute(
|
|
193
|
+
"SELECT last_ply_end FROM conversations WHERE id = ?", (conversation_id,)
|
|
194
|
+
).fetchone()
|
|
195
|
+
last_ply_end = row["last_ply_end"] if row is not None else -1
|
|
196
|
+
|
|
197
|
+
exchanges = parse_exchanges(jsonl_path, min_chars=min_chars)
|
|
198
|
+
new_exchanges = [ex for ex in exchanges if ex.ply_start > last_ply_end]
|
|
199
|
+
|
|
200
|
+
if not new_exchanges:
|
|
201
|
+
con.close()
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
# conversations に登録 or 更新
|
|
205
|
+
mtime = datetime.fromtimestamp(jsonl_path.stat().st_mtime, tz=UTC).isoformat()
|
|
206
|
+
if row is None:
|
|
207
|
+
con.execute(
|
|
208
|
+
"INSERT INTO conversations (id, source_path, started_at, last_ply_end) "
|
|
209
|
+
"VALUES (?, ?, ?, ?)",
|
|
210
|
+
(conversation_id, str(jsonl_path), mtime, new_exchanges[-1].ply_end),
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
con.execute(
|
|
214
|
+
"UPDATE conversations SET last_ply_end = ? WHERE id = ?",
|
|
215
|
+
(new_exchanges[-1].ply_end, conversation_id),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
for ex in new_exchanges:
|
|
219
|
+
con.execute(
|
|
220
|
+
"""
|
|
221
|
+
INSERT OR IGNORE INTO exchanges
|
|
222
|
+
(id, conversation_id, ply_start, ply_end, user_content, agent_content)
|
|
223
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
224
|
+
""",
|
|
225
|
+
(
|
|
226
|
+
ex.id,
|
|
227
|
+
ex.conversation_id,
|
|
228
|
+
ex.ply_start,
|
|
229
|
+
ex.ply_end,
|
|
230
|
+
ex.user_content,
|
|
231
|
+
ex.agent_content,
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
con.commit()
|
|
236
|
+
con.close()
|
|
237
|
+
return len(new_exchanges)
|
codeatrium/llm.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""LLM 呼び出しラッパー: claude --print でプロンプトを実行し JSON を返す"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
# ---- プロンプト定数 ----
|
|
11
|
+
|
|
12
|
+
DISTILL_PROMPT_TEMPLATE = """\
|
|
13
|
+
この対話のやり取りをJSONに蒸留してください:
|
|
14
|
+
|
|
15
|
+
- "exchange_core": 1-2文。何が達成または決定されましたか?\
|
|
16
|
+
やり取り内の特定の用語を使用してください。\
|
|
17
|
+
テキストに存在しない詳細を捏造しないでください。\
|
|
18
|
+
やり取りがほぼ空の場合は、簡潔にその旨を述べてください。
|
|
19
|
+
- "specific_context": テキストからの具体的な詳細1つ:\
|
|
20
|
+
数値、エラーメッセージ、パラメータ名、またはファイルパス。\
|
|
21
|
+
テキストから正確にコピーしてください。プロジェクトパスは使用しないでください。
|
|
22
|
+
- "room_assignments": 1-3個の部屋。各部屋はこのやり取りが属するトピックです。\
|
|
23
|
+
{{"room_type": "<file|concept|workflow>", "room_key": "<識別子>",\
|
|
24
|
+
"room_label": "<短いラベル>", "relevance": <0.0-1.0>}}\
|
|
25
|
+
部屋は関連するやり取りをグループ化するのに十分具体的なものにしてください\
|
|
26
|
+
(例:「errors」ではなく「retry_timeout」)。
|
|
27
|
+
|
|
28
|
+
"files_touched"は含めないでください。
|
|
29
|
+
|
|
30
|
+
やり取り (メッセージ {ply_start}-{ply_end}): {messages_text}
|
|
31
|
+
|
|
32
|
+
JSONのみで回答してください。"""
|
|
33
|
+
|
|
34
|
+
JSON_SCHEMA = json.dumps(
|
|
35
|
+
{
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"exchange_core": {"type": "string", "maxLength": 300},
|
|
39
|
+
"specific_context": {"type": "string", "maxLength": 200},
|
|
40
|
+
"room_assignments": {
|
|
41
|
+
"type": "array",
|
|
42
|
+
"maxItems": 3,
|
|
43
|
+
"items": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {
|
|
46
|
+
"room_type": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"enum": ["file", "concept", "workflow"],
|
|
49
|
+
},
|
|
50
|
+
"room_key": {"type": "string"},
|
|
51
|
+
"room_label": {"type": "string"},
|
|
52
|
+
"relevance": {
|
|
53
|
+
"type": "number",
|
|
54
|
+
"minimum": 0,
|
|
55
|
+
"maximum": 1,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
"required": ["room_type", "room_key", "room_label", "relevance"],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
"required": ["exchange_core", "specific_context", "room_assignments"],
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---- 副作用制御 ----
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _session_dir() -> Path:
|
|
71
|
+
"""claude -p が書き出す JSONL のディレクトリ"""
|
|
72
|
+
return Path.home() / ".claude" / "projects"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _snapshot_jsonl(session_dir: Path) -> set[Path]:
|
|
76
|
+
if not session_dir.exists():
|
|
77
|
+
return set()
|
|
78
|
+
return set(session_dir.rglob("*.jsonl"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cleanup_side_effect_jsonls(session_dir: Path, before: set[Path]) -> None:
|
|
82
|
+
"""claude -p 呼び出しで生成された JSONL を削除する"""
|
|
83
|
+
if not session_dir.exists():
|
|
84
|
+
return
|
|
85
|
+
after = set(session_dir.rglob("*.jsonl"))
|
|
86
|
+
for p in after - before:
|
|
87
|
+
try:
|
|
88
|
+
p.unlink()
|
|
89
|
+
except OSError:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---- LLM 呼び出し ----
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def call_claude(prompt: str, model: str | None = None) -> dict[str, Any]:
|
|
97
|
+
"""claude -p でプロンプトを実行し JSON を返す(テストでモック対象)"""
|
|
98
|
+
import shutil
|
|
99
|
+
|
|
100
|
+
from codeatrium.config import DEFAULT_DISTILL_MODEL
|
|
101
|
+
|
|
102
|
+
cli = shutil.which("claude")
|
|
103
|
+
if cli is None:
|
|
104
|
+
raise RuntimeError("claude CLI not found in PATH")
|
|
105
|
+
|
|
106
|
+
session_dir = _session_dir()
|
|
107
|
+
before = _snapshot_jsonl(session_dir)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
[
|
|
112
|
+
cli,
|
|
113
|
+
"--print",
|
|
114
|
+
"--model",
|
|
115
|
+
model or DEFAULT_DISTILL_MODEL,
|
|
116
|
+
"--output-format",
|
|
117
|
+
"json",
|
|
118
|
+
"--json-schema",
|
|
119
|
+
JSON_SCHEMA,
|
|
120
|
+
"--no-session-persistence",
|
|
121
|
+
"--setting-sources",
|
|
122
|
+
"",
|
|
123
|
+
],
|
|
124
|
+
input=prompt,
|
|
125
|
+
capture_output=True,
|
|
126
|
+
text=True,
|
|
127
|
+
timeout=300,
|
|
128
|
+
)
|
|
129
|
+
finally:
|
|
130
|
+
_cleanup_side_effect_jsonls(session_dir, before)
|
|
131
|
+
|
|
132
|
+
if result.returncode != 0:
|
|
133
|
+
raise RuntimeError(f"claude -p failed: {result.stderr}")
|
|
134
|
+
|
|
135
|
+
outer = json.loads(result.stdout)
|
|
136
|
+
if isinstance(outer, dict):
|
|
137
|
+
if "structured_output" in outer and outer["structured_output"]:
|
|
138
|
+
return outer["structured_output"]
|
|
139
|
+
inner = outer.get("result", "")
|
|
140
|
+
if isinstance(inner, str) and inner.strip():
|
|
141
|
+
text = inner.strip()
|
|
142
|
+
if text.startswith("```"):
|
|
143
|
+
lines = text.splitlines()
|
|
144
|
+
text = "\n".join(
|
|
145
|
+
lines[1:-1] if lines[-1].strip() == "```" else lines[1:]
|
|
146
|
+
)
|
|
147
|
+
return json.loads(text.strip())
|
|
148
|
+
return outer
|
codeatrium/models.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""共有データクラス定義"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class PalaceObject:
|
|
11
|
+
"""蒸留済み palace object"""
|
|
12
|
+
|
|
13
|
+
exchange_core: str
|
|
14
|
+
specific_context: str
|
|
15
|
+
room_assignments: list[dict[str, Any]]
|
|
16
|
+
files_touched: list[str] = field(default_factory=list)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class BM25Result:
|
|
21
|
+
"""BM25 verbatim 検索結果"""
|
|
22
|
+
|
|
23
|
+
exchange_id: str
|
|
24
|
+
user_content: str
|
|
25
|
+
agent_content: str
|
|
26
|
+
bm25_score: float
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class HNSWPalaceResult:
|
|
31
|
+
"""HNSW distilled 検索結果"""
|
|
32
|
+
|
|
33
|
+
exchange_id: str
|
|
34
|
+
user_content: str
|
|
35
|
+
agent_content: str
|
|
36
|
+
exchange_core: str
|
|
37
|
+
specific_context: str
|
|
38
|
+
distance: float
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class FusedResult:
|
|
43
|
+
"""RRF 融合検索結果(SPEC 準拠の出力フォーマット)"""
|
|
44
|
+
|
|
45
|
+
exchange_id: str
|
|
46
|
+
user_content: str
|
|
47
|
+
agent_content: str
|
|
48
|
+
score: float
|
|
49
|
+
exchange_core: str | None = None
|
|
50
|
+
specific_context: str | None = None
|
|
51
|
+
verbatim_ref: str | None = None
|
|
52
|
+
rooms: list[dict[str, Any]] = field(default_factory=list)
|
|
53
|
+
symbols: list[dict[str, Any]] = field(default_factory=list)
|
codeatrium/paths.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""パス解決ヘルパー: プロジェクトルート・DB パス・Claude セッションログパスの解決"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
|
|
9
|
+
CODEATRIUM_DIR = ".codeatrium"
|
|
10
|
+
DB_NAME = "memory.db"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def git_root() -> Path | None:
|
|
14
|
+
"""git rev-parse --show-toplevel でリポジトリルートを返す。git 外なら None"""
|
|
15
|
+
try:
|
|
16
|
+
result = subprocess.run(
|
|
17
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
18
|
+
capture_output=True,
|
|
19
|
+
text=True,
|
|
20
|
+
check=True,
|
|
21
|
+
)
|
|
22
|
+
return Path(result.stdout.strip())
|
|
23
|
+
except subprocess.CalledProcessError:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_project_root() -> Path:
|
|
28
|
+
""".codeatrium/ を探してプロジェクトルートを返す。
|
|
29
|
+
検索順: cwd → 親ディレクトリ(git root まで)
|
|
30
|
+
git root を超えて遡らないことでプロジェクト外の DB を拾わない。
|
|
31
|
+
"""
|
|
32
|
+
cwd = Path.cwd()
|
|
33
|
+
root = git_root()
|
|
34
|
+
candidates = [cwd, *cwd.parents]
|
|
35
|
+
for p in candidates:
|
|
36
|
+
if (p / CODEATRIUM_DIR).exists():
|
|
37
|
+
return p
|
|
38
|
+
if root and p == root:
|
|
39
|
+
break
|
|
40
|
+
return root or cwd
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def db_path(project_root: Path) -> Path:
|
|
44
|
+
return project_root / CODEATRIUM_DIR / DB_NAME
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_claude_projects_path(project_root: Path) -> Path | None:
|
|
48
|
+
"""project_root から対応する ~/.claude/projects/<hash>/ を解決する。
|
|
49
|
+
Claude Code はパスの "/" を "-" に変換したディレクトリ名を使う。
|
|
50
|
+
"""
|
|
51
|
+
if not CLAUDE_PROJECTS_DIR.exists():
|
|
52
|
+
return None
|
|
53
|
+
candidates = [project_root, Path.cwd()]
|
|
54
|
+
for base in candidates:
|
|
55
|
+
dir_name = str(base).replace("/", "-")
|
|
56
|
+
candidate = CLAUDE_PROJECTS_DIR / dir_name
|
|
57
|
+
if candidate.exists() and any(candidate.rglob("*.jsonl")):
|
|
58
|
+
return candidate
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def sock_path(project_root: Path) -> Path:
|
|
63
|
+
return db_path(project_root).parent / "embedder.sock"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def server_pid_path(project_root: Path) -> Path:
|
|
67
|
+
return db_path(project_root).parent / "embedder.pid"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def loci_bin() -> str:
|
|
71
|
+
"""sys.executable と同じ venv の bin/loci のフルパスを返す(PATH 非依存)。"""
|
|
72
|
+
import sys
|
|
73
|
+
|
|
74
|
+
return str(Path(sys.executable).parent / "loci")
|
codeatrium/py.typed
ADDED
|
File without changes
|