chat-memory-sync 0.1.4__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: chat-memory-sync
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: Unified, searchable memory bank of your AI coding chats across Cursor, Claude Code, OpenCode, Codex, Gemini CLI, Pi and more — synced across machines.
5
5
  Project-URL: Homepage, https://github.com/insideaayush/chat-memory-sync
6
6
  Project-URL: Repository, https://github.com/insideaayush/chat-memory-sync
@@ -89,6 +89,7 @@ chat-memory install # config + data repo + daily job + recall
89
89
  chat-memory sync # pull -> export -> commit -> push (what the job runs)
90
90
  chat-memory export # export only (no git)
91
91
  chat-memory recall … # search past chats (also the standalone `recall` command)
92
+ chat-memory serve # browse & search your chats in a local web UI
92
93
  chat-memory doctor # config, detected harnesses, chat counts
93
94
  ```
94
95
 
@@ -111,7 +112,7 @@ Releases are automated (PyPI + Homebrew) — see [RELEASING.md](RELEASING.md).
111
112
  cd ~/code/my-project
112
113
  recall "auth token refresh" # ranked (BM25) hits for THIS repo, with snippets
113
114
  recall "rate limiting" --all # every project
114
- recall "payout bug" --harness cursor --since 2026-09 -k 10
115
+ recall "flaky integration test" --harness cursor --since 2026-09 -k 10
115
116
  recall "..." --full # just file paths (pipe-friendly)
116
117
  ```
117
118
 
@@ -123,6 +124,17 @@ index.db`), never synced.
123
124
  hits, then open those `.md` files for full context. Add a line to your project's
124
125
  `AGENTS.md`/`CLAUDE.md` so assistants do this automatically.
125
126
 
127
+ ## Browse in a UI
128
+
129
+ ```bash
130
+ chat-memory serve # opens http://127.0.0.1:8787
131
+ chat-memory serve --port 9000 --no-open
132
+ ```
133
+
134
+ A lightweight local web UI (pure stdlib, no deps, binds to localhost only): a sidebar
135
+ of projects, instant BM25 search across everything, and a reader that renders each
136
+ chat. Backed by the same index as `recall`.
137
+
126
138
  ## Multi-machine
127
139
 
128
140
  Every machine runs the same job independently. Each run:
@@ -68,6 +68,7 @@ chat-memory install # config + data repo + daily job + recall
68
68
  chat-memory sync # pull -> export -> commit -> push (what the job runs)
69
69
  chat-memory export # export only (no git)
70
70
  chat-memory recall … # search past chats (also the standalone `recall` command)
71
+ chat-memory serve # browse & search your chats in a local web UI
71
72
  chat-memory doctor # config, detected harnesses, chat counts
72
73
  ```
73
74
 
@@ -90,7 +91,7 @@ Releases are automated (PyPI + Homebrew) — see [RELEASING.md](RELEASING.md).
90
91
  cd ~/code/my-project
91
92
  recall "auth token refresh" # ranked (BM25) hits for THIS repo, with snippets
92
93
  recall "rate limiting" --all # every project
93
- recall "payout bug" --harness cursor --since 2026-09 -k 10
94
+ recall "flaky integration test" --harness cursor --since 2026-09 -k 10
94
95
  recall "..." --full # just file paths (pipe-friendly)
95
96
  ```
96
97
 
@@ -102,6 +103,17 @@ index.db`), never synced.
102
103
  hits, then open those `.md` files for full context. Add a line to your project's
103
104
  `AGENTS.md`/`CLAUDE.md` so assistants do this automatically.
104
105
 
106
+ ## Browse in a UI
107
+
108
+ ```bash
109
+ chat-memory serve # opens http://127.0.0.1:8787
110
+ chat-memory serve --port 9000 --no-open
111
+ ```
112
+
113
+ A lightweight local web UI (pure stdlib, no deps, binds to localhost only): a sidebar
114
+ of projects, instant BM25 search across everything, and a reader that renders each
115
+ chat. Backed by the same index as `recall`.
116
+
105
117
  ## Multi-machine
