flowent 0.2.4 → 0.3.1

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