mindslice 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. mindslice/__init__.py +58 -0
  2. mindslice/__main__.py +5 -0
  3. mindslice/baking/__init__.py +1 -0
  4. mindslice/baking/archive.py +66 -0
  5. mindslice/baking/bake.py +299 -0
  6. mindslice/baking/bake_batch.py +537 -0
  7. mindslice/baking/slice_transfer.py +265 -0
  8. mindslice/errors.py +104 -0
  9. mindslice/interfaces/__init__.py +1 -0
  10. mindslice/interfaces/bake_review.py +230 -0
  11. mindslice/interfaces/cli.py +392 -0
  12. mindslice/interfaces/cli_addressing.py +52 -0
  13. mindslice/interfaces/doctor.py +184 -0
  14. mindslice/interfaces/mcp_server.py +536 -0
  15. mindslice/interfaces/render.py +346 -0
  16. mindslice/interfaces/staging_review.py +92 -0
  17. mindslice/model/__init__.py +1 -0
  18. mindslice/model/addressing.py +251 -0
  19. mindslice/model/card.py +641 -0
  20. mindslice/model/lineage.py +228 -0
  21. mindslice/model/text.py +19 -0
  22. mindslice/model/tomlfmt.py +129 -0
  23. mindslice/operations/__init__.py +1 -0
  24. mindslice/operations/audit.py +195 -0
  25. mindslice/operations/boot.py +183 -0
  26. mindslice/operations/impact.py +98 -0
  27. mindslice/operations/recovery.py +113 -0
  28. mindslice/operations/staging.py +641 -0
  29. mindslice/operations/status.py +137 -0
  30. mindslice/operations/validation.py +250 -0
  31. mindslice/operations/writer.py +481 -0
  32. mindslice/retrieval/__init__.py +1 -0
  33. mindslice/retrieval/index.py +634 -0
  34. mindslice/retrieval/lexical.py +215 -0
  35. mindslice/retrieval/query.py +593 -0
  36. mindslice/storage/__init__.py +1 -0
  37. mindslice/storage/card_io.py +14 -0
  38. mindslice/storage/config.py +192 -0
  39. mindslice/storage/fsutil.py +141 -0
  40. mindslice/storage/history.py +182 -0
  41. mindslice/storage/locking.py +125 -0
  42. mindslice/storage/snapshots.py +262 -0
  43. mindslice/workspace.py +385 -0
  44. mindslice-0.1.0.dist-info/METADATA +98 -0
  45. mindslice-0.1.0.dist-info/RECORD +50 -0
  46. mindslice-0.1.0.dist-info/WHEEL +5 -0
  47. mindslice-0.1.0.dist-info/entry_points.txt +2 -0
  48. mindslice-0.1.0.dist-info/licenses/LICENSE +202 -0
  49. mindslice-0.1.0.dist-info/licenses/NOTICE +22 -0
  50. mindslice-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,641 @@
1
+ """Memory-card parsing and serialization.
2
+
3
+ A card is a Markdown file with optional ``+++``-fenced TOML frontmatter::
4
+
5
+ +++
6
+ title = "..."
7
+ aliases = [...]
8
+ tags = [...]
9
+ extends = []
10
+ supersedes = []
11
+ load = "always" # optional; default "as-needed"
12
+ +++
13
+
14
+ One or two sentence summary paragraph.
15
+
16
+ - Atomic statement with a [[wikilink]].
17
+
18
+ Parsing is lenient (so the index can always make progress on hand-edited
19
+ files); strictness lives in ``validate()``. Serialization keeps untouched parts
20
+ of a card byte-for-byte so human formatting and TOML comments survive edits
21
+ that don't concern them.
22
+
23
+ Plain Markdown needs no conversion: a file without frontmatter takes its title
24
+ from its first ``# heading`` (or its file name) and its summary from its first
25
+ prose paragraph. Legacy ``---`` YAML frontmatter (Jekyll, Hugo, Obsidian) is
26
+ read as a flat subset: scalars, ``[a, b]`` and ``- item`` lists, ``|``/``>``
27
+ blocks. Nested mappings are skipped with a warning. ``write()`` keeps a YAML
28
+ block verbatim unless it changes the card's fields, and then writes ``+++`` TOML.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import json
35
+ import re
36
+ import tomllib
37
+ from dataclasses import dataclass, field
38
+ from typing import Any, Iterable
39
+
40
+ from . import tomlfmt
41
+ from .addressing import extract_wikilinks
42
+ from .text import normalize
43
+ from ..errors import FrontmatterError
44
+
45
+ FENCE = "+++"
46
+ YAML_FENCE = "---"
47
+ KNOWN_FIELDS = ("title", "aliases", "tags", "extends", "supersedes", "load")
48
+ LIST_FIELDS = ("aliases", "tags", "extends", "supersedes")
49
+ # ``load = "always"``: the card is read into every session at start (persona, language, standing
50
+ # instructions), not only when a query hits it. ``"as-needed"`` (the default) is an ordinary card.
51
+ LOAD_ALWAYS = "always"
52
+ LOAD_AS_NEEDED = "as-needed"
53
+ LOAD_VALUES = (LOAD_ALWAYS, LOAD_AS_NEEDED)
54
+ SUMMARY_PREVIEW_CHARS = 240
55
+ MAX_CARD_LINES = 50
56
+ PARSER_VERSION = "2" # in the index signature: "2" reads YAML frontmatter and plain-Markdown lead paragraphs
57
+
58
+ _BLOCK_START_RE = re.compile(r"^(?:[-*+]\s|#|```|~~~|>|\||<|\d+[.)]\s|(?:-{3,}|\*{3,}|_{3,})\s*$|\+\+\+)")
59
+ _BULLET_RE = re.compile(r"^[-*+]\s+")
60
+ _NUMBERED_RE = re.compile(r"^\d+[.)]\s+")
61
+ _HEADING_RE = re.compile(r"^#{1,6}\s+(.*?)\s*#*\s*$")
62
+ _WS_RE = re.compile(r"\s+")
63
+
64
+
65
+ def sha256_bytes(data: bytes) -> str:
66
+ return hashlib.sha256(data).hexdigest()
67
+
68
+
69
+ def normalize_newlines(text: str) -> str:
70
+ text = text.lstrip("")
71
+ return text.replace("\r\n", "\n").replace("\r", "\n")
72
+
73
+
74
+ @dataclass
75
+ class ParsedCard:
76
+ """Structured view over a card's text."""
77
+
78
+ frontmatter: dict[str, Any]
79
+ raw_frontmatter: str | None # text between the fences, or None when absent
80
+ after: str # everything after the closing fence line (or the whole text)
81
+ summary: str
82
+ body: str
83
+ errors: list[str] = field(default_factory=list)
84
+ warnings: list[str] = field(default_factory=list)
85
+ frontmatter_format: str | None = None # "toml" (+++), "yaml" (legacy ---) or None (plain Markdown)
86
+
87
+ # -- typed accessors -------------------------------------------------
88
+ @property
89
+ def has_frontmatter(self) -> bool:
90
+ return self.raw_frontmatter is not None
91
+
92
+ @property
93
+ def is_card_format(self) -> bool:
94
+ """True for a native card (``+++`` TOML frontmatter); False for plain Markdown or YAML."""
95
+ return self.frontmatter_format == "toml"
96
+
97
+ def index_parts(self) -> tuple[str, str]:
98
+ """``(summary, body)`` as indexed and previewed.
99
+
100
+ A native card's summary is the paragraph right under its frontmatter. Plain Markdown and
101
+ YAML-fronted notes usually open with a heading, so their summary is their first prose
102
+ paragraph, taken out of the body so it is not counted twice.
103
+ """
104
+ if self.is_card_format or self.summary or self.errors:
105
+ return self.summary, self.body
106
+ return split_lead(self.body)
107
+
108
+ def list_field(self, name: str) -> list[str]:
109
+ value = self.frontmatter.get(name)
110
+ if value is None:
111
+ return []
112
+ if isinstance(value, str):
113
+ return [value]
114
+ if isinstance(value, list):
115
+ return [v for v in value if isinstance(v, str)]
116
+ return []
117
+
118
+ @property
119
+ def title(self) -> str:
120
+ value = self.frontmatter.get("title")
121
+ return value if isinstance(value, str) else ""
122
+
123
+ @property
124
+ def aliases(self) -> list[str]:
125
+ return self.list_field("aliases")
126
+
127
+ @property
128
+ def tags(self) -> list[str]:
129
+ return self.list_field("tags")
130
+
131
+ @property
132
+ def extends(self) -> list[str]:
133
+ return self.list_field("extends")
134
+
135
+ @property
136
+ def supersedes(self) -> list[str]:
137
+ return self.list_field("supersedes")
138
+
139
+ @property
140
+ def load(self) -> str:
141
+ """``"always"`` or ``"as-needed"``; an invalid value counts as the default (validate() reports it)."""
142
+ return LOAD_ALWAYS if self.frontmatter.get("load") == LOAD_ALWAYS else LOAD_AS_NEEDED
143
+
144
+ def wikilink_targets(self) -> list[str]:
145
+ seen: dict[str, None] = {}
146
+ for link in extract_wikilinks(self.after):
147
+ seen.setdefault(link.target, None)
148
+ return list(seen)
149
+
150
+ def heading_title(self) -> str:
151
+ for line in self.after.split("\n"):
152
+ m = _HEADING_RE.match(line.strip())
153
+ if m and line.lstrip().startswith("# "):
154
+ return m.group(1).strip()
155
+ return ""
156
+
157
+ def display_title(self, path: str) -> str:
158
+ if self.title.strip():
159
+ return self.title.strip()
160
+ heading = self.heading_title()
161
+ if heading:
162
+ return heading
163
+ leaf = path.rsplit("/", 1)[-1]
164
+ return leaf.replace("-", " ").replace("_", " ").strip().capitalize() or path
165
+
166
+
167
+ def split_frontmatter(text: str) -> tuple[str | None, str]:
168
+ """Return ``(raw_frontmatter, after)``; ``raw_frontmatter`` is None when absent.
169
+
170
+ Raises ``FrontmatterError`` when an opening fence has no closing fence.
171
+ """
172
+ text = normalize_newlines(text)
173
+ first_nl = text.find("\n")
174
+ first_line = text if first_nl < 0 else text[:first_nl]
175
+ if first_line.rstrip() != FENCE:
176
+ return None, text
177
+ pos = len(text) if first_nl < 0 else first_nl + 1
178
+ while pos < len(text):
179
+ nl = text.find("\n", pos)
180
+ line_end = len(text) if nl < 0 else nl
181
+ if text[pos:line_end].rstrip() == FENCE:
182
+ return text[first_nl + 1 : pos], ("" if nl < 0 else text[nl + 1 :])
183
+ if nl < 0:
184
+ break
185
+ pos = nl + 1
186
+ raise FrontmatterError("frontmatter opened with '+++' but never closed")
187
+
188
+
189
+ def split_summary(after: str) -> tuple[str, str]:
190
+ """Split post-frontmatter text into ``(summary_paragraph, body)``."""
191
+ lines = after.split("\n")
192
+ i = 0
193
+ while i < len(lines) and not lines[i].strip():
194
+ i += 1
195
+ if i >= len(lines):
196
+ return "", ""
197
+ if _BLOCK_START_RE.match(lines[i].lstrip()) or lines[i].startswith((" ", "\t")):
198
+ return "", "\n".join(lines[i:])
199
+ j = i
200
+ while j < len(lines) and lines[j].strip():
201
+ j += 1
202
+ summary = "\n".join(line.strip() for line in lines[i:j]).strip()
203
+ k = j
204
+ while k < len(lines) and not lines[k].strip():
205
+ k += 1
206
+ return summary, "\n".join(lines[k:])
207
+
208
+
209
+ def parse_card(text: str) -> ParsedCard:
210
+ """Parse card text leniently; problems are reported in ``errors`` / ``warnings``."""
211
+ errors: list[str] = []
212
+ warnings: list[str] = []
213
+ fmt: str | None = None
214
+ try:
215
+ raw_fm, after = split_frontmatter(text)
216
+ except FrontmatterError as exc:
217
+ errors.append(str(exc))
218
+ raw_fm, after = None, normalize_newlines(text)
219
+ frontmatter: dict[str, Any] = {}
220
+ if raw_fm is not None:
221
+ fmt = "toml"
222
+ try:
223
+ frontmatter = tomllib.loads(raw_fm)
224
+ except tomllib.TOMLDecodeError as exc:
225
+ errors.append(f"invalid TOML frontmatter: {exc}")
226
+ frontmatter = {}
227
+ errors.extend(schema_errors(frontmatter))
228
+ for name in LIST_FIELDS:
229
+ if isinstance(frontmatter.get(name), str):
230
+ warnings.append(f"frontmatter field {name!r} should be a list of strings")
231
+ elif not errors:
232
+ yaml = split_yaml_frontmatter(text)
233
+ if yaml is not None:
234
+ fmt = "yaml"
235
+ raw_fm, after, frontmatter, yaml_warnings = yaml
236
+ warnings.extend(yaml_warnings)
237
+ errors.extend(schema_errors(frontmatter))
238
+ summary, body = split_summary(after)
239
+ return ParsedCard(frontmatter, raw_fm, after, summary, body, errors, warnings, fmt)
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Plain Markdown: lead paragraph and legacy YAML frontmatter
244
+ # ---------------------------------------------------------------------------
245
+
246
+
247
+ def split_lead(body: str) -> tuple[str, str]:
248
+ """``(first prose paragraph, body without it)``; the paragraph is what ``first_paragraph`` finds."""
249
+ lines = body.split("\n")
250
+ in_fence = False
251
+ start = None
252
+ for i, line in enumerate(lines):
253
+ stripped = line.strip()
254
+ if start is not None:
255
+ if not stripped or stripped.startswith(("```", "~~~")) or _BLOCK_START_RE.match(stripped):
256
+ end = i
257
+ break
258
+ continue
259
+ if stripped.startswith(("```", "~~~")):
260
+ in_fence = not in_fence
261
+ continue
262
+ if in_fence or not stripped or _BLOCK_START_RE.match(stripped) or line.startswith((" ", "\t")):
263
+ continue
264
+ start = i
265
+ else:
266
+ end = len(lines)
267
+ if start is None:
268
+ return "", body
269
+ lead = " ".join(line.strip() for line in lines[start:end])
270
+ rest = lines[:start] + lines[end:]
271
+ while start < len(rest) and start > 0 and not rest[start].strip() and not rest[start - 1].strip():
272
+ del rest[start] # collapse the blank line pair the paragraph left behind
273
+ return lead, "\n".join(rest).strip("\n")
274
+
275
+
276
+ _YAML_KEY_RE = re.compile(r"^([A-Za-z0-9_][\w-]*)[ \t]*:(?:[ \t]+(.*?))?[ \t]*$")
277
+ _YAML_BLOCK_SCALARS = ("|", ">", "|-", ">-", "|+", ">+")
278
+
279
+
280
+ def split_yaml_frontmatter(text: str) -> tuple[str, str, dict[str, Any], list[str]] | None:
281
+ """``(raw, after, fields, warnings)`` for a leading ``---`` YAML block, or None when there is none.
282
+
283
+ A block counts only if it is closed (``---`` or ``...``) and every line is a ``key: value``
284
+ entry, a list item, an indented continuation or a comment; otherwise the ``---`` is a thematic
285
+ break and the text is plain Markdown.
286
+ """
287
+ text = normalize_newlines(text)
288
+ lines = text.split("\n")
289
+ if not lines or lines[0].rstrip() != YAML_FENCE:
290
+ return None
291
+ close = next((i for i in range(1, len(lines)) if lines[i].rstrip() in (YAML_FENCE, "...")), None)
292
+ if close is None:
293
+ return None
294
+ parsed = parse_yaml_subset(lines[1:close])
295
+ if parsed is None:
296
+ return None
297
+ fields, warnings = parsed
298
+ raw = "".join(line + "\n" for line in lines[1:close])
299
+ return raw, "\n".join(lines[close + 1 :]), fields, warnings
300
+
301
+
302
+ def parse_yaml_subset(lines: list[str]) -> tuple[dict[str, Any], list[str]] | None:
303
+ """Flat YAML: ``key: scalar``, flow and block lists of scalars, block scalars. Values stay strings."""
304
+ fields: dict[str, Any] = {}
305
+ warnings: list[str] = []
306
+ i, n = 0, len(lines)
307
+ while i < n:
308
+ line = lines[i]
309
+ stripped = line.strip()
310
+ i += 1
311
+ if not stripped or stripped.startswith("#"):
312
+ continue
313
+ m = _YAML_KEY_RE.match(line)
314
+ if m is None:
315
+ return None # prose or an indented line at the top level: not a frontmatter block
316
+ key, rest = m.group(1), _strip_yaml_comment(m.group(2) or "")
317
+ value: Any
318
+ if rest in _YAML_BLOCK_SCALARS:
319
+ block: list[str] = []
320
+ while i < n and (not lines[i].strip() or lines[i][:1] in (" ", "\t")):
321
+ block.append(lines[i].strip())
322
+ i += 1
323
+ while block and not block[-1]:
324
+ block.pop()
325
+ value = "\n".join(block) if rest[0] == "|" else " ".join(b for b in block if b)
326
+ elif rest.startswith("["):
327
+ while rest.count("[") > rest.count("]") and i < n:
328
+ rest += " " + lines[i].strip()
329
+ i += 1
330
+ items = _yaml_flow_list(rest)
331
+ if items is None:
332
+ warnings.append(f"YAML field {key!r} is not a flat list; ignored")
333
+ continue
334
+ value = items
335
+ elif rest.startswith("{"):
336
+ warnings.append(f"YAML field {key!r} is a mapping; ignored")
337
+ continue
338
+ elif rest == "":
339
+ items, nested = [], False
340
+ while i < n:
341
+ s = lines[i].strip()
342
+ if s and not s.startswith("#") and not (lines[i][:1] in (" ", "\t") or s == "-" or s.startswith("- ")):
343
+ break
344
+ if s == "-" or s.startswith("- "):
345
+ items.append(_yaml_scalar(s[1:].strip()))
346
+ elif s and not s.startswith("#"):
347
+ nested = True
348
+ i += 1
349
+ if nested:
350
+ warnings.append(f"YAML field {key!r} is a nested mapping; ignored")
351
+ continue
352
+ if not items:
353
+ continue # null
354
+ value = [v for v in items if v is not None]
355
+ else:
356
+ while i < n and lines[i].strip() and lines[i][:1] in (" ", "\t"):
357
+ rest += " " + lines[i].strip() # a folded multi-line scalar
358
+ i += 1
359
+ value = _yaml_scalar(rest)
360
+ if value is None:
361
+ continue
362
+ if key in LIST_FIELDS and isinstance(value, str):
363
+ value = [v.strip() for v in value.split(",") if v.strip()] if key == "tags" else [value]
364
+ fields[key] = value
365
+ return fields, warnings
366
+
367
+
368
+ def _strip_yaml_comment(value: str) -> str:
369
+ if value[:1] in ("'", '"'):
370
+ quote = value[0]
371
+ end = value.find(quote, 1)
372
+ while quote == "'" and end != -1 and value[end + 1 : end + 2] == "'":
373
+ end = value.find(quote, end + 2)
374
+ while quote == '"' and end != -1 and value[end - 1] == "\\":
375
+ end = value.find(quote, end + 1)
376
+ if end != -1:
377
+ tail = value[end + 1 :]
378
+ return value[: end + 1] if tail.strip().startswith("#") or not tail.strip() else value
379
+ return value
380
+ hash_at = value.find(" #")
381
+ return value[:hash_at].rstrip() if hash_at != -1 else value
382
+
383
+
384
+ def _yaml_scalar(value: str) -> str | None:
385
+ value = _strip_yaml_comment(value.strip())
386
+ if len(value) >= 2 and value[0] == value[-1] == '"':
387
+ try:
388
+ return json.loads(value)
389
+ except json.JSONDecodeError:
390
+ return value[1:-1]
391
+ if len(value) >= 2 and value[0] == value[-1] == "'":
392
+ return value[1:-1].replace("''", "'")
393
+ if value in ("", "~", "null", "Null", "NULL"):
394
+ return None
395
+ return value
396
+
397
+
398
+ def _yaml_flow_list(value: str) -> list[str] | None:
399
+ value = _strip_yaml_comment(value.strip())
400
+ if not (value.startswith("[") and value.endswith("]")):
401
+ return None
402
+ inner = value[1:-1]
403
+ if "[" in inner or "{" in inner:
404
+ return None
405
+ items: list[str] = []
406
+ current, quote = "", None
407
+ for ch in inner + ",":
408
+ if quote:
409
+ current += ch
410
+ if ch == quote:
411
+ quote = None
412
+ elif ch in ("'", '"') and not current.strip():
413
+ quote = ch
414
+ current += ch
415
+ elif ch == ",":
416
+ item = _yaml_scalar(current)
417
+ if item is not None:
418
+ items.append(item)
419
+ current = ""
420
+ else:
421
+ current += ch
422
+ return items
423
+
424
+
425
+ def schema_errors(frontmatter: dict[str, Any]) -> list[str]:
426
+ errs: list[str] = []
427
+ for key, value in frontmatter.items():
428
+ if isinstance(value, str):
429
+ continue
430
+ if isinstance(value, list) and all(isinstance(v, str) for v in value):
431
+ if key == "title":
432
+ errs.append("frontmatter field 'title' must be a string")
433
+ continue
434
+ errs.append(f"frontmatter field {key!r} must be a string or a list of strings (got {type(value).__name__})")
435
+ if "load" in frontmatter and frontmatter["load"] not in LOAD_VALUES:
436
+ errs.append(f"frontmatter field 'load' must be {' or '.join(repr(v) for v in LOAD_VALUES)} "
437
+ f"(got {frontmatter['load']!r})")
438
+ return errs
439
+
440
+
441
+ # ---------------------------------------------------------------------------
442
+ # Serialization
443
+ # ---------------------------------------------------------------------------
444
+
445
+
446
+ def ordered_frontmatter(fields: dict[str, Any]) -> dict[str, Any]:
447
+ """Canonical key order: known fields first (always present), then extras in original order."""
448
+ out: dict[str, Any] = {}
449
+ out["title"] = fields.get("title") or ""
450
+ if fields.get("load") is not None: # written only when set, so ordinary cards keep their frontmatter
451
+ out["load"] = fields["load"]
452
+ for name in LIST_FIELDS:
453
+ value = fields.get(name) or []
454
+ if isinstance(value, str):
455
+ value = [value]
456
+ out[name] = list(value)
457
+ for key, value in fields.items():
458
+ if key not in out:
459
+ out[key] = value
460
+ errs = schema_errors(out)
461
+ if errs:
462
+ raise FrontmatterError("; ".join(errs))
463
+ return out
464
+
465
+
466
+ def dump_frontmatter(fields: dict[str, Any]) -> str:
467
+ return tomlfmt.dumps_flat(ordered_frontmatter(fields))
468
+
469
+
470
+ def compose_after(summary: str | None, body: str | None) -> str:
471
+ chunks = []
472
+ if summary and summary.strip():
473
+ chunks.append(summary.strip())
474
+ if body and body.strip():
475
+ chunks.append("\n".join(line.rstrip() for line in body.strip("\n").split("\n")))
476
+ return "\n\n".join(chunks) + "\n" if chunks else ""
477
+
478
+
479
+ def compose_card(fields: dict[str, Any], after: str) -> str:
480
+ fm = dump_frontmatter(fields)
481
+ after = after.lstrip("\n")
482
+ text = f"{FENCE}\n{fm}{FENCE}\n"
483
+ if after:
484
+ text += "\n" + after
485
+ if not text.endswith("\n"):
486
+ text += "\n"
487
+ return text
488
+
489
+
490
+ def render_card(fields: dict[str, Any], summary: str | None, body: str | None) -> str:
491
+ return compose_card(fields, compose_after(summary, body))
492
+
493
+
494
+ # ---------------------------------------------------------------------------
495
+ # Bullet statements (append + dedupe)
496
+ # ---------------------------------------------------------------------------
497
+
498
+
499
+ def _norm_statement(block: str) -> str:
500
+ first, *rest = block.split("\n")
501
+ first = _BULLET_RE.sub("", first.strip(), count=1)
502
+ return _WS_RE.sub(" ", normalize(" ".join([first] + [r.strip() for r in rest]))).strip().casefold()
503
+
504
+
505
+ def statement_blocks(content: str | Iterable[str]) -> list[str]:
506
+ """Normalize caller content into ``- ...`` bullet blocks (continuation lines kept)."""
507
+ items = [content] if isinstance(content, str) else [str(c) for c in content]
508
+ blocks: list[str] = []
509
+ for item in items:
510
+ current_item_blocks: list[str] = []
511
+ for line in normalize_newlines(item).split("\n"):
512
+ if not line.strip():
513
+ continue
514
+ if line[:1] in (" ", "\t") and current_item_blocks:
515
+ current_item_blocks[-1] += "\n" + line.rstrip()
516
+ continue
517
+ s = line.strip()
518
+ if _BULLET_RE.match(s):
519
+ s = "- " + _BULLET_RE.sub("", s, count=1)
520
+ elif not _NUMBERED_RE.match(s):
521
+ s = "- " + s
522
+ current_item_blocks.append(s)
523
+ blocks.extend(current_item_blocks)
524
+ return blocks
525
+
526
+
527
+ def existing_statements(text: str) -> set[str]:
528
+ out: set[str] = set()
529
+ current: list[str] | None = None
530
+ for line in text.split("\n"):
531
+ if _BULLET_RE.match(line) or _NUMBERED_RE.match(line):
532
+ if current:
533
+ out.add(_norm_statement("\n".join(current)))
534
+ current = [line]
535
+ elif current is not None and line[:1] in (" ", "\t") and line.strip():
536
+ current.append(line)
537
+ else:
538
+ if current:
539
+ out.add(_norm_statement("\n".join(current)))
540
+ current = None
541
+ if current:
542
+ out.add(_norm_statement("\n".join(current)))
543
+ return out
544
+
545
+
546
+ def append_statements(after: str, blocks: list[str]) -> tuple[str, list[str]]:
547
+ """Append bullet blocks not already present; returns ``(new_after, added_blocks)``."""
548
+ seen = existing_statements(after)
549
+ added: list[str] = []
550
+ for block in blocks:
551
+ key = _norm_statement(block)
552
+ if key in seen:
553
+ continue
554
+ seen.add(key)
555
+ added.append(block)
556
+ if not added:
557
+ return after, []
558
+ base = after.rstrip()
559
+ if not base.strip():
560
+ return "\n" + "\n".join(added) + "\n", added
561
+ last_para = base.split("\n\n")[-1]
562
+ first_line = last_para.lstrip("\n").split("\n", 1)[0]
563
+ sep = "\n" if (_BULLET_RE.match(first_line) or _NUMBERED_RE.match(first_line)) else "\n\n"
564
+ return base + sep + "\n".join(added) + "\n", added
565
+
566
+
567
+ # ---------------------------------------------------------------------------
568
+ # Previews
569
+ # ---------------------------------------------------------------------------
570
+
571
+
572
+ def _strip_markup(line: str) -> str:
573
+ line = _BULLET_RE.sub("", line.strip(), count=1)
574
+ line = re.sub(r"^#{1,6}\s+", "", line)
575
+ return line.strip()
576
+
577
+
578
+ def first_paragraph(text: str) -> str:
579
+ """First run of prose lines, skipping headings, code fences, lists, quotes and tables."""
580
+ in_fence = False
581
+ collected: list[str] = []
582
+ for line in text.split("\n"):
583
+ stripped = line.strip()
584
+ if stripped.startswith(("```", "~~~")):
585
+ in_fence = not in_fence
586
+ if collected:
587
+ break
588
+ continue
589
+ if in_fence:
590
+ continue
591
+ if not stripped:
592
+ if collected:
593
+ break
594
+ continue
595
+ if _BLOCK_START_RE.match(stripped):
596
+ if collected:
597
+ break
598
+ continue
599
+ collected.append(stripped)
600
+ return " ".join(collected)
601
+
602
+
603
+ def preview_text(summary: str, body: str, limit: int = SUMMARY_PREVIEW_CHARS) -> tuple[str, bool]:
604
+ """Return a whitespace-collapsed summary capped at ``limit`` chars plus a truncation flag."""
605
+ text = summary.strip() or first_paragraph(body)
606
+ if not text:
607
+ for line in body.split("\n"):
608
+ cleaned = _strip_markup(line)
609
+ if cleaned and not cleaned.startswith(("```", "~~~", "|", "<")):
610
+ text = cleaned
611
+ break
612
+ text = _WS_RE.sub(" ", text).strip()
613
+ return truncate(text, limit)
614
+
615
+
616
+ def truncate(text: str, limit: int) -> tuple[str, bool]:
617
+ if limit <= 0:
618
+ return "", bool(text)
619
+ if len(text) <= limit:
620
+ return text, False
621
+ cut = text[: limit - 1]
622
+ space = cut.rfind(" ")
623
+ if space >= limit * 0.6:
624
+ cut = cut[:space]
625
+ return cut.rstrip(" ,;:") + "…", True
626
+
627
+
628
+ def reassemble(parsed: ParsedCard, original: str, fields: dict[str, Any] | None, after: str | None) -> str:
629
+ """Rebuild a card, keeping untouched regions (frontmatter text, body) verbatim."""
630
+ original = normalize_newlines(original)
631
+ if fields is None and after is None:
632
+ return original if original.endswith("\n") or not original else original + "\n"
633
+ new_after = (parsed.after if after is None else after).lstrip("\n")
634
+ if fields is None and parsed.raw_frontmatter is None:
635
+ text = new_after
636
+ elif fields is None and parsed.frontmatter_format == "yaml":
637
+ text = f"---\n{parsed.raw_frontmatter}---\n" + ("\n" + new_after if new_after else "") # kept verbatim
638
+ else:
639
+ fm = parsed.raw_frontmatter if fields is None else dump_frontmatter(fields)
640
+ text = f"+++\n{fm}+++\n" + ("\n" + new_after if new_after else "")
641
+ return text if text.endswith("\n") or not text else text + "\n"