Skip to content

Commit b69177e

Browse files
committed
fix(memory): legible capacity errors, list action, and full-store education
The project-memory budget check silently added a 3-char entry delimiter that the rejection message never disclosed, so a near-full store reported e.g. "2085/2200, entry (113) exceeds" — math that reads as satisfiable (2085+113<2200) but isn't. With no visibility into the true ceiling or what was stored, the agent could only blind-shrink the entry and loop until interrupted. - project_memory: delimiter-aware accounting; rejections now report exact free chars, the entry's real cost (content + separator), used/limit, and a compact inventory (index, size, preview) so the next remove/replace is guided. Add status() and capacity(); flag capacity failures via MemoryOpResult.full. - Memory tool: new read-only `list` action for mid-session introspection; on a full-store rejection, append a plain-language explanation (nothing lost, task continues, how to free space) to the user-facing tool card. - Raise limits MEMORY 2200->5000, USER 1375->2500 (within the 8 KB injection budget). - /memory: show per-store capacity and a "nearly full" guidance panel at >=85%. - memory.md: best-effort housekeeping guidance — don't loop on rejection.
1 parent 9ba42df commit b69177e

7 files changed

Lines changed: 204 additions & 13 deletions

File tree

src/pythinker_code/project_memory.py

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@
3535
from pythinker_code.soul.pythinkersoul import PythinkerSoul
3636

3737
ENTRY_DELIMITER = "\n§\n"
38-
MEMORY_CHAR_LIMIT = 2200
39-
USER_CHAR_LIMIT = 1375
38+
MEMORY_CHAR_LIMIT = 5000
39+
USER_CHAR_LIMIT = 2500
4040
INJECTION_BUDGET_BYTES = 8 * 1024
4141
_JOURNAL_MAX_ENTRIES = 100
4242

