553d86570c
RFC 001 §7.3.2 ranks "agent stamps provenance via a skill instruction" as the ❌ worst possible place — per-call boilerplate, forgettable, improvisable. It was right, and we had shipped exactly that: the mempalace skill told the agent to pass added_by="<harness>@<device>" by hand. Measured on the shared palace, 199 rows had reached it unresolvable, 10 of them filed by the very agent that wrote the instruction, in a drawer about host provenance. The trigger was a cross-host misattribution: a session on tor-ms22 read its own diary, could not tell that the entries were written on EMB-7KJ4VR4G, and reported another machine's verification as this one's. Move the same convention into the ⚠️ edge row, where it is uniform and unforgettable (§7.3.5): * extensions/pi/mempalace.ts defaults the writer field on every tool that has one — added_by (add_drawer, checkpoint), agent (mine), from_agent (event_append), created_by (artifact_put) — from $MEMPALACE_PI_DEVICE. An explicit value always wins, so filing for another device stays possible. The allowlist is per tool, never blanket: 3.8.0's dispatcher hard-rejects undeclared args with -32602, so injecting added_by into diary_write or kg_add (which have no such property) would break the call outright. * mine gets miner@<device> when the caller invokes it, but <harness>@<device> for the bridge's own transcript feed — bulk extraction is not agent-authored memory, and that keeps the pi/opencode/miner taxonomy honest. * diary_write has no metadata slot at all, and the device must never go in agent_name (wing = f"wing_{agent_name}" would splinter the diary per host). So the entry TEXT carries an AAAK field, HOST:<device>|SESSION:… — which is also the only channel a READER sees: search projects a fixed key set and diary_read returns content, so no metadata fix, not even a server-authoritative one, would have prevented the misattribution. * The wake-up block now states the device and warns that diary_read interleaves every machine's diary. * R1: doubly gated on MEMPALACE_PI_DEVICE and MEMPALACE_REMOTE_URL, so a solitary devbox stamps nothing and behaves exactly as before — which is also the correct semantics per §7.3.3. Version the reconciler that was living only on synlig (bin/ + contrib/systemd/), add --dry-run, and teach it two new rules: diary_host_marker reads the HOST: field, and sibling_chunk propagates a resolved origin across a drawer's chunks (a text marker lands in chunk 0 only, so a 5-chunk diary entry would otherwise stamp 1 and leave 4 blank). --dry-run against the real palace before deploying earned its keep twice, and scripts/test-device-stamp.sh pins both findings with the strings it found: HOST: was ALREADY in use with a composite grammar (HOST:emb-7kj4vr4g.f1d3c3f89e3e.v1.8.3.pi0.84.2) and for bare container ids, so an unvalidated rule invented devices like "f1d3c3f89e3e.pi0.84.2"; and HOST: also carries a different SENSE elsewhere (HOST:exec.via.ssh-controlmaster->…, meaning where I was executing). Validating against the known-device set both refuses those and recovers the composite entries correctly. A marker convention inherits every prior meaning of its own name. Deployed and verified on synlig: device 14,217 → 14,317, integrity ok, idempotent on immediate re-run, no invented device values. RFC updates: §7.3.1 corrected (the arg whitelist is a hard -32602 in 3.8.0, not a silent drop; get_drawer DOES return metadata, search structurally cannot; triples and logstream live in separate databases the stamper cannot reach), §7.3.5 added (what is deployed, including the divergence from §7.3.4's opaque origin_device — tor-ms22 vs tor-ms22-native is that cost already visible), and Phase 4 now carries per-device tokens motivated FIRST by revocation, with the finding that tokens are the cheap half: core holds one scalar auth_token and has zero device concept, so authoritative stamping needs a component we own.
207 lines
10 KiB
Python
Executable File
207 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Stamp provenance onto MemPalace drawers: `device` (which machine) and
|
|
`agent_kind` (which harness: pi / opencode / miner / cli), plus a *_source key
|
|
recording HOW each was determined so an inference is never mistaken for a fact.
|
|
|
|
Why this exists: mempalace core (3.7.1 through 3.8.0) records neither, and the
|
|
single shared bearer token means the server cannot tell clients apart either --
|
|
core's `auth_token` is one scalar string (mcp_server.py:7685) and the package
|
|
contains zero occurrences of any device/origin concept. The feeder does encode
|
|
both facts incidentally -- device in the per-device inbox directory, harness in
|
|
the transcript filename prefix (pi_*.jsonl) -- so this recovers them.
|
|
|
|
Idempotent: only fills keys that are absent, so re-running is cheap and never
|
|
downgrades an authoritative value. It must run on a timer, not once: live
|
|
re-mining REPLACES metadata rows and silently drops earlier stamps.
|
|
|
|
RFC 001 7.3.2 places provenance at the sync boundary, never on the agent. As of
|
|
2026-08-25 the pi bridge (extensions/pi/mempalace.ts) implements the interim
|
|
"edge" row: it defaults added_by/agent/from_agent/created_by to
|
|
`<harness>@<device>` from $MEMPALACE_PI_DEVICE, so rule `agent_at_device` below
|
|
reads both directly instead of inferring them. This script's remaining job is
|
|
(a) historic rows, (b) rows re-mined after a stamp, and (c) diary entries, which
|
|
have no writer field at all -- see `diary_host_marker`.
|
|
|
|
These stamps are self-asserted and therefore ADVISORY (7.3.2 Phase 2): a hint,
|
|
never load-bearing for authorization or destructive scoping. When per-device
|
|
tokens land (Phase 4) the primary can stamp authoritatively and overwrite with
|
|
device_source='token'; the *_source key is what makes that upgrade lossless.
|
|
|
|
Usage: mempalace-device-stamp [--dry-run]
|
|
--dry-run report what would be stamped, write nothing. Use this after
|
|
changing any rule below -- the palace is shared by the whole fleet.
|
|
"""
|
|
import sqlite3, os, re, sys, collections
|
|
|
|
DRY_RUN = '--dry-run' in sys.argv[1:]
|
|
FEED = os.path.expanduser('~/mempalace-feed')
|
|
DB = os.path.expanduser('~/.mempalace/palace/chroma.sqlite3')
|
|
HOSTPATH = [('/Users/ECSJPER/', 'emb-7kj4vr4g'), ('/Users/joakim/', 'mbp-m1-2020'),
|
|
('/home/jper/', 'tor-ms22'), ('/home/ecsjper/src', 'synlig')]
|
|
DEV_FRAG = [('emb7kj', 'emb-7kj4vr4g'), ('mbp', 'mbp-m1-2020'), ('tor-ms22', 'tor-ms22')]
|
|
KNOWN_DEV = set(os.listdir(FEED)) if os.path.isdir(FEED) else set()
|
|
|
|
base2dev, dupes = {}, set()
|
|
if os.path.isdir(FEED):
|
|
for dev in sorted(KNOWN_DEV):
|
|
d = os.path.join(FEED, dev)
|
|
if not os.path.isdir(d): continue
|
|
for f in os.listdir(d):
|
|
if f in base2dev and base2dev[f] != dev: dupes.add(f)
|
|
base2dev[f] = dev
|
|
|
|
def split_agent(who):
|
|
"""'pi@emb-7kj4vr4g' -> ('pi', 'emb-7kj4vr4g'); the forward convention."""
|
|
if who and '@' in who:
|
|
h, d = who.split('@', 1)
|
|
if d in KNOWN_DEV or re.match(r'^[a-z0-9][a-z0-9.-]*$', d or ''):
|
|
return h.strip().lower() or None, d.strip().lower() or None
|
|
return None, None
|
|
|
|
def resolve_device(sf, added_by, agent, legacy_machine, doc=None):
|
|
for who in (added_by, agent):
|
|
_, d = split_agent(who)
|
|
if d: return d, 'agent_at_device'
|
|
if sf:
|
|
if '/mempalace-feed/' in sf:
|
|
return sf.split('/mempalace-feed/', 1)[1].split('/')[0], 'inbox_path'
|
|
m = re.match(r'^([a-z0-9][a-z0-9-]*):', sf)
|
|
if m and (m.group(1) in KNOWN_DEV): return m.group(1), 'source_prefix'
|
|
b = os.path.basename(sf)
|
|
if b in base2dev and b not in dupes: return base2dev[b], 'basename_index'
|
|
for pref, dev in HOSTPATH:
|
|
if sf.startswith(pref): return dev, 'host_path_heuristic'
|
|
if legacy_machine:
|
|
return legacy_machine.strip().lower(), 'legacy_source_machine'
|
|
if doc:
|
|
# Diary entries have NO writer field: diary_write exposes only
|
|
# agent_name, and a device there would become the wing name
|
|
# (wing = f"wing_{agent_name}"), splitting the diary per host and
|
|
# hiding entries from diary_read. So the pi bridge stamps the device
|
|
# into the entry TEXT as an AAAK field -- `HOST:<device>|SESSION:...`.
|
|
# That is also the only channel a *reader* ever sees, since search
|
|
# projects a fixed key set and diary_read returns content, never
|
|
# metadata. Anchored to a field boundary so it cannot match prose.
|
|
#
|
|
# MUST validate against KNOWN_DEV, for two reasons found by dry-running
|
|
# this rule against the real palace before deploying it:
|
|
# 1. `HOST:` was ALREADY in use in older diary entries with a richer
|
|
# grammar -- `HOST:emb-7kj4vr4g.f1d3c3f89e3e.v1.8.3.pi0.84.2`
|
|
# (host.container.image.pi) and bare container ids like
|
|
# `HOST:2efe2b06f480`. Taking the whole match would invent devices
|
|
# like "f1d3c3f89e3e.pi0.84.2" and fragment every device query, so
|
|
# read the first dotted segment and keep it only if it is a real
|
|
# device. A container id resolves to nothing, which is correct:
|
|
# containers are not devices and change on every recreate.
|
|
# 2. `HOST:` also carries a DIFFERENT SENSE in some entries -- e.g.
|
|
# `HOST:exec.via.ssh-controlmaster->alpserv-2(...)` means "the box I
|
|
# was executing on", not "the box that wrote this". Validation
|
|
# rejects it, so the two senses cannot be conflated.
|
|
m = re.search(r'(?:^|\|)\s*HOST:\s*([a-z0-9][a-z0-9._-]*)', doc, re.I)
|
|
if m:
|
|
cand = m.group(1).strip().lower().rstrip('.')
|
|
for probe in (cand, cand.split('.')[0]):
|
|
if probe in KNOWN_DEV:
|
|
return probe, 'diary_host_marker'
|
|
for who in (added_by, agent):
|
|
if who:
|
|
for frag, dev in DEV_FRAG:
|
|
if frag in who.lower(): return dev, 'agent_name_heuristic'
|
|
return None, None
|
|
|
|
def resolve_kind(sf, added_by, agent, ingest_mode, doc=None):
|
|
for who in (added_by, agent):
|
|
h, _ = split_agent(who)
|
|
if h: return h, 'agent_at_device'
|
|
if sf and '/mempalace-feed/' in sf:
|
|
# Filename shape identifies the harness, but the two shapes differ and a
|
|
# naive "text before the first _" is WRONG for opencode:
|
|
# pi_<uuid>.jsonl -> pi
|
|
# <slug>_ses_<id>.jsonl -> opencode (leading segment is an arbitrary slug)
|
|
b = os.path.basename(sf)
|
|
if re.match(r'^pi_', b): return 'pi', 'transcript_prefix'
|
|
if '_ses_' in b: return 'opencode', 'transcript_prefix'
|
|
if doc:
|
|
# Each session's first chunk carries a synthetic header marker.
|
|
m = re.search(r'\|\s*source:\s*(pi|opencode)\b', doc)
|
|
if m: return m.group(1).lower(), 'header_marker'
|
|
for who in (agent, added_by):
|
|
if not who: continue
|
|
w = who.lower()
|
|
if 'opencode' in w: return 'opencode', 'agent_name'
|
|
if w == 'pi' or w.startswith('pi-') or w.startswith('pi_') or 'pi-devbox' in w:
|
|
return 'pi', 'agent_name'
|
|
if w in ('mcp', 'checkpoint'): return 'pi', 'agent_name_weak'
|
|
if w == 'mempalace': return 'miner', 'added_by_default'
|
|
if ingest_mode: return 'miner', 'ingest_mode'
|
|
return None, None
|
|
|
|
c = sqlite3.connect(DB, timeout=60)
|
|
c.execute('pragma busy_timeout=60000')
|
|
meta = collections.defaultdict(dict)
|
|
for rid, k, sv in c.execute(
|
|
"select id,key,string_value from embedding_metadata where key in "
|
|
"('source_file','added_by','agent','device','agent_kind','ingest_mode','source_machine',"
|
|
"'parent_drawer_id','chroma:document')"):
|
|
meta[rid][k] = sv
|
|
|
|
dev_rows, kind_rows = [], []
|
|
for rid, m in meta.items():
|
|
if not m.get('device'):
|
|
d, how = resolve_device(m.get('source_file'), m.get('added_by'), m.get('agent'),
|
|
m.get('source_machine'), m.get('chroma:document'))
|
|
if d: dev_rows.append((rid, d, how))
|
|
if not m.get('agent_kind'):
|
|
k, how = resolve_kind(m.get('source_file'), m.get('added_by'), m.get('agent'),
|
|
m.get('ingest_mode'), m.get('chroma:document'))
|
|
if k: kind_rows.append((rid, k, how))
|
|
|
|
# --- Propagate within a drawer -------------------------------------------
|
|
# Every chunk of one drawer was produced by ONE write call, so they share an
|
|
# origin by construction. Without this, any doc-text rule (HOST: marker, header
|
|
# marker) resolves only the chunk that happens to contain the marker -- a
|
|
# 5-chunk diary entry would end up 1 stamped and 4 blank. Fill-only: never
|
|
# overrides a row that resolved on its own evidence, and only propagates when
|
|
# the drawer's known chunks agree.
|
|
def propagate(rows, key):
|
|
known = {rid: v for rid, v, _ in rows}
|
|
for rid, m in meta.items():
|
|
if m.get(key) and rid not in known:
|
|
known[rid] = m[key] # already-stamped siblings are evidence too
|
|
by_drawer = collections.defaultdict(set)
|
|
for rid, m in meta.items():
|
|
p = m.get('parent_drawer_id')
|
|
if p and rid in known: by_drawer[p].add(known[rid])
|
|
added = []
|
|
for rid, m in meta.items():
|
|
p = m.get('parent_drawer_id')
|
|
if not p or rid in known or m.get(key): continue
|
|
vals = by_drawer.get(p)
|
|
if vals and len(vals) == 1: # unanimous, else leave blank
|
|
added.append((rid, next(iter(vals)), 'sibling_chunk'))
|
|
return rows + added
|
|
|
|
dev_rows = propagate(dev_rows, 'device')
|
|
kind_rows = propagate(kind_rows, 'agent_kind')
|
|
|
|
cur = c.cursor()
|
|
def put(rows, key, skey):
|
|
if not rows or DRY_RUN: return
|
|
cur.executemany("insert or replace into embedding_metadata (id,key,string_value) values (?,?,?)",
|
|
[(r, key, v) for r, v, _ in rows])
|
|
cur.executemany("insert or replace into embedding_metadata (id,key,string_value) values (?,?,?)",
|
|
[(r, skey, h) for r, _, h in rows])
|
|
put(dev_rows, 'device', 'device_source')
|
|
put(kind_rows, 'agent_kind', 'agent_kind_source')
|
|
if not DRY_RUN: c.commit()
|
|
tot = lambda k: c.execute("select count(*) from embedding_metadata where key=?", (k,)).fetchone()[0]
|
|
print("%sdevice+%d %s | agent_kind+%d %s | totals device=%d agent_kind=%d" % (
|
|
"DRY RUN would stamp " if DRY_RUN else "stamped ",
|
|
len(dev_rows), dict(collections.Counter(h for _, _, h in dev_rows)) or {},
|
|
len(kind_rows), dict(collections.Counter(v for _, v, _ in kind_rows)) or {},
|
|
tot('device'), tot('agent_kind')))
|
|
if DRY_RUN:
|
|
print(" by new rule:", dict(collections.Counter(
|
|
"%s->%s" % (how, v) for _, v, how in dev_rows
|
|
if how in ('diary_host_marker', 'sibling_chunk', 'agent_at_device'))))
|