pygim 0.1.0.dev1__tar.gz → 0.1.0.dev2__tar.gz

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.
Files changed (59) hide show
  1. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/PKG-INFO +1 -1
  2. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_cli/_cli_app.py +73 -22
  3. pygim-0.1.0.dev2/src/_pygim/_mcp/_packs.py +124 -0
  4. pygim-0.1.0.dev2/src/_pygim/_mcp/_stores.py +232 -0
  5. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_mcp/memory.py +240 -17
  6. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/memory/adapter/bindings.cpp +3 -0
  7. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/__main__.py +34 -17
  8. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/memory.pyi +1 -0
  9. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim.egg-info/PKG-INFO +1 -1
  10. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim.egg-info/SOURCES.txt +2 -0
  11. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/LICENSE +0 -0
  12. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/README.rst +0 -0
  13. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/pyproject.toml +0 -0
  14. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/setup.cfg +0 -0
  15. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/setup.py +0 -0
  16. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/__init__.py +0 -0
  17. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_cli/__init__.py +0 -0
  18. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_cli/_commenter.py +0 -0
  19. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_cli/_docs_serve.py +0 -0
  20. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_core/__init__.py +0 -0
  21. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_core/_error_msgs.py +0 -0
  22. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_core/_exceptions.py +0 -0
  23. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_core/_typing.py +0 -0
  24. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_core/protocols.py +0 -0
  25. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_core/protocols.pyi +0 -0
  26. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_mcp/__init__.py +0 -0
  27. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/datagen/bindings.cpp +0 -0
  28. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/each/bindings.cpp +0 -0
  29. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/pathlike/adapter/bindings.cpp +0 -0
  30. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/pathlike/adapter/third_party/simdjson/simdjson.cpp +0 -0
  31. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/persistence/adapter/bindings.cpp +0 -0
  32. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/persistence/adapter/test_bindings.cpp +0 -0
  33. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/tools/bench_bindings.cpp +0 -0
  34. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/utils/bindings.cpp +0 -0
  35. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/wiring/factory/bindings.cpp +0 -0
  36. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/wiring/ioc/bindings.cpp +0 -0
  37. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/wiring/registry/bindings.cpp +0 -0
  38. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/__init__.py +0 -0
  39. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/_stubs.py +0 -0
  40. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/ai/__init__.py +0 -0
  41. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/core/__init__.py +0 -0
  42. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/core/explib.py +0 -0
  43. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/core/funcs.py +0 -0
  44. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/core/protocols.py +0 -0
  45. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/core/testing.py +0 -0
  46. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/core/testing.pyi +0 -0
  47. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/core/timing_and_profiling.py +0 -0
  48. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/pathlike.pyi +0 -0
  49. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/persistence.py +0 -0
  50. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim.egg-info/dependency_links.txt +0 -0
  51. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim.egg-info/entry_points.txt +0 -0
  52. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim.egg-info/requires.txt +0 -0
  53. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim.egg-info/top_level.txt +0 -0
  54. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/tests/static/mapping_proofs.cpp +0 -0
  55. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/tests/static/memory_proofs.cpp +0 -0
  56. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/tests/static/pathlike_core_proofs.cpp +0 -0
  57. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/tests/static/pathlike_parity_proofs.cpp +0 -0
  58. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/tests/static/pathlike_scalar_proofs.cpp +0 -0
  59. {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/tests/static/registry_core_proofs.cpp +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygim
3
- Version: 0.1.0.dev1
3
+ Version: 0.1.0.dev2
4
4
  Summary: Full Python Gimmicks project with all of its sub-projects.
5
5
  Project-URL: homepage, https://github.com/Debith/pygim
6
6
  Project-URL: documentation, https://readthedocs.org/projects/pygim/
@@ -9,6 +9,7 @@ from subprocess import Popen, DEVNULL
9
9
  import sys
10
10
  import shutil
11
11
  import functools
12
+ from typing import Optional
12
13
  from pathlib import Path
13
14
  from dataclasses import dataclass
14
15
  from importlib import import_module
@@ -93,19 +94,8 @@ class GimmicksCliApp:
93
94
  except (FileNotFoundError, _docs_serve.ServeError) as exc:
94
95
  raise click.ClickException(str(exc)) from exc
95
96
 
96
- def memory_init(self, *, root: str) -> None:
97
- """Create a memory repository at *root* with the base vocabulary."""
98
- from pygim.memory import Memory
99
-
100
- try:
101
- Memory.init(root)
102
- except RuntimeError as exc:
103
- raise click.ClickException(str(exc)) from exc
104
- m = Memory(root)
105
- click.echo(f"created {m.root} at v{m.version} — add a pack under taxonomy/, or start with the base")
106
-
107
- def memory_mcp(self, *, root: str) -> None:
108
- """Serve the repository at *root* over MCP on stdio."""
97
+ def memory_mcp(self, *, root: Optional[str]) -> None:
98
+ """Serve the project's store over MCP on stdio; the server starts even without one."""
109
99
  from _pygim._mcp import memory as server
110
100
  from pygim.memory import VocabularyError
111
101
 
@@ -114,33 +104,94 @@ class GimmicksCliApp:
114
104
  except (RuntimeError, VocabularyError) as exc:
115
105
  raise click.ClickException(str(exc)) from exc
116
106
 
117
- def memory_ingest(self, *, corpus: str, root: str) -> None:
118
- """Ingest a hand-written corpus file into the repository at *root*."""
107
+ @staticmethod
108
+ def _store(root: Optional[str]) -> str:
109
+ """The store for this project, or a ClickException that says how to make one."""
110
+ from _pygim._mcp import _stores
111
+
112
+ found = _stores.find(root)
113
+ if found is None or not found.exists:
114
+ raise click.ClickException(_stores.guidance())
115
+ return str(found.root)
116
+
117
+ def memory_setup(self, *, kind: Optional[str], name: Optional[str], path: Optional[str], source: Optional[str],
118
+ register: bool) -> None:
119
+ """Find or create the project's store, point the clone at it, and register the server."""
120
+ from _pygim._mcp import _stores
121
+
122
+ cwd = Path.cwd()
123
+ try:
124
+ if kind == "user":
125
+ root = _stores.setup_user(cwd, name, Path(source) if source else None)
126
+ how = "a user-level store"
127
+ elif kind == "local":
128
+ root = _stores.setup_local(cwd, Path(source) if source else None)
129
+ how = "the project's own .memory"
130
+ elif kind == "branch":
131
+ root = _stores.setup_branch(cwd, Path(path) if path else None, Path(source) if source else None)
132
+ how = f"the `{_stores.BRANCH}` branch"
133
+ else:
134
+ found = _stores.find(cwd=cwd)
135
+ if found is None or not found.exists:
136
+ raise click.ClickException("no store yet — choose where it lives: `oo memory setup --user` "
137
+ "(your user data directory) or `oo memory setup --branch` "
138
+ "(an orphan branch shared through git)")
139
+ root, how = found.root, found.how
140
+ except RuntimeError as exc:
141
+ raise click.ClickException(str(exc)) from exc
142
+ click.echo(f"store: {root} ({how})")
143
+ if _stores.git(["config", "--get", _stores.GIT_KEY], cwd):
144
+ click.echo(f"every worktree of this clone finds it through `git config {_stores.GIT_KEY}`")
145
+ local = Path(cwd) / ".mcp.json"
146
+ if local.is_file() and _stores.SERVER in local.read_text(encoding="utf-8"):
147
+ click.echo(f"note: {local} also defines `{_stores.SERVER}`; a project-scoped entry overrides the user one")
148
+ if register:
149
+ r = _stores.register()
150
+ click.echo(r.message)
151
+ if not r.ran and not r.message.startswith(f"`{_stores.SERVER}` is already"):
152
+ click.echo(" " + " ".join(r.command))
153
+
154
+ def memory_ingest(self, *, corpus: str, root: Optional[str]) -> None:
155
+ """Ingest a hand-written corpus file into the project's store."""
119
156
  from pygim.memory import Memory
120
157
 
121
- result = Memory(root).ingest(corpus)
158
+ result = Memory(self._store(root)).ingest(corpus)
122
159
  click.echo(f"{result['added']} added, {result['superseded']} superseded, {result['unchanged']} unchanged")
123
160
  for line in result["refused"]:
124
161
  click.echo(f" refused {line}")
125
162
  if result["refused"]:
126
163
  raise click.exceptions.Exit(1)
127
164
 
128
- def memory_accept(self, *, memory: str, reason: str, root: str) -> None:
129
- """Accept a generalisation in the repository at *root*."""
165
+ def memory_accept(self, *, memory: Optional[str], pack: Optional[str], reason: str, replace: bool,
166
+ root: Optional[str]) -> None:
167
+ """Accept a generalisation (*memory*) or a drafted vocabulary pack (*pack*) in the project's store."""
130
168
  from pygim.memory import Memory
131
169
 
132
- result = Memory(root).accept(memory, reason=reason)
170
+ if (memory is None) == (pack is None):
171
+ raise click.UsageError("accept one thing: a generalisation as MEMORY, or a vocabulary draft with --pack")
172
+ if pack is not None:
173
+ from _pygim._mcp import _packs
174
+
175
+ done = _packs.accept(Path(self._store(root)), Path(pack).resolve(), replace=replace)
176
+ if not done["ok"]:
177
+ raise click.ClickException(done["errors"])
178
+ click.echo(f"accepted pack `{done['pack']}`: {len(done['dimensions'])} dimension(s), {done['values']} value(s)"
179
+ + (f"; {len(done['inventory'])} document(s) added to the inventory" if done["inventory"] else ""))
180
+ click.echo("a running MCP server picks it up at its next call")
181
+ return
182
+ result = Memory(self._store(root)).accept(memory, reason=reason)
133
183
  if not result["ok"]:
134
184
  raise click.ClickException(f"{result['refused']}: {result['message']}"
135
185
  + "".join(f"\n {fact}" for fact in result["facts"]))
136
186
  click.echo(f"accepted {memory} — its instances fold from the next read (report: {result['report']})")
137
187
 
138
- def memory_status(self, *, root: str) -> None:
188
+ def memory_status(self, *, root: Optional[str]) -> None:
139
189
  """Print where the repository at *root* stands."""
140
190
  from pygim.memory import Memory
141
191
 
142
- info = Memory(root).session()
143
- click.echo(f"{root}: v{info['version']}, {info['memories']} memories, vocabulary {info['taxonomy'][:12]}")
192
+ store = self._store(root)
193
+ info = Memory(store).session()
194
+ click.echo(f"{store}: v{info['version']}, {info['memories']} memories, vocabulary {info['taxonomy'][:12]}")
144
195
  for r in info["reviews"]:
145
196
  click.echo(f" review ({r['kind']}): {r['text']}")
146
197
  for p in info["proposals"]:
@@ -0,0 +1,124 @@
1
+ """Drafted vocabulary packs and the citations they rest on.
2
+
3
+ A pack drafted by the ``prepare-vocabulary`` prompt is a proposal until a person accepts it
4
+ (overview §4.6). ``check`` loads a proposal beside the store's current vocabulary in a scratch
5
+ store, so every error comes back by file and line without touching the real one; ``accept`` is
6
+ the person's step that makes it live. ``cite`` turns a line of a project document into a locator
7
+ — the passage digest a value carries, and the document's version for the inventory.
8
+
9
+ Citation paths are relative to the project's root, not to the store: a store in a user directory
10
+ or on the ``memory`` branch lives outside the checkout, and every worktree must resolve the same
11
+ document from the same path.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import shutil
17
+ import tempfile
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List
20
+
21
+ _PACK_NAME = re.compile(r"^pack:\s*([A-Za-z0-9_\-]+)\s*$", re.MULTILINE)
22
+ _INVENTORY_ID = re.compile(r"^([A-Za-z0-9_.\-]+):\s*$", re.MULTILINE)
23
+
24
+
25
+ def pack_name(text: str) -> str:
26
+ m = _PACK_NAME.search(text)
27
+ if not m:
28
+ raise ValueError("a pack starts with `pack: <domain>` — the domain's name is the pack's name")
29
+ return m.group(1)
30
+
31
+
32
+ def check(store: Path, proposal: Path) -> Dict[str, Any]:
33
+ """Loads *proposal* as ``taxonomy/pack-<name>.yaml`` beside the store's vocabulary, in a scratch
34
+ copy. ``ok`` with what the pack adds, or ``ok: False`` with the loader's messages, which name
35
+ the proposal's own path and line."""
36
+ from pygim.memory import Memory, VocabularyError
37
+
38
+ text = proposal.read_text(encoding="utf-8")
39
+ try:
40
+ name = pack_name(text)
41
+ except ValueError as exc:
42
+ return {"ok": False, "errors": f"{proposal}:1: {exc}"}
43
+ target = f"pack-{name}.yaml"
44
+ with tempfile.TemporaryDirectory(prefix="pygim-pack-check-") as tmp:
45
+ scratch = Path(tmp) / "store"
46
+ Memory.init(str(scratch))
47
+ for existing in sorted((store / "taxonomy").glob("*.yaml")):
48
+ shutil.copyfile(existing, scratch / "taxonomy" / existing.name)
49
+ (scratch / "taxonomy" / target).write_text(text, encoding="utf-8")
50
+ try:
51
+ memory = Memory(str(scratch))
52
+ except VocabularyError as exc:
53
+ message = str(exc).replace(str(scratch / "taxonomy" / target), str(proposal)).replace(f"taxonomy/{target}", str(proposal))
54
+ return {"ok": False, "errors": message}
55
+ dims = [d for d in memory.vocabulary()["dimensions"] if d.get("pack") == name]
56
+ del memory
57
+ return {"ok": True, "pack": name, "dimensions": [d["name"] for d in dims],
58
+ "values": sum(sum(1 for v in d["values"] if not v.get("any")) for d in dims),
59
+ "replaces": (store / "taxonomy" / target).exists()}
60
+
61
+
62
+ def accept(store: Path, proposal: Path, replace: bool = False) -> Dict[str, Any]:
63
+ """A person's step: checks the proposal, copies it to ``taxonomy/pack-<name>.yaml``, and adds the
64
+ documents of an ``inventory.yaml`` beside it to ``sources/inventory.yaml``. A pack of that name
65
+ already live is replaced only when asked."""
66
+ result = check(store, proposal)
67
+ if not result["ok"]:
68
+ return result
69
+ if result["replaces"] and not replace:
70
+ return {"ok": False, "errors": f"taxonomy/pack-{result['pack']}.yaml is already live — pass --replace to replace it"}
71
+ shutil.copyfile(proposal, store / "taxonomy" / f"pack-{result['pack']}.yaml")
72
+ result["inventory"] = _merge_inventory(store, proposal.parent / "inventory.yaml")
73
+ return result
74
+
75
+
76
+ def _merge_inventory(store: Path, drafted: Path) -> List[str]:
77
+ """Appends the drafted inventory's documents that the store's inventory lacks; returns their ids."""
78
+ if not drafted.is_file():
79
+ return []
80
+ live = store / "sources" / "inventory.yaml"
81
+ live.parent.mkdir(parents=True, exist_ok=True)
82
+ have = set(_INVENTORY_ID.findall(live.read_text(encoding="utf-8"))) if live.is_file() else set()
83
+ blocks = re.split(r"(?m)^(?=[A-Za-z0-9_.\-]+:\s*$)", drafted.read_text(encoding="utf-8"))
84
+ added, keep = [], []
85
+ for block in blocks:
86
+ m = _INVENTORY_ID.match(block)
87
+ if m and m.group(1) not in have:
88
+ added.append(m.group(1))
89
+ keep.append(block.rstrip() + "\n")
90
+ if added:
91
+ header = "" if live.is_file() else "# The documents this vocabulary cites. Paths are relative to the project's root.\n"
92
+ with live.open("a", encoding="utf-8") as fh:
93
+ fh.write(header + "".join(keep))
94
+ return added
95
+
96
+
97
+ def doc_id(relative: Path) -> str:
98
+ """A stable, readable id for a document: its project-relative path with separators as dashes."""
99
+ stem = relative.with_suffix("") if relative.suffix in (".md", ".rst", ".txt") else relative
100
+ return re.sub(r"[^A-Za-z0-9_.]+", "-", stem.as_posix()).strip("-").lower()
101
+
102
+
103
+ def cite(project: Path, path: str, line: int, lines: int = 1) -> Dict[str, Any]:
104
+ """A locator into a project document: the passage at *line* (1-based) for *lines* lines."""
105
+ from pygim.memory import digest
106
+
107
+ file = (project / path).resolve() if not Path(path).is_absolute() else Path(path).resolve()
108
+ try:
109
+ relative = file.relative_to(project.resolve())
110
+ except ValueError:
111
+ raise ValueError(f"{file} is outside the project {project} — cite the project's own documents") from None
112
+ # \r\n is a line ending, never part of a line, as for hand-written memories (corpus.h): a checkout
113
+ # with Windows line endings must cite the same passage, and the same version, as one without.
114
+ content = file.read_bytes().replace(b"\r\n", b"\n")
115
+ text = content.decode("utf-8").split("\n")
116
+ if line < 1 or line + lines - 1 > len(text):
117
+ raise ValueError(f"{relative} has {len(text)} lines; line {line} for {lines} is out of range")
118
+ passage = "\n".join(text[line - 1:line - 1 + lines])
119
+ doc = doc_id(relative)
120
+ return {
121
+ "source": {"doc": doc, "line": line, "lines": lines, "passage": digest(passage.encode("utf-8"))},
122
+ "inventory": {"id": doc, "kind": "text", "path": relative.as_posix(), "version": digest(content)},
123
+ "text": passage,
124
+ }
@@ -0,0 +1,232 @@
1
+ """Where a project's memory lives, and how a machine is set up to use it.
2
+
3
+ A store is found, in order, by:
4
+
5
+ 1. an explicit root (``--root``);
6
+ 2. ``$PYGIM_MEMORY_ROOT``;
7
+ 3. ``git config pygim.memory`` — git keeps it in the clone's shared config, so every worktree of
8
+ the project finds the same store;
9
+ 4. a ``.memory`` directory found by walking up from the working directory.
10
+
11
+ ``oo memory setup`` writes that git config, creates the store — in a user-level directory, or
12
+ on an orphan ``memory`` branch checked out as a worktree of its own — and registers the MCP
13
+ server with Claude Code at user scope, without a root, so it finds each project's store from the
14
+ directory it is started in. Nothing here imports click; the CLI and the MCP server both use it.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import List, Optional, Sequence
25
+
26
+ ENV = "PYGIM_MEMORY_ROOT"
27
+ GIT_KEY = "pygim.memory"
28
+ LOCAL = ".memory"
29
+ BRANCH = "memory"
30
+ SERVER = "pygim-memory"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Found:
35
+ """A store's root and how it was found — shown to the user, since four places can name one."""
36
+ root: Path
37
+ how: str
38
+
39
+ @property
40
+ def exists(self) -> bool:
41
+ return is_store(self.root)
42
+
43
+
44
+ def is_store(path: Path) -> bool:
45
+ return (path / "taxonomy" / "base.yaml").is_file()
46
+
47
+
48
+ def git(args: Sequence[str], cwd: Path) -> Optional[str]:
49
+ """``git <args>`` in *cwd*: its stripped output, or None when git is missing or it fails."""
50
+ try:
51
+ done = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, text=True, timeout=60)
52
+ except (OSError, subprocess.TimeoutExpired):
53
+ return None
54
+ return done.stdout.strip() if done.returncode == 0 else None
55
+
56
+
57
+ def main_worktree(cwd: Path) -> Optional[Path]:
58
+ """The clone's main worktree — the directory that holds its ``.git`` — from any of its worktrees."""
59
+ common = git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd)
60
+ if not common:
61
+ return None
62
+ common_dir = Path(common)
63
+ return common_dir.parent if common_dir.name == ".git" else Path(git(["rev-parse", "--show-toplevel"], cwd) or cwd)
64
+
65
+
66
+ def project_root(cwd: Path) -> Path:
67
+ """What a source citation's path is relative to: this worktree's top, or *cwd* outside git."""
68
+ top = git(["rev-parse", "--show-toplevel"], cwd)
69
+ return Path(top) if top else cwd
70
+
71
+
72
+ def find(explicit: Optional[str] = None, cwd: Optional[Path] = None) -> Optional[Found]:
73
+ """The store for *cwd*, by the order in the module docstring, or None when nothing names one.
74
+ The first three are returned whether or not a store exists there yet — setup creates it."""
75
+ cwd = Path(cwd or os.getcwd()).resolve()
76
+ if explicit:
77
+ return Found(Path(explicit).expanduser().resolve(), "--root")
78
+ if os.environ.get(ENV):
79
+ return Found(Path(os.environ[ENV]).expanduser().resolve(), "$" + ENV)
80
+ configured = git(["config", "--get", GIT_KEY], cwd)
81
+ if configured:
82
+ path = Path(configured).expanduser()
83
+ if not path.is_absolute():
84
+ path = (main_worktree(cwd) or cwd) / path
85
+ return Found(path.resolve(), "git config " + GIT_KEY)
86
+ for directory in (cwd, *cwd.parents):
87
+ if is_store(directory / LOCAL):
88
+ return Found((directory / LOCAL).resolve(), LOCAL + " above the working directory")
89
+ return None
90
+
91
+
92
+ def guidance(cwd: Optional[Path] = None) -> str:
93
+ """What to tell someone whose project has no store yet."""
94
+ where = find(cwd=cwd)
95
+ if where and not where.exists:
96
+ return f"{where.root} (from {where.how}) is not a memory store yet — run `oo memory setup` in the project"
97
+ return ("no memory store for this project — run `oo memory setup --user` (a store in your user directory) "
98
+ "or `oo memory setup --branch` (an orphan `memory` branch shared through git) in the project; "
99
+ "`--local` keeps one inside the project instead")
100
+
101
+
102
+ # ── setup ─────────────────────────────────────────────────────────────────────
103
+
104
+
105
+ def user_data_dir() -> Path:
106
+ """Where user-level stores live: the platform's per-user data directory, under pygim/memory."""
107
+ if sys.platform == "win32":
108
+ base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
109
+ elif sys.platform == "darwin":
110
+ base = Path.home() / "Library" / "Application Support"
111
+ else:
112
+ base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
113
+ return base / "pygim" / "memory"
114
+
115
+
116
+ def project_name(cwd: Path) -> str:
117
+ main = main_worktree(cwd)
118
+ return (main or cwd).name
119
+
120
+
121
+ def point_git_at(root: Path, cwd: Path) -> bool:
122
+ """Record *root* in the clone's shared git config; False outside git."""
123
+ if git(["rev-parse", "--git-dir"], cwd) is None:
124
+ return False
125
+ return git(["config", GIT_KEY, str(root)], cwd) is not None
126
+
127
+
128
+ def create(root: Path, source: Optional[Path] = None) -> None:
129
+ """A new store at *root*: empty, or a copy of the store at *source* — every file but ``local/``,
130
+ which is one clone's alone. The copy is a new clone of the same history: its next write starts a
131
+ new audit file, and the old ones keep replaying beside it."""
132
+ from pygim.memory import Memory
133
+
134
+ if source is None:
135
+ Memory.init(str(root))
136
+ return
137
+ if not is_store(source):
138
+ raise RuntimeError(f"{source} is not a memory store — nothing to copy")
139
+ shutil.copytree(source, root, ignore=shutil.ignore_patterns("local"), dirs_exist_ok=True)
140
+ (root / "local").mkdir(exist_ok=True)
141
+
142
+
143
+ def setup_user(cwd: Path, name: Optional[str] = None, source: Optional[Path] = None) -> Path:
144
+ """A store under the user data directory, named for the project, and git pointed at it."""
145
+ root = user_data_dir() / (name or project_name(cwd))
146
+ if not is_store(root):
147
+ create(root, source)
148
+ point_git_at(root, cwd)
149
+ return root
150
+
151
+
152
+ def setup_local(cwd: Path, source: Optional[Path] = None) -> Path:
153
+ """A store inside the project, as ``.memory`` at this worktree's top, committed with the code. Git
154
+ config is left alone: this store belongs to the branch that carries it, and other worktrees on
155
+ other branches find theirs, or none."""
156
+ root = project_root(cwd) / LOCAL
157
+ if not is_store(root):
158
+ create(root, source)
159
+ return root
160
+
161
+
162
+ def setup_branch(cwd: Path, path: Optional[Path] = None, source: Optional[Path] = None) -> Path:
163
+ """A store on the orphan ``memory`` branch, checked out as its own worktree beside the main one.
164
+ An existing local or remote-tracking ``memory`` branch is checked out rather than created — a
165
+ second machine gets the project's memory with ``git fetch`` and this call."""
166
+ main = main_worktree(cwd)
167
+ if main is None:
168
+ raise RuntimeError(f"{cwd} is not in a git repository — use `oo memory setup --user` instead")
169
+ root = (path or main.parent / f"{main.name}-{BRANCH}").expanduser().resolve()
170
+ if is_store(root):
171
+ point_git_at(root, cwd)
172
+ return root
173
+ if root.exists() and any(root.iterdir()):
174
+ raise RuntimeError(f"{root} exists and is not a memory store — choose another --path")
175
+ has_local = git(["show-ref", "--verify", "--quiet", f"refs/heads/{BRANCH}"], cwd) is not None
176
+ has_remote = git(["show-ref", "--verify", "--quiet", f"refs/remotes/origin/{BRANCH}"], cwd) is not None
177
+ if has_local:
178
+ _require(git(["worktree", "add", str(root), BRANCH], cwd), f"git worktree add {root} {BRANCH}")
179
+ elif has_remote:
180
+ _require(git(["worktree", "add", "--track", "-b", BRANCH, str(root), f"origin/{BRANCH}"], cwd),
181
+ f"git worktree add --track -b {BRANCH} {root} origin/{BRANCH}")
182
+ else:
183
+ _require(git(["worktree", "add", "--orphan", "-b", BRANCH, str(root)], cwd),
184
+ f"git worktree add --orphan -b {BRANCH} {root}")
185
+ create(root, source)
186
+ git(["add", "-A"], root)
187
+ if git(["commit", "-m", "memory: the store, as initialised by oo memory setup"], root) is None:
188
+ raise RuntimeError(f"created the store in {root}, but could not commit it — commit it there yourself")
189
+ if not is_store(root):
190
+ raise RuntimeError(f"the {BRANCH} branch checked out in {root} holds no memory store")
191
+ point_git_at(root, cwd)
192
+ return root
193
+
194
+
195
+ def _require(result: Optional[str], what: str) -> None:
196
+ if result is None:
197
+ raise RuntimeError(f"`{what}` failed — run it yourself to see why")
198
+
199
+
200
+ # ── registration ──────────────────────────────────────────────────────────────
201
+
202
+
203
+ def server_command() -> List[str]:
204
+ """How to start this installation's server, with no root: it finds each project's store from the
205
+ directory the host starts it in. Absolute, so the host needs nothing on its PATH."""
206
+ scripts = Path(sys.executable).parent
207
+ for candidate in (scripts / "oo", scripts / "oo.exe", scripts / "Scripts" / "oo.exe"):
208
+ if candidate.is_file():
209
+ return [str(candidate), "memory", "mcp"]
210
+ return [sys.executable, "-c", "from pygim.__main__ import cli_oo; cli_oo()", "memory", "mcp"]
211
+
212
+
213
+ @dataclass(frozen=True)
214
+ class Registration:
215
+ ran: bool # True when `claude mcp add` ran here
216
+ command: List[str]
217
+ message: str
218
+
219
+
220
+ def register() -> Registration:
221
+ """Registers the server with Claude Code at user scope, so every project and worktree gets it.
222
+ Without the ``claude`` command on PATH, returns the command to run instead of guessing a config file."""
223
+ command = ["claude", "mcp", "add", "--scope", "user", SERVER, "--", *server_command()]
224
+ claude = shutil.which("claude")
225
+ if claude is None:
226
+ return Registration(False, command, "the `claude` command is not on PATH — run this where it is")
227
+ if subprocess.run([claude, "mcp", "get", SERVER], capture_output=True, text=True).returncode == 0:
228
+ return Registration(False, command, f"`{SERVER}` is already registered — `claude mcp remove {SERVER} -s user` to replace it")
229
+ done = subprocess.run([claude, *command[1:]], capture_output=True, text=True)
230
+ if done.returncode != 0:
231
+ return Registration(False, command, "`claude mcp add` failed: " + (done.stderr or done.stdout).strip())
232
+ return Registration(True, command, f"registered `{SERVER}` for every project at user scope")
@@ -12,12 +12,18 @@ is the overview's §4.5.
12
12
  """
13
13
  from __future__ import annotations
14
14
 
15
+ import datetime
15
16
  import json
17
+ import os
16
18
  import sys
19
+ from pathlib import Path
17
20
  from typing import Any, Callable, Dict, IO, List, Optional
18
21
 
22
+ from . import _packs, _stores
23
+
19
24
  PROTOCOL_VERSION = "2025-06-18"
20
25
  SERVER_NAME = "pygim-memory"
26
+ STANDING_TOKENS = 2000 # how much preference text the startup instructions may carry
21
27
 
22
28
  INSTRUCTIONS = """\