106
118
 
107
119
  Every machine runs the same job independently. Each run:
@@ -15,7 +15,7 @@ assistant in Claude Code can read what you discussed in Cursor, Codex, Gemini, e
15
15
  ```bash
16
16
  recall "auth token refresh" # ranked hits for the CURRENT project (by CWD)
17
17
  recall "rate limiting" --all # across every project
18
- recall "payout bug" --harness cursor --since 2026-09 -k 10
18
+ recall "flaky integration test" --harness cursor --since 2026-09 -k 10
19
19
  recall "oauth flow" --full # just file paths (easy to pipe / open)
20
20
  ```
21
21
 
@@ -1,2 +1,2 @@
1
1
  """chat-memory-sync: unified, searchable memory bank of your AI coding chats."""
2
- __version__ = "0.1.4"
2
+ __version__ = "0.2.0"
@@ -12,6 +12,7 @@ commands:
12
12
  sync pull -> export all harnesses -> commit -> push (what the job runs)
13
13
  export export only (no git)
14
14
  recall search past chats (also available as the `recall` command)
15
+ serve browse & search your chats in a local web UI
15
16
  doctor show config, detected harnesses, and chat counts
16
17
  version print version
17
18
 
@@ -19,6 +20,7 @@ examples:
19
20
  chat-memory install --data-remote git@github.com:you/chat-memory.git
20
21
  chat-memory sync
21
22
  chat-memory recall "auth token refresh"
23
+ chat-memory serve
22
24
  recall "rate limiting" --all
23
25
  """
24
26
 
@@ -51,6 +53,8 @@ def main():
51
53
  from . import sync; return sync.main()
52
54
  if cmd == "recall":
53
55
  from . import recall; return recall.main(rest)
56
+ if cmd == "serve":
57
+ from . import web; return web.main(rest)
54
58
  if cmd in ("doctor", "status"):
55
59
  return doctor()
56
60
  print(f"unknown command: {cmd}\n", file=sys.stderr)
@@ -12,12 +12,12 @@ then open those files for full context.
12
12
  """
13
13
 
14
14
  EXAMPLES = """examples:
15
- recall "affiliate tier rebate" auto-scope to the current repo (by CWD)
16
- recall "worktree flow" --project based-one-mono
17
- recall "nesting model" --all search every project
18
- recall "payout bug" --harness cursor --since 2026-09 -k 10
19
- recall "oauth flow" --full print file paths only (pipe-friendly)
20
- recall --reindex force rebuild the index
15
+ recall "rate limiting" auto-scope to the current repo (by CWD)
16
+ recall "retry backoff" --project my-api
17
+ recall "feature flag rollout" --all search every project
18
+ recall "flaky integration test" --harness cursor --since 2026-09 -k 10
19
+ recall "websocket reconnect" --full print file paths only (pipe-friendly)
20
+ recall --reindex force rebuild the index
21
21
  """
22
22
  import os, sys, re, glob, sqlite3, argparse, datetime
