flowent 0.0.12 → 0.0.13

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 (43) hide show
  1. package/backend/pyproject.toml +1 -1
  2. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  3. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  4. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/channels.py +296 -0
  17. package/backend/src/flowent/main.py +128 -2
  18. package/backend/src/flowent/static/assets/index-CEZrWoDG.css +2 -0
  19. package/backend/src/flowent/static/assets/index-S5a0Rkj1.js +81 -0
  20. package/backend/src/flowent/static/index.html +2 -2
  21. package/backend/src/flowent/storage.py +179 -0
  22. package/backend/src/flowent/tools.py +28 -5
  23. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  24. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  25. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  26. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  27. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  28. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  29. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  30. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  31. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/test_agent_tools.py +54 -0
  33. package/backend/tests/test_channels.py +360 -0
  34. package/backend/tests/test_persistence.py +30 -0
  35. package/backend/uv.lock +1 -1
  36. package/dist/frontend/assets/index-CEZrWoDG.css +2 -0
  37. package/dist/frontend/assets/index-S5a0Rkj1.js +81 -0
  38. package/dist/frontend/index.html +2 -2
  39. package/package.json +1 -1
  40. package/backend/src/flowent/static/assets/index-BwQOML_0.css +0 -2
  41. package/backend/src/flowent/static/assets/index-DXQ_smj0.js +0 -81
  42. package/dist/frontend/assets/index-BwQOML_0.css +0 -2
  43. package/dist/frontend/assets/index-DXQ_smj0.js +0 -81
@@ -6,8 +6,8 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>Flowent</title>
8
8
  <meta name="description" content="Flowent application" />
9
- <script type="module" crossorigin src="/assets/index-DXQ_smj0.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-BwQOML_0.css">
9
+ <script type="module" crossorigin src="/assets/index-S5a0Rkj1.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-CEZrWoDG.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -8,6 +8,28 @@ from flowent.llm import ProviderFormat, ReasoningEffort
8
8
  from flowent.paths import data_directory
9
9
 
10
10
 
11
+ class StoredTelegramSession(BaseModel):
12
+ model_config = ConfigDict(extra="forbid")
13
+
14
+ chat_id: str
15
+ display_name: str = ""
16
+ recent_message: str = ""
17
+ status: str
18
+ updated_at: int = 0
19
+ user_id: str = ""
20
+ username: str = ""
21
+
22
+
23
+ class StoredTelegramBot(BaseModel):
24
+ model_config = ConfigDict(extra="forbid")
25
+
26
+ bot_token: str
27
+ enabled: bool
28
+ error: str = ""
29
+ sessions: list[StoredTelegramSession] = Field(default_factory=list)
30
+ status: str = "disabled"
31
+
32
+
11
33
  class StoredProvider(BaseModel):
12
34
  model_config = ConfigDict(extra="forbid")
13
35
 
@@ -55,6 +77,7 @@ class StoredState(BaseModel):
55
77
  messages: list[StoredMessage]
56
78
  providers: list[StoredProvider]
57
79
  settings: StoredSettings
80
+ telegram_bot: StoredTelegramBot
58
81
 
59
82
 
60
83
  class StateStore:
@@ -74,6 +97,7 @@ class StateStore:
74
97
 
75
98
  def read_state(self) -> StoredState:
76
99
  with self.connect() as connection:
100
+ telegram_bot = self._read_telegram_bot(connection)
77
101
  providers = [
78
102
  StoredProvider(
79
103
  api_key=row["api_key"],
@@ -130,6 +154,105 @@ class StateStore:
130
154
  if settings_row
131
155
  else "",
132
156
  ),
157
+ telegram_bot=telegram_bot,
158
+ )
159
+
160
+ def read_telegram_bot(self) -> StoredTelegramBot:
161
+ with self.connect() as connection:
162
+ return self._read_telegram_bot(connection)
163
+
164
+ def save_telegram_bot(self, telegram_bot: StoredTelegramBot) -> StoredTelegramBot:
165
+ with self.connect() as connection:
166
+ connection.execute(
167
+ """
168
+ INSERT INTO telegram_bot (
169
+ id,
170
+ enabled,
171
+ bot_token
172
+ )
173
+ VALUES (1, ?, ?)
174
+ ON CONFLICT(id) DO UPDATE SET
175
+ enabled = excluded.enabled,
176
+ bot_token = excluded.bot_token,
177
+ updated_at = unixepoch()
178
+ """,
179
+ (
180
+ int(telegram_bot.enabled),
181
+ telegram_bot.bot_token,
182
+ ),
183
+ )
184
+ return self._read_telegram_bot(connection)
185
+
186
+ def save_telegram_session(
187
+ self, session: StoredTelegramSession
188
+ ) -> StoredTelegramSession:
189
+ with self.connect() as connection:
190
+ connection.execute(
191
+ """
192
+ INSERT INTO telegram_sessions (
193
+ chat_id,
194
+ user_id,
195
+ username,
196
+ display_name,
197
+ recent_message,
198
+ status
199
+ )
200
+ VALUES (?, ?, ?, ?, ?, ?)
201
+ ON CONFLICT(chat_id) DO UPDATE SET
202
+ user_id = excluded.user_id,
203
+ username = excluded.username,
204
+ display_name = excluded.display_name,
205
+ recent_message = excluded.recent_message,
206
+ status = excluded.status,
207
+ updated_at = unixepoch()
208
+ """,
209
+ (
210
+ session.chat_id,
211
+ session.user_id,
212
+ session.username,
213
+ session.display_name,
214
+ session.recent_message,
215
+ session.status,
216
+ ),
217
+ )
218
+ return session
219
+
220
+ def approve_telegram_session(self, chat_id: str) -> StoredTelegramSession:
221
+ with self.connect() as connection:
222
+ connection.execute(
223
+ """
224
+ UPDATE telegram_sessions
225
+ SET status = 'approved',
226
+ updated_at = unixepoch()
227
+ WHERE chat_id = ?
228
+ """,
229
+ (chat_id,),
230
+ )
231
+ row = connection.execute(
232
+ """
233
+ SELECT
234
+ chat_id,
235
+ user_id,
236
+ username,
237
+ display_name,
238
+ recent_message,
239
+ status,
240
+ updated_at
241
+ FROM telegram_sessions
242
+ WHERE chat_id = ?
243
+ """,
244
+ (chat_id,),
245
+ ).fetchone()
246
+ if row is None:
247
+ raise KeyError(chat_id)
248
+ return StoredTelegramSession(
249
+ chat_id=row["chat_id"],
250
+ display_name=row["display_name"],
251
+ recent_message=row["recent_message"],
252
+ status=row["status"],
253
+ updated_at=row["updated_at"],
254
+ user_id=row["user_id"],
255
+ username=row["username"],
133
256
  )
