Skip to content

Commit 58c710c

Browse files
authored
feat: tracing dashboard redesign and robust project-memory tooling (#127)
* feat: MCP skill bridge, agent guardrails, and session UX fixes ReadSkill now resolves a connected MCP server name (including plugin-style aliases like designer-skill:designer-skill) to a bridge listing that server's tools instead of "Skill not found", and ships a designer-skill stub that routes frontend work to the MCP tools. best_practices_always config option folds the full /best-practices engineering guidance into the root session's system prompt at startup (default off), so the guardrails apply to every new session without running the command. StrReplaceFile returns a precise, actionable error when a multi-edit batch fails schema validation (e.g. entries collapsed by a streaming glitch), naming the bad entries and steering toward single-edit calls; valid edits are never partially applied. The default system prompt now requires absence claims ("no em-dashes", "no leftover debug", "matches the source") to be backed by an actual zero-hit scan, and to re-ask rather than act on a self-authored reading of a non-responsive clarifying answer. Also: /recap on|off toggle with grey autosuggest; recaps strip <system-reminder> blocks; configured /login providers get distinct state styling; braille background spinner and hanging-indent working tips; scratch files are cleaned up on exception-path session exit. Removes leftover debug instrumentation from the skill tool. * fix(tui): render diff context lines in normal text color Unchanged context lines in file-edit diff snippets (Write, StrReplaceFile, and all diff cards) used a muted grey (tool_diff_context), which read as dimmed against the normal body text. Match the normal `text` token instead — terminal default foreground in dark, #213853 in light — so edited-file previews are easy to read. Added/removed lines keep their green/red styling, so changes still stand out. * Redesign tracing dashboard with Usage page and polish Refresh the Statistics and Sessions surfaces of the agent tracing visualizer and add a new Usage page, keeping the existing neutral/zinc identity (no new brand accent) and the dependency-free SVG charts. - Add shared shadcn-style Card primitives (12px radius, subtle shadow) - Statistics: icon-tile metric cards with helper lines, titled chart/ tool/table cards, rounded bars, bordered hover-row project table, width-contained layout, and an empty state - Sessions: focus rings and a search hint in the toolbar, softer card radius with hover lift, folder-tile project group headers - New Usage page: summary cards, a GitHub-style activity heatmap keyed on daily turns (monochrome intensity ramp), and a turn-trend chart - Header: app icon tile, subtitle, accessible theme toggle; add the Usage tab as polished pills - Add a prefers-reduced-motion safety block to global CSS Behavior, data flow, and DOM/event contracts are unchanged. * Rank command-name matches above alias matches in slash completer Typing /report surfaced /report_error first because its "report" alias was an exact match, ranked above /reports (a command-name prefix match). Rank by match tier (name exact, name prefix, alias exact, alias prefix) then by command-name length, so the closest command name wins. /report now lists /reports first. Drop the now-unused _command_lookup and add regression tests. * Polish dashboard with blue accent and richer Usage page Apply a soft-enterprise analytics treatment across the tracing visualizer and make the Usage visualizations feel intentional. - Introduce a single restrained blue accent (primary/ring tokens) for light and dark; charts and the heatmap now carry visual hierarchy - Shared premium MetricCard (rounded-2xl, icon tile, hover lift); used by Statistics and Usage - Usage: heatmap and a new Usage Insights panel sit side by side to use the available width; larger blue GitHub-style heatmap with a Turns/Sessions toggle and Less/More legend - New area trend chart with gradient fill, gridlines, axis labels, and a hover tooltip (dependency-free SVG) - Statistics: single accent tool bars with a neutral error badge instead of red segments; blue daily-usage series - Soft muted page background and consistent rounded-2xl cards Charts remain hand-rolled SVG (no recharts). Behavior and data flow are unchanged. * chore(deps): bump react to 19.2.7 in vis and web Align the web and vis frontends on the latest stable React (19.2.7, @types/react 19.2.17) on top of vite 8, and refresh both lockfiles. Both frontends type-check and build cleanly under vite 8 + react 19.2.7. * Make Daily Usage chart fill the card width The chart used a fixed 600x140 viewBox with maxHeight, so preserveAspectRatio letterboxed it: the plot rendered ~600px wide and floated centered in a much wider card. Measure the container width with a ResizeObserver and render the SVG full width (taller, with gridlines and a sessions Y axis), matching the Usage trend chart. * 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. * feat(memory): routing-guard advisory and index-based entry locator Add a structural detector that nudges the agent toward editing the authoritative project file when a memory write looks like a rule or value-assignment that belongs in a file (a recurring failure mode: a correction rephrased as a "preference" and stored in memory while the governing file stays stale). The guard never blocks — it only appends a one-line advisory on add/replace — so false positives cost a sentence. Also let replace/remove identify an entry by 0-based `index` (from `list`) as a deterministic alternative to `old_text` substring matching; out-of-range indices report the inventory so the retry is guided. Slash completer now surfaces the matched alias as the menu label (`/res` -> `/resume`) while keeping name matches ranked above alias-only matches. memory.md guidance updated for authoritative-files-first and the new index-based locator. * test: green CI for new module, recap usage string, and designer-skill Regenerate the inline snapshots that pin auto-discovered state so they match the new code: - pyinstaller `hiddenimports` now includes the new `pythinker_code.tools.memory.routing_guard` module. - wire-handshake slash-command list: `/recap` usage gained `on|off` and the `skill:designer-skill` command was added. Make the recap slash-command test await type-safe: the registry types commands as `None | Awaitable[None]`, so guard with `isinstance(ret, Awaitable)` before awaiting (matching the existing shell-slash test helper) instead of awaiting the union directly, which pyright rejects. * fix(deps): regenerate web and vis lockfiles so npm ci succeeds in build The react 19.2.7 / vite 8.0.16 bump left both lockfiles inconsistent: they were missing the platform-specific optional @emnapi/* wasm-binding deps (oxc-parser, oxc-resolver, rolldown). Because those are optional per-OS deps, a partial `--package-lock-only` regen on one platform does not capture the set CI's Linux runner needs, so `npm ci` kept failing with EUSAGE ("package.json and package-lock.json are not in sync") and broke `make build-web` / build-vis in the PyInstaller onefile jobs. Regenerate both lockfiles from a clean install so they carry the full cross-platform optional-dep tree. Direct deps are unchanged (react 19.2.7, vite 8.0.16, typescript 5.9.3); the churn is transitive/optional ordering. Verified `npm ci` succeeds and `build-web`/`build-vis` produce assets in both web/ and vis/. * fix: address PR review feedback - cli: replace silent contextlib.suppress(Exception) in the exception-path session cleanup with explicit try/except that logs at debug, so import or runtime failures during best-effort cleanup are traceable (CodeRabbit). - slash (/recap): revert the in-memory turn_recaps toggle when persistence fails, so runtime state matches the reported failure instead of silently diverging (CodeRabbit, major). - test_slash_recap: await the recap command via a type-honest cast instead of a bare `await ret` (clears the "statement has no effect" code-scanning flag). - test_session: silence ARG001 on the three exception-cleanup tests with @pytest.mark.usefixtures (renaming the param to _isolated_share_dir would break pytest fixture injection). - test_settings_recaps_slash: assert save_config is actually called in the singular `/recap on` alias test (CodeRabbit). * feat(memory): confirm before remembering routing-flagged writes When an add/replace looks like a rule, a value/limit, or names a project file (the existing routing_guard signals), gate it behind user confirmation before writing — so the agent can't silently fill memory with corrections that belong in an authoritative file. Plain durable facts (no signals) still write directly. Uses the shared approval flow (Runtime.approval), so yolo/auto-approve and "allow for session" are honored automatically: - approved -> the entry is written; - declined by the user -> not written; the agent is told to edit the governing file instead, with the user's feedback; - no user available (headless) -> skipped cleanly, reported as skipped (not an error), so unattended runs never accumulate flagged memory. This replaces the weaker post-write advisory nudge with a real gate. Also makes the full-store guidance target-specific ("Project memory" vs "User memory") instead of always saying "Project memory" (CodeRabbit). * fix(web): pin ultracite to 7.1.1 so biome resolves ultracite/core Regenerating web/package-lock.json bumped ultracite within `^7.1.1` to 7.8.3, which restructured its package exports and dropped `ultracite/core` — web/biome.json extends `ultracite/core`, so `make check-web` (biome) failed with "Could not resolve ultracite/core". Pin ultracite to exactly 7.1.1 (the version main uses) until biome.json is migrated to newer ultracite. The cross-platform @emnapi lockfile fix is preserved; `npm ci`, biome, and the web build all pass. * fix: address CodeRabbit review on latest commits - memory gate: stop logging the raw content preview (content[:60]) in the routing-flagged debug log — a declined/blocked write could otherwise leak secrets/PII; log only target + signals (CodeRabbit, major). - cli: drop the redundant local `logger` import in the exception-path cleanup; the enclosing function already binds `logger` in scope (CodeRabbit). - docs/build: the react bump moved web/vis to Vite 8, but build_vis.py, web/ AGENTS.md, and architecture.md still said "Vite 7". Update the labels (the Node engine constraint ^20.19.0 || >=22.12.0 is unchanged in Vite 8).
1 parent d020e4e commit 58c710c

64 files changed

Lines changed: 6863 additions & 4133 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **designer-skill MCP bridge.** Bundled a `designer-skill` stub skill that routes frontend work to the connected designer-skill MCP tools instead of failing ReadSkill; plugin-style names like `designer-skill:designer-skill` resolve correctly, and ReadSkill falls back to a generic MCP bridge (any user-configured server name) when only the MCP server is connected.
19+
- **Always-on best practices.** New `best_practices_always` config option folds the full `/best-practices` engineering guidance into the root session's system prompt at startup, so the guardrails apply to every new session without running the command. Default off.
20+
- **Smarter multi-edit errors.** A `StrReplaceFile` batch that fails schema validation (e.g. edit entries collapsed by a streaming glitch) now returns a precise, actionable error naming the bad entries and steering toward single-edit calls, instead of a wall of validation errors. Valid edits are never partially applied.
21+
- **Agent guardrails.** The default system prompt now requires absence claims ("no em-dashes", "no leftover debug", "matches the source") to be backed by an actual zero-hit scan, and to re-ask rather than act on a self-authored reading of a non-responsive clarifying answer.
22+
- **`/recap on|off`.** Toggle turn recaps from the recap command (mirrors `/settings recap(s)`), with a grey inline autosuggest reflecting the current state.
23+
- **Recap hygiene.** Session recaps strip `<system-reminder>` blocks so injected harness context no longer leaks into one-line recaps.
24+
- **Login selector polish.** Configured `/login` providers render with distinct success/state styling; the background working indicator uses the braille spinner, and working tips wrap with a hanging indent under the verb.
25+
- **Scratch cleanup on exit.** Sessions that end via an exception now clean up their scratch files instead of orphaning them.
26+
- **Readable diff context.** Unchanged context lines in file-edit diff snippets now render in the normal body-text color instead of muted grey, so edited-file previews are easier to read; added/removed lines are unchanged.
27+
1828
## 0.42.0 (2026-06-12)
1929

2030
- **Adversarial branch review hardening.** A multi-agent review pass confirmed and fixed: committed-but-clean isolation worktrees are now retained (commits ahead of the creation base count as changes — they were previously orphaned on cleanup); foreground shell commands and relative-path file edits now resolve against the agent's work dir, so worktree isolation actually binds them (host exec gained a `cwd` argument); safe mode now also disables the read-only-command prompt elision; locally parallel-safe MCP/tools run in parallel in the same-step gate; worktree add/remove serializes per repo. Behavior notes: same-step tool calls without `supports_parallel` now serialize deterministically (previously fully concurrent), and text-mode error diagnostics moved to stderr — capture `2>&1` or use `--output-format stream-json` if you scraped stdout.

docs/en/customization/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ is load-bearing for approval gating of persistent-backdoor vectors (`AGENTS.md`,
219219
| --- | --- | --- |
220220
| `src/pythinker_code/web/` | FastAPI backend (port 5494) managing CLI sessions via subprocess workers; bearer-token auth; `/api/*`; sensitive-path restriction. | `create_app`, `run_web_server`, `PythinkerCLIRunner`, `SessionProcess`, `AuthMiddleware` |
221221
| `src/pythinker_code/vis/` | FastAPI read-only tracing/statistics backend (port 5495) for the visualizer. | `create_app`, `run_vis_server` |
222-
| `web/` | React 19 + Vite 7 + TypeScript SPA chat UI; bundled into the package. See `web/AGENTS.md`. | `main.tsx`, `App`, `apiClient`, generated client `src/lib/api/`, `useSessionStream` |
222+
| `web/` | React 19 + Vite 8 + TypeScript SPA chat UI; bundled into the package. See `web/AGENTS.md`. | `main.tsx`, `App`, `apiClient`, generated client `src/lib/api/`, `useSessionStream` |
223223
| `vis/` | React 19 + Vite session-tracing visualizer. See `vis/AGENTS.md`. | `main.tsx`, `App`, hand-written `src/lib/api.ts` (`WireEvent`, `ContextMessage`, `SessionInfo`), feature panels under `src/features/` |
224224

225225
Both frontends build with `tsc -b && vite build` and are synced into the Python package by

scripts/build_vis.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def resolve_npm() -> str | None:
3535

3636

3737
def check_node_version() -> bool:
38-
"""Vite 7 requires Node.js ^20.19.0 || >=22.12.0."""
38+
"""Vite 8 requires Node.js ^20.19.0 || >=22.12.0."""
3939
node = shutil.which("node")
4040
if not node:
4141
return False
@@ -47,7 +47,7 @@ def check_node_version() -> bool:
4747
ok = (major == 20 and minor >= 19) or (major >= 22 and (major > 22 or minor >= 12))
4848
if not ok:
4949
print(
50-
f"Node.js ^20.19.0 or >=22.12.0 required (Vite 7), found v{version}",
50+
f"Node.js ^20.19.0 or >=22.12.0 required (Vite 8), found v{version}",
5151
file=sys.stderr,
5252
)
5353
return False

src/pythinker_code/agents/default/system.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Eight rules that override convenience, speed, and every other instruction in thi
2323

2424
1. **Read before write.** Never edit a file you have not read this session; confirm the exact lines you are about to modify still match what you read.
2525
2. **Complete code only.** Never write placeholders, stubs, `TODO: implement`, elided bodies, or "rest of the file unchanged" markers into files. If a change is too large for one step, split the work — never abridge the code. (Genuine `TODO:` notes for real technical debt are fine.)
26-
3. **Evidence before claims.** Every "done", "fixed", or "works" names the command you ran and the result you observed. Verification means a passing test, a working repro, or a deterministic command that confirms the intended behavior — compiling or type-checking alone is not verification. This definition is canonical: it is what "verify" means everywhere in this prompt.
26+
3. **Evidence before claims.** Every "done", "fixed", or "works" names the command you ran and the result you observed. Verification means a passing test, a working repro, or a deterministic command that confirms the intended behavior — compiling or type-checking alone is not verification. This definition is canonical: it is what "verify" means everywhere in this prompt. A claim that something is *absent* — no banned strings, no em-dashes, no leftover debug instrumentation, no TODOs, output matches the source — is only true after a scan that returned zero hits; never assert absence from memory.
2727
4. **Re-verify after every edit.** An edit invalidates all prior verification; re-run the smallest check that proves the change is sound before building on top of it.
2828
5. **Honest failure.** When verification fails, report the failing output verbatim under **BLOCKERS**. Never weaken an assertion, skip a test, widen a tolerance, swallow an error, or silently narrow scope to get to green.
2929
6. **Match the codebase.** Existing style, granularity, naming, and idioms beat your preferences. A correct change that fights the codebase's conventions is not done.
@@ -57,7 +57,7 @@ State multi-step plans inline as `Step → verify: check`; substantial tasks kee
5757

5858
**Report** with `path:line` references over pasted blocks, concise findings, and explicit residual risk — unverified assumptions, untested paths, recommended follow-ups, and unrelated issues noticed but not touched.
5959

60-
**Ask vs. act.** Act without asking when intent is clear, the change is reversible, and it is in scope. Ask one focused question — before implementation, never after mistakes — when interpretations genuinely diverge, an action is irreversible or destructive, credentials are needed, requirements conflict, or scope grows beyond the request. Never ask what a tool call can answer.
60+
**Ask vs. act.** Act without asking when intent is clear, the change is reversible, and it is in scope. Ask one focused question — before implementation, never after mistakes — when interpretations genuinely diverge, an action is irreversible or destructive, credentials are needed, requirements conflict, or scope grows beyond the request. Never ask what a tool call can answer. If an answer to a clarifying question does not actually resolve the ambiguity, say so and re-ask with your default stated — never act on a self-authored interpretation of a non-answer.
6161

6262
**Steering.** If the user interjects or redirects mid-task, stop, reconcile the new instruction with the current plan, update the todos, then continue.
6363

src/pythinker_code/app.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,14 @@ async def create(
352352
)
353353
_phase_timings_ms["mcp_ms"] = int((time.monotonic() - _phase_t) * 1000)
354354

355+
if runtime.config.best_practices_always:
356+
from pythinker_code.prompts import apply_always_on_best_practices
357+
358+
agent = dataclasses.replace(
359+
agent,
360+
system_prompt=apply_always_on_best_practices(agent.system_prompt, enabled=True),
361+
)
362+
355363
if startup_progress is not None:
356364
startup_progress("Restoring conversation...")
357365
context = Context(session.context_file)

src/pythinker_code/cli/__init__.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1203,10 +1203,26 @@ async def _reload_loop(session_id: str | None) -> tuple[str | None, int]:
12031203
# the most recent _run() call, which may have failed before returning.
12041204
# last_session is from a *previous* iteration and must not be touched.
12051205
if _latest_created_session is not None:
1206+
try:
1207+
from pythinker_code.scratchpad import cleanup_session_scratch
1208+
1209+
await cleanup_session_scratch(
1210+
_latest_created_session.work_dir,
1211+
session_id=_latest_created_session.id,
1212+
session_title=_latest_created_session.title,
1213+
)
1214+
except Exception:
1215+
# Best-effort cleanup: log at debug so the failure is traceable
1216+
# without disrupting the exception currently being re-raised.
1217+
logger.opt(exception=True).debug(
1218+
"Best-effort exception-path scratch cleanup failed"
1219+
)
12061220
_print_resume_hint(_latest_created_session)
12071221
if _latest_created_session.is_empty():
1208-
with contextlib.suppress(Exception):
1222+
try:
12091223
await _delete_empty_session(_latest_created_session)
1224+
except Exception:
1225+
logger.opt(exception=True).debug("Best-effort empty-session cleanup failed")
12101226
raise
12111227

12121228
if _picker_mode:

src/pythinker_code/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,14 @@ class Config(BaseModel):
10201020
),
10211021
)
10221022
default_yolo: bool = Field(default=False, description="Default yolo (auto-approve) mode")
1023+
best_practices_always: bool = Field(
1024+
default=False,
1025+
description=(
1026+
"When true, fold the full /best-practices engineering guidance into the root "
1027+
"session's system prompt at startup, so the guardrails apply without running "
1028+
"/best-practices manually. Applies to new sessions; costs context tokens each session."
1029+
),
1030+
)
10231031
ask_user_question_policy: Literal["always", "ask_except_auto", "never", "auto_deliberate"] = (
10241032
Field(
10251033
default="ask_except_auto",

src/pythinker_code/project_memory.py

Lines changed: 101 additions & 17 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.")
@@ -241,11 +275,28 @@ def _match_one(entries: list[str], old_text: str) -> int | MemoryOpResult:
241275
)
242276
return matches[0]
243277

244-
async def replace(self, target: Target, old_text: str, new_content: str) -> MemoryOpResult:
278+
def _locate(self, entries: list[str], old_text: str, index: int | None) -> int | MemoryOpResult:
279+
"""Resolve which entry to mutate. ``index`` (0-based, from `list`) is the
280+
deterministic path — preferred when substring matching is uncertain;
281+
``old_text`` is the substring fallback. Out-of-range indices report the
282+
inventory so the retry is guided, not a guess."""
283+
if index is not None:
284+
if not 0 <= index < len(entries):
285+
return MemoryOpResult(
286+
False,
287+
f"No entry at index {index} ({len(entries)} stored). "
288+
f"Current entries:\n{self._inventory(entries)}",
289+
)
290+
return index
291+
if old_text:
292+
return self._match_one(entries, old_text)
293+
return MemoryOpResult(False, "Provide old_text or index to identify the entry.")
294+
295+
async def replace(
296+
self, target: Target, old_text: str, new_content: str, *, index: int | None = None
297+
) -> MemoryOpResult:
245298
old_text = old_text.strip()
246299
new_content = new_content.strip()
247-
if not old_text:
248-
return MemoryOpResult(False, "old_text cannot be empty.")
249300
if not new_content:
250301
return MemoryOpResult(False, "new_content cannot be empty. Use 'remove' to delete.")
251302
blocked = scan_memory_content(new_content)
@@ -259,21 +310,29 @@ async def replace(self, target: Target, old_text: str, new_content: str) -> Memo
259310
return MemoryOpResult(
260311
False, f"Memory read failed ({exc}); aborting write to avoid data loss."
261312
)
262-
idx = self._match_one(entries, old_text)
313+
idx = self._locate(entries, old_text, index)
263314
if isinstance(idx, MemoryOpResult):
264315
return idx
265316
limit = self._char_limit(target)
266317
candidate = list(entries)
267318
candidate[idx] = new_content
268-
if len(ENTRY_DELIMITER.join(candidate)) > limit:
269-
return MemoryOpResult(False, f"Replacement would exceed the {limit}-char limit.")
319+
projected = self._used(candidate)
320+
if projected > limit:
321+
over = projected - limit
322+
return MemoryOpResult(
323+
False,
324+
f"Replacement too large by {over} chars: result would be "
325+
f"{projected}/{limit}. Shorten the new text by ≥{over} chars, or remove "
326+
f"another entry first.\nCurrent entries:\n{self._inventory(entries)}",
327+
full=True,
328+
)
270329
await self._write_entries(target, candidate)
271330
return MemoryOpResult(True, "Entry replaced.")
272331

273-
async def remove(self, target: Target, old_text: str) -> MemoryOpResult:
332+
async def remove(
333+
self, target: Target, old_text: str, *, index: int | None = None
334+
) -> MemoryOpResult:
274335
old_text = old_text.strip()
275-
if not old_text:
276-
return MemoryOpResult(False, "old_text cannot be empty.")
277336
path = await self._path_for(target)
278337
async with self._async_lock, self._file_lock(path):
279338
try:
@@ -282,13 +341,38 @@ async def remove(self, target: Target, old_text: str) -> MemoryOpResult:
282341
return MemoryOpResult(
283342
False, f"Memory read failed ({exc}); aborting write to avoid data loss."
284343
)
285-
idx = self._match_one(entries, old_text)
344+
idx = self._locate(entries, old_text, index)
286345
if isinstance(idx, MemoryOpResult):
287346
return idx
288347
entries.pop(idx)
289348
await self._write_entries(target, entries)
290349
return MemoryOpResult(True, "Entry removed.")
291350

351+
async def capacity(self, target: Target) -> tuple[int, int, int]:
352+
"""Return ``(used, limit, free)`` chars for ``target`` (free accounts for the
353+
delimiter a new entry would cost). Used by the UI to show/explain capacity."""
354+
entries = await self.read_entries(target)
355+
limit = self._char_limit(target)
356+
used = self._used(entries)
357+
free = max(0, limit - used - self._append_overhead(entries))
358+
return used, limit, free
359+
360+
async def status(self, target: Target) -> str:
361+
"""Read-only capacity + inventory snapshot for mid-session introspection.
362+
363+
Lets the agent see what is stored, at what size, and exactly how much room
364+
is free before attempting a write — so it can remove/consolidate instead of
365+
repeatedly retrying an over-budget add.
366+
"""
367+
entries = await self.read_entries(target)
368+
limit = self._char_limit(target)
369+
used = self._used(entries)
370+
free = max(0, limit - used - self._append_overhead(entries))
371+
return (
372+
f"{self._filename(target)}: {used}/{limit} chars across {len(entries)} "
373+
f"entries; {free} free for a new entry.\nCurrent entries:\n{self._inventory(entries)}"
374+
)
375+
292376
async def append_journal(self, recap: str) -> MemoryOpResult:
293377
"""Prepend one stable session recap to ``JOURNAL.md`` if it is new."""
294378
recap = recap.strip()

0 commit comments

Comments
 (0)