threadlens 1.0.1 → 1.1.1
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.
- package/README.md +55 -11
- package/bin/resolve.js +85 -0
- package/bin/threadlens.js +14 -60
- package/package.json +8 -9
- package/vendor/threadlens/__init__.py +0 -4
- package/vendor/threadlens/__main__.py +0 -6
- package/vendor/threadlens/cli.py +0 -1395
- package/vendor/threadlens/extract.py +0 -369
- package/vendor/threadlens/models.py +0 -25
- package/vendor/threadlens/paths.py +0 -85
- package/vendor/threadlens/profiles.py +0 -102
- package/vendor/threadlens/skills/threadlens/SKILL.md +0 -102
- package/vendor/threadlens/skills/threadlens/agents/openai.yaml +0 -4
- package/vendor/threadlens/sources.py +0 -665
- package/vendor/threadlens/store.py +0 -652
package/vendor/threadlens/cli.py
DELETED
|
@@ -1,1395 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import argparse
|
|
4
|
-
import json
|
|
5
|
-
import shlex
|
|
6
|
-
import sqlite3
|
|
7
|
-
import string
|
|
8
|
-
import sys
|
|
9
|
-
import time
|
|
10
|
-
import urllib.parse
|
|
11
|
-
from collections import Counter
|
|
12
|
-
from importlib import resources
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
from time import perf_counter
|
|
15
|
-
from typing import Any
|
|
16
|
-
|
|
17
|
-
from . import __version__
|
|
18
|
-
from .models import ThreadMessage
|
|
19
|
-
from .paths import default_db_path
|
|
20
|
-
from .profiles import DEFAULT_CONFIG, ProfileConfigError, SourceProfile, load_profiles, save_profiles, validate_source_name
|
|
21
|
-
from .sources import (
|
|
22
|
-
DEFAULT_SOURCE_NAMES,
|
|
23
|
-
SOURCE_NAMES,
|
|
24
|
-
custom_jsonl_paths,
|
|
25
|
-
describe_sources,
|
|
26
|
-
iter_path_messages,
|
|
27
|
-
source_profile_messages,
|
|
28
|
-
source_profile_paths,
|
|
29
|
-
source_paths,
|
|
30
|
-
)
|
|
31
|
-
from .extract import custom_jsonl_messages
|
|
32
|
-
from .store import ThreadStore
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
DEFAULT_DB = default_db_path()
|
|
36
|
-
RESUME_TEMPLATE_FIELDS = {"cwd", "session_id", "source"}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def main(argv: list[str] | None = None) -> int:
|
|
40
|
-
parser = argparse.ArgumentParser(prog="threadlens")
|
|
41
|
-
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
42
|
-
parser.add_argument("--db", type=Path, default=DEFAULT_DB, help="SQLite index path")
|
|
43
|
-
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG, help="Custom source profile config")
|
|
44
|
-
sub = parser.add_subparsers(dest="command", required=True)
|
|
45
|
-
|
|
46
|
-
sources_parser = sub.add_parser("sources", help="Show detected local sources")
|
|
47
|
-
sources_parser.add_argument("--home", type=Path, default=Path.home())
|
|
48
|
-
sources_parser.add_argument("--json", action="store_true", help="Emit JSON")
|
|
49
|
-
sources_sub = sources_parser.add_subparsers(dest="sources_command")
|
|
50
|
-
|
|
51
|
-
sources_add = sources_sub.add_parser("add", help="Add or update a custom source profile")
|
|
52
|
-
sources_add.add_argument("name")
|
|
53
|
-
sources_add.add_argument("--path", action="append", required=True, help="File, directory, or glob to scan")
|
|
54
|
-
sources_add.add_argument("--format", choices=("jsonl",), default="jsonl")
|
|
55
|
-
sources_add.add_argument("--session-key", default="sessionId")
|
|
56
|
-
sources_add.add_argument("--message-key", default="uuid")
|
|
57
|
-
sources_add.add_argument("--role-key", default="message.role")
|
|
58
|
-
sources_add.add_argument("--text-key", default="message.content")
|
|
59
|
-
sources_add.add_argument("--timestamp-key", default="timestamp")
|
|
60
|
-
sources_add.add_argument("--cwd-key", default="cwd")
|
|
61
|
-
sources_add.add_argument("--title-key", default="title")
|
|
62
|
-
sources_add.add_argument("--resume-template", default="")
|
|
63
|
-
|
|
64
|
-
sources_remove = sources_sub.add_parser("remove", help="Remove a custom source profile")
|
|
65
|
-
sources_remove.add_argument("name")
|
|
66
|
-
|
|
67
|
-
refresh_parser = sub.add_parser("refresh", help="Refresh the local searchable session cache")
|
|
68
|
-
add_refresh_args(refresh_parser)
|
|
69
|
-
|
|
70
|
-
start_parser = sub.add_parser("start", help="Set up or repair the local search index")
|
|
71
|
-
add_refresh_args(start_parser)
|
|
72
|
-
|
|
73
|
-
index_parser = sub.add_parser("index", help="Alias for refresh")
|
|
74
|
-
add_refresh_args(index_parser)
|
|
75
|
-
|
|
76
|
-
search_parser = sub.add_parser("search", help="Search indexed sessions")
|
|
77
|
-
search_parser.add_argument("query", nargs="+")
|
|
78
|
-
search_parser.add_argument("--source", help="Restrict to one source")
|
|
79
|
-
search_parser.add_argument("--cwd", help="Restrict to sessions from this cwd directory")
|
|
80
|
-
search_parser.add_argument("--limit", type=int, default=10)
|
|
81
|
-
search_parser.add_argument("--json", action="store_true", help="Emit JSON lines")
|
|
82
|
-
search_parser.add_argument("--no-bootstrap", action="store_true", help="Do not auto-index when the search index is empty")
|
|
83
|
-
search_parser.add_argument("--home", type=Path, default=Path.home(), help=argparse.SUPPRESS)
|
|
84
|
-
|
|
85
|
-
doctor_parser = sub.add_parser("doctor", help="Check source stores and adapter readability")
|
|
86
|
-
doctor_parser.add_argument("--home", type=Path, default=Path.home())
|
|
87
|
-
doctor_parser.add_argument("--json", action="store_true", help="Emit JSON")
|
|
88
|
-
|
|
89
|
-
brief_parser = sub.add_parser("brief", help="Print a compact session brief")
|
|
90
|
-
brief_parser.add_argument("result_id", help="Result id, usually source:session_id")
|
|
91
|
-
brief_parser.add_argument("--source", help="Source when passing a bare session id")
|
|
92
|
-
brief_parser.add_argument("--json", action="store_true", help="Emit JSON")
|
|
93
|
-
|
|
94
|
-
resume_parser = sub.add_parser("resume", help="Print the verified resume command for a result")
|
|
95
|
-
resume_parser.add_argument("result_id", help="Result id, usually source:session_id")
|
|
96
|
-
resume_parser.add_argument("--source", help="Source when passing a bare session id")
|
|
97
|
-
|
|
98
|
-
eval_parser = sub.add_parser("eval", help="Evaluate query-to-session retrieval quality")
|
|
99
|
-
eval_parser.add_argument("eval_file", type=Path)
|
|
100
|
-
eval_parser.add_argument("--limit", type=int, default=5)
|
|
101
|
-
eval_parser.add_argument("--min-recall", type=float, default=0.9)
|
|
102
|
-
eval_parser.add_argument("--timings", action="store_true", help="Include query timing summary")
|
|
103
|
-
eval_parser.add_argument("--json", action="store_true", help="Emit JSON")
|
|
104
|
-
|
|
105
|
-
bench_parser = sub.add_parser("bench", help="Benchmark query latency from an eval file")
|
|
106
|
-
bench_parser.add_argument("eval_file", type=Path)
|
|
107
|
-
bench_parser.add_argument("--limit", type=int, default=5)
|
|
108
|
-
bench_parser.add_argument("--max-p95-ms", type=float, default=250.0)
|
|
109
|
-
bench_parser.add_argument("--json", action="store_true", help="Emit JSON")
|
|
110
|
-
|
|
111
|
-
stats_parser = sub.add_parser("stats", help="Show index counts")
|
|
112
|
-
stats_parser.set_defaults(_stats=True)
|
|
113
|
-
|
|
114
|
-
skill_parser = sub.add_parser("skill", help="Show the bundled Codex skill path")
|
|
115
|
-
skill_parser.add_argument("--json", action="store_true", help="Emit JSON")
|
|
116
|
-
|
|
117
|
-
args = parser.parse_args(argv)
|
|
118
|
-
|
|
119
|
-
if args.command == "sources":
|
|
120
|
-
return cmd_sources(args)
|
|
121
|
-
if args.command == "start":
|
|
122
|
-
return cmd_start(args)
|
|
123
|
-
if args.command in {"index", "refresh"}:
|
|
124
|
-
return cmd_refresh(args)
|
|
125
|
-
if args.command == "search":
|
|
126
|
-
return cmd_search(args)
|
|
127
|
-
if args.command == "doctor":
|
|
128
|
-
return cmd_doctor(args)
|
|
129
|
-
if args.command == "brief":
|
|
130
|
-
return cmd_brief(args)
|
|
131
|
-
if args.command == "resume":
|
|
132
|
-
return cmd_resume(args)
|
|
133
|
-
if args.command == "eval":
|
|
134
|
-
return cmd_eval(args)
|
|
135
|
-
if args.command == "bench":
|
|
136
|
-
return cmd_bench(args)
|
|
137
|
-
if args.command == "stats":
|
|
138
|
-
return cmd_stats(args)
|
|
139
|
-
if args.command == "skill":
|
|
140
|
-
return cmd_skill(args)
|
|
141
|
-
return 2
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def add_refresh_args(parser: argparse.ArgumentParser) -> None:
|
|
145
|
-
parser.add_argument("--source", action="append", help="Source to refresh")
|
|
146
|
-
parser.add_argument("--all", action="store_true", help="Refresh default sources plus custom profiles")
|
|
147
|
-
parser.add_argument("--include", type=Path, action="append", default=[], help="Extra JSONL file or directory")
|
|
148
|
-
parser.add_argument("--home", type=Path, default=Path.home())
|
|
149
|
-
parser.add_argument("--limit-files", type=int, help="Limit files per source while testing")
|
|
150
|
-
parser.add_argument("--reset", action="store_true", help="Reset the whole database before refreshing")
|
|
151
|
-
parser.add_argument("--force", action="store_true", help="Refresh matching files even if unchanged")
|
|
152
|
-
parser.add_argument("--days", type=float, help="Only scan files modified in the last N days")
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def cmd_sources(args: argparse.Namespace) -> int:
|
|
156
|
-
profiles = load_profiles_for_cli(args.config)
|
|
157
|
-
if profiles is None:
|
|
158
|
-
return 1
|
|
159
|
-
|
|
160
|
-
if args.sources_command == "add":
|
|
161
|
-
try:
|
|
162
|
-
validate_source_name(args.name, reserved=set(SOURCE_NAMES) | {"custom"})
|
|
163
|
-
validate_resume_template(args.resume_template)
|
|
164
|
-
except ValueError as exc:
|
|
165
|
-
print(str(exc))
|
|
166
|
-
return 1
|
|
167
|
-
|
|
168
|
-
profiles[args.name] = SourceProfile(
|
|
169
|
-
name=args.name,
|
|
170
|
-
paths=args.path,
|
|
171
|
-
format=args.format,
|
|
172
|
-
session_key=args.session_key,
|
|
173
|
-
message_key=args.message_key,
|
|
174
|
-
role_key=args.role_key,
|
|
175
|
-
text_key=args.text_key,
|
|
176
|
-
timestamp_key=args.timestamp_key,
|
|
177
|
-
cwd_key=args.cwd_key,
|
|
178
|
-
title_key=args.title_key,
|
|
179
|
-
resume_template=args.resume_template,
|
|
180
|
-
)
|
|
181
|
-
save_profiles(profiles, args.config)
|
|
182
|
-
print(f"Saved source profile: {args.name}")
|
|
183
|
-
return 0
|
|
184
|
-
|
|
185
|
-
if args.sources_command == "remove":
|
|
186
|
-
if args.name not in profiles:
|
|
187
|
-
print(f"No custom source profile found: {args.name}")
|
|
188
|
-
return 1
|
|
189
|
-
profiles.pop(args.name)
|
|
190
|
-
save_profiles(profiles, args.config)
|
|
191
|
-
print(f"Removed source profile: {args.name}")
|
|
192
|
-
return 0
|
|
193
|
-
|
|
194
|
-
rows = []
|
|
195
|
-
for source, count, examples in describe_sources(home=args.home):
|
|
196
|
-
rows.append(
|
|
197
|
-
{
|
|
198
|
-
"source": source,
|
|
199
|
-
"kind": "builtin",
|
|
200
|
-
"paths": count,
|
|
201
|
-
"examples": [str(path) for path in examples],
|
|
202
|
-
}
|
|
203
|
-
)
|
|
204
|
-
for profile in sorted(profiles.values(), key=lambda item: item.name):
|
|
205
|
-
paths = source_profile_paths(profile)
|
|
206
|
-
rows.append(
|
|
207
|
-
{
|
|
208
|
-
"source": profile.name,
|
|
209
|
-
"kind": "custom",
|
|
210
|
-
"format": profile.format,
|
|
211
|
-
"paths": len(paths),
|
|
212
|
-
"examples": [str(path) for path in paths[:5]],
|
|
213
|
-
}
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
if args.json:
|
|
217
|
-
print(json.dumps(rows, ensure_ascii=False))
|
|
218
|
-
return 0
|
|
219
|
-
|
|
220
|
-
for row in rows:
|
|
221
|
-
marker = "custom" if row["kind"] == "custom" else "builtin"
|
|
222
|
-
print(f"{row['source']}: {row['paths']} path(s) [{marker}]")
|
|
223
|
-
for path in row["examples"]:
|
|
224
|
-
print(f" {path}")
|
|
225
|
-
return 0
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def cmd_refresh(args: argparse.Namespace) -> int:
|
|
229
|
-
profiles = load_profiles_for_cli(args.config)
|
|
230
|
-
if profiles is None:
|
|
231
|
-
return 1
|
|
232
|
-
selected_sources = selected_refresh_sources(args, profiles)
|
|
233
|
-
store = ThreadStore(args.db)
|
|
234
|
-
try:
|
|
235
|
-
if args.reset:
|
|
236
|
-
store.reset()
|
|
237
|
-
report = refresh_store(
|
|
238
|
-
store,
|
|
239
|
-
selected_sources,
|
|
240
|
-
profiles,
|
|
241
|
-
home=args.home,
|
|
242
|
-
limit_files=args.limit_files,
|
|
243
|
-
force=args.force,
|
|
244
|
-
days=args.days,
|
|
245
|
-
include_paths=args.include,
|
|
246
|
-
)
|
|
247
|
-
if report["unknown_source"]:
|
|
248
|
-
print(f"Unknown source: {report['unknown_source']}")
|
|
249
|
-
return 1
|
|
250
|
-
print_refresh_report(report, args.db)
|
|
251
|
-
return 0
|
|
252
|
-
finally:
|
|
253
|
-
store.close()
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
def cmd_start(args: argparse.Namespace) -> int:
|
|
257
|
-
profiles = load_profiles_for_cli(args.config)
|
|
258
|
-
if profiles is None:
|
|
259
|
-
return 1
|
|
260
|
-
|
|
261
|
-
selected_sources = selected_refresh_sources(args, profiles)
|
|
262
|
-
unknown_sources = unknown_selected_sources(selected_sources, profiles)
|
|
263
|
-
if unknown_sources:
|
|
264
|
-
print(f"Unknown source: {unknown_sources[0]}")
|
|
265
|
-
return 1
|
|
266
|
-
discovery_rows = source_discovery_rows(selected_sources, profiles, home=args.home)
|
|
267
|
-
|
|
268
|
-
print("Threadlens setup")
|
|
269
|
-
print(f"Index: {args.db}")
|
|
270
|
-
print("Threadlens reads local transcripts and writes only its own SQLite search index.")
|
|
271
|
-
print("Detected sources:")
|
|
272
|
-
if discovery_rows:
|
|
273
|
-
for row in discovery_rows:
|
|
274
|
-
kind = "custom" if row["kind"] == "custom" else "builtin"
|
|
275
|
-
print(f" {row['source']}: {row['paths']} path(s) [{kind}]")
|
|
276
|
-
else:
|
|
277
|
-
print(" none")
|
|
278
|
-
print("Indexing...")
|
|
279
|
-
|
|
280
|
-
store = ThreadStore(args.db)
|
|
281
|
-
try:
|
|
282
|
-
if args.reset:
|
|
283
|
-
store.reset()
|
|
284
|
-
report = refresh_store(
|
|
285
|
-
store,
|
|
286
|
-
selected_sources,
|
|
287
|
-
profiles,
|
|
288
|
-
home=args.home,
|
|
289
|
-
limit_files=args.limit_files,
|
|
290
|
-
force=args.force,
|
|
291
|
-
days=args.days,
|
|
292
|
-
include_paths=args.include,
|
|
293
|
-
)
|
|
294
|
-
if report["unknown_source"]:
|
|
295
|
-
print(f"Unknown source: {report['unknown_source']}")
|
|
296
|
-
return 1
|
|
297
|
-
print_refresh_report(report, args.db)
|
|
298
|
-
total_messages = store.message_count()
|
|
299
|
-
stats_rows = [dict(row) for row in store.stats()]
|
|
300
|
-
finally:
|
|
301
|
-
store.close()
|
|
302
|
-
|
|
303
|
-
readiness = start_readiness_report(discovery_rows, stats_rows, report)
|
|
304
|
-
if total_messages <= 0:
|
|
305
|
-
print("Not ready: no searchable messages were indexed.")
|
|
306
|
-
print("Run `threadlens doctor` for details.")
|
|
307
|
-
return 1
|
|
308
|
-
|
|
309
|
-
if readiness["status"] == "partial":
|
|
310
|
-
print("Partially ready.")
|
|
311
|
-
if readiness["missing_indexed_sources"]:
|
|
312
|
-
print(f"Missing indexed sources: {', '.join(readiness['missing_indexed_sources'])}")
|
|
313
|
-
if readiness["errored_sources"]:
|
|
314
|
-
print(f"Sources with errors: {', '.join(readiness['errored_sources'])}")
|
|
315
|
-
print("Run `threadlens doctor` for details.")
|
|
316
|
-
else:
|
|
317
|
-
print("Ready.")
|
|
318
|
-
print("Try:")
|
|
319
|
-
print(' threadlens search "raycast"')
|
|
320
|
-
print(' threadlens search "error message"')
|
|
321
|
-
return 0
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
def refresh_store(
|
|
325
|
-
store: ThreadStore,
|
|
326
|
-
selected_sources: list[str],
|
|
327
|
-
profiles: dict[str, SourceProfile],
|
|
328
|
-
*,
|
|
329
|
-
home: Path,
|
|
330
|
-
limit_files: int | None,
|
|
331
|
-
force: bool,
|
|
332
|
-
days: float | None,
|
|
333
|
-
include_paths: list[Path],
|
|
334
|
-
) -> dict[str, Any]:
|
|
335
|
-
batch: list[ThreadMessage] = []
|
|
336
|
-
counts: Counter[str] = Counter()
|
|
337
|
-
skipped: Counter[str] = Counter()
|
|
338
|
-
errored: Counter[str] = Counter()
|
|
339
|
-
errors: list[tuple[str, Path, str]] = []
|
|
340
|
-
report: dict[str, Any] = {
|
|
341
|
-
"added": 0,
|
|
342
|
-
"counts": counts,
|
|
343
|
-
"skipped": skipped,
|
|
344
|
-
"errored": errored,
|
|
345
|
-
"errors": errors,
|
|
346
|
-
"unknown_source": "",
|
|
347
|
-
"rebuilt_fts": False,
|
|
348
|
-
}
|
|
349
|
-
changed_files = 0
|
|
350
|
-
|
|
351
|
-
def flush() -> int:
|
|
352
|
-
if not batch:
|
|
353
|
-
return 0
|
|
354
|
-
added_count = store.add_messages(batch, rebuild=False, commit=False)
|
|
355
|
-
batch.clear()
|
|
356
|
-
return added_count
|
|
357
|
-
|
|
358
|
-
for source in selected_sources:
|
|
359
|
-
profile = profiles.get(source)
|
|
360
|
-
if source in SOURCE_NAMES:
|
|
361
|
-
paths = source_paths(source, home=home)
|
|
362
|
-
elif profile:
|
|
363
|
-
paths = source_profile_paths(profile)
|
|
364
|
-
else:
|
|
365
|
-
report["unknown_source"] = source
|
|
366
|
-
return report
|
|
367
|
-
if limit_files is not None:
|
|
368
|
-
paths = paths[:limit_files]
|
|
369
|
-
for path in filtered_paths(paths, days=days):
|
|
370
|
-
messages = source_profile_messages(profile, path) if profile else iter_path_messages(source, path)
|
|
371
|
-
indexed, message_count, error = index_file_safely(
|
|
372
|
-
store,
|
|
373
|
-
source,
|
|
374
|
-
path,
|
|
375
|
-
messages,
|
|
376
|
-
batch,
|
|
377
|
-
force=force,
|
|
378
|
-
)
|
|
379
|
-
if error:
|
|
380
|
-
errored[source] += 1
|
|
381
|
-
errors.append((source, path, error))
|
|
382
|
-
elif indexed:
|
|
383
|
-
counts[source] += message_count
|
|
384
|
-
changed_files += 1
|
|
385
|
-
else:
|
|
386
|
-
skipped[source] += 1
|
|
387
|
-
if len(batch) >= 1000:
|
|
388
|
-
report["added"] += flush()
|
|
389
|
-
|
|
390
|
-
if include_paths:
|
|
391
|
-
paths = custom_jsonl_paths(include_paths)
|
|
392
|
-
if limit_files is not None:
|
|
393
|
-
paths = paths[:limit_files]
|
|
394
|
-
for path in filtered_paths(paths, days=days):
|
|
395
|
-
indexed, message_count, error = index_file_safely(
|
|
396
|
-
store,
|
|
397
|
-
"custom",
|
|
398
|
-
path,
|
|
399
|
-
custom_jsonl_messages(path),
|
|
400
|
-
batch,
|
|
401
|
-
force=force,
|
|
402
|
-
)
|
|
403
|
-
if error:
|
|
404
|
-
errored["custom"] += 1
|
|
405
|
-
errors.append(("custom", path, error))
|
|
406
|
-
elif indexed:
|
|
407
|
-
counts["custom"] += message_count
|
|
408
|
-
changed_files += 1
|
|
409
|
-
else:
|
|
410
|
-
skipped["custom"] += 1
|
|
411
|
-
if len(batch) >= 1000:
|
|
412
|
-
report["added"] += flush()
|
|
413
|
-
|
|
414
|
-
report["added"] += flush()
|
|
415
|
-
if changed_files:
|
|
416
|
-
store.rebuild_fts()
|
|
417
|
-
report["rebuilt_fts"] = True
|
|
418
|
-
store.conn.commit()
|
|
419
|
-
return report
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
def print_refresh_report(
|
|
423
|
-
report: dict[str, Any],
|
|
424
|
-
db_path: Path,
|
|
425
|
-
*,
|
|
426
|
-
out=None,
|
|
427
|
-
err=None,
|
|
428
|
-
) -> None:
|
|
429
|
-
out = out or sys.stdout
|
|
430
|
-
err = err or sys.stderr
|
|
431
|
-
print(f"Refreshed {report['added']} message(s) into {db_path}", file=out)
|
|
432
|
-
for source, count in sorted(report["counts"].items()):
|
|
433
|
-
print(f" {source}: {count}", file=out)
|
|
434
|
-
for source, count in sorted(report["skipped"].items()):
|
|
435
|
-
print(f" {source} skipped unchanged files: {count}", file=out)
|
|
436
|
-
for source, count in sorted(report["errored"].items()):
|
|
437
|
-
print(f" {source} skipped errored files: {count}", file=out)
|
|
438
|
-
errors = report["errors"]
|
|
439
|
-
for source, path, error in errors[:10]:
|
|
440
|
-
print(f" error: {source} {path}: {error}", file=err)
|
|
441
|
-
if len(errors) > 10:
|
|
442
|
-
print(f" error: {len(errors) - 10} additional refresh file error(s) omitted", file=err)
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
def source_discovery_rows(
|
|
446
|
-
selected_sources: list[str],
|
|
447
|
-
profiles: dict[str, SourceProfile],
|
|
448
|
-
*,
|
|
449
|
-
home: Path,
|
|
450
|
-
) -> list[dict[str, Any]]:
|
|
451
|
-
rows: list[dict[str, Any]] = []
|
|
452
|
-
for source in selected_sources:
|
|
453
|
-
if source in SOURCE_NAMES:
|
|
454
|
-
paths = source_paths(source, home=home)
|
|
455
|
-
rows.append({"source": source, "kind": "builtin", "paths": len(paths)})
|
|
456
|
-
elif source in profiles:
|
|
457
|
-
paths = source_profile_paths(profiles[source])
|
|
458
|
-
rows.append({"source": source, "kind": "custom", "paths": len(paths)})
|
|
459
|
-
return rows
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
def start_readiness_report(
|
|
463
|
-
discovery_rows: list[dict[str, Any]],
|
|
464
|
-
stats_rows: list[dict[str, Any]],
|
|
465
|
-
refresh_report: dict[str, Any],
|
|
466
|
-
) -> dict[str, Any]:
|
|
467
|
-
indexed_counts = {str(row["source"]): int(row["messages"]) for row in stats_rows}
|
|
468
|
-
expected_sources = [
|
|
469
|
-
str(row["source"])
|
|
470
|
-
for row in discovery_rows
|
|
471
|
-
if int(row.get("paths") or 0) > 0
|
|
472
|
-
]
|
|
473
|
-
missing = [source for source in expected_sources if indexed_counts.get(source, 0) == 0]
|
|
474
|
-
errored = sorted(str(source) for source, count in refresh_report["errored"].items() if count > 0)
|
|
475
|
-
status = "partial" if missing or errored else "ready"
|
|
476
|
-
return {
|
|
477
|
-
"status": status,
|
|
478
|
-
"missing_indexed_sources": missing,
|
|
479
|
-
"errored_sources": errored,
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
def load_profiles_for_cli(config_path: Path) -> dict[str, SourceProfile] | None:
|
|
484
|
-
try:
|
|
485
|
-
return load_profiles(config_path, strict=True)
|
|
486
|
-
except ProfileConfigError as exc:
|
|
487
|
-
print(f"Could not load source profile config: {exc}", file=sys.stderr)
|
|
488
|
-
return None
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
def selected_refresh_sources(args: argparse.Namespace, profiles: dict[str, SourceProfile]) -> list[str]:
|
|
492
|
-
if args.source:
|
|
493
|
-
return args.source
|
|
494
|
-
if args.all:
|
|
495
|
-
return list(DEFAULT_SOURCE_NAMES) + sorted(profiles)
|
|
496
|
-
return list(DEFAULT_SOURCE_NAMES)
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
def unknown_selected_sources(selected_sources: list[str], profiles: dict[str, SourceProfile]) -> list[str]:
|
|
500
|
-
return [source for source in selected_sources if source not in SOURCE_NAMES and source not in profiles]
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
def filtered_paths(paths: list[Path], *, days: float | None) -> list[Path]:
|
|
504
|
-
if days is None:
|
|
505
|
-
return paths
|
|
506
|
-
cutoff = time.time() - (days * 86400)
|
|
507
|
-
filtered: list[Path] = []
|
|
508
|
-
for path in paths:
|
|
509
|
-
try:
|
|
510
|
-
if path.stat().st_mtime >= cutoff:
|
|
511
|
-
filtered.append(path)
|
|
512
|
-
except OSError:
|
|
513
|
-
continue
|
|
514
|
-
return filtered
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
def index_file(
|
|
518
|
-
store: ThreadStore,
|
|
519
|
-
source: str,
|
|
520
|
-
path: Path,
|
|
521
|
-
messages,
|
|
522
|
-
batch: list[ThreadMessage],
|
|
523
|
-
*,
|
|
524
|
-
force: bool,
|
|
525
|
-
) -> tuple[bool, int]:
|
|
526
|
-
try:
|
|
527
|
-
stat = path.stat()
|
|
528
|
-
except OSError:
|
|
529
|
-
return False, 0
|
|
530
|
-
|
|
531
|
-
if not force and store.file_is_current(source, path, mtime_ns=stat.st_mtime_ns, size=stat.st_size):
|
|
532
|
-
return False, 0
|
|
533
|
-
|
|
534
|
-
store.delete_file(source, path)
|
|
535
|
-
message_count = 0
|
|
536
|
-
for message in messages:
|
|
537
|
-
batch.append(message)
|
|
538
|
-
message_count += 1
|
|
539
|
-
store.mark_file_indexed(
|
|
540
|
-
source,
|
|
541
|
-
path,
|
|
542
|
-
mtime_ns=stat.st_mtime_ns,
|
|
543
|
-
size=stat.st_size,
|
|
544
|
-
message_count=message_count,
|
|
545
|
-
)
|
|
546
|
-
return True, message_count
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
def index_file_safely(
|
|
550
|
-
store: ThreadStore,
|
|
551
|
-
source: str,
|
|
552
|
-
path: Path,
|
|
553
|
-
messages,
|
|
554
|
-
batch: list[ThreadMessage],
|
|
555
|
-
*,
|
|
556
|
-
force: bool,
|
|
557
|
-
) -> tuple[bool, int, str | None]:
|
|
558
|
-
batch_start = len(batch)
|
|
559
|
-
store.conn.execute("savepoint refresh_file")
|
|
560
|
-
try:
|
|
561
|
-
indexed, message_count = index_file(
|
|
562
|
-
store,
|
|
563
|
-
source,
|
|
564
|
-
path,
|
|
565
|
-
messages,
|
|
566
|
-
batch,
|
|
567
|
-
force=force,
|
|
568
|
-
)
|
|
569
|
-
except sqlite3.Error:
|
|
570
|
-
del batch[batch_start:]
|
|
571
|
-
store.conn.execute("rollback to refresh_file")
|
|
572
|
-
store.conn.execute("release refresh_file")
|
|
573
|
-
raise
|
|
574
|
-
except Exception as exc: # noqa: BLE001 - refresh should report bad input files and continue.
|
|
575
|
-
del batch[batch_start:]
|
|
576
|
-
store.conn.execute("rollback to refresh_file")
|
|
577
|
-
store.conn.execute("release refresh_file")
|
|
578
|
-
return False, 0, str(exc) or exc.__class__.__name__
|
|
579
|
-
store.conn.execute("release refresh_file")
|
|
580
|
-
return indexed, message_count, None
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
def cmd_search(args: argparse.Namespace) -> int:
|
|
584
|
-
profiles = load_profiles_for_cli(args.config)
|
|
585
|
-
if profiles is None:
|
|
586
|
-
return 1
|
|
587
|
-
store = ThreadStore(args.db)
|
|
588
|
-
try:
|
|
589
|
-
if search_needs_bootstrap(store, args.source):
|
|
590
|
-
if args.no_bootstrap:
|
|
591
|
-
print(index_empty_message(args.source), file=sys.stderr)
|
|
592
|
-
return 1
|
|
593
|
-
selected_sources = [args.source] if args.source else list(DEFAULT_SOURCE_NAMES)
|
|
594
|
-
target = f" for source {args.source}" if args.source else ""
|
|
595
|
-
print(f"Index is empty{target}. Running first-time indexing...", file=sys.stderr)
|
|
596
|
-
report = refresh_store(
|
|
597
|
-
store,
|
|
598
|
-
selected_sources,
|
|
599
|
-
profiles,
|
|
600
|
-
home=args.home,
|
|
601
|
-
limit_files=None,
|
|
602
|
-
force=False,
|
|
603
|
-
days=None,
|
|
604
|
-
include_paths=[],
|
|
605
|
-
)
|
|
606
|
-
if report["unknown_source"]:
|
|
607
|
-
print(f"Unknown source: {report['unknown_source']}", file=sys.stderr)
|
|
608
|
-
return 1
|
|
609
|
-
print_refresh_report(report, args.db, out=sys.stderr, err=sys.stderr)
|
|
610
|
-
|
|
611
|
-
try:
|
|
612
|
-
results = store.search_sessions(
|
|
613
|
-
" ".join(args.query),
|
|
614
|
-
limit=args.limit,
|
|
615
|
-
source=args.source,
|
|
616
|
-
cwd_prefix=normalize_cwd_filter(args.cwd) if args.cwd else None,
|
|
617
|
-
)
|
|
618
|
-
except sqlite3.Error as exc:
|
|
619
|
-
print(f"Search failed: {exc}", file=sys.stderr)
|
|
620
|
-
return 1
|
|
621
|
-
if args.json:
|
|
622
|
-
for result in results:
|
|
623
|
-
payload = with_actions(result, profiles)
|
|
624
|
-
print(json.dumps(payload, ensure_ascii=False))
|
|
625
|
-
return 0
|
|
626
|
-
|
|
627
|
-
if not results:
|
|
628
|
-
print("No results.")
|
|
629
|
-
return 1
|
|
630
|
-
|
|
631
|
-
for idx, result in enumerate(results, 1):
|
|
632
|
-
result = with_actions(result, profiles)
|
|
633
|
-
print(f"[{idx}] {result['source']} {result['session_id']} score={result['score']}")
|
|
634
|
-
print(f" title: {result['title'] or '-'}")
|
|
635
|
-
print(f" cwd: {result['cwd'] or '-'}")
|
|
636
|
-
print(f" last: {result['last_timestamp'] or '-'}")
|
|
637
|
-
print(f" result: {result['result_id']}")
|
|
638
|
-
command = result["actions"].get("resume_command")
|
|
639
|
-
if command:
|
|
640
|
-
print(f" resume: {command}")
|
|
641
|
-
for snippet in result["best_snippets"]:
|
|
642
|
-
print(f" {snippet['role']} {snippet['timestamp']}: {snippet['snippet']}")
|
|
643
|
-
return 0
|
|
644
|
-
finally:
|
|
645
|
-
store.close()
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
def search_needs_bootstrap(store: ThreadStore, source: str | None) -> bool:
|
|
649
|
-
if source:
|
|
650
|
-
return store.message_count(source) == 0
|
|
651
|
-
return store.message_count() == 0
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
def normalize_cwd_filter(value: str) -> str:
|
|
655
|
-
return str(Path(value).expanduser().resolve(strict=False))
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
def index_empty_message(source: str | None) -> str:
|
|
659
|
-
if source:
|
|
660
|
-
return f"No indexed messages for source {source}. Run `threadlens start` or `threadlens refresh --source {source}`."
|
|
661
|
-
return "Index is empty. Run `threadlens start`."
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
def with_actions(result: dict[str, Any], profiles: dict[str, SourceProfile] | None = None) -> dict[str, Any]:
|
|
665
|
-
payload = dict(result)
|
|
666
|
-
actions = dict(payload.get("actions") or {})
|
|
667
|
-
command = resume_command_for(payload["source"], payload["session_id"], payload.get("cwd") or "", profiles=profiles)
|
|
668
|
-
if command:
|
|
669
|
-
actions["resume_command"] = command
|
|
670
|
-
actions["open_source"] = f"{payload['source_path']}:{payload['source_line']}"
|
|
671
|
-
payload["actions"] = actions
|
|
672
|
-
return payload
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
def resume_command_for(
|
|
676
|
-
source: str,
|
|
677
|
-
thread_id: str,
|
|
678
|
-
cwd: str,
|
|
679
|
-
*,
|
|
680
|
-
profiles: dict[str, SourceProfile] | None = None,
|
|
681
|
-
) -> str:
|
|
682
|
-
if not thread_id:
|
|
683
|
-
return ""
|
|
684
|
-
|
|
685
|
-
prefix = f"cd {shlex.quote(cwd)} && " if cwd else ""
|
|
686
|
-
if source == "codex":
|
|
687
|
-
return f"{prefix}codex resume {shlex.quote(thread_id)}"
|
|
688
|
-
if source == "claude":
|
|
689
|
-
return f"{prefix}claude --resume {shlex.quote(thread_id)}"
|
|
690
|
-
if source == "pi":
|
|
691
|
-
return f"{prefix}pi --session {shlex.quote(thread_id)}"
|
|
692
|
-
if source == "omp":
|
|
693
|
-
return f"{prefix}omp --resume {shlex.quote(thread_id)}"
|
|
694
|
-
if source == "droid":
|
|
695
|
-
return f"{prefix}droid --resume {shlex.quote(thread_id)}"
|
|
696
|
-
if source == "opencode":
|
|
697
|
-
return f"{prefix}opencode --session {shlex.quote(thread_id)}"
|
|
698
|
-
profile = (profiles or {}).get(source)
|
|
699
|
-
if profile and profile.resume_template:
|
|
700
|
-
values = {
|
|
701
|
-
"source": shlex.quote(source),
|
|
702
|
-
"session_id": shlex.quote(thread_id),
|
|
703
|
-
"cwd": shlex.quote(cwd),
|
|
704
|
-
}
|
|
705
|
-
try:
|
|
706
|
-
return profile.resume_template.format_map(values)
|
|
707
|
-
except (KeyError, ValueError):
|
|
708
|
-
return ""
|
|
709
|
-
return ""
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
def validate_resume_template(template: str) -> None:
|
|
713
|
-
if not template:
|
|
714
|
-
return
|
|
715
|
-
try:
|
|
716
|
-
fields = [field for _, field, _, _ in string.Formatter().parse(template) if field]
|
|
717
|
-
except ValueError as exc:
|
|
718
|
-
raise ValueError(f"Invalid resume template: {exc}") from exc
|
|
719
|
-
for field in fields:
|
|
720
|
-
root = field.split(".", 1)[0].split("[", 1)[0]
|
|
721
|
-
if root not in RESUME_TEMPLATE_FIELDS:
|
|
722
|
-
allowed = ", ".join(sorted(RESUME_TEMPLATE_FIELDS))
|
|
723
|
-
raise ValueError(f"Resume template field is not supported: {root}. Use only: {allowed}")
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
def parse_result_id(result_id: str, source: str | None = None) -> tuple[str | None, str]:
|
|
727
|
-
if source:
|
|
728
|
-
return source, result_id
|
|
729
|
-
if ":" in result_id:
|
|
730
|
-
return result_id.split(":", 1)
|
|
731
|
-
return None, result_id
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
def resolve_session(store: ThreadStore, result_id: str, source: str | None = None) -> tuple[str, str, list[Any]]:
|
|
735
|
-
parsed_source, session_id = parse_result_id(result_id, source=source)
|
|
736
|
-
if parsed_source:
|
|
737
|
-
rows = store.get_session(parsed_source, session_id)
|
|
738
|
-
if rows:
|
|
739
|
-
return parsed_source, session_id, rows
|
|
740
|
-
raise ValueError(f"No session found for {parsed_source}:{session_id}")
|
|
741
|
-
|
|
742
|
-
matches = store.find_sessions(session_id)
|
|
743
|
-
if not matches:
|
|
744
|
-
raise ValueError(f"No session found for {session_id}")
|
|
745
|
-
if len(matches) > 1:
|
|
746
|
-
sources = ", ".join(f"{row['source']}:{row['thread_id']}" for row in matches)
|
|
747
|
-
raise ValueError(f"Ambiguous session id. Use one of: {sources}")
|
|
748
|
-
resolved_source = matches[0]["source"]
|
|
749
|
-
rows = store.get_session(resolved_source, session_id)
|
|
750
|
-
return resolved_source, session_id, rows
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
754
|
-
profiles = load_profiles_for_cli(args.config)
|
|
755
|
-
if profiles is None:
|
|
756
|
-
return 1
|
|
757
|
-
report = []
|
|
758
|
-
for source in SOURCE_NAMES:
|
|
759
|
-
paths = source_paths(source, home=args.home)
|
|
760
|
-
readable = 0
|
|
761
|
-
parsed = 0
|
|
762
|
-
errors = []
|
|
763
|
-
path_fingerprints = []
|
|
764
|
-
for path in paths:
|
|
765
|
-
try:
|
|
766
|
-
stat = path.stat()
|
|
767
|
-
readable += 1
|
|
768
|
-
path_fingerprints.append(
|
|
769
|
-
{
|
|
770
|
-
"source": source,
|
|
771
|
-
"path": str(path),
|
|
772
|
-
"mtime_ns": stat.st_mtime_ns,
|
|
773
|
-
"size": stat.st_size,
|
|
774
|
-
}
|
|
775
|
-
)
|
|
776
|
-
first = next(iter_path_messages(source, path), None)
|
|
777
|
-
if first is not None:
|
|
778
|
-
parsed += 1
|
|
779
|
-
except Exception as exc: # noqa: BLE001 - doctor should report and continue.
|
|
780
|
-
errors.append({"path": str(path), "error": str(exc)})
|
|
781
|
-
report.append(
|
|
782
|
-
{
|
|
783
|
-
"source": source,
|
|
784
|
-
"paths": len(paths),
|
|
785
|
-
"readable_paths": readable,
|
|
786
|
-
"paths_with_messages": parsed,
|
|
787
|
-
"errors": errors[:5],
|
|
788
|
-
"status": "ok" if readable == len(paths) and not errors else "degraded",
|
|
789
|
-
"_path_fingerprints": path_fingerprints,
|
|
790
|
-
}
|
|
791
|
-
)
|
|
792
|
-
for profile in sorted(profiles.values(), key=lambda item: item.name):
|
|
793
|
-
paths = source_profile_paths(profile)
|
|
794
|
-
readable = 0
|
|
795
|
-
parsed = 0
|
|
796
|
-
errors = []
|
|
797
|
-
path_fingerprints = []
|
|
798
|
-
for path in paths:
|
|
799
|
-
try:
|
|
800
|
-
stat = path.stat()
|
|
801
|
-
readable += 1
|
|
802
|
-
path_fingerprints.append(
|
|
803
|
-
{
|
|
804
|
-
"source": profile.name,
|
|
805
|
-
"path": str(path),
|
|
806
|
-
"mtime_ns": stat.st_mtime_ns,
|
|
807
|
-
"size": stat.st_size,
|
|
808
|
-
}
|
|
809
|
-
)
|
|
810
|
-
first = next(source_profile_messages(profile, path), None)
|
|
811
|
-
if first is not None:
|
|
812
|
-
parsed += 1
|
|
813
|
-
except Exception as exc: # noqa: BLE001 - doctor should report and continue.
|
|
814
|
-
errors.append({"path": str(path), "error": str(exc)})
|
|
815
|
-
report.append(
|
|
816
|
-
{
|
|
817
|
-
"source": profile.name,
|
|
818
|
-
"kind": "custom",
|
|
819
|
-
"paths": len(paths),
|
|
820
|
-
"readable_paths": readable,
|
|
821
|
-
"paths_with_messages": parsed,
|
|
822
|
-
"errors": errors[:5],
|
|
823
|
-
"status": "ok" if readable == len(paths) and not errors else "degraded",
|
|
824
|
-
"_path_fingerprints": path_fingerprints,
|
|
825
|
-
}
|
|
826
|
-
)
|
|
827
|
-
|
|
828
|
-
index = index_readiness_report(args.db, report)
|
|
829
|
-
if args.json:
|
|
830
|
-
public_report = public_source_report(report)
|
|
831
|
-
print(json.dumps({"sources": public_report, "index": index, "ready": index["status"] == "ready"}, ensure_ascii=False))
|
|
832
|
-
return 0
|
|
833
|
-
|
|
834
|
-
print("Sources")
|
|
835
|
-
for item in report:
|
|
836
|
-
print(f" {item['source']}: {item['status']} ({item['paths_with_messages']}/{item['paths']} paths with messages)")
|
|
837
|
-
for error in item["errors"]:
|
|
838
|
-
print(f" error: {error['path']} {error['error']}")
|
|
839
|
-
print("Index")
|
|
840
|
-
print(f" db: {index['db']}")
|
|
841
|
-
print(f" status: {index['status']}")
|
|
842
|
-
print(f" messages: {index['messages']}")
|
|
843
|
-
for source in index["sources"]:
|
|
844
|
-
print(f" {source['source']}: {source['messages']} messages, {source['threads']} threads")
|
|
845
|
-
if index["missing_indexed_sources"]:
|
|
846
|
-
print(f" missing indexed sources: {', '.join(index['missing_indexed_sources'])}")
|
|
847
|
-
freshness = index.get("freshness") or {}
|
|
848
|
-
if freshness.get("missing_files") or freshness.get("stale_files") or freshness.get("deleted_files"):
|
|
849
|
-
print(
|
|
850
|
-
" freshness: "
|
|
851
|
-
f"{freshness.get('missing_files', 0)} missing, "
|
|
852
|
-
f"{freshness.get('stale_files', 0)} stale, "
|
|
853
|
-
f"{freshness.get('deleted_files', 0)} deleted"
|
|
854
|
-
)
|
|
855
|
-
for sample in freshness.get("samples", [])[:5]:
|
|
856
|
-
print(f" stale: {sample['source']} {sample['path']} ({sample['reason']})")
|
|
857
|
-
if freshness.get("action"):
|
|
858
|
-
print(f" freshness action: {freshness['action']}")
|
|
859
|
-
if index["action"]:
|
|
860
|
-
print(f" action: {index['action']}")
|
|
861
|
-
return 0
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
def public_source_report(report: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
865
|
-
return [
|
|
866
|
-
{key: value for key, value in item.items() if not key.startswith("_")}
|
|
867
|
-
for item in report
|
|
868
|
-
]
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
def index_readiness_report(db_path: Path, source_report: list[dict[str, Any]]) -> dict[str, Any]:
|
|
872
|
-
base: dict[str, Any] = {
|
|
873
|
-
"db": str(db_path),
|
|
874
|
-
"exists": db_path.exists(),
|
|
875
|
-
"status": "not_ready",
|
|
876
|
-
"messages": 0,
|
|
877
|
-
"sources": [],
|
|
878
|
-
"missing_indexed_sources": [],
|
|
879
|
-
"freshness": {
|
|
880
|
-
"status": "fresh",
|
|
881
|
-
"missing_files": 0,
|
|
882
|
-
"stale_files": 0,
|
|
883
|
-
"deleted_files": 0,
|
|
884
|
-
"action": "",
|
|
885
|
-
"samples": [],
|
|
886
|
-
},
|
|
887
|
-
"action": "run: threadlens start",
|
|
888
|
-
"error": "",
|
|
889
|
-
}
|
|
890
|
-
if not db_path.exists():
|
|
891
|
-
return base
|
|
892
|
-
|
|
893
|
-
try:
|
|
894
|
-
uri_path = urllib.parse.quote(str(db_path), safe="/:")
|
|
895
|
-
conn = sqlite3.connect(f"file:{uri_path}?mode=ro", uri=True)
|
|
896
|
-
conn.row_factory = sqlite3.Row
|
|
897
|
-
except sqlite3.Error as exc:
|
|
898
|
-
base["status"] = "error"
|
|
899
|
-
base["error"] = str(exc)
|
|
900
|
-
return base
|
|
901
|
-
|
|
902
|
-
try:
|
|
903
|
-
tables = {
|
|
904
|
-
row[0]
|
|
905
|
-
for row in conn.execute("select name from sqlite_master where type = 'table'")
|
|
906
|
-
}
|
|
907
|
-
messages_table = conn.execute(
|
|
908
|
-
"select 1 from sqlite_master where type = 'table' and name = 'messages'"
|
|
909
|
-
).fetchone()
|
|
910
|
-
if messages_table is None:
|
|
911
|
-
return base
|
|
912
|
-
|
|
913
|
-
rows = list(
|
|
914
|
-
conn.execute(
|
|
915
|
-
"""
|
|
916
|
-
select source, count(*) as messages, count(distinct thread_id) as threads
|
|
917
|
-
from messages
|
|
918
|
-
group by source
|
|
919
|
-
order by source
|
|
920
|
-
"""
|
|
921
|
-
)
|
|
922
|
-
)
|
|
923
|
-
sources = [
|
|
924
|
-
{"source": row["source"], "messages": int(row["messages"]), "threads": int(row["threads"])}
|
|
925
|
-
for row in rows
|
|
926
|
-
]
|
|
927
|
-
total = sum(row["messages"] for row in sources)
|
|
928
|
-
indexed_counts = {row["source"]: row["messages"] for row in sources}
|
|
929
|
-
expected_sources = [
|
|
930
|
-
item["source"]
|
|
931
|
-
for item in source_report
|
|
932
|
-
if item.get("paths_with_messages", 0) > 0
|
|
933
|
-
]
|
|
934
|
-
missing = [source for source in expected_sources if indexed_counts.get(source, 0) == 0]
|
|
935
|
-
indexed_files = []
|
|
936
|
-
if "indexed_files" in tables:
|
|
937
|
-
indexed_files = [
|
|
938
|
-
dict(row)
|
|
939
|
-
for row in conn.execute(
|
|
940
|
-
"""
|
|
941
|
-
select source, path, mtime_ns, size, message_count, indexed_at
|
|
942
|
-
from indexed_files
|
|
943
|
-
"""
|
|
944
|
-
)
|
|
945
|
-
]
|
|
946
|
-
freshness = index_freshness_report(source_report, indexed_files)
|
|
947
|
-
|
|
948
|
-
base["messages"] = total
|
|
949
|
-
base["sources"] = sources
|
|
950
|
-
base["missing_indexed_sources"] = missing
|
|
951
|
-
base["freshness"] = freshness
|
|
952
|
-
if total <= 0:
|
|
953
|
-
base["status"] = "not_ready"
|
|
954
|
-
base["action"] = "run: threadlens start"
|
|
955
|
-
elif missing:
|
|
956
|
-
base["status"] = "partial"
|
|
957
|
-
base["action"] = "run: threadlens start"
|
|
958
|
-
else:
|
|
959
|
-
base["status"] = "ready"
|
|
960
|
-
base["action"] = ""
|
|
961
|
-
return base
|
|
962
|
-
except sqlite3.Error as exc:
|
|
963
|
-
base["status"] = "error"
|
|
964
|
-
base["error"] = str(exc)
|
|
965
|
-
return base
|
|
966
|
-
finally:
|
|
967
|
-
conn.close()
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
def index_freshness_report(source_report: list[dict[str, Any]], indexed_files: list[dict[str, Any]]) -> dict[str, Any]:
|
|
971
|
-
expected: dict[tuple[str, str], dict[str, Any]] = {}
|
|
972
|
-
for item in source_report:
|
|
973
|
-
for fingerprint in item.get("_path_fingerprints", []):
|
|
974
|
-
key = (str(fingerprint["source"]), str(fingerprint["path"]))
|
|
975
|
-
expected[key] = fingerprint
|
|
976
|
-
|
|
977
|
-
indexed: dict[tuple[str, str], dict[str, Any]] = {}
|
|
978
|
-
for row in indexed_files:
|
|
979
|
-
key = (str(row["source"]), str(row["path"]))
|
|
980
|
-
indexed[key] = row
|
|
981
|
-
|
|
982
|
-
samples: list[dict[str, str]] = []
|
|
983
|
-
missing_files = 0
|
|
984
|
-
stale_files = 0
|
|
985
|
-
deleted_files = 0
|
|
986
|
-
|
|
987
|
-
for key, fingerprint in expected.items():
|
|
988
|
-
row = indexed.get(key)
|
|
989
|
-
if row is None:
|
|
990
|
-
missing_files += 1
|
|
991
|
-
add_freshness_sample(samples, key, "not indexed")
|
|
992
|
-
continue
|
|
993
|
-
if int(row.get("mtime_ns") or 0) != int(fingerprint.get("mtime_ns") or 0) or int(row.get("size") or 0) != int(fingerprint.get("size") or 0):
|
|
994
|
-
stale_files += 1
|
|
995
|
-
add_freshness_sample(samples, key, "changed")
|
|
996
|
-
|
|
997
|
-
for key in indexed:
|
|
998
|
-
if key not in expected:
|
|
999
|
-
deleted_files += 1
|
|
1000
|
-
add_freshness_sample(samples, key, "missing source file")
|
|
1001
|
-
|
|
1002
|
-
return {
|
|
1003
|
-
"status": "stale" if missing_files or stale_files or deleted_files else "fresh",
|
|
1004
|
-
"missing_files": missing_files,
|
|
1005
|
-
"stale_files": stale_files,
|
|
1006
|
-
"deleted_files": deleted_files,
|
|
1007
|
-
"action": "run: threadlens refresh" if missing_files or stale_files or deleted_files else "",
|
|
1008
|
-
"samples": samples,
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
def add_freshness_sample(samples: list[dict[str, str]], key: tuple[str, str], reason: str) -> None:
|
|
1013
|
-
if len(samples) >= 10:
|
|
1014
|
-
return
|
|
1015
|
-
source, path = key
|
|
1016
|
-
samples.append({"source": source, "path": path, "reason": reason})
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
def cmd_brief(args: argparse.Namespace) -> int:
|
|
1020
|
-
profiles = load_profiles_for_cli(args.config)
|
|
1021
|
-
if profiles is None:
|
|
1022
|
-
return 1
|
|
1023
|
-
store = ThreadStore(args.db)
|
|
1024
|
-
try:
|
|
1025
|
-
try:
|
|
1026
|
-
source, session_id, rows = resolve_session(store, args.result_id, source=args.source)
|
|
1027
|
-
except ValueError as exc:
|
|
1028
|
-
print(str(exc))
|
|
1029
|
-
return 1
|
|
1030
|
-
|
|
1031
|
-
brief = build_brief(source, session_id, rows, profiles=profiles)
|
|
1032
|
-
if args.json:
|
|
1033
|
-
print(json.dumps(brief, ensure_ascii=False))
|
|
1034
|
-
return 0
|
|
1035
|
-
|
|
1036
|
-
print(f"{brief['source']}:{brief['session_id']}")
|
|
1037
|
-
print(f"title: {brief['title'] or '-'}")
|
|
1038
|
-
print(f"cwd: {brief['cwd'] or '-'}")
|
|
1039
|
-
print(f"messages: {brief['message_count']}")
|
|
1040
|
-
print(f"last: {brief['last_timestamp'] or '-'}")
|
|
1041
|
-
if brief["resume_command"]:
|
|
1042
|
-
print(f"resume: {brief['resume_command']}")
|
|
1043
|
-
if brief["last_user_message"]:
|
|
1044
|
-
print(f"last user: {brief['last_user_message']}")
|
|
1045
|
-
if brief["last_assistant_message"]:
|
|
1046
|
-
print(f"last assistant: {brief['last_assistant_message']}")
|
|
1047
|
-
return 0
|
|
1048
|
-
finally:
|
|
1049
|
-
store.close()
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
def cmd_resume(args: argparse.Namespace) -> int:
|
|
1053
|
-
profiles = load_profiles_for_cli(args.config)
|
|
1054
|
-
if profiles is None:
|
|
1055
|
-
return 1
|
|
1056
|
-
store = ThreadStore(args.db)
|
|
1057
|
-
try:
|
|
1058
|
-
try:
|
|
1059
|
-
source, session_id, rows = resolve_session(store, args.result_id, source=args.source)
|
|
1060
|
-
except ValueError as exc:
|
|
1061
|
-
print(str(exc))
|
|
1062
|
-
return 1
|
|
1063
|
-
cwd = first_non_empty(rows, "cwd")
|
|
1064
|
-
command = resume_command_for(source, session_id, cwd, profiles=profiles)
|
|
1065
|
-
if not command:
|
|
1066
|
-
print(f"No verified resume command for source: {source}")
|
|
1067
|
-
return 1
|
|
1068
|
-
print(command)
|
|
1069
|
-
return 0
|
|
1070
|
-
finally:
|
|
1071
|
-
store.close()
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
def cmd_eval(args: argparse.Namespace) -> int:
|
|
1075
|
-
try:
|
|
1076
|
-
cases = load_eval_cases(args.eval_file)
|
|
1077
|
-
except ValueError as exc:
|
|
1078
|
-
print(f"Could not read eval file: {exc}")
|
|
1079
|
-
return 1
|
|
1080
|
-
|
|
1081
|
-
store = ThreadStore(args.db)
|
|
1082
|
-
try:
|
|
1083
|
-
positive_total = 0
|
|
1084
|
-
positive_hits = 0
|
|
1085
|
-
negative_total = 0
|
|
1086
|
-
negative_failures = 0
|
|
1087
|
-
case_results = []
|
|
1088
|
-
durations_ms: list[float] = []
|
|
1089
|
-
|
|
1090
|
-
for case in cases:
|
|
1091
|
-
targets = eval_case_targets(case)
|
|
1092
|
-
if not targets:
|
|
1093
|
-
continue
|
|
1094
|
-
target_source, target_session = targets[0]
|
|
1095
|
-
|
|
1096
|
-
positives = []
|
|
1097
|
-
for query in case.get("queries", []):
|
|
1098
|
-
started = perf_counter()
|
|
1099
|
-
try:
|
|
1100
|
-
results = store.search_sessions(query, limit=args.limit)
|
|
1101
|
-
except sqlite3.Error as exc:
|
|
1102
|
-
print(f"Eval search failed: {exc}", file=sys.stderr)
|
|
1103
|
-
return 1
|
|
1104
|
-
duration_ms = (perf_counter() - started) * 1000
|
|
1105
|
-
durations_ms.append(duration_ms)
|
|
1106
|
-
hit_rank = rank_for_targets(results, targets)
|
|
1107
|
-
positive_total += 1
|
|
1108
|
-
if hit_rank is not None:
|
|
1109
|
-
positive_hits += 1
|
|
1110
|
-
entry = {"query": query, "hit_rank": hit_rank}
|
|
1111
|
-
if args.timings:
|
|
1112
|
-
entry["duration_ms"] = round(duration_ms, 3)
|
|
1113
|
-
positives.append(entry)
|
|
1114
|
-
|
|
1115
|
-
negatives = []
|
|
1116
|
-
for query in case.get("negative_queries", []):
|
|
1117
|
-
started = perf_counter()
|
|
1118
|
-
try:
|
|
1119
|
-
results = store.search_sessions(query, limit=args.limit)
|
|
1120
|
-
except sqlite3.Error as exc:
|
|
1121
|
-
print(f"Eval search failed: {exc}", file=sys.stderr)
|
|
1122
|
-
return 1
|
|
1123
|
-
duration_ms = (perf_counter() - started) * 1000
|
|
1124
|
-
durations_ms.append(duration_ms)
|
|
1125
|
-
hit_rank = rank_for_targets(results, targets)
|
|
1126
|
-
negative_total += 1
|
|
1127
|
-
if hit_rank is not None:
|
|
1128
|
-
negative_failures += 1
|
|
1129
|
-
entry = {"query": query, "hit_rank": hit_rank}
|
|
1130
|
-
if args.timings:
|
|
1131
|
-
entry["duration_ms"] = round(duration_ms, 3)
|
|
1132
|
-
negatives.append(entry)
|
|
1133
|
-
|
|
1134
|
-
case_results.append(
|
|
1135
|
-
{
|
|
1136
|
-
"case_id": case.get("case_id") or f"{target_source}:{target_session}",
|
|
1137
|
-
"target": {"source": target_source, "session_id": target_session},
|
|
1138
|
-
"targets": [{"source": source, "session_id": session_id} for source, session_id in targets],
|
|
1139
|
-
"positives": positives,
|
|
1140
|
-
"negatives": negatives,
|
|
1141
|
-
}
|
|
1142
|
-
)
|
|
1143
|
-
|
|
1144
|
-
recall = positive_hits / positive_total if positive_total else 0.0
|
|
1145
|
-
passed = recall >= args.min_recall and negative_failures == 0
|
|
1146
|
-
report = {
|
|
1147
|
-
"passed": passed,
|
|
1148
|
-
"recall_at_limit": recall,
|
|
1149
|
-
"positive_hits": positive_hits,
|
|
1150
|
-
"positive_total": positive_total,
|
|
1151
|
-
"negative_failures": negative_failures,
|
|
1152
|
-
"negative_total": negative_total,
|
|
1153
|
-
"limit": args.limit,
|
|
1154
|
-
"cases": case_results,
|
|
1155
|
-
}
|
|
1156
|
-
if args.timings:
|
|
1157
|
-
report["timings_ms"] = timing_summary(durations_ms)
|
|
1158
|
-
|
|
1159
|
-
if args.json:
|
|
1160
|
-
print(json.dumps(report, ensure_ascii=False))
|
|
1161
|
-
else:
|
|
1162
|
-
print(f"passed: {passed}")
|
|
1163
|
-
print(f"recall@{args.limit}: {positive_hits}/{positive_total} = {recall:.3f}")
|
|
1164
|
-
print(f"negative failures: {negative_failures}/{negative_total}")
|
|
1165
|
-
if args.timings:
|
|
1166
|
-
summary = report["timings_ms"]
|
|
1167
|
-
print(
|
|
1168
|
-
"timings: "
|
|
1169
|
-
f"count={summary['count']} "
|
|
1170
|
-
f"p50={summary['p50']:.1f}ms "
|
|
1171
|
-
f"p95={summary['p95']:.1f}ms "
|
|
1172
|
-
f"max={summary['max']:.1f}ms"
|
|
1173
|
-
)
|
|
1174
|
-
for case in case_results:
|
|
1175
|
-
print(f"- {case['case_id']}")
|
|
1176
|
-
for positive in case["positives"]:
|
|
1177
|
-
suffix = f", {positive['duration_ms']:.1f}ms" if args.timings else ""
|
|
1178
|
-
print(f" + {positive['query']}: rank {positive['hit_rank']}{suffix}")
|
|
1179
|
-
for negative in case["negatives"]:
|
|
1180
|
-
suffix = f", {negative['duration_ms']:.1f}ms" if args.timings else ""
|
|
1181
|
-
print(f" - {negative['query']}: target rank {negative['hit_rank']}{suffix}")
|
|
1182
|
-
return 0 if passed else 1
|
|
1183
|
-
finally:
|
|
1184
|
-
store.close()
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
def cmd_bench(args: argparse.Namespace) -> int:
|
|
1188
|
-
try:
|
|
1189
|
-
cases = load_eval_cases(args.eval_file)
|
|
1190
|
-
except ValueError as exc:
|
|
1191
|
-
print(f"Could not read eval file: {exc}")
|
|
1192
|
-
return 1
|
|
1193
|
-
|
|
1194
|
-
queries = eval_queries(cases)
|
|
1195
|
-
store = ThreadStore(args.db)
|
|
1196
|
-
try:
|
|
1197
|
-
rows = []
|
|
1198
|
-
for query in queries:
|
|
1199
|
-
started = perf_counter()
|
|
1200
|
-
try:
|
|
1201
|
-
store.search_sessions(query, limit=args.limit)
|
|
1202
|
-
except sqlite3.Error as exc:
|
|
1203
|
-
print(f"Bench search failed: {exc}", file=sys.stderr)
|
|
1204
|
-
return 1
|
|
1205
|
-
duration_ms = (perf_counter() - started) * 1000
|
|
1206
|
-
rows.append({"query": query, "duration_ms": round(duration_ms, 3)})
|
|
1207
|
-
|
|
1208
|
-
summary = timing_summary([row["duration_ms"] for row in rows])
|
|
1209
|
-
passed = summary["p95"] <= args.max_p95_ms
|
|
1210
|
-
report = {
|
|
1211
|
-
"passed": passed,
|
|
1212
|
-
"max_p95_ms": args.max_p95_ms,
|
|
1213
|
-
"timings_ms": summary,
|
|
1214
|
-
"slowest": sorted(rows, key=lambda row: row["duration_ms"], reverse=True)[:10],
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
if args.json:
|
|
1218
|
-
print(json.dumps(report, ensure_ascii=False))
|
|
1219
|
-
else:
|
|
1220
|
-
print(f"passed: {passed}")
|
|
1221
|
-
print(
|
|
1222
|
-
"timings: "
|
|
1223
|
-
f"count={summary['count']} "
|
|
1224
|
-
f"p50={summary['p50']:.1f}ms "
|
|
1225
|
-
f"p95={summary['p95']:.1f}ms "
|
|
1226
|
-
f"max={summary['max']:.1f}ms"
|
|
1227
|
-
)
|
|
1228
|
-
for row in report["slowest"]:
|
|
1229
|
-
print(f" {row['duration_ms']:.1f}ms {row['query']}")
|
|
1230
|
-
return 0 if passed else 1
|
|
1231
|
-
finally:
|
|
1232
|
-
store.close()
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
def load_eval_cases(path: Path) -> list[dict[str, Any]]:
|
|
1236
|
-
try:
|
|
1237
|
-
cases = json.loads(path.read_text(encoding="utf-8"))
|
|
1238
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
1239
|
-
raise ValueError(str(exc)) from exc
|
|
1240
|
-
if not isinstance(cases, list):
|
|
1241
|
-
raise ValueError("Eval file must contain a JSON array")
|
|
1242
|
-
return [case for case in cases if isinstance(case, dict)]
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
def eval_queries(cases: list[dict[str, Any]]) -> list[str]:
|
|
1246
|
-
queries: list[str] = []
|
|
1247
|
-
for case in cases:
|
|
1248
|
-
queries.extend(str(query) for query in case.get("queries", []) if str(query).strip())
|
|
1249
|
-
queries.extend(str(query) for query in case.get("negative_queries", []) if str(query).strip())
|
|
1250
|
-
return queries
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
def timing_summary(durations_ms: list[float]) -> dict[str, float | int]:
|
|
1254
|
-
if not durations_ms:
|
|
1255
|
-
return {"count": 0, "p50": 0.0, "p95": 0.0, "max": 0.0, "mean": 0.0}
|
|
1256
|
-
ordered = sorted(durations_ms)
|
|
1257
|
-
return {
|
|
1258
|
-
"count": len(ordered),
|
|
1259
|
-
"p50": percentile(ordered, 0.50),
|
|
1260
|
-
"p95": percentile(ordered, 0.95),
|
|
1261
|
-
"max": max(ordered),
|
|
1262
|
-
"mean": sum(ordered) / len(ordered),
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
def percentile(ordered_values: list[float], fraction: float) -> float:
|
|
1267
|
-
if not ordered_values:
|
|
1268
|
-
return 0.0
|
|
1269
|
-
index = min(len(ordered_values) - 1, max(0, int(round((len(ordered_values) - 1) * fraction))))
|
|
1270
|
-
return ordered_values[index]
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
def build_brief(
|
|
1274
|
-
source: str,
|
|
1275
|
-
session_id: str,
|
|
1276
|
-
rows: list[Any],
|
|
1277
|
-
*,
|
|
1278
|
-
profiles: dict[str, SourceProfile] | None = None,
|
|
1279
|
-
) -> dict[str, Any]:
|
|
1280
|
-
row_dicts = [dict(row) for row in rows]
|
|
1281
|
-
first = row_dicts[0] if row_dicts else {}
|
|
1282
|
-
last = row_dicts[-1] if row_dicts else {}
|
|
1283
|
-
cwd = first_non_empty(rows, "cwd")
|
|
1284
|
-
return {
|
|
1285
|
-
"source": source,
|
|
1286
|
-
"session_id": session_id,
|
|
1287
|
-
"title": first.get("title") or "",
|
|
1288
|
-
"cwd": cwd,
|
|
1289
|
-
"message_count": len(row_dicts),
|
|
1290
|
-
"first_timestamp": first.get("timestamp") or "",
|
|
1291
|
-
"last_timestamp": last.get("timestamp") or "",
|
|
1292
|
-
"source_path": first.get("path") or "",
|
|
1293
|
-
"resume_command": resume_command_for(source, session_id, cwd, profiles=profiles),
|
|
1294
|
-
"first_user_message": compact_for_display(first_role_text(row_dicts, "user")),
|
|
1295
|
-
"last_user_message": compact_for_display(last_role_text(row_dicts, "user")),
|
|
1296
|
-
"last_assistant_message": compact_for_display(last_role_text(row_dicts, "assistant")),
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
def first_non_empty(rows: list[Any], field: str) -> str:
|
|
1301
|
-
for row in rows:
|
|
1302
|
-
value = row[field]
|
|
1303
|
-
if value:
|
|
1304
|
-
return str(value)
|
|
1305
|
-
return ""
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
def first_role_text(rows: list[dict[str, Any]], role: str) -> str:
|
|
1309
|
-
for row in rows:
|
|
1310
|
-
if row.get("role") == role:
|
|
1311
|
-
return row.get("text") or ""
|
|
1312
|
-
return ""
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
def last_role_text(rows: list[dict[str, Any]], role: str) -> str:
|
|
1316
|
-
for row in reversed(rows):
|
|
1317
|
-
if row.get("role") == role:
|
|
1318
|
-
return row.get("text") or ""
|
|
1319
|
-
return ""
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
def compact_for_display(text: str, limit: int = 320) -> str:
|
|
1323
|
-
compact = " ".join(text.split())
|
|
1324
|
-
if len(compact) <= limit:
|
|
1325
|
-
return compact
|
|
1326
|
-
return compact[:limit].rstrip() + "..."
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
def rank_for_target(results: list[dict[str, Any]], source: str, session_id: str) -> int | None:
|
|
1330
|
-
for index, result in enumerate(results, 1):
|
|
1331
|
-
if result["source"] == source and result["session_id"] == session_id:
|
|
1332
|
-
return index
|
|
1333
|
-
return None
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
def rank_for_targets(results: list[dict[str, Any]], targets: list[tuple[str, str]]) -> int | None:
|
|
1337
|
-
target_set = set(targets)
|
|
1338
|
-
for index, result in enumerate(results, 1):
|
|
1339
|
-
if (result["source"], result["session_id"]) in target_set:
|
|
1340
|
-
return index
|
|
1341
|
-
return None
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
def eval_case_targets(case: dict[str, Any]) -> list[tuple[str, str]]:
|
|
1345
|
-
raw_targets = case.get("targets")
|
|
1346
|
-
if isinstance(raw_targets, list):
|
|
1347
|
-
targets = [eval_target_tuple(target) for target in raw_targets if isinstance(target, dict)]
|
|
1348
|
-
return [target for target in targets if target[0] and target[1]]
|
|
1349
|
-
|
|
1350
|
-
target = case.get("target") if isinstance(case.get("target"), dict) else {}
|
|
1351
|
-
source = target.get("source") or case.get("source")
|
|
1352
|
-
session_id = target.get("session_id") or target.get("thread_id") or case.get("target_thread_id")
|
|
1353
|
-
if not source or not session_id:
|
|
1354
|
-
return []
|
|
1355
|
-
return [(str(source), str(session_id))]
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
def eval_target_tuple(target: dict[str, Any]) -> tuple[str, str]:
|
|
1359
|
-
source = target.get("source")
|
|
1360
|
-
session_id = target.get("session_id") or target.get("thread_id")
|
|
1361
|
-
return str(source or ""), str(session_id or "")
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
def cmd_stats(args: argparse.Namespace) -> int:
|
|
1365
|
-
store = ThreadStore(args.db)
|
|
1366
|
-
try:
|
|
1367
|
-
rows = store.stats()
|
|
1368
|
-
if not rows:
|
|
1369
|
-
print("No indexed messages.")
|
|
1370
|
-
return 0
|
|
1371
|
-
for row in rows:
|
|
1372
|
-
print(f"{row['source']}: {row['messages']} message(s), {row['threads']} thread(s)")
|
|
1373
|
-
return 0
|
|
1374
|
-
finally:
|
|
1375
|
-
store.close()
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
def cmd_skill(args: argparse.Namespace) -> int:
|
|
1379
|
-
skill_path = resources.files("threadlens").joinpath("skills", "threadlens")
|
|
1380
|
-
skill_md = skill_path.joinpath("SKILL.md")
|
|
1381
|
-
if args.json:
|
|
1382
|
-
print(
|
|
1383
|
-
json.dumps(
|
|
1384
|
-
{
|
|
1385
|
-
"name": "threadlens",
|
|
1386
|
-
"path": str(skill_path),
|
|
1387
|
-
"skill_md": str(skill_md),
|
|
1388
|
-
},
|
|
1389
|
-
ensure_ascii=False,
|
|
1390
|
-
)
|
|
1391
|
-
)
|
|
1392
|
-
return 0
|
|
1393
|
-
print(skill_path)
|
|
1394
|
-
print(f"SKILL.md: {skill_md}")
|
|
1395
|
-
return 0
|