Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion hooks.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
# no hooks.json here and nothing generates one; tools/generate.py refuses this host by
# name for exactly that reason.
repo=memvara/memvara
sha=026be5cfd815419fa4c7eda2cb0ada109ea22cab
sha=6cd83bf6abb64a4f4a6140df4baf24d82102e76d
path=plugin/hooks
host=opencode
39 changes: 31 additions & 8 deletions hooks/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,15 +253,31 @@ def run(self) -> int:
server.bind(self.path)
except OSError:
return 0
# The file's identity, read straight after `bind()`, so that `_release` removes
# only this daemon's socket. It is read from the path because `fstat` on a socket
# does not describe the socket's file. A second daemon needs a failed `bind()`, an
# import and a refused probe before it can replace the file, which is far longer
# than the one call between `bind()` and this one. A file that is already gone
# belongs to that other daemon now, so this one leaves.
try:
os.chmod(self.path, 0o600)
ours = os.stat(self.path)
except OSError:
pass
self._sweep_stale()

server.close()
return 0
# Listen at once. A second daemon whose `bind()` fails probes this path, and takes
# a refused probe to mean the owner is dead: a socket that is bound and not yet
# listening refuses too, so listening only after the sweep below let a loser
# unlink this path and bind its own, leaving this daemon on a socket nothing
# could reach (#344). The socket is private before `chmod` as well, because its
# directory is 0700 (`lib.ipc.runtime_dir`).
server.listen(16)
server.settimeout(30.0)
try:
try:
os.chmod(self.path, 0o600)
except OSError:
pass
self._sweep_stale()
server.settimeout(30.0)
while True:
try:
conn, _ = server.accept()
Expand All @@ -280,10 +296,17 @@ def run(self) -> int:
threading.Thread(target=self._serve, args=(conn,), daemon=True).start()
finally:
server.close()
try:
self._release(ours)

def _release(self, ours: os.stat_result) -> None:
"""Remove the socket file, if the path still names this daemon's socket. Another
daemon may have replaced it, and removing that daemon's socket would leave it
serving an address no client can reach."""
try:
if os.path.samestat(os.stat(self.path), ours):
os.unlink(self.path)
except OSError:
pass
except OSError:
pass


def main() -> int:
Expand Down
45 changes: 45 additions & 0 deletions hooks/lib/deadline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""One deadline for the whole hook process, which every hosted call respects.

