flowstatemcp 2.5.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 (143) hide show
  1. flowstate/__init__.py +34 -0
  2. flowstate/__main__.py +7 -0
  3. flowstate/activity.py +224 -0
  4. flowstate/agent_guide.py +251 -0
  5. flowstate/app_keys.py +141 -0
  6. flowstate/approvals.py +974 -0
  7. flowstate/approve_page.py +565 -0
  8. flowstate/ask.py +107 -0
  9. flowstate/autosession.py +480 -0
  10. flowstate/backup.py +201 -0
  11. flowstate/brief.py +420 -0
  12. flowstate/card_offer.py +208 -0
  13. flowstate/card_taps.py +165 -0
  14. flowstate/card_trust.py +127 -0
  15. flowstate/claims.py +461 -0
  16. flowstate/cleanup.py +619 -0
  17. flowstate/cli.py +728 -0
  18. flowstate/cli_data.py +434 -0
  19. flowstate/cli_more.py +302 -0
  20. flowstate/cli_review.py +459 -0
  21. flowstate/cloudflared_check.py +49 -0
  22. flowstate/conflict_card_ui.py +286 -0
  23. flowstate/conflict_cards.py +1193 -0
  24. flowstate/conflict_reopen.py +320 -0
  25. flowstate/conflicts.py +420 -0
  26. flowstate/connections.py +33 -0
  27. flowstate/database.py +2191 -0
  28. flowstate/dbconn.py +308 -0
  29. flowstate/deletes.py +860 -0
  30. flowstate/embeddings.py +354 -0
  31. flowstate/erase.py +1047 -0
  32. flowstate/extras.py +71 -0
  33. flowstate/git_capture.py +157 -0
  34. flowstate/guard.py +224 -0
  35. flowstate/handoffs.py +168 -0
  36. flowstate/history.py +755 -0
  37. flowstate/history_page.py +276 -0
  38. flowstate/http_app.py +204 -0
  39. flowstate/identity.py +387 -0
  40. flowstate/install_name.py +140 -0
  41. flowstate/ledger.py +272 -0
  42. flowstate/legacy_args.py +65 -0
  43. flowstate/links.py +609 -0
  44. flowstate/markdown_io.py +242 -0
  45. flowstate/meaning.py +398 -0
  46. flowstate/memory_import.py +92 -0
  47. flowstate/migrations/001_auto_sessions.sql +16 -0
  48. flowstate/migrations/002_review_queue.sql +33 -0
  49. flowstate/migrations/003_links.sql +47 -0
  50. flowstate/migrations/004_review_mode.sql +24 -0
  51. flowstate/migrations/005_history.sql +21 -0
  52. flowstate/migrations/006_activity.sql +26 -0
  53. flowstate/migrations/007_meaning_index.sql +16 -0
  54. flowstate/migrations/008_git_capture.sql +12 -0
  55. flowstate/migrations/009_hard_rules.sql +13 -0
  56. flowstate/migrations/010_handoffs.sql +14 -0
  57. flowstate/migrations/011_record_meta.sql +13 -0
  58. flowstate/migrations/012_accountability.sql +71 -0
  59. flowstate/migrations/013_todo_claims.sql +36 -0
  60. flowstate/migrations/014_decision_conflicts.sql +41 -0
  61. flowstate/migrations/015_session_runs.sql +17 -0
  62. flowstate/migrations/016_write_receipts.sql +35 -0
  63. flowstate/migrations/017_run_views.sql +26 -0
  64. flowstate/migrations/018_run_views_upto.sql +4 -0
  65. flowstate/migrations/019_approvals.sql +47 -0
  66. flowstate/migrations/020_source_stamps.sql +10 -0
  67. flowstate/migrations/021_review_links.sql +11 -0
  68. flowstate/migrations/022_review_start_tickets.sql +10 -0
  69. flowstate/migrations/023_conflict_cards.sql +68 -0
  70. flowstate/migrations/024_rule_confirmations.sql +7 -0
  71. flowstate/migrations/025_confirmed_hash.sql +7 -0
  72. flowstate/migrations/__init__.py +149 -0
  73. flowstate/models.py +307 -0
  74. flowstate/names.py +157 -0
  75. flowstate/offsite.py +386 -0
  76. flowstate/offsite_crypto.py +272 -0
  77. flowstate/paths.py +265 -0
  78. flowstate/popup.py +238 -0
  79. flowstate/priority.py +66 -0
  80. flowstate/privatefs.py +476 -0
  81. flowstate/project_history.py +710 -0
  82. flowstate/provenance.py +696 -0
  83. flowstate/receipts.py +377 -0
  84. flowstate/relay/__init__.py +72 -0
  85. flowstate/relay/acme.py +271 -0
  86. flowstate/relay/api.py +164 -0
  87. flowstate/relay/cli.py +344 -0
  88. flowstate/relay/crypto.py +137 -0
  89. flowstate/relay/ct.py +118 -0
  90. flowstate/relay/runner.py +177 -0
  91. flowstate/relay/store.py +191 -0
  92. flowstate/relay/tunnel.py +413 -0
  93. flowstate/release_signers +14 -0
  94. flowstate/remote_auth.py +1219 -0
  95. flowstate/review.py +320 -0
  96. flowstate/review_apply.py +140 -0
  97. flowstate/review_card_ui.py +241 -0
  98. flowstate/review_cards.py +564 -0
  99. flowstate/review_mode.py +274 -0
  100. flowstate/review_page.py +1361 -0
  101. flowstate/review_phone.py +465 -0
  102. flowstate/review_tunnel.py +338 -0
  103. flowstate/routing.py +392 -0
  104. flowstate/rules.py +418 -0
  105. flowstate/safety.py +98 -0
  106. flowstate/saves.py +83 -0
  107. flowstate/scheduler.py +160 -0
  108. flowstate/schema.sql +713 -0
  109. flowstate/search.py +355 -0
  110. flowstate/secret_store.py +271 -0
  111. flowstate/secrets_scan.py +135 -0
  112. flowstate/selfupdate.py +1020 -0
  113. flowstate/server.py +1299 -0
  114. flowstate/session_contents.py +382 -0
  115. flowstate/session_records.py +629 -0
  116. flowstate/site_style.py +46 -0
  117. flowstate/stored.py +158 -0
  118. flowstate/story.py +442 -0
  119. flowstate/surface.py +173 -0
  120. flowstate/tasks.py +231 -0
  121. flowstate/term.py +40 -0
  122. flowstate/tool_labels.py +104 -0
  123. flowstate/tooldefs.py +383 -0
  124. flowstate/tools/__init__.py +118 -0
  125. flowstate/tools/core.py +545 -0
  126. flowstate/tools/files.py +416 -0
  127. flowstate/tools/git_ops.py +1081 -0
  128. flowstate/tools/intelligence.py +376 -0
  129. flowstate/tools/learning.py +282 -0
  130. flowstate/tools/problems.py +159 -0
  131. flowstate/tools/sessions.py +266 -0
  132. flowstate/tools/story.py +48 -0
  133. flowstate/tools/todos.py +79 -0
  134. flowstate/tools/utils.py +71 -0
  135. flowstate/tools/variables.py +345 -0
  136. flowstate/updates.py +482 -0
  137. flowstate/urls.py +7 -0
  138. flowstate/waiting.py +828 -0
  139. flowstate/who.py +302 -0
  140. flowstatemcp-2.5.0.dist-info/METADATA +88 -0
  141. flowstatemcp-2.5.0.dist-info/RECORD +143 -0
  142. flowstatemcp-2.5.0.dist-info/WHEEL +4 -0
  143. flowstatemcp-2.5.0.dist-info/entry_points.txt +3 -0
