lollms-client 0.23.0__py3-none-any.whl → 0.24.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lollms-client might be problematic. Click here for more details.

@@ -1,60 +1,94 @@
1
- import yaml
2
- import json
3
1
  import base64
4
- import os
5
- import uuid
6
- import shutil
2
+ import json
7
3
  import re
8
- from collections import defaultdict
4
+ import uuid
9
5
  from datetime import datetime
10
- from typing import List, Dict, Optional, Union, Any, Type, Callable
11
6
  from pathlib import Path
12
7
  from types import SimpleNamespace
13
-
14
- from sqlalchemy import (create_engine, Column, String, Text, Integer, DateTime,
15
- ForeignKey, JSON, Boolean, LargeBinary, Index, Float)
16
- from sqlalchemy.orm import sessionmaker, relationship, Session, declarative_base, declared_attr
17
- from sqlalchemy.types import TypeDecorator
8
+ from typing import Any, Callable, Dict, List, Optional, Type, Union
9
+ from ascii_colors import trace_exception
10
+ import yaml
11
+ from sqlalchemy import (Column, DateTime, Float, ForeignKey, Integer, JSON,
12
+ LargeBinary, String, Text, create_engine)
13
+ from sqlalchemy.orm import (Session, declarative_base, declared_attr,
14
+ relationship, sessionmaker)
18
15
  from sqlalchemy.orm.exc import NoResultFound
19
-
16
+ from sqlalchemy.types import TypeDecorator
17
+ from sqlalchemy import text
20
18
  try:
21
19
  from cryptography.fernet import Fernet, InvalidToken
20
+ from cryptography.hazmat.backends import default_backend
22
21
  from cryptography.hazmat.primitives import hashes
23
22
  from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
24
- from cryptography.hazmat.backends import default_backend
25
23
  ENCRYPTION_AVAILABLE = True
26
24
  except ImportError:
27
25
  ENCRYPTION_AVAILABLE = False
28
26
 
29
- from lollms_client.lollms_types import MSG_TYPE
30
27
  # Type hint placeholders for classes defined externally
31
- if False:
28
+ if False:
32
29
  from lollms_client import LollmsClient
33
30
  from lollms_personality import LollmsPersonality
34
31
 
32
+
35
33
  class EncryptedString(TypeDecorator):
36
- """A SQLAlchemy TypeDecorator for field-level database encryption."""
34
+ """A SQLAlchemy TypeDecorator for field-level database encryption.
35
+
36
+ This class provides transparent encryption and decryption for string-based
37
+ database columns. It derives a stable encryption key from a user-provided
38
+ password and a fixed salt using PBKDF2HMAC, then uses Fernet for
39
+ symmetric encryption.
40
+
41
+ Requires the 'cryptography' library to be installed.
42
+ """
37
43
  impl = LargeBinary
38
44
  cache_ok = True
39
45
 
40
46
  def __init__(self, key: str, *args, **kwargs):
47
+ """Initializes the encryption engine.
48
+
49
+ Args:
50
+ key: The secret key (password) to use for encryption.
51
+ """
41
52
  super().__init__(*args, **kwargs)
42
53
  if not ENCRYPTION_AVAILABLE:
43
54
  raise ImportError("'cryptography' is required for DB encryption.")
55
+
44
56
  self.salt = b'lollms-fixed-salt-for-db-encryption'
45
57
  kdf = PBKDF2HMAC(
46
- algorithm=hashes.SHA256(), length=32, salt=self.salt,
47
- iterations=480000, backend=default_backend()
58
+ algorithm=hashes.SHA256(),
59
+ length=32,
60
+ salt=self.salt,
61
+ iterations=480000,
62
+ backend=default_backend()
48
63
  )
49
64
  derived_key = base64.urlsafe_b64encode(kdf.derive(key.encode()))
50
65
  self.fernet = Fernet(derived_key)
51
66
 
52
67
  def process_bind_param(self, value: Optional[str], dialect) -> Optional[bytes]:
68
+ """Encrypts the string value before writing it to the database.
69
+
70
+ Args:
71
+ value: The plaintext string to encrypt.
72
+ dialect: The database dialect in use.
73
+
74
+ Returns:
75
+ The encrypted value as bytes, or None if the input was None.
76
+ """
53
77
  if value is None:
54
78
  return None
55
79
  return self.fernet.encrypt(value.encode('utf-8'))
56
80
 
57
81
  def process_result_value(self, value: Optional[bytes], dialect) -> Optional[str]:
82
+ """Decrypts the byte value from the database into a string.
83
+
84
+ Args:
85
+ value: The encrypted bytes from the database.
86
+ dialect: The database dialect in use.
87
+
88
+ Returns:
89
+ The decrypted plaintext string, a special error message if decryption
90
+ fails, or None if the input was None.
91
+ """
58
92
  if value is None:
59
93
  return None
60
94
  try:
@@ -62,12 +96,32 @@ class EncryptedString(TypeDecorator):
62
96
  except InvalidToken:
63
97
  return "<DECRYPTION_FAILED: Invalid Key or Corrupt Data>"
64
98
 
65
- def create_dynamic_models(discussion_mixin: Optional[Type] = None, message_mixin: Optional[Type] = None, encryption_key: Optional[str] = None):
66
- """Factory to dynamically create SQLAlchemy ORM models with custom mixins."""
99
+
100
+ def create_dynamic_models(
101
+ discussion_mixin: Optional[Type] = None,
102
+ message_mixin: Optional[Type] = None,
103
+ encryption_key: Optional[str] = None
104
+ ) -> tuple[Type, Type, Type]:
105
+ """Factory to dynamically create SQLAlchemy ORM models.
106
+
107
+ This function builds the `Discussion` and `Message` SQLAlchemy models,
108
+ optionally including custom mixin classes for extending functionality and
109
+ applying encryption to text fields if a key is provided.
110
+
111
+ Args:
112
+ discussion_mixin: An optional class to mix into the Discussion model.
113
+ message_mixin: An optional class to mix into the Message model.
114
+ encryption_key: An optional key to enable database field encryption.
115
+
116
+ Returns:
117
+ A tuple containing the declarative Base, the created Discussion model,
118
+ and the created Message model.
119
+ """
67
120
  Base = declarative_base()
68
121
  EncryptedText = EncryptedString(encryption_key) if encryption_key else Text
69
122
 
70
123
  class DiscussionBase:
124
+ """Abstract base for the Discussion ORM model."""
71
125
  __abstract__ = True
72
126
  id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
73
127
  system_prompt = Column(EncryptedText, nullable=True)