The host stops a hook at its time limit: 10 seconds for recall and 20 for session start on
every host. The hosted client waits up to `hosted.TIMEOUT_SEC` for each request and retries
a request that got no answer once, and one hook makes several calls, so an endpoint that
accepted connections and never answered kept session start for about 60 seconds and recall
for about 36 (#345). The host killed the hook first, so the turn got no memories and not
even the status line that would have said why.

A reading hook sets the deadline once: `MARGIN_SEC` seconds before its host's limit, which
leaves that long for writing its reply. Every hosted call then waits at most the time left, and none is started or retried
once it is spent. A process that sets no deadline, such as the daemon or the capture hook,
waits as it always has.

Only `time` is imported, because recall imports this on every prompt.
"""

from __future__ import annotations

import time

#: What a hook keeps back from its host's limit, for the work after its last hosted call:
#: writing the reply and its log lines. The interpreter's start is inside the host's limit
#: too, before the hook can set anything.
MARGIN_SEC = 1.5

_at: "float | None" = None


def set_from_limit(limit: float) -> None:
"""Set the deadline to `MARGIN_SEC` seconds before a limit of `limit` seconds from
now. With a 10-second limit, the deadline is 8.5 seconds from now."""
global _at
_at = time.monotonic() + max(0.0, limit - MARGIN_SEC)


def left() -> "float | None":
"""Seconds until the deadline, which may be 0 or less, or None when none is set."""
return None if _at is None else _at - time.monotonic()


def clear() -> None:
"""Remove the deadline."""
global _at
_at = None
31 changes: 28 additions & 3 deletions hooks/lib/fast.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
import sys
import time

from . import deadline
from .ipc import CLIENT_TIMEOUT_SEC, log_line, send, socket_path, store_key

#: Set in a spawned daemon's environment so a daemon can never spawn a daemon.
Expand Down Expand Up @@ -197,6 +198,11 @@ def _spawn(root: str) -> None:
DAILY = "daily"


#: The reason token for a local store that is configured and could not open, followed by
#: the exception's class after a colon, such as `open:DatabaseError`.
OPEN = "open"


def _reason(exc: "BaseException") -> str:
"""The short token for a failure, or `""` when there is nothing useful to add.

Expand Down Expand Up @@ -254,7 +260,9 @@ def recall(query: str, *, k: int = 6, budget: int = 700, header: str | None = No

The third slot is `reason`: `""` when there is nothing to add, else a short token the
caller can turn into words: `"quota"` for a spent monthly allowance and `"daily"` for a
spent daily one, each with its reset after a colon when the refusal said. It exists
spent daily one, each with its reset after a colon when the refusal said, and
`"open:<exception class>"` for a local store that is configured and could not open,
which comes with `ok=False` where "nothing configured" comes with `ok=None`. It exists
because `False` alone sent a user to read a log about a store that was answering
perfectly and telling him, in the body of a 402, exactly which allowance was spent and
when it resets.
Expand All @@ -276,6 +284,16 @@ def recall(query: str, *, k: int = 6, budget: int = 700, header: str | None = No
except Exception:
path = None

# The hook's deadline bounds the daemon's wait as it bounds a hosted call's (see
# `lib.deadline`): a hook that used most of its time on earlier calls must not spend
# this wait on top, and one that used all of it goes straight to the fallbacks below.
left = deadline.left()
if left is not None and left <= 0:
path = None
if left is not None:
# The same for the in-process rewrite below, which waits `rewrite_wait` for a model.
rewrite_wait = min(rewrite_wait, max(left, 0.0))

if path is not None:
request = {"q": query, "k": k, "budget": budget}
if min_score:
Expand All @@ -294,6 +312,8 @@ def recall(query: str, *, k: int = 6, budget: int = 700, header: str | None = No
# plain read.
request["query_rewrite"] = True
wait = rewrite_wait if query_rewrite else CLIENT_TIMEOUT_SEC
if left is not None:
wait = min(wait, left)
began = _clock()
answer = send(path, request, timeout=wait)
served = _served(answer)
Expand All @@ -318,8 +338,13 @@ def recall(query: str, *, k: int = 6, budget: int = 700, header: str | None = No

client = open_hosted()
if client is None:
# Nothing is configured at all -- no local database, no library to read one
# with, and no credentials file. Distinct from a store that would not answer.
# No hosted store either. That is "nothing configured" -- no local database, no
# library to read one with, and no credentials file -- only when the local
# store was not configured, rather than configured and unable to open (#337).
from . import open as opener # noqa: PLC0415 - already imported by _local_store

if opener.failure is not None:
return "", False, f"{OPEN}:{type(opener.failure).__name__}"
return "", None, ""
try:
# No `query_rewrite` here, whatever the caller asked: the hosted client always
Expand Down
18 changes: 18 additions & 0 deletions hooks/lib/hosted.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import os.path
import ssl

from . import deadline
from .ipc import log_line
from .project import ENV as PROJECT_ENV

Expand Down Expand Up @@ -243,9 +244,26 @@ def _rpc(self, method: str, params: "dict | None" = None,
if project:
headers[PROJECT_HEADER] = project

# The hook's deadline, when one is set (see `lib.deadline`): no call starts once it
# has passed, and none waits longer than the time left. A call not made is the
# same as a call not answered, so the caller reports the store as not answering.
left = deadline.left()
if left is not None and left <= 0:
return None

try:
if self._conn is None:
self._conn = self._connect()
if left is not None:
# A kept-alive connection has the timeout it was made with, and the time
# left is shorter now. Only with a deadline: otherwise the timeout is the
# one the connection already has, and setting it again costs a system call
# on every request of a daemon that lives for half an hour.
wait = min(TIMEOUT_SEC, left)
self._conn.timeout = wait
sock = getattr(self._conn, "sock", None)
if sock is not None:
sock.settimeout(wait)
self._conn.request("POST", MCP_PATH, json.dumps(body), headers)
response = self._conn.getresponse()
raw = response.read()
Expand Down
15 changes: 13 additions & 2 deletions hooks/lib/open.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@
#: Written by `memvara-mcp login`, read when there is no local store to open.
_CREDENTIALS = Path.home() / ".memvara" / "credentials.json"

#: Why the last `open_store` call could not open the local store it was configured to
#: open, or None. `open_store` still answers None then, as it does when nothing is
#: configured, so the hooks read this to tell the two apart: a store that is configured
#: and cannot open is a failure, and reporting it as "not configured" sent a person
#: looking for configuration that was there (#337).
failure: "BaseException | None" = None


def _import_memvara(env: Mapping[str, str]) -> Any:
"""Import the library, honouring a PYTHONPATH that only the server block knows.
Expand Down Expand Up @@ -57,6 +64,8 @@ def open_store() -> Any | None:
for a second client to be better at. It briefly took a `recalls` flag, when the MCP
surface could not carry `sources=` and the library's client could.
"""
global failure
failure = None
# The client's block loses to a real environment variable; `client_env` is where that
# rule is written, for this function, the daemon's address and the rewrite decision.
env = client_env()
Expand Down Expand Up @@ -112,8 +121,10 @@ def open_store() -> Any | None:
# degrades to the route that works instead of to a silent outage.
return None
return build_memvara(config)
except Exception:
except Exception as exc:
# Deliberately bare. ConfigError, ImportError, EmbedderMismatchError, a corrupt
# SQLite file and a revoked API key are all the same event from here: no memory
# this turn.
# this turn. Kept in `failure`, because it is not the same event as having no
# store configured, and the hooks say which it was.
failure = exc
return None
72 changes: 72 additions & 0 deletions hooks/lib/standing.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@

from __future__ import annotations

import hashlib
import os
import re
import unicodedata
from typing import Any, Callable, NamedTuple

from . import state_file
from .mark import marked
from .mark import on as mark_on
from .mark import unmark_block

#: A row of the "Believed now, not believed then" half of a `memory_since` reply. The other
#: half is claims the store STOPPED believing, and parsing it into the standing set would
Expand Down Expand Up @@ -374,3 +378,71 @@ def standing_block(store: Any, *, hosted: bool, budget: int, header: str,
return str(fallback() or "")
except Exception:
return ""


# -- what the session has already seen ----------------------------------------------------

def seen_dir() -> str:
"""Where the recall hook keeps each session's state, `recall.SEEN_DIR`.

Worked out when called rather than at import, so that session start writes under the
home directory it runs with.
"""
return os.path.join(os.path.expanduser("~"), ".memvara", ".hooks", "recalled")


def state_path(directory: str, session: str) -> "str | None":
"""The state file of `session` under `directory`, or None for an id that cannot name
one. A NUL byte makes every `os` call raise `ValueError`, not the `OSError` the state
functions are written to absorb, so such an id gets no state file at all."""
if not session or "/" in session or "\0" in session or session in (".", ".."):
return None
return os.path.join(directory, f"{session}.json")


def fingerprint(text: str) -> str:
"""A short hash of `text` with its whitespace collapsed. Recall's dedup hashes each
memory line with it, and `digest` hashes the standing block with it."""
return hashlib.sha256(" ".join(text.split()).encode("utf-8")).hexdigest()[:16]


def digest(block: str) -> str:
"""The digest the recall hook compares to tell whether the standing block changed.

Taken over the block without the recall mark. The mark is presentation, and hashing it
made every running session report "standing preferences updated" once after an upgrade
and again each time the `recall_mark` switch changed.
"""
return fingerprint(unmark_block(block))


def normalised(data: object) -> dict:
"""A session's state file contents as a dict. A bare list is the older format of the
file, which held only the seen hashes."""
if isinstance(data, list):
return {"seen": [h for h in data if isinstance(h, str)]}
return data if isinstance(data, dict) else {}


def record_injected(session: str, block: str, now: float,
directory: "str | None" = None) -> None:
"""Record in `session`'s recall state that `block` was injected at `now`.

Session start calls this after it injects the standing block. The recall hook checks
the block again once `recall.STANDING_REFRESH_SECONDS` have passed since the time
recorded here, and injects it only when its digest differs. With nothing recorded, the
first prompt of every session found the check due and the digest different, and
injected the whole block a second time under "standing preferences updated" (#343).
Every other key in the state file is kept. Nothing here raises: a failure costs one
repeated block, which is less than a failed session start.
"""
directory = directory or seen_dir()
path = state_path(directory, session)
if path is None:
return

def change(raw: object) -> dict:
return {**normalised(raw), "standing": digest(block), "standing_at": now}

state_file.update_json(path, change, lock_path=os.path.join(directory, ".lock"),
prefix=".recalled-")
10 changes: 6 additions & 4 deletions hooks/lib/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,12 @@ def remember_kwargs(memory_type: "str | None", turn: str, hosted: bool,
"""
kwargs: dict = {"confidence": 0.7}
if memory_type:
# The hosted tool takes the type's name. The local library takes its enum, and a
# plain string fails there with `AttributeError: 'str' object has no attribute
# 'value'` when the claim is stored. Every fact this hook wrote to a local store
# failed that way, and capture.log recorded each one under `failed=`.
# The hosted tool takes the type's name. The local library takes its enum, and
# before #270 a plain string failed there with `AttributeError: 'str' object has
# no attribute 'value'` when the claim was stored, so every fact this hook wrote
# to a local store failed and capture.log recorded each one under `failed=`. The
# library converts a name itself now; converting here as well keeps the hook
# working with an installed library older than that fix.
kwargs["memory_type"] = memory_type if hosted else _memory_type(memory_type)
# The label the host writes under. It is stored on every claim and rendered back
# by `memory_why`, so it is a fact about recorded history rather than a string to
Expand Down
Loading
Loading