Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ GitHub Releases page; `0.8.0` is the new starting line.

## Unreleased

- **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.
- **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.
- **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.
- **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.
- **`/recap on|off`.** Toggle turn recaps from the recap command (mirrors `/settings recap(s)`), with a grey inline autosuggest reflecting the current state.
- **Recap hygiene.** Session recaps strip `<system-reminder>` blocks so injected harness context no longer leaks into one-line recaps.
- **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.
- **Scratch cleanup on exit.** Sessions that end via an exception now clean up their scratch files instead of orphaning them.
- **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.

## 0.42.0 (2026-06-12)

- **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.
Expand Down
2 changes: 1 addition & 1 deletion docs/en/customization/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ is load-bearing for approval gating of persistent-backdoor vectors (`AGENTS.md`,
| --- | --- | --- |
| `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` |
| `src/pythinker_code/vis/` | FastAPI read-only tracing/statistics backend (port 5495) for the visualizer. | `create_app`, `run_vis_server` |
| `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` |
| `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` |
| `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/` |

Both frontends build with `tsc -b && vite build` and are synced into the Python package by
Expand Down
4 changes: 2 additions & 2 deletions scripts/build_vis.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def resolve_npm() -> str | None:


def check_node_version() -> bool:
"""Vite 7 requires Node.js ^20.19.0 || >=22.12.0."""
"""Vite 8 requires Node.js ^20.19.0 || >=22.12.0."""
node = shutil.which("node")
if not node:
return False
Expand All @@ -47,7 +47,7 @@ def check_node_version() -> bool:
ok = (major == 20 and minor >= 19) or (major >= 22 and (major > 22 or minor >= 12))
if not ok:
print(
f"Node.js ^20.19.0 or >=22.12.0 required (Vite 7), found v{version}",
f"Node.js ^20.19.0 or >=22.12.0 required (Vite 8), found v{version}",
file=sys.stderr,
)
return False
Expand Down
4 changes: 2 additions & 2 deletions src/pythinker_code/agents/default/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Eight rules that override convenience, speed, and every other instruction in thi

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.
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.)
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.
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.
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.
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.
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.
Expand Down Expand Up @@ -57,7 +57,7 @@ State multi-step plans inline as `Step → verify: check`; substantial tasks kee

**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.

**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.
**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.

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

Expand Down
8 changes: 8 additions & 0 deletions src/pythinker_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,14 @@ async def create(
)
_phase_timings_ms["mcp_ms"] = int((time.monotonic() - _phase_t) * 1000)

if runtime.config.best_practices_always:
from pythinker_code.prompts import apply_always_on_best_practices

agent = dataclasses.replace(
agent,
system_prompt=apply_always_on_best_practices(agent.system_prompt, enabled=True),
)

if startup_progress is not None:
startup_progress("Restoring conversation...")
context = Context(session.context_file)
Expand Down
18 changes: 17 additions & 1 deletion src/pythinker_code/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1203,10 +1203,26 @@ async def _reload_loop(session_id: str | None) -> tuple[str | None, int]:
# the most recent _run() call, which may have failed before returning.
# last_session is from a *previous* iteration and must not be touched.
if _latest_created_session is not None:
try:
from pythinker_code.scratchpad import cleanup_session_scratch

await cleanup_session_scratch(
_latest_created_session.work_dir,
session_id=_latest_created_session.id,
session_title=_latest_created_session.title,
)
except Exception:
# Best-effort cleanup: log at debug so the failure is traceable
# without disrupting the exception currently being re-raised.
logger.opt(exception=True).debug(
"Best-effort exception-path scratch cleanup failed"
)
_print_resume_hint(_latest_created_session)
if _latest_created_session.is_empty():
with contextlib.suppress(Exception):
try:
await _delete_empty_session(_latest_created_session)
except Exception:
logger.opt(exception=True).debug("Best-effort empty-session cleanup failed")
raise

