threadlens 1.0.0 → 1.1.0

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.
@@ -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