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 +9 -0
- agrep/__main__.py +37 -0
- agrep/_bin/agrep-rs.exe +0 -0
- agrep/cli.py +315 -0
- agrep/py/README.md +150 -0
- agrep/py/around.py +208 -0
- agrep/py/ask.py +265 -0
- agrep/py/common.py +502 -0
- agrep/py/concepts.py +311 -0
- agrep/py/corpusdb.py +316 -0
- agrep/py/doctor.py +228 -0
- agrep/py/embed.py +264 -0
- agrep/py/embed_summaries.py +105 -0
- agrep/py/emotion.py +273 -0
- agrep/py/explore.py +658 -0
- agrep/py/indexer.py +142 -0
- agrep/py/judge.py +128 -0
- agrep/py/label_concepts.py +191 -0
- agrep/py/live.py +902 -0
- agrep/py/native.py +228 -0
- agrep/py/rawfetch.py +190 -0
- agrep/py/report.py +141 -0
- agrep/py/resume.py +130 -0
- agrep/py/search.py +406 -0
- agrep/py/server.py +514 -0
- agrep/py/setupjobs.py +323 -0
- agrep/py/summarize.py +181 -0
- agrep/py/tail.py +89 -0
- agrep/py/titles.py +86 -0
- agrep/py/vibe.py +359 -0
- agrep/reindex.py +136 -0
- agrep/requirements.txt +20 -0
- agrep/web/app.html +7471 -0
- agrep/web/report.template.html +189 -0
- agrep-0.1.0.dist-info/METADATA +203 -0
- agrep-0.1.0.dist-info/RECORD +39 -0
- agrep-0.1.0.dist-info/WHEEL +4 -0
- agrep-0.1.0.dist-info/entry_points.txt +2 -0
- agrep-0.1.0.dist-info/licenses/LICENSE +21 -0
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())
|
agrep/_bin/agrep-rs.exe
ADDED
|
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`. |
|