if _picker_mode:
Expand Down
8 changes: 8 additions & 0 deletions src/pythinker_code/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,14 @@ class Config(BaseModel):
),
)
default_yolo: bool = Field(default=False, description="Default yolo (auto-approve) mode")
best_practices_always: bool = Field(
default=False,
description=(
"When true, fold the full /best-practices engineering guidance into the root "
"session's system prompt at startup, so the guardrails apply without running "
"/best-practices manually. Applies to new sessions; costs context tokens each session."
),
)
ask_user_question_policy: Literal["always", "ask_except_auto", "never", "auto_deliberate"] = (
Field(
default="ask_except_auto",
Expand Down
118 changes: 101 additions & 17 deletions src/pythinker_code/project_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@
from pythinker_code.soul.pythinkersoul import PythinkerSoul

ENTRY_DELIMITER = "\n§\n"
MEMORY_CHAR_LIMIT = 2200
USER_CHAR_LIMIT = 1375
MEMORY_CHAR_LIMIT = 5000
USER_CHAR_LIMIT = 2500
INJECTION_BUDGET_BYTES = 8 * 1024
_JOURNAL_MAX_ENTRIES = 100

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


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

@staticmethod
def _used(entries: list[str]) -> int:
return len(ENTRY_DELIMITER.join(entries))

@staticmethod
def _append_overhead(entries: list[str]) -> int:
"""Chars an appended entry costs beyond its own text (the joining delimiter)."""
return len(ENTRY_DELIMITER) if entries else 0

@staticmethod
def _inventory(entries: list[str]) -> str:
"""Compact, model-readable listing: index, size, and a one-line preview.

The preview doubles as a copy-paste ``old_text`` substring for remove/replace,
turning a space-rejection into a guided one-step fix instead of a guessing game.
"""
if not entries:
return " (none)"
lines: list[str] = []
for i, entry in enumerate(entries):
preview = " ".join(entry.split())
clipped = preview[:60] + ("…" if len(preview) > 60 else "")
lines.append(f" [{i}] {len(entry)} chars — {clipped}")
return "\n".join(lines)

async def _path_for(self, target: Target) -> Path:
root = await self._ensure_dir()
return root / "memory" / self._filename(target)
Expand Down Expand Up @@ -219,13 +247,19 @@ async def add(self, target: Target, content: str) -> MemoryOpResult:
if content in entries:
return MemoryOpResult(True, "Entry already exists (no duplicate added).")
limit = self._char_limit(target)
new_total = len(ENTRY_DELIMITER.join([*entries, content]))
if new_total > limit:
current = len(ENTRY_DELIMITER.join(entries))
if len(ENTRY_DELIMITER.join([*entries, content])) > limit:
used = self._used(entries)
overhead = self._append_overhead(entries)
free = max(0, limit - used - overhead)
need = len(content) + overhead
return MemoryOpResult(
False,
f"Memory at {current}/{limit} chars; this entry ({len(content)}) "
"exceeds the limit. Replace or remove entries first.",
f"Not enough room: this entry needs {need} chars "
f"(content {len(content)} + {overhead} separator), but only {free} free "
f"({used}/{limit} used). Remove or replace an entry to free space, "
f"or shorten this entry to ≤{free} chars.\n"
f"Current entries:\n{self._inventory(entries)}",
full=True,
)
await self._write_entries(target, [*entries, content])
return MemoryOpResult(True, "Entry added.")
Expand All @@ -241,11 +275,28 @@ def _match_one(entries: list[str], old_text: str) -> int | MemoryOpResult:
)
return matches[0]

async def replace(self, target: Target, old_text: str, new_content: str) -> MemoryOpResult:
def _locate(self, entries: list[str], old_text: str, index: int | None) -> int | MemoryOpResult:
"""Resolve which entry to mutate. ``index`` (0-based, from `list`) is the
deterministic path — preferred when substring matching is uncertain;
``old_text`` is the substring fallback. Out-of-range indices report the
inventory so the retry is guided, not a guess."""
if index is not None:
if not 0 <= index < len(entries):
return MemoryOpResult(
False,
f"No entry at index {index} ({len(entries)} stored). "
f"Current entries:\n{self._inventory(entries)}",
)
return index
if old_text:
return self._match_one(entries, old_text)
return MemoryOpResult(False, "Provide old_text or index to identify the entry.")

async def replace(
self, target: Target, old_text: str, new_content: str, *, index: int | None = None
) -> MemoryOpResult:
old_text = old_text.strip()
new_content = new_content.strip()
if not old_text:
return MemoryOpResult(False, "old_text cannot be empty.")
if not new_content:
return MemoryOpResult(False, "new_content cannot be empty. Use 'remove' to delete.")
blocked = scan_memory_content(new_content)
Expand All @@ -259,21 +310,29 @@ async def replace(self, target: Target, old_text: str, new_content: str) -> Memo
return MemoryOpResult(
False, f"Memory read failed ({exc}); aborting write to avoid data loss."
)
idx = self._match_one(entries, old_text)
idx = self._locate(entries, old_text, index)
if isinstance(idx, MemoryOpResult):
return idx
limit = self._char_limit(target)
candidate = list(entries)
candidate[idx] = new_content
if len(ENTRY_DELIMITER.join(candidate)) > limit:
return MemoryOpResult(False, f"Replacement would exceed the {limit}-char limit.")
projected = self._used(candidate)
if projected > limit:
over = projected - limit
return MemoryOpResult(
False,
f"Replacement too large by {over} chars: result would be "
f"{projected}/{limit}. Shorten the new text by ≥{over} chars, or remove "
f"another entry first.\nCurrent entries:\n{self._inventory(entries)}",
full=True,
)
await self._write_entries(target, candidate)
return MemoryOpResult(True, "Entry replaced.")

async def remove(self, target: Target, old_text: str) -> MemoryOpResult:
async def remove(
self, target: Target, old_text: str, *, index: int | None = None
) -> MemoryOpResult:
old_text = old_text.strip()
if not old_text:
return MemoryOpResult(False, "old_text cannot be empty.")
path = await self._path_for(target)
async with self._async_lock, self._file_lock(path):
try:
Expand All @@ -282,13 +341,38 @@ async def remove(self, target: Target, old_text: str) -> MemoryOpResult:
return MemoryOpResult(
False, f"Memory read failed ({exc}); aborting write to avoid data loss."
)
idx = self._match_one(entries, old_text)
idx = self._locate(entries, old_text, index)
if isinstance(idx, MemoryOpResult):
return idx
entries.pop(idx)
await self._write_entries(target, entries)
return MemoryOpResult(True, "Entry removed.")

async def capacity(self, target: Target) -> tuple[int, int, int]:
"""Return ``(used, limit, free)`` chars for ``target`` (free accounts for the
delimiter a new entry would cost). Used by the UI to show/explain capacity."""
entries = await self.read_entries(target)
limit = self._char_limit(target)
used = self._used(entries)
free = max(0, limit - used - self._append_overhead(entries))
return used, limit, free

async def status(self, target: Target) -> str:
"""Read-only capacity + inventory snapshot for mid-session introspection.

Lets the agent see what is stored, at what size, and exactly how much room
is free before attempting a write — so it can remove/consolidate instead of
repeatedly retrying an over-budget add.
"""
entries = await self.read_entries(target)
limit = self._char_limit(target)
used = self._used(entries)
free = max(0, limit - used - self._append_overhead(entries))
return (
f"{self._filename(target)}: {used}/{limit} chars across {len(entries)} "
f"entries; {free} free for a new entry.\nCurrent entries:\n{self._inventory(entries)}"
)

async def append_journal(self, recap: str) -> MemoryOpResult:
"""Prepend one stable session recap to ``JOURNAL.md`` if it is new."""
recap = recap.strip()
Expand Down
Loading
Loading