diff --git a/src/void_tools/cli.py b/src/void_tools/cli.py index c9b52b8..bab6028 100644 --- a/src/void_tools/cli.py +++ b/src/void_tools/cli.py @@ -82,6 +82,40 @@ def create_annotation(client, target_uri, text, motivation="commenting", quote=N print(f" Error annotating {target_uri}: {e}", file=sys.stderr) return None +def append_to_user_memory(handle, facts): + """Appends a list of facts to a specific user's memfs markdown file.""" + if not facts: + return + + agent_id = os.getenv("AGENT_ID") + if not agent_id: + print(" Warning: Cannot update user memory, AGENT_ID missing from .env") + return + + memfs_users_dir = os.path.expanduser(f"~/.letta/agents/{agent_id}/memory/users") + safe_handle = handle.replace(".", "_").replace("-", "_") + memory_file = os.path.join(memfs_users_dir, f"user_{safe_handle}.md") + + try: + import datetime + today = datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%d') + + # If file doesn't exist, create stub first + if not os.path.exists(memory_file): + stub_content = f"---\ndescription: User block for {safe_handle}\n---\n# User: @{handle}\n\n## Interaction History\n" + with open(memory_file, 'w') as mf: + mf.write(stub_content) + + # Append facts + with open(memory_file, 'a') as mf: + mf.write(f"\n### Automated Extractions ({today})\n") + for fact in facts: + mf.write(f"- {fact}\n") + print(f" [+] Appended {len(facts)} facts to memory block for @{handle}") + + except Exception as e: + print(f" [!] Failed to update memory block for @{handle}: {e}") + def cmd_sync(args): client = get_client() agent_id = os.getenv("AGENT_ID", "agent-c52e371d-cb89-4f99-8634-b606bdc69cad") @@ -137,7 +171,11 @@ def cmd_sync(args): latest_seen_timestamp = notif.indexed_at continue - if notif.is_read: + if getattr(args, 'unread_only', True) and notif.is_read: + stop_pagination = True + break + + if not getattr(args, 'unread_only', True) and len(inbox) >= 100: stop_pagination = True break @@ -152,6 +190,7 @@ def cmd_sync(args): local_id = f"notif_{start_index}" start_index += 1 handle = notif.author.handle + item = { "id": local_id, "type": notif.reason, @@ -160,56 +199,56 @@ def cmd_sync(args): if hasattr(notif.record, 'text'): item["text"] = notif.record.text - # Attempt to load memory block - safe_handle = handle.replace(".", "_").replace("-", "_") - memory_file = os.path.join(memfs_users_dir, f"user_{safe_handle}.md") - - if os.path.exists(memory_file): - try: - with open(memory_file, 'r') as mf: - content = mf.read() - if content.startswith("---"): - parts = content.split("---", 2) - if len(parts) >= 3: - content = parts[2].strip() - item["memory_block"] = content - except Exception as e: - item["memory_block"] = f"Error reading memory: {e}" - else: - item["memory_block"] = "No prior records found. Auto-creating stub." - try: - import datetime - today = datetime.datetime.utcnow().strftime('%Y-%m-%d') - stub_content = f"---\ndescription: User block for {safe_handle}\n---\n# User: @{handle}\n\n## Interaction History\n- **{today}:** Profile initialized via automated sync discovery.\n" - with open(memory_file, 'w') as mf: - mf.write(stub_content) - print(f" [+] Auto-created core memory block for @{handle}") - except Exception as e: - print(f" [!] Failed to auto-create block for @{handle}: {e}") + # Attempt to load memory block + safe_handle = handle.replace(".", "_").replace("-", "_") + memory_file = os.path.join(memfs_users_dir, f"user_{safe_handle}.md") - # Fetch thread context for replies, quotes, or mentions - if notif.reason in ['reply', 'quote', 'mention']: - try: - thread_uri = notif.uri - thread_resp = client.app.bsky.feed.get_post_thread({'uri': thread_uri, 'depth': 0, 'parent_height': 5}) - - context = [] - curr = thread_resp.thread - while hasattr(curr, 'parent') and curr.parent: - curr = curr.parent - if hasattr(curr, 'post') and hasattr(curr.post, 'record') and hasattr(curr.post.record, 'text'): - context.insert(0, { - "author": f"@{curr.post.author.handle}", - "text": curr.post.record.text - }) + if os.path.exists(memory_file): + try: + with open(memory_file, 'r') as mf: + content = mf.read() + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + content = parts[2].strip() + item["memory_block"] = content + except Exception as e: + item["memory_block"] = f"Error reading memory: {e}" + else: + item["memory_block"] = "No prior records found. Auto-creating stub." + try: + import datetime + today = datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%d') + stub_content = f"---\ndescription: User block for {safe_handle}\n---\n# User: @{handle}\n\n## Interaction History\n- **{today}:** Profile initialized via automated sync discovery.\n" + with open(memory_file, 'w') as mf: + mf.write(stub_content) + print(f" [+] Auto-created core memory block for @{handle}") + except Exception as e: + print(f" [!] Failed to auto-create block for @{handle}: {e}") - if context: - item["thread_context"] = context - except Exception as e: - item["thread_context"] = f"Failed to fetch context: {str(e)}" - - inbox.append(item) - mapping[local_id] = notif.uri + # Fetch thread context for replies, quotes, or mentions + if notif.reason in ['reply', 'quote', 'mention']: + try: + thread_uri = notif.uri + thread_resp = client.app.bsky.feed.get_post_thread({'uri': thread_uri, 'depth': 0, 'parent_height': 5}) + + context = [] + curr = thread_resp.thread + while hasattr(curr, 'parent') and curr.parent: + curr = curr.parent + if hasattr(curr, 'post') and hasattr(curr.post, 'record') and hasattr(curr.post.record, 'text'): + context.insert(0, { + "author": f"@{curr.post.author.handle}", + "text": curr.post.record.text + }) + + if context: + item["thread_context"] = context + except Exception as e: + item["thread_context"] = f"Failed to fetch context: {str(e)}" + + inbox.append(item) + mapping[local_id] = notif.uri if stop_pagination or not response.cursor: break @@ -247,6 +286,10 @@ def cmd_sync(args): # - "My reply text" # annotation: # Optional: automatically annotates the TARGET post you are replying to # text: "Memory trace" +# memory_update: # Optional: Append facts to the user's local Letta memory file +# target_handle: "user.bsky.social" +# facts: +# - "User is interested in Python." # # 3. Margin Annotation (Standalone) # - action: annotate @@ -333,6 +376,13 @@ def cmd_dispatch(args): print(f"Error: Mandatory annotation block missing for post {first_post_uri}. Aborting dispatch to enforce compliance.", file=sys.stderr) sys.exit(1) + mem_update = act.get("memory_update") + if mem_update: + target_handle = mem_update.get("target_handle") + facts = mem_update.get("facts", []) + if target_handle and facts: + append_to_user_memory(target_handle, facts) + elif action_type == "reply": target_id = act.get("target_id") if target_id not in mapping: @@ -368,6 +418,13 @@ def cmd_dispatch(args): print(f"Error: Mandatory annotation block missing for reply to {target_uri}. Aborting dispatch to enforce compliance.", file=sys.stderr) sys.exit(1) + mem_update = act.get("memory_update") + if mem_update: + target_handle = mem_update.get("target_handle") + facts = mem_update.get("facts", []) + if target_handle and facts: + append_to_user_memory(target_handle, facts) + except Exception as e: print(f"Error replying to {target_uri}: {e}") @@ -463,6 +520,13 @@ def cmd_dispatch(args): try: os.remove(args.file) print(f"Cleared {args.file} to prevent state desynchronization.") + + # If any memfs updates happened, trigger a git add/commit in the background + agent_id = os.getenv("AGENT_ID") + if agent_id: + mem_dir = os.path.expanduser(f"~/.letta/agents/{agent_id}/memory") + os.system(f"cd {mem_dir} && git add users/ && git commit -m 'chore: automated user block ingestion via void-cli dispatch' 2>/dev/null") + except Exception as e: print(f"Failed to clear outbox file: {e}")