flowstatemcp 2.5.0__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 (146) hide show
  1. flowstatemcp-2.5.0/.gitignore +63 -0
  2. flowstatemcp-2.5.0/PKG-INFO +88 -0
  3. flowstatemcp-2.5.0/README.md +52 -0
  4. flowstatemcp-2.5.0/flowstate/__init__.py +34 -0
  5. flowstatemcp-2.5.0/flowstate/__main__.py +7 -0
  6. flowstatemcp-2.5.0/flowstate/activity.py +224 -0
  7. flowstatemcp-2.5.0/flowstate/agent_guide.py +251 -0
  8. flowstatemcp-2.5.0/flowstate/app_keys.py +141 -0
  9. flowstatemcp-2.5.0/flowstate/approvals.py +974 -0
  10. flowstatemcp-2.5.0/flowstate/approve_page.py +565 -0
  11. flowstatemcp-2.5.0/flowstate/ask.py +107 -0
  12. flowstatemcp-2.5.0/flowstate/autosession.py +480 -0
  13. flowstatemcp-2.5.0/flowstate/backup.py +201 -0
  14. flowstatemcp-2.5.0/flowstate/brief.py +420 -0
  15. flowstatemcp-2.5.0/flowstate/card_offer.py +208 -0
  16. flowstatemcp-2.5.0/flowstate/card_taps.py +165 -0
  17. flowstatemcp-2.5.0/flowstate/card_trust.py +127 -0
  18. flowstatemcp-2.5.0/flowstate/claims.py +461 -0
  19. flowstatemcp-2.5.0/flowstate/cleanup.py +619 -0
  20. flowstatemcp-2.5.0/flowstate/cli.py +728 -0
  21. flowstatemcp-2.5.0/flowstate/cli_data.py +434 -0
  22. flowstatemcp-2.5.0/flowstate/cli_more.py +302 -0
  23. flowstatemcp-2.5.0/flowstate/cli_review.py +459 -0
  24. flowstatemcp-2.5.0/flowstate/cloudflared_check.py +49 -0
  25. flowstatemcp-2.5.0/flowstate/conflict_card_ui.py +286 -0
  26. flowstatemcp-2.5.0/flowstate/conflict_cards.py +1193 -0
  27. flowstatemcp-2.5.0/flowstate/conflict_reopen.py +320 -0
  28. flowstatemcp-2.5.0/flowstate/conflicts.py +420 -0
  29. flowstatemcp-2.5.0/flowstate/connections.py +33 -0
  30. flowstatemcp-2.5.0/flowstate/database.py +2191 -0
  31. flowstatemcp-2.5.0/flowstate/dbconn.py +308 -0
  32. flowstatemcp-2.5.0/flowstate/deletes.py +860 -0
  33. flowstatemcp-2.5.0/flowstate/embeddings.py +354 -0
  34. flowstatemcp-2.5.0/flowstate/erase.py +1047 -0
  35. flowstatemcp-2.5.0/flowstate/extras.py +71 -0
  36. flowstatemcp-2.5.0/flowstate/git_capture.py +157 -0
  37. flowstatemcp-2.5.0/flowstate/guard.py +224 -0
  38. flowstatemcp-2.5.0/flowstate/handoffs.py +168 -0
  39. flowstatemcp-2.5.0/flowstate/history.py +755 -0
  40. flowstatemcp-2.5.0/flowstate/history_page.py +276 -0
  41. flowstatemcp-2.5.0/flowstate/http_app.py +204 -0
  42. flowstatemcp-2.5.0/flowstate/identity.py +387 -0
  43. flowstatemcp-2.5.0/flowstate/install_name.py +140 -0
  44. flowstatemcp-2.5.0/flowstate/ledger.py +272 -0
  45. flowstatemcp-2.5.0/flowstate/legacy_args.py +65 -0
  46. flowstatemcp-2.5.0/flowstate/links.py +609 -0
  47. flowstatemcp-2.5.0/flowstate/markdown_io.py +242 -0
  48. flowstatemcp-2.5.0/flowstate/meaning.py +398 -0
  49. flowstatemcp-2.5.0/flowstate/memory_import.py +92 -0
  50. flowstatemcp-2.5.0/flowstate/migrations/001_auto_sessions.sql +16 -0
  51. flowstatemcp-2.5.0/flowstate/migrations/002_review_queue.sql +33 -0
  52. flowstatemcp-2.5.0/flowstate/migrations/003_links.sql +47 -0
  53. flowstatemcp-2.5.0/flowstate/migrations/004_review_mode.sql +24 -0
  54. flowstatemcp-2.5.0/flowstate/migrations/005_history.sql +21 -0
  55. flowstatemcp-2.5.0/flowstate/migrations/006_activity.sql +26 -0
  56. flowstatemcp-2.5.0/flowstate/migrations/007_meaning_index.sql +16 -0
  57. flowstatemcp-2.5.0/flowstate/migrations/008_git_capture.sql +12 -0
  58. flowstatemcp-2.5.0/flowstate/migrations/009_hard_rules.sql +13 -0
  59. flowstatemcp-2.5.0/flowstate/migrations/010_handoffs.sql +14 -0
  60. flowstatemcp-2.5.0/flowstate/migrations/011_record_meta.sql +13 -0
  61. flowstatemcp-2.5.0/flowstate/migrations/012_accountability.sql +71 -0
  62. flowstatemcp-2.5.0/flowstate/migrations/013_todo_claims.sql +36 -0
  63. flowstatemcp-2.5.0/flowstate/migrations/014_decision_conflicts.sql +41 -0
  64. flowstatemcp-2.5.0/flowstate/migrations/015_session_runs.sql +17 -0
  65. flowstatemcp-2.5.0/flowstate/migrations/016_write_receipts.sql +35 -0
  66. flowstatemcp-2.5.0/flowstate/migrations/017_run_views.sql +26 -0
  67. flowstatemcp-2.5.0/flowstate/migrations/018_run_views_upto.sql +4 -0
  68. flowstatemcp-2.5.0/flowstate/migrations/019_approvals.sql +47 -0
  69. flowstatemcp-2.5.0/flowstate/migrations/020_source_stamps.sql +10 -0
  70. flowstatemcp-2.5.0/flowstate/migrations/021_review_links.sql +11 -0
  71. flowstatemcp-2.5.0/flowstate/migrations/022_review_start_tickets.sql +10 -0
  72. flowstatemcp-2.5.0/flowstate/migrations/023_conflict_cards.sql +68 -0
  73. flowstatemcp-2.5.0/flowstate/migrations/024_rule_confirmations.sql +7 -0
  74. flowstatemcp-2.5.0/flowstate/migrations/025_confirmed_hash.sql +7 -0
  75. flowstatemcp-2.5.0/flowstate/migrations/__init__.py +149 -0
  76. flowstatemcp-2.5.0/flowstate/models.py +307 -0
  77. flowstatemcp-2.5.0/flowstate/names.py +157 -0
  78. flowstatemcp-2.5.0/flowstate/offsite.py +386 -0
  79. flowstatemcp-2.5.0/flowstate/offsite_crypto.py +272 -0
  80. flowstatemcp-2.5.0/flowstate/paths.py +265 -0
  81. flowstatemcp-2.5.0/flowstate/popup.py +238 -0
  82. flowstatemcp-2.5.0/flowstate/priority.py +66 -0
  83. flowstatemcp-2.5.0/flowstate/privatefs.py +476 -0
  84. flowstatemcp-2.5.0/flowstate/project_history.py +710 -0
  85. flowstatemcp-2.5.0/flowstate/provenance.py +696 -0
  86. flowstatemcp-2.5.0/flowstate/receipts.py +377 -0
  87. flowstatemcp-2.5.0/flowstate/relay/__init__.py +72 -0
  88. flowstatemcp-2.5.0/flowstate/relay/acme.py +271 -0
  89. flowstatemcp-2.5.0/flowstate/relay/api.py +164 -0
  90. flowstatemcp-2.5.0/flowstate/relay/cli.py +344 -0
  91. flowstatemcp-2.5.0/flowstate/relay/crypto.py +137 -0
  92. flowstatemcp-2.5.0/flowstate/relay/ct.py +118 -0
  93. flowstatemcp-2.5.0/flowstate/relay/runner.py +177 -0
  94. flowstatemcp-2.5.0/flowstate/relay/store.py +191 -0
  95. flowstatemcp-2.5.0/flowstate/relay/tunnel.py +413 -0
  96. flowstatemcp-2.5.0/flowstate/release_signers +14 -0
  97. flowstatemcp-2.5.0/flowstate/remote_auth.py +1219 -0
  98. flowstatemcp-2.5.0/flowstate/review.py +320 -0
  99. flowstatemcp-2.5.0/flowstate/review_apply.py +140 -0
  100. flowstatemcp-2.5.0/flowstate/review_card_ui.py +241 -0
  101. flowstatemcp-2.5.0/flowstate/review_cards.py +564 -0
  102. flowstatemcp-2.5.0/flowstate/review_mode.py +274 -0
  103. flowstatemcp-2.5.0/flowstate/review_page.py +1361 -0
  104. flowstatemcp-2.5.0/flowstate/review_phone.py +465 -0
  105. flowstatemcp-2.5.0/flowstate/review_tunnel.py +338 -0
  106. flowstatemcp-2.5.0/flowstate/routing.py +392 -0
  107. flowstatemcp-2.5.0/flowstate/rules.py +418 -0
  108. flowstatemcp-2.5.0/flowstate/safety.py +98 -0
  109. flowstatemcp-2.5.0/flowstate/saves.py +83 -0
  110. flowstatemcp-2.5.0/flowstate/scheduler.py +160 -0
  111. flowstatemcp-2.5.0/flowstate/schema.sql +713 -0
  112. flowstatemcp-2.5.0/flowstate/search.py +355 -0
  113. flowstatemcp-2.5.0/flowstate/secret_store.py +271 -0
  114. flowstatemcp-2.5.0/flowstate/secrets_scan.py +135 -0
  115. flowstatemcp-2.5.0/flowstate/selfupdate.py +1020 -0
  116. flowstatemcp-2.5.0/flowstate/server.py +1299 -0
  117. flowstatemcp-2.5.0/flowstate/session_contents.py +382 -0
  118. flowstatemcp-2.5.0/flowstate/session_records.py +629 -0
  119. flowstatemcp-2.5.0/flowstate/site_style.py +46 -0
  120. flowstatemcp-2.5.0/flowstate/stored.py +158 -0
  121. flowstatemcp-2.5.0/flowstate/story.py +442 -0
  122. flowstatemcp-2.5.0/flowstate/surface.py +173 -0
  123. flowstatemcp-2.5.0/flowstate/tasks.py +231 -0
  124. flowstatemcp-2.5.0/flowstate/term.py +40 -0
  125. flowstatemcp-2.5.0/flowstate/tool_labels.py +104 -0
  126. flowstatemcp-2.5.0/flowstate/tooldefs.py +383 -0
  127. flowstatemcp-2.5.0/flowstate/tools/__init__.py +118 -0
  128. flowstatemcp-2.5.0/flowstate/tools/core.py +545 -0
  129. flowstatemcp-2.5.0/flowstate/tools/files.py +416 -0
  130. flowstatemcp-2.5.0/flowstate/tools/git_ops.py +1081 -0
  131. flowstatemcp-2.5.0/flowstate/tools/intelligence.py +376 -0
  132. flowstatemcp-2.5.0/flowstate/tools/learning.py +282 -0
  133. flowstatemcp-2.5.0/flowstate/tools/problems.py +159 -0
  134. flowstatemcp-2.5.0/flowstate/tools/sessions.py +266 -0
  135. flowstatemcp-2.5.0/flowstate/tools/story.py +48 -0
  136. flowstatemcp-2.5.0/flowstate/tools/todos.py +79 -0
  137. flowstatemcp-2.5.0/flowstate/tools/utils.py +71 -0
  138. flowstatemcp-2.5.0/flowstate/tools/variables.py +345 -0
  139. flowstatemcp-2.5.0/flowstate/updates.py +482 -0
  140. flowstatemcp-2.5.0/flowstate/urls.py +7 -0
  141. flowstatemcp-2.5.0/flowstate/waiting.py +828 -0
  142. flowstatemcp-2.5.0/flowstate/who.py +302 -0
  143. flowstatemcp-2.5.0/pyproject.toml +62 -0
  144. flowstatemcp-2.5.0/requirements.txt +9 -0
  145. flowstatemcp-2.5.0/run_server.bat +2 -0
  146. flowstatemcp-2.5.0/run_server.sh +8 -0
