flowent 0.2.4 → 0.3.0

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.
Files changed (46) hide show
  1. package/README.md +3 -3
  2. package/backend/README.md +3 -3
  3. package/backend/pyproject.toml +1 -1
  4. package/backend/src/flowent/agent.py +1 -1
  5. package/backend/src/flowent/api_models.py +103 -0
  6. package/backend/src/flowent/app.py +151 -0
  7. package/backend/src/flowent/cli.py +13 -4
  8. package/backend/src/flowent/compact.py +34 -13
  9. package/backend/src/flowent/llm.py +2 -0
  10. package/backend/src/flowent/main.py +18 -1994
  11. package/backend/src/flowent/mcp.py +100 -2
  12. package/backend/src/flowent/network.py +5 -0
  13. package/backend/src/flowent/provider_connections.py +42 -0
  14. package/backend/src/flowent/routes/__init__.py +0 -0
  15. package/backend/src/flowent/routes/integrations.py +105 -0
  16. package/backend/src/flowent/routes/permissions.py +36 -0
  17. package/backend/src/flowent/routes/providers.py +30 -0
  18. package/backend/src/flowent/routes/system.py +49 -0
  19. package/backend/src/flowent/routes/workflow_routes.py +63 -0
  20. package/backend/src/flowent/routes/workspace.py +105 -0
  21. package/backend/src/flowent/state/__init__.py +53 -0
  22. package/backend/src/flowent/state/models.py +257 -0
  23. package/backend/src/flowent/state/schema.py +186 -0
  24. package/backend/src/flowent/state/store.py +1013 -0
  25. package/backend/src/flowent/static/assets/index-CvWZZMtK.css +2 -0
  26. package/backend/src/flowent/static/assets/index-ma2v8oW7.js +90 -0
  27. package/backend/src/flowent/static/index.html +2 -2
  28. package/backend/src/flowent/storage.py +52 -1318
  29. package/backend/src/flowent/system_tools.py +25 -0
  30. package/backend/src/flowent/tools.py +4 -2
  31. package/backend/src/flowent/usage.py +9 -4
  32. package/backend/src/flowent/workflows.py +282 -0
  33. package/backend/src/flowent/workspace/__init__.py +0 -0
  34. package/backend/src/flowent/workspace/context.py +249 -0
  35. package/backend/src/flowent/workspace/events.py +180 -0
  36. package/backend/src/flowent/workspace/output.py +274 -0
  37. package/backend/src/flowent/workspace/runtime.py +1041 -0
  38. package/backend/uv.lock +1 -1
  39. package/dist/frontend/assets/index-CvWZZMtK.css +2 -0
  40. package/dist/frontend/assets/index-ma2v8oW7.js +90 -0
  41. package/dist/frontend/index.html +2 -2
  42. package/package.json +1 -1
  43. package/backend/src/flowent/static/assets/index-BH30iLzb.css +0 -2
  44. package/backend/src/flowent/static/assets/index-sBFt3ORj.js +0 -84
  45. package/dist/frontend/assets/index-BH30iLzb.css +0 -2
  46. package/dist/frontend/assets/index-sBFt3ORj.js +0 -84