@@ -76,78 +130,152 @@ def create_dynamic_models(discussion_mixin: Optional[Type] = None, message_mixin
76
130
  discussion_metadata = Column(JSON, nullable=True, default=dict)
77
131
  created_at = Column(DateTime, default=datetime.utcnow)
78
132
  updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
79
-
133
+
134
+ # Fields for non-destructive context pruning
135
+ pruning_summary = Column(EncryptedText, nullable=True)
136
+ pruning_point_id = Column(String, nullable=True)
137
+
80
138
  @declared_attr
81
139
  def messages(cls):
82
140
  return relationship("Message", back_populates="discussion", cascade="all, delete-orphan", lazy="joined")
83
141
 
84
142
  class MessageBase:
143
+ """Abstract base for the Message ORM model."""
85
144
  __abstract__ = True
86
145
  id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
87
146
  discussion_id = Column(String, ForeignKey('discussions.id'), nullable=False, index=True)
88
147
  parent_id = Column(String, ForeignKey('messages.id'), nullable=True, index=True)
89
148
  sender = Column(String, nullable=False)
90
149
  sender_type = Column(String, nullable=False)
91
-
150
+
92
151
  raw_content = Column(EncryptedText, nullable=True)
93
152
  thoughts = Column(EncryptedText, nullable=True)
94
153
  content = Column(EncryptedText, nullable=False)
95
154
  scratchpad = Column(EncryptedText, nullable=True)
96
-
155
+
97
156
  tokens = Column(Integer, nullable=True)
98
157
  binding_name = Column(String, nullable=True)
99
158
  model_name = Column(String, nullable=True)
100
159
  generation_speed = Column(Float, nullable=True)
101
-
160
+
102
161
  message_metadata = Column(JSON, nullable=True, default=dict)
103
162
  images = Column(JSON, nullable=True, default=list)
104
163
  created_at = Column(DateTime, default=datetime.utcnow)
105
-
164
+
106
165
  @declared_attr
107
166
  def discussion(cls):
108
167
  return relationship("Discussion", back_populates="messages")
109
-
168
+
110
169
  discussion_bases = (discussion_mixin, DiscussionBase, Base) if discussion_mixin else (DiscussionBase, Base)
111
170
  DynamicDiscussion = type('Discussion', discussion_bases, {'__tablename__': 'discussions'})
112
171
 
113
172
  message_bases = (message_mixin, MessageBase, Base) if message_mixin else (MessageBase, Base)
114
173
  DynamicMessage = type('Message', message_bases, {'__tablename__': 'messages'})
115
-
174
+
116
175
  return Base, DynamicDiscussion, DynamicMessage
117
176
 
177
+
118
178
  class LollmsDataManager:
119
- """Manages database connection, session, and table creation."""
179
+ """Manages database connection, session, and table creation.
180
+
181
+ This class serves as the central point of contact for all database
182
+ operations, abstracting away the SQLAlchemy engine and session management.
183
+ """
184
+
120
185
  def __init__(self, db_path: str, discussion_mixin: Optional[Type] = None, message_mixin: Optional[Type] = None, encryption_key: Optional[str] = None):
186
+ """Initializes the data manager.
187
+
188
+ Args:
189
+ db_path: The connection string for the SQLAlchemy database
190
+ (e.g., 'sqlite:///mydatabase.db').
191
+ discussion_mixin: Optional mixin class for the Discussion model.
192
+ message_mixin: Optional mixin class for the Message model.
193
+ encryption_key: Optional key to enable database encryption.
194
+ """
121
195
  if not db_path:
122
196
  raise ValueError("Database path cannot be empty.")
197
+
123
198
  self.Base, self.DiscussionModel, self.MessageModel = create_dynamic_models(
124
199
  discussion_mixin, message_mixin, encryption_key
125
200
  )
126
201
  self.engine = create_engine(db_path)
127
202
  self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
128
- self.create_tables()
203
+ self.create_and_migrate_tables()
129
204
 
130
- def create_tables(self):
205
+ def create_and_migrate_tables(self):
206
+ """Creates all tables if they don't exist and performs simple schema migrations."""
131
207
  self.Base.metadata.create_all(bind=self.engine)
208
+ try:
209
+ with self.engine.connect() as connection:
210
+ print("Checking for database schema upgrades...")
211
+
212
+ # --- THIS IS THE FIX ---
213
+ # We must wrap raw SQL strings in the `text()` function for direct execution.
214
+ cursor = connection.execute(text("PRAGMA table_info(discussions)"))
215
+ columns = [row[1] for row in cursor.fetchall()]
216
+
217
+ if 'pruning_summary' not in columns:
218
+ print(" -> Upgrading 'discussions' table: Adding 'pruning_summary' column.")
219
+ connection.execute(text("ALTER TABLE discussions ADD COLUMN pruning_summary TEXT"))
220
+
221
+ if 'pruning_point_id' not in columns:
222
+ print(" -> Upgrading 'discussions' table: Adding 'pruning_point_id' column.")
223
+ connection.execute(text("ALTER TABLE discussions ADD COLUMN pruning_point_id VARCHAR"))
224
+
225
+ print("Database schema is up to date.")
226
+ # This is important to apply the ALTER TABLE statements
227
+ connection.commit()
228
+
229
+ except Exception as e:
230
+ print(f"\n--- DATABASE MIGRATION WARNING ---")
231
+ print(f"An error occurred during database schema migration: {e}")
232
+ print("The application might not function correctly if the schema is outdated.")
233
+ print("If problems persist, consider backing up and deleting the database file.")
234
+ print("---")
132
235
 
133
236
  def get_session(self) -> Session:
237
+ """Returns a new SQLAlchemy session."""
134
238
  return self.SessionLocal()
135
239
 
136
240
  def list_discussions(self) -> List[Dict]:
241
+ """Retrieves a list of all discussions from the database.
242
+
243
+ Returns:
244
+ A list of dictionaries, where each dictionary represents a discussion.
245
+ """
137
246
  with self.get_session() as session:
138
247
  discussions = session.query(self.DiscussionModel).all()
139
248
  return [{c.name: getattr(disc, c.name) for c in disc.__table__.columns} for disc in discussions]
140
249
 
141
250
  def get_discussion(self, lollms_client: 'LollmsClient', discussion_id: str, **kwargs) -> Optional['LollmsDiscussion']:
251
+ """Retrieves a single discussion by its ID and wraps it.
252
+
253
+ Args:
254
+ lollms_client: The LollmsClient instance for the discussion to use.
255
+ discussion_id: The unique ID of the discussion to retrieve.
256
+ **kwargs: Additional arguments to pass to the LollmsDiscussion constructor.
257
+
258
+ Returns:
259
+ An LollmsDiscussion instance if found, otherwise None.
260
+ """
142
261
  with self.get_session() as session:
143
262
  try:
144
263
  db_disc = session.query(self.DiscussionModel).filter_by(id=discussion_id).one()
145
- session.expunge(db_disc)
264
+ session.expunge(db_disc) # Detach from session before returning
146
265
  return LollmsDiscussion(lollmsClient=lollms_client, db_manager=self, db_discussion_obj=db_disc, **kwargs)
147
266
  except NoResultFound:
148
267
  return None
149
268
 
150
269
  def search_discussions(self, **criteria) -> List[Dict]:
270
+ """Searches for discussions based on provided criteria.
271
+
272
+ Args:
273
+ **criteria: Keyword arguments where the key is a column name and
274
+ the value is the string to search for.
275
+
276
+ Returns:
277
+ A list of dictionaries representing the matching discussions.
278
+ """
151
279
  with self.get_session() as session:
152
280
  query = session.query(self.DiscussionModel)
153
281
  for key, value in criteria.items():
@@ -157,24 +285,43 @@ class LollmsDataManager:
157
285
  return [{c.name: getattr(disc, c.name) for c in disc.__table__.columns} for disc in discussions]
158
286
 
159
287
  def delete_discussion(self, discussion_id: str):
288
+ """Deletes a discussion and all its associated messages from the database.
289
+
290
+ Args:
291
+ discussion_id: The ID of the discussion to delete.
292
+ """
160
293
  with self.get_session() as session:
161
294
  db_disc = session.query(self.DiscussionModel).filter_by(id=discussion_id).first()
162
295
  if db_disc:
163
296
  session.delete(db_disc)
164
297
  session.commit()
165
298
 
299
+
166
300
  class LollmsMessage:
167
- """A wrapper for a message ORM object, providing direct attribute access."""
301
+ """A lightweight proxy wrapper for a message ORM object.
302
+
303
+ This class provides a more direct and convenient API for interacting with a
304
+ message's data, proxying attribute access to the underlying database object.
305
+ """
306
+
168
307
  def __init__(self, discussion: 'LollmsDiscussion', db_message: Any):
308
+ """Initializes the message proxy.
309
+
310
+ Args:
311
+ discussion: The parent LollmsDiscussion instance.
312
+ db_message: The underlying SQLAlchemy ORM message object or a SimpleNamespace.
313
+ """
169
314
  object.__setattr__(self, '_discussion', discussion)
170
315
  object.__setattr__(self, '_db_message', db_message)
171
316
 
172
317
  def __getattr__(self, name: str) -> Any:
318
+ """Proxies attribute getting to the underlying DB object."""
173
319
  if name == 'metadata':
174
320
  return getattr(self._db_message, 'message_metadata', None)
175
321
  return getattr(self._db_message, name)
176
322
 
177
323
  def __setattr__(self, name: str, value: Any):
324
+ """Proxies attribute setting to the underlying DB object and marks discussion as dirty."""
178
325
  if name == 'metadata':
179
326
  setattr(self._db_message, 'message_metadata', value)
180
327
  else:
@@ -182,23 +329,44 @@ class LollmsMessage:
182
329
  self._discussion.touch()
183
330
 
184
331
  def __repr__(self) -> str:
332
+ """Provides a developer-friendly representation of the message."""
185
333
  return f"<LollmsMessage id={self.id} sender='{self.sender}'>"
186
334
 
335
+
187
336
  class LollmsDiscussion:
188
- """Represents and manages a single discussion, acting as a high-level interface."""
189
- def __init__(self, lollmsClient: 'LollmsClient', db_manager: Optional[LollmsDataManager] = None,
190
- discussion_id: Optional[str] = None, db_discussion_obj: Optional[Any] = None,
191
- autosave: bool = False, max_context_size: Optional[int] = None):
192
-
337
+ """Represents and manages a single discussion.
338
+
339
+ This class is the primary user-facing interface for interacting with a
340
+ conversation. It can be database-backed or entirely in-memory. It handles
341
+ message management, branching, context formatting, and automatic,
342
+ non-destructive context pruning.
343
+ """
344
+
345
+ def __init__(self,
346
+ lollmsClient: 'LollmsClient',
347
+ db_manager: Optional[LollmsDataManager] = None,
348
+ discussion_id: Optional[str] = None,
349
+ db_discussion_obj: Optional[Any] = None,
350
+ autosave: bool = False,
351
+ max_context_size: Optional[int] = None):
352
+ """Initializes a discussion instance.
353
+
354
+ Args:
355
+ lollmsClient: The LollmsClient instance used for generation and token counting.
356
+ db_manager: An optional LollmsDataManager for database persistence.
357
+ discussion_id: The ID of the discussion to load (if db_manager is provided).
358
+ db_discussion_obj: A pre-loaded ORM object to wrap.
359
+ autosave: If True, commits changes to the DB automatically after modifications.
360
+ max_context_size: The maximum number of tokens to allow in the context
361
+ before triggering automatic pruning.
362
+ """
193
363
  object.__setattr__(self, 'lollmsClient', lollmsClient)
194
364
  object.__setattr__(self, 'db_manager', db_manager)
195
365
  object.__setattr__(self, 'autosave', autosave)
196
366
  object.__setattr__(self, 'max_context_size', max_context_size)
197
367
  object.__setattr__(self, 'scratchpad', "")
198
- object.__setattr__(self, 'show_thoughts', False)
199
- object.__setattr__(self, 'include_thoughts_in_context', False)
200
- object.__setattr__(self, 'thought_placeholder', "<thought process hidden>")
201
368
 
369
+ # Internal state
202
370
  object.__setattr__(self, '_session', None)
203
371
  object.__setattr__(self, '_db_discussion', None)
204
372
  object.__setattr__(self, '_message_index', None)
@@ -220,21 +388,23 @@ class LollmsDiscussion:
220
388
  raise ValueError(f"No discussion found with ID: {discussion_id}")
221
389
  else:
222
390
  self._create_in_memory_proxy(id=discussion_id)
391
+
223
392
  self._rebuild_message_index()
224
-
225
- @property
226
- def remaining_tokens(self) -> Optional[int]:
227
- """Calculates the remaining tokens available in the context window."""
228
- binding = self.lollmsClient.binding
229
- if not binding or not hasattr(binding, 'ctx_size') or not binding.ctx_size:
230
- return None
231
- max_ctx = binding.ctx_size
232
- current_prompt = self.format_discussion(max_ctx)
233
- current_tokens = self.lollmsClient.count_tokens(current_prompt)
234
- return max_ctx - current_tokens
235
393
 
236
394
  @classmethod
237
395
  def create_new(cls, lollms_client: 'LollmsClient', db_manager: Optional[LollmsDataManager] = None, **kwargs) -> 'LollmsDiscussion':
396
+ """Creates a new discussion and persists it if a db_manager is provided.
397
+
398
+ This is the recommended factory method for creating new discussions.
399
+
400
+ Args:
401
+ lollms_client: The LollmsClient instance to associate with the discussion.
402
+ db_manager: An optional LollmsDataManager to make the discussion persistent.
403
+ **kwargs: Attributes for the new discussion (e.g., id, title).
404
+
405
+ Returns:
406
+ A new LollmsDiscussion instance.
407
+ """
238
408
  init_args = {
239
409
  'autosave': kwargs.pop('autosave', False),
240
410
  'max_context_size': kwargs.pop('max_context_size', None)
@@ -252,6 +422,7 @@ class LollmsDiscussion:
252
422
  return cls(lollmsClient=lollms_client, discussion_id=kwargs.get('id'), **init_args)
253
423
 
254
424
  def __getattr__(self, name: str) -> Any:
425
+ """Proxies attribute getting to the underlying discussion object."""
255
426
  if name == 'metadata':
256
427
  return getattr(self._db_discussion, 'discussion_metadata', None)
257
428
  if name == 'messages':
@@ -259,10 +430,10 @@ class LollmsDiscussion:
259
430
  return getattr(self._db_discussion, name)
260
431
 
261
432
  def __setattr__(self, name: str, value: Any):
433
+ """Proxies attribute setting to the underlying discussion object."""
262
434
  internal_attrs = [
263
- 'lollmsClient','db_manager','autosave','max_context_size','scratchpad',
264
- 'show_thoughts', 'include_thoughts_in_context', 'thought_placeholder',
265
- '_session','_db_discussion','_message_index','_messages_to_delete_from_db', '_is_db_backed'
435
+ 'lollmsClient', 'db_manager', 'autosave', 'max_context_size', 'scratchpad',
436
+ '_session', '_db_discussion', '_message_index', '_messages_to_delete_from_db', '_is_db_backed'
266
437
  ]
267
438
  if name in internal_attrs:
268
439
  object.__setattr__(self, name, value)
@@ -274,32 +445,47 @@ class LollmsDiscussion:
274
445
  self.touch()
275
446
 
276
447
  def _create_in_memory_proxy(self, id: Optional[str] = None):
448
+ """Creates a SimpleNamespace object to mimic a DB record for in-memory discussions."""
277
449
  proxy = SimpleNamespace()
278
- proxy.id, proxy.system_prompt, proxy.participants = id or str(uuid.uuid4()), None, {}
279
- proxy.active_branch_id, proxy.discussion_metadata = None, {}
280
- proxy.created_at, proxy.updated_at = datetime.utcnow(), datetime.utcnow()
450
+ proxy.id = id or str(uuid.uuid4())
451
+ proxy.system_prompt = None
452
+ proxy.participants = {}
453
+ proxy.active_branch_id = None
454
+ proxy.discussion_metadata = {}
455
+ proxy.created_at = datetime.utcnow()
456
+ proxy.updated_at = datetime.utcnow()
281
457
  proxy.messages = []
458
+ proxy.pruning_summary = None
459
+ proxy.pruning_point_id = None
282
460
  object.__setattr__(self, '_db_discussion', proxy)
283
461
 
284
462
  def _rebuild_message_index(self):
463
+ """Rebuilds the internal dictionary mapping message IDs to message objects."""
285
464
  if self._is_db_backed and self._session.is_active and self._db_discussion in self._session:
286
465
  self._session.refresh(self._db_discussion, ['messages'])
287
466
  self._message_index = {msg.id: msg for msg in self._db_discussion.messages}
288
467
 
289
468
  def touch(self):
469
+ """Marks the discussion as updated and saves it if autosave is enabled."""
290
470
  setattr(self._db_discussion, 'updated_at', datetime.utcnow())
291
471
  if self._is_db_backed and self.autosave:
292
472
  self.commit()
293
473
 
294
474
  def commit(self):
475
+ """Commits all pending changes to the database.
476
+
477
+ This includes new/modified discussion attributes and any pending message deletions.
478
+ """
295
479
  if not self._is_db_backed or not self._session:
296
480
  return
481
+
297
482
  if self._messages_to_delete_from_db:
298
483
  for msg_id in self._messages_to_delete_from_db:
299
484
  msg_to_del = self._session.get(self.db_manager.MessageModel, msg_id)
300
485
  if msg_to_del:
301
486
  self._session.delete(msg_to_del)
302
487
  self._messages_to_delete_from_db.clear()
488
+
303
489
  try:
304
490
  self._session.commit()
305
491
  self._rebuild_message_index()
@@ -308,15 +494,34 @@ class LollmsDiscussion:
308
494
  raise e
309
495
 
310
496
  def close(self):
497
+ """Commits any final changes and closes the database session."""
311
498
  if self._session:
312
499
  self.commit()
313
500
  self._session.close()
314
501
 
315
502
  def add_message(self, **kwargs) -> LollmsMessage:
316
- msg_id, parent_id = kwargs.get('id', str(uuid.uuid4())), kwargs.get('parent_id', self.active_branch_id)
317
- message_data = {'id': msg_id, 'parent_id': parent_id, 'discussion_id': self.id, 'created_at': datetime.utcnow(), **kwargs}
503
+ """Adds a new message to the discussion.
504
+
505
+ Args:
506
+ **kwargs: Attributes for the new message (e.g., sender, content, parent_id).
507
+
508
+ Returns:
509
+ The newly created LollmsMessage instance.
510
+ """
511
+ msg_id = kwargs.get('id', str(uuid.uuid4()))
512
+ parent_id = kwargs.get('parent_id', self.active_branch_id)
513
+
514
+ message_data = {
515
+ 'id': msg_id,
516
+ 'parent_id': parent_id,
517
+ 'discussion_id': self.id,
518
+ 'created_at': datetime.utcnow(),
519
+ **kwargs
520
+ }
521
+
318
522
  if 'metadata' in message_data:
319
523
  message_data['message_metadata'] = message_data.pop('metadata')
524
+
320
525
  if self._is_db_backed:
321
526
  valid_keys = {c.name for c in self.db_manager.MessageModel.__table__.columns}
322
527
  filtered_data = {k: v for k, v in message_data.items() if k in valid_keys}
@@ -327,158 +532,185 @@ class LollmsDiscussion:
327
532
  else:
328
533
  new_msg_orm = SimpleNamespace(**message_data)
329
534
  self._db_discussion.messages.append(new_msg_orm)
330
- self._message_index[msg_id], self.active_branch_id = new_msg_orm, msg_id
535
+
536
+ self._message_index[msg_id] = new_msg_orm
537
+ self.active_branch_id = msg_id
331
538
  self.touch()
332
539
  return LollmsMessage(self, new_msg_orm)
333
540
 
334
541
  def get_branch(self, leaf_id: Optional[str]) -> List[LollmsMessage]:
542
+ """Traces a branch of the conversation from a leaf message back to the root.
543
+
544
+ Args:
545
+ leaf_id: The ID of the message at the end of the branch.
546
+
547
+ Returns:
548
+ A list of LollmsMessage objects, ordered from the root to the leaf.
549
+ """
335
550
  if not leaf_id:
336
551
  return []
337
- branch_orms, current_id = [], leaf_id
552
+
553
+ branch_orms = []
554
+ current_id = leaf_id
338
555
  while current_id and current_id in self._message_index:
339
556
  msg_orm = self._message_index[current_id]
340
557
  branch_orms.append(msg_orm)
341
558
  current_id = msg_orm.parent_id
559
+
342
560
  return [LollmsMessage(self, orm) for orm in reversed(branch_orms)]
343
561
 
344
562
 
345
-
346
563
  def chat(
347
- self,
348
- user_message: str,
564
+ self,
565
+ user_message: str,
349
566
  personality: Optional['LollmsPersonality'] = None,
350
567
  use_mcps: Union[None, bool, List[str]] = None,
351
568
  use_data_store: Union[None, Dict[str, Callable]] = None,
352
- build_plan: bool = True,
353
- add_user_message: bool = True, # New parameter
354
- max_tool_calls = 10,
355
- rag_top_k = 5,
569
+ add_user_message: bool = True,
570
+ max_reasoning_steps: int = 10,
571
+ images: Optional[List[str]] = None,
356
572
  **kwargs
357
- ) -> Dict[str, 'LollmsMessage']: # Return type changed
358
- """
359
- Main interaction method for the discussion. It can perform a simple chat or
360
- trigger a complex agentic loop with RAG and MCP tool use.
573
+ ) -> Dict[str, 'LollmsMessage']:
574
+ """Main interaction method that can invoke the dynamic, multi-modal agent.
575
+
576
+ This method orchestrates the entire response generation process. It can
577
+ trigger a simple, direct chat with the language model, or it can invoke
578
+ the powerful `generate_with_mcp_rag` agent.
579
+
580
+ When an agentic turn is used, the agent's full reasoning process (the
581
+ `final_scratchpad`), tool calls, and any retrieved RAG sources are
582
+ automatically stored in the resulting AI message object for full persistence
583
+ and auditability. It also handles clarification requests from the agent.
361
584
 
362
585
  Args:
363
- user_message (str): The new message from the user.
364
- personality (Optional[LollmsPersonality], optional): The personality to use. Defaults to None.
365
- use_mcps (Union[None, bool, List[str]], optional): Controls MCP tool usage. Defaults to None.
366
- use_data_store (Union[None, Dict[str, Callable]], optional): Controls RAG usage. Defaults to None.
367
- build_plan (bool, optional): If True, the agent will generate an initial plan. Defaults to True.
368
- add_user_message (bool, optional): If True, a new user message is created from the prompt.
369
- If False, it assumes regeneration on the current active user message. Defaults to True.
370
- **kwargs: Additional keyword arguments passed to the underlying generation method.
586
+ user_message: The new message from the user.
587
+ personality: An optional LollmsPersonality to use for the response,
588
+ which can influence system prompts and other behaviors.
589
+ use_mcps: Controls MCP tool usage for the agent. Can be None (disabled),
590
+ True (all tools), or a list of specific tool names.
591
+ use_data_store: Controls RAG usage for the agent. A dictionary mapping
592
+ store names to their query callables.
593
+ add_user_message: If True, a new user message is created from the prompt.
594
+ If False, it assumes regeneration on the current active
595
+ user message.
596
+ max_reasoning_steps: The maximum number of reasoning cycles for the agent
597
+ before it must provide a final answer.
598
+ images: A list of base64-encoded images provided by the user, which will
599
+ be passed to the agent or a multi-modal LLM.
600
+ **kwargs: Additional keyword arguments passed to the underlying generation
601
+ methods, such as 'streaming_callback'.
371
602
 
372
603
  Returns:
373
- Dict[str, LollmsMessage]: A dictionary with 'user_message' and 'ai_message' objects.
604
+ A dictionary with 'user_message' and 'ai_message' LollmsMessage objects,
605
+ where the 'ai_message' will contain rich metadata if an agentic turn was used.
374
606
  """
375
607
  if self.max_context_size is not None:
376
608
  self.summarize_and_prune(self.max_context_size)
377
609
 
378
- # Add user message to the discussion or get the existing one
610
+ # Step 1: Add user message, now including any images.
379
611
  if add_user_message:
380
- # Pass kwargs to capture images, etc., sent from the router
381
- user_msg = self.add_message(sender="user", sender_type="user", content=user_message, **kwargs)
382
- else:
383
- # We are regenerating. The current active branch tip must be the user message.
612
+ # Pass kwargs through to capture images and other potential message attributes
613
+ user_msg = self.add_message(
614
+ sender="user",
615
+ sender_type="user",
616
+ content=user_message,
617
+ images=images,
618
+ **kwargs # Use kwargs to allow other fields to be set from the caller
619
+ )
620
+ else: # Regeneration logic
384
621
  if self.active_branch_id not in self._message_index:
385
622
  raise ValueError("Regeneration failed: active branch tip not found or is invalid.")
386
623
  user_msg_orm = self._message_index[self.active_branch_id]
387
624
  if user_msg_orm.sender_type != 'user':
388
625
  raise ValueError(f"Regeneration failed: active branch tip is a '{user_msg_orm.sender_type}' message, not 'user'.")
389
626
  user_msg = LollmsMessage(self, user_msg_orm)
627
+ # For regeneration, we use the images from the original user message
628
+ images = user_msg.images
629
+
630
+ # Step 2: Determine if this is a simple chat or a complex agentic turn.
631
+ is_agentic_turn = (use_mcps is not None and use_mcps) or (use_data_store is not None and use_data_store)
390
632
 
391
- # --- (The existing generation logic remains the same) ---
392
- is_agentic_turn = (use_mcps is not None and len(use_mcps)>0) or (use_data_store is not None and len(use_data_store)>0)
393
- rag_context = None
394
- original_system_prompt = self.system_prompt
395
- if personality:
396
- self.system_prompt = personality.system_prompt
397
- if user_message and not is_agentic_turn:
398
- rag_context = personality.get_rag_context(user_message)
399
- if rag_context:
400
- self.system_prompt = f"{original_system_prompt or ''}\n\n--- Relevant Information ---\n{rag_context}\n---"
401
633
  start_time = datetime.now()
402
- if is_agentic_turn:
403
- # --- FIX: Provide the full conversation context to the agent ---
404
- # 1. Get the model's max context size.
405
- max_ctx = self.lollmsClient.binding.get_ctx_size(self.lollmsClient.binding.model_name) if self.lollmsClient.binding else None
406
-
407
- # 2. Format the entire discussion up to this point, including the new user message.
408
- # This ensures the agent has the full history.
409
- full_context_prompt = self.format_discussion(max_allowed_tokens=max_ctx)
634
+
635
+ agent_result = None
636
+ final_scratchpad = None
637
+ final_raw_response = ""
638
+ final_content = ""
410
639
 
411
- # 3. Call the agent with the complete context.
412
- # We pass the full context to the 'prompt' argument. The `system_prompt` is already
413
- # included within the formatted text, so we don't pass it separately to avoid duplication.
640
+ # Step 3: Execute the appropriate generation logic.
641
+ if is_agentic_turn:
642
+ # --- AGENTIC TURN ---
414
643
  agent_result = self.lollmsClient.generate_with_mcp_rag(
415
- prompt=full_context_prompt,
416
- use_mcps=use_mcps,
417
- use_data_store=use_data_store,
418
- build_plan=build_plan,
419
- max_tool_calls = max_tool_calls,
420
- rag_top_k= rag_top_k,
644
+ prompt=user_message,
645
+ use_mcps=use_mcps,
646
+ use_data_store=use_data_store,
647
+ max_reasoning_steps=max_reasoning_steps,
648
+ images=images,
421
649
  **kwargs
422
650
  )
423
- final_content = agent_result.get("final_answer", "")
424
- thoughts_text = None
425
- final_raw_response = json.dumps(agent_result)
651
+ final_content = agent_result.get("final_answer", "The agent did not produce a final answer.")
652
+ final_scratchpad = agent_result.get("final_scratchpad", "")
653
+ final_raw_response = json.dumps(agent_result, indent=2)
654
+
426
655
  else:
427
- if personality and personality.script_module and hasattr(personality.script_module, 'run'):
428
- try:
429
- final_raw_response = personality.script_module.run(self, kwargs.get("streaming_callback"))
430
- except Exception as e:
431
- final_raw_response = f"Error executing personality script: {e}"
432
- else:
433
- is_streaming = "streaming_callback" in kwargs and kwargs.get("streaming_callback") is not None
434
- if is_streaming:
435
- raw_response_accumulator = self.lollmsClient.chat(self, **kwargs)
436
- final_raw_response = "".join(raw_response_accumulator)
437
- else:
438
- kwargs["stream"] = False
439
- final_raw_response = self.lollmsClient.chat(self, **kwargs) or ""
440
- thoughts_match = re.search(r"<think>(.*?)</think>", final_raw_response, re.DOTALL)
441
- thoughts_text = thoughts_match.group(1).strip() if thoughts_match else None
656
+ # --- SIMPLE CHAT TURN ---
657
+ # For simple chat, we also need to consider images if the model is multi-modal
658
+ final_raw_response = self.lollmsClient.chat(self, images=images, **kwargs) or ""
442
659
  final_content = self.lollmsClient.remove_thinking_blocks(final_raw_response)
443
- if rag_context or (personality and self.system_prompt != original_system_prompt):
444
- self.system_prompt = original_system_prompt
660
+ final_scratchpad = None # No agentic scratchpad in a simple turn
661
+
662
+ # Step 4: Post-generation processing and statistics.
445
663
  end_time = datetime.now()
446
664
  duration = (end_time - start_time).total_seconds()
447
665
  token_count = self.lollmsClient.count_tokens(final_content)
448
666
  tok_per_sec = (token_count / duration) if duration > 0 else 0
449
- # --- (End of existing logic) ---
450
667
 
451
- # --- FIX: Store agentic results in metadata ---
668
+ # Step 5: Collect metadata from the agentic turn for storage.
452
669
  message_meta = {}
453
670
  if is_agentic_turn and isinstance(agent_result, dict):
454
- # We store the 'steps' and 'sources' if they exist in the agent result.
455
- # This makes them available to the frontend in the final message object.
456
- if "steps" in agent_result:
457
- message_meta["steps"] = agent_result["steps"]
671
+ if "tool_calls" in agent_result:
672
+ message_meta["tool_calls"] = agent_result["tool_calls"]
458
673
  if "sources" in agent_result:
459
674
  message_meta["sources"] = agent_result["sources"]
675
+ if agent_result.get("clarification_required", False):
676
+ message_meta["clarification_required"] = True
460
677
 
678
+ # Step 6: Add the final AI message to the discussion.
461
679
  ai_message_obj = self.add_message(
462
- sender=personality.name if personality else "assistant", sender_type="assistant", content=final_content,
463
- raw_content=final_raw_response, thoughts=thoughts_text, tokens=token_count,
464
- binding_name=self.lollmsClient.binding.binding_name, model_name=self.lollmsClient.binding.model_name,
680
+ sender=personality.name if personality else "assistant",
681
+ sender_type="assistant",
682
+ content=final_content,
683
+ raw_content=final_raw_response,
684
+ # Store the agent's full reasoning log in the message's dedicated scratchpad field
685
+ scratchpad=final_scratchpad,
686
+ tokens=token_count,
465
687
  generation_speed=tok_per_sec,
466
- parent_id=user_msg.id, # Ensure the AI response is a child of the user message
467
- metadata=message_meta # Pass the collected metadata here
688
+ parent_id=user_msg.id,
689
+ metadata=message_meta
468
690
  )
691
+
469
692
  if self._is_db_backed and self.autosave:
470
693
  self.commit()
471
694
 
472
695
  return {"user_message": user_msg, "ai_message": ai_message_obj}
473
696
 
474
697
  def regenerate_branch(self, **kwargs) -> Dict[str, 'LollmsMessage']:
698
+ """Regenerates the last AI response in the active branch.
699
+
700
+ It deletes the previous AI response and calls chat() again with the
701
+ same user prompt.
702
+
703
+ Args:
704
+ **kwargs: Additional arguments for the chat() method.
705
+
706
+ Returns:
707
+ A dictionary with the user and the newly generated AI message.
708
+ """
475
709
  if not self.active_branch_id or self.active_branch_id not in self._message_index:
476
710
  raise ValueError("No active message to regenerate from.")
477
711
 
478
712
  last_message_orm = self._message_index[self.active_branch_id]
479
713
 
480
- # If the current active message is the assistant's, we need to delete it
481
- # and set the active branch to its parent (the user message).
482
714
  if last_message_orm.sender_type == 'assistant':
483
715
  parent_id = last_message_orm.parent_id
484
716
  if not parent_id:
@@ -493,218 +725,245 @@ class LollmsDiscussion:
493
725
  self.active_branch_id = parent_id
494
726
  self.touch()
495
727
 
496
- # The active branch is now guaranteed to be on a user message.
497
- # Call chat, but do not add a new user message.
498
728
  prompt_to_regenerate = self._message_index[self.active_branch_id].content
499
729
  return self.chat(user_message=prompt_to_regenerate, add_user_message=False, **kwargs)
730
+ def delete_branch(self, message_id: str):
731
+ """Deletes a message and its entire descendant branch.
500
732
 
501
- def process_and_summarize(self, large_text: str, user_prompt: str, chunk_size: int = 4096, **kwargs) -> LollmsMessage:
502
- user_msg = self.add_message(sender="user", sender_type="user", content=user_prompt)
503
- chunks = [large_text[i:i + chunk_size] for i in range(0, len(large_text), chunk_size)]
504
- current_summary, total_chunks = "", len(chunks)
505
- for i, chunk in enumerate(chunks):
506
- print(f"\nProcessing chunk {i+1}/{total_chunks}...")
507
- if i == 0:
508
- prompt = f"""The user wants to know: "{user_prompt}"\nHere is the first part of the document (chunk 1 of {total_chunks}). \nRead it and create a detailed summary of all information relevant to the user's prompt.\n\nDOCUMENT CHUNK:\n---\n{chunk}\n---\nSUMMARY:"""
509
- else:
510
- prompt = f"""The user wants to know: "{user_prompt}"\nYou are processing a large document sequentially. Here is the summary of the previous chunks and the content of the next chunk ({i+1} of {total_chunks}).\nUpdate your summary by integrating new relevant information from the new chunk. Do not repeat information you already have. Output ONLY the new, updated, complete summary.\n\nPREVIOUS SUMMARY:\n---\n{current_summary}\n---\n\nNEW DOCUMENT CHUNK:\n---\n{chunk}\n---\nUPDATED SUMMARY:"""
511
- current_summary = self.lollmsClient.generate_text(prompt, **kwargs).strip()
512
- final_prompt = f"""Based on the following comprehensive summary of a document, provide a final answer to the user's original prompt.\nUser's prompt: "{user_prompt}"\n\nCOMPREHENSIVE SUMMARY:\n---\n{current_summary}\n---\nFINAL ANSWER:"""
513
- final_answer = self.lollmsClient.generate_text(final_prompt, **kwargs).strip()
514
- ai_message_obj = self.add_message(
515
- sender="assistant", sender_type="assistant", content=final_answer,
516
- scratchpad=current_summary, parent_id=user_msg.id
517
- )
518
- if self._is_db_backed and not self.autosave:
519
- self.commit()
520
- return ai_message_obj
733
+ This method removes the specified message and any messages that have it
734
+ as a parent or an ancestor. After deletion, the active branch is moved
735
+ to the parent of the deleted message.
521
736
 
737
+ This operation is only supported for database-backed discussions.
522
738
 
523
- def delete_branch(self, message_id: str):
739
+ Args:
740
+ message_id: The ID of the message at the root of the branch to be deleted.
741
+
742
+ Raises:
743
+ NotImplementedError: If the discussion is not database-backed.
744
+ ValueError: If the message ID is not found in the discussion.
745
+ """
524
746
  if not self._is_db_backed:
525
747
  raise NotImplementedError("Branch deletion is only supported for database-backed discussions.")
748
+
526
749
  if message_id not in self._message_index:
527
- raise ValueError("Message not found.")
528
- msg_to_delete = self._session.query(self.db_manager.MessageModel).filter_by(id=message_id).first()
529
- if msg_to_delete:
530
- self.active_branch_id = msg_to_delete.parent_id
531
- self._session.delete(msg_to_delete)
532
- self.commit()
750
+ raise ValueError(f"Message with ID '{message_id}' not found in the discussion.")
533
751
 
534
- def switch_to_branch(self, message_id: str):
535
- if message_id not in self._message_index:
536
- raise ValueError(f"Message ID '{message_id}' not found in the current discussion.")
537
- self.active_branch_id = message_id
538
- self.touch()
752
+ # --- 1. Identify all messages to delete ---
753
+ # We start with the target message and find all of its descendants.
754
+ messages_to_delete_ids = set()
755
+ queue = [message_id] # A queue for breadth-first search of descendants
539
756
 
540
- def format_discussion(self, max_allowed_tokens: int, branch_tip_id: Optional[str] = None) -> str:
541
- return self.export("lollms_text", branch_tip_id, max_allowed_tokens)
757
+ while queue:
758
+ current_id = queue.pop(0)
759
+ if current_id in messages_to_delete_ids:
760
+ continue # Already processed
761
+
762
+ messages_to_delete_ids.add(current_id)
542
763
 
543
- def _get_full_system_prompt(self) -> Optional[str]:
544
- parts = []
545
- if self.scratchpad:
546
- parts.extend(["--- KNOWLEDGE SCRATCHPAD ---", self.scratchpad.strip(), "--- END SCRATCHPAD ---"])
547
- if self.system_prompt and self.system_prompt.strip():
548
- parts.append(self.system_prompt.strip())
549
- return "\n\n".join(parts) if parts else None
764
+ # Find all direct children of the current message
765
+ children = [msg.id for msg in self._db_discussion.messages if msg.parent_id == current_id]
766
+ queue.extend(children)
767
+
768
+ # --- 2. Get the parent of the starting message to reset the active branch ---
769
+ original_message_orm = self._message_index[message_id]
770
+ new_active_branch_id = original_message_orm.parent_id
771
+
772
+ # --- 3. Perform the deletion ---
773
+ # Remove from the ORM object's list
774
+ self._db_discussion.messages = [
775
+ msg for msg in self._db_discussion.messages if msg.id not in messages_to_delete_ids
776
+ ]
777
+
778
+ # Remove from the quick-access index
779
+ for mid in messages_to_delete_ids:
780
+ if mid in self._message_index:
781
+ del self._message_index[mid]
782
+
783
+ # Add to the set of messages to be deleted from the DB on next commit
784
+ self._messages_to_delete_from_db.update(messages_to_delete_ids)
550
785
 
786
+ # --- 4. Update the active branch ---
787
+ # If we deleted the branch that was active, move to its parent.
788
+ if self.active_branch_id in messages_to_delete_ids:
789
+ self.active_branch_id = new_active_branch_id
790
+
791
+ self.touch() # Mark discussion as updated and save if autosave is on
792
+
793
+ print(f"Marked branch starting at {message_id} ({len(messages_to_delete_ids)} messages) for deletion.")
794
+
551
795
  def export(self, format_type: str, branch_tip_id: Optional[str] = None, max_allowed_tokens: Optional[int] = None) -> Union[List[Dict], str]:
796
+ """Exports the discussion history into a specified format.
797
+
798
+ This method can format the conversation for different backends like OpenAI,
799
+ Ollama, or the native `lollms_text` format. It intelligently handles
800
+ context limits and non-destructive pruning summaries.
801
+
802
+ Args:
803
+ format_type: The target format. Can be "lollms_text", "openai_chat",
804
+ or "ollama_chat".
805
+ branch_tip_id: The ID of the message to use as the end of the context.
806
+ Defaults to the active branch ID.
807
+ max_allowed_tokens: The maximum number of tokens the final prompt can contain.
808
+ This is primarily used by "lollms_text".
809
+
810
+ Returns:
811
+ A string for "lollms_text" or a list of dictionaries for "openai_chat"
812
+ and "ollama_chat".
813
+
814
+ Raises:
815
+ ValueError: If an unsupported format_type is provided.
816
+ """
552
817
  branch_tip_id = branch_tip_id or self.active_branch_id
553
818
  if not branch_tip_id and format_type in ["lollms_text", "openai_chat", "ollama_chat"]:
554
819
  return "" if format_type == "lollms_text" else []
555
- branch, full_system_prompt, participants = self.get_branch(branch_tip_id), self._get_full_system_prompt(), self.participants or {}
820
+
821
+ branch = self.get_branch(branch_tip_id)
822
+ full_system_prompt = self.system_prompt # Simplified for clarity
823
+ participants = self.participants or {}
556
824
 
557
- def get_full_content(msg: LollmsMessage) -> str:
825
+ def get_full_content(msg: 'LollmsMessage') -> str:
558
826
  content_to_use = msg.content
559
- if self.include_thoughts_in_context and msg.sender_type == 'assistant' and msg.raw_content:
560
- if self.thought_placeholder:
561
- content_to_use = re.sub(r"<think>.*?</think>", f"<think>{self.thought_placeholder}</think>", msg.raw_content, flags=re.DOTALL)
562
- else:
563
- content_to_use = msg.raw_content
564
-
565
- parts = [f"--- Internal Scratchpad ---\n{msg.scratchpad.strip()}\n---"] if msg.scratchpad and msg.scratchpad.strip() else []
566
- parts.append(content_to_use.strip())
567
- return "\n".join(parts)
827
+ # You can expand this logic to include thoughts, scratchpads etc. based on settings
828
+ return content_to_use.strip()
568
829
 
830
+ # --- NATIVE LOLLMS_TEXT FORMAT ---
569
831
  if format_type == "lollms_text":
570
- prompt_parts, current_tokens = [], 0
832
+ # --- FIX STARTS HERE ---
833
+ final_prompt_parts = []
834
+ message_parts = [] # Temporary list for correctly ordered messages
835
+
836
+ current_tokens = 0
837
+ messages_to_render = branch
838
+
839
+ # 1. Handle non-destructive pruning summary
840
+ summary_text = ""
841
+ if self.pruning_summary and self.pruning_point_id:
842
+ pruning_index = -1
843
+ for i, msg in enumerate(branch):
844
+ if msg.id == self.pruning_point_id:
845
+ pruning_index = i
846
+ break
847
+ if pruning_index != -1:
848
+ messages_to_render = branch[pruning_index:]
849
+ summary_text = f"!@>system:\n--- Conversation Summary ---\n{self.pruning_summary.strip()}\n"
850
+
851
+ # 2. Add main system prompt to the final list
852
+ sys_msg_text = ""
571
853
  if full_system_prompt:
572
- sys_msg_text = f"!@>system:\n{full_system_prompt}\n"
854
+ sys_msg_text = f"!@>system:\n{full_system_prompt.strip()}\n"
573
855
  sys_tokens = self.lollmsClient.count_tokens(sys_msg_text)
574
856
  if max_allowed_tokens is None or sys_tokens <= max_allowed_tokens:
575
- prompt_parts.append(sys_msg_text)
857
+ final_prompt_parts.append(sys_msg_text)
576
858
  current_tokens += sys_tokens
577
- for msg in reversed(branch):
859
+
860
+ # 3. Add pruning summary (if it exists) to the final list
861
+ if summary_text:
862
+ summary_tokens = self.lollmsClient.count_tokens(summary_text)
863
+ if max_allowed_tokens is None or current_tokens + summary_tokens <= max_allowed_tokens:
864
+ final_prompt_parts.append(summary_text)
865
+ current_tokens += summary_tokens
866
+
867
+ # 4. Build the message list in correct order, respecting token limits
868
+ for msg in reversed(messages_to_render):
578
869
  sender_str = msg.sender.replace(':', '').replace('!@>', '')
579
870
  content = get_full_content(msg)
580
871
  if msg.images:
581
872
  content += f"\n({len(msg.images)} image(s) attached)"
582
873
  msg_text = f"!@>{sender_str}:\n{content}\n"
583
874
  msg_tokens = self.lollmsClient.count_tokens(msg_text)
875
+
584
876
  if max_allowed_tokens is not None and current_tokens + msg_tokens > max_allowed_tokens:
585
877
  break
586
- prompt_parts.insert(1 if full_system_prompt else 0, msg_text)
878
+
879
+ # Always insert at the beginning of the temporary list
880
+ message_parts.insert(0, msg_text)
587
881
  current_tokens += msg_tokens
588
- return "".join(prompt_parts).strip()
882
+
883
+ # 5. Combine system/summary prompts with the message parts
884
+ final_prompt_parts.extend(message_parts)
885
+ return "".join(final_prompt_parts).strip()
886
+ # --- FIX ENDS HERE ---
589
887
 
888
+ # --- OPENAI & OLLAMA CHAT FORMATS (remains the same and is correct) ---
590
889
  messages = []
591
890
  if full_system_prompt:
592
891
  messages.append({"role": "system", "content": full_system_prompt})
892
+
593
893
  for msg in branch:
594
- role, content, images = participants.get(msg.sender, "user"), get_full_content(msg), msg.images or []
894
+ if msg.sender_type == 'user':
895
+ role = participants.get(msg.sender, "user")
896
+ else:
897
+ role = participants.get(msg.sender, "assistant")
898
+
899
+ content, images = get_full_content(msg), msg.images or []
900
+
595
901
  if format_type == "openai_chat":
596
902
  if images:
597
903
  content_parts = [{"type": "text", "text": content}] if content else []
598
904
  for img in images:
599
- content_parts.append({"type": "image_url", "image_url": {"url": img['data'] if img['type'] == 'url' else f"data:image/jpeg;base64,{img['data']}", "detail": "auto"}})
905
+ img_data = img['data']
906
+ url = f"data:image/jpeg;base64,{img_data}" if img['type'] == 'base64' else img_data
907
+ content_parts.append({"type": "image_url", "image_url": {"url": url, "detail": "auto"}})
600
908
  messages.append({"role": role, "content": content_parts})
601
909
  else:
602
910
  messages.append({"role": role, "content": content})
911
+
603
912
  elif format_type == "ollama_chat":
604
913
  message_dict = {"role": role, "content": content}
605
914
  base64_images = [img['data'] for img in images if img['type'] == 'base64']
606
915
  if base64_images:
607
916
  message_dict["images"] = base64_images
608
917
  messages.append(message_dict)
918
+
609
919
  else:
610
920
  raise ValueError(f"Unsupported export format_type: {format_type}")
921
+
611
922
  return messages
923
+
612
924
 
613
925
  def summarize_and_prune(self, max_tokens: int, preserve_last_n: int = 4):
926
+ """Non-destructively prunes the discussion by summarizing older messages.
927
+
928
+ This method does NOT delete messages. Instead, it generates a summary of
929
+ the older parts of the conversation and bookmarks the point from which
930
+ the full conversation should resume. The `export()` method then uses this
931
+ information to build a context-window-friendly prompt.
932
+
933
+ Args:
934
+ max_tokens: The token limit that triggers the pruning process.
935
+ preserve_last_n: The number of recent messages to keep in full detail.
936
+ """
614
937
  branch_tip_id = self.active_branch_id
615
938
  if not branch_tip_id:
616
939
  return
617
- current_tokens = self.lollmsClient.count_tokens(self.format_discussion(999999, branch_tip_id))
940
+
941
+ current_formatted_text = self.export("lollms_text", branch_tip_id, 999999)
942
+ current_tokens = self.lollmsClient.count_tokens(current_formatted_text)
943
+
618
944
  if current_tokens <= max_tokens:
619
945
  return
946
+
620
947
  branch = self.get_branch(branch_tip_id)
621
948
  if len(branch) <= preserve_last_n:
622
949
  return
950
+
623
951
  messages_to_prune = branch[:-preserve_last_n]
952
+ pruning_point_message = branch[-preserve_last_n]
953
+
624
954
  text_to_summarize = "\n\n".join([f"{m.sender}: {m.content}" for m in messages_to_prune])
625
- summary_prompt = f"Concisely summarize this conversation excerpt:\n---\n{text_to_summarize}\n---\nSUMMARY:"
955
+ summary_prompt = f"Concisely summarize this conversation excerpt, capturing all key facts, questions, and decisions:\n---\n{text_to_summarize}\n---\nSUMMARY:"
956
+
626
957
  try:
627
- summary = self.lollmsClient.generate_text(summary_prompt, n_predict=300, temperature=0.1)
958
+ print("\n[INFO] Context window is full. Summarizing older messages...")
959
+ summary = self.lollmsClient.generate_text(summary_prompt, n_predict=512, temperature=0.1)
628
960
  except Exception as e:
629
961
  print(f"\n[WARNING] Pruning failed, couldn't generate summary: {e}")
630
962
  return
631
- self.scratchpad = f"{self.scratchpad}\n\n--- Summary of earlier conversation ---\n{summary.strip()}".strip()
632
- pruned_ids = {msg.id for msg in messages_to_prune}
633
- if self._is_db_backed:
634
- self._messages_to_delete_from_db.update(pruned_ids)
635
- self._db_discussion.messages = [m for m in self._db_discussion.messages if m.id not in pruned_ids]
636
- else:
637
- self._db_discussion.messages = [m for m in self._db_discussion.messages if m.id not in pruned_ids]
638
- self._rebuild_message_index()
639
- self.touch()
640
- print(f"\n[INFO] Discussion auto-pruned. {len(messages_to_prune)} messages summarized.")
641
-
642
- def to_dict(self):
643
- return {
644
- "id": self.id, "system_prompt": self.system_prompt, "participants": self.participants,
645
- "active_branch_id": self.active_branch_id, "metadata": self.metadata, "scratchpad": self.scratchpad,
646
- "messages": [{ 'id': m.id, 'parent_id': m.parent_id, 'discussion_id': m.discussion_id, 'sender': m.sender,
647
- 'sender_type': m.sender_type, 'content': m.content, 'scratchpad': m.scratchpad, 'images': m.images,
648
- 'created_at': m.created_at.isoformat(), 'metadata': m.metadata } for m in self.messages],
649
- "created_at": self.created_at.isoformat() if self.created_at else None,
650
- "updated_at": self.updated_at.isoformat() if self.updated_at else None
651
- }
652
963
 
653
- def load_from_dict(self, data: Dict):
654
- self._create_in_memory_proxy(id=data.get("id"))
655
- self.system_prompt, self.participants = data.get("system_prompt"), data.get("participants", {})
656
- self.active_branch_id, self.metadata = data.get("active_branch_id"), data.get("metadata", {})
657
- self.scratchpad = data.get("scratchpad", "")
658
- for msg_data in data.get("messages", []):
659
- if 'created_at' in msg_data and isinstance(msg_data['created_at'], str):
660
- try:
661
- msg_data['created_at'] = datetime.fromisoformat(msg_data['created_at'])
662
- except ValueError:
663
- msg_data['created_at'] = datetime.utcnow()
664
- self.add_message(**msg_data)
665
- self.created_at = datetime.fromisoformat(data['created_at']) if data.get('created_at') else datetime.utcnow()
666
- self.updated_at = datetime.fromisoformat(data['updated_at']) if data.get('updated_at') else self.created_at
667
-
668
- @staticmethod
669
- def migrate(lollms_client: 'LollmsClient', db_manager: LollmsDataManager, folder_path: Union[str, Path]):
670
- folder = Path(folder_path)
671
- if not folder.is_dir():
672
- print(f"Error: Path '{folder}' is not a valid directory.")
673
- return
674
- print(f"\n--- Starting Migration from '{folder}' ---")
675
- files = list(folder.glob("*.json")) + list(folder.glob("*.yaml"))
676
- with db_manager.get_session() as session:
677
- valid_disc_keys = {c.name for c in db_manager.DiscussionModel.__table__.columns}
678
- valid_msg_keys = {c.name for c in db_manager.MessageModel.__table__.columns}
679
- for i, file_path in enumerate(files):
680
- print(f"Migrating file {i+1}/{len(files)}: {file_path.name} ... ", end="")
681
- try:
682
- data = yaml.safe_load(file_path.read_text(encoding='utf-8'))
683
- discussion_id = data.get("id", str(uuid.uuid4()))
684
- if session.query(db_manager.DiscussionModel).filter_by(id=discussion_id).first():
685
- print("SKIPPED (already exists)")
686
- continue
687
- discussion_data = data.copy()
688
- if 'metadata' in discussion_data:
689
- discussion_data['discussion_metadata'] = discussion_data.pop('metadata')
690
- for key in ['created_at', 'updated_at']:
691
- if key in discussion_data and isinstance(discussion_data[key], str):
692
- discussion_data[key] = datetime.fromisoformat(discussion_data[key])
693
- db_discussion = db_manager.DiscussionModel(**{k: v for k, v in discussion_data.items() if k in valid_disc_keys})
694
- session.add(db_discussion)
695
- for msg_data in data.get("messages", []):
696
- msg_data['discussion_id'] = db_discussion.id
697
- if 'metadata' in msg_data:
698
- msg_data['message_metadata'] = msg_data.pop('metadata')
699
- if 'created_at' in msg_data and isinstance(msg_data['created_at'], str):
700
- msg_data['created_at'] = datetime.fromisoformat(msg_data['created_at'])
701
- msg_orm = db_manager.MessageModel(**{k: v for k, v in msg_data.items() if k in valid_msg_keys})
702
- session.add(msg_orm)
703
- session.flush()
704
- print("OK")
705
- except Exception as e:
706
- print(f"FAILED. Error: {e}")
707
- session.rollback()
708
- continue
709
- session.commit()
710
- print("--- Migration Finished ---")
964
+ current_summary = self.pruning_summary or ""
965
+ self.pruning_summary = f"{current_summary}\n\n--- Summary of earlier conversation ---\n{summary.strip()}".strip()
966
+ self.pruning_point_id = pruning_point_message.id
967
+
968
+ self.touch()
969
+ print(f"[INFO] Discussion auto-pruned. {len(messages_to_prune)} messages summarized. History preserved.")