interlatch 0.22.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.
- chronicle/__init__.py +17 -0
- chronicle/__main__.py +3 -0
- chronicle/access.py +93 -0
- chronicle/agents.py +23 -0
- chronicle/analyze.py +498 -0
- chronicle/antigravity_parser.py +311 -0
- chronicle/artifacts.py +621 -0
- chronicle/bob_parser.py +259 -0
- chronicle/cases.py +75 -0
- chronicle/chat_import.py +325 -0
- chronicle/chatgpt_export.py +127 -0
- chronicle/claude_export.py +129 -0
- chronicle/cli.py +2980 -0
- chronicle/codex_cloud.py +138 -0
- chronicle/codex_parser.py +753 -0
- chronicle/config.py +627 -0
- chronicle/connectors.py +666 -0
- chronicle/container.py +202 -0
- chronicle/copilot_parser.py +494 -0
- chronicle/db.py +598 -0
- chronicle/desktop.py +543 -0
- chronicle/diagram.py +488 -0
- chronicle/digest.py +194 -0
- chronicle/export_md.py +210 -0
- chronicle/friction.py +841 -0
- chronicle/glossary.py +522 -0
- chronicle/groups.py +181 -0
- chronicle/hooks.py +169 -0
- chronicle/hub.py +2291 -0
- chronicle/i18n.py +37 -0
- chronicle/i18n_ja.py +421 -0
- chronicle/ingest.py +1190 -0
- chronicle/install.py +590 -0
- chronicle/instructions.py +699 -0
- chronicle/ladder.py +244 -0
- chronicle/learning.py +170 -0
- chronicle/llm.py +570 -0
- chronicle/mcp_server.py +403 -0
- chronicle/menubar.py +771 -0
- chronicle/migrate.py +580 -0
- chronicle/mirror.py +453 -0
- chronicle/news.py +110 -0
- chronicle/notify.py +92 -0
- chronicle/parser.py +908 -0
- chronicle/people.py +536 -0
- chronicle/pg.py +122 -0
- chronicle/pricing.py +168 -0
- chronicle/providers.py +591 -0
- chronicle/redact.py +50 -0
- chronicle/reviews.py +251 -0
- chronicle/screen.py +308 -0
- chronicle/search.py +238 -0
- chronicle/server.py +3009 -0
- chronicle/session_export.py +178 -0
- chronicle/statusline.py +258 -0
- chronicle/suggest.py +643 -0
- chronicle/synthesize.py +365 -0
- chronicle/systems.py +1265 -0
- chronicle/tailnet.py +97 -0
- chronicle/team_store.py +450 -0
- chronicle/update.py +404 -0
- chronicle/util.py +217 -0
- chronicle/views.py +249 -0
- chronicle/web/PLEX-OFL.txt +93 -0
- chronicle/web/app.css +2350 -0
- chronicle/web/app.js +7587 -0
- chronicle/web/art-book.webp +0 -0
- chronicle/web/art-camera.webp +0 -0
- chronicle/web/art-chip.webp +0 -0
- chronicle/web/art-clock.webp +0 -0
- chronicle/web/art-cloud.webp +0 -0
- chronicle/web/art-coins.webp +0 -0
- chronicle/web/art-container.webp +0 -0
- chronicle/web/art-dashboard.webp +0 -0
- chronicle/web/art-dex.webp +0 -0
- chronicle/web/art-document.webp +0 -0
- chronicle/web/art-flag.webp +0 -0
- chronicle/web/art-folder.webp +0 -0
- chronicle/web/art-gateway.webp +0 -0
- chronicle/web/art-gears.webp +0 -0
- chronicle/web/art-glitch.webp +0 -0
- chronicle/web/art-globe.webp +0 -0
- chronicle/web/art-laptop.webp +0 -0
- chronicle/web/art-log.webp +0 -0
- chronicle/web/art-lumi.webp +0 -0
- chronicle/web/art-magnifier.webp +0 -0
- chronicle/web/art-pip.webp +0 -0
- chronicle/web/art-plug.webp +0 -0
- chronicle/web/art-rack.webp +0 -0
- chronicle/web/art-tink.webp +0 -0
- chronicle/web/boot.js +25 -0
- chronicle/web/favicon.png +0 -0
- chronicle/web/i18n.js +50 -0
- chronicle/web/icon-180.png +0 -0
- chronicle/web/icon-192.png +0 -0
- chronicle/web/icon-512.png +0 -0
- chronicle/web/icon.png +0 -0
- chronicle/web/index.html +92 -0
- chronicle/web/ja.js +1880 -0
- chronicle/web/learning.js +438 -0
- chronicle/web/manifest.webmanifest +15 -0
- chronicle/web/panel.css +175 -0
- chronicle/web/panel.html +14 -0
- chronicle/web/panel.js +241 -0
- chronicle/web/plex-mono-400.woff2 +0 -0
- chronicle/web/plex-mono-500.woff2 +0 -0
- chronicle/web/plex-mono-600.woff2 +0 -0
- chronicle/web/plex-sans-400.woff2 +0 -0
- chronicle/web/plex-sans-500.woff2 +0 -0
- chronicle/web/plex-sans-600.woff2 +0 -0
- chronicle/web/plex-sans-700.woff2 +0 -0
- chronicle/web/rough.js +2 -0
- chronicle/worker.py +390 -0
- interlatch-0.22.0.dist-info/METADATA +221 -0
- interlatch-0.22.0.dist-info/RECORD +118 -0
- interlatch-0.22.0.dist-info/WHEEL +4 -0
- interlatch-0.22.0.dist-info/entry_points.txt +3 -0
- interlatch-0.22.0.dist-info/licenses/LICENSE +21 -0
chronicle/suggest.py
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
"""Suggestions: one approval queue of proposed fixes, from the friction catalog and from trusted knowledge.
|
|
2
|
+
|
|
3
|
+
refresh() rebuilds the queue after a sync: every recurring, still-happening friction cause proposes its fixes (an
|
|
4
|
+
instruction line, a config change, or a setup step for you to run), and knowledge worth an instruction line proposes
|
|
5
|
+
one. Nothing is written until a suggestion is applied. A dismissed suggestion never comes back, text you edited
|
|
6
|
+
before applying is kept, and a proposal whose cause stopped happening goes stale instead of nagging.
|
|
7
|
+
|
|
8
|
+
Applying writes only inside Interlatch's block of an instruction file (instructions.py), or adds the missing
|
|
9
|
+
Playwright MCP arguments to ~/.claude.json. Environment steps are shown, never run: you mark them done.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import difflib
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sqlite3
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import friction, instructions
|
|
24
|
+
from .config import Config
|
|
25
|
+
from .i18n import lang, tr, trn
|
|
26
|
+
from .util import dumps, loads, one_line, utcnow_iso
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("chronicle.suggest")
|
|
29
|
+
|
|
30
|
+
STATUSES = ("new", "applied", "dismissed", "stale", "done")
|
|
31
|
+
COLUMNS = ("key", "kind", "origin", "cause_id", "knowledge_id", "project_path", "agent", "target_path", "title", "text",
|
|
32
|
+
"evidence_json", "warnings_json", "score")
|
|
33
|
+
_CONFIG_CHANGE = re.compile(r"^\s*mcpServers\.([\w.@/-]+?)\.args\s*\+=\s*(\[.*\])\s*$", re.S)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------- reading
|
|
37
|
+
def _row(r) -> dict:
|
|
38
|
+
d = dict(r)
|
|
39
|
+
d["evidence"] = loads(d.pop("evidence_json", None), {}) or {}
|
|
40
|
+
d["warnings"] = loads(d.pop("warnings_json", None), {}) or {}
|
|
41
|
+
return d
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get(conn: sqlite3.Connection, sid: int) -> dict | None:
|
|
45
|
+
r = conn.execute("SELECT * FROM suggestions WHERE id = ?", (int(sid),)).fetchone()
|
|
46
|
+
return _row(r) if r else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def list_suggestions(conn: sqlite3.Connection, status: str | None = None, project: str | None = None) -> list[dict]:
|
|
50
|
+
sql, params = "SELECT * FROM suggestions WHERE 1 = 1", []
|
|
51
|
+
if status:
|
|
52
|
+
sql += " AND status = ?"
|
|
53
|
+
params.append(status)
|
|
54
|
+
if project:
|
|
55
|
+
sql += " AND project_path = ?"
|
|
56
|
+
params.append(project)
|
|
57
|
+
return [_row(r) for r in conn.execute(sql + " ORDER BY score DESC, id", params)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def display(s: dict) -> dict:
|
|
61
|
+
"""A suggestion as the dashboard shows it, in the viewer's language (i18n). The row keeps its English title and
|
|
62
|
+
evidence line (a knowledge title, and every line's text, stay as they were written), so they are rebuilt here."""
|
|
63
|
+
if lang.get() == "en":
|
|
64
|
+
return s
|
|
65
|
+
e = dict(s.get("evidence") or {})
|
|
66
|
+
if s["origin"] == "friction":
|
|
67
|
+
s = {**s, "title": tr(s["title"])}
|
|
68
|
+
if e.get("cause"):
|
|
69
|
+
e["cause"] = tr(e["cause"])
|
|
70
|
+
if e.get("line") and "sessions" in e and "still_happening" in e:
|
|
71
|
+
e["line"] = _friction_line(e)
|
|
72
|
+
e["examples"] = [{**x, "note": friction.display_note(x.get("note"))} for x in e.get("examples") or []]
|
|
73
|
+
elif s["origin"] == "knowledge" and e.get("line") and "sessions" in e:
|
|
74
|
+
e["line"] = _knowledge_line(e)
|
|
75
|
+
return {**s, "evidence": e}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _friction_line(e: dict) -> str:
|
|
79
|
+
"""_evidence_line, in the viewer's language."""
|
|
80
|
+
n, p = e.get("sessions") or 0, e.get("projects") or 0
|
|
81
|
+
parts = [trn(n, "seen in {n} session across {p} projects", "seen in {n} sessions across {p} projects", p=p) if p > 1
|
|
82
|
+
else trn(n, "seen in {n} session", "seen in {n} sessions")]
|
|
83
|
+
parts.append(tr("still happening") if e.get("still_happening") else tr("not seen lately"))
|
|
84
|
+
if e.get("last_seen"):
|
|
85
|
+
parts.append(tr("last {date}", date=e["last_seen"]))
|
|
86
|
+
if e.get("project_sessions") is not None:
|
|
87
|
+
parts.append(tr("{n} in this project", n=e["project_sessions"]))
|
|
88
|
+
return " · ".join(parts)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _knowledge_line(e: dict) -> str:
|
|
92
|
+
"""A knowledge suggestion's evidence line (instructions._candidate), in the viewer's language."""
|
|
93
|
+
n, p = e.get("sessions") or 0, e.get("projects") or 0
|
|
94
|
+
where = e["line"].rsplit(" · ", 1)[-1]
|
|
95
|
+
confirmed = (trn(n, "confirmed in {n} session across {p} projects", "confirmed in {n} sessions across {p} projects", p=p)
|
|
96
|
+
if p > 1 else trn(n, "confirmed in {n} session", "confirmed in {n} sessions"))
|
|
97
|
+
return " · ".join([tr(e.get("stage") or "provisional"), confirmed, tr(where) if where == "user level" else where])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def counts(conn: sqlite3.Connection) -> dict[str, int]:
|
|
101
|
+
out = {s: 0 for s in STATUSES}
|
|
102
|
+
for r in conn.execute("SELECT status, COUNT(*) FROM suggestions GROUP BY status"):
|
|
103
|
+
out[r[0]] = r[1]
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def count_unseen(conn: sqlite3.Connection) -> int:
|
|
108
|
+
return conn.execute("SELECT COUNT(*) FROM suggestions WHERE status = 'new' AND seen_at IS NULL").fetchone()[0]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def mark_seen(conn: sqlite3.Connection) -> int:
|
|
112
|
+
cur = conn.execute("UPDATE suggestions SET seen_at = ? WHERE status = 'new' AND seen_at IS NULL", (utcnow_iso(),))
|
|
113
|
+
conn.commit()
|
|
114
|
+
return cur.rowcount
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------- building proposals
|
|
118
|
+
def claude_json_path() -> Path:
|
|
119
|
+
"""Claude Code's user config (MCP servers): $CLAUDE_CONFIG_DIR/.claude.json when set, else ~/.claude.json."""
|
|
120
|
+
custom = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
121
|
+
if custom and (Path(custom).expanduser() / ".claude.json").exists():
|
|
122
|
+
return Path(custom).expanduser() / ".claude.json"
|
|
123
|
+
return Path("~/.claude.json").expanduser()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def playwright_output_dir() -> str:
|
|
127
|
+
return str(Path("~/.cache/playwright-mcp").expanduser())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def fix_text(fix: dict, cfg: Config) -> str:
|
|
131
|
+
"""A catalog fix's text in the language Interlatch writes in: instruction lines have a Japanese version (text_ja);
|
|
132
|
+
commands and config changes are the same in every language."""
|
|
133
|
+
text = fix.get("text_ja") if cfg.analysis.language == "ja" and fix.get("text_ja") else fix["text"]
|
|
134
|
+
return text.replace("{playwright_output_dir}", playwright_output_dir())
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _evidence_line(cause: dict) -> str:
|
|
138
|
+
n, p = cause["sessions"], len(cause["projects"])
|
|
139
|
+
parts = [f"seen in {n} session{'s' if n != 1 else ''}" + (f" across {p} projects" if p > 1 else "")]
|
|
140
|
+
parts.append("still happening" if cause["still_happening"] else "not seen lately")
|
|
141
|
+
if cause["last_seen"]:
|
|
142
|
+
parts.append(f"last {cause['last_seen']}")
|
|
143
|
+
return " · ".join(parts)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _friction_evidence(cause: dict, text: str) -> dict:
|
|
147
|
+
return {
|
|
148
|
+
"line": _evidence_line(cause), "sessions": cause["sessions"], "occurrences": cause["occurrences"],
|
|
149
|
+
"immediate_repeats": cause["immediate_repeats"], "projects": len(cause["projects"]),
|
|
150
|
+
"project_names": cause["projects"][:5], "agents": cause["agents"], "first_seen": cause["first_seen"],
|
|
151
|
+
"last_seen": cause["last_seen"], "still_happening": cause["still_happening"], "rate": cause["rate"],
|
|
152
|
+
"weekly": cause["weekly"], "examples": cause["examples"], "cause": cause["name"], "generated_text": text,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _file_label(target: Path) -> str:
|
|
157
|
+
return "claude" if target.name == "CLAUDE.md" else "codex"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _read(path: Path | None) -> str:
|
|
161
|
+
try:
|
|
162
|
+
return path.read_text() if path else ""
|
|
163
|
+
except OSError:
|
|
164
|
+
return ""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _friction_proposals(conn: sqlite3.Connection, cfg: Config, cause: dict,
|
|
168
|
+
project_agents: dict[str, list[str]] | None = None,
|
|
169
|
+
choices: dict[str, tuple[str, str]] | None = None) -> list[dict]:
|
|
170
|
+
out = []
|
|
171
|
+
cid = cause["id"]
|
|
172
|
+
choice = instructions.chosen_scope(choices or {}, [f"cause:{cid}"]) # where you moved it, if you did
|
|
173
|
+
seen_agents = [a for a in cause["agents"] if a in ("claude", "codex", "copilot", "bob", "antigravity")]
|
|
174
|
+
score = round(cause["sessions"] + 0.5 * len(cause["projects"]) + 0.25 * cause["immediate_repeats"], 3)
|
|
175
|
+
base = {"origin": "friction", "cause_id": cid, "knowledge_id": None, "score": score}
|
|
176
|
+
for fix in cause["fixes"]:
|
|
177
|
+
kind = fix["kind"]
|
|
178
|
+
text = fix_text(fix, cfg)
|
|
179
|
+
if kind == "environment":
|
|
180
|
+
out.append({**base, "key": f"friction:{cid}:all:user:environment", "kind": kind, "project_path": None,
|
|
181
|
+
"agent": "all", "target_path": None, "title": fix["title"], "text": text,
|
|
182
|
+
"warnings": {"sensitive": instructions.sensitive(text), "public_repo": False, "untracked": False,
|
|
183
|
+
"overlap": False}})
|
|
184
|
+
continue
|
|
185
|
+
if kind == "config":
|
|
186
|
+
if "claude" not in seen_agents or config_state(claude_json_path(), text) != "pending":
|
|
187
|
+
continue # Playwright MCP not configured here, or the arguments are already there
|
|
188
|
+
out.append({**base, "key": f"friction:{cid}:claude:user:config", "kind": kind, "project_path": None,
|
|
189
|
+
"agent": "claude", "target_path": str(claude_json_path()), "title": fix["title"], "text": text,
|
|
190
|
+
"warnings": {"sensitive": [], "public_repo": False, "untracked": False, "overlap": False}})
|
|
191
|
+
continue
|
|
192
|
+
agents = seen_agents if "all" in fix["agents"] else [a for a in fix["agents"] if a in seen_agents]
|
|
193
|
+
if not agents:
|
|
194
|
+
continue
|
|
195
|
+
spread = len(cause["projects"]) >= 3
|
|
196
|
+
scope = choice or ("project" if fix["scope"] == "project" else ("user" if spread else "project"))
|
|
197
|
+
targets: dict[tuple[Path, str | None], list[str]] = {}
|
|
198
|
+
if scope == "user":
|
|
199
|
+
for a in agents:
|
|
200
|
+
t = instructions.target_for(a, "user", None, cfg)
|
|
201
|
+
if t:
|
|
202
|
+
targets.setdefault((t, None), []).append(a)
|
|
203
|
+
else:
|
|
204
|
+
paths = cause.get("project_paths") or []
|
|
205
|
+
chosen = [p["path"] for p in paths if p["sessions"] >= 2][:5] if "project" in (fix["scope"], choice) else []
|
|
206
|
+
for path in chosen or [p["path"] for p in paths[:1]]:
|
|
207
|
+
here = (project_agents or {}).get(path)
|
|
208
|
+
for a in [a for a in agents if not here or a in here]:
|
|
209
|
+
t = instructions.target_for(a, "project", path, cfg)
|
|
210
|
+
if t:
|
|
211
|
+
targets.setdefault((t, path), []).append(a)
|
|
212
|
+
for (target, project), who in targets.items():
|
|
213
|
+
content = _read(target)
|
|
214
|
+
if any(instructions.overlap(t, content) for t in (text, fix["text"], fix.get("text_ja")) if t):
|
|
215
|
+
continue # the file already says this, in either language
|
|
216
|
+
out.append({**base, "key": f"friction:{cid}:{_file_label(target)}:{project or 'user'}", "kind": "instruction",
|
|
217
|
+
"project_path": project, "agent": who[0] if len(who) == 1 else "all",
|
|
218
|
+
"target_path": str(target), "title": fix["title"], "text": text,
|
|
219
|
+
"warnings": instructions.warnings_for(text, target, project, conn)})
|
|
220
|
+
here = {p["path"]: p["sessions"] for p in cause.get("project_paths") or []}
|
|
221
|
+
for p in out:
|
|
222
|
+
p["evidence"] = _friction_evidence(cause, p["text"])
|
|
223
|
+
if p["project_path"] and len(here) > 1:
|
|
224
|
+
n = here.get(p["project_path"], 0)
|
|
225
|
+
p["evidence"]["project_sessions"] = n
|
|
226
|
+
p["evidence"]["line"] += f" · {n} in this project"
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def proposals(conn: sqlite3.Connection, cfg: Config) -> tuple[list[dict], dict[str, dict], list[dict]]:
|
|
231
|
+
"""(every suggestion the data supports right now, the scanned causes by id, knowledge lines waiting for room in
|
|
232
|
+
their file)."""
|
|
233
|
+
causes = {c["id"]: c for c in friction._scan(conn)}
|
|
234
|
+
project_agents = instructions._project_agents(conn)
|
|
235
|
+
choices = instructions.scope_choices(conn)
|
|
236
|
+
rows = [dict(r) for r in conn.execute("SELECT * FROM suggestions")]
|
|
237
|
+
status = {r["key"]: r["status"] for r in rows}
|
|
238
|
+
decided = [r for r in rows if r["origin"] == "knowledge" and r["status"] in ("dismissed", "applied")]
|
|
239
|
+
out: list[dict] = []
|
|
240
|
+
waiting: list[dict] = []
|
|
241
|
+
for c in causes.values():
|
|
242
|
+
if c["noise"] or c["category"] == "other" or not c["fixes"]:
|
|
243
|
+
continue
|
|
244
|
+
if not c["still_happening"] or not (c["sessions"] >= 3 or len(c["projects"]) >= 2):
|
|
245
|
+
continue
|
|
246
|
+
out.extend(_friction_proposals(conn, cfg, c, project_agents, choices))
|
|
247
|
+
# lessons you already dismissed or applied in a file take none of its places, so the next ones come up
|
|
248
|
+
for k in instructions.candidates(conn, cfg, lambda c: status.get(c["key"]) in ("dismissed", "applied", "done")
|
|
249
|
+
or _decided_lesson(decided, c), waiting):
|
|
250
|
+
out.append({**k, "warnings": k.get("warnings") or {}})
|
|
251
|
+
seen: set[str] = set()
|
|
252
|
+
return [p for p in out if not (p["key"] in seen or seen.add(p["key"]))], causes, waiting
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def refresh(conn: sqlite3.Connection, cfg: Config) -> dict[str, int]:
|
|
256
|
+
"""Upsert every current proposal by key; mark proposals the data no longer supports stale."""
|
|
257
|
+
found, causes, waiting = proposals(conn, cfg)
|
|
258
|
+
now = utcnow_iso()
|
|
259
|
+
existing = {r["key"]: dict(r) for r in conn.execute("SELECT * FROM suggestions")}
|
|
260
|
+
report = {"new": 0, "updated": 0, "stale": 0}
|
|
261
|
+
for p in found:
|
|
262
|
+
evidence, warnings = dumps(p["evidence"]), dumps(p["warnings"])
|
|
263
|
+
old = existing.get(p["key"])
|
|
264
|
+
if old is None and _decided_lesson(existing.values(), p):
|
|
265
|
+
continue # the same lesson, under another member's id, was already dismissed or applied here
|
|
266
|
+
if old is None and _in_file(p):
|
|
267
|
+
continue # its line is already in the file's block (its marker is the same in every language)
|
|
268
|
+
if old is None:
|
|
269
|
+
conn.execute(
|
|
270
|
+
f"INSERT INTO suggestions({', '.join(COLUMNS)}, status, created_at, updated_at) "
|
|
271
|
+
f"VALUES ({', '.join('?' * len(COLUMNS))}, 'new', ?, ?)",
|
|
272
|
+
(p["key"], p["kind"], p["origin"], p["cause_id"], p["knowledge_id"], p["project_path"], p["agent"],
|
|
273
|
+
p["target_path"], p["title"], p["text"], evidence, warnings, p["score"], now, now))
|
|
274
|
+
report["new"] += 1
|
|
275
|
+
continue
|
|
276
|
+
if old["status"] == "dismissed": # never resurrected; only its cluster is kept current for _decided_lesson
|
|
277
|
+
ev = loads(old["evidence_json"], {}) or {}
|
|
278
|
+
ids = sorted({*(ev.get("knowledge_ids") or []), *(p["evidence"].get("knowledge_ids") or [])})
|
|
279
|
+
if ids and ev.get("knowledge_ids") != ids:
|
|
280
|
+
conn.execute("UPDATE suggestions SET evidence_json = ? WHERE id = ?", (dumps({**ev, "knowledge_ids": ids}), old["id"]))
|
|
281
|
+
continue
|
|
282
|
+
status, seen_at = old["status"], old["seen_at"]
|
|
283
|
+
if status == "stale":
|
|
284
|
+
status, seen_at = "new", None
|
|
285
|
+
report["new"] += 1
|
|
286
|
+
generated = (loads(old["evidence_json"], {}) or {}).get("generated_text")
|
|
287
|
+
edited = generated is not None and old["text"] != generated
|
|
288
|
+
text = old["text"] if edited or old["status"] in ("applied", "done") else p["text"]
|
|
289
|
+
if status == "applied" and old["origin"] == "friction":
|
|
290
|
+
cause = causes.get(old["cause_id"] or "")
|
|
291
|
+
if cause:
|
|
292
|
+
p["evidence"]["sessions_since_applied"] = friction.sessions_since(cause, old["applied_at"])
|
|
293
|
+
evidence = dumps(p["evidence"])
|
|
294
|
+
# an applied line stays where it was written: undo must find it there, whatever the routing says now
|
|
295
|
+
agent, target = (old["agent"], old["target_path"]) if status in ("applied", "done") else (p["agent"], p["target_path"])
|
|
296
|
+
changed = (text, evidence, warnings, p["score"], status, p["title"], agent, target) != (
|
|
297
|
+
old["text"], old["evidence_json"], old["warnings_json"], old["score"], old["status"], old["title"],
|
|
298
|
+
old["agent"], old["target_path"])
|
|
299
|
+
if changed:
|
|
300
|
+
conn.execute(
|
|
301
|
+
"UPDATE suggestions SET text = ?, title = ?, evidence_json = ?, warnings_json = ?, score = ?, status = ?, "
|
|
302
|
+
"seen_at = ?, agent = ?, target_path = ?, updated_at = ? WHERE id = ?",
|
|
303
|
+
(text, p["title"], evidence, warnings, p["score"], status, seen_at, agent, target, now, old["id"]))
|
|
304
|
+
if old["status"] != "stale":
|
|
305
|
+
report["updated"] += 1
|
|
306
|
+
keys = {p["key"] for p in found}
|
|
307
|
+
# a waiting card whose lesson or cause is now proposed for another file, or waits for room in its file, was only
|
|
308
|
+
# a proposal: it is dropped rather than called stale, and comes back where and when there is room
|
|
309
|
+
live = {i for p in found + waiting if p["origin"] == "knowledge" for i in _lesson_ids(p)}
|
|
310
|
+
live_causes = {p["cause_id"] for p in found if p["origin"] == "friction" and p["kind"] == "instruction"}
|
|
311
|
+
for key, old in existing.items():
|
|
312
|
+
if key in keys:
|
|
313
|
+
continue
|
|
314
|
+
if old["status"] == "new" and old["kind"] == "instruction" and (
|
|
315
|
+
old["cause_id"] in live_causes if old["origin"] == "friction" else bool(live & _lesson_ids(old))):
|
|
316
|
+
conn.execute("DELETE FROM suggestions WHERE id = ?", (old["id"],))
|
|
317
|
+
elif old["status"] == "new":
|
|
318
|
+
conn.execute("UPDATE suggestions SET status = 'stale', updated_at = ? WHERE id = ?", (now, old["id"]))
|
|
319
|
+
report["stale"] += 1
|
|
320
|
+
elif old["status"] == "applied" and old["origin"] == "friction":
|
|
321
|
+
cause = causes.get(old["cause_id"] or "") # no longer proposed (fixed?): keep counting what follows
|
|
322
|
+
if cause:
|
|
323
|
+
ev = loads(old["evidence_json"], {}) or {}
|
|
324
|
+
ev["sessions_since_applied"] = friction.sessions_since(cause, old["applied_at"])
|
|
325
|
+
ev["still_happening"] = cause["still_happening"]
|
|
326
|
+
conn.execute("UPDATE suggestions SET evidence_json = ? WHERE id = ?", (dumps(ev), old["id"]))
|
|
327
|
+
conn.commit()
|
|
328
|
+
return report
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def move(conn: sqlite3.Connection, cfg: Config, sid: int, to: str) -> dict:
|
|
332
|
+
"""Send a waiting instruction line to the user-level file ("user") or back to the files of its projects
|
|
333
|
+
("project"), and remember that over the automatic choice. Every waiting card of the same lesson or cause is made
|
|
334
|
+
again where it now goes, ahead of the 8-a-file limit; applied and dismissed ones stay as they are."""
|
|
335
|
+
s = get(conn, sid)
|
|
336
|
+
if s is None:
|
|
337
|
+
return {"ok": False, "error": tr("no such suggestion")}
|
|
338
|
+
if to not in ("user", "project"):
|
|
339
|
+
return {"ok": False, "error": tr("move to user or project")}
|
|
340
|
+
if s["kind"] != "instruction" or s["status"] != "new" or s["origin"] not in ("friction", "knowledge"):
|
|
341
|
+
return {"ok": False, "error": tr("only an instruction line waiting for review can move")}
|
|
342
|
+
if (s["project_path"] is None) == (to == "user"):
|
|
343
|
+
return {"ok": False, "error": tr("it is already there")}
|
|
344
|
+
ids = _lesson_ids(s)
|
|
345
|
+
subjects = [f"cause:{s['cause_id']}"] if s["origin"] == "friction" else [f"knowledge:{i}" for i in sorted(ids)]
|
|
346
|
+
|
|
347
|
+
def mine(r: dict) -> bool:
|
|
348
|
+
if r["origin"] != s["origin"]:
|
|
349
|
+
return False
|
|
350
|
+
return r["cause_id"] == s["cause_id"] if r["origin"] == "friction" else bool(ids & _lesson_ids(r))
|
|
351
|
+
now = utcnow_iso()
|
|
352
|
+
conn.executemany("INSERT INTO suggestion_scopes(subject, scope, updated_at) VALUES (?, ?, ?) "
|
|
353
|
+
"ON CONFLICT(subject) DO UPDATE SET scope = excluded.scope, updated_at = excluded.updated_at",
|
|
354
|
+
[(x, to, now) for x in subjects])
|
|
355
|
+
conn.commit()
|
|
356
|
+
refresh(conn, cfg) # makes its cards where it now goes and drops the waiting ones where it was
|
|
357
|
+
waiting = "SELECT * FROM suggestions WHERE status = 'new' AND kind = 'instruction'"
|
|
358
|
+
arrived = [r for r in map(dict, conn.execute(waiting)) if mine(r) and (r["project_path"] is None) == (to == "user")]
|
|
359
|
+
if s["text"] != s["evidence"].get("generated_text", s["text"]): # your wording comes along
|
|
360
|
+
conn.executemany("UPDATE suggestions SET text = ? WHERE id = ?", [(s["text"], r["id"]) for r in arrived])
|
|
361
|
+
conn.commit()
|
|
362
|
+
return {"ok": True, "moved": len(arrived), "targets": sorted({r["target_path"] for r in arrived})}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _in_file(p: dict) -> bool:
|
|
366
|
+
"""The target file's Interlatch block already holds this proposal's line, by its marker."""
|
|
367
|
+
if p["kind"] != "instruction" or not p["target_path"]:
|
|
368
|
+
return False
|
|
369
|
+
try:
|
|
370
|
+
return marker(p) in {k for k, _text in instructions.read_block(Path(p["target_path"]))}
|
|
371
|
+
except instructions.MalformedBlock:
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _lesson_ids(s: dict) -> set[int]:
|
|
376
|
+
"""The knowledge items behind a proposal or a stored suggestion: the one it names and the rest of its lesson."""
|
|
377
|
+
ev = s["evidence"] if "evidence" in s else (loads(s.get("evidence_json"), {}) or {})
|
|
378
|
+
return {i for i in (s.get("knowledge_id"), *(ev.get("knowledge_ids") or [])) if i is not None}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _decided_lesson(rows, p: dict) -> bool:
|
|
382
|
+
"""A knowledge proposal for a lesson already dismissed or applied for the same file: one of its knowledge items
|
|
383
|
+
was in that suggestion's cluster, or its title is a near-duplicate of that suggestion's title."""
|
|
384
|
+
if p["origin"] != "knowledge":
|
|
385
|
+
return False
|
|
386
|
+
ids = set(p["evidence"].get("knowledge_ids") or [])
|
|
387
|
+
title = instructions.tokens(p["title"])
|
|
388
|
+
for r in rows:
|
|
389
|
+
if r["origin"] != "knowledge" or r["status"] not in ("dismissed", "applied") or r["target_path"] != p["target_path"]:
|
|
390
|
+
continue
|
|
391
|
+
theirs = {r["knowledge_id"], *((loads(r["evidence_json"], {}) or {}).get("knowledge_ids") or [])}
|
|
392
|
+
if ids & theirs or instructions.jaccard(title, instructions.tokens(r["title"])) >= instructions.JACCARD_DUPLICATE:
|
|
393
|
+
return True
|
|
394
|
+
return False
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def after_sync(conn: sqlite3.Connection, cfg: Config) -> dict[str, int] | None:
|
|
398
|
+
"""The background run's hook: refresh when enabled, and notify about new suggestions when asked to."""
|
|
399
|
+
if not cfg.suggestions_enabled:
|
|
400
|
+
return None
|
|
401
|
+
try:
|
|
402
|
+
report = refresh(conn, cfg)
|
|
403
|
+
except Exception: # never let suggestions break a sync
|
|
404
|
+
log.exception("suggestions refresh failed")
|
|
405
|
+
return None
|
|
406
|
+
if cfg.suggestions_notify and report["new"] > 0:
|
|
407
|
+
from .notify import post
|
|
408
|
+
|
|
409
|
+
post("Interlatch", f"{report['new']} new suggestion{'s' if report['new'] != 1 else ''}. "
|
|
410
|
+
"Open the dashboard > Suggestions")
|
|
411
|
+
return report
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ---------------------------------------------------------------- config changes (~/.claude.json)
|
|
415
|
+
# the only config change Interlatch makes: these flags, on a Playwright MCP server (an edited text cannot widen it)
|
|
416
|
+
CONFIG_FLAGS = {"--isolated": False, "--output-dir": True} # flag -> takes a value
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def parse_config_change(text: str) -> tuple[str, list[str]]:
|
|
420
|
+
"""'mcpServers.<name>.args += [...]' -> (server name, args to add). Raises ValueError otherwise."""
|
|
421
|
+
m = _CONFIG_CHANGE.match(text or "")
|
|
422
|
+
if not m:
|
|
423
|
+
raise ValueError(tr("expected: mcpServers.<name>.args += [\"--flag\", ...]"))
|
|
424
|
+
try:
|
|
425
|
+
args = json.loads(m.group(2))
|
|
426
|
+
except ValueError:
|
|
427
|
+
raise ValueError(tr("the arguments must be a JSON list of strings")) from None
|
|
428
|
+
if not isinstance(args, list) or not all(isinstance(a, str) for a in args):
|
|
429
|
+
raise ValueError(tr("the arguments must be a JSON list of strings"))
|
|
430
|
+
if "playwright" not in m.group(1).lower():
|
|
431
|
+
raise ValueError(tr("Interlatch only changes the Playwright MCP server's arguments"))
|
|
432
|
+
i = 0
|
|
433
|
+
while i < len(args):
|
|
434
|
+
if args[i] not in CONFIG_FLAGS:
|
|
435
|
+
raise ValueError(tr("Interlatch does not set {arg!r}; only {flags}", arg=args[i], flags=", ".join(CONFIG_FLAGS)))
|
|
436
|
+
if CONFIG_FLAGS[args[i]]:
|
|
437
|
+
i += 1
|
|
438
|
+
if i >= len(args) or not args[i].startswith("/"):
|
|
439
|
+
raise ValueError(tr("{flag} needs an absolute path", flag=args[i - 1]))
|
|
440
|
+
i += 1
|
|
441
|
+
return m.group(1), args
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _groups(args: list[str]) -> list[list[str]]:
|
|
445
|
+
"""Split ['--isolated', '--output-dir', 'x'] into [['--isolated'], ['--output-dir', 'x']]."""
|
|
446
|
+
out: list[list[str]] = []
|
|
447
|
+
for a in args:
|
|
448
|
+
if a.startswith("-") or not out:
|
|
449
|
+
out.append([a])
|
|
450
|
+
else:
|
|
451
|
+
out[-1].append(a)
|
|
452
|
+
return out
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def missing_args(current: list[str], wanted: list[str]) -> list[str]:
|
|
456
|
+
"""The groups of `wanted` whose flag `current` lacks (a flag already set is left as it is)."""
|
|
457
|
+
have = set(current)
|
|
458
|
+
return [a for g in _groups(wanted) if g[0] not in have for a in g]
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _load_json(path: Path) -> tuple[str, dict]:
|
|
462
|
+
old = path.read_text()
|
|
463
|
+
data = json.loads(old) if old.strip() else {}
|
|
464
|
+
if not isinstance(data, dict):
|
|
465
|
+
raise ValueError(tr("{path} is not a JSON object", path=path))
|
|
466
|
+
return old, data
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def config_text(name: str, args: list[str]) -> str:
|
|
470
|
+
return f"mcpServers.{name}.args += {json.dumps(args)}"
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def config_state(path: Path, text: str) -> str:
|
|
474
|
+
"""'pending' (the change would do something), 'satisfied', 'no-server', 'no-file' or 'invalid'."""
|
|
475
|
+
try:
|
|
476
|
+
name, wanted = parse_config_change(text)
|
|
477
|
+
_old, data = _load_json(path)
|
|
478
|
+
except FileNotFoundError:
|
|
479
|
+
return "no-file"
|
|
480
|
+
except (OSError, ValueError):
|
|
481
|
+
return "invalid"
|
|
482
|
+
server = (data.get("mcpServers") or {}).get(name)
|
|
483
|
+
if not isinstance(server, dict):
|
|
484
|
+
return "no-server"
|
|
485
|
+
return "pending" if missing_args(list(server.get("args") or []), wanted) else "satisfied"
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def plan_config(path: Path, text: str, *, remove: bool = False) -> tuple[str, str]:
|
|
489
|
+
"""(old file text, new file text) for adding (or removing) the arguments; every other key is kept.
|
|
490
|
+
|
|
491
|
+
Undo passes only the groups apply added (applied_text), so flags you had set yourself stay.
|
|
492
|
+
"""
|
|
493
|
+
name, wanted = parse_config_change(text)
|
|
494
|
+
old, data = _load_json(path)
|
|
495
|
+
server = (data.get("mcpServers") or {}).get(name)
|
|
496
|
+
if not isinstance(server, dict):
|
|
497
|
+
raise ValueError(tr("no MCP server named {name!r} in {path}", name=name, path=path))
|
|
498
|
+
args = list(server.get("args") or [])
|
|
499
|
+
if remove:
|
|
500
|
+
for g in _groups(wanted):
|
|
501
|
+
for i in range(len(args) - len(g) + 1):
|
|
502
|
+
if args[i:i + len(g)] == g:
|
|
503
|
+
del args[i:i + len(g)]
|
|
504
|
+
break
|
|
505
|
+
else:
|
|
506
|
+
args += missing_args(args, wanted)
|
|
507
|
+
server["args"] = args
|
|
508
|
+
new = json.dumps(data, indent=2, ensure_ascii=False)
|
|
509
|
+
return old, new + "\n" if old.endswith("\n") else new # keep the file's own last line as it was
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# ---------------------------------------------------------------- preview / apply
|
|
513
|
+
def _diff(old: str, new: str, path: Path) -> str:
|
|
514
|
+
return "".join(difflib.unified_diff(old.splitlines(keepends=True), new.splitlines(keepends=True),
|
|
515
|
+
fromfile=str(path), tofile=str(path)))
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def marker(s: dict) -> str:
|
|
519
|
+
"""The id written into the file next to the line: short and free of paths (the full key holds the project path,
|
|
520
|
+
which has no business in a committed CLAUDE.md). Unique within one file: one line per cause or lesson."""
|
|
521
|
+
ident = s["cause_id"] if s["origin"] == "friction" else s["knowledge_id"]
|
|
522
|
+
if ident is not None and str(ident).strip() and not re.search(r"[\s/]", str(ident)):
|
|
523
|
+
return f"{s['origin']}:{ident}"
|
|
524
|
+
return "s-" + hashlib.sha1(s["key"].encode()).hexdigest()[:12]
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _plan(s: dict, text: str, *, remove: bool = False) -> tuple[Path, str, str]:
|
|
528
|
+
path = Path(s["target_path"])
|
|
529
|
+
if s["project_path"] and not Path(s["project_path"]).is_dir():
|
|
530
|
+
raise ValueError(tr("the project {path} no longer exists", path=s["project_path"]))
|
|
531
|
+
if s["kind"] == "instruction":
|
|
532
|
+
old = _read(path) if path.exists() else ""
|
|
533
|
+
mark = marker(s)
|
|
534
|
+
new = instructions.merge_file(path, old, [] if remove else [(mark, text)], {mark, s["key"]} if remove else set())
|
|
535
|
+
return path, old, new
|
|
536
|
+
if s["kind"] == "config":
|
|
537
|
+
old, new = plan_config(path, text, remove=remove)
|
|
538
|
+
return path, old, new
|
|
539
|
+
raise ValueError(tr("{kind} suggestions are not written by Interlatch", kind=s["kind"]))
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def preview(conn: sqlite3.Connection, cfg: Config, sid: int, text: str | None = None) -> dict:
|
|
543
|
+
"""The unified diff applying would make, written nowhere. Environment steps return their command instead."""
|
|
544
|
+
s = get(conn, sid)
|
|
545
|
+
if s is None:
|
|
546
|
+
return {"ok": False, "error": tr("no such suggestion")}
|
|
547
|
+
text = (text if text is not None else s["text"]).strip()
|
|
548
|
+
# ~/.claude.json is never committed or shared, so a home path there is not a leak
|
|
549
|
+
warnings = {**(s["warnings"] or {}), "sensitive": [] if s["kind"] == "config" else instructions.sensitive(text)}
|
|
550
|
+
if s["kind"] == "environment":
|
|
551
|
+
return {"ok": True, "diff": "", "path": None, "command": text, "warnings": warnings}
|
|
552
|
+
try:
|
|
553
|
+
path, old, new = _plan(s, text)
|
|
554
|
+
except (OSError, ValueError) as exc:
|
|
555
|
+
return {"ok": False, "error": str(exc), "diff": "", "path": s["target_path"], "warnings": warnings}
|
|
556
|
+
return {"ok": True, "diff": _diff(old, new, path), "path": str(path), "warnings": warnings}
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def apply(conn: sqlite3.Connection, cfg: Config, sid: int, text: str | None = None) -> dict:
|
|
560
|
+
"""Write the suggestion (backing the file up first) and mark it applied. Returns {ok, path, diff}."""
|
|
561
|
+
s = get(conn, sid)
|
|
562
|
+
if s is None:
|
|
563
|
+
return {"ok": False, "error": tr("no such suggestion")}
|
|
564
|
+
if s["kind"] == "environment":
|
|
565
|
+
return {"ok": False, "error": tr("Interlatch does not run setup steps: run the command yourself, then mark it done")}
|
|
566
|
+
text = one_line(text, 2000) if text is not None and s["kind"] == "instruction" else (text or s["text"]).strip()
|
|
567
|
+
applied = text
|
|
568
|
+
evidence = dict(s["evidence"])
|
|
569
|
+
try:
|
|
570
|
+
path, old, new = _plan(s, text)
|
|
571
|
+
if s["kind"] == "instruction" and s["status"] != "applied": # applying again keeps what the first apply found
|
|
572
|
+
evidence["created_file"] = not Path(os.path.realpath(path)).exists()
|
|
573
|
+
if s["kind"] == "config": # remember only what was added, so undo leaves your own flags alone
|
|
574
|
+
name, wanted = parse_config_change(text)
|
|
575
|
+
server = ((json.loads(old) if old.strip() else {}).get("mcpServers") or {}).get(name) or {}
|
|
576
|
+
applied = config_text(name, missing_args(list(server.get("args") or []), wanted))
|
|
577
|
+
if new != old:
|
|
578
|
+
instructions.write_atomic(path, new, cfg=cfg, mkdir=not s["project_path"])
|
|
579
|
+
except (OSError, ValueError) as exc:
|
|
580
|
+
return {"ok": False, "error": str(exc), "path": s["target_path"], "diff": ""}
|
|
581
|
+
now = utcnow_iso()
|
|
582
|
+
conn.execute("UPDATE suggestions SET status = 'applied', text = ?, applied_text = ?, applied_at = ?, updated_at = ?, "
|
|
583
|
+
"seen_at = COALESCE(seen_at, ?), evidence_json = ? WHERE id = ?",
|
|
584
|
+
(text, applied, now, now, now, dumps(evidence), sid))
|
|
585
|
+
conn.commit()
|
|
586
|
+
return {"ok": True, "path": str(path), "diff": _diff(old, new, path), "applied_text": applied}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def unapply(conn: sqlite3.Connection, cfg: Config, sid: int) -> dict:
|
|
590
|
+
"""Take an applied suggestion back out of its file (or an environment step out of 'done'); it returns to 'new'."""
|
|
591
|
+
s = get(conn, sid)
|
|
592
|
+
if s is None:
|
|
593
|
+
return {"ok": False, "error": tr("no such suggestion")}
|
|
594
|
+
diff, path = "", None
|
|
595
|
+
gone = bool(s["project_path"]) and not Path(s["project_path"]).is_dir() # the line went with the project
|
|
596
|
+
if s["kind"] != "environment" and s["status"] == "applied" and not gone:
|
|
597
|
+
try:
|
|
598
|
+
path, old, new = _plan(s, s["applied_text"] or s["text"], remove=True)
|
|
599
|
+
if not new.strip() and s["evidence"].get("created_file") and s["kind"] == "instruction":
|
|
600
|
+
instructions.write_atomic(path, new, cfg=cfg, mkdir=False) # backs it up first
|
|
601
|
+
Path(os.path.realpath(path)).unlink(missing_ok=True) # Interlatch made this file: leave none behind
|
|
602
|
+
elif new != old:
|
|
603
|
+
instructions.write_atomic(path, new, cfg=cfg, mkdir=not s["project_path"])
|
|
604
|
+
diff = _diff(old, new, path)
|
|
605
|
+
except (OSError, ValueError) as exc:
|
|
606
|
+
return {"ok": False, "error": str(exc)}
|
|
607
|
+
conn.execute("UPDATE suggestions SET status = 'new', applied_at = NULL, applied_text = NULL, updated_at = ? "
|
|
608
|
+
"WHERE id = ?", (utcnow_iso(), sid))
|
|
609
|
+
conn.commit()
|
|
610
|
+
return {"ok": True, "path": str(path) if path else None, "diff": diff}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def edit_text(conn: sqlite3.Connection, sid: int, text: str) -> dict:
|
|
614
|
+
"""Keep an edited proposal text; refresh will not overwrite it."""
|
|
615
|
+
text = (text or "").strip()
|
|
616
|
+
if not text:
|
|
617
|
+
return {"ok": False, "error": tr("empty text")}
|
|
618
|
+
cur = conn.execute("UPDATE suggestions SET text = ?, updated_at = ? WHERE id = ? AND status IN ('new', 'stale')",
|
|
619
|
+
(text, utcnow_iso(), sid))
|
|
620
|
+
conn.commit()
|
|
621
|
+
return {"ok": cur.rowcount == 1}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def dismiss(conn: sqlite3.Connection, sid: int, reason: str | None = None) -> dict:
|
|
625
|
+
s = get(conn, sid)
|
|
626
|
+
if s is not None and s["status"] == "applied":
|
|
627
|
+
return {"ok": False, "error": tr("this one is applied: undo it first, so its line leaves the file")}
|
|
628
|
+
cur = conn.execute("UPDATE suggestions SET status = 'dismissed', dismissed_reason = ?, updated_at = ? WHERE id = ?",
|
|
629
|
+
((reason or "").strip() or None, utcnow_iso(), sid))
|
|
630
|
+
conn.commit()
|
|
631
|
+
return {"ok": cur.rowcount == 1}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def mark_done(conn: sqlite3.Connection, sid: int) -> dict:
|
|
635
|
+
"""You ran an environment step yourself."""
|
|
636
|
+
s = get(conn, sid)
|
|
637
|
+
if s is None or s["kind"] != "environment":
|
|
638
|
+
return {"ok": False, "error": tr("only environment steps are marked done")}
|
|
639
|
+
now = utcnow_iso()
|
|
640
|
+
conn.execute("UPDATE suggestions SET status = 'done', applied_at = ?, applied_text = text, updated_at = ? WHERE id = ?",
|
|
641
|
+
(now, now, sid))
|
|
642
|
+
conn.commit()
|
|
643
|
+
return {"ok": True}
|