2026-06-21 11:46:54 -05:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""echo.py — the single validated, cross-platform client for the ECHO Obsidian
|
|
|
|
|
Local REST API.
|
|
|
|
|
|
|
|
|
|
Replaces the original echo.sh so the toolchain runs identically on Windows,
|
|
|
|
|
macOS, and Linux with no bash / GNU-vs-BSD `date` dependency — only a Python 3
|
|
|
|
|
interpreter (which the linter already requires).
|
|
|
|
|
|
|
|
|
|
Every read/write to the vault should go through this script. It centralizes:
|
|
|
|
|
auth injection, HTTP-status checking (non-zero exit on >=400 so a failed write
|
|
|
|
|
can never look like success), one bounded retry on 5xx/connection errors,
|
|
|
|
|
WHOLE-LINE idempotent append (a new line that is merely a substring of an
|
|
|
|
|
existing one is no longer wrongly skipped), correct `::` heading-target handling,
|
|
|
|
|
frontmatter field patches, a read-back-confirmed advisory multi-writer lock, a
|
|
|
|
|
one-call cold-start `load`, and scope show/set.
|
|
|
|
|
|
2026-06-22 23:55:19 -05:00
|
|
|
Network is keep-alive and connection-pooled (one persistent connection per thread,
|
|
|
|
|
reused across requests), and `read_many` fans bulk GETs across a thread pool — so
|
|
|
|
|
full-vault scripts (sweep, lint, recall rebuild) make a handful of warm round-trips
|
|
|
|
|
instead of hundreds of fresh TLS handshakes, and no longer blow past tool timeouts.
|
|
|
|
|
|
2026-06-23 22:17:39 -05:00
|
|
|
Config (owner/endpoint/key are machine-local — see echo_config; the plugin ships none):
|
|
|
|
|
owner/endpoint/key resolved by echo_config from ~/.claude/echo-memory/config.json
|
|
|
|
|
(env ECHO_OWNER / ECHO_BASE / ECHO_KEY override per field).
|
|
|
|
|
Set up a machine with `echo.py config init` then edit, or
|
|
|
|
|
`echo.py config set --owner ... --endpoint ... --key ...`.
|
2026-06-21 11:46:54 -05:00
|
|
|
ECHO_VERIFY default 1 — read-back verify after a PUT
|
|
|
|
|
ECHO_LOCK_TTL default 900 — seconds before an advisory lock is considered stale
|
2026-06-22 23:55:19 -05:00
|
|
|
ECHO_TIMEOUT default 30 — per-request socket timeout (seconds)
|
|
|
|
|
ECHO_WORKERS default 8 — concurrency for read_many bulk reads
|
2026-06-21 11:46:54 -05:00
|
|
|
ECHO_TODAY YYYY-MM-DD to use as "today" (pass the conversation's currentDate)
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
echo.py load # cold-start: the 6 orientation reads in one call
|
|
|
|
|
echo.py get <path> # print file contents (404 -> exit 44)
|
|
|
|
|
echo.py map <path> # document-map JSON (headings/blocks/frontmatter)
|
|
|
|
|
echo.py ls <dir> # directory listing JSON
|
|
|
|
|
echo.py search <query...> # /search/simple
|
|
|
|
|
echo.py put <path> [file] # create/overwrite (body from file or stdin)
|
|
|
|
|
echo.py post <path> [file] # raw append (NON-idempotent; prefer `append`)
|
|
|
|
|
echo.py append <path> <line> # idempotent append: skips only on an exact whole-line match
|
|
|
|
|
echo.py patch <path> <append|prepend|replace> <heading|frontmatter|block> <target> [file]
|
2026-07-03 12:57:52 -05:00
|
|
|
echo.py fm <path> <field> <json-value> # create-or-replace a frontmatter scalar
|
2026-06-21 11:46:54 -05:00
|
|
|
echo.py bump <path> [YYYY-MM-DD] # set frontmatter updated: to today (or given date)
|
2026-07-03 12:57:52 -05:00
|
|
|
echo.py triage [--list --json | <proposals.json> [--apply]] # one-tap inbox triage + audit log
|
2026-06-21 11:46:54 -05:00
|
|
|
echo.py delete <path> # DELETE (destructive; explicit use only)
|
|
|
|
|
echo.py lock <owner-id> # acquire advisory lock (exit 75 if held & fresh, or lost the race)
|
|
|
|
|
echo.py unlock <owner-id> # release advisory lock if owned by <owner-id>
|
|
|
|
|
echo.py scope show # print active scope, its freshness, and sessions-since
|
|
|
|
|
echo.py scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated)
|
2026-06-23 22:17:39 -05:00
|
|
|
echo.py config [show|init|set|import] # machine-local owner/endpoint/key (config import <file> adopts a provided file)
|
2026-06-21 11:46:54 -05:00
|
|
|
|
2026-07-03 12:57:52 -05:00
|
|
|
Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 76 duplicate-gate (capture) ·
|
|
|
|
|
78 config-required · 2 usage · 1 other HTTP/transport error.
|
2026-06-21 11:46:54 -05:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import datetime as dt
|
2026-06-22 23:55:19 -05:00
|
|
|
import http.client
|
2026-06-21 11:46:54 -05:00
|
|
|
import json
|
|
|
|
|
import locale
|
|
|
|
|
import os
|
2026-07-03 12:57:52 -05:00
|
|
|
import re
|
2026-06-21 11:46:54 -05:00
|
|
|
import sys
|
|
|
|
|
import tempfile
|
2026-06-22 23:55:19 -05:00
|
|
|
import threading
|
2026-06-21 11:46:54 -05:00
|
|
|
import time
|
|
|
|
|
import urllib.parse
|
2026-06-22 23:55:19 -05:00
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
2026-06-21 11:46:54 -05:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
# Never let a non-ASCII byte (em-dash in our own output, or Unicode in a fetched
|
|
|
|
|
# note) raise UnicodeEncodeError on a legacy Windows console.
|
|
|
|
|
for _stream in (sys.stdout, sys.stderr):
|
|
|
|
|
try:
|
|
|
|
|
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
2026-06-23 22:17:39 -05:00
|
|
|
# Owner / endpoint / key are machine-local and resolved by echo_config from
|
|
|
|
|
# ~/.claude/echo-memory/config.json (env ECHO_OWNER / ECHO_BASE / ECHO_KEY override).
|
|
|
|
|
# The plugin ships none of them; load() returns "" for anything unset so --help,
|
|
|
|
|
# `config`, and `doctor` stay usable. Network ops raise a clear error if endpoint/key
|
|
|
|
|
# are missing (see _require_endpoint / request).
|
2026-06-22 09:27:36 -05:00
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
2026-06-23 22:17:39 -05:00
|
|
|
import echo_config # noqa: E402
|
|
|
|
|
_CFG = echo_config.load()
|
|
|
|
|
BASE = _CFG["endpoint"]
|
|
|
|
|
KEY = _CFG["key"]
|
|
|
|
|
OWNER = _CFG["owner"]
|
2026-06-21 11:46:54 -05:00
|
|
|
VERIFY = os.environ.get("ECHO_VERIFY", "1") != "0"
|
|
|
|
|
LOCK_TTL = int(os.environ.get("ECHO_LOCK_TTL", "900"))
|
2026-06-22 23:55:19 -05:00
|
|
|
# Per-request socket timeout. A single stuck file fails fast instead of stalling a
|
|
|
|
|
# full-vault pass; with read_many concurrency it only blocks one worker, not the run.
|
|
|
|
|
TIMEOUT = int(os.environ.get("ECHO_TIMEOUT", "30"))
|
|
|
|
|
# Concurrency for bulk reads (read_many). I/O-bound, so threads bypass the GIL fine.
|
|
|
|
|
MAX_WORKERS = max(1, int(os.environ.get("ECHO_WORKERS", "8")))
|
2026-06-21 11:46:54 -05:00
|
|
|
|
|
|
|
|
LOCK_PATH = "_agent/locks/vault.lock"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EchoError(RuntimeError):
|
|
|
|
|
def __init__(self, message: str, code: int = 1) -> None:
|
|
|
|
|
super().__init__(message)
|
|
|
|
|
self.code = code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def today() -> str:
|
|
|
|
|
return os.environ.get("ECHO_TODAY") or dt.date.today().isoformat()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def now_iso() -> str:
|
|
|
|
|
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def vault_url(path: str) -> str:
|
|
|
|
|
safe = "/".join(urllib.parse.quote(part) for part in path.split("/"))
|
|
|
|
|
if path.endswith("/") and not safe.endswith("/"):
|
|
|
|
|
safe += "/"
|
|
|
|
|
return f"{BASE}/vault/{safe}"
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 23:55:19 -05:00
|
|
|
# --- persistent connection pool (keep-alive) ---------------------------------
|
|
|
|
|
# urllib.urlopen opened a fresh TCP+TLS connection per call; against a remote HTTPS
|
|
|
|
|
# endpoint the repeated handshake dominated, so a full-vault pass made hundreds of
|
|
|
|
|
# serial handshakes and blew past the tool/sandbox timeout. We keep ONE connection
|
|
|
|
|
# per thread (http.client is not thread-safe, so each thread — main + every pool
|
|
|
|
|
# worker — owns its own) and reuse it across requests. read_many fans out over a
|
|
|
|
|
# thread pool, so the pool's connections stay warm for the duration of a sweep.
|
|
|
|
|
_local = threading.local()
|
|
|
|
|
MAX_ATTEMPTS = 3
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 22:17:39 -05:00
|
|
|
def _require_config() -> None:
|
|
|
|
|
"""Fail loudly (not with an opaque DNS/connection error) when this machine has no
|
|
|
|
|
usable key file yet — the most common fresh-install state. Also catches an
|
|
|
|
|
untouched `config init` template (placeholder endpoint/key)."""
|
|
|
|
|
if not echo_config.is_configured(_CFG):
|
|
|
|
|
raise EchoError(
|
|
|
|
|
"echo: NOT CONFIGURED — no usable ECHO key file on this machine. Ask the "
|
|
|
|
|
f"operator for their config (owner/endpoint/key) and install it at "
|
|
|
|
|
f"{echo_config.config_path()}: `echo.py config import <file>`, or "
|
|
|
|
|
"`echo.py config set --owner ... --endpoint ... --key ...` "
|
|
|
|
|
"(or set ECHO_BASE / ECHO_KEY).", 2)
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 23:55:19 -05:00
|
|
|
def _new_connection() -> http.client.HTTPConnection:
|
|
|
|
|
parts = urllib.parse.urlsplit(BASE)
|
|
|
|
|
host = parts.hostname or parts.path
|
|
|
|
|
if parts.scheme == "http":
|
|
|
|
|
return http.client.HTTPConnection(host, parts.port or 80, timeout=TIMEOUT)
|
|
|
|
|
return http.client.HTTPSConnection(host, parts.port or 443, timeout=TIMEOUT)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _connection() -> http.client.HTTPConnection:
|
|
|
|
|
conn = getattr(_local, "conn", None)
|
|
|
|
|
if conn is None:
|
|
|
|
|
conn = _new_connection()
|
|
|
|
|
_local.conn = conn
|
|
|
|
|
return conn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _drop_connection() -> None:
|
|
|
|
|
conn = getattr(_local, "conn", None)
|
|
|
|
|
if conn is not None:
|
|
|
|
|
try:
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
_local.conn = None
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 11:46:54 -05:00
|
|
|
def request(method: str, url: str, data: bytes | None = None,
|
|
|
|
|
headers: dict[str, str] | None = None) -> tuple[int, bytes]:
|
2026-06-22 23:55:19 -05:00
|
|
|
"""Issue one request over this thread's reused connection. Returns (status, body);
|
|
|
|
|
status 0 == transport failure (echo_queue relies on this to trigger offline queueing).
|
|
|
|
|
Retries: a stale pooled connection reconnects immediately; 5xx and other transport
|
|
|
|
|
errors get one bounded sleep-retry, same as before."""
|
2026-06-23 22:17:39 -05:00
|
|
|
_require_config()
|
2026-06-21 11:46:54 -05:00
|
|
|
merged = {"Authorization": f"Bearer {KEY}", **(headers or {})}
|
2026-06-22 23:55:19 -05:00
|
|
|
parts = urllib.parse.urlsplit(url)
|
|
|
|
|
target = parts.path or "/"
|
|
|
|
|
if parts.query:
|
|
|
|
|
target += "?" + parts.query
|
2026-06-21 11:46:54 -05:00
|
|
|
last_status, last_body = 0, b""
|
2026-06-22 23:55:19 -05:00
|
|
|
for attempt in range(MAX_ATTEMPTS):
|
2026-06-21 11:46:54 -05:00
|
|
|
try:
|
2026-06-22 23:55:19 -05:00
|
|
|
conn = _connection()
|
|
|
|
|
conn.request(method, target, body=data, headers=merged)
|
|
|
|
|
resp = conn.getresponse()
|
|
|
|
|
body = resp.read() # drain fully so the connection can be reused
|
|
|
|
|
status = resp.status
|
|
|
|
|
if status >= 500 and attempt < MAX_ATTEMPTS - 1:
|
|
|
|
|
_drop_connection()
|
2026-06-21 11:46:54 -05:00
|
|
|
time.sleep(1)
|
|
|
|
|
continue
|
2026-06-22 23:55:19 -05:00
|
|
|
return status, body
|
|
|
|
|
except (http.client.RemoteDisconnected, http.client.BadStatusLine,
|
|
|
|
|
ConnectionResetError, BrokenPipeError) as exc:
|
|
|
|
|
# The server closed an idle keep-alive connection between requests.
|
|
|
|
|
# Expected, not a failure: reconnect and retry without backing off.
|
|
|
|
|
_drop_connection()
|
2026-06-21 11:46:54 -05:00
|
|
|
last_status, last_body = 0, str(exc).encode()
|
2026-06-22 23:55:19 -05:00
|
|
|
continue
|
|
|
|
|
except Exception as exc: # timeout, DNS, refused, TLS, ...
|
|
|
|
|
_drop_connection()
|
|
|
|
|
last_status, last_body = 0, str(exc).encode()
|
|
|
|
|
if attempt < MAX_ATTEMPTS - 1:
|
2026-06-21 11:46:54 -05:00
|
|
|
time.sleep(1)
|
|
|
|
|
continue
|
|
|
|
|
return last_status, last_body
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 23:55:19 -05:00
|
|
|
def get_text(path: str) -> str | None:
|
|
|
|
|
"""GET a vault path -> decoded text, or None on 404/any error. Never raises, so a
|
|
|
|
|
single unreadable note can't abort a bulk pass (resilience for sweep/lint)."""
|
|
|
|
|
try:
|
|
|
|
|
status, body = request("GET", vault_url(path))
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
return body.decode(errors="replace") if status == 200 else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_many(paths) -> dict[str, str | None]:
|
|
|
|
|
"""Concurrently GET many vault paths -> {path: text_or_None}. The single biggest
|
|
|
|
|
win for full-vault scripts: it turns hundreds of serial round-trips into MAX_WORKERS
|
|
|
|
|
parallel ones over warm keep-alive connections. Resilient — a bad file maps to None."""
|
|
|
|
|
unique = list(dict.fromkeys(paths))
|
|
|
|
|
if not unique:
|
|
|
|
|
return {}
|
|
|
|
|
workers = min(MAX_WORKERS, len(unique))
|
|
|
|
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
|
|
|
return dict(zip(unique, pool.map(get_text, unique)))
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 11:46:54 -05:00
|
|
|
def check(status: int, body: bytes, context: str) -> None:
|
|
|
|
|
if status == 404:
|
|
|
|
|
raise EchoError(f"{context}: not found", 44)
|
|
|
|
|
if status == 0:
|
|
|
|
|
raise EchoError(f"{context}: vault unreachable ({body.decode(errors='replace')}) [{BASE}]", 1)
|
|
|
|
|
if status >= 400:
|
|
|
|
|
raise EchoError(f"{context}: HTTP {status} - {body.decode(errors='replace')}", 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_body(arg: str | None) -> bytes:
|
|
|
|
|
if arg in (None, "-"):
|
|
|
|
|
raw = sys.stdin.buffer.read()
|
|
|
|
|
try:
|
|
|
|
|
text = raw.decode("utf-8")
|
|
|
|
|
except UnicodeDecodeError:
|
|
|
|
|
text = raw.decode(locale.getpreferredencoding(False) or "cp1252", errors="replace")
|
|
|
|
|
return text.encode("utf-8")
|
|
|
|
|
return Path(arg).read_bytes()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_patch_body(data: bytes, op: str, target_type: str) -> bytes:
|
|
|
|
|
"""Heading PATCH bodies need a leading newline on replace and a trailing
|
|
|
|
|
newline on append/prepend so the API does not concatenate onto adjacent text."""
|
|
|
|
|
if target_type != "heading":
|
|
|
|
|
return data
|
|
|
|
|
text = data.decode("utf-8", errors="replace")
|
|
|
|
|
if op == "replace":
|
|
|
|
|
text = text.strip("\r\n")
|
|
|
|
|
text = f"\n{text}\n" if text else "\n"
|
|
|
|
|
elif text and not text.endswith("\n"):
|
|
|
|
|
text += "\n"
|
|
|
|
|
return text.encode("utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_json_scalar(value: str) -> bytes:
|
|
|
|
|
"""Accept either a raw scalar ('Fabrication Lead') or pre-formed JSON ('"2026-06-20"')."""
|
|
|
|
|
value = value.strip()
|
|
|
|
|
try:
|
|
|
|
|
json.loads(value)
|
|
|
|
|
return value.encode("utf-8")
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
return json.dumps(value).encode("utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def temp_file(data: bytes) -> str:
|
|
|
|
|
fh = tempfile.NamedTemporaryFile(delete=False)
|
|
|
|
|
try:
|
|
|
|
|
fh.write(data)
|
|
|
|
|
return fh.name
|
|
|
|
|
finally:
|
|
|
|
|
fh.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- commands ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def cmd_get(path: str) -> int:
|
|
|
|
|
status, body = request("GET", vault_url(path))
|
|
|
|
|
check(status, body, f"get {path}")
|
|
|
|
|
sys.stdout.buffer.write(body)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_map(path: str) -> int:
|
|
|
|
|
status, body = request("GET", vault_url(path),
|
|
|
|
|
headers={"Accept": "application/vnd.olrapi.document-map+json"})
|
|
|
|
|
check(status, body, f"map {path}")
|
|
|
|
|
sys.stdout.buffer.write(body)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_ls(path: str) -> int:
|
|
|
|
|
if not path.endswith("/"):
|
|
|
|
|
path += "/"
|
|
|
|
|
status, body = request("GET", vault_url(path))
|
|
|
|
|
check(status, body, f"ls {path}")
|
|
|
|
|
sys.stdout.buffer.write(body)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_search(query: list[str]) -> int:
|
|
|
|
|
q = urllib.parse.quote_plus(" ".join(query))
|
|
|
|
|
status, body = request("POST", f"{BASE}/search/simple/?query={q}")
|
|
|
|
|
check(status, body, "search")
|
|
|
|
|
sys.stdout.buffer.write(body)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 09:27:36 -05:00
|
|
|
def _qwrite(method: str, url: str, data: bytes | None = None, headers: dict | None = None):
|
|
|
|
|
"""A mutating request that survives an outage: routes through echo_queue.safe_request,
|
|
|
|
|
which enqueues the write and returns (202, ...) when the vault is unreachable (H2)."""
|
|
|
|
|
import echo_queue
|
|
|
|
|
return echo_queue.safe_request(method, url, data=data, headers=headers)
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 11:46:54 -05:00
|
|
|
def cmd_put(path: str, file_arg: str | None) -> int:
|
|
|
|
|
data = read_body(file_arg)
|
2026-06-22 09:27:36 -05:00
|
|
|
status, body = _qwrite("PUT", vault_url(path), data=data,
|
2026-06-21 11:46:54 -05:00
|
|
|
headers={"Content-Type": "text/markdown"})
|
2026-06-22 09:27:36 -05:00
|
|
|
if status == 202:
|
|
|
|
|
print(f"queued (offline): PUT {path} — will sync on the next reachable session")
|
|
|
|
|
return 0
|
2026-06-21 11:46:54 -05:00
|
|
|
check(status, body, f"put {path}")
|
|
|
|
|
if VERIFY:
|
|
|
|
|
status, _ = request("GET", vault_url(path))
|
|
|
|
|
if status != 200:
|
|
|
|
|
raise EchoError(f"put {path}: write did not verify (GET returned {status})")
|
|
|
|
|
print(f"ok: PUT {path}")
|
2026-07-03 12:57:52 -05:00
|
|
|
# Keep the recall (BM25) index current when a corpus note is written directly —
|
|
|
|
|
# session logs, journal notes, hand-authored entity notes. update_note early-returns
|
|
|
|
|
# for non-corpus paths and never raises, so this can't fail or slow a normal put.
|
|
|
|
|
try:
|
|
|
|
|
import echo_recall
|
|
|
|
|
echo_recall.update_note(path, data.decode("utf-8", errors="replace"))
|
|
|
|
|
except Exception as exc: # noqa: BLE001 — index upkeep must never fail a put
|
|
|
|
|
print(f"echo.py: recall-index update skipped ({exc})", file=sys.stderr)
|
2026-06-21 11:46:54 -05:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_post(path: str, file_arg: str | None) -> int:
|
|
|
|
|
data = read_body(file_arg)
|
2026-06-22 09:27:36 -05:00
|
|
|
status, body = _qwrite("POST", vault_url(path), data=data,
|
2026-06-21 11:46:54 -05:00
|
|
|
headers={"Content-Type": "text/markdown"})
|
2026-06-22 09:27:36 -05:00
|
|
|
if status == 202:
|
|
|
|
|
print(f"queued (offline): POST {path} — will sync on the next reachable session")
|
|
|
|
|
return 0
|
2026-06-21 11:46:54 -05:00
|
|
|
check(status, body, f"post {path}")
|
|
|
|
|
print(f"ok: POST {path}")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_append(path: str, line: str) -> int:
|
|
|
|
|
"""Idempotent append. Skips ONLY when the exact whole line already exists —
|
|
|
|
|
a new line that is a substring of an existing one is still appended."""
|
|
|
|
|
status, body = request("GET", vault_url(path))
|
|
|
|
|
if status == 200:
|
|
|
|
|
existing = [ln.rstrip("\r") for ln in body.decode(errors="replace").splitlines()]
|
|
|
|
|
if line.rstrip("\r") in existing:
|
|
|
|
|
print(f"skip: line already present in {path}")
|
|
|
|
|
return 0
|
2026-06-22 09:27:36 -05:00
|
|
|
elif status == 0:
|
|
|
|
|
pass # offline: skip the idempotency read; queue the POST (flush dedups by content)
|
2026-06-21 11:46:54 -05:00
|
|
|
elif status != 404:
|
|
|
|
|
check(status, body, f"append(read) {path}")
|
2026-06-22 09:27:36 -05:00
|
|
|
status, body = _qwrite("POST", vault_url(path), data=f"{line}\n".encode(),
|
2026-06-21 11:46:54 -05:00
|
|
|
headers={"Content-Type": "text/markdown"})
|
2026-06-22 09:27:36 -05:00
|
|
|
if status == 202:
|
|
|
|
|
print(f"queued (offline): APPEND {path} — will sync on the next reachable session")
|
|
|
|
|
return 0
|
2026-06-21 11:46:54 -05:00
|
|
|
check(status, body, f"append {path}")
|
|
|
|
|
print(f"ok: APPEND {path}")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_patch(path: str, op: str, target_type: str, target: str, file_arg: str | None) -> int:
|
|
|
|
|
if op not in {"append", "prepend", "replace"}:
|
|
|
|
|
raise EchoError("patch: op must be append, prepend, or replace", 2)
|
|
|
|
|
if target_type not in {"heading", "frontmatter", "block"}:
|
|
|
|
|
raise EchoError("patch: target-type must be heading, frontmatter, or block", 2)
|
|
|
|
|
data = normalize_patch_body(read_body(file_arg), op, target_type)
|
2026-06-22 09:27:36 -05:00
|
|
|
status, body = _qwrite(
|
2026-06-21 11:46:54 -05:00
|
|
|
"PATCH",
|
|
|
|
|
vault_url(path),
|
|
|
|
|
data=data,
|
|
|
|
|
headers={
|
|
|
|
|
"Operation": op,
|
|
|
|
|
"Target-Type": target_type,
|
|
|
|
|
"Target": target,
|
|
|
|
|
"Content-Type": "application/json" if target_type == "frontmatter" else "text/markdown",
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-06-22 09:27:36 -05:00
|
|
|
if status == 202:
|
|
|
|
|
print(f"queued (offline): PATCH {op} '{target}' -> {path} — will sync on the next reachable session")
|
|
|
|
|
return 0
|
2026-06-21 11:46:54 -05:00
|
|
|
check(status, body, f"patch {path} ({target})")
|
|
|
|
|
print(f"ok: PATCH {op} {target_type} '{target}' -> {path}")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 12:57:52 -05:00
|
|
|
def _yaml_flow(value) -> str:
|
|
|
|
|
"""Render a JSON-decoded value as a YAML flow scalar/list for one frontmatter line."""
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
return "[" + ", ".join(_yaml_flow(v) for v in value) + "]"
|
|
|
|
|
if isinstance(value, bool):
|
|
|
|
|
return "true" if value else "false"
|
|
|
|
|
if value is None:
|
|
|
|
|
return ""
|
|
|
|
|
s = str(value)
|
|
|
|
|
return s if re.match(r"^[A-Za-z0-9][A-Za-z0-9 ._/-]*$", s) else json.dumps(s)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fm_upsert_line(text: str, field: str, rendered: str) -> str:
|
|
|
|
|
"""Surgically set `field: rendered` inside the YAML frontmatter block, creating the
|
|
|
|
|
key (or the whole block) if absent. Every other line is preserved verbatim — this is
|
|
|
|
|
the safety property that makes it OK to fall back from a failed PATCH."""
|
|
|
|
|
line = f"{field}: {rendered}".rstrip()
|
|
|
|
|
lines = text.splitlines()
|
|
|
|
|
if lines and lines[0].strip() == "---":
|
|
|
|
|
for end in range(1, len(lines)):
|
|
|
|
|
if lines[end].strip() == "---":
|
|
|
|
|
for i in range(1, end): # key exists -> replace that one line
|
|
|
|
|
if re.match(rf"^{re.escape(field)}\s*:", lines[i]):
|
|
|
|
|
lines[i] = line
|
|
|
|
|
break
|
|
|
|
|
else:
|
|
|
|
|
lines.insert(end, line) # else append just before the closing ---
|
|
|
|
|
return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
|
|
|
|
|
return f"---\n{line}\n---\n{text}" # no frontmatter at all -> prepend a minimal block
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 11:46:54 -05:00
|
|
|
def cmd_fm(path: str, field: str, value: str) -> int:
|
2026-07-03 12:57:52 -05:00
|
|
|
"""Create-or-replace a frontmatter scalar. PATCH replace handles the common case;
|
|
|
|
|
a missing key returns 400 invalid-target (PATCH cannot create), which used to make
|
|
|
|
|
`fm` fail on exactly the notes that needed repair — now it falls back to a surgical
|
|
|
|
|
GET -> insert-one-line -> verified PUT."""
|
|
|
|
|
data = normalize_json_scalar(value)
|
|
|
|
|
try:
|
|
|
|
|
return cmd_patch(path, "replace", "frontmatter", field, temp_file(data))
|
|
|
|
|
except EchoError as exc:
|
|
|
|
|
if "HTTP 400" not in str(exc):
|
|
|
|
|
raise
|
|
|
|
|
status, body = request("GET", vault_url(path))
|
|
|
|
|
check(status, body, f"fm {path}")
|
|
|
|
|
new = _fm_upsert_line(body.decode(errors="replace"), field,
|
|
|
|
|
_yaml_flow(json.loads(data.decode("utf-8"))))
|
|
|
|
|
status, body = _qwrite("PUT", vault_url(path), data=new.encode("utf-8"),
|
|
|
|
|
headers={"Content-Type": "text/markdown"})
|
|
|
|
|
if status == 202:
|
|
|
|
|
print(f"queued (offline): FM {field} -> {path} — will sync on the next reachable session")
|
|
|
|
|
return 0
|
|
|
|
|
check(status, body, f"fm {path}")
|
|
|
|
|
if VERIFY:
|
|
|
|
|
status, body = request("GET", vault_url(path))
|
|
|
|
|
if status != 200 or not re.search(rf"(?m)^{re.escape(field)}\s*:", body.decode(errors="replace")):
|
|
|
|
|
raise EchoError(f"fm {path}: created key '{field}' did not verify on read-back")
|
|
|
|
|
print(f"ok: FM created '{field}' in {path}")
|
|
|
|
|
return 0
|
2026-06-21 11:46:54 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_delete(path: str) -> int:
|
2026-06-22 09:27:36 -05:00
|
|
|
status, body = _qwrite("DELETE", vault_url(path))
|
|
|
|
|
if status == 202:
|
|
|
|
|
print(f"queued (offline): DELETE {path} — will sync on the next reachable session")
|
|
|
|
|
return 0
|
2026-06-21 11:46:54 -05:00
|
|
|
check(status, body, f"delete {path}")
|
|
|
|
|
print(f"ok: DELETE {path}")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_lock_time(value: str) -> int:
|
|
|
|
|
try:
|
|
|
|
|
return int(dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
.replace(tzinfo=dt.timezone.utc).timestamp())
|
|
|
|
|
except Exception:
|
|
|
|
|
# Unparseable timestamp -> treat the lock as FRESH (return "now"), so a
|
|
|
|
|
# garbled lock is respected rather than silently stomped.
|
|
|
|
|
return int(time.time())
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 09:27:36 -05:00
|
|
|
def cmd_lock(owner: str, quiet: bool = False) -> int:
|
2026-06-21 11:46:54 -05:00
|
|
|
status, body = request("GET", vault_url(LOCK_PATH))
|
|
|
|
|
if status == 200 and body.strip():
|
|
|
|
|
current = body.decode(errors="replace").strip()
|
|
|
|
|
held_owner, _, held_iso = current.partition(" @ ")
|
|
|
|
|
if held_owner != owner and int(time.time()) - parse_lock_time(held_iso) < LOCK_TTL:
|
|
|
|
|
print(f"lock held by '{held_owner}' since {held_iso} (fresh)", file=sys.stderr)
|
|
|
|
|
return 75
|
|
|
|
|
status, body = request("PUT", vault_url(LOCK_PATH), data=f"{owner} @ {now_iso()}\n".encode(),
|
|
|
|
|
headers={"Content-Type": "text/markdown"})
|
|
|
|
|
check(status, body, "lock")
|
|
|
|
|
# Read-back confirm we actually hold it. The REST API has no compare-and-swap,
|
|
|
|
|
# so two racers can both PUT; last writer wins. Whoever does not own the file
|
|
|
|
|
# on read-back backs off instead of proceeding under a false lock.
|
|
|
|
|
status, body = request("GET", vault_url(LOCK_PATH))
|
|
|
|
|
if status == 200:
|
|
|
|
|
held_owner = body.decode(errors="replace").split(" @ ", 1)[0].strip()
|
|
|
|
|
if held_owner != owner:
|
|
|
|
|
print(f"lock race: now held by '{held_owner}', not '{owner}' — backing off", file=sys.stderr)
|
|
|
|
|
return 75
|
2026-06-22 09:27:36 -05:00
|
|
|
if not quiet:
|
|
|
|
|
print(f"ok: locked by {owner}")
|
2026-06-21 11:46:54 -05:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 09:27:36 -05:00
|
|
|
def cmd_unlock(owner: str, quiet: bool = False) -> int:
|
2026-06-21 11:46:54 -05:00
|
|
|
status, body = request("GET", vault_url(LOCK_PATH))
|
|
|
|
|
if status == 200:
|
|
|
|
|
held_owner = body.decode(errors="replace").split(" @ ", 1)[0].strip()
|
|
|
|
|
if held_owner != owner:
|
|
|
|
|
print(f"lock owned by '{held_owner}', not '{owner}' — not releasing", file=sys.stderr)
|
|
|
|
|
return 75
|
|
|
|
|
status, body = request("DELETE", vault_url(LOCK_PATH))
|
|
|
|
|
if status != 404:
|
|
|
|
|
check(status, body, "unlock")
|
2026-06-22 09:27:36 -05:00
|
|
|
if not quiet:
|
|
|
|
|
print("ok: unlocked")
|
2026-06-21 11:46:54 -05:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_heading(markdown: str, heading: str) -> str:
|
|
|
|
|
"""Return the body under a `## <heading>` up to the next `## ` heading."""
|
|
|
|
|
out: list[str] = []
|
|
|
|
|
capture = False
|
|
|
|
|
marker = f"## {heading}"
|
|
|
|
|
for line in markdown.splitlines():
|
|
|
|
|
if line.strip() == marker:
|
|
|
|
|
capture = True
|
|
|
|
|
continue
|
|
|
|
|
if capture and line.startswith("## "):
|
|
|
|
|
break
|
|
|
|
|
if capture:
|
|
|
|
|
out.append(line)
|
|
|
|
|
return "\n".join(out).strip()
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 22:51:33 -05:00
|
|
|
def scope_show_op() -> dict:
|
|
|
|
|
"""Core scope-show (Phase 0): active scope + freshness + sessions-since, as data."""
|
|
|
|
|
path = "_agent/context/current-context.md"
|
|
|
|
|
status, body = request("GET", vault_url(path))
|
|
|
|
|
check(status, body, "scope show")
|
|
|
|
|
current = body.decode(errors="replace")
|
|
|
|
|
scope_text = extract_heading(current, "Scope")
|
|
|
|
|
scope_updated = ""
|
|
|
|
|
for line in current.splitlines():
|
|
|
|
|
if line.startswith("scope_updated:"):
|
|
|
|
|
scope_updated = line.split(":", 1)[1].strip().strip('"').strip("'")
|
|
|
|
|
break
|
|
|
|
|
sessions_since = None
|
|
|
|
|
# Count session logs dated after scope_updated (the drift signal).
|
|
|
|
|
if scope_updated:
|
|
|
|
|
status, body = request("GET", vault_url("_agent/sessions/"))
|
|
|
|
|
if status == 200:
|
|
|
|
|
try:
|
|
|
|
|
files = json.loads(body).get("files", [])
|
|
|
|
|
sessions_since = sum(1 for f in files if f.endswith(".md") and f[:10] > scope_updated)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
pass
|
|
|
|
|
return {"ok": True, "action": "scope-show", "scope": scope_text,
|
|
|
|
|
"scope_updated": scope_updated or None, "sessions_since": sessions_since}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def scope_set_op(text: str) -> dict:
|
|
|
|
|
"""Core scope-set (Phase 0): atomic switch (history + replace + stamp). Chatter
|
|
|
|
|
from the underlying PATCHes routes to stderr; returns the switch envelope."""
|
|
|
|
|
import contextlib
|
|
|
|
|
if not text:
|
|
|
|
|
raise EchoError("scope set needs the new scope text", 2)
|
2026-06-21 11:46:54 -05:00
|
|
|
path = "_agent/context/current-context.md"
|
|
|
|
|
status, body = request("GET", vault_url(path))
|
2026-07-28 22:51:33 -05:00
|
|
|
check(status, body, "scope set")
|
2026-06-21 11:46:54 -05:00
|
|
|
current = body.decode(errors="replace")
|
2026-07-28 22:51:33 -05:00
|
|
|
prior = extract_heading(current, "Scope").replace("\n", " ").strip()[:140] or "(prior scope)"
|
|
|
|
|
with contextlib.redirect_stdout(sys.stderr):
|
|
|
|
|
cmd_patch(path, "prepend", "heading", "Current Context::Scope History",
|
|
|
|
|
temp_file(f"- {today()}: {prior}\n".encode()))
|
|
|
|
|
cmd_patch(path, "replace", "heading", "Current Context::Scope",
|
|
|
|
|
temp_file(f"{text}\n".encode()))
|
|
|
|
|
try:
|
|
|
|
|
cmd_patch(path, "replace", "frontmatter", "scope_updated",
|
|
|
|
|
temp_file(json.dumps(today()).encode()))
|
|
|
|
|
except EchoError as exc:
|
|
|
|
|
raise EchoError(
|
|
|
|
|
"scope set: body switched, but scope_updated frontmatter is missing "
|
|
|
|
|
f"(run bootstrap.py to add it) [{exc}]"
|
|
|
|
|
)
|
|
|
|
|
return {"ok": True, "action": "scope-set", "scope": text, "prior": prior,
|
|
|
|
|
"scope_updated": today()}
|
|
|
|
|
|
2026-06-21 11:46:54 -05:00
|
|
|
|
2026-07-28 22:51:33 -05:00
|
|
|
def cmd_scope(subcommand: str, text: str | None = None, as_json: bool = False) -> int:
|
2026-06-21 11:46:54 -05:00
|
|
|
if subcommand == "show":
|
2026-07-28 22:51:33 -05:00
|
|
|
env = scope_show_op()
|
2026-07-03 12:57:52 -05:00
|
|
|
if as_json:
|
2026-07-28 22:51:33 -05:00
|
|
|
print(json.dumps(env, ensure_ascii=False))
|
2026-07-03 12:57:52 -05:00
|
|
|
return 0
|
|
|
|
|
print("-- Active scope --")
|
2026-07-28 22:51:33 -05:00
|
|
|
print(env["scope"])
|
|
|
|
|
print(f"scope_updated: {env['scope_updated'] or '<missing — drift cannot be detected; run scope set or repair>'}")
|
|
|
|
|
if env["sessions_since"] is not None:
|
|
|
|
|
print(f"sessions logged since: {env['sessions_since']}")
|
2026-06-21 11:46:54 -05:00
|
|
|
return 0
|
|
|
|
|
if subcommand != "set":
|
|
|
|
|
raise EchoError("scope: use 'show' or 'set \"<text>\"'", 2)
|
2026-07-28 22:51:33 -05:00
|
|
|
env = scope_set_op(text or "")
|
|
|
|
|
print(f"ok: scope switched (prior archived to Scope History; scope_updated={env['scope_updated']})")
|
2026-06-21 11:46:54 -05:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 22:34:42 -05:00
|
|
|
def _fetch_statuses(paths: list[str]) -> dict[str, tuple[int, bytes]]:
|
|
|
|
|
"""Concurrently GET vault paths keeping (status, body) per path — unlike read_many,
|
|
|
|
|
the 404-vs-offline distinction survives, which cmd_load's cache logic needs."""
|
|
|
|
|
if not paths:
|
|
|
|
|
return {}
|
|
|
|
|
workers = min(MAX_WORKERS, len(paths))
|
|
|
|
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
|
|
|
return dict(zip(paths, pool.map(lambda p: request("GET", vault_url(p)), paths)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _bullet_lines(text: str) -> list[str]:
|
|
|
|
|
return [ln for ln in text.splitlines() if ln.lstrip().startswith("- ")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fm_line(text: str, field: str) -> str:
|
|
|
|
|
for ln in text.splitlines():
|
|
|
|
|
if ln.startswith(f"{field}:"):
|
|
|
|
|
return ln.split(":", 1)[1].strip().strip('"').strip("'")
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_brief(texts: dict[str, str | None], listing_files: list[str],
|
|
|
|
|
offline: bool) -> str:
|
|
|
|
|
"""The token-budgeted cold-start digest (2.1.0). Selection over compression: Fact /
|
|
|
|
|
Pattern rules in full, recent Observations only, scope + freshness (not the whole
|
|
|
|
|
history), the last session's key sections, today's Agent Log lines, inbox COUNT.
|
|
|
|
|
Sections are trimmed lowest-priority-first when over ECHO_LOAD_BUDGET."""
|
|
|
|
|
budget = int(os.environ.get("ECHO_LOAD_BUDGET", "8000"))
|
|
|
|
|
S: list[tuple[str, str]] = [] # (section-name, text) in display order
|
|
|
|
|
|
|
|
|
|
marker = texts.get("marker")
|
|
|
|
|
if marker is None:
|
|
|
|
|
S.append(("marker", "marker: _agent/echo-vault.md ABSENT — vault not bootstrapped "
|
|
|
|
|
"(run bootstrap.py before relying on memory)"))
|
|
|
|
|
else:
|
|
|
|
|
ver = _fm_line(marker, "schema_version") or "?"
|
|
|
|
|
S.append(("marker", f"marker: _agent/echo-vault.md — bootstrapped, schema_version {ver}"))
|
|
|
|
|
|
|
|
|
|
ctx = texts.get("context")
|
|
|
|
|
if ctx:
|
|
|
|
|
scope = extract_heading(ctx, "Scope") or "(empty)"
|
|
|
|
|
upd = _fm_line(ctx, "scope_updated") or "unknown"
|
|
|
|
|
since = sum(1 for f in listing_files if f.endswith(".md") and f[:10] > upd) \
|
|
|
|
|
if upd != "unknown" else None
|
|
|
|
|
head = f"scope (updated {upd}"
|
|
|
|
|
if since is not None:
|
|
|
|
|
head += f", {since} session(s) logged since"
|
|
|
|
|
S.append(("scope", head + "):\n" + scope))
|
|
|
|
|
else:
|
|
|
|
|
S.append(("scope", "scope: current-context.md absent"))
|
|
|
|
|
|
|
|
|
|
prefs = texts.get("preferences")
|
|
|
|
|
if prefs:
|
|
|
|
|
fact = extract_heading(prefs, "Fact / Pattern")
|
|
|
|
|
if fact:
|
|
|
|
|
S.append(("facts", "preferences — Fact / Pattern:\n" + fact))
|
|
|
|
|
obs = _bullet_lines(extract_heading(prefs, "Observations"))
|
|
|
|
|
if obs:
|
|
|
|
|
shown = obs[-10:]
|
|
|
|
|
head = f"preferences — Observations (last {len(shown)} of {len(obs)}):"
|
|
|
|
|
S.append(("observations", head + "\n" + "\n".join(shown)))
|
|
|
|
|
|
|
|
|
|
hb = texts.get("heartbeat")
|
|
|
|
|
if hb and hb.strip():
|
|
|
|
|
pointer = hb.strip().splitlines()[0]
|
|
|
|
|
sess_path = pointer.split(" @ ", 1)[0].strip()
|
|
|
|
|
part = [f"last session: {pointer}"]
|
|
|
|
|
log_text = texts.get("_pointed_session")
|
|
|
|
|
if log_text:
|
|
|
|
|
for h in ("Goal", "Decisions Made", "Open Threads", "Suggested Next Step"):
|
|
|
|
|
sec = extract_heading(log_text, h)
|
|
|
|
|
if sec and sec.strip("() \n"):
|
|
|
|
|
part.append(f" {h}: " + " / ".join(ln.strip() for ln in sec.splitlines()
|
|
|
|
|
if ln.strip())[:400])
|
|
|
|
|
elif sess_path:
|
|
|
|
|
part.append(" (session log not readable — see the path above)")
|
|
|
|
|
S.append(("session", "\n".join(part)))
|
|
|
|
|
elif listing_files:
|
|
|
|
|
recent = sorted((f for f in listing_files if f.endswith(".md")), reverse=True)[:3]
|
|
|
|
|
S.append(("session", "last session: heartbeat absent — recent logs:\n"
|
|
|
|
|
+ "\n".join(f" _agent/sessions/{f}" for f in recent)))
|
|
|
|
|
|
|
|
|
|
today_note = texts.get("today")
|
|
|
|
|
if today_note:
|
|
|
|
|
log_lines = _bullet_lines(extract_heading(today_note, "Agent Log"))
|
|
|
|
|
S.append(("agent-log", "today's Agent Log:\n" + ("\n".join(log_lines) or " (empty)")))
|
|
|
|
|
else:
|
|
|
|
|
S.append(("agent-log", "today's Agent Log: (no daily note yet)"))
|
|
|
|
|
|
|
|
|
|
inbox = texts.get("inbox")
|
|
|
|
|
if inbox:
|
|
|
|
|
items = re.findall(r"(?m)^\s*-\s*(\d{4}-\d{2}-\d{2})", inbox)
|
|
|
|
|
if items:
|
|
|
|
|
try:
|
|
|
|
|
oldest = (dt.date.fromisoformat(today()) - dt.date.fromisoformat(min(items))).days
|
|
|
|
|
S.append(("inbox", f"inbox: {len(items)} capture(s), oldest {oldest}d — "
|
|
|
|
|
"offer triage if any are older than ~7 days"))
|
|
|
|
|
except ValueError:
|
|
|
|
|
S.append(("inbox", f"inbox: {len(items)} capture(s)"))
|
|
|
|
|
else:
|
|
|
|
|
S.append(("inbox", "inbox: empty"))
|
|
|
|
|
else:
|
|
|
|
|
S.append(("inbox", "inbox: empty"))
|
|
|
|
|
|
|
|
|
|
def total() -> int:
|
|
|
|
|
return sum(len(t) + 2 for _, t in S)
|
|
|
|
|
|
|
|
|
|
# Over budget -> trim lowest-priority sections first, with explicit markers.
|
|
|
|
|
for name, keep in (("observations", 4), ("agent-log", 4), ("session", 9)):
|
|
|
|
|
if total() <= budget:
|
|
|
|
|
break
|
|
|
|
|
for i, (n, t) in enumerate(S):
|
|
|
|
|
if n == name:
|
|
|
|
|
lines = t.splitlines()
|
|
|
|
|
if len(lines) > keep:
|
|
|
|
|
S[i] = (n, "\n".join(lines[:keep]) + "\n (truncated — /echo-load for full)")
|
|
|
|
|
out = f"ECHO load (brief) — {today()}\n\n" + "\n\n".join(t for _, t in S)
|
|
|
|
|
if len(out) > budget:
|
|
|
|
|
out = out[:budget] + "\n(truncated — /echo-load for full)"
|
|
|
|
|
if offline:
|
|
|
|
|
out += ("\n\nNOTE: vault unreachable — context above is last-known-good cache; "
|
|
|
|
|
"writes this session will be queued and synced when the vault returns.")
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 22:51:33 -05:00
|
|
|
LOAD_TARGETS = [
|
|
|
|
|
("marker", "_agent/echo-vault.md"),
|
|
|
|
|
("preferences", "_agent/memory/semantic/operator-preferences.md"),
|
|
|
|
|
("context", "_agent/context/current-context.md"),
|
|
|
|
|
("heartbeat", "_agent/heartbeat/last-session.md"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_gather(targets):
|
|
|
|
|
"""Fetch the orientation reads (+ the sessions listing) in parallel and resolve
|
|
|
|
|
each to text via status/cache. Shared by cmd_load and load_op. Returns
|
|
|
|
|
(results, texts, listing_files, offline, marker_missing, heartbeat_absent)."""
|
|
|
|
|
import echo_queue
|
|
|
|
|
listing_path = "_agent/sessions/"
|
|
|
|
|
results = _fetch_statuses([p for _, p in targets] + [listing_path])
|
|
|
|
|
marker_missing = heartbeat_absent = offline = False
|
|
|
|
|
texts: dict[str, str | None] = {}
|
|
|
|
|
for label, path in targets:
|
|
|
|
|
status, body = results[path]
|
|
|
|
|
if status == 200:
|
|
|
|
|
echo_queue.cache_put(path, body) # refresh last-known-good for offline fallback
|
|
|
|
|
texts[label] = body.decode(errors="replace")
|
|
|
|
|
elif status == 0: # vault unreachable -> degrade to last-known-good cache
|
|
|
|
|
offline = True
|
|
|
|
|
cached = echo_queue.cache_get(path)
|
|
|
|
|
texts[label] = cached.decode(errors="replace") if cached is not None else None
|
|
|
|
|
else:
|
|
|
|
|
texts[label] = None
|
|
|
|
|
if status == 404:
|
|
|
|
|
if label == "marker":
|
|
|
|
|
marker_missing = True
|
|
|
|
|
if label == "heartbeat":
|
|
|
|
|
heartbeat_absent = True
|
|
|
|
|
lst_status, lst_body = results[listing_path]
|
|
|
|
|
listing_files: list[str] = []
|
|
|
|
|
if lst_status == 200:
|
|
|
|
|
try:
|
|
|
|
|
listing_files = list(json.loads(lst_body).get("files", []))
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
pass
|
|
|
|
|
return results, texts, listing_files, offline, marker_missing, heartbeat_absent
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 00:08:48 -05:00
|
|
|
def _auto_fast_sweep(offline: bool) -> str | None:
|
|
|
|
|
"""Opportunistic index maintenance at load (2.3): when this machine's last fast
|
|
|
|
|
sweep is older than ECHO_FAST_SWEEP_DAYS (default 7), run `sweep --fast` so the
|
|
|
|
|
local recall + entity indexes true up against edits made by Obsidian or other
|
|
|
|
|
clients. Best-effort; skipped offline; never raises."""
|
|
|
|
|
if offline:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
import sweep as sweep_mod
|
|
|
|
|
days = float(os.environ.get("ECHO_FAST_SWEEP_DAYS", "7"))
|
|
|
|
|
age = sweep_mod.fast_sweep_age_days()
|
|
|
|
|
if age is not None and age < days:
|
|
|
|
|
return None
|
|
|
|
|
return sweep_mod.fast()
|
|
|
|
|
except Exception: # noqa: BLE001 — maintenance must never block a load
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 22:51:33 -05:00
|
|
|
def _fetch_pointed_session(texts: dict) -> None:
|
|
|
|
|
"""Follow the heartbeat pointer and stash the pointed session log for the digest."""
|
|
|
|
|
hb = texts.get("heartbeat")
|
|
|
|
|
if hb and hb.strip():
|
|
|
|
|
sess_path = hb.strip().splitlines()[0].split(" @ ", 1)[0].strip()
|
|
|
|
|
if sess_path:
|
|
|
|
|
st2, b2 = request("GET", vault_url(sess_path))
|
|
|
|
|
if st2 == 200:
|
|
|
|
|
texts["_pointed_session"] = b2.decode(errors="replace")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_op(brief: bool = True) -> dict:
|
|
|
|
|
"""Core load (Phase 0): the orientation reads as data — sections keyed by label,
|
|
|
|
|
plus offline/bootstrap flags, queue counts, recent sessions, and (with brief) the
|
|
|
|
|
rendered digest. Raises EchoError(78) when the machine is not configured."""
|
|
|
|
|
if not echo_config.is_configured(_CFG):
|
|
|
|
|
raise EchoError("NOT CONFIGURED — no usable ECHO key file on this machine "
|
|
|
|
|
f"(expected at {echo_config.config_path()})", 78)
|
|
|
|
|
import echo_queue
|
|
|
|
|
synced = flagged = 0
|
|
|
|
|
try:
|
|
|
|
|
synced = echo_queue.flush()
|
|
|
|
|
flagged = len(echo_queue.needs_attention())
|
|
|
|
|
except Exception: # noqa: BLE001 — queue upkeep must never block a load
|
|
|
|
|
pass
|
|
|
|
|
targets = LOAD_TARGETS + [("today", f"journal/daily/{today()}.md"),
|
|
|
|
|
("inbox", "inbox/captures/inbox.md")]
|
|
|
|
|
_, texts, listing_files, offline, marker_missing, _ = _load_gather(targets)
|
2026-07-29 00:08:48 -05:00
|
|
|
swept = _auto_fast_sweep(offline)
|
2026-07-28 22:51:33 -05:00
|
|
|
data = {"ok": True, "action": "load", "offline": offline,
|
|
|
|
|
"marker_missing": marker_missing, "synced": synced,
|
2026-07-29 00:08:48 -05:00
|
|
|
"needs_attention": flagged, "fast_sweep": swept,
|
2026-07-28 22:51:33 -05:00
|
|
|
"sections": {k: v for k, v in texts.items() if not k.startswith("_")},
|
|
|
|
|
"recent_sessions": sorted((f for f in listing_files if f.endswith(".md")),
|
|
|
|
|
reverse=True)[:5]}
|
|
|
|
|
if brief:
|
|
|
|
|
_fetch_pointed_session(texts)
|
|
|
|
|
data["brief"] = _render_brief(texts, listing_files, offline)
|
|
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 22:34:42 -05:00
|
|
|
def cmd_load(brief: bool = False) -> int:
|
|
|
|
|
"""Cold-start orientation: the canonical 6 reads in one call (fetched in parallel).
|
|
|
|
|
404s on today's daily note and the inbox are normal (printed as absent, not errors).
|
|
|
|
|
`brief` renders the token-budgeted digest the SessionStart hook injects; the default
|
|
|
|
|
full mode (and /echo-load) prints the raw sections unchanged."""
|
2026-06-23 22:17:39 -05:00
|
|
|
if not echo_config.is_configured(_CFG):
|
|
|
|
|
print("echo: NOT CONFIGURED — this machine has no usable ECHO key file yet.")
|
|
|
|
|
print(f"Expected at: {echo_config.config_path()}")
|
|
|
|
|
print("ACTION: ask the operator for their echo-memory config file (it holds the")
|
|
|
|
|
print(" vault owner, endpoint, and API key), then install it with ONE of:")
|
|
|
|
|
print(" python3 echo.py config import <path-to-their-file>")
|
|
|
|
|
print(" python3 echo.py config set --owner \"…\" --endpoint \"https://…\" --key \"…\"")
|
|
|
|
|
print("Then re-run load. (Memory is unavailable until configured.)")
|
|
|
|
|
return 78 # distinct: configuration required
|
2026-07-28 22:51:33 -05:00
|
|
|
targets = LOAD_TARGETS + [("today", f"journal/daily/{today()}.md"),
|
|
|
|
|
("inbox", "inbox/captures/inbox.md")]
|
2026-06-22 09:27:36 -05:00
|
|
|
import echo_queue
|
|
|
|
|
# H2: sync any writes queued during a prior outage, best-effort and quiet on empty.
|
|
|
|
|
try:
|
|
|
|
|
synced = echo_queue.flush()
|
|
|
|
|
if synced:
|
|
|
|
|
print(f"(synced {synced} write(s) queued during a prior offline session)\n")
|
2026-07-28 22:34:42 -05:00
|
|
|
flagged = echo_queue.needs_attention()
|
|
|
|
|
if flagged:
|
|
|
|
|
print(f"NOTE: {len(flagged)} queued write(s) need attention (e.g. a capture "
|
|
|
|
|
"stopped at the duplicate gate on replay) — resolve with capture "
|
|
|
|
|
"--merge-into/--force; `echo.py flush` re-lists them.\n")
|
2026-06-22 09:27:36 -05:00
|
|
|
except Exception as exc: # noqa: BLE001 — never let queue upkeep block a load
|
|
|
|
|
print(f"(queue flush skipped: {exc})\n", file=sys.stderr)
|
|
|
|
|
|
2026-07-28 22:51:33 -05:00
|
|
|
results, texts, listing_files, offline, marker_missing, heartbeat_absent = \
|
|
|
|
|
_load_gather(targets)
|
2026-07-28 22:34:42 -05:00
|
|
|
|
2026-07-29 00:08:48 -05:00
|
|
|
swept = _auto_fast_sweep(offline)
|
|
|
|
|
if swept:
|
|
|
|
|
print(f"({swept})\n")
|
|
|
|
|
|
2026-07-28 22:34:42 -05:00
|
|
|
if brief:
|
2026-07-28 22:51:33 -05:00
|
|
|
_fetch_pointed_session(texts)
|
2026-07-28 22:34:42 -05:00
|
|
|
print(_render_brief(texts, listing_files, offline))
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
# ---- full mode: raw sections, output format unchanged ----
|
|
|
|
|
for label, path in targets:
|
|
|
|
|
status, body = results[path]
|
|
|
|
|
if status == 200:
|
2026-06-22 09:27:36 -05:00
|
|
|
print(f"===== {label}: {path} (HTTP 200) =====")
|
2026-07-28 22:34:42 -05:00
|
|
|
text = texts[label] or ""
|
2026-06-21 11:46:54 -05:00
|
|
|
sys.stdout.write(text)
|
|
|
|
|
if not text.endswith("\n"):
|
|
|
|
|
sys.stdout.write("\n")
|
|
|
|
|
elif status == 404:
|
2026-06-22 09:27:36 -05:00
|
|
|
print(f"===== {label}: {path} (HTTP 404) =====")
|
2026-06-21 11:46:54 -05:00
|
|
|
print("(absent — fine)")
|
2026-07-28 22:34:42 -05:00
|
|
|
elif status == 0:
|
|
|
|
|
if texts[label] is not None:
|
2026-06-22 09:27:36 -05:00
|
|
|
print(f"===== {label}: {path} (OFFLINE — serving stale cache) =====")
|
2026-07-28 22:34:42 -05:00
|
|
|
sys.stdout.write(texts[label])
|
|
|
|
|
if not texts[label].endswith("\n"):
|
2026-06-22 09:27:36 -05:00
|
|
|
sys.stdout.write("\n")
|
|
|
|
|
else:
|
|
|
|
|
print(f"===== {label}: {path} (OFFLINE — no cache) =====")
|
|
|
|
|
print("(unreachable and not cached)")
|
2026-06-21 11:46:54 -05:00
|
|
|
else:
|
2026-06-22 09:27:36 -05:00
|
|
|
print(f"===== {label}: {path} (HTTP {status}) =====")
|
2026-06-21 11:46:54 -05:00
|
|
|
print(f"(error HTTP {status}: {body.decode(errors='replace')[:200]})")
|
|
|
|
|
print()
|
2026-06-22 09:27:36 -05:00
|
|
|
# M3: heartbeat pointer missing/stale -> fall back to the recent sessions listing
|
2026-07-28 22:34:42 -05:00
|
|
|
# (already fetched in the batch) so orientation works without the pointer.
|
|
|
|
|
if heartbeat_absent and not offline and listing_files:
|
|
|
|
|
files = sorted((f for f in listing_files if f.endswith(".md")), reverse=True)
|
|
|
|
|
if files:
|
|
|
|
|
print("===== recent sessions (heartbeat absent — fallback) =====")
|
|
|
|
|
for f in files[:5]:
|
|
|
|
|
print(f" _agent/sessions/{f}")
|
|
|
|
|
print()
|
2026-06-22 09:27:36 -05:00
|
|
|
if offline:
|
|
|
|
|
print("NOTE: vault unreachable — context above is last-known-good cache; writes this "
|
|
|
|
|
"session will be queued and synced when the vault returns.")
|
2026-06-21 11:46:54 -05:00
|
|
|
if marker_missing:
|
|
|
|
|
print("NOTE: marker absent — vault not bootstrapped. Run bootstrap.py before relying on memory.")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 22:17:39 -05:00
|
|
|
def cmd_config(args) -> int:
|
|
|
|
|
"""show | init | set the machine-local owner/endpoint/key config."""
|
|
|
|
|
path = echo_config.config_path()
|
|
|
|
|
if args.action == "init":
|
|
|
|
|
p, created = echo_config.init_template()
|
|
|
|
|
print(f"ok: wrote config template to {p} — edit it with your owner/endpoint/key"
|
|
|
|
|
if created else f"config already exists at {p} (left unchanged)")
|
|
|
|
|
return 0
|
|
|
|
|
if args.action == "import":
|
|
|
|
|
if not args.path:
|
|
|
|
|
print("config import: pass the path to a config file "
|
|
|
|
|
"(JSON with owner/endpoint/key)", file=sys.stderr)
|
|
|
|
|
return 2
|
|
|
|
|
try:
|
|
|
|
|
p = echo_config.import_file(args.path)
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
print(exc, file=sys.stderr)
|
|
|
|
|
return 2
|
|
|
|
|
print(f"ok: imported config to {p} (chmod 600 best-effort)")
|
|
|
|
|
return 0
|
|
|
|
|
if args.action == "set":
|
|
|
|
|
if args.owner is None and args.endpoint is None and args.key is None:
|
|
|
|
|
print("config set: pass at least one of --owner / --endpoint / --key", file=sys.stderr)
|
|
|
|
|
return 2
|
|
|
|
|
p = echo_config.write_config(args.owner, args.endpoint, args.key)
|
|
|
|
|
print(f"ok: updated {p} (chmod 600 best-effort)")
|
|
|
|
|
return 0
|
|
|
|
|
# show — never prints the key in the clear
|
|
|
|
|
cfg = echo_config.load()
|
|
|
|
|
key = cfg["key"]
|
|
|
|
|
redacted = (key[:4] + "…" + key[-4:]) if len(key) >= 12 else ("set" if key else "")
|
|
|
|
|
print(f"config file: {path}" + ("" if path.exists() else " (absent)"))
|
|
|
|
|
for field, shown in (("owner", cfg["owner"]), ("endpoint", cfg["endpoint"]), ("key", redacted)):
|
|
|
|
|
src = echo_config.source(field)
|
|
|
|
|
print(f" {field:9} {shown or '(unset)'} [{src}]")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 11:46:54 -05:00
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
|
|
|
parser = argparse.ArgumentParser(description="Validated cross-platform ECHO vault client")
|
|
|
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
2026-07-28 22:34:42 -05:00
|
|
|
p = sub.add_parser("load")
|
|
|
|
|
p.add_argument("--brief", action="store_true") # token-budgeted digest (hook default)
|
2026-06-22 09:27:36 -05:00
|
|
|
sub.add_parser("flush") # H2: replay writes queued during a prior outage
|
|
|
|
|
sub.add_parser("doctor") # M3: one-call readiness check
|
2026-06-21 11:46:54 -05:00
|
|
|
sub.add_parser("get").add_argument("path")
|
|
|
|
|
sub.add_parser("map").add_argument("path")
|
|
|
|
|
sub.add_parser("ls").add_argument("path")
|
|
|
|
|
sub.add_parser("search").add_argument("query", nargs="+")
|
|
|
|
|
p = sub.add_parser("put"); p.add_argument("path"); p.add_argument("file", nargs="?")
|
|
|
|
|
p = sub.add_parser("post"); p.add_argument("path"); p.add_argument("file", nargs="?")
|
|
|
|
|
p = sub.add_parser("append"); p.add_argument("path"); p.add_argument("line")
|
|
|
|
|
p = sub.add_parser("patch")
|
|
|
|
|
p.add_argument("path"); p.add_argument("op"); p.add_argument("target_type")
|
|
|
|
|
p.add_argument("target"); p.add_argument("file", nargs="?")
|
|
|
|
|
p = sub.add_parser("fm"); p.add_argument("path"); p.add_argument("field"); p.add_argument("value")
|
|
|
|
|
p = sub.add_parser("bump"); p.add_argument("path"); p.add_argument("date", nargs="?")
|
|
|
|
|
sub.add_parser("delete").add_argument("path")
|
|
|
|
|
sub.add_parser("lock").add_argument("owner")
|
|
|
|
|
sub.add_parser("unlock").add_argument("owner")
|
2026-06-23 22:17:39 -05:00
|
|
|
p = sub.add_parser("config") # machine-local owner/endpoint/key (see echo_config)
|
|
|
|
|
p.add_argument("action", nargs="?", default="show", choices=["show", "init", "set", "import"])
|
|
|
|
|
p.add_argument("path", nargs="?") # for `config import <path>`
|
|
|
|
|
p.add_argument("--owner"); p.add_argument("--endpoint"); p.add_argument("--key")
|
2026-06-21 11:46:54 -05:00
|
|
|
p = sub.add_parser("scope")
|
|
|
|
|
p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"])
|
|
|
|
|
p.add_argument("text", nargs="?")
|
2026-07-03 12:57:52 -05:00
|
|
|
p.add_argument("--json", action="store_true") # structured scope + freshness
|
2026-06-22 09:27:36 -05:00
|
|
|
p = sub.add_parser("reflect") # H5: apply a JSON proposal set (dry-run unless --apply)
|
|
|
|
|
p.add_argument("file", nargs="?")
|
|
|
|
|
p.add_argument("--apply", action="store_true")
|
2026-07-28 22:34:42 -05:00
|
|
|
p = sub.add_parser("session-end") # 2.1.0: one-call session end (log+agent-log+reflect+scope+heartbeat)
|
|
|
|
|
p.add_argument("file", nargs="?") # bundle JSON; - or stdin
|
|
|
|
|
p.add_argument("--apply", action="store_true")
|
2026-07-03 12:57:52 -05:00
|
|
|
p = sub.add_parser("triage") # one-tap inbox triage (reflect pipeline + audit log)
|
|
|
|
|
p.add_argument("file", nargs="?") # proposals JSON; omit to list
|
|
|
|
|
p.add_argument("--list", action="store_true", dest="list_inbox")
|
|
|
|
|
p.add_argument("--json", action="store_true") # structured inbox listing
|
|
|
|
|
p.add_argument("--apply", action="store_true") # route + write processing log
|
2026-06-21 11:46:54 -05:00
|
|
|
|
|
|
|
|
# High-level operations (entity index + cross-links + recall). See echo_ops.py.
|
|
|
|
|
sub.add_parser("resolve").add_argument("mention", nargs="+")
|
|
|
|
|
p = sub.add_parser("recall"); p.add_argument("query", nargs="+"); p.add_argument("--limit", type=int, default=6)
|
2026-07-03 12:57:52 -05:00
|
|
|
p.add_argument("--json", action="store_true") # structured hits + neighbourhood
|
2026-06-21 11:46:54 -05:00
|
|
|
p = sub.add_parser("link"); p.add_argument("a"); p.add_argument("b")
|
2026-07-03 12:57:52 -05:00
|
|
|
p.add_argument("--json", action="store_true")
|
2026-06-21 11:46:54 -05:00
|
|
|
p = sub.add_parser("capture")
|
|
|
|
|
p.add_argument("title")
|
|
|
|
|
p.add_argument("file", nargs="?")
|
|
|
|
|
p.add_argument("--kind")
|
|
|
|
|
p.add_argument("--inbox", action="store_true")
|
|
|
|
|
p.add_argument("--status", default="")
|
|
|
|
|
p.add_argument("--aliases", default="")
|
|
|
|
|
p.add_argument("--source", default="")
|
2026-07-03 12:57:52 -05:00
|
|
|
p.add_argument("--tags", default="") # extra tags; the kind is always seeded
|
2026-06-21 11:46:54 -05:00
|
|
|
p.add_argument("--date")
|
|
|
|
|
p.add_argument("--domain", default="business")
|
|
|
|
|
p.add_argument("--no-log", action="store_true")
|
2026-06-22 09:27:36 -05:00
|
|
|
p.add_argument("--json", action="store_true") # M4: emit a JSON result envelope
|
|
|
|
|
p.add_argument("--dry-run", action="store_true") # M4: preview the plan, write nothing
|
2026-07-03 12:57:52 -05:00
|
|
|
p.add_argument("--force", action="store_true") # create despite a duplicate-gate hit
|
|
|
|
|
p.add_argument("--merge-into", metavar="SLUG") # route as an update to this entity
|
2026-06-21 11:46:54 -05:00
|
|
|
return parser
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
|
|
|
args = build_parser().parse_args(argv)
|
|
|
|
|
try:
|
|
|
|
|
if args.cmd == "load":
|
2026-07-28 22:34:42 -05:00
|
|
|
return cmd_load(brief=args.brief)
|
2026-06-22 09:27:36 -05:00
|
|
|
if args.cmd == "doctor":
|
|
|
|
|
import echo_doctor
|
|
|
|
|
return echo_doctor.run()
|
|
|
|
|
if args.cmd == "flush":
|
|
|
|
|
import echo_queue
|
|
|
|
|
n = echo_queue.flush()
|
2026-07-28 22:34:42 -05:00
|
|
|
remaining = echo_queue.pending()
|
|
|
|
|
flagged = [r for r in remaining if r.get("needs_attention")]
|
2026-06-22 09:27:36 -05:00
|
|
|
if n:
|
|
|
|
|
print(f"ok: flushed {n} queued write(s)"
|
2026-07-28 22:34:42 -05:00
|
|
|
+ (f"; {len(remaining)} still queued" if remaining else ""))
|
2026-06-22 09:27:36 -05:00
|
|
|
elif remaining:
|
2026-07-28 22:34:42 -05:00
|
|
|
print(f"ok: {len(remaining)} write(s) still queued")
|
2026-06-22 09:27:36 -05:00
|
|
|
else:
|
|
|
|
|
print("ok: queue empty")
|
2026-07-28 22:34:42 -05:00
|
|
|
for r in flagged:
|
|
|
|
|
what = (f"capture '{(r.get('args') or {}).get('title')}'" if r.get("op") == "capture"
|
|
|
|
|
else f"{r.get('method')} {r.get('url')}")
|
|
|
|
|
print(f" needs attention ({r['needs_attention']}): {what} — resolve "
|
|
|
|
|
"(e.g. capture --merge-into <slug> / --force), it will not auto-land")
|
2026-06-22 09:27:36 -05:00
|
|
|
return 0
|
2026-06-23 22:17:39 -05:00
|
|
|
if args.cmd == "config":
|
|
|
|
|
return cmd_config(args)
|
2026-06-21 11:46:54 -05:00
|
|
|
if args.cmd == "get":
|
|
|
|
|
return cmd_get(args.path)
|
|
|
|
|
if args.cmd == "map":
|
|
|
|
|
return cmd_map(args.path)
|
|
|
|
|
if args.cmd == "ls":
|
|
|
|
|
return cmd_ls(args.path)
|
|
|
|
|
if args.cmd == "search":
|
|
|
|
|
return cmd_search(args.query)
|
|
|
|
|
if args.cmd == "put":
|
|
|
|
|
return cmd_put(args.path, args.file)
|
|
|
|
|
if args.cmd == "post":
|
|
|
|
|
return cmd_post(args.path, args.file)
|
|
|
|
|
if args.cmd == "append":
|
|
|
|
|
return cmd_append(args.path, args.line)
|
|
|
|
|
if args.cmd == "patch":
|
|
|
|
|
return cmd_patch(args.path, args.op, args.target_type, args.target, args.file)
|
|
|
|
|
if args.cmd == "fm":
|
|
|
|
|
return cmd_fm(args.path, args.field, args.value)
|
|
|
|
|
if args.cmd == "bump":
|
|
|
|
|
return cmd_fm(args.path, "updated", json.dumps(args.date or today()))
|
|
|
|
|
if args.cmd == "delete":
|
|
|
|
|
return cmd_delete(args.path)
|
|
|
|
|
if args.cmd == "lock":
|
|
|
|
|
return cmd_lock(args.owner)
|
|
|
|
|
if args.cmd == "unlock":
|
|
|
|
|
return cmd_unlock(args.owner)
|
|
|
|
|
if args.cmd == "scope":
|
2026-07-03 12:57:52 -05:00
|
|
|
return cmd_scope(args.subcommand, args.text, as_json=args.json)
|
2026-06-22 09:27:36 -05:00
|
|
|
if args.cmd == "reflect":
|
|
|
|
|
import echo_reflect
|
|
|
|
|
raw = read_body(args.file).decode("utf-8", errors="replace")
|
|
|
|
|
try:
|
|
|
|
|
proposals = json.loads(raw)
|
|
|
|
|
except json.JSONDecodeError as exc:
|
|
|
|
|
raise EchoError(f"reflect: proposals must be a JSON array ({exc})", 2)
|
|
|
|
|
if not isinstance(proposals, list):
|
|
|
|
|
raise EchoError("reflect: proposals must be a JSON array of objects", 2)
|
|
|
|
|
return echo_reflect.apply(proposals, confirm=args.apply)
|
2026-07-28 22:34:42 -05:00
|
|
|
if args.cmd == "session-end":
|
|
|
|
|
import echo_session
|
|
|
|
|
raw = read_body(args.file).decode("utf-8", errors="replace")
|
|
|
|
|
try:
|
|
|
|
|
bundle = json.loads(raw)
|
|
|
|
|
except json.JSONDecodeError as exc:
|
|
|
|
|
raise EchoError(f"session-end: bundle must be a JSON object ({exc})", 2)
|
|
|
|
|
return echo_session.session_end(bundle, apply=args.apply)
|
2026-07-03 12:57:52 -05:00
|
|
|
if args.cmd == "triage":
|
|
|
|
|
import echo_triage
|
|
|
|
|
if args.list_inbox or not args.file:
|
|
|
|
|
return echo_triage.list_inbox(as_json=args.json)
|
|
|
|
|
raw = read_body(args.file).decode("utf-8", errors="replace")
|
|
|
|
|
try:
|
|
|
|
|
proposals = json.loads(raw)
|
|
|
|
|
except json.JSONDecodeError as exc:
|
|
|
|
|
raise EchoError(f"triage: proposals must be a JSON array ({exc})", 2)
|
|
|
|
|
if not isinstance(proposals, list):
|
|
|
|
|
raise EchoError("triage: proposals must be a JSON array of objects", 2)
|
|
|
|
|
return echo_triage.apply(proposals, confirm=args.apply)
|
2026-06-21 11:46:54 -05:00
|
|
|
if args.cmd in ("resolve", "recall", "link", "capture"):
|
|
|
|
|
import echo_ops # lazy: only the high-level ops pull in the index/link modules
|
|
|
|
|
if args.cmd == "resolve":
|
|
|
|
|
return echo_ops.resolve(" ".join(args.mention))
|
|
|
|
|
if args.cmd == "recall":
|
2026-07-03 12:57:52 -05:00
|
|
|
return echo_ops.recall(args.query, limit=args.limit, as_json=args.json)
|
2026-06-21 11:46:54 -05:00
|
|
|
if args.cmd == "link":
|
2026-07-03 12:57:52 -05:00
|
|
|
return echo_ops.link(args.a, args.b, as_json=args.json)
|
2026-06-21 11:46:54 -05:00
|
|
|
return echo_ops.capture(
|
|
|
|
|
args.kind, args.title, args.file, status_v=args.status,
|
|
|
|
|
aliases=args.aliases.split(",") if args.aliases else [],
|
|
|
|
|
sources=args.source.split(",") if args.source else [],
|
2026-07-03 12:57:52 -05:00
|
|
|
tags=args.tags.split(",") if args.tags else [],
|
2026-06-22 09:27:36 -05:00
|
|
|
date=args.date, domain=args.domain, inbox=args.inbox, no_log=args.no_log,
|
2026-07-03 12:57:52 -05:00
|
|
|
as_json=args.json, dry_run=args.dry_run,
|
|
|
|
|
force=args.force, merge_into=args.merge_into)
|
2026-07-28 22:34:42 -05:00
|
|
|
except RuntimeError as exc:
|
|
|
|
|
# Catches EchoError from THIS module and from helper modules' own `import echo`
|
|
|
|
|
# twin (when echo.py runs as __main__ the two class objects differ, but both
|
|
|
|
|
# subclass RuntimeError and carry .code).
|
2026-06-21 11:46:54 -05:00
|
|
|
print(f"echo.py: {exc}", file=sys.stderr)
|
2026-07-28 22:34:42 -05:00
|
|
|
return getattr(exc, "code", 1)
|
2026-06-21 11:46:54 -05:00
|
|
|
return 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|