@@ -271,6 +271,40 @@ def _post_daemon_rest(path: str, body: dict) -> dict:
271271 raise DaemonError (f"daemon unreachable at { _daemon_url ()} : { e } " ) from e
272272
273273
274+ def _patch_daemon_rest (path : str , body : dict ) -> dict :
275+ """PATCH a daemon REST endpoint — for single-drawer metadata moves.
276+
277+ Mirrors :func:`_post_daemon_rest` (X-API-Key header, JSON body) but uses
278+ the PATCH verb. Returns the parsed JSON response, or None on 404/401/403
279+ (endpoint missing on an older daemon, or auth mismatch — the caller maps
280+ that to the same exit code as an unreachable daemon). Raises DaemonError
281+ on network failure.
282+ """
283+ import urllib .error
284+ import urllib .request
285+
286+ url = f"{ _daemon_url ()} { path } "
287+ headers = {"content-type" : "application/json" }
288+ api_key = os .environ .get ("PALACE_API_KEY" , "" ).strip ()
289+ if api_key :
290+ headers ["x-api-key" ] = api_key
291+ req = urllib .request .Request (
292+ url ,
293+ data = json .dumps (body ).encode ("utf-8" ),
294+ headers = headers ,
295+ method = "PATCH" ,
296+ )
297+ try :
298+ with urllib .request .urlopen (req , timeout = _daemon_timeout ()) as resp :
299+ return json .loads (resp .read ().decode ("utf-8" , errors = "replace" ))
300+ except urllib .error .HTTPError as e :
301+ if e .code in (404 , 401 , 403 ):
302+ return None
303+ raise DaemonError (f"daemon REST { path } failed ({ e .code } ): { e .reason } " ) from e
304+ except (urllib .error .URLError , ConnectionError , OSError ) as e :
305+ raise DaemonError (f"daemon unreachable at { _daemon_url ()} : { e } " ) from e
306+
307+
274308def _post_daemon_mine_cli (directory : str , wing : str , mode : str = "convos" ) -> bool :
275309 """POST a mine request to the daemon's ``/mine`` endpoint.
276310
@@ -1805,6 +1839,140 @@ def cmd_list(args):
18051839 _print_list_table (data )
18061840
18071841
1842+ # ── mempalace move ────────────────────────────────────────────────────
1843+ #
1844+ # Single-drawer metadata relocation: wraps the daemon's
1845+ # ``PATCH /memory/{drawer_id}`` (palace-daemon main.py:1522). The route
1846+ # accepts content/wing/room, but `move` deliberately exposes only
1847+ # --wing / --room — the fork's verbatim-always principle forbids the
1848+ # human CLI from ever mutating stored drawer text. `move` relocates
1849+ # metadata only; the bulk wing-rename complement is `rename-wing`.
1850+
1851+
1852+ def _resolve_move_format (args ) -> str :
1853+ """Pick ``mempalace move`` output format. ``--format`` wins, then ``--json``."""
1854+ fmt = getattr (args , "format" , None )
1855+ if fmt :
1856+ return fmt
1857+ if getattr (args , "json" , False ):
1858+ return "json"
1859+ return "table"
1860+
1861+
1862+ def cmd_move (args ):
1863+ """Fast direct-to-daemon single-drawer relocation (issue #191).
1864+
1865+ Wraps ``PATCH /memory/{drawer_id}`` with a body carrying only the
1866+ supplied ``wing`` / ``room`` keys. At least one is required — an empty
1867+ PATCH is an ambiguous no-op the daemon would 400, so we refuse it
1868+ client-side with a clear message. No ``--content`` flag exists by
1869+ design: verbatim-always means the human CLI never edits drawer text.
1870+ Daemon unreachable / 404 / 401 / 403 → exit 1 (sibling parity with
1871+ cmd_list / cmd_graph / cmd_cypher / cmd_stats); inner-error envelope
1872+ (daemon reachable but the move failed) → exit 2.
1873+ """
1874+ fmt = _resolve_move_format (args )
1875+ want_json = fmt == "json"
1876+
1877+ drawer_id = args .drawer_id
1878+ new_wing = getattr (args , "wing" , None )
1879+ new_room = getattr (args , "room" , None )
1880+
1881+ if new_wing is None and new_room is None :
1882+ msg = "move requires at least one of --wing / --room (nothing to change)."
1883+ if want_json :
1884+ _emit_json ({"error" : "no_change" , "hint" : msg })
1885+ else :
1886+ print (f"\n ERROR: { msg } " , file = sys .stderr )
1887+ sys .exit (2 )
1888+
1889+ if not _daemon_url ():
1890+ msg = (
1891+ "move requires the palace-daemon. Set PALACE_DAEMON_URL "
1892+ "(or daemon_url in ~/.mempalace/config.json) and retry."
1893+ )
1894+ if want_json :
1895+ _emit_json ({"error" : "daemon_required" , "hint" : msg })
1896+ else :
1897+ print (f"\n ERROR: { msg } " , file = sys .stderr )
1898+ sys .exit (2 )
1899+
1900+ body : dict = {}
1901+ if new_wing is not None :
1902+ body ["wing" ] = new_wing
1903+ if new_room is not None :
1904+ body ["room" ] = new_room
1905+
1906+ try :
1907+ data = _patch_daemon_rest (f"/memory/{ drawer_id } " , body )
1908+ except DaemonError as e :
1909+ if want_json :
1910+ _emit_json ({"error" : str (e ), "source" : "daemon" })
1911+ else :
1912+ print (
1913+ f"palace daemon unreachable at { _daemon_url ()} — "
1914+ f"see mempalace status for diagnostics ({ e } )" ,
1915+ file = sys .stderr ,
1916+ )
1917+ sys .exit (1 )
1918+
1919+ if data is None :
1920+ # _patch_daemon_rest returns None on 404/401/403 — route missing on
1921+ # an older daemon, or auth mismatch. Exit 1 matches the unreachable
1922+ # case so scripts treat "no daemon move" uniformly.
1923+ if want_json :
1924+ _emit_json ({"error" : "daemon PATCH /memory unavailable" , "source" : "daemon" })
1925+ else :
1926+ print (
1927+ f"palace daemon unreachable at { _daemon_url ()} — "
1928+ "see mempalace status for diagnostics" ,
1929+ file = sys .stderr ,
1930+ )
1931+ sys .exit (1 )
1932+
1933+ # mempalace_update_drawer returns success=False on a not-found drawer or
1934+ # an inner sanitize/validation failure — daemon reachable, move failed.
1935+ if not data .get ("success" , True ):
1936+ if want_json :
1937+ _emit_json (data )
1938+ else :
1939+ print (f"\n ERROR: { data .get ('error' , 'move failed' )} " , file = sys .stderr )
1940+ sys .exit (2 )
1941+
1942+ if want_json :
1943+ _emit_json (data )
1944+ return
1945+
1946+ _print_move_result (data , drawer_id , requested_wing = new_wing , requested_room = new_room )
1947+
1948+
1949+ def _print_move_result (data , drawer_id , requested_wing = None , requested_room = None ):
1950+ """Human-readable old→new confirmation for a single-drawer move.
1951+
1952+ The daemon's update_drawer response carries only the *new* wing/room
1953+ (there's no cheap single-drawer GET route to read the prior values), so
1954+ the "old" column shows the requested change as ``→ new`` and unchanged
1955+ fields are marked ``(unchanged)``. Warnings (e.g. non-canonical room
1956+ per the taxonomy) are surfaced if the daemon returned any.
1957+ """
1958+ final_wing = data .get ("wing" , "" )
1959+ final_room = data .get ("room" , "" )
1960+ print ()
1961+ print (f" Moved drawer { drawer_id } " )
1962+ if requested_wing is not None :
1963+ print (f" wing → { final_wing } " )
1964+ else :
1965+ print (f" wing { final_wing } (unchanged)" )
1966+ if requested_room is not None :
1967+ print (f" room → { final_room } " )
1968+ else :
1969+ print (f" room { final_room } (unchanged)" )
1970+ warnings = data .get ("warnings" ) or []
1971+ for w in warnings :
1972+ print (f" warning: { w } " )
1973+ print ()
1974+
1975+
18081976# ── mempalace graph ───────────────────────────────────────────────────
18091977#
18101978# Pre-aggregated structural snapshot: wings, rooms, passive tunnels,
@@ -4206,6 +4374,24 @@ def main():
42064374 ),
42074375 )
42084376
4377+ # move — single-drawer metadata relocation (complement to rename-wing)
4378+ p_move = sub .add_parser (
4379+ "move" ,
4380+ help = "Relocate one drawer to a different wing/room (metadata only)" ,
4381+ )
4382+ p_move .add_argument ("drawer_id" , help = "ID of the drawer to move" )
4383+ p_move .add_argument ("--wing" , default = None , help = "New wing (omit to leave unchanged)" )
4384+ p_move .add_argument ("--room" , default = None , help = "New room (omit to leave unchanged)" )
4385+ p_move .add_argument (
4386+ "--format" ,
4387+ choices = ("table" , "json" ),
4388+ default = None ,
4389+ help = (
4390+ "Output format: table (default, old→new confirmation), "
4391+ "json (daemon pass-through; same as --json)"
4392+ ),
4393+ )
4394+
42094395 # graph
42104396 p_graph = sub .add_parser (
42114397 "graph" ,
@@ -4691,6 +4877,7 @@ def _nonneg_int(value: str) -> int:
46914877 "split" : cmd_split ,
46924878 "search" : cmd_search ,
46934879 "list" : cmd_list ,
4880+ "move" : cmd_move ,
46944881 "graph" : cmd_graph ,
46954882 "cypher" : cmd_cypher ,
46964883 "export" : cmd_export ,
0 commit comments