chat-memory-sync 0.1.3__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,2 @@
1
+ """chat-memory-sync: unified, searchable memory bank of your AI coding chats."""
2
+ __version__ = "0.1.3"
@@ -0,0 +1,5 @@
1
+ import sys
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main() or 0)
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+ """Unified `chat-memory` CLI."""
3
+ import sys
4
+ from . import __version__
5
+
6
+ USAGE = """chat-memory - unified memory bank of your AI coding chats
7
+
8
+ usage: chat-memory <command> [options]
9
+
10
+ commands:
11
+ install set up config, data repo, daily background job, recall command
12
+ sync pull -> export all harnesses -> commit -> push (what the job runs)
13
+ export export only (no git)
14
+ recall search past chats (also available as the `recall` command)
15
+ doctor show config, detected harnesses, and chat counts
16
+ version print version
17
+
18
+ examples:
19
+ chat-memory install --data-remote git@github.com:you/chat-memory.git
20
+ chat-memory sync
21
+ chat-memory recall "auth token refresh"
22
+ recall "rate limiting" --all
23
+ """
24
+
25
+
26
+ def doctor():
27
+ import os, glob, shutil
28
+ from . import cmlib
29
+ print(f"chat-memory-sync {__version__}")
30
+ print(f"config file : {cmlib.CONFIG_PATH} ({'exists' if os.path.exists(cmlib.CONFIG_PATH) else 'missing'})")
31
+ print(f"data dir : {cmlib.data_dir()} ({'ok' if os.path.isdir(cmlib.data_dir()) else 'missing'})")
32
+ print(f"client : {cmlib.client_id()} platform={cmlib.PLATFORM}")
33
+ print(f"recall idx : {cmlib.index_db()} ({'built' if os.path.exists(cmlib.index_db()) else 'not built'})")
34
+ print(f"recall cmd : {shutil.which('recall') or 'not on PATH'}")
35
+ n = len(glob.glob(os.path.join(cmlib.projects_dir(), '*', '*', '*.md')))
36
+ print(f"chats stored: {n}")
37
+
38
+
39
+ def main():
40
+ argv = sys.argv[1:]
41
+ if not argv or argv[0] in ("-h", "--help", "help"):
42
+ print(USAGE); return 0
43
+ cmd, rest = argv[0], argv[1:]
44
+ if cmd in ("version", "--version", "-V"):
45
+ print(f"chat-memory-sync {__version__}"); return 0
46
+ if cmd == "install":
47
+ from . import install; return install.main(rest)
48
+ if cmd in ("sync", "run"):
49
+ from . import run; return run.main()
50
+ if cmd == "export":
51
+ from . import sync; return sync.main()
52
+ if cmd == "recall":
53
+ from . import recall; return recall.main(rest)
54
+ if cmd in ("doctor", "status"):
55
+ return doctor()
56
+ print(f"unknown command: {cmd}\n", file=sys.stderr)
57
+ print(USAGE, file=sys.stderr)
58
+ return 2
59
+
60
+
61
+ if __name__ == "__main__":
62
+ sys.exit(main() or 0)
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shared config/paths for chat-memory-sync.
4
+
5
+ Decouples the (public) tooling from the (private) data repo:
6
+
7
+ - data repo location: $CHAT_MEMORY_DATA_DIR > config.json "data_dir" > ~/chat-memory
8
+ - machine-local state: $CHAT_MEMORY_HOME > ~/.config/chat-memory
9
+ (holds config.json, index.db, client, sync.log -- never in the data repo)
10
+ - client name: $CHAT_MEMORY_CLIENT > config.json "client" > hostname
11
+ """
12
+ import os, json, re, platform, socket
13
+
14
+ HOME = os.path.expanduser("~")
15
+ CONFIG_DIR = os.path.expanduser(os.environ.get("CHAT_MEMORY_HOME") or os.path.join(HOME, ".config", "chat-memory"))
16
+ CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
17
+
18
+ PLATFORM = {"Darwin": "mac", "Windows": "pc", "Linux": "linux"}.get(platform.system(), platform.system().lower())
19
+
20
+
21
+ def load_config():
22
+ try:
23
+ return json.load(open(CONFIG_PATH))
24
+ except Exception:
25
+ return {}
26
+
27
+
28
+ def save_config(cfg):
29
+ os.makedirs(CONFIG_DIR, exist_ok=True)
30
+ json.dump(cfg, open(CONFIG_PATH, "w"), indent=2)
31
+
32
+
33
+ def state_dir():
34
+ os.makedirs(CONFIG_DIR, exist_ok=True)
35
+ return CONFIG_DIR
36
+
37
+
38
+ def data_dir():
39
+ v = os.environ.get("CHAT_MEMORY_DATA_DIR")
40
+ if v:
41
+ return os.path.abspath(os.path.expanduser(v))
42
+ c = load_config().get("data_dir")
43
+ if c:
44
+ return os.path.abspath(os.path.expanduser(c))
45
+ return os.path.join(HOME, "chat-memory")
46
+
47
+
48
+ def projects_dir():
49
+ return os.path.join(data_dir(), "projects")
50
+
51
+
52
+ def index_db():
53
+ return os.path.join(state_dir(), "index.db")
54
+
55
+
56
+ def _cslug(s):
57
+ s = re.sub(r"[^\w\-]", "-", (s or "").strip().lower())
58
+ s = re.sub(r"-+", "-", s).strip("-")
59
+ return s[:40] or "unknown-client"
60
+
61
+
62
+ def client_id():
63
+ v = os.environ.get("CHAT_MEMORY_CLIENT")
64
+ if v and v.strip():
65
+ return _cslug(v)
66
+ c = load_config().get("client")
67
+ if c:
68
+ return _cslug(c)
69
+ name = socket.gethostname().split(".")[0]
70
+ cfg = load_config()
71
+ cfg["client"] = name
72
+ try:
73
+ save_config(cfg)
74
+ except Exception:
75
+ pass
76
+ return _cslug(name)
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Installer: config + data repo + background job + recall command.
4
+
5
+ Invoked as `chat-memory install ...`. Registers a daily job that runs
6
+ `chat-memory sync` via the OS scheduler (launchd / Task Scheduler / systemd|cron).
7
+ """
8
+ import os, sys, subprocess, platform, argparse, getpass, shutil
9
+ from . import cmlib
10
+
11
+ PY = sys.executable or "python3"
12
+ LABEL = "com.user.chat-memory-sync"
13
+
14
+
15
+ def sh(cmd, **kw):
16
+ print("+", " ".join(cmd) if isinstance(cmd, list) else cmd)
17
+ return subprocess.run(cmd, **kw)
18
+
19
+
20
+ def sync_command():
21
+ """The command the scheduler should run to perform a sync."""
22
+ exe = shutil.which("chat-memory")
23
+ if exe:
24
+ return [exe, "sync"]
25
+ return [PY, "-m", "chat_memory_sync", "sync"]
26
+
27
+
28
+ def recall_command_str():
29
+ exe = shutil.which("recall")
30
+ if exe:
31
+ return None # already on PATH via the package entry point
32
+ return f'"{PY}" -m chat_memory_sync.recall'
33
+
34
+
35
+ # ----------------------------------------------------------- schedulers ------
36
+
37
+ def _mac_launcher(cmd):
38
+ """Compile a Mach-O named 'chat-memory-sync' so Login Items shows a friendly
39
+ name instead of the interpreter. Returns its path or None."""
40
+ cc = shutil.which("cc") or shutil.which("clang")
41
+ if not cc:
42
+ return None
43
+ bindir = os.path.expanduser("~/bin")
44
+ srcdir = os.path.join(bindir, ".src")
45
+ os.makedirs(srcdir, exist_ok=True)
46
+ src = os.path.join(srcdir, "chat-memory-sync.c")
47
+ out = os.path.join(bindir, "chat-memory-sync")
48
+ argv = ", ".join(['"chat-memory-sync"'] + [f'"{a}"' for a in cmd[1:]] + ["(char*)0"])
49
+ open(src, "w").write(
50
+ "#include <unistd.h>\n#include <sys/wait.h>\nint main(void){\n"
51
+ " pid_t p=fork();\n if(p==0){\n"
52
+ f" char *a[]={{{argv}}};\n"
53
+ f' execv("{cmd[0]}",a);\n _exit(127);\n }}\n'
54
+ " int s=0; waitpid(p,&s,0);\n return WIFEXITED(s)?WEXITSTATUS(s):1;\n}\n")
55
+ if subprocess.run([cc, "-O2", "-o", out, src], capture_output=True).returncode == 0:
56
+ return out
57
+ return None
58
+
59
+
60
+ def install_mac(hh, mm):
61
+ cmd = sync_command()
62
+ launcher = _mac_launcher(cmd)
63
+ if launcher:
64
+ prog = f"<array><string>{launcher}</string></array>"
65
+ else:
66
+ prog = "<array>" + "".join(f"<string>{a}</string>" for a in cmd) + "</array>"
67
+ state = cmlib.state_dir()
68
+ plist = f"""<?xml version="1.0" encoding="UTF-8"?>
69
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
70
+ <plist version="1.0">
71
+ <dict>
72
+ <key>Label</key><string>{LABEL}</string>
73
+ <key>ProgramArguments</key>
74
+ {prog}
75
+ <key>StartCalendarInterval</key>
76
+ <dict><key>Hour</key><integer>{hh}</integer><key>Minute</key><integer>{mm}</integer></dict>
77
+ <key>RunAtLoad</key><false/>
78
+ <key>StandardOutPath</key><string>{state}/launchd.out.log</string>
79
+ <key>StandardErrorPath</key><string>{state}/launchd.err.log</string>
80
+ </dict>
81
+ </plist>
82
+ """
83
+ dest = os.path.expanduser(f"~/Library/LaunchAgents/{LABEL}.plist")
84
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
85
+ open(dest, "w").write(plist)
86
+ uid = os.getuid()
87
+ subprocess.run(["launchctl", "bootout", f"gui/{uid}/{LABEL}"], capture_output=True)
88
+ subprocess.run(["launchctl", "load", dest])
89
+ print(f"launchd job installed -> {dest} (daily {hh:02d}:{mm:02d})")
90
+
91
+
92
+ def install_windows(hh, mm):
93
+ cmd = sync_command()
94
+ tr = " ".join(f'"{a}"' if " " in a else a for a in cmd)
95
+ sh(["schtasks", "/Create", "/F", "/TN", "ChatMemorySync",
96
+ "/SC", "DAILY", "/ST", f"{hh:02d}:{mm:02d}", "/TR", tr])
97
+ print(f"Task Scheduler job 'ChatMemorySync' installed (daily {hh:02d}:{mm:02d})")
98
+
99
+
100
+ def install_linux(hh, mm):
101
+ cmd = " ".join(sync_command())
102
+ unit_dir = os.path.expanduser("~/.config/systemd/user")
103
+ have_systemd = subprocess.run(["systemctl", "--user", "--version"],
104
+ capture_output=True).returncode == 0
105
+ if have_systemd:
106
+ os.makedirs(unit_dir, exist_ok=True)
107
+ open(os.path.join(unit_dir, "chat-memory-sync.service"), "w").write(
108
+ f"[Unit]\nDescription=chat-memory sync\n\n[Service]\nType=oneshot\n"
109
+ f"ExecStart={cmd}\n")
110
+ open(os.path.join(unit_dir, "chat-memory-sync.timer"), "w").write(
111
+ f"[Unit]\nDescription=daily chat-memory sync\n\n[Timer]\n"
112
+ f"OnCalendar=*-*-* {hh:02d}:{mm:02d}:00\nPersistent=true\n\n"
113
+ f"[Install]\nWantedBy=timers.target\n")
114
+ sh(["systemctl", "--user", "daemon-reload"])
115
+ sh(["systemctl", "--user", "enable", "--now", "chat-memory-sync.timer"])
116
+ print(f"systemd user timer installed (daily {hh:02d}:{mm:02d}). "
117
+ f"Tip: `loginctl enable-linger {getpass.getuser()}` to run while logged out.")
118
+ return
119
+ line = f"{mm} {hh} * * * {cmd} >> {cmlib.state_dir()}/cron.log 2>&1"
120
+ cur = subprocess.run(["crontab", "-l"], capture_output=True, text=True).stdout
121
+ lines = [l for l in cur.splitlines() if "chat-memory" not in l]
122
+ lines.append(line + " # chat-memory")
123
+ subprocess.run(["crontab", "-"], input="\n".join(lines) + "\n", text=True)
124
+ print(f"cron job installed (daily {hh:02d}:{mm:02d})")
125
+
126
+
127
+ def install_recall_launcher():
128
+ if shutil.which("recall"):
129
+ print("recall command already on PATH (package entry point)")
130
+ return
131
+ cmd = recall_command_str()
132
+ home = os.path.expanduser("~")
133
+ if platform.system() == "Windows":
134
+ bindir = os.path.join(home, "bin")
135
+ os.makedirs(bindir, exist_ok=True)
136
+ p = os.path.join(bindir, "recall.cmd")
137
+ open(p, "w").write(f'@echo off\r\n{cmd} %*\r\n')
138
+ print(f"recall command -> {p} (ensure {bindir} is on PATH)")
139
+ return
140
+ path_dirs = os.environ.get("PATH", "").split(":")
141
+ prefs = [os.path.join(home, ".local/bin"), os.path.join(home, "bin")]
142
+ bindir = next((d for d in prefs if d in path_dirs), os.path.join(home, "bin"))
143
+ os.makedirs(bindir, exist_ok=True)
144
+ p = os.path.join(bindir, "recall")
145
+ open(p, "w").write(f'#!/bin/bash\nexec {cmd} "$@"\n')
146
+ os.chmod(p, 0o755)
147
+ print(f"recall command -> {p}" + ("" if bindir in path_dirs else f" (add {bindir} to PATH)"))
148
+
149
+
150
+ # ----------------------------------------------------------- data repo -------
151
+
152
+ def setup_data_repo(data_dir, remote):
153
+ if os.path.isdir(os.path.join(data_dir, ".git")):
154
+ print(f"data repo already present: {data_dir}")
155
+ return
156
+ os.makedirs(os.path.dirname(os.path.abspath(data_dir)) or ".", exist_ok=True)
157
+ if remote:
158
+ if sh(["git", "clone", remote, data_dir]).returncode != 0:
159
+ print("clone failed; initializing empty repo instead")
160
+ _init_repo(data_dir, remote)
161
+ else:
162
+ _init_repo(data_dir, None)
163
+
164
+
165
+ def _init_repo(data_dir, remote):
166
+ os.makedirs(data_dir, exist_ok=True)
167
+ sh(["git", "init", "-q", data_dir])
168
+ if remote:
169
+ sh(["git", "-C", data_dir, "remote", "add", "origin", remote])
170
+
171
+
172
+ # ----------------------------------------------------------------- main ------
173
+
174
+ def main(argv=None):
175
+ ap = argparse.ArgumentParser(prog="chat-memory install")
176
+ ap.add_argument("--data-remote", help="git URL of your private data repo (recommended)")
177
+ ap.add_argument("--data-dir", help="where the data repo lives (default ~/chat-memory)")
178
+ ap.add_argument("--client", help="name for this machine (default: hostname)")
179
+ ap.add_argument("--time", default="21:00", help="daily run time HH:MM (default 21:00)")
180
+ ap.add_argument("--no-job", action="store_true", help="skip registering the background job")
181
+ args = ap.parse_args(argv)
182
+
183
+ cfg = cmlib.load_config()
184
+ data_dir = os.path.abspath(os.path.expanduser(
185
+ args.data_dir or cfg.get("data_dir") or os.path.join(cmlib.HOME, "chat-memory")))
186
+ cfg["data_dir"] = data_dir
187
+ if args.client:
188
+ cfg["client"] = args.client.strip()
189
+ cmlib.save_config(cfg)
190
+ print(f"config -> {cmlib.CONFIG_PATH}\n data_dir = {data_dir}\n client = {cmlib.client_id()}")
191
+
192
+ try:
193
+ hh, mm = [int(x) for x in args.time.split(":")]
194
+ except Exception:
195
+ print("bad --time, expected HH:MM"); sys.exit(1)
196
+
197
+ print("\n== data repo ==")
198
+ setup_data_repo(data_dir, args.data_remote)
199
+
200
+ print("\n== first run ==")
201
+ if subprocess.run(sync_command()).returncode != 0:
202
+ print("first run returned non-zero; check the log in", cmlib.state_dir())
203
+
204
+ if not args.no_job:
205
+ print("\n== background job ==")
206
+ s = platform.system()
207
+ (install_mac if s == "Darwin" else install_windows if s == "Windows" else install_linux)(hh, mm)
208
+
209
+ print("\n== recall command ==")
210
+ install_recall_launcher()
211
+
212
+ print("\nDone. The background job keeps this machine's chats synced daily.")
213
+ has_origin = subprocess.run(["git", "remote", "get-url", "origin"],
214
+ cwd=data_dir, capture_output=True).returncode == 0
215
+ if not has_origin:
216
+ print("NOTE: data repo has no 'origin' remote - chats commit locally but won't push.\n"
217
+ f" git -C {data_dir} remote add origin <url> && git -C {data_dir} push -u origin HEAD")
218
+
219
+
220
+ if __name__ == "__main__":
221
+ main()
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ recall - find relevant past chats without scanning the whole repo.
4
+
5
+ Builds a local SQLite full-text index (BM25) over all chat markdown and queries
6
+ it, auto-scoped to the current project. The index (.sync/index.db) is rebuilt
7
+ automatically when chats change; it is machine-local (gitignored).
8
+
9
+ Usage:
10
+ recall "affiliate tier rebate" # auto-scope to CWD's project
11
+ recall "worktree flow" --project based-one-mono
12
+ recall "nesting model" --all # search every project
13
+ recall "payout bug" --harness cursor --since 2026-09 -k 10
14
+ recall "..." --full # print file paths only (for piping)
15
+ recall --reindex # force rebuild the index
16
+
17
+ Agents: run this from inside the repo you're working on, read the top hits,
18
+ then open those files for full context.
19
+ """
20
+ import os, sys, re, glob, sqlite3, argparse, datetime
21
+ from . import cmlib
22
+
23
+ REPO = cmlib.data_dir()
24
+ PROJ_DIR = cmlib.projects_dir()
25
+ DB = cmlib.index_db()
26
+
27
+ WS_MARKERS = ["/ws/", "/pspace/", "/src/", "/code/", "/projects/", "/Developer/"]
28
+
29
+ def _slug(s, n=60):
30
+ s = re.sub(r"\s+", " ", (s or "").strip())
31
+ s = re.sub(r"[^\w\- ]", "", s).strip().replace(" ", "-")
32
+ return s[:n] or ""
33
+
34
+ def canonical_project(path):
35
+ if not path:
36
+ return ""
37
+ p = os.path.abspath(path).rstrip("/")
38
+ for m in WS_MARKERS:
39
+ i = p.find(m)
40
+ if i != -1:
41
+ rest = [x for x in p[i + len(m):].split("/") if x]
42
+ if not rest:
43
+ continue
44
+ if rest[0] == ".worktrees" and len(rest) > 1:
45
+ return _slug(rest[1])
46
+ return _slug(rest[0])
47
+ return _slug(os.path.basename(p))
48
+
49
+ def read_frontmatter(path):
50
+ fm = {}
51
+ try:
52
+ with open(path, encoding="utf-8", errors="ignore") as f:
53
+ if f.readline().strip() != "---":
54
+ return fm
55
+ for _ in range(40):
56
+ line = f.readline()
57
+ if not line or line.strip() == "---":
58
+ break
59
+ m = re.match(r"^(\w+):\s*(.*)$", line.rstrip("\n"))
60
+ if m:
61
+ k, v = m.group(1), m.group(2).strip().strip('"')
62
+ fm[k] = v
63
+ except Exception:
64
+ pass
65
+ return fm
66
+
67
+ def has_fts5():
68
+ try:
69
+ c = sqlite3.connect(":memory:")
70
+ c.execute("CREATE VIRTUAL TABLE t USING fts5(x)")
71
+ c.close()
72
+ return True
73
+ except Exception:
74
+ return False
75
+
76
+ def newest_md_mtime():
77
+ mt = 0.0
78
+ for p in glob.glob(os.path.join(PROJ_DIR, "*", "*", "*.md")):
79
+ try:
80
+ mt = max(mt, os.path.getmtime(p))
81
+ except OSError:
82
+ pass
83
+ return mt
84
+
85
+ def build_index():
86
+ """Contentless FTS5 index: we store only metadata + the inverted index, never
87
+ a copy of the chat text. Snippets are generated from the source files at query
88
+ time. Keeps the DB small (no duplicated transcripts)."""
89
+ if os.path.exists(DB):
90
+ os.remove(DB)
91
+ con = sqlite3.connect(DB)
92
+ con.execute("""CREATE TABLE d(
93
+ rowid INTEGER PRIMARY KEY, project TEXT, harness TEXT, client TEXT,
94
+ date TEXT, title TEXT, path TEXT)""")
95
+ con.execute("CREATE INDEX idx_proj ON d(project)")
96
+ fts = has_fts5()
97
+ if fts:
98
+ con.execute("CREATE VIRTUAL TABLE fts USING fts5(body, content='')")
99
+ rid = 0
100
+ for p in glob.glob(os.path.join(PROJ_DIR, "*", "*", "*.md")):
101
+ fm = read_frontmatter(p)
102
+ proj = os.path.basename(os.path.dirname(os.path.dirname(p)))
103
+ try:
104
+ body = open(p, encoding="utf-8", errors="ignore").read()
105
+ except Exception:
106
+ continue
107
+ rid += 1
108
+ con.execute("INSERT INTO d VALUES(?,?,?,?,?,?,?)",
109
+ (rid, proj, fm.get("harness", ""), fm.get("client", ""),
110
+ (fm.get("updated") or fm.get("created") or "")[:10],
111
+ fm.get("title", ""), os.path.relpath(p, REPO)))
112
+ if fts:
113
+ con.execute("INSERT INTO fts(rowid, body) VALUES(?,?)", (rid, fm.get("title", "") + "\n" + body))
114
+ con.commit()
115
+ con.execute("PRAGMA user_version=2")
116
+ con.commit()
117
+ con.close()
118
+ return rid
119
+
120
+ def ensure_index(force=False):
121
+ if force or not os.path.exists(DB) or os.path.getmtime(DB) < newest_md_mtime():
122
+ n = build_index()
123
+ return n
124
+ return None
125
+
126
+ def make_snippet(path, query, width=180):
127
+ """Pull a short context window around the first query term from the file."""
128
+ terms = [t for t in re.split(r"\W+", query.lower()) if len(t) > 1]
129
+ try:
130
+ text = open(os.path.join(REPO, path), encoding="utf-8", errors="ignore").read()
131
+ except Exception:
132
+ return ""
133
+ # start searching after the YAML frontmatter so snippets show real content
134
+ body_start = 0
135
+ if text.startswith("---"):
136
+ end = text.find("\n---", 3)
137
+ if end != -1:
138
+ body_start = end + 4
139
+ low = text.lower()
140
+ hits = [low.find(t, body_start) for t in terms if low.find(t, body_start) != -1]
141
+ pos = min(hits) if hits else body_start
142
+ s = max(body_start, pos - width // 3)
143
+ seg = re.sub(r"\s+", " ", text[s:s + width]).strip()
144
+ for t in terms:
145
+ seg = re.sub("(" + re.escape(t) + ")", r">>\1<<", seg, flags=re.I)
146
+ return seg
147
+
148
+ def search(query, project=None, harness=None, since=None, k=8):
149
+ con = sqlite3.connect(DB)
150
+ con.row_factory = sqlite3.Row
151
+ fts = has_fts5()
152
+ where, params = [], []
153
+ if project:
154
+ where.append("d.project = ?"); params.append(project)
155
+ if harness:
156
+ where.append("d.harness = ?"); params.append(harness)
157
+ if since:
158
+ where.append("d.date >= ?"); params.append(since)
159
+ filt = (" AND " + " AND ".join(where)) if where else ""
160
+ rows = []
161
+ if fts:
162
+ try:
163
+ sql = (f"SELECT d.*, bm25(fts) AS score FROM fts JOIN d ON d.rowid = fts.rowid "
164
+ f"WHERE fts MATCH ?{filt} ORDER BY score LIMIT ?")
165
+ rows = con.execute(sql, [query] + params + [k]).fetchall()
166
+ except sqlite3.OperationalError:
167
+ rows = []
168
+ if not rows: # fallback: read files and score by term frequency
169
+ terms = [t for t in re.split(r"\s+", query.lower()) if t]
170
+ cand = con.execute(f"SELECT d.* FROM d WHERE 1=1{filt}", params).fetchall()
171
+ scored = []
172
+ for r in cand:
173
+ try:
174
+ b = open(os.path.join(REPO, r["path"]), encoding="utf-8", errors="ignore").read().lower()
175
+ except Exception:
176
+ continue
177
+ sc = sum(b.count(t) for t in terms)
178
+ if sc:
179
+ scored.append((sc, r))
180
+ scored.sort(key=lambda x: -x[0])
181
+ rows = [r for _, r in scored[:k]]
182
+ con.close()
183
+ return [(r, make_snippet(r["path"], query)) for r in rows]
184
+
185
+ def main(argv=None):
186
+ ap = argparse.ArgumentParser(prog="recall", description="find relevant past chats")
187
+ ap.add_argument("query", nargs="?", default="")
188
+ ap.add_argument("--project", help="project to scope to (default: infer from CWD)")
189
+ ap.add_argument("--all", action="store_true", help="search all projects")
190
+ ap.add_argument("--harness", help="cursor|cursor-agent|claude-code|opencode|pi|codex|gemini")
191
+ ap.add_argument("--since", help="YYYY-MM or YYYY-MM-DD lower bound")
192
+ ap.add_argument("-k", type=int, default=8, help="max results (default 8)")
193
+ ap.add_argument("--full", action="store_true", help="print file paths only")
194
+ ap.add_argument("--reindex", action="store_true", help="force rebuild index")
195
+ args = ap.parse_args(argv)
196
+
197
+ n = ensure_index(force=args.reindex)
198
+ if args.reindex or n is not None:
199
+ print(f"[indexed {n if n is not None else '?'} chats]", file=sys.stderr)
200
+ if not args.query:
201
+ if args.reindex:
202
+ return
203
+ ap.error("provide a search query")
204
+
205
+ project = None if args.all else (args.project or canonical_project(os.getcwd()) or None)
206
+ if project and not args.all:
207
+ # only scope if that project actually exists in the repo
208
+ if not os.path.isdir(os.path.join(PROJ_DIR, project)):
209
+ print(f"[no chats for project '{project}'; searching all]", file=sys.stderr)
210
+ project = None
211
+
212
+ rows = search(args.query, project=project, harness=args.harness,
213
+ since=args.since, k=args.k)
214
+ if not rows:
215
+ print("No matches.", file=sys.stderr)
216
+ return
217
+ if args.full:
218
+ for r, _ in rows:
219
+ print(os.path.join(REPO, r["path"]))
220
+ return
221
+ scope = f"project={project}" if project else "all projects"
222
+ print(f"Top {len(rows)} for \"{args.query}\" ({scope}):\n")
223
+ for i, (r, snip) in enumerate(rows, 1):
224
+ print(f"{i}. {r['date']} [{r['harness']}/{r['client']}] {r['title']}")
225
+ print(f" {r['path']}")
226
+ if snip:
227
+ print(f" … {snip} …")
228
+ print()
229
+
230
+ if __name__ == "__main__":
231
+ main()