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.
@@ -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())
@@ -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
+ )
@@ -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