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 ADDED
@@ -0,0 +1,3 @@
1
+ """Local-first memory for repos."""
2
+
3
+ __version__ = "0.0.1"
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,10 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from projectmem.storage import initialize
6
+
7
+
8
+ def run() -> None:
9
+ path = initialize()
10
+ typer.echo(f"Initialized {path}")
@@ -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,10 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from projectmem.summary import regenerate_summary
6
+
7
+
8
+ def run() -> None:
9
+ path = regenerate_summary()
10
+ typer.echo(f"Regenerated {path}")
@@ -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}")
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from projectmem.storage import summary_path
6
+
7
+
8
+ def run() -> None:
9
+ typer.echo(summary_path().read_text(encoding="utf-8"))
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.26.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ pm = projectmem.cli:main
3
+ projectmem = projectmem.cli:main
@@ -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.