agrep 0.1.0__py3-none-win_amd64.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.
agrep/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """agrep — grep your cross-agent chat history, one command.
2
+
3
+ This package is a thin wrapper that ships the existing flat agrep codebase (cli.py,
4
+ reindex.py, py/, web/) plus the prebuilt rust ingest binary, so `uvx agrep` /
5
+ `pipx run agrep` / `pip install agrep` give you the explorer with no clone and no
6
+ cargo. The real logic lives in the bundled modules; see __main__.py.
7
+ """
8
+
9
+ __version__ = "0.1.0"
agrep/__main__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Console entry for `agrep` (and `python -m agrep`).
2
+
3
+ The wheel installs the flat tilt tree as package data under this package:
4
+
5
+ site-packages/agrep/
6
+ cli.py reindex.py py/*.py web/app.html _bin/agrep-rs[.exe]
7
+
8
+ We point the code at the bundled rust binary (AGREP_RS_BIN), put the bundled dirs on
9
+ sys.path so the existing flat imports (`import common`, `import cli`) resolve, then
10
+ hand off to cli.py. Bare `agrep` prints status + usage and exits (the explorer
11
+ is `agrep ui`), same as `python cli.py`. Data and the smart-tier venv go to a per-user
12
+ dir (see common.py), since site-packages is read-only.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ PKG = Path(__file__).resolve().parent
22
+
23
+
24
+ def main() -> int:
25
+ exe = "agrep-rs.exe" if sys.platform == "win32" else "agrep-rs"
26
+ bundled = PKG / "_bin" / exe
27
+ if bundled.exists():
28
+ os.environ.setdefault("AGREP_RS_BIN", str(bundled))
29
+ # py/ first so flat `import common` wins; PKG so `import cli` finds cli.py
30
+ sys.path.insert(0, str(PKG / "py"))
31
+ sys.path.insert(0, str(PKG))
32
+ import cli # noqa: PLC0415 -- bundled module, resolvable only after the path setup
33
+ return cli.main()
34
+
35
+
36
+ if __name__ == "__main__":
37
+ raise SystemExit(main())
Binary file
agrep/cli.py ADDED
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env python
2
+ """agrep — grep and explore your cross-agent chat history.
3
+
4
+ agrep "race condition" grep your whole agent history; print matches (the namesake)
5
+ agrep around <id> <turn> show the conversation around a search hit, tools inline
6
+ agrep resume <id> jump back into a past session in its agent, cd'd there
7
+ agrep ui the explorer (tilt): index, serve, and open the app
8
+ agrep doctor check what's installed and what each tier needs
9
+ agrep index just (re)build the index from your agent stores
10
+ agrep reindex full pipeline: index + embeddings + affect + topics + arcs
11
+ agrep serve just run the server (it auto-indexes in the background)
12
+ agrep tail follow live agent events as JSON lines
13
+
14
+ A bare first argument that isn't a command is treated as a search, so `agrep deadlock`
15
+ greps. Bare `agrep` prints status + usage (it never starts a server). In a dev checkout
16
+ the same commands run as `python cli.py <cmd>`. Run `agrep <command> --help` for a
17
+ command's own options.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import socket
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ import webbrowser
29
+ from pathlib import Path
30
+
31
+ ROOT = Path(__file__).resolve().parent
32
+ WIN = sys.platform == "win32"
33
+ sys.path.insert(0, str(ROOT / "py"))
34
+ import common # noqa: E402 -- single source for binary / venv / data paths
35
+
36
+ INGEST_BIN = common.ingest_bin()
37
+
38
+
39
+ def _version() -> str:
40
+ """Single-sourced from agrep/__init__.py (pyproject reads the same file).
41
+ Resolves both installed (site-packages/agrep) and dev (repo root/agrep)."""
42
+ try:
43
+ from agrep import __version__
44
+ return __version__
45
+ except ImportError:
46
+ return "dev"
47
+
48
+
49
+ def _server_python() -> str:
50
+ """The python the SERVER runs under. Semantic search needs the smart tier's
51
+ deps (numpy/torch/...), which live in the venv — launching the server with
52
+ whatever python invoked us silently downgrades every 'meaning' search
53
+ to keyword when that python lacks them (it logs one line and carries on).
54
+ Prefer the venv whenever it exists; plain interpreters still run the core."""
55
+ return common.venv_python()
56
+
57
+
58
+ def _ensure_binary() -> bool:
59
+ if INGEST_BIN.exists():
60
+ return True
61
+ import shutil
62
+ if not shutil.which("cargo"):
63
+ print(" ! no ingest binary and no cargo on PATH.")
64
+ print(f" install Rust (https://rustup.rs) and re-run, or `{common.cli_name()} serve` "
65
+ "to view an existing index.")
66
+ return False
67
+ print("=== first run: building the ingest binary (cargo build --release) ===", flush=True)
68
+ return subprocess.run(["cargo", "build", "--release"], cwd=str(ROOT)).returncode == 0 \
69
+ and INGEST_BIN.exists()
70
+
71
+
72
+ def _index() -> bool:
73
+ # the ingest invocation + derived-db refresh live in common.build_index() (shared
74
+ # with the auto-index-on-first-search path); here we just wrap it with progress.
75
+ print("=== indexing transcripts ===", flush=True)
76
+ t = time.perf_counter()
77
+ ok = common.build_index()
78
+ if ok:
79
+ print(f" ({time.perf_counter() - t:.1f}s)", flush=True)
80
+ return ok
81
+
82
+
83
+ def _wait_for(port: int, timeout: float = 30.0) -> bool:
84
+ t0 = time.time()
85
+ while time.time() - t0 < timeout:
86
+ try:
87
+ with socket.create_connection(("127.0.0.1", port), timeout=0.5):
88
+ return True
89
+ except OSError:
90
+ time.sleep(0.25)
91
+ return False
92
+
93
+
94
+ # --- status (bare `agrep`) ------------------------------------------------
95
+
96
+ def _fmt_age(seconds: float) -> str:
97
+ """Compact human age: '3s', '12m', '5h', '2d' ago. Coarse on purpose — the
98
+ status line wants a glance, not a stopwatch."""
99
+ s = int(max(0, seconds))
100
+ if s < 60:
101
+ return f"{s}s"
102
+ if s < 3600:
103
+ return f"{s // 60}m"
104
+ if s < 86400:
105
+ return f"{s // 3600}h"
106
+ return f"{s // 86400}d"
107
+
108
+
109
+ def _status_lines(cli: str) -> list[str]:
110
+ """Cheap index summary for the bare-`agrep` banner. Reads sessions.jsonl (one
111
+ line per chat, with the per-chat message count `n` and `agent`) — never parses
112
+ messages.jsonl, which is ~50 MB. mtime of messages.jsonl dates the last index;
113
+ corpus.db's presence + size says whether keyword search is ready; the smart-tier
114
+ venv's existence says whether meaning search is installed."""
115
+ sessions = common.DATA_DIR / "sessions.jsonl"
116
+ out: list[str] = []
117
+ if not sessions.exists():
118
+ out.append(f" no index yet — any search will build it on first run, "
119
+ f"or run `{cli} index`.")
120
+ return out
121
+
122
+ n_sessions = n_messages = 0
123
+ agents: set[str] = set()
124
+ with sessions.open(encoding="utf-8") as f:
125
+ for line in f:
126
+ line = line.strip()
127
+ if not line:
128
+ continue
129
+ try:
130
+ o = json.loads(line)
131
+ except json.JSONDecodeError:
132
+ continue
133
+ n_sessions += 1
134
+ n_messages += int(o.get("n", 0))
135
+ a = o.get("agent")
136
+ if a:
137
+ agents.add(a)
138
+
139
+ ag = ", ".join(sorted(agents)) if agents else "—"
140
+ out.append(f" {n_messages:,} messages · {n_sessions:,} sessions · "
141
+ f"{len(agents)} agent{'s' if len(agents) != 1 else ''} ({ag})")
142
+
143
+ msgs = common.MESSAGES_PATH
144
+ if msgs.exists():
145
+ out.append(f" last indexed {_fmt_age(time.time() - msgs.stat().st_mtime)} ago")
146
+
147
+ db = common.DATA_DIR / "corpus.db"
148
+ ready = db.exists() and db.stat().st_size > 0
149
+ out.append(f" search index: {'ready' if ready else 'missing (builds on first search)'}")
150
+
151
+ smart = "installed" if Path(common.venv_python()) != Path(sys.executable) else \
152
+ f"not installed (keyword only; `{cli} doctor` to add meaning search)"
153
+ out.append(f" smart tier: {smart}")
154
+ return out
155
+
156
+
157
+ def cmd_status(a) -> int:
158
+ """Bare `agrep`: print where the index stands and how to drive the tool, then exit.
159
+ Deliberately does NOT start a server — the explorer is `agrep ui`."""
160
+ # the banner carries em-dashes / middots; windows consoles default to cp1252.
161
+ for stream in (sys.stdout, sys.stderr):
162
+ try:
163
+ stream.reconfigure(encoding="utf-8", errors="replace")
164
+ except Exception: # noqa: BLE001 -- not a reconfigurable stream (piped oddly)
165
+ pass
166
+ cli = common.cli_name() # `python cli.py` in a dev checkout, `agrep` once installed
167
+ print("agrep — grep and explore your cross-agent chat history\n")
168
+ for line in _status_lines(cli):
169
+ print(line)
170
+ print("\ntry:")
171
+ print(f' {cli} "race condition" grep every agent for a phrase')
172
+ print(f" {cli} deadlock --agent codex filter to one agent")
173
+ print(f" {cli} -E 'TODO|FIXME' regex search")
174
+ print(f" {cli} -l auth which chats mention it")
175
+ print(f" {cli} around <id> <turn> the conversation around a hit")
176
+ print(f" {cli} resume <id> reopen a past session in its agent")
177
+ print(f" {cli} ui the explorer: index, serve, open the app")
178
+ print(f"\n{cli} <command> -h for a command's own options.")
179
+ return 0
180
+
181
+
182
+ # --- subcommands ----------------------------------------------------------
183
+
184
+ def cmd_up(a) -> int:
185
+ if not a.no_index and _ensure_binary():
186
+ _index()
187
+ url = f"http://127.0.0.1:{a.port}"
188
+ print(f"=== serving {url} ===", flush=True)
189
+ srv = subprocess.Popen([_server_python(), str(ROOT / "py" / "server.py"),
190
+ "--port", str(a.port)], cwd=str(ROOT))
191
+ try:
192
+ if not _wait_for(a.port):
193
+ print(" ! server didn't come up within 30s; see the output above.")
194
+ srv.terminate()
195
+ return 1
196
+ if not a.no_open:
197
+ webbrowser.open(url)
198
+ print(" ctrl-c stops the server.", flush=True)
199
+ return srv.wait()
200
+ except KeyboardInterrupt:
201
+ srv.terminate()
202
+ return 0
203
+
204
+
205
+ def cmd_index(a) -> int:
206
+ if not _ensure_binary():
207
+ return 1
208
+ return 0 if _index() else 1
209
+
210
+
211
+ def cmd_reindex(a) -> int:
212
+ return subprocess.run([sys.executable, str(ROOT / "reindex.py"), *a.rest],
213
+ cwd=str(ROOT)).returncode
214
+
215
+
216
+ def cmd_serve(a) -> int:
217
+ return subprocess.run([_server_python(), str(ROOT / "py" / "server.py"), *a.rest],
218
+ cwd=str(ROOT)).returncode
219
+
220
+
221
+ def cmd_doctor(a) -> int:
222
+ return subprocess.run([sys.executable, str(ROOT / "py" / "doctor.py"), *a.rest],
223
+ cwd=str(ROOT)).returncode
224
+
225
+
226
+ def cmd_tail(a) -> int:
227
+ return subprocess.run([sys.executable, str(ROOT / "py" / "tail.py"), *a.rest],
228
+ cwd=str(ROOT)).returncode
229
+
230
+
231
+ def cmd_search(a) -> int:
232
+ # in-process (stdlib-only, like resume): spawning a second interpreter doubled
233
+ # the cold-start cost of the single hottest command. --semantic just queries a
234
+ # running server, so no torch in this process either way.
235
+ import search
236
+ return search.main(a.rest)
237
+
238
+
239
+ def cmd_around(a) -> int:
240
+ # core-tier like search: stdlib over the materialized index, runs in-process.
241
+ import around
242
+ return around.main(a.rest)
243
+
244
+
245
+ def cmd_resume(a) -> int:
246
+ # imported and called in-process (not a subprocess) so the resumed agent is a direct
247
+ # child of this process and cleanly inherits the terminal.
248
+ import resume
249
+ return resume.main(a.rest)
250
+
251
+
252
+ def main() -> int:
253
+ p = argparse.ArgumentParser(
254
+ prog="agrep", description="grep and explore your cross-agent chat history")
255
+ p.add_argument("-V", "--version", action="version", version=f"agrep {_version()}")
256
+ sub = p.add_subparsers(dest="cmd")
257
+
258
+ # `ui` is the explorer (tilt): index + serve + open. `up` is a kept-working alias
259
+ # (it lives in scripts / muscle memory) but only `ui` is advertised — a subparser
260
+ # added without help= is omitted from the command listing, so `up` stays hidden.
261
+ for name in ("ui", "up"):
262
+ kw = {"help": "index, serve, and open the explorer (tilt)"} if name == "ui" else {}
263
+ u = sub.add_parser(name, **kw)
264
+ u.add_argument("--port", type=int, default=8732)
265
+ u.add_argument("--no-open", action="store_true", help="don't open the browser")
266
+ u.add_argument("--no-index", action="store_true", help="serve the existing index as-is")
267
+ u.set_defaults(fn=cmd_up)
268
+
269
+ di = sub.add_parser("doctor", help="check installed tiers; --fix does safe setup")
270
+ di.set_defaults(fn=cmd_doctor)
271
+
272
+ ix = sub.add_parser("index", help="rebuild the base index from your agent stores")
273
+ ix.set_defaults(fn=cmd_index)
274
+
275
+ rx = sub.add_parser("reindex", help="full pipeline (embeddings/affect/topics/arcs)")
276
+ rx.set_defaults(fn=cmd_reindex)
277
+
278
+ sv = sub.add_parser("serve", help="run the server only (auto-indexes in background)")
279
+ sv.set_defaults(fn=cmd_serve)
280
+
281
+ ta = sub.add_parser("tail", help="follow live agent events as JSON lines (turn ends by default)")
282
+ ta.set_defaults(fn=cmd_tail)
283
+
284
+ se = sub.add_parser("search", help="grep your chat history (keyword; --semantic for meaning)")
285
+ se.set_defaults(fn=cmd_search)
286
+
287
+ ar = sub.add_parser("around", help="show the conversation around one turn of a chat")
288
+ ar.set_defaults(fn=cmd_around)
289
+
290
+ rs = sub.add_parser("resume", help="resume a past session in its own agent, cd'd there")
291
+ rs.set_defaults(fn=cmd_resume)
292
+
293
+ # The agrep promise: a bare pattern greps. If the first arg isn't a known verb
294
+ # (and isn't a global flag), treat the whole invocation as a search — so
295
+ # `agrep "rust simd"` works, while `agrep ui` / `agrep serve --port N` still
296
+ # dispatch. `agrep` alone prints status + usage; `agrep -h` shows top-level help.
297
+ raw = sys.argv[1:]
298
+ verbs = set(sub.choices)
299
+ if raw and raw[0] not in verbs and raw[0] not in ("-h", "--help", "-V", "--version"):
300
+ return cmd_search(argparse.Namespace(rest=raw))
301
+
302
+ # parse_known_args instead of REMAINDER positionals: REMAINDER errors on
303
+ # leading optionals (`agrep serve --port N` never reached the server), and
304
+ # mixing it with parse_known_args scrambles token order. Unknown args pass
305
+ # through to the subcommand verbatim.
306
+ args, unknown = p.parse_known_args()
307
+ args.rest = unknown
308
+ if not getattr(args, "fn", None):
309
+ # bare `agrep` prints status + usage and exits — the explorer is `agrep ui`.
310
+ return cmd_status(args)
311
+ return args.fn(args)
312
+
313
+
314
+ if __name__ == "__main__":
315
+ raise SystemExit(main())
agrep/py/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # tilt — Python semantic sidecar
2
+
3
+ This directory is the Python side of tilt: it turns the developer-chat messages
4
+ that `tilt scan` produces into (a) semantic embeddings for similarity search and
5
+ (b) a graded affect read per message. The Rust core (`crates/agrep-core`) reads
6
+ the files written here.
7
+
8
+ A venv is expected at `py/.venv` (see the repo-root `requirements.txt`). Use its
9
+ interpreter for the ML stages:
10
+
11
+ ```
12
+ # Windows
13
+ py/.venv/Scripts/python embed.py
14
+ # macOS / Linux
15
+ py/.venv/bin/python embed.py
16
+ ```
17
+
18
+ Requirements: `transformers>=4.51.0` (needed for Qwen3), `sentence-transformers`,
19
+ `torch`, `numpy`, `scikit-learn`. A CUDA GPU speeds embeddings/affect up; everything
20
+ also runs on CPU (slower), and the code auto-detects which. None of this is needed for
21
+ the core explorer — see the repo-root README's tier table.
22
+
23
+ ---
24
+
25
+ ## The data contract (Python writes, Rust reads — must agree exactly)
26
+
27
+ | File | Format |
28
+ |---|---|
29
+ | `data/messages.jsonl` | one JSON per line: `{id, agent, project, session, ts, turn, text}`. `id == "agent:session:turn"`. Produced by `tilt scan`. **Input** to every script here. |
30
+ | `data/embeddings.f32` | raw little-endian `f32`, row-major, `N` rows × `D` cols. **Each row is L2-normalized.** `D = 256` (Matryoshka truncation of the model's native dim, then renormalized). |
31
+ | `data/embeddings.ids` | UTF-8, one message id per line. Row `r` of `embeddings.f32` ↔ line `r` here. **The ids file is the authority for row order** — it need not match `messages.jsonl` order. |
32
+ | `data/query.f32` | a single `D=256`-dim L2-normalized `f32` vector (the current search query). |
33
+ | `data/emotions.jsonl` | one JSON per line: `{id, rage_raw, hype_raw, top:[...], routed_to_judge}`. |
34
+
35
+ Because every stored row is L2-normalized, **cosine similarity == dot product** —
36
+ exactly what the Rust AVX2 brute-force kernel computes.
37
+
38
+ ---
39
+
40
+ ## How to run
41
+
42
+ ### 1. Embed all messages
43
+
44
+ ```
45
+ .venv/Scripts/python.exe embed.py
46
+ ```
47
+
48
+ Reads `data/messages.jsonl`, embeds each message as a **passage**, truncates to
49
+ 256-d, L2-normalizes, and writes `data/embeddings.f32` + `data/embeddings.ids`.
50
+ Prints model used, device, count, dim, elapsed, and approx VRAM (to stderr).
51
+
52
+ Quick check without embedding everything:
53
+
54
+ ```
55
+ .venv/Scripts/python.exe embed.py --smoke 8
56
+ ```
57
+
58
+ ### 2. Embed a search query
59
+
60
+ ```
61
+ .venv/Scripts/python.exe embed_query.py "why is the build still broken"
62
+ ```
63
+
64
+ Applies the correct **query-side** prefix for whichever model is configured,
65
+ truncates to 256-d, normalizes, and writes `data/query.f32`. The Rust side then
66
+ dots `query.f32` against `embeddings.f32` to rank messages.
67
+
68
+ ### 3. Affect gate
69
+
70
+ ```
71
+ .venv/Scripts/python.exe emotion.py
72
+ .venv/Scripts/python.exe emotion.py --smoke 16
73
+ .venv/Scripts/python.exe emotion.py --profanity-ids data/profanity.ids
74
+ ```
75
+
76
+ Reads `data/messages.jsonl`, runs a GoEmotions classifier, and writes
77
+ `data/emotions.jsonl` with per-message `rage_raw`, `hype_raw`, top-3 labels, and
78
+ a `routed_to_judge` flag.
79
+
80
+ Typical query flow end to end:
81
+
82
+ ```
83
+ tilt scan # (Rust) writes data/messages.jsonl
84
+ .venv/Scripts/python.exe embed.py # writes embeddings.f32 + .ids
85
+ .venv/Scripts/python.exe embed_query.py "…" # writes query.f32
86
+ tilt search … # (Rust) AVX2 dot-product over the matrix
87
+ ```
88
+
89
+ `embed.py` and `emotion.py` are independent — run them in either order.
90
+
91
+ ---
92
+
93
+ ## resolve-or-fallback model loading
94
+
95
+ Each script tries a **primary** model and, on any load failure (404, gated repo,
96
+ out-of-memory, load error), falls through to the next candidate, logging which
97
+ id was used. So a missing or gated Qwen3 repo degrades gracefully to a smaller
98
+ permissive model rather than crashing.
99
+
100
+ | Script | Primary | Fallbacks |
101
+ |---|---|---|
102
+ | `embed.py` / `embed_query.py` | `Qwen/Qwen3-Embedding-0.6B` (1024-d) | `BAAI/bge-base-en-v1.5` (768-d), `sentence-transformers/all-MiniLM-L6-v2` (384-d) |
103
+ | `emotion.py` | `cirimus/modernbert-base-go-emotions` | `SamLowe/roberta-base-go_emotions` |
104
+
105
+ **Model-specific embedding handling** (branched on which candidate loaded):
106
+
107
+ - **Qwen3-Embedding** — last-token pooling, **left padding**, and an asymmetric
108
+ instruction: **passages get NO prefix**; **queries** get
109
+ `Instruct: Retrieve developer-chat messages relevant to the query\nQuery: {q}`.
110
+ - **BGE** — mean pooling; passages no prefix; queries get
111
+ `Represent this sentence for searching relevant passages: {q}`.
112
+ - **MiniLM** — mean pooling; no prefix on either side.
113
+
114
+ `embed_query.py` re-runs the same resolve list so the query is embedded by the
115
+ same model that produced the matrix, and applies the matching query prefix.
116
+
117
+ > Whichever model `embed.py` uses, `embed_query.py` must use the same one —
118
+ > cosine is only meaningful within a single embedding space. If you pin or
119
+ > change the candidate list, change it in `embed.py` (the single source of
120
+ > truth `embed_query.py` imports from).
121
+
122
+ ---
123
+
124
+ ## Affect gate details (`emotion.py`)
125
+
126
+ Multi-label GoEmotions (sigmoid per label, **graded** — not argmax). Per message:
127
+
128
+ - `rage_raw = anger + annoyance + disapproval + disgust + disappointment`
129
+ - `hype_raw = excitement + admiration + joy + approval + amusement`
130
+ - `top` = the 3 highest-scoring emotion labels
131
+ - `routed_to_judge = profanity-present AND ambiguous`, where *ambiguous* means
132
+ rage and hype are within a small margin of each other, or both are weak.
133
+ Profanity comes from `--profanity-ids` (an upstream set) when provided, else
134
+ is recomputed from a small built-in lexicon.
135
+
136
+ **The LLM judge is a separate, later stage.** Messages flagged
137
+ `routed_to_judge=true` are intended to be handed to an LLM (Qwen3.5-4B) for a
138
+ final affect verdict. That judge is **not** implemented here — `emotion.py` only
139
+ scores and routes.
140
+
141
+ ---
142
+
143
+ ## File map
144
+
145
+ | File | Role |
146
+ |---|---|
147
+ | `common.py` | message loading; embeddings/ids/query writers (little-endian f32, L2-normalized rows); Matryoshka truncation; resolve-or-fallback model loader; device/VRAM helpers. |
148
+ | `embed.py` | embed every message (passages) → `embeddings.f32` + `.ids`. `--smoke N`. |
149
+ | `embed_query.py` | embed one query string (query prefix) → `query.f32`. |
150
+ | `emotion.py` | affect gate → `emotions.jsonl`. `--smoke N`, `--profanity-ids`. |