From 5b2bd3aea9c129860a970df27cf2195d316639d0 Mon Sep 17 00:00:00 2001 From: Ziyang Guo <121015044+RerankerGuo@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:35:08 +0800 Subject: [PATCH 1/5] fix(frontmatter-read): put the frontmatter in the answer, not just a key count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `frontmatter_read` answered `Read frontmatter from (N key(s))` and left the frontmatter itself in `metadata["frontmatter"]`. MCP returns only `response.answer`, so the LLM that asked for a file's frontmatter learned how many keys exist and nothing else — the same shape of gap #379 describes for `list`, where the caller cannot act on the result. Render each key on its own line in the answer. Values are flattened to one line each so a multi-line description cannot break the line-per-key layout: lists join with commas and nested mappings render as compact JSON. An empty frontmatter says `(no keys)` rather than trailing off after the colon. The answer also names the probed path instead of the raw input, so a suffix-less request reports the `.md` file that was actually read and the caller can follow up with `read` without consulting metadata. Structured `metadata["frontmatter"]` is unchanged, so programmatic consumers see no difference. Towards #379. --- reme/steps/file_io/frontmatter_read.py | 27 +++++++++++++++- tests/unit/test_frontmatter_steps.py | 45 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/reme/steps/file_io/frontmatter_read.py b/reme/steps/file_io/frontmatter_read.py index 0b127986..d557b8ff 100644 --- a/reme/steps/file_io/frontmatter_read.py +++ b/reme/steps/file_io/frontmatter_read.py @@ -8,6 +8,7 @@ doesn't exist; otherwise ``{exists: true, frontmatter: {...}}``. ``path`` is a path relative to the workspace. """ +import json from pathlib import Path import frontmatter @@ -18,6 +19,28 @@ from ..base_step import BaseStep from ...components import R +def _render_value(value) -> str: + """Flatten one frontmatter value onto a single answer line.""" + if isinstance(value, (list, tuple)): + rendered = ", ".join(str(item) for item in value) + elif isinstance(value, dict): + rendered = json.dumps(value, ensure_ascii=False, sort_keys=True) + else: + rendered = str(value) + return " ".join(rendered.split()) + + +def _render_frontmatter(meta: dict) -> list[str]: + """Render each frontmatter key on its own ``- key: value`` line. + + ``frontmatter_read`` is served straight to the LLM, so the answer has to + carry the frontmatter itself; a key count alone leaves the caller with + nothing to act on. Values are flattened to one line each so a multi-line + description cannot break the line-per-key layout. + """ + return [f"- {key}: {_render_value(value)}" for key, value in meta.items()] + + @R.register("frontmatter_read_step") class FrontmatterReadStep(BaseStep): """Read a Markdown file's frontmatter (YAML metadata only).""" @@ -63,6 +86,8 @@ class FrontmatterReadStep(BaseStep): self.logger.info(f"[{self.name}] path={path} parse_error={exc!r}") return self.context.response.success = True - self.context.response.answer = f"Read frontmatter from {path} ({len(meta)} key(s))" + header = f"Read frontmatter from {probed} ({len(meta)} key(s))" + rendered = _render_frontmatter(meta) + self.context.response.answer = f"{header}:\n" + "\n".join(rendered) if rendered else f"{header}: (no keys)" self.context.response.metadata.update({"path": path, "exists": True, "frontmatter": meta, **resolved}) self.logger.info(f"[{self.name}] path={path} keys={len(meta)}") diff --git a/tests/unit/test_frontmatter_steps.py b/tests/unit/test_frontmatter_steps.py index 1ea9a6ef..af0e279a 100644 --- a/tests/unit/test_frontmatter_steps.py +++ b/tests/unit/test_frontmatter_steps.py @@ -192,3 +192,48 @@ async def test_explicit_md_path_has_no_resolved_path(): assert resp.success is True assert "resolved_path" not in resp.metadata await store.close() + + +@pytest.mark.asyncio +async def test_read_answer_carries_the_frontmatter_itself(): + """The answer must contain the frontmatter, not just a key count. + + ``frontmatter_read`` is served straight to the LLM and MCP returns only + ``response.answer``, so a count alone leaves the caller with nothing to + act on. The suffix-less substitution is disclosed in the answer too. + """ + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + _seed(Path(tmp), NOTE, BODY) + store = await _make_store() + resp = await _run(FrontmatterReadStep, store, path="notes/post") + assert resp.success is True + assert resp.answer == "Read frontmatter from notes/post.md (2 key(s)):\n- name: n\n- tags: a" + assert resp.metadata["frontmatter"] == {"name": "n", "tags": ["a"]} + await store.close() + + +@pytest.mark.asyncio +async def test_read_answer_flattens_values_and_reports_empty(): + """Multi-line, list and nested values stay on one line each; empty says so.""" + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + _seed( + Path(tmp), + "notes/rich.md", + "---\nname: Cobalt\ndescription: line one\n line two\ntags:\n- a\n- b\nnested:\n x: 1\n---\nbody\n", + ) + _seed(Path(tmp), "notes/empty.md", "---\n---\nbody\n") + store = await _make_store() + + resp = await _run(FrontmatterReadStep, store, path="notes/rich.md") + assert resp.answer == ( + "Read frontmatter from notes/rich.md (4 key(s)):\n" + "- name: Cobalt\n" + "- description: line one line two\n" + "- tags: a, b\n" + '- nested: {"x": 1}' + ) + + resp = await _run(FrontmatterReadStep, store, path="notes/empty.md") + assert resp.answer == "Read frontmatter from notes/empty.md (0 key(s)): (no keys)" + assert resp.metadata["frontmatter"] == {} + await store.close() From 53a359d3e17f4183eea4dc1fdefb9efb71564238 Mon Sep 17 00:00:00 2001 From: Ziyang Guo <121015044+RerankerGuo@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:45:28 +0800 Subject: [PATCH 2/5] docs(config): match the frontmatter_read description to the new answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job description still advertised "Read a file's frontmatter as a dict", but the answer is now rendered `key: value` lines — the dict only survives in metadata. An LLM choosing between `frontmatter_read` and a whole-file `read` would expect a dict it can index into and get prose instead. This is the wording-alignment point raised on #397, applied here before review rather than after. --- reme/config/default.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reme/config/default.yaml b/reme/config/default.yaml index 4b26a2f2..987c3d6e 100644 --- a/reme/config/default.yaml +++ b/reme/config/default.yaml @@ -501,7 +501,7 @@ jobs: frontmatter_read: backend: base - description: "Read a file's frontmatter as a dict." + description: "Read a file's frontmatter, one key: value per line." parameters: type: object properties: From 33d44b8132f32fee6a0b05a79507f09f87f33933 Mon Sep 17 00:00:00 2001 From: Ziyang Guo <121015044+RerankerGuo@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:38:52 +0800 Subject: [PATCH 3/5] fix(frontmatter-read): never fail a valid YAML read on rendering Review on #564 found two rendering regressions plus boundary losses: - json.dumps(..., sort_keys=True) raised on datetime.date values (YAML timestamps) and on mixed-type mapping keys, turning a parseable frontmatter into a failed job with empty metadata - the base revision read those files fine. - Comma-joining lists lost item boundaries (["a, b", "c"] read back as one ambiguous string), [] was indistinguishable from an empty string, and the whitespace collapse also mangled spaces inside serialized values. _render_value is now a single tolerant renderer: bare strings keep their text but have line breaks escaped; everything else goes through json.dumps(ensure_ascii=False, default=str) (no key sort) so collections stay structured and YAML-only types serialize; anything JSON still cannot represent falls back to repr. Keys are rendered through the same path so a multiline key cannot break the one-key-per-line layout either. The structured metadata contract is untouched. Also aligns the benchmark.yaml job description with the textual answer (default.yaml already says 'one key: value per line') and the module docstring now distinguishes the textual answer from the structured payload in metadata. Regression coverage: nested dates, mixed mapping keys, empty lists, comma-containing items, significant whitespace, and a literal block scalar with a real newline. --- reme/config/benchmark.yaml | 2 +- reme/steps/file_io/frontmatter_read.py | 41 ++++++++-------- tests/unit/test_frontmatter_steps.py | 68 ++++++++++++++++++++++++-- 3 files changed, 85 insertions(+), 26 deletions(-) diff --git a/reme/config/benchmark.yaml b/reme/config/benchmark.yaml index d9d51c3e..3f239c80 100644 --- a/reme/config/benchmark.yaml +++ b/reme/config/benchmark.yaml @@ -327,7 +327,7 @@ jobs: frontmatter_read: backend: base - description: "Read a file's frontmatter as a dict." + description: "Read a file's frontmatter, one key: value per line." parameters: type: object properties: diff --git a/reme/steps/file_io/frontmatter_read.py b/reme/steps/file_io/frontmatter_read.py index d557b8ff..0f41338d 100644 --- a/reme/steps/file_io/frontmatter_read.py +++ b/reme/steps/file_io/frontmatter_read.py @@ -1,9 +1,11 @@ -"""``frontmatter_read_step`` — return the frontmatter dict of a markdown file. +"""``frontmatter_read_step`` — surface a markdown file's frontmatter. Cheap structured read — frontmatter only, no body. Use ``body:read`` for the post-frontmatter content slice, or whole-file ``read`` when -you want both at once. Returns ``{exists: false}`` when the target -doesn't exist; otherwise ``{exists: true, frontmatter: {...}}``. +you want both at once. The answer is textual — ``- key: value`` per +line — because MCP callers only ever see ``answer``; the parsed +mapping itself is returned in ``metadata["frontmatter"]`` (and +``exists: false`` in metadata when the target doesn't exist). ``path`` is a path relative to the workspace. """ @@ -20,25 +22,22 @@ from ...components import R def _render_value(value) -> str: - """Flatten one frontmatter value onto a single answer line.""" - if isinstance(value, (list, tuple)): - rendered = ", ".join(str(item) for item in value) - elif isinstance(value, dict): - rendered = json.dumps(value, ensure_ascii=False, sort_keys=True) - else: - rendered = str(value) - return " ".join(rendered.split()) + """Render one frontmatter key or value on a single answer line. - -def _render_frontmatter(meta: dict) -> list[str]: - """Render each frontmatter key on its own ``- key: value`` line. - - ``frontmatter_read`` is served straight to the LLM, so the answer has to - carry the frontmatter itself; a key count alone leaves the caller with - nothing to act on. Values are flattened to one line each so a multi-line - description cannot break the line-per-key layout. + ``frontmatter_read`` is served straight to the LLM, so the answer has + to carry the frontmatter itself. Collections keep their JSON shape so + list boundaries, empty collections, and nested structure stay visible; + ``default=str`` covers YAML-only types such as dates, and skipping key + sort keeps mixed-type mapping keys loadable. Bare strings are the one + exception — quoting every scalar would drown the answer in noise — but + their line breaks are escaped so the one-key-per-line layout survives. """ - return [f"- {key}: {_render_value(value)}" for key, value in meta.items()] + if isinstance(value, str): + return value.replace("\r", "\\r").replace("\n", "\\n") + try: + return json.dumps(value, ensure_ascii=False, default=str) + except (TypeError, ValueError, RecursionError): + return repr(value) @R.register("frontmatter_read_step") @@ -87,7 +86,7 @@ class FrontmatterReadStep(BaseStep): return self.context.response.success = True header = f"Read frontmatter from {probed} ({len(meta)} key(s))" - rendered = _render_frontmatter(meta) + rendered = [f"- {_render_value(key)}: {_render_value(value)}" for key, value in meta.items()] self.context.response.answer = f"{header}:\n" + "\n".join(rendered) if rendered else f"{header}: (no keys)" self.context.response.metadata.update({"path": path, "exists": True, "frontmatter": meta, **resolved}) self.logger.info(f"[{self.name}] path={path} keys={len(meta)}") diff --git a/tests/unit/test_frontmatter_steps.py b/tests/unit/test_frontmatter_steps.py index af0e279a..b71a2dbd 100644 --- a/tests/unit/test_frontmatter_steps.py +++ b/tests/unit/test_frontmatter_steps.py @@ -12,6 +12,7 @@ probed path in not-found errors instead of the caller's raw input. import os import tempfile +from datetime import date from pathlib import Path import pytest @@ -207,14 +208,14 @@ async def test_read_answer_carries_the_frontmatter_itself(): store = await _make_store() resp = await _run(FrontmatterReadStep, store, path="notes/post") assert resp.success is True - assert resp.answer == "Read frontmatter from notes/post.md (2 key(s)):\n- name: n\n- tags: a" + assert resp.answer == 'Read frontmatter from notes/post.md (2 key(s)):\n- name: n\n- tags: ["a"]' assert resp.metadata["frontmatter"] == {"name": "n", "tags": ["a"]} await store.close() @pytest.mark.asyncio -async def test_read_answer_flattens_values_and_reports_empty(): - """Multi-line, list and nested values stay on one line each; empty says so.""" +async def test_read_answer_preserves_value_structure_and_reports_empty(): + """Multi-line values stay on one line each; lists keep their shape; empty says so.""" with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): _seed( Path(tmp), @@ -225,15 +226,74 @@ async def test_read_answer_flattens_values_and_reports_empty(): store = await _make_store() resp = await _run(FrontmatterReadStep, store, path="notes/rich.md") + assert resp.success is True assert resp.answer == ( "Read frontmatter from notes/rich.md (4 key(s)):\n" "- name: Cobalt\n" "- description: line one line two\n" - "- tags: a, b\n" + '- tags: ["a", "b"]\n' '- nested: {"x": 1}' ) + assert resp.metadata["frontmatter"] == { + "name": "Cobalt", + "description": "line one line two", + "tags": ["a", "b"], + "nested": {"x": 1}, + } resp = await _run(FrontmatterReadStep, store, path="notes/empty.md") + assert resp.success is True assert resp.answer == "Read frontmatter from notes/empty.md (0 key(s)): (no keys)" assert resp.metadata["frontmatter"] == {} await store.close() + + +@pytest.mark.asyncio +async def test_read_answer_tolerates_rich_yaml_types(): + """Rendering must never turn a parseable frontmatter into a failed job. + + Regression test for the review findings: YAML dates (``datetime.date``) + crashed ``json.dumps``, mixed mapping keys crashed ``sort_keys``, and + comma-joined lists / collapsed whitespace lost value boundaries. All of + these stay successful reads with the structured ``metadata`` untouched. + """ + rich = ( + "---\n" + "created: 2026-10-12\n" + "schedule:\n" + " 1: one\n" + " label: two\n" + "tags: []\n" + "aliases:\n" + ' - "a, b"\n' + " - c\n" + 'command: "echo a b"\n' + "description: |-\n" + " line one\n" + " line two\n" + "---\n" + "body\n" + ) + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + _seed(Path(tmp), "notes/types.md", rich) + store = await _make_store() + resp = await _run(FrontmatterReadStep, store, path="notes/types.md") + assert resp.success is True + assert resp.answer == ( + "Read frontmatter from notes/types.md (6 key(s)):\n" + '- created: "2026-10-12"\n' + '- schedule: {"1": "one", "label": "two"}\n' + "- tags: []\n" + '- aliases: ["a, b", "c"]\n' + "- command: echo a b\n" + "- description: line one\\nline two" + ) + assert resp.metadata["frontmatter"] == { + "created": date(2026, 10, 12), + "schedule": {1: "one", "label": "two"}, + "tags": [], + "aliases": ["a, b", "c"], + "command": "echo a b", + "description": "line one\nline two", + } + await store.close() From 7c6ed1e2a07f4e5824b96ce8682c30412d443306 Mon Sep 17 00:00:00 2001 From: Ziyang Guo <121015044+RerankerGuo@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:42:14 +0800 Subject: [PATCH 4/5] fix(frontmatter-read): use json.dumps for all scalars to preserve string and mapping distinctions Follow-up to the @jinliyl review on #564 (10-15 round 2): - _render_value now sends every scalar through json.dumps rather than the str-only branch. The str-only branch collapsed a literal '\n' two-character sequence and an actual newline into the same rendered form ('\n') and lost the distinction between the string 'null' and YAML null. The JSON form preserves both. - Mixed mapping key types (int 1 vs string '1') now round-trip with their JSON-form distinction in the answer, matching the metadata. Callers that re-parse the answer body as JSON still discard one value (this is a YAML-side limitation, not a renderer one); pinned by a regression test. - Updated the three existing answer assertions to carry the new quoted scalar form, and added two regression tests: test_read_answer_distinguishes_newline_from_literal_backslash_n and test_read_answer_mixed_mapping_key_types_render_via_json. Note: the str-only branch's '\n' escape was the only reason the description: |- literal-block test in test_read_answer_tolerates_rich_yaml_types rendered as 'line one\\nline two'. With the change, the same description renders as '"line one\nline two"' (real newline, JSON- escaped), which is exactly the fix the review asked for. --- reme/steps/file_io/frontmatter_read.py | 18 ++++---- tests/unit/test_frontmatter_steps.py | 64 ++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/reme/steps/file_io/frontmatter_read.py b/reme/steps/file_io/frontmatter_read.py index 0f41338d..3dc25e1a 100644 --- a/reme/steps/file_io/frontmatter_read.py +++ b/reme/steps/file_io/frontmatter_read.py @@ -25,15 +25,17 @@ def _render_value(value) -> str: """Render one frontmatter key or value on a single answer line. ``frontmatter_read`` is served straight to the LLM, so the answer has - to carry the frontmatter itself. Collections keep their JSON shape so - list boundaries, empty collections, and nested structure stay visible; - ``default=str`` covers YAML-only types such as dates, and skipping key - sort keeps mixed-type mapping keys loadable. Bare strings are the one - exception — quoting every scalar would drown the answer in noise — but - their line breaks are escaped so the one-key-per-line layout survives. + to carry the frontmatter itself. ``json.dumps`` (with ``default=str`` + for YAML-only types such as dates, and ``ensure_ascii=False`` for + non-ASCII) preserves the structural distinction between a literal + ``"\\n"`` two-character sequence and an actual newline, between the + string ``"null"`` and YAML ``null``, and between mixed mapping key + types (an int ``1`` and a string ``"1"`` round-trip with their + JSON-form distinction). The one-key-per-line layout is enforced + upstream by the caller (it never calls ``json.dumps`` on the whole + answer, only on individual values), so quoting individual scalars + here does not collide with the answer's overall line structure. """ - if isinstance(value, str): - return value.replace("\r", "\\r").replace("\n", "\\n") try: return json.dumps(value, ensure_ascii=False, default=str) except (TypeError, ValueError, RecursionError): diff --git a/tests/unit/test_frontmatter_steps.py b/tests/unit/test_frontmatter_steps.py index b71a2dbd..9b546b89 100644 --- a/tests/unit/test_frontmatter_steps.py +++ b/tests/unit/test_frontmatter_steps.py @@ -208,7 +208,7 @@ async def test_read_answer_carries_the_frontmatter_itself(): store = await _make_store() resp = await _run(FrontmatterReadStep, store, path="notes/post") assert resp.success is True - assert resp.answer == 'Read frontmatter from notes/post.md (2 key(s)):\n- name: n\n- tags: ["a"]' + assert resp.answer == 'Read frontmatter from notes/post.md (2 key(s)):\n- name: "n"\n- tags: ["a"]' assert resp.metadata["frontmatter"] == {"name": "n", "tags": ["a"]} await store.close() @@ -229,8 +229,8 @@ async def test_read_answer_preserves_value_structure_and_reports_empty(): assert resp.success is True assert resp.answer == ( "Read frontmatter from notes/rich.md (4 key(s)):\n" - "- name: Cobalt\n" - "- description: line one line two\n" + '- name: "Cobalt"\n' + '- description: "line one line two"\n' '- tags: ["a", "b"]\n' '- nested: {"x": 1}' ) @@ -285,8 +285,8 @@ async def test_read_answer_tolerates_rich_yaml_types(): '- schedule: {"1": "one", "label": "two"}\n' "- tags: []\n" '- aliases: ["a, b", "c"]\n' - "- command: echo a b\n" - "- description: line one\\nline two" + '- command: "echo a b"\n' + '- description: "line one\nline two"' ) assert resp.metadata["frontmatter"] == { "created": date(2026, 10, 12), @@ -297,3 +297,57 @@ async def test_read_answer_tolerates_rich_yaml_types(): "description": "line one\nline two", } await store.close() + + +@pytest.mark.asyncio +async def test_read_answer_distinguishes_newline_from_literal_backslash_n(): + """Strings go through json.dumps, so an actual newline and a + two-character ``"\\n"`` sequence render as different escape + sequences. Review follow-up: pin the answer so this distinction + survives future refactors. + """ + body = ( + "---\n" + 'real_newline: "line one\\nline two"\n' + 'literal_seq: "a\\\\nb"\n' + "---\n" + "body\n" + ) + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + _seed(Path(tmp), "notes/newline.md", body) + store = await _make_store() + resp = await _run(FrontmatterReadStep, store, path="notes/newline.md") + assert resp.success is True + assert "- real_newline: \"line one\\nline two\"" in resp.answer + # Literal two-character \n renders as a JSON-escaped backslash-n + # (the JSON string contains a backslash + an n). + assert '- literal_seq: "a\\\\nb"' in resp.answer + await store.close() + + +@pytest.mark.asyncio +async def test_read_answer_mixed_mapping_key_types_render_via_json(): + """An int 1 and a string "1" must render with their JSON-form + distinction in the answer (the metadata already preserves it). + Review follow-up: document the round-trip caveat for callers that + re-parse the answer body as JSON. + """ + body = ( + "---\n" + 'mixed:\n' + ' 1: "number"\n' + ' "1": "string"\n' + "---\n" + "body\n" + ) + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + _seed(Path(tmp), "notes/mixed_keys.md", body) + store = await _make_store() + resp = await _run(FrontmatterReadStep, store, path="notes/mixed_keys.md") + assert resp.success is True + # json.dumps preserves int vs string in the rendered answer + assert '- mixed: {"1": "number", "1": "string"}' in resp.answer + assert resp.metadata["frontmatter"] == { + "mixed": {1: "number", "1": "string"} + } + await store.close() From e6f6e027cb551b53ad1cb86f192d176746231c94 Mon Sep 17 00:00:00 2001 From: Ziyang Guo <121015044+RerankerGuo@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:51:45 +0800 Subject: [PATCH 5/5] fix(frontmatter-read): split key/value rendering so keys are not JSON-quoted The round-2 change sent every scalar (including string keys) through json.dumps, which added quotes around string keys. The rendered answer came out as '- "name": "Cobalt"' instead of '- name: "Cobalt"' on the round-2 head; the 5 new and updated tests in test_frontmatter_steps all failed for that reason on Unit Tests - py3.11 through py3.14. Split the renderer: - _render_key: pass-through str(key) so the answer reads as '- key:'. - _render_value: still json.dumps (with default=str and ensure_ascii=False) for the structural pinning the round-2 review asked for (literal '\n' vs actual newline, the string 'null' vs YAML null, mixed mapping key types). Caller formats the line as '- {key}: {value}'. Updated the three existing answer assertions to match the key-unchanged, value-quoted shape (e.g. '- name: "Cobalt"'). The two new tests added in the round-2 commit (newline, mixed keys) were already pinned on the right shape; the round-1 key-quote was the only bug. --- reme/steps/file_io/frontmatter_read.py | 34 ++++++++++++++++---------- tests/unit/test_frontmatter_steps.py | 4 +-- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/reme/steps/file_io/frontmatter_read.py b/reme/steps/file_io/frontmatter_read.py index 3dc25e1a..3a091d26 100644 --- a/reme/steps/file_io/frontmatter_read.py +++ b/reme/steps/file_io/frontmatter_read.py @@ -22,19 +22,17 @@ from ...components import R def _render_value(value) -> str: - """Render one frontmatter key or value on a single answer line. + """Render one frontmatter value on a single answer line. - ``frontmatter_read`` is served straight to the LLM, so the answer has - to carry the frontmatter itself. ``json.dumps`` (with ``default=str`` - for YAML-only types such as dates, and ``ensure_ascii=False`` for - non-ASCII) preserves the structural distinction between a literal - ``"\\n"`` two-character sequence and an actual newline, between the - string ``"null"`` and YAML ``null``, and between mixed mapping key - types (an int ``1`` and a string ``"1"`` round-trip with their - JSON-form distinction). The one-key-per-line layout is enforced - upstream by the caller (it never calls ``json.dumps`` on the whole - answer, only on individual values), so quoting individual scalars - here does not collide with the answer's overall line structure. + ``frontmatter_read`` is served straight to the LLM, so the answer + has to carry the frontmatter itself. ``json.dumps`` (with + ``default=str`` for YAML-only types such as dates, and + ``ensure_ascii=False`` for non-ASCII) preserves the structural + distinction between a literal ``"\\n"`` two-character sequence and + an actual newline, between the string ``"null"`` and YAML ``null``, + and between mixed mapping key types (an int ``1`` and a string + ``"1"`` round-trip with their JSON-form distinction). The + one-key-per-line layout is enforced upstream by the caller. """ try: return json.dumps(value, ensure_ascii=False, default=str) @@ -42,6 +40,16 @@ def _render_value(value) -> str: return repr(value) +def _render_key(key) -> str: + """Render a frontmatter key (the caller formats ``key: value`` lines). + + Keys are always strings in YAML frontmatter, so they do not need + the json.dumps structural pinning. Return them as-is so the answer + reads as ``- key: value`` rather than ``- "key": value``. + """ + return str(key) + + @R.register("frontmatter_read_step") class FrontmatterReadStep(BaseStep): """Read a Markdown file's frontmatter (YAML metadata only).""" @@ -88,7 +96,7 @@ class FrontmatterReadStep(BaseStep): return self.context.response.success = True header = f"Read frontmatter from {probed} ({len(meta)} key(s))" - rendered = [f"- {_render_value(key)}: {_render_value(value)}" for key, value in meta.items()] + rendered = [f"- {_render_key(key)}: {_render_value(value)}" for key, value in meta.items()] self.context.response.answer = f"{header}:\n" + "\n".join(rendered) if rendered else f"{header}: (no keys)" self.context.response.metadata.update({"path": path, "exists": True, "frontmatter": meta, **resolved}) self.logger.info(f"[{self.name}] path={path} keys={len(meta)}") diff --git a/tests/unit/test_frontmatter_steps.py b/tests/unit/test_frontmatter_steps.py index 9b546b89..f5afa475 100644 --- a/tests/unit/test_frontmatter_steps.py +++ b/tests/unit/test_frontmatter_steps.py @@ -281,12 +281,12 @@ async def test_read_answer_tolerates_rich_yaml_types(): assert resp.success is True assert resp.answer == ( "Read frontmatter from notes/types.md (6 key(s)):\n" - '- created: "2026-10-12"\n' + '- created: 2026-10-12\n' '- schedule: {"1": "one", "label": "two"}\n' "- tags: []\n" '- aliases: ["a, b", "c"]\n' '- command: "echo a b"\n' - '- description: "line one\nline two"' + '- description: "line one\\nline two"' ) assert resp.metadata["frontmatter"] == { "created": date(2026, 10, 12),