diff --git a/.gitignore b/.gitignore index 03906ea..141733b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ # Python bytecode files __pycache__/ *.pyc + +# Easy-memory logs +easy-memory/ diff --git a/skills/.experimental/easy-memory/LICENSE.txt b/skills/.experimental/easy-memory/LICENSE.txt new file mode 100644 index 0000000..900a003 --- /dev/null +++ b/skills/.experimental/easy-memory/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 memeda230606@gmail.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/.experimental/easy-memory/SKILL.md b/skills/.experimental/easy-memory/SKILL.md new file mode 100644 index 0000000..6fdc9bb --- /dev/null +++ b/skills/.experimental/easy-memory/SKILL.md @@ -0,0 +1,147 @@ +--- +name: easy-memory +description: Project-local memory logging for history/notes stored as YYYY-MM-DD.log files in ./easy-memory relative to the current working directory. Use when you must read today's memory, search memory by keywords (English preferred), append structured log entries with IDs (English preferred; UTF-8 accepted), or update/delete entries by ID. Logs always go to the working directory even if the skill is installed globally. +--- + +# Easy Memory + +## Overview + +Maintain project-local memory logs inside the ./easy-memory directory under the current working directory. Each day uses one log file named `YYYY-MM-DD.log`. + +## Initialization (must follow) + +Run initialization once per project before any other scripts when: +- `./easy-memory` does not exist, or +- `./easy-memory/init.log` is missing. + +Run: + +``` +python3 scripts/init_memory.py +``` + +Initialization behavior: +- Create `./easy-memory` if needed. +- Locate `AGENTS.MD` in the project root (current working directory). If missing, create it. +- If `AGENTS.MD` exists, append a rules block to the end: + ``` + ## easy-memory rules + - At the start of the current session (before the first task), use the `easy-memory` skill and follow all rules and constraints in its `SKILL.md`. + - Only re-run memory read/search when necessary for the task. + ``` +- Create `./easy-memory/init.log` containing the same rules block and a `Date: YYYY-MM-DD` line at the end. +- If `init.log` already exists, initialization is skipped and `AGENTS.MD` is not modified. + +If you need the absolute usage path for easy-memory scripts, use the directory +containing this `SKILL.md` (the `scripts/` folder sits alongside it). Avoid +persisting absolute paths in project `AGENTS.MD` because different environments +may maintain the same project. + +All other scripts require `init.log` to exist and will exit if initialization has not been run. + +## Mandatory workflow (must follow) + +1. At the start of the current session (before the first task), run `scripts/read_today_log.py` to load the full log for today. +2. At the start of the current session (before the first task), run `scripts/search_memory.py` with English-preferred keywords for the session/task. Only repeat steps 1-2 when necessary for the task. Choose `--max-results` based on task complexity (this is the memory search depth). +3. Before finishing or submitting any task, append a new entry with `scripts/write_memory.py` following the log rules below. +4. Log entries should be written in English when possible; UTF-8 is accepted. + +## Remote repository alignment gate (must follow) + +Condition precedent. Prior to executing `scripts/write_memory.py`, the agent shall determine, with reasonable diligence, whether any Remote Alignment Operation is expected to occur after the time of writing within the same task or session. + +For purposes of this skill, a "Remote Alignment Operation" means any action that publishes, synchronizes, or otherwise updates a remote code repository or mirror, including but not limited to `git push`, `svn commit`, `hg push`, and any API- or UI-based upload of code changes to Git-, SVN-, or similar systems. + +If a Remote Alignment Operation is expected or cannot be reasonably ruled out, the agent shall, before initiating any Repository-Change Operation, write memory in accordance with this skill and shall not perform any Repository-Change Operation until the memory entry has been written. + +For purposes of this skill, a "Repository-Change Operation" includes any action that modifies tracked files or repository history, including edits to tracked files, staging, commits, amend/merge/rebase/cherry-pick, and related history-rewriting actions. + +For avoidance of doubt, if no Remote Alignment Operation is expected after the time of writing and this can be reasonably confirmed, the agent may proceed with the ordinary timing for memory writing, subject to the Mandatory workflow above. + +## Log entry format + +Each entry is a single line and must end with a timestamp: + +``` +[ID:] [REF:] [FACT:] [TIME:YYYY-MM-DD:HH:MM] +``` + +Rules: +- Log file name must be `YYYY-MM-DD.log` and use the current date only. +- If today's log file does not exist, create it; otherwise append to the end. +- Entries should be written in English when possible; UTF-8 is accepted. +- The timestamp must be the final token of the line and must be accurate to minutes. +- Each entry must include a unique ID, a reference level, and a factual flag. + +## Scripts + +### Initialize memory + +``` +python3 scripts/init_memory.py +``` + +Runs one-time initialization to create `AGENTS.MD` rules and `./easy-memory/init.log`. + +### Read today's log + +``` +python3 scripts/read_today_log.py +``` + +Reads the full log for the current date. + +### Search memory + +``` +python3 scripts/search_memory.py --max-results 5 +``` + +Searches all `.log` files in the ./easy-memory directory under the current working directory. Keywords should be English; UTF-8 is accepted. Default `--max-results` is 5. +Results are prioritized in this order: +- Factual entries (`FACT:true`) first +- Higher reference level first (`REF:critical` > `high` > `medium` > `low`, or higher numeric values) +- Newer timestamps first + +### Write memory + +``` +python3 scripts/write_memory.py --content "..." --factual true --ref-level medium +``` + +Appends a new entry to today's log. Content should be English and single-line; UTF-8 is accepted. The script generates the unique ID and timestamp. + +### Update memory + +``` +python3 scripts/update_memory.py --id --content "..." --ref-level high --factual false +``` + +Updates the entry matching the ID across all logs. The timestamp is refreshed to the current time. + +Use update when: +- New factual findings contradict older memory entries (especially results from recent searches). +- The latest task outcomes refine or correct existing memory. + +### Delete memory + +``` +python3 scripts/delete_memory.py --id +``` + +Deletes the entry matching the ID across all logs. + +Use delete when: +- Older memory entries are no longer valuable or are misleading. +- A memory entry conflicts with verified facts and should be removed instead of updated. + +## Log location rule + +Logs are always stored under `./easy-memory` relative to the directory where you run the scripts. The skill can be installed globally; logs never go to the install directory. + +## Reminder to repeat each session + +- Log entries should be written in English when possible; UTF-8 is accepted. +- At the start of the current session (before the first task), run `scripts/read_today_log.py` and `scripts/search_memory.py` with English-preferred keywords; adjust `--max-results` based on task complexity. Only repeat when necessary. +- Before finishing or submitting any task, write a log entry using `scripts/write_memory.py` following the rules above. diff --git a/skills/.experimental/easy-memory/scripts/delete_memory.py b/skills/.experimental/easy-memory/scripts/delete_memory.py new file mode 100755 index 0000000..f9616e9 --- /dev/null +++ b/skills/.experimental/easy-memory/scripts/delete_memory.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse + +from memory_utils import ( + list_log_files, + log_base_dir, + parse_entry_line, + require_initialized, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Delete a memory entry by ID across all logs." + ) + parser.add_argument("--id", required=True, help="Entry ID to delete.") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + base_dir = log_base_dir(create=True) + require_initialized(base_dir) + + matches: list[tuple] = [] + for log_path in list_log_files(base_dir): + text = log_path.read_text(encoding="utf-8") + lines = text.splitlines() + for idx, line in enumerate(lines): + entry = parse_entry_line(line) + if entry and entry["id"] == args.id: + matches.append((log_path, lines, idx)) + + if not matches: + raise SystemExit("Entry ID not found.") + if len(matches) > 1: + raise SystemExit("Entry ID appears multiple times. Refine the logs manually.") + + log_path, lines, idx = matches[0] + del lines[idx] + + output = "\n".join(lines) + if output: + output += "\n" + log_path.write_text(output, encoding="utf-8") + + print(f"Deleted entry ID: {args.id}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/.experimental/easy-memory/scripts/init_memory.py b/skills/.experimental/easy-memory/scripts/init_memory.py new file mode 100644 index 0000000..c2ed184 --- /dev/null +++ b/skills/.experimental/easy-memory/scripts/init_memory.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from memory_utils import ensure_initialized, init_log_path, log_base_dir + + +def main() -> int: + base_dir = log_base_dir(create=True) + init_log = init_log_path(base_dir) + if init_log.exists(): + print("Initialization already completed.") + return 0 + + ensure_initialized(base_dir) + print(f"Initialized easy-memory in {base_dir}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/.experimental/easy-memory/scripts/memory_utils.py b/skills/.experimental/easy-memory/scripts/memory_utils.py new file mode 100755 index 0000000..a157b20 --- /dev/null +++ b/skills/.experimental/easy-memory/scripts/memory_utils.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import re +from datetime import date, datetime +from pathlib import Path +from typing import Optional + +ENTRY_RE = re.compile( + r"^\[ID:(?P[^\]]+)\] " + r"\[REF:(?P[^\]]+)\] " + r"\[FACT:(?Ptrue|false)\] " + r"(?P.*) " + r"\[TIME:(?P\d{4}-\d{2}-\d{2}:\d{2}:\d{2})\]$" +) + +_REF_LEVEL_RE = re.compile(r"^[A-Za-z0-9._-]+$") +INIT_LOG_NAME = "init.log" +AGENTS_FILE_NAME = "AGENTS.MD" + + +def log_base_dir(create: bool = False) -> Path: + base_dir = Path.cwd() / "easy-memory" + if create: + base_dir.mkdir(parents=True, exist_ok=True) + return base_dir + + +def log_path_for_date(log_date: date, base_dir: Path) -> Path: + return base_dir / f"{log_date.strftime('%Y-%m-%d')}.log" + + +def list_log_files(base_dir: Path) -> list[Path]: + if not base_dir.exists(): + return [] + return sorted(base_dir.glob("*.log"), reverse=True) + + +def init_log_path(base_dir: Path) -> Path: + return base_dir / INIT_LOG_NAME + + +def init_rules_block() -> str: + return "\n".join( + [ + "## easy-memory rules", + "- At the start of the current session (before the first task), use the " + "`easy-memory` skill and follow all rules and constraints in its " + "`SKILL.md`.", + "- Only re-run memory read/search when necessary for the task.", + ] + ) + + +def ensure_initialized(base_dir: Path) -> None: + init_log = init_log_path(base_dir) + if init_log.exists(): + return + + base_dir.mkdir(parents=True, exist_ok=True) + + rules_block = init_rules_block() + agents_path = Path.cwd() / AGENTS_FILE_NAME + if agents_path.exists(): + existing = agents_path.read_text(encoding="utf-8") + if existing and not existing.endswith("\n"): + existing += "\n" + if existing.strip(): + existing += "\n" + existing += f"{rules_block}\n" + agents_path.write_text(existing, encoding="utf-8") + else: + agents_path.write_text(f"{rules_block}\n", encoding="utf-8") + + date_stamp = date.today().isoformat() + init_log_content = f"{rules_block}\nDate: {date_stamp}\n" + init_log.write_text(init_log_content, encoding="utf-8") + + +def require_initialized(base_dir: Path) -> None: + init_log = init_log_path(base_dir) + if not base_dir.exists() or not init_log.exists(): + raise SystemExit( + "Initialization required. Run `python3 scripts/init_memory.py` " + "from the project root." + ) + + +def ensure_single_line(text: str, label: str) -> None: + if "\n" in text or "\r" in text: + raise SystemExit(f"{label} must be a single line.") + + +def normalize_bool(value: str) -> bool: + normalized = value.strip().lower() + if normalized == "true": + return True + if normalized == "false": + return False + raise SystemExit("factual must be 'true' or 'false'.") + + +def validate_ref_level(value: str) -> str: + if not value: + raise SystemExit("ref-level must be a non-empty string.") + if not _REF_LEVEL_RE.match(value): + raise SystemExit("ref-level must match [A-Za-z0-9._-]+.") + return value + + +def format_timestamp(dt: datetime) -> str: + return dt.strftime("%Y-%m-%d:%H:%M") + + +def format_entry_line( + entry_id: str, + ref_level: str, + factual: bool, + content: str, + timestamp: str, +) -> str: + fact_value = "true" if factual else "false" + return ( + f"[ID:{entry_id}] [REF:{ref_level}] [FACT:{fact_value}] {content} " + f"[TIME:{timestamp}]" + ) + + +def parse_entry_line(line: str) -> Optional[dict]: + match = ENTRY_RE.match(line.strip()) + if not match: + return None + return { + "id": match.group("id"), + "ref": match.group("ref"), + "factual": match.group("factual") == "true", + "content": match.group("content"), + "timestamp": match.group("ts"), + } diff --git a/skills/.experimental/easy-memory/scripts/read_today_log.py b/skills/.experimental/easy-memory/scripts/read_today_log.py new file mode 100755 index 0000000..8b466db --- /dev/null +++ b/skills/.experimental/easy-memory/scripts/read_today_log.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from datetime import date + +from memory_utils import log_base_dir, log_path_for_date, require_initialized + +EMPTY_LOG_MESSAGE = ( + "No log entries for today. Created an empty log file; " + "please continue with the remaining task steps." +) + + +def main() -> int: + base_dir = log_base_dir(create=True) + require_initialized(base_dir) + + log_path = log_path_for_date(date.today(), base_dir) + if not log_path.exists(): + log_path.touch() + print(EMPTY_LOG_MESSAGE) + return 0 + + content = log_path.read_text(encoding="utf-8") + if not content.strip(): + print(EMPTY_LOG_MESSAGE) + return 0 + + print(content, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/.experimental/easy-memory/scripts/search_memory.py b/skills/.experimental/easy-memory/scripts/search_memory.py new file mode 100755 index 0000000..09ed7c9 --- /dev/null +++ b/skills/.experimental/easy-memory/scripts/search_memory.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from datetime import date, datetime + +from memory_utils import ( + log_base_dir, + log_path_for_date, + list_log_files, + parse_entry_line, + require_initialized, +) + +_REF_LEVEL_SCORES = { + "low": 1, + "medium": 2, + "high": 3, + "critical": 4, +} + +_TIME_FORMAT = "%Y-%m-%d:%H:%M" + +EMPTY_LOG_MESSAGE = ( + "No log entries for today. Created an empty log file; " + "please continue with the remaining task steps." +) +NO_MATCH_MESSAGE = "No matching entries found for the provided keywords." +IMPORTANT_REMINDER = ( + "IMPORTANT NOTICE: The foregoing search history may be used as material reference " + "for this task; however, should any subsequent work disclose new information " + "inconsistent with, superseding, or rendering any entry outdated, you are hereby " + "required, prior to writing new logs or submitting this task, to correct or update " + "the relevant entries using the appropriate tool scripts, or to delete them." +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Search memory logs in the easy-memory directory." + ) + parser.add_argument( + "keywords", + nargs="+", + help="Keywords (English preferred; space-separated).", + ) + parser.add_argument( + "--max-results", + type=int, + default=5, + help="Maximum number of entries to return (default: 5).", + ) + return parser.parse_args() + + +def ref_level_score(value: str) -> int: + normalized = value.strip().lower() + if normalized in _REF_LEVEL_SCORES: + return _REF_LEVEL_SCORES[normalized] + try: + return int(normalized) + except ValueError: + return 0 + + +def parse_timestamp(value: str) -> datetime: + try: + return datetime.strptime(value, _TIME_FORMAT) + except ValueError: + return datetime.min + + +def main() -> int: + args = parse_args() + base_dir = log_base_dir(create=True) + require_initialized(base_dir) + + keywords = [k.lower() for k in args.keywords] + max_results = args.max_results + if max_results <= 0: + raise SystemExit("max-results must be a positive integer.") + + log_paths = list_log_files(base_dir) + if not log_paths: + log_path_for_date(date.today(), base_dir).touch() + print(EMPTY_LOG_MESSAGE) + return 0 + + matches = [] + order = 0 + has_any_entries = False + for log_path in log_paths: + lines = log_path.read_text(encoding="utf-8").splitlines() + if lines: + has_any_entries = True + for line in lines: + entry = parse_entry_line(line) + haystack = entry["content"] if entry else line + if any(k in haystack.lower() for k in keywords): + factual_score = 0 + ref_score = 0 + timestamp = datetime.min + if entry: + factual_score = 1 if entry["factual"] else 0 + ref_score = ref_level_score(entry["ref"]) + timestamp = parse_timestamp(entry["timestamp"]) + matches.append( + { + "log": log_path.name, + "line": line, + "factual": factual_score, + "ref": ref_score, + "timestamp": timestamp, + "order": order, + } + ) + order += 1 + + if not has_any_entries: + log_path = log_path_for_date(date.today(), base_dir) + if not log_path.exists(): + log_path.touch() + print(EMPTY_LOG_MESSAGE) + return 0 + + if not matches: + print(NO_MATCH_MESSAGE) + return 0 + + matches.sort(key=lambda item: (item["factual"], item["ref"], item["timestamp"]), reverse=True) + results = [f"{item['log']}: {item['line']}" for item in matches[:max_results]] + results.append(IMPORTANT_REMINDER) + print("\n".join(results)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/.experimental/easy-memory/scripts/update_memory.py b/skills/.experimental/easy-memory/scripts/update_memory.py new file mode 100755 index 0000000..dcf2d29 --- /dev/null +++ b/skills/.experimental/easy-memory/scripts/update_memory.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from datetime import datetime + +from memory_utils import ( + ensure_single_line, + format_entry_line, + format_timestamp, + list_log_files, + log_base_dir, + normalize_bool, + parse_entry_line, + require_initialized, + validate_ref_level, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Update a memory entry by ID across all logs." + ) + parser.add_argument("--id", required=True, help="Entry ID to update.") + parser.add_argument( + "--content", + help="New content (English preferred; UTF-8 accepted).", + ) + parser.add_argument( + "--factual", + help="Whether the entry is factual: true or false.", + ) + parser.add_argument( + "--ref-level", + help="Reference level (e.g., low, medium, high, critical).", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + base_dir = log_base_dir(create=True) + require_initialized(base_dir) + + if not any([args.content, args.factual, args.ref_level]): + raise SystemExit("Provide at least one field to update.") + + matches: list[tuple] = [] + for log_path in list_log_files(base_dir): + text = log_path.read_text(encoding="utf-8") + lines = text.splitlines() + for idx, line in enumerate(lines): + entry = parse_entry_line(line) + if entry and entry["id"] == args.id: + matches.append((log_path, lines, idx, entry)) + + if not matches: + raise SystemExit("Entry ID not found.") + if len(matches) > 1: + raise SystemExit("Entry ID appears multiple times. Refine the logs manually.") + + log_path, lines, idx, entry = matches[0] + + content = entry["content"] + if args.content is not None: + content = args.content.strip() + if not content: + raise SystemExit("content must not be empty.") + ensure_single_line(content, "content") + + factual = entry["factual"] + if args.factual is not None: + factual = normalize_bool(args.factual) + + ref_level = entry["ref"] + if args.ref_level is not None: + ref_level = validate_ref_level(args.ref_level) + + timestamp = format_timestamp(datetime.now()) + lines[idx] = format_entry_line(args.id, ref_level, factual, content, timestamp) + + output = "\n".join(lines) + "\n" + log_path.write_text(output, encoding="utf-8") + + print(f"Updated entry ID: {args.id}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/.experimental/easy-memory/scripts/write_memory.py b/skills/.experimental/easy-memory/scripts/write_memory.py new file mode 100755 index 0000000..8d8dd0b --- /dev/null +++ b/skills/.experimental/easy-memory/scripts/write_memory.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from datetime import date, datetime +from uuid import uuid4 + +from memory_utils import ( + ensure_single_line, + format_entry_line, + format_timestamp, + log_base_dir, + log_path_for_date, + normalize_bool, + require_initialized, + validate_ref_level, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Append a memory entry to today's log." + ) + parser.add_argument( + "--content", + required=True, + help="Log content (English preferred; UTF-8 accepted).", + ) + parser.add_argument( + "--factual", + required=True, + help="Whether the entry is factual: true or false.", + ) + parser.add_argument( + "--ref-level", + required=True, + help="Reference level (e.g., low, medium, high, critical).", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + base_dir = log_base_dir(create=True) + require_initialized(base_dir) + + content = args.content.strip() + if not content: + raise SystemExit("content must not be empty.") + + ensure_single_line(content, "content") + + factual = normalize_bool(args.factual) + ref_level = validate_ref_level(args.ref_level) + + entry_id = uuid4().hex + timestamp = format_timestamp(datetime.now()) + + entry_line = format_entry_line(entry_id, ref_level, factual, content, timestamp) + + log_path = log_path_for_date(date.today(), base_dir) + with log_path.open("a", encoding="utf-8") as handle: + handle.write(entry_line) + handle.write("\n") + + print(f"Appended entry ID: {entry_id}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())