23
29
  A problem-space memory: knowledge is found by the kind of problem being solved,
@@ -40,6 +46,10 @@ not by similarity to the prompt.
40
46
  `generalises` naming them, and record `lessons`. Nothing is retired, and a
41
47
  generalisation folds its instances only after the user accepts it themselves
42
48
  with `oo memory accept` — there is no tool for that, on purpose.
49
+ 6. A project with no vocabulary pack of its own starts with the `prepare-vocabulary`
50
+ prompt (the user accepts the draft with `oo memory accept --pack`), then
51
+ `seed-memories`. If a tool says there is no store, tell the user to run
52
+ `oo memory setup` in the project.
43
53
  """
44
54
 
45
55
  CONSOLIDATE = """\
@@ -69,6 +79,89 @@ Consolidate what this session has written into memory so far.
69
79
  accept it for them.
70
80
  """
71
81
 
82
+ PACK_SHAPE = """\
83
+ pack: {domain}
84
+ entry: {{brief: ..., when: ..., when_not: ..., example: ...}} # the domain itself: domain={domain}
85
+ dimensions:
86
+ <dimension>:
87
+ role: hard # hard: filters by default. soft: only orders
88
+ weight: 1.0
89
+ entry: {{brief: ..., full: ..., when: ..., when_not: ..., example: ...}}
90
+ values:
91
+ <value>:
92
+ entry: {{brief: ..., when: ..., when_not: ..., example: ...}}
93
+ source: {{doc: <id from cite>, line: 12, lines: 1, passage: <digest from cite>}}
94
+ extends: # this domain's values for the base dimensions
95
+ artifact:
96
+ <value>: {{entry: {{...}}, source: {{...}}}}
97
+ task:
98
+ <value>: {{entry: {{...}}}}
99
+ tier:
100
+ <value>: {{entry: {{...}}}}"""
101
+
102
+ PREPARE_VOCABULARY = """\
103
+ Prepare the vocabulary for the `{domain}` domain: a pack drafted from this project's own
104
+ documents, which becomes the vocabulary only when the user accepts it.
105
+
106
+ The base vocabulary (domain, artifact, task, kind, tier) ships with every store. A pack adds
107
+ this domain's values to artifact, task and tier, and dimensions of its own. Every memory will be
108
+ found through these tags, so the vocabulary is the first thing a project needs.
109
+
110
+ 1. Call `session` (it gives the store's `root` and the `project` root) and `vocabulary`.
111
+ 2. Inventory the sources. The project's documents are the only guaranteed input: the README,
112
+ design documents, guides, and the names the source tree itself uses — directories, modules,
113
+ manifests. List them. Anything you compute from them, such as a term count, is derived, not a source.
114
+ 3. Find the questions. A dimension is one question with a closed list of answers, independent of
115
+ the others. Make one hard only when knowledge under one answer should never surface while
116
+ working under another, as a subsystem's should not; everything else is soft. Look first for
117
+ closed lists the sources already label — directory names, manifest kinds, document types:
118
+ they cost nothing and classify reliably.
119
+ 4. Name every value in the project's ubiquitous language. Count how the sources name each
120
+ concept and use their word; never coin one. The dimension and the value together should read
121
+ as a term the project uses. Give each value a locator with `cite`, and list the values nothing
122
+ cites rather than inventing a source.
123
+ 5. Write a codebook entry for every dimension and value: brief, full (dimensions), when,
124
+ when_not, example. An artifact value names what a thing is, never how it is used — given
125
+ only the thing, two readers should agree on it.
126
+ 6. Reconcile every list you derived against the sources' complete list: what is in both, what
127
+ the sources have that you left out, and what you have that the sources do not. Never present
128
+ a derived list alone.
129
+ 7. Probe coverage: take one real item for every artifact value and try to tag it. Note what
130
+ cannot be tagged — that is where the vocabulary is thin.
131
+ 8. Draft into the store, under `taxonomy/studies/{today}-{domain}/`:
132
+ - `proposal/pack-{domain}.yaml`, in this shape:
133
+ {pack_shape}
134
+ - `proposal/inventory.yaml`: one entry per cited document, from `cite`'s `inventory` —
135
+ `<id>:` then `kind`, `path` (relative to the project root) and `version`.
136
+ - `report.md`: the sources, each dimension and why it exists, the counts that chose each name,
137
+ the uncited values, the reconciliation, the coverage probe, and your open questions.
138
+ 9. Call `check_pack` on the proposal and fix every error it names, by file and line.
139
+ 10. Tell the user where the report is, and that the pack becomes the vocabulary only when they
140
+ run `oo memory accept --pack <path to the proposal>`. Do not copy it into taxonomy/ yourself.
141
+ """
142
+
143
+ SEED_MEMORIES = """\
144
+ Seed this project's memory with what is already known, so the first working session does not
145
+ start from nothing.
146
+
147
+ 1. Call `session` and `vocabulary`. If no pack names this project's domain yet, stop and suggest
148
+ the `prepare-vocabulary` prompt first: a memory tagged from the base vocabulary alone is hard
149
+ to find again.
150
+ 2. Find durable knowledge in the project's documents and history: decisions and their reasons,
151
+ conventions the code follows, how recurring tasks are done (adding a module, releasing,
152
+ testing), known pitfalls. Skip what a document already states plainly — sources are cited,
153
+ not copied. A memory earns its place by saying what a reader would otherwise rediscover.
154
+ 3. For each one, `read` its space first, then decide: nothing covers it, so `remember`;
155
+ something says less or says it wrong, so `remember` superseding it; something already says
156
+ exactly this, so `learn`. Answer every hard dimension, and `cite` the passage it rests on.
157
+ 4. Write how a recurring task is done as a procedure (kind=procedure, one per artifact and
158
+ task), a choice and its reason as a decision, and a rule of thumb as a principle only when
159
+ several cases show it.
160
+ 5. Write concretely — the case and where it came from. Leave patterns for a later `consolidate`.
161
+ 6. Record `lessons`: what you seeded, what you left out and why, and the gaps — knowledge the
162
+ vocabulary could not tag. Tell the user the report's path.
163
+ """
164
+
72
165
  PROMPTS: List[Dict[str, Any]] = [
73
166
  {
74
167
  "name": "consolidate",
@@ -76,8 +169,35 @@ PROMPTS: List[Dict[str, Any]] = [
76
169
  "any pattern several memories share. Nothing is retired.",
77
170
  "arguments": [],
78
171
  },
172
+ {
173
+ "name": "prepare-vocabulary",
174
+ "description": "Draft this project's vocabulary pack from its own documents, with a study report, "
175
+ "for the user to accept with `oo memory accept --pack`.",
176
+ "arguments": [{"name": "domain", "description": "The domain's name, which is the pack's name "
177
+ "(default: the project directory's name).", "required": False}],
178
+ },
179
+ {
180
+ "name": "seed-memories",
181
+ "description": "Record what the project's documents and history already know — decisions, conventions, "
182
+ "procedures — as the store's first memories.",
183
+ "arguments": [],
184
+ },
79
185
  ]
80
- _PROMPT_TEXT = {"consolidate": CONSOLIDATE}
186
+
187
+
188
+ def _domain_of(arguments: Dict[str, Any], server: "MemoryServer") -> str:
189
+ given = str(arguments.get("domain") or "").strip()
190
+ name = given or _stores.project_name(server._cwd)
191
+ return "".join(c if c.isalnum() else "_" for c in name.lower()).strip("_") or "project"
192
+
193
+
194
+ _PROMPT_TEXT: Dict[str, Callable[[Dict[str, Any], "MemoryServer"], str]] = {
195
+ "consolidate": lambda args, server: CONSOLIDATE,
196
+ "prepare-vocabulary": lambda args, server: PREPARE_VOCABULARY.format(
197
+ domain=_domain_of(args, server), today=datetime.date.today().isoformat(),
198
+ pack_shape="\n".join(" " + line for line in PACK_SHAPE.format(domain=_domain_of(args, server)).split("\n"))),
199
+ "seed-memories": lambda args, server: SEED_MEMORIES,
200
+ }
81
201
 
82
202
  _REF = {"type": "string", "description": "A memory: #n from a read or show, or at least 8 characters of its key."}
83
203
  _TAGS = {"type": "array", "items": {"type": "string"}, "description": "Qualified tags, dimension=value, from the vocabulary."}
@@ -199,18 +319,51 @@ TOOLS: List[Dict[str, Any]] = [
199
319
  "adding it to a pack file under taxonomy/; the asking memories are then linked on the next start.",
200
320
  "inputSchema": _schema({}),
201
321
  },
322
+ {
323
+ "name": "cite",
324
+ "description": "A locator into one of this project's documents: the passage digest a vocabulary value or a "
325
+ "memory carries, the document's inventory entry, and the passage's text. Paths are relative "
326
+ "to the project root.",
327
+ "inputSchema": _schema({
328
+ "path": {"type": "string", "description": "The document, relative to the project root."},
329
+ "line": {"type": "integer", "minimum": 1},
330
+ "lines": {"type": "integer", "minimum": 1, "description": "How many lines the passage spans (default 1)."},
331
+ }, ["path", "line"]),
332
+ },
333
+ {
334
+ "name": "check_pack",
335
+ "description": "Loads a drafted pack beside the store's vocabulary in a scratch copy and reports every error "
336
+ "by file and line, or what the pack adds. Nothing live changes; the user accepts a pack.",
337
+ "inputSchema": _schema({"path": {"type": "string",
338
+ "description": "The proposal, relative to the store root or the project root."}},
339
+ ["path"]),
340
+ },
202
341
  ]
203
342
 
204
343
 
205
- class MemoryServer:
206
- """Answers MCP messages for one repository. `handle` takes one decoded
207
- message and returns the response to write, or None for a notification."""
344
+ class NoStore(RuntimeError):
345
+ """The project has no memory store yet; the message says how to make one."""
208
346
 
209
- def __init__(self, memory: Any) -> None:
210
- self.memory = memory
347
+
348
+ class MemoryServer:
349
+ """Answers MCP messages for one project's store. `handle` takes one decoded
350
+ message and returns the response to write, or None for a notification.
351
+
352
+ The store is found lazily (see `_stores.find`), so the server starts in a project
353
+ that has none and answers with setup guidance until one exists. It reopens the
354
+ store when a vocabulary file under taxonomy/ changes, so an accepted pack is live
355
+ at the next call without restarting the server."""
356
+
357
+ def __init__(self, memory: Any = None, *, root: Optional[str] = None, cwd: Optional[Path] = None) -> None:
358
+ self._memory = memory
359
+ self._root = root
360
+ self._cwd = Path(cwd or os.getcwd())
361
+ self._stamp = self._taxonomy_stamp() if memory is not None else None
211
362
  self.session: Optional[int] = None
212
363
  self.turn = 0
213
364
  self._calls: Dict[str, Callable[[Dict[str, Any]], Any]] = {
365
+ "cite": lambda a: _packs.cite(_stores.project_root(self._cwd), a["path"], int(a["line"]), int(a.get("lines", 1))),
366
+ "check_pack": lambda a: _packs.check(Path(self.memory.root), self._store_path(a["path"])),
214
367
  "session": self._session,
215
368
  "vocabulary": lambda a: self.memory.vocabulary(),
216
369
  "read": lambda a: self.memory.read(a["hard"], a.get("soft", []), max=a.get("max", 8),
@@ -229,6 +382,67 @@ class MemoryServer:
229
382
  "proposals": lambda a: self.memory.proposals(),
230
383
  }
231
384
 
385
+ # ── the store ───────────────────────────────────────────────────────────
386
+
387
+ @property
388
+ def memory(self) -> Any:
389
+ from pygim.memory import Memory
390
+
391
+ if self._memory is None:
392
+ found = _stores.find(self._root, self._cwd)
393
+ if found is None or not found.exists:
394
+ raise NoStore(_stores.guidance(self._cwd))
395
+ self._memory = Memory(str(found.root))
396
+ self._stamp = self._taxonomy_stamp()
397
+ elif self._taxonomy_stamp() != self._stamp:
398
+ self._memory = Memory(self._memory.root) # a broken pack raises here, by file and line, and nothing is swapped
399
+ self._stamp = self._taxonomy_stamp()
400
+ return self._memory
401
+
402
+ def _standing(self) -> str:
403
+ """The project's standing knowledge, appended to the instructions a host loads into every
404
+ session: each preference in full, and the title of each procedure — a read naming its
405
+ artifact and task places its steps first anyway. Nothing is recorded as read. Empty when
406
+ there is no store, or nothing of either kind; capped at STANDING_TOKENS, naming what it leaves out."""
407
+ try:
408
+ preferences = self.memory.heads(["kind=preference"])
409
+ procedures = self.memory.heads(["kind=procedure"])
410
+ except Exception: # no store yet, or a vocabulary that will not load: the tools will say so
411
+ return ""
412
+ if not preferences and not procedures:
413
+ return ""
414
+ out = ["", "Standing knowledge from this project's memory, as of this server's start. It applies to",
415
+ "every task, so it is given here instead of waiting for a read."]
416
+ if preferences:
417
+ out += ["", "Preferences:"]
418
+ used, left_out = 0, []
419
+ for p in preferences:
420
+ if used + p["tokens"] > STANDING_TOKENS:
421
+ left_out.append(f"{p['memory']} {p['title']}")
422
+ continue
423
+ used += p["tokens"]
424
+ body = p["text"].strip().replace("\n", "\n ")
425
+ out.append(f"- {p['memory']} {p['title']}: {body}")
426
+ if left_out:
427
+ out.append("- not shown, for length (`show` them): " + "; ".join(left_out))
428
+ if procedures:
429
+ out += ["", "Procedures — a read naming their artifact and task places the steps first:"]
430
+ for p in procedures:
431
+ where = " ".join(t for t in p["tags"] if t.startswith(("artifact=", "task=")))
432
+ out.append(f"- {p['memory']} {p['title']} — {where}")
433
+ return "\n".join(out) + "\n"
434
+
435
+ def _taxonomy_stamp(self) -> Any:
436
+ files = sorted(Path(self._memory.root, "taxonomy").glob("*.yaml"))
437
+ return tuple((f.name, f.stat().st_mtime_ns, f.stat().st_size) for f in files)
438
+
439
+ def _store_path(self, path: str) -> Path:
440
+ p = Path(path).expanduser()
441
+ if p.is_absolute():
442
+ return p
443
+ in_store = Path(self.memory.root) / p
444
+ return in_store if in_store.exists() else _stores.project_root(self._cwd) / p
445
+
232
446
  # ── tools ───────────────────────────────────────────────────────────────
233
447
 
234
448
  def _session_no(self) -> int:
@@ -239,6 +453,8 @@ class MemoryServer:
239
453
  def _session(self, _: Dict[str, Any]) -> Any:
240
454
  info = self.memory.session()
241
455
  self.session = int(info["session"])
456
+ info["root"] = self.memory.root
457
+ info["project"] = str(_stores.project_root(self._cwd))
242
458
  return info
243
459
 
244
460
  def _remember(self, a: Dict[str, Any]) -> Any:
@@ -261,7 +477,7 @@ class MemoryServer:
261
477
  "protocolVersion": requested,
262
478
  "capabilities": {"tools": {"listChanged": False}, "prompts": {"listChanged": False}},
263
479
  "serverInfo": {"name": SERVER_NAME, "version": _version()},
264
- "instructions": INSTRUCTIONS,
480
+ "instructions": INSTRUCTIONS + self._standing(),
265
481
  })
266
482
  if method == "ping":
267
483
  return _result(mid, {})
@@ -273,11 +489,13 @@ class MemoryServer:
273
489
  if method == "prompts/list":
274
490
  return _result(mid, {"prompts": PROMPTS})
275
491
  if method == "prompts/get":
276
- name = (msg.get("params") or {}).get("name", "")
277
- text = _PROMPT_TEXT.get(name)
278
- if text is None:
492
+ params = msg.get("params") or {}
493
+ name = params.get("name", "")
494
+ render = _PROMPT_TEXT.get(name)
495
+ if render is None:
279
496
  return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32602, "message": f"unknown prompt: {name}"}}
280
497
  meta = next(p for p in PROMPTS if p["name"] == name)
498
+ text = render(params.get("arguments") or {}, self)
281
499
  return _result(mid, {"description": meta["description"],
282
500
  "messages": [{"role": "user", "content": {"type": "text", "text": text}}]})
283
501
  return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": f"method not found: {method}"}}
@@ -291,6 +509,8 @@ class MemoryServer:
291
509
  return _text(f"unknown tool: {name}", error=True)
292
510
  try:
293
511
  result = fn(arguments)
512
+ except NoStore as exc:
513
+ return _text(f"{name}: {exc}", error=True)
294
514
  except KeyError as exc:
295
515
  return _text(f"{name}: missing argument {exc.args[0]!r}", error=True)
296
516
  except Exception as exc: # the extension's messages already name file and line
@@ -333,10 +553,13 @@ def _version() -> str:
333
553
  return "0"
334
554
 
335
555
 
336
- def run(root: str, stdin: Optional[IO[str]] = None, stdout: Optional[IO[str]] = None) -> None:
337
- """Serves the repository at *root* over stdio until stdin closes."""
338
- from pygim.memory import Memory
339
-
340
- memory = Memory(root)
341
- print(f"{SERVER_NAME}: serving {memory.root} at v{memory.version}", file=sys.stderr)
342
- MemoryServer(memory).serve(stdin or sys.stdin, stdout or sys.stdout)
556
+ def run(root: Optional[str] = None, stdin: Optional[IO[str]] = None, stdout: Optional[IO[str]] = None) -> None:
557
+ """Serves this project's store over stdio until stdin closes. With no *root*, the store is found
558
+ from the working directory the host started the server in; with none at all the server still
559
+ starts, and answers with how to create one."""
560
+ found = _stores.find(root)
561
+ if found and found.exists:
562
+ print(f"{SERVER_NAME}: serving {found.root} (from {found.how})", file=sys.stderr)
563
+ else:
564
+ print(f"{SERVER_NAME}: {_stores.guidance()}", file=sys.stderr)
565
+ MemoryServer(root=root).serve(stdin or sys.stdin, stdout or sys.stdout)
@@ -62,6 +62,9 @@ operation returns a plain dict, and a refused write is a result with
62
62
  .def("lessons", &Memory::lessons, py::arg("session"), py::arg("text"), py::kw_only(), py::arg("author") = "agent",
63
63
  "Records what a consolidation learnt and publishes it in reviews/session-<n>.md, beside the\n"
64
64
  "generalisations waiting for acceptance, the cases left, and the proposals raised.")
65
+ .def("heads", &Memory::heads, py::arg("tags"),
66
+ "Every head carrying any of `tags`, without recording a read — standing knowledge such as\n"
67
+ "kind=preference, handed to every session without touching usage counters.")
65
68
  .def("review", &Memory::review, py::arg("session"),
66
69
  "What one session wrote, in order, with what already generalises each memory — where a\n"
67
70
  "consolidation starts.")
@@ -93,22 +93,35 @@ def memory():
93
93
  """Problem-space memory: retrieval by the kind of problem being solved."""
94
94
 
95
95
 
96
- _ROOT = click.option("--root", default=".memory", show_default=True, type=click.Path(file_okay=False),
97
- help="The memory repository.")
98
-
99
-
100
- @memory.command("init")
101
- @_ROOT
102
- def memory_init(root):
103
- """Create a repository with the base vocabulary."""
104
- GimmicksCliApp().memory_init(root=root)
96
+ _ROOT = click.option("--root", default=None, type=click.Path(file_okay=False),
97
+ help="The memory store. Default: $PYGIM_MEMORY_ROOT, then `git config pygim.memory` "
98
+ "(shared by every worktree), then a .memory above the working directory.")
99
+
100
+
101
+ @memory.command("setup")
102
+ @click.option("--user", "kind", flag_value="user", help="Create the store in your user data directory.")
103
+ @click.option("--branch", "kind", flag_value="branch",
104
+ help="Keep the store on an orphan `memory` branch, checked out as a worktree of its own.")
105
+ @click.option("--local", "kind", flag_value="local",
106
+ help="Keep the store in the project as .memory, committed with the code on this branch.")
107
+ @click.option("--name", default=None, help="--user: the store's name (default: the project directory's name).")
108
+ @click.option("--path", "path", default=None, type=click.Path(file_okay=False),
109
+ help="--branch: where to check the branch out (default: beside the main worktree).")
110
+ @click.option("--from", "source", default=None, type=click.Path(exists=True, file_okay=False),
111
+ help="Start the new store as a copy of an existing one, such as a project's .memory.")
112
+ @click.option("--no-register", is_flag=True, help="Do not register the MCP server with Claude Code.")
113
+ def memory_setup(kind, name, path, source, no_register):
114
+ """Set this project and machine up to use a memory store: find or create the store, point every
115
+ worktree of the clone at it, and register the MCP server with Claude Code at user scope.
116
+ Run it again on another machine to join a project whose store already exists."""
117
+ GimmicksCliApp().memory_setup(kind=kind, name=name, path=path, source=source, register=not no_register)
105
118
 
106
119
 
107
120
  @memory.command("mcp")
108
121
  @_ROOT
109
122
  def memory_mcp(root):
110
- """Serve the repository to an agent over MCP (stdio). Register it with
111
- `claude mcp add memory -- oo memory mcp --root <absolute path>`."""
123
+ """Serve the project's store to an agent over MCP (stdio). With no --root it is found from the
124
+ directory the host starts it in, so one registration serves every project and worktree."""
112
125
  GimmicksCliApp().memory_mcp(root=root)
113
126
 
114
127
 
@@ -121,13 +134,17 @@ def memory_ingest(corpus, root):
121
134
 
122
135
 
123
136
  @memory.command("accept")
124
- @click.argument("memory_ref", metavar="MEMORY")
125
- @click.option("--reason", default="", help="Why the pattern holds; kept in the audit log.")
137
+ @click.argument("memory_ref", metavar="[MEMORY]", required=False)
138
+ @click.option("--pack", "pack", default=None, type=click.Path(exists=True, dir_okay=False),
139
+ help="Accept a drafted vocabulary pack instead: check it, make it live, add its cited documents.")
140
+ @click.option("--reason", default="", help="MEMORY: why the pattern holds; kept in the audit log.")
141
+ @click.option("--replace", is_flag=True, help="--pack: replace a pack of the same name that is already live.")
126
142
  @_ROOT
127
- def memory_accept(memory_ref, reason, root):
128
- """Accept a generalisation: from the next read its instances fold under it.
129
- A person runs this after reading reviews/session-<n>.md the agent has no tool for it."""
130
- GimmicksCliApp().memory_accept(memory=memory_ref, reason=reason, root=root)
143
+ def memory_accept(memory_ref, pack, reason, replace, root):
144
+ """Accept what the agent drafted, after reading it: a generalisation (MEMORY, from
145
+ reviews/session-<n>.md), whose instances then fold under it; or a vocabulary pack (--pack, from
146
+ its study report), which then becomes the vocabulary. A person runs this — the agent has no tool for it."""
147
+ GimmicksCliApp().memory_accept(memory=memory_ref, pack=pack, reason=reason, replace=replace, root=root)
131
148
 
132
149
 
133
150
  @memory.command("status")
@@ -33,6 +33,7 @@ class Memory:
33
33
  cites: Sequence[str] = ..., proposals: List[Dict[str, str]] = ..., session: int = 0, turn: int = 0,
34
34
  author: str = "agent") -> Dict[str, Any]: ...
35
35
  def review(self, session: int) -> Dict[str, Any]: ...
36
+ def heads(self, tags: Sequence[str]) -> List[Dict[str, Any]]: ...
36
37
  def accept(self, memory: str, *, reason: str = "", author: str = "human") -> Dict[str, Any]: ...
37
38
  def lessons(self, session: int, text: str, *, author: str = "agent") -> Dict[str, Any]: ...
38
39
  def merge(self, memories: Sequence[str], *, title: str, text: str, reason: str, tags: Sequence[str] = ...,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygim
3
- Version: 0.1.0.dev1
3
+ Version: 0.1.0.dev2
4
4
  Summary: Full Python Gimmicks project with all of its sub-projects.
5
5
  Project-URL: homepage, https://github.com/Debith/pygim
6
6
  Project-URL: documentation, https://readthedocs.org/projects/pygim/
@@ -20,6 +20,8 @@ src/_pygim/_core/_typing.py
20
20
  src/_pygim/_core/protocols.py
21
21
  src/_pygim/_core/protocols.pyi
22
22
  src/_pygim/_mcp/__init__.py
23
+ src/_pygim/_mcp/_packs.py
24
+ src/_pygim/_mcp/_stores.py
23
25
  src/_pygim/_mcp/memory.py
24
26
  src/_pygim_fast/datagen/bindings.cpp
25
27
  src/_pygim_fast/each/bindings.cpp
File without changes
File without changes
File without changes
File without changes
File without changes