flowai-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. flowai/__init__.py +72 -0
  2. flowai/__main__.py +8 -0
  3. flowai/agents/__init__.py +21 -0
  4. flowai/agents/base.py +61 -0
  5. flowai/agents/patterns/__init__.py +11 -0
  6. flowai/agents/patterns/debate.py +79 -0
  7. flowai/agents/patterns/reflection.py +74 -0
  8. flowai/agents/specialized.py +87 -0
  9. flowai/agents/standard.py +176 -0
  10. flowai/cli/__init__.py +5 -0
  11. flowai/cli/main.py +562 -0
  12. flowai/cli/ui.py +98 -0
  13. flowai/config/__init__.py +5 -0
  14. flowai/config/settings.py +204 -0
  15. flowai/errors.py +30 -0
  16. flowai/mcp/__init__.py +5 -0
  17. flowai/mcp/client.py +77 -0
  18. flowai/memory/__init__.py +16 -0
  19. flowai/memory/base.py +45 -0
  20. flowai/memory/chroma.py +97 -0
  21. flowai/memory/in_memory.py +65 -0
  22. flowai/memory/qdrant.py +104 -0
  23. flowai/observability/__init__.py +15 -0
  24. flowai/observability/events.py +45 -0
  25. flowai/observability/opentelemetry.py +56 -0
  26. flowai/observability/tracer.py +80 -0
  27. flowai/plugins/__init__.py +5 -0
  28. flowai/plugins/loader.py +31 -0
  29. flowai/providers/__init__.py +35 -0
  30. flowai/providers/anthropic.py +268 -0
  31. flowai/providers/base.py +180 -0
  32. flowai/providers/gemini.py +230 -0
  33. flowai/providers/nvidia.py +57 -0
  34. flowai/providers/ollama.py +237 -0
  35. flowai/providers/openai.py +274 -0
  36. flowai/providers/registry.py +50 -0
  37. flowai/runtime/__init__.py +5 -0
  38. flowai/runtime/session.py +76 -0
  39. flowai/server/__init__.py +5 -0
  40. flowai/server/app.py +133 -0
  41. flowai/tools/__init__.py +39 -0
  42. flowai/tools/base.py +56 -0
  43. flowai/tools/builtin.py +128 -0
  44. flowai/tools/decorator.py +103 -0
  45. flowai/tools/files.py +197 -0
  46. flowai/tools/registry.py +84 -0
  47. flowai/tools/web.py +94 -0
  48. flowai/workflow/__init__.py +15 -0
  49. flowai/workflow/checkpoint.py +73 -0
  50. flowai/workflow/engine.py +144 -0
  51. flowai/workflow/state.py +43 -0
  52. flowai_cli-0.2.0.dist-info/METADATA +223 -0
  53. flowai_cli-0.2.0.dist-info/RECORD +56 -0
  54. flowai_cli-0.2.0.dist-info/WHEEL +4 -0
  55. flowai_cli-0.2.0.dist-info/entry_points.txt +2 -0
  56. flowai_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,237 @@
