sessionfs 0.3.0__tar.gz → 0.3.2__tar.gz

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 (131) hide show
  1. {sessionfs-0.3.0 → sessionfs-0.3.2}/PKG-INFO +12 -5
  2. {sessionfs-0.3.0 → sessionfs-0.3.2}/README.md +9 -4
  3. {sessionfs-0.3.0 → sessionfs-0.3.2}/pyproject.toml +2 -1
  4. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/__init__.py +1 -1
  5. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/cmd_audit.py +43 -5
  6. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/cmd_cloud.py +30 -8
  7. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/cmd_sessions.py +20 -0
  8. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/daemon/main.py +12 -9
  9. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/daemon/status.py +8 -4
  10. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/judge/evidence.py +0 -1
  11. sessionfs-0.3.2/src/sessionfs/judge/export.py +85 -0
  12. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/judge/judge.py +1 -1
  13. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/judge/providers.py +42 -5
  14. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/app.py +3 -1
  15. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/auth/dependencies.py +9 -0
  16. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/config.py +2 -1
  17. sessionfs-0.3.2/src/sessionfs/server/db/migrations/versions/006_judge_settings.py +25 -0
  18. sessionfs-0.3.2/src/sessionfs/server/db/migrations/versions/007_admin_audit_log.py +27 -0
  19. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/db/models.py +30 -0
  20. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/email_templates.py +25 -4
  21. sessionfs-0.3.2/src/sessionfs/server/routes/admin.py +435 -0
  22. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/routes/audit.py +41 -10
  23. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/routes/auth.py +13 -0
  24. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/routes/handoffs.py +57 -61
  25. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/routes/sessions.py +79 -49
  26. sessionfs-0.3.2/src/sessionfs/server/routes/settings.py +105 -0
  27. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/workspace/resolver.py +1 -1
  28. {sessionfs-0.3.0 → sessionfs-0.3.2}/.gitignore +0 -0
  29. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/audit.py +0 -0
  30. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/__init__.py +0 -0
  31. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/cmd_admin.py +0 -0
  32. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/cmd_config.py +0 -0
  33. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/cmd_daemon.py +0 -0
  34. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/cmd_io.py +0 -0
  35. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/cmd_mcp.py +0 -0
  36. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/cmd_ops.py +0 -0
  37. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/cmd_search.py +0 -0
  38. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/common.py +0 -0
  39. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/cost.py +0 -0
  40. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/main.py +0 -0
  41. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/sfs_to_cc.py +0 -0
  42. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/sfs_to_md.py +0 -0
  43. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/cli/titles.py +0 -0
  44. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/converters/__init__.py +0 -0
  45. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/converters/amp_to_sfs.py +0 -0
  46. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/converters/cline_to_sfs.py +0 -0
  47. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/converters/codex_injector.py +0 -0
  48. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/converters/copilot_injector.py +0 -0
  49. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/converters/copilot_to_sfs.py +0 -0
  50. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/converters/cursor_to_sfs.py +0 -0
  51. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/converters/gemini_injector.py +0 -0
  52. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/converters/gemini_to_sfs.py +0 -0
  53. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/converters/sfs_to_codex.py +0 -0
  54. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/converters/sfs_to_copilot.py +0 -0
  55. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/converters/sfs_to_gemini.py +0 -0
  56. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/daemon/__init__.py +0 -0
  57. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/daemon/config.py +0 -0
  58. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/judge/__init__.py +0 -0
  59. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/judge/extractor.py +0 -0
  60. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/judge/report.py +0 -0
  61. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/mcp/__init__.py +0 -0
  62. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/mcp/cloud_client.py +0 -0
  63. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/mcp/remote_server.py +0 -0
  64. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/mcp/search.py +0 -0
  65. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/mcp/server.py +0 -0
  66. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/security/__init__.py +0 -0
  67. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/security/secrets.py +0 -0
  68. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/__init__.py +0 -0
  69. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/auth/__init__.py +0 -0
  70. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/auth/keys.py +0 -0
  71. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/auth/rate_limit.py +0 -0
  72. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/db/__init__.py +0 -0
  73. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/db/engine.py +0 -0
  74. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/db/migrations/env.py +0 -0
  75. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/db/migrations/versions/001_initial_schema.py +0 -0
  76. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/db/migrations/versions/002_verification_and_tiers.py +0 -0
  77. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/db/migrations/versions/003_search_index.py +0 -0
  78. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/db/migrations/versions/004_bigint_tokens.py +0 -0
  79. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/db/migrations/versions/005_handoffs.py +0 -0
  80. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/email.py +0 -0
  81. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/errors.py +0 -0
  82. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/routes/__init__.py +0 -0
  83. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/routes/health.py +0 -0
  84. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/schemas/__init__.py +0 -0
  85. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/schemas/auth.py +0 -0
  86. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/schemas/errors.py +0 -0
  87. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/schemas/handoffs.py +0 -0
  88. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/schemas/sessions.py +0 -0
  89. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/storage/__init__.py +0 -0
  90. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/storage/base.py +0 -0
  91. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/storage/gcs.py +0 -0
  92. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/storage/local.py +0 -0
  93. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/server/storage/s3.py +0 -0
  94. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/session_id.py +0 -0
  95. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/__init__.py +0 -0
  96. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/convert_cc.py +0 -0
  97. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/examples/complete/manifest.json +0 -0
  98. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/examples/complete/messages.jsonl +0 -0
  99. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/examples/complete/tools.json +0 -0
  100. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/examples/complete/workspace.json +0 -0
  101. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/examples/minimal/manifest.json +0 -0
  102. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/examples/minimal/messages.jsonl +0 -0
  103. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/examples/subagent/manifest.json +0 -0
  104. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/examples/subagent/messages.jsonl +0 -0
  105. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/examples/subagent/tools.json +0 -0
  106. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/examples/subagent/workspace.json +0 -0
  107. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/schemas/manifest.schema.json +0 -0
  108. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/schemas/message.schema.json +0 -0
  109. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/schemas/tools.schema.json +0 -0
  110. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/schemas/workspace.schema.json +0 -0
  111. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/validate.py +0 -0
  112. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/spec/version.py +0 -0
  113. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/store/__init__.py +0 -0
  114. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/store/index.py +0 -0
  115. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/store/local.py +0 -0
  116. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/sync/__init__.py +0 -0
  117. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/sync/archive.py +0 -0
  118. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/sync/client.py +0 -0
  119. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/utils/__init__.py +0 -0
  120. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/utils/title_utils.py +0 -0
  121. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/watchers/__init__.py +0 -0
  122. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/watchers/amp.py +0 -0
  123. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/watchers/base.py +0 -0
  124. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/watchers/claude_code.py +0 -0
  125. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/watchers/cline.py +0 -0
  126. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/watchers/codex.py +0 -0
  127. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/watchers/copilot.py +0 -0
  128. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/watchers/cursor.py +0 -0
  129. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/watchers/gemini.py +0 -0
  130. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/watchers/roo.py +0 -0
  131. {sessionfs-0.3.0 → sessionfs-0.3.2}/src/sessionfs/workspace/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sessionfs
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Capture, sync, and resume AI coding sessions across 8 tools — Claude Code, Codex, Gemini, Copilot, Cursor, Amp, Cline, and Roo Code.
5
5
  Project-URL: Homepage, https://sessionfs.dev
