Memory tools: never expose raw memo commands to agents

- Add MEMO_TOOLS=1 env var to memo script; when set, all printed
  instructions reference native tool names instead of memo paths
- Set MEMO_TOOLS=1 in all memory tool .meta wrappers
- Add memory_nap tool for compressions
- Add memory_zoom tool for tree navigation
- Add part/T pagination args to memory_wake
- Update system prompt to use memory_zoom tool call
- Fix inject ctl: submit as user message when agent is idle
This commit is contained in:
Levi Neely 2026-08-19 10:13:02 +02:00
parent 9bb2e41e33
commit b4baddd826
9 changed files with 101 additions and 27 deletions

View File

@ -831,7 +831,14 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
if text == "" {
return nil, fmt.Errorf("inject requires text")
}
a.InjectRewrite(text)
if a.IsRunning() {
a.InjectRewrite(text)
} else {
startAsync(s.Ctx, func() {
a.Submit(s.Ctx, text)
a.EnsureTrailingNewline()
})
}
return []byte("ok\n"), nil
},
"i": func(args []string) ([]byte, error) {
@ -839,7 +846,14 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
if text == "" {
return nil, fmt.Errorf("inject requires text")
}
a.InjectRewrite(text)
if a.IsRunning() {
a.InjectRewrite(text)
} else {
startAsync(s.Ctx, func() {
a.Submit(s.Ctx, text)
a.EnsureTrailingNewline()
})
}
return []byte("ok\n"), nil
},
"agent": func(args []string) ([]byte, error) {

View File

@ -117,9 +117,8 @@ If `memory_remember` asks a compression: do it before your next action.
Your memories also form a binary tree: #0-1, #2-3 ... exist as one-line summaries, pairs of those as #0-3, and so on — every `#a-b` line wake prints is one node of it. To open a node into its two halves, down to the raw memories:
```bash
MEMORY_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/ollie/optmem" \
"${XDG_CONFIG_HOME:-$HOME/.config}/ollie/optmem/memo" zoom <a-b>
```
memory_zoom(range="<a-b>")
```
## If you're a subagent: skip everything above

View File

@ -9,6 +9,8 @@
"memory_wake",
"memory_recall",
"memory_remember",
"memory_nap",
"memory_zoom",
"file_read",
"file_edit",
"file_glob",

View File

@ -0,0 +1,14 @@
{
"description": "Submit a memory compression (nap) to OptMem.",
"prompt": "## memory_nap\n\nSubmit a compression for a memory block range. Called when memory_wake or memory_remember prints a compression prompt.\n\n**Calling convention:**\n```\nmemory_nap(range=\"0-1\", text=\"compressed one-line summary\")\n```\n- `range`: block range as printed by the compression prompt (e.g. \"0-1\")\n- `text`: the compressed one-line summary (max 280 bytes)\n\nIf more compressions remain, the tool prints the next one.",
"cmd": "input=$(cat); range=$(printf '%s' \"$input\" | jq -er '.range'); text=$(printf '%s' \"$input\" | jq -er '.text'); MEMO_TOOLS=1 MEMORY_DIR=\"${XDG_DATA_HOME:-$HOME/.local/share}/ollie/optmem\" exec \"${XDG_CONFIG_HOME:-$HOME/.config}/ollie/optmem/memo\" nap \"$range\" \"$text\"",
"args": {
"type": "object",
"required": ["range", "text"],
"properties": {
"range": {"type": "string", "description": "Block range (e.g. \"0-1\")"},
"text": {"type": "string", "description": "Compressed one-line summary (max 280 bytes)"}
}
},
"scope": "global"
}

View File

@ -1,7 +1,7 @@
{
"description": "Search stored memories for relevant context.",
"prompt": "## memory_recall\n\nSearch stored memories for relevant context using OptMem's bounded memory index.\n\n**Calling convention:**\n```\nmemory_recall(query=\"keyword\")\n```\n- `query`: regular expression or short search term.\n\n**Returns:** matching memory records from the persistent OptMem store.",
"cmd": "input=$(cat); query=$(printf '%s' \"$input\" | jq -er '.query'); MEMORY_DIR=\"${XDG_DATA_HOME:-$HOME/.local/share}/ollie/optmem\" exec \"${XDG_CONFIG_HOME:-$HOME/.config}/ollie/optmem/memo\" recall \"$query\"",
"cmd": "input=$(cat); query=$(printf '%s' \"$input\" | jq -er '.query'); MEMO_TOOLS=1 MEMORY_DIR=\"${XDG_DATA_HOME:-$HOME/.local/share}/ollie/optmem\" exec \"${XDG_CONFIG_HOME:-$HOME/.config}/ollie/optmem/memo\" recall \"$query\"",
"args": {
"type": "object",
"required": ["query"],

View File

@ -1,7 +1,7 @@
{
"description": "Persist a fact that would otherwise be lost when the session ends.",
"prompt": "## memory_remember\n\nPersist a durable fact in OptMem's append-only memory store.\n\n**Calling convention:**\n```\nmemory_remember(title=\"...\", tags=\"...\", body=\"...\")\n```\n- `title`: short noun phrase\n- `tags`: comma-separated tags\n- `body`: one standalone fact, no more than 280 bytes\n\nThe title, tags, and body are stored as one searchable memory record.",
"cmd": "input=$(cat); title=$(printf '%s' \"$input\" | jq -er '.title'); tags=$(printf '%s' \"$input\" | jq -er '.tags'); body=$(printf '%s' \"$input\" | jq -er '.body'); record=\"[$tags] $title: $body\"; bytes=$(printf '%s' \"$record\" | wc -c); [ \"$bytes\" -le 280 ] || { printf 'memory exceeds OptMem limit: %s bytes (maximum 280)\\n' \"$bytes\" >&2; exit 1; }; MEMORY_DIR=\"${XDG_DATA_HOME:-$HOME/.local/share}/ollie/optmem\" exec \"${XDG_CONFIG_HOME:-$HOME/.config}/ollie/optmem/memo\" note \"$record\"",
"cmd": "input=$(cat); title=$(printf '%s' \"$input\" | jq -er '.title'); tags=$(printf '%s' \"$input\" | jq -er '.tags'); body=$(printf '%s' \"$input\" | jq -er '.body'); record=\"[$tags] $title: $body\"; bytes=$(printf '%s' \"$record\" | wc -c); [ \"$bytes\" -le 280 ] || { printf 'memory exceeds OptMem limit: %s bytes (maximum 280)\\n' \"$bytes\" >&2; exit 1; }; MEMO_TOOLS=1 MEMORY_DIR=\"${XDG_DATA_HOME:-$HOME/.local/share}/ollie/optmem\" exec \"${XDG_CONFIG_HOME:-$HOME/.config}/ollie/optmem/memo\" note \"$record\"",
"args": {
"type": "object",
"required": ["title", "tags", "body"],

View File

@ -1,10 +1,13 @@
{
"description": "Load the bounded OptMem context at session startup.",
"prompt": "## memory_wake\n\nLoad the bounded persistent memory context. Run this once before other tools at the start of every top-level session. Follow any printed OptMem compression instruction before continuing. Do not run from a sub-agent.",
"cmd": "MEMORY_DIR=\"${XDG_DATA_HOME:-$HOME/.local/share}/ollie/optmem\" exec \"${XDG_CONFIG_HOME:-$HOME/.config}/ollie/optmem/memo\" wake",
"cmd": "input=$(cat); part=$(printf '%s' \"$input\" | jq -r '.part // empty'); T=$(printf '%s' \"$input\" | jq -r '.T // empty'); args=''; [ -n \"$part\" ] && args=\"$part\"; [ -n \"$T\" ] && args=\"$args $T\"; MEMO_TOOLS=1 MEMORY_DIR=\"${XDG_DATA_HOME:-$HOME/.local/share}/ollie/optmem\" exec \"${XDG_CONFIG_HOME:-$HOME/.config}/ollie/optmem/memo\" wake $args",
"args": {
"type": "object",
"properties": {}
"properties": {
"part": {"type": "integer", "description": "Page number for paginated memory (default: 1)"},
"T": {"type": "integer", "description": "Memory snapshot count (used with part for pagination)"}
}
},
"tier": "cold",
"scope": "read"

View File

@ -0,0 +1,14 @@
{
"description": "Expand a memory tree node into its two halves.",
"prompt": "## memory_zoom\n\nOpen a memory tree node into its two halves, down to raw memories.\n\n**Calling convention:**\n```\nmemory_zoom(range=\"0-3\")\n```\n- `range`: block range as printed by memory_wake (e.g. \"0-3\")\n\nReturns the two child nodes (summaries or raw memories).",
"cmd": "input=$(cat); range=$(printf '%s' \"$input\" | jq -er '.range'); MEMO_TOOLS=1 MEMORY_DIR=\"${XDG_DATA_HOME:-$HOME/.local/share}/ollie/optmem\" exec \"${XDG_CONFIG_HOME:-$HOME/.config}/ollie/optmem/memo\" zoom \"$range\"",
"args": {
"type": "object",
"required": ["range"],
"properties": {
"range": {"type": "string", "description": "Block range to expand (e.g. \"0-3\")"}
}
},
"tier": "cold",
"scope": "read"
}

View File

@ -44,6 +44,30 @@ def pretty(p):
# PATH, so a bare `memo nap 0-1 "..."` would not: the tool names itself.
ME = pretty(__file__)
# When called through native tool wrappers, MEMO_TOOLS=1 switches all printed
# instructions to reference tool names instead of raw memo commands.
TOOL_MODE = os.environ.get("MEMO_TOOLS") == "1"
def tool_cmd(verb, args=""):
"""Return the command string the agent should run. In tool mode this is a
tool call; otherwise it's the raw memo invocation."""
if TOOL_MODE:
MAP = {
"wake": "memory_wake",
"note": "memory_remember",
"nap": "memory_nap",
"recall": "memory_recall",
"zoom": "memory_zoom",
}
name = MAP.get(verb, "%s %s" % (ME, verb))
if args:
return "%s(%s)" % (name, args)
return name
if args:
return "%s %s %s" % (ME, verb, args)
return "%s %s" % (ME, verb)
# The sizes a memory may override in its own `config` file: the default, and
# what it means. `memo config` shows and edits them. The globals below start
# at these defaults, so a memory that overrides nothing follows the tool.
@ -482,12 +506,13 @@ def nap_prompt(d, lo, hi, left):
tail = "" if not left else "\n%s after this one." % (
"1 compression remains" if left == 1 else
"%d compressions remain" % left)
nap_cmd = tool_cmd("nap", 'range="%d-%d", text="<your line>"' % (lo, hi - 1))
return ("Compress memories #%d-%d into one line of at most %d bytes.\n"
"Keep what has lasting effect, drop what does not. Invent "
"nothing.\n\n"
"%s\n%s\n"
"Run: %s nap %d-%d \"<your line>\""
% (lo, hi - 1, ENTRY_CHARS, body, tail, ME, lo, hi - 1))
"Run: %s"
% (lo, hi - 1, ENTRY_CHARS, body, tail, nap_cmd))
def next_nap(d, T):
@ -595,13 +620,13 @@ def cmd_wake(d, args):
if len(args) == 2:
T = int(args[1])
if T > now:
die("T=%d, but the log holds %s. Run: %s wake"
% (T, plural(now, "memory"), ME))
die("T=%d, but the log holds %s. Run: %s"
% (T, plural(now, "memory"), tool_cmd("wake")))
# A part is rendered as of T, so a note landing between two parts cannot
# shift a boundary and drop a line.
if not T:
print("No memories yet. Record the first with: %s note \"<one line>\""
% ME)
print("No memories yet. Use %s to record your first one."
% tool_cmd("note"))
print("You are awake.")
return
lines = []
@ -619,9 +644,10 @@ def cmd_wake(d, args):
# costing no round trip.
print("Cannot wake: the memory context needs #%d-%d, "
"which is not compressed yet.\nDo the %s below, "
"then run %s wake again.\n"
"then run %s again.\n"
% (lo, hi - 1,
plural(pending_count(d, T), "compression"), ME))
plural(pending_count(d, T), "compression"),
tool_cmd("wake")))
print(nap)
sys.exit(1)
s = tree_get(d, lo, hi) # a parallel session may have paid it
@ -633,8 +659,8 @@ def cmd_wake(d, args):
lines.append("#%d-%d %s" % (lo, hi - 1, s))
parts = paginate(lines)
if not 1 <= k <= len(parts):
die("No part %d: the memory has %s. Run: %s wake"
% (k, plural(len(parts), "part"), ME))
die("No part %d: the memory has %s. Run: %s"
% (k, plural(len(parts), "part"), tool_cmd("wake")))
if len(parts) > 1:
# The count is here so the T in `memo wake 2 296` reads as what it
# is: the snapshot this document was written from.
@ -645,7 +671,8 @@ def cmd_wake(d, args):
# This footer is the only instruction that survives every harness's
# truncation (pi drops the HEAD of a long output), so it has to say
# both that the read is unfinished and how to continue it.
print("Not awake yet. Run: %s wake %d %d" % (ME, k + 1, T))
print("Not awake yet. Run: %s"
% tool_cmd("wake", 'part=%d, T=%d' % (k + 1, T)))
else:
# always, even for a one-part memory: the contract an agent is given
# is "run parts until one says awake", so it must always arrive
@ -683,8 +710,8 @@ def cmd_nap(d, args):
print("%d-%d is already settled." % (lo, hi - 1))
else:
die("Wrong block: %s. Blocks are built in order; the next is "
"%d-%d. Run: %s nap"
% (args[0], todo[0][0], todo[0][1] - 1, ME))
"%d-%d. Run: %s"
% (args[0], todo[0][0], todo[0][1] - 1, tool_cmd("nap")))
elif not tree_put(d, lo, hi, check(args[1])):
print("%d-%d was settled or forgotten meanwhile." % (lo, hi - 1))
else:
@ -728,8 +755,9 @@ def cmd_forget(d, args):
gone = tree_drop(d, *block_id(args[0]))
if not gone:
die("No summary at %s." % args[0])
print("Forgot %s, from %d-%d up. Run: %s nap"
% (plural(len(gone), "summary"), gone[0][0], gone[0][1] - 1, ME))
print("Forgot %s, from %d-%d up. Run: %s"
% (plural(len(gone), "summary"), gone[0][0], gone[0][1] - 1,
tool_cmd("nap")))
def cmd_recall(d, args):
@ -773,8 +801,8 @@ def cmd_zoom(d, args):
lo, hi = block_id(args[0])
T = log_len(d)
if lo >= T:
die("#%s is beyond the memory: it holds %s. Run: %s wake"
% (args[0], plural(T, "memory"), ME))
die("#%s is beyond the memory: it holds %s. Run: %s"
% (args[0], plural(T, "memory"), tool_cmd("wake")))
mid = (lo + hi) // 2
for a, b in ((lo, mid), (mid, hi)):
if a >= T:
@ -824,7 +852,7 @@ def cmd_import(d, args):
% (plural(len(out), "memory"), base, base + len(out) - 1))
n = pending_count(d, log_len(d))
if n:
print("%s pending. Run: %s nap" % (plural(n, "compression"), ME))
print("%s pending. Run: %s" % (plural(n, "compression"), tool_cmd("nap")))
COMMANDS = {"init": cmd_init, "wake": cmd_wake, "note": cmd_note,