projectmem 0.0.1__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.
- projectmem/__init__.py +3 -0
- projectmem/cli.py +96 -0
- projectmem/commands/__init__.py +1 -0
- projectmem/commands/attempt.py +41 -0
- projectmem/commands/decision.py +13 -0
- projectmem/commands/fix.py +31 -0
- projectmem/commands/init.py +10 -0
- projectmem/commands/log.py +21 -0
- projectmem/commands/note.py +13 -0
- projectmem/commands/regenerate.py +10 -0
- projectmem/commands/search.py +17 -0
- projectmem/commands/show.py +9 -0
- projectmem/models.py +66 -0
- projectmem/search.py +15 -0
- projectmem/storage.py +136 -0
- projectmem/summary.py +132 -0
- projectmem-0.0.1.dist-info/METADATA +212 -0
- projectmem-0.0.1.dist-info/RECORD +21 -0
- projectmem-0.0.1.dist-info/WHEEL +4 -0
- projectmem-0.0.1.dist-info/entry_points.txt +3 -0
- projectmem-0.0.1.dist-info/licenses/LICENSE +21 -0
projectmem/__init__.py
ADDED
projectmem/cli.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from projectmem.commands import attempt as attempt_command
|
|
6
|
+
from projectmem.commands import decision as decision_command
|
|
7
|
+
from projectmem.commands import fix as fix_command
|
|
8
|
+
from projectmem.commands import init as init_command
|
|
9
|
+
from projectmem.commands import log as log_command
|
|
10
|
+
from projectmem.commands import note as note_command
|
|
11
|
+
from projectmem.commands import regenerate as regenerate_command
|
|
12
|
+
from projectmem.commands import search as search_command
|
|
13
|
+
from projectmem.commands import show as show_command
|
|
14
|
+
from projectmem.storage import ProjectMemError
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
help="Local-first memory for repos.",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
add_completion=False,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.callback()
|
|
24
|
+
def callback() -> None:
|
|
25
|
+
"""Capture issues, attempts, fixes, decisions, and notes for this repo."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command()
|
|
29
|
+
def init() -> None:
|
|
30
|
+
"""Create .projectmem/ in the current repo."""
|
|
31
|
+
init_command.run()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def log(text: str) -> None:
|
|
36
|
+
"""Start a new issue."""
|
|
37
|
+
log_command.run(text)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def attempt(
|
|
42
|
+
text: str,
|
|
43
|
+
worked: bool = typer.Option(False, "--worked", help="Mark the attempt as worked."),
|
|
44
|
+
failed: bool = typer.Option(False, "--failed", help="Mark the attempt as failed."),
|
|
45
|
+
partial: bool = typer.Option(False, "--partial", help="Mark the attempt as partial."),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Record an attempt on the current issue."""
|
|
48
|
+
attempt_command.run(text, worked=worked, failed=failed, partial=partial)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def fix(text: str) -> None:
|
|
53
|
+
"""Record a fix and close the current issue."""
|
|
54
|
+
fix_command.run(text)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command()
|
|
58
|
+
def decision(text: str) -> None:
|
|
59
|
+
"""Record a decision."""
|
|
60
|
+
decision_command.run(text)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def note(text: str) -> None:
|
|
65
|
+
"""Record a free-form note."""
|
|
66
|
+
note_command.run(text)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def show() -> None:
|
|
71
|
+
"""Print the current summary.md."""
|
|
72
|
+
show_command.run()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command()
|
|
76
|
+
def search(query: str) -> None:
|
|
77
|
+
"""Plain-text search across events."""
|
|
78
|
+
search_command.run(query)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command()
|
|
82
|
+
def regenerate() -> None:
|
|
83
|
+
"""Rebuild summary.md from events.jsonl."""
|
|
84
|
+
regenerate_command.run()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main() -> None:
|
|
88
|
+
try:
|
|
89
|
+
app()
|
|
90
|
+
except ProjectMemError as exc:
|
|
91
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
92
|
+
raise typer.Exit(1) from exc
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command implementations for projectmem."""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from projectmem.models import Event
|
|
6
|
+
from projectmem.storage import (
|
|
7
|
+
ProjectMemError,
|
|
8
|
+
append_event,
|
|
9
|
+
current_issue_id,
|
|
10
|
+
get_git_commit,
|
|
11
|
+
read_events,
|
|
12
|
+
)
|
|
13
|
+
from projectmem.summary import regenerate_summary
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run(text: str, *, worked: bool, failed: bool, partial: bool) -> None:
|
|
17
|
+
selected = [name for name, flag in [
|
|
18
|
+
("worked", worked),
|
|
19
|
+
("failed", failed),
|
|
20
|
+
("partial", partial),
|
|
21
|
+
] if flag]
|
|
22
|
+
if len(selected) > 1:
|
|
23
|
+
raise ProjectMemError("Use only one of --worked, --failed, or --partial.")
|
|
24
|
+
|
|
25
|
+
events = read_events()
|
|
26
|
+
issue_id = current_issue_id(events)
|
|
27
|
+
if issue_id is None:
|
|
28
|
+
raise ProjectMemError("No open issue found. Run `pm log <text>` first.")
|
|
29
|
+
|
|
30
|
+
outcome = selected[0] if selected else "partial"
|
|
31
|
+
append_event(
|
|
32
|
+
Event(
|
|
33
|
+
type="attempt",
|
|
34
|
+
issue_id=issue_id,
|
|
35
|
+
summary=text,
|
|
36
|
+
outcome=outcome,
|
|
37
|
+
git_commit=get_git_commit(),
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
regenerate_summary()
|
|
41
|
+
typer.echo(f"Recorded {outcome} attempt on #{issue_id}")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from projectmem.models import Event
|
|
6
|
+
from projectmem.storage import append_event, get_git_commit
|
|
7
|
+
from projectmem.summary import regenerate_summary
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run(text: str) -> None:
|
|
11
|
+
append_event(Event(type="decision", summary=text, git_commit=get_git_commit()))
|
|
12
|
+
regenerate_summary()
|
|
13
|
+
typer.echo("Recorded decision")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from projectmem.models import Event
|
|
6
|
+
from projectmem.storage import (
|
|
7
|
+
ProjectMemError,
|
|
8
|
+
append_event,
|
|
9
|
+
current_issue_id,
|
|
10
|
+
get_git_commit,
|
|
11
|
+
read_events,
|
|
12
|
+
)
|
|
13
|
+
from projectmem.summary import regenerate_summary
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run(text: str) -> None:
|
|
17
|
+
events = read_events()
|
|
18
|
+
issue_id = current_issue_id(events)
|
|
19
|
+
if issue_id is None:
|
|
20
|
+
raise ProjectMemError("No open issue found. Run `pm log <text>` first.")
|
|
21
|
+
|
|
22
|
+
append_event(
|
|
23
|
+
Event(
|
|
24
|
+
type="fix",
|
|
25
|
+
issue_id=issue_id,
|
|
26
|
+
summary=text,
|
|
27
|
+
git_commit=get_git_commit(),
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
regenerate_summary()
|
|
31
|
+
typer.echo(f"Fixed issue #{issue_id}")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from projectmem.models import Event
|
|
6
|
+
from projectmem.storage import append_event, get_git_commit, next_issue_id, read_events
|
|
7
|
+
from projectmem.summary import regenerate_summary
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run(text: str) -> None:
|
|
11
|
+
events = read_events()
|
|
12
|
+
issue_id = next_issue_id(events)
|
|
13
|
+
event = Event(
|
|
14
|
+
type="issue",
|
|
15
|
+
issue_id=issue_id,
|
|
16
|
+
summary=text,
|
|
17
|
+
git_commit=get_git_commit(),
|
|
18
|
+
)
|
|
19
|
+
append_event(event)
|
|
20
|
+
regenerate_summary()
|
|
21
|
+
typer.echo(f"Logged issue #{issue_id}")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from projectmem.models import Event
|
|
6
|
+
from projectmem.storage import append_event, get_git_commit
|
|
7
|
+
from projectmem.summary import regenerate_summary
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run(text: str) -> None:
|
|
11
|
+
append_event(Event(type="note", summary=text, git_commit=get_git_commit()))
|
|
12
|
+
regenerate_summary()
|
|
13
|
+
typer.echo("Recorded note")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from projectmem.search import search_events
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run(query: str) -> None:
|
|
9
|
+
matches = search_events(query)
|
|
10
|
+
if not matches:
|
|
11
|
+
typer.echo("No matches.")
|
|
12
|
+
return
|
|
13
|
+
|
|
14
|
+
for event in matches:
|
|
15
|
+
issue = f" #{event.issue_id}" if event.issue_id else ""
|
|
16
|
+
outcome = f" ({event.outcome})" if event.outcome else ""
|
|
17
|
+
typer.echo(f"{event.timestamp} {event.type}{issue}{outcome}: {event.summary}")
|
projectmem/models.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
VALID_EVENT_TYPES = {
|
|
10
|
+
"issue",
|
|
11
|
+
"hypothesis",
|
|
12
|
+
"attempt",
|
|
13
|
+
"fix",
|
|
14
|
+
"decision",
|
|
15
|
+
"note",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
VALID_OUTCOMES = {"worked", "failed", "partial"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def utc_now_iso() -> str:
|
|
22
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(
|
|
23
|
+
"+00:00", "Z"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Event:
|
|
29
|
+
type: str
|
|
30
|
+
summary: str
|
|
31
|
+
id: str = field(default_factory=lambda: f"evt_{uuid4().hex[:20]}")
|
|
32
|
+
timestamp: str = field(default_factory=utc_now_iso)
|
|
33
|
+
issue_id: str | None = None
|
|
34
|
+
outcome: str | None = None
|
|
35
|
+
files: list[str] = field(default_factory=list)
|
|
36
|
+
command: str | None = None
|
|
37
|
+
notes: str | None = None
|
|
38
|
+
git_commit: str | None = None
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
if self.type not in VALID_EVENT_TYPES:
|
|
42
|
+
raise ValueError(f"Unsupported event type: {self.type}")
|
|
43
|
+
if self.outcome is not None and self.outcome not in VALID_OUTCOMES:
|
|
44
|
+
raise ValueError(f"Unsupported outcome: {self.outcome}")
|
|
45
|
+
self.summary = self.summary.strip()
|
|
46
|
+
if not self.summary:
|
|
47
|
+
raise ValueError("Event summary cannot be empty")
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict[str, Any]:
|
|
50
|
+
data = asdict(self)
|
|
51
|
+
return {key: value for key, value in data.items() if value not in (None, [])}
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_dict(cls, data: dict[str, Any]) -> "Event":
|
|
55
|
+
return cls(
|
|
56
|
+
id=data.get("id") or f"evt_{uuid4().hex[:20]}",
|
|
57
|
+
timestamp=data.get("timestamp") or utc_now_iso(),
|
|
58
|
+
type=data["type"],
|
|
59
|
+
issue_id=data.get("issue_id"),
|
|
60
|
+
summary=data["summary"],
|
|
61
|
+
outcome=data.get("outcome"),
|
|
62
|
+
files=list(data.get("files") or []),
|
|
63
|
+
command=data.get("command"),
|
|
64
|
+
notes=data.get("notes"),
|
|
65
|
+
git_commit=data.get("git_commit"),
|
|
66
|
+
)
|
projectmem/search.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from projectmem.models import Event
|
|
4
|
+
from projectmem.storage import read_events
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def search_events(query: str) -> list[Event]:
|
|
8
|
+
needle = query.casefold()
|
|
9
|
+
return [
|
|
10
|
+
event
|
|
11
|
+
for event in read_events()
|
|
12
|
+
if needle in event.summary.casefold()
|
|
13
|
+
or (event.notes and needle in event.notes.casefold())
|
|
14
|
+
or any(needle in file_path.casefold() for file_path in event.files)
|
|
15
|
+
]
|
projectmem/storage.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from projectmem.models import Event
|
|
8
|
+
|
|
9
|
+
MEM_DIR = ".projectmem"
|
|
10
|
+
SUMMARY_FILE = "summary.md"
|
|
11
|
+
EVENTS_FILE = "events.jsonl"
|
|
12
|
+
CONFIG_FILE = "config.toml"
|
|
13
|
+
ISSUES_DIR = "issues"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProjectMemError(RuntimeError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def mem_path(root: Path | None = None) -> Path:
|
|
21
|
+
return (root or Path.cwd()) / MEM_DIR
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def require_mem_dir(root: Path | None = None) -> Path:
|
|
25
|
+
path = mem_path(root)
|
|
26
|
+
if not path.exists():
|
|
27
|
+
raise ProjectMemError("No .projectmem directory found. Run `projectmem init`.")
|
|
28
|
+
return path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def events_path(root: Path | None = None) -> Path:
|
|
32
|
+
return require_mem_dir(root) / EVENTS_FILE
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def summary_path(root: Path | None = None) -> Path:
|
|
36
|
+
return require_mem_dir(root) / SUMMARY_FILE
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def issues_dir(root: Path | None = None) -> Path:
|
|
40
|
+
return require_mem_dir(root) / ISSUES_DIR
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def initialize(root: Path | None = None) -> Path:
|
|
44
|
+
root_path = root or Path.cwd()
|
|
45
|
+
project_dir = mem_path(root_path)
|
|
46
|
+
project_dir.mkdir(exist_ok=True)
|
|
47
|
+
(project_dir / ISSUES_DIR).mkdir(exist_ok=True)
|
|
48
|
+
(project_dir / EVENTS_FILE).touch(exist_ok=True)
|
|
49
|
+
|
|
50
|
+
config = project_dir / CONFIG_FILE
|
|
51
|
+
if not config.exists():
|
|
52
|
+
config.write_text(
|
|
53
|
+
'summary_size_limit_kb = 20\nrecent_days = 30\nproject_description = ""\n',
|
|
54
|
+
encoding="utf-8",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
summary = project_dir / SUMMARY_FILE
|
|
58
|
+
if not summary.exists():
|
|
59
|
+
summary.write_text(
|
|
60
|
+
"# projectmem\n\n"
|
|
61
|
+
"_Last updated: never_\n\n"
|
|
62
|
+
"## What this project is\n"
|
|
63
|
+
"Not described yet.\n",
|
|
64
|
+
encoding="utf-8",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
ensure_gitignore_entry(root_path)
|
|
68
|
+
return project_dir
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def ensure_gitignore_entry(root: Path) -> None:
|
|
72
|
+
gitignore = root / ".gitignore"
|
|
73
|
+
entry = f"{MEM_DIR}/{EVENTS_FILE}"
|
|
74
|
+
if gitignore.exists():
|
|
75
|
+
lines = gitignore.read_text(encoding="utf-8").splitlines()
|
|
76
|
+
if entry in lines:
|
|
77
|
+
return
|
|
78
|
+
prefix = "" if not lines or lines[-1] == "" else "\n"
|
|
79
|
+
gitignore.write_text(
|
|
80
|
+
gitignore.read_text(encoding="utf-8") + f"{prefix}{entry}\n",
|
|
81
|
+
encoding="utf-8",
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
gitignore.write_text(f"{entry}\n", encoding="utf-8")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_events(root: Path | None = None) -> list[Event]:
|
|
88
|
+
path = events_path(root)
|
|
89
|
+
events: list[Event] = []
|
|
90
|
+
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
91
|
+
if not line.strip():
|
|
92
|
+
continue
|
|
93
|
+
try:
|
|
94
|
+
events.append(Event.from_dict(json.loads(line)))
|
|
95
|
+
except (json.JSONDecodeError, KeyError, ValueError) as exc:
|
|
96
|
+
raise ProjectMemError(f"Invalid event at {path}:{line_number}: {exc}") from exc
|
|
97
|
+
return events
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def append_event(event: Event, root: Path | None = None) -> Event:
|
|
101
|
+
path = events_path(root)
|
|
102
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
103
|
+
handle.write(json.dumps(event.to_dict(), sort_keys=True) + "\n")
|
|
104
|
+
return event
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def next_issue_id(events: list[Event]) -> str:
|
|
108
|
+
issue_ids = [
|
|
109
|
+
int(event.issue_id)
|
|
110
|
+
for event in events
|
|
111
|
+
if event.type == "issue" and event.issue_id and event.issue_id.isdigit()
|
|
112
|
+
]
|
|
113
|
+
return f"{(max(issue_ids) if issue_ids else 0) + 1:04d}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def current_issue_id(events: list[Event]) -> str | None:
|
|
117
|
+
closed = {event.issue_id for event in events if event.type == "fix" and event.issue_id}
|
|
118
|
+
for event in reversed(events):
|
|
119
|
+
if event.type == "issue" and event.issue_id not in closed:
|
|
120
|
+
return event.issue_id
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_git_commit(root: Path | None = None) -> str | None:
|
|
125
|
+
try:
|
|
126
|
+
result = subprocess.run(
|
|
127
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
128
|
+
cwd=root or Path.cwd(),
|
|
129
|
+
check=True,
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
)
|
|
133
|
+
except (OSError, subprocess.CalledProcessError):
|
|
134
|
+
return None
|
|
135
|
+
commit = result.stdout.strip()
|
|
136
|
+
return commit or None
|
projectmem/summary.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from projectmem.models import Event
|
|
9
|
+
from projectmem.storage import issues_dir, read_events, summary_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def regenerate_summary(root: Path | None = None) -> Path:
|
|
13
|
+
events = read_events(root)
|
|
14
|
+
content = build_summary(events, root or Path.cwd())
|
|
15
|
+
path = summary_path(root)
|
|
16
|
+
path.write_text(content, encoding="utf-8")
|
|
17
|
+
write_issue_files(events, root)
|
|
18
|
+
return path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_summary(events: list[Event], root: Path) -> str:
|
|
22
|
+
project_name = root.name
|
|
23
|
+
now = datetime.now(timezone.utc).date().isoformat()
|
|
24
|
+
issues = group_issue_events(events)
|
|
25
|
+
decisions = [event for event in events if event.type == "decision"]
|
|
26
|
+
notes = [event for event in events if event.type == "note"]
|
|
27
|
+
|
|
28
|
+
lines = [
|
|
29
|
+
f"# projectmem — {project_name}",
|
|
30
|
+
"",
|
|
31
|
+
f"_Last updated: {now}_",
|
|
32
|
+
"",
|
|
33
|
+
"## What this project is",
|
|
34
|
+
"Not described yet.",
|
|
35
|
+
"",
|
|
36
|
+
"## Recent issues",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
if not issues:
|
|
40
|
+
lines.append("- No issues logged yet.")
|
|
41
|
+
else:
|
|
42
|
+
for issue_id, issue_events in sorted(issues.items(), reverse=True):
|
|
43
|
+
issue = next(event for event in issue_events if event.type == "issue")
|
|
44
|
+
fix = next((event for event in reversed(issue_events) if event.type == "fix"), None)
|
|
45
|
+
status = "fixed" if fix else "open"
|
|
46
|
+
marker = "DONE" if fix else "OPEN"
|
|
47
|
+
outcome = f" -> {fix.summary}" if fix else ""
|
|
48
|
+
lines.append(f"- [{marker}] #{issue_id} {issue.summary}{outcome} ({status})")
|
|
49
|
+
lessons = [
|
|
50
|
+
event.summary
|
|
51
|
+
for event in issue_events
|
|
52
|
+
if event.type == "attempt" and event.outcome == "failed"
|
|
53
|
+
]
|
|
54
|
+
for lesson in lessons[-2:]:
|
|
55
|
+
lines.append(f" - Failed attempt: {lesson}")
|
|
56
|
+
|
|
57
|
+
lines.extend(["", "## Decisions"])
|
|
58
|
+
if decisions:
|
|
59
|
+
for event in decisions:
|
|
60
|
+
lines.append(f"- {event.summary}")
|
|
61
|
+
else:
|
|
62
|
+
lines.append("- No decisions logged yet.")
|
|
63
|
+
|
|
64
|
+
lines.extend(["", "## Notes"])
|
|
65
|
+
if notes:
|
|
66
|
+
for event in notes[-10:]:
|
|
67
|
+
lines.append(f"- {event.summary}")
|
|
68
|
+
else:
|
|
69
|
+
lines.append("- No notes logged yet.")
|
|
70
|
+
|
|
71
|
+
lines.extend(["", "## Key files"])
|
|
72
|
+
key_files = collect_files(events)
|
|
73
|
+
if key_files:
|
|
74
|
+
for file_path in key_files[:20]:
|
|
75
|
+
lines.append(f"- `{file_path}`")
|
|
76
|
+
else:
|
|
77
|
+
lines.append("- No key files logged yet.")
|
|
78
|
+
|
|
79
|
+
lines.extend(["", "## Open questions"])
|
|
80
|
+
lines.append("- None logged yet.")
|
|
81
|
+
lines.append("")
|
|
82
|
+
return "\n".join(lines)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def group_issue_events(events: list[Event]) -> dict[str, list[Event]]:
|
|
86
|
+
issues: dict[str, list[Event]] = defaultdict(list)
|
|
87
|
+
for event in events:
|
|
88
|
+
if event.issue_id:
|
|
89
|
+
issues[event.issue_id].append(event)
|
|
90
|
+
return dict(issues)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def collect_files(events: list[Event]) -> list[str]:
|
|
94
|
+
seen: set[str] = set()
|
|
95
|
+
files: list[str] = []
|
|
96
|
+
for event in events:
|
|
97
|
+
for explicit in event.files:
|
|
98
|
+
if explicit not in seen:
|
|
99
|
+
seen.add(explicit)
|
|
100
|
+
files.append(explicit)
|
|
101
|
+
for inferred in infer_file_mentions(event.summary):
|
|
102
|
+
if inferred not in seen:
|
|
103
|
+
seen.add(inferred)
|
|
104
|
+
files.append(inferred)
|
|
105
|
+
return files
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def infer_file_mentions(text: str) -> list[str]:
|
|
109
|
+
pattern = r"(?<![\w/.-])[\w./-]+\.[A-Za-z0-9]+(?::\d+)?"
|
|
110
|
+
return re.findall(pattern, text)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def write_issue_files(events: list[Event], root: Path | None = None) -> None:
|
|
114
|
+
for issue_id, issue_events in group_issue_events(events).items():
|
|
115
|
+
issue = next((event for event in issue_events if event.type == "issue"), None)
|
|
116
|
+
if issue is None:
|
|
117
|
+
continue
|
|
118
|
+
slug = slugify(issue.summary)
|
|
119
|
+
path = issues_dir(root) / f"{issue_id}-{slug}.md"
|
|
120
|
+
lines = [f"# #{issue_id} {issue.summary}", ""]
|
|
121
|
+
for event in issue_events:
|
|
122
|
+
detail = f"- {event.timestamp} `{event.type}`: {event.summary}"
|
|
123
|
+
if event.outcome:
|
|
124
|
+
detail += f" ({event.outcome})"
|
|
125
|
+
lines.append(detail)
|
|
126
|
+
lines.append("")
|
|
127
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def slugify(text: str) -> str:
|
|
131
|
+
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
132
|
+
return (slug or "issue")[:48].strip("-") or "issue"
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: projectmem
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Project memory for humans and AI tools: capture issues, attempts, fixes, and decisions in readable Markdown and JSONL.
|
|
5
|
+
Project-URL: Homepage, https://github.com/riponcm/projectmem
|
|
6
|
+
Project-URL: Repository, https://github.com/riponcm/projectmem
|
|
7
|
+
Project-URL: Issues, https://github.com/riponcm/projectmem/issues
|
|
8
|
+
Author: Ripon Chandra Malo
|
|
9
|
+
License: MIT
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: typer>=0.12
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# projectmem
|
|
27
|
+
|
|
28
|
+
`projectmem` gives your projects a readable memory.
|
|
29
|
+
|
|
30
|
+
It records the development story that usually disappears between commits:
|
|
31
|
+
issues, failed attempts, working fixes, decisions, notes, and the files involved.
|
|
32
|
+
The result is a small `.projectmem/` folder that both humans and AI tools can
|
|
33
|
+
read without a vendor-specific integration.
|
|
34
|
+
|
|
35
|
+
Git shows what changed. `projectmem` helps explain what happened, what was
|
|
36
|
+
tried, what failed, and why the current solution exists.
|
|
37
|
+
|
|
38
|
+
## Why It Exists
|
|
39
|
+
|
|
40
|
+
Project context is expensive to rebuild.
|
|
41
|
+
|
|
42
|
+
When you return to a codebase after days or weeks, you often need to rediscover
|
|
43
|
+
why something was implemented a certain way. AI coding tools have the same
|
|
44
|
+
problem: every new session may scan files, infer history, and repeat analysis
|
|
45
|
+
that was already done before.
|
|
46
|
+
|
|
47
|
+
`projectmem` is designed to reduce that repeated work. Instead of asking a
|
|
48
|
+
person or an AI assistant to reconstruct the full project story from source
|
|
49
|
+
files alone, it keeps a compact, structured memory in Markdown and JSONL.
|
|
50
|
+
|
|
51
|
+
For AI-assisted development, the intended pattern is simple:
|
|
52
|
+
|
|
53
|
+
- read `.projectmem/summary.md` first
|
|
54
|
+
- open detailed issue files only when needed
|
|
55
|
+
- avoid repeating failed approaches that were already recorded
|
|
56
|
+
- spend more context window on the current task instead of rediscovering old
|
|
57
|
+
decisions
|
|
58
|
+
|
|
59
|
+
The exact token savings depend on the project and workflow, but the goal is to
|
|
60
|
+
replace repeated broad scans of tens or hundreds of kilobytes with a concise
|
|
61
|
+
summary targeted to stay under roughly 20 KB. In practical AI workflows, that
|
|
62
|
+
can save a significant percentage of context tokens across repeated sessions,
|
|
63
|
+
especially on long-lived projects.
|
|
64
|
+
|
|
65
|
+
## What It Captures
|
|
66
|
+
|
|
67
|
+
`projectmem` is intentionally narrow. It is a project logbook for development
|
|
68
|
+
knowledge that does not fit cleanly into commits.
|
|
69
|
+
|
|
70
|
+
It captures:
|
|
71
|
+
|
|
72
|
+
- issues you are investigating
|
|
73
|
+
- hypotheses and attempts
|
|
74
|
+
- whether an attempt worked, failed, or partially helped
|
|
75
|
+
- final fixes
|
|
76
|
+
- architectural or implementation decisions
|
|
77
|
+
- notes and gotchas
|
|
78
|
+
- file references that matter to the story
|
|
79
|
+
|
|
80
|
+
It does not replace Git, issue trackers, documentation, code search, or AI
|
|
81
|
+
memory systems. It complements them by preserving the reasoning and failed paths
|
|
82
|
+
around a project.
|
|
83
|
+
|
|
84
|
+
## Installation
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install projectmem
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
This installs two commands:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
projectmem
|
|
94
|
+
pm
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Both commands run the same CLI. `projectmem` is the canonical command; `pm` is a
|
|
98
|
+
short alias for daily use.
|
|
99
|
+
|
|
100
|
+
## Quick Start
|
|
101
|
+
|
|
102
|
+
Initialize memory inside a project:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
cd path/to/your-project
|
|
106
|
+
projectmem init
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Record the story as you work:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pm log "auth tokens expire after 1h instead of 24h"
|
|
113
|
+
pm attempt "bumped JWT_EXPIRY in config.py" --failed
|
|
114
|
+
pm attempt "found hardcoded TTL in middleware" --worked
|
|
115
|
+
pm fix "changed TOKEN_TTL in auth/middleware.py:42"
|
|
116
|
+
pm decision "keep auth middleware stateless so workers can scale horizontally"
|
|
117
|
+
pm note "local test suite requires the test database to be running"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Read the current project memory:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pm show
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Search previous entries:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
pm search token
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Regenerate the summary from the raw event log:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pm regenerate
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Project Memory Files
|
|
139
|
+
|
|
140
|
+
`projectmem init` creates:
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
.projectmem/
|
|
144
|
+
├── summary.md
|
|
145
|
+
├── events.jsonl
|
|
146
|
+
├── issues/
|
|
147
|
+
└── config.toml
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
File roles:
|
|
151
|
+
|
|
152
|
+
- `.projectmem/summary.md` is the compact memory for humans and AI tools.
|
|
153
|
+
- `.projectmem/events.jsonl` is the append-only raw event log.
|
|
154
|
+
- `.projectmem/issues/` contains per-issue Markdown files for deeper context.
|
|
155
|
+
- `.projectmem/config.toml` stores project-specific settings.
|
|
156
|
+
|
|
157
|
+
By default, `projectmem init` adds `.projectmem/events.jsonl` to `.gitignore`.
|
|
158
|
+
The raw log can contain noisy or sensitive working notes. The generated summary,
|
|
159
|
+
issue files, and config are the parts most teams may choose to commit.
|
|
160
|
+
|
|
161
|
+
## Commands
|
|
162
|
+
|
|
163
|
+
| Command | Purpose |
|
|
164
|
+
|---|---|
|
|
165
|
+
| `projectmem init` | Create `.projectmem/` in the current project |
|
|
166
|
+
| `pm log <text>` | Start a new issue |
|
|
167
|
+
| `pm attempt <text> --worked` | Record a successful attempt |
|
|
168
|
+
| `pm attempt <text> --failed` | Record a failed attempt |
|
|
169
|
+
| `pm attempt <text> --partial` | Record a partially useful attempt |
|
|
170
|
+
| `pm fix <text>` | Record the fix for the current issue |
|
|
171
|
+
| `pm decision <text>` | Record a project decision |
|
|
172
|
+
| `pm note <text>` | Record a free-form note |
|
|
173
|
+
| `pm show` | Print `.projectmem/summary.md` |
|
|
174
|
+
| `pm search <query>` | Search recorded events |
|
|
175
|
+
| `pm regenerate` | Rebuild the summary from `events.jsonl` |
|
|
176
|
+
|
|
177
|
+
## AI Workflow
|
|
178
|
+
|
|
179
|
+
Any AI assistant can use `projectmem` without a plugin.
|
|
180
|
+
|
|
181
|
+
At the start of a session, ask the assistant to read:
|
|
182
|
+
|
|
183
|
+
```text
|
|
184
|
+
.projectmem/summary.md
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
That file is designed to provide the current project story: recent issues,
|
|
188
|
+
outcomes, known gotchas, decisions, and key files. If more detail is needed, the
|
|
189
|
+
assistant can open the relevant file under `.projectmem/issues/`.
|
|
190
|
+
|
|
191
|
+
This keeps the memory portable across tools such as Claude Code, Cursor, Codex,
|
|
192
|
+
custom agents, and plain terminal workflows. The storage is ordinary Markdown
|
|
193
|
+
and JSONL.
|
|
194
|
+
|
|
195
|
+
## Design Principles
|
|
196
|
+
|
|
197
|
+
- Local-first: no network calls, no cloud service, no telemetry.
|
|
198
|
+
- Human-readable: Markdown and JSONL only.
|
|
199
|
+
- AI-tool-agnostic: no dependency on one assistant or editor.
|
|
200
|
+
- Append-only raw log: the original event history remains available.
|
|
201
|
+
- Compact derived summary: the main AI-readable file stays small.
|
|
202
|
+
- Small CLI surface: a few commands focused on daily development memory.
|
|
203
|
+
|
|
204
|
+
## Development Status
|
|
205
|
+
|
|
206
|
+
`projectmem` is in early development. Version `0.0.1` is the initial package
|
|
207
|
+
release for testing and name reservation. The first stable public workflow will
|
|
208
|
+
come after real use across several projects.
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
projectmem/__init__.py,sha256=RnLA1Mg1dOdpW8_p9hfvIiMtsuOmelWBTn5hAHrmE-8,59
|
|
2
|
+
projectmem/cli.py,sha256=dan4mJvyuGUPjUsnzjBOv5KlxqLKa3VX-eYyS355OHE,2384
|
|
3
|
+
projectmem/models.py,sha256=wNrwqWgP7VkX-tc6UjAWKFja_rZp-uGNGeIte_G-W9E,2005
|
|
4
|
+
projectmem/search.py,sha256=LDJJwOmMUWF0TRsw4SQ9qIm2xbqFVe0yR1-rhut38mc,445
|
|
5
|
+
projectmem/storage.py,sha256=VdthSF56TRDY1F80oQlfLD51uorSodHgsMz2tyjvAxM,4035
|
|
6
|
+
projectmem/summary.py,sha256=qDFnhlGM2F37120MsHSmV7OY1n9ie0HLrhEGM2WRF4c,4468
|
|
7
|
+
projectmem/commands/__init__.py,sha256=xXtvqrB6FKi-acrLa9NAyzUwYkxT_y-Yq9qh7yg7qek,46
|
|
8
|
+
projectmem/commands/attempt.py,sha256=k0Fz98w7rigPfqVrmU3BdIjLsG5E13J5YtbCSfvfcJ4,1114
|
|
9
|
+
projectmem/commands/decision.py,sha256=c5EowWN6LJXwF6E9FlaKjn9l0OB8LVW1ruKYvPO3lu4,371
|
|
10
|
+
projectmem/commands/fix.py,sha256=EejsZJfSa8mPAb3tpGnIkNmyqkLpkbNo5NFXBH7AsxM,712
|
|
11
|
+
projectmem/commands/init.py,sha256=g2iqOwPEDxcup9JfeHNvkq7BRaxiPYCYfcoaGh1cZ7M,175
|
|
12
|
+
projectmem/commands/log.py,sha256=dxnbN8y10PohFdkAUpHwY_snMn5DyKjysjbxELdsDKw,544
|
|
13
|
+
projectmem/commands/note.py,sha256=xMbO5GjCVyaW3BRR-EVR78S6I0EOeFQyRmYgSk70GPA,363
|
|
14
|
+
projectmem/commands/regenerate.py,sha256=-Q9MK15o85eGD-Icxz8nj2Mc5Uem6gH0mmdCEjT7DUg,191
|
|
15
|
+
projectmem/commands/search.py,sha256=5Kuf-pmtVNnlxCr_oShfbVOywTqkRifomfLNuIg6Kl0,472
|
|
16
|
+
projectmem/commands/show.py,sha256=xXNHms6MauAAA74QwfVeZX5iYCKjPl78T_wTXpUuznE,174
|
|
17
|
+
projectmem-0.0.1.dist-info/METADATA,sha256=gyHO36TtCC_togD2SAAycicacKXPFDQsck5lkBMbLh8,6757
|
|
18
|
+
projectmem-0.0.1.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
|
|
19
|
+
projectmem-0.0.1.dist-info/entry_points.txt,sha256=olP4t3SzkwaCNpDqhe5OUzHxEOs6fqcbNEJWXQiVwlo,76
|
|
20
|
+
projectmem-0.0.1.dist-info/licenses/LICENSE,sha256=si3wH1MtIlq7k3Eh1RyOfLKuV_pJjupeLrmAHM2_QXM,1080
|
|
21
|
+
projectmem-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 projectmem contributors
|
|
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.
|