"""Synchronous owner-scoped SQLite projections; routes must offload calls. Missing/foreign objects raise KeyError; invalid input ValueError; conflicting revision, idempotency payload or terminal transition ChatConflict. Branch with edited user content copies BEFORE that user, without submitting replacement: caller must send the edited turn. Otherwise copy through the selected message. Only completed role/content history seeds a fresh Hermes session; no tools rerun. """ from __future__ import annotations import json import time import uuid class ChatConflict(ValueError): """Optimistic revision, idempotency payload or state conflict (HTTP 409).""" def text(value, name, maximum, empty=False): if not isinstance(value, str) or len(value) > maximum or (not empty and not value.strip()) or '\x00' in value: raise ValueError(f'Invalid {name}') try: if len(value.encode('utf-8')) > maximum * 4: raise ValueError(f'Invalid {name}') except UnicodeError as exc: raise ValueError(f'Invalid {name}') from exc return value def integer(value, name, minimum=0, maximum=2**63-1): if type(value) is not int or not minimum <= value <= maximum: raise ValueError(f'Invalid {name}') return value class ChatStore: TERMINAL = {'completed', 'failed', 'cancelled'} def __init__(self, accounts): self.accounts, self.db, self.lock = accounts, accounts.db, accounts.lock with self.lock, self.db: self.db.execute('PRAGMA foreign_keys=ON') self.db.executescript(''' CREATE TABLE IF NOT EXISTS chat_workspaces ( id TEXT PRIMARY KEY, owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, instructions TEXT NOT NULL, revision INTEGER NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL, UNIQUE(id,owner)); CREATE TABLE IF NOT EXISTS chat_conversations ( id TEXT PRIMARY KEY, owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, title TEXT NOT NULL, workspace_id TEXT, pinned INTEGER NOT NULL DEFAULT 0, pin_rank INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, temporary INTEGER NOT NULL DEFAULT 0, revision INTEGER NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL, hermes_session_id TEXT, hermes_seeded INTEGER NOT NULL DEFAULT 0, UNIQUE(id,owner), FOREIGN KEY(workspace_id,owner) REFERENCES chat_workspaces(id,owner)); CREATE TABLE IF NOT EXISTS chat_messages ( id TEXT PRIMARY KEY, owner TEXT NOT NULL, conversation_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('user','assistant')), content TEXT NOT NULL, status TEXT NOT NULL, run_id TEXT, created_at REAL NOT NULL, position INTEGER NOT NULL, UNIQUE(conversation_id,position), FOREIGN KEY(conversation_id,owner) REFERENCES chat_conversations(id,owner) ON DELETE CASCADE, FOREIGN KEY(run_id,owner) REFERENCES chat_runs(id,owner) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS chat_runs ( id TEXT PRIMARY KEY, owner TEXT NOT NULL, conversation_id TEXT NOT NULL, status TEXT NOT NULL, message_id TEXT NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL, error TEXT, UNIQUE(id,owner), FOREIGN KEY(conversation_id,owner) REFERENCES chat_conversations(id,owner) ON DELETE CASCADE, FOREIGN KEY(message_id) REFERENCES chat_messages(id) DEFERRABLE INITIALLY DEFERRED); CREATE TABLE IF NOT EXISTS chat_idempotency ( owner TEXT NOT NULL, conversation_id TEXT NOT NULL, key TEXT NOT NULL, payload TEXT NOT NULL, message_id TEXT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE, run_id TEXT NOT NULL REFERENCES chat_runs(id) ON DELETE CASCADE, PRIMARY KEY(owner,conversation_id,key), FOREIGN KEY(conversation_id,owner) REFERENCES chat_conversations(id,owner) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS chat_events ( owner TEXT NOT NULL, run_id TEXT NOT NULL, sequence INTEGER NOT NULL, event TEXT NOT NULL, data TEXT NOT NULL, PRIMARY KEY(run_id,sequence), FOREIGN KEY(run_id,owner) REFERENCES chat_runs(id,owner) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS chat_preferences ( owner TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, palette TEXT NOT NULL, appearance TEXT NOT NULL, revision INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS chat_changes ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, kind TEXT NOT NULL, object_id TEXT NOT NULL, deleted INTEGER NOT NULL, data TEXT, created_at REAL NOT NULL); CREATE INDEX IF NOT EXISTS chat_changes_owner ON chat_changes(owner,sequence); CREATE INDEX IF NOT EXISTS chat_conversation_owner ON chat_conversations(owner,updated_at,id); CREATE INDEX IF NOT EXISTS chat_message_order ON chat_messages(conversation_id,position); ''') columns = {r[1] for r in self.db.execute('PRAGMA table_info(chat_conversations)')} for column, definition in [('hermes_session_id', 'TEXT'), ('hermes_seeded', 'INTEGER NOT NULL DEFAULT 0')]: if column not in columns: self.db.execute(f'ALTER TABLE chat_conversations ADD COLUMN {column} {definition}') if 'attachments' not in {r[1] for r in self.db.execute('PRAGMA table_info(chat_messages)')}: self.db.execute("ALTER TABLE chat_messages ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]'") def _owner(self, owner): text(owner, 'owner', 128) if not self.db.execute("SELECT 1 FROM users WHERE id=? AND desired!='deleted'", (owner,)).fetchone(): raise KeyError(owner) def _quota(self, table, owner, maximum): if self.db.execute(f'SELECT count(*) FROM {table} WHERE owner=?',(owner,)).fetchone()[0]>=maximum: raise ValueError('Account chat storage limit reached') def _row(self, table, owner, identity): self._owner(owner) text(identity, 'id', 128) row = self.db.execute(f'SELECT * FROM {table} WHERE owner=? AND id=?', (owner, identity)).fetchone() if row is None: raise KeyError(identity) return dict(row) @staticmethod def _conversation(row): result = {k: row[k] for k in ('id','title','workspace_id','pinned','pin_rank','archived','temporary','revision','created_at','updated_at')} for k in ('pinned', 'archived', 'temporary'): result[k] = bool(result[k]) return result @staticmethod def _message(row): return {**{k: row[k] for k in ('id','conversation_id','role','content','status','run_id','created_at')}, 'attachments': json.loads(row['attachments']) if 'attachments' in row.keys() else []} @staticmethod def _run(row): return {k: row[k] for k in ('id','conversation_id','status','message_id','created_at','updated_at','error')} @staticmethod def _workspace(row): return {k: row[k] for k in ('id','name','instructions','revision','created_at','updated_at')} def _touch(self, owner, cid): self.db.execute('UPDATE chat_conversations SET revision=revision+1,updated_at=? WHERE id=? AND owner=?',(time.time(),cid,owner)) self._change(owner,'conversation',cid,self._conversation(self._row('chat_conversations',owner,cid))) def _change(self, owner, kind, identity, data=None): self._quota('chat_changes',owner,100000) self.db.execute('INSERT INTO chat_changes(owner,kind,object_id,deleted,data,created_at) VALUES(?,?,?,?,?,?)', (owner, kind, identity, data is None, json.dumps(data) if data is not None else None, time.time())) def create_conversation(self, owner, title='New chat', workspace_id=None, temporary=False): text(title, 'title', 200) if type(temporary) is not bool or temporary: raise ValueError('Temporary conversations are not enabled') with self.lock, self.db: self._owner(owner) if workspace_id is not None: self._row('chat_workspaces', owner, workspace_id) self._quota('chat_conversations',owner,1000) identity, now = uuid.uuid4().hex, time.time() self.db.execute('INSERT INTO chat_conversations(id,owner,title,workspace_id,temporary,revision,created_at,updated_at) VALUES(?,?,?,?,?,1,?,?)', (identity, owner, title, workspace_id, temporary, now, now)) result = self._conversation(self._row('chat_conversations', owner, identity)) self._change(owner, 'conversation', identity, result) return result @staticmethod def _cursor(cursor): if cursor is None: return 0 if not isinstance(cursor, str) or not cursor.isascii() or not cursor.isdigit() or len(cursor)>9: raise ValueError('Invalid cursor') return int(cursor) def list_conversations(self, owner, q='', workspace_id=None, archived=False, cursor=None, limit=50): text(q, 'search', 200, empty=True) integer(limit, 'limit', 1, 200) if type(archived) is not bool: raise ValueError('Invalid archived') offset = self._cursor(cursor) with self.lock: self._owner(owner) clauses, params = ['owner=?', 'archived=?'], [owner, archived] if workspace_id is not None: self._row('chat_workspaces', owner, workspace_id) clauses.append('workspace_id=?') params.append(workspace_id) if q: clauses.append('(instr(lower(title),lower(?))>0 OR EXISTS (SELECT 1 FROM chat_messages m WHERE m.conversation_id=chat_conversations.id AND instr(lower(m.content),lower(?))>0))') params.extend([q, q]) rows = list(self.db.execute('SELECT * FROM chat_conversations WHERE ' + ' AND '.join(clauses) + ' ORDER BY pinned DESC,pin_rank,updated_at DESC,id LIMIT ? OFFSET ?', (*params, limit+1, offset))) return {'conversations': [self._conversation(r) for r in rows[:limit]], 'next_cursor': str(offset+limit) if len(rows)>limit else None} def get_conversation(self, owner, cid, cursor=None, limit=100): integer(limit, 'limit', 1, 500) offset = self._cursor(cursor) with self.lock: conversation = self._conversation(self._row('chat_conversations', owner, cid)) if cursor is None: rows = list(self.db.execute('SELECT * FROM chat_messages WHERE owner=? AND conversation_id=? ORDER BY position DESC LIMIT ?', (owner,cid,limit+1))) else: rows = list(self.db.execute('SELECT * FROM chat_messages WHERE owner=? AND conversation_id=? AND positionlimit else None} def update_conversation(self, owner, cid, revision, **fields): integer(revision, 'revision', 1) if not fields or not fields.keys() <= {'title','workspace_id','pinned','pin_rank','archived'}: raise ValueError('Invalid conversation update') for key,value in fields.items(): if key=='title': text(value, key, 200) if key in ('pinned','archived') and type(value) is not bool: raise ValueError(f'Invalid {key}') if key=='pin_rank': integer(value,key,0,1000000) with self.lock, self.db: row = self._row('chat_conversations',owner,cid) if row['revision']!=revision: raise ChatConflict('Stale revision') if fields.get('workspace_id') is not None: self._row('chat_workspaces',owner,fields['workspace_id']) self.db.execute('UPDATE chat_conversations SET '+','.join(f'{k}=?' for k in fields)+',revision=revision+1,updated_at=? WHERE id=? AND owner=?', (*fields.values(),time.time(),cid,owner)) result = self._conversation(self._row('chat_conversations',owner,cid)) self._change(owner,'conversation',cid,result) return result def delete_conversation(self, owner, cid): with self.lock, self.db: self._row('chat_conversations',owner,cid) if self.db.execute("SELECT 1 FROM chat_runs WHERE owner=? AND conversation_id=? AND status IN ('queued','running','awaiting_approval') LIMIT 1",(owner,cid)).fetchone(): raise ChatConflict('Execution still active') if self.db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='chat_transport'").fetchone(): for row in self.db.execute('SELECT metadata FROM chat_transport WHERE owner=? AND conversation_id=?',(owner,cid)): metadata=json.loads(row['metadata']) if metadata.get('upstream_id') and not metadata.get('remote_terminal'): raise ChatConflict('Remote execution still active') self.db.execute('DELETE FROM chat_transport WHERE owner=? AND conversation_id=?',(owner,cid)) self.db.execute('DELETE FROM chat_conversations WHERE id=? AND owner=?',(cid,owner)) self._change(owner,'conversation',cid) return {'ok': True} def create_workspace(self, owner, name, instructions=''): text(name,'name',100); text(instructions,'instructions',16000,empty=True) with self.lock, self.db: self._owner(owner) self._quota('chat_workspaces',owner,100) identity,now = uuid.uuid4().hex,time.time() self.db.execute('INSERT INTO chat_workspaces VALUES(?,?,?,?,1,?,?)',(identity,owner,name,instructions,now,now)) result=self._workspace(self._row('chat_workspaces',owner,identity)) self._change(owner,'workspace',identity,result) return result def list_workspaces(self, owner): with self.lock: self._owner(owner) return [self._workspace(r) for r in self.db.execute('SELECT * FROM chat_workspaces WHERE owner=? ORDER BY created_at,id',(owner,))] def get_workspace(self, owner, wid): with self.lock: return self._workspace(self._row('chat_workspaces',owner,wid)) def update_workspace(self, owner, wid, revision, **fields): integer(revision,'revision',1) if not fields or not fields.keys()<={'name','instructions'}: raise ValueError('Invalid workspace update') for key,value in fields.items(): text(value,key,100 if key=='name' else 16000,empty=key=='instructions') with self.lock,self.db: row=self._row('chat_workspaces',owner,wid) if row['revision']!=revision: raise ChatConflict('Stale revision') self.db.execute('UPDATE chat_workspaces SET '+','.join(f'{k}=?' for k in fields)+',revision=revision+1,updated_at=? WHERE id=? AND owner=?',(*fields.values(),time.time(),wid,owner)) result=self._workspace(self._row('chat_workspaces',owner,wid)); self._change(owner,'workspace',wid,result) return result def delete_workspace(self, owner, wid): with self.lock,self.db: self._row('chat_workspaces',owner,wid) ids=[r[0] for r in self.db.execute('SELECT id FROM chat_conversations WHERE owner=? AND workspace_id=?',(owner,wid))] self.db.execute('UPDATE chat_conversations SET workspace_id=NULL,revision=revision+1,updated_at=? WHERE owner=? AND workspace_id=?',(time.time(),owner,wid)) for cid in ids: self._change(owner,'conversation',cid,self._conversation(self._row('chat_conversations',owner,cid))) self.db.execute('DELETE FROM chat_workspaces WHERE owner=? AND id=?',(owner,wid)); self._change(owner,'workspace',wid) return {'ok':True} def get_preferences(self, owner): with self.lock: self._owner(owner) row=self.db.execute('SELECT palette,appearance,revision FROM chat_preferences WHERE owner=?',(owner,)).fetchone() return dict(row) if row else {'palette':'opal','appearance':'system','revision':0} def set_preferences(self, owner, revision, palette, appearance): integer(revision,'revision') if not isinstance(palette,str) or palette not in ('opal','graphite','ocean','sage','lavender'): raise ValueError('Invalid palette') if not isinstance(appearance,str) or appearance not in ('system','light','dark'): raise ValueError('Invalid appearance') with self.lock,self.db: if self.get_preferences(owner)['revision']!=revision: raise ChatConflict('Stale revision') self.db.execute('INSERT INTO chat_preferences VALUES(?,?,?,?) ON CONFLICT(owner) DO UPDATE SET palette=excluded.palette,appearance=excluded.appearance,revision=excluded.revision',(owner,palette,appearance,revision+1)) result=self.get_preferences(owner); self._change(owner,'preferences',owner,result) return result def sync(self, owner, after=0, limit=200): integer(after,'after'); integer(limit,'limit',1,500) with self.lock: self._owner(owner) rows=list(self.db.execute('SELECT * FROM chat_changes WHERE owner=? AND sequence>? ORDER BY sequence LIMIT ?',(owner,after,limit))) changes=[{'sequence':r['sequence'],'kind':r['kind'],'id':r['object_id'],'deleted':bool(r['deleted']),'data':json.loads(r['data']) if r['data'] is not None else None,'created_at':r['created_at']} for r in rows] return {'changes':changes,'cursor':rows[-1]['sequence'] if rows else after} def _insert_message(self,owner,cid,role,content,status,run_id=None,identity=None): identity=identity or uuid.uuid4().hex position=self.db.execute('SELECT COALESCE(MAX(position),0)+1 FROM chat_messages WHERE conversation_id=?',(cid,)).fetchone()[0] self._quota('chat_messages', owner, 20000) self.db.execute('INSERT INTO chat_messages(id,owner,conversation_id,role,content,status,run_id,created_at,position) VALUES(?,?,?,?,?,?,?,?,?)',(identity,owner,cid,role,content,status,run_id,time.time(),position)) return self._message(self._row('chat_messages',owner,identity)) def send(self, owner, cid, content, key, attachment_ids=None, mode='normal'): """Atomic admission; replay returns same IDs and never creates new work.""" text(content,'content',32000); text(key,'idempotency key',200) if attachment_ids is None: attachment_ids=[] if not isinstance(attachment_ids,list) or len(attachment_ids)>8: raise ValueError('Invalid attachments') for attachment_id in attachment_ids: text(attachment_id,'attachment id',128) if len(set(attachment_ids))!=len(attachment_ids): raise ValueError('Duplicate attachments') if not isinstance(mode,str) or mode not in ('normal','research'): raise ValueError('Invalid mode') payload=json.dumps({'content':content,'attachment_ids':attachment_ids,'mode':mode},sort_keys=True) with self.lock,self.db: self._row('chat_conversations',owner,cid) prior=self.db.execute('SELECT * FROM chat_idempotency WHERE owner=? AND conversation_id=? AND key=?',(owner,cid,key)).fetchone() if prior: if prior['payload']!=payload: raise ChatConflict('Idempotency payload changed') return {'message':self._message(self._row('chat_messages',owner,prior['message_id'])),'run':self._run(self._row('chat_runs',owner,prior['run_id']))} for attachment_id in attachment_ids: exists=self.db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='hope_attachments'").fetchone() if not exists or not self.db.execute('SELECT 1 FROM hope_attachments WHERE owner=? AND id=? AND deleted_at IS NULL',(owner,attachment_id)).fetchone(): raise KeyError(attachment_id) self._quota('chat_runs',owner,10000) mid,rid,now=uuid.uuid4().hex,uuid.uuid4().hex,time.time() self.db.execute('INSERT INTO chat_runs VALUES(?,?,?,?,?,?,?,?)',(rid,owner,cid,'queued',mid,now,now,None)) self._insert_message(owner,cid,'user',content,'completed',rid,mid) self.db.execute('UPDATE chat_messages SET attachments=? WHERE id=?',(json.dumps(attachment_ids),mid)) message=self._message(self._row('chat_messages',owner,mid)) self.db.execute('INSERT INTO chat_idempotency VALUES(?,?,?,?,?,?)',(owner,cid,key,payload,mid,rid)) self.db.execute('UPDATE chat_conversations SET revision=revision+1,updated_at=? WHERE id=?',(now,cid)) self._change(owner,'conversation',cid,self._conversation(self._row('chat_conversations',owner,cid))) return {'message':message,'run':self._run(self._row('chat_runs',owner,rid))} def get_run(self, owner, rid): with self.lock: run=self._run(self._row('chat_runs',owner,rid)) return {'run':run,'messages':[self._message(r) for r in self.db.execute('SELECT * FROM chat_messages WHERE owner=? AND run_id=? ORDER BY position',(owner,rid))]} def update_run(self, owner, rid, status, error=None): transitions={'queued':{'running','failed','cancelled'},'running':{'awaiting_approval','completed','failed','cancelled'},'awaiting_approval':{'running','failed','cancelled'}} if not isinstance(status,str) or status not in {*transitions,*self.TERMINAL}: raise ValueError('Invalid run status') if error is not None: text(error,'error',1000,empty=True) with self.lock,self.db: row=self._row('chat_runs',owner,rid) if row['status']==status: if row['error']!=error: raise ChatConflict('Run already has this status') return self._run(row) if status not in transitions.get(row['status'],set()): raise ChatConflict('Invalid run transition') self.db.execute('UPDATE chat_runs SET status=?,error=?,updated_at=? WHERE id=? AND owner=?',(status,error,time.time(),rid,owner)) if status in self.TERMINAL: self._touch(owner,row['conversation_id']) return self._run(self._row('chat_runs',owner,rid)) def add_message(self, owner, cid, role, content, status='completed', run_id=None): text(content,'content',32000,empty=True) if not isinstance(role,str) or role not in ('user','assistant') or not isinstance(status,str) or status not in ('pending','streaming','completed','failed','cancelled'): raise ValueError('Invalid message') with self.lock,self.db: self._row('chat_conversations',owner,cid) if run_id is not None: run=self._row('chat_runs',owner,run_id) if run['conversation_id']!=cid: raise KeyError(run_id) if run['status'] in self.TERMINAL: raise ChatConflict('Run finished') result=self._insert_message(owner,cid,role,content,status,run_id) if status in self.TERMINAL: self._touch(owner,cid) return result def update_message(self, owner, mid, content=None, status=None): if content is not None: text(content,'content',32000,empty=True) if status is not None and (not isinstance(status,str) or status not in ('pending','streaming','completed','failed','cancelled')): raise ValueError('Invalid message status') with self.lock,self.db: row=self._row('chat_messages',owner,mid) new_content=row['content'] if content is None else content new_status=row['status'] if status is None else status transitions={'pending':{'streaming','completed','failed','cancelled'},'streaming':self.TERMINAL} if row['status'] in self.TERMINAL and (new_content!=row['content'] or new_status!=row['status']): raise ChatConflict('Message finished') if new_status!=row['status'] and new_status not in transitions.get(row['status'],set()): raise ChatConflict('Invalid message transition') if row['run_id'] and new_status in ('pending','streaming','completed'): run=self._row('chat_runs',owner,row['run_id']) if run['status'] in ('failed','cancelled') and (new_content!=row['content'] or new_status!=row['status']): raise ChatConflict('Run finished') self.db.execute('UPDATE chat_messages SET content=?,status=? WHERE owner=? AND id=?',(new_content,new_status,owner,mid)) if new_status in self.TERMINAL and (new_content!=row['content'] or new_status!=row['status']): self._touch(owner,row['conversation_id']) return self._message(self._row('chat_messages',owner,mid)) def append_event(self, owner, rid, event, data): if not isinstance(event,str) or event not in ('message','run','progress','approval','done'): raise ValueError('Invalid event') if not isinstance(data,dict): raise ValueError('Invalid event data') try: encoded=json.dumps(data,allow_nan=False) except (ValueError,TypeError) as exc: raise ValueError('Invalid event data') from exc if len(encoded.encode())>131072: raise ValueError('Event too large') with self.lock,self.db: self._row('chat_runs',owner,rid) budget=self.db.execute('SELECT count(*),COALESCE(SUM(length(CAST(data AS BLOB))),0) FROM chat_events WHERE run_id=?',(rid,)).fetchone() # Reserve terminal event slots so capacity failures can terminate cleanly. ceiling=10010 if event in ('run','done') else 10000 if budget[0]>=ceiling or budget[1]+len(encoded.encode())>16*1024*1024+(131072 if event in ('run','done') else 0): raise ValueError('Run event storage limit reached') sequence=self.db.execute('SELECT COALESCE(MAX(sequence),0)+1 FROM chat_events WHERE run_id=?',(rid,)).fetchone()[0] self.db.execute('INSERT INTO chat_events VALUES(?,?,?,?,?)',(owner,rid,sequence,event,encoded)) return {'sequence':sequence,'event':event,'data':json.loads(encoded)} def events(self, owner, rid, after=0, limit=500): integer(after,'after'); integer(limit,'limit',1,1000) with self.lock: self._row('chat_runs',owner,rid) return [{'sequence':r['sequence'],'event':r['event'],'data':json.loads(r['data'])} for r in self.db.execute('SELECT * FROM chat_events WHERE owner=? AND run_id=? AND sequence>? ORDER BY sequence LIMIT ?',(owner,rid,after,limit))] def branch(self, owner, cid, message_id, content=None, title=None): if content is not None: text(content,'content',32000) if title is not None: text(title,'title',200) with self.lock,self.db: parent=self._row('chat_conversations',owner,cid) cutoff=self._row('chat_messages',owner,message_id) if cutoff['conversation_id']!=cid: raise KeyError(message_id) if content is not None and cutoff['role']!='user': raise ValueError('Only user messages can be edited') if cutoff['status']!='completed': raise ChatConflict('Cannot branch unfinished message') self._quota('chat_conversations',owner,1000) identity,now=uuid.uuid4().hex,time.time() self.db.execute('INSERT INTO chat_conversations(id,owner,title,workspace_id,temporary,revision,created_at,updated_at) VALUES(?,?,?,?,?,1,?,?)',(identity,owner,title or parent['title'],parent['workspace_id'],parent['temporary'],now,now)) comparator='<' if content is not None else '<=' rows=list(self.db.execute(f'SELECT * FROM chat_messages WHERE owner=? AND conversation_id=? AND position{comparator}? ORDER BY position',(owner,cid,cutoff['position']))) for row in rows: if row['status']=='completed': copied=self._insert_message(owner,identity,row['role'],row['content'],'completed') self.db.execute('UPDATE chat_messages SET attachments=? WHERE id=?',(row['attachments'],copied['id'])) result=self._conversation(self._row('chat_conversations',owner,identity)); self._change(owner,'conversation',identity,result) return result def history(self, owner, cid): """Completed branch-local turns for one-time Hermes history seeding.""" with self.lock: self._row('chat_conversations',owner,cid) return [{'role':r['role'],'content':r['content']} for r in self.db.execute("SELECT role,content FROM chat_messages WHERE owner=? AND conversation_id=? AND status='completed' ORDER BY position",(owner,cid))] def get_session(self, owner, cid): """Trusted executor only; never include this result in public JSON.""" with self.lock: row=self._row('chat_conversations',owner,cid) return {'hermes_session_id':row['hermes_session_id'],'hermes_seeded':bool(row['hermes_seeded'])} def set_session(self, owner, cid, hermes_session_id, seeded=False): text(hermes_session_id,'Hermes session',200) if type(seeded) is not bool: raise ValueError('Invalid seeded flag') with self.lock,self.db: row=self._row('chat_conversations',owner,cid) if row['hermes_session_id'] is not None and row['hermes_session_id']!=hermes_session_id: raise ChatConflict('Session already assigned') if row['hermes_seeded'] and not seeded: raise ChatConflict('Session already seeded') self.db.execute('UPDATE chat_conversations SET hermes_session_id=?,hermes_seeded=? WHERE owner=? AND id=?',(hermes_session_id,seeded,owner,cid)) return {'hermes_session_id':hermes_session_id,'hermes_seeded':seeded} def get_hermes_session(self, owner, cid): return self.get_session(owner,cid)['hermes_session_id'] def set_hermes_session(self, owner, cid, session_id, seeded=True): return self.set_session(owner,cid,session_id,seeded) def active_runs(self, owner=None): """Trusted recovery listing; None lists all owners, with private owner ID.""" with self.lock: if owner is not None: self._owner(owner) where="status IN ('queued','running','awaiting_approval')" params=() if owner is not None: where+=' AND owner=?'; params=(owner,) return [{**self._run(r),'owner':r['owner']} for r in self.db.execute('SELECT * FROM chat_runs WHERE '+where+' ORDER BY created_at,id',params)] def claim_run(self, owner, rid): """Only queued -> running admits execution; a retry cannot claim twice.""" with self.lock,self.db: row=self._row('chat_runs',owner,rid) if row['status']!='queued': return False self.db.execute("UPDATE chat_runs SET status='running',updated_at=? WHERE id=? AND owner=?",(time.time(),rid,owner)) return True def get_message(self, owner, mid): with self.lock: return self._message(self._row('chat_messages',owner,mid)) get_events = events get_history = history conversation_history = history branch_conversation = branch update_preferences = set_preferences list_events = events pending_runs = active_runs