diff --git a/pyproject.toml b/pyproject.toml index f9551a08..0a47611b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/synapt/recall/__init__.py b/src/synapt/recall/__init__.py index 9140704a..432ce8fe 100644 --- a/src/synapt/recall/__init__.py +++ b/src/synapt/recall/__init__.py @@ -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, diff --git a/src/synapt/recall/cli.py b/src/synapt/recall/cli.py index 6c92e761..33ffaf56 100644 --- a/src/synapt/recall/cli.py +++ b/src/synapt/recall/cli.py @@ -85,6 +85,7 @@ _read_all_session_ids, auto_extract_entry, append_entry, + split_journal_field, ) @@ -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, @@ -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", diff --git a/src/synapt/recall/journal.py b/src/synapt/recall/journal.py index 32ee6201..fee5c5c4 100644 --- a/src/synapt/recall/journal.py +++ b/src/synapt/recall/journal.py @@ -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. @@ -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) @@ -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) diff --git a/src/synapt/recall/server.py b/src/synapt/recall/server.py index cc10e515..42506fe5 100644 --- a/src/synapt/recall/server.py +++ b/src/synapt/recall/server.py @@ -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) @@ -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 ( @@ -1655,6 +1666,7 @@ def recall_journal( read_entries, read_latest, read_previous_meaningful, + split_journal_field, ) if action == "read": @@ -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, diff --git a/tests/recall/test_journal.py b/tests/recall/test_journal.py index 36331daa..6b79a15d 100644 --- a/tests/recall/test_journal.py +++ b/tests/recall/test_journal.py @@ -8,6 +8,7 @@ from synapt.recall.journal import ( JournalEntry, + split_journal_field, _dedup_entries, append_entry, auto_extract_entry, @@ -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", + )