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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "synapt"
version = "0.19.0"
version = "0.19.1"
description = "Persistent conversational memory for AI coding assistants"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/synapt/recall/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""synapt.recall — persistent conversational memory for Claude Code and ChatGPT sessions."""

__version__ = "0.19.0"
__version__ = "0.19.1"

from synapt.recall.core import (
TranscriptChunk,
Expand Down
13 changes: 7 additions & 6 deletions src/synapt/recall/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
_read_all_session_ids,
auto_extract_entry,
append_entry,
split_journal_field,
)


Expand Down Expand Up @@ -1722,12 +1723,12 @@ def cmd_journal(args: argparse.Namespace) -> None:
if args.focus:
entry.focus = args.focus
if args.done:
entry.done = [d.strip() for d in args.done.split(";")]
entry.done = split_journal_field(args.done)
if args.decisions:
entry.decisions = [d.strip() for d in args.decisions.split(";")]
entry.decisions = split_journal_field(args.decisions)
explicit_next_steps = list(entry.next_steps)
if args.next:
entry.next_steps = [n.strip() for n in args.next.split(";")]
entry.next_steps = split_journal_field(args.next)
explicit_next_steps = list(entry.next_steps)
entry.next_steps = merge_carried_forward_next_steps(
entry.next_steps,
Expand Down Expand Up @@ -3181,9 +3182,9 @@ def make_parser() -> argparse.ArgumentParser:
journal_parser.add_argument("--list", action="store_true", help="List recent journal entries")
journal_parser.add_argument("--show", type=int, default=None, help="Show Nth most recent entry")
journal_parser.add_argument("--focus", default=None, help="What this session was about")
journal_parser.add_argument("--done", default=None, help="What got done (semicolon-separated)")
journal_parser.add_argument("--decisions", default=None, help="Key decisions (semicolon-separated)")
journal_parser.add_argument("--next", default=None, help="Next steps (semicolon-separated)")
journal_parser.add_argument("--done", default=None, help="What got done (one per line; a single-line value falls back to semicolon-separated)")
journal_parser.add_argument("--decisions", default=None, help="Key decisions (one per line; a single-line value falls back to semicolon-separated)")
journal_parser.add_argument("--next", default=None, help="Next steps (one per line; a single-line value falls back to semicolon-separated)")
journal_parser.add_argument("--repair", action="store_true",
help="Recover fields swallowed by an unclosed tool-call parameter (append-only)")
journal_parser.add_argument("--dry-run", action="store_true",
Expand Down
32 changes: 28 additions & 4 deletions src/synapt/recall/journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,24 @@ def _entry_collapses(entry: JournalEntry) -> list[tuple[str, str]]:
return found


def split_journal_field(text: str) -> list[str]:
"""Split a journal field into items, preferring newlines over semicolons.

Semicolons are ordinary punctuation in the prose these fields carry, so
splitting on them silently fragments sentences and strips their subject --
an entry that reads as terse notes rather than as damage. Newlines are how
the fields are actually written, one item per line.

Newline-first keeps every existing single-line caller working unchanged:
with no newline present the semicolon behaviour is exactly what it was.
"""
if "\n" in text:
parts = text.split("\n")
else:
parts = text.split(";")
return [item.strip() for item in parts if item.strip()]


def _served(values: list[str]) -> list[str]:
"""Drop collapsed values from anything about to be displayed.

Expand Down Expand Up @@ -446,10 +464,13 @@ def format_for_session_start(entry: JournalEntry) -> str:
if entry.focus and not is_collapsed(entry.focus):
lines.append(f"Last session ({ts}): {entry.focus}")

