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.
- flowstate/__init__.py +34 -0
- flowstate/__main__.py +7 -0
- flowstate/activity.py +224 -0
- flowstate/agent_guide.py +251 -0
- flowstate/app_keys.py +141 -0
- flowstate/approvals.py +974 -0
- flowstate/approve_page.py +565 -0
- flowstate/ask.py +107 -0
- flowstate/autosession.py +480 -0
- flowstate/backup.py +201 -0
- flowstate/brief.py +420 -0
- flowstate/card_offer.py +208 -0
- flowstate/card_taps.py +165 -0
- flowstate/card_trust.py +127 -0
- flowstate/claims.py +461 -0
- flowstate/cleanup.py +619 -0
- flowstate/cli.py +728 -0
- flowstate/cli_data.py +434 -0
- flowstate/cli_more.py +302 -0
- flowstate/cli_review.py +459 -0
- flowstate/cloudflared_check.py +49 -0
- flowstate/conflict_card_ui.py +286 -0
- flowstate/conflict_cards.py +1193 -0
- flowstate/conflict_reopen.py +320 -0
- flowstate/conflicts.py +420 -0
- flowstate/connections.py +33 -0
- flowstate/database.py +2191 -0
- flowstate/dbconn.py +308 -0
- flowstate/deletes.py +860 -0
- flowstate/embeddings.py +354 -0
- flowstate/erase.py +1047 -0
- flowstate/extras.py +71 -0
- flowstate/git_capture.py +157 -0
- flowstate/guard.py +224 -0
- flowstate/handoffs.py +168 -0
- flowstate/history.py +755 -0
- flowstate/history_page.py +276 -0
- flowstate/http_app.py +204 -0
- flowstate/identity.py +387 -0
- flowstate/install_name.py +140 -0
- flowstate/ledger.py +272 -0
- flowstate/legacy_args.py +65 -0
- flowstate/links.py +609 -0
- flowstate/markdown_io.py +242 -0
- flowstate/meaning.py +398 -0
- flowstate/memory_import.py +92 -0
- flowstate/migrations/001_auto_sessions.sql +16 -0
- flowstate/migrations/002_review_queue.sql +33 -0
- flowstate/migrations/003_links.sql +47 -0
- flowstate/migrations/004_review_mode.sql +24 -0
- flowstate/migrations/005_history.sql +21 -0
- flowstate/migrations/006_activity.sql +26 -0
- flowstate/migrations/007_meaning_index.sql +16 -0
- flowstate/migrations/008_git_capture.sql +12 -0
- flowstate/migrations/009_hard_rules.sql +13 -0
- flowstate/migrations/010_handoffs.sql +14 -0
- flowstate/migrations/011_record_meta.sql +13 -0
- flowstate/migrations/012_accountability.sql +71 -0
- flowstate/migrations/013_todo_claims.sql +36 -0
- flowstate/migrations/014_decision_conflicts.sql +41 -0
- flowstate/migrations/015_session_runs.sql +17 -0
- flowstate/migrations/016_write_receipts.sql +35 -0
- flowstate/migrations/017_run_views.sql +26 -0
- flowstate/migrations/018_run_views_upto.sql +4 -0
- flowstate/migrations/019_approvals.sql +47 -0
- flowstate/migrations/020_source_stamps.sql +10 -0
- flowstate/migrations/021_review_links.sql +11 -0
- flowstate/migrations/022_review_start_tickets.sql +10 -0
- flowstate/migrations/023_conflict_cards.sql +68 -0
- flowstate/migrations/024_rule_confirmations.sql +7 -0
- flowstate/migrations/025_confirmed_hash.sql +7 -0
- flowstate/migrations/__init__.py +149 -0
- flowstate/models.py +307 -0
- flowstate/names.py +157 -0
- flowstate/offsite.py +386 -0
- flowstate/offsite_crypto.py +272 -0
- flowstate/paths.py +265 -0
- flowstate/popup.py +238 -0
- flowstate/priority.py +66 -0
- flowstate/privatefs.py +476 -0
- flowstate/project_history.py +710 -0
- flowstate/provenance.py +696 -0
- flowstate/receipts.py +377 -0
- flowstate/relay/__init__.py +72 -0
- flowstate/relay/acme.py +271 -0
- flowstate/relay/api.py +164 -0
- flowstate/relay/cli.py +344 -0
- flowstate/relay/crypto.py +137 -0
- flowstate/relay/ct.py +118 -0
- flowstate/relay/runner.py +177 -0
- flowstate/relay/store.py +191 -0
- flowstate/relay/tunnel.py +413 -0
- flowstate/release_signers +14 -0
- flowstate/remote_auth.py +1219 -0
- flowstate/review.py +320 -0
- flowstate/review_apply.py +140 -0
- flowstate/review_card_ui.py +241 -0
- flowstate/review_cards.py +564 -0
- flowstate/review_mode.py +274 -0
- flowstate/review_page.py +1361 -0
- flowstate/review_phone.py +465 -0
- flowstate/review_tunnel.py +338 -0
- flowstate/routing.py +392 -0
- flowstate/rules.py +418 -0
- flowstate/safety.py +98 -0
- flowstate/saves.py +83 -0
- flowstate/scheduler.py +160 -0
- flowstate/schema.sql +713 -0
- flowstate/search.py +355 -0
- flowstate/secret_store.py +271 -0
- flowstate/secrets_scan.py +135 -0
- flowstate/selfupdate.py +1020 -0
- flowstate/server.py +1299 -0
- flowstate/session_contents.py +382 -0
- flowstate/session_records.py +629 -0
- flowstate/site_style.py +46 -0
- flowstate/stored.py +158 -0
- flowstate/story.py +442 -0
- flowstate/surface.py +173 -0
- flowstate/tasks.py +231 -0
- flowstate/term.py +40 -0
- flowstate/tool_labels.py +104 -0
- flowstate/tooldefs.py +383 -0
- flowstate/tools/__init__.py +118 -0
- flowstate/tools/core.py +545 -0
- flowstate/tools/files.py +416 -0
- flowstate/tools/git_ops.py +1081 -0
- flowstate/tools/intelligence.py +376 -0
- flowstate/tools/learning.py +282 -0
- flowstate/tools/problems.py +159 -0
- flowstate/tools/sessions.py +266 -0
- flowstate/tools/story.py +48 -0
- flowstate/tools/todos.py +79 -0
- flowstate/tools/utils.py +71 -0
- flowstate/tools/variables.py +345 -0
- flowstate/updates.py +482 -0
- flowstate/urls.py +7 -0
- flowstate/waiting.py +828 -0
- flowstate/who.py +302 -0
- flowstatemcp-2.5.0.dist-info/METADATA +88 -0
- flowstatemcp-2.5.0.dist-info/RECORD +143 -0
- flowstatemcp-2.5.0.dist-info/WHEEL +4 -0
- 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
|
flowstate/autosession.py
ADDED
|
@@ -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
|