session-sidekick 0.1.0__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,192 @@
1
+ Metadata-Version: 2.4
2
+ Name: session-sidekick
3
+ Version: 0.1.0
4
+ Summary: Search your Claude Code sessions and get semantic recall hints as you type
5
+ Project-URL: Homepage, https://github.com/AravindKurapati/session-sidekick
6
+ Project-URL: Issues, https://github.com/AravindKurapati/session-sidekick/issues
7
+ Author: Aravind Kurapati
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: anthropic,claude,claude-code,cli,hooks,recall,search,semantic,sessions
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Utilities
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: click>=8.1
19
+ Requires-Dist: fastembed>=0.4
20
+ Requires-Dist: platformdirs>=4.2
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Requires-Dist: ruff>=0.6; extra == 'dev'
25
+ Provides-Extra: titler
26
+ Requires-Dist: anthropic>=0.40; extra == 'titler'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # session-sidekick
30
+
31
+ > **Stop re-explaining context you've already worked through.**
32
+
33
+ A local CLI + Claude Code hook that indexes all your past sessions and surfaces relevant ones as you type — before you accidentally redo work you've already done.
34
+
35
+ ---
36
+
37
+ ## The problem this solves
38
+
39
+ Claude Code sessions are ephemeral. Every time you start a new one, Claude has no memory of the three hours you spent debugging the same Modal deployment last week, or the exact vLLM flag you figured out, or how you structured that RAG pipeline.
40
+
41
+ You either:
42
+ - Re-explain everything from scratch every session (slow)
43
+ - Search through raw JSONL files manually (painful)
44
+ - Forget you solved it and solve it again (expensive)
45
+
46
+ Session-sidekick fixes this with two pieces:
47
+
48
+ 1. **A recall hook** — fires on every prompt via `UserPromptSubmit`. Runs semantic search against all your past sessions. If it finds a confident match, it prints a one-line hint before Claude sees your message: `💡 You did this before — session abc12345: add modal vllm endpoint`
49
+
50
+ 2. **A search CLI** — keyword + semantic + combined search across every session you've ever had. `sidekick search "modal deployment"` ranks results by relevance, not just recency.
51
+
52
+ Everything is local. No API keys. No data leaves your machine.
53
+
54
+ ---
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install session-sidekick
60
+ ```
61
+
62
+ Or from source:
63
+ ```bash
64
+ git clone https://github.com/AravindKurapati/session-sidekick
65
+ cd session-sidekick
66
+ pip install -e .
67
+ ```
68
+
69
+ **Requirements:** Python 3.11+ — no API keys needed.
70
+
71
+ ---
72
+
73
+ ## Quick start
74
+
75
+ ```bash
76
+ # Index your Claude Code sessions (reads ~/.claude/projects/)
77
+ sidekick reindex
78
+
79
+ # Build semantic embeddings (one-time, ~1-2 min)
80
+ sidekick embed
81
+
82
+ # Check what got indexed
83
+ sidekick stats
84
+
85
+ # Search
86
+ sidekick search "modal vllm"
87
+ sidekick search "react state management" --mode fts
88
+ sidekick list --project my-project
89
+ sidekick show abc12345 --full
90
+ ```
91
+
92
+ ---
93
+
94
+ ## Live recall hook (the main feature)
95
+
96
+ The recall hook injects a past-session hint into your terminal at the moment you submit a prompt to Claude Code — giving you context before Claude even sees your message.
97
+
98
+ **Setup:**
99
+
100
+ ```bash
101
+ # 1. Install the hooks into ~/.claude/settings.json
102
+ sidekick install-hooks --apply
103
+
104
+ # 2. Start the daemon (keeps the embedding model warm for fast recall)
105
+ sidekick-daemon
106
+ ```
107
+
108
+ The daemon needs to be running for recall to work. Add it to your shell startup to keep it always-on:
109
+
110
+ ```bash
111
+ # ~/.zshrc or ~/.bashrc
112
+ sidekick-daemon &>/dev/null &
113
+ ```
114
+
115
+ **How it looks:**
116
+
117
+ ```
118
+ You: fix the vllm timeout on modal
119
+
120
+ 💡 You may have done this before — session a3f2b1c8 (2026-04-28): debug modal vllm timeout
121
+ Set request_timeout=120 in the Modal endpoint config, not in the vLLM args.
122
+ Tags: modal,vllm,timeout
123
+ Resume with: claude --resume a3f2b1c8
124
+
125
+ Claude: ...
126
+ ```
127
+
128
+ The hint only appears when the confidence score exceeds the threshold (default 0.78). Silent otherwise — it never interrupts you.
129
+
130
+ ---
131
+
132
+ ## All commands
133
+
134
+ | Command | What it does |
135
+ |---------|-------------|
136
+ | `reindex` | Incrementally scan `~/.claude/projects/*.jsonl` |
137
+ | `embed` | Build/update semantic embeddings for unembedded turns |
138
+ | `stats` | Sessions, turns, embeddings count + DB path |
139
+ | `list` | Browse sessions (`--project`, `--status`, `--limit`) |
140
+ | `search` | Keyword + semantic search (`--mode fts\|semantic\|combined`) |
141
+ | `show` | Full session detail by id (`--full` for all turns) |
142
+ | `install-hooks` | Print or apply hook config (`--apply` to patch settings.json) |
143
+ | `stop-hook` | Run by Claude Code Stop event — reindex + embed |
144
+
145
+ ---
146
+
147
+ ## How it works
148
+
149
+ ```
150
+ ~/.claude/projects/**/*.jsonl
151
+ ↓ sidekick reindex
152
+ SQLite (WAL) + FTS5
153
+ ↓ sidekick embed
154
+ MiniLM embeddings (384d, ONNX, local)
155
+ ↓ sidekick-daemon
156
+ TCP socket server (Windows) / Unix socket (macOS/Linux)
157
+ ↓ UserPromptSubmit hook → sidekick-recall
158
+ Cosine similarity → hint printed if score > 0.78
159
+ ```
160
+
161
+ - **Index:** `~/.session-sidekick/index.db` — SQLite with FTS5 full-text and raw float32 embeddings
162
+ - **Model:** `sentence-transformers/all-MiniLM-L6-v2` via fastembed (ONNX, ~80MB, downloads once)
163
+ - **Search:** FTS5 keyword, cosine similarity semantic, or RRF-fused combined
164
+ - **Recall budget:** 300ms timeout — silent if daemon is slow or down, never blocks Claude Code
165
+
166
+ ---
167
+
168
+ ## Data & privacy
169
+
170
+ - All data stays local at `~/.session-sidekick/`
171
+ - Only reads `~/.claude/projects/` — never writes to it
172
+ - No network calls (the optional titler uses Anthropic's API but is disabled by default)
173
+ - The daemon runs on localhost only
174
+
175
+ ---
176
+
177
+ ## Optional: session titler
178
+
179
+ If you want auto-generated titles for sessions (requires `ANTHROPIC_API_KEY`):
180
+
181
+ ```bash
182
+ pip install "session-sidekick[titler]"
183
+ ANTHROPIC_API_KEY=... sidekick-titler
184
+ ```
185
+
186
+ Calls Claude Haiku (~$0.0005 per session). Titles are stored locally and shown in `sidekick list`.
187
+
188
+ ---
189
+
190
+ ## License
191
+
192
+ MIT
@@ -0,0 +1,18 @@
1
+ sidekick/__init__.py,sha256=QTYqXqSTHFRkM9TEgpDFcHvwLbvqHDqvqfQ9EiXkcAM,23
2
+ sidekick/__main__.py,sha256=kiU1ZwEt06j4cTHQ6fnFHCuX5MZx4x8ni25PJVWOzOk,73
3
+ sidekick/cli.py,sha256=IbW018nt36VtIXjVtibaISygiSoSlX8X3Z7XhbtJQMs,4644
4
+ sidekick/daemon.py,sha256=ZvJA3j96l5gUrDSc-woEH2EyJFRuZtNlIUpkpTtib-k,4306
5
+ sidekick/db.py,sha256=EL2HvJFABk4D3XkQwDK189TzExtXDFNqM_afkCnFNBE,1817
6
+ sidekick/embeddings.py,sha256=cQz12jaYkCcpk-RioH_dyrSuFbnOtit80PTQUm-JfD0,1026
7
+ sidekick/hooks.py,sha256=ZX5uCJ6aIsW1QwpBvSPkS2wAFUkc0vpKiXv_8grJdz0,1882
8
+ sidekick/indexer.py,sha256=bSlPvEuMGGgKSmUMcenY8oxEbGmrDKxvrKnlsSjpwy4,4353
9
+ sidekick/parser.py,sha256=obyZdFByKhg-bbP0SCwigAWvcjnn47SM-Fyk_Mhh3Zk,2717
10
+ sidekick/paths.py,sha256=VVS5m3NipulNP1kNOCffIL4eQdYHgwG4Iv31NLw3Ndk,733
11
+ sidekick/recall.py,sha256=6lZ8dwbIhSnhelAAz9deuJjTRFEbWQghvuTKuZmUHTM,2342
12
+ sidekick/search.py,sha256=YjK6PQWAO9FEmF_PRGZwE4Xuq4kgN29RrjM84a_1OnM,3285
13
+ sidekick/titler.py,sha256=CflcQk1eDOnRqwRvcKZppnfN_rfg9SwUCZ2JS-sxhZc,4138
14
+ session_sidekick-0.1.0.dist-info/METADATA,sha256=M-Z2dlZXAhXM856UO2XuBJ4b3e4E-KqOewYS0esfO6k,5982
15
+ session_sidekick-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
16
+ session_sidekick-0.1.0.dist-info/entry_points.txt,sha256=34eHFR53wHmm-lWpYUlIbYzmP03bxBg3ouFmzPTFuyQ,164
17
+ session_sidekick-0.1.0.dist-info/licenses/LICENSE,sha256=1jQMZN1uCxe0u_XL7YZOqjvHMHGKwvESVQBlPuDQD80,1094
18
+ session_sidekick-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ sidekick = sidekick.cli:main
3
+ sidekick-daemon = sidekick.daemon:main
4
+ sidekick-recall = sidekick.recall:main
5
+ sidekick-titler = sidekick.titler:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aravind Kurapati
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
sidekick/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
sidekick/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from sidekick.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
sidekick/cli.py ADDED
@@ -0,0 +1,119 @@
1
+ """Click CLI: composition root."""
2
+ from __future__ import annotations
3
+ import click
4
+ from sidekick import db, indexer
5
+ from sidekick import search as searchmod
6
+ from sidekick.paths import db_path
7
+
8
+ @click.group()
9
+ def main() -> None:
10
+ """session-sidekick: search & recall your Claude Code sessions."""
11
+
12
+ @main.command()
13
+ def reindex() -> None:
14
+ """Incrementally index ~/.claude/projects/*.jsonl."""
15
+ n = indexer.run()
16
+ click.echo(f"indexed {n} new turn(s)")
17
+
18
+ @main.command()
19
+ def stats() -> None:
20
+ """Print database stats."""
21
+ conn = db.connect()
22
+ s = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
23
+ t = conn.execute("SELECT COUNT(*) FROM turns").fetchone()[0]
24
+ e = conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0]
25
+ titled = conn.execute(
26
+ "SELECT COUNT(*) FROM sessions WHERE titled_at IS NOT NULL"
27
+ ).fetchone()[0]
28
+ click.echo(f"db: {db_path()}")
29
+ click.echo(f"sessions: {s} (titled: {titled})")
30
+ click.echo(f"turns: {t}")
31
+ click.echo(f"embeddings: {e}")
32
+
33
+ @main.command(name="list")
34
+ @click.option("--project", default=None, help="Filter by project name (substring).")
35
+ @click.option("--status", default=None, help="completed|abandoned|context_limit|unknown")
36
+ @click.option("--limit", default=20, type=int)
37
+ def list_sessions(project: str | None, status: str | None, limit: int) -> None:
38
+ """List indexed sessions, most recent first."""
39
+ conn = db.connect()
40
+ sql = "SELECT id, project, title, status, ended_at FROM sessions WHERE 1=1"
41
+ args: list = []
42
+ if project:
43
+ sql += " AND project LIKE ?"
44
+ args.append(f"%{project}%")
45
+ if status:
46
+ sql += " AND status=?"
47
+ args.append(status)
48
+ sql += " ORDER BY ended_at DESC LIMIT ?"
49
+ args.append(limit)
50
+ for sid, proj, title, st, ended in conn.execute(sql, args):
51
+ click.echo(f"{sid:<36} {proj:<30} {st or 'unknown':<12} {title or '(untitled)'}")
52
+
53
+ @main.command()
54
+ def embed() -> None:
55
+ """Embed any turns that don't yet have an embedding."""
56
+ n = indexer.embed_pending()
57
+ click.echo(f"embedded {n} turn(s)")
58
+
59
+ @main.command()
60
+ @click.argument("query")
61
+ @click.option("--project", default=None)
62
+ @click.option("--limit", default=10, type=int)
63
+ @click.option("--mode", type=click.Choice(["fts", "semantic", "combined"]), default="combined")
64
+ def search(query: str, project: str | None, limit: int, mode: str) -> None:
65
+ """Search across all indexed sessions."""
66
+ if mode == "fts":
67
+ hits = searchmod.fts(query, limit=limit, project=project)
68
+ elif mode == "semantic":
69
+ hits = searchmod.semantic(query, limit=limit, project=project)
70
+ else:
71
+ hits = searchmod.combined(query, limit=limit, project=project)
72
+ if not hits:
73
+ click.echo("(no hits)")
74
+ return
75
+ for h in hits:
76
+ score = h.get("score", 0.0)
77
+ click.echo(f"{h['session_id']:<36} turn {h['turn_idx']:>3} score={score:.3f} {h['project']:<25} {h['snippet'][:120]}")
78
+
79
+ @main.command()
80
+ @click.argument("session_id")
81
+ @click.option("--full", is_flag=True, help="Print every turn's text.")
82
+ def show(session_id: str, full: bool) -> None:
83
+ """Show a session by id (partial id supported)."""
84
+ conn = db.connect()
85
+ row = conn.execute(
86
+ "SELECT id, project, title, summary, tags, status, ended_at FROM sessions WHERE id LIKE ?",
87
+ (f"{session_id}%",),
88
+ ).fetchone()
89
+ if not row:
90
+ click.echo(f"no session matching {session_id!r}")
91
+ return
92
+ click.echo(f"id: {row[0]}")
93
+ click.echo(f"project: {row[1]}")
94
+ click.echo(f"title: {row[2] or '(untitled)'}")
95
+ click.echo(f"summary: {row[3] or '-'}")
96
+ click.echo(f"tags: {row[4] or '-'}")
97
+ click.echo(f"status: {row[5]}")
98
+ click.echo(f"ended: {row[6]}")
99
+ if full:
100
+ click.echo("---")
101
+ for tidx, role, text in conn.execute(
102
+ "SELECT turn_idx, role, text FROM turns WHERE session_id=? ORDER BY turn_idx", (row[0],)
103
+ ):
104
+ click.echo(f"[{tidx}] {role}: {text[:300]}")
105
+
106
+ from sidekick import hooks as hooksmod
107
+
108
+ @main.command(name="install-hooks")
109
+ @click.option("--apply", is_flag=True, help="Patch ~/.claude/settings.json. Otherwise prints snippet.")
110
+ def install_hooks(apply: bool) -> None:
111
+ """Print or install the Claude Code hook config."""
112
+ out = hooksmod.install(apply=apply)
113
+ click.echo(out)
114
+
115
+ @main.command(name="stop-hook")
116
+ def stop_hook() -> None:
117
+ """Run by Claude Code Stop event: incremental index + embed."""
118
+ indexer.run()
119
+ indexer.embed_pending(batch_size=64)
sidekick/daemon.py ADDED
@@ -0,0 +1,128 @@
1
+ """Local socket daemon. Unix domain socket on POSIX, TCP localhost on Windows."""
2
+ from __future__ import annotations
3
+ import json
4
+ import os
5
+ import socket
6
+ import sys
7
+ import threading
8
+ from sidekick import db, search
9
+ from sidekick.embeddings import Embedder
10
+ from sidekick.paths import sidekick_dir
11
+
12
+ CONFIDENCE_THRESHOLD = 0.78
13
+
14
+ def _socket_address():
15
+ if sys.platform == "win32":
16
+ return ("127.0.0.1", 0)
17
+ return str(sidekick_dir() / "daemon.sock")
18
+
19
+ class Server:
20
+ def __init__(self) -> None:
21
+ self._address = None
22
+ self._sock: socket.socket | None = None
23
+ self._thread: threading.Thread | None = None
24
+ self._stop = threading.Event()
25
+ self._embedder: Embedder | None = None
26
+
27
+ @property
28
+ def address(self):
29
+ return self._address
30
+
31
+ def start(self) -> None:
32
+ if sys.platform == "win32":
33
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
34
+ self._sock.bind(("127.0.0.1", 0))
35
+ self._address = self._sock.getsockname()
36
+ (sidekick_dir() / "daemon.port").write_text(str(self._address[1]))
37
+ else:
38
+ path = _socket_address()
39
+ try:
40
+ os.unlink(path)
41
+ except FileNotFoundError:
42
+ pass
43
+ self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
44
+ self._sock.bind(path)
45
+ self._address = path
46
+ self._sock.listen(8)
47
+ self._sock.settimeout(0.5)
48
+ self._thread = threading.Thread(target=self._run, daemon=True)
49
+ self._thread.start()
50
+
51
+ def stop(self) -> None:
52
+ self._stop.set()
53
+ if self._sock:
54
+ self._sock.close()
55
+ if self._thread:
56
+ self._thread.join(timeout=2.0)
57
+
58
+ def _run(self) -> None:
59
+ while not self._stop.is_set():
60
+ try:
61
+ conn, _ = self._sock.accept()
62
+ except (socket.timeout, OSError):
63
+ continue
64
+ threading.Thread(target=self._handle, args=(conn,), daemon=True).start()
65
+
66
+ def _handle(self, conn: socket.socket) -> None:
67
+ try:
68
+ f = conn.makefile("rb")
69
+ line = f.readline()
70
+ req = json.loads(line)
71
+ resp = self._dispatch(req)
72
+ conn.sendall(json.dumps(resp).encode() + b"\n")
73
+ except Exception as e:
74
+ try:
75
+ conn.sendall(json.dumps({"ok": False, "error": str(e)}).encode() + b"\n")
76
+ except OSError:
77
+ pass
78
+ finally:
79
+ conn.close()
80
+
81
+ def _dispatch(self, req: dict) -> dict:
82
+ op = req.get("op")
83
+ if op == "ping":
84
+ return {"ok": True, "pong": True}
85
+ if op == "recall":
86
+ return self._recall(req.get("prompt", ""), req.get("project"))
87
+ return {"ok": False, "error": f"unknown op: {op}"}
88
+
89
+ def _recall(self, prompt: str, project: str | None) -> dict:
90
+ if not prompt.strip():
91
+ return {"ok": True, "hit": None}
92
+ if self._embedder is None:
93
+ self._embedder = Embedder()
94
+ hits = search.semantic(prompt, limit=5, project=project)
95
+ if not hits:
96
+ return {"ok": True, "hit": None}
97
+ top = hits[0]
98
+ if top["score"] < CONFIDENCE_THRESHOLD:
99
+ return {"ok": True, "hit": None}
100
+ conn = db.connect()
101
+ row = conn.execute(
102
+ "SELECT title, summary, tags, ended_at FROM sessions WHERE id=?",
103
+ (top["session_id"],),
104
+ ).fetchone()
105
+ conn.close()
106
+ title, summary, tags, ended = row if row else (None, None, None, None)
107
+ return {
108
+ "ok": True,
109
+ "hit": {
110
+ "session_id": top["session_id"],
111
+ "score": top["score"],
112
+ "title": title,
113
+ "summary": summary,
114
+ "tags": tags,
115
+ "ended_at": ended,
116
+ },
117
+ }
118
+
119
+ def main() -> None:
120
+ """Entry point: `sidekick-daemon`. Runs forever."""
121
+ srv = Server()
122
+ srv.start()
123
+ addr = srv.address
124
+ print(f"sidekick-daemon listening on {addr}", flush=True)
125
+ try:
126
+ srv._thread.join()
127
+ except KeyboardInterrupt:
128
+ srv.stop()
sidekick/db.py ADDED
@@ -0,0 +1,66 @@
1
+ """SQLite connection + schema. Lives at ~/.session-sidekick/index.db."""
2
+ from __future__ import annotations
3
+ import sqlite3
4
+ from sidekick.paths import db_path
5
+
6
+ SCHEMA = """
7
+ CREATE TABLE IF NOT EXISTS sessions (
8
+ id TEXT PRIMARY KEY,
9
+ project TEXT,
10
+ cwd TEXT,
11
+ started_at TEXT,
12
+ ended_at TEXT,
13
+ title TEXT,
14
+ summary TEXT,
15
+ tags TEXT,
16
+ status TEXT,
17
+ titled_at TEXT,
18
+ last_seen_offset INTEGER DEFAULT 0
19
+ );
20
+
21
+ CREATE TABLE IF NOT EXISTS turns (
22
+ session_id TEXT NOT NULL,
23
+ turn_idx INTEGER NOT NULL,
24
+ role TEXT NOT NULL,
25
+ text TEXT NOT NULL,
26
+ timestamp TEXT,
27
+ input_tokens INTEGER DEFAULT 0,
28
+ output_tokens INTEGER DEFAULT 0,
29
+ PRIMARY KEY (session_id, turn_idx)
30
+ );
31
+
32
+ CREATE TABLE IF NOT EXISTS file_offsets (
33
+ file_path TEXT PRIMARY KEY,
34
+ mtime REAL NOT NULL,
35
+ byte_offset INTEGER NOT NULL,
36
+ indexed_at TEXT NOT NULL
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS embeddings (
40
+ session_id TEXT NOT NULL,
41
+ turn_idx INTEGER NOT NULL,
42
+ vec BLOB NOT NULL,
43
+ PRIMARY KEY (session_id, turn_idx)
44
+ );
45
+
46
+ CREATE VIRTUAL TABLE IF NOT EXISTS turns_fts USING fts5(
47
+ text,
48
+ session_id UNINDEXED,
49
+ turn_idx UNINDEXED,
50
+ role UNINDEXED,
51
+ content='turns',
52
+ content_rowid='rowid'
53
+ );
54
+
55
+ CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id);
56
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project);
57
+ CREATE INDEX IF NOT EXISTS idx_sessions_titled ON sessions(titled_at) WHERE titled_at IS NULL;
58
+ """
59
+
60
+ def connect() -> sqlite3.Connection:
61
+ conn = sqlite3.connect(db_path(), isolation_level=None)
62
+ conn.execute("PRAGMA journal_mode=WAL")
63
+ conn.execute("PRAGMA synchronous=NORMAL")
64
+ conn.execute("PRAGMA foreign_keys=ON")
65
+ conn.executescript(SCHEMA)
66
+ return conn
sidekick/embeddings.py ADDED
@@ -0,0 +1,30 @@
1
+ """Embeddings via fastembed (ONNX MiniLM). 384 dims, L2-normalized."""
2
+ from __future__ import annotations
3
+ import numpy as np
4
+ from fastembed import TextEmbedding
5
+
6
+ MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
7
+
8
+ class Embedder:
9
+ def __init__(self) -> None:
10
+ self._model = TextEmbedding(model_name=MODEL_NAME)
11
+ self.dim = 384
12
+
13
+ def embed_one(self, text: str) -> np.ndarray:
14
+ v = next(iter(self._model.embed([text])))
15
+ v = v.astype(np.float32)
16
+ n = np.linalg.norm(v)
17
+ return v / n if n > 0 else v
18
+
19
+ def embed_many(self, texts: list[str]) -> np.ndarray:
20
+ out = list(self._model.embed(texts))
21
+ arr = np.stack([v.astype(np.float32) for v in out])
22
+ norms = np.linalg.norm(arr, axis=1, keepdims=True)
23
+ norms[norms == 0] = 1.0
24
+ return arr / norms
25
+
26
+ def to_blob(v: np.ndarray) -> bytes:
27
+ return v.astype(np.float32).tobytes()
28
+
29
+ def from_blob(b: bytes) -> np.ndarray:
30
+ return np.frombuffer(b, dtype=np.float32)
sidekick/hooks.py ADDED
@@ -0,0 +1,60 @@
1
+ """Build + install Claude Code hook config in ~/.claude/settings.json."""
2
+ from __future__ import annotations
3
+ import json
4
+ from pathlib import Path
5
+ from sidekick.paths import home
6
+
7
+ def build_hook_config() -> dict:
8
+ return {
9
+ "Stop": [
10
+ {
11
+ "matcher": "*",
12
+ "hooks": [
13
+ {"type": "command", "command": "sidekick stop-hook", "timeout": 5}
14
+ ],
15
+ }
16
+ ],
17
+ "UserPromptSubmit": [
18
+ {
19
+ "matcher": "*",
20
+ "hooks": [
21
+ {"type": "command", "command": "sidekick-recall", "timeout": 1}
22
+ ],
23
+ }
24
+ ],
25
+ }
26
+
27
+ def settings_path() -> Path:
28
+ return home() / ".claude" / "settings.json"
29
+
30
+ def merge_into(existing: dict, new_hooks: dict) -> dict:
31
+ out = dict(existing)
32
+ out.setdefault("hooks", {})
33
+ for event, entries in new_hooks.items():
34
+ out["hooks"].setdefault(event, [])
35
+ out["hooks"][event] = [
36
+ e for e in out["hooks"][event]
37
+ if not any(
38
+ "sidekick" in (h.get("command", "") if isinstance(h, dict) else "")
39
+ for h in (e.get("hooks", []) if isinstance(e, dict) else [])
40
+ )
41
+ ]
42
+ out["hooks"][event].extend(entries)
43
+ return out
44
+
45
+ def install(apply: bool = False) -> str:
46
+ cfg = build_hook_config()
47
+ snippet = json.dumps({"hooks": cfg}, indent=2)
48
+ if not apply:
49
+ return snippet
50
+ sp = settings_path()
51
+ sp.parent.mkdir(parents=True, exist_ok=True)
52
+ existing = {}
53
+ if sp.exists():
54
+ try:
55
+ existing = json.loads(sp.read_text())
56
+ except json.JSONDecodeError:
57
+ existing = {}
58
+ merged = merge_into(existing, cfg)
59
+ sp.write_text(json.dumps(merged, indent=2))
60
+ return f"wrote {sp}"
sidekick/indexer.py ADDED
@@ -0,0 +1,119 @@
1
+ """Incremental indexer. Walks ~/.claude/projects, parses new JSONL bytes, inserts."""
2
+ from __future__ import annotations
3
+ import datetime as dt
4
+ from pathlib import Path
5
+ from sidekick import db
6
+ from sidekick.parser import parse_session_file, Turn
7
+ from sidekick.paths import claude_projects_dir
8
+
9
+ def _project_name(path: Path) -> str:
10
+ return path.parent.name
11
+
12
+ def _upsert_session(conn, turn: Turn, project: str) -> None:
13
+ conn.execute(
14
+ """
15
+ INSERT INTO sessions (id, project, cwd, started_at, ended_at, status)
16
+ VALUES (?, ?, ?, ?, ?, 'unknown')
17
+ ON CONFLICT(id) DO UPDATE SET
18
+ cwd=COALESCE(sessions.cwd, excluded.cwd),
19
+ ended_at=excluded.ended_at,
20
+ project=COALESCE(sessions.project, excluded.project)
21
+ """,
22
+ (turn.session_id, project, turn.cwd, turn.timestamp, turn.timestamp),
23
+ )
24
+
25
+ def _insert_turn(conn, turn: Turn) -> None:
26
+ conn.execute(
27
+ """
28
+ INSERT OR IGNORE INTO turns
29
+ (session_id, turn_idx, role, text, timestamp, input_tokens, output_tokens)
30
+ VALUES (?, ?, ?, ?, ?, ?, ?)
31
+ """,
32
+ (turn.session_id, turn.turn_idx, turn.role, turn.text,
33
+ turn.timestamp, turn.input_tokens, turn.output_tokens),
34
+ )
35
+ conn.execute(
36
+ """
37
+ INSERT OR REPLACE INTO turns_fts (rowid, text, session_id, turn_idx, role)
38
+ SELECT rowid, text, session_id, turn_idx, role FROM turns
39
+ WHERE session_id=? AND turn_idx=?
40
+ """,
41
+ (turn.session_id, turn.turn_idx),
42
+ )
43
+
44
+ def _last_offset(conn, file_path: Path) -> tuple[float, int]:
45
+ row = conn.execute(
46
+ "SELECT mtime, byte_offset FROM file_offsets WHERE file_path=?",
47
+ (str(file_path),),
48
+ ).fetchone()
49
+ return (row[0], row[1]) if row else (0.0, 0)
50
+
51
+ def _save_offset(conn, file_path: Path, mtime: float, offset: int) -> None:
52
+ conn.execute(
53
+ """
54
+ INSERT INTO file_offsets (file_path, mtime, byte_offset, indexed_at)
55
+ VALUES (?, ?, ?, ?)
56
+ ON CONFLICT(file_path) DO UPDATE SET
57
+ mtime=excluded.mtime,
58
+ byte_offset=excluded.byte_offset,
59
+ indexed_at=excluded.indexed_at
60
+ """,
61
+ (str(file_path), mtime, offset, dt.datetime.utcnow().isoformat()),
62
+ )
63
+
64
+ def embed_pending(batch_size: int = 64) -> int:
65
+ """Embed turns that have no embedding yet. Returns count embedded."""
66
+ from sidekick.embeddings import Embedder, to_blob
67
+ conn = db.connect()
68
+ rows = conn.execute(
69
+ """
70
+ SELECT t.session_id, t.turn_idx, t.text
71
+ FROM turns t
72
+ LEFT JOIN embeddings e
73
+ ON e.session_id=t.session_id AND e.turn_idx=t.turn_idx
74
+ WHERE e.session_id IS NULL AND t.text != ''
75
+ """
76
+ ).fetchall()
77
+ if not rows:
78
+ return 0
79
+ embedder = Embedder()
80
+ total = 0
81
+ for i in range(0, len(rows), batch_size):
82
+ batch = rows[i : i + batch_size]
83
+ vecs = embedder.embed_many([r[2] for r in batch])
84
+ conn.executemany(
85
+ "INSERT OR REPLACE INTO embeddings (session_id, turn_idx, vec) VALUES (?, ?, ?)",
86
+ [(r[0], r[1], to_blob(v)) for r, v in zip(batch, vecs)],
87
+ )
88
+ total += len(batch)
89
+ conn.close()
90
+ return total
91
+
92
+ def run(only_session: str | None = None) -> int:
93
+ """Index all new bytes; return number of new turns inserted."""
94
+ root = claude_projects_dir()
95
+ if not root.exists():
96
+ return 0
97
+ conn = db.connect()
98
+ new_turns = 0
99
+ files = sorted(root.rglob("*.jsonl"))
100
+ for f in files:
101
+ if only_session and only_session not in f.name:
102
+ continue
103
+ mtime = f.stat().st_mtime
104
+ last_mtime, last_offset = _last_offset(conn, f)
105
+ size = f.stat().st_size
106
+ if mtime <= last_mtime and size <= last_offset:
107
+ continue
108
+ project = _project_name(f)
109
+ max_offset = last_offset
110
+ for turn in parse_session_file(f):
111
+ if turn.byte_offset < last_offset:
112
+ continue
113
+ _upsert_session(conn, turn, project)
114
+ _insert_turn(conn, turn)
115
+ new_turns += 1
116
+ max_offset = max(max_offset, turn.byte_offset + 1)
117
+ _save_offset(conn, f, mtime, size)
118
+ conn.close()
119
+ return new_turns
sidekick/parser.py ADDED
@@ -0,0 +1,78 @@
1
+ """JSONL → Turn dataclasses. Pure parsing; no DB, no network."""
2
+ from __future__ import annotations
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Iterator
7
+
8
+ @dataclass(frozen=True)
9
+ class Turn:
10
+ session_id: str
11
+ turn_idx: int
12
+ role: str
13
+ text: str
14
+ timestamp: str
15
+ cwd: str | None
16
+ input_tokens: int
17
+ output_tokens: int
18
+ byte_offset: int
19
+ raw_type: str
20
+ raw_subtype: str | None
21
+
22
+ def _flatten_content(content) -> str:
23
+ if isinstance(content, str):
24
+ return content
25
+ if isinstance(content, list):
26
+ parts = []
27
+ for item in content:
28
+ if not isinstance(item, dict):
29
+ continue
30
+ if item.get("type") == "text":
31
+ parts.append(item.get("text", ""))
32
+ elif item.get("type") == "tool_use":
33
+ parts.append(f"[tool_use:{item.get('name','?')}]")
34
+ elif item.get("type") == "tool_result":
35
+ tr = item.get("content", "")
36
+ if isinstance(tr, list):
37
+ tr = " ".join(p.get("text", "") for p in tr if isinstance(p, dict))
38
+ parts.append(f"[tool_result:{tr[:500]}]")
39
+ return "\n".join(parts)
40
+ return ""
41
+
42
+ def parse_session_file(path: Path) -> Iterator[Turn]:
43
+ """Yield Turn for each well-formed line. Skip malformed lines silently."""
44
+ if not path.exists():
45
+ return
46
+ turn_idx = 0
47
+ offset = 0
48
+ with open(path, "rb") as f:
49
+ for raw in f:
50
+ line_offset = offset
51
+ offset += len(raw)
52
+ try:
53
+ obj = json.loads(raw)
54
+ except json.JSONDecodeError:
55
+ continue
56
+ if not isinstance(obj, dict):
57
+ continue
58
+ session_id = obj.get("sessionId") or obj.get("session_id")
59
+ if not session_id:
60
+ continue
61
+ msg = obj.get("message") or {}
62
+ role = msg.get("role") or obj.get("type", "")
63
+ text = _flatten_content(msg.get("content", ""))
64
+ usage = msg.get("usage") or {}
65
+ yield Turn(
66
+ session_id=session_id,
67
+ turn_idx=turn_idx,
68
+ role=role,
69
+ text=text,
70
+ timestamp=obj.get("timestamp", ""),
71
+ cwd=obj.get("cwd"),
72
+ input_tokens=int(usage.get("input_tokens", 0) or 0),
73
+ output_tokens=int(usage.get("output_tokens", 0) or 0),
74
+ byte_offset=line_offset,
75
+ raw_type=obj.get("type", ""),
76
+ raw_subtype=obj.get("subtype"),
77
+ )
78
+ turn_idx += 1
sidekick/paths.py ADDED
@@ -0,0 +1,26 @@
1
+ """Centralized path resolution. All FS paths come from here."""
2
+ from __future__ import annotations
3
+ from pathlib import Path
4
+
5
+ def home() -> Path:
6
+ return Path.home()
7
+
8
+ def sidekick_dir() -> Path:
9
+ p = home() / ".session-sidekick"
10
+ p.mkdir(parents=True, exist_ok=True)
11
+ return p
12
+
13
+ def db_path() -> Path:
14
+ return sidekick_dir() / "index.db"
15
+
16
+ def logs_dir() -> Path:
17
+ p = sidekick_dir() / "logs"
18
+ p.mkdir(parents=True, exist_ok=True)
19
+ return p
20
+
21
+ def claude_projects_dir() -> Path:
22
+ """Where Claude Code stores session JSONLs. Tries new path first."""
23
+ new = home() / ".config" / "claude" / "projects"
24
+ if new.exists():
25
+ return new
26
+ return home() / ".claude" / "projects"
sidekick/recall.py ADDED
@@ -0,0 +1,69 @@
1
+ """Recall client. Invoked by the UserPromptSubmit hook. Always returns 0."""
2
+ from __future__ import annotations
3
+ import json
4
+ import socket
5
+ import sys
6
+ from sidekick.paths import sidekick_dir
7
+
8
+ def _daemon_address():
9
+ if sys.platform == "win32":
10
+ port_file = sidekick_dir() / "daemon.port"
11
+ if not port_file.exists():
12
+ return None
13
+ try:
14
+ return ("127.0.0.1", int(port_file.read_text().strip()))
15
+ except ValueError:
16
+ return None
17
+ sock = sidekick_dir() / "daemon.sock"
18
+ return str(sock) if sock.exists() else None
19
+
20
+ def _connect(addr, timeout: float) -> socket.socket | None:
21
+ try:
22
+ if isinstance(addr, tuple):
23
+ return socket.create_connection(addr, timeout=timeout)
24
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
25
+ s.settimeout(timeout)
26
+ s.connect(addr)
27
+ return s
28
+ except OSError:
29
+ return None
30
+
31
+ def _format_hit(hit: dict, prompt: str) -> str:
32
+ title = hit.get("title") or "(untitled session)"
33
+ summary = hit.get("summary") or ""
34
+ tags = hit.get("tags") or ""
35
+ sid = hit["session_id"]
36
+ ended = hit.get("ended_at", "")
37
+ return (
38
+ f"\U0001f4a1 You may have done this before — session `{sid[:8]}` ({ended[:10]}): {title}\n"
39
+ f" {summary}\n"
40
+ f" Tags: {tags}\n"
41
+ f" Resume with: claude --resume {sid}\n"
42
+ )
43
+
44
+ def run(prompt: str, project: str | None = None, timeout_ms: int = 300) -> int:
45
+ """Print hint to stdout if confident match; otherwise silent. Always exits 0."""
46
+ addr = _daemon_address()
47
+ if not addr:
48
+ return 0
49
+ timeout = timeout_ms / 1000.0
50
+ sock = _connect(addr, timeout)
51
+ if not sock:
52
+ return 0
53
+ try:
54
+ sock.sendall(json.dumps({"op": "recall", "prompt": prompt, "project": project}).encode() + b"\n")
55
+ f = sock.makefile("rb")
56
+ line = f.readline()
57
+ resp = json.loads(line)
58
+ if resp.get("ok") and resp.get("hit"):
59
+ print(_format_hit(resp["hit"], prompt))
60
+ except (OSError, json.JSONDecodeError):
61
+ pass
62
+ finally:
63
+ sock.close()
64
+ return 0
65
+
66
+ def main() -> None:
67
+ """Entry: `sidekick-recall`. Reads the prompt from stdin."""
68
+ prompt = sys.stdin.read()
69
+ sys.exit(run(prompt))
sidekick/search.py ADDED
@@ -0,0 +1,85 @@
1
+ """Search functions. FTS5 first; semantic added in Task 8."""
2
+ from __future__ import annotations
3
+ from sidekick import db
4
+
5
+ def fts(query: str, limit: int = 20, project: str | None = None) -> list[dict]:
6
+ """FTS5 keyword search. Returns ranked hits with snippet."""
7
+ conn = db.connect()
8
+ sql = """
9
+ SELECT t.session_id, t.turn_idx, t.role, s.project,
10
+ snippet(turns_fts, 0, '[[', ']]', '...', 12) AS snippet,
11
+ s.title
12
+ FROM turns_fts JOIN turns t ON t.rowid = turns_fts.rowid
13
+ JOIN sessions s ON s.id = t.session_id
14
+ WHERE turns_fts MATCH ?
15
+ """
16
+ args: list = [query]
17
+ if project:
18
+ sql += " AND s.project LIKE ?"
19
+ args.append(f"%{project}%")
20
+ sql += " ORDER BY rank LIMIT ?"
21
+ args.append(limit)
22
+ return [
23
+ {"session_id": r[0], "turn_idx": r[1], "role": r[2],
24
+ "project": r[3], "snippet": r[4], "title": r[5]}
25
+ for r in conn.execute(sql, args).fetchall()
26
+ ]
27
+
28
+ import numpy as np
29
+ from sidekick.embeddings import Embedder, from_blob
30
+
31
+ _embedder: Embedder | None = None
32
+
33
+ def _embedder_singleton() -> Embedder:
34
+ global _embedder
35
+ if _embedder is None:
36
+ _embedder = Embedder()
37
+ return _embedder
38
+
39
+ def semantic(query: str, limit: int = 20, project: str | None = None) -> list[dict]:
40
+ """Cosine similarity over stored embeddings."""
41
+ conn = db.connect()
42
+ sql = """
43
+ SELECT e.session_id, e.turn_idx, e.vec, t.text, t.role, s.project, s.title
44
+ FROM embeddings e
45
+ JOIN turns t ON t.session_id=e.session_id AND t.turn_idx=e.turn_idx
46
+ JOIN sessions s ON s.id=e.session_id
47
+ """
48
+ args: list = []
49
+ if project:
50
+ sql += " WHERE s.project LIKE ?"
51
+ args.append(f"%{project}%")
52
+ rows = conn.execute(sql, args).fetchall()
53
+ if not rows:
54
+ return []
55
+ qv = _embedder_singleton().embed_one(query)
56
+ scored = []
57
+ for sid, tidx, blob, text, role, proj, title in rows:
58
+ v = from_blob(blob)
59
+ score = float(np.dot(qv, v))
60
+ scored.append({
61
+ "session_id": sid, "turn_idx": tidx, "role": role,
62
+ "project": proj, "title": title,
63
+ "snippet": text[:200], "score": score,
64
+ })
65
+ scored.sort(key=lambda h: h["score"], reverse=True)
66
+ return scored[:limit]
67
+
68
+ def combined(query: str, limit: int = 20, project: str | None = None) -> list[dict]:
69
+ """RRF fusion of FTS and semantic."""
70
+ fts_hits = fts(query, limit=limit * 2, project=project)
71
+ sem_hits = semantic(query, limit=limit * 2, project=project)
72
+ k = 60.0
73
+ rrf: dict[tuple[str, int], dict] = {}
74
+ for rank, h in enumerate(fts_hits, start=1):
75
+ key = (h["session_id"], h["turn_idx"])
76
+ rrf.setdefault(key, {**h, "score": 0.0, "mode": "fts"})
77
+ rrf[key]["score"] += 1.0 / (k + rank)
78
+ for rank, h in enumerate(sem_hits, start=1):
79
+ key = (h["session_id"], h["turn_idx"])
80
+ rrf.setdefault(key, {**h, "score": 0.0, "mode": "semantic"})
81
+ rrf[key]["score"] += 1.0 / (k + rank)
82
+ if rrf[key].get("mode") != "semantic":
83
+ rrf[key]["mode"] = "hybrid"
84
+ out = sorted(rrf.values(), key=lambda h: h["score"], reverse=True)
85
+ return out[:limit]
sidekick/titler.py ADDED
@@ -0,0 +1,121 @@
1
+ """Haiku-powered titler + heuristic status detection."""
2
+ from __future__ import annotations
3
+ import datetime as dt
4
+ import json
5
+ import os
6
+ from sidekick import db
7
+
8
+ MODEL = "claude-haiku-4-5-20251001"
9
+
10
+ PROMPT_TEMPLATE = """You are summarizing a developer's coding session with an AI assistant.
11
+
12
+ Output STRICT JSON with this schema:
13
+ {{
14
+ "title": "<5-word title in lowercase, no punctuation>",
15
+ "tags": ["<tag1>", "<tag2>", "<tag3>"],
16
+ "summary": "<one sentence describing what got done or what was attempted>"
17
+ }}
18
+
19
+ Only output JSON. No prose, no markdown fences.
20
+
21
+ First user prompts:
22
+ {first_user_prompts}
23
+
24
+ Last assistant turns:
25
+ {last_assistant_turns}
26
+ """
27
+
28
+ def _client():
29
+ import anthropic
30
+ return anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
31
+
32
+ def detect_status(session_id: str) -> str:
33
+ conn = db.connect()
34
+ rows = conn.execute(
35
+ "SELECT role, text, input_tokens FROM turns WHERE session_id=? ORDER BY turn_idx",
36
+ (session_id,),
37
+ ).fetchall()
38
+ if not rows:
39
+ return "unknown"
40
+ last_text = (rows[-1][1] or "").lower()
41
+ if "compact" in last_text or "context auto-compact" in last_text:
42
+ return "context_limit"
43
+ max_input = max((r[2] or 0) for r in rows)
44
+ if max_input > 180000:
45
+ return "context_limit"
46
+ last_user = [r for r in rows if r[0] == "user"][-3:]
47
+ short_corrections = sum(
48
+ 1 for r in last_user
49
+ if len((r[1] or "").strip()) <= 12
50
+ and any(w in (r[1] or "").lower() for w in ("no", "stop", "forget", "nope", "cancel"))
51
+ )
52
+ if short_corrections >= 2:
53
+ return "abandoned"
54
+ return "completed"
55
+
56
+ def _build_prompt(session_id: str) -> str:
57
+ conn = db.connect()
58
+ user_rows = conn.execute(
59
+ "SELECT text FROM turns WHERE session_id=? AND role='user' ORDER BY turn_idx LIMIT 5",
60
+ (session_id,),
61
+ ).fetchall()
62
+ asst_rows = conn.execute(
63
+ "SELECT text FROM turns WHERE session_id=? AND role='assistant' ORDER BY turn_idx DESC LIMIT 3",
64
+ (session_id,),
65
+ ).fetchall()
66
+ first_user = "\n---\n".join(r[0][:800] for r in user_rows)
67
+ last_asst = "\n---\n".join(r[0][:800] for r in reversed(asst_rows))
68
+ return PROMPT_TEMPLATE.format(
69
+ first_user_prompts=first_user or "(none)",
70
+ last_assistant_turns=last_asst or "(none)",
71
+ )
72
+
73
+ def title_session(session_id: str) -> dict | None:
74
+ """Call Haiku, parse JSON, persist to sessions row. Returns the parsed dict or None."""
75
+ status = detect_status(session_id)
76
+ prompt = _build_prompt(session_id)
77
+ client = _client()
78
+ resp = client.messages.create(
79
+ model=MODEL,
80
+ max_tokens=200,
81
+ messages=[{"role": "user", "content": prompt}],
82
+ )
83
+ raw = resp.content[0].text if resp.content else ""
84
+ raw = raw.strip()
85
+ if raw.startswith("```"):
86
+ raw = raw.strip("`")
87
+ if raw.startswith("json"):
88
+ raw = raw[4:]
89
+ try:
90
+ data = json.loads(raw)
91
+ except json.JSONDecodeError:
92
+ return None
93
+ title = (data.get("title") or "").strip().lower()[:80]
94
+ tags = ",".join((data.get("tags") or [])[:3])
95
+ summary = (data.get("summary") or "").strip()[:300]
96
+ conn = db.connect()
97
+ conn.execute(
98
+ """
99
+ UPDATE sessions
100
+ SET title=?, tags=?, summary=?, status=?, titled_at=?
101
+ WHERE id=?
102
+ """,
103
+ (title, tags, summary, status, dt.datetime.utcnow().isoformat(), session_id),
104
+ )
105
+ return {"title": title, "tags": tags, "summary": summary, "status": status}
106
+
107
+ def main() -> None:
108
+ """Entry: `sidekick-titler <session_id>` — titles untitled sessions if no arg."""
109
+ import sys
110
+ if len(sys.argv) > 1:
111
+ title_session(sys.argv[1])
112
+ return
113
+ conn = db.connect()
114
+ ids = [r[0] for r in conn.execute(
115
+ "SELECT id FROM sessions WHERE titled_at IS NULL LIMIT 50"
116
+ ).fetchall()]
117
+ for sid in ids:
118
+ try:
119
+ title_session(sid)
120
+ except Exception as e:
121
+ print(f"titler error on {sid}: {e}", file=__import__("sys").stderr)