dogear 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.
dogear/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """dogear: favourite, list and resume Claude Code and Codex sessions."""
2
+
3
+ __version__ = "0.1.0"
dogear/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from dogear.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,81 @@
1
+ """Agent adapters. Adding an agent means adding one module with an Adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Protocol
8
+
9
+ if TYPE_CHECKING:
10
+ from dogear.proctree import ProcessTable
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Session:
15
+ agent: str
16
+ session_id: str
17
+ cwd: str
18
+ title: str
19
+ mtime: float
20
+ path: Path | None
21
+
22
+
23
+ class Adapter(Protocol):
24
+ name: str
25
+
26
+ def find(self, prefix: str) -> list[Session]: ...
27
+ def get(self, session_id: str) -> Session | None: ...
28
+ def exists(self, session_id: str) -> bool: ...
29
+ def recent(self, limit: int) -> list[Session]: ...
30
+ def is_agent_process(self, command: str) -> bool: ...
31
+ def detect(self, pid: int, table: "ProcessTable") -> list[Session]: ...
32
+ def resume_args(self, session_id: str) -> list[str]: ...
33
+
34
+
35
+ def command_basename(command: str) -> str:
36
+ """'C:\\x\\claude.exe' -> 'claude'; '-zsh' -> 'zsh' (login shells)."""
37
+ base = command.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
38
+ if base.lower().endswith(".exe"):
39
+ base = base[:-4]
40
+ return base.lstrip("-")
41
+
42
+
43
+ def newest_by_id(paths_with_ids: list[tuple[str, Path]]) -> dict[str, Path]:
44
+ """Several files can exist for one session id; keep the newest (spec §11b)."""
45
+ best: dict[str, tuple[float, Path]] = {}
46
+ for sid, p in paths_with_ids:
47
+ try:
48
+ m = p.stat().st_mtime
49
+ except OSError:
50
+ continue
51
+ if sid not in best or m > best[sid][0]:
52
+ best[sid] = (m, p)
53
+ return {sid: p for sid, (m, p) in best.items()}
54
+
55
+
56
+ def newest_first(by_id: dict[str, Path], limit: int) -> list[tuple[str, Path]]:
57
+ """Sort by mtime without failing when a file disappears meanwhile (agents prune old ones)."""
58
+ stamped = []
59
+ for sid, p in by_id.items():
60
+ try:
61
+ stamped.append((p.stat().st_mtime, sid, p))
62
+ except OSError:
63
+ continue
64
+ stamped.sort(key=lambda t: t[0], reverse=True)
65
+ return [(sid, p) for _, sid, p in stamped[:limit]]
66
+
67
+
68
+ def get_adapters() -> dict[str, Adapter]:
69
+ from dogear.agents.claude import ClaudeAdapter
70
+ from dogear.agents.codex import CodexAdapter
71
+
72
+ return {"claude": ClaudeAdapter(), "codex": CodexAdapter()}
73
+
74
+
75
+ def find_sessions(prefix: str, agent: str | None = None) -> list[Session]:
76
+ out: list[Session] = []
77
+ prefix = prefix.lower() # session ids are lowercase UUIDs
78
+ for name, adapter in get_adapters().items():
79
+ if agent is None or agent == name:
80
+ out.extend(adapter.find(prefix))
81
+ return out
@@ -0,0 +1,134 @@
1
+ """Claude Code session store (read-only). Internal files observed on Claude Code 2.1.x."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob
6
+ import json
7
+ import re
8
+ from pathlib import Path
9
+
10
+ from dogear.agents import Session, command_basename, newest_by_id, newest_first
11
+ from dogear.paths import claude_home
12
+
13
+ TITLE_LEN = 60
14
+ SESSION_FILE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
15
+
16
+
17
+ def _prompt_text(message: object) -> str:
18
+ if not isinstance(message, dict):
19
+ return ""
20
+ content = message.get("content")
21
+ if isinstance(content, str):
22
+ text = content
23
+ elif isinstance(content, list):
24
+ text = next(
25
+ (b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"),
26
+ "",
27
+ )
28
+ else:
29
+ return ""
30
+ text = " ".join(str(text).split())
31
+ if text.startswith("<"): # command wrappers and other meta content, not a real prompt
32
+ return ""
33
+ return text
34
+
35
+
36
+ def _shorten(text: str) -> str:
37
+ return text if len(text) <= TITLE_LEN else text[: TITLE_LEN - 1].rstrip() + "…"
38
+
39
+
40
+ class ClaudeAdapter:
41
+ name = "claude"
42
+
43
+ def __init__(self, home: Path | None = None):
44
+ self.home = home or claude_home()
45
+
46
+ # store -----------------------------------------------------------------
47
+
48
+ def _transcripts(self, prefix: str = "") -> dict[str, Path]:
49
+ # only the part below the store root is a pattern; the root may contain [ ] * ?
50
+ pattern = f"*/{glob.escape(prefix)}*.jsonl"
51
+ # sub-agent and other side files (e.g. agent-*.jsonl) are not resumable sessions
52
+ found = [(p.stem, p) for p in (self.home / "projects").glob(pattern) if SESSION_FILE.match(p.stem)]
53
+ return newest_by_id(found)
54
+
55
+ def _session(self, sid: str, path: Path) -> Session:
56
+ custom = ai = prompt = ""
57
+ cwd = ""
58
+ for obj in self._wanted_lines(path, lambda: not cwd, lambda: not prompt):
59
+ t = obj.get("type")
60
+ if not cwd and isinstance(obj.get("cwd"), str):
61
+ cwd = obj["cwd"]
62
+ if t == "custom-title" and isinstance(obj.get("customTitle"), str):
63
+ custom = obj["customTitle"]
64
+ elif t == "ai-title" and isinstance(obj.get("aiTitle"), str):
65
+ ai = obj["aiTitle"]
66
+ elif t == "user" and not prompt and not obj.get("isMeta"):
67
+ prompt = _prompt_text(obj.get("message"))
68
+ title = custom.strip() or ai.strip() or (_shorten(prompt) if prompt else "(untitled)")
69
+ try:
70
+ mtime = path.stat().st_mtime
71
+ except OSError:
72
+ mtime = 0.0
73
+ return Session("claude", sid, cwd, title, mtime, path)
74
+
75
+ @staticmethod
76
+ def _wanted_lines(path: Path, need_cwd, need_prompt):
77
+ """Parse only the lines that can matter: transcripts are large and most lines
78
+ are messages, so skip JSON-decoding them once cwd and the first prompt are known."""
79
+ try:
80
+ f = path.open("rb")
81
+ except OSError:
82
+ return
83
+ with f:
84
+ for raw in f:
85
+ if not (b'-title"' in raw or need_cwd() or (need_prompt() and b'"user"' in raw)):
86
+ continue
87
+ try:
88
+ obj = json.loads(raw)
89
+ except ValueError:
90
+ continue
91
+ if isinstance(obj, dict):
92
+ yield obj
93
+
94
+ def find(self, prefix: str) -> list[Session]:
95
+ return [self._session(sid, p) for sid, p in sorted(self._transcripts(prefix).items())]
96
+
97
+ def get(self, session_id: str) -> Session | None:
98
+ p = self._transcripts(session_id).get(session_id)
99
+ return self._session(session_id, p) if p else None
100
+
101
+ def exists(self, session_id: str) -> bool:
102
+ return session_id in self._transcripts(session_id)
103
+
104
+ def recent(self, limit: int) -> list[Session]:
105
+ return [self._session(sid, p) for sid, p in newest_first(self._transcripts(), limit)]
106
+
107
+ # processes -------------------------------------------------------------
108
+
109
+ def is_agent_process(self, command: str) -> bool:
110
+ norm = command.replace("\\", "/")
111
+ return (
112
+ command_basename(command) == "claude"
113
+ or "/claude/versions/" in norm
114
+ or "/@anthropic-ai/claude-code/" in norm
115
+ )
116
+
117
+ def detect(self, pid: int, table) -> list[Session]:
118
+ """Claude writes sessions/<pid>.json for each live process; stale files are ignored."""
119
+ try:
120
+ live = json.loads((self.home / "sessions" / f"{pid}.json").read_text(encoding="utf-8"))
121
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
122
+ return []
123
+ if not isinstance(live, dict) or live.get("pid") != pid or not isinstance(live.get("sessionId"), str):
124
+ return []
125
+ sid = live["sessionId"]
126
+ found = self.get(sid)
127
+ if found:
128
+ return [found]
129
+ cwd = live.get("cwd") if isinstance(live.get("cwd"), str) else ""
130
+ name = live.get("name") if isinstance(live.get("name"), str) and live.get("name") else "(untitled)"
131
+ return [Session("claude", sid, cwd, name, 0.0, None)]
132
+
133
+ def resume_args(self, session_id: str) -> list[str]:
134
+ return ["--resume", session_id]
dogear/agents/codex.py ADDED
@@ -0,0 +1,113 @@
1
+ """Codex CLI session store (read-only). Internal files observed on codex-cli 0.16x."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from dogear.agents import Session, command_basename, newest_by_id, newest_first
10
+ from dogear.jsonl import iter_jsonl
11
+ from dogear.paths import codex_home
12
+
13
+ UUID_LEN = 36
14
+
15
+
16
+ def _first_line(path: Path) -> dict:
17
+ try:
18
+ with path.open("r", encoding="utf-8", errors="replace") as f:
19
+ obj = json.loads(f.readline())
20
+ except (OSError, json.JSONDecodeError):
21
+ return {}
22
+ return obj if isinstance(obj, dict) else {}
23
+
24
+
25
+ class CodexAdapter:
26
+ name = "codex"
27
+
28
+ def __init__(self, home: Path | None = None):
29
+ self.home = home or codex_home()
30
+
31
+ # store -----------------------------------------------------------------
32
+
33
+ def _rollouts(self, prefix: str = "") -> dict[str, Path]:
34
+ # only the part below the store root is a pattern; the root may contain [ ] * ?
35
+ found = []
36
+ for p in (self.home / "sessions").glob("*/*/*/rollout-*.jsonl"):
37
+ sid = p.stem[-UUID_LEN:]
38
+ if sid.startswith(prefix):
39
+ found.append((sid, p))
40
+ return newest_by_id(found)
41
+
42
+ def _titles(self) -> dict[str, str]:
43
+ titles: dict[str, str] = {}
44
+ for obj in iter_jsonl(self.home / "session_index.jsonl"):
45
+ sid, name = obj.get("id"), obj.get("thread_name")
46
+ if isinstance(sid, str) and isinstance(name, str) and name.strip():
47
+ titles[sid] = name.strip()
48
+ return titles
49
+
50
+ def _session(self, sid: str, path: Path, titles: dict[str, str]) -> Session:
51
+ meta = _first_line(path)
52
+ payload = meta.get("payload") if meta.get("type") == "session_meta" else None
53
+ payload = payload if isinstance(payload, dict) else {}
54
+ if isinstance(payload.get("id"), str):
55
+ sid = payload["id"]
56
+ cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else ""
57
+ try:
58
+ mtime = path.stat().st_mtime
59
+ except OSError:
60
+ mtime = 0.0
61
+ return Session("codex", sid, cwd, titles.get(sid, "(untitled)"), mtime, path)
62
+
63
+ def find(self, prefix: str) -> list[Session]:
64
+ titles = self._titles()
65
+ return [self._session(sid, p, titles) for sid, p in sorted(self._rollouts(prefix).items())]
66
+
67
+ def get(self, session_id: str) -> Session | None:
68
+ p = self._rollouts(session_id).get(session_id)
69
+ return self._session(session_id, p, self._titles()) if p else None
70
+
71
+ def exists(self, session_id: str) -> bool:
72
+ return session_id in self._rollouts(session_id)
73
+
74
+ def recent(self, limit: int) -> list[Session]:
75
+ newest = newest_first(self._rollouts(), limit)
76
+ titles = self._titles()
77
+ return [self._session(sid, p, titles) for sid, p in newest]
78
+
79
+ # processes -------------------------------------------------------------
80
+
81
+ def is_agent_process(self, command: str) -> bool:
82
+ return command_basename(command) == "codex"
83
+
84
+ def detect(self, pid: int, table) -> list[Session]:
85
+ """The rollout the process has open; else rollouts in its cwd written since it started."""
86
+ sessions_dir = os.path.realpath(self.home / "sessions")
87
+ open_ids = []
88
+ for f in table.open_files(pid):
89
+ real = os.path.realpath(f)
90
+ name = os.path.basename(real)
91
+ if real.startswith(sessions_dir + os.sep) and name.startswith("rollout-") and name.endswith(".jsonl"):
92
+ open_ids.append(name[: -len(".jsonl")][-UUID_LEN:])
93
+ if open_ids:
94
+ return [s for sid in dict.fromkeys(open_ids) if (s := self.get(sid))]
95
+ cwd, start = table.cwd(pid), table.start_time(pid)
96
+ if not cwd or start is None:
97
+ return []
98
+ real_cwd = os.path.realpath(cwd)
99
+ titles = self._titles()
100
+ out = []
101
+ for sid, p in sorted(self._rollouts().items()):
102
+ try:
103
+ if p.stat().st_mtime < start:
104
+ continue
105
+ except OSError:
106
+ continue
107
+ s = self._session(sid, p, titles)
108
+ if s.cwd and os.path.realpath(s.cwd) == real_cwd:
109
+ out.append(s)
110
+ return out
111
+
112
+ def resume_args(self, session_id: str) -> list[str]:
113
+ return ["resume", session_id]
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: dogear
3
+ description: Save the current Claude Code session as a dogear favourite (bookmark) so it can be listed and resumed later from any folder. Use when the user runs /dogear or explicitly asks to dogear, bookmark or favourite this session. Not for browser bookmarks or bookmarks in code.
4
+ argument-hint: "[name] [tags, note, launch flags — plain words are fine]"
5
+ allowed-tools: Bash(command -v dogear), Bash(where dogear), Bash(dogear add *), Bash(dogear rename *)
6
+ ---
7
+
8
+ Save this session as a dogear favourite.
9
+
10
+ The user's request: $ARGUMENTS
11
+ (If that is empty, use what the user just asked for; it may be nothing beyond "save it".)
12
+
13
+ 1. Check that the `dogear` command is installed: run `command -v dogear`
14
+ (on Windows, `where dogear`).
15
+ 2. If it is not installed, reply with exactly this line and stop:
16
+ `dogear is not installed. Install it with: uv tool install dogear (or pipx install dogear)`
17
+ 3. Turn the request into one `dogear add` command:
18
+ - Already in flag form (`my-label -t work -n "note"`): use it unchanged.
19
+ - A name or label: put it first, as one word (lowercase, hyphens instead of
20
+ spaces, not only digits).
21
+ - Tags: one `-t TAG` per tag. A note: `-n "TEXT"`. Launch flags such as a model:
22
+ `--args "FLAGS"`. Quote any value with spaces.
23
+ - Nothing given: plain `dogear add`.
24
+ - Never add a session id unless the user gave one.
25
+
26
+ Examples:
27
+ - "save with name testtest" → `dogear add testtest`
28
+ - "call it jobs, tags work and client, note: auth half done" →
29
+ `dogear add jobs -t work -t client -n "auth half done"`
30
+ - "bookmark this with opus" → `dogear add --args "--model opus"`
31
+ 4. Run that command once with the shell tool.
32
+ 5. If the output says `already saved as OLD` and the user asked for a
33
+ different name NEW, run `dogear rename OLD NEW` once.
34
+ 6. Reply with the commands' output verbatim and nothing else. If a command
35
+ fails, show its error and stop; do not retry or run anything else.
@@ -0,0 +1,2 @@
1
+ policy:
2
+ allow_implicit_invocation: true
@@ -0,0 +1,32 @@
1
+ ---
2
+ name: dogear
3
+ description: Save the current Codex session as a dogear favourite (bookmark) so it can be listed and resumed later from any folder. Use when the user invokes $dogear or explicitly asks to dogear, bookmark or favourite this session. Not for browser bookmarks or bookmarks in code.
4
+ ---
5
+
6
+ Save this session as a dogear favourite.
7
+
8
+ The user's request is whatever they typed after `$dogear`, or what they just
9
+ asked for; it may be nothing beyond "save it".
10
+
11
+ 1. Check that the `dogear` command is installed: run `command -v dogear`
12
+ (on Windows, `where dogear`).
13
+ 2. If it is not installed, reply with exactly this line and stop:
14
+ `dogear is not installed. Install it with: uv tool install dogear (or pipx install dogear)`
15
+ 3. Turn the request into one `dogear add` command:
16
+ - Already in flag form (`my-label -t work -n "note"`): use it unchanged.
17
+ - A name or label: put it first, as one word (lowercase, hyphens instead of
18
+ spaces, not only digits).
19
+ - Tags: one `-t TAG` per tag. A note: `-n "TEXT"`. Launch flags: `--args "FLAGS"`.
20
+ Quote any value with spaces.
21
+ - Nothing given: plain `dogear add`.
22
+ - Never add a session id unless the user gave one.
23
+
24
+ Examples:
25
+ - "save with name testtest" → `dogear add testtest`
26
+ - "call it jobs, tags work and client, note: auth half done" →
27
+ `dogear add jobs -t work -t client -n "auth half done"`
28
+ 4. Run that command once with the shell tool.
29
+ 5. If the output says `already saved as OLD` and the user asked for a
30
+ different name NEW, run `dogear rename OLD NEW` once.
31
+ 6. Reply with the commands' output verbatim and nothing else. If a command
32
+ fails, show its error and stop; do not retry or run anything else.