@@ -92,6 +92,9 @@ async def project_key(work_dir: HostPath, *, git_runner: GitRunner | None = None
9292
class MemoryOpResult:
9393
ok: bool
9494
message: str
95+
# True only when the op failed because the store is at capacity. Lets the UI
96+
# layer attach user-facing guidance without string-matching the message.
97+
full: bool = False
9598

9699

97100
class ProjectMemoryStore:
@@ -130,6 +133,31 @@ def _filename(self, target: Target) -> str:
130133
def _char_limit(self, target: Target) -> int:
131134
return self._user_limit if target == "user" else self._memory_limit
132135

136+
@staticmethod
137+
def _used(entries: list[str]) -> int:
138+
return len(ENTRY_DELIMITER.join(entries))
139+
140+
@staticmethod
141+
def _append_overhead(entries: list[str]) -> int:
142+
"""Chars an appended entry costs beyond its own text (the joining delimiter)."""
143+
return len(ENTRY_DELIMITER) if entries else 0
144+
145+
@staticmethod
146+
def _inventory(entries: list[str]) -> str:
147+
"""Compact, model-readable listing: index, size, and a one-line preview.
148+
149+
The preview doubles as a copy-paste ``old_text`` substring for remove/replace,
150+
turning a space-rejection into a guided one-step fix instead of a guessing game.
151+
"""
152+
if not entries:
153+
return " (none)"
154+
lines: list[str] = []
155+
for i, entry in enumerate(entries):
156+
preview = " ".join(entry.split())
157+
clipped = preview[:60] + ("…" if len(preview) > 60 else "")
158+
lines.append(f" [{i}] {len(entry)} chars — {clipped}")
159+
return "\n".join(lines)
160+
133161
async def _path_for(self, target: Target) -> Path:
134162
root = await self._ensure_dir()
135163
return root / "memory" / self._filename(target)
@@ -219,13 +247,19 @@ async def add(self, target: Target, content: str) -> MemoryOpResult:
219247
if content in entries:
220248
return MemoryOpResult(True, "Entry already exists (no duplicate added).")
221249
limit = self._char_limit(target)
222-
new_total = len(ENTRY_DELIMITER.join([*entries, content]))
223-
if new_total > limit:
224-
current = len(ENTRY_DELIMITER.join(entries))
250+
if len(ENTRY_DELIMITER.join([*entries, content])) > limit:
251+
used = self._used(entries)
252+
overhead = self._append_overhead(entries)
253+
free = max(0, limit - used - overhead)
254+
need = len(content) + overhead
225255
return MemoryOpResult(
226256
False,
227-
f"Memory at {current}/{limit} chars; this entry ({len(content)}) "
228-
"exceeds the limit. Replace or remove entries first.",
257+
f"Not enough room: this entry needs {need} chars "
258+
f"(content {len(content)} + {overhead} separator), but only {free} free "
259+
f"({used}/{limit} used). Remove or replace an entry to free space, "
260+
f"or shorten this entry to ≤{free} chars.\n"
261+
f"Current entries:\n{self._inventory(entries)}",
262+
full=True,
229263
)
230264
await self._write_entries(target, [*entries, content])
231265
return MemoryOpResult(True, "Entry added.")
@@ -265,8 +299,16 @@ async def replace(self, target: Target, old_text: str, new_content: str) -> Memo
265299
limit = self._char_limit(target)
266300
candidate = list(entries)
267301
candidate[idx] = new_content
268-
if len(ENTRY_DELIMITER.join(candidate)) > limit:
269-
return MemoryOpResult(False, f"Replacement would exceed the {limit}-char limit.")
302+
projected = self._used(candidate)
303+
if projected > limit:
304+
over = projected - limit
305+
return MemoryOpResult(
306+
False,
307+
f"Replacement too large by {over} chars: result would be "
308+
f"{projected}/{limit}. Shorten the new text by ≥{over} chars, or remove "
309+
f"another entry first.\nCurrent entries:\n{self._inventory(entries)}",
310+
full=True,
311+
)
270312
await self._write_entries(target, candidate)
271313
return MemoryOpResult(True, "Entry replaced.")
272314

@@ -289,6 +331,31 @@ async def remove(self, target: Target, old_text: str) -> MemoryOpResult:
289331
await self._write_entries(target, entries)
290332
return MemoryOpResult(True, "Entry removed.")
291333

334+
async def capacity(self, target: Target) -> tuple[int, int, int]:
335+
"""Return ``(used, limit, free)`` chars for ``target`` (free accounts for the
336+
delimiter a new entry would cost). Used by the UI to show/explain capacity."""
337+
entries = await self.read_entries(target)
338+
limit = self._char_limit(target)
339+
used = self._used(entries)
340+
free = max(0, limit - used - self._append_overhead(entries))
341+
return used, limit, free
342+
343+
async def status(self, target: Target) -> str:
344+
"""Read-only capacity + inventory snapshot for mid-session introspection.
345+
346+
Lets the agent see what is stored, at what size, and exactly how much room
347+
is free before attempting a write — so it can remove/consolidate instead of
348+
repeatedly retrying an over-budget add.
349+
"""
350+
entries = await self.read_entries(target)
351+
limit = self._char_limit(target)
352+
used = self._used(entries)
353+
free = max(0, limit - used - self._append_overhead(entries))
354+
return (
355+
f"{self._filename(target)}: {used}/{limit} chars across {len(entries)} "
356+
f"entries; {free} free for a new entry.\nCurrent entries:\n{self._inventory(entries)}"
357+
)
358+
292359
async def append_journal(self, recap: str) -> MemoryOpResult:
293360
"""Prepend one stable session recap to ``JOURNAL.md`` if it is new."""
294361
recap = recap.strip()

src/pythinker_code/tools/memory/__init__.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111

1212
class Params(BaseModel):
13-
action: Literal["add", "replace", "remove"] = Field(description="The memory operation.")
13+
action: Literal["add", "replace", "remove", "list"] = Field(description="The memory operation.")
1414
target: Literal["memory", "user"] = Field(description="Which store to write.")
1515
content: str | None = Field(default=None, description="Entry text for add/replace.")
1616
old_text: str | None = Field(
@@ -43,6 +43,10 @@ async def __call__(self, params: Params) -> ToolReturnValue:
4343
message="old_text is required for replace/remove.", brief="memory: no old_text"
4444
)
4545

46+
if params.action == "list":
47+
output = await self._store.status(params.target)
48+
return ToolOk(output=output, message=output, brief="list")
49+
4650
if params.action == "add":
4751
result = await self._store.add(params.target, params.content or "")
4852
elif params.action == "replace":
@@ -53,7 +57,18 @@ async def __call__(self, params: Params) -> ToolReturnValue:
5357
result = await self._store.remove(params.target, params.old_text or "")
5458

5559
if not result.ok:
56-
return ToolError(message=result.message, brief="memory: rejected")
60+
message = result.message
61+
if result.full:
62+
# Educate the human reading the tool card: what happened, that nothing
63+
# was lost, and the real ways to fix it. Kept short and command-accurate.
64+
message += (
65+
"\n\nProject memory for this repo is full — nothing was lost, and your "
66+
"task can continue. To free space: ask me to merge or drop stale entries, "
67+
"run /memory to see what's stored, or edit MEMORY.md / USER.md directly. "
68+
"Memory is for durable facts only, so occasional pruning is expected."
69+
)
70+
brief = "memory: full" if result.full else "memory: rejected"
71+
return ToolError(message=message, brief=brief)
5772
rearm = getattr(self._runtime, "rearm_injection", None)
5873
if rearm is not None:
5974
rearm("project_memory")

src/pythinker_code/tools/memory/memory.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,14 @@ ACTIONS:
1818
- `add`: append a new entry (requires `content`).
1919
- `replace`: update an entry (`old_text` = a unique substring of the target entry; `content` = new text).
2020
- `remove`: delete an entry (`old_text` = a unique substring of the target entry).
21+
- `list`: show current entries with their sizes and how much room is free (read-only).
22+
23+
WHEN A WRITE IS REJECTED FOR SPACE:
24+
The error reports the exact free budget and lists existing entries. Do NOT retry the
25+
same write with a slightly shorter entry — instead `remove` or `replace` a stale or
26+
redundant entry to free room, or consolidate two entries into one. Use `list` first if
27+
unsure what is stored.
28+
29+
Memory writes are best-effort housekeeping: if room cannot be freed in one or two
30+
steps, drop the write and continue the user's actual task. Never loop on a rejected
31+
memory write.

src/pythinker_code/ui/shell/slash.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2016,7 +2016,7 @@ async def show_memory(app: Shell, args: str):
20162016
soul = ensure_pythinker_soul(app)
20172017
if soul is None:
20182018
return
2019-
from pythinker_code.project_memory import ProjectMemoryStore
2019+
from pythinker_code.project_memory import ProjectMemoryStore, Target
20202020

20212021
store = ProjectMemoryStore(soul.runtime.work_dir)
20222022
parts = args.split()
@@ -2062,6 +2062,25 @@ async def show_memory(app: Shell, args: str):
20622062
return
20632063
console.print(block)
20642064

2065+
# Capacity line + education: surface how full each store is so the user
2066+
# understands the "memory full" rejection and how to act on it.
2067+
near_full = False
2068+
cap_parts: list[str] = []
2069+
targets: tuple[tuple[Target, str], ...] = (("memory", "Project"), ("user", "User"))
2070+
for target, label in targets:
2071+
used, limit, free = await store.capacity(target)
2072+
cap_parts.append(f"{label} {used}/{limit} ({free} free)")
2073+
if limit and used / limit >= 0.85:
2074+
near_full = True
2075+
console.print(f"\n[dim]Capacity — {' · '.join(cap_parts)}[/dim]")
2076+
if near_full:
2077+
console.print(
2078+
"[yellow]Memory is nearly full.[/yellow] When full, new facts are rejected "
2079+
"(nothing is lost). To make room: ask the agent to merge or drop stale entries, "
2080+
"or edit MEMORY.md / USER.md directly. Memory holds only durable facts, so "
2081+
"occasional pruning is expected."
2082+
)
2083+
20652084

20662085
@registry.command(name="update", aliases=["upgrade"])
20672086
async def update_command(app: Shell, args: str):

tests/core/test_project_memory.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,54 @@ async def test_add_success_dedup_guard_and_limit(tmp_path, monkeypatch):
158158
r = await store.add("memory", " ")
159159
assert not r.ok
160160

161+
# Over-budget add: the message must disclose the exact free space (delimiter
162+
# included) and list current entries so the model can free room in one step,
163+
# instead of blind-shrinking against an invisible boundary.
161164
r = await store.add("memory", "x" * 60)
162-
assert not r.ok and "limit" in r.message.lower()
165+
assert not r.ok
166+
# used=11 ("uses pytest"), overhead=3 (delimiter), free = 40 - 11 - 3 = 26.
167+
assert "26 free" in r.message
168+
assert "11/40" in r.message
169+
assert "uses pytest" in r.message # inventory preview present
170+
171+
172+
async def test_add_rejection_accounts_for_delimiter_overhead(tmp_path, monkeypatch):
173+
"""An entry that fits the raw budget but not the delimiter must still be rejected,
174+
and the message must state the true free budget (limit - used - delimiter)."""
175+
store = _store(tmp_path, monkeypatch) # limit 40
176+
await store.add("memory", "x" * 35) # used = 35
177+
# Raw free = 5, but a new entry also costs the 3-char delimiter, so only 2
178+
# content chars actually fit. A 4-char entry (35+3+4=42 > 40) must be rejected.
179+
r = await store.add("memory", "abcd")
180+
assert not r.ok
181+
assert "2 free" in r.message
182+
# And the largest entry that DOES fit (35+3+2=40) is accepted.
183+
r = await store.add("memory", "ab")
184+
assert r.ok
185+
186+
187+
async def test_status_reports_capacity_and_inventory(tmp_path, monkeypatch):
188+
store = _store(tmp_path, monkeypatch) # limit 40
189+
190+
empty = await store.status("memory")
191+
assert "0/40" in empty
192+
assert "(none)" in empty
193+
194+
await store.add("memory", "uses pytest")
195+
s = await store.status("memory")
196+
assert "11/40" in s
197+
assert "26 free" in s # 40 - 11 - 3
198+
assert "uses pytest" in s
199+
200+
201+
async def test_replace_over_limit_reports_overage(tmp_path, monkeypatch):
202+
store = _store(tmp_path, monkeypatch) # limit 40
203+
await store.add("memory", "short") # used = 5
204+
205+
r = await store.replace("memory", "short", "y" * 50)
206+
assert not r.ok
207+
assert "by 10 chars" in r.message # 50 - 40
208+
assert "short" in r.message # inventory present
163209

164210

165211
async def test_replace_matches_substring_and_errors(tmp_path, monkeypatch):

tests/tools/test_memory_tool.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,21 @@ async def test_memory_tool_add_and_read_back(tmp_path, monkeypatch):
5858
assert calls == ["project_memory"]
5959

6060

61+
async def test_memory_tool_list_reports_status(tmp_path, monkeypatch):
62+
from pythinker_code.tools.memory import Params
63+
64+
tool = _make_tool(tmp_path, monkeypatch)
65+
await tool._store.add("memory", "uses pytest")
66+
67+
# `list` is read-only: needs no content/old_text and must not rearm injection.
68+
rearmed: list[str] = []
69+
tool._runtime.rearm_injection = rearmed.append
70+
res = await tool(Params(action="list", target="memory"))
71+
assert res.is_error is False
72+
assert "uses pytest" in res.output
73+
assert rearmed == []
74+
75+
6176
async def test_memory_tool_missing_content_errors(tmp_path, monkeypatch):
6277
from pythinker_code.tools.memory import Params
6378

tests/ui_and_conv/test_memory_slash.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,24 @@ async def _boom(*args, **kwargs):
5757
assert called["scan"] is False
5858

5959

60+
async def test_memory_shows_capacity_line(tmp_path, monkeypatch, capsys):
61+
"""Bare `/memory` prints a capacity summary so the user can see how full it is."""
62+
monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path / "share"))
63+
from pythinker_code.project_memory import ProjectMemoryStore
64+
from pythinker_code.ui.shell import slash
65+
66+
soul = _fake_soul(tmp_path, consolidation=False)
67+
monkeypatch.setattr(slash, "ensure_pythinker_soul", lambda app: soul)
68+
69+
store = ProjectMemoryStore(soul.runtime.work_dir)
70+
await store.add("memory", "uses pytest")
71+
72+
await _run("", SimpleNamespace())
73+
out = capsys.readouterr().out
74+
assert "Capacity" in out
75+
assert "/5000" in out # raised project-memory limit
76+
77+
6078
async def test_memory_inbox_enabled_invokes_scan(tmp_path, monkeypatch, capsys):
6179
"""With the flag on, `/memory inbox scan` reaches the consolidation path."""
6280
from pythinker_code.ui.shell import slash

0 commit comments

Comments
 (0)