1
+ """Ollama Local Inference Provider implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import AsyncIterator, Sequence
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from flowai.errors import ConfigurationError
13
+ from flowai.providers.base import (
14
+ BaseProvider,
15
+ Message,
16
+ ProviderResponse,
17
+ ProviderUsage,
18
+ StreamChunk,
19
+ ToolCall,
20
+ )
21
+
22
+
23
+ class OllamaProvider(BaseProvider):
24
+ """Local Ollama Provider running at http://localhost:11434."""
25
+
26
+ DEFAULT_BASE_URL = "http://localhost:11434"
27
+ requires_api_key = False
28
+
29
+ def __init__(
30
+ self,
31
+ model: str = "llama3.2",
32
+ api_key: str | None = None,
33
+ base_url: str | None = None,
34
+ timeout: float = 120.0,
35
+ **kwargs: Any,
36
+ ):
37
+ resolved_url = base_url or os.getenv("OLLAMA_BASE_URL", self.DEFAULT_BASE_URL)
38
+ super().__init__(
39
+ model=model,
40
+ api_key=api_key,
41
+ base_url=resolved_url,
42
+ timeout=timeout,
43
+ **kwargs,
44
+ )
45
+
46
+ def _convert_messages(self, messages: Sequence[Message]) -> list[dict[str, Any]]:
47
+ out = []
48
+ for m in messages:
49
+ msg: dict[str, Any] = {"role": m.role, "content": m.content or ""}
50
+ if m.tool_calls:
51
+ msg["tool_calls"] = [
52
+ {
53
+ "function": {
54
+ "name": tc.name,
55
+ "arguments": tc.arguments,
56
+ }
57
+ }
58
+ for tc in m.tool_calls
59
+ ]
60
+ out.append(msg)
61
+ return out
62
+
63
+ async def generate(
64
+ self,
65
+ messages: Sequence[Message],
66
+ tools: Sequence[dict[str, Any]] | None = None,
67
+ temperature: float = 0.7,
68
+ max_tokens: int | None = None,
69
+ **kwargs: Any,
70
+ ) -> ProviderResponse:
71
+ url = f"{self.base_url.rstrip('/')}/api/chat"
72
+ payload: dict[str, Any] = {
73
+ "model": self.model,
74
+ "messages": self._convert_messages(messages),
75
+ "stream": False,
76
+ "options": {"temperature": temperature},
77
+ }
78
+ if max_tokens:
79
+ payload["options"]["num_predict"] = max_tokens
80
+ if tools:
81
+ # Transform OpenAI-style tools to Ollama tools if needed
82
+ ollama_tools = []
83
+ for t in tools:
84
+ if "function" in t:
85
+ ollama_tools.append(t)
86
+ else:
87
+ ollama_tools.append({"type": "function", "function": t})
88
+ payload["tools"] = ollama_tools
89
+
90
+ try:
91
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
92
+ resp = await client.post(url, json=payload)
93
+ await self._raise_for_status(resp)
94
+ data = resp.json()
95
+ except httpx.ConnectError as exc:
96
+ raise self._not_running(exc) from exc
97
+
98
+ raw_msg = data.get("message", {})
99
+ content = raw_msg.get("content", "")
100
+ tool_calls: list[ToolCall] = []
101
+
102
+ if raw_msg.get("tool_calls"):
103
+ for i, tc in enumerate(raw_msg["tool_calls"]):
104
+ fn = tc.get("function", {})
105
+ args = fn.get("arguments", {})
106
+ if isinstance(args, str):
107
+ try:
108
+ args = json.loads(args)
109
+ except Exception:
110
+ args = {"raw": args}
111
+ tool_calls.append(
112
+ ToolCall(
113
+ id=f"call_{i}_{fn.get('name')}",
114
+ name=fn.get("name", ""),
115
+ arguments=args,
116
+ )
117
+ )
118
+
119
+ prompt_eval = data.get("prompt_eval_count", 0)
120
+ eval_count = data.get("eval_count", 0)
121
+ usage = ProviderUsage(
122
+ prompt_tokens=prompt_eval,
123
+ completion_tokens=eval_count,
124
+ total_tokens=prompt_eval + eval_count,
125
+ )
126
+
127
+ return ProviderResponse(
128
+ content=content,
129
+ tool_calls=tool_calls,
130
+ finish_reason="stop" if data.get("done") else "length",
131
+ usage=usage,
132
+ )
133
+
134
+ async def stream(
135
+ self,
136
+ messages: Sequence[Message],
137
+ tools: Sequence[dict[str, Any]] | None = None,
138
+ temperature: float = 0.7,
139
+ max_tokens: int | None = None,
140
+ **kwargs: Any,
141
+ ) -> AsyncIterator[StreamChunk]:
142
+ url = f"{self.base_url.rstrip('/')}/api/chat"
143
+ payload: dict[str, Any] = {
144
+ "model": self.model,
145
+ "messages": self._convert_messages(messages),
146
+ "stream": True,
147
+ "options": {"temperature": temperature},
148
+ }
149
+ if max_tokens:
150
+ payload["options"]["num_predict"] = max_tokens
151
+ if tools:
152
+ payload["tools"] = tools
153
+
154
+ try:
155
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
156
+ async with client.stream("POST", url, json=payload) as response:
157
+ await self._raise_for_status(response)
158
+ call_index = 0
159
+ async for line in response.aiter_lines():
160
+ line = line.strip()
161
+ if not line:
162
+ continue
163
+ try:
164
+ data = json.loads(line)
165
+ except json.JSONDecodeError:
166
+ continue
167
+
168
+ raw_msg = data.get("message", {})
169
+ content_delta = raw_msg.get("content", "")
170
+ done = bool(data.get("done"))
171
+
172
+ # Ollama sends each tool call whole, so emit them all.
173
+ for raw_tc in raw_msg.get("tool_calls") or []:
174
+ fn = raw_tc.get("function", {})
175
+ call_index += 1
176
+ yield StreamChunk(
177
+ tool_call_delta=ToolCall(
178
+ id=f"call_{call_index}_{fn.get('name', '')}",
179
+ name=fn.get("name", ""),
180
+ arguments=self._parse_arguments(fn.get("arguments", {})),
181
+ )
182
+ )
183
+
184
+ if content_delta or done:
185
+ yield StreamChunk(
186
+ text_delta=content_delta,
187
+ finish_reason="stop" if done else None,
188
+ usage=self._usage_from(data) if done else None,
189
+ )
190
+ except httpx.ConnectError as exc:
191
+ raise self._not_running(exc) from exc
192
+
193
+ async def embeddings(
194
+ self,
195
+ texts: list[str],
196
+ model: str | None = None,
197
+ **kwargs: Any,
198
+ ) -> list[list[float]]:
199
+ url = f"{self.base_url.rstrip('/')}/api/embed"
200
+ payload = {
201
+ "model": model or self.model,
202
+ "input": texts,
203
+ }
204
+ try:
205
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
206
+ resp = await client.post(url, json=payload)
207
+ await self._raise_for_status(resp)
208
+ data = resp.json()
209
+ except httpx.ConnectError as exc:
210
+ raise self._not_running(exc) from exc
211
+ return data.get("embeddings", [])
212
+
213
+ def _not_running(self, exc: Exception) -> ConfigurationError:
214
+ """Ollama is local: a refused connection means it is not started."""
215
+ return ConfigurationError(
216
+ f"Cannot reach Ollama at {self.base_url} ({exc}). "
217
+ "Start it with 'ollama serve', or point FlowAI elsewhere with OLLAMA_BASE_URL."
218
+ )
219
+
220
+ @staticmethod
221
+ def _parse_arguments(args):
222
+ if isinstance(args, str):
223
+ try:
224
+ args = json.loads(args)
225
+ except json.JSONDecodeError:
226
+ return {"raw": args}
227
+ return args if isinstance(args, dict) else {"value": args}
228
+
229
+ @staticmethod
230
+ def _usage_from(data: dict[str, Any]) -> ProviderUsage:
231
+ prompt_eval = data.get("prompt_eval_count", 0)
232
+ eval_count = data.get("eval_count", 0)
233
+ return ProviderUsage(
234
+ prompt_tokens=prompt_eval,
235
+ completion_tokens=eval_count,
236
+ total_tokens=prompt_eval + eval_count,
237
+ )
@@ -0,0 +1,274 @@
1
+ """OpenAI and OpenRouter Provider implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import AsyncIterator, Sequence
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from flowai.providers.base import (
13
+ BaseProvider,
14
+ Message,
15
+ ProviderResponse,
16
+ ProviderUsage,
17
+ StreamChunk,
18
+ ToolCall,
19
+ )
20
+
21
+
22
+ class OpenAIProvider(BaseProvider):
23
+ """Standard OpenAI Provider (GPT-4o, GPT-4o-mini, o1, etc.)."""
24
+
25
+ DEFAULT_BASE_URL = "https://api.openai.com/v1"
26
+ api_key_env = "OPENAI_API_KEY"
27
+
28
+ def __init__(
29
+ self,
30
+ model: str = "gpt-4o",
31
+ api_key: str | None = None,
32
+ base_url: str | None = None,
33
+ timeout: float = 60.0,
34
+ **kwargs: Any,
35
+ ):
36
+ resolved_key = api_key or os.getenv("OPENAI_API_KEY", "")
37
+ resolved_url = base_url or os.getenv("OPENAI_BASE_URL", self.DEFAULT_BASE_URL)
38
+ super().__init__(
39
+ model=model,
40
+ api_key=resolved_key,
41
+ base_url=resolved_url,
42
+ timeout=timeout,
43
+ **kwargs,
44
+ )
45
+
46
+ def _get_headers(self) -> dict[str, str]:
47
+ headers = {
48
+ "Content-Type": "application/json",
49
+ "Accept": "application/json",
50
+ }
51
+ if self.api_key:
52
+ headers["Authorization"] = f"Bearer {self.api_key}"
53
+ return headers
54
+
55
+ def _convert_messages(self, messages: Sequence[Message]) -> list[dict[str, Any]]:
56
+ out = []
57
+ for m in messages:
58
+ msg: dict[str, Any] = {"role": m.role}
59
+ if m.content is not None:
60
+ msg["content"] = m.content
61
+ if m.tool_calls:
62
+ msg["tool_calls"] = [
63
+ {
64
+ "id": tc.id,
65
+ "type": "function",
66
+ "function": {
67
+ "name": tc.name,
68
+ "arguments": json.dumps(tc.arguments),
69
+ },
70
+ }
71
+ for tc in m.tool_calls
72
+ ]
73
+ if m.tool_call_id:
74
+ msg["tool_call_id"] = m.tool_call_id
75
+ if m.name:
76
+ msg["name"] = m.name
77
+ out.append(msg)
78
+ return out
79
+
80
+ async def generate(
81
+ self,
82
+ messages: Sequence[Message],
83
+ tools: Sequence[dict[str, Any]] | None = None,
84
+ temperature: float = 0.7,
85
+ max_tokens: int | None = None,
86
+ **kwargs: Any,
87
+ ) -> ProviderResponse:
88
+ url = f"{self.base_url.rstrip('/')}/chat/completions"
89
+ payload: dict[str, Any] = {
90
+ "model": self.model,
91
+ "messages": self._convert_messages(messages),
92
+ "temperature": temperature,
93
+ }
94
+ if max_tokens:
95
+ payload["max_tokens"] = max_tokens
96
+ if tools:
97
+ payload["tools"] = tools
98
+
99
+ self.require_api_key()
100
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
101
+ resp = await client.post(url, headers=self._get_headers(), json=payload)
102
+ await self._raise_for_status(resp)
103
+ data = resp.json()
104
+
105
+ choice = data["choices"][0]
106
+ raw_msg = choice.get("message", {})
107
+ content = raw_msg.get("content") or ""
108
+
109
+ tool_calls: list[ToolCall] = []
110
+ if raw_msg.get("tool_calls"):
111
+ for tc in raw_msg["tool_calls"]:
112
+ tool_calls.append(self.parse_tool_call(tc))
113
+
114
+ raw_usage = data.get("usage", {})
115
+ usage = ProviderUsage(
116
+ prompt_tokens=raw_usage.get("prompt_tokens", 0),
117
+ completion_tokens=raw_usage.get("completion_tokens", 0),
118
+ total_tokens=raw_usage.get("total_tokens", 0),
119
+ )
120
+
121
+ return ProviderResponse(
122
+ content=content,
123
+ tool_calls=tool_calls,
124
+ finish_reason=choice.get("finish_reason", "stop"),
125
+ usage=usage,
126
+ )
127
+
128
+ async def stream(
129
+ self,
130
+ messages: Sequence[Message],
131
+ tools: Sequence[dict[str, Any]] | None = None,
132
+ temperature: float = 0.7,
133
+ max_tokens: int | None = None,
134
+ **kwargs: Any,
135
+ ) -> AsyncIterator[StreamChunk]:
136
+ url = f"{self.base_url.rstrip('/')}/chat/completions"
137
+ payload: dict[str, Any] = {
138
+ "model": self.model,
139
+ "messages": self._convert_messages(messages),
140
+ "temperature": temperature,
141
+ "stream": True,
142
+ "stream_options": {"include_usage": True},
143
+ }
144
+ if max_tokens:
145
+ payload["max_tokens"] = max_tokens
146
+ if tools:
147
+ payload["tools"] = tools
148
+
149
+ self.require_api_key()
150
+
151
+ # Tool calls arrive in fragments keyed by index: the name in one chunk,
152
+ # the arguments spread over many. They are assembled here and emitted
153
+ # once complete, so the agent always sees a whole call.
154
+ pending: dict[int, dict[str, str]] = {}
155
+
156
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
157
+ async with client.stream("POST", url, headers=self._get_headers(), json=payload) as response:
158
+ await self._raise_for_status(response)
159
+ async for line in response.aiter_lines():
160
+ line = line.strip()
161
+ if not line.startswith("data: "):
162
+ continue
163
+ data_str = line[6:]
164
+ if data_str == "[DONE]":
165
+ break
166
+ try:
167
+ chunk_dict = json.loads(data_str)
168
+ except json.JSONDecodeError:
169
+ continue
170
+
171
+ usage = None
172
+ if chunk_dict.get("usage"):
173
+ raw = chunk_dict["usage"]
174
+ usage = ProviderUsage(
175
+ prompt_tokens=raw.get("prompt_tokens", 0),
176
+ completion_tokens=raw.get("completion_tokens", 0),
177
+ total_tokens=raw.get("total_tokens", 0),
178
+ )
179
+
180
+ choices = chunk_dict.get("choices") or []
181
+ if not choices:
182
+ if usage:
183
+ yield StreamChunk(usage=usage)
184
+ continue
185
+
186
+ choice = choices[0]
187
+ delta = choice.get("delta", {})
188
+ finish_reason = choice.get("finish_reason")
189
+
190
+ for raw_tc in delta.get("tool_calls") or []:
191
+ index = raw_tc.get("index", 0)
192
+ slot = pending.setdefault(index, {"id": "", "name": "", "arguments": ""})
193
+ if raw_tc.get("id"):
194
+ slot["id"] = raw_tc["id"]
195
+ fn = raw_tc.get("function") or {}
196
+ if fn.get("name"):
197
+ slot["name"] = fn["name"]
198
+ if fn.get("arguments"):
199
+ slot["arguments"] += fn["arguments"]
200
+
201
+ text_delta = delta.get("content") or ""
202
+ if text_delta or usage:
203
+ yield StreamChunk(text_delta=text_delta, usage=usage)
204
+
205
+ if finish_reason:
206
+ for slot in pending.values():
207
+ yield StreamChunk(tool_call_delta=self._assemble_tool_call(slot))
208
+ pending.clear()
209
+ yield StreamChunk(finish_reason=finish_reason)
210
+
211
+ # A stream cut short before finish_reason still carries usable calls.
212
+ for slot in pending.values():
213
+ yield StreamChunk(tool_call_delta=self._assemble_tool_call(slot))
214
+
215
+ @staticmethod
216
+ def _assemble_tool_call(slot: dict[str, str]) -> ToolCall:
217
+ """Turn an accumulated streaming fragment into a complete ToolCall."""
218
+ raw_args = slot.get("arguments") or "{}"
219
+ try:
220
+ arguments = json.loads(raw_args) if raw_args.strip() else {}
221
+ except json.JSONDecodeError:
222
+ arguments = {"raw": raw_args}
223
+ if not isinstance(arguments, dict):
224
+ arguments = {"value": arguments}
225
+ return ToolCall(id=slot.get("id") or "", name=slot.get("name") or "", arguments=arguments)
226
+
227
+ async def embeddings(
228
+ self,
229
+ texts: list[str],
230
+ model: str | None = None,
231
+ **kwargs: Any,
232
+ ) -> list[list[float]]:
233
+ url = f"{self.base_url.rstrip('/')}/embeddings"
234
+ payload = {
235
+ "input": texts,
236
+ "model": model or "text-embedding-3-small",
237
+ }
238
+ self.require_api_key()
239
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
240
+ resp = await client.post(url, headers=self._get_headers(), json=payload)
241
+ await self._raise_for_status(resp)
242
+ data = resp.json()
243
+ return [item["embedding"] for item in data.get("data", [])]
244
+
245
+
246
+ class OpenRouterProvider(OpenAIProvider):
247
+ """OpenRouter Provider for routing across hundreds of open/commercial models."""
248
+
249
+ DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
250
+ api_key_env = "OPENROUTER_API_KEY"
251
+
252
+ def __init__(
253
+ self,
254
+ model: str = "deepseek/deepseek-r1:free",
255
+ api_key: str | None = None,
256
+ base_url: str | None = None,
257
+ timeout: float = 60.0,
258
+ **kwargs: Any,
259
+ ):
260
+ resolved_key = api_key or os.getenv("OPENROUTER_API_KEY", "")
261
+ resolved_url = base_url or os.getenv("OPENROUTER_BASE_URL", self.DEFAULT_BASE_URL)
262
+ super().__init__(
263
+ model=model,
264
+ api_key=resolved_key,
265
+ base_url=resolved_url,
266
+ timeout=timeout,
267
+ **kwargs,
268
+ )
269
+
270
+ def _get_headers(self) -> dict[str, str]:
271
+ headers = super()._get_headers()
272
+ headers["HTTP-Referer"] = "https://github.com/Ajayramaswamy2005/FlowAI"
273
+ headers["X-Title"] = "FlowAI Agent Harness"
274
+ return headers
@@ -0,0 +1,50 @@
1
+ """Provider Registry and Dynamic Factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from flowai.providers.anthropic import AnthropicProvider
6
+ from flowai.providers.base import BaseProvider
7
+ from flowai.providers.gemini import GeminiProvider
8
+ from flowai.providers.nvidia import NvidiaProvider
9
+ from flowai.providers.ollama import OllamaProvider
10
+ from flowai.providers.openai import OpenAIProvider, OpenRouterProvider
11
+
12
+
13
+ class ProviderRegistry:
14
+ """Registry maintaining active provider classes and factory instantiations."""
15
+
16
+ _registry: dict[str, type[BaseProvider]] = {
17
+ "nvidia": NvidiaProvider,
18
+ "ollama": OllamaProvider,
19
+ "openai": OpenAIProvider,
20
+ "anthropic": AnthropicProvider,
21
+ "gemini": GeminiProvider,
22
+ "openrouter": OpenRouterProvider,
23
+ }
24
+
25
+ @classmethod
26
+ def register(cls, name: str, provider_cls: type[BaseProvider]) -> None:
27
+ """Register a custom provider class."""
28
+ cls._registry[name.lower()] = provider_cls
29
+
30
+ @classmethod
31
+ def get(cls, name: str) -> type[BaseProvider]:
32
+ """Fetch provider class by name."""
33
+ provider_name = name.lower()
34
+ if provider_name not in cls._registry:
35
+ available = ", ".join(cls._registry.keys())
36
+ raise ValueError(f"Unknown provider '{name}'. Available providers: {available}")
37
+ return cls._registry[provider_name]
38
+
39
+ @classmethod
40
+ def create(cls, name: str, model: str, **kwargs) -> BaseProvider:
41
+ """Instantiate a provider by name."""
42
+ provider_cls = cls.get(name)
43
+ return provider_cls(model=model, **kwargs)
44
+
45
+
46
+ def get_provider(provider_or_name: str | BaseProvider, model: str = "gpt-4o", **kwargs) -> BaseProvider:
47
+ """Convenience helper to resolve provider instance or name."""
48
+ if isinstance(provider_or_name, BaseProvider):
49
+ return provider_or_name
50
+ return ProviderRegistry.create(provider_or_name, model=model, **kwargs)
@@ -0,0 +1,5 @@
1
+ """FlowAI Runtime and Session Management."""
2
+
3
+ from flowai.runtime.session import SessionStore
4
+
5
+ __all__ = ["SessionStore"]
@@ -0,0 +1,76 @@
1
+ """Session management and persistent conversation storage on disk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from flowai.providers.base import Message
10
+
11
+
12
+ class SessionStore:
13
+ """Manages persistent conversation sessions on disk (stored in ~/.flowai/sessions/)."""
14
+
15
+ def __init__(self, storage_dir: Path | str | None = None):
16
+ if storage_dir is None:
17
+ self.storage_dir = Path.home() / ".flowai" / "sessions"
18
+ else:
19
+ self.storage_dir = Path(storage_dir)
20
+ self.storage_dir.mkdir(parents=True, exist_ok=True)
21
+
22
+ def _get_path(self, session_id: str) -> Path:
23
+ return self.storage_dir / f"{session_id}.json"
24
+
25
+ def save(self, session_id: str, messages: list[Message], metadata: dict[str, Any] | None = None) -> Path:
26
+ """Persist message history and metadata to disk."""
27
+ data = {
28
+ "session_id": session_id,
29
+ "metadata": metadata or {},
30
+ "messages": [m.model_dump() for m in messages],
31
+ }
32
+ path = self._get_path(session_id)
33
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
34
+
35
+ # Update a 'latest' pointer for instant resumption
36
+ latest_path = self.storage_dir / "latest.txt"
37
+ latest_path.write_text(session_id, encoding="utf-8")
38
+ return path
39
+
40
+ def load(self, session_id: str) -> list[Message]:
41
+ """Load messages from an existing disk session."""
42
+ path = self._get_path(session_id)
43
+ if not path.exists():
44
+ return []
45
+ try:
46
+ raw = json.loads(path.read_text(encoding="utf-8"))
47
+ return [Message(**m) for m in raw.get("messages", [])]
48
+ except Exception:
49
+ return []
50
+
51
+ def get_latest_session_id(self) -> str | None:
52
+ """Retrieve ID of the most recent session."""
53
+ latest_path = self.storage_dir / "latest.txt"
54
+ if latest_path.exists():
55
+ sid = latest_path.read_text(encoding="utf-8").strip()
56
+ if self._get_path(sid).exists():
57
+ return sid
58
+ # Fallback to the newest json file in the directory
59
+ files = sorted(self.storage_dir.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True)
60
+ return files[0].stem if files else None
61
+
62
+ def list_sessions(self) -> list[dict[str, Any]]:
63
+ """List all saved sessions with summary statistics."""
64
+ sessions = []
65
+ for p in self.storage_dir.glob("*.json"):
66
+ try:
67
+ data = json.loads(p.read_text(encoding="utf-8"))
68
+ sessions.append({
69
+ "session_id": data.get("session_id", p.stem),
70
+ "turns": len(data.get("messages", [])),
71
+ "path": str(p),
72
+ "modified": p.stat().st_mtime,
73
+ })
74
+ except Exception:
75
+ continue
76
+ return sorted(sessions, key=lambda s: s["modified"], reverse=True)
@@ -0,0 +1,5 @@
1
+ """FlowAI Server Package."""
2
+
3
+ from flowai.server.app import create_app
4
+
5
+ __all__ = ["create_app"]