feat(mem0-agent): read path, hooks, maintenance, CLI and skills
- pack.py: single-call context pack, budgeted, sanitized, cached, with served-id tracking that turns a citation into POSITIVE feedback - assist.py: error-signature lookup (never raw stdout, which returned 0 results in v1) - maintain.py: transactional consolidation (add merged -> verify -> delete sources) - transcript.py: JSONL parsing into non-overlapping windows via a cursor - cli.py: the twelve commands the hooks invoke; detached assist queues a block that the next prompt hook drains, keeping the hot path network-free - hooks generated from one spec; plugin manifest, MCP wiring, five skills Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e102b21180
commit
3ea00cf88c
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate editor hook manifests from hooks.spec.yaml.
|
||||
|
||||
v1 kept four hand-written hook manifests (Claude Code, Cursor, Codex, Antigravity).
|
||||
They drifted apart -- events present in one file and missing in another, timeouts that
|
||||
disagreed, and hooks that had been dead in two editors for months without anyone
|
||||
noticing. This script makes the spec the only thing a human edits.
|
||||
|
||||
python3 hooks/generate.py # write manifests for every supported editor
|
||||
python3 hooks/generate.py --check # exit 1 if the committed manifests differ
|
||||
|
||||
The YAML parser here is a deliberately tiny stdlib-only subset (maps, lists of maps,
|
||||
quoted/bare scalars, comments) because the package has zero runtime dependencies and
|
||||
the spec is kept simple enough to parse. The spec is still valid YAML, so an editor's
|
||||
syntax highlighting and any real YAML parser agree with this one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
HOOKS_DIR = Path(__file__).resolve().parent
|
||||
SPEC_PATH = HOOKS_DIR / "hooks.spec.yaml"
|
||||
OUT_DIR = HOOKS_DIR / "generated"
|
||||
|
||||
HOOK_FIELDS = {
|
||||
"id", "event", "matcher", "command", "timeout",
|
||||
"background", "blocking", "local_only", "status_message", "why",
|
||||
}
|
||||
REQUIRED_HOOK_FIELDS = {"id", "event", "command", "why"}
|
||||
# Events that run on the hot path of every turn and therefore may never do network I/O.
|
||||
LOCAL_ONLY_EVENTS = {"UserPromptSubmit"}
|
||||
|
||||
|
||||
class SpecError(RuntimeError):
|
||||
"""The spec is malformed or violates a wiring rule."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- YAML
|
||||
_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.\-]*:")
|
||||
|
||||
|
||||
def _strip_comment(line: str) -> str:
|
||||
"""Drop a trailing `#` comment without touching `#` inside quotes."""
|
||||
out: list[str] = []
|
||||
quote: str | None = None
|
||||
i = 0
|
||||
while i < len(line):
|
||||
ch = line[i]
|
||||
if quote:
|
||||
if ch == "\\" and i + 1 < len(line):
|
||||
out.append(ch)
|
||||
out.append(line[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == quote:
|
||||
quote = None
|
||||
out.append(ch)
|
||||
elif ch in "\"'":
|
||||
quote = ch
|
||||
out.append(ch)
|
||||
elif ch == "#" and (not out or out[-1] in " \t"):
|
||||
break
|
||||
else:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out).rstrip()
|
||||
|
||||
|
||||
def _scalar(token: str) -> Any:
|
||||
t = token.strip()
|
||||
if len(t) >= 2 and t[0] == t[-1] and t[0] in "\"'":
|
||||
return t[1:-1].replace('\\"', '"').replace("\\'", "'")
|
||||
low = t.lower()
|
||||
if low in ("", "null", "~"):
|
||||
return None
|
||||
if low == "true":
|
||||
return True
|
||||
if low == "false":
|
||||
return False
|
||||
try:
|
||||
return int(t)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return float(t)
|
||||
except ValueError:
|
||||
pass
|
||||
return t
|
||||
|
||||
|
||||
def _lines(text: str) -> list[tuple[int, str]]:
|
||||
rows: list[tuple[int, str]] = []
|
||||
for raw in text.splitlines():
|
||||
if "\t" in raw[: len(raw) - len(raw.lstrip())]:
|
||||
raise SpecError("tabs are not allowed for indentation")
|
||||
stripped = _strip_comment(raw)
|
||||
if not stripped.strip():
|
||||
continue
|
||||
rows.append((len(stripped) - len(stripped.lstrip(" ")), stripped.strip()))
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_block(rows: list[tuple[int, str]], i: int, indent: int) -> tuple[Any, int]:
|
||||
if rows[i][1].startswith("- "):
|
||||
return _parse_list(rows, i, indent)
|
||||
return _parse_map(rows, i, indent)
|
||||
|
||||
|
||||
def _parse_map(rows: list[tuple[int, str]], i: int, indent: int) -> tuple[dict, int]:
|
||||
obj: dict[str, Any] = {}
|
||||
while i < len(rows):
|
||||
ind, text = rows[i]
|
||||
if ind < indent:
|
||||
break
|
||||
if ind > indent:
|
||||
raise SpecError(f"unexpected indentation at: {text!r}")
|
||||
if text.startswith("- "):
|
||||
break
|
||||
if not _KEY_RE.match(text):
|
||||
raise SpecError(f"expected `key: value`, got: {text!r}")
|
||||
key, _, rest = text.partition(":")
|
||||
key = key.strip()
|
||||
if rest.strip():
|
||||
obj[key] = _scalar(rest)
|
||||
i += 1
|
||||
continue
|
||||
# Block value: everything indented deeper than this key.
|
||||
if i + 1 < len(rows) and rows[i + 1][0] > ind:
|
||||
obj[key], i = _parse_block(rows, i + 1, rows[i + 1][0])
|
||||
else:
|
||||
obj[key] = None
|
||||
i += 1
|
||||
return obj, i
|
||||
|
||||
|
||||
def _parse_list(rows: list[tuple[int, str]], i: int, indent: int) -> tuple[list, int]:
|
||||
items: list[Any] = []
|
||||
while i < len(rows) and rows[i][0] == indent and rows[i][1].startswith("- "):
|
||||
head = rows[i][1][2:].strip()
|
||||
children: list[tuple[int, str]] = []
|
||||
j = i + 1
|
||||
while j < len(rows) and rows[j][0] > indent:
|
||||
children.append(rows[j])
|
||||
j += 1
|
||||
if _KEY_RE.match(head):
|
||||
sub = [(indent + 2, head), *children]
|
||||
value, _ = _parse_map(sub, 0, indent + 2)
|
||||
items.append(value)
|
||||
else:
|
||||
if children:
|
||||
raise SpecError(f"scalar list item cannot have children: {head!r}")
|
||||
items.append(_scalar(head))
|
||||
i = j
|
||||
return items, i
|
||||
|
||||
|
||||
def parse_yaml(text: str) -> dict:
|
||||
rows = _lines(text)
|
||||
if not rows:
|
||||
return {}
|
||||
value, _ = _parse_block(rows, 0, rows[0][0])
|
||||
if not isinstance(value, dict):
|
||||
raise SpecError("spec must be a mapping at the top level")
|
||||
return value
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- spec
|
||||
def load_spec(path: Path = SPEC_PATH) -> dict:
|
||||
spec = parse_yaml(path.read_text())
|
||||
validate(spec)
|
||||
return spec
|
||||
|
||||
|
||||
def validate(spec: dict) -> dict:
|
||||
if not isinstance(spec.get("editors"), list) or not spec["editors"]:
|
||||
raise SpecError("spec needs a non-empty `editors:` list")
|
||||
if not isinstance(spec.get("hooks"), list) or not spec["hooks"]:
|
||||
raise SpecError("spec needs a non-empty `hooks:` list")
|
||||
|
||||
seen_editors = set()
|
||||
for ed in spec["editors"]:
|
||||
for field in ("id", "supported", "output", "why"):
|
||||
if field not in ed:
|
||||
raise SpecError(f"editor {ed.get('id')!r} is missing `{field}`")
|
||||
if ed["id"] in seen_editors:
|
||||
raise SpecError(f"duplicate editor id {ed['id']!r}")
|
||||
seen_editors.add(ed["id"])
|
||||
if not isinstance(ed["supported"], bool):
|
||||
raise SpecError(f"editor {ed['id']!r}: `supported` must be a boolean")
|
||||
|
||||
seen_hooks = set()
|
||||
for entry in spec["hooks"]:
|
||||
missing = REQUIRED_HOOK_FIELDS - set(entry)
|
||||
if missing:
|
||||
raise SpecError(f"hook {entry.get('id')!r} is missing {sorted(missing)}")
|
||||
unknown = set(entry) - HOOK_FIELDS
|
||||
if unknown:
|
||||
raise SpecError(f"hook {entry['id']!r} has unknown fields {sorted(unknown)}")
|
||||
if entry["id"] in seen_hooks:
|
||||
raise SpecError(f"duplicate hook id {entry['id']!r}")
|
||||
seen_hooks.add(entry["id"])
|
||||
for flag in ("background", "blocking", "local_only"):
|
||||
value = entry.get(flag, spec.get("defaults", {}).get(flag))
|
||||
if not isinstance(value, bool):
|
||||
raise SpecError(f"hook {entry['id']!r}: `{flag}` must be a boolean")
|
||||
if entry.get("background") and entry.get("blocking"):
|
||||
raise SpecError(f"hook {entry['id']!r}: a backgrounded hook cannot also be blocking")
|
||||
if entry["event"] in LOCAL_ONLY_EVENTS and not entry.get("local_only"):
|
||||
raise SpecError(
|
||||
f"hook {entry['id']!r} runs on {entry['event']} and must declare local_only: true"
|
||||
)
|
||||
if not str(entry["command"]).startswith("mem0-agent "):
|
||||
raise SpecError(f"hook {entry['id']!r}: command must invoke the mem0-agent CLI")
|
||||
return spec
|
||||
|
||||
|
||||
def editor(spec: dict, editor_id: str) -> dict:
|
||||
for ed in spec["editors"]:
|
||||
if ed["id"] == editor_id:
|
||||
return ed
|
||||
raise SpecError(f"unknown editor {editor_id!r}")
|
||||
|
||||
|
||||
def supported_editors(spec: dict) -> list[dict]:
|
||||
return [ed for ed in spec["editors"] if ed["supported"]]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- rendering
|
||||
def render_command(entry: dict, ed: dict, defaults: dict | None = None) -> str:
|
||||
"""Spec command -> the exact shell string an editor will run.
|
||||
|
||||
Placeholders are editor-specific, MEM0_EDITOR is always pinned (so a hook cannot
|
||||
be misattributed), MEM0_LOCAL_ONLY is the machine-enforced half of the local-only
|
||||
contract, and background hooks are detached so a network write can never be on the
|
||||
developer's critical path.
|
||||
"""
|
||||
defaults = defaults or {}
|
||||
command = str(entry["command"])
|
||||
for name, value in (ed.get("placeholders") or {}).items():
|
||||
command = command.replace("{" + name + "}", f'"{value}"')
|
||||
left = re.search(r"\{[a-z_]+\}", command)
|
||||
if left:
|
||||
raise SpecError(f"hook {entry['id']!r}: editor {ed['id']!r} has no value for {left.group()}")
|
||||
|
||||
env = dict(ed.get("env") or {})
|
||||
if entry.get("local_only", defaults.get("local_only", False)):
|
||||
env["MEM0_LOCAL_ONLY"] = "1"
|
||||
prefix = "".join(f"{k}={v} " for k, v in env.items())
|
||||
command = prefix + command
|
||||
if entry.get("background", defaults.get("background", False)):
|
||||
command = f"({command} >/dev/null 2>&1 &)"
|
||||
return command
|
||||
|
||||
|
||||
def build_manifest(spec: dict, editor_id: str = "claude-code") -> dict:
|
||||
"""Emit the nested schema Claude Code expects.
|
||||
|
||||
{"hooks": {"<Event>": [{"matcher": ..., "hooks": [{"type": "command", ...}]}]}}
|
||||
Entries sharing an event and matcher are merged into one group, in spec order.
|
||||
"""
|
||||
ed = editor(spec, editor_id)
|
||||
if ed.get("dialect") != "claude-code":
|
||||
raise SpecError(f"editor {editor_id!r} uses dialect {ed.get('dialect')!r}, not yet emitted")
|
||||
defaults = spec.get("defaults") or {}
|
||||
|
||||
events: dict[str, list[dict]] = {}
|
||||
for entry in spec["hooks"]:
|
||||
step: dict[str, Any] = {
|
||||
"type": "command",
|
||||
"command": render_command(entry, ed, defaults),
|
||||
"timeout": int(entry.get("timeout") or defaults.get("timeout") or 10),
|
||||
}
|
||||
if entry.get("status_message"):
|
||||
step["statusMessage"] = entry["status_message"]
|
||||
|
||||
groups = events.setdefault(entry["event"], [])
|
||||
matcher = entry.get("matcher")
|
||||
for group in groups:
|
||||
if group.get("matcher") == matcher:
|
||||
group["hooks"].append(step)
|
||||
break
|
||||
else:
|
||||
group = {}
|
||||
if matcher:
|
||||
group["matcher"] = matcher
|
||||
group["hooks"] = [step]
|
||||
groups.append(group)
|
||||
return {"hooks": events}
|
||||
|
||||
|
||||
def serialize(manifest: dict) -> str:
|
||||
return json.dumps(manifest, indent=2) + "\n"
|
||||
|
||||
|
||||
def output_path(spec: dict, editor_id: str) -> Path:
|
||||
return (HOOKS_DIR / editor(spec, editor_id)["output"]).resolve()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- CLI
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description="Generate editor hook manifests from hooks.spec.yaml")
|
||||
ap.add_argument("--check", action="store_true",
|
||||
help="regenerate in memory and exit non-zero if the committed manifest differs")
|
||||
ap.add_argument("--editor", action="append", default=None,
|
||||
help="limit to one editor id (default: every supported editor)")
|
||||
ap.add_argument("--spec", type=Path, default=SPEC_PATH)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
try:
|
||||
spec = load_spec(args.spec)
|
||||
except (SpecError, OSError) as exc:
|
||||
print(f"spec error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
ids = args.editor or [ed["id"] for ed in supported_editors(spec)]
|
||||
drift = 0
|
||||
for editor_id in ids:
|
||||
try:
|
||||
manifest = build_manifest(spec, editor_id)
|
||||
except SpecError as exc:
|
||||
print(f"{editor_id}: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
wanted = serialize(manifest)
|
||||
path = output_path(spec, editor_id)
|
||||
events = ", ".join(manifest["hooks"])
|
||||
if args.check:
|
||||
current = path.read_text() if path.exists() else None
|
||||
if current != wanted:
|
||||
what = "missing" if current is None else "out of date"
|
||||
print(f"DRIFT {path.name} is {what}; run `python3 hooks/generate.py`", file=sys.stderr)
|
||||
drift += 1
|
||||
else:
|
||||
print(f"ok {path.name} ({len(manifest['hooks'])} events: {events})")
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(wanted)
|
||||
print(f"wrote {path} ({len(manifest['hooks'])} events: {events})")
|
||||
return 1 if drift else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume|compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "MEM0_EDITOR=claude-code mem0-agent context --session-id \"$CLAUDE_SESSION_ID\"",
|
||||
"timeout": 10,
|
||||
"statusMessage": "Loading mem0 context..."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "MEM0_EDITOR=claude-code MEM0_LOCAL_ONLY=1 mem0-agent observe --source prompt",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "(MEM0_EDITOR=claude-code mem0-agent assist-error >/dev/null 2>&1 &)",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "(MEM0_EDITOR=claude-code mem0-agent flush >/dev/null 2>&1 &)",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "(MEM0_EDITOR=claude-code mem0-agent flush --reason precompact >/dev/null 2>&1 &)",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "(MEM0_EDITOR=claude-code mem0-agent flush --reason end >/dev/null 2>&1 &)",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
# mem0-agent hook wiring -- the single source of truth.
|
||||
#
|
||||
# v1 hand-maintained four editor dialects (Claude Code, Cursor, Codex, Antigravity).
|
||||
# They drifted: an event existed in one file and not another, timeouts disagreed, and
|
||||
# a hook was silently non-functional in two editors for months. Nothing here is edited
|
||||
# per editor. Run `python3 hooks/generate.py` to emit the manifests into hooks/generated/,
|
||||
# and `python3 hooks/generate.py --check` in CI to fail the build when they drift.
|
||||
#
|
||||
# Field reference for `hooks:` entries
|
||||
# event editor lifecycle event the hook binds to
|
||||
# matcher regex the editor matches against the event subject (null = all)
|
||||
# command shell command; {placeholders} are filled per editor
|
||||
# timeout seconds the editor waits before killing the hook
|
||||
# background true = detach and return immediately; output is discarded
|
||||
# blocking true = the editor waits for stdout and may inject it into the session
|
||||
# local_only true = the hook MUST NOT touch the network (enforced via MEM0_LOCAL_ONLY=1)
|
||||
# status_message spinner text while a blocking hook runs (omit for silent hooks)
|
||||
# why one line: why this hook exists at all
|
||||
|
||||
version: 1
|
||||
policy: "v2"
|
||||
|
||||
defaults:
|
||||
timeout: 10
|
||||
background: false
|
||||
blocking: false
|
||||
local_only: false
|
||||
|
||||
editors:
|
||||
- id: claude-code
|
||||
supported: true
|
||||
output: "generated/claude-code.hooks.json"
|
||||
dialect: "claude-code"
|
||||
env:
|
||||
MEM0_EDITOR: "claude-code"
|
||||
placeholders:
|
||||
session_id: "$CLAUDE_SESSION_ID"
|
||||
why: "Reference target. Nested schema: hooks -> Event -> [{matcher, hooks:[{type,command,timeout}]}]."
|
||||
|
||||
- id: cursor
|
||||
supported: false
|
||||
output: "generated/cursor.hooks.json"
|
||||
dialect: "flat-camel"
|
||||
env:
|
||||
MEM0_EDITOR: "cursor"
|
||||
placeholders:
|
||||
session_id: "$CURSOR_SESSION_ID"
|
||||
why: "Flat camelCase events (sessionStart) with no inner hooks array, and no SessionEnd/PreCompact equivalent. Declared so the gap is visible; unsupported until the dialect is verified end to end."
|
||||
|
||||
- id: codex
|
||||
supported: false
|
||||
output: "generated/codex.hooks.json"
|
||||
dialect: "claude-code"
|
||||
env:
|
||||
MEM0_EDITOR: "codex"
|
||||
placeholders:
|
||||
session_id: "$CODEX_SESSION_ID"
|
||||
why: "Same nested schema as Claude Code but hooks are installed into ~/.codex by a script rather than discovered from the package. Unsupported until that installer is rewritten for v2."
|
||||
|
||||
- id: antigravity
|
||||
supported: false
|
||||
output: "generated/antigravity.hooks.json"
|
||||
dialect: "claude-code"
|
||||
env:
|
||||
MEM0_EDITOR: "antigravity"
|
||||
placeholders:
|
||||
session_id: "$ANTIGRAVITY_SESSION_ID"
|
||||
why: "Ships no stable hook contract yet. Declared so it is never re-added as a hand-written fifth file."
|
||||
|
||||
hooks:
|
||||
- id: session-context
|
||||
event: "SessionStart"
|
||||
matcher: "startup|resume|compact"
|
||||
command: "mem0-agent context --session-id {session_id}"
|
||||
timeout: 10
|
||||
background: false
|
||||
blocking: true
|
||||
local_only: false
|
||||
status_message: "Loading mem0 context..."
|
||||
why: "The one retrieval per session. Injects durable project knowledge plus the open-thread snapshot so the assistant starts where the last session ended."
|
||||
|
||||
- id: prompt-observe
|
||||
event: "UserPromptSubmit"
|
||||
matcher: null
|
||||
command: "mem0-agent observe --source prompt"
|
||||
timeout: 3
|
||||
background: false
|
||||
blocking: true
|
||||
local_only: true
|
||||
status_message: null
|
||||
why: "Runs on the hot path of every keystroke-to-response, so it is local-only by contract: it scores the prompt for capture triggers and drains the pending assist buffer. v1 made a network search here and added latency to every single turn."
|
||||
|
||||
- id: bash-assist-error
|
||||
event: "PostToolUse"
|
||||
matcher: "Bash"
|
||||
command: "mem0-agent assist-error"
|
||||
timeout: 5
|
||||
background: true
|
||||
blocking: false
|
||||
local_only: false
|
||||
status_message: null
|
||||
why: "A failing command is the highest-value retrieval moment. Detached so a lookup never stalls the tool loop; any hit is written to the session buffer and surfaced by the next UserPromptSubmit."
|
||||
|
||||
- id: stop-flush
|
||||
event: "Stop"
|
||||
matcher: null
|
||||
command: "mem0-agent flush"
|
||||
timeout: 5
|
||||
background: true
|
||||
blocking: false
|
||||
local_only: false
|
||||
status_message: null
|
||||
why: "Turn boundary is the only place a candidate can be judged against how the turn actually ended. Batched so one turn costs at most one write."
|
||||
|
||||
- id: precompact-flush
|
||||
event: "PreCompact"
|
||||
matcher: null
|
||||
command: "mem0-agent flush --reason precompact"
|
||||
timeout: 5
|
||||
background: true
|
||||
blocking: false
|
||||
local_only: false
|
||||
status_message: null
|
||||
why: "Compaction is where context dies. Flush the candidate buffer before it is summarized away."
|
||||
|
||||
- id: sessionend-flush
|
||||
event: "SessionEnd"
|
||||
matcher: null
|
||||
command: "mem0-agent flush --reason end"
|
||||
timeout: 5
|
||||
background: true
|
||||
blocking: false
|
||||
local_only: false
|
||||
status_message: null
|
||||
why: "Last chance to persist the open-thread snapshot. Backgrounded so quitting the editor is never delayed by a network write."
|
||||
Reference in New Issue
Block a user