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.
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/PKG-INFO +1 -1
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_cli/_cli_app.py +73 -22
- pygim-0.1.0.dev2/src/_pygim/_mcp/_packs.py +124 -0
- pygim-0.1.0.dev2/src/_pygim/_mcp/_stores.py +232 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_mcp/memory.py +240 -17
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/memory/adapter/bindings.cpp +3 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/__main__.py +34 -17
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/memory.pyi +1 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim.egg-info/PKG-INFO +1 -1
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim.egg-info/SOURCES.txt +2 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/LICENSE +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/README.rst +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/pyproject.toml +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/setup.cfg +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/setup.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/__init__.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_cli/__init__.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_cli/_commenter.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_cli/_docs_serve.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_core/__init__.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_core/_error_msgs.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_core/_exceptions.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_core/_typing.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_core/protocols.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_core/protocols.pyi +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim/_mcp/__init__.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/datagen/bindings.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/each/bindings.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/pathlike/adapter/bindings.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/pathlike/adapter/third_party/simdjson/simdjson.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/persistence/adapter/bindings.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/persistence/adapter/test_bindings.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/tools/bench_bindings.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/utils/bindings.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/wiring/factory/bindings.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/wiring/ioc/bindings.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/_pygim_fast/wiring/registry/bindings.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/__init__.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/_stubs.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/ai/__init__.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/core/__init__.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/core/explib.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/core/funcs.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/core/protocols.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/core/testing.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/core/testing.pyi +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/core/timing_and_profiling.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/pathlike.pyi +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim/persistence.py +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim.egg-info/dependency_links.txt +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim.egg-info/entry_points.txt +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim.egg-info/requires.txt +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/src/pygim.egg-info/top_level.txt +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/tests/static/mapping_proofs.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/tests/static/memory_proofs.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/tests/static/pathlike_core_proofs.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/tests/static/pathlike_parity_proofs.cpp +0 -0
- {pygim-0.1.0.dev1 → pygim-0.1.0.dev2}/tests/static/pathlike_scalar_proofs.cpp +0 -0
- {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.
|
|
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
|
|
97
|
-
"""
|
|
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
|
-
|
|
118
|
-
|
|
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,
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
"""
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
338
|
-
from
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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=
|
|
97
|
-
help="The memory
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
@
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
111
|
-
|
|
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("--
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|