devin-search 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devin_search/__init__.py +1 -0
- devin_search/cli.py +221 -0
- devin_search/extract.py +112 -0
- devin_search/fmt.py +96 -0
- devin_search/history.py +52 -0
- devin_search/index.py +355 -0
- devin_search/misslog.py +91 -0
- devin_search/paths.py +87 -0
- devin_search/query.py +118 -0
- devin_search-0.1.0.dist-info/METADATA +212 -0
- devin_search-0.1.0.dist-info/RECORD +15 -0
- devin_search-0.1.0.dist-info/WHEEL +5 -0
- devin_search-0.1.0.dist-info/entry_points.txt +2 -0
- devin_search-0.1.0.dist-info/licenses/LICENSE +21 -0
- devin_search-0.1.0.dist-info/top_level.txt +1 -0
devin_search/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
devin_search/cli.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Thin CLI wrapper — all logic lives in the library modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from devin_internals import SchemaError
|
|
11
|
+
|
|
12
|
+
from devin_search import __version__
|
|
13
|
+
from devin_search.history import attach_history_notes
|
|
14
|
+
from devin_search.fmt import (
|
|
15
|
+
hits_table,
|
|
16
|
+
hits_to_dicts,
|
|
17
|
+
stats_line,
|
|
18
|
+
stats_to_dict,
|
|
19
|
+
)
|
|
20
|
+
from devin_search.index import build_index
|
|
21
|
+
from devin_search.paths import (
|
|
22
|
+
default_acp_dir,
|
|
23
|
+
default_index_path,
|
|
24
|
+
default_sessions_db,
|
|
25
|
+
)
|
|
26
|
+
from devin_search.query import parse_since, search
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_index(arg: str | None) -> Path:
|
|
30
|
+
return Path(arg).expanduser() if arg else default_index_path()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cmd_index(args: argparse.Namespace) -> int:
|
|
34
|
+
sessions_db = (
|
|
35
|
+
Path(args.sessions_db).expanduser()
|
|
36
|
+
if args.sessions_db
|
|
37
|
+
else default_sessions_db()
|
|
38
|
+
)
|
|
39
|
+
acp_dir = (
|
|
40
|
+
None
|
|
41
|
+
if args.no_acp
|
|
42
|
+
else Path(args.acp_dir).expanduser()
|
|
43
|
+
if args.acp_dir
|
|
44
|
+
else default_acp_dir()
|
|
45
|
+
)
|
|
46
|
+
if sessions_db is None and acp_dir is None:
|
|
47
|
+
print(
|
|
48
|
+
"devin-search: no Devin stores found in the default locations; "
|
|
49
|
+
"pass --sessions-db and/or --acp-dir",
|
|
50
|
+
file=sys.stderr,
|
|
51
|
+
)
|
|
52
|
+
return 2
|
|
53
|
+
if sessions_db is not None and not sessions_db.exists():
|
|
54
|
+
print(
|
|
55
|
+
f"devin-search: {sessions_db}: no such file", file=sys.stderr
|
|
56
|
+
)
|
|
57
|
+
return 2
|
|
58
|
+
try:
|
|
59
|
+
stats = build_index(
|
|
60
|
+
_resolve_index(args.index),
|
|
61
|
+
sessions_db=sessions_db,
|
|
62
|
+
acp_dir=acp_dir,
|
|
63
|
+
rebuild=args.rebuild,
|
|
64
|
+
)
|
|
65
|
+
except SchemaError as exc:
|
|
66
|
+
print(f"devin-search: {exc}", file=sys.stderr)
|
|
67
|
+
return 2
|
|
68
|
+
if args.json:
|
|
69
|
+
print(json.dumps(stats_to_dict(stats), ensure_ascii=False, indent=2))
|
|
70
|
+
else:
|
|
71
|
+
print(stats_line(stats))
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _cmd_query(args: argparse.Namespace) -> int:
|
|
76
|
+
try:
|
|
77
|
+
since = parse_since(args.since) if args.since else None
|
|
78
|
+
except ValueError:
|
|
79
|
+
print(
|
|
80
|
+
f"devin-search: --since {args.since!r} is not a date "
|
|
81
|
+
"(YYYY-MM-DD) or epoch ms",
|
|
82
|
+
file=sys.stderr,
|
|
83
|
+
)
|
|
84
|
+
return 2
|
|
85
|
+
try:
|
|
86
|
+
hits = search(
|
|
87
|
+
_resolve_index(args.index),
|
|
88
|
+
args.term,
|
|
89
|
+
role=args.role,
|
|
90
|
+
project=args.project,
|
|
91
|
+
since=since,
|
|
92
|
+
limit=args.limit,
|
|
93
|
+
)
|
|
94
|
+
except FileNotFoundError as exc:
|
|
95
|
+
print(f"devin-search: {exc}", file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
if not args.no_log:
|
|
98
|
+
from devin_search.misslog import record
|
|
99
|
+
record(_resolve_index(args.index), args.term, len(hits),
|
|
100
|
+
role=args.role, project=args.project, since=args.since)
|
|
101
|
+
if args.history_dir:
|
|
102
|
+
hits = attach_history_notes(hits, args.history_dir)
|
|
103
|
+
if args.json:
|
|
104
|
+
print(
|
|
105
|
+
json.dumps(
|
|
106
|
+
{"term": args.term, "hits": hits_to_dicts(hits)},
|
|
107
|
+
ensure_ascii=False,
|
|
108
|
+
indent=2,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
print(hits_table(hits))
|
|
113
|
+
return 0 if hits else 1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
117
|
+
p = argparse.ArgumentParser(
|
|
118
|
+
prog="devin-search",
|
|
119
|
+
description="Full-text search across all Devin sessions "
|
|
120
|
+
"(sessions.db + acp-messages → local FTS5 index).",
|
|
121
|
+
)
|
|
122
|
+
p.add_argument(
|
|
123
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
124
|
+
)
|
|
125
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
126
|
+
|
|
127
|
+
ix = sub.add_parser(
|
|
128
|
+
"index", help="build/update the local search index (incremental)"
|
|
129
|
+
)
|
|
130
|
+
ix.add_argument(
|
|
131
|
+
"--sessions-db",
|
|
132
|
+
help="path to Devin's sessions.db (default: auto-detect)",
|
|
133
|
+
)
|
|
134
|
+
ix.add_argument(
|
|
135
|
+
"--acp-dir",
|
|
136
|
+
help="path to Devin's User/acp-messages dir (default: auto-detect)",
|
|
137
|
+
)
|
|
138
|
+
ix.add_argument(
|
|
139
|
+
"--no-acp",
|
|
140
|
+
action="store_true",
|
|
141
|
+
help="index sessions.db only — never auto-detect the real "
|
|
142
|
+
"acp-messages dir (also the deterministic choice for tests)",
|
|
143
|
+
)
|
|
144
|
+
ix.add_argument("--index", help="index file path (default: app dir)")
|
|
145
|
+
ix.add_argument(
|
|
146
|
+
"--rebuild",
|
|
147
|
+
action="store_true",
|
|
148
|
+
help="drop and rebuild the index from scratch",
|
|
149
|
+
)
|
|
150
|
+
ix.add_argument(
|
|
151
|
+
"--json", action="store_true", help="machine-readable JSON output"
|
|
152
|
+
)
|
|
153
|
+
ix.set_defaults(func=_cmd_index)
|
|
154
|
+
|
|
155
|
+
q = sub.add_parser("query", help="search the index")
|
|
156
|
+
q.add_argument("term", help="free-text search (tokens ANDed)")
|
|
157
|
+
q.add_argument("--role", help="filter: user/assistant/tool/system/shell")
|
|
158
|
+
q.add_argument(
|
|
159
|
+
"--project", help="substring filter on the session working directory"
|
|
160
|
+
)
|
|
161
|
+
q.add_argument(
|
|
162
|
+
"--since", help="only hits at/after this date (YYYY-MM-DD or epoch ms)"
|
|
163
|
+
)
|
|
164
|
+
q.add_argument("--limit", type=int, default=20)
|
|
165
|
+
q.add_argument("--index", help="index file path (default: app dir)")
|
|
166
|
+
q.add_argument(
|
|
167
|
+
"--history-dir",
|
|
168
|
+
help="devin-history export dir — hits link to their "
|
|
169
|
+
"<YYYY-MM-DD>_<session-id>.md notes when present",
|
|
170
|
+
)
|
|
171
|
+
q.add_argument(
|
|
172
|
+
"--json", action="store_true", help="machine-readable JSON output"
|
|
173
|
+
)
|
|
174
|
+
q.add_argument(
|
|
175
|
+
"--no-log", action="store_true",
|
|
176
|
+
help="skip the local query log (<index>.queries.jsonl)",
|
|
177
|
+
)
|
|
178
|
+
q.set_defaults(func=_cmd_query)
|
|
179
|
+
|
|
180
|
+
m = sub.add_parser(
|
|
181
|
+
"misses",
|
|
182
|
+
help="zero-hit query stats from the local query log "
|
|
183
|
+
"(the objective trigger for semantic search)",
|
|
184
|
+
)
|
|
185
|
+
m.add_argument("--index", help="index file path (default: app dir)")
|
|
186
|
+
m.add_argument("--json", action="store_true")
|
|
187
|
+
m.set_defaults(func=_cmd_misses)
|
|
188
|
+
return p
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _cmd_misses(args: argparse.Namespace) -> int:
|
|
192
|
+
from devin_search.misslog import summarize
|
|
193
|
+
s = summarize(_resolve_index(args.index))
|
|
194
|
+
if args.json:
|
|
195
|
+
print(json.dumps(s, ensure_ascii=False, indent=2))
|
|
196
|
+
return 0
|
|
197
|
+
print(f"query log: {s['log']}")
|
|
198
|
+
print(f" {s['total_queries']} queries · "
|
|
199
|
+
f"{s['zero_hit']} zero-hit ({s['zero_hit_rate']:.0%})")
|
|
200
|
+
for month, b in sorted(s["per_month"].items()):
|
|
201
|
+
print(f" {month}: {b['queries']} queries, "
|
|
202
|
+
f"{b['zero_hit']} zero-hit")
|
|
203
|
+
if s["top_missed_terms"]:
|
|
204
|
+
print(" top missed terms:")
|
|
205
|
+
for term, n in s["top_missed_terms"]:
|
|
206
|
+
print(f" {n:>3}× {term}")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main(argv: list[str] | None = None) -> int:
|
|
211
|
+
for stream in (sys.stdout, sys.stderr):
|
|
212
|
+
try:
|
|
213
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
214
|
+
except (AttributeError, ValueError):
|
|
215
|
+
pass
|
|
216
|
+
args = build_parser().parse_args(argv)
|
|
217
|
+
return args.func(args)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
raise SystemExit(main())
|
devin_search/extract.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Tolerant extraction of searchable text and roles from Devin payloads.
|
|
2
|
+
|
|
3
|
+
SCHEMA.md (devin-internals-spec) marks every payload column as *unstable*:
|
|
4
|
+
``chat_message`` has been seen as ``{"role", "content"}`` and
|
|
5
|
+
``{"role", "text"}`` (with ``content`` sometimes a list of typed blocks),
|
|
6
|
+
``tool_call_*_json`` and ACP ``payload`` shapes are undocumented. Extraction
|
|
7
|
+
therefore never assumes a shape: known roles are normalized, text is pulled
|
|
8
|
+
from known keys first and from a recursive string walk as fallback, and
|
|
9
|
+
anything undecodable yields ``None`` rather than a crash.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
_ROLE_ALIASES = {
|
|
18
|
+
"user": "user",
|
|
19
|
+
"assistant": "assistant",
|
|
20
|
+
"agent": "assistant",
|
|
21
|
+
"thought": "assistant",
|
|
22
|
+
"tool": "tool",
|
|
23
|
+
"tool_call": "tool",
|
|
24
|
+
"tool_result": "tool",
|
|
25
|
+
"system": "system",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_role(raw: Any) -> str:
|
|
30
|
+
"""Map a raw role/kind string to a normalized role."""
|
|
31
|
+
if not isinstance(raw, str) or not raw:
|
|
32
|
+
return "unknown"
|
|
33
|
+
# acp kinds may be namespaced ("fixture.user"); take the last segment.
|
|
34
|
+
short = raw.rsplit(".", 1)[-1].lower()
|
|
35
|
+
return _ROLE_ALIASES.get(short, short)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _block_text(block: Any) -> str:
|
|
39
|
+
if isinstance(block, str):
|
|
40
|
+
return block
|
|
41
|
+
if isinstance(block, dict):
|
|
42
|
+
inner = block.get("content")
|
|
43
|
+
if isinstance(inner, dict):
|
|
44
|
+
inner = inner.get("text")
|
|
45
|
+
if isinstance(inner, str):
|
|
46
|
+
return inner
|
|
47
|
+
text = block.get("text")
|
|
48
|
+
if isinstance(text, str):
|
|
49
|
+
return text
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _walk_strings(value: Any, out: list[str], *, _depth: int = 0) -> None:
|
|
54
|
+
"""Collect string leaves from arbitrary JSON — the fallback path."""
|
|
55
|
+
if _depth > 8:
|
|
56
|
+
return
|
|
57
|
+
if isinstance(value, str):
|
|
58
|
+
if value.strip():
|
|
59
|
+
out.append(value)
|
|
60
|
+
elif isinstance(value, dict):
|
|
61
|
+
for v in value.values():
|
|
62
|
+
_walk_strings(v, out, _depth=_depth + 1)
|
|
63
|
+
elif isinstance(value, (list, tuple)):
|
|
64
|
+
for v in value:
|
|
65
|
+
_walk_strings(v, out, _depth=_depth + 1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def extract_text(d: dict[str, Any]) -> str:
|
|
69
|
+
"""Pull human-readable text out of a decoded payload dict."""
|
|
70
|
+
for key in ("content", "text"):
|
|
71
|
+
value = d.get(key)
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
return value
|
|
74
|
+
if isinstance(value, list):
|
|
75
|
+
parts = [_block_text(b) for b in value]
|
|
76
|
+
text = "\n".join(p for p in parts if p)
|
|
77
|
+
if text:
|
|
78
|
+
return text
|
|
79
|
+
parts: list[str] = []
|
|
80
|
+
_walk_strings(d, parts)
|
|
81
|
+
return "\n".join(parts)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def extract_chat_message(raw: str) -> tuple[str, str] | None:
|
|
85
|
+
"""``chat_message`` blob → ``(role, text)``; ``None`` when undecodable."""
|
|
86
|
+
try:
|
|
87
|
+
d = json.loads(raw)
|
|
88
|
+
except (json.JSONDecodeError, TypeError):
|
|
89
|
+
return None
|
|
90
|
+
if not isinstance(d, dict):
|
|
91
|
+
return None
|
|
92
|
+
return normalize_role(d.get("role")), extract_text(d)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def extract_payload(raw: str | None, kind: Any = None) -> tuple[str, str] | None:
|
|
96
|
+
"""Opaque JSON payload (tool calls, acp messages) → ``(role, text)``.
|
|
97
|
+
|
|
98
|
+
``kind`` supplies the role when the payload itself has none.
|
|
99
|
+
"""
|
|
100
|
+
if raw is None:
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
d = json.loads(raw)
|
|
104
|
+
except (json.JSONDecodeError, TypeError):
|
|
105
|
+
# Not JSON — still searchable as plain text.
|
|
106
|
+
return normalize_role(kind), raw
|
|
107
|
+
if isinstance(d, dict):
|
|
108
|
+
role = normalize_role(d.get("role") or d.get("kind") or kind)
|
|
109
|
+
return role, extract_text(d)
|
|
110
|
+
if isinstance(d, str):
|
|
111
|
+
return normalize_role(kind), d
|
|
112
|
+
return normalize_role(kind), json.dumps(d)
|
devin_search/fmt.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Pure emitters — terminal table and JSON shapes. No logic, no I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Iterable
|
|
8
|
+
|
|
9
|
+
from devin_search.index import IndexStats
|
|
10
|
+
from devin_search.query import Hit
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fmt_ts(ts_ms: int) -> str:
|
|
14
|
+
if not ts_ms:
|
|
15
|
+
return "-"
|
|
16
|
+
return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime(
|
|
17
|
+
"%Y-%m-%d %H:%M"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _flatten(text: str, width: int) -> str:
|
|
22
|
+
flat = re.sub(r"\s+", " ", text).strip()
|
|
23
|
+
if len(flat) <= width:
|
|
24
|
+
return flat
|
|
25
|
+
return flat[: width - 1] + "…"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _project_label(project: str) -> str:
|
|
29
|
+
tail = project.rstrip("/\\").rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
|
30
|
+
return tail or project or "-"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def hits_table(hits: Iterable[Hit]) -> str:
|
|
34
|
+
"""Fixed-width table: when · role · project · session · snippet."""
|
|
35
|
+
hits = list(hits)
|
|
36
|
+
if not hits:
|
|
37
|
+
return "no hits"
|
|
38
|
+
header = (
|
|
39
|
+
f"{'WHEN':<16} {'ROLE':<10} {'PROJECT':<16} {'SESSION':<10} SNIPPET"
|
|
40
|
+
)
|
|
41
|
+
lines = [header, "-" * len(header) + "-----"]
|
|
42
|
+
for h in hits:
|
|
43
|
+
lines.append(
|
|
44
|
+
f"{fmt_ts(h.ts):<16} {h.role:<10} {_project_label(h.project):<16}"
|
|
45
|
+
f" {h.session_id[:8]:<10} {_flatten(h.snippet, 60)}"
|
|
46
|
+
)
|
|
47
|
+
if h.history_note:
|
|
48
|
+
lines.append(
|
|
49
|
+
f"{'':<16} {'':<10} {'':<16} {'':<10} note: {h.history_note}"
|
|
50
|
+
)
|
|
51
|
+
return "\n".join(lines)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def hit_to_dict(h: Hit) -> dict:
|
|
55
|
+
return {
|
|
56
|
+
"session_id": h.session_id,
|
|
57
|
+
"role": h.role,
|
|
58
|
+
"ts": h.ts,
|
|
59
|
+
"project": h.project,
|
|
60
|
+
"session_title": h.session_title,
|
|
61
|
+
"source": h.source,
|
|
62
|
+
"ref": h.ref,
|
|
63
|
+
"snippet": h.snippet,
|
|
64
|
+
"rank": h.rank,
|
|
65
|
+
"history_note": h.history_note,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def hits_to_dicts(hits: Iterable[Hit]) -> list[dict]:
|
|
70
|
+
return [hit_to_dict(h) for h in hits]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def stats_to_dict(stats: IndexStats) -> dict:
|
|
74
|
+
return {
|
|
75
|
+
"index": str(stats.index_path),
|
|
76
|
+
"rebuilt": stats.rebuilt,
|
|
77
|
+
"indexed": stats.indexed,
|
|
78
|
+
"removed": stats.removed,
|
|
79
|
+
"sources": stats.sources,
|
|
80
|
+
"by_source": {
|
|
81
|
+
"message_nodes": stats.message_nodes,
|
|
82
|
+
"prompt_history": stats.prompt_history,
|
|
83
|
+
"tool_calls": stats.tool_calls,
|
|
84
|
+
"acp_messages": stats.acp_messages,
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def stats_line(stats: IndexStats) -> str:
|
|
90
|
+
verb = "rebuilt" if stats.rebuilt else "indexed"
|
|
91
|
+
return (
|
|
92
|
+
f"{verb}: +{stats.indexed} docs"
|
|
93
|
+
f" (nodes {stats.message_nodes} · prompts {stats.prompt_history}"
|
|
94
|
+
f" · tools {stats.tool_calls} · acp {stats.acp_messages})"
|
|
95
|
+
f" · removed {stats.removed} → {stats.index_path}"
|
|
96
|
+
)
|
devin_search/history.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Optional links from search hits to devin-history export notes.
|
|
2
|
+
|
|
3
|
+
``devin-history export`` writes one note per session named
|
|
4
|
+
``<YYYY-MM-DD>_<session-id>.md`` (or ``.json``) in its export directory.
|
|
5
|
+
The index does not store the session's ``created_at``, so matching is by
|
|
6
|
+
the ``_<session-id>.<ext>`` filename suffix. Everything here is
|
|
7
|
+
best-effort: a missing directory or missing note produces no link and no
|
|
8
|
+
error.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Iterable
|
|
16
|
+
|
|
17
|
+
from devin_search.query import Hit
|
|
18
|
+
|
|
19
|
+
# Preferred note formats first — an export dir may hold both.
|
|
20
|
+
_NOTE_SUFFIXES = (".md", ".json")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def history_note(history_dir: str | Path, session_id: str) -> Path | None:
|
|
24
|
+
"""The exported note for ``session_id`` in ``history_dir``, or ``None``."""
|
|
25
|
+
if not session_id:
|
|
26
|
+
return None
|
|
27
|
+
d = Path(history_dir).expanduser()
|
|
28
|
+
if not d.is_dir():
|
|
29
|
+
return None
|
|
30
|
+
names = sorted(p.name for p in d.iterdir() if p.is_file())
|
|
31
|
+
for ext in _NOTE_SUFFIXES:
|
|
32
|
+
for name in names:
|
|
33
|
+
if name.endswith(f"_{session_id}{ext}"):
|
|
34
|
+
return d / name
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def attach_history_notes(
|
|
39
|
+
hits: Iterable[Hit], history_dir: str | Path
|
|
40
|
+
) -> list[Hit]:
|
|
41
|
+
"""Return hits with ``history_note`` set when the note file exists."""
|
|
42
|
+
d = Path(history_dir).expanduser()
|
|
43
|
+
if not d.is_dir():
|
|
44
|
+
return list(hits)
|
|
45
|
+
cache: dict[str, Path | None] = {}
|
|
46
|
+
out: list[Hit] = []
|
|
47
|
+
for h in hits:
|
|
48
|
+
if h.session_id not in cache:
|
|
49
|
+
cache[h.session_id] = history_note(d, h.session_id)
|
|
50
|
+
note = cache[h.session_id]
|
|
51
|
+
out.append(h if note is None else replace(h, history_note=str(note)))
|
|
52
|
+
return out
|