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.
Files changed (118) hide show
  1. chronicle/__init__.py +17 -0
  2. chronicle/__main__.py +3 -0
  3. chronicle/access.py +93 -0
  4. chronicle/agents.py +23 -0
  5. chronicle/analyze.py +498 -0
  6. chronicle/antigravity_parser.py +311 -0
  7. chronicle/artifacts.py +621 -0
  8. chronicle/bob_parser.py +259 -0
  9. chronicle/cases.py +75 -0
  10. chronicle/chat_import.py +325 -0
  11. chronicle/chatgpt_export.py +127 -0
  12. chronicle/claude_export.py +129 -0
  13. chronicle/cli.py +2980 -0
  14. chronicle/codex_cloud.py +138 -0
  15. chronicle/codex_parser.py +753 -0
  16. chronicle/config.py +627 -0
  17. chronicle/connectors.py +666 -0
  18. chronicle/container.py +202 -0
  19. chronicle/copilot_parser.py +494 -0
  20. chronicle/db.py +598 -0
  21. chronicle/desktop.py +543 -0
  22. chronicle/diagram.py +488 -0
  23. chronicle/digest.py +194 -0
  24. chronicle/export_md.py +210 -0
  25. chronicle/friction.py +841 -0
  26. chronicle/glossary.py +522 -0
  27. chronicle/groups.py +181 -0
  28. chronicle/hooks.py +169 -0
  29. chronicle/hub.py +2291 -0
  30. chronicle/i18n.py +37 -0
  31. chronicle/i18n_ja.py +421 -0
  32. chronicle/ingest.py +1190 -0
  33. chronicle/install.py +590 -0
  34. chronicle/instructions.py +699 -0
  35. chronicle/ladder.py +244 -0
  36. chronicle/learning.py +170 -0
  37. chronicle/llm.py +570 -0
  38. chronicle/mcp_server.py +403 -0
  39. chronicle/menubar.py +771 -0
  40. chronicle/migrate.py +580 -0
  41. chronicle/mirror.py +453 -0
  42. chronicle/news.py +110 -0
  43. chronicle/notify.py +92 -0
  44. chronicle/parser.py +908 -0
  45. chronicle/people.py +536 -0
  46. chronicle/pg.py +122 -0
  47. chronicle/pricing.py +168 -0
  48. chronicle/providers.py +591 -0
  49. chronicle/redact.py +50 -0
  50. chronicle/reviews.py +251 -0
  51. chronicle/screen.py +308 -0
  52. chronicle/search.py +238 -0
  53. chronicle/server.py +3009 -0
  54. chronicle/session_export.py +178 -0
  55. chronicle/statusline.py +258 -0
  56. chronicle/suggest.py +643 -0
  57. chronicle/synthesize.py +365 -0
  58. chronicle/systems.py +1265 -0
  59. chronicle/tailnet.py +97 -0
  60. chronicle/team_store.py +450 -0
  61. chronicle/update.py +404 -0
  62. chronicle/util.py +217 -0
  63. chronicle/views.py +249 -0
  64. chronicle/web/PLEX-OFL.txt +93 -0
  65. chronicle/web/app.css +2350 -0
  66. chronicle/web/app.js +7587 -0
  67. chronicle/web/art-book.webp +0 -0
  68. chronicle/web/art-camera.webp +0 -0
  69. chronicle/web/art-chip.webp +0 -0
  70. chronicle/web/art-clock.webp +0 -0
  71. chronicle/web/art-cloud.webp +0 -0
  72. chronicle/web/art-coins.webp +0 -0
  73. chronicle/web/art-container.webp +0 -0
  74. chronicle/web/art-dashboard.webp +0 -0
  75. chronicle/web/art-dex.webp +0 -0
  76. chronicle/web/art-document.webp +0 -0
  77. chronicle/web/art-flag.webp +0 -0
  78. chronicle/web/art-folder.webp +0 -0
  79. chronicle/web/art-gateway.webp +0 -0
  80. chronicle/web/art-gears.webp +0 -0
  81. chronicle/web/art-glitch.webp +0 -0
  82. chronicle/web/art-globe.webp +0 -0
  83. chronicle/web/art-laptop.webp +0 -0
  84. chronicle/web/art-log.webp +0 -0
  85. chronicle/web/art-lumi.webp +0 -0
  86. chronicle/web/art-magnifier.webp +0 -0
  87. chronicle/web/art-pip.webp +0 -0
  88. chronicle/web/art-plug.webp +0 -0
  89. chronicle/web/art-rack.webp +0 -0
  90. chronicle/web/art-tink.webp +0 -0
  91. chronicle/web/boot.js +25 -0
  92. chronicle/web/favicon.png +0 -0
  93. chronicle/web/i18n.js +50 -0
  94. chronicle/web/icon-180.png +0 -0
  95. chronicle/web/icon-192.png +0 -0
  96. chronicle/web/icon-512.png +0 -0
  97. chronicle/web/icon.png +0 -0
  98. chronicle/web/index.html +92 -0
  99. chronicle/web/ja.js +1880 -0
  100. chronicle/web/learning.js +438 -0
  101. chronicle/web/manifest.webmanifest +15 -0
  102. chronicle/web/panel.css +175 -0
  103. chronicle/web/panel.html +14 -0
  104. chronicle/web/panel.js +241 -0
  105. chronicle/web/plex-mono-400.woff2 +0 -0
  106. chronicle/web/plex-mono-500.woff2 +0 -0
  107. chronicle/web/plex-mono-600.woff2 +0 -0
  108. chronicle/web/plex-sans-400.woff2 +0 -0
  109. chronicle/web/plex-sans-500.woff2 +0 -0
  110. chronicle/web/plex-sans-600.woff2 +0 -0
  111. chronicle/web/plex-sans-700.woff2 +0 -0
  112. chronicle/web/rough.js +2 -0
  113. chronicle/worker.py +390 -0
  114. interlatch-0.22.0.dist-info/METADATA +221 -0
  115. interlatch-0.22.0.dist-info/RECORD +118 -0
  116. interlatch-0.22.0.dist-info/WHEEL +4 -0
  117. interlatch-0.22.0.dist-info/entry_points.txt +3 -0
  118. 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}