Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion hooks/hooks-cursor.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"hooks": {
"sessionStart": [
{
"command": "export PATH=\"$HOME/.local/bin:$HOME/bin:$PATH\"; sr=session-recall; pgrep -f \"$sr sync\" >/dev/null 2>&1 || { command -v \"$sr\" >/dev/null 2>&1 && (\"$sr\" sync >/tmp/session-recall-index.log 2>&1 &) || echo 'session-recall: CLI not on PATH - history not synced (see README: Keeping the index fresh)' >>/tmp/session-recall-index.log; }"
"command": "export PATH=\"$HOME/.local/bin:$HOME/bin:$PATH\"; log=\"${TMPDIR:-/tmp}/session-recall-sync.log\"; command -v session-recall >/dev/null 2>&1 && (session-recall sync >\"$log\" 2>&1 &) || echo 'session-recall: CLI not on PATH - history not synced (see README: Keeping the index fresh)' >>\"$log\""
}
]
}
Expand Down
4 changes: 2 additions & 2 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"description": "Keep the session-recall index fresh — incremental re-index in the background at session start.",
"description": "Keep session-recall up to date — incremental sync in the background at session start (local index in solo mode, push in hub mode).",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"timeout": 10,
"command": "export PATH=\"$HOME/.local/bin:$HOME/bin:$PATH\"; sr=session-recall; pgrep -f \"$sr sync\" >/dev/null 2>&1 || { command -v \"$sr\" >/dev/null 2>&1 && (\"$sr\" sync >/tmp/session-recall-index.log 2>&1 &) || echo 'session-recall: CLI not on PATH - history not synced (see README: Keeping the index fresh)' >>/tmp/session-recall-index.log; }"
"command": "export PATH=\"$HOME/.local/bin:$HOME/bin:$PATH\"; log=\"${TMPDIR:-/tmp}/session-recall-sync.log\"; command -v session-recall >/dev/null 2>&1 && (session-recall sync >\"$log\" 2>&1 &) || echo 'session-recall: CLI not on PATH - history not synced (see README: Keeping the index fresh)' >>\"$log\""
}
]
}
Expand Down
44 changes: 31 additions & 13 deletions src/session_recall/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@ def _run_health(store: Store) -> int:
# is reported precisely by `index`; health must not crash and must
# not use the global DB mtime as a fake conversation timestamp.
pass
from .hub.client import CONFIG_PATH as HUB_CONFIG_PATH
secret_files = (HUB_CONFIG_PATH, config.DATA_DIR / "share" / "identity.json")
report = check_all(
store, make_embedder(), roots, transcripts, source_timestamps)
store, make_embedder(), roots, transcripts, source_timestamps,
secret_files)

width = max(len(d.name) for d in report.dimensions)
for d in report.dimensions:
Expand Down Expand Up @@ -133,19 +136,34 @@ def main(argv=None):
return hub_cli.run(args)
if args.cmd == "sync":
from .hub.client import HubConfig, HubError, push
cfg = HubConfig.load()
if cfg is None:
# Solo install: sync IS the old index run, unchanged. Re-entering
# main() keeps that one behaviour defined in exactly one place.
return main(["index"])
from .metadocs.lock import acquire_lock, release_lock
# Session start fires this, and sessions overlap: a second run racing
# the first used to mean two indexers fighting over one SQLite file
# ("database is locked") or two pushes re-uploading the same bytes. The
# guard lives here, not in the hook, because the hook is a shell string
# and no shell one-liner is portable — `pgrep` alone does not exist on
# Windows, where it silently degraded to no guard at all.
lock_fd = acquire_lock(config.DATA_DIR, "sync.lock")
if lock_fd is None:
# exit 0: the other run is doing this run's work, which is success
print("session-recall: a sync is already running — stepping aside")
return 0
try:
stats = push(cfg)
except HubError as failure:
print(f"session-recall: hub push failed: {failure}", file=sys.stderr)
return 1
print(f"pushed {stats['files']} transcript(s), "
f"{stats['uploaded_bytes']} B, {stats['redacted']} secret(s) redacted")
return 1 if stats["failed"] else 0
cfg = HubConfig.load()
if cfg is None:
# Solo install: sync IS the old index run, unchanged. Re-entering
# main() keeps that one behaviour defined in exactly one place.
return main(["index"])
try:
stats = push(cfg)
except HubError as failure:
print(f"session-recall: hub push failed: {failure}", file=sys.stderr)
return 1
print(f"pushed {stats['files']} transcript(s), "
f"{stats['uploaded_bytes']} B, {stats['redacted']} secret(s) redacted")
return 1 if stats["failed"] else 0
finally:
release_lock(lock_fd)
if args.cmd == "metadocs": # reads the index read-only via plain sqlite3
return metadocs_cli.run(args)
if args.cmd == "setup": # indexes via a child process (fresh env resolve)
Expand Down
18 changes: 14 additions & 4 deletions src/session_recall/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,19 @@
CODEX_ARCHIVED_SESSIONS = CODEX_HOME / "archived_sessions"