@@ -0,0 +1,1013 @@
1
+ import json
2
+ import sqlite3
3
+ from pathlib import Path
4
+
5
+ from flowent.llm import ChatMessage, ReasoningEffort
6
+ from flowent.paths import data_directory
7
+ from flowent.state.models import (
8
+ StoredAssistantOutputGroup,
9
+ StoredCompactionCheckpoint,
10
+ StoredMcpServer,
11
+ StoredMcpTool,
12
+ StoredMessage,
13
+ StoredProvider,
14
+ StoredSettings,
15
+ StoredState,
16
+ StoredTelegramBot,
17
+ StoredTelegramSession,
18
+ StoredToolItem,
19
+ StoredWorkflow,
20
+ StoredWorkflowDefinition,
21
+ StoredWritablePath,
22
+ )
23
+ from flowent.state.schema import migrate
24
+ from flowent.usage import TokenUsageInfo
25
+
26
+
27
+ class StateStore:
28
+ def __init__(self, directory: Path | None = None) -> None:
29
+ self.directory = directory or data_directory()
30
+ self.database_path = self.directory / "flowent.db"
31
+
32
+ def connect(self) -> sqlite3.Connection:
33
+ self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
34
+ connection = sqlite3.connect(self.database_path)
35
+ connection.row_factory = sqlite3.Row
36
+ connection.execute("PRAGMA foreign_keys = ON")
37
+ connection.execute("PRAGMA journal_mode = WAL")
38
+ connection.execute("PRAGMA busy_timeout = 5000")
39
+ migrate(connection)
40
+ return connection
41
+
42
+ def read_state(self) -> StoredState:
43
+ with self.connect() as connection:
44
+ mcp_servers = self._read_mcp_servers(connection)
45
+ telegram_bot = self._read_telegram_bot(connection)
46
+ writable_paths = self._read_writable_paths(connection)
47
+ workflows = self._read_workflows(connection)
48
+ providers = [
49
+ StoredProvider(
50
+ api_key=row["api_key"],
51
+ base_url=row["base_url"],
52
+ id=row["id"],
53
+ models=self._provider_models(connection, row["id"]),
54
+ name=row["name"],
55
+ type=row["type"],
56
+ )
57
+ for row in connection.execute(
58
+ """
59
+ SELECT id, name, type, base_url, api_key
60
+ FROM providers
61
+ ORDER BY created_at, id
62
+ """
63
+ )
64
+ ]
65
+ settings_row = connection.execute(
66
+ """
67
+ SELECT selected_provider_id, selected_model, reasoning_effort, agent_prompt, context_window_limit
68
+ FROM settings
69
+ WHERE id = 1
70
+ """
71
+ ).fetchone()
72
+ messages = [
73
+ StoredMessage(
74
+ author=row["author"],
75
+ content=row["content"],
76
+ groups=[
77
+ StoredAssistantOutputGroup.model_validate(group)
78
+ for group in json.loads(row["groups"] or "[]")
79
+ ],
80
+ id=row["id"],
81
+ status=row["status"],
82
+ thinking=row["thinking"],
83
+ tools=[
84
+ StoredToolItem.model_validate(tool)
85
+ for tool in json.loads(row["tools"] or "[]")
86
+ ],
87
+ usage_info=TokenUsageInfo.model_validate_json(row["usage_info"])
88
+ if row["usage_info"]
89
+ else None,
90
+ )
91
+ for row in connection.execute(
92
+ """
93
+ SELECT id, author, content, tools, thinking, groups, status, usage_info
94
+ FROM messages
95
+ ORDER BY position, id
96
+ """
97
+ )
98
+ ]
99
+ usage_row = connection.execute(
100
+ """
101
+ SELECT is_compacting, usage_info
102
+ FROM workspace_context
103
+ WHERE id = 1
104
+ """
105
+ ).fetchone()
106
+ usage_info = (
107
+ TokenUsageInfo.model_validate_json(usage_row["usage_info"])
108
+ if usage_row and usage_row["usage_info"]
109
+ else None
110
+ )
111
+
112
+ return StoredState(
113
+ mcp_servers=mcp_servers,
114
+ is_compacting=bool(usage_row["is_compacting"]) if usage_row else False,
115
+ messages=messages,
116
+ providers=providers,
117
+ settings=StoredSettings(
118
+ agent_prompt=settings_row["agent_prompt"] if settings_row else "",
119
+ context_window_limit=settings_row["context_window_limit"]
120
+ if settings_row
121
+ else None,
122
+ reasoning_effort=settings_row["reasoning_effort"]
123
+ if settings_row
124
+ else ReasoningEffort.DEFAULT,
125
+ selected_model=settings_row["selected_model"] if settings_row else "",
126
+ selected_provider_id=settings_row["selected_provider_id"]
127
+ if settings_row
128
+ else "",
129
+ ),
130
+ skills=[],
131
+ telegram_bot=telegram_bot,
132
+ usage_info=usage_info,
133
+ writable_paths=writable_paths,
134
+ workflows=workflows,
135
+ )
136
+
137
+ def read_writable_paths(self) -> list[StoredWritablePath]:
138
+ with self.connect() as connection:
139
+ return self._read_writable_paths(connection)
140
+
141
+ def save_writable_path(self, path: Path) -> StoredWritablePath:
142
+ normalized_path = str(path.expanduser().resolve(strict=False))
143
+ with self.connect() as connection:
144
+ connection.execute(
145
+ """
146
+ INSERT INTO writable_paths (path)
147
+ VALUES (?)
148
+ ON CONFLICT(path) DO NOTHING
149
+ """,
150
+ (normalized_path,),
151
+ )
152
+ row = connection.execute(
153
+ """
154
+ SELECT path, created_at
155
+ FROM writable_paths
156
+ WHERE path = ?
157
+ """,
158
+ (normalized_path,),
159
+ ).fetchone()
160
+ return StoredWritablePath(path=row["path"], created_at=row["created_at"])
161
+
162
+ def delete_writable_path(self, path: Path) -> list[StoredWritablePath]:
163
+ normalized_path = str(path.expanduser().resolve(strict=False))
164
+ with self.connect() as connection:
165
+ connection.execute(
166
+ "DELETE FROM writable_paths WHERE path = ?", (normalized_path,)
167
+ )
168
+ return self._read_writable_paths(connection)
169
+
170
+ def read_workflows(self) -> list[StoredWorkflow]:
171
+ with self.connect() as connection:
172
+ return self._read_workflows(connection)
173
+
174
+ def save_workflow(self, workflow: StoredWorkflow) -> StoredWorkflow:
175
+ with self.connect() as connection:
176
+ connection.execute(
177
+ """
178
+ INSERT INTO workflows (
179
+ id,
180
+ name,
181
+ definition
182
+ )
183
+ VALUES (?, ?, ?)
184
+ ON CONFLICT(id) DO UPDATE SET
185
+ name = excluded.name,
186
+ definition = excluded.definition,
187
+ updated_at = unixepoch()
188
+ """,
189
+ (
190
+ workflow.id,
191
+ workflow.name,
192
+ workflow.definition.model_dump_json(),
193
+ ),
194
+ )
195
+ row = connection.execute(
196
+ """
197
+ SELECT id, name, definition, created_at, updated_at
198
+ FROM workflows
199
+ WHERE id = ?
200
+ """,
201
+ (workflow.id,),
202
+ ).fetchone()
203
+ return self._workflow_from_row(row)
204
+
205
+ def delete_workflow(self, workflow_id: str) -> None:
206
+ with self.connect() as connection:
207
+ connection.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
208
+
209
+ def read_skill_enabled(self) -> dict[str, bool]:
210
+ with self.connect() as connection:
211
+ return {
212
+ row["id"]: bool(row["enabled"])
213
+ for row in connection.execute(
214
+ """
215
+ SELECT id, enabled
216
+ FROM skill_settings
217
+ ORDER BY id
218
+ """
219
+ )
220
+ }
221
+
222
+ def save_skill_enabled(self, skill_id: str, enabled: bool) -> None:
223
+ with self.connect() as connection:
224
+ connection.execute(
225
+ """
226
+ INSERT INTO skill_settings (id, enabled)
227
+ VALUES (?, ?)
228
+ ON CONFLICT(id) DO UPDATE SET
229
+ enabled = excluded.enabled,
230
+ updated_at = unixepoch()
231
+ """,
232
+ (skill_id, int(enabled)),
233
+ )
234
+
235
+ def read_mcp_servers(self) -> list[StoredMcpServer]:
236
+ with self.connect() as connection:
237
+ return self._read_mcp_servers(connection)
238
+
239
+ def save_mcp_server(self, server: StoredMcpServer) -> StoredMcpServer:
240
+ with self.connect() as connection:
241
+ connection.execute(
242
+ """
243
+ INSERT INTO mcp_servers (
244
+ id,
245
+ name,
246
+ type,
247
+ command,
248
+ args,
249
+ config,
250
+ url,
251
+ enabled
252
+ )
253
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
254
+ ON CONFLICT(id) DO UPDATE SET
255
+ name = excluded.name,
256
+ type = excluded.type,
257
+ command = excluded.command,
258
+ args = excluded.args,
259
+ config = excluded.config,
260
+ url = excluded.url,
261
+ enabled = excluded.enabled,
262
+ updated_at = unixepoch()
263
+ """,
264
+ (
265
+ server.id,
266
+ server.name,
267
+ server.type,
268
+ server.command,
269
+ json.dumps(server.args),
270
+ json.dumps(server.config, ensure_ascii=False),
271
+ server.url,
272
+ int(server.enabled),
273
+ ),
274
+ )
275
+ existing = [
276
+ current_server
277
+ for current_server in self._read_mcp_servers(connection)
278
+ if current_server.id == server.id
279
+ ]
280
+ return existing[0] if existing else server
281
+
282
+ def save_mcp_tools(
283
+ self, server_id: str, tools: list[StoredMcpTool]
284
+ ) -> list[StoredMcpTool]:
285
+ with self.connect() as connection:
286
+ connection.execute(
287
+ "DELETE FROM mcp_tools WHERE server_id = ?", (server_id,)
288
+ )
289
+ connection.executemany(
290
+ """
291
+ INSERT INTO mcp_tools (
292
+ server_id,
293
+ name,
294
+ description,
295
+ input_schema,
296
+ output_schema,
297
+ position
298
+ )
299
+ VALUES (?, ?, ?, ?, ?, ?)
300
+ """,
301
+ [
302
+ (
303
+ server_id,
304
+ tool.name,
305
+ tool.description,
306
+ json.dumps(tool.input_schema),
307
+ json.dumps(tool.output_schema)
308
+ if tool.output_schema is not None
309
+ else None,
310
+ position,
311
+ )
312
+ for position, tool in enumerate(tools)
313
+ ],
314
+ )
315
+ return tools
316
+
317
+ def delete_mcp_server(self, server_id: str) -> None:
318
+ with self.connect() as connection:
319
+ connection.execute("DELETE FROM mcp_servers WHERE id = ?", (server_id,))
320
+
321
+ def read_telegram_bot(self) -> StoredTelegramBot:
322
+ with self.connect() as connection:
323
+ return self._read_telegram_bot(connection)
324
+
325
+ def save_telegram_bot(self, telegram_bot: StoredTelegramBot) -> StoredTelegramBot:
326
+ with self.connect() as connection:
327
+ connection.execute(
328
+ """
329
+ INSERT INTO telegram_bot (
330
+ id,
331
+ enabled,
332
+ bot_token
333
+ )
334
+ VALUES (1, ?, ?)
335
+ ON CONFLICT(id) DO UPDATE SET
336
+ enabled = excluded.enabled,
337
+ bot_token = excluded.bot_token,
338
+ updated_at = unixepoch()
339
+ """,
340
+ (
341
+ int(telegram_bot.enabled),
342
+ telegram_bot.bot_token,
343
+ ),
344
+ )
345
+ return self._read_telegram_bot(connection)
346
+
347
+ def save_telegram_session(
348
+ self, session: StoredTelegramSession
349
+ ) -> StoredTelegramSession:
350
+ with self.connect() as connection:
351
+ connection.execute(
352
+ """
353
+ INSERT INTO telegram_sessions (
354
+ chat_id,
355
+ user_id,
356
+ username,
357
+ display_name,
358
+ recent_message,
359
+ status
360
+ )
361
+ VALUES (?, ?, ?, ?, ?, ?)
362
+ ON CONFLICT(chat_id) DO UPDATE SET
363
+ user_id = excluded.user_id,
364
+ username = excluded.username,
365
+ display_name = excluded.display_name,
366
+ recent_message = excluded.recent_message,
367
+ status = excluded.status,
368
+ updated_at = unixepoch()
369
+ """,
370
+ (
371
+ session.chat_id,
372
+ session.user_id,
373
+ session.username,
374
+ session.display_name,
375
+ session.recent_message,
376
+ session.status,
377
+ ),
378
+ )
379
+ return session
380
+
381
+ def approve_telegram_session(self, chat_id: str) -> StoredTelegramSession:
382
+ with self.connect() as connection:
383
+ connection.execute(
384
+ """
385
+ UPDATE telegram_sessions
386
+ SET status = 'approved',
387
+ updated_at = unixepoch()
388
+ WHERE chat_id = ?
389
+ """,
390
+ (chat_id,),
391
+ )
392
+ row = connection.execute(
393
+ """
394
+ SELECT
395
+ chat_id,
396
+ user_id,
397
+ username,
398
+ display_name,
399
+ recent_message,
400
+ status,
401
+ updated_at
402
+ FROM telegram_sessions
403
+ WHERE chat_id = ?
404
+ """,
405
+ (chat_id,),
406
+ ).fetchone()
407
+ if row is None:
408
+ raise KeyError(chat_id)
409
+ return StoredTelegramSession(
410
+ chat_id=row["chat_id"],
411
+ display_name=row["display_name"],
412
+ recent_message=row["recent_message"],
413
+ status=row["status"],
414
+ updated_at=row["updated_at"],
415
+ user_id=row["user_id"],
416
+ username=row["username"],
417
+ )
418
+
419
+ def save_provider(self, provider: StoredProvider) -> StoredProvider:
420
+ with self.connect() as connection:
421
+ connection.execute(
422
+ """
423
+ INSERT INTO providers (id, name, type, base_url, api_key)
424
+ VALUES (?, ?, ?, ?, ?)
425
+ ON CONFLICT(id) DO UPDATE SET
426
+ name = excluded.name,
427
+ type = excluded.type,
428
+ base_url = excluded.base_url,
429
+ api_key = excluded.api_key,
430
+ updated_at = unixepoch()
431
+ """,
432
+ (
433
+ provider.id,
434
+ provider.name,
435
+ provider.type.value,
436
+ provider.base_url,
437
+ provider.api_key,
438
+ ),
439
+ )
440
+ connection.execute(
441
+ "DELETE FROM provider_models WHERE provider_id = ?", (provider.id,)
442
+ )
443
+ connection.executemany(
444
+ """
445
+ INSERT INTO provider_models (provider_id, model, position)
446
+ VALUES (?, ?, ?)
447
+ """,
448
+ [
449
+ (provider.id, model, position)
450
+ for position, model in enumerate(provider.models)
451
+ ],
452
+ )
453
+ return provider
454
+
455
+ def delete_provider(self, provider_id: str) -> None:
456
+ with self.connect() as connection:
457
+ settings_row = connection.execute(
458
+ """
459
+ SELECT selected_provider_id
460
+ FROM settings
461
+ WHERE id = 1
462
+ """
463
+ ).fetchone()
464
+ provider_rows = connection.execute(
465
+ """
466
+ SELECT id
467
+ FROM providers
468
+ ORDER BY created_at, id
469
+ """
470
+ ).fetchall()
471
+ removed_index = next(
472
+ (
473
+ index
474
+ for index, provider_row in enumerate(provider_rows)
475
+ if provider_row["id"] == provider_id
476
+ ),
477
+ -1,
478
+ )
479
+ remaining_provider_ids = [
480
+ provider_row["id"]
481
+ for provider_row in provider_rows
482
+ if provider_row["id"] != provider_id
483
+ ]
484
+ connection.execute("DELETE FROM providers WHERE id = ?", (provider_id,))
485
+ if settings_row and settings_row["selected_provider_id"] == provider_id:
486
+ next_provider_id = ""
487
+ if removed_index >= 0:
488
+ next_provider_id = (
489
+ remaining_provider_ids[removed_index]
490
+ if removed_index < len(remaining_provider_ids)
491
+ else remaining_provider_ids[removed_index - 1]
492
+ if remaining_provider_ids
493
+ else ""
494
+ )
495
+ next_model = ""
496
+ if next_provider_id:
497
+ model_row = connection.execute(
498
+ """
499
+ SELECT model
500
+ FROM provider_models
501
+ WHERE provider_id = ?
502
+ ORDER BY position, model
503
+ LIMIT 1
504
+ """,
505
+ (next_provider_id,),
506
+ ).fetchone()
507
+ next_model = model_row["model"] if model_row else ""
508
+ connection.execute(
509
+ """
510
+ UPDATE settings
511
+ SET selected_provider_id = ?,
512
+ selected_model = ?,
513
+ updated_at = unixepoch()
514
+ WHERE id = 1
515
+ """,
516
+ (next_provider_id, next_model),
517
+ )
518
+
519
+ def save_settings(self, settings: StoredSettings) -> StoredSettings:
520
+ with self.connect() as connection:
521
+ connection.execute(
522
+ """
523
+ INSERT INTO settings (
524
+ id,
525
+ selected_provider_id,
526
+ selected_model,
527
+ reasoning_effort,
528
+ agent_prompt,
529
+ context_window_limit
530
+ )
531
+ VALUES (1, ?, ?, ?, ?, ?)
532
+ ON CONFLICT(id) DO UPDATE SET
533
+ selected_provider_id = excluded.selected_provider_id,
534
+ selected_model = excluded.selected_model,
535
+ reasoning_effort = excluded.reasoning_effort,
536
+ agent_prompt = excluded.agent_prompt,
537
+ context_window_limit = excluded.context_window_limit,
538
+ updated_at = unixepoch()
539
+ """,
540
+ (
541
+ settings.selected_provider_id,
542
+ settings.selected_model,
543
+ settings.reasoning_effort.value,
544
+ settings.agent_prompt,
545
+ settings.context_window_limit,
546
+ ),
547
+ )
548
+ return settings
549
+
550
+ def save_messages(self, messages: list[StoredMessage]) -> list[StoredMessage]:
551
+ with self.connect() as connection:
552
+ connection.execute("DELETE FROM messages")
553
+ if messages:
554
+ latest_usage_info = next(
555
+ (
556
+ message.usage_info
557
+ for message in reversed(messages)
558
+ if message.usage_info is not None
559
+ ),
560
+ None,
561
+ )
562
+ if latest_usage_info is not None:
563
+ connection.execute(
564
+ """
565
+ INSERT INTO workspace_context (id, usage_info)
566
+ VALUES (1, ?)
567
+ ON CONFLICT(id) DO UPDATE SET
568
+ usage_info = excluded.usage_info,
569
+ updated_at = unixepoch()
570
+ """,
571
+ (latest_usage_info.model_dump_json(),),
572
+ )
573
+ connection.executemany(
574
+ """
575
+ INSERT INTO messages (
576
+ id,
577
+ author,
578
+ content,
579
+ tools,
580
+ thinking,
581
+ groups,
582
+ status,
583
+ usage_info,
584
+ position
585
+ )
586
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
587
+ """,
588
+ [
589
+ (
590
+ message.id,
591
+ message.author,
592
+ message.content,
593
+ json.dumps(
594
+ [
595
+ tool.model_dump(exclude_none=True)
596
+ for tool in message.tools
597
+ ]
598
+ ),
599
+ message.thinking,
600
+ json.dumps(
601
+ [
602
+ group.model_dump(exclude_none=True)
603
+ for group in message.groups
604
+ ],
605
+ ensure_ascii=False,
606
+ ),
607
+ message.status,
608
+ message.usage_info.model_dump_json()
609
+ if message.usage_info
610
+ else None,
611
+ position,
612
+ )
613
+ for position, message in enumerate(messages)
614
+ ],
615
+ )
616
+ if not messages:
617
+ connection.execute("DELETE FROM workspace_context WHERE id = 1")
618
+ return messages
619
+
620
+ def upsert_message(self, message: StoredMessage) -> StoredMessage:
621
+ with self.connect() as connection:
622
+ row = connection.execute(
623
+ "SELECT position FROM messages WHERE id = ?", (message.id,)
624
+ ).fetchone()
625
+ if row:
626
+ position = row["position"]
627
+ else:
628
+ position_row = connection.execute(
629
+ "SELECT COALESCE(MAX(position) + 1, 0) AS position FROM messages"
630
+ ).fetchone()
631
+ position = position_row["position"]
632
+ connection.execute(
633
+ """
634
+ INSERT INTO messages (
635
+ id,
636
+ author,
637
+ content,
638
+ tools,
639
+ thinking,
640
+ groups,
641
+ status,
642
+ usage_info,
643
+ position
644
+ )
645
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
646
+ ON CONFLICT(id) DO UPDATE SET
647
+ author = excluded.author,
648
+ content = excluded.content,
649
+ tools = excluded.tools,
650
+ thinking = excluded.thinking,
651
+ groups = excluded.groups,
652
+ status = excluded.status,
653
+ usage_info = excluded.usage_info,
654
+ position = excluded.position
655
+ """,
656
+ (
657
+ message.id,
658
+ message.author,
659
+ message.content,
660
+ json.dumps(
661
+ [tool.model_dump(exclude_none=True) for tool in message.tools]
662
+ ),
663
+ message.thinking,
664
+ json.dumps(
665
+ [
666
+ group.model_dump(exclude_none=True)
667
+ for group in message.groups
668
+ ],
669
+ ensure_ascii=False,
670
+ ),
671
+ message.status,
672
+ message.usage_info.model_dump_json()
673
+ if message.usage_info
674
+ else None,
675
+ position,
676
+ ),
677
+ )
678
+ if message.usage_info is not None:
679
+ connection.execute(
680
+ """
681
+ INSERT INTO workspace_context (id, usage_info)
682
+ VALUES (1, ?)
683
+ ON CONFLICT(id) DO UPDATE SET
684
+ usage_info = excluded.usage_info,
685
+ updated_at = unixepoch()
686
+ """,
687
+ (message.usage_info.model_dump_json(),),
688
+ )
689
+ return message
690
+
691
+ def read_usage_info(self) -> TokenUsageInfo | None:
692
+ with self.connect() as connection:
693
+ row = connection.execute(
694
+ """
695
+ SELECT usage_info
696
+ FROM workspace_context
697
+ WHERE id = 1
698
+ """
699
+ ).fetchone()
700
+ if row is None or not row["usage_info"]:
701
+ return None
702
+ return TokenUsageInfo.model_validate_json(row["usage_info"])
703
+
704
+ def save_usage_info(self, usage_info: TokenUsageInfo) -> TokenUsageInfo:
705
+ with self.connect() as connection:
706
+ connection.execute(
707
+ """
708
+ INSERT INTO workspace_context (id, usage_info)
709
+ VALUES (1, ?)
710
+ ON CONFLICT(id) DO UPDATE SET
711
+ usage_info = excluded.usage_info,
712
+ updated_at = unixepoch()
713
+ """,
714
+ (usage_info.model_dump_json(),),
715
+ )
716
+ return usage_info
717
+
718
+ def read_compacted_context(self) -> str:
719
+ with self.connect() as connection:
720
+ row = connection.execute(
721
+ """
722
+ SELECT compacted_summary
723
+ FROM workspace_context
724
+ WHERE id = 1
725
+ """
726
+ ).fetchone()
727
+ return row["compacted_summary"] if row else ""
728
+
729
+ def save_compacted_context(self, summary: str) -> str:
730
+ with self.connect() as connection:
731
+ connection.execute(
732
+ """
733
+ INSERT INTO workspace_context (id, compacted_summary)
734
+ VALUES (1, ?)
735
+ ON CONFLICT(id) DO UPDATE SET
736
+ compacted_summary = excluded.compacted_summary,
737
+ active_compaction_id = NULL,
738
+ usage_info = NULL,
739
+ updated_at = unixepoch()
740
+ """,
741
+ (summary,),
742
+ )
743
+ return summary
744
+
745
+ def read_is_compacting(self) -> bool:
746
+ with self.connect() as connection:
747
+ row = connection.execute(
748
+ """
749
+ SELECT is_compacting
750
+ FROM workspace_context
751
+ WHERE id = 1
752
+ """
753
+ ).fetchone()
754
+ return bool(row["is_compacting"]) if row else False
755
+
756
+ def save_is_compacting(self, is_compacting: bool) -> bool:
757
+ with self.connect() as connection:
758
+ connection.execute(
759
+ """
760
+ INSERT INTO workspace_context (id, is_compacting)
761
+ VALUES (1, ?)
762
+ ON CONFLICT(id) DO UPDATE SET
763
+ is_compacting = excluded.is_compacting,
764
+ updated_at = unixepoch()
765
+ """,
766
+ (int(is_compacting),),
767
+ )
768
+ return is_compacting
769
+
770
+ def read_active_compaction_checkpoint(
771
+ self,
772
+ ) -> StoredCompactionCheckpoint | None:
773
+ with self.connect() as connection:
774
+ row = connection.execute(
775
+ """
776
+ SELECT
777
+ checkpoint.id,
778
+ checkpoint.trigger,
779
+ checkpoint.method,
780
+ checkpoint.summary,
781
+ checkpoint.replacement_history,
782
+ checkpoint.source_message_id,
783
+ checkpoint.token_before,
784
+ checkpoint.token_after,
785
+ checkpoint.created_at
786
+ FROM workspace_context context
787
+ JOIN compaction_checkpoints checkpoint
788
+ ON checkpoint.id = context.active_compaction_id
789
+ WHERE context.id = 1
790
+ """
791
+ ).fetchone()
792
+ if row is None:
793
+ return None
794
+ return StoredCompactionCheckpoint(
795
+ created_at=row["created_at"],
796
+ id=row["id"],
797
+ method=row["method"],
798
+ replacement_history=[
799
+ ChatMessage.model_validate(message)
800
+ for message in json.loads(row["replacement_history"] or "[]")
801
+ ],
802
+ source_message_id=row["source_message_id"],
803
+ summary=row["summary"],
804
+ token_after=row["token_after"],
805
+ token_before=row["token_before"],
806
+ trigger=row["trigger"],
807
+ )
808
+
809
+ def save_compaction_checkpoint(
810
+ self, checkpoint: StoredCompactionCheckpoint
811
+ ) -> StoredCompactionCheckpoint:
812
+ with self.connect() as connection:
813
+ connection.execute(
814
+ """
815
+ INSERT INTO compaction_checkpoints (
816
+ id,
817
+ trigger,
818
+ method,
819
+ summary,
820
+ replacement_history,
821
+ source_message_id,
822
+ token_before,
823
+ token_after
824
+ )
825
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
826
+ ON CONFLICT(id) DO UPDATE SET
827
+ trigger = excluded.trigger,
828
+ method = excluded.method,
829
+ summary = excluded.summary,
830
+ replacement_history = excluded.replacement_history,
831
+ source_message_id = excluded.source_message_id,
832
+ token_before = excluded.token_before,
833
+ token_after = excluded.token_after
834
+ """,
835
+ (
836
+ checkpoint.id,
837
+ checkpoint.trigger,
838
+ checkpoint.method,
839
+ checkpoint.summary,
840
+ json.dumps(
841
+ [
842
+ message.model_dump()
843
+ for message in checkpoint.replacement_history
844
+ ],
845
+ ensure_ascii=False,
846
+ ),
847
+ checkpoint.source_message_id,
848
+ checkpoint.token_before,
849
+ checkpoint.token_after,
850
+ ),
851
+ )
852
+ connection.execute(
853
+ """
854
+ INSERT INTO workspace_context (
855
+ id,
856
+ compacted_summary,
857
+ active_compaction_id
858
+ )
859
+ VALUES (1, ?, ?)
860
+ ON CONFLICT(id) DO UPDATE SET
861
+ compacted_summary = excluded.compacted_summary,
862
+ active_compaction_id = excluded.active_compaction_id,
863
+ updated_at = unixepoch()
864
+ """,
865
+ (checkpoint.summary, checkpoint.id),
866
+ )
867
+ row = connection.execute(
868
+ """
869
+ SELECT created_at
870
+ FROM compaction_checkpoints
871
+ WHERE id = ?
872
+ """,
873
+ (checkpoint.id,),
874
+ ).fetchone()
875
+ return checkpoint.model_copy(update={"created_at": row["created_at"]})
876
+
877
+ def _provider_models(
878
+ self, connection: sqlite3.Connection, provider_id: str
879
+ ) -> list[str]:
880
+ return [
881
+ row["model"]
882
+ for row in connection.execute(
883
+ """
884
+ SELECT model
885
+ FROM provider_models
886
+ WHERE provider_id = ?
887
+ ORDER BY position, model
888
+ """,
889
+ (provider_id,),
890
+ )
891
+ ]
892
+
893
+ def _read_telegram_bot(self, connection: sqlite3.Connection) -> StoredTelegramBot:
894
+ bot_row = connection.execute(
895
+ """
896
+ SELECT enabled, bot_token
897
+ FROM telegram_bot
898
+ WHERE id = 1
899
+ """
900
+ ).fetchone()
901
+ sessions = [
902
+ StoredTelegramSession(
903
+ chat_id=row["chat_id"],
904
+ display_name=row["display_name"],
905
+ recent_message=row["recent_message"],
906
+ status=row["status"],
907
+ updated_at=row["updated_at"],
908
+ user_id=row["user_id"],
909
+ username=row["username"],
910
+ )
911
+ for row in connection.execute(
912
+ """
913
+ SELECT
914
+ chat_id,
915
+ user_id,
916
+ username,
917
+ display_name,
918
+ recent_message,
919
+ status,
920
+ updated_at
921
+ FROM telegram_sessions
922
+ ORDER BY status DESC, updated_at DESC, chat_id
923
+ """
924
+ )
925
+ ]
926
+ return StoredTelegramBot(
927
+ bot_token=bot_row["bot_token"] if bot_row else "",
928
+ enabled=bool(bot_row["enabled"]) if bot_row else False,
929
+ sessions=sessions,
930
+ )
931
+
932
+ def _read_mcp_servers(
933
+ self, connection: sqlite3.Connection
934
+ ) -> list[StoredMcpServer]:
935
+ servers: list[StoredMcpServer] = []
936
+ for row in connection.execute(
937
+ """
938
+ SELECT id, name, type, command, args, config, url, enabled
939
+ FROM mcp_servers
940
+ ORDER BY created_at, id
941
+ """
942
+ ):
943
+ tools = [
944
+ StoredMcpTool(
945
+ description=tool_row["description"],
946
+ input_schema=json.loads(tool_row["input_schema"] or "{}"),
947
+ name=tool_row["name"],
948
+ output_schema=json.loads(tool_row["output_schema"])
949
+ if tool_row["output_schema"]
950
+ else None,
951
+ )
952
+ for tool_row in connection.execute(
953
+ """
954
+ SELECT name, description, input_schema, output_schema
955
+ FROM mcp_tools
956
+ WHERE server_id = ?
957
+ ORDER BY position, name
958
+ """,
959
+ (row["id"],),
960
+ )
961
+ ]
962
+ servers.append(
963
+ StoredMcpServer(
964
+ args=json.loads(row["args"] or "[]"),
965
+ command=row["command"],
966
+ config=json.loads(row["config"] or "{}"),
967
+ enabled=bool(row["enabled"]),
968
+ id=row["id"],
969
+ name=row["name"],
970
+ status="disabled",
971
+ tools=tools,
972
+ type=row["type"],
973
+ url=row["url"],
974
+ )
975
+ )
976
+ return servers
977
+
978
+ def _read_writable_paths(
979
+ self, connection: sqlite3.Connection
980
+ ) -> list[StoredWritablePath]:
981
+ return [
982
+ StoredWritablePath(created_at=row["created_at"], path=row["path"])
983
+ for row in connection.execute(
984
+ """
985
+ SELECT path, created_at
986
+ FROM writable_paths
987
+ ORDER BY path
988
+ """
989
+ )
990
+ ]
991
+
992
+ def _workflow_from_row(self, row: sqlite3.Row) -> StoredWorkflow:
993
+ return StoredWorkflow(
994
+ created_at=row["created_at"],
995
+ definition=StoredWorkflowDefinition.model_validate(
996
+ json.loads(row["definition"] or "{}")
997
+ ),
998
+ id=row["id"],
999
+ name=row["name"],
1000
+ updated_at=row["updated_at"],
1001
+ )
1002
+
1003
+ def _read_workflows(self, connection: sqlite3.Connection) -> list[StoredWorkflow]:
1004
+ return [
1005
+ self._workflow_from_row(row)
1006
+ for row in connection.execute(
1007
+ """
1008
+ SELECT id, name, definition, created_at, updated_at
1009
+ FROM workflows
1010
+ ORDER BY updated_at DESC, name, id
1011
+ """
1012
+ )
1013
+ ]