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 ADDED
@@ -0,0 +1,3 @@
1
+ """codeatrium — Memory palace for AI coding agents"""
2
+
3
+ __version__ = "0.1.0"
codeatrium/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """python -m codeatrium エントリポイント"""
2
+
3
+ from codeatrium.cli import app
4
+
5
+ app()
@@ -0,0 +1,295 @@
1
+ """loci CLI エントリポイント — サブコマンド登録のみ"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from codeatrium.cli.distill_cmd import distill
11
+ from codeatrium.cli.hook_cmd import hook_app
12
+ from codeatrium.cli.index_cmd import index
13
+ from codeatrium.cli.prime_cmd import prime
14
+ from codeatrium.cli.search_cmd import context, search
15
+ from codeatrium.cli.server_cmd import server_app
16
+ from codeatrium.cli.show_cmd import dump, show
17
+ from codeatrium.cli.status_cmd import status
18
+
19
+ app = typer.Typer(help="CLI-first memory layer for AI coding agents")
20
+
21
+ DEFAULT_DISTILL_RECENT = 50
22
+
23
+
24
+ @app.command()
25
+ def init(
26
+ skip_existing: Annotated[
27
+ bool,
28
+ typer.Option("--skip-existing", help="既存 exchange の蒸留をスキップする"),
29
+ ] = False,
30
+ distill_limit: Annotated[
31
+ int | None,
32
+ typer.Option("--distill-limit", help="既存 exchange のうち直近 N 件のみ蒸留対象にする"),
33
+ ] = None,
34
+ min_chars: Annotated[
35
+ int | None,
36
+ typer.Option("--min-chars", help="既存 exchange の最小文字数フィルタ(省略時は対話で選択)"),
37
+ ] = None,
38
+ ) -> None:
39
+ """プロジェクトルートに .codeatrium/memory.db を初期化する"""
40
+ from codeatrium.db import get_connection, init_db
41
+ from codeatrium.indexer import index_file, parse_exchanges
42
+ from codeatrium.paths import (
43
+ db_path,
44
+ find_project_root,
45
+ resolve_claude_projects_path,
46
+ )
47
+
48
+ root = find_project_root()
49
+ db = db_path(root)
50
+
51
+ if db.exists():
52
+ typer.echo(f"Already initialized: {db}")
53
+ return
54
+
55
+ # 既存セッションの検出
56
+ target_dir = resolve_claude_projects_path(root)
57
+ jsonl_files = list(target_dir.rglob("*.jsonl")) if target_dir else []
58
+
59
+ # --- 対話フェーズ(DB 作成前にすべての質問を完了する) ---
60
+ resolved_min_chars = 50
61
+ skip_count = 0
62
+ skip_strategy = "recent"
63
+ total_exchanges = 0
64
+ run_distill_now = False
65
+
66
+ if jsonl_files:
67
+ resolved_min_chars = _resolve_min_chars(jsonl_files, min_chars)
68
+
69
+ total_exchanges = sum(
70
+ len(parse_exchanges(jsonl, min_chars=resolved_min_chars))
71
+ for jsonl in jsonl_files
72
+ )
73
+
74
+ if total_exchanges > 0:
75
+ skip_count, skip_strategy = _resolve_skip_count(
76
+ total_exchanges, skip_existing, distill_limit
77
+ )
78
+
79
+ distill_count = total_exchanges - skip_count
80
+ if distill_count > 0:
81
+ run_distill_now = _ask_run_distill_now(distill_count)
82
+
83
+ # --- 実行フェーズ(ここから DB・ファイルを作成) ---
84
+ init_db(db)
85
+
86
+ config_path = db.parent / "config.toml"
87
+ if not config_path.exists():
88
+ config_path.write_text(
89
+ "# Codeatrium configuration\n"
90
+ "\n"
91
+ "[distill]\n"
92
+ '# model = "claude-haiku-4-5-20251001"\n'
93
+ "# batch_limit = 20\n"
94
+ "# min_chars = 100 # この文字数未満の exchange は蒸留スキップ\n"
95
+ "\n"
96
+ "[index]\n"
97
+ "# min_chars = 50 # trivial フィルタ閾値(文字数)\n"
98
+ )
99
+
100
+ typer.echo(f"Initialized: {db}")
101
+
102
+ from codeatrium.cli.prime_cmd import inject_claude_md
103
+
104
+ if inject_claude_md(root):
105
+ typer.echo(f"Updated: {root / 'CLAUDE.md'} (codeatrium section)")
106
+
107
+ if total_exchanges == 0:
108
+ return
109
+
110
+ actual_total = 0
111
+ for jsonl in jsonl_files:
112
+ actual_total += index_file(jsonl, db, min_chars=resolved_min_chars)
113
+
114
+ typer.echo(f"Indexed {actual_total} existing exchange(s).")
115
+
116
+ if skip_count > 0:
117
+ order_clause = (
118
+ "ORDER BY LENGTH(user_content) + LENGTH(agent_content) ASC"
119
+ if skip_strategy == "longest"
120
+ else "ORDER BY ply_start ASC"
121
+ )
122
+ con = get_connection(db)
123
+ con.execute(
124
+ f"""
125
+ UPDATE exchanges SET distilled_at = 'skipped'
126
+ WHERE distilled_at IS NULL
127
+ AND id IN (
128
+ SELECT id FROM exchanges
129
+ WHERE distilled_at IS NULL
130
+ {order_clause}
131
+ LIMIT ?
132
+ )
133
+ """,
134
+ (skip_count,),
135
+ )
136
+ con.commit()
137
+ con.close()
138
+ remaining = actual_total - skip_count
139
+ typer.echo(f"Marked {skip_count} exchange(s) as skipped. {remaining} will be distilled.")
140
+ else:
141
+ typer.echo(f"All {actual_total} exchange(s) will be distilled.")
142
+
143
+ if run_distill_now:
144
+ from codeatrium.config import load_config
145
+ from codeatrium.distiller import distill_all
146
+
147
+ cfg = load_config(root)
148
+ typer.echo("Running distillation...")
149
+ def _on_progress(cur: int, tot: int, error: str | None = None) -> None:
150
+ if error:
151
+ typer.echo(f" [{cur}/{tot}] error: {error}", err=True)
152
+ else:
153
+ typer.echo(f" [{cur}/{tot}] distilled", err=True)
154
+
155
+ count = distill_all(
156
+ db,
157
+ model=cfg.distill_model,
158
+ on_progress=_on_progress,
159
+ project_root=str(root),
160
+ distill_min_chars=cfg.distill_min_chars,
161
+ )
162
+ typer.echo(f"Distilled {count} exchange(s).")
163
+
164
+
165
+ _MIN_CHARS_CANDIDATES = [50, 100, 200, 500]
166
+
167
+
168
+ def _count_exchanges_by_threshold(
169
+ jsonl_files: list[Path], thresholds: list[int]
170
+ ) -> dict[int, int]:
171
+ """各閾値ごとの exchange 件数をカウントする。min_chars=0 で全件パースして集計。"""
172
+ from codeatrium.indexer import parse_exchanges
173
+
174
+ # 全 exchange の combined 文字数を収集
175
+ lengths: list[int] = []
176
+ for jsonl in jsonl_files:
177
+ for ex in parse_exchanges(jsonl, min_chars=0):
178
+ lengths.append(len(ex.user_content) + len(ex.agent_content))
179
+
180
+ return {t: sum(1 for length in lengths if length >= t) for t in thresholds}
181
+
182
+
183
+ def _resolve_min_chars(
184
+ jsonl_files: list[Path], min_chars_flag: int | None
185
+ ) -> int:
186
+ """init 時の min_chars を決定する。フラグ指定済みならそのまま、未指定なら対話。"""
187
+ if min_chars_flag is not None:
188
+ return min_chars_flag
189
+
190
+ counts = _count_exchanges_by_threshold(jsonl_files, _MIN_CHARS_CANDIDATES)
191
+
192
+ # exchange が0件なら対話不要
193
+ if counts.get(_MIN_CHARS_CANDIDATES[0], 0) == 0:
194
+ return _MIN_CHARS_CANDIDATES[0]
195
+
196
+ typer.echo("\nMin chars threshold for existing exchanges:")
197
+ for i, threshold in enumerate(_MIN_CHARS_CANDIDATES, 1):
198
+ label = " (default)" if threshold == 50 else ""
199
+ typer.echo(f" [{i}] {threshold} chars{label} — {counts[threshold]} exchanges")
200
+ typer.echo(f" [{len(_MIN_CHARS_CANDIDATES) + 1}] Custom")
201
+
202
+ choice = typer.prompt("Choice", default="1")
203
+
204
+ for i, threshold in enumerate(_MIN_CHARS_CANDIDATES, 1):
205
+ if choice == str(i):
206
+ return threshold
207
+
208
+ if choice == str(len(_MIN_CHARS_CANDIDATES) + 1):
209
+ return typer.prompt("Min chars threshold?", type=int)
210
+
211
+ typer.echo("Invalid choice. Using default (50).")
212
+ return 50
213
+
214
+
215
+ def _ask_run_distill_now(distill_count: int) -> bool:
216
+ """蒸留を今すぐ実行するか聞く。"""
217
+ typer.echo(
218
+ f"\nStart distillation now? ({distill_count} exchanges, uses claude --print)"
219
+ )
220
+ typer.echo(" [1] No — distill on next session start (default)")
221
+ typer.echo(" [2] Yes — run now")
222
+
223
+ choice = typer.prompt("Choice", default="1")
224
+ return choice == "2"
225
+
226
+
227
+ def _ask_distill_priority() -> str:
228
+ """蒸留対象の優先順位を選択する。"""
229
+ typer.echo("\nDistill priority:")
230
+ typer.echo(" [1] Recent — newest exchanges first")
231
+ typer.echo(" [2] Longest — longest exchanges first")
232
+
233
+ choice = typer.prompt("Choice", default="1")
234
+
235
+ if choice == "2":
236
+ return "longest"
237
+ return "recent"
238
+
239
+
240
+ def _resolve_skip_count(
241
+ total: int, skip_existing: bool, distill_limit: int | None
242
+ ) -> tuple[int, str]:
243
+ """スキップする exchange 数と優先順位を決定する。フラグ or 対話プロンプト。
244
+
245
+ Returns: (skip_count, strategy) where strategy is "recent" or "longest"
246
+ """
247
+ if skip_existing:
248
+ return total, "recent"
249
+ if distill_limit is not None:
250
+ skip = max(0, total - distill_limit)
251
+ strategy = _ask_distill_priority() if skip > 0 else "recent"
252
+ return skip, strategy
253
+
254
+ # 対話プロンプト
255
+ typer.echo(
256
+ f"\nFound {total} existing exchanges from past sessions.\n"
257
+ "Distillation uses claude --print (Haiku) and consumes tokens.\n"
258
+ "⚠ Skipped exchanges cannot be distilled later.\n"
259
+ )
260
+ typer.echo("How should existing exchanges be handled?")
261
+ typer.echo(" [1] Skip all — only distill future sessions")
262
+ typer.echo(f" [2] Distill last {DEFAULT_DISTILL_RECENT} — recent history only")
263
+ typer.echo(f" [3] Distill all — {total} exchanges (token consumption)")
264
+ typer.echo(" [4] Custom — specify how many exchanges to distill")
265
+
266
+ choice = typer.prompt("Choice", default="1")
267
+
268
+ if choice == "1":
269
+ return total, "recent"
270
+ elif choice == "3":
271
+ return 0, "recent"
272
+
273
+ if choice == "2":
274
+ skip = max(0, total - DEFAULT_DISTILL_RECENT)
275
+ elif choice == "4":
276
+ n = typer.prompt("How many exchanges to distill?", type=int)
277
+ skip = max(0, total - n)
278
+ else:
279
+ typer.echo("Invalid choice. Skipping all existing exchanges.")
280
+ return total, "recent"
281
+
282
+ strategy = _ask_distill_priority() if skip > 0 else "recent"
283
+ return skip, strategy
284
+
285
+
286
+ app.command()(index)
287
+ app.command()(distill)
288
+ app.command()(search)
289
+ app.command()(context)
290
+ app.command()(status)
291
+ app.command()(show)
292
+ app.command()(dump)
293
+ app.command()(prime)
294
+ app.add_typer(hook_app, name="hook")
295
+ app.add_typer(server_app, name="server")
@@ -0,0 +1,76 @@
1
+ """loci distill コマンド"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+
10
+ def distill(
11
+ limit: Annotated[
12
+ int | None,
13
+ typer.Option("--limit", "-n", help="処理する最大件数(省略時は全件)"),
14
+ ] = None,
15
+ ) -> None:
16
+ """未蒸留の exchange を claude -p で蒸留して palace_objects を生成する"""
17
+ import os
18
+
19
+ from codeatrium.config import load_config
20
+ from codeatrium.distiller import distill_all
21
+ from codeatrium.paths import db_path, find_project_root
22
+
23
+ root = find_project_root()
24
+ db = db_path(root)
25
+ cfg = load_config(root)
26
+
27
+ if not db.exists():
28
+ typer.echo("Not initialized. Run `loci init` first.", err=True)
29
+ raise typer.Exit(1)
30
+
31
+ lock_path = db.parent / "distill.lock"
32
+
33
+ # ロック取得: O_CREAT | O_EXCL で原子的に作成(TOCTOU 防止)
34
+ try:
35
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
36
+ os.write(fd, str(os.getpid()).encode())
37
+ os.close(fd)
38
+ except FileExistsError:
39
+ # 既存ロックのプロセスが生きているか確認
40
+ try:
41
+ existing_pid = int(lock_path.read_text().strip())
42
+ os.kill(existing_pid, 0)
43
+ typer.echo(
44
+ f"loci distill is already running (PID {existing_pid}). Exiting.",
45
+ err=True,
46
+ )
47
+ raise typer.Exit(0)
48
+ except (ValueError, ProcessLookupError, PermissionError):
49
+ # stale lock — 再取得
50
+ lock_path.unlink(missing_ok=True)
51
+ try:
52
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
53
+ os.write(fd, str(os.getpid()).encode())
54
+ os.close(fd)
55
+ except FileExistsError:
56
+ typer.echo("loci distill: lost lock race after stale cleanup. Exiting.", err=True)
57
+ raise typer.Exit(0)
58
+
59
+ def _on_progress(cur: int, tot: int, error: str | None = None) -> None:
60
+ if error:
61
+ typer.echo(f" [{cur}/{tot}] error: {error}", err=True)
62
+ else:
63
+ typer.echo(f" [{cur}/{tot}] distilled", err=True)
64
+
65
+ try:
66
+ count = distill_all(
67
+ db,
68
+ limit=limit,
69
+ model=cfg.distill_model,
70
+ on_progress=_on_progress,
71
+ project_root=str(root),
72
+ distill_min_chars=cfg.distill_min_chars,
73
+ )
74
+ typer.echo(f"Distilled {count} exchange(s).")
75
+ finally:
76
+ lock_path.unlink(missing_ok=True)
@@ -0,0 +1,24 @@
1
+ """loci hook install コマンド"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ hook_app = typer.Typer(help="Claude Code hook 管理")
8
+
9
+
10
+ @hook_app.command("install")
11
+ def hook_install() -> None:
12
+ """Claude Code の Stop / SessionStart フックに loci を登録する。
13
+
14
+ Stop (async): loci index — 毎ターン・ノンブロッキング
15
+ SessionStart: loci distill — CC起動・/clear・/resume・compact 時
16
+ claude --print サブセッションは SessionStart を発火しないためループなし
17
+ """
18
+ from codeatrium.config import load_config
19
+ from codeatrium.hooks import install_hooks
20
+ from codeatrium.paths import find_project_root
21
+
22
+ cfg = load_config(find_project_root())
23
+ _changed, message = install_hooks(batch_limit=cfg.distill_batch_limit)
24
+ typer.echo(message)
@@ -0,0 +1,62 @@
1
+ """loci index コマンド"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+
11
+ def index(
12
+ path: Annotated[
13
+ Path | None, typer.Option(help="インデックス対象ディレクトリ")
14
+ ] = None,
15
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
16
+ ) -> None:
17
+ """未処理の .jsonl を処理して exchanges テーブルに登録する(FTS5 自動同期)"""
18
+ from codeatrium.config import load_config
19
+ from codeatrium.db import init_db
20
+ from codeatrium.indexer import index_file
21
+ from codeatrium.paths import (
22
+ db_path,
23
+ find_project_root,
24
+ resolve_claude_projects_path,
25
+ )
26
+
27
+ root = find_project_root()
28
+ db = db_path(root)
29
+
30
+ if not db.exists() and not (root / ".codeatrium").exists():
31
+ typer.echo("Not initialized. Run `loci init` first.", err=True)
32
+ raise typer.Exit(1)
33
+
34
+ init_db(db)
35
+ cfg = load_config(root)
36
+
37
+ target_dir = path or resolve_claude_projects_path(root)
38
+ if target_dir is None:
39
+ typer.echo("Claude projects dir not found. Use --path to specify.", err=True)
40
+ raise typer.Exit(1)
41
+
42
+ jsonl_files = list(target_dir.rglob("*.jsonl"))
43
+ if not jsonl_files:
44
+ typer.echo("No .jsonl files found.")
45
+ return
46
+
47
+ total_exchanges = 0
48
+ files_with_new = 0
49
+ for jsonl in jsonl_files:
50
+ count = index_file(jsonl, db, min_chars=cfg.index_min_chars)
51
+ if count == 0:
52
+ continue
53
+ files_with_new += 1
54
+ if verbose:
55
+ typer.echo(f" {jsonl.name}: {count} exchanges")
56
+ total_exchanges += count
57
+
58
+ if total_exchanges == 0:
59
+ typer.echo("Nothing new to index.")
60
+ return
61
+
62
+ typer.echo(f"Indexed {files_with_new} file(s), {total_exchanges} exchange(s).")
@@ -0,0 +1,90 @@
1
+ """loci prime — SessionStart Hook でエージェントのコンテキストにインストラクションを注入する"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ BEGIN_MARKER = "<!-- BEGIN CODEATRIUM -->"
10
+ END_MARKER = "<!-- END CODEATRIUM -->"
11
+
12
+ PRIME_TEXT = """\
13
+ ## Past Memory Search (codeatrium)
14
+
15
+ Use `loci search` to find past implementations, decisions, and code locations.
16
+
17
+ ### When to use
18
+
19
+ - When asked "where did we implement X?" or "where is X?"
20
+ - When checking if a similar bug was fixed before
21
+ - When verifying if a feature already exists
22
+ - When looking up the reasoning behind a past design decision
23
+ - Before editing code you lack context about — use `loci context --symbol` to review past discussions
24
+ - Before refactoring or changing the behavior of a function — use `loci context --symbol` to check past design decisions
25
+
26
+ ### Commands
27
+
28
+ ```bash
29
+ # Semantic search
30
+ loci search "query" --json --limit 5
31
+
32
+ # Reverse lookup: code symbol -> past conversations
33
+ loci context --symbol "symbol_name" --json
34
+
35
+ # Retrieve verbatim conversation (use verbatim_ref from search results)
36
+ loci show "<verbatim_ref>" --json
37
+ ```\
38
+ """
39
+
40
+ CLAUDE_MD_SECTION = f"""\
41
+ {BEGIN_MARKER}
42
+ ## Past Memory Search (codeatrium)
43
+
44
+ IMPORTANT: Command usage is injected automatically at session start via `loci prime` (SessionStart hook).
45
+ If not in context, run `loci prime`.
46
+
47
+ ### Rules
48
+
49
+ 1. **Search before implementing** — always check if something was discussed or built before starting work.
50
+ 2. **Check symbols when you lack context** — run `loci context --symbol` before changing a function you don't have enough background on.
51
+ 3. **Use technical terms** — queries with exact symbol names, error messages, or parameter names yield better results.
52
+ 4. **Follow up with `loci show`** — when `exchange_core` is ambiguous, fetch the full verbatim conversation.
53
+ {END_MARKER}\
54
+ """
55
+
56
+
57
+ def prime() -> None:
58
+ """エージェント向けインストラクションを stdout に出力する。
59
+
60
+ SessionStart Hook で自動実行され、エージェントのコンテキストウィンドウに
61
+ 使い方を注入する。CLAUDE.md にテンプレートを貼る必要がなくなる。
62
+ """
63
+ typer.echo(PRIME_TEXT)
64
+
65
+
66
+ def inject_claude_md(project_root: Path) -> bool:
67
+ """CLAUDE.md にマーカー付きセクションを挿入・更新する。
68
+
69
+ Returns: True if file was modified.
70
+ """
71
+ claude_md = project_root / "CLAUDE.md"
72
+
73
+ if claude_md.exists():
74
+ content = claude_md.read_text()
75
+ if BEGIN_MARKER in content:
76
+ # マーカー内を更新
77
+ before = content[: content.index(BEGIN_MARKER)]
78
+ after = content[content.index(END_MARKER) + len(END_MARKER) :]
79
+ new_content = before + CLAUDE_MD_SECTION + after
80
+ if new_content == content:
81
+ return False
82
+ claude_md.write_text(new_content)
83
+ return True
84
+ else:
85
+ # 末尾に追加
86
+ claude_md.write_text(content.rstrip() + "\n\n" + CLAUDE_MD_SECTION + "\n")
87
+ return True
88
+ else:
89
+ claude_md.write_text("# CLAUDE.md\n\n" + CLAUDE_MD_SECTION + "\n")
90
+ return True
@@ -0,0 +1,128 @@
1
+ """loci search / loci context コマンド"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+
11
+ def search(
12
+ query: Annotated[str, typer.Argument(help="検索クエリ")],
13
+ limit: Annotated[int, typer.Option("--limit", "-n", help="返す件数")] = 5,
14
+ json_output: Annotated[bool, typer.Option("--json", help="JSON で出力")] = False,
15
+ ) -> None:
16
+ """BM25(V) + HNSW(D) RRF でクエリに近い過去会話を返す"""
17
+ from codeatrium.embedder import Embedder
18
+ from codeatrium.paths import db_path, find_project_root
19
+ from codeatrium.search import search_combined
20
+
21
+ root = find_project_root()
22
+ db = db_path(root)
23
+
24
+ if not db.exists():
25
+ typer.echo("Not initialized. Run `loci init` first.", err=True)
26
+ raise typer.Exit(1)
27
+
28
+ embedder = Embedder()
29
+ query_vec = embedder.embed(query)
30
+ results = search_combined(db, query, query_vec, limit=limit)
31
+
32
+ if not results:
33
+ typer.echo("No results found.")
34
+ return
35
+
36
+ if json_output:
37
+ output = [
38
+ {
39
+ "exchange_core": r.exchange_core,
40
+ "specific_context": r.specific_context,
41
+ "rooms": r.rooms,
42
+ "symbols": r.symbols,
43
+ "verbatim_ref": r.verbatim_ref,
44
+ }
45
+ for r in results
46
+ ]
47
+ typer.echo(json.dumps(output, ensure_ascii=False, indent=2))
48
+ else:
49
+ for i, r in enumerate(results, 1):
50
+ typer.echo(f"\n[{i}] score={r.score:.4f}")
51
+ if r.exchange_core:
52
+ typer.echo(f" {r.exchange_core}")
53
+ for sym in r.symbols[:2]:
54
+ typer.echo(f" {sym['file']}:{sym['line']} {sym['name']}")
55
+ if r.verbatim_ref:
56
+ typer.echo(f" {r.verbatim_ref}")
57
+
58
+
59
+ def context(
60
+ symbol: Annotated[
61
+ str, typer.Option("--symbol", "-s", help="シンボル名(部分一致)")
62
+ ],
63
+ limit: Annotated[int, typer.Option("--limit", "-n", help="返す件数")] = 5,
64
+ json_output: Annotated[bool, typer.Option("--json", help="JSON で出力")] = False,
65
+ ) -> None:
66
+ """シンボル名から関連する過去会話を逆引きする"""
67
+ from codeatrium.db import get_connection
68
+ from codeatrium.paths import db_path, find_project_root
69
+
70
+ root = find_project_root()
71
+ db = db_path(root)
72
+
73
+ if not db.exists():
74
+ typer.echo("Not initialized. Run `loci init` first.", err=True)
75
+ raise typer.Exit(1)
76
+
77
+ con = get_connection(db)
78
+ rows = con.execute(
79
+ """
80
+ SELECT
81
+ s.symbol_name,
82
+ s.symbol_kind,
83
+ s.file_path,
84
+ s.signature,
85
+ s.line,
86
+ e.id AS exchange_id,
87
+ e.user_content,
88
+ e.agent_content,
89
+ p.exchange_core,
90
+ p.specific_context
91
+ FROM symbols s
92
+ JOIN palace_objects p ON p.id = s.palace_object_id
93
+ JOIN exchanges e ON e.id = p.exchange_id
94
+ WHERE s.symbol_name LIKE ?
95
+ LIMIT ?
96
+ """,
97
+ (f"%{symbol}%", limit),
98
+ ).fetchall()
99
+ con.close()
100
+
101
+ if not rows:
102
+ typer.echo("No results found.")
103
+ return
104
+
105
+ if json_output:
106
+ output = [
107
+ {
108
+ "symbol_name": r["symbol_name"],
109
+ "symbol_kind": r["symbol_kind"],
110
+ "file_path": r["file_path"],
111
+ "signature": r["signature"],
112
+ "line": r["line"],
113
+ "exchange_id": r["exchange_id"],
114
+ "exchange_core": r["exchange_core"],
115
+ "specific_context": r["specific_context"],
116
+ "user_content": r["user_content"],
117
+ "agent_content": r["agent_content"],
118
+ }
119
+ for r in rows
120
+ ]
121
+ typer.echo(json.dumps(output, ensure_ascii=False, indent=2))
122
+ else:
123
+ for i, r in enumerate(rows, 1):
124
+ typer.echo(f"\n[{i}] {r['symbol_kind']} {r['symbol_name']}")
125
+ typer.echo(f" {r['file_path']}:{r['line']}")
126
+ typer.echo(f" {r['signature']}")
127
+ if r["exchange_core"]:
128
+ typer.echo(f" Core: {r['exchange_core']}")