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
@@ -1,1319 +1,53 @@
1
- import json
2
- import sqlite3
3
- from pathlib import Path
4
- from typing import Annotated, Literal
5
-
6
- from pydantic import BaseModel, ConfigDict, Field, PositiveInt
7
-
8
- from flowent.llm import ChatMessage, ProviderFormat, ReasoningEffort
9
- from flowent.paths import data_directory
10
- from flowent.usage import TokenUsageInfo
11
-
12
-
13
- class StoredTelegramSession(BaseModel):
14
- model_config = ConfigDict(extra="forbid")
15
-
16
- chat_id: str
17
- display_name: str = ""
18
- recent_message: str = ""
19
- status: str
20
- updated_at: int = 0
21
- user_id: str = ""
22
- username: str = ""
23
-
24
-
25
- class StoredTelegramBot(BaseModel):
26
- model_config = ConfigDict(extra="forbid")
27
-
28
- bot_token: str
29
- enabled: bool
30
- error: str = ""
31
- sessions: list[StoredTelegramSession] = Field(default_factory=list)
32
- status: str = "disabled"
33
-
34
-
35
- class StoredMcpTool(BaseModel):
36
- model_config = ConfigDict(extra="forbid")
37
-
38
- description: str = ""
39
- input_schema: dict[str, object] = Field(default_factory=dict)
40
- name: str
41
- output_schema: dict[str, object] | None = None
42
-
43
-
44
- class StoredMcpServer(BaseModel):
45
- model_config = ConfigDict(extra="forbid")
46
-
47
- args: list[str] = Field(default_factory=list)
48
- command: str = ""
49
- config: dict[str, object] = Field(default_factory=dict)
50
- enabled: bool = True
51
- error: str = ""
52
- id: str
53
- name: str
54
- status: str = "disabled"
55
- tools: list[StoredMcpTool] = Field(default_factory=list)
56
- type: str
57
- url: str = ""
58
-
59
-
60
- class StoredSkill(BaseModel):
61
- model_config = ConfigDict(extra="forbid")
62
-
63
- description: str
64
- enabled: bool = True
65
- error: str = ""
66
- id: str
67
- name: str
68
- path: str
69
- scope: str
70
- slug: str
71
-
72
-
73
- class StoredWritablePath(BaseModel):
74
- model_config = ConfigDict(extra="forbid")
75
-
76
- created_at: int = 0
77
- path: str
78
-
79
-
80
- class StoredProvider(BaseModel):
81
- model_config = ConfigDict(extra="forbid")
82
-
83
- api_key: str
84
- base_url: str
85
- id: str
86
- models: list[str]
87
- name: str
88
- type: ProviderFormat
89
-
90
-
91
- class StoredSettings(BaseModel):
92
- model_config = ConfigDict(extra="forbid")
93
-
94
- agent_prompt: str = Field(default="", exclude_if=lambda value: value == "")
95
- context_window_limit: PositiveInt | None = None
96
- reasoning_effort: ReasoningEffort = ReasoningEffort.DEFAULT
97
- selected_model: str
98
- selected_provider_id: str
99
-
100
-
101
- class StoredToolItem(BaseModel):
102
- model_config = ConfigDict(extra="forbid")
103
-
104
- id: str
105
- name: str
106
- status: str
107
- title: str
108
- arguments: dict[str, object] | None = None
109
- content: str | None = None
110
- data: dict[str, object] | None = None
111
-
112
-
113
- class StoredThinkingOutputItem(BaseModel):
114
- model_config = ConfigDict(extra="forbid")
115
-
116
- content: str
117
- id: str
118
- type: Literal["thinking"]
119
-
120
-
121
- class StoredTextOutputItem(BaseModel):
122
- model_config = ConfigDict(extra="forbid")
123
-
124
- content: str
125
- id: str
126
- type: Literal["text"]
127
-
128
-
129
- class StoredErrorOutputItem(BaseModel):
130
- model_config = ConfigDict(extra="forbid")
131
-
132
- detail: str = Field(default="", exclude_if=lambda value: value == "")
133
- id: str
134
- message: str
135
- title: str
136
- type: Literal["error"]
137
-
138
-
139
- class StoredToolOutputItem(BaseModel):
140
- model_config = ConfigDict(extra="forbid")
141
-
142
- id: str
143
- tool: StoredToolItem
144
- type: Literal["tool"]
145
-
146
-
147
- StoredOutputItem = Annotated[
148
- StoredThinkingOutputItem
149
- | StoredTextOutputItem
150
- | StoredErrorOutputItem
151
- | StoredToolOutputItem,
152
- Field(discriminator="type"),
1
+ from flowent.state import (
2
+ StateStore,
3
+ StoredAssistantOutputGroup,
4
+ StoredCompactionCheckpoint,
5
+ StoredErrorOutputItem,
6
+ StoredMcpServer,
7
+ StoredMcpTool,
8
+ StoredMessage,
9
+ StoredOutputItem,
10
+ StoredProvider,
11
+ StoredSettings,
12
+ StoredSkill,
13
+ StoredState,
14
+ StoredTelegramBot,
15
+ StoredTelegramSession,
16
+ StoredTextOutputItem,
17
+ StoredThinkingOutputItem,
18
+ StoredToolItem,
19
+ StoredToolOutputItem,
20
+ StoredWorkflow,
21
+ StoredWorkflowDefinition,
22
+ StoredWorkflowEdge,
23
+ StoredWorkflowNode,
24
+ StoredWorkflowNodePosition,
25
+ StoredWritablePath,
26
+ )
27
+
28
+ __all__ = [
29
+ "StateStore",
30
+ "StoredAssistantOutputGroup",
31
+ "StoredCompactionCheckpoint",
32
+ "StoredErrorOutputItem",
33
+ "StoredMcpServer",
34
+ "StoredMcpTool",
35
+ "StoredMessage",
36
+ "StoredOutputItem",
37
+ "StoredProvider",
38
+ "StoredSettings",
39
+ "StoredSkill",
40
+ "StoredState",
41
+ "StoredTelegramBot",
42
+ "StoredTelegramSession",
43
+ "StoredTextOutputItem",
44
+ "StoredThinkingOutputItem",
45
+ "StoredToolItem",
46
+ "StoredToolOutputItem",
47
+ "StoredWorkflow",
48
+ "StoredWorkflowDefinition",
49
+ "StoredWorkflowEdge",
50
+ "StoredWorkflowNode",
51
+ "StoredWorkflowNodePosition",
52
+ "StoredWritablePath",
153
53
  ]
154
-
155
-
156
- class StoredAssistantOutputGroup(BaseModel):
157
- model_config = ConfigDict(extra="forbid")
158
-
159
- id: str
160
- items: list[StoredOutputItem]
161
-
162
-
163
- class StoredMessage(BaseModel):
164
- model_config = ConfigDict(extra="forbid")
165
-
166
- author: str
167
- content: str
168
- groups: list[StoredAssistantOutputGroup] = Field(
169
- default_factory=list, exclude_if=lambda value: value == []
170
- )
171
- id: str
172
- status: str = Field(
173
- default="completed", exclude_if=lambda value: value == "completed"
174
- )
175
- thinking: str = Field(default="", exclude_if=lambda value: value == "")
176
- tools: list[StoredToolItem] = Field(default_factory=list)
177
- usage_info: TokenUsageInfo | None = Field(
178
- default=None, exclude_if=lambda value: value is None
179
- )
180
-
181
-
182
- class StoredCompactionCheckpoint(BaseModel):
183
- model_config = ConfigDict(extra="forbid")
184
-
185
- created_at: int = 0
186
- id: str
187
- method: str
188
- replacement_history: list[ChatMessage]
189
- source_message_id: str | None = None
190
- summary: str
191
- token_after: int = 0
192
- token_before: int = 0
193
- trigger: str
194
-
195
-
196
- class StoredState(BaseModel):
197
- model_config = ConfigDict(extra="forbid")
198
-
199
- active_run_event_index: int = 0
200
- active_run_id: str | None = None
201
- is_compacting: bool = False
202
- mcp_servers: list[StoredMcpServer]
203
- messages: list[StoredMessage]
204
- providers: list[StoredProvider]
205
- settings: StoredSettings
206
- skills: list[StoredSkill]
207
- telegram_bot: StoredTelegramBot
208
- usage_info: TokenUsageInfo | None = Field(
209
- default=None, exclude_if=lambda value: value is None
210
- )
211
- writable_paths: list[StoredWritablePath] = Field(default_factory=list)
212
-
213
-
214
- class StateStore:
215
- def __init__(self, directory: Path | None = None) -> None:
216
- self.directory = directory or data_directory()
217
- self.database_path = self.directory / "flowent.db"
218
-
219
- def connect(self) -> sqlite3.Connection:
220
- self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
221
- connection = sqlite3.connect(self.database_path)
222
- connection.row_factory = sqlite3.Row
223
- connection.execute("PRAGMA foreign_keys = ON")
224
- connection.execute("PRAGMA journal_mode = WAL")
225
- connection.execute("PRAGMA busy_timeout = 5000")
226
- self._migrate(connection)
227
- return connection
228
-
229
- def read_state(self) -> StoredState:
230
- with self.connect() as connection:
231
- mcp_servers = self._read_mcp_servers(connection)
232
- telegram_bot = self._read_telegram_bot(connection)
233
- writable_paths = self._read_writable_paths(connection)
234
- providers = [
235
- StoredProvider(
236
- api_key=row["api_key"],
237
- base_url=row["base_url"],
238
- id=row["id"],
239
- models=self._provider_models(connection, row["id"]),
240
- name=row["name"],
241
- type=row["type"],
242
- )
243
- for row in connection.execute(
244
- """
245
- SELECT id, name, type, base_url, api_key
246
- FROM providers
247
- ORDER BY created_at, id
248
- """
249
- )
250
- ]
251
- settings_row = connection.execute(
252
- """
253
- SELECT selected_provider_id, selected_model, reasoning_effort, agent_prompt, context_window_limit
254
- FROM settings
255
- WHERE id = 1
256
- """
257
- ).fetchone()
258
- messages = [
259
- StoredMessage(
260
- author=row["author"],
261
- content=row["content"],
262
- groups=[
263
- StoredAssistantOutputGroup.model_validate(group)
264
- for group in json.loads(row["groups"] or "[]")
265
- ],
266
- id=row["id"],
267
- status=row["status"],
268
- thinking=row["thinking"],
269
- tools=[
270
- StoredToolItem.model_validate(tool)
271
- for tool in json.loads(row["tools"] or "[]")
272
- ],
273
- usage_info=TokenUsageInfo.model_validate_json(row["usage_info"])
274
- if row["usage_info"]
275
- else None,
276
- )
277
- for row in connection.execute(
278
- """
279
- SELECT id, author, content, tools, thinking, groups, status, usage_info
280
- FROM messages
281
- ORDER BY position, id
282
- """
283
- )
284
- ]
285
- usage_row = connection.execute(
286
- """
287
- SELECT is_compacting, usage_info
288
- FROM workspace_context
289
- WHERE id = 1
290
- """
291
- ).fetchone()
292
- usage_info = (
293
- TokenUsageInfo.model_validate_json(usage_row["usage_info"])
294
- if usage_row and usage_row["usage_info"]
295
- else None
296
- )
297
-
298
- return StoredState(
299
- mcp_servers=mcp_servers,
300
- is_compacting=bool(usage_row["is_compacting"]) if usage_row else False,
301
- messages=messages,
302
- providers=providers,
303
- settings=StoredSettings(
304
- agent_prompt=settings_row["agent_prompt"] if settings_row else "",
305
- context_window_limit=settings_row["context_window_limit"]
306
- if settings_row
307
- else None,
308
- reasoning_effort=settings_row["reasoning_effort"]
309
- if settings_row
310
- else ReasoningEffort.DEFAULT,
311
- selected_model=settings_row["selected_model"] if settings_row else "",
312
- selected_provider_id=settings_row["selected_provider_id"]
313
- if settings_row
314
- else "",
315
- ),
316
- skills=[],
317
- telegram_bot=telegram_bot,
318
- usage_info=usage_info,
319
- writable_paths=writable_paths,
320
- )
321
-
322
- def read_writable_paths(self) -> list[StoredWritablePath]:
323
- with self.connect() as connection:
324
- return self._read_writable_paths(connection)
325
-
326
- def save_writable_path(self, path: Path) -> StoredWritablePath:
327
- normalized_path = str(path.expanduser().resolve(strict=False))
328
- with self.connect() as connection:
329
- connection.execute(
330
- """
331
- INSERT INTO writable_paths (path)
332
- VALUES (?)
333
- ON CONFLICT(path) DO NOTHING
334
- """,
335
- (normalized_path,),
336
- )
337
- row = connection.execute(
338
- """
339
- SELECT path, created_at
340
- FROM writable_paths
341
- WHERE path = ?
342
- """,
343
- (normalized_path,),
344
- ).fetchone()
345
- return StoredWritablePath(path=row["path"], created_at=row["created_at"])
346
-
347
- def delete_writable_path(self, path: Path) -> list[StoredWritablePath]:
348
- normalized_path = str(path.expanduser().resolve(strict=False))
349
- with self.connect() as connection:
350
- connection.execute(
351
- "DELETE FROM writable_paths WHERE path = ?", (normalized_path,)
352
- )
353
- return self._read_writable_paths(connection)
354
-
355
- def read_skill_enabled(self) -> dict[str, bool]:
356
- with self.connect() as connection:
357
- return {
358
- row["id"]: bool(row["enabled"])
359
- for row in connection.execute(
360
- """
361
- SELECT id, enabled
362
- FROM skill_settings
363
- ORDER BY id
364
- """
365
- )
366
- }
367
-
368
- def save_skill_enabled(self, skill_id: str, enabled: bool) -> None:
369
- with self.connect() as connection:
370
- connection.execute(
371
- """
372
- INSERT INTO skill_settings (id, enabled)
373
- VALUES (?, ?)
374
- ON CONFLICT(id) DO UPDATE SET
375
- enabled = excluded.enabled,
376
- updated_at = unixepoch()
377
- """,
378
- (skill_id, int(enabled)),
379
- )
380
-
381
- def read_mcp_servers(self) -> list[StoredMcpServer]:
382
- with self.connect() as connection:
383
- return self._read_mcp_servers(connection)
384
-
385
- def save_mcp_server(self, server: StoredMcpServer) -> StoredMcpServer:
386
- with self.connect() as connection:
387
- connection.execute(
388
- """
389
- INSERT INTO mcp_servers (
390
- id,
391
- name,
392
- type,
393
- command,
394
- args,
395
- config,
396
- url,
397
- enabled
398
- )
399
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
400
- ON CONFLICT(id) DO UPDATE SET
401
- name = excluded.name,
402
- type = excluded.type,
403
- command = excluded.command,
404
- args = excluded.args,
405
- config = excluded.config,
406
- url = excluded.url,
407
- enabled = excluded.enabled,
408
- updated_at = unixepoch()
409
- """,
410
- (
411
- server.id,
412
- server.name,
413
- server.type,
414
- server.command,
415
- json.dumps(server.args),
416
- json.dumps(server.config, ensure_ascii=False),
417
- server.url,
418
- int(server.enabled),
419
- ),
420
- )
421
- existing = [
422
- current_server
423
- for current_server in self._read_mcp_servers(connection)
424
- if current_server.id == server.id
425
- ]
426
- return existing[0] if existing else server
427
-
428
- def save_mcp_tools(
429
- self, server_id: str, tools: list[StoredMcpTool]
430
- ) -> list[StoredMcpTool]:
431
- with self.connect() as connection:
432
- connection.execute(
433
- "DELETE FROM mcp_tools WHERE server_id = ?", (server_id,)
434
- )
435
- connection.executemany(
436
- """
437
- INSERT INTO mcp_tools (
438
- server_id,
439
- name,
440
- description,
441
- input_schema,
442
- output_schema,
443
- position
444
- )
445
- VALUES (?, ?, ?, ?, ?, ?)
446
- """,
447
- [
448
- (
449
- server_id,
450
- tool.name,
451
- tool.description,
452
- json.dumps(tool.input_schema),
453
- json.dumps(tool.output_schema)
454
- if tool.output_schema is not None
455
- else None,
456
- position,
457
- )
458
- for position, tool in enumerate(tools)
459
- ],
460
- )
461
- return tools
462
-
463
- def delete_mcp_server(self, server_id: str) -> None:
464
- with self.connect() as connection:
465
- connection.execute("DELETE FROM mcp_servers WHERE id = ?", (server_id,))
466
-
467
- def read_telegram_bot(self) -> StoredTelegramBot:
468
- with self.connect() as connection:
469
- return self._read_telegram_bot(connection)
470
-
471
- def save_telegram_bot(self, telegram_bot: StoredTelegramBot) -> StoredTelegramBot:
472
- with self.connect() as connection:
473
- connection.execute(
474
- """
475
- INSERT INTO telegram_bot (
476
- id,
477
- enabled,
478
- bot_token
479
- )
480
- VALUES (1, ?, ?)
481
- ON CONFLICT(id) DO UPDATE SET
482
- enabled = excluded.enabled,
483
- bot_token = excluded.bot_token,
484
- updated_at = unixepoch()
485
- """,
486
- (
487
- int(telegram_bot.enabled),
488
- telegram_bot.bot_token,
489
- ),
490
- )
491
- return self._read_telegram_bot(connection)
492
-
493
- def save_telegram_session(
494
- self, session: StoredTelegramSession
495
- ) -> StoredTelegramSession:
496
- with self.connect() as connection:
497
- connection.execute(
498
- """
499
- INSERT INTO telegram_sessions (
500
- chat_id,
501
- user_id,
502
- username,
503
- display_name,
504
- recent_message,
505
- status
506
- )
507
- VALUES (?, ?, ?, ?, ?, ?)
508
- ON CONFLICT(chat_id) DO UPDATE SET
509
- user_id = excluded.user_id,
510
- username = excluded.username,
511
- display_name = excluded.display_name,
512
- recent_message = excluded.recent_message,
513
- status = excluded.status,
514
- updated_at = unixepoch()
515
- """,
516
- (
517
- session.chat_id,
518
- session.user_id,
519
- session.username,
520
- session.display_name,
521
- session.recent_message,
522
- session.status,
523
- ),
524
- )
525
- return session
526
-
527
- def approve_telegram_session(self, chat_id: str) -> StoredTelegramSession:
528
- with self.connect() as connection:
529
- connection.execute(
530
- """
531
- UPDATE telegram_sessions
532
- SET status = 'approved',
533
- updated_at = unixepoch()
534
- WHERE chat_id = ?
535
- """,
536
- (chat_id,),
537
- )
538
- row = connection.execute(
539
- """
540
- SELECT
541
- chat_id,
542
- user_id,
543
- username,
544
- display_name,
545
- recent_message,
546
- status,
547
- updated_at
548
- FROM telegram_sessions
549
- WHERE chat_id = ?
550
- """,
551
- (chat_id,),
552
- ).fetchone()
553
- if row is None:
554
- raise KeyError(chat_id)
555
- return StoredTelegramSession(
556
- chat_id=row["chat_id"],
557
- display_name=row["display_name"],
558
- recent_message=row["recent_message"],
559
- status=row["status"],
560
- updated_at=row["updated_at"],
561
- user_id=row["user_id"],
562
- username=row["username"],
563
- )
564
-
565
- def save_provider(self, provider: StoredProvider) -> StoredProvider:
566
- with self.connect() as connection:
567
- connection.execute(
568
- """
569
- INSERT INTO providers (id, name, type, base_url, api_key)
570
- VALUES (?, ?, ?, ?, ?)
571
- ON CONFLICT(id) DO UPDATE SET
572
- name = excluded.name,
573
- type = excluded.type,
574
- base_url = excluded.base_url,
575
- api_key = excluded.api_key,
576
- updated_at = unixepoch()
577
- """,
578
- (
579
- provider.id,
580
- provider.name,
581
- provider.type.value,
582
- provider.base_url,
583
- provider.api_key,
584
- ),
585
- )
586
- connection.execute(
587
- "DELETE FROM provider_models WHERE provider_id = ?", (provider.id,)
588
- )
589
- connection.executemany(
590
- """
591
- INSERT INTO provider_models (provider_id, model, position)
592
- VALUES (?, ?, ?)
593
- """,
594
- [
595
- (provider.id, model, position)
596
- for position, model in enumerate(provider.models)
597
- ],
598
- )
599
- return provider
600
-
601
- def delete_provider(self, provider_id: str) -> None:
602
- with self.connect() as connection:
603
- settings_row = connection.execute(
604
- """
605
- SELECT selected_provider_id
606
- FROM settings
607
- WHERE id = 1
608
- """
609
- ).fetchone()
610
- provider_rows = connection.execute(
611
- """
612
- SELECT id
613
- FROM providers
614
- ORDER BY created_at, id
615
- """
616
- ).fetchall()
617
- removed_index = next(
618
- (
619
- index
620
- for index, provider_row in enumerate(provider_rows)
621
- if provider_row["id"] == provider_id
622
- ),
623
- -1,
624
- )
625
- remaining_provider_ids = [
626
- provider_row["id"]
627
- for provider_row in provider_rows
628
- if provider_row["id"] != provider_id
629
- ]
630
- connection.execute("DELETE FROM providers WHERE id = ?", (provider_id,))
631
- if settings_row and settings_row["selected_provider_id"] == provider_id:
632
- next_provider_id = ""
633
- if removed_index >= 0:
634
- next_provider_id = (
635
- remaining_provider_ids[removed_index]
636
- if removed_index < len(remaining_provider_ids)
637
- else remaining_provider_ids[removed_index - 1]
638
- if remaining_provider_ids
639
- else ""
640
- )
641
- next_model = ""
642
- if next_provider_id:
643
- model_row = connection.execute(
644
- """
645
- SELECT model
646
- FROM provider_models
647
- WHERE provider_id = ?
648
- ORDER BY position, model
649
- LIMIT 1
650
- """,
651
- (next_provider_id,),
652
- ).fetchone()
653
- next_model = model_row["model"] if model_row else ""
654
- connection.execute(
655
- """
656
- UPDATE settings
657
- SET selected_provider_id = ?,
658
- selected_model = ?,
659
- updated_at = unixepoch()
660
- WHERE id = 1
661
- """,
662
- (next_provider_id, next_model),
663
- )
664
-
665
- def save_settings(self, settings: StoredSettings) -> StoredSettings:
666
- with self.connect() as connection:
667
- connection.execute(
668
- """
669
- INSERT INTO settings (
670
- id,
671
- selected_provider_id,
672
- selected_model,
673
- reasoning_effort,
674
- agent_prompt,
675
- context_window_limit
676
- )
677
- VALUES (1, ?, ?, ?, ?, ?)
678
- ON CONFLICT(id) DO UPDATE SET
679
- selected_provider_id = excluded.selected_provider_id,
680
- selected_model = excluded.selected_model,
681
- reasoning_effort = excluded.reasoning_effort,
682
- agent_prompt = excluded.agent_prompt,
683
- context_window_limit = excluded.context_window_limit,
684
- updated_at = unixepoch()
685
- """,
686
- (
687
- settings.selected_provider_id,
688
- settings.selected_model,
689
- settings.reasoning_effort.value,
690
- settings.agent_prompt,
691
- settings.context_window_limit,
692
- ),
693
- )
694
- return settings
695
-
696
- def save_messages(self, messages: list[StoredMessage]) -> list[StoredMessage]:
697
- with self.connect() as connection:
698
- connection.execute("DELETE FROM messages")
699
- if messages:
700
- latest_usage_info = next(
701
- (
702
- message.usage_info
703
- for message in reversed(messages)
704
- if message.usage_info is not None
705
- ),
706
- None,
707
- )
708
- if latest_usage_info is not None:
709
- connection.execute(
710
- """
711
- INSERT INTO workspace_context (id, usage_info)
712
- VALUES (1, ?)
713
- ON CONFLICT(id) DO UPDATE SET
714
- usage_info = excluded.usage_info,
715
- updated_at = unixepoch()
716
- """,
717
- (latest_usage_info.model_dump_json(),),
718
- )
719
- connection.executemany(
720
- """
721
- INSERT INTO messages (
722
- id,
723
- author,
724
- content,
725
- tools,
726
- thinking,
727
- groups,
728
- status,
729
- usage_info,
730
- position
731
- )
732
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
733
- """,
734
- [
735
- (
736
- message.id,
737
- message.author,
738
- message.content,
739
- json.dumps(
740
- [
741
- tool.model_dump(exclude_none=True)
742
- for tool in message.tools
743
- ]
744
- ),
745
- message.thinking,
746
- json.dumps(
747
- [
748
- group.model_dump(exclude_none=True)
749
- for group in message.groups
750
- ],
751
- ensure_ascii=False,
752
- ),
753
- message.status,
754
- message.usage_info.model_dump_json()
755
- if message.usage_info
756
- else None,
757
- position,
758
- )
759
- for position, message in enumerate(messages)
760
- ],
761
- )
762
- if not messages:
763
- connection.execute("DELETE FROM workspace_context WHERE id = 1")
764
- return messages
765
-
766
- def upsert_message(self, message: StoredMessage) -> StoredMessage:
767
- with self.connect() as connection:
768
- row = connection.execute(
769
- "SELECT position FROM messages WHERE id = ?", (message.id,)
770
- ).fetchone()
771
- if row:
772
- position = row["position"]
773
- else:
774
- position_row = connection.execute(
775
- "SELECT COALESCE(MAX(position) + 1, 0) AS position FROM messages"
776
- ).fetchone()
777
- position = position_row["position"]
778
- connection.execute(
779
- """
780
- INSERT INTO messages (
781
- id,
782
- author,
783
- content,
784
- tools,
785
- thinking,
786
- groups,
787
- status,
788
- usage_info,
789
- position
790
- )
791
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
792
- ON CONFLICT(id) DO UPDATE SET
793
- author = excluded.author,
794
- content = excluded.content,
795
- tools = excluded.tools,
796
- thinking = excluded.thinking,
797
- groups = excluded.groups,
798
- status = excluded.status,
799
- usage_info = excluded.usage_info,
800
- position = excluded.position
801
- """,
802
- (
803
- message.id,
804
- message.author,
805
- message.content,
806
- json.dumps(
807
- [tool.model_dump(exclude_none=True) for tool in message.tools]
808
- ),
809
- message.thinking,
810
- json.dumps(
811
- [
812
- group.model_dump(exclude_none=True)
813
- for group in message.groups
814
- ],
815
- ensure_ascii=False,
816
- ),
817
- message.status,
818
- message.usage_info.model_dump_json()
819
- if message.usage_info
820
- else None,
821
- position,
822
- ),
823
- )
824
- if message.usage_info is not None:
825
- connection.execute(
826
- """
827
- INSERT INTO workspace_context (id, usage_info)
828
- VALUES (1, ?)
829
- ON CONFLICT(id) DO UPDATE SET
830
- usage_info = excluded.usage_info,
831
- updated_at = unixepoch()
832
- """,
833
- (message.usage_info.model_dump_json(),),
834
- )
835
- return message
836
-
837
- def read_usage_info(self) -> TokenUsageInfo | None:
838
- with self.connect() as connection:
839
- row = connection.execute(
840
- """
841
- SELECT usage_info
842
- FROM workspace_context
843
- WHERE id = 1
844
- """
845
- ).fetchone()
846
- if row is None or not row["usage_info"]:
847
- return None
848
- return TokenUsageInfo.model_validate_json(row["usage_info"])
849
-
850
- def save_usage_info(self, usage_info: TokenUsageInfo) -> TokenUsageInfo:
851
- with self.connect() as connection:
852
- connection.execute(
853
- """
854
- INSERT INTO workspace_context (id, usage_info)
855
- VALUES (1, ?)
856
- ON CONFLICT(id) DO UPDATE SET
857
- usage_info = excluded.usage_info,
858
- updated_at = unixepoch()
859
- """,
860
- (usage_info.model_dump_json(),),
861
- )
862
- return usage_info
863
-
864
- def read_compacted_context(self) -> str:
865
- with self.connect() as connection:
866
- row = connection.execute(
867
- """
868
- SELECT compacted_summary
869
- FROM workspace_context
870
- WHERE id = 1
871
- """
872
- ).fetchone()
873
- return row["compacted_summary"] if row else ""
874
-
875
- def save_compacted_context(self, summary: str) -> str:
876
- with self.connect() as connection:
877
- connection.execute(
878
- """
879
- INSERT INTO workspace_context (id, compacted_summary)
880
- VALUES (1, ?)
881
- ON CONFLICT(id) DO UPDATE SET
882
- compacted_summary = excluded.compacted_summary,
883
- active_compaction_id = NULL,
884
- usage_info = NULL,
885
- updated_at = unixepoch()
886
- """,
887
- (summary,),
888
- )
889
- return summary
890
-
891
- def read_is_compacting(self) -> bool:
892
- with self.connect() as connection:
893
- row = connection.execute(
894
- """
895
- SELECT is_compacting
896
- FROM workspace_context
897
- WHERE id = 1
898
- """
899
- ).fetchone()
900
- return bool(row["is_compacting"]) if row else False
901
-
902
- def save_is_compacting(self, is_compacting: bool) -> bool:
903
- with self.connect() as connection:
904
- connection.execute(
905
- """
906
- INSERT INTO workspace_context (id, is_compacting)
907
- VALUES (1, ?)
908
- ON CONFLICT(id) DO UPDATE SET
909
- is_compacting = excluded.is_compacting,
910
- updated_at = unixepoch()
911
- """,
912
- (int(is_compacting),),
913
- )
914
- return is_compacting
915
-
916
- def read_active_compaction_checkpoint(
917
- self,
918
- ) -> StoredCompactionCheckpoint | None:
919
- with self.connect() as connection:
920
- row = connection.execute(
921
- """
922
- SELECT
923
- checkpoint.id,
924
- checkpoint.trigger,
925
- checkpoint.method,
926
- checkpoint.summary,
927
- checkpoint.replacement_history,
928
- checkpoint.source_message_id,
929
- checkpoint.token_before,
930
- checkpoint.token_after,
931
- checkpoint.created_at
932
- FROM workspace_context context
933
- JOIN compaction_checkpoints checkpoint
934
- ON checkpoint.id = context.active_compaction_id
935
- WHERE context.id = 1
936
- """
937
- ).fetchone()
938
- if row is None:
939
- return None
940
- return StoredCompactionCheckpoint(
941
- created_at=row["created_at"],
942
- id=row["id"],
943
- method=row["method"],
944
- replacement_history=[
945
- ChatMessage.model_validate(message)
946
- for message in json.loads(row["replacement_history"] or "[]")
947
- ],
948
- source_message_id=row["source_message_id"],
949
- summary=row["summary"],
950
- token_after=row["token_after"],
951
- token_before=row["token_before"],
952
- trigger=row["trigger"],
953
- )
954
-
955
- def save_compaction_checkpoint(
956
- self, checkpoint: StoredCompactionCheckpoint
957
- ) -> StoredCompactionCheckpoint:
958
- with self.connect() as connection:
959
- connection.execute(
960
- """
961
- INSERT INTO compaction_checkpoints (
962
- id,
963
- trigger,
964
- method,
965
- summary,
966
- replacement_history,
967
- source_message_id,
968
- token_before,
969
- token_after
970
- )
971
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
972
- ON CONFLICT(id) DO UPDATE SET
973
- trigger = excluded.trigger,
974
- method = excluded.method,
975
- summary = excluded.summary,
976
- replacement_history = excluded.replacement_history,
977
- source_message_id = excluded.source_message_id,
978
- token_before = excluded.token_before,
979
- token_after = excluded.token_after
980
- """,
981
- (
982
- checkpoint.id,
983
- checkpoint.trigger,
984
- checkpoint.method,
985
- checkpoint.summary,
986
- json.dumps(
987
- [
988
- message.model_dump()
989
- for message in checkpoint.replacement_history
990
- ],
991
- ensure_ascii=False,
992
- ),
993
- checkpoint.source_message_id,
994
- checkpoint.token_before,
995
- checkpoint.token_after,
996
- ),
997
- )
998
- connection.execute(
999
- """
1000
- INSERT INTO workspace_context (
1001
- id,
1002
- compacted_summary,
1003
- active_compaction_id
1004
- )
1005
- VALUES (1, ?, ?)
1006
- ON CONFLICT(id) DO UPDATE SET
1007
- compacted_summary = excluded.compacted_summary,
1008
- active_compaction_id = excluded.active_compaction_id,
1009
- updated_at = unixepoch()
1010
- """,
1011
- (checkpoint.summary, checkpoint.id),
1012
- )
1013
- row = connection.execute(
1014
- """
1015
- SELECT created_at
1016
- FROM compaction_checkpoints
1017
- WHERE id = ?
1018
- """,
1019
- (checkpoint.id,),
1020
- ).fetchone()
1021
- return checkpoint.model_copy(update={"created_at": row["created_at"]})
1022
-
1023
- def _provider_models(
1024
- self, connection: sqlite3.Connection, provider_id: str
1025
- ) -> list[str]:
1026
- return [
1027
- row["model"]
1028
- for row in connection.execute(
1029
- """
1030
- SELECT model
1031
- FROM provider_models
1032
- WHERE provider_id = ?
1033
- ORDER BY position, model
1034
- """,
1035
- (provider_id,),
1036
- )
1037
- ]
1038
-
1039
- def _read_telegram_bot(self, connection: sqlite3.Connection) -> StoredTelegramBot:
1040
- bot_row = connection.execute(
1041
- """
1042
- SELECT enabled, bot_token
1043
- FROM telegram_bot
1044
- WHERE id = 1
1045
- """
1046
- ).fetchone()
1047
- sessions = [
1048
- StoredTelegramSession(
1049
- chat_id=row["chat_id"],
1050
- display_name=row["display_name"],
1051
- recent_message=row["recent_message"],
1052
- status=row["status"],
1053
- updated_at=row["updated_at"],
1054
- user_id=row["user_id"],
1055
- username=row["username"],
1056
- )
1057
- for row in connection.execute(
1058
- """
1059
- SELECT
1060
- chat_id,
1061
- user_id,
1062
- username,
1063
- display_name,
1064
- recent_message,
1065
- status,
1066
- updated_at
1067
- FROM telegram_sessions
1068
- ORDER BY status DESC, updated_at DESC, chat_id
1069
- """
1070
- )
1071
- ]
1072
- return StoredTelegramBot(
1073
- bot_token=bot_row["bot_token"] if bot_row else "",
1074
- enabled=bool(bot_row["enabled"]) if bot_row else False,
1075
- sessions=sessions,
1076
- )
1077
-
1078
- def _read_mcp_servers(
1079
- self, connection: sqlite3.Connection
1080
- ) -> list[StoredMcpServer]:
1081
- servers: list[StoredMcpServer] = []
1082
- for row in connection.execute(
1083
- """
1084
- SELECT id, name, type, command, args, config, url, enabled
1085
- FROM mcp_servers
1086
- ORDER BY created_at, id
1087
- """
1088
- ):
1089
- tools = [
1090
- StoredMcpTool(
1091
- description=tool_row["description"],
1092
- input_schema=json.loads(tool_row["input_schema"] or "{}"),
1093
- name=tool_row["name"],
1094
- output_schema=json.loads(tool_row["output_schema"])
1095
- if tool_row["output_schema"]
1096
- else None,
1097
- )
1098
- for tool_row in connection.execute(
1099
- """
1100
- SELECT name, description, input_schema, output_schema
1101
- FROM mcp_tools
1102
- WHERE server_id = ?
1103
- ORDER BY position, name
1104
- """,
1105
- (row["id"],),
1106
- )
1107
- ]
1108
- servers.append(
1109
- StoredMcpServer(
1110
- args=json.loads(row["args"] or "[]"),
1111
- command=row["command"],
1112
- config=json.loads(row["config"] or "{}"),
1113
- enabled=bool(row["enabled"]),
1114
- id=row["id"],
1115
- name=row["name"],
1116
- status="disabled",
1117
- tools=tools,
1118
- type=row["type"],
1119
- url=row["url"],
1120
- )
1121
- )
1122
- return servers
1123
-
1124
- def _read_writable_paths(
1125
- self, connection: sqlite3.Connection
1126
- ) -> list[StoredWritablePath]:
1127
- return [
1128
- StoredWritablePath(created_at=row["created_at"], path=row["path"])
1129
- for row in connection.execute(
1130
- """
1131
- SELECT path, created_at
1132
- FROM writable_paths
1133
- ORDER BY path
1134
- """
1135
- )
1136
- ]
1137
-
1138
- def _migrate(self, connection: sqlite3.Connection) -> None:
1139
- connection.executescript(
1140
- """
1141
- CREATE TABLE IF NOT EXISTS mcp_servers (
1142
- id TEXT PRIMARY KEY,
1143
- name TEXT NOT NULL,
1144
- type TEXT NOT NULL,
1145
- command TEXT NOT NULL DEFAULT '',
1146
- args TEXT NOT NULL DEFAULT '[]',
1147
- config TEXT NOT NULL DEFAULT '{}',
1148
- url TEXT NOT NULL DEFAULT '',
1149
- enabled INTEGER NOT NULL DEFAULT 1,
1150
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
1151
- updated_at INTEGER NOT NULL DEFAULT (unixepoch())
1152
- );
1153
-
1154
- CREATE TABLE IF NOT EXISTS mcp_tools (
1155
- server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
1156
- name TEXT NOT NULL,
1157
- description TEXT NOT NULL DEFAULT '',
1158
- input_schema TEXT NOT NULL DEFAULT '{}',
1159
- output_schema TEXT,
1160
- position INTEGER NOT NULL,
1161
- PRIMARY KEY (server_id, name)
1162
- );
1163
-
1164
- CREATE TABLE IF NOT EXISTS telegram_bot (
1165
- id INTEGER PRIMARY KEY CHECK (id = 1),
1166
- enabled INTEGER NOT NULL DEFAULT 0,
1167
- bot_token TEXT NOT NULL,
1168
- updated_at INTEGER NOT NULL DEFAULT (unixepoch())
1169
- );
1170
-
1171
- CREATE TABLE IF NOT EXISTS telegram_sessions (
1172
- chat_id TEXT PRIMARY KEY,
1173
- user_id TEXT NOT NULL DEFAULT '',
1174
- username TEXT NOT NULL DEFAULT '',
1175
- display_name TEXT NOT NULL DEFAULT '',
1176
- recent_message TEXT NOT NULL DEFAULT '',
1177
- status TEXT NOT NULL,
1178
- updated_at INTEGER NOT NULL DEFAULT (unixepoch())
1179
- );
1180
-
1181
- CREATE TABLE IF NOT EXISTS providers (
1182
- id TEXT PRIMARY KEY,
1183
- name TEXT NOT NULL,
1184
- type TEXT NOT NULL,
1185
- base_url TEXT NOT NULL,
1186
- api_key TEXT NOT NULL,
1187
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
1188
- updated_at INTEGER NOT NULL DEFAULT (unixepoch())
1189
- );
1190
-
1191
- CREATE TABLE IF NOT EXISTS provider_models (
1192
- provider_id TEXT NOT NULL REFERENCES providers(id) ON DELETE CASCADE,
1193
- model TEXT NOT NULL,
1194
- position INTEGER NOT NULL,
1195
- PRIMARY KEY (provider_id, model)
1196
- );
1197
-
1198
- CREATE TABLE IF NOT EXISTS settings (
1199
- id INTEGER PRIMARY KEY CHECK (id = 1),
1200
- selected_provider_id TEXT NOT NULL DEFAULT '',
1201
- selected_model TEXT NOT NULL DEFAULT '',
1202
- reasoning_effort TEXT NOT NULL DEFAULT 'default',
1203
- agent_prompt TEXT NOT NULL DEFAULT '',
1204
- context_window_limit INTEGER,
1205
- updated_at INTEGER NOT NULL DEFAULT (unixepoch())
1206
- );
1207
-
1208
- CREATE TABLE IF NOT EXISTS messages (
1209
- id TEXT PRIMARY KEY,
1210
- author TEXT NOT NULL,
1211
- content TEXT NOT NULL,
1212
- status TEXT NOT NULL DEFAULT 'completed',
1213
- usage_info TEXT,
1214
- position INTEGER NOT NULL
1215
- );
1216
-
1217
- CREATE TABLE IF NOT EXISTS workspace_context (
1218
- id INTEGER PRIMARY KEY CHECK (id = 1),
1219
- compacted_summary TEXT NOT NULL DEFAULT '',
1220
- active_compaction_id TEXT,
1221
- is_compacting INTEGER NOT NULL DEFAULT 0,
1222
- usage_info TEXT,
1223
- updated_at INTEGER NOT NULL DEFAULT (unixepoch())
1224
- );
1225
-
1226
- CREATE TABLE IF NOT EXISTS compaction_checkpoints (
1227
- id TEXT PRIMARY KEY,
1228
- trigger TEXT NOT NULL,
1229
- method TEXT NOT NULL,
1230
- summary TEXT NOT NULL,
1231
- replacement_history TEXT NOT NULL DEFAULT '[]',
1232
- source_message_id TEXT,
1233
- token_before INTEGER NOT NULL DEFAULT 0,
1234
- token_after INTEGER NOT NULL DEFAULT 0,
1235
- created_at INTEGER NOT NULL DEFAULT (unixepoch())
1236
- );
1237
-
1238
- CREATE TABLE IF NOT EXISTS skill_settings (
1239
- id TEXT PRIMARY KEY,
1240
- enabled INTEGER NOT NULL DEFAULT 1,
1241
- updated_at INTEGER NOT NULL DEFAULT (unixepoch())
1242
- );
1243
-
1244
- CREATE TABLE IF NOT EXISTS writable_paths (
1245
- path TEXT PRIMARY KEY,
1246
- created_at INTEGER NOT NULL DEFAULT (unixepoch())
1247
- );
1248
-
1249
- CREATE TABLE IF NOT EXISTS schema_migrations (
1250
- version INTEGER PRIMARY KEY
1251
- );
1252
-
1253
- INSERT OR IGNORE INTO schema_migrations (version) VALUES (1);
1254
- """
1255
- )
1256
- columns = {
1257
- row["name"] for row in connection.execute("PRAGMA table_info(mcp_servers)")
1258
- }
1259
- if "config" not in columns:
1260
- connection.execute(
1261
- """
1262
- ALTER TABLE mcp_servers
1263
- ADD COLUMN config TEXT NOT NULL DEFAULT '{}'
1264
- """
1265
- )
1266
- columns = {
1267
- row["name"] for row in connection.execute("PRAGMA table_info(messages)")
1268
- }
1269
- if "tools" not in columns:
1270
- connection.execute(
1271
- "ALTER TABLE messages ADD COLUMN tools TEXT NOT NULL DEFAULT '[]'"
1272
- )
1273
- if "thinking" not in columns:
1274
- connection.execute(
1275
- "ALTER TABLE messages ADD COLUMN thinking TEXT NOT NULL DEFAULT ''"
1276
- )
1277
- if "status" not in columns:
1278
- connection.execute(
1279
- "ALTER TABLE messages ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'"
1280
- )
1281
- if "groups" not in columns:
1282
- connection.execute(
1283
- "ALTER TABLE messages ADD COLUMN groups TEXT NOT NULL DEFAULT '[]'"
1284
- )
1285
- if "usage_info" not in columns:
1286
- connection.execute("ALTER TABLE messages ADD COLUMN usage_info TEXT")
1287
- settings_columns = {
1288
- row["name"] for row in connection.execute("PRAGMA table_info(settings)")
1289
- }
1290
- if "reasoning_effort" not in settings_columns:
1291
- connection.execute(
1292
- "ALTER TABLE settings "
1293
- "ADD COLUMN reasoning_effort TEXT NOT NULL DEFAULT 'default'"
1294
- )
1295
- if "agent_prompt" not in settings_columns:
1296
- connection.execute(
1297
- "ALTER TABLE settings ADD COLUMN agent_prompt TEXT NOT NULL DEFAULT ''"
1298
- )
1299
- if "context_window_limit" not in settings_columns:
1300
- connection.execute(
1301
- "ALTER TABLE settings ADD COLUMN context_window_limit INTEGER"
1302
- )
1303
- workspace_context_columns = {
1304
- row["name"]
1305
- for row in connection.execute("PRAGMA table_info(workspace_context)")
1306
- }
1307
- if "active_compaction_id" not in workspace_context_columns:
1308
- connection.execute(
1309
- "ALTER TABLE workspace_context ADD COLUMN active_compaction_id TEXT"
1310
- )
1311
- if "usage_info" not in workspace_context_columns:
1312
- connection.execute(
1313
- "ALTER TABLE workspace_context ADD COLUMN usage_info TEXT"
1314
- )
1315
- if "is_compacting" not in workspace_context_columns:
1316
- connection.execute(
1317
- "ALTER TABLE workspace_context "
1318
- "ADD COLUMN is_compacting INTEGER NOT NULL DEFAULT 0"
1319
- )