sessionmemory 0.2.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,177 @@
1
+ """Read the git facts that identify which project a directory belongs to.
2
+
3
+ The repository root is the main checkout's working tree, derived from
4
+ `--git-common-dir`, never from `--show-toplevel` alone. In a worktree the two differ:
5
+ `--show-toplevel` returns the worktree directory while `--git-common-dir` returns the
6
+ main checkout's `.git`. Resolving on the former would make every worktree an
7
+ unregistered project. `_main_worktree` documents the layouts where the shared git
8
+ directory is not a `.git` beside the checkout.
9
+
10
+ An empty answer from git is not a fact about the directory. Git declining to inspect a
11
+ directory, timing out, or not being installed at all is a different state from git
12
+ reporting that the directory is not in a repository, and `GitContext.git_answered`
13
+ carries that distinction so a caller never reads git's silence as "there is no
14
+ repository here".
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import subprocess
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ from sessionmemory.lib import registry
25
+
26
+ GIT_TIMEOUT_SECONDS = 10
27
+
28
+ # The one git failure that is an answer rather than an outage. Every other non-zero exit,
29
+ # such as a `safe.directory` refusal or a held index lock, leaves the question open.
30
+ _NOT_A_REPOSITORY = "not a git repository"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class _GitAnswer:
35
+ """One git invocation's outcome, separating a definite answer from a failure to ask."""
36
+
37
+ stdout: str | None
38
+ answered: bool
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class GitContext:
43
+ """What git knows about a working directory."""
44
+
45
+ repo_root: Path | None
46
+ worktree_root: Path | None
47
+ remotes: tuple[str, ...]
48
+ git_answered: bool
49
+ is_bare: bool
50
+
51
+ @property
52
+ def is_worktree(self) -> bool:
53
+ """Report whether this directory is a linked worktree.
54
+
55
+ Returns:
56
+ bool: True when the working tree differs from the main checkout.
57
+ """
58
+ if self.repo_root is None or self.worktree_root is None:
59
+ return False
60
+ return self.repo_root != self.worktree_root
61
+
62
+
63
+ def _git(cwd: Path, *args: str) -> _GitAnswer:
64
+ """Run a git command and report both its output and whether git answered at all.
65
+
66
+ Args:
67
+ cwd (Path): The directory to run in.
68
+ *args: Arguments passed to git.
69
+
70
+ Returns:
71
+ _GitAnswer: The stripped stdout, and whether git reached a verdict. A non-zero
72
+ exit counts as a verdict only when git says the directory is not a
73
+ repository.
74
+ """
75
+ try:
76
+ completed = subprocess.run( # noqa: S603
77
+ ["git", *args], # noqa: S607
78
+ cwd=cwd,
79
+ capture_output=True,
80
+ text=True,
81
+ check=False,
82
+ timeout=GIT_TIMEOUT_SECONDS,
83
+ # The C locale keeps git's own messages in the wording the classifier reads.
84
+ env={**os.environ, "LC_ALL": "C", "LANGUAGE": ""},
85
+ )
86
+ except (OSError, subprocess.SubprocessError):
87
+ return _GitAnswer(stdout=None, answered=False)
88
+
89
+ if completed.returncode != 0:
90
+ return _GitAnswer(stdout=None, answered=_NOT_A_REPOSITORY in completed.stderr.lower())
91
+ return _GitAnswer(stdout=completed.stdout.strip() or None, answered=True)
92
+
93
+
94
+ def _main_worktree(cwd: Path, common_dir: Path, worktree_root: Path | None) -> Path:
95
+ """Return the main checkout's working tree directory.
96
+
97
+ The parent of `--git-common-dir` is the main checkout only where the shared git
98
+ directory is a `.git` inside that checkout. A submodule's is
99
+ `<superproject>/.git/modules/<path>` and a `--separate-git-dir` checkout's is
100
+ wherever it was put, so the parent rule would name a directory that is no working
101
+ tree at all: two submodules under one directory even share the same parent, which
102
+ would make the second one look like the first. Git sets `core.worktree` in exactly
103
+ the layouts where the parent rule fails, and it names the main working tree directly.
104
+
105
+ Args:
106
+ cwd (Path): The directory being inspected.
107
+ common_dir (Path): The absolute `--git-common-dir` for `cwd`.
108
+ worktree_root (Path | None): The `--show-toplevel` for `cwd`, if git gave one.
109
+
110
+ Returns:
111
+ Path: The main checkout's root.
112
+ """
113
+ # core.worktree is stored relative to the shared git directory, which is the same
114
+ # file for a linked worktree as for the checkout it was added from.
115
+ configured = _git(cwd, "config", "--local", "--get", "core.worktree").stdout
116
+ if configured:
117
+ return (common_dir / configured).resolve()
118
+
119
+ if common_dir.name == ".git":
120
+ return common_dir.resolve().parent
121
+
122
+ # No rule names the main checkout here, so this working tree stands in for it. It is
123
+ # a real directory, which the parent of the git directory would not be, and remotes
124
+ # are the primary registry key regardless.
125
+ return worktree_root or common_dir.resolve().parent
126
+
127
+
128
+ def git_context(cwd: Path) -> GitContext:
129
+ """Read the repository root, worktree root, and normalized remotes for `cwd`.
130
+
131
+ Args:
132
+ cwd (Path): The directory to inspect.
133
+
134
+ Returns:
135
+ GitContext: The git facts. Both roots are None when `cwd` is not in a repository,
136
+ when the repository is bare, and when git could not answer, so a caller that
137
+ cares which of those it is reads `git_answered` and `is_bare`.
138
+ """
139
+ common_dir = _git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir")
140
+ if common_dir.stdout is None:
141
+ return GitContext(
142
+ repo_root=None,
143
+ worktree_root=None,
144
+ remotes=(),
145
+ git_answered=common_dir.answered,
146
+ is_bare=False,
147
+ )
148
+
149
+ # A bare repository's --git-common-dir is the repository directory itself, so the
150
+ # parent-of-common-dir rule would name the directory containing it and hand that
151
+ # directory's whole subtree a project slug. It has no working tree, so no working
152
+ # directory belongs to it.
153
+ is_bare = _git(cwd, "rev-parse", "--is-bare-repository").stdout == "true"
154
+ if is_bare:
155
+ return GitContext(
156
+ repo_root=None, worktree_root=None, remotes=(), git_answered=True, is_bare=True
157
+ )
158
+
159
+ toplevel = _git(cwd, "rev-parse", "--show-toplevel").stdout
160
+ worktree_root = Path(toplevel).resolve() if toplevel else None
161
+ repo_root = _main_worktree(cwd, Path(common_dir.stdout), worktree_root)
162
+
163
+ # --local reads only this repository's config. Without it a stray remote in
164
+ # ~/.gitconfig would be read as this repository's remote and become a registry key
165
+ # every repository on the machine shares. A worktree reads the same local config as
166
+ # its main checkout, so a worktree still resolves to its parent project.
167
+ listed = _git(cwd, "config", "--local", "--get-regexp", r"^remote\..*\.url").stdout or ""
168
+ urls = [line.split(" ", 1)[1] for line in listed.splitlines() if " " in line]
169
+ remotes = tuple(dict.fromkeys(registry.normalize_remote(url) for url in urls if url))
170
+
171
+ return GitContext(
172
+ repo_root=repo_root,
173
+ worktree_root=worktree_root,
174
+ remotes=remotes,
175
+ git_answered=True,
176
+ is_bare=False,
177
+ )
@@ -0,0 +1,101 @@
1
+ """Turn a title into a filename stem.
2
+
3
+ A stem only has to be unique inside the flat directory it is written to. The slug
4
+ alphabet is the memoryfield spec's: lowercase ASCII letters, digits, and hyphens.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import unicodedata
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Container, Iterator
15
+
16
+ MAX_ID_LENGTH = 72
17
+
18
+ _NON_SLUG = re.compile(r"[^a-z0-9]+")
19
+
20
+ # What a leading date costs an id, reserved out of the length budget so a dated id
21
+ # still fits MAX_ID_LENGTH.
22
+ DATE_PREFIX_WIDTH = len("2026-08-18-")
23
+
24
+
25
+ def slugify(title: str, *, max_length: int = MAX_ID_LENGTH) -> str:
26
+ """Reduce a title to a lowercase, hyphenated, ascii slug.
27
+
28
+ Args:
29
+ title (str): The note title.
30
+ max_length (int): The longest slug to return. A caller whose id will carry a
31
+ prefix passes a smaller budget so the finished id still fits the limit.
32
+
33
+ Returns:
34
+ str: The slug.
35
+
36
+ Raises:
37
+ ValueError: If the title contains no characters that survive slugification.
38
+ """
39
+ folded = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
40
+ slug = _NON_SLUG.sub("-", folded.lower()).strip("-")
41
+
42
+ if not slug:
43
+ msg = f"title {title!r} has no usable characters for an id"
44
+ raise ValueError(msg)
45
+
46
+ if len(slug) > max_length:
47
+ slug = slug[:max_length].rsplit("-", 1)[0].strip("-")
48
+
49
+ return slug
50
+
51
+
52
+ def strip_date(title: str, created: str) -> str:
53
+ """Remove `created` from `title`, which the filename is about to supply anyway.
54
+
55
+ Only a date equal to the note's own creation date is removed, because the problem is
56
+ redundancy rather than discovery: matching any date-shaped run of digits would eat a
57
+ version number or a date the title is genuinely about. The title itself is never
58
+ rewritten, only the stem derived from it, so a wrong guess costs an odd filename and
59
+ not a renamed note.
60
+
61
+ Args:
62
+ title (str): The note title.
63
+ created (str): The note's creation date as an ISO string.
64
+
65
+ Returns:
66
+ str: The title with the date removed and its whitespace collapsed, or the title
67
+ unchanged when removing the date would leave nothing to slugify.
68
+ """
69
+ stripped = " ".join(title.replace(created, " ").split())
70
+ return stripped or title
71
+
72
+
73
+ def id_candidates(base: str, taken: Container[str]) -> Iterator[str]:
74
+ """Yield `base`, then `base-2`, `base-3`, ..., skipping anything `taken` reports as claimed.
75
+
76
+ `base` is expected to already be `slugify` output; its length guarantee (at most
77
+ `MAX_ID_LENGTH`) is what keeps the first candidate within the limit. `taken` is a
78
+ filter to skip likely collisions, not the final word on which candidate is free: a
79
+ caller with a stronger source of truth, such as an exclusive filesystem claim, is
80
+ expected to test each yielded candidate itself and keep pulling from this generator
81
+ on rejection.
82
+
83
+ Args:
84
+ base (str): The desired slug.
85
+ taken (Container[str]): Ids known to be in use, case folded. Every candidate
86
+ tested against it is lowercase, since `base` is a slug.
87
+
88
+ Yields:
89
+ str: Each untried candidate, in order, without limit.
90
+ """
91
+ if base not in taken:
92
+ yield base
93
+
94
+ counter = 2
95
+ while True:
96
+ suffix = f"-{counter}"
97
+ trimmed = base[: MAX_ID_LENGTH - len(suffix)].strip("-")
98
+ candidate = f"{trimmed}{suffix}"
99
+ if candidate not in taken:
100
+ yield candidate
101
+ counter += 1
@@ -0,0 +1,97 @@
1
+ """Assemble and render what a project's session starts with.
2
+
3
+ Guidance first, then titles. Titles rather than nothing because an agent does not
4
+ reliably decide to search; titles rather than summaries because this is a push channel
5
+ and must stay small. Bodies never enter an injection.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ from sessionmemory.lib import field, paths
14
+
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+ OPEN_ITEM = "- [ ]"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Injection:
23
+ """Everything one project's session start receives."""
24
+
25
+ project: str
26
+ titles: tuple[str, ...]
27
+ open_backlog: int
28
+ specs: tuple[str, ...]
29
+ plans: tuple[str, ...]
30
+
31
+
32
+ def _titles(directory: Path) -> tuple[str, ...]:
33
+ titles = [field.read_page(path).title or path.stem for path in field.iter_pages(directory)]
34
+ return tuple(sorted(titles, key=str.casefold))
35
+
36
+
37
+ def _open_backlog(path: Path) -> int:
38
+ if not path.is_file():
39
+ return 0
40
+ return sum(
41
+ 1
42
+ for line in path.read_text(encoding="utf-8").splitlines()
43
+ if line.lstrip().startswith(OPEN_ITEM)
44
+ )
45
+
46
+
47
+ def build(vault: Path, slug: str) -> Injection:
48
+ """Read the project's pages and files; the index is never consulted here."""
49
+ return Injection(
50
+ project=slug,
51
+ titles=_titles(paths.learnings_dir(vault, slug)),
52
+ open_backlog=_open_backlog(paths.backlog_path(vault, slug)),
53
+ specs=_titles(paths.specs_dir(vault, slug)),
54
+ plans=_titles(paths.plans_dir(vault, slug)),
55
+ )
56
+
57
+
58
+ GUIDANCE = """## Using this vault
59
+
60
+ Durable memory for this project lives in a vault of markdown pages. Below is the
61
+ list of what it already knows; each title is one `{command} search` away.
62
+
63
+ - A title below matches what you are doing: `{command} search "<words>"` returns
64
+ the page's path, and you Read it. Search before assuming nothing was written down.
65
+ - Past sessions: `{command} search "<words>" --logs`. Open work: read `backlog.md`
66
+ in the project's vault folder (`{command} project --json` prints every path).
67
+ - Something worth keeping past this session: `{command} new learning --title "..."
68
+ --summary "..." --cwd .` creates the page and prints the path to write prose into.
69
+ Keep a page under 8KB; more detail is another page.
70
+ - Specs and plans: `{command} new spec|plan --title "..." --cwd .` creates the file.
71
+ Edit `backlog.md`, specs, and plans directly; the CLI only creates pages."""
72
+
73
+
74
+ def render(injection: Injection, *, command: str = "sessionmemory") -> str:
75
+ """Render the block a session start receives: guidance, then titles, then work."""
76
+ lines = [GUIDANCE.format(command=command), "", "## What this project knows", ""]
77
+ lines.extend(f" - {title}" for title in injection.titles)
78
+ if not injection.titles:
79
+ lines.append(" nothing yet")
80
+ lines.extend(["", "## Open work", ""])
81
+ item = "item" if injection.open_backlog == 1 else "items"
82
+ lines.append(f" {injection.open_backlog} open backlog {item}")
83
+ lines.extend(f" spec: {title}" for title in injection.specs)
84
+ lines.extend(f" plan: {title}" for title in injection.plans)
85
+ return "\n".join(lines)
86
+
87
+
88
+ def payload(injection: Injection, *, command: str) -> dict[str, object]:
89
+ """Build the machine-readable form, a superset of the prose."""
90
+ return {
91
+ "guidance": GUIDANCE.format(command=command),
92
+ "project": injection.project,
93
+ "titles": list(injection.titles),
94
+ "open_backlog": injection.open_backlog,
95
+ "specs": list(injection.specs),
96
+ "plans": list(injection.plans),
97
+ }
@@ -0,0 +1,73 @@
1
+ """A project's session logs: one page per session, replaced whole on every write.
2
+
3
+ The body is replaced rather than appended to, so a caller sends all of it every time
4
+ and repeated calls are idempotent. Logs are a field of their own so they can be
5
+ searched on request without diluting the learnings field.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ from sessionmemory.lib import field, paths
15
+ from sessionmemory.lib.ids import slugify, strip_date
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+ SESSION_FIELD = "session_id"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Upserted:
25
+ """Where the log landed, and whether this call created it."""
26
+
27
+ path: Path
28
+ created: bool
29
+
30
+
31
+ def find_session_log(vault: Path, slug: str, session_id: str) -> Path | None:
32
+ """Return the page already recording this session, if any."""
33
+ for path in field.iter_pages(paths.logs_dir(vault, slug)):
34
+ if field.read_page(path).meta.get(SESSION_FIELD) == session_id:
35
+ return path
36
+ return None
37
+
38
+
39
+ def upsert_log( # noqa: PLR0913
40
+ vault: Path,
41
+ *,
42
+ slug: str,
43
+ session_id: str,
44
+ title: str,
45
+ summary: str,
46
+ body: str,
47
+ now: str,
48
+ today: str,
49
+ ) -> Upserted:
50
+ """Write this session's log, replacing the page it already has rather than adding one."""
51
+ existing = find_session_log(vault, slug, session_id)
52
+ if existing is not None:
53
+ page = field.read_page(existing)
54
+ meta = dict(page.meta)
55
+ meta.update({"title": title, "summary": summary, "updated": now})
56
+ field.write_page(existing, meta, body)
57
+ return Upserted(path=existing, created=False)
58
+
59
+ try:
60
+ stem = f"{today}-{slugify(strip_date(title, today))}"
61
+ except ValueError as error:
62
+ raise field.PageError(str(error)) from error
63
+ path = field.claim_filename(paths.logs_dir(vault, slug), title, stem=stem)
64
+ meta = {
65
+ "title": title,
66
+ "uuid": str(uuid.uuid4()),
67
+ "summary": summary,
68
+ "created": now,
69
+ "updated": now,
70
+ SESSION_FIELD: session_id,
71
+ }
72
+ field.write_page(path, meta, body)
73
+ return Upserted(path=path, created=True)
@@ -0,0 +1,77 @@
1
+ """Where a project's files live inside the vault.
2
+
3
+ `learnings/` and `logs/` are fields: flat directories of pages, each with its own
4
+ index. `specs/`, `plans/`, and `backlog.md` sit beside them and are never indexed. A
5
+ project's files are found by its slug and nothing else; there is no global scope.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from pathlib import Path
14
+
15
+ PROJECTS_DIR = "projects"
16
+ SYSTEM_DIR = "_system"
17
+
18
+ LEARNINGS_DIR = "learnings"
19
+ LOGS_DIR = "logs"
20
+ SPECS_DIR = "specs"
21
+ PLANS_DIR = "plans"
22
+ BACKLOG_FILE = "backlog.md"
23
+
24
+ FIELD_DIRS: tuple[str, ...] = (LEARNINGS_DIR, LOGS_DIR)
25
+
26
+
27
+ def project_dir(vault: Path, slug: str) -> Path:
28
+ """Return the folder holding everything belonging to one project."""
29
+ return vault / PROJECTS_DIR / slug
30
+
31
+
32
+ def learnings_dir(vault: Path, slug: str) -> Path:
33
+ """Return the project's learnings field."""
34
+ return project_dir(vault, slug) / LEARNINGS_DIR
35
+
36
+
37
+ def logs_dir(vault: Path, slug: str) -> Path:
38
+ """Return the project's logs field."""
39
+ return project_dir(vault, slug) / LOGS_DIR
40
+
41
+
42
+ def specs_dir(vault: Path, slug: str) -> Path:
43
+ """Return the project's specs folder."""
44
+ return project_dir(vault, slug) / SPECS_DIR
45
+
46
+
47
+ def plans_dir(vault: Path, slug: str) -> Path:
48
+ """Return the project's plans folder."""
49
+ return project_dir(vault, slug) / PLANS_DIR
50
+
51
+
52
+ def backlog_path(vault: Path, slug: str) -> Path:
53
+ """Return the project's backlog checklist file."""
54
+ return project_dir(vault, slug) / BACKLOG_FILE
55
+
56
+
57
+ def project_paths(vault: Path, slug: str) -> dict[str, str]:
58
+ """Return every path a caller of `sessionmemory project --json` reads, by its payload key.
59
+
60
+ The keys are what `sessionmemory project --json` emits and the plugin's hooks read.
61
+ """
62
+ return {
63
+ "project_dir": str(project_dir(vault, slug)),
64
+ "learnings": str(learnings_dir(vault, slug)),
65
+ "logs": str(logs_dir(vault, slug)),
66
+ "specs": str(specs_dir(vault, slug)),
67
+ "plans": str(plans_dir(vault, slug)),
68
+ "backlog": str(backlog_path(vault, slug)),
69
+ }
70
+
71
+
72
+ def iter_project_slugs(vault: Path) -> list[str]:
73
+ """Return every project folder's name, sorted, whether or not it is registered."""
74
+ root = vault / PROJECTS_DIR
75
+ if not root.is_dir():
76
+ return []
77
+ return sorted(entry.name for entry in root.iterdir() if entry.is_dir())