omnish 1.6.6 → 2.0.0
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.
- package/CHANGELOG.md +32 -0
- package/README.md +26 -18
- package/config.example.json +1 -0
- package/dist/downloads/omnish-claude/install.sh +174 -0
- package/dist/downloads/omnish-claude/uninstall.sh +114 -0
- package/dist/downloads/omnish-claude.tar.gz +0 -0
- package/dist/downloads/omnish-cursor/install.sh +162 -0
- package/dist/downloads/omnish-cursor/uninstall.sh +107 -0
- package/dist/downloads/omnish-cursor.tar.gz +0 -0
- package/dist/index.js +424 -392
- package/dist/omnish-claude/README.md +80 -0
- package/dist/omnish-claude/hooks/omnish-notify.py +213 -0
- package/dist/omnish-claude/hooks/omnish-notify.sh +13 -0
- package/dist/omnish-claude/install.sh +174 -0
- package/dist/omnish-claude/omnish-notify.json.example +8 -0
- package/dist/omnish-claude/scripts/doctor.sh +99 -0
- package/dist/omnish-claude/skill/SKILL.md +39 -0
- package/dist/omnish-claude/skill/files-and-sharing.md +94 -0
- package/dist/omnish-claude/skill/notifications.md +113 -0
- package/dist/omnish-claude/skill/setup-paths.md +81 -0
- package/dist/omnish-claude/uninstall.sh +114 -0
- package/dist/omnish-cursor/README.md +176 -0
- package/dist/omnish-cursor/hooks/__pycache__/omnish-notify.cpython-313.pyc +0 -0
- package/dist/omnish-cursor/hooks/omnish-notify.py +563 -0
- package/dist/omnish-cursor/hooks/omnish-notify.sh +13 -0
- package/dist/omnish-cursor/hooks/omnish-session-start.sh +48 -0
- package/dist/omnish-cursor/install.sh +162 -0
- package/dist/omnish-cursor/omnish-notify.json.example +13 -0
- package/dist/omnish-cursor/rules/omnish-notify.mdc +45 -0
- package/dist/omnish-cursor/scripts/doctor.sh +126 -0
- package/dist/omnish-cursor/skill/SKILL.md +129 -0
- package/dist/omnish-cursor/skill/files-and-sharing.md +94 -0
- package/dist/omnish-cursor/skill/notifications.md +155 -0
- package/dist/omnish-cursor/skill/setup-paths.md +81 -0
- package/dist/omnish-cursor/uninstall.sh +107 -0
- package/dist/ui/assets/{index-aUJGrxrr.js → index-BwG51a2I.js} +10 -10
- package/dist/ui/index.html +16 -1
- package/package.json +12 -4
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cursor stop hook: send curated Omnish briefs when agent work is worth notifying."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
CONFIG_PATH = Path.home() / ".cursor" / "omnish-notify.json"
|
|
17
|
+
LOG_PATH = Path.home() / ".cursor" / "hooks" / "omnish-notify.log"
|
|
18
|
+
STATE_PATH = Path.home() / ".cursor" / "hooks" / "omnish-notify-state.json"
|
|
19
|
+
CHATS_ROOT = Path.home() / ".cursor" / "chats"
|
|
20
|
+
|
|
21
|
+
# Only edits/deliverables count as substantive — Shell/Grep/Read alone should not ping.
|
|
22
|
+
WRITE_TOOLS = {
|
|
23
|
+
"Write",
|
|
24
|
+
"StrReplace",
|
|
25
|
+
"Delete",
|
|
26
|
+
"EditNotebook",
|
|
27
|
+
"ApplyPatch",
|
|
28
|
+
"GenerateImage",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
ACTION_TOOLS = WRITE_TOOLS | {
|
|
32
|
+
"Shell",
|
|
33
|
+
"Task",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def log(message: str) -> None:
|
|
40
|
+
try:
|
|
41
|
+
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
with LOG_PATH.open("a", encoding="utf-8") as handle:
|
|
43
|
+
handle.write(message.rstrip() + "\n")
|
|
44
|
+
except OSError:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config() -> dict[str, Any]:
|
|
49
|
+
defaults: dict[str, Any] = {
|
|
50
|
+
"enabled": True,
|
|
51
|
+
"peer": os.environ.get("OMNISH_CURSOR_NOTIFY_PEER", "*"),
|
|
52
|
+
"min_assistant_chars": 120,
|
|
53
|
+
"notify_on_error": True,
|
|
54
|
+
"skip_aborted": True,
|
|
55
|
+
"include_full_chat_id": True,
|
|
56
|
+
"summary_max_chars": 300,
|
|
57
|
+
"followup_max_chars": 1000,
|
|
58
|
+
"max_followup_messages": 0,
|
|
59
|
+
"send_followups": False,
|
|
60
|
+
"dedupe_minutes": 45,
|
|
61
|
+
}
|
|
62
|
+
if CONFIG_PATH.exists():
|
|
63
|
+
try:
|
|
64
|
+
defaults.update(json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
|
|
65
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
66
|
+
log(f"config read failed: {exc}")
|
|
67
|
+
return defaults
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def strip_user_query(text: str) -> str:
|
|
71
|
+
cleaned = re.sub(r"</?user_query>", "", text, flags=re.IGNORECASE)
|
|
72
|
+
lines = [re.sub(r"\s+", " ", line).strip() for line in cleaned.splitlines()]
|
|
73
|
+
return "\n".join(line for line in lines if line).strip()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def normalize_notify_text(text: str) -> str:
|
|
77
|
+
text = re.sub(r"\[REDACTED\]", "", text)
|
|
78
|
+
lines = [re.sub(r"[ \t]+", " ", line).strip() for line in text.splitlines()]
|
|
79
|
+
compact = "\n".join(line for line in lines if line).strip()
|
|
80
|
+
return re.sub(r"\n{3,}", "\n\n", compact)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def truncate_at_sentence(text: str, max_chars: int) -> tuple[str, bool]:
|
|
84
|
+
compact = normalize_notify_text(text)
|
|
85
|
+
if len(compact) <= max_chars:
|
|
86
|
+
return compact, False
|
|
87
|
+
|
|
88
|
+
window = compact[: max_chars + 1]
|
|
89
|
+
last_break = -1
|
|
90
|
+
for match in SENTENCE_END.finditer(window):
|
|
91
|
+
if match.start() <= max_chars:
|
|
92
|
+
last_break = match.start()
|
|
93
|
+
|
|
94
|
+
if last_break > max_chars // 2:
|
|
95
|
+
return compact[:last_break].rstrip(), True
|
|
96
|
+
|
|
97
|
+
trimmed = compact[:max_chars].rstrip()
|
|
98
|
+
if " " in trimmed:
|
|
99
|
+
trimmed = trimmed.rsplit(" ", 1)[0].rstrip()
|
|
100
|
+
return trimmed + "…", True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def extract_summary(text: str, max_chars: int) -> str:
|
|
104
|
+
compact = normalize_notify_text(text)
|
|
105
|
+
if not compact:
|
|
106
|
+
return ""
|
|
107
|
+
|
|
108
|
+
sentences: list[str] = []
|
|
109
|
+
for part in SENTENCE_END.split(compact):
|
|
110
|
+
piece = part.strip()
|
|
111
|
+
if not piece:
|
|
112
|
+
continue
|
|
113
|
+
if not piece.endswith((".", "!", "?")):
|
|
114
|
+
piece += "."
|
|
115
|
+
sentences.append(piece)
|
|
116
|
+
joined = " ".join(sentences)
|
|
117
|
+
if len(joined) > max_chars:
|
|
118
|
+
sentences.pop()
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
if sentences:
|
|
122
|
+
summary = " ".join(sentences).strip()
|
|
123
|
+
if len(summary) <= max_chars:
|
|
124
|
+
return summary
|
|
125
|
+
|
|
126
|
+
return truncate_at_sentence(compact, max_chars)[0]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def chunk_text(text: str, max_chars: int) -> list[str]:
|
|
130
|
+
compact = normalize_notify_text(text)
|
|
131
|
+
if not compact:
|
|
132
|
+
return []
|
|
133
|
+
if len(compact) <= max_chars:
|
|
134
|
+
return [compact]
|
|
135
|
+
|
|
136
|
+
chunks: list[str] = []
|
|
137
|
+
remaining = compact
|
|
138
|
+
while remaining:
|
|
139
|
+
if len(remaining) <= max_chars:
|
|
140
|
+
chunks.append(remaining)
|
|
141
|
+
break
|
|
142
|
+
piece, truncated = truncate_at_sentence(remaining, max_chars)
|
|
143
|
+
if not piece:
|
|
144
|
+
chunks.append(remaining[:max_chars].rstrip() + "…")
|
|
145
|
+
break
|
|
146
|
+
chunks.append(piece)
|
|
147
|
+
if not truncated:
|
|
148
|
+
break
|
|
149
|
+
core = piece[:-1].rstrip() if piece.endswith("…") else piece
|
|
150
|
+
idx = remaining.find(core)
|
|
151
|
+
if idx == -1:
|
|
152
|
+
remaining = remaining[max_chars:].lstrip()
|
|
153
|
+
else:
|
|
154
|
+
remaining = remaining[idx + len(core) :].lstrip()
|
|
155
|
+
return chunks
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def flatten_text(content: Any) -> str:
|
|
159
|
+
if isinstance(content, str):
|
|
160
|
+
return content.strip()
|
|
161
|
+
if isinstance(content, list):
|
|
162
|
+
parts: list[str] = []
|
|
163
|
+
for item in content:
|
|
164
|
+
if isinstance(item, dict):
|
|
165
|
+
if item.get("type") == "text" and isinstance(item.get("text"), str):
|
|
166
|
+
parts.append(item["text"])
|
|
167
|
+
elif isinstance(item.get("text"), str):
|
|
168
|
+
parts.append(item["text"])
|
|
169
|
+
elif isinstance(item, str):
|
|
170
|
+
parts.append(item)
|
|
171
|
+
return "\n".join(part.strip() for part in parts if part.strip()).strip()
|
|
172
|
+
return ""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_transcript(path: str | None) -> dict[str, Any]:
|
|
176
|
+
result: dict[str, Any] = {
|
|
177
|
+
"user_query": "",
|
|
178
|
+
"assistant_summary": "",
|
|
179
|
+
"tool_count": 0,
|
|
180
|
+
"action_tool_count": 0,
|
|
181
|
+
"write_tool_count": 0,
|
|
182
|
+
"assistant_chars": 0,
|
|
183
|
+
"turns": 0,
|
|
184
|
+
}
|
|
185
|
+
if not path:
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
transcript_path = Path(path)
|
|
189
|
+
if not transcript_path.is_file():
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
lines = transcript_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
log(f"transcript read failed: {exc}")
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
last_user = ""
|
|
199
|
+
last_assistant = ""
|
|
200
|
+
last_assistant_nonempty = ""
|
|
201
|
+
|
|
202
|
+
for line in lines:
|
|
203
|
+
line = line.strip()
|
|
204
|
+
if not line:
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
entry = json.loads(line)
|
|
208
|
+
except json.JSONDecodeError:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
role = entry.get("role")
|
|
212
|
+
message = entry.get("message") or {}
|
|
213
|
+
content = message.get("content")
|
|
214
|
+
|
|
215
|
+
if role == "user":
|
|
216
|
+
text = flatten_text(content)
|
|
217
|
+
query = strip_user_query(text)
|
|
218
|
+
if query:
|
|
219
|
+
last_user = query
|
|
220
|
+
result["turns"] += 1
|
|
221
|
+
elif role == "assistant":
|
|
222
|
+
if isinstance(content, list):
|
|
223
|
+
for item in content:
|
|
224
|
+
if not isinstance(item, dict):
|
|
225
|
+
continue
|
|
226
|
+
if item.get("type") == "tool_use":
|
|
227
|
+
result["tool_count"] += 1
|
|
228
|
+
tool_name = item.get("name") or item.get("tool_name")
|
|
229
|
+
if tool_name in ACTION_TOOLS:
|
|
230
|
+
result["action_tool_count"] += 1
|
|
231
|
+
if tool_name in WRITE_TOOLS:
|
|
232
|
+
result["write_tool_count"] += 1
|
|
233
|
+
elif item.get("type") == "text":
|
|
234
|
+
text = item.get("text", "")
|
|
235
|
+
if text.strip():
|
|
236
|
+
last_assistant = text.strip()
|
|
237
|
+
cleaned = re.sub(r"\[REDACTED\]", "", text).strip()
|
|
238
|
+
if cleaned:
|
|
239
|
+
last_assistant_nonempty = cleaned
|
|
240
|
+
elif isinstance(content, str) and content.strip():
|
|
241
|
+
last_assistant = content.strip()
|
|
242
|
+
cleaned = re.sub(r"\[REDACTED\]", "", content).strip()
|
|
243
|
+
if cleaned:
|
|
244
|
+
last_assistant_nonempty = cleaned
|
|
245
|
+
|
|
246
|
+
result["user_query"] = last_user
|
|
247
|
+
result["assistant_summary"] = last_assistant_nonempty or last_assistant
|
|
248
|
+
result["assistant_chars"] = len(last_assistant)
|
|
249
|
+
return result
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def find_chat_title(conversation_id: str) -> str:
|
|
253
|
+
if not conversation_id or not CHATS_ROOT.is_dir():
|
|
254
|
+
return ""
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
for workspace_dir in CHATS_ROOT.iterdir():
|
|
258
|
+
meta_path = workspace_dir / conversation_id / "meta.json"
|
|
259
|
+
if not meta_path.is_file():
|
|
260
|
+
continue
|
|
261
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
262
|
+
title = str(meta.get("title") or "").strip()
|
|
263
|
+
if title:
|
|
264
|
+
return title
|
|
265
|
+
except (OSError, json.JSONDecodeError):
|
|
266
|
+
pass
|
|
267
|
+
return ""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def should_notify(payload: dict[str, Any], transcript: dict[str, Any], cfg: dict[str, Any]) -> bool:
|
|
271
|
+
status = payload.get("status", "completed")
|
|
272
|
+
if cfg.get("skip_aborted") and status == "aborted":
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
if status == "error" and cfg.get("notify_on_error"):
|
|
276
|
+
return True
|
|
277
|
+
|
|
278
|
+
# Substantive = repo/file changes. Read-only Shell/Grep turns stay silent.
|
|
279
|
+
if transcript.get("write_tool_count", 0) > 0:
|
|
280
|
+
return True
|
|
281
|
+
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def notify_fingerprint(conversation_id: str, user_query: str) -> str:
|
|
286
|
+
normalized = re.sub(r"\s+", " ", (user_query or "").lower().strip())[:500]
|
|
287
|
+
raw = f"{conversation_id}|{normalized}".encode("utf-8")
|
|
288
|
+
return hashlib.sha256(raw).hexdigest()[:20]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def load_notify_state() -> dict[str, Any]:
|
|
292
|
+
if not STATE_PATH.is_file():
|
|
293
|
+
return {"entries": {}}
|
|
294
|
+
try:
|
|
295
|
+
data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
|
296
|
+
if isinstance(data, dict) and isinstance(data.get("entries"), dict):
|
|
297
|
+
return data
|
|
298
|
+
except (OSError, json.JSONDecodeError):
|
|
299
|
+
pass
|
|
300
|
+
return {"entries": {}}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def save_notify_state(state: dict[str, Any]) -> None:
|
|
304
|
+
try:
|
|
305
|
+
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
entries = state.get("entries") or {}
|
|
307
|
+
if len(entries) > 200:
|
|
308
|
+
sorted_keys = sorted(
|
|
309
|
+
entries.keys(),
|
|
310
|
+
key=lambda k: float((entries[k] or {}).get("ts") or 0),
|
|
311
|
+
)
|
|
312
|
+
for key in sorted_keys[: len(entries) - 200]:
|
|
313
|
+
entries.pop(key, None)
|
|
314
|
+
STATE_PATH.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
|
|
315
|
+
except OSError as exc:
|
|
316
|
+
log(f"state write failed: {exc}")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def is_duplicate_notify(
|
|
320
|
+
conversation_id: str,
|
|
321
|
+
user_query: str,
|
|
322
|
+
cfg: dict[str, Any],
|
|
323
|
+
) -> bool:
|
|
324
|
+
cooldown_min = int(cfg.get("dedupe_minutes") or 45)
|
|
325
|
+
if cooldown_min <= 0 or not conversation_id:
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
fp = notify_fingerprint(conversation_id, user_query)
|
|
329
|
+
state = load_notify_state()
|
|
330
|
+
entries = state.setdefault("entries", {})
|
|
331
|
+
prev = entries.get(fp)
|
|
332
|
+
now = time.time()
|
|
333
|
+
if prev and now - float(prev.get("ts") or 0) < cooldown_min * 60:
|
|
334
|
+
return True
|
|
335
|
+
|
|
336
|
+
entries[fp] = {"ts": now}
|
|
337
|
+
save_notify_state(state)
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def build_title(payload: dict[str, Any], cfg: dict[str, Any]) -> str:
|
|
342
|
+
conversation_id = str(payload.get("conversation_id") or "").strip()
|
|
343
|
+
status = str(payload.get("status") or "completed")
|
|
344
|
+
chat_title = find_chat_title(conversation_id)
|
|
345
|
+
|
|
346
|
+
if status == "error":
|
|
347
|
+
base = "Cursor Error"
|
|
348
|
+
elif status == "aborted":
|
|
349
|
+
base = "Cursor Aborted"
|
|
350
|
+
else:
|
|
351
|
+
base = "Cursor Done"
|
|
352
|
+
|
|
353
|
+
if chat_title:
|
|
354
|
+
short_title, _ = truncate_at_sentence(chat_title, 48)
|
|
355
|
+
return f"{base}: {short_title}"
|
|
356
|
+
return base
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def build_main_brief(
|
|
360
|
+
payload: dict[str, Any],
|
|
361
|
+
transcript: dict[str, Any],
|
|
362
|
+
cfg: dict[str, Any],
|
|
363
|
+
) -> str:
|
|
364
|
+
conversation_id = str(payload.get("conversation_id") or "").strip()
|
|
365
|
+
short_id = conversation_id[:8] if conversation_id else "unknown"
|
|
366
|
+
status = str(payload.get("status") or "completed")
|
|
367
|
+
workspace_roots = payload.get("workspace_roots") or []
|
|
368
|
+
workspace = workspace_roots[0] if workspace_roots else ""
|
|
369
|
+
|
|
370
|
+
summary_limit = int(cfg.get("summary_max_chars") or 300)
|
|
371
|
+
user_query = normalize_notify_text(transcript.get("user_query") or "")
|
|
372
|
+
assistant_summary = normalize_notify_text(transcript.get("assistant_summary") or "")
|
|
373
|
+
|
|
374
|
+
lines: list[str] = []
|
|
375
|
+
|
|
376
|
+
if status == "error":
|
|
377
|
+
lines.append("Status: Error")
|
|
378
|
+
elif status == "aborted":
|
|
379
|
+
lines.append("Status: Aborted")
|
|
380
|
+
else:
|
|
381
|
+
lines.append("Status: Done")
|
|
382
|
+
|
|
383
|
+
if cfg.get("include_full_chat_id", True) and conversation_id:
|
|
384
|
+
lines.append(f"Chat: {conversation_id}")
|
|
385
|
+
else:
|
|
386
|
+
lines.append(f"Chat: {short_id}")
|
|
387
|
+
|
|
388
|
+
if workspace:
|
|
389
|
+
lines.append(f"Workspace: {Path(workspace).name}")
|
|
390
|
+
|
|
391
|
+
if user_query:
|
|
392
|
+
preview, truncated = truncate_at_sentence(user_query, min(summary_limit, 220))
|
|
393
|
+
lines.append("")
|
|
394
|
+
lines.append("You asked:")
|
|
395
|
+
lines.append(preview)
|
|
396
|
+
if truncated:
|
|
397
|
+
lines.append("(see follow-up for full request)")
|
|
398
|
+
|
|
399
|
+
lines.append("")
|
|
400
|
+
lines.append("Outcome:")
|
|
401
|
+
if assistant_summary:
|
|
402
|
+
lines.append(extract_summary(assistant_summary, summary_limit))
|
|
403
|
+
elif status == "error":
|
|
404
|
+
lines.append("Agent ended with an error.")
|
|
405
|
+
else:
|
|
406
|
+
lines.append("Agent finished.")
|
|
407
|
+
|
|
408
|
+
if transcript["action_tool_count"] > 0:
|
|
409
|
+
lines.append("")
|
|
410
|
+
lines.append(f"Tools: {transcript['action_tool_count']} action(s)")
|
|
411
|
+
|
|
412
|
+
return "\n".join(lines).strip()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def build_followups(
|
|
416
|
+
payload: dict[str, Any],
|
|
417
|
+
transcript: dict[str, Any],
|
|
418
|
+
cfg: dict[str, Any],
|
|
419
|
+
) -> list[tuple[str, str]]:
|
|
420
|
+
if not cfg.get("send_followups", True):
|
|
421
|
+
return []
|
|
422
|
+
|
|
423
|
+
max_followups = max(0, int(cfg.get("max_followup_messages") or 2))
|
|
424
|
+
if max_followups == 0:
|
|
425
|
+
return []
|
|
426
|
+
|
|
427
|
+
followup_limit = int(cfg.get("followup_max_chars") or 1000)
|
|
428
|
+
summary_limit = int(cfg.get("summary_max_chars") or 300)
|
|
429
|
+
conversation_id = str(payload.get("conversation_id") or "").strip()
|
|
430
|
+
short_id = conversation_id[:8] if conversation_id else "unknown"
|
|
431
|
+
chat_ref = conversation_id if cfg.get("include_full_chat_id", True) and conversation_id else short_id
|
|
432
|
+
|
|
433
|
+
user_query = normalize_notify_text(transcript.get("user_query") or "")
|
|
434
|
+
assistant_summary = normalize_notify_text(transcript.get("assistant_summary") or "")
|
|
435
|
+
|
|
436
|
+
followups: list[tuple[str, str]] = []
|
|
437
|
+
|
|
438
|
+
if user_query:
|
|
439
|
+
preview, was_truncated = truncate_at_sentence(user_query, min(summary_limit, 220))
|
|
440
|
+
if was_truncated or len(user_query) > len(preview) + 20:
|
|
441
|
+
for index, chunk in enumerate(chunk_text(user_query, followup_limit), start=1):
|
|
442
|
+
if len(followups) >= max_followups:
|
|
443
|
+
break
|
|
444
|
+
suffix = f" ({index})" if len(chunk_text(user_query, followup_limit)) > 1 else ""
|
|
445
|
+
body = f"Chat: {chat_ref}\n\nYou asked:\n{chunk}"
|
|
446
|
+
followups.append((f"Cursor · Request{suffix}", body))
|
|
447
|
+
|
|
448
|
+
if assistant_summary and len(followups) < max_followups:
|
|
449
|
+
summary = extract_summary(assistant_summary, summary_limit)
|
|
450
|
+
summary_core = summary.rstrip("…")
|
|
451
|
+
tail = assistant_summary
|
|
452
|
+
if summary_core and tail.startswith(summary_core):
|
|
453
|
+
tail = tail[len(summary_core) :].lstrip(" .,;:-")
|
|
454
|
+
if len(tail) >= 40:
|
|
455
|
+
parts = chunk_text(tail, followup_limit)
|
|
456
|
+
for index, chunk in enumerate(parts, start=1):
|
|
457
|
+
if len(followups) >= max_followups:
|
|
458
|
+
break
|
|
459
|
+
suffix = f" ({index})" if len(parts) > 1 else ""
|
|
460
|
+
body = f"Chat: {chat_ref}\n\nDetails:\n{chunk}"
|
|
461
|
+
followups.append((f"Cursor · Outcome{suffix}", body))
|
|
462
|
+
|
|
463
|
+
return followups[:max_followups]
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def build_messages(
|
|
467
|
+
payload: dict[str, Any],
|
|
468
|
+
transcript: dict[str, Any],
|
|
469
|
+
cfg: dict[str, Any],
|
|
470
|
+
) -> list[tuple[str, str]]:
|
|
471
|
+
title = build_title(payload, cfg)
|
|
472
|
+
main_brief = build_main_brief(payload, transcript, cfg)
|
|
473
|
+
if not main_brief.strip():
|
|
474
|
+
return []
|
|
475
|
+
|
|
476
|
+
messages = [(title, main_brief)]
|
|
477
|
+
messages.extend(build_followups(payload, transcript, cfg))
|
|
478
|
+
return messages
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def send_notification(title: str, brief: str, peer: str) -> None:
|
|
482
|
+
destination = (peer or "*").strip() or "*"
|
|
483
|
+
env = os.environ.copy()
|
|
484
|
+
if destination != "*":
|
|
485
|
+
env["OMNISH_PEER_KEY"] = destination
|
|
486
|
+
|
|
487
|
+
inner = f"/sendto {destination} -t {title} -- {brief}"
|
|
488
|
+
subprocess.run(
|
|
489
|
+
["omnish", "i", "-c", inner],
|
|
490
|
+
check=False,
|
|
491
|
+
env=env,
|
|
492
|
+
stdout=subprocess.DEVNULL,
|
|
493
|
+
stderr=subprocess.DEVNULL,
|
|
494
|
+
timeout=30,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def which(name: str) -> str | None:
|
|
499
|
+
for directory in os.environ.get("PATH", "").split(os.pathsep):
|
|
500
|
+
candidate = Path(directory) / name
|
|
501
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
502
|
+
return str(candidate)
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def main() -> int:
|
|
507
|
+
try:
|
|
508
|
+
payload = json.load(sys.stdin)
|
|
509
|
+
except json.JSONDecodeError as exc:
|
|
510
|
+
log(f"invalid hook payload: {exc}")
|
|
511
|
+
print("{}")
|
|
512
|
+
return 0
|
|
513
|
+
|
|
514
|
+
cfg = load_config()
|
|
515
|
+
if not cfg.get("enabled", True):
|
|
516
|
+
print("{}")
|
|
517
|
+
return 0
|
|
518
|
+
|
|
519
|
+
transcript = parse_transcript(payload.get("transcript_path"))
|
|
520
|
+
if not should_notify(payload, transcript, cfg):
|
|
521
|
+
log(f"skip notify chat={payload.get('conversation_id')} status={payload.get('status')}")
|
|
522
|
+
print("{}")
|
|
523
|
+
return 0
|
|
524
|
+
|
|
525
|
+
conversation_id = str(payload.get("conversation_id") or "").strip()
|
|
526
|
+
user_query = transcript.get("user_query") or ""
|
|
527
|
+
if is_duplicate_notify(conversation_id, user_query, cfg):
|
|
528
|
+
log(f"skip duplicate chat={conversation_id}")
|
|
529
|
+
print("{}")
|
|
530
|
+
return 0
|
|
531
|
+
|
|
532
|
+
messages = build_messages(payload, transcript, cfg)
|
|
533
|
+
if not messages:
|
|
534
|
+
print("{}")
|
|
535
|
+
return 0
|
|
536
|
+
|
|
537
|
+
peer = str(cfg.get("peer") or "*").strip() or "*"
|
|
538
|
+
|
|
539
|
+
if not which("omnish"):
|
|
540
|
+
log("omnish not found on PATH")
|
|
541
|
+
print("{}")
|
|
542
|
+
return 0
|
|
543
|
+
|
|
544
|
+
try:
|
|
545
|
+
for index, (title, brief) in enumerate(messages):
|
|
546
|
+
if index > 0:
|
|
547
|
+
time.sleep(0.4)
|
|
548
|
+
send_notification(title, brief, peer)
|
|
549
|
+
log(
|
|
550
|
+
f"sent chat={payload.get('conversation_id')} dest={peer!r} "
|
|
551
|
+
f"messages={len(messages)} title={messages[0][0]!r}"
|
|
552
|
+
)
|
|
553
|
+
except subprocess.TimeoutExpired:
|
|
554
|
+
log(f"notify timeout chat={payload.get('conversation_id')}")
|
|
555
|
+
except OSError as exc:
|
|
556
|
+
log(f"notify failed: {exc}")
|
|
557
|
+
|
|
558
|
+
print("{}")
|
|
559
|
+
return 0
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
if __name__ == "__main__":
|
|
563
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# stop hook: send curated Omnish brief when Cursor agent work is worth notifying.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
PYTHON="${OMNISH_NOTIFY_PYTHON:-python3}"
|
|
7
|
+
|
|
8
|
+
if ! command -v "$PYTHON" >/dev/null 2>&1; then
|
|
9
|
+
echo '{}'
|
|
10
|
+
exit 0
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
exec "$PYTHON" "$SCRIPT_DIR/omnish-notify.py"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# sessionStart hook: inject Omnish notification guidance into every agent session.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
read -r _input
|
|
6
|
+
|
|
7
|
+
python3 - <<'PY'
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
cfg_path = Path.home() / ".cursor" / "omnish-notify.json"
|
|
13
|
+
enabled = True
|
|
14
|
+
peer = os.environ.get("OMNISH_CURSOR_NOTIFY_PEER", "*")
|
|
15
|
+
if cfg_path.is_file():
|
|
16
|
+
try:
|
|
17
|
+
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
18
|
+
enabled = bool(cfg.get("enabled", True))
|
|
19
|
+
peer = str(cfg.get("peer") or peer)
|
|
20
|
+
except Exception:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
if not enabled:
|
|
24
|
+
print("{}")
|
|
25
|
+
raise SystemExit(0)
|
|
26
|
+
|
|
27
|
+
dest = peer if peer and peer != "peer" else "*"
|
|
28
|
+
notify_cmd = f"omnish i -c '/sendto {dest} -t <title> -- <brief>'"
|
|
29
|
+
|
|
30
|
+
context = f"""Omnish mobile notifications (global user policy):
|
|
31
|
+
- When you finish useful work, or hit a blocker that needs human input, send a concise status via Omnish.
|
|
32
|
+
- Command: {notify_cmd}
|
|
33
|
+
- Always include the Cursor chat id (conversation_id) in the brief when notifying.
|
|
34
|
+
- Send only when the update is actionable: Done, Progress, Question, or Blocked. Skip trivial Q&A, pure exploration with no outcome, or duplicate "still working" pings.
|
|
35
|
+
- Prefer one Done notification at completion; use Progress sparingly on major milestones.
|
|
36
|
+
- Default destination is {dest!r} (all allowlisted peers on this gateway). Requires omnish run with platform token or local WhatsApp/Telegram."""
|
|
37
|
+
|
|
38
|
+
out = {
|
|
39
|
+
"additional_context": context,
|
|
40
|
+
"env": {
|
|
41
|
+
"OMNISH_NOTIFY_CMD": f"omnish i -c '/sendto {dest} -t <title> -- <brief>'",
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
if dest != "*":
|
|
45
|
+
out["env"]["OMNISH_PEER_KEY"] = dest
|
|
46
|
+
|
|
47
|
+
print(json.dumps(out))
|
|
48
|
+
PY
|