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.
- chat_memory_sync/__init__.py +2 -0
- chat_memory_sync/__main__.py +5 -0
- chat_memory_sync/cli.py +62 -0
- chat_memory_sync/cmlib.py +76 -0
- chat_memory_sync/install.py +221 -0
- chat_memory_sync/recall.py +231 -0
- chat_memory_sync/run.py +92 -0
- chat_memory_sync/sync.py +938 -0
- chat_memory_sync-0.1.3.dist-info/METADATA +204 -0
- chat_memory_sync-0.1.3.dist-info/RECORD +13 -0
- chat_memory_sync-0.1.3.dist-info/WHEEL +4 -0
- chat_memory_sync-0.1.3.dist-info/entry_points.txt +3 -0
- chat_memory_sync-0.1.3.dist-info/licenses/LICENSE +21 -0
chat_memory_sync/cli.py
ADDED
|
@@ -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()
|