6
6
  Project-URL: Repository, https://github.com/sessionfs/sessionfs
@@ -36,6 +36,7 @@ Requires-Dist: alembic<2.0,>=1.13; extra == 'dev'
36
36
  Requires-Dist: asyncpg<1.0,>=0.29; extra == 'dev'
37
37
  Requires-Dist: boto3<2.0,>=1.34; extra == 'dev'
38
38
  Requires-Dist: build<2.0,>=1.0; extra == 'dev'
39
+ Requires-Dist: cryptography<44.0,>=42.0; extra == 'dev'
39
40
  Requires-Dist: fastapi<1.0,>=0.110; extra == 'dev'
40
41
  Requires-Dist: google-cloud-storage<3.0,>=2.14; extra == 'dev'
41
42
  Requires-Dist: httpx<1.0,>=0.27; extra == 'dev'
@@ -52,6 +53,7 @@ Provides-Extra: server
52
53
  Requires-Dist: alembic<2.0,>=1.13; extra == 'server'
53
54
  Requires-Dist: asyncpg<1.0,>=0.29; extra == 'server'
54
55
  Requires-Dist: boto3<2.0,>=1.34; extra == 'server'
56
+ Requires-Dist: cryptography<44.0,>=42.0; extra == 'server'
55
57
  Requires-Dist: fastapi<1.0,>=0.110; extra == 'server'
56
58
  Requires-Dist: google-cloud-storage<3.0,>=2.14; extra == 'server'
57
59
  Requires-Dist: pydantic-settings<3.0,>=2.0; extra == 'server'
@@ -61,6 +63,10 @@ Requires-Dist: sqlalchemy[asyncio]<3.0,>=2.0; extra == 'server'
61
63
  Requires-Dist: uvicorn[standard]<1.0,>=0.27; extra == 'server'