# Open threads FIRST. This read is BOUNDED, so ordering is not taste here --
# whatever leads consumes the window. Completed work is recoverable from git
# and the board; an unrecorded open question is recoverable from nowhere.
for label, items in (
("Done:", _served(entry.done)),
("Decisions:", _served(entry.decisions)),
("Next steps:", _served(entry.next_steps)),
("Decisions:", _served(entry.decisions)),
("Done:", _served(entry.done)),
):
if items:
lines.append(label)
Expand All @@ -465,10 +486,13 @@ def format_entry_full(entry: JournalEntry) -> str:
lines.append(f"**Branch:** {entry.branch}")
if entry.focus and not is_collapsed(entry.focus):
lines.append(f"**Focus:** {entry.focus}")
# Same order as the session-start read. This surface is unbounded, so the
# ordering is not forced here -- but two surfaces that teach different
# priorities are their own defect, and a human reading top-down also stops.
for heading, items in (
("\n### Done", _served(entry.done)),
("\n### Decisions", _served(entry.decisions)),
("\n### Next", _served(entry.next_steps)),
("\n### Decisions", _served(entry.decisions)),
("\n### Done", _served(entry.done)),
):
if items:
lines.append(heading)
Expand Down
26 changes: 19 additions & 7 deletions src/synapt/recall/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def _get_index(use_embeddings: bool = True) -> TranscriptIndex | None:
try:
from synapt.recall.journal import (
extract_session_id, latest_transcript_path,
)
)
live_path = latest_transcript_path()
if live_path:
_cached_index._current_session_id = extract_session_id(live_path)
Expand Down Expand Up @@ -1638,9 +1638,20 @@ def recall_journal(
action: "read" (latest entry), "write" (create entry), "list" (recent entries),
or "pending" (unresolved carry-forward next steps only).
focus: What this session was about (write only).
done: Semicolon-separated list of accomplishments (write only).
decisions: Semicolon-separated list of key decisions (write only).
next_steps: Semicolon-separated list of next steps (write only).
done: Accomplishments, ONE PER LINE (write only).
decisions: Key decisions, ONE PER LINE (write only).
next_steps: Next steps, ONE PER LINE (write only).

Item separation: these three fields split on NEWLINES, so a semicolon inside
a multi-line field is ordinary punctuation and the sentence stays whole. If a
field contains NO newline it falls back to splitting on semicolons, which
keeps older single-line callers working -- so a semicolon inside a
single-line item WILL still split it. Write one item per line and this never
bites you.

Ordering: the session-start read is TRUNCATED, and it leads with next_steps.
Put what is unresolved there; completed work is recoverable from version
control and the tracker, an unrecorded open question is not.
"""
try:
from synapt.recall.journal import (
Expand All @@ -1655,6 +1666,7 @@ def recall_journal(
read_entries,
read_latest,
read_previous_meaningful,
split_journal_field,
)

if action == "read":
Expand Down Expand Up @@ -1687,12 +1699,12 @@ def recall_journal(
if focus:
entry.focus = focus
if done:
entry.done = [d.strip() for d in done.split(";")]
entry.done = split_journal_field(done)
if decisions:
entry.decisions = [d.strip() for d in decisions.split(";")]
entry.decisions = split_journal_field(decisions)
explicit_next_steps = list(entry.next_steps)
if next_steps:
entry.next_steps = [n.strip() for n in next_steps.split(";")]
entry.next_steps = split_journal_field(next_steps)
explicit_next_steps = list(entry.next_steps)
entry.next_steps = merge_carried_forward_next_steps(
entry.next_steps,
Expand Down
71 changes: 71 additions & 0 deletions tests/recall/test_journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from synapt.recall.journal import (
JournalEntry,
split_journal_field,
_dedup_entries,
append_entry,
auto_extract_entry,
Expand Down Expand Up @@ -727,3 +728,73 @@ def test_all_done_returns_empty(self):

if __name__ == "__main__":
unittest.main()


class TestJournalFieldSurvivesTheReader(unittest.TestCase):
"""The claim is not "what I wrote" -- it is "what the reader receives"."""

def test_prose_with_a_semicolon_reaches_the_reader_whole(self):
# Written the way agents actually write these fields: one item per line,
# with semicolons as ordinary punctuation inside a sentence.
written = (
"LIVE HAZARD: do not run the cleanup; the installed binary predates the fix\n"
"second item"
)
entry = JournalEntry(timestamp="2026-01-01T00:00", next_steps=split_journal_field(written))

# Read back what a FRESH SESSION RECEIVES, not what was written.
served = format_for_session_start(entry)

self.assertIn(
"do not run the cleanup; the installed binary predates the fix",
served,
"the sentence must reach the reader whole -- a fragment reads as a terse "
"note rather than as damage, so nothing looks broken",
)
self.assertEqual(len(entry.next_steps), 2, "one item per line, not per clause")

def test_single_line_semicolons_still_split_for_existing_callers(self):
# Control, and a documented limit: with no newline the semicolon behaviour
# is exactly what it was, so `--done "a; b; c"` keeps working.
self.assertEqual(split_journal_field("a; b; c"), ["a", "b", "c"])


class TestBoundedReadLeadsWithOpenThreads(unittest.TestCase):
"""This read is BOUNDED, so whatever leads consumes the window."""

def test_open_threads_precede_completed_work(self):
entry = JournalEntry(
timestamp="2026-01-01T00:00",
done=["shipped the thing"],
decisions=["chose the approach"],
next_steps=["LIVE HAZARD: unresolved"],
)
served = format_for_session_start(entry)

hazard = served.index("LIVE HAZARD: unresolved")
completed = served.index("shipped the thing")
self.assertLess(
hazard,
completed,
"completed work is recoverable from git and the board; an unrecorded "
"open question is recoverable from nowhere, so it must not be the part "
"that gets truncated away",
)

# Positive control: both are actually present, so the ordering assertion
# is about ORDER and cannot pass by one of them simply being absent.
self.assertIn("shipped the thing", served)
self.assertIn("chose the approach", served)

def test_full_display_teaches_the_same_priority(self):
entry = JournalEntry(
timestamp="2026-01-01T00:00",
done=["shipped the thing"],
next_steps=["still open"],
)
text = format_entry_full(entry)
self.assertLess(
text.index("### Next"),
text.index("### Done"),
"two surfaces that teach different priorities are their own defect",
)
Loading