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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **agent-write** — `append_child_to_node` normalizes source files missing a trailing newline before line splice, preventing new bullets from being appended onto the last line ([#72](https://github.com/MarcoPorcellato/logseq-matryca-parser/issues/72)).
- **SYNAPSE** — cyclic `{{embed [[Page]]}}` chains no longer duplicate parent literal text; page embed expansion tracks an immutable host-page chain seeded from `to_context_enriched_chunks` ([#65](https://github.com/MarcoPorcellato/logseq-matryca-parser/issues/65)).
- **agent-write** — `SessionAliasRegistry.load_from_disk` tolerates empty, malformed, or legacy-wrapped X-Ray JSON; KINETIC exits with a clear message instead of a traceback ([#60](https://github.com/MarcoPorcellato/logseq-matryca-parser/issues/60)).

### Added

Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ Human-facing RAG (SYNAPSE enriched chunks, breadcrumbs, inherited properties) op

#### Stateful session registry (`.matryca_xray_state.json`)

X-Ray is designed for **stateless LLM toolchains**: each `agent-read` invocation is a fresh process, yet agents must still **write back** to blocks they only saw as `[n]` tokens. After **`generate_aliases`**, **KINETIC** persists the alias map to **`<graph_root>/.matryca_xray_state.json`** via **`SessionAliasRegistry.save_to_disk`**. A later **`matryca-parse agent-write --alias N`** loads that JSON with **`load_from_disk`**, resolves `N → uuid`, and hands off to **`append_child_to_node`**. The read/write pair therefore composes as **two independent CLI exits** without stuffing UUIDs into the model context — the filesystem holds session continuity.
X-Ray is designed for **stateless LLM toolchains**: each `agent-read` invocation is a fresh process, yet agents must still **write back** to blocks they only saw as `[n]` tokens. After **`generate_aliases`**, **KINETIC** persists the alias map to **`<graph_root>/.matryca_xray_state.json`** via **`SessionAliasRegistry.save_to_disk`**. A later **`matryca-parse agent-write --alias N`** loads that JSON with **`load_from_disk`**, resolves `N → uuid`, and hands off to **`append_child_to_node`**. Malformed or empty state files yield a controlled CLI exit (empty registry for whitespace-only files; **`SessionAliasRegistryError`** for invalid JSON) rather than an uncaught traceback. The read/write pair therefore composes as **two independent CLI exits** without stuffing UUIDs into the model context — the filesystem holds session continuity.

#### Session alias mechanics (`SessionAliasRegistry`)

Expand Down
61 changes: 57 additions & 4 deletions src/logseq_matryca_parser/agent_press.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import json
import logging
from pathlib import Path
from typing import Any

from logseq_matryca_parser.exceptions import SessionAliasRegistryError
from logseq_matryca_parser.logos_core import LogseqNode

logger = logging.getLogger(__name__)
Expand All @@ -31,6 +33,22 @@ def walk(node: LogseqNode) -> None:
return flat


def _normalize_alias_payload(raw: Any) -> dict[str, Any]:
"""Coerce on-disk JSON into a flat ``alias_str -> uuid`` mapping."""
if not isinstance(raw, dict):
msg = "X-Ray state file must contain a JSON object mapping aliases to UUIDs"
raise SessionAliasRegistryError(msg)
if set(raw.keys()) == {"aliases"} and isinstance(raw.get("aliases"), dict):
logger.warning(
"SessionAliasRegistry.load_from_disk: unwrap legacy 'aliases' wrapper key"
)
raw = raw["aliases"]
if not isinstance(raw, dict):
msg = "X-Ray state 'aliases' value must be a JSON object"
raise SessionAliasRegistryError(msg)
return raw


class SessionAliasRegistry:
"""Maps sequential integer aliases to Logseq block UUIDs for a single agent session."""

Expand Down Expand Up @@ -69,23 +87,58 @@ def save_to_disk(self, filepath: Path) -> None:
@classmethod
def load_from_disk(cls, filepath: Path) -> SessionAliasRegistry:
"""Reconstruct a registry from a JSON file written by :meth:`save_to_disk`."""
raw = json.loads(filepath.read_text(encoding="utf-8"))
raw_text = filepath.read_text(encoding="utf-8").strip()
if not raw_text:
logger.warning("SessionAliasRegistry.load_from_disk: empty state file %s", filepath)
return cls()
try:
parsed = json.loads(raw_text)
except json.JSONDecodeError as exc:
msg = f"Invalid JSON in X-Ray state file: {filepath}"
raise SessionAliasRegistryError(msg) from exc
raw = _normalize_alias_payload(parsed)
registry = cls()
seen_uuids: set[str] = set()
for alias_str in sorted(raw.keys(), key=int):
ordered_aliases: list[int] = []
for alias_str in raw:
if not isinstance(alias_str, str):
logger.warning(
"SessionAliasRegistry.load_from_disk: skip non-string alias key=%r",
alias_str,
)
continue
try:
ordered_aliases.append(int(alias_str))
except ValueError:
logger.warning(
"SessionAliasRegistry.load_from_disk: skip non-integer alias key=%s",
alias_str,
)

for alias in sorted(ordered_aliases):
alias_str = str(alias)
node_uuid = raw[alias_str]
if not isinstance(node_uuid, str) or not node_uuid.strip():
logger.warning(
"SessionAliasRegistry.load_from_disk: skip invalid uuid for alias=%s",
alias_str,
)
continue
if node_uuid in seen_uuids:
logger.warning(
"SessionAliasRegistry.load_from_disk: skip duplicate uuid=%s alias=%s",
node_uuid,
alias_str,
)
continue
alias = int(alias_str)
seen_uuids.add(node_uuid)
registry._alias_to_uuid[alias] = node_uuid
registry._uuid_to_alias[node_uuid] = alias
logger.debug("SessionAliasRegistry loaded %s aliases from %s", len(registry._alias_to_uuid), filepath)
logger.debug(
"SessionAliasRegistry loaded %s aliases from %s",
len(registry._alias_to_uuid),
filepath,
)
return registry


Expand Down
4 changes: 4 additions & 0 deletions src/logseq_matryca_parser/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ class LogseqIndentationError(LogseqParserError):

class BlockReferenceError(LogseqParserError):
"""Raised when a block reference cannot be resolved."""


class SessionAliasRegistryError(Exception):
"""Raised when the X-Ray alias state file cannot be parsed or validated."""
12 changes: 10 additions & 2 deletions src/logseq_matryca_parser/kinetic.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,13 +641,21 @@ def agent_write(
if not registry_path.is_file():
print(f"Alias state file not found: {registry_path}", file=sys.stderr)
raise typer.Exit(code=1)
registry = SessionAliasRegistry.load_from_disk(registry_path)
from logseq_matryca_parser.exceptions import SessionAliasRegistryError

try:
registry = SessionAliasRegistry.load_from_disk(registry_path)
except SessionAliasRegistryError as exc:
print(str(exc), file=sys.stderr)
raise typer.Exit(code=1) from exc
parent_uuid = registry.resolve_alias(alias)
if parent_uuid is None:
print(f"Unknown alias: {alias}", file=sys.stderr)
raise typer.Exit(code=1)

assert parent_uuid is not None
if parent_uuid is None:
print("Parent block UUID could not be resolved.", file=sys.stderr)
raise typer.Exit(code=1)
try:
append_child_to_node(graph, parent_uuid, content)
except ValueError as exc:
Expand Down
45 changes: 45 additions & 0 deletions tests/test_agent_press.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from pathlib import Path

import pytest
from typer.testing import CliRunner

from logseq_matryca_parser.agent_press import SessionAliasRegistry, to_xray_markdown
Expand Down Expand Up @@ -81,6 +82,50 @@ def test_session_alias_registry_load_skips_duplicate_uuids(tmp_path: Path) -> No
assert registry.alias_for_uuid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") == 0


def test_session_alias_registry_load_empty_file(tmp_path: Path) -> None:
state_path = tmp_path / "alias_state.json"
state_path.write_text("", encoding="utf-8")

registry = SessionAliasRegistry.load_from_disk(state_path)

assert registry.resolve_alias(0) is None


def test_session_alias_registry_load_invalid_json(tmp_path: Path) -> None:
from logseq_matryca_parser.exceptions import SessionAliasRegistryError

state_path = tmp_path / "alias_state.json"
state_path.write_text("{not json", encoding="utf-8")

with pytest.raises(SessionAliasRegistryError, match="Invalid JSON"):
SessionAliasRegistry.load_from_disk(state_path)


def test_session_alias_registry_load_skips_non_integer_alias_keys(tmp_path: Path) -> None:
state_path = tmp_path / "alias_state.json"
state_path.write_text(
'{"abc": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "0": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"}',
encoding="utf-8",
)

registry = SessionAliasRegistry.load_from_disk(state_path)

assert registry.resolve_alias(0) == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
assert registry.resolve_alias(1) is None


def test_session_alias_registry_load_unwraps_aliases_wrapper(tmp_path: Path) -> None:
state_path = tmp_path / "alias_state.json"
state_path.write_text(
'{"aliases": {"0": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}}',
encoding="utf-8",
)

registry = SessionAliasRegistry.load_from_disk(state_path)

assert registry.resolve_alias(0) == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"


def test_to_xray_markdown_nested_properties_stripped() -> None:
child_one = _make_node(
"dddddddd-dddd-dddd-dddd-dddddddddddd",
Expand Down
25 changes: 25 additions & 0 deletions tests/test_kinetic.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,3 +512,28 @@ def test_agent_write_missing_state_file_exits_nonzero(tmp_path: Path) -> None:
],
)
assert result.exit_code == 1


def test_agent_write_corrupt_state_file_exits_nonzero(tmp_path: Path) -> None:
"""Malformed X-Ray JSON must exit 1 with a clear message (#60)."""
graph_root = tmp_path / "graph"
pages = graph_root / "pages"
pages.mkdir(parents=True)
(graph_root / "journals").mkdir()
state_path = graph_root / ".matryca_xray_state.json"
state_path.write_text("{not json", encoding="utf-8")

result = runner.invoke(
app,
[
"agent-write",
str(graph_root),
"--content",
"x",
"--alias",
"0",
],
)

assert result.exit_code == 1
assert "Invalid JSON" in (result.stderr or result.output)