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/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