def _default_cursor_db() -> Path:
base = (Path.home() / "Library" / "Application Support"
if sys.platform == "darwin" else Path.home() / ".config")
def _default_cursor_db(platform: str | None = None, env: dict | None = None) -> Path:
"""Cursor keeps its per-user state where its VS Code base does, which is a
different directory on each OS — `%APPDATA%` on Windows, not `~/.config`,
which is why a Windows install reported `sources: missing cursor` while the
file sat there all along."""
platform = sys.platform if platform is None else platform
env = os.environ if env is None else env
if platform == "darwin":
base = Path.home() / "Library" / "Application Support"
elif platform.startswith("win"):
base = Path(env.get("APPDATA") or (Path.home() / "AppData" / "Roaming"))
else:
base = Path(env.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
return base / "Cursor" / "User" / "globalStorage" / "state.vscdb"


Expand Down Expand Up @@ -104,7 +114,7 @@ def user_lang(env: dict | None = None) -> str | None:
return lang
if live:
try:
stored = (json.loads(SETTINGS_PATH.read_text()).get("lang") or "")
stored = (json.loads(SETTINGS_PATH.read_text(encoding="utf-8")).get("lang") or "")
return stored.strip().lower() or None
except (OSError, ValueError):
return None
Expand Down
2 changes: 1 addition & 1 deletion src/session_recall/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ def workspace_folder(db_path: Path, workspace_id: str) -> tuple[str, str]:
return "", ""
ws = db_path.parent.parent / "workspaceStorage" / workspace_id / "workspace.json"
try:
folder = json.loads(ws.read_text()).get("folder") or ""
folder = json.loads(ws.read_text(encoding="utf-8")).get("folder") or ""
except (OSError, ValueError):
return "", ""
if folder.startswith("file://"):
Expand Down
29 changes: 28 additions & 1 deletion src/session_recall/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,29 @@ def check_embed_space(store: Store) -> Dimension:
"run `session-recall index` once to attest every source")


def check_secrets(secret_files: tuple[Path, ...]) -> Dimension | None:
"""Are the files holding keys actually private?

Worth a dimension rather than a comment because the answer depends on
where the data directory ended up, and nothing else in the tool would ever
say so. None when this machine holds no such file yet — an empty row would
only be noise before the first `hub join`."""
from .perms import exposure

present = [p for p in secret_files if Path(p).exists()]
if not present:
return None
leaks = [(p, why) for p in present if (why := exposure(p))]
if not leaks:
return Dimension("Key files", "GREEN",
f"{len(present)} private to this account")
path, why = leaks[0]
return Dimension(
"Key files", "RED", f"{path.name}: {why}",
"move the data directory back under your home directory, or treat the "
"key as shared and reissue it")


@dataclass(frozen=True)
class Report:
dimensions: list[Dimension]
Expand All @@ -144,7 +167,8 @@ class Report:

def check_all(store: Store, embedder, roots: dict[str, Path],
transcripts: list[Path],
source_timestamps: tuple[int | float, ...] = ()) -> Report:
source_timestamps: tuple[int | float, ...] = (),
secret_files: tuple[Path, ...] = ()) -> Report:
"""Every dimension plus one verdict. The verdict is the worst zone present: a
single dead dimension makes recall untrustworthy, and averaging would hide it
behind everything that still works."""
Expand All @@ -155,6 +179,9 @@ def check_all(store: Store, embedder, roots: dict[str, Path],
check_corpus(store),
check_paths(roots),
]
secrets = check_secrets(secret_files)
if secrets is not None:
dims.append(secrets)
order = {"GREEN": 0, "AMBER": 1, "RED": 2}
verdict = max((d.zone for d in dims), key=lambda z: order[z])
return Report(dimensions=dims, verdict=verdict)
9 changes: 5 additions & 4 deletions src/session_recall/hub/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@