62
64
  Description-Content-Type: text/markdown
63
65
 
66
+ <p align="center">
67
+ <img src="brand/logo-full.svg" alt="SessionFS" width="300">
68
+ </p>
69
+
64
70
  # SessionFS
65
71
 
66
72
  **Stop re-prompting. Start resuming.**
@@ -217,21 +223,22 @@ All file paths are relative to workspace root. Sessions are append-only — conf
217
223
 
218
224
  ## Status
219
225
 
220
- **v0.3.0 — Public Beta.** 640 tests passing.
226
+ **v0.3.2 — Public Beta.** 673 tests passing.
221
227
 
222
228
  What works today:
223
229
  - Eight-tool session capture (Claude Code, Codex, Gemini, Cursor, Copilot CLI, Amp, Cline, Roo Code)
224
230
  - Cross-tool resume between Claude Code, Codex, Gemini, and Copilot CLI
225
231
  - Full-text search across all sessions (CLI + dashboard + API)
226
232
  - MCP server — AI tools can search your past sessions for context
233
+ - LLM-as-a-Judge — audit sessions for hallucinations (BYOK, multi-provider, OpenRouter)
234
+ - Team handoff with email notification and smart workspace resolution
227
235
  - Browse, inspect, export, fork, and checkpoint sessions
228
236
  - Cloud sync with push/pull, email verification, and ETag conflict detection
229
237
  - Self-hosted API server with auth, PostgreSQL, S3/GCS storage
230
- - Web dashboard with session management and search
231
- - Team handoff with email notification
232
- - 12 security controls including secret detection, path traversal protection, and audit logging
238
+ - Web dashboard with session management, search, handoffs, and audit
233
239
 
234
240
  On the roadmap:
241
+ - Admin API and dashboard
235
242
  - Stripe billing integration
236
243
  - Session similarity and duplicate detection
237
244
  - Cost analytics dashboard
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="brand/logo-full.svg" alt="SessionFS" width="300">
3
+ </p>
4
+
1
5
  # SessionFS
2
6
 
3
7
  **Stop re-prompting. Start resuming.**
@@ -154,21 +158,22 @@ All file paths are relative to workspace root. Sessions are append-only — conf
154
158
 
155
159
  ## Status
156
160
 
157
- **v0.3.0 — Public Beta.** 640 tests passing.
161
+ **v0.3.2 — Public Beta.** 673 tests passing.
158
162
 
159
163
  What works today:
160
164
  - Eight-tool session capture (Claude Code, Codex, Gemini, Cursor, Copilot CLI, Amp, Cline, Roo Code)
161
165
  - Cross-tool resume between Claude Code, Codex, Gemini, and Copilot CLI
162
166
  - Full-text search across all sessions (CLI + dashboard + API)
163
167
  - MCP server — AI tools can search your past sessions for context
168
+ - LLM-as-a-Judge — audit sessions for hallucinations (BYOK, multi-provider, OpenRouter)
169
+ - Team handoff with email notification and smart workspace resolution
164
170
  - Browse, inspect, export, fork, and checkpoint sessions
165
171
  - Cloud sync with push/pull, email verification, and ETag conflict detection
166
172
  - Self-hosted API server with auth, PostgreSQL, S3/GCS storage
167
- - Web dashboard with session management and search
168
- - Team handoff with email notification
169
- - 12 security controls including secret detection, path traversal protection, and audit logging
173
+ - Web dashboard with session management, search, handoffs, and audit
170
174
 
171
175
  On the roadmap:
176
+ - Admin API and dashboard
172
177
  - Stripe billing integration
173
178
  - Session similarity and duplicate detection
174
179
  - Cost analytics dashboard
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sessionfs"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "Capture, sync, and resume AI coding sessions across 8 tools — Claude Code, Codex, Gemini, Copilot, Cursor, Amp, Cline, and Roo Code."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -56,6 +56,7 @@ server = [
56
56
  "pydantic-settings>=2.0,<3.0",
57
57
  "PyJWT>=2.8,<3.0",
58
58
  "google-cloud-storage>=2.14,<3.0",
59
+ "cryptography>=42.0,<44.0",
59
60
  ]
