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.
- sessionmemory/__init__.py +1 -0
- sessionmemory/cli.py +62 -0
- sessionmemory/commands/__init__.py +1 -0
- sessionmemory/commands/_common.py +207 -0
- sessionmemory/commands/delete.py +71 -0
- sessionmemory/commands/doctor.py +30 -0
- sessionmemory/commands/export.py +61 -0
- sessionmemory/commands/init.py +88 -0
- sessionmemory/commands/inject.py +25 -0
- sessionmemory/commands/log.py +64 -0
- sessionmemory/commands/new.py +102 -0
- sessionmemory/commands/project.py +321 -0
- sessionmemory/commands/reindex.py +46 -0
- sessionmemory/commands/search.py +83 -0
- sessionmemory/lib/__init__.py +1 -0
- sessionmemory/lib/atomic.py +71 -0
- sessionmemory/lib/bootstrap.py +138 -0
- sessionmemory/lib/config.py +102 -0
- sessionmemory/lib/doctor.py +186 -0
- sessionmemory/lib/embed.py +123 -0
- sessionmemory/lib/export.py +25 -0
- sessionmemory/lib/field.py +181 -0
- sessionmemory/lib/fieldindex.py +214 -0
- sessionmemory/lib/frontmatter.py +163 -0
- sessionmemory/lib/gitinfo.py +177 -0
- sessionmemory/lib/ids.py +101 -0
- sessionmemory/lib/inject.py +97 -0
- sessionmemory/lib/log.py +73 -0
- sessionmemory/lib/paths.py +77 -0
- sessionmemory/lib/registry.py +269 -0
- sessionmemory/lib/resolve.py +75 -0
- sessionmemory-0.2.0.dist-info/METADATA +250 -0
- sessionmemory-0.2.0.dist-info/RECORD +35 -0
- sessionmemory-0.2.0.dist-info/WHEEL +4 -0
- sessionmemory-0.2.0.dist-info/entry_points.txt +3 -0
|
@@ -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
|
+
)
|
sessionmemory/lib/ids.py
ADDED
|
@@ -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
|
+
}
|
sessionmemory/lib/log.py
ADDED
|
@@ -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())
|