import hashlib
import json
import os
import re
import secrets
import time
from pathlib import Path

from .. import perms

_KEY_RE = re.compile(r"^sr_([a-z0-9][a-z0-9-]{0,31})_([0-9a-f]{32})$")
_BEARER_RE = re.compile(r"^Bearer\s+(\S+)$", re.IGNORECASE)

Expand Down Expand Up @@ -62,15 +63,15 @@ def __init__(self, path: Path, clock=time.time):

def _load(self) -> dict:
try:
return json.loads(self.path.read_text())
return json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}

def _save(self, data: dict) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, sort_keys=True))
os.chmod(tmp, 0o600)
tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
perms.protect(tmp)
tmp.replace(self.path) # atomic: a crash mid-write never truncates the store

def issue(self, owner: str, note: str = "") -> str:
Expand Down
4 changes: 2 additions & 2 deletions src/session_recall/hub/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class HubConfig:
@classmethod
def load(cls, path: Path | None = None) -> "HubConfig | None":
try:
data = json.loads(Path(path or CONFIG_PATH).read_text())
data = json.loads(Path(path or CONFIG_PATH).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not data.get("url") or not data.get("key"):
Expand All @@ -89,7 +89,7 @@ def save(self, path: Path | None = None) -> None:
# 0600 before the key is written, not after: a world-readable moment
# is all it takes on a shared machine.
fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as fh:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump({"url": self.url, "key": self.key,
"consented": self.consented}, fh, indent=2)

Expand Down
9 changes: 5 additions & 4 deletions src/session_recall/hub/masking.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@

import hashlib
import json
import os
import re
import secrets as pysecrets
import subprocess
import time
from pathlib import Path

from .. import perms

MIN_LENGTH = 12
_DOPPLER_TIMEOUT_S = 30

Expand Down Expand Up @@ -150,7 +151,7 @@ def build(cls, entries: dict[str, str], salt: str | None = None) -> "SecretMap":
@classmethod
def load(cls, path: Path) -> "SecretMap":
try:
data = json.loads(Path(path).read_text())
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, ValueError):
return cls(salt="", labels={})
return cls(data.get("salt", ""), data.get("labels", {}),
Expand All @@ -162,8 +163,8 @@ def save(self, path: Path) -> None:
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(
{"salt": self.salt, "labels": self.labels, "updated": self.updated},
indent=2, sort_keys=True))
os.chmod(tmp, 0o600)
indent=2, sort_keys=True), encoding="utf-8")
perms.protect(tmp)
tmp.replace(path)

def __bool__(self) -> bool:
Expand Down
4 changes: 2 additions & 2 deletions src/session_recall/hub/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ def _path(self, owner: str) -> Path:

def read(self, owner: str) -> dict[str, int]:
try:
data = json.loads(self._path(owner).read_text())
data = json.loads(self._path(owner).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return {k: int(v) for k, v in data.items() if isinstance(v, int)}
Expand All @@ -151,7 +151,7 @@ def write(self, owner: str, rel: str, received: int) -> None:
path = self._path(owner)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, sort_keys=True))
tmp.write_text(json.dumps(data, sort_keys=True), encoding="utf-8")
tmp.replace(path)


Expand Down
2 changes: 1 addition & 1 deletion src/session_recall/metadocs/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ def run(args: argparse.Namespace) -> int:
f"sessions distilled so far: {len(marks.marks)}")
log = app_config.DATA_DIR / "metadocs.log"
if log.exists():
tail = log.read_text()[-800:]
tail = log.read_text(encoding="utf-8")[-800:]
print(f"--- log tail ---\n{tail.strip()}")
return 0

Expand Down
9 changes: 5 additions & 4 deletions src/session_recall/metadocs/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,15 @@ def load(data_dir: Path | None = None) -> MetaConfig | None:
p = config_path(data_dir)
if not p.exists():
return None
raw = json.loads(p.read_text())
raw = json.loads(p.read_text(encoding="utf-8"))
return MetaConfig(**raw)


