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/__init__.py
ADDED
codeatrium/__main__.py
ADDED
|
@@ -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']}")
|