msgsearch 0.1.0__tar.gz

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.
Files changed (52) hide show
  1. msgsearch-0.1.0/.claude/agents/corpus-explorer.md +22 -0
  2. msgsearch-0.1.0/.claude/agents/retrieval-critic.md +30 -0
  3. msgsearch-0.1.0/.claude/commands/bench.md +12 -0
  4. msgsearch-0.1.0/.claude/commands/go.md +17 -0
  5. msgsearch-0.1.0/.claude/commands/recon.md +8 -0
  6. msgsearch-0.1.0/.claude/hooks/verify-retrieval.py +114 -0
  7. msgsearch-0.1.0/.claude/settings.json +52 -0
  8. msgsearch-0.1.0/.github/workflows/ci.yml +68 -0
  9. msgsearch-0.1.0/.github/workflows/release.yml +62 -0
  10. msgsearch-0.1.0/.gitignore +27 -0
  11. msgsearch-0.1.0/AGENTS.md +66 -0
  12. msgsearch-0.1.0/ARCHITECTURE.md +249 -0
  13. msgsearch-0.1.0/CHANGELOG.md +38 -0
  14. msgsearch-0.1.0/CLAUDE.md +1 -0
  15. msgsearch-0.1.0/CONTRIBUTING.md +100 -0
  16. msgsearch-0.1.0/LICENSE +21 -0
  17. msgsearch-0.1.0/PKG-INFO +303 -0
  18. msgsearch-0.1.0/README.md +257 -0
  19. msgsearch-0.1.0/eval/__init__.py +0 -0
  20. msgsearch-0.1.0/eval/bench.py +160 -0
  21. msgsearch-0.1.0/eval/gold.jsonl +9 -0
  22. msgsearch-0.1.0/eval/label.py +156 -0
  23. msgsearch-0.1.0/eval/results/baseline.json +29 -0
  24. msgsearch-0.1.0/eval/results/retriever.json +23 -0
  25. msgsearch-0.1.0/eval/results/retriever_bm25.json +29 -0
  26. msgsearch-0.1.0/eval/results/retriever_dense.json +29 -0
  27. msgsearch-0.1.0/eval/results/retriever_norerank.json +29 -0
  28. msgsearch-0.1.0/eval/retriever.py +94 -0
  29. msgsearch-0.1.0/eval/retriever_bm25.py +7 -0
  30. msgsearch-0.1.0/eval/retriever_dense.py +7 -0
  31. msgsearch-0.1.0/eval/retriever_norerank.py +7 -0
  32. msgsearch-0.1.0/pyproject.toml +81 -0
  33. msgsearch-0.1.0/src/msgsearch/__init__.py +18 -0
  34. msgsearch-0.1.0/src/msgsearch/attributed_body.py +59 -0
  35. msgsearch-0.1.0/src/msgsearch/chunk.py +268 -0
  36. msgsearch-0.1.0/src/msgsearch/cli.py +304 -0
  37. msgsearch-0.1.0/src/msgsearch/config.py +107 -0
  38. msgsearch-0.1.0/src/msgsearch/contacts.py +281 -0
  39. msgsearch-0.1.0/src/msgsearch/doctor.py +245 -0
  40. msgsearch-0.1.0/src/msgsearch/embedder.py +126 -0
  41. msgsearch-0.1.0/src/msgsearch/explore.py +157 -0
  42. msgsearch-0.1.0/src/msgsearch/extract.py +246 -0
  43. msgsearch-0.1.0/src/msgsearch/index.py +298 -0
  44. msgsearch-0.1.0/src/msgsearch/py.typed +0 -0
  45. msgsearch-0.1.0/src/msgsearch/search.py +447 -0
  46. msgsearch-0.1.0/src/msgsearch/tagging.py +123 -0
  47. msgsearch-0.1.0/tests/test_chunk.py +150 -0
  48. msgsearch-0.1.0/tests/test_cli.py +77 -0
  49. msgsearch-0.1.0/tests/test_contacts.py +178 -0
  50. msgsearch-0.1.0/tests/test_index.py +106 -0
  51. msgsearch-0.1.0/tests/test_search.py +39 -0
  52. msgsearch-0.1.0/tests/test_tagging.py +85 -0
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: corpus-explorer
3
+ description: Answers structural questions about chat.db (schema, joins, Apple quirks) without exposing message content.
4
+ tools: Bash, Read, Grep
5
+ ---
6
+
7
+ You answer questions about the *shape* of the iMessage corpus — schema, joins,
8
+ distributions, encoding quirks — never its content.
9
+
10
+ Rules:
11
+ - Open only `~/msgsearch/chat.db` and only read-only (`file:...?mode=ro`).
12
+ `~/Library/Messages` is off limits; refuse if asked.
13
+ - Report aggregates, counts, and schema. Never print message bodies. If an example
14
+ is genuinely necessary to explain an encoding quirk, show a hexdump of the
15
+ structural bytes, not the decoded text.
16
+ - Known quirks to account for, and to check before trusting any query:
17
+ `message.date` mixes second- and nanosecond-scale Apple-epoch values; ~86% of
18
+ rows carry text in `attributedBody` rather than `text`;
19
+ `associated_message_type != 0` rows are tapbacks, not messages;
20
+ some messages have no `chat_message_join` row at all.
21
+
22
+ Return findings as a short table plus the SQL you ran, so it can be re-verified.
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: retrieval-critic
3
+ description: Adversarially audits a retrieval change. Use after any change to ranking, chunking, embedding, or fusion — before claiming it works.
4
+ tools: Bash, Read, Grep
5
+ ---
6
+
7
+ You are a skeptical IR engineer. Your job is to find the reason the reported
8
+ improvement is fake. Assume it is until proven otherwise.
9
+
10
+ Check, in order:
11
+
12
+ 1. **Is it measured at all?** Run `python3 eval/bench.py`. Queries with an empty
13
+ `relevant` list are scored as nothing. If the labeled set is under ~20 queries,
14
+ say plainly that the result is noise, not signal.
15
+ 2. **Test-set contamination.** Was `eval/gold.jsonl` edited in the same change as
16
+ the retriever? Check `git diff`. Labeling the gold set to match new output is
17
+ the most common way to fake a win here.
18
+ 3. **Metric selection.** Did the report cite only the metric that moved? Show all
19
+ four (p@10, r@50, MRR, nDCG@10). A gain in p@10 with a drop in r@50 usually
20
+ means the change narrowed recall rather than improving ranking.
21
+ 4. **Degenerate wins.** Does the retriever return tapbacks, duplicates, or the same
22
+ message under multiple ids? Does it work on short queries and one-word queries?
23
+ Try a query with no plausible answer and confirm it returns few or no results
24
+ rather than confident garbage.
25
+ 5. **Privacy.** Does the diff introduce anything that prints message bodies to
26
+ stdout in committed code, or sends text off the machine? This is a hard stop.
27
+
28
+ Report only defects you actually confirmed by running something. For each, give the
29
+ command you ran and its output. If the change is genuinely sound, say so in one line
30
+ — do not invent findings to seem useful.
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: Run the retrieval eval and report the before/after metrics table
3
+ ---
4
+ Run the verification loop for this project:
5
+
6
+ 1. `python3 eval/bench.py --against baseline`
7
+ 2. If `index.sqlite` is missing, rebuild it first with `python3 search/index.py`.
8
+ 3. Report the metrics table verbatim. Do NOT summarize it away or round the numbers.
9
+ 4. If any metric regressed, say so plainly and explain which change caused it.
10
+ 5. If there are unlabeled gold queries, list them — they measure nothing until labeled.
11
+
12
+ A retrieval change that does not move these numbers is not an improvement.
@@ -0,0 +1,17 @@
1
+ ---
2
+ description: Verify, simplify, and commit the current change
3
+ ---
4
+ Finish the current change end to end:
5
+
6
+ 1. **Verify** — run `python3 eval/bench.py --against baseline` and paste the table.
7
+ For non-retrieval changes, run the affected script directly and show real output.
8
+ Never claim something works without having run it.
9
+ 2. **Simplify** — reread the diff. Remove dead code, collapse needless abstraction,
10
+ and check it matches the conventions in AGENTS.md.
11
+ 3. **Record** — if you got anything wrong during this task, append the correction to
12
+ the "Learned corrections" section of AGENTS.md. This is not optional; it is how
13
+ the project gets easier over time.
14
+ 4. **Commit** — stage and commit with a message describing the *why*. Do not push
15
+ unless asked.
16
+
17
+ Confirm no message bodies appear in the diff before committing.
@@ -0,0 +1,8 @@
1
+ ---
2
+ description: Re-run structural recon on the message corpus
3
+ ---
4
+ Run `python3 explore.py` and interpret the output: message volume, decode coverage,
5
+ date range, top chats. Flag anything that changed since the numbers recorded in
6
+ AGENTS.md, and update AGENTS.md if the corpus shape has shifted.
7
+
8
+ Never print message bodies.
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env python3
2
+ """Stop hook: refuse to finish a retrieval change that made retrieval worse.
3
+
4
+ Must be launched with .venv/bin/python (settings.json does this). Under the
5
+ system python3, which is an x86_64 build, child processes inherit that
6
+ architecture and the arm64 numpy in the venv fails to load -- the bench then
7
+ exits non-zero and the check silently passes.
8
+
9
+ AGENTS.md asks an agent to justify retrieval changes with eval/bench.py. This
10
+ turns that request into a wall, because a request is something a model can
11
+ forget and a hook is not.
12
+
13
+ Fires only when a file that can change ranking was touched, and only when the
14
+ gold set actually has labels -- there is nothing to measure otherwise.
15
+
16
+ Exit 0 allows the stop. Exit 2 blocks it and sends stderr back to the agent.
17
+ """
18
+
19
+ import contextlib
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import re
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+
28
+ ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
29
+ PY_BIN = os.path.join(ROOT, ".venv", "bin", "python")
30
+ BASELINE = os.path.join(ROOT, "eval", "results", "baseline.json")
31
+ GOLD = os.path.join(ROOT, "eval", "gold.jsonl")
32
+
33
+ # Anything in the engine package can move a ranking, as can the eval adapter.
34
+ # Matched as path prefixes so this survives files being added or renamed -- an
35
+ # earlier version listed modules by bare name and broke the moment the project
36
+ # was restructured into src/.
37
+ WATCHED_PREFIXES = ("src/msgsearch/", "eval/retriever")
38
+
39
+ # A drop smaller than this is noise at the current gold-set size, not a regression.
40
+ TOLERANCE = 0.02
41
+
42
+
43
+ def sh(*args):
44
+ return subprocess.run(args, cwd=ROOT, capture_output=True, text=True).stdout
45
+
46
+
47
+ def touched():
48
+ diff = sh("git", "diff", "HEAD", "--name-only") + sh("git", "diff", "--name-only")
49
+ files = {line.strip() for line in diff.splitlines() if line.strip()}
50
+ return sorted(f for f in files if f.startswith(WATCHED_PREFIXES))
51
+
52
+
53
+ def labeled_queries():
54
+ if not os.path.exists(GOLD):
55
+ return 0
56
+ with open(GOLD) as f:
57
+ return sum(1 for line in f if line.strip() and json.loads(line).get("relevant"))
58
+
59
+
60
+ def already_blocked_for(state):
61
+ """Block at most once per code state, so a change the agent cannot fix
62
+ does not trap it in a loop."""
63
+ marker = os.path.join(tempfile.gettempdir(), f"msgsearch-hook-{state}")
64
+ if os.path.exists(marker):
65
+ return True
66
+ open(marker, "w").close()
67
+ return False
68
+
69
+
70
+ def main():
71
+ with contextlib.suppress(Exception):
72
+ json.load(sys.stdin)
73
+
74
+ files = touched()
75
+ if not files or not os.path.exists(PY_BIN):
76
+ return 0
77
+ if labeled_queries() < 3 or not os.path.exists(BASELINE):
78
+ return 0
79
+
80
+ proc = subprocess.run(
81
+ [PY_BIN, "eval/bench.py", "-r", "eval.retriever", "--against", "baseline"],
82
+ cwd=ROOT,
83
+ capture_output=True,
84
+ text=True,
85
+ timeout=600,
86
+ )
87
+ out = proc.stdout
88
+ match = re.search(r"^mrr\s+([\d.]+)", out, re.M)
89
+ if not match:
90
+ return 0
91
+
92
+ mrr = float(match.group(1))
93
+ base = json.load(open(BASELINE))["metrics"]["mrr"]
94
+ if mrr >= base - TOLERANCE:
95
+ return 0
96
+
97
+ state = hashlib.sha1((sh("git", "diff", "HEAD") + f"{mrr}").encode()).hexdigest()[:12]
98
+ if already_blocked_for(state):
99
+ return 0
100
+
101
+ print(
102
+ f"Retrieval regression: MRR {base:.4f} -> {mrr:.4f} "
103
+ f"({mrr - base:+.4f}) after editing {', '.join(files)}.\n\n"
104
+ f"{out.strip()}\n\n"
105
+ "Either fix it, or revert the change. If the drop is intended and "
106
+ "justified, say so explicitly and re-snapshot with "
107
+ "`eval/bench.py -r eval.retriever --save baseline`.",
108
+ file=sys.stderr,
109
+ )
110
+ return 2
111
+
112
+
113
+ if __name__ == "__main__":
114
+ sys.exit(main())
@@ -0,0 +1,52 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(./.venv/bin/python:*)",
5
+ "Bash(git add:*)",
6
+ "Bash(git checkout:*)",
7
+ "Bash(git commit:*)",
8
+ "Bash(git diff:*)",
9
+ "Bash(git log:*)",
10
+ "Bash(git status:*)",
11
+ "Bash(python3 -c:*)",
12
+ "Bash(python3 eval/bench.py:*)",
13
+ "Bash(python3 explore.py)",
14
+ "Bash(python3 search/index.py:*)",
15
+ "Edit(./AGENTS.md)",
16
+ "Edit(./eval/**)",
17
+ "Edit(./search/**)",
18
+ "Read(./**)"
19
+ ],
20
+ "deny": [
21
+ "Read(~/Library/Messages/**)",
22
+ "Bash(sqlite3 ~/Library/Messages/**)",
23
+ "Write(~/msgsearch/**)"
24
+ ]
25
+ },
26
+ "hooks": {
27
+ "PostToolUse": [
28
+ {
29
+ "matcher": "Write|Edit",
30
+ "hooks": [
31
+ {
32
+ "type": "command",
33
+ "command": "python3 -c \"import ast,sys,glob; [ast.parse(open(f).read(),f) for f in glob.glob('**/*.py',recursive=True) if '.venv' not in f]\" || true"
34
+ }
35
+ ]
36
+ }
37
+ ],
38
+ "Stop": [
39
+ {
40
+ "matcher": "*",
41
+ "hooks": [
42
+ {
43
+ "type": "command",
44
+ "command": "${CLAUDE_PROJECT_DIR}/.venv/bin/python ${CLAUDE_PROJECT_DIR}/.claude/hooks/verify-retrieval.py",
45
+ "timeout": 600,
46
+ "statusMessage": "checking retrieval didn't regress"
47
+ }
48
+ ]
49
+ }
50
+ ]
51
+ }
52
+ }
@@ -0,0 +1,68 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ concurrency:
10
+ group: ${{ github.workflow }}-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ test:
15
+ name: py${{ matrix.python-version }} on ${{ matrix.os }}
16
+ runs-on: ${{ matrix.os }}
17
+ strategy:
18
+ fail-fast: false
19
+ matrix:
20
+ os: [ubuntu-latest]
21
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
22
+ include:
23
+ - os: macos-latest
24
+ python-version: "3.12"
25
+
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+
29
+ - uses: actions/setup-python@v5
30
+ with:
31
+ python-version: ${{ matrix.python-version }}
32
+ cache: pip
33
+
34
+ # PyTorch is ~1GB and nothing under test needs it: the parsing, chunking
35
+ # and tagging logic is pure Python, and the modules that do use models
36
+ # import them lazily. Installing numpy alone keeps CI to a few seconds
37
+ # while still checking that every module imports.
38
+ - name: Install
39
+ run: |
40
+ python -m pip install --upgrade pip
41
+ python -m pip install "numpy>=2,<3" "ruff==0.15.4"
42
+ python -m pip install -e . --no-deps
43
+
44
+ - name: Lint
45
+ run: ruff check .
46
+
47
+ - name: Format
48
+ run: ruff format --check .
49
+
50
+ - name: Unit tests
51
+ run: python -m unittest discover -s tests -v
52
+
53
+ - name: Every module imports
54
+ run: |
55
+ python -c "
56
+ import msgsearch, msgsearch.cli, msgsearch.config, msgsearch.chunk
57
+ import msgsearch.contacts
58
+ import msgsearch.embedder, msgsearch.explore, msgsearch.extract
59
+ import msgsearch.doctor, msgsearch.index, msgsearch.search, msgsearch.tagging
60
+ print('msgsearch', msgsearch.__version__)
61
+ "
62
+
63
+ - name: CLI responds
64
+ run: |
65
+ msgsearch --version
66
+ msgsearch --help
67
+ msgsearch search --help
68
+ msgsearch doctor --help
@@ -0,0 +1,62 @@
1
+ name: Release
2
+
3
+ # Publishing is driven by tags, so the released artefact always corresponds to a
4
+ # specific commit that CI has already tested.
5
+ on:
6
+ push:
7
+ tags: ["v*"]
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ build:
12
+ name: Build distributions
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.12"
20
+
21
+ - name: Build sdist and wheel
22
+ run: |
23
+ python -m pip install --upgrade pip build
24
+ python -m build
25
+
26
+ # A tag that disagrees with pyproject.toml would publish a version nobody
27
+ # asked for, and PyPI versions cannot be reused once taken.
28
+ - name: Tag must match the package version
29
+ if: startsWith(github.ref, 'refs/tags/v')
30
+ run: |
31
+ tag="${GITHUB_REF_NAME#v}"
32
+ version=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
33
+ echo "tag=$tag pyproject=$version"
34
+ test "$tag" = "$version"
35
+
36
+ - uses: actions/upload-artifact@v4
37
+ with:
38
+ name: distributions
39
+ path: dist/
40
+
41
+ publish:
42
+ name: Publish to PyPI
43
+ needs: build
44
+ runs-on: ubuntu-latest
45
+ if: startsWith(github.ref, 'refs/tags/v')
46
+
47
+ # Trusted Publishing: PyPI verifies this workflow's OIDC identity, so there
48
+ # is no API token to store, leak or rotate. Configure the publisher once at
49
+ # https://pypi.org/manage/project/msgsearch/settings/publishing/
50
+ environment:
51
+ name: pypi
52
+ url: https://pypi.org/p/msgsearch
53
+ permissions:
54
+ id-token: write
55
+
56
+ steps:
57
+ - uses: actions/download-artifact@v4
58
+ with:
59
+ name: distributions
60
+ path: dist/
61
+
62
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,27 @@
1
+ # Message data and anything derived from it must never be committed. The index is
2
+ # a plaintext, searchable copy of every private thing anyone has ever texted you.
3
+ *.db
4
+ *.db-shm
5
+ *.db-wal
6
+ *.sqlite
7
+ *.sqlite3
8
+ *.npy
9
+ *.npz
10
+ index/
11
+ msgsearch_index/
12
+
13
+ # Local configuration holds phone numbers and email addresses.
14
+ config_local.py
15
+
16
+ # Build artefacts
17
+ dist/
18
+ build/
19
+ *.egg-info/
20
+
21
+ # Environments and build noise
22
+ .venv/
23
+ venv/
24
+ __pycache__/
25
+ *.py[cod]
26
+ .pytest_cache/
27
+ .DS_Store
@@ -0,0 +1,66 @@
1
+ # query_messages
2
+
3
+ Semantic search over ~260k personal iMessages (2018-08 → 2026-09), by meaning
4
+ rather than keyword.
5
+
6
+ Source of truth for all coding agents; `CLAUDE.md` symlinks here.
7
+
8
+ ## Rules
9
+
10
+ Kept deliberately short. A rule earns a line here only if breaking it fails
11
+ *silently* or *irreversibly* — anything that fails loudly teaches itself, and
12
+ anything already enforced in code or `settings.json` is not repeated here.
13
+
14
+ - **Never** touch `~/Library/Messages`. Work against `~/msgsearch/chat.db`,
15
+ opened `file:...?mode=ro`. (Irreversible.)
16
+ - Message content is private: none sent over the network, none in commits, logs or
17
+ eval fixtures, and no bodies printed by diagnostic or eval scripts. `search.py`
18
+ is the single exemption — printing results to the terminal is its entire
19
+ function. (Irreversible.)
20
+ - `message.date` is Apple-epoch and **mixes scales** — pre-10.13 rows are
21
+ seconds, later ones nanoseconds. Use `extract.apple_timestamp()`; `explore.py`
22
+ carries a standalone copy so it can run before anything else exists. (Silent:
23
+ the wrong scale yields plausible, wrong dates.)
24
+ - ~86% of rows have `text IS NULL` with content in the `attributedBody`
25
+ typedstream blob. Go through `attributed_body.message_text(text, blob)`.
26
+ (Silent: reading `message.text` looks fine and loses most of the corpus.)
27
+
28
+ ## Commands
29
+
30
+ Use `./.venv/bin/python` — it is the arm64 interpreter. The system `python3` is an
31
+ Intel build and cannot install torch.
32
+
33
+ ```bash
34
+ ./.venv/bin/msgsearch doctor # setup preflight
35
+ ./.venv/bin/msgsearch contacts --unresolved # unnamed handles by volume
36
+ ./.venv/bin/msgsearch explore # structural recon
37
+ ./.venv/bin/msgsearch index # rebuild ~/msgsearch/index/
38
+ ./.venv/bin/msgsearch search "atria login" # query it
39
+
40
+ ./.venv/bin/ruff check . && ./.venv/bin/ruff format --check .
41
+ ./.venv/bin/python -m unittest discover -s tests
42
+
43
+ ./.venv/bin/python eval/label.py "a query" # add a labeled gold query
44
+ ./.venv/bin/python eval/bench.py -r eval.retriever --against baseline
45
+ ```
46
+
47
+ Code lives in `src/msgsearch/`; install it with `pip install -e ".[dev]"` once.
48
+
49
+ Retriever variants for `-r`, to score one stage at a time:
50
+ `eval.retriever` (full) · `eval.retriever_bm25` · `eval.retriever_dense` ·
51
+ `eval.retriever_norerank`
52
+
53
+ ## The verification loop
54
+
55
+ A retrieval change is justified by `eval/bench.py` or it is a guess. Report the
56
+ before/after table; don't claim an improvement without it.
57
+
58
+ Architecture: `ARCHITECTURE.md`.
59
+
60
+ ## Learned corrections
61
+
62
+ Append here **only** when an agent actually gets something wrong in practice.
63
+ Nothing speculative. If a rule can instead be enforced in code, a hook, or
64
+ `settings.json`, do that and leave this file alone.
65
+
66
+ _(empty — nothing has gone wrong yet)_