134
257
 
135
258
  def save_provider(self, provider: StoredProvider) -> StoredProvider:
@@ -263,9 +386,65 @@ class StateStore:
263
386
  )
264
387
  ]
265
388
 
389
+ def _read_telegram_bot(self, connection: sqlite3.Connection) -> StoredTelegramBot:
390
+ bot_row = connection.execute(
391
+ """
392
+ SELECT enabled, bot_token
393
+ FROM telegram_bot
394
+ WHERE id = 1
395
+ """
396
+ ).fetchone()
397
+ sessions = [
398
+ StoredTelegramSession(
399
+ chat_id=row["chat_id"],
400
+ display_name=row["display_name"],
401
+ recent_message=row["recent_message"],
402
+ status=row["status"],
403
+ updated_at=row["updated_at"],
404
+ user_id=row["user_id"],
405
+ username=row["username"],
406
+ )
407
+ for row in connection.execute(
408
+ """
409
+ SELECT
410
+ chat_id,
411
+ user_id,
412
+ username,
413
+ display_name,
414
+ recent_message,
415
+ status,
416
+ updated_at
417
+ FROM telegram_sessions
418
+ ORDER BY status DESC, updated_at DESC, chat_id
419
+ """
420
+ )
421
+ ]
422
+ return StoredTelegramBot(
423
+ bot_token=bot_row["bot_token"] if bot_row else "",
424
+ enabled=bool(bot_row["enabled"]) if bot_row else False,
425
+ sessions=sessions,
426
+ )
427
+
266
428
  def _migrate(self, connection: sqlite3.Connection) -> None:
