okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petkernel: keep output once, bound snapshots and replays, drop the outbox scanf479db5

main
4.5 KiB115 linesraw
1"""Project-scoped Markdown memory with literal and full-text recall.
2
3The files live on the daemon's machine, wherever this kernel runs, and every
4call goes through the memory host route. Reads of a linked workspace's memory
5come back with it; writes always land in this workspace's own.
6"""
7
8from __future__ import annotations
9
10from collections.abc import Callable
11from datetime import date
12import re
13from typing import cast
14
15from albedo_api import PythonApi, Text
16from albedo_plugins.files import Rows
17
18
19class Memory:
20    def __init__(self, host: Callable[[str, dict[str, object]], object]) -> None:
21        self._host = host
22
23    def read(self, max_chars: int = 16000) -> Text:
24        if not 0 < max_chars <= 200_000:
25            raise ValueError("0 < max_chars <= 200000")
26        content = cast(str, self._host("memory.read", {}))
27        suffix = (
28            "\n[truncated; raise max_chars to read more]"
29            if len(content) > max_chars
30            else ""
31        )
32        return Text(content[:max_chars] + suffix)
33
34    def save(self, text: str) -> Text:
35        """Atomically replace curated memory."""
36        return Text(cast(str, self._host("memory.save", {"text": text})))
37
38    def append(self, text: str) -> Text:
39        return Text(cast(str, self._host("memory.append", {"text": text})))
40
41    def journal(self, text: str) -> Text:
42        day = date.today().isoformat()
43        return Text(
44            cast(str, self._host("memory.journal", {"date": day, "text": text}))
45        )
46
47    def _documents(self) -> tuple[list[tuple[str, str]], bool]:
48        """(name, text) for every memory and journal file this workspace
49        reads; a linked workspace's names lead with it."""
50        answer = cast(dict, self._host("memory.documents", {}))
51        named = [
52            (
53                document["path"]
54                if document["workspace"] == answer["own"]
55                else f"[{document['workspace']}] {document['path']}",
56                document["text"],
57            )
58            for document in answer["documents"]
59        ]
60        return named, bool(answer["truncated"])
61
62    def grep(self, term: str, limit: int = 20) -> Rows:
63        if not term or not 1 <= limit <= 100:
64            raise ValueError("term must be nonempty and 1 <= limit <= 100")
65        documents, cut = self._documents()
66        matches = []
67        for name, content in documents:
68            for number, line in enumerate(content.splitlines(), 1):
69                if term.casefold() in line.casefold():
70                    matches.append(f"{name}:{number}: {line[:500]}")
71                    if len(matches) == limit:
72                        return Rows(matches, truncated=True)
73        return Rows(matches, truncated=cut)
74
75    def search(self, query: str, limit: int = 20) -> Rows:
76        """FTS5 ranks paragraphs, including multiline entries, without a stale index."""
77        if not query.strip() or not 1 <= limit <= 100:
78            raise ValueError("query must be nonempty and 1 <= limit <= 100")
79        import sqlite3  # only search needs it; not worth every kernel's boot
80
81        documents, cut = self._documents()
82        with sqlite3.connect(":memory:") as db:
83            db.execute(
84                "CREATE VIRTUAL TABLE notes USING fts5(path UNINDEXED, line UNINDEXED, body)"
85            )
86            for name, content in documents:
87                for match in re.finditer(
88                    r"(?s)\S.*?(?=\n[ \t]*\n|\s*\Z)", content, re.S
89                ):
90                    line = content.count("\n", 0, match.start()) + 1
91                    db.execute(
92                        "INSERT INTO notes VALUES (?, ?, ?)",
93                        (name, line, match.group()[:100_000]),
94                    )
95            try:
96                rows = db.execute(
97                    "SELECT path, line, snippet(notes, 2, '[', ']', '…', 16) "
98                    "FROM notes WHERE notes MATCH ? ORDER BY rank LIMIT ?",
99                    (query, limit),
100                ).fetchall()
101            except sqlite3.OperationalError as exc:
102                raise ValueError(f"invalid full-text query: {exc}") from exc
103        return Rows(
104            (
105                f"{path}:{line}: {snippet.replace(chr(10), ' ')[:500]}"
106                for path, line, snippet in rows
107            ),
108            truncated=cut,
109        )
110
111
112def setup(api: PythonApi) -> dict[str, object]:
113    if api.host_now is None:
114        raise RuntimeError("this host cannot answer synchronous memory calls")
115    return {"memory": Memory(api.host_now)}