60
61
  dev = [
61
62
  "sessionfs[server]",
@@ -5,4 +5,4 @@ from importlib.metadata import version, PackageNotFoundError
5
5
  try:
6
6
  __version__ = version("sessionfs")
7
7
  except PackageNotFoundError:
8
- __version__ = "0.3.0" # fallback for development
8
+ __version__ = "0.3.2" # fallback for development
@@ -24,10 +24,11 @@ def audit(
24
24
  session_id: str = typer.Argument(..., help="Session ID or prefix"),
25
25
  model: str = typer.Option("claude-sonnet-4", "--model", help="Judge LLM model"),
26
26
  api_key: Optional[str] = typer.Option(None, "--api-key", help="LLM API key"),
27
- provider: Optional[str] = typer.Option(None, "--provider", help="LLM provider (anthropic, openai, google)"),
27
+ provider: Optional[str] = typer.Option(None, "--provider", help="LLM provider (anthropic, openai, google, openrouter)"),
28
28
  consensus: bool = typer.Option(False, "--consensus", help="Run 3 passes, report only where 2+ agree"),
29
29
  report_only: bool = typer.Option(False, "--report", help="Show existing report only"),
30
30
  json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
31
+ fmt: Optional[str] = typer.Option(None, "--format", help="Export format: json, markdown, csv (use with --report)"),
31
32
  ) -> None:
32
33
  """Audit a session for hallucinations using LLM-as-a-Judge."""
33
34
  store = open_store()
@@ -35,7 +36,7 @@ def audit(
35
36
  session_dir = get_session_dir_or_exit(store, session_id)
36
37
 
37
38
  if report_only:
38
- _show_existing_report(session_dir, session_id, json_output)
39
+ _show_existing_report(session_dir, session_id, json_output, fmt)
39
40
  return
40
41
 
41
42
  resolved_key = _resolve_api_key(api_key, model)
@@ -58,7 +59,10 @@ def audit(
58
59
  err_console.print(f"[red]Audit failed: {exc}[/red]")
59
60
  raise typer.Exit(1)
60
61
 
61
- _display_report(report, json_output)
62
+ if fmt:
63
+ _export_report(report, fmt, session_dir)
64
+ else:
65
+ _display_report(report, json_output)
62
66
 
63
67
 
64
68
  def _resolve_api_key(explicit_key: str | None, model: str) -> str | None:
@@ -137,7 +141,7 @@ async def _run_judge(
137
141
  )
138
142
 
139
143
 
140
- def _show_existing_report(session_dir, session_id: str, json_output: bool) -> None:
144
+ def _show_existing_report(session_dir, session_id: str, json_output: bool, fmt: str | None = None) -> None:
141
145
  """Load and display an existing audit report."""
142
146
  from sessionfs.judge.report import load_report
143
147
 
@@ -149,7 +153,41 @@ def _show_existing_report(session_dir, session_id: str, json_output: bool) -> No
149
153
  )
150
154
  raise typer.Exit(1)
151
155
 
152
- _display_report(report, json_output)
156
+ if fmt:
157
+ _export_report(report, fmt, session_dir)
158
+ else:
159
+ _display_report(report, json_output)
160
+
161
+
162
+ def _export_report(report, fmt: str, session_dir=None) -> None:
163
+ """Export report in the specified format."""
164
+ from sessionfs.judge.export import export_csv, export_json, export_markdown
165
+
166
+ fmt = fmt.lower()
167
+
168
+ if fmt == "markdown":
169
+ # Try to read session metadata for richer markdown export
170
+ session_title = ""
171
+ session_tool = ""
172
+ message_count = 0
173
+ if session_dir:
174
+ manifest_path = session_dir / "manifest.json"
175
+ if manifest_path.exists():
176
+ try:
177
+ manifest = json.loads(manifest_path.read_text())
178
+ session_title = manifest.get("title", "")
179
+ session_tool = manifest.get("source", {}).get("tool", "")
180
+ message_count = manifest.get("stats", {}).get("message_count", 0)
181
+ except (json.JSONDecodeError, OSError):
182
+ pass
183
+ console.print(export_markdown(report, session_title, session_tool, message_count))
184
+ elif fmt == "csv":
185
+ console.print(export_csv(report))
186
+ elif fmt == "json":
187
+ console.print(export_json(report))
188
+ else:
189
+ err_console.print(f"[red]Unknown format: {fmt}. Use json, markdown, or csv.[/red]")
190
+ raise typer.Exit(1)
153
191
 
154
192
 
155
193
  def _display_report(report, json_output: bool) -> None:
@@ -194,13 +194,35 @@ def auth_signup(
194
194
  def auth_status() -> None:
195
195
  """Show current authentication status."""
196
196
  cfg = _load_sync_config()
197
- if cfg["api_key"]:
198
- console.print("[green]Authenticated[/green]")
199
- console.print(f" Server: {cfg['api_url']}")
200
- console.print(f" Sync enabled: {cfg['enabled']}")
201
- console.print(f" API key: {cfg['api_key'][:8]}...")
202
- else:
197
+ if not cfg["api_key"]:
203
198
  console.print("[dim]Not authenticated. Run 'sfs auth login'.[/dim]")
199
+ return
200
+
201
+ console.print("[green]Authenticated[/green]")
202
+ console.print(f" Server: {cfg['api_url']}")
203
+ console.print(f" API key: {cfg['api_key'][:12]}...")
204
+
205
+ # Fetch user profile from server
206
+ try:
207
+ import httpx
208
+
209
+ resp = httpx.get(
210
+ f"{cfg['api_url']}/api/v1/auth/me",
211
+ headers={"Authorization": f"Bearer {cfg['api_key']}"},
212
+ timeout=10,
213
+ )
214
+ if resp.status_code == 200:
215
+ me = resp.json()
216
+ console.print(f" Email: [cyan]{me.get('email', '?')}[/cyan]")
217
+ console.print(f" Tier: {me.get('tier', 'free')}")
218
+ verified = me.get("email_verified", False)
219
+ console.print(f" Verified: {'[green]yes[/green]' if verified else '[yellow]no — check your inbox[/yellow]'}")
220
+ else:
221
+ console.print(f" [dim]Could not fetch profile (HTTP {resp.status_code})[/dim]")
222
+ except Exception:
223
+ console.print(" [dim]Could not reach server[/dim]")
224
+
225
+ console.print(f" Sync enabled: {cfg['enabled']}")
204
226
 
205
227
 
206
228
  def push(
@@ -519,7 +541,7 @@ def handoff(
519
541
  console.print(f"\n[green]Handoff created: {handoff_id}[/green]")
520
542
  console.print(f" Recipient: {to}")
521
543
  console.print(f" Expires: {data['expires_at']}")
522
- console.print(f"\nRecipient can pull with:")
544
+ console.print("\nRecipient can pull with:")
523
545
  console.print(f" sfs pull --handoff {handoff_id}")
524
546
  else:
525
547
  err_console.print(f"[red]Handoff failed: {resp.text}[/red]")
@@ -641,7 +663,7 @@ def pull_handoff(
641
663
  f"\n[green]Pulled handoff session {session_id}[/green]\n"
642
664
  f" Size: {len(pull_result.data):,} bytes"
643
665
  )
644
- console.print(f"\nResume with:")
666
+ console.print("\nResume with:")
645
667
  console.print(f" sfs resume {session_id} --in {tool}")
646
668
 
647
669
  finally:
@@ -168,6 +168,7 @@ def list_sessions(
168
168
  table.add_column("Msgs", justify="right", no_wrap=True, max_width=5)
169
169
  table.add_column("Tokens", justify="right", no_wrap=True, max_width=6)
170
170
  table.add_column("Age", no_wrap=True, max_width=8)
171
+ table.add_column("Audit", no_wrap=True, max_width=5)
171
172
  table.add_column("Title", ratio=1)
172
173
 
173
174
  for s in sessions:
@@ -178,6 +179,24 @@ def list_sessions(
178
179
  from sessionfs.cli.titles import _truncate_at_word
179
180
  title = _truncate_at_word(title, 60)
180
181
 
182
+ # Load audit report if it exists
183
+ audit_display = ""
184
+ session_dir = store.get_session_dir(s["session_id"])
185
+ if session_dir:
186
+ audit_path = session_dir / "audit_report.json"
187
+ if audit_path.exists():
188
+ try:
189
+ audit_data = json.loads(audit_path.read_text())
190
+ trust = audit_data.get("summary", {}).get("trust_score", 0)
191
+ if trust >= 0.8:
192
+ audit_display = f"[green]{trust:.0%}[/green]"
193
+ elif trust >= 0.5:
194
+ audit_display = f"[yellow]{trust:.0%}[/yellow]"
195
+ else:
196
+ audit_display = f"[red]{trust:.0%}[/red]"
197
+ except (json.JSONDecodeError, OSError):
198
+ pass
199
+
181
200
  table.add_row(
182
201
  s["session_id"][:8],
183
202
  abbreviate_tool(s.get("source_tool")),
@@ -185,6 +204,7 @@ def list_sessions(
185
204
  str(s.get("message_count", 0)),
186
205
  format_token_count(total_tokens),
187
206
  format_relative_time(s.get("updated_at") or s.get("created_at")),
207
+ audit_display,
188
208
  title,
189
209
  )
190
210
 
@@ -370,18 +370,21 @@ class Daemon:
370
370
 
371
371
  try:
372
372
  while self._running:
373
- # Handle config reload
374
- if self._reload_requested:
375
- self._reload_requested = False
376
- self._reload_config()
373
+ try:
374
+ # Handle config reload
375
+ if self._reload_requested:
376
+ self._reload_requested = False
377
+ self._reload_config()
377
378
 
378
- for watcher in self.watchers:
379
- watcher.process_events()
379
+ for watcher in self.watchers:
380
+ watcher.process_events()
380
381
 
381
- # Sync any pending sessions
382
- self._syncer.maybe_sync()
382
+ # Sync any pending sessions
383
+ self._syncer.maybe_sync()
383
384
 
384
- self._update_status()
385
+ self._update_status()
386
+ except Exception as exc:
387
+ logger.error("Error in daemon loop (continuing): %s", exc)
385
388
  time.sleep(1.0)
386
389
  finally:
387
390
  logger.info("sfsd shutting down...")
@@ -47,10 +47,14 @@ class DaemonStatus(BaseModel):
47
47
 
48
48
  def write_status(status: DaemonStatus, status_path: Path) -> None:
49
49
  """Write daemon status atomically (write to temp, rename)."""
50
- status.last_updated_at = datetime.now(timezone.utc).isoformat()
51
- tmp_path = status_path.with_suffix(".tmp")
52
- tmp_path.write_text(json.dumps(status.model_dump(), indent=2))
53
- tmp_path.rename(status_path)
50
+ try:
51
+ status.last_updated_at = datetime.now(timezone.utc).isoformat()
52
+ status_path.parent.mkdir(parents=True, exist_ok=True)
53
+ tmp_path = status_path.with_suffix(".tmp")
54
+ tmp_path.write_text(json.dumps(status.model_dump(), indent=2))
55
+ tmp_path.rename(status_path)
56
+ except OSError:
57
+ pass # Non-fatal — status is informational, don't crash the daemon
54
58
 
55
59
 
56
60
  def read_status(status_path: Path) -> DaemonStatus | None:
@@ -163,7 +163,6 @@ def gather_evidence(messages: list[dict]) -> list[Evidence]:
163
163
  if tool_id in tool_uses:
164
164
  use_idx, tool_name, inp = tool_uses[tool_id]
165
165
  else:
166
- use_idx = idx
167
166
  tool_name = msg.get("name", "unknown")
168
167
  inp = {}
169
168
 
@@ -0,0 +1,85 @@
1
+ """Export judge reports in multiple formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import json
8
+ from dataclasses import asdict
9
+
10
+ from sessionfs.judge.report import JudgeReport
11
+
12
+
13
+ def export_markdown(
14
+ report: JudgeReport,
15
+ session_title: str = "",
16
+ session_tool: str = "",
17
+ message_count: int = 0,
18
+ ) -> str:
19
+ """Export audit report as markdown."""
20
+ s = report.summary
21
+ lines: list[str] = []
22
+
23
+ lines.append(f"# Audit Report: {report.session_id}")
24
+ lines.append("")
25
+
26
+ if session_title:
27
+ lines.append(f"**Title:** {session_title}")
28
+ if session_tool:
29
+ lines.append(f"**Tool:** {session_tool}")
30
+ if message_count:
31
+ lines.append(f"**Messages:** {message_count}")
32
+ lines.append(f"**Model:** {report.model}")
33
+ lines.append(f"**Timestamp:** {report.timestamp}")
34
+ lines.append("")
35
+
36
+ # Summary
37
+ lines.append("## Summary")
38
+ lines.append("")
39
+ lines.append("| Metric | Value |")
40
+ lines.append("|--------|-------|")
41
+ lines.append(f"| Trust Score | {s.trust_score:.0%} |")
42
+ lines.append(f"| Total Claims | {s.total_claims} |")
43
+ lines.append(f"| Verified | {s.verified} |")
44
+ lines.append(f"| Unverified | {s.unverified} |")
45
+ lines.append(f"| Hallucinations | {s.hallucinations} |")
46
+ lines.append(f"| Major | {s.major_findings} |")
47
+ lines.append(f"| Moderate | {s.moderate_findings} |")
48
+ lines.append(f"| Minor | {s.minor_findings} |")
49
+ lines.append("")
50
+
51
+ # Findings
52
+ if report.findings:
53
+ lines.append("## Findings")
54
+ lines.append("")
55
+ lines.append("| Msg | Verdict | Severity | Claim | Evidence | Explanation |")
56
+ lines.append("|-----|---------|----------|-------|----------|-------------|")
57
+ for f in report.findings:
58
+ claim = f.claim.replace("|", "\\|")
59
+ evidence = f.evidence.replace("|", "\\|")
60
+ explanation = f.explanation.replace("|", "\\|")
61
+ lines.append(
62
+ f"| {f.message_index} | {f.verdict} | {f.severity} "
63
+ f"| {claim} | {evidence} | {explanation} |"
64
+ )
65
+ lines.append("")
66
+ else:
67
+ lines.append("No verifiable claims found in this session.")
68
+ lines.append("")
69
+
70
+ return "\n".join(lines)
71
+
72
+
73
+ def export_csv(report: JudgeReport) -> str:
74
+ """Export as CSV: message_index,verdict,severity,claim,evidence,explanation"""
75
+ output = io.StringIO()
76
+ writer = csv.writer(output)
77
+ writer.writerow(["message_index", "verdict", "severity", "claim", "evidence", "explanation"])
78
+ for f in report.findings:
79
+ writer.writerow([f.message_index, f.verdict, f.severity, f.claim, f.evidence, f.explanation])
80
+ return output.getvalue()
81
+
82
+
83
+ def export_json(report: JudgeReport) -> str:
84
+ """Export as indented JSON."""
85
+ return json.dumps(asdict(report), indent=2)
@@ -160,7 +160,7 @@ def _parse_judge_response(response: str, claims: list[Claim]) -> list[Finding]:
160
160
  if text.startswith("```"):
161
161
  lines = text.split("\n")
162
162
  # Remove first and last fence lines
163
- lines = [l for l in lines if not l.strip().startswith("```")]
163
+ lines = [ln for ln in lines if not ln.strip().startswith("```")]
164
164
  text = "\n".join(lines)
165
165
 
166
166
  try:
@@ -17,6 +17,7 @@ _PROVIDER_DETECT = [
17
17
  ("gpt-", "openai"),
18
18
  ("o1", "openai"),
19
19
  ("o3", "openai"),
20
+ ("o4", "openai"),
20
21
  ("gemini-", "google"),
21
22
  ]
22
23
 
@@ -25,18 +26,25 @@ _OPENAI_URL = "https://api.openai.com/v1/chat/completions"
25
26
  _GOOGLE_URL_TEMPLATE = (
26
27
  "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
27
28
  )
29
+ _OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
28
30
 
29
31
 
30
32
  def _detect_provider(model: str) -> str:
31
- """Auto-detect provider from model name."""
33
+ """Auto-detect provider from model name.
34
+
35
+ Models containing "/" are routed to OpenRouter.
36
+ Unknown models fall back to OpenRouter.
37
+ """
38
+ if "/" in model:
39
+ return "openrouter"
40
+
32
41
  model_lower = model.lower()
33
42
  for prefix, provider in _PROVIDER_DETECT:
34
43
  if model_lower.startswith(prefix):
35
44
  return provider
36
- raise ValueError(
37
- f"Cannot auto-detect provider for model '{model}'. "
38
- "Pass --provider explicitly (anthropic, openai, google)."
39
- )
45
+
46
+ # Unknown model fall back to OpenRouter
47
+ return "openrouter"
40
48
 
41
49
 
42
50
  async def _call_anthropic(model: str, system: str, prompt: str, api_key: str, temperature: float = 0) -> str:
@@ -108,6 +116,31 @@ async def _call_google(model: str, system: str, prompt: str, api_key: str, tempe
108
116
  return ""
109
117
 
110
118
 
119
+ async def _call_openrouter(model: str, system: str, prompt: str, api_key: str, temperature: float = 0) -> str:
120
+ """Call the OpenRouter Chat Completions API."""
121
+ headers = {
122
+ "Authorization": f"Bearer {api_key}",
123
+ "Content-Type": "application/json",
124
+ "HTTP-Referer": "https://sessionfs.dev",
125
+ "X-Title": "SessionFS",
126
+ }
127
+ body = {
128
+ "model": model,
129
+ "messages": [
130
+ {"role": "system", "content": system},
131
+ {"role": "user", "content": prompt},
132
+ ],
133
+ "max_tokens": 4096,
134
+ "temperature": temperature,
135
+ "response_format": {"type": "json_object"},
136
+ }
137
+ async with httpx.AsyncClient(timeout=120) as client:
138
+ resp = await client.post(_OPENROUTER_URL, json=body, headers=headers)
139
+ resp.raise_for_status()
140
+ data = resp.json()
141
+ return data["choices"][0]["message"]["content"]
142
+
143
+
111
144
  async def call_llm(
112
145
  model: str,
113
146
  system: str,
@@ -122,6 +155,8 @@ async def call_llm(
122
155
  - claude-* -> anthropic
123
156
  - gpt-*, o1*, o3* -> openai
124
157
  - gemini-* -> google
158
+ - models containing "/" -> openrouter
159
+ - unknown models -> openrouter (fallback)
125
160
 
126
161
  Uses httpx directly — no SDK dependencies. The API key is used for
127
162
  this single request only and is never persisted. Temperature defaults
@@ -139,5 +174,7 @@ async def call_llm(
139
174
  return await _call_openai(model, system, prompt, api_key, temperature)
140
175
  elif provider == "google":
141
176
  return await _call_google(model, system, prompt, api_key, temperature)
177
+ elif provider == "openrouter":
178
+ return await _call_openrouter(model, system, prompt, api_key, temperature)
142
179
  else:
143
180
  raise ValueError(f"Unsupported provider: {provider}")
@@ -12,7 +12,7 @@ from sessionfs import __version__
12
12
  from sessionfs.server.config import ServerConfig
13
13
  from sessionfs.server.db.engine import close_engine, init_engine
14
14
  from sessionfs.server.errors import register_exception_handlers
15
- from sessionfs.server.routes import audit, auth, handoffs, health, sessions
15
+ from sessionfs.server.routes import admin, audit, auth, handoffs, health, sessions, settings
16
16
  from sessionfs.server.storage.local import LocalBlobStore
17
17
 
18
18
 
@@ -81,6 +81,8 @@ def create_app(config: ServerConfig | None = None) -> FastAPI:
81
81
  app.include_router(sessions.router)
82
82
  app.include_router(handoffs.router)
83
83
  app.include_router(audit.router)
84
+ app.include_router(settings.router)
85
+ app.include_router(admin.router)
84
86
 
85
87
  # Serve dashboard static files if the dist directory exists.
86
88
  # The dashboard is a React SPA — all non-API routes serve index.html.
@@ -70,6 +70,15 @@ async def get_current_user(
70
70
  return user
71
71
 
72
72
 
73
+ async def require_admin(
74
+ user: User = Depends(get_current_user),
75
+ ) -> User:
76
+ """Require that the authenticated user has admin tier."""
77
+ if user.tier != "admin":
78
+ raise HTTPException(status_code=403, detail="Admin access required")
79
+ return user
80
+
81
+
73
82
  async def require_verified_user(
74
83
  user: User = Depends(get_current_user),
75
84
  ) -> User:
@@ -22,7 +22,8 @@ class ServerConfig(BaseSettings):
22
22
 
23
23
  resend_api_key: str = ""
24
24
  verification_secret: str = ""
25
- max_sync_bytes: int = 10_485_760 # 10 MB
25
+ max_sync_bytes_free: int = 52_428_800 # 50 MB — free tier
26
+ max_sync_bytes_paid: int = 314_572_800 # 300 MB — pro/team/enterprise/admin
26
27
  retention_days_free: int = 14
27
28
 
28
29
  host: str = "0.0.0.0"
@@ -0,0 +1,25 @@
1
+ """Add user_judge_settings table for stored LLM API keys.
2
+
3
+ Revision ID: 006
4
+ Revises: 005
5
+ """
6
+ from alembic import op
7
+ import sqlalchemy as sa
8
+
9
+ revision = "006"
10
+ down_revision = "005"
11
+
12
+
13
+ def upgrade():
14
+ op.create_table(
15
+ "user_judge_settings",
16
+ sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), primary_key=True),
17
+ sa.Column("provider", sa.String(50), nullable=False),
18
+ sa.Column("model", sa.String(100), nullable=False),
19
+ sa.Column("encrypted_api_key", sa.Text(), nullable=False),
20
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
21
+ )
22
+
23
+
24
+ def downgrade():
25
+ op.drop_table("user_judge_settings")
@@ -0,0 +1,27 @@
1
+ """Add admin_actions table for audit logging.
2
+
3
+ Revision ID: 007
4
+ Revises: 006
5
+ """
6
+ from alembic import op
7
+ import sqlalchemy as sa
8
+
9
+ revision = "007"
10
+ down_revision = "006"
11
+
12
+
13
+ def upgrade():
14
+ op.create_table(
15
+ "admin_actions",
16
+ sa.Column("id", sa.String(36), primary_key=True),
17
+ sa.Column("admin_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
18
+ sa.Column("action", sa.String(50), nullable=False),
19
+ sa.Column("target_type", sa.String(20), nullable=False),
20
+ sa.Column("target_id", sa.String(64), nullable=False),
21
+ sa.Column("details", sa.Text(), nullable=True),
22
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
23
+ )
24
+
25
+
26
+ def downgrade():
27
+ op.drop_table("admin_actions")