267
429
  connection.executescript(
268
430
  """
431
+ CREATE TABLE IF NOT EXISTS telegram_bot (
432
+ id INTEGER PRIMARY KEY CHECK (id = 1),
433
+ enabled INTEGER NOT NULL DEFAULT 0,
434
+ bot_token TEXT NOT NULL,
435
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
436
+ );
437
+
438
+ CREATE TABLE IF NOT EXISTS telegram_sessions (
439
+ chat_id TEXT PRIMARY KEY,
440
+ user_id TEXT NOT NULL DEFAULT '',
441
+ username TEXT NOT NULL DEFAULT '',
442
+ display_name TEXT NOT NULL DEFAULT '',
443
+ recent_message TEXT NOT NULL DEFAULT '',
444
+ status TEXT NOT NULL,
445
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
446
+ );
447
+
269
448
  CREATE TABLE IF NOT EXISTS providers (
270
449
  id TEXT PRIMARY KEY,
271
450
  name TEXT NOT NULL,
@@ -162,7 +162,7 @@ def tool_call_title(name: str, arguments: dict[str, object]) -> str:
162
162
  if name == "grep_files":
163
163
  return f"Searching {arguments.get('pattern', 'files')}"
164
164
  if name == "apply_patch":
165
- return "Applying patch"
165
+ return "Editing files"
166
166
  if name == "shell_command":
167
167
  return f"Running {arguments.get('command', 'command')}"
168
168
  if name == "update_plan":
@@ -192,9 +192,10 @@ def run_tool(
192
192
  return web_search(arguments, context)
193
193
  raise ValueError("Tool is not available.")
194
194
  except Exception as error:
195
- return ToolResult(
196
- content=str(error), ok=False, title=tool_call_title(name, arguments)
195
+ title = (
196
+ "Edit failed" if name == "apply_patch" else tool_call_title(name, arguments)
197
197
  )
198
+ return ToolResult(content=str(error), ok=False, title=title)
198
199
 
199
200
 
200
201
  def integer_argument(arguments: dict[str, object], name: str, default: int) -> int:
@@ -262,13 +263,35 @@ def apply_patch_tool(arguments: dict[str, object], context: ToolContext) -> Tool
262
263
  )
263
264
  if result.exit_code != 0:
264
265
  raise SandboxError(tool_failure_content(result))
266
+ data = json.loads(result.stdout or "{}")
265
267
  return ToolResult(
266
268
  content=result.stdout,
267
- data={"files": [str(path) for path in paths]},
268
- title="Applied patch",
269
+ data=data if isinstance(data, dict) else {},
270
+ title=patch_title_from_result(data),
269
271
  )
270
272
 
271
273
 
274
+ def patch_title_from_result(data: object) -> str:
275
+ if not isinstance(data, dict):
276
+ return "Edited files"
277
+ files = data.get("files")
278
+ if not isinstance(files, list) or not files:
279
+ return "Edited files"
280
+ if len(files) > 1:
281
+ return f"Edited {len(files)} files"
282
+ file_info = files[0]
283
+ if not isinstance(file_info, dict):
284
+ return "Edited files"
285
+ raw_path = file_info.get("path")
286
+ name = Path(str(raw_path)).name if raw_path else "file"
287
+ status = file_info.get("status")
288
+ if status == "added":
289
+ return f"Added {name}"
290
+ if status == "deleted":
291
+ return f"Deleted {name}"
292
+ return f"Edited {name}"
293
+
294
+
272
295
  def tool_failure_content(result: object) -> str:
273
296
  stdout = str(getattr(result, "stdout", "") or "").strip()
274
297
  stderr = str(getattr(result, "stderr", "") or "").strip()
@@ -253,9 +253,60 @@ def test_apply_patch_modifies_workdir_file(tmp_path) -> None:
253
253
  result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
254
254
 
255
255
  assert result.ok
256
+ assert result.title == "Edited notes.txt"
256
257
  assert target.read_text() == "alpha\nready\n"
257
258
 
258
259
 
260
+ def test_apply_patch_added_file_title(tmp_path) -> None:
261
+ patch = """*** Begin Patch
262
+ *** Add File: created.txt
263
+ +hello
264
+ *** End Patch
265
+ """
266
+
267
+ result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
268
+
269
+ assert result.ok
270
+ assert result.title == "Added created.txt"
271
+ assert (tmp_path / "created.txt").read_text() == "hello\n"
272
+
273
+
274
+ def test_apply_patch_deleted_file_title(tmp_path) -> None:
275
+ target = tmp_path / "old.txt"
276
+ target.write_text("remove me\n")
277
+ patch = """*** Begin Patch
278
+ *** Delete File: old.txt
279
+ *** End Patch
280
+ """
281
+
282
+ result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
283
+
284
+ assert result.ok
285
+ assert result.title == "Deleted old.txt"
286
+ assert not target.exists()
287
+
288
+
289
+ def test_apply_patch_multiple_files_title(tmp_path) -> None:
290
+ target = tmp_path / "notes.txt"
291
+ target.write_text("alpha\nbeta\n")
292
+ patch = """*** Begin Patch
293
+ *** Update File: notes.txt
294
+ @@
295
+ -beta
296
+ +ready
297
+ *** Add File: created.txt
298
+ +hello
299
+ *** End Patch
300
+ """
301
+
302
+ result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
303
+
304
+ assert result.ok
305
+ assert result.title == "Edited 2 files"
306
+ assert target.read_text() == "alpha\nready\n"
307
+ assert (tmp_path / "created.txt").read_text() == "hello\n"
308
+
309
+
259
310
  def test_apply_patch_rejects_outside_workdir_file(tmp_path) -> None:
260
311
  outside = Path(__file__).resolve().parent / "outside-patch.txt"
261
312
  outside.write_text("alpha\n")
@@ -271,6 +322,7 @@ def test_apply_patch_rejects_outside_workdir_file(tmp_path) -> None:
271
322
  result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
272
323
 
273
324
  assert not result.ok
325
+ assert result.title == "Edit failed"
274
326
  assert outside.read_text() == "alpha\n"
275
327
  finally:
276
328
  outside.unlink(missing_ok=True)
@@ -297,6 +349,7 @@ def test_apply_patch_uses_internal_subcommand(tmp_path, monkeypatch) -> None:
297
349
  result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
298
350
 
299
351
  assert result.ok
352
+ assert result.title == "Edited files"
300
353
  assert calls
301
354
  assert calls[0][1:4] == ["-m", "flowent.cli", "apply-patch"]
302
355
 
@@ -326,6 +379,7 @@ def test_apply_patch_reports_patch_error_when_stderr_has_warning(
326
379
  result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
327
380
 
328
381
  assert not result.ok
382
+ assert result.title == "Edit failed"
329
383
  assert result.content == "Patch context was not found."
330
384
 
331
385