def save(cfg: MetaConfig, data_dir: Path | None = None) -> None:
p = config_path(data_dir)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(asdict(cfg), indent=2, ensure_ascii=False))
p.write_text(json.dumps(asdict(cfg), indent=2, ensure_ascii=False),
encoding="utf-8")


class Watermarks:
Expand All @@ -68,7 +69,7 @@ class Watermarks:
def __init__(self, path: Path):
self.path = path
self.marks: dict[str, int] = (
json.loads(path.read_text()) if path.exists() else {})
json.loads(path.read_text(encoding="utf-8")) if path.exists() else {})

def key(self, source: str, session_id: str) -> str:
return f"{source}:{session_id}"
Expand All @@ -83,4 +84,4 @@ def advance(self, source: str, session_id: str, ts: int) -> None:

def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps(self.marks))
self.path.write_text(json.dumps(self.marks), encoding="utf-8")
2 changes: 1 addition & 1 deletion src/session_recall/metadocs/distill.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def distill(project: str, session_key: str, turns: list) -> bool | None:
with tempfile.TemporaryDirectory() as empty:
cfg_path = Path(empty) / "mcp.json"
cfg_path.write_text(json.dumps(
_mcp_config(repo, project, session_key)))
_mcp_config(repo, project, session_key)), encoding="utf-8")
argv = [shutil.which("claude") or "claude", "-p",
"--no-session-persistence",
"--mcp-config", str(cfg_path),
Expand Down
14 changes: 7 additions & 7 deletions src/session_recall/metadocs/entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,17 @@ def save(repo: Path, entry: Entry) -> Path:
entry.updated = _today()
path = entry_path(repo, entry)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(render(entry))
path.write_text(render(entry), encoding="utf-8")
return path


def load(repo: Path, entry_id: str) -> Entry | None:
for path in repo.glob(f"*/*/{entry_id}.md"):
got = parse(path.read_text())
got = parse(path.read_text(encoding="utf-8"))
if got and got.id == entry_id:
return got
for path in repo.glob(f"{USER_DIR}/{entry_id}.md"):
got = parse(path.read_text())
got = parse(path.read_text(encoding="utf-8"))
if got and got.id == entry_id:
return got
return None
Expand Down Expand Up @@ -136,7 +136,7 @@ def iter_entries(repo: Path, project: str | None = None,
if path in seen:
continue
seen.add(path)
entry = parse(path.read_text())
entry = parse(path.read_text(encoding="utf-8"))
if entry:
yield entry

Expand Down Expand Up @@ -191,7 +191,7 @@ def migrate(repo: Path) -> int:
if old.name not in _OLD_FILES:
continue
project, category = old.parent.name, old.stem
for title, body in _sections(old.read_text()):
for title, body in _sections(old.read_text(encoding="utf-8")):
m = _SOURCES_RE.search(body)
sources = [s.strip() for s in m.group(1).split(",")] if m else []
body_clean = _SOURCES_RE.sub("", body).strip()
Expand All @@ -202,8 +202,8 @@ def migrate(repo: Path) -> int:
old.unlink()
user_map = repo / "USER.md"
if user_map.exists():
for title, body in _sections(user_map.read_text()) or [("Карта данных",
user_map.read_text())]:
raw_user_map = user_map.read_text(encoding="utf-8")
for title, body in _sections(raw_user_map) or [("Карта данных", raw_user_map)]:
m = _SOURCES_RE.search(body)
sources = [s.strip() for s in m.group(1).split(",")] if m else []
save(repo, Entry(id=new_id("user"), project="", category="user",
Expand Down
2 changes: 1 addition & 1 deletion src/session_recall/metadocs/indexing.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def index_metadocs(store: Store, embedder, repo: Path) -> int:
sig = f"{_SIG_TAG}:{_embed_fp()}:{int(st.st_mtime)}:{st.st_size}"
if store.is_indexed(str(path), sig):
continue
entry = entries.parse(path.read_text())
entry = entries.parse(path.read_text(encoding="utf-8"))
if entry is None:
continue # half-written or foreign file: skip, no marker
text = f"{entry.title}\n\n{entry.body}"
Expand Down
Loading
Loading