flowstate/ask.py ADDED
@@ -0,0 +1,107 @@
1
+ """ask: a plain question in, a short answer out, with the ids of the records it came from
2
+ (v2 Phase D, E7).
3
+
4
+ Local only: built on meaning.search (meaning + keywords when a local model is installed,
5
+ keywords otherwise), plus the project's hard rules and open handoff notes. The answer is
6
+ extractive: the best-matching sentence of each top record, quoted with its id. FlowState
7
+ does not make anything up; if nothing matches, it says the memory has nothing on it.
8
+ """
9
+
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from . import handoffs, links, meaning, provenance, rules
15
+
16
+ # Question words that carry no topic ("what port does the server use" -> "port server use").
17
+ _QWORDS = set("""what which who whom whose where when why how does do did is are was were can
18
+ could should would will shall may might we our us i my me you your it its there their they
19
+ about tell know any some have has had please the a an of to in on for with and or""".split())
20
+ _SPLIT = re.compile(r"(?<=[.!?;])\s+|\n+")
21
+ MAX_ANSWER_ITEMS = 3
22
+
23
+
24
+ def _topic(question: str) -> str:
25
+ words = re.findall(r"[A-Za-z0-9_][A-Za-z0-9_.:/\\-]*", question or "")
26
+ kept = [w for w in words if w.lower() not in _QWORDS]
27
+ return " ".join(kept) or (question or "")
28
+
29
+
30
+ def _best_sentence(text: str, topic: str, width: int = 220) -> str:
31
+ stems = {meaning._stem(w) for w in meaning._WORD.findall(topic.lower()) if w not in meaning._STOP}
32
+ best, score = "", -1
33
+ for s in _SPLIT.split(text or ""):
34
+ s = " ".join(s.split())
35
+ if not s:
36
+ continue
37
+ toks = {meaning._stem(w) for w in meaning._WORD.findall(s.lower())}
38
+ sc = sum(1 for w in stems if any(t.startswith(w) for t in toks))
39
+ if sc > score:
40
+ best, score = s, sc
41
+ return links._one_line(best, width)
42
+
43
+
44
+ def _extras(conn, pid: Optional[int]) -> list[dict]:
45
+ """Hard rules and open handoff notes as searchable pseudo-records."""
46
+ if pid is None:
47
+ return []
48
+ out = [{"type": "rule", "id": r["id"], "project_id": pid, "title": r["rule"], "body": "",
49
+ "status": None} for r in rules.active(conn, pid)]
50
+ out += [{"type": "handoff", "id": n["id"], "project_id": pid, "title": n["where_i_was"],
51
+ "body": n.get("next_step") or "", "status": None} for n in handoffs.open_notes(conn, pid)]
52
+ return out
53
+
54
+
55
+ def ask(db_path: Path, question: str, pid: Optional[int] = None, limit: int = 5) -> dict:
56
+ question = " ".join((question or "").split())
57
+ if not question:
58
+ return {"error": "question_required", "message": "Ask a question, e.g. "
59
+ "ask(question=\"which port does the LAN server use?\", project=\"FlowState\")."}
60
+ limit = max(1, min(int(limit or 5), 10))
61
+ topic = _topic(question)
62
+ found = meaning.search(db_path, topic, pid, None, limit * 2, "auto")
63
+ conn = links.connect(db_path)
64
+ try:
65
+ replaced = links.superseded_decisions(conn) if links.available(conn) else {}
66
+ hits = [r for r in found["results"]
67
+ if not (r["content_type"] == "decision" and r["content_id"] in replaced)]
68
+ extra = meaning.keyword_hits(_extras(conn, pid), topic, 3)
69
+ names = {pid: links.project_name(conn, pid)} if pid is not None else {}
70
+ sources = []
71
+ for _s, r in extra:
72
+ sources.append({"type": r["type"], "id": r["id"], "project": names.get(pid),
73
+ "text": _best_sentence(f"{r['title']}. {r['body']}", topic)})
74
+ for r in hits[:limit]:
75
+ text = f"{r['title']}. {r.get('snippet') or ''}"
76
+ item = {"type": r["content_type"], "id": r["content_id"], "project": r["project"],
77
+ "text": _best_sentence(text, topic), "matched_by": r["matched_by"]}
78
+ if r.get("status"):
79
+ item["status"] = r["status"]
80
+ sources.append(item)
81
+ metas: dict = {}
82
+ for t in {s["type"] for s in sources if s["type"] in provenance.TABLES}:
83
+ metas[t] = provenance.get(conn, t, [s["id"] for s in sources if s["type"] == t])
84
+ for s in sources:
85
+ m = metas.get(s["type"], {}).get(s["id"])
86
+ if m:
87
+ s.update(m)
88
+ finally:
89
+ conn.close()
90
+
91
+ if not sources:
92
+ return {"question": question, "answer": "FlowState has nothing on this"
93
+ + (" in this project" if pid is not None else "") + ". (Nothing matched; "
94
+ "it does not guess.)", "sources": [], "mode": found["mode"],
95
+ **({"note": found["note"]} if found.get("note") else {})}
96
+ lines = []
97
+ for s in sources[:MAX_ANSWER_ITEMS + len(extra)]:
98
+ label = {"rule": f"hard rule R{s['id']}", "handoff": f"handoff H{s['id']}"}.get(
99
+ s["type"], f"{s['type']} #{s['id']}")
100
+ where = f", {s['project']}" if s.get("project") and pid is None else ""
101
+ lines.append(f"- {s['text']} [{label}{where}{provenance.tag(s).replace(' (', ', ').rstrip(')')}]")
102
+ answer = ("From FlowState (best matches, quoted; check the records for detail):\n"
103
+ + "\n".join(lines))
104
+ out = {"question": question, "answer": answer, "sources": sources, "mode": found["mode"]}
105
+ if found.get("note"):
106
+ out["note"] = found["note"]
107
+ return out
@@ -0,0 +1,480 @@
1
+ """Implicit sessions: the server opens a FlowState session on a client's first call
2
+ for a project and closes it after an idle timeout, on shutdown, or when the agent ends
3
+ it explicitly. Works the same for stdio and HTTP; state lives in SQLite so a session
4
+ left behind by a crashed process is closed by the next reaper run of any process.
5
+
6
+ Nothing in here may break a tool call: callers wrap every method in try/except.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import sqlite3
12
+ import uuid
13
+ from datetime import datetime, timedelta, timezone
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from . import dbconn
18
+
19
+ # Kept identical to the v2 block at the end of database/schema.sql (a test checks it).
20
+ AUTO_SESSIONS_DDL = """CREATE TABLE IF NOT EXISTS auto_sessions (
21
+ session_id INTEGER PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
22
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
23
+ client TEXT NOT NULL,
24
+ server_instance TEXT NOT NULL,
25
+ opened_at TEXT NOT NULL, -- UTC, 'YYYY-MM-DD HH:MM:SS'
26
+ last_activity TEXT NOT NULL, -- UTC
27
+ call_count INTEGER NOT NULL DEFAULT 0,
28
+ events TEXT NOT NULL DEFAULT '[]', -- JSON list of short event labels (capped)
29
+ closed_at TEXT,
30
+ close_reason TEXT -- idle | shutdown | stale | explicit
31
+ );
32
+ CREATE INDEX IF NOT EXISTS idx_auto_sessions_open ON auto_sessions(closed_at, client, project_id);"""
33
+
34
+ AUTO_TAG = "[auto-captured, needs review]"
35
+ OFF_TAG = "[auto summary, review off]"
36
+ SKIP_TAG = "[auto summary, housekeeping]" # saved nothing new: not queued
37
+ MAX_EVENTS = 60
38
+ MAX_SUMMARY = 600
39
+ TS_FMT = "%Y-%m-%d %H:%M:%S"
40
+
41
+
42
+ def _utcnow() -> datetime:
43
+ return datetime.now(timezone.utc).replace(tzinfo=None)
44
+
45
+
46
+ def _ts(dt: datetime) -> str:
47
+ return dt.strftime(TS_FMT)
48
+
49
+
50
+ def _add(events: list, event: str) -> list:
51
+ """`events` plus `event`, at most MAX_EVENTS long: the oldest read goes first, so a long
52
+ session never loses what it saved to its reads (is_housekeeping, build_summary)."""
53
+ events = events + [event[:120]]
54
+ while len(events) > MAX_EVENTS:
55
+ del events[next((i for i, e in enumerate(events) if e.startswith("read:")), 0)]
56
+ return events
57
+
58
+
59
+ def _parse(ts: str) -> datetime:
60
+ return datetime.strptime(ts[:19].replace("T", " "), TS_FMT)
61
+
62
+
63
+ def idle_minutes_from_env() -> float:
64
+ try:
65
+ return float(os.environ.get("FLOWSTATE_IDLE_MINUTES", "120"))
66
+ except ValueError:
67
+ return 120.0
68
+
69
+
70
+ def ensure_schema(db_path: Path) -> None:
71
+ """Additive migration: create auto_sessions if missing. Never alters existing data."""
72
+ conn = dbconn.connect(str(db_path), timeout=5.0)
73
+ try:
74
+ conn.execute("PRAGMA busy_timeout = 5000")
75
+ conn.executescript(AUTO_SESSIONS_DDL)
76
+ conn.commit()
77
+ finally:
78
+ conn.close()
79
+
80
+
81
+ def _has_runs(conn) -> bool:
82
+ return conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' "
83
+ "AND name='session_runs'").fetchone() is not None
84
+
85
+
86
+ _OPEN = """SELECT a.* FROM auto_sessions a WHERE a.closed_at IS NULL AND a.client = ?
87
+ AND a.project_id = ?"""
88
+ # 2.4.0 (migration 015): one session per connection; conn_key None = the old matching
89
+ _BY_CONN = " AND a.session_id IN (SELECT session_id FROM session_runs WHERE conn_key = ?)"
90
+ _LATEST = " ORDER BY a.session_id DESC LIMIT 1"
91
+
92
+
93
+ class AutoSessions:
94
+ """Tracks implicit sessions keyed by (client, project, connection). The connection
95
+ (`conn_key`: identity.Identity.session, i.e. the stdio server process or the HTTP MCP
96
+ session) keeps two windows of one app apart; without one (or on a database before
97
+ migration 015) sessions are keyed by (client, project) as before."""
98
+
99
+ def __init__(self, db_path: Path, idle_minutes: Optional[float] = None,
100
+ instance: Optional[str] = None):
101
+ self.db_path = Path(db_path)
102
+ self.idle = timedelta(minutes=idle_minutes if idle_minutes is not None
103
+ else idle_minutes_from_env())
104
+ self.instance = instance or uuid.uuid4().hex[:12]
105
+ self._last: dict = {} # conn_key -> the session it touched last (this process)
106
+
107
+ def _conn(self) -> sqlite3.Connection:
108
+ conn = dbconn.connect(str(self.db_path), timeout=5.0)
109
+ conn.row_factory = sqlite3.Row
110
+ conn.execute("PRAGMA busy_timeout = 5000")
111
+ conn.execute("PRAGMA foreign_keys = ON")
112
+ return conn
113
+
114
+ # ---------------------------------------------------------------- touch
115
+ def _find(self, conn, client: str, project_id: int, conn_key: Optional[str]):
116
+ if conn_key and _has_runs(conn):
117
+ return conn.execute(_OPEN + _BY_CONN + _LATEST,
118
+ (client, project_id, conn_key)).fetchone()
119
+ return conn.execute(_OPEN + _LATEST, (client, project_id)).fetchone()
120
+
121
+ def touch(self, client: str, project_id: int, event: Optional[str] = None,
122
+ conn_key: Optional[str] = None, ident=None) -> int:
123
+ """Record activity; open a session if none is open for this connection. Returns
124
+ sessions.id. `ident` (identity.Identity) is recorded with a new session."""
125
+ client = (client or "unknown")[:80]
126
+ conn_key = (conn_key or "")[:80] or None
127
+ now = _utcnow()
128
+ conn = self._conn()
129
+ try:
130
+ conn.execute("BEGIN IMMEDIATE")
131
+ row = self._find(conn, client, project_id, conn_key)
132
+ if row is not None and now - _parse(row["last_activity"]) > self.idle:
133
+ self._close_row(conn, row, "stale")
134
+ row = None
135
+ if row is None:
136
+ cur = conn.execute(
137
+ "INSERT INTO sessions (project_id, started_at) VALUES (?, ?)",
138
+ (project_id, _ts(now)))
139
+ sid = cur.lastrowid
140
+ conn.execute(
141
+ """INSERT INTO auto_sessions (session_id, project_id, client,
142
+ server_instance, opened_at, last_activity, call_count, events)
143
+ VALUES (?, ?, ?, ?, ?, ?, 0, '[]')""",
144
+ (sid, project_id, client, self.instance, _ts(now), _ts(now)))
145
+ if conn_key and _has_runs(conn):
146
+ who = ident.as_dict() if ident is not None else {}
147
+ conn.execute(
148
+ """INSERT INTO session_runs (session_id, conn_key, app, model, machine,
149
+ identity_source) VALUES (?, ?, ?, ?, ?, ?)""",
150
+ (sid, conn_key, who.get("app") or client, who.get("model"),
151
+ who.get("machine"), who.get("identity_source")))
152
+ events: list = []
153
+ count = 0
154
+ else:
155
+ sid = row["session_id"]
156
+ events = json.loads(row["events"] or "[]")
157
+ count = row["call_count"]
158
+ if event:
159
+ events = _add(events, event)
160
+ conn.execute(
161
+ """UPDATE auto_sessions SET last_activity = ?, call_count = ?, events = ?,
162
+ server_instance = ? WHERE session_id = ?""",
163
+ (_ts(now), count + 1, json.dumps(events), self.instance, sid))
164
+ conn.commit()
165
+ if conn_key:
166
+ dbconn.after(self.db_path, lambda ok: ok and self._last.__setitem__(conn_key, sid))
167
+ return sid
168
+ except Exception:
169
+ conn.rollback()
170
+ raise
171
+ finally:
172
+ conn.close()
173
+
174
+ def note(self, session_id: int, event: str) -> None:
175
+ """Append an event label to an open session (used after a call succeeds)."""
176
+ conn = self._conn()
177
+ try:
178
+ conn.execute("BEGIN IMMEDIATE")
179
+ row = conn.execute("SELECT events FROM auto_sessions WHERE session_id = ? "
180
+ "AND closed_at IS NULL", (session_id,)).fetchone()
181
+ if row is not None:
182
+ events = _add(json.loads(row["events"] or "[]"), event)
183
+ conn.execute("UPDATE auto_sessions SET events = ? WHERE session_id = ?",
184
+ (json.dumps(events), session_id))
185
+ conn.commit()
186
+ except Exception:
187
+ conn.rollback()
188
+ raise
189
+ finally:
190
+ conn.close()
191
+
192
+ def current(self, client: str, project_id: int,
193
+ conn_key: Optional[str] = None) -> Optional[dict]:
194
+ conn = self._conn()
195
+ try:
196
+ row = self._find(conn, (client or "unknown")[:80], project_id,
197
+ (conn_key or "")[:80] or None)
198
+ return dict(row) if row else None
199
+ finally:
200
+ conn.close()
201
+
202
+ def own(self, client: str, project_id: Optional[int], conn_key: Optional[str]) -> Optional[dict]:
203
+ """This connection's open automatic session (in `project_id`, else the one it used
204
+ last). Never another connection's: two windows of one app each get their own."""
205
+ if not conn_key:
206
+ return self.current(client, project_id) if project_id else None
207
+ conn = self._conn()
208
+ try:
209
+ if not _has_runs(conn):
210
+ return None
211
+ last = self._last.get(conn_key[:80]) if not project_id else None
212
+ if last is not None: # exact: the session this connection's last call touched
213
+ row = conn.execute("SELECT * FROM auto_sessions WHERE session_id = ? AND "
214
+ "closed_at IS NULL", (last,)).fetchone()
215
+ if row is not None:
216
+ return dict(row)
217
+ sql = ("SELECT a.* FROM auto_sessions a WHERE a.closed_at IS NULL AND a.client = ?"
218
+ + (" AND a.project_id = ?" if project_id else "") + _BY_CONN
219
+ + " ORDER BY a.last_activity DESC, a.session_id DESC LIMIT 1")
220
+ args = (client[:80] if client else "unknown",) + ((project_id,) if project_id else ()) \
221
+ + (conn_key[:80],)
222
+ row = conn.execute(sql, args).fetchone()
223
+ return dict(row) if row else None
224
+ finally:
225
+ conn.close()
226
+
227
+ def is_auto(self, session_id: int) -> bool:
228
+ conn = self._conn()
229
+ try:
230
+ return conn.execute("SELECT 1 FROM auto_sessions WHERE session_id = ?",
231
+ (session_id,)).fetchone() is not None
232
+ finally:
233
+ conn.close()
234
+
235
+ # ---------------------------------------------------------------- closing
236
+ def close_idle(self) -> list[int]:
237
+ """Close every open auto session idle longer than the timeout (any process)."""
238
+ cutoff = _ts(_utcnow() - self.idle)
239
+ return self._close_where("last_activity < ?", (cutoff,), "idle")
240
+
241
+ def close_instance(self, reason: str = "shutdown") -> list[int]:
242
+ """Close sessions this process owns (client disconnected / server stopping)."""
243
+ return self._close_where("server_instance = ?", (self.instance,), reason)
244
+
245
+ def mark_explicit_end(self, session_id: int) -> None:
246
+ """Agent ended the session itself: keep its summary, just stop tracking."""
247
+ conn = self._conn()
248
+ try:
249
+ cur = conn.execute(
250
+ """UPDATE auto_sessions SET closed_at = ?, close_reason = 'explicit'
251
+ WHERE session_id = ? AND closed_at IS NULL""",
252
+ (_ts(_utcnow()), session_id))
253
+ if cur.rowcount:
254
+ pid = conn.execute("SELECT project_id FROM auto_sessions WHERE session_id = ?",
255
+ (session_id,)).fetchone()
256
+ self._release_claims(conn, session_id, pid["project_id"], "explicit")
257
+ conn.commit()
258
+ finally:
259
+ conn.close()
260
+
261
+ def _close_where(self, where: str, params: tuple, reason: str) -> list[int]:
262
+ conn = self._conn()
263
+ closed = []
264
+ try:
265
+ conn.execute("BEGIN IMMEDIATE")
266
+ rows = conn.execute(
267
+ f"SELECT * FROM auto_sessions WHERE closed_at IS NULL AND {where}",
268
+ params).fetchall()
269
+ for row in rows:
270
+ self._close_row(conn, row, reason)
271
+ closed.append(row["session_id"])
272
+ conn.commit()
273
+ return closed
274
+ except Exception:
275
+ conn.rollback()
276
+ raise
277
+ finally:
278
+ conn.close()
279
+
280
+ def _close_row(self, conn: sqlite3.Connection, row: sqlite3.Row, reason: str) -> None:
281
+ opened, last = _parse(row["opened_at"]), _parse(row["last_activity"])
282
+ minutes = max(0, int((last - opened).total_seconds() // 60))
283
+ events = json.loads(row["events"] or "[]")
284
+ # Reads go first when the list is full (_add); a full list without one may have lost
285
+ # saves, so it counts as work.
286
+ trimmed = (len(events) >= MAX_EVENTS and row["call_count"] > len(events)
287
+ and not any(e.startswith("read:") for e in events))
288
+ name = conn.execute("SELECT name FROM projects WHERE id = ?",
289
+ (row["project_id"],)).fetchone()
290
+ project = name[0] if name else f"project #{row['project_id']}"
291
+ # The records it saved, by id (run ledger): stored with the review item so every page
292
+ # shows them next to the short summary line (session_contents.py). The line itself
293
+ # stays short: it is what Keep saves as the session's record.
294
+ from . import session_contents
295
+ try:
296
+ recs = session_contents.refs(conn, row["session_id"])
297
+ except sqlite3.Error:
298
+ recs = None
299
+ summary = build_summary(row["client"], project, events, trimmed,
300
+ _taken_back(conn, recs))
301
+ outcomes = {"auto_captured": True, "needs_review": True, "close_reason": reason,
302
+ "client": row["client"], "calls": row["call_count"]}
303
+ if recs:
304
+ outcomes["records"] = len(recs)
305
+ from .review_mode import get_with
306
+ if not trimmed and is_housekeeping(events):
307
+ # Nothing new was saved (reads, undo, review decisions...): a plain record for
308
+ # the session's history, but nothing for the user to judge in the queue.
309
+ summary = SKIP_TAG + summary[len(AUTO_TAG):]
310
+ outcomes.update({"needs_review": False, "review": "skipped_housekeeping"})
311
+ elif get_with(conn, row["project_id"])["mode"] == "off":
312
+ # The user turned review off: keep the summary as a normal one, queue nothing.
313
+ summary = OFF_TAG + summary[len(AUTO_TAG):]
314
+ outcomes.update({"needs_review": False, "review": "off"})
315
+ # Only fill the session if the agent hasn't ended it with its own summary. The
316
+ # summary stays flagged as provisional and waits in the review queue for the
317
+ # user's OK (approve replaces it with the reviewed text).
318
+ cur = conn.execute(
319
+ """UPDATE sessions SET ended_at = ?, summary = ?, outcomes = ?,
320
+ duration_minutes = ? WHERE id = ? AND ended_at IS NULL""",
321
+ (row["last_activity"], summary, json.dumps(outcomes), minutes,
322
+ row["session_id"]))
323
+ if cur.rowcount and outcomes["needs_review"]:
324
+ from .review import add_item
325
+ from .identity import on_behalf_of
326
+ run = conn.execute(
327
+ "SELECT app, model, machine, identity_source FROM session_runs "
328
+ "WHERE session_id = ? ORDER BY rowid LIMIT 1",
329
+ (row["session_id"],)).fetchone() if _has_runs(conn) else None
330
+ # the app its session recorded (with the sign-in it came in on), else the client
331
+ who = (on_behalf_of(run["app"] or row["client"], run["machine"],
332
+ run["identity_source"], run["model"]) if run is not None
333
+ else on_behalf_of(row["client"]))
334
+ item = add_item(self.db_path, row["project_id"], "session_summary", summary,
335
+ payload={"close_reason": reason, "calls": row["call_count"],
336
+ **({"records": recs} if recs else {})},
337
+ source=row["client"], session_id=row["session_id"], conn=conn,
338
+ ident=who)
339
+ if item is not None:
340
+ outcomes["review_item_id"] = item
341
+ conn.execute("UPDATE sessions SET outcomes = ? WHERE id = ?",
342
+ (json.dumps(outcomes), row["session_id"]))
343
+ conn.execute(
344
+ "UPDATE auto_sessions SET closed_at = ?, close_reason = ? WHERE session_id = ?",
345
+ (_ts(_utcnow()), reason, row["session_id"]))
346
+ self._release_claims(conn, row["session_id"], row["project_id"], reason)
347
+
348
+ @staticmethod
349
+ def _release_claims(conn, session_id: int, project_id: int, reason: str) -> None:
350
+ """A claim ends with its session (claims.py): drop what this connection held here."""
351
+ if not _has_runs(conn):
352
+ return
353
+ key = conn.execute("SELECT conn_key, app FROM session_runs WHERE session_id = ?",
354
+ (session_id,)).fetchone()
355
+ if key and key["conn_key"]:
356
+ from . import claims
357
+ claims.release_for_session(conn, key["conn_key"], key["app"], project_id, reason)
358
+
359
+
360
+ # Event labels (server._label: "<kind or tool/action>[: text]", reads "read:<tool>") of
361
+ # calls that save no new work: taking something back, deciding the review queue, picking up
362
+ # a handoff note, adopting the session FlowState opened, holding a todo while working on it
363
+ # (a claim ends with its session). Reads are housekeeping too. Any other write label is
364
+ # real work, so its session keeps its summary (fail safe). Value: the word for the summary.
365
+ HOUSEKEEPING = {
366
+ "history/undo": "undo",
367
+ "review_approve": "review decision",
368
+ "review_reject": "review decision",
369
+ "resolve_review_card": "review decision", # a tap on a review card (re-audit N-F15)
370
+ "resolve_conflict_card": "review decision", # a tap on a disagreement card
371
+ "handoff/pickup": "handoff picked up",
372
+ "session/start": None,
373
+ "todo/claim": "todo claim",
374
+ "todo/renew": "todo claim",
375
+ "todo/release": "todo claim",
376
+ }
377
+
378
+ # Saved records, by the kind their label starts with (log_work kinds and the older tools).
379
+ _SAVED = {"learning": "learning", "log_learning": "learning", "decision": "decision",
380
+ "session/log_conversation": "decision", "change": "change", "log_change": "change",
381
+ "problem": "problem", "problem/log": "problem", "todo": "todo", "todo/add": "todo"}
382
+ _KINDS = ("learning", "decision", "change", "problem", "todo")
383
+ # Plain words for the other writes (anything else: its label with spaces).
384
+ _WORDS = {"todo/update": "todo update", "todo/block": "todo blocked",
385
+ "todo/unblock": "todo unblocked", "todo/handoff": "todo handed off",
386
+ "problem/solve": "problem solved", "attempt/log": "fix attempt",
387
+ "attempt/outcome": "fix attempt result", "link/add": "new link",
388
+ "link/remove": "link removed", "rules/add": "hard rule request", "rules/remove": "rule removal request",
389
+ "handoff/leave": "handoff note", "verify/mark": "fact checked",
390
+ "verify/source": "source changed", "review_mode/set": "review mode change",
391
+ "delete_request/record": "delete", "delete_request/project": "project delete",
392
+ "delete_request/component": "component delete", "cleanup_suggest": "clean-up ideas",
393
+ "import_memory": "memory import", "update_project": "project update",
394
+ "create_component": "new component", "update_component": "component update"}
395
+
396
+
397
+ def _what(event: str) -> str:
398
+ """The label's kind or tool/action part (before ": <text>")."""
399
+ return event.split(": ", 1)[0].strip()
400
+
401
+
402
+ def is_housekeeping(events: list) -> bool:
403
+ """True when a session saved nothing new: no events, or only reads and HOUSEKEEPING
404
+ labels. Such a session files no session_summary review item."""
405
+ return all(e.startswith("read:") or _what(e) in HOUSEKEEPING for e in events)
406
+
407
+
408
+ def _n(count: int, word: str) -> str:
409
+ return f"{count} {word}{'' if count == 1 else 's'}"
410
+
411
+
412
+ def _and(parts: list) -> str:
413
+ return parts[0] if len(parts) == 1 else ", ".join(parts[:-1]) + " and " + parts[-1]
414
+
415
+
416
+ def _taken_back(conn, recs) -> dict:
417
+ """kind -> how many records the session saved that are no longer there when it closes
418
+ (an undo took one back, or the session deleted it): the summary counts what is still
419
+ there (audit 53, N-F39). From the run ledger's records; empty without one."""
420
+ from .history import TABLES
421
+ out: dict = {}
422
+ for rtype, rid, verb in recs or []:
423
+ if rtype not in _KINDS or str(verb).split(", ")[0] != "saved":
424
+ continue
425
+ try:
426
+ there = conn.execute(f'SELECT 1 FROM "{TABLES[rtype]}" WHERE id = ?',
427
+ (int(rid),)).fetchone()
428
+ except (sqlite3.Error, KeyError, ValueError, TypeError):
429
+ continue
430
+ if there is None:
431
+ out[rtype] = out.get(rtype, 0) + 1
432
+ return out
433
+
434
+
435
+ def build_summary(client: str, project: str, events: list, trimmed: bool = False,
436
+ taken_back: Optional[dict] = None) -> str:
437
+ """Short, plain summary from the event log: what the session saved, counted by kind.
438
+ Starts with AUTO_TAG (the caller swaps it for OFF_TAG / SKIP_TAG). Which records they
439
+ are is shown beside it (session_contents.py), not in it. `taken_back`: kind -> saved
440
+ records no longer there (_taken_back), not counted as saved."""
441
+ saved, suggested, other, chores = {}, {}, {}, {}
442
+ for e in events:
443
+ if e.startswith("read:"):
444
+ continue
445
+ what = _what(e)
446
+ if what.startswith("suggested "):
447
+ kind = _SAVED.get(what[10:])
448
+ if kind:
449
+ suggested[kind] = suggested.get(kind, 0) + 1
450
+ continue
451
+ if what in _SAVED:
452
+ saved[_SAVED[what]] = saved.get(_SAVED[what], 0) + 1
453
+ elif what in HOUSEKEEPING:
454
+ if HOUSEKEEPING[what]:
455
+ chores[HOUSEKEEPING[what]] = chores.get(HOUSEKEEPING[what], 0) + 1
456
+ else:
457
+ words = _WORDS.get(what) or what.replace("/", " ").replace("_", " ")
458
+ other[words] = other.get(words, 0) + 1
459
+ for k, n in (taken_back or {}).items():
460
+ if k in saved:
461
+ saved[k] -= n
462
+ if saved[k] <= 0:
463
+ del saved[k]
464
+ parts = []
465
+ if saved:
466
+ parts.append("saved " + _and([_n(saved[k], k) for k in _KINDS if k in saved]))
467
+ if suggested:
468
+ parts.append("suggested " + _and([_n(suggested[k], k) for k in _KINDS if k in suggested])
469
+ + " for review")
470
+ if other:
471
+ parts.append("also " + ", ".join(f"{k} (x{v})" if v > 1 else k for k, v in other.items()))
472
+ if not parts:
473
+ parts.append("nothing new saved" + (f" ({_and([_n(v, k) for k, v in chores.items()])})"
474
+ if chores else ""))
475
+ text = f"{AUTO_TAG} {client} session in {project}: " + "; ".join(parts) + "."
476
+ if trimmed:
477
+ text += f" Only its last {MAX_EVENTS} saves were counted."
478
+ if len(text) > MAX_SUMMARY:
479
+ text = text[:MAX_SUMMARY - 3] + "..."
480
+ return text