forked from jason/echo
ver 1.5.0 — six-improvement pass: retention, routing, self-firing memory
1. capture-update keeps the whole body (was truncating to first line) 2. frontmatter completeness: kind-default status + kind-seeded tags at capture, fm create-or-replace, incomplete-frontmatter lint, sweep backfill (one data source: KIND_STATUS/KIND_REQUIRED_FM) 3. pre-write duplicate gate (exit 76, --merge-into/--force) 4. recall corpus + ranking: sessions/journal indexed (down-weighted), BM25 x freshness x status fusion, recall --json, index schema 2 5. session hooks: SessionStart auto-load, Stop reflection nudge 6. one-tap triage verb with processing-log audit; --json on read verbs +22 mock end-to-end tests; all suites green. Docs current (README, CHANGELOG, SKILL.md, commands, references, MAINTENANCE, eval README). Drops tracked .DS_Store (gitignored); retires README-gretchen.md (moved to gitignored dist/); adds the field report that drove item 2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,8 +40,9 @@ Usage:
|
||||
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]
|
||||
echo.py fm <path> <field> <json-value> # PATCH a frontmatter scalar
|
||||
echo.py fm <path> <field> <json-value> # create-or-replace a frontmatter scalar
|
||||
echo.py bump <path> [YYYY-MM-DD] # set frontmatter updated: to today (or given date)
|
||||
echo.py triage [--list --json | <proposals.json> [--apply]] # one-tap inbox triage + audit log
|
||||
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>
|
||||
@@ -49,7 +50,8 @@ Usage:
|
||||
echo.py scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated)
|
||||
echo.py config [show|init|set|import] # machine-local owner/endpoint/key (config import <file> adopts a provided file)
|
||||
|
||||
Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 78 config-required · 2 usage · 1 other HTTP/transport error.
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -60,6 +62,7 @@ import http.client
|
||||
import json
|
||||
import locale
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -339,6 +342,14 @@ def cmd_put(path: str, file_arg: str | None) -> int:
|
||||
if status != 200:
|
||||
raise EchoError(f"put {path}: write did not verify (GET returned {status})")
|
||||
print(f"ok: PUT {path}")
|
||||
# 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)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -402,8 +413,64 @@ def cmd_patch(path: str, op: str, target_type: str, target: str, file_arg: str |
|
||||
return 0
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def cmd_fm(path: str, field: str, value: str) -> int:
|
||||
return cmd_patch(path, "replace", "frontmatter", field, temp_file(normalize_json_scalar(value)))
|
||||
"""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
|
||||
|
||||
|
||||
def cmd_delete(path: str) -> int:
|
||||
@@ -482,31 +549,39 @@ def extract_heading(markdown: str, heading: str) -> str:
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def cmd_scope(subcommand: str, text: str | None = None) -> int:
|
||||
def cmd_scope(subcommand: str, text: str | None = None, as_json: bool = False) -> int:
|
||||
path = "_agent/context/current-context.md"
|
||||
status, body = request("GET", vault_url(path))
|
||||
check(status, body, f"scope {subcommand}")
|
||||
current = body.decode(errors="replace")
|
||||
|
||||
if subcommand == "show":
|
||||
print("-- Active scope --")
|
||||
print(extract_heading(current, "Scope"))
|
||||
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
|
||||
print(f"scope_updated: {scope_updated or '<missing — drift cannot be detected; run scope set or repair>'}")
|
||||
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", [])
|
||||
n = sum(1 for f in files if f.endswith(".md") and f[:10] > scope_updated)
|
||||
print(f"sessions logged since: {n}")
|
||||
sessions_since = sum(1 for f in files if f.endswith(".md") and f[:10] > scope_updated)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if as_json:
|
||||
print(json.dumps({"ok": True, "action": "scope-show", "scope": scope_text,
|
||||
"scope_updated": scope_updated or None,
|
||||
"sessions_since": sessions_since}, ensure_ascii=False))
|
||||
return 0
|
||||
print("-- Active scope --")
|
||||
print(scope_text)
|
||||
print(f"scope_updated: {scope_updated or '<missing — drift cannot be detected; run scope set or repair>'}")
|
||||
if sessions_since is not None:
|
||||
print(f"sessions logged since: {sessions_since}")
|
||||
return 0
|
||||
|
||||
if subcommand != "set":
|
||||
@@ -682,14 +757,22 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
p = sub.add_parser("scope")
|
||||
p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"])
|
||||
p.add_argument("text", nargs="?")
|
||||
p.add_argument("--json", action="store_true") # structured scope + freshness
|
||||
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")
|
||||
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
|
||||
|
||||
# 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)
|
||||
p.add_argument("--json", action="store_true") # structured hits + neighbourhood
|
||||
p = sub.add_parser("link"); p.add_argument("a"); p.add_argument("b")
|
||||
p.add_argument("--json", action="store_true")
|
||||
p = sub.add_parser("capture")
|
||||
p.add_argument("title")
|
||||
p.add_argument("file", nargs="?")
|
||||
@@ -698,11 +781,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
p.add_argument("--status", default="")
|
||||
p.add_argument("--aliases", default="")
|
||||
p.add_argument("--source", default="")
|
||||
p.add_argument("--tags", default="") # extra tags; the kind is always seeded
|
||||
p.add_argument("--date")
|
||||
p.add_argument("--domain", default="business")
|
||||
p.add_argument("--no-log", action="store_true")
|
||||
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
|
||||
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
|
||||
return parser
|
||||
|
||||
|
||||
@@ -755,7 +841,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.cmd == "unlock":
|
||||
return cmd_unlock(args.owner)
|
||||
if args.cmd == "scope":
|
||||
return cmd_scope(args.subcommand, args.text)
|
||||
return cmd_scope(args.subcommand, args.text, as_json=args.json)
|
||||
if args.cmd == "reflect":
|
||||
import echo_reflect
|
||||
raw = read_body(args.file).decode("utf-8", errors="replace")
|
||||
@@ -766,20 +852,34 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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)
|
||||
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)
|
||||
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":
|
||||
return echo_ops.recall(args.query, limit=args.limit)
|
||||
return echo_ops.recall(args.query, limit=args.limit, as_json=args.json)
|
||||
if args.cmd == "link":
|
||||
return echo_ops.link(args.a, args.b)
|
||||
return echo_ops.link(args.a, args.b, as_json=args.json)
|
||||
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 [],
|
||||
tags=args.tags.split(",") if args.tags else [],
|
||||
date=args.date, domain=args.domain, inbox=args.inbox, no_log=args.no_log,
|
||||
as_json=args.json, dry_run=args.dry_run)
|
||||
as_json=args.json, dry_run=args.dry_run,
|
||||
force=args.force, merge_into=args.merge_into)
|
||||
except EchoError as exc:
|
||||
print(f"echo.py: {exc}", file=sys.stderr)
|
||||
return exc.code
|
||||
|
||||
Reference in New Issue
Block a user