23
23
  from . import cmlib
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ chat-memory serve - a lightweight local web UI to browse & search your chats.
4
+
5
+ Pure standard library (http.server + sqlite). Reuses the recall BM25 index.
6
+ """
7
+ import os, json, sqlite3, argparse, threading, webbrowser, html
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from urllib.parse import urlparse, parse_qs
10
+ from . import cmlib, recall
11
+
12
+
13
+ def _db():
14
+ return sqlite3.connect(cmlib.index_db())
15
+
16
+
17
+ def api_projects():
18
+ recall.ensure_index()
19
+ con = _db(); con.row_factory = sqlite3.Row
20
+ rows = con.execute(
21
+ "SELECT project, COUNT(*) n, MAX(date) last FROM d GROUP BY project ORDER BY last DESC"
22
+ ).fetchall()
23
+ con.close()
24
+ total = sum(r["n"] for r in rows)
25
+ return {"total": total, "projects": [dict(r) for r in rows]}
26
+
27
+
28
+ def api_chats(project):
29
+ recall.ensure_index()
30
+ con = _db(); con.row_factory = sqlite3.Row
31
+ rows = con.execute(
32
+ "SELECT date, harness, client, title, path FROM d WHERE project=? ORDER BY date DESC, title",
33
+ (project,)).fetchall()
34
+ con.close()
35
+ return {"chats": [dict(r) for r in rows]}
36
+
37
+
38
+ def api_search(q, project, harness, all_):
39
+ recall.ensure_index()
40
+ rows = recall.search(q, project=None if all_ else (project or None),
41
+ harness=harness or None, k=50)
42
+ out = []
43
+ for r, snip in rows:
44
+ out.append({"date": r["date"], "harness": r["harness"], "client": r["client"],
45
+ "title": r["title"], "path": r["path"], "project": r["project"], "snippet": snip})
46
+ return {"results": out}
47
+
48
+
49
+ def api_chat(path):
50
+ base = os.path.realpath(cmlib.projects_dir())
51
+ full = os.path.realpath(os.path.join(cmlib.data_dir(), path))
52
+ if not full.startswith(base + os.sep) or not full.endswith(".md"):
53
+ return None
54
+ try:
55
+ return open(full, encoding="utf-8", errors="ignore").read()
56
+ except Exception:
57
+ return None
58
+
59
+
60
+ class Handler(BaseHTTPRequestHandler):
61
+ def log_message(self, *a):
62
+ pass
63
+
64
+ def _send(self, body, ctype="application/json", code=200):
65
+ if isinstance(body, (dict, list)):
66
+ body = json.dumps(body)
67
+ data = body.encode("utf-8")
68
+ self.send_response(code)
69
+ self.send_header("Content-Type", ctype)
70
+ self.send_header("Content-Length", str(len(data)))
71
+ self.end_headers()
72
+ self.wfile.write(data)
73
+
74
+ def do_GET(self):
75
+ u = urlparse(self.path)
76
+ q = {k: v[0] for k, v in parse_qs(u.query).items()}
77
+ try:
78
+ if u.path == "/":
79
+ return self._send(INDEX_HTML, "text/html; charset=utf-8")
80
+ if u.path == "/api/projects":
81
+ return self._send(api_projects())
82
+ if u.path == "/api/chats":
83
+ return self._send(api_chats(q.get("project", "")))
84
+ if u.path == "/api/search":
85
+ return self._send(api_search(q.get("q", ""), q.get("project", ""),
86
+ q.get("harness", ""), q.get("all") == "1"))
87
+ if u.path == "/api/chat":
88
+ c = api_chat(q.get("path", ""))
89
+ if c is None:
90
+ return self._send({"error": "not found"}, code=404)
91
+ return self._send({"markdown": c})
92
+ self._send({"error": "not found"}, code=404)
93
+ except Exception as e:
94
+ self._send({"error": str(e)}, code=500)
95
+
96
+
97
+ INDEX_HTML = r"""<!doctype html><html><head><meta charset="utf-8">
98
+ <meta name="viewport" content="width=device-width, initial-scale=1">
99
+ <title>chat-memory</title>
100
+ <style>
101
+ :root{--bg:#0f1117;--panel:#171a21;--line:#262b36;--fg:#e6e9ef;--mut:#8b93a7;--acc:#6ea8fe;--hit:#f5d06f}
102
+ *{box-sizing:border-box}body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--fg);height:100vh;display:flex;flex-direction:column}
103
+ header{display:flex;gap:10px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--line);background:var(--panel)}
104
+ header b{font-weight:700}header .tot{color:var(--mut);font-size:12px}
105
+ input,select{background:#0b0d12;color:var(--fg);border:1px solid var(--line);border-radius:7px;padding:7px 10px;font-size:13px}
106
+ #q{flex:1;min-width:120px}
107
+ main{flex:1;display:flex;min-height:0}
108
+ #side{width:220px;border-right:1px solid var(--line);overflow:auto;background:var(--panel)}
109
+ #list{width:360px;border-right:1px solid var(--line);overflow:auto}
110
+ #view{flex:1;overflow:auto;padding:18px 26px}
111
+ .p{padding:7px 12px;cursor:pointer;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:6px}
112
+ .p:hover,.p.sel{background:#1e2430}.p .n{color:var(--mut);font-size:12px}
113
+ .row{padding:9px 12px;cursor:pointer;border-bottom:1px solid var(--line)}
114
+ .row:hover,.row.sel{background:#1e2430}
115
+ .row .t{font-weight:600;margin-bottom:2px}
116
+ .row .m{color:var(--mut);font-size:11.5px;display:flex;gap:8px;flex-wrap:wrap}
117
+ .row .s{color:var(--mut);font-size:12px;margin-top:4px}
118
+ .tag{background:#222836;border-radius:4px;padding:0 6px;font-size:11px;color:#aab2c5}
119
+ mark{background:transparent;color:var(--hit);font-weight:700}
120
+ #view h1{font-size:19px;margin:0 0 4px}#view h3{margin:18px 0 2px;color:var(--acc);font-size:14px;border-top:1px solid var(--line);padding-top:12px}
121
+ #view pre{background:#0b0d12;border:1px solid var(--line);border-radius:7px;padding:10px;overflow:auto;font-size:12.5px}
122
+ #view code{font-family:ui-monospace,Menlo,monospace}
123
+ #view details{margin:6px 0;color:var(--mut)}#view summary{cursor:pointer}
124
+ .meta{color:var(--mut);font-size:12px;margin-bottom:14px}
125
+ .empty{color:var(--mut);padding:24px;text-align:center}
126
+ </style></head><body>
127
+ <header><b>chat-memory</b><span class="tot" id="tot"></span>
128
+ <input id="q" placeholder="search all your chats…" autocomplete="off">
129
+ <label class="tot"><input type="checkbox" id="allp"> all projects</label>
130
+ <select id="harness"><option value="">any harness</option></select>
131
+ </header>
132
+ <main>
133
+ <div id="side"></div><div id="list"></div><div id="view"><div class="empty">Pick a project or search.</div></div>
134
+ </main>
135
+ <script>
136
+ const E=(t,c,h)=>{const e=document.createElement(t);if(c)e.className=c;if(h!=null)e.innerHTML=h;return e};
137
+ const esc=s=>(s||'').replace(/[&<>]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[m]));
138
+ let state={project:null,harness:''};
139
+ const HARN=['cursor','cursor-agent','claude-code','opencode','pi','codex','gemini'];
140
+ HARN.forEach(h=>harness.appendChild(E('option',null,h)).value=h);
141
+ harness.onchange=()=>{state.harness=harness.value; q.value?doSearch():loadChats()};
142
+
143
+ async function j(u){return (await fetch(u)).json()}
144
+ async function loadProjects(){
145
+ const d=await j('/api/projects'); tot.textContent=d.total+' chats';
146
+ side.innerHTML='';
147
+ d.projects.forEach(p=>{const el=E('div','p');el.append(E('span',null,esc(p.project)),E('span','n',p.n));
148
+ el.onclick=()=>{document.querySelectorAll('.p').forEach(x=>x.classList.remove('sel'));el.classList.add('sel');state.project=p.project;q.value='';loadChats()};side.append(el)});
149
+ }
150
+ function hl(s){return esc(s).replace(/&gt;&gt;(.*?)&lt;&lt;/g,'<mark>$1</mark>')}
151
+ function rowEl(r,withSnip){
152
+ const el=E('div','row');
153
+ el.append(E('div','t',esc(r.title)||'(untitled)'));
154
+ const m=E('div','m');m.append(E('span','tag',r.harness),E('span',null,r.date||''),E('span',null,esc(r.client||'')));
155
+ if(withSnip&&r.project)m.append(E('span','tag',esc(r.project)));
156
+ el.append(m);
157
+ if(withSnip&&r.snippet)el.append(E('div','s',hl(r.snippet)));
158
+ el.onclick=()=>{document.querySelectorAll('.row').forEach(x=>x.classList.remove('sel'));el.classList.add('sel');openChat(r.path)};
159
+ return el;
160
+ }
161
+ async function loadChats(){
162
+ if(!state.project){list.innerHTML='';return}
163
+ const d=await j('/api/chats?project='+encodeURIComponent(state.project));
164
+ list.innerHTML=''; if(!d.chats.length){list.append(E('div','empty','No chats.'));return}
165
+ d.chats.filter(c=>!state.harness||c.harness===state.harness).forEach(c=>list.append(rowEl(c,false)));
166
+ }
167
+ let tmr;
168
+ q.oninput=()=>{clearTimeout(tmr);tmr=setTimeout(doSearch,180)};
169
+ async function doSearch(){
170
+ const term=q.value.trim(); if(!term)return loadChats();
171
+ const u='/api/search?q='+encodeURIComponent(term)+'&all='+(allp.checked?1:0)+
172
+ (state.project&&!allp.checked?'&project='+encodeURIComponent(state.project):'')+
173
+ (state.harness?'&harness='+state.harness:'');
174
+ const d=await j(u); list.innerHTML='';
175
+ if(!d.results.length){list.append(E('div','empty','No matches.'));return}
176
+ d.results.forEach(r=>list.append(rowEl(r,true)));
177
+ }
178
+ allp.onchange=doSearch;
179
+ function md(src){
180
+ // split frontmatter
181
+ let meta='';if(src.startsWith('---')){const e=src.indexOf('\n---',3);if(e>-1){meta=src.slice(3,e).trim();src=src.slice(e+4)}}
182
+ let h=esc(src);
183
+ h=h.replace(/```([\w-]*)\n([\s\S]*?)```/g,(m,l,c)=>'<pre><code>'+c.replace(/&amp;/g,'&')+'</code></pre>');
184
+ h=h.replace(/&lt;details&gt;&lt;summary&gt;([\s\S]*?)&lt;\/summary&gt;/g,'<details><summary>$1</summary>').replace(/&lt;\/details&gt;/g,'</details>');
185
+ h=h.replace(/^### (.*)$/gm,'<h3>$1</h3>').replace(/^## (.*)$/gm,'<h3>$1</h3>').replace(/^# (.*)$/gm,'<h1>$1</h1>');
186
+ h=h.replace(/\*\*(.+?)\*\*/g,'<b>$1</b>');
187
+ h=h.replace(/`([^`\n]+?)`/g,'<code>$1</code>');
188
+ const mh=meta?'<div class="meta">'+esc(meta).replace(/\n/g,' · ')+'</div>':'';
189
+ return mh+h;
190
+ }
191
+ async function openChat(path){
192
+ view.innerHTML='<div class="empty">loading…</div>';
193
+ const d=await j('/api/chat?path='+encodeURIComponent(path));
194
+ view.innerHTML=d.markdown?md(d.markdown):'<div class="empty">not found</div>';
195
+ view.scrollTop=0;
196
+ }
197
+ loadProjects();
198
+ </script></body></html>"""
199
+
200
+
201
+ def serve(port=8787, open_browser=True):
202
+ recall.ensure_index()
203
+ httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
204
+ url = f"http://127.0.0.1:{port}"
205
+ print(f"chat-memory UI -> {url} (Ctrl-C to stop)")
206
+ if open_browser:
207
+ threading.Timer(0.6, lambda: webbrowser.open(url)).start()
208
+ try:
209
+ httpd.serve_forever()
210
+ except KeyboardInterrupt:
211
+ print("\nstopped")
212
+
213
+
214
+ def main(argv=None):
215
+ ap = argparse.ArgumentParser(prog="chat-memory serve",
216
+ description="browse & search your chats in a local web UI")
217
+ ap.add_argument("--port", type=int, default=8787)
218
+ ap.add_argument("--no-open", action="store_true", help="don't auto-open the browser")
219
+ args = ap.parse_args(argv)
220
+ serve(args.port, open_browser=not args.no_open)
221
+
222
+
223
+ if __name__ == "__main__":
224
+ main()