@@ -0,0 +1,63 @@
1
+ # OS files
2
+ .DS_Store
3
+ Thumbs.db
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.egg-info/
9
+ dist/
10
+ *.egg
11
+ .venv/
12
+ .venv.pre-update/
13
+ venv/
14
+
15
+ # IDE
16
+ .idea/
17
+ .vscode/
18
+ *.swp
19
+ *.swo
20
+
21
+ # Environment / Secrets
22
+ .env
23
+ .env.local
24
+ *.env
25
+
26
+ # SQLite
27
+ *.db
28
+ *.db-journal
29
+ *.db-wal
30
+ *.db-shm
31
+ *.sqlite
32
+ *.sqlite-journal
33
+ *.sqlite-wal
34
+ *.sqlite-shm
35
+
36
+ # Backups
37
+ *.bak
38
+ *.backup.json
39
+
40
+ # Local test data and scratch (never commit DB copies)
41
+ testdata/
42
+ playground/
43
+ .pytest_cache/
44
+
45
+ # Local FlowState runtime files (never commit)
46
+ *.log
47
+ logs/
48
+ http-token.txt
49
+ *token*.txt
50
+ *.pem
51
+ *.key
52
+
53
+ # Relay agent state and other credentials (never commit)
54
+ /relay/
55
+ relay-state/
56
+ device-token*
57
+ acme-account*
58
+ *.p8
59
+ *.p12
60
+ *.pfx
61
+ master.key
62
+ *recovery*code*
63
+ *.crt
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.5
2
+ Name: flowstatemcp
3
+ Version: 2.5.0
4
+ Summary: Local-first shared project memory for AI apps (MCP server)
5
+ Project-URL: Homepage, https://flowstatemcp.com
6
+ Project-URL: Repository, https://github.com/dialectforge/FlowState
7
+ Project-URL: Changelog, https://github.com/dialectforge/FlowState/blob/main/CHANGELOG.md
8
+ Project-URL: Hosted, https://flowstatemcp.com/hosted
9
+ Author: John Martin
10
+ License-Expression: MIT
11
+ Keywords: ai,flowstatemcp,local-first,mcp,memory,model-context-protocol,sqlite
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: mcp<2,>=1.30
24
+ Requires-Dist: pydantic>=2.0.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: httpx2; extra == 'dev'
27
+ Requires-Dist: httpx>=0.27; extra == 'dev'
28
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
29
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
30
+ Provides-Extra: embeddings
31
+ Requires-Dist: sentence-transformers>=2.2.0; extra == 'embeddings'
32
+ Requires-Dist: sqlite-vec>=0.1.0; extra == 'embeddings'
33
+ Provides-Extra: offsite
34
+ Requires-Dist: cryptography>=42; extra == 'offsite'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # flowstatemcp
38
+
39
+ Local-first shared project memory for AI apps. One SQLite file on your computer holds the
40
+ todos, changes, decisions, problems, and lessons your AI apps share. The import package is
41
+ `flowstate`. The PyPI name is `flowstatemcp`, because `flowstate` is a different project.
42
+
43
+ - Site: https://flowstatemcp.com
44
+ - Hosted (no install): https://flowstatemcp.com/hosted
45
+ - Source: https://github.com/dialectforge/FlowState
46
+
47
+ ## Install
48
+
49
+ You need Python 3.11+ and [uv](https://docs.astral.sh/uv/getting-started/installation/) for the
50
+ one-command form.
51
+
52
+ ```bash
53
+ uvx flowstatemcp
54
+ ```
55
+
56
+ That starts the stdio MCP server. These do the same thing:
57
+
58
+ ```bash
59
+ pipx run flowstatemcp
60
+ pip install flowstatemcp
61
+ flowstatemcp
62
+ flowstate
63
+ ```
64
+
65
+ `pip install flowstate` does not install this project.
66
+
67
+ The git clone is the fallback when you want `setup_flowstate.py` to write app configs, schedule
68
+ backups, and install the FlowState skill. See the repository
69
+ [README](https://github.com/dialectforge/FlowState/blob/main/README.md).
70
+
71
+ ## Run
72
+
73
+ ```bash
74
+ flowstatemcp # stdio (what desktop apps start)
75
+ flowstate # the same server
76
+ python -m flowstate --transport http --host 127.0.0.1 --port 8766
77
+ flowstate --help
78
+ ```
79
+
80
+ The database is `FLOWSTATE_DB` if set, otherwise the per-OS default in the repository AGENTS.md.
81
+
82
+ A plain `pip install flowstatemcp` (or of a built wheel) includes the base schema
83
+ (`flowstate/schema.sql`), the migrations, and `release_signers`. `flowstate update` only works
84
+ in a git clone set up by `setup_flowstate.py`. Like every FlowState 2.4.0 or later, it checks
85
+ GitHub once a day for a newer release (nothing about you or your projects is sent);
86
+ `flowstate update check off` or `FLOWSTATE_UPDATE_CHECK=0` turns that off.
87
+
88
+ Search by meaning is optional; see "Honest limits" in the repository README.
@@ -0,0 +1,52 @@
1
+ # flowstatemcp
2
+
3
+ Local-first shared project memory for AI apps. One SQLite file on your computer holds the
4
+ todos, changes, decisions, problems, and lessons your AI apps share. The import package is
5
+ `flowstate`. The PyPI name is `flowstatemcp`, because `flowstate` is a different project.
6
+
7
+ - Site: https://flowstatemcp.com
8
+ - Hosted (no install): https://flowstatemcp.com/hosted
9
+ - Source: https://github.com/dialectforge/FlowState
10
+
11
+ ## Install
12
+
13
+ You need Python 3.11+ and [uv](https://docs.astral.sh/uv/getting-started/installation/) for the
14
+ one-command form.
15
+
16
+ ```bash
17
+ uvx flowstatemcp
18
+ ```
19
+
20
+ That starts the stdio MCP server. These do the same thing:
21
+
22
+ ```bash
23
+ pipx run flowstatemcp
24
+ pip install flowstatemcp
25
+ flowstatemcp
26
+ flowstate
27
+ ```
28
+
29
+ `pip install flowstate` does not install this project.
30
+
31
+ The git clone is the fallback when you want `setup_flowstate.py` to write app configs, schedule
32
+ backups, and install the FlowState skill. See the repository
33
+ [README](https://github.com/dialectforge/FlowState/blob/main/README.md).
34
+
35
+ ## Run
36
+
37
+ ```bash
38
+ flowstatemcp # stdio (what desktop apps start)
39
+ flowstate # the same server
40
+ python -m flowstate --transport http --host 127.0.0.1 --port 8766
41
+ flowstate --help
42
+ ```
43
+
44
+ The database is `FLOWSTATE_DB` if set, otherwise the per-OS default in the repository AGENTS.md.
45
+
46
+ A plain `pip install flowstatemcp` (or of a built wheel) includes the base schema
47
+ (`flowstate/schema.sql`), the migrations, and `release_signers`. `flowstate update` only works
48
+ in a git clone set up by `setup_flowstate.py`. Like every FlowState 2.4.0 or later, it checks
49
+ GitHub once a day for a newer release (nothing about you or your projects is sent);
50
+ `flowstate update check off` or `FLOWSTATE_UPDATE_CHECK=0` turns that off.
51
+
52
+ Search by meaning is optional; see "Honest limits" in the repository README.
@@ -0,0 +1,34 @@
1
+ # -*- coding: utf-8 -*-
2
+ """FlowState - Development memory system.
3
+
4
+ A project-centric memory system with:
5
+ - Hierarchical organization (Project -> Component -> Changes/Problems/Solutions)
6
+ - Decision tree tracking (what failed, what worked, why)
7
+ - Conversation logging (what prompts led to what outcomes)
8
+ - Semantic search with embeddings
9
+ - Story generation for project narratives
10
+ """
11
+
12
+ __version__ = "2.5.0"
13
+
14
+ from .database import Database, init_db
15
+ from .models import (
16
+ Project, Component, Change, Problem, SolutionAttempt,
17
+ Solution, Todo, Conversation, Learning, Session, ProjectContext
18
+ )
19
+
20
+ __all__ = [
21
+ "Database",
22
+ "init_db",
23
+ "Project",
24
+ "Component",
25
+ "Change",
26
+ "Problem",
27
+ "SolutionAttempt",
28
+ "Solution",
29
+ "Todo",
30
+ "Conversation",
31
+ "Learning",
32
+ "Session",
33
+ "ProjectContext",
34
+ ]
@@ -0,0 +1,7 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Entry point for python -m flowstate.server"""
3
+
4
+ from .server import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,224 @@
1
+ """Which app did what, and a pause switch per app (v2 Phase D, migration 006).
2
+
3
+ Every tool call adds one row: time, app, tool, action, project id, ok or the error code,
4
+ and (migration 012) the model, machine and identity source (identity.py) plus the type and
5
+ id of a record the call created. Never the arguments or any text, so nothing secret can
6
+ land here. Rows older than
7
+ FLOWSTATE_ACTIVITY_DAYS (default 90) are pruned now and then.
8
+
9
+ The pause switch belongs to the user: it is set from the command line or the review page,
10
+ never through an MCP tool. A paused app can still read; its writes get "paused by the user".
11
+ """
12
+
13
+ import os
14
+ import random
15
+ import sqlite3
16
+ from datetime import datetime, timedelta, timezone
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ from . import dbconn
21
+ from . import identity
22
+
23
+
24
+ def _now() -> str:
25
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
26
+
27
+
28
+ def _conn(db_path: Path) -> sqlite3.Connection:
29
+ conn = dbconn.connect(str(db_path), timeout=5.0)
30
+ conn.row_factory = sqlite3.Row
31
+ conn.execute("PRAGMA busy_timeout = 5000")
32
+ return conn
33
+
34
+
35
+ def available(conn) -> bool:
36
+ return conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' "
37
+ "AND name='activity_log'").fetchone() is not None
38
+
39
+
40
+ def keep_days() -> int:
41
+ try:
42
+ return max(1, int(os.environ.get("FLOWSTATE_ACTIVITY_DAYS", "90")))
43
+ except ValueError:
44
+ return 90
45
+
46
+
47
+ def record(db_path: Path, app: str, tool: str, action: Optional[str], project_id: Optional[int],
48
+ write: bool, ok: bool, error: Optional[str] = None, ms: Optional[int] = None,
49
+ caller: Optional[str] = None, record: Optional[tuple] = None) -> None:
50
+ """One row per call, with the caller's model / machine / identity source (migration
51
+ 012) and, for a write that created a record, its (record_type, id)."""
52
+ conn = _conn(db_path)
53
+ try:
54
+ if not available(conn):
55
+ return
56
+ row = {"at": _now(), "app": (app or "unknown")[:80],
57
+ "caller": (caller or "")[:80] or None, "tool": tool[:60],
58
+ "action": (action or None) and str(action)[:40], "project_id": project_id,
59
+ "write": int(bool(write)), "ok": int(bool(ok)),
60
+ "error": (error or None) and str(error)[:60], "ms": ms}
61
+ if record and identity.has_column(conn, "activity_log", "record_type"):
62
+ row["record_type"], row["record_id"] = str(record[0])[:20], int(record[1])
63
+ identity.insert(conn, "activity_log", row, app)
64
+ if random.random() < 0.01: # prune now and then
65
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=keep_days())).strftime(
66
+ "%Y-%m-%d %H:%M:%S")
67
+ conn.execute("DELETE FROM activity_log WHERE at < ?", (cutoff,))
68
+ conn.commit()
69
+ finally:
70
+ conn.close()
71
+
72
+
73
+ def is_paused(db_path: Path, app: str) -> bool:
74
+ conn = _conn(db_path)
75
+ try:
76
+ if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' "
77
+ "AND name='app_controls'").fetchone():
78
+ return False
79
+ row = conn.execute("SELECT paused FROM app_controls WHERE app = ?",
80
+ ((app or "unknown")[:80],)).fetchone()
81
+ return bool(row and row[0])
82
+ finally:
83
+ conn.close()
84
+
85
+
86
+ def set_paused(db_path: Path, app: str, paused: bool, by: str) -> dict:
87
+ app = (app or "").strip()
88
+ if not app:
89
+ raise ValueError("name the app (as shown by `flowstate apps list`)")
90
+ conn = _conn(db_path)
91
+ try:
92
+ conn.execute("""INSERT INTO app_controls (app, paused, changed_at, changed_by)
93
+ VALUES (?, ?, ?, ?) ON CONFLICT(app) DO UPDATE SET paused = excluded.paused,
94
+ changed_at = excluded.changed_at, changed_by = excluded.changed_by""",
95
+ (app[:80], int(paused), _now(), by[:40]))
96
+ conn.commit()
97
+ return {"app": app, "paused": paused}
98
+ finally:
99
+ conn.close()
100
+
101
+
102
+ def pause_names(db_path: Path, app: str, paused: bool = True) -> list:
103
+ """The names Pause / Resume on `app` set: the app's own name and every key, token or
104
+ sign-in it came in on (apps()' "via"). An app's name is its own claim, so pausing only
105
+ that name would let it come back as "Grok Bot 2" on the same key; the key's name it can't
106
+ change. Resume (paused=False) leaves out a key that another paused app also uses
107
+ (still_paused_by): resuming one app must not let the other dodge its pause by renaming
108
+ itself (audit N-F18)."""
109
+ app = (app or "").strip()
110
+ rows = apps(db_path)
111
+ row = next((a for a in rows if a["app"] == app), None)
112
+ names = [app] + [v for v in (row or {}).get("via", []) if v.casefold() != app.casefold()]
113
+ if paused:
114
+ return names
115
+ held = still_paused_by(db_path, app, rows)
116
+ return [n for n in names if n not in held]
117
+
118
+
119
+ def still_paused_by(db_path: Path, app: str, rows: Optional[list] = None) -> dict:
120
+ """{key / sign-in name: [other apps]} for the keys `app` uses that stay paused when `app`
121
+ is resumed, because another app on the same key is paused by its own name."""
122
+ app = (app or "").strip()
123
+ rows = rows if rows is not None else apps(db_path)
124
+ row = next((a for a in rows if a["app"] == app), None)
125
+ mine = {v for v in (row or {}).get("via", []) if v.casefold() != app.casefold()}
126
+ if not mine:
127
+ return {}
128
+ own = _own_pauses(db_path)
129
+ held: dict = {}
130
+ for other in rows:
131
+ if other["app"] == app or not own.get(other["app"]):
132
+ continue
133
+ for v in mine.intersection(other.get("via") or []):
134
+ if v.casefold() != other["app"].casefold():
135
+ held.setdefault(v, []).append(other["app"])
136
+ return held
137
+
138
+
139
+ def _own_pauses(db_path: Path) -> dict:
140
+ """{name: paused} as the user set it, name by name (no key inheritance)."""
141
+ conn = _conn(db_path)
142
+ try:
143
+ if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' "
144
+ "AND name='app_controls'").fetchone():
145
+ return {}
146
+ return {r["app"]: bool(r["paused"]) for r in conn.execute(
147
+ "SELECT app, paused FROM app_controls")}
148
+ finally:
149
+ conn.close()
150
+
151
+
152
+ def held_text(app: str, key: str, others: list) -> str:
153
+ return (f"{key!r} stays paused: {', '.join(repr(o) for o in others)} also use(s) it and "
154
+ f"{'is' if len(others) == 1 else 'are'} paused, so {app!r}'s writes through it are "
155
+ f"still refused. Resume {'that app' if len(others) == 1 else 'those apps'} too, or "
156
+ f"give {app!r} its own key or token.")
157
+
158
+
159
+ def set_paused_all(db_path: Path, app: str, paused: bool, by: str) -> list:
160
+ """set_paused for each of pause_names(app, paused); returns the names."""
161
+ names = pause_names(db_path, app, paused)
162
+ for n in names:
163
+ set_paused(db_path, n, paused, by)
164
+ return names
165
+
166
+
167
+ def apps(db_path: Path) -> list[dict]:
168
+ """Every app seen (or paused), with last use and counts."""
169
+ conn = _conn(db_path)
170
+ try:
171
+ if not available(conn):
172
+ return []
173
+ rows = {r["app"]: dict(r) for r in conn.execute(
174
+ """SELECT app, MAX(at) last_seen, COUNT(*) calls, SUM(write) writes,
175
+ SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END) errors
176
+ FROM activity_log GROUP BY app""")}
177
+ controls = {}
178
+ for r in conn.execute("SELECT app, paused, changed_at FROM app_controls"):
179
+ controls[r["app"]] = bool(r["paused"])
180
+ rows.setdefault(r["app"], {"app": r["app"], "last_seen": None, "calls": 0,
181
+ "writes": 0, "errors": 0})
182
+ rows[r["app"]]["paused"] = bool(r["paused"])
183
+ rows[r["app"]]["paused_changed_at"] = r["changed_at"]
184
+ if identity.has_column(conn, "activity_log", "identity_source"):
185
+ for r in conn.execute("SELECT DISTINCT app, machine, identity_source FROM activity_log "
186
+ "WHERE identity_source IN ('oauth_client', 'remote_token', 'app_key')"):
187
+ signed = identity.via(r["machine"], r["identity_source"])
188
+ if signed and r["app"] in rows and signed not in rows[r["app"]].setdefault("via", []):
189
+ rows[r["app"]]["via"].append(signed)
190
+ for v in rows.values():
191
+ v.setdefault("paused", False)
192
+ v["via"] = sorted(v.get("via", []))
193
+ # writes are refused when the key or sign-in it uses is paused too
194
+ v["paused"] = v["paused"] or any(controls.get(x) for x in v["via"])
195
+ return sorted(rows.values(), key=lambda v: v["last_seen"] or "", reverse=True)
196
+ finally:
197
+ conn.close()
198
+
199
+
200
+ def app_label(a: dict) -> str:
201
+ """An apps() row for people: 'Grok Bot (via Cursor)' when it came in on keys, tokens or
202
+ sign-ins of another name (pausing either name stops it), else the name."""
203
+ others = [v for v in a.get("via") or [] if v.casefold() != (a["app"] or "").casefold()]
204
+ return f"{a['app']} (via {', '.join(others)})" if others else a["app"]
205
+
206
+
207
+ def recent(db_path: Path, app: Optional[str] = None, limit: int = 50) -> list[dict]:
208
+ conn = _conn(db_path)
209
+ try:
210
+ if not available(conn):
211
+ return []
212
+ who = ", ".join(f"a.{c}" if identity.has_column(conn, "activity_log", c) else f"NULL AS {c}"
213
+ for c in ("machine", "identity_source"))
214
+ sql = f"""SELECT a.at, a.app, a.tool, a.action, a.project_id, p.name project, a.write, a.ok,
215
+ a.error, {who} FROM activity_log a LEFT JOIN projects p ON p.id = a.project_id"""
216
+ params: list = []
217
+ if app:
218
+ sql += " WHERE a.app = ?"
219
+ params.append(app)
220
+ sql += " ORDER BY a.id DESC LIMIT ?"
221
+ params.append(max(1, min(int(limit), 500)))
222
+ return [dict(r) for r in conn.execute(sql, params)]
223
+ finally:
224
+ conn.close()