memlog.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.8"
  4. # ///
  5. """memlog — an append-only memory log: LLM-optimal working memory for a skill.
  6. A memlog is the dense, chronological record of everything that mattered in a piece of
  7. work — every item the user generated or accepted — kept minimal like human memory: only
  8. what's important, never bloated. It persists ACROSS sessions, so a fresh session can
  9. load it and continue. It is NOT a deliverable; downstream artifacts (a brief, a PRD, a
  10. deck, a report) are *derived* from it on demand. The host skill supplies the vocabulary
  11. by how it calls `append` — the tool stays neutral.
  12. It is a FLAT log: there are no sections or grouping. Every entry is one line, recorded
  13. at the END in the order it happened. The chronology itself is the structure — an event
  14. like "started technique X" is just another entry, same as an idea or an insight.
  15. Three invariants make it trustworthy:
  16. 1. Append-only, chronological. Entries land at the end, in the order they happen.
  17. Nothing is ever inserted backward, reordered, edited, or removed. There is no
  18. edit or delete subcommand by design; history is never rewritten.
  19. 2. Write-only / blind. Every command is an atomic, context-free write and echoes the
  20. new state as one line of JSON, so the caller never re-reads the file mid-session.
  21. The one time the file is read is on resume — and the caller reads it itself, not
  22. via this script.
  23. 3. No lifecycle status. A memory log has no "complete" flag. Whether the work is done,
  24. blocked, or paused is itself a fact that happened, so it is recorded as an entry
  25. (e.g. `append --type event --text "session complete"`), never as frontmatter the
  26. log would have to mutate. The chronology stays the single source of truth, and a
  27. resume learns the state by reading the last entries — the same way it learns
  28. everything else.
  29. Atomicity: every write goes to a temp file, is flushed and fsync'd, then atomically
  30. renamed over the target, so a crash never leaves a half-written entry.
  31. The file shape (.memlog.md):
  32. ---
  33. topic: Onboarding flow for a budgeting app
  34. goal: lift week-1 retention
  35. updated: 2026-06-07T14:22
  36. ---
  37. - (note) user picked techniques: SCAMPER, then Six Thinking Hats
  38. - (technique) started SCAMPER
  39. - (idea) skip the signup wall: let people try with sample data first
  40. - (idea) auto-import one bank account so the first screen shows real numbers
  41. - (question) is open-banking consent too heavy for step one?
  42. - (insight) the "scary numbers" risk and the "real numbers" idea are one lever: show real data, pre-categorized
  43. - (direction) optimize for the anxious first-timer, not the power user
  44. - (decision) lead with one pre-categorized account; defer multi-account import
  45. - (event) session complete
  46. Each entry may carry an optional `--type` — what KIND it is (idea, insight, question,
  47. decision, direction, assumption, gap, note, event, …) — and an optional `--by` naming
  48. who it came from (e.g. `user`, `coach`), for sessions where authorship matters. Both
  49. render into one short inline tag: `(idea)`, `(idea by user)`, `(by coach)`. Omit them
  50. for a plain note. The host skill names the vocabulary; the script does not enforce one.
  51. Commands:
  52. init (--workspace DIR | --path FILE) [--field k=v ...] create the memlog (errors if it exists)
  53. append (--workspace DIR | --path FILE) --text STR [--type T] [--by W] append one entry at the end
  54. set (--workspace DIR | --path FILE) --key K --value V set/replace a descriptive frontmatter field
  55. Addressing: `--workspace` is the run folder, and the memlog is always {workspace}/.memlog.md.
  56. `--path` points straight at the memlog file instead, for callers that already hold the path.
  57. """
  58. from __future__ import annotations # keep type-hint syntax lazy so the script runs on 3.8+
  59. import argparse
  60. import json
  61. import os
  62. import sys
  63. from datetime import datetime
  64. from pathlib import Path
  65. MEMLOG = ".memlog.md"
  66. def now() -> str:
  67. return datetime.now().strftime("%Y-%m-%dT%H:%M")
  68. def resolve(args) -> Path:
  69. """The memlog file, from either addressing mode: {workspace}/.memlog.md or an explicit --path."""
  70. return Path(args.path) if args.path else Path(args.workspace) / MEMLOG
  71. def split(text: str) -> tuple[dict, str]:
  72. """Return (frontmatter dict in source order, body str). Frontmatter is plain key: value.
  73. The closing fence is the first line that is *exactly* `---`, so a `---` inside a
  74. field value (topic/goal are free user text) never truncates the frontmatter.
  75. """
  76. lines = text.splitlines()
  77. if not lines or lines[0] != "---":
  78. raise ValueError(".memlog.md has no frontmatter")
  79. end = next((i for i in range(1, len(lines)) if lines[i] == "---"), None)
  80. if end is None:
  81. raise ValueError(".memlog.md frontmatter is not terminated")
  82. meta: dict[str, str] = {}
  83. for line in lines[1:end]:
  84. if ":" in line:
  85. k, v = line.split(":", 1)
  86. meta[k.strip()] = v.strip()
  87. return meta, "\n".join(lines[end + 1:]).lstrip("\n")
  88. def render(meta: dict, body: str) -> str:
  89. # Neutralize newlines in values so a multi-line field can't break the fence on re-read.
  90. fm = "\n".join(f"{k}: {' '.join(str(v).splitlines())}" for k, v in meta.items())
  91. return "---\n" + fm + "\n---\n\n" + body.rstrip("\n") + "\n"
  92. def touch(meta: dict) -> None:
  93. """Stamp `updated` and keep it last so the field order stays predictable."""
  94. meta.pop("updated", None)
  95. meta["updated"] = now()
  96. def write_atomic(path: Path, text: str) -> None:
  97. """Temp + flush + fsync + atomic rename, so a crash never half-writes an entry."""
  98. tmp = path.with_suffix(path.suffix + ".tmp")
  99. with open(tmp, "w", encoding="utf-8") as f:
  100. f.write(text)
  101. f.flush()
  102. os.fsync(f.fileno())
  103. os.replace(tmp, path)
  104. def entry_count(body: str) -> int:
  105. return sum(1 for ln in body.splitlines() if ln.startswith("- "))
  106. def ack(path: Path, body: str) -> None:
  107. """Echo new state so the caller never re-reads the file to know where it stands."""
  108. print(json.dumps({
  109. "ok": True,
  110. "memlog": str(path),
  111. "entries": entry_count(body),
  112. }))
  113. def cmd_init(args) -> int:
  114. path = resolve(args)
  115. if path.exists():
  116. print(f"error: {path} already exists; use append/set to update it", file=sys.stderr)
  117. return 2
  118. path.parent.mkdir(parents=True, exist_ok=True)
  119. meta: dict[str, str] = {}
  120. for pair in args.field or []:
  121. if "=" not in pair:
  122. print(f"error: --field expects key=value, got {pair!r}", file=sys.stderr)
  123. return 2
  124. k, v = pair.split("=", 1)
  125. meta[k.strip()] = v.strip()
  126. touch(meta)
  127. write_atomic(path, render(meta, ""))
  128. ack(path, "")
  129. return 0
  130. def cmd_append(args) -> int:
  131. path = resolve(args)
  132. meta, body = split(path.read_text(encoding="utf-8"))
  133. text = " ".join(args.text.split()) # collapse newlines/runs → one-line entry, no prose bloat
  134. label = args.type or ""
  135. if args.by:
  136. label = f"{label} by {args.by}".strip() # attribution: "(idea by user)" / "(by coach)"
  137. tag = f"({label}) " if label else ""
  138. entry = f"- {tag}{text}"
  139. body = (body.rstrip("\n") + "\n" + entry) if body.strip() else entry # always at the end
  140. touch(meta)
  141. write_atomic(path, render(meta, body))
  142. ack(path, body)
  143. return 0
  144. def cmd_set(args) -> int:
  145. path = resolve(args)
  146. meta, body = split(path.read_text(encoding="utf-8"))
  147. meta[args.key] = args.value
  148. touch(meta)
  149. write_atomic(path, render(meta, body))
  150. ack(path, body)
  151. return 0
  152. def add_target(sp) -> None:
  153. """Every command addresses the memlog the same way: a run folder or an explicit path."""
  154. g = sp.add_mutually_exclusive_group(required=True)
  155. g.add_argument("--workspace", help="run folder; the memlog is {workspace}/.memlog.md")
  156. g.add_argument("--path", help="explicit memlog file path (alternative to --workspace)")
  157. def main(argv: list[str] | None = None) -> int:
  158. p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
  159. sub = p.add_subparsers(dest="cmd", required=True)
  160. pi = sub.add_parser("init", help="create the memlog")
  161. add_target(pi)
  162. pi.add_argument("--field", action="append", metavar="KEY=VALUE", help="frontmatter field (repeatable)")
  163. pi.set_defaults(func=cmd_init)
  164. pa = sub.add_parser("append", help="append one entry at the end")
  165. add_target(pa)
  166. pa.add_argument("--text", required=True)
  167. pa.add_argument("--type", help="entry kind, rendered as an inline tag")
  168. pa.add_argument("--by", help="who the entry came from (e.g. user, coach); rendered into the tag")
  169. pa.set_defaults(func=cmd_append)
  170. pset = sub.add_parser("set", help="set a descriptive frontmatter field")
  171. add_target(pset)
  172. pset.add_argument("--key", required=True)
  173. pset.add_argument("--value", required=True)
  174. pset.set_defaults(func=cmd_set)
  175. args = p.parse_args(argv)
  176. return args.func(args)
  177. if __name__ == "__main